From 2a1aca81f058a878bcd6f2170a406efe2f76c4b9 Mon Sep 17 00:00:00 2001 From: el-j Date: Sat, 24 Oct 2026 00:27:05 +0200 Subject: [PATCH 001/199] M2.1: allow Pen tool and path.join to continue, close, and join compound paths (#3274, #3276, #3379, #3380) --- crates/engine/src/cmd/object.rs | 11 +- crates/engine/src/cmd/path.rs | 634 +++++++++++++++--- crates/engine/src/cmd/select.rs | 6 +- crates/engine/src/tests_draw2.rs | 552 ++++++++++++++- crates/tools/src/pen.rs | 295 +++++--- .../src/i18n/untranslated_messages.txt | 7 + 6 files changed, 1286 insertions(+), 219 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 93cdfd74a..cdc43c51f 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -883,6 +883,10 @@ fn show_all(s: &mut Session, _: &Value) -> Result { Ok(json!({ "count": ids.len() })) } +/// A closed contour smaller than this (in square document units) has no usable winding direction, +/// so Make Compound Path leaves its orientation alone rather than flipping it on noise. +const MIN_WINDING_AREA: f64 = 1e-6; + fn compound_make(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) }; @@ -904,7 +908,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result { // The paths in front run against the backmost one, so where they overlap it they cut holes // under the non-zero fill rule (#1197). A compound path's paths turn together, by its // first: its own holes (a letter's counters) stay holes. - let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum()); + let direction = |n: &Node| { + n.path_data() + .and_then(|pd| pd.subpaths.first()) + .filter(|sp| sp.closed && sp.signed_area().abs() > MIN_WINDING_AREA) + .map_or(0.0, |sp| sp.signed_area().signum()) + }; let first = |unit: &[Arc]| unit.first().map_or(0.0, |p| direction(p)); let back = units.first().map_or(0.0, |u| first(u)); for (i, unit) in units.into_iter().enumerate() { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index f55378a34..94667d776 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; -use vectorcraft_doc::{NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2}; use super::create::anchor_from_json; @@ -19,11 +19,19 @@ pub fn specs() -> Vec { "Add Anchor (Pen)", [], None, - "{id, x, y, in?: [x,y], out?: [x,y]} append to the end of the last open subpath", + "{id, x, y, in?: [x,y], out?: [x,y], subpath?: usize (the open subpath to extend, 0 included; an invalid or closed one is an error; default: the last open subpath)} append to the end of an open subpath", has_doc, append_anchor ), - cmd!("path.close", "Close Path", [], None, "{id, in?: [x,y] (handle into the first anchor), independent?: bool}", has_doc, close), + cmd!( + "path.close", + "Close Path", + [], + None, + "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor), independent?: bool}", + has_doc, + close + ), cmd!( "path.moveAnchors", "Move Anchors", @@ -65,7 +73,7 @@ pub fn specs() -> Vec { "Reverse Path Direction", ["Object", "Path"], None, - "{ids?|id?, reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", + "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", has_doc, reverse ), @@ -74,7 +82,7 @@ pub fn specs() -> Vec { "Join", ["Object", "Path"], Some("Cmd+J"), - "{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead", + "{ids?: [path, ...] (default: the selected paths; a compound path ID expands to its open member paths), subpaths?: [usize, usize] (which subpaths to join), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, for two paths/subpaths; default without ids: direct-selected ends if any, else the nearest pair; default with ids: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two or more open paths/subpaths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead", has_selection, join ), @@ -141,23 +149,42 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< fn append_anchor(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; + let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); s.edit("Pen", |d, sel| { let path = path_mut(d, id)?; - match path.subpaths.last_mut() { - Some(sp) if !sp.closed => sp.anchors.push(a), - _ => path.subpaths.push(SubPath::new(vec![a], false)), - } - sel.set([id]); - let last = path.subpaths.len() - 1; - let ai = path.subpaths[last].anchors.len() - 1; - sel.anchors.insert(id, BTreeSet::from([(last, ai)])); + let si = match subpath { + // An explicit subpath is honoured, 0 included, or refused: never swapped for another. + Some(si) => match path.subpaths.get_mut(si).filter(|sp| !sp.closed) { + Some(sp) => { + sp.anchors.push(a); + si + } + None => return Err(bad("path.appendAnchor", format!("subpath {si} is not an open subpath"))), + }, + None => match path.subpaths.iter_mut().enumerate().rev().find(|(_, sp)| !sp.closed) { + Some((si, sp)) => { + sp.anchors.push(a); + si + } + None => { + path.subpaths.push(SubPath::new(vec![a], false)); + path.subpaths.len() - 1 + } + }, + }; + let ai = path.subpaths[si].anchors.len() - 1; + let top = compound_or_self(d, id); + sel.set([top]); + sel.anchors.insert(id, BTreeSet::from([(si, ai)])); Ok(()) })?; ok() } +/// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor. fn close(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let h_in = match p.get("in").filter(|h| !h.is_null()) { None => None, Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?), @@ -165,7 +192,10 @@ fn close(s: &mut Session, p: &Value) -> Result { let independent = switch_or(p, "independent", false, "path.close")?; s.edit("Close Path", |d, sel| { let path = path_mut(d, id)?; - let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; + let sp = match subpath { + Some(si) => path.subpaths.get_mut(si).filter(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?, + None => path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?, + }; sp.closed = true; if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) { first.h_in = h; @@ -174,7 +204,8 @@ fn close(s: &mut Session, p: &Value) -> Result { first.kind = AnchorKind::Smooth; } } - sel.set([id]); + let top = compound_or_self(d, id); + sel.set([top]); Ok(()) })?; ok() @@ -288,9 +319,41 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { ok() } +/// The compound path enclosing `id`, or `id` itself if it is not inside a compound path. +fn compound_or_self(d: &Document, id: NodeId) -> NodeId { + d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id) +} + +/// The open member paths of a compound path, or its first child if none are open. +fn open_members(n: &Node) -> Vec { + let NodeKind::Compound { children, .. } = &n.kind else { return vec![] }; + let open: Vec = + children.iter().filter(|c| c.path_data().is_some_and(|pd| pd.subpaths.iter().any(|sp| !sp.closed))).map(|c| c.id).collect(); + if open.is_empty() { children.first().map(|c| vec![c.id]).unwrap_or_default() } else { open } +} + +/// The selected paths to join or average, resolving compound paths to their open member paths. fn selected_paths(s: &Session) -> Result> { let st = s.doc()?; - Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect()) + let mut out = vec![]; + for &id in &st.selection.objects { + let Some(n) = st.doc.node(id) else { continue }; + match &n.kind { + NodeKind::Path { .. } => out.push(id), + NodeKind::Compound { .. } => { + let open = open_members(n); + let with_anchors: Vec = + open.iter().filter(|id| st.selection.anchors.get(id).is_some_and(|s| !s.is_empty())).copied().collect(); + if !with_anchors.is_empty() { + out.extend(with_anchors); + } else { + out.extend(open); + } + } + _ => {} + } + } + Ok(out) } /// The paths in the subtrees of `ids` (in groups and compound paths too), each once. @@ -309,6 +372,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec Result { const C: &str = "path.reverse"; let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?; + let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let ids = paths_in(&s.doc()?.doc, &targets(s, p)?); if ids.is_empty() { return Err(bad(C, "select paths")); @@ -316,18 +380,22 @@ fn reverse(s: &mut Session, p: &Value) -> Result { let changed = super::overprint::edit_counted(s, "Reverse Path Direction", |d| { let mut changed = 0; for id in &ids { - // The subpaths to flip: all, or those not yet running the wanted way. - let flip: Vec = d - .node(*id) - .and_then(|n| n.path_data()) - .map_or(vec![], |pd| pd.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect()); - if flip.contains(&true) { - for (sp, f) in path_mut(d, *id)?.subpaths.iter_mut().zip(flip) { - if f { - sp.reverse(); + let path = path_mut(d, *id)?; + if let Some(si) = subpath { + if let Some(sp) = path.subpaths.get_mut(si) { + sp.reverse(); + changed += 1; + } + } else { + let flip: Vec = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(); + if flip.contains(&true) { + for (sp, f) in path.subpaths.iter_mut().zip(flip) { + if f { + sp.reverse(); + } } + changed += 1; } - changed += 1; } } Ok(changed) @@ -386,6 +454,70 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: before - sp.anchors.len() } +/// Close subpath `si` of path `id`, merging coincident endpoints if present. +fn close_subpath_in_node(d: &mut Document, id: NodeId, si: usize, join_kind: Option) -> Result<()> { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).filter(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; + if sp.anchors.len() > 2 + && end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6) + && let Some(last) = sp.anchors.pop() + && let Some(first) = sp.anchors.first_mut() + { + first.h_in = last.h_in; + sp.closed = true; + if let Some(smooth) = join_kind { + join_point(sp, 0, smooth); + } + } + sp.closed = true; + Ok(()) +} + +/// Join subpaths `sa` and `sb` in the same path node `id`. +fn join_subpaths_in_node(d: &mut Document, id: NodeId, (sa, sb): (usize, usize), (rx, ry): (bool, bool), join_kind: Option) -> Result<()> { + if sa == sb { + return close_subpath_in_node(d, id, sa, join_kind); + } + let path = path_mut(d, id)?; + let mut x = path.subpaths.get(sa).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let mut y = path.subpaths.get(sb).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + if x.closed || y.closed { + return Err(EngineError::Other("join needs open paths".into())); + } + if rx { + x.reverse(); + } + if ry { + y.reverse(); + } + let junction = x.anchors.len().saturating_sub(1); + if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p)) + && xe.distance(ys) < 1e-6 + { + let first = y.anchors.remove(0); + if let Some(l) = x.anchors.last_mut() { + l.h_out = first.h_out; + } + } + x.anchors.extend(y.anchors); + if let Some(smooth) = join_kind { + join_point(&mut x, junction, smooth); + } + if sa > sb { + path.subpaths.remove(sb); + if let Some(sub) = path.subpaths.get_mut(sa - 1) { + *sub = x; + } + } else { + if let Some(sub) = path.subpaths.get_mut(sa) { + *sub = x; + } + path.subpaths.remove(sb); + } + Ok(()) +} + +/// Join open paths into a single path, or close an open path, preserving compound path structure. fn join(s: &mut Session, p: &Value) -> Result { const C: &str = "path.join"; let join_kind = match p.get("to").and_then(Value::as_str) { @@ -394,19 +526,148 @@ fn join(s: &mut Session, p: &Value) -> Result { Some("corner") => Some(false), Some(_) => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")), }; - let ids = match ids_param(p, "ids") { - Some(ids) => { - let st = s.doc()?; - if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) { - return Err(bad(C, format!("{} isn't a path", id.0))); + let subpaths = match p.get("subpaths") { + None => None, + Some(v) => match v.as_array().and_then(|a| { + if a.len() == 2 + && let (Some(sa), Some(sb)) = (a[0].as_u64(), a[1].as_u64()) + { + Some((sa as usize, sb as usize)) + } else { + None } - ids - } - None => selected_paths(s)?, + }) { + Some(pair) => Some(pair), + None => return Err(bad(C, "`subpaths` must be two integer indices")), + }, }; + // The ends to join, as (reverse the first path, reverse the second): the first path's end + // must come last, the second's first. + let ends = match p.get("ends") { + None => None, + Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::>()).as_deref() { + Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")), + _ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")), + }, + }; + + if let Some(ids) = ids_param(p, "ids") { + if ends.is_some() && ids.len() > 2 { + return Err(bad(C, "`ends` applies to joining exactly two paths")); + } + if ids.len() == 2 && ids[0] == ids[1] { + let id = ids[0]; + let (sa, sb) = subpaths.ok_or_else(|| bad(C, "subpaths required when joining the same path"))?; + let (rx, ry) = ends.unwrap_or((false, false)); + s.edit("Join", |d, sel| { + join_subpaths_in_node(d, id, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, id)]); + Ok(()) + })?; + return ok(); + } + if ids.len() == 2 + && let Some((sa, sb)) = subpaths + { + let (a, b) = (ids[0], ids[1]); + let (rx, ry) = ends.unwrap_or((false, false)); + s.edit("Join", |d, sel| { + join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); + Ok(()) + })?; + return ok(); + } + + let st = s.doc()?; + let mut resolved = vec![]; + for &id in &ids { + let Some(n) = st.doc.node(id) else { + return Err(bad(C, format!("{} isn't a path", id.0))); + }; + match &n.kind { + NodeKind::Path { .. } => resolved.push(id), + NodeKind::Compound { .. } => { + let open = open_members(n); + if ids.len() > 1 && open.len() != 1 { + return Err(bad(C, format!("compound {} must contain exactly one open path to join", id.0))); + } + resolved.extend(open); + } + _ => return Err(bad(C, format!("{} isn't a path", id.0))), + } + } + match resolved.as_slice() { + [one] => { + let id = *one; + let si = match subpaths { + Some((si, _)) => si, + None => { + let pd = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + pd.subpaths + .iter() + .position(|sp| !sp.closed && !sp.anchors.is_empty()) + .ok_or_else(|| EngineError::Other("path is already closed".into()))? + } + }; + s.edit("Join", |d, sel| { + close_subpath_in_node(d, id, si, join_kind)?; + sel.set([compound_or_self(d, id)]); + Ok(()) + })?; + } + [a, b, ..] => { + if resolved.iter().collect::>().len() != resolved.len() { + return Err(bad(C, "join two different paths")); + } + let (a, b) = (*a, *b); + if ends.is_some() && resolved.len() > 2 { + return Err(bad(C, "`ends` applies to joining exactly two paths")); + } + if let Some((rx, ry)) = ends { + let st = s.doc()?; + let pa = st.doc.node(a).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(a))?; + let pb = st.doc.node(b).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(b))?; + let sa = match subpaths { + Some((s, _)) => s, + None => pa.subpaths.iter().position(|s| !s.closed).ok_or_else(|| EngineError::Other("join needs open paths".into()))?, + }; + let sb = match subpaths { + Some((_, s)) => s, + None => pb.subpaths.iter().position(|s| !s.closed).ok_or_else(|| EngineError::Other("join needs open paths".into()))?, + }; + s.edit("Join", |d, sel| { + join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); + Ok(()) + })?; + return ok(); + } + let st = s.doc()?; + let mut open_subs = Vec::with_capacity(resolved.len()); + for &id in &resolved { + let pd = st.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + let si = pd + .subpaths + .iter() + .position(|sp| !sp.closed && !sp.anchors.is_empty()) + .ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + open_subs.push((id, si)); + } + s.edit("Join", |d, sel| { + let top = join_nearest(d, open_subs, join_kind)?; + sel.set([top]); + Ok(()) + })?; + } + [] => return Err(EngineError::Other("select paths to join".into())), + } + return ok(); + } + // Direct-selected anchors lying on one another along a path (two corners brought together by // Average) become one anchor; the paths themselves are joined only when there are none. - if p.get("ids").is_none() { + { let st = s.doc()?; let mut merged = vec![]; for (id, picked) in &st.selection.anchors { @@ -423,96 +684,253 @@ fn join(s: &mut Session, p: &Value) -> Result { for (id, path) in merged { *path_mut(d, id)? = path; } - // The anchor indices changed: the paths stay selected, whole. sel.set(objects); Ok(()) })?; return ok(); } } - // The ends to join, as (reverse the first path, reverse the second): the first path's end - // must come last, the second's first. - let ends = match p.get("ends") { - None => None, - Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::>()).as_deref() { - Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")), - _ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")), - }, - }; - match ids.as_slice() { - [one] => { - let id = *one; - s.edit("Join", |d, _| { - let path = path_mut(d, id)?; - let sp = path.subpaths.iter_mut().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; - // Merge coincident end points. - if sp.anchors.len() > 2 - && end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6) - && let Some(last) = sp.anchors.pop() - && let Some(first) = sp.anchors.first_mut() - { - first.h_in = last.h_in; - // Closed first, so the merged anchor smooths between both its neighbours. - sp.closed = true; - if let Some(smooth) = join_kind { - join_point(sp, 0, smooth); + + let direct_ends: Vec<(NodeId, usize, &'static str)> = { + let st = s.doc()?; + let mut ends = vec![]; + for (&id, picked) in &st.selection.anchors { + if let Some(pd) = st.doc.node(id).and_then(|n| n.path_data()) { + for (si, sp) in pd.subpaths.iter().enumerate() { + if sp.closed || sp.anchors.is_empty() { + continue; + } + let last_idx = sp.anchors.len() - 1; + let f = picked.contains(&(si, 0)); + let l = picked.contains(&(si, last_idx)); + if f && l && last_idx > 0 { + ends.push((id, si, "both")); + } else if f { + ends.push((id, si, "first")); + } else if l { + ends.push((id, si, "last")); } } - sp.closed = true; - Ok(()) - })?; - } - [a, b, ..] => { - let (a, b) = (*a, *b); - if a == b { - return Err(bad(C, "join two different paths")); } - s.edit("Join", |d, sel| { - let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?; - let pa = path_mut(d, a)?; - let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else { - return Err(EngineError::Other("join needs open paths".into())); - }; - let mut x = pa.subpaths[sa].clone(); - let mut y = pb.subpaths[sb].clone(); - let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else { - return Err(EngineError::Other("join needs open paths".into())); - }; - // The ends asked for, else the closest pair. - let cands = - [(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)]; - let (rx, ry) = - ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry))); - if rx { - x.reverse(); - } - if ry { - y.reverse(); - } - let junction = x.anchors.len() - 1; - if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p)) - && xe.distance(ys) < 1e-6 - { - let first = y.anchors.remove(0); - if let Some(l) = x.anchors.last_mut() { - l.h_out = first.h_out; + } + ends + }; + + // More than two selected ends can't name one join: refuse it instead of joining everything. + // A single end (left selected by the Pen) falls through to joining the selected paths. + if direct_ends.len() > 2 { + return Err(bad(C, "select exactly two path ends to join")); + } + + if direct_ends.len() == 1 && direct_ends[0].2 == "both" { + let (id, si, _) = direct_ends[0]; + s.edit("Join", |d, sel| { + close_subpath_in_node(d, id, si, join_kind)?; + sel.set([compound_or_self(d, id)]); + Ok(()) + })?; + return ok(); + } + + if direct_ends.len() == 2 { + let (ida, sa, ea) = direct_ends[0]; + let (idb, sb, eb) = direct_ends[1]; + let (rx, ry) = match (ea, eb) { + ("first", "first") => (true, false), + ("first", "last") => (true, true), + ("last", "first") => (false, false), + ("last", "last") => (false, true), + _ => { + let st = s.doc()?; + let pa = st.doc.node(ida).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(ida))?; + let pb = st.doc.node(idb).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(idb))?; + let (fa, la) = pa.subpaths.get(sa).and_then(end_points).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let (fb, lb) = pb.subpaths.get(sb).and_then(end_points).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let mut best: Option<(bool, bool, f64)> = None; + let cands = [ + (la.distance(fb), false, false, (ea == "last" || ea == "both") && (eb == "first" || eb == "both")), + (la.distance(lb), false, true, (ea == "last" || ea == "both") && (eb == "last" || eb == "both")), + (fa.distance(fb), true, false, (ea == "first" || ea == "both") && (eb == "first" || eb == "both")), + (fa.distance(lb), true, true, (ea == "first" || ea == "both") && (eb == "last" || eb == "both")), + ]; + for (dist, rx_c, ry_c, valid) in cands { + if valid && best.as_ref().is_none_or(|b| dist < b.2) { + best = Some((rx_c, ry_c, dist)); } } - x.anchors.extend(y.anchors); - if let Some(smooth) = join_kind { - join_point(&mut x, junction, smooth); + match best { + Some((rx, ry, _)) => (rx, ry), + None => (ea == "first", eb == "last"), } - pa.subpaths[sa] = x; - d.remove(b)?; - sel.set([a]); + } + }; + if ida == idb { + if sa == sb { + s.edit("Join", |d, sel| { + close_subpath_in_node(d, ida, sa, join_kind)?; + sel.set([compound_or_self(d, ida)]); + Ok(()) + })?; + } else { + s.edit("Join", |d, sel| { + join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, ida)]); + Ok(()) + })?; + } + return ok(); + } else { + s.edit("Join", |d, sel| { + join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, ida)]); Ok(()) })?; + return ok(); } - [] => return Err(EngineError::Other("select paths to join".into())), } + + let ids = selected_paths(s)?; + if ids.is_empty() { + return Err(EngineError::Other("select paths to join".into())); + } + let mut open_subs: Vec<(NodeId, usize)> = vec![]; + { + let st = s.doc()?; + for &id in &ids { + if let Some(pd) = st.doc.node(id).and_then(|n| n.path_data()) { + for (si, sp) in pd.subpaths.iter().enumerate() { + if !sp.closed && !sp.anchors.is_empty() { + open_subs.push((id, si)); + } + } + } + } + } + if open_subs.is_empty() { + if ids.len() == 1 { + return Err(EngineError::Other("path is already closed".into())); + } + return Err(EngineError::Other("select paths to join".into())); + } + if open_subs.len() == 1 { + let (id, si) = open_subs[0]; + s.edit("Join", |d, sel| { + close_subpath_in_node(d, id, si, join_kind)?; + sel.set([compound_or_self(d, id)]); + Ok(()) + })?; + return ok(); + } + + s.edit("Join", |d, sel| { + let top = join_nearest(d, open_subs, join_kind)?; + sel.set([top]); + Ok(()) + })?; ok() } +/// Join open subpaths, always the two whose ends lie nearest each other, until one is left. +/// `open_subs` lists `(node, subpath)` pairs. Returns the node holding the result (or its compound). +fn join_nearest(d: &mut Document, mut open_subs: Vec<(NodeId, usize)>, join_kind: Option) -> Result { + let err = || EngineError::Other("join needs open paths".into()); + while open_subs.len() > 1 { + let mut ends_info = Vec::with_capacity(open_subs.len()); + for &(id, si) in &open_subs { + let p = d.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + let sp = p.subpaths.get(si).ok_or_else(err)?; + ends_info.push(end_points(sp).ok_or_else(err)?); + } + let mut best: Option<(usize, usize, bool, bool, f64)> = None; + for (i, &(fa, la)) in ends_info.iter().enumerate() { + for (j, &(fb, lb)) in ends_info.iter().enumerate().skip(i + 1) { + let cands = + [(la.distance(fb), false, false), (la.distance(lb), false, true), (fa.distance(fb), true, false), (fa.distance(lb), true, true)]; + for (dist, rx, ry) in cands { + if best.as_ref().is_none_or(|b| dist < b.4) { + best = Some((i, j, rx, ry, dist)); + } + } + } + } + let Some((i, j, rx, ry, _)) = best else { return Err(err()) }; + let ((ida, sa), (idb, sb)) = (open_subs[i], open_subs[j]); + if ida == idb { + join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?; + // Subpath `sb` is gone and `sa` took the merged result; later subpaths shift down. + open_subs.remove(j); + if sa > sb { + open_subs[i].1 -= 1; + } + for (k, item) in open_subs.iter_mut().enumerate() { + if k != i && item.0 == ida && item.1 > sb { + item.1 -= 1; + } + } + } else { + // `join_two` removes `b` only when `sb` was its last subpath; otherwise `b` keeps its + // other subpaths, which stay in `open_subs` with their indexes shifted down. + join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?; + open_subs.remove(j); + for item in &mut open_subs { + if item.0 == idb && item.1 > sb { + item.1 -= 1; + } + } + } + } + let first = open_subs.first().map(|(id, _)| *id).ok_or_else(err)?; + Ok(compound_or_self(d, first)) +} + +/// Join path `b` into path `a` along the specified subpaths and orientations. +fn join_two(d: &mut Document, a: NodeId, b: NodeId, (sa, sb): (usize, usize), (rx, ry): (bool, bool), join_kind: Option) -> Result<()> { + let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?; + let pa = path_mut(d, a)?; + let mut x = pa.subpaths.get(sa).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let mut y = pb.subpaths.get(sb).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + if x.closed || y.closed || x.anchors.is_empty() || y.anchors.is_empty() { + return Err(EngineError::Other("join needs open paths".into())); + } + if rx { + x.reverse(); + } + if ry { + y.reverse(); + } + let junction = x.anchors.len().saturating_sub(1); + if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p)) + && xe.distance(ys) < 1e-6 + { + let first = y.anchors.remove(0); + if let Some(l) = x.anchors.last_mut() { + l.h_out = first.h_out; + } + } + x.anchors.extend(y.anchors); + if let Some(smooth) = join_kind { + join_point(&mut x, junction, smooth); + } + if let Some(sub) = pa.subpaths.get_mut(sa) { + *sub = x; + } + if pb.subpaths.len() <= 1 { + let parent = d.parent_of(b).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))); + d.remove(b)?; + if let Some(parent) = parent + && d.node(parent).is_some_and(|n| n.children().is_some_and(|children| children.is_empty())) + { + d.remove(parent)?; + } + } else { + let pb_mut = path_mut(d, b)?; + if sb < pb_mut.subpaths.len() { + pb_mut.subpaths.remove(sb); + } + } + Ok(()) +} + pub(super) type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>; pub(super) fn anchor_targets(s: &Session) -> Result { @@ -624,6 +1042,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result { let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect(); without_segments(sp, &cuts) }) + .filter(|s| s.anchors.len() >= 2) .collect(); } else { // Remove every selected anchor against the original indexes, keeping surviving @@ -635,6 +1054,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result { let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect(); without_anchors(sp, &gone) }) + .filter(|s| s.anchors.len() >= 2) .collect(); } path.subpaths.retain(|s| s.anchors.len() >= 2); @@ -693,7 +1113,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { } /// Remove connections, splitting open/closed contours into their surviving open runs. -fn without_segments(sp: SubPath, removed: &BTreeSet) -> Vec { +pub(super) fn without_segments(sp: SubPath, removed: &BTreeSet) -> Vec { let cuts: BTreeSet = removed.iter().copied().filter(|&i| i < sp.segment_count()).collect(); let Some(&first) = cuts.first() else { return vec![sp] }; let n = sp.anchors.len(); diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 2f39fe472..8ec804c13 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -471,8 +471,10 @@ fn anchors(s: &mut Session, p: &Value) -> Result { if mode == "set" { sel.clear(); } - // A path selected as a whole has every anchor selected: toggle and add start from them all. - let whole = sel.contains(id) && sel.partial(id).is_none(); + // A path selected as a whole has every anchor selected: toggle and add start from them all, + // but only when no partial anchor selection is active and only on a single object. With + // several objects selected, a click picks that anchor alone, so Join can take two picked ends. + let whole = sel.contains(id) && sel.partial(id).is_none() && sel.anchors.is_empty() && sel.objects.len() <= 1; sel.add(id); // A segment pick's endpoints aren't selected anchors: start from none of them. if sel.segments.remove(&id).is_some() { diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index cc247ee8a..d5278c566 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -1683,25 +1683,6 @@ fn direct_segment_delete_keeps_the_other_rectangle_edges_and_one_undo() { } } -/// A segment pick's endpoints are hollow: Shift-clicking or Shift-marqueeing one selects that -/// anchor, rather than toggling it off and leaving the other endpoint selected. -#[test] -fn selecting_an_anchor_after_a_segment_pick_starts_from_no_anchors() { - let mut s = session(); - let id = rect(&mut s, 100.0, 100.0, 200.0, 150.0); - let segment = json!({"id": id.0, "segments": [[0, 0]]}); - s.execute("select.segments", &segment).unwrap(); - s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"})).unwrap(); - let sel = &s.doc().unwrap().selection; - assert_eq!(sel.partial(id), Some(&[(0, 0)].into())); - assert!(sel.segments.is_empty()); - s.execute("select.segments", &segment).unwrap(); - s.execute("select.anchorsMany", &json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "add"})).unwrap(); - let sel = &s.doc().unwrap().selection; - assert_eq!(sel.partial(id), Some(&[(0, 1)].into())); - assert!(sel.segments.is_empty()); -} - #[test] fn segment_delete_preserves_curves_appearance_and_unrelated_contours() { let data = "M100 100C110 70 150 130 170 100C180 75 230 125 250 100C270 140 280 80 310 100C320 120 140 140 100 100Z M400 100L500 100L500 200Z"; @@ -2398,3 +2379,536 @@ fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() { assert_eq!(s.doc().unwrap().selection.objects, vec![id]); } } + +/// Pen tool can continue drawing an open path inside a compound path and close it. +#[test] +fn compound_path_pen_continue_and_close() { + use vectorcraft_tools::Cursor; + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let r = rect(&mut s, 50.0, 50.0, 200.0, 200.0); + let p = line(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("select.set", &json!({"ids": [r.0, p.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + s.select_tool("pen", v).unwrap(); + // Hovering over the last anchor of the open member path shows PenContinue. + assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue); + // Click on the last anchor to continue drawing. + gesture(&mut s, &[(200.0, 100.0)], none); + // Rubber band is active, cursor over the start of the path shows PenClose. + assert_eq!(s.cursor(Point::new(100.0, 100.0), none, v), Cursor::PenClose); + // Add an intermediate anchor. + gesture(&mut s, &[(200.0, 200.0)], none); + assert_eq!(s.cursor(Point::new(100.0, 100.0), none, v), Cursor::PenClose); + // Click on the first anchor to close the path. + gesture(&mut s, &[(100.0, 100.0)], none); + + // Verify member path is now closed inside the compound path. + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 2); + for child in ch { + let pd = child.path_data().unwrap(); + assert!(pd.is_closed(), "all member paths should be closed"); + } + // Selection should be on the compound path. + assert_eq!(s.doc().unwrap().selection.objects, vec![cid]); +} + +/// Pen tool can continue an open path inside a compound path and join another open path inside it. +#[test] +fn compound_path_pen_continue_and_join() { + use vectorcraft_tools::Cursor; + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 100.0, 400.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + s.select_tool("pen", v).unwrap(); + assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue); + // Continue from a's end. + gesture(&mut s, &[(200.0, 100.0)], none); + // Hover over b's start shows PenJoin. + assert_eq!(s.cursor(Point::new(300.0, 100.0), none, v), Cursor::PenJoin); + // Click on b's start to join them. + assert_eq!(gesture(&mut s, &[(300.0, 100.0)], none), 1); + + // The two open paths are now joined into one path inside the compound path. + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1, "joined into one path"); + let pd = ch[0].path_data().unwrap(); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid]); +} + +/// Path join (Cmd+J) works on compound paths with open paths. +#[test] +fn compound_path_join_command() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 100.0, 400.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + // Join with compound path selected joins its 2 open paths. + s.execute("path.join", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1); + let pd = ch[0].path_data().unwrap(); + assert!(!pd.is_closed()); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid]); + + // Joining again closes the single open path. + s.execute("path.join", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1); + assert!(ch[0].path_data().unwrap().is_closed()); +} + +/// Path join with direct-selected endpoints of a compound path connects those endpoints. +#[test] +fn compound_path_join_direct_selected_endpoints() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 100.0, 400.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + let ch = s.doc().unwrap().doc.node(cid).unwrap().children().unwrap().clone(); + let ca = ch[0].id; + let cb = ch[1].id; + + // Direct select endpoints: ca's first (100, 100) and cb's start (300, 100). + s.execute("select.anchors", &json!({"id": ca.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": cb.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1); + let pd = ch[0].path_data().unwrap(); + let pts: Vec<(f64, f64)> = pd.subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + // ca's first was connected to cb's last. + assert_eq!(pts, [(200.0, 100.0), (100.0, 100.0), (300.0, 100.0), (400.0, 100.0)]); +} + +/// Explicit ids in path.join validate compound expansion and allow joining three or more paths. +#[test] +fn compound_path_join_explicit_ids_validation() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 100.0, 400.0, 100.0); + let c = line(&mut s, 500.0, 100.0, 600.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + // Multi-id join with compound containing != 1 open path (has 2) errors. + let err = s.execute("path.join", &json!({"ids": [cid.0, c.0]})).unwrap_err(); + assert!(err.to_string().contains("must contain exactly one open path to join")); + + // Single compound id with 2 open paths resolves to 2 paths and succeeds. + assert!(s.execute("path.join", &json!({"ids": [cid.0]})).is_ok()); + + // Multi-id join with compound now containing 1 open path + another path succeeds. + assert!(s.execute("path.join", &json!({"ids": [cid.0, c.0]})).is_ok()); + + // Joining with explicit ends and > 2 paths errors. + let x = line(&mut s, 0.0, 0.0, 10.0, 10.0); + let y = line(&mut s, 20.0, 0.0, 30.0, 10.0); + let z = line(&mut s, 40.0, 0.0, 50.0, 10.0); + let err = s.execute("path.join", &json!({"ids": [x.0, y.0, z.0], "ends": ["first", "last"]})).unwrap_err(); + assert!(err.to_string().contains("`ends` applies to joining exactly two paths")); + + // Joining three or more paths joins them all. + s.execute("path.join", &json!({"ids": [x.0, y.0, z.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + let xnode = d.node(x).unwrap(); + let pd = xnode.path_data().unwrap(); + assert_eq!(pd.subpaths[0].anchors.len(), 6); + assert!(d.node(y).is_none()); + assert!(d.node(z).is_none()); +} + +/// Joining the only member of a compound path into another path removes the empty compound parent. +#[test] +fn join_removes_empty_compound_parent() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = line(&mut s, 12.0, 0.0, 20.0, 0.0); + // Make b into a compound path (single open path child) + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + // Join a and the compound path + s.execute("select.set", &json!({"ids": [a.0, cid.0]})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + // Compound path container should be removed since its only member was joined into a. + assert!(d.node(cid).is_none()); + assert!(d.node(a).is_some()); + let pd = d.node(a).unwrap().path_data().unwrap(); + assert_eq!(pd.subpaths[0].anchors.len(), 4); +} + +/// Join with three or more open paths selected joins them all into a single path (Illustrator parity). +#[test] +fn join_three_or_more_paths() { + let mut s = session(); + // Path 1: (0, 0) -> (10, 0) + let p1 = line(&mut s, 0.0, 0.0, 10.0, 0.0); + // Path 2: (12, 0) -> (20, 0) + let p2 = line(&mut s, 12.0, 0.0, 20.0, 0.0); + // Path 3: (22, 0) -> (30, 0) + let p3 = line(&mut s, 22.0, 0.0, 30.0, 0.0); + // Path 4: (32, 0) -> (40, 0) + let p4 = line(&mut s, 32.0, 0.0, 40.0, 0.0); + + s.execute("select.set", &json!({"ids": [p1.0, p2.0, p3.0, p4.0]})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let remaining_id = s.doc().unwrap().selection.objects[0]; + let pd = d.node(remaining_id).unwrap().path_data().unwrap(); + assert_eq!(pd.subpaths.len(), 1); + assert!(!pd.is_closed()); + assert_eq!(pd.subpaths[0].anchors.len(), 8); + let xs: Vec = pd.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert_eq!(xs, vec![0.0, 10.0, 12.0, 20.0, 22.0, 30.0, 32.0, 40.0]); +} + +/// Join with compound path containing three open member paths joins them all into one. +#[test] +fn compound_path_join_three_open_members() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = line(&mut s, 15.0, 0.0, 25.0, 0.0); + let c = line(&mut s, 30.0, 0.0, 40.0, 0.0); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let cnode = d.node(cid).unwrap(); + let ch = cnode.children().unwrap(); + assert_eq!(ch.len(), 1); + let pd = ch[0].path_data().unwrap(); + assert!(!pd.is_closed()); + assert_eq!(pd.subpaths[0].anchors.len(), 6); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid]); +} + +/// Joining one subpath of a multi-subpath node retains its other subpaths. +#[test] +fn join_multi_subpath_retains_unjoined_subpaths() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 200.0, 0.0, 300.0, 0.0); + // Add a second open subpath to b. + let second_sub = vectorcraft_geom::SubPath::polyline(&[Point::new(400.0, 50.0), Point::new(500.0, 50.0)], false); + s.edit("Add Subpath", |d, _| { + if let NodeKind::Path { path, .. } = &mut d.node_mut(b).unwrap().kind { + path.subpaths.push(second_sub); + } + Ok(()) + }) + .unwrap(); + + // Direct-select an endpoint on a and an endpoint on b's second subpath. + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[1, 0]], "mode": "add"})).unwrap(); + + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + // Node b still exists because its first subpath was not consumed. + assert!(d.node(b).is_some()); + let pb = d.node(b).unwrap().path_data().unwrap(); + assert_eq!(pb.subpaths.len(), 1); + assert_eq!(pb.subpaths[0].anchors[0].p, Point::new(200.0, 0.0)); + + // Node a now has the joined subpath. + let pa = d.node(a).unwrap().path_data().unwrap(); + assert_eq!(pa.subpaths.len(), 1); + assert_eq!(pa.subpaths[0].anchors.len(), 4); +} + +/// The Pen tool does not continue or join guide paths. +#[test] +fn pen_does_not_continue_or_join_guide_paths() { + use vectorcraft_tools::Cursor; + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let g = line(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + + s.select_tool("pen", v).unwrap(); + // Hovering over the guide endpoint does not offer PenContinue. + assert_ne!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue); +} + +/// Joining two open subpaths within the same path node merges them into one subpath. +#[test] +fn join_two_subpaths_of_same_path_node() { + let mut s = session(); + let p = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let sub2 = vectorcraft_geom::SubPath::polyline(&[Point::new(150.0, 0.0), Point::new(250.0, 0.0)], false); + s.edit("Add Subpath", |d, _| { + if let NodeKind::Path { path, .. } = &mut d.node_mut(p).unwrap().kind { + path.subpaths.push(sub2); + } + Ok(()) + }) + .unwrap(); + + // Direct-select anchor (0, 1) and anchor (1, 0) of the same node. + s.execute("select.anchors", &json!({"id": p.0, "anchors": [[0, 1], [1, 0]], "mode": "set"})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let pd = d.node(p).unwrap().path_data().unwrap(); + assert_eq!(pd.subpaths.len(), 1); + assert!(!pd.subpaths[0].closed); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(0.0, 0.0)); + assert_eq!(pd.subpaths[0].anchors[1].p, Point::new(100.0, 0.0)); + assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(150.0, 0.0)); + assert_eq!(pd.subpaths[0].anchors[3].p, Point::new(250.0, 0.0)); +} + +/// Direct-selecting 2 open endpoints in a complex compound path with multiple open segments joins only those 2. +#[test] +fn join_complex_compound_path_direct_endpoints() { + let mut s = session(); + let c1 = line(&mut s, 0.0, 0.0, 50.0, 0.0); + let c2 = line(&mut s, 100.0, 0.0, 150.0, 0.0); + let c3 = line(&mut s, 200.0, 0.0, 250.0, 0.0); + + s.execute("select.set", &json!({"ids": [c1.0, c2.0, c3.0]})).unwrap(); + let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + // Direct-select the end of c1 and the start of c3. + s.execute("select.anchors", &json!({"id": c1.0, "anchors": [[0, 1]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": c3.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + + s.execute("path.join", &json!({})).unwrap(); + + let d = &s.doc().unwrap().doc; + let comp_node = d.node(comp).unwrap(); + let children = comp_node.children().unwrap(); + // c3 was joined into c1, c2 remains untouched. Total 2 children in compound. + assert_eq!(children.len(), 2); + let joined = children.iter().find(|c| c.id == c1).unwrap().path_data().unwrap(); + assert_eq!(joined.subpaths.len(), 1); + assert_eq!(joined.subpaths[0].anchors.len(), 4); + assert_eq!(joined.subpaths[0].anchors[0].p, Point::new(0.0, 0.0)); + assert_eq!(joined.subpaths[0].anchors[1].p, Point::new(50.0, 0.0)); + assert_eq!(joined.subpaths[0].anchors[2].p, Point::new(200.0, 0.0)); + assert_eq!(joined.subpaths[0].anchors[3].p, Point::new(250.0, 0.0)); + + let intact = children.iter().find(|c| c.id == c2).unwrap().path_data().unwrap(); + assert_eq!(intact.subpaths.len(), 1); + assert_eq!(intact.subpaths[0].anchors.len(), 2); +} + +/// Continuing a complex compound path with the Pen tool begins at the exact clicked anchor and joins to another open segment. +#[test] +fn pen_continue_and_join_specific_open_subpath_in_compound() { + let v = view(); + let mut s = session(); + let c1 = line(&mut s, 10.0, 100.0, 60.0, 100.0); + let c2 = line(&mut s, 10.0, 200.0, 60.0, 200.0); + s.execute("select.set", &json!({"ids": [c1.0, c2.0]})).unwrap(); + let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + + s.select_tool("pen", v).unwrap(); + // Click at the end of c1 (60, 100) to continue drawing c1. + s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 100.0), v).unwrap(); + + // Now click at the start of c2 (10, 200) to join c1 and c2. + s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 200.0), v).unwrap(); + + let d = &s.doc().unwrap().doc; + let comp_node = d.node(comp).unwrap(); + let children = comp_node.children().unwrap(); + assert_eq!(children.len(), 1); + let pd = children[0].path_data().unwrap(); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(10.0, 100.0)); + assert_eq!(pd.subpaths[0].anchors[1].p, Point::new(60.0, 100.0)); + assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(10.0, 200.0)); + assert_eq!(pd.subpaths[0].anchors[3].p, Point::new(60.0, 200.0)); +} + +#[test] +fn join_rejects_ends_with_more_than_two_paths() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let err = s.execute("path.join", &json!({"ids": [a.0, b.0, c.0], "ends": ["first", "last"]})).unwrap_err(); + assert!(err.to_string().contains("`ends` applies to joining exactly two paths")); +} + +#[test] +fn join_removes_empty_compound_when_last_member_joined() { + let mut s = session(); + let p1 = NodeId(s.execute("path.create", &json!({"d": "M10 10 L50 10"})).unwrap()["id"].as_u64().unwrap()); + let p2 = NodeId(s.execute("path.create", &json!({"d": "M60 10 L100 10"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [p2.0]})).unwrap(); + let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + assert!(matches!(s.doc().unwrap().doc.node(comp).unwrap().kind, NodeKind::Compound { .. })); + // Join p1 and compound containing p2 + s.execute("path.join", &json!({"ids": [p1.0, comp.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(comp).is_none(), "empty compound container was removed"); +} + +#[test] +fn test_join_direct_selected_two_anchors() { + for (pick_a, pick_b, exp_junction) in [(0, 0, (0.0, 105.0)), (0, 1, (0.0, 200.0)), (1, 0, (100.0, 105.0)), (1, 1, (100.0, 200.0))] { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 105.0, 0.0, 200.0, 0.0); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, pick_a]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, pick_b]], "mode": "toggle"})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let pd = path(&s, a); + let pts: Vec = pd.subpaths[0].anchors.iter().map(|x| x.p.x).collect(); + // Check junction: there must be consecutive points matching exp_junction + let connected = + pts.windows(2).any(|w| (w[0] == exp_junction.0 && w[1] == exp_junction.1) || (w[0] == exp_junction.1 && w[1] == exp_junction.0)); + assert!(connected, "pick_a={pick_a}, pick_b={pick_b}, exp={exp_junction:?}, got pts={pts:?}"); + } +} + +#[test] +fn test_join_direct_selected_after_whole_selection() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 105.0, 0.0, 200.0, 0.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + // User wants to join (0, 0) and (200, 0): pick a's anchor 0 and b's anchor 1 + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "toggle"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 1]], "mode": "toggle"})).unwrap(); + s.execute("path.join", &json!({})).unwrap(); + + let pd = path(&s, a); + let pts: Vec = pd.subpaths[0].anchors.iter().map(|x| x.p.x).collect(); + let connected = pts.windows(2).any(|w| (w[0] == 0.0 && w[1] == 200.0) || (w[0] == 200.0 && w[1] == 0.0)); + assert!(connected, "expected 0.0 and 200.0 to be connected, got pts={pts:?}"); +} + +/// Review #2: the Pen sends the subpath it continues even when that is subpath 0, so the engine +/// never falls back to the last open subpath (append, close) or to every subpath (reverse). +#[test] +fn pen_continues_and_reverses_subpath_zero_of_a_path_with_two_open_subpaths() { + let v = view(); + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 M0 50 L100 50"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("pen", v).unwrap(); + + // Continue from the end of subpath 0: the new anchor must land on subpath 0, not on subpath 1. + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 0.0), v).unwrap(); + let pd = path(&s, id); + assert_eq!(pd.subpaths[0].anchors.len(), 3, "the new anchor extends subpath 0"); + assert_eq!(pd.subpaths[1].anchors.len(), 2, "subpath 1 is left alone"); + assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(200.0, 0.0)); + + // Continue from the start of subpath 0: only subpath 0 reverses, the other keeps its direction. + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 M0 50 L100 50"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("pen", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 0.0, 0.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 0.0, 0.0), v).unwrap(); + let pd = path(&s, id); + assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(100.0, 0.0), "subpath 0 was reversed"); + assert_eq!(pd.subpaths[1].anchors[0].p, Point::new(0.0, 50.0), "subpath 1 keeps its direction"); +} + +/// Review #5: Join closes the first open subpath, not subpath 0, so a closed subpath 0 doesn't +/// make it fail with "path is already closed". +#[test] +fn join_closes_the_open_subpath_when_subpath_zero_is_closed() { + for explicit in [false, true] { + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L10 0 L10 10 Z M50 0 L90 0 L90 40"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let params = if explicit { json!({"ids": [id.0]}) } else { json!({}) }; + s.execute("path.join", ¶ms).unwrap(); + let pd = path(&s, id); + assert!(pd.subpaths[0].closed && pd.subpaths[1].closed, "both subpaths are closed now: {params}"); + assert_eq!(pd.subpaths[0].anchors.len(), 3, "the closed subpath is untouched"); + } +} + +/// Review #7: more than two selected path ends can't name one join, so it is refused rather than +/// silently joining every selected path. +#[test] +fn join_refuses_more_than_two_selected_ends() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = line(&mut s, 20.0, 0.0, 30.0, 0.0); + let c = line(&mut s, 40.0, 0.0, 50.0, 0.0); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("path.join", &json!({})).unwrap_err(); + assert!(err.to_string().contains("exactly two"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing was joined"); +} + +/// Review #7: joining a path to one subpath of a node that has other subpaths leaves that node and +/// its remaining subpaths in place, also when more paths follow. +#[test] +fn join_keeps_the_other_subpaths_of_a_joined_node() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = NodeId(s.execute("path.create", &json!({"d": "M10 0 L20 0 M100 100 L110 100"})).unwrap()["id"].as_u64().unwrap()); + let c = line(&mut s, 20.0, 0.0, 30.0, 0.0); + s.execute("path.join", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + assert_eq!(path(&s, a).subpaths[0].anchors.len(), 4, "a, b's first subpath and c became one"); + let rest = path(&s, b); + assert_eq!(rest.subpaths.len(), 1, "b keeps its other subpath"); + assert_eq!(rest.subpaths[0].anchors[0].p, Point::new(100.0, 100.0)); + assert!(s.doc().unwrap().doc.node(c).is_none(), "c was consumed"); +} + +/// Review #7: an empty subpath is refused with an error, not an underflow. +#[test] +fn join_two_refuses_an_empty_subpath() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 10.0, 0.0); + let b = line(&mut s, 20.0, 0.0, 30.0, 0.0); + s.edit("Empty subpath", |d, _| { + if let NodeKind::Path { path, .. } = &mut d.node_mut(a).unwrap().kind { + path.subpaths[0] = vectorcraft_geom::SubPath::new(vec![], false); + } + Ok(()) + }) + .unwrap(); + assert!(s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["last", "first"], "subpaths": [0, 0]})).is_err()); +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 85168a4b2..2b7bf12e8 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -30,7 +30,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{BezPath, Point, Vec2}; use crate::direct::hit_handle; -use crate::draw2::{AnchorTool, editable_paths}; +use crate::draw2::{AnchorTool, editable_paths, shown_paths}; use crate::guides::{DrawSnap, Leave}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -40,6 +40,8 @@ pub struct PenTool { drawing: bool, /// ...this one, once the engine made it: another path selected meanwhile isn't drawn on. path: Option, + /// The subpath of `path` being drawn or continued. + subpath: Option, /// The anchor a press placed, while the drag shapes its handles. drag: Option, hover: Option, @@ -72,48 +74,110 @@ const DRAG_THRESHOLD: f64 = 3.0; struct Place { at: Point, path: Option, + subpath: Option, closing: bool, } -/// The open path the pen is extending: the single selected open path. -fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { +/// The open path the pen is extending: the single selected open path, or the single open path of a +/// single selected compound path. +fn active_path(cx: &ToolContext) -> Option<(NodeId, usize, Point, Point, Point)> { if cx.selection.objects.len() != 1 { return None; } - let id = cx.selection.objects[0]; + let id = cx.selection.objects.first().copied()?; let n = cx.doc.node(id)?; - let NodeKind::Path { path, .. } = &n.kind else { return None }; - let sp = path.subpaths.last()?; - if sp.closed { + let path_node = match &n.kind { + NodeKind::Path { .. } => Some(n), + NodeKind::Compound { children, .. } => { + let open: Vec<&vectorcraft_doc::Node> = + children.iter().filter(|c| c.path_data().is_some_and(|pd| pd.subpaths.iter().any(|sp| !sp.closed))).map(|c| c.as_ref()).collect(); + if open.len() == 1 { open.first().copied() } else { None } + } + _ => None, + }?; + let path = path_node.path_data()?; + let open_subs: Vec<(usize, &vectorcraft_geom::SubPath)> = path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).collect(); + if open_subs.len() != 1 { return None; } + let (si, sp) = open_subs[0]; let first = sp.anchors.first()?.p; let last = sp.anchors.last()?; - Some((id, first, last.p, last.h_out)) + Some((path_node.id, si, first, last.p, last.h_out)) } -/// An end of an open path (one subpath, not `except`) under `p`, within `tol`: the path the Pen -/// continues or joins to, and whether it's the path's first anchor. Only paths that can be edited, -/// not guides. -fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option) -> Option<(NodeId, bool)> { - let id = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?.leaf; - if Some(id) == except || !cx.doc.is_editable(id) { +/// Checks if an editable endpoint on path `id` lies within `tol` of point `p`, returning the endpoint info and distance. +fn endpoint_on(cx: &ToolContext, id: NodeId, p: Point, tol: f64, except: Option<(NodeId, usize)>) -> Option<((NodeId, usize, bool), f64)> { + if !cx.doc.is_editable(id) || cx.doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::Path { guide: true, .. })) { return None; } - let NodeKind::Path { path, guide: false, .. } = &cx.doc.node(id)?.kind else { return None }; - let [sp] = path.subpaths.as_slice() else { return None }; - if sp.closed { - return None; + let pd = cx.doc.node(id).and_then(|n| n.path_data())?; + let mut best: Option<((NodeId, usize, bool), f64)> = None; + for (si, sp) in pd.subpaths.iter().enumerate() { + if Some((id, si)) == except || sp.closed || sp.anchors.is_empty() { + continue; + } + let first = sp.anchors.first().map(|a| a.p); + let Some(last) = sp.anchors.last().map(|a| a.p) else { continue }; + if let Some(end) = pick_endpoint(first, last, p, tol, Endpoint::Last) { + let pt = match end { + Endpoint::First => first.unwrap_or(last), + Endpoint::Last => last, + }; + let dist = p.distance(pt); + if best.as_ref().is_none_or(|(_, d)| dist < *d) { + best = Some(((id, si, end == Endpoint::First), dist)); + } + } } - let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p); - let end = pick_endpoint(Some(first), last, p, tol, Endpoint::Last)?; - Some((id, end == Endpoint::First)) + best } -/// The last anchor of `id`'s last subpath: (subpath, anchor). -fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> { +/// An end of an open path (any editable path, including inside groups and compound paths, not `except`) +/// under `p`, within `tol`: the path the Pen continues or joins to, and whether it's the path's first anchor. +fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option<(NodeId, usize)>) -> Option<(NodeId, usize, bool)> { + let mut best: Option<((NodeId, usize, bool), f64)> = None; + // 1. Shown paths (anchors visible because object, group, or compound path is selected). + for id in shown_paths(cx) { + if let Some(cand) = endpoint_on(cx, id, p, tol, except) + && best.as_ref().is_none_or(|(_, d)| cand.1 < *d) + { + best = Some(cand); + } + } + if best.is_some() { + return best.map(|(res, _)| res); + } + // 2. Hit-tested object (clicked directly on an unselected path, group, or compound path). + if let Some(hit) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) { + let leaf = hit.leaf; + if cx.doc.is_editable(leaf) { + if let Some(cand) = endpoint_on(cx, leaf, p, tol, except) { + return Some(cand.0); + } + if let Some(n) = cx.doc.node(leaf) + && let NodeKind::Compound { children, .. } = &n.kind + { + for c in children { + if let Some(cand) = endpoint_on(cx, c.id, p, tol, except) + && best.as_ref().is_none_or(|(_, d)| cand.1 < *d) + { + best = Some(cand); + } + } + } + } + } + best.map(|(res, _)| res) +} + +/// The last anchor of `id`'s subpath: (subpath, anchor). +fn last_anchor(cx: &ToolContext, id: NodeId, subpath: Option) -> Option<(usize, usize)> { let pd = cx.doc.node(id)?.path_data()?; - let si = pd.subpaths.len().checked_sub(1)?; + let si = match subpath { + Some(si) if si < pd.subpaths.len() => si, + _ => pd.subpaths.len().checked_sub(1)?, + }; let ai = pd.subpaths.get(si)?.anchors.len().checked_sub(1)?; Some((si, ai)) } @@ -181,8 +245,12 @@ impl Tool for PenTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let tol = cx.tol(5.0).max(cx.point_tol()); // The path a press began is the selected one by the next event. - if self.drawing && self.path.is_none() { - self.path = active_path(cx).map(|a| a.0); + if self.drawing + && self.path.is_none() + && let Some(a) = active_path(cx) + { + self.path = Some(a.0); + self.subpath = Some(a.1); } let active = self.active(cx); if self.drawing && active.is_none() && ev.kind == PointerKind::Down { @@ -190,10 +258,12 @@ impl Tool for PenTool { } // The path being drawn moves its bounds as it grows: only its anchors and segments pull. let drawn = active.map(|a| a.0); - let from = active.map(|(_, _, last, handle)| { + + let from = active.map(|(_, _, _, last, handle)| { let leave = Leave::segment(cx, last, ev.mods.shift); if handle == last { leave } else { leave.without_contact() } }); + let p = match ev.kind { PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()), PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()), @@ -208,15 +278,16 @@ impl Tool for PenTool { self.last = ev.pos; self.pressed = ev.pos; self.dragged = false; - if let Some((id, first, last, _)) = active { - let end = last_anchor(cx, id); + if let Some((id, si, first, last, _)) = active { + let end = last_anchor(cx, id, Some(si)); let closing_end = end.filter(|(_, ai)| *ai > 0).map(|_| first); let picked = pick_endpoint(closing_end, last, ev.pos, tol, Endpoint::First); // Pick at the raw pointer before snapping or constraining a new point. // A one-anchor path doesn't close on itself: its anchor is the last one too. if picked == Some(Endpoint::First) { - self.drag = Some(Place { at: first, path: Some(id), closing: true }); - return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; + self.drag = Some(Place { at: first, path: Some(id), subpath: Some(si), closing: true }); + let params = json!({"id": id.0, "subpath": si}); + return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)]; } // Alt uses Anchor Point behavior at the terminal anchor too. if let Some(acts) = self.alt_convert(cx, ev) { @@ -228,12 +299,13 @@ impl Tool for PenTool { self.handle = Some((id, si, ai, last)); return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)]; } - let raw_other = open_end_at(cx, ev.pos, tol, Some(id)); + let raw_other = open_end_at(cx, ev.pos, tol, Some((id, si))); if raw_other.is_none() && p != ev.pos { let snapped = pick_endpoint(closing_end, last, p, tol, Endpoint::First); if snapped == Some(Endpoint::First) { - self.drag = Some(Place { at: first, path: Some(id), closing: true }); - return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; + self.drag = Some(Place { at: first, path: Some(id), subpath: Some(si), closing: true }); + let params = json!({"id": id.0, "subpath": si}); + return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)]; } if snapped == Some(Endpoint::Last) && let Some((si, ai)) = end @@ -243,14 +315,17 @@ impl Tool for PenTool { } } // An end of another open path: the two become one, and the path is finished. - if let Some((other, at_first)) = raw_other.or_else(|| open_end_at(cx, p, tol, Some(id))) { + if let Some((other, other_si, at_first)) = raw_other.or_else(|| open_end_at(cx, p, tol, Some((id, si)))) { self.stop(); let end = if at_first { "first" } else { "last" }; - return vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", end]}))]; + let mut params = json!({"ids": [id.0, other.0], "ends": ["last", end]}); + params["subpaths"] = json!([si, other_si]); + return vec![Action::Exec("path.join".into(), params)]; } - self.drag = Some(Place { at: p, path: Some(id), closing: false }); + self.drag = Some(Place { at: p, path: Some(id), subpath: Some(si), closing: false }); self.in_h = p; - return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; + let params = json!({"id": id.0, "x": p.x, "y": p.y, "subpath": si}); + return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), params)]; } if let Some(acts) = self.alt_convert(cx, ev) { return acts; @@ -261,22 +336,35 @@ impl Tool for PenTool { // A raw endpoint anywhere wins before either snapped continuation route. for at in std::iter::once(ev.pos).chain((p != ev.pos).then_some(p)) { // Continue a selected open path when clicking on one of its ends. - if let Some((id, first, last, _)) = active_path(cx) + if let Some((id, si, first, last, _)) = active_path(cx) && let Some(picked) = pick_endpoint(Some(first), last, at, tol, Endpoint::Last) { self.drawing = true; self.path = Some(id); + self.subpath = Some(si); if picked == Endpoint::First { - return vec![Action::Exec("path.reverse".into(), json!({}))]; + let p = + if cx.selection.objects.as_slice() == [id] { json!({"subpath": si}) } else { json!({"ids": [id.0], "subpath": si}) }; + return vec![Action::Exec("path.reverse".into(), p)]; } return vec![]; } // Continue any other open path from the end clicked, selecting it. - if let Some((id, at_first)) = open_end_at(cx, at, tol, None) { - (self.drawing, self.path) = (true, Some(id)); - let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + if let Some((id, si, at_first)) = open_end_at(cx, at, tol, None) { + (self.drawing, self.path, self.subpath) = (true, Some(id), Some(si)); + let top = cx + .doc + .parent_of(id) + .filter(|p| matches!(cx.doc.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))) + .unwrap_or(id); + let mut out = vec![]; + if cx.selection.objects.as_slice() != [top] { + out.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); + } if at_first { - out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]}))); + let p = + if cx.selection.objects.as_slice() == [id] { json!({"subpath": si}) } else { json!({"ids": [id.0], "subpath": si}) }; + out.push(Action::Exec("path.reverse".into(), p)); } return out; } @@ -284,8 +372,8 @@ impl Tool for PenTool { if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) { return vec![act]; } - (self.drawing, self.path) = (true, None); - self.drag = Some(Place { at: p, path: None, closing: false }); + (self.drawing, self.path, self.subpath) = (true, None, None); + self.drag = Some(Place { at: p, path: None, subpath: None, closing: false }); vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] } PointerKind::Drag => { @@ -309,13 +397,13 @@ impl Tool for PenTool { } return vec![set_out_handle(id, si, ai, h)]; } - let Some(Place { at: mut a, path, closing }) = self.drag else { return vec![] }; + let Some(Place { at: mut a, path, subpath, closing }) = self.drag else { return vec![] }; // Space held moves the anchor being placed, handles and all. if ev.mods.space && !closing { let d = ev.pos - self.last; a += d; self.in_h += d; - self.drag = Some(Place { at: a, path, closing }); + self.drag = Some(Place { at: a, path, subpath, closing }); } self.last = ev.pos; let mut out_h = ev.pos; @@ -332,19 +420,21 @@ impl Tool for PenTool { )]; }; if closing { - return vec![Action::Preview( - "path.close".into(), - json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}), - )]; + let mut params = json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}); + if let Some(si) = subpath { + params["subpath"] = json!(si); + } + return vec![Action::Preview("path.close".into(), params)]; } // Alt pressed mid-drag keeps the curve already shaped into the anchor: only the // outgoing handle follows. Held from the start, the incoming handle stays retracted. let in_h = if alt { self.in_h } else { a - (out_h - a) }; self.in_h = in_h; - vec![Action::Preview( - "path.appendAnchor".into(), - json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}), - )] + let mut params = json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}); + if let Some(si) = subpath { + params["subpath"] = json!(si); + } + vec![Action::Preview("path.appendAnchor".into(), params)] } PointerKind::Up => { self.snap.clear(); @@ -369,7 +459,9 @@ impl Tool for PenTool { _ => vec![], } } + /// Deactivate the pen tool, committing in-flight operations and restoring compound path selection. fn deactivate(&mut self, cx: &ToolContext) -> Vec { + let path = self.path; self.stop(); self.snap.clear(); let placing = self.drag.take().is_some(); @@ -379,6 +471,13 @@ impl Tool for PenTool { if placing || handling { acts.push(Action::Commit); } + if let Some(id) = path + && cx.selection.objects.as_slice() == [id] + && let Some(parent) = cx.doc.parent_of(id) + && cx.doc.node(parent).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) + { + acts.push(Action::Exec("select.set".into(), json!({"ids": [parent.0]}))); + } acts } fn overlays(&self, cx: &ToolContext) -> Vec { @@ -389,7 +488,7 @@ impl Tool for PenTool { if cx.pen_rubber_band && self.drag.is_none() && self.handle.is_none() - && let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover) + && let (Some((id, _, _, last, out)), Some(h)) = (self.active(cx), self.hover) { let mut bp = BezPath::new(); bp.move_to(last); @@ -413,14 +512,14 @@ impl Tool for PenTool { } let tol = cx.tol(5.0).max(cx.point_tol()); let active = self.active(cx); - if let Some((id, first, last, _)) = active { - let closing_end = last_anchor(cx, id).filter(|(_, ai)| *ai > 0).map(|_| first); + if let Some((id, si, first, last, _)) = active { + let closing_end = last_anchor(cx, id, Some(si)).filter(|(_, ai)| *ai > 0).map(|_| first); match pick_endpoint(closing_end, last, p, tol, Endpoint::First) { Some(Endpoint::First) => return Cursor::PenClose, Some(Endpoint::Last) => return Cursor::PenConvert, None => {} } - if !alt_converts(cx, p, m) && open_end_at(cx, p, tol, Some(id)).is_some() { + if !alt_converts(cx, p, m) && open_end_at(cx, p, tol, Some((id, si))).is_some() { return Cursor::PenJoin; } } @@ -435,9 +534,7 @@ impl Tool for PenTool { Some([_]) => return Cursor::PenAdd, _ => {} } - if active_path(cx).is_some_and(|(_, first, last, _)| pick_endpoint(Some(first), last, p, tol, Endpoint::Last).is_some()) - || open_end_at(cx, p, tol, None).is_some() - { + if open_end_at(cx, p, tol, None).is_some() { return Cursor::PenContinue; } match auto_add_delete(cx, p, m, tol) { @@ -454,13 +551,31 @@ impl Tool for PenTool { impl PenTool { /// The open path being drawn: the one the pen made or went on with, while it is the single /// selected open path. - fn active(&self, cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { - active_path(cx).filter(|(id, ..)| self.drawing && self.path.is_none_or(|p| p == *id)) + fn active(&self, cx: &ToolContext) -> Option<(NodeId, usize, Point, Point, Point)> { + if self.drawing + && let Some(pid) = self.path + && (cx.selection.objects.as_slice() == [pid] + || cx.doc.parent_of(pid).is_some_and(|parent| { + cx.selection.objects.as_slice() == [parent] && cx.doc.node(parent).is_some_and(|pn| matches!(pn.kind, NodeKind::Compound { .. })) + })) + && cx.doc.is_editable(pid) + && let Some(n) = cx.doc.node(pid) + && let Some(pd) = n.path_data() + { + let (si, sp) = match self.subpath { + Some(si) => pd.subpaths.get(si).filter(|s| !s.closed).map(|s| (si, s))?, + None => pd.subpaths.iter().enumerate().rev().find(|(_, s)| !s.closed)?, + }; + let first = sp.anchors.first()?.p; + let last = sp.anchors.last()?; + return Some((pid, si, first, last.p, last.h_out)); + } + active_path(cx).filter(|(id, si, ..)| self.drawing && self.path.is_none_or(|p| p == *id) && self.subpath.is_none_or(|s| s == *si)) } /// The path is finished. fn stop(&mut self) { - (self.drawing, self.path) = (false, None); + (self.drawing, self.path, self.subpath) = (false, None, None); } /// Hand an Alt press on a selected path's handle, anchor or segment to the Anchor Point tool. @@ -550,7 +665,7 @@ mod tests { assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let raw = Point::new(237.0, 403.0); @@ -558,8 +673,8 @@ mod tests { assert!(raw.distance(first) > tol && raw.distance(last) > tol); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); - assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); - assert!(open_end_at(&cx, last, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, raw, tol, Some((id, 0))).is_none()); + assert!(open_end_at(&cx, last, tol, Some((id, 0))).is_none()); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), @@ -569,7 +684,7 @@ mod tests { assert!(pen.drag.is_none() && pen.busy()); assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); assert!(pen.handle.is_none() && !pen.busy()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); } #[test] @@ -595,17 +710,17 @@ mod tests { }; let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let tol = cx.tol(5.0).max(cx.point_tol()); assert!(raw.distance(first) > tol && raw.distance(last) > tol); - assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + assert_eq!(open_end_at(&cx, raw, tol, Some((id, 0))), Some((other, 0, true))); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"], "subpaths": [0, 0]}))] ); assert!(!pen.busy() && pen.active(&cx).is_none()); assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); @@ -633,7 +748,7 @@ mod tests { assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let raw = Point::new(63.0, 303.0); @@ -641,12 +756,12 @@ mod tests { assert!(raw.distance(first) > tol && raw.distance(last) > tol); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); - assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); - assert!(open_end_at(&cx, first, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, raw, tol, Some((id, 0))).is_none()); + assert!(open_end_at(&cx, first, tol, Some((id, 0))).is_none()); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let place = pen.drag.unwrap(); assert!(place.closing); @@ -678,17 +793,17 @@ mod tests { }; let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let tol = cx.tol(5.0).max(cx.point_tol()); assert!(raw.distance(first) > tol && raw.distance(last) > tol); - assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + assert_eq!(open_end_at(&cx, raw, tol, Some((id, 0))), Some((other, 0, true))); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"], "subpaths": [0, 0]}))] ); assert!(!pen.busy() && pen.active(&cx).is_none()); assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); @@ -796,7 +911,7 @@ mod tests { acts, vec![Action::Preview( "path.appendAnchor".into(), - json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]}) + json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0], "subpath": 0}) )] ); } @@ -865,7 +980,7 @@ mod tests { assert_eq!(t.active(&cx).map(|a| a.0), Some(id)); assert_eq!( t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); } @@ -915,7 +1030,7 @@ mod tests { let cursor = if first_end { Cursor::PenClose } else { Cursor::PenConvert }; let actual_cursor = pen.cursor(&cx, at, mods); let expected = if first_end { - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] } else { vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)] }; @@ -952,7 +1067,7 @@ mod tests { let at = Point::new(first.x + offset / zoom, first.y); let mut pen = PenTool::default(); assert_eq!(pen.cursor(&cx, at, Mods::default()), Cursor::PenContinue); - let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({}))] } else { vec![] }; + let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({"subpath": 0}))] } else { vec![] }; let actual = click(&mut pen, &cx, at.x, at.y, Mods::default()); if actual != expected { failures.push((zoom, grid, offset, actual)); @@ -975,7 +1090,7 @@ mod tests { assert_eq!(drawing.cursor(&cx, at, Mods::default()), Cursor::PenClose); assert_eq!( click(&mut drawing, &cx, at.x, at.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let mut resuming = PenTool::default(); assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue); @@ -1032,7 +1147,7 @@ mod tests { assert_eq!(closing.cursor(&cx, first, Mods::default()), Cursor::PenClose); assert_eq!( click(&mut closing, &cx, first.x, first.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let mut alt_pen = PenTool { drawing: true, ..PenTool::default() }; let alt = Mods { alt: true, ..Mods::default() }; @@ -1099,12 +1214,12 @@ mod tests { let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0)); - assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]); + assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))]); // What the tool sees once the preview applied: the path closed. d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; let c = cx(&d, &s, &p); let close = |x: f64, y: f64, alt: bool| { - vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))] + vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt, "subpath": 0}))] }; for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] { assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})"); @@ -1216,10 +1331,10 @@ mod tests { let to = Point::new(150.0, 318.0); assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); let a = click(&mut t, &cx, 152.0, 318.0, Mods::default()); - assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0}))); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0, "subpath": 0}))); let shift = Mods { shift: true, ..Mods::default() }; let a = click(&mut t, &cx, 152.0, 310.0, shift); - assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0}))); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0, "subpath": 0}))); // The first anchor of a new path lands on the rect's corner. let mut t = PenTool::default(); let a = click(&mut t, &cx, 102.0, 99.0, Mods::default()); diff --git a/crates/ui-egui/src/i18n/untranslated_messages.txt b/crates/ui-egui/src/i18n/untranslated_messages.txt index e526314c8..f0c050348 100644 --- a/crates/ui-egui/src/i18n/untranslated_messages.txt +++ b/crates/ui-egui/src/i18n/untranslated_messages.txt @@ -34,6 +34,7 @@ JSON recovery transport exceeds 8 MiB; use the binary adapter PDF content is incomplete or exceeds the reader's limits; no document was imported PDF flattener: {e} PDF options: {e} +PDF security settings are not an object SVG is not UTF-8, UTF-16 or ISO 8859-1 Save writes one artboard: name one, or export several with document.export Select a dimension first @@ -73,6 +74,7 @@ Text options: {e} `direction` must be "auto", "leftToRight" or "rightToLeft" `each` is the value one design stands for, more than 0 `ecc` must be "L", "M", "Q" or "H" +`ends` applies to joining exactly two paths `event` must be one of {_1} `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `folder` must be an absolute folder path @@ -139,6 +141,7 @@ Text options: {e} `starts` must be an array of anchor indices (or nulls) `stop` must be a stop index `strokeMode` applies to strokes (stroke: true) +`subpaths` must be two integer indices `style` must be roman or japanese `symbol` must be a symbol name `target` must be "object" or "contents", not {v} @@ -386,6 +389,7 @@ command must be a shape.* command commands[{i}]: missing `command` commands[{i}]: unknown command `{id}` composer must be "singleLine" or "everyLine" +compound {_1} must contain exactly one open path to join copy needs path: the file to copy into the document's folder copy takes images only: relink placed documents where their files are copying a file isn't available on the web @@ -952,6 +956,7 @@ select area type and closed paths (at least one area type object) select artwork to add its colours select artwork to make a group of its colours select artwork to recolour, or give a colour `group` +select exactly two path ends to join select objects or give ids select objects to align to the guide select one Intertwine group @@ -1003,6 +1008,8 @@ subLines contains an invalid lane subLines is missing subLines must be a list or null subLines must contain at most {MAX_SUB_LINES} valid lanes +subpath {si} is not an open subpath +subpaths required when joining the same path swatchConflict must be "merge" or "add", not {v} text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text transform is collapsed or unrepresentable From 11f1d5c4d6c5fcbcb8a96df2334580dc044330bf Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 16:08:09 -0600 Subject: [PATCH 002/199] M2.1: Pen continuation in a compound path reverses only the path it continues Clicking the first anchor of an open path inside a compound path made the Pen select the compound and then reverse through the selection, which `paths_in` expands to every member path: the clicked subpath of each sibling flipped too, and under the non-zero fill rule a hole became a fill. Name the path in the reverse instead. The adjacent continuation of an already selected path is unaffected: it issues no selection change, so its selection-addressed reverse still means that path. Regressions: an engine test continues a compound member with two open subpaths at its first anchor and holds the member's other subpath and the sibling member byte-identical (anchors, handles, closed flags); a tool test pins the emitted `path.reverse` parameters to the member's id. --- crates/engine/src/tests_draw2.rs | 26 ++++++++++++++++++ crates/tools/src/pen.rs | 45 +++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 3181f2155..80b3f5dc1 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -2960,3 +2960,29 @@ fn join_two_refuses_an_empty_subpath() { .unwrap(); assert!(s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["last", "first"], "subpaths": [0, 0]})).is_err()); } + +/// Continuing a compound member at its first anchor reverses that subpath only. The Pen puts the +/// compound back in the selection before it reverses, so the reverse has to name the member path: +/// addressed through the selection it would walk the compound and flip every member's subpath. +#[test] +fn pen_continue_at_a_first_anchor_in_a_compound_reverses_only_that_subpath() { + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let a = NodeId(s.execute("path.create", &json!({"d": "M100 100 L200 100 M100 150 L200 150"})).unwrap()["id"].as_u64().unwrap()); + let b = NodeId(s.execute("path.create", &json!({"d": "M300 100 L400 100 M300 150 L400 150"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + // The member path itself is the whole selection, as a direct selection inside the compound + // leaves it: continuing it selects the compound again, which must not widen the reverse. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let (before_a, before_b) = (path(&s, a), path(&s, b)); + + s.select_tool("pen", v).unwrap(); + gesture(&mut s, &[(100.0, 100.0)], none); + + let after_a = path(&s, a); + assert_eq!(after_a.subpaths[0].anchors.iter().map(|x| x.p).collect::>(), vec![Point::new(200.0, 100.0), Point::new(100.0, 100.0)]); + assert_eq!(after_a.subpaths[1], before_a.subpaths[1], "the member's other subpath keeps its anchors and handles"); + assert_eq!(path(&s, b), before_b, "the compound's other member keeps its anchors, handles and closed flags"); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid], "the compound is selected again"); +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 2b7bf12e8..dc04c370b 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -362,9 +362,10 @@ impl Tool for PenTool { out.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); } if at_first { - let p = - if cx.selection.objects.as_slice() == [id] { json!({"subpath": si}) } else { json!({"ids": [id.0], "subpath": si}) }; - out.push(Action::Exec("path.reverse".into(), p)); + // Name the path: the `select.set` above may have just put its compound + // parent in the selection, and a reverse addressed through the + // selection would flip this subpath of every member of it. + out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))); } return out; } @@ -1458,4 +1459,42 @@ mod tests { assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "the path is finished"); assert_eq!(Cursor::PenDrawing.precise(), Cursor::Crosshair); } + + /// Continuing an open path inside a compound path at its first anchor names the path it + /// reverses. The same press selects the compound, and `path.reverse` without `ids` would then + /// walk the compound and flip that subpath of every member. + #[test] + fn continuing_a_compound_member_at_its_first_anchor_reverses_that_path_by_id() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (a, b, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let two_subpaths = |x: f64| { + vectorcraft_geom::PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(x, 300.0), Point::new(x + 100.0, 300.0)], false), + vectorcraft_geom::SubPath::polyline(&[Point::new(x, 350.0), Point::new(x + 100.0, 350.0)], false), + ]) + }; + let path = |id, x| std::sync::Arc::new(vectorcraft_doc::Node::path(id, two_subpaths(x), vectorcraft_doc::Appearance::default_art())); + let mut c = vectorcraft_doc::Node::new( + compound, + NodeKind::Compound { children: vec![path(a, 10.0), path(b, 300.0)], rule: vectorcraft_geom::FillRule::NonZero }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 1, c).unwrap(); + let p = paint(); + // The member path alone is the selection, as a direct selection inside the compound leaves it. + let mut s = Selection::default(); + s.set([a]); + let cx = ToolContext { smart_guides: false, snap_to_point: false, snap_to_grid: false, snap_to_pixel: false, ..cx(&d, &s, &p) }; + let mut pen = PenTool::default(); + assert_eq!( + click(&mut pen, &cx, 10.0, 300.0, Mods::default()), + vec![ + Action::Exec("select.set".into(), json!({"ids": [compound.0]})), + Action::Exec("path.reverse".into(), json!({"ids": [a.0], "subpath": 0})), + ], + "the reverse names the member path, not the compound the select.set just selected" + ); + assert_eq!((pen.path, pen.subpath), (Some(a), Some(0))); + } } From 44044d93baf10d1595ceb217a1c0a7f3fedfcd8d Mon Sep 17 00:00:00 2001 From: el-j Date: Mon, 26 Oct 2026 10:04:10 +0100 Subject: [PATCH 003/199] M2.1: honor ends on selected join and compute nearest pair for subpaths (#3274, #3276, #3379, #3380) --- crates/engine/src/cmd/path.rs | 50 ++++++++++++++++++++++++++++++-- crates/engine/src/tests_draw2.rs | 25 ++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 94667d776..3684df0c3 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -408,6 +408,14 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> { Some((sp.anchors.first()?.p, sp.anchors.last()?.p)) } +/// Orientations (reverse first, reverse second) that bring the nearest ends together. +fn nearest_ends(sp_a: &SubPath, sp_b: &SubPath) -> Option<(bool, bool)> { + let (fa, la) = end_points(sp_a)?; + let (fb, lb) = end_points(sp_b)?; + let cands = [(la.distance(fb), false, false), (la.distance(lb), false, true), (fa.distance(fb), true, false), (fa.distance(lb), true, true)]; + cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map(|(_, rx, ry)| (rx, ry)) +} + /// Corner or smooth on an anchor a join leaves behind. fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) { if smooth { @@ -558,7 +566,14 @@ fn join(s: &mut Session, p: &Value) -> Result { if ids.len() == 2 && ids[0] == ids[1] { let id = ids[0]; let (sa, sb) = subpaths.ok_or_else(|| bad(C, "subpaths required when joining the same path"))?; - let (rx, ry) = ends.unwrap_or((false, false)); + let st = s.doc()?; + let p = st.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + let spa = p.subpaths.get(sa).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let spb = p.subpaths.get(sb).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let (rx, ry) = match ends { + Some(e) => e, + None => nearest_ends(spa, spb).unwrap_or((false, false)), + }; s.edit("Join", |d, sel| { join_subpaths_in_node(d, id, (sa, sb), (rx, ry), join_kind)?; sel.set([compound_or_self(d, id)]); @@ -570,7 +585,15 @@ fn join(s: &mut Session, p: &Value) -> Result { && let Some((sa, sb)) = subpaths { let (a, b) = (ids[0], ids[1]); - let (rx, ry) = ends.unwrap_or((false, false)); + let st = s.doc()?; + let pa = st.doc.node(a).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(a))?; + let pb = st.doc.node(b).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(b))?; + let spa = pa.subpaths.get(sa).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let spb = pb.subpaths.get(sb).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let (rx, ry) = match ends { + Some(e) => e, + None => nearest_ends(spa, spb).unwrap_or((false, false)), + }; s.edit("Join", |d, sel| { join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?; sel.set([compound_or_self(d, a)]); @@ -812,6 +835,9 @@ fn join(s: &mut Session, p: &Value) -> Result { } return Err(EngineError::Other("select paths to join".into())); } + if ends.is_some() && open_subs.len() > 2 { + return Err(bad(C, "`ends` applies to joining exactly two paths")); + } if open_subs.len() == 1 { let (id, si) = open_subs[0]; s.edit("Join", |d, sel| { @@ -821,6 +847,26 @@ fn join(s: &mut Session, p: &Value) -> Result { })?; return ok(); } + if let Some((rx, ry)) = ends + && open_subs.len() == 2 + { + let (a, sa) = open_subs[0]; + let (b, sb) = open_subs[1]; + if a == b { + s.edit("Join", |d, sel| { + join_subpaths_in_node(d, a, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); + Ok(()) + })?; + } else { + s.edit("Join", |d, sel| { + join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); + Ok(()) + })?; + } + return ok(); + } s.edit("Join", |d, sel| { let top = join_nearest(d, open_subs, join_kind)?; diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 80b3f5dc1..528f1de90 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -2169,6 +2169,31 @@ fn join_takes_the_paths_and_the_ends_to_join() { } } +/// `path.join {ends}` on selected paths honors the requested ends, not the nearest pair. +#[test] +fn join_selected_paths_honors_ends_parameter() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + s.select(|_, sel| sel.set([a, b])).unwrap(); + // The far ends: a's first to b's last (nearest pair would be a's last and b's first). + s.execute("path.join", &json!({"ends": ["first", "last"]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]); +} + +/// `path.join {ids, subpaths}` without `ends` computes the nearest pair within those subpaths. +#[test] +fn join_subpaths_without_ends_computes_nearest_pair() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 200.0, 0.0, 110.0, 0.0); + // Nearest ends between a (0..100) and b (200..110) are 100 and 110. + s.execute("path.join", &json!({"ids": [a.0, b.0], "subpaths": [0, 0]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (110.0, 0.0), (200.0, 0.0)]); +} + /// `path.join {to}` makes the joined anchor a corner or smooth point. #[test] fn join_to_corner_or_smooth_sets_the_merged_anchor() { From 777a764a0e01918152c36ce58ed17dc93ad7ebed Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:05:40 +0000 Subject: [PATCH 004/199] test: scan source directories recursively The EPS error-meaning test walks src/import and its subdirectories. The picks and tl! literal scans skip directories named tests, as the undo and message scans already do, so test modules moved to foo/tests.rs or tests/.rs are not read as interface code. --- crates/eps/src/import/tests_generators.rs | 29 ++++++++++++++--------- crates/ui-egui/src/i18n/tests.rs | 4 +++- crates/ui-egui/src/tests_picks.rs | 4 +++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/crates/eps/src/import/tests_generators.rs b/crates/eps/src/import/tests_generators.rs index 892bcf417..2fe6ba324 100644 --- a/crates/eps/src/import/tests_generators.rs +++ b/crates/eps/src/import/tests_generators.rs @@ -768,17 +768,24 @@ fn errors_say_what_the_postscript_error_means() { fn every_postscript_error_the_reader_raises_has_a_meaning() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join("import"); let mut raised = std::collections::BTreeSet::new(); - for entry in std::fs::read_dir(&dir).unwrap() { - let path = entry.unwrap().path(); - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); - if !name.ends_with(".rs") || name.starts_with("tests") { - continue; - } - let text = std::fs::read_to_string(&path).unwrap(); - for marker in ["Ps(\"", "ps_err(\""] { - for (at, _) in text.match_indices(marker) { - let rest = &text[at + marker.len()..]; - raised.insert(rest[..rest.find('"').unwrap()].to_string()); + let mut stack = vec![dir]; + while let Some(d) = stack.pop() { + for entry in std::fs::read_dir(&d).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if path.is_dir() { + stack.push(path); + continue; + } + if !name.ends_with(".rs") || name.starts_with("tests") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + for marker in ["Ps(\"", "ps_err(\""] { + for (at, _) in text.match_indices(marker) { + let rest = &text[at + marker.len()..]; + raised.insert(rest[..rest.find('"').unwrap()].to_string()); + } } } } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index d383eaa01..22ade9b79 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -491,7 +491,9 @@ pub fn tl_literals() -> std::collections::BTreeSet { let path = entry.path(); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if path.is_dir() { - stack.push(path); + if name != "tests" { + stack.push(path); + } } else if name.ends_with(".rs") && name != "lib.rs" && !name.starts_with("tests") { let text = std::fs::read_to_string(&path).unwrap_or_default(); let code = text.split("#[cfg(test)]").next().unwrap_or(""); diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests_picks.rs index 686fddd18..609f38bd5 100644 --- a/crates/ui-egui/src/tests_picks.rs +++ b/crates/ui-egui/src/tests_picks.rs @@ -991,7 +991,9 @@ fn only_picks_shows_the_hosts_dialogs() { let path = entry.path(); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(); if path.is_dir() { - stack.push(path); + if name != "tests" { + stack.push(path); + } } else if name.ends_with(".rs") && !name.starts_with("tests") && name != "picks.rs" { let key = path.strip_prefix(&dir).unwrap().to_string_lossy().replace('\\', "/"); scanned.insert(key, without_tests(&code_only(&std::fs::read_to_string(&path).unwrap()))); From 6c57ddb063ec44682c325c995db483f5844cf2a4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:18 +0000 Subject: [PATCH 005/199] refactor(color): move cms/mod.rs to foo.rs files --- crates/color/src/{cms/mod.rs => cms.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/color/src/{cms/mod.rs => cms.rs} (100%) diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms.rs similarity index 100% rename from crates/color/src/cms/mod.rs rename to crates/color/src/cms.rs From 6bbfb91d1612a43609a376d3425873902935b644 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:18 +0000 Subject: [PATCH 006/199] refactor(effects): move pixel/mod.rs to foo.rs files --- crates/effects/src/{pixel/mod.rs => pixel.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/effects/src/{pixel/mod.rs => pixel.rs} (100%) diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel.rs similarity index 100% rename from crates/effects/src/pixel/mod.rs rename to crates/effects/src/pixel.rs From 62261b17c90c3ce1f90b8a6bb31c58c581a6a626 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:21 +0000 Subject: [PATCH 007/199] refactor(engine): move cmd/mod.rs cmd/fileio/mod.rs cmd/findfiles/mod.rs cmd/place/mod.rs to foo.rs files --- crates/engine/src/{cmd/mod.rs => cmd.rs} | 0 crates/engine/src/cmd/{fileio/mod.rs => fileio.rs} | 0 crates/engine/src/cmd/{findfiles/mod.rs => findfiles.rs} | 0 crates/engine/src/cmd/{place/mod.rs => place.rs} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename crates/engine/src/{cmd/mod.rs => cmd.rs} (100%) rename crates/engine/src/cmd/{fileio/mod.rs => fileio.rs} (100%) rename crates/engine/src/cmd/{findfiles/mod.rs => findfiles.rs} (100%) rename crates/engine/src/cmd/{place/mod.rs => place.rs} (100%) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio.rs similarity index 100% rename from crates/engine/src/cmd/fileio/mod.rs rename to crates/engine/src/cmd/fileio.rs diff --git a/crates/engine/src/cmd/findfiles/mod.rs b/crates/engine/src/cmd/findfiles.rs similarity index 100% rename from crates/engine/src/cmd/findfiles/mod.rs rename to crates/engine/src/cmd/findfiles.rs diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place.rs similarity index 100% rename from crates/engine/src/cmd/place/mod.rs rename to crates/engine/src/cmd/place.rs From b9a73ba1af8ea0b74cb43306933b6551656ac35d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:21 +0000 Subject: [PATCH 008/199] refactor(eps): move import/mod.rs import/ai/mod.rs print/mod.rs to foo.rs files --- crates/eps/src/{import/mod.rs => import.rs} | 0 crates/eps/src/import/{ai/mod.rs => ai.rs} | 0 crates/eps/src/{print/mod.rs => print.rs} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename crates/eps/src/{import/mod.rs => import.rs} (100%) rename crates/eps/src/import/{ai/mod.rs => ai.rs} (100%) rename crates/eps/src/{print/mod.rs => print.rs} (100%) diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import.rs similarity index 100% rename from crates/eps/src/import/mod.rs rename to crates/eps/src/import.rs diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai.rs similarity index 100% rename from crates/eps/src/import/ai/mod.rs rename to crates/eps/src/import/ai.rs diff --git a/crates/eps/src/print/mod.rs b/crates/eps/src/print.rs similarity index 100% rename from crates/eps/src/print/mod.rs rename to crates/eps/src/print.rs From 0b04172c4503e08d303c063061fbc21563c0cf01 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:21 +0000 Subject: [PATCH 009/199] refactor(pdf): move print/mod.rs to foo.rs files --- crates/pdf/src/{print/mod.rs => print.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/pdf/src/{print/mod.rs => print.rs} (100%) diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print.rs similarity index 100% rename from crates/pdf/src/print/mod.rs rename to crates/pdf/src/print.rs From f1ab4965c4bfefb8b61ead75647fd877ff237e45 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:22 +0000 Subject: [PATCH 010/199] refactor(render): move encode/mod.rs to foo.rs files --- crates/render/src/{encode/mod.rs => encode.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/render/src/{encode/mod.rs => encode.rs} (100%) diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode.rs similarity index 100% rename from crates/render/src/encode/mod.rs rename to crates/render/src/encode.rs From 769505778032d840ee85744e8d7457fd06405cdf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:22 +0000 Subject: [PATCH 011/199] refactor(trace): move centerline/mod.rs logo/mod.rs to foo.rs files --- crates/trace/src/{centerline/mod.rs => centerline.rs} | 0 crates/trace/src/{logo/mod.rs => logo.rs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename crates/trace/src/{centerline/mod.rs => centerline.rs} (100%) rename crates/trace/src/{logo/mod.rs => logo.rs} (100%) diff --git a/crates/trace/src/centerline/mod.rs b/crates/trace/src/centerline.rs similarity index 100% rename from crates/trace/src/centerline/mod.rs rename to crates/trace/src/centerline.rs diff --git a/crates/trace/src/logo/mod.rs b/crates/trace/src/logo.rs similarity index 100% rename from crates/trace/src/logo/mod.rs rename to crates/trace/src/logo.rs From a042e3ff2578c8344c70e4c1b7b7c0628b9c660a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:45 +0000 Subject: [PATCH 012/199] refactor(ui-egui): move dialogs/mod.rs i18n/mod.rs panels/mod.rs to foo.rs files --- .../src/{dialogs/mod.rs => dialogs.rs} | 0 crates/ui-egui/src/{i18n/mod.rs => i18n.rs} | 48 +++++++++---------- .../ui-egui/src/{panels/mod.rs => panels.rs} | 0 3 files changed, 24 insertions(+), 24 deletions(-) rename crates/ui-egui/src/{dialogs/mod.rs => dialogs.rs} (100%) rename crates/ui-egui/src/{i18n/mod.rs => i18n.rs} (94%) rename crates/ui-egui/src/{panels/mod.rs => panels.rs} (100%) diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs.rs similarity index 100% rename from crates/ui-egui/src/dialogs/mod.rs rename to crates/ui-egui/src/dialogs.rs diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n.rs similarity index 94% rename from crates/ui-egui/src/i18n/mod.rs rename to crates/ui-egui/src/i18n.rs index 690b716d5..e141965bb 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n.rs @@ -101,32 +101,32 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "el", name: "Ελληνικά", - source: include_str!("el.tsv"), + source: include_str!("i18n/el.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(), }, // Japanese: the whole interface (every menu string and `tl!` literal) and the status and error // messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`). - LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "ja", name: "日本語", source: include_str!("i18n/ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Korean: the whole interface and the status and error messages, keeping the same names in // English as Japanese; a count takes no plural, so plurals have one form. Hangul is drawn with an // installed system font (craft-fonts has no Korean UI face yet); every `ko-*` locale resolves here. - LangInfo { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "ko", name: "한국어", source: include_str!("i18n/ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Vietnamese: complete menus and messages, with one plural form. // Every `vi-*` locale resolves here. LangInfo { - code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() + code: "vi", name: "Tiếng Việt", source: include_str!("i18n/vi.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Czech: the whole interface (every menu string and `tl!` literal) and the status and error // messages, with one/few/other plurals, keeping the same names in English as Spanish. - LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "cs", name: "Čeština", source: include_str!("i18n/cs.tsv"), plural: plural_czech, complete_menus: true, catalog: OnceLock::new() }, // German: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `de-*` locale (`de-DE`, `de-AT`, `de-CH`, `de-LU` …) resolves here. LangInfo { code: "de", name: "Deutsch", - source: include_str!("de.tsv"), + source: include_str!("i18n/de.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(), @@ -136,20 +136,20 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "es", name: "Español", - source: include_str!("es.tsv"), + source: include_str!("i18n/es.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(), }, // French: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `fr-*` locale (`fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` …) resolves here. - LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_french, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "fr", name: "Français", source: include_str!("i18n/fr.tsv"), plural: plural_french, complete_menus: true, catalog: OnceLock::new() }, // Italian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `it-*` locale (`it-IT`, `it-CH`, `it-SM` …) resolves here. LangInfo { code: "it", name: "Italiano", - source: include_str!("it.tsv"), + source: include_str!("i18n/it.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(), @@ -158,14 +158,14 @@ pub static LANGUAGES: [LangInfo; 25] = [ // English as Spanish. Georgian nouns stay singular after a number, so one plural form; // `ka-GE` and `ka_GE.UTF-8` resolve here. LangInfo { - code: "ka", name: "ქართული", source: include_str!("ka.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() + code: "ka", name: "ქართული", source: include_str!("i18n/ka.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Russian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` …) resolves here. LangInfo { code: "ru", name: "Русский", - source: include_str!("ru.tsv"), + source: include_str!("i18n/ru.tsv"), plural: plural_east_slavic, complete_menus: true, catalog: OnceLock::new(), @@ -175,7 +175,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "uk", name: "Українська", - source: include_str!("uk.tsv"), + source: include_str!("i18n/uk.tsv"), plural: plural_east_slavic, complete_menus: true, catalog: OnceLock::new(), @@ -185,7 +185,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "bg", name: "Български", - source: include_str!("bg.tsv"), + source: include_str!("i18n/bg.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(), @@ -193,17 +193,17 @@ pub static LANGUAGES: [LangInfo; 25] = [ // Hungarian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; a count takes the singular ("3 réteg"), so plurals have one form. Every // `hu-*` locale (including `hu-HU`) resolves here. - LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "hu", name: "Magyar", source: include_str!("i18n/hu.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Polish: the whole interface and the status and error messages, with one/few/many plurals, // keeping the same names in English as Spanish; every `pl-*` locale (including `pl-PL`) // resolves here. - LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "pl", name: "Polski", source: include_str!("i18n/pl.tsv"), plural: plural_polish, complete_menus: true, catalog: OnceLock::new() }, // Swedish: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `sv-*` locale (`sv-SE`, `sv-FI` …) resolves here. LangInfo { code: "sv", name: "Svenska", - source: include_str!("sv.tsv"), + source: include_str!("i18n/sv.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(), @@ -214,7 +214,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "pt-br", name: "Português (Brasil)", - source: include_str!("pt-br.tsv"), + source: include_str!("i18n/pt-br.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(), @@ -224,7 +224,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "zh-hant", name: "繁體中文", - source: include_str!("zh-hant.tsv"), + source: include_str!("i18n/zh-hant.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new(), @@ -234,7 +234,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "zh-hans", name: "简体中文", - source: include_str!("zh-hans.tsv"), + source: include_str!("i18n/zh-hans.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new(), @@ -242,19 +242,19 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "nl", name: "Nederlands", - source: include_str!("nl.tsv"), + source: include_str!("i18n/nl.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(), }, // Danish; every `da-*` locale (`da-DK`, `da_DK.UTF-8`…) resolves here. - LangInfo { code: "da", name: "Dansk", source: include_str!("da.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "da", name: "Dansk", source: include_str!("i18n/da.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, // Indonesian; every `id-*` locale (`id-ID`, `id_ID.UTF-8`…) resolves here. CLDR `id` counts // have one form, so `@plural` entries list a single form. LangInfo { code: "id", name: "Bahasa Indonesia", - source: include_str!("id.tsv"), + source: include_str!("i18n/id.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new(), @@ -262,11 +262,11 @@ pub static LANGUAGES: [LangInfo; 25] = [ // Finnish: the whole interface and the status and error messages, keeping the same names in // English as Spanish; one/other plurals with the partitive after any number but 1 ("2 tasoa"). // Every `fi-*` locale resolves here. - LangInfo { code: "fi", name: "Suomi", source: include_str!("fi.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "fi", name: "Suomi", source: include_str!("i18n/fi.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, // Turkish: the whole interface and the status and error messages, keeping the same names in // English as Spanish; a count takes the singular ("3 katman"), so plurals have one form. Every // `tr-*` locale (including `tr-TR` and `tr-CY`) resolves here. - LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels.rs similarity index 100% rename from crates/ui-egui/src/panels/mod.rs rename to crates/ui-egui/src/panels.rs From 2cd1a3a3f17efb61f978ad801d6a982e662ea57b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:53 +0000 Subject: [PATCH 013/199] refactor(engine): move cmd/graph.rs tests to cmd/graph/tests.rs --- crates/engine/src/cmd/graph.rs | 2766 +------------------------- crates/engine/src/cmd/graph/tests.rs | 2763 +++++++++++++++++++++++++ 2 files changed, 2764 insertions(+), 2765 deletions(-) create mode 100644 crates/engine/src/cmd/graph/tests.rs diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index cd8fa7b36..18e4bc795 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -2502,2768 +2502,4 @@ fn set_type(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use serde_json::json; - use vectorcraft_color::Paint; - use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind}; - - use crate::{NodeId, Session}; - - #[test] - fn fmt_value_does_not_saturate_beyond_i64() { - assert_eq!(super::fmt_value(1e19), "10000000000000000000"); - assert_eq!(super::fmt_value(2e19), "20000000000000000000"); - assert_eq!(super::fmt_value(-2e19), "-20000000000000000000"); - assert_eq!(super::fmt_value(42.0), "42"); - assert_eq!(super::fmt_value(-0.0), "0"); - assert_eq!(super::fmt_value(2.5), "2.5"); - } - - fn graph(s: &mut Session, ty: &str) -> NodeId { - let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; - NodeId( - s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ) - } - - fn group<'a>(n: &'a vectorcraft_doc::Node, name: &str) -> &'a vectorcraft_doc::Node { - n.children().unwrap().iter().find(|c| c.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no {name}")) - } - - /// The generated group stored for series `index`, not a sibling that happens to share its name. - fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node { - n.children().unwrap().iter().find(|c| c.extras().series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) - } - - #[test] - fn every_graph_type_generates_series_groups() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { - let id = graph(&mut s, ty); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.extras().graph.is_some(), "{ty}"); - if ty != "scatter" { - assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}"); - assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}"); - } - assert!(n.geometric_bounds().unwrap().width() > 100.0, "{ty}"); - } - } - - #[test] - fn column_heights_follow_data_and_data_edits_regenerate_in_place() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - let heights = |s: &Session| -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - // Bars only (the legend swatch is the 8 × 8 square at the end). - let g = group(&n, "2025"); - let ch = g.children().unwrap(); - ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap().height()).collect() - }; - let h = heights(&s); - assert!((h[2] / h[0] - 3.0).abs() < 1e-6, "{h:?}"); - // Move the graph, then change the data: it stays where it was moved. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); - let x0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; - let v = s.execute("graph.setData", &json!({})).unwrap(); - assert!(v["csv"].as_str().unwrap().contains("Q2,5,4")); - s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap(); - let h2 = heights(&s); - assert!((h2[1] / h2[0] - 4.0).abs() < 1e-6, "{h2:?}"); - assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0); - // Type change keeps the data; undo restores the columns. - s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column")); - // Selecting a bar inside still targets the graph. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let bar = group(&n, "2024").children().unwrap()[0].id; - s.execute("select.set", &json!({"ids": [bar.0]})).unwrap(); - assert!(s.execute("graph.setData", &json!({})).is_ok()); - assert!(matches!(n.kind, NodeKind::Group { .. })); - } - - /// The x of every marker of series `index`, left to right. - fn marker_xs(s: &Session, id: NodeId, index: u32) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let mut xs: Vec = series(&n, index) - .children() - .unwrap() - .iter() - .map(|c| c.geometric_bounds().unwrap()) - .filter(|b| (b.width() - 5.0).abs() < 1e-6) - .map(|b| b.center().x) - .collect(); - xs.sort_by(f64::total_cmp); - xs - } - - #[test] - fn edge_to_edge_lines_off_puts_line_points_at_category_centres() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // 300 pt wide, three categories: centres at 150, 250, 350 (off by default); edges at 100 and 400. - let id = graph(&mut s, "line"); - assert_eq!(marker_xs(&s, id, 0), vec![150.0, 250.0, 350.0]); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["edgeToEdge"], json!(false)); - s.execute("graph.setType", &json!({"edgeToEdge": true})).unwrap(); - assert_eq!(marker_xs(&s, id, 0), vec![100.0, 250.0, 400.0]); - // A graph saved before the option existed keeps its edge-to-edge lines. - let old: GraphSpec = serde_json::from_value(json!({"kind": "line"})).unwrap(); - assert!(old.edge_to_edge); - } - - #[test] - fn radar_graphs_follow_mark_and_connect_data_points() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "radar"); - let parts = |s: &Session| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap().len(); - // The ring, three markers and the legend swatch. - assert_eq!(parts(&s), 5); - s.execute("graph.setType", &json!({"markPoints": false})).unwrap(); - assert_eq!(parts(&s), 2); - s.execute("graph.setType", &json!({"markPoints": true, "connectPoints": false})).unwrap(); - assert_eq!(parts(&s), 4); - } - - #[test] - fn an_overridden_value_axis_is_used_exactly() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = s.execute("graph.create", &json!({"type": "column", "x": 20, "y": 20, "width": 170, "height": 74, "series": ["a"], "categories": ["x", "y"], "rows": [[4839], [10488]]})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let tallest = |s: &Session| -> f64 { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let bars = series(&n, 0).children().unwrap(); - bars.iter().map(|c| c.geometric_bounds().unwrap().height()).fold(0.0, f64::max) - }; - // Calculated: a nice axis (0 to 12500 here), so the 10488 column falls short of 10488 / 12000 of the plot. - assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0); - s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 12000})).unwrap(); - assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() < 1e-6, "{}", tallest(&s)); - let o = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((o["axisMin"].as_f64(), o["axisMax"].as_f64()), (Some(0.0), Some(12000.0))); - // One bound only keeps the calculated (nice) axis around it. - s.execute("graph.setType", &json!({"axisMax": null})).unwrap(); - assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0); - } - - #[test] - fn a_scatter_x_axis_that_overflows_does_not_hang() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // Finite x values whose rounded axis end is infinite: this used to add ticks forever. Now the - // command returns: points that far off the plot can't be drawn, which is an error. - let csv = ",y1,x1\nP,1,1.7e308\nQ,2,1.5e308"; - let refused = |r: crate::Result| assert!(r.unwrap_err().to_string().contains("exceed the canvas")); - refused(s.execute("graph.create", &json!({"type": "scatter", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv}))); - // The same data through Graph Data, on an ordinary scatter graph: it stays as it was. - let axes = |s: &Session, id: NodeId| group(s.doc().unwrap().doc.node(id).unwrap(), "Axes").children().unwrap().len(); - let id = make(&mut s, "scatter", ",y1,x1\nP,1,10\nQ,2,20"); - let before = axes(&s, id); - refused(s.execute("graph.setData", &json!({"id": id.0, "csv": csv}))); - assert_eq!(axes(&s, id), before); - } - - #[test] - fn tick_values_end_on_the_maximum_and_never_run_away() { - // Counted, not accumulated: an offset range keeps its last tick. - let t = super::tick_values(1e10, 1e10 + 1.0, 0.01); - assert_eq!(t.len(), 101); - assert!((t[100] - (1e10 + 1.0)).abs() < 1e-3); - // A range too wide for f64, or a step too small to move, gives no ticks or a capped list. - assert!(super::tick_values(-1.7e308, 1.7e308, f64::INFINITY).is_empty()); - assert!(super::tick_values(1e20, 1e20 + 16384.0, 163.84).len() <= 1001); - assert!(super::tick_values(0.0, 1.0, 0.0).is_empty()); - // An override whose span overflows falls back to the calculated axis instead of hanging. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - graph(&mut s, "column"); - s.execute("graph.setType", &json!({"axisMin": -1.7e308, "axisMax": 1.7e308})).unwrap(); - } - - #[test] - fn group_selection_paint_becomes_the_series_colour_and_survives_regeneration() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for ty in GraphKind::ALL { - let id = graph(&mut s, ty.id()); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); - let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); - assert_eq!(spec, restored); - let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); - assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); - let selected_group = group(s.doc().unwrap().doc.node(id).unwrap(), "2024"); - assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}"); - s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); - s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap(); - assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); - let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); - assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}"); - } - } - - #[test] - fn painting_a_series_is_one_undoable_edit() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap(); - assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] - .fill - .as_ref() - .and_then(Paint::color) - .unwrap() - .to_hex(), - "#e8573f" - ); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints.is_empty()); - assert_eq!( - group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(), - Some(super::default_series_color(0)) - ); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] - .fill - .as_ref() - .and_then(Paint::color) - .unwrap() - .to_hex(), - "#e8573f" - ); - } - - /// A fill that a series' marks or a value axis number have already is one undo step when the graph - /// keeps no fill for them (as after a command that paints only the art): the art stays as it was - /// and the graph keeps the fill. Given again, the fill records no undo step. - #[test] - fn a_fill_the_art_has_already_is_kept_by_the_graph_in_one_step() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - let label = text_id(&s, id, "Axes", "4"); - for target in [series_id, label] { - let fill = json!({"ids": [target.0], "color": "#e8573f"}); - s.execute("paint.setFill", &fill).unwrap(); - // The graph keeps nothing, as after a command that paints only the art. - s.edit("Forget", |d, _| { - let g = d.node_mut(id).unwrap().extras_if_any().unwrap().graph.as_deref_mut().unwrap(); - g.series_paints.clear(); - g.label_styles = vectorcraft_doc::LabelStyles::default(); - Ok(()) - }) - .unwrap(); - let art = s.doc().unwrap().doc.node(target).unwrap().clone(); - let steps = s.doc().unwrap().history.undo.len(); - s.execute("paint.setFill", &fill).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{target:?}"); - assert_eq!(s.doc().unwrap().doc.node(target).unwrap(), &art, "the art is as it was"); - let g = spec(&s, id); - let kept = if target == series_id { g.series_paints[0].fill.clone() } else { g.label_styles.values.map(|st| st.fill) }; - assert_eq!(kept.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#e8573f".into()), "{target:?}"); - s.execute("paint.setFill", &fill).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing left to keep: {target:?}"); - } - } - - #[test] - fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; - s.execute("paint.setFill", &json!({"ids": [bar.0], "color": "#12ab34"})).unwrap(); - s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let node = s.doc().unwrap().doc.node(id).unwrap(); - let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0); - assert_eq!(node.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); - assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green))); - } - - /// A series index read from a file past the graph's series is not a series: painting its art - /// paints the art alone and stores nothing (no allocation sized by the file). - #[test] - fn a_series_index_past_the_graphs_series_is_ignored() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.edit("Junk", |d, _| { - d.node_mut(series_id).unwrap().extras_mut().series_index = Some(u32::MAX); - Ok(()) - }) - .unwrap(); - let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; - s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap(); - let node = s.doc().unwrap().doc.node(id).unwrap(); - assert!(node.extras().graph.as_ref().unwrap().series_paints.is_empty()); - assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into())); - } - - #[test] - fn painting_a_line_series_stroke_sets_its_persistent_colour() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "line"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap(); - let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0); - assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), - Some(blue) - ); - assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none()); - s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - assert!( - group(s.doc().unwrap().doc.node(id).unwrap(), "2024") - .children() - .unwrap() - .iter() - .any(|n| n.appearance.stroke_paint().color() == Some(blue)) - ); - } - - #[test] - fn series_colours_survive_native_save_open_and_export() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let first = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id; - s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap(); - s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap(); - let dir = vectorcraft_testkit::temp_dir("graph-palette"); - let path = dir.join("palette.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let paints = &s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints; - assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::>(), vec!["#ff0000", "#008080"]); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap(); - let swatch = group(n, "2025").children().unwrap().last().unwrap(); - assert_eq!(swatch.appearance.fill_paint().color().unwrap().to_hex(), "#008080"); - let third = group(n, "Series 3").children().unwrap().first().unwrap(); - assert_eq!(third.appearance.fill_paint().color().unwrap().to_hex(), "#cccccc"); - let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); - let svg = svg["text"].as_str().unwrap(); - assert!(svg.contains("#ff0000") && svg.contains("#008080")); - let png = dir.join("palette.png"); - s.execute("document.export", &json!({"format": "png", "path": png})).unwrap(); - let pixels = image::open(&png).unwrap().to_rgba8(); - assert!(pixels.pixels().any(|p| p.0 == [255, 0, 0, 255])); - assert!(pixels.pixels().any(|p| p.0 == [0, 128, 128, 255])); - let old: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]]})).unwrap(); - assert!(old.series_paints.is_empty(), "older files keep the greyscale default"); - } - - #[test] - fn a_column_stroke_stays_a_stroke_and_a_marker_fill_does_not_recolour_its_line() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap(); - s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); - let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); - assert_eq!(spec.series_paints[0].stroke, Some(blue.clone())); - assert!(spec.series_paints[0].fill.is_none()); - let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; - assert_eq!(bar.appearance.fill_paint().color(), Some(super::default_series_color(0))); - assert_eq!(bar.appearance.stroke_paint(), blue); - - let id = graph(&mut s, "line"); - let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id; - s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap(); - s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); - assert!(spec.series_paints[0].stroke.is_none()); - let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); - assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0))); - assert_eq!(series[1].appearance.fill_paint().color(), Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))); - } - - #[test] - fn a_gradient_fill_survives_regeneration() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; - s.execute( - "paint.setFill", - &json!({"ids": [series_id.0], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), - ) - .unwrap(); - s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); - assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_)))); - let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; - assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_))); - } - - #[test] - fn a_series_named_legend_keeps_its_own_index_and_an_empty_series_is_omitted() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let csv = ",Legend,Axes\nQ1,3,2\nQ2,5,4"; - let id = NodeId(s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "csv": csv})).unwrap()["id"].as_u64().unwrap()); - let legend_id = { - let n = s.doc().unwrap().doc.node(id).unwrap(); - let legend = series(n, 0); - assert_eq!(legend.name.as_deref(), Some("Legend")); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.extras().series_index.is_none())); - legend.id - }; - s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap(); - s.execute("graph.setData", &json!({"csv": csv})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); - assert!(n.extras().graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); - assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); - assert_eq!(series(n, 1).name.as_deref(), Some("Axes")); - assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); - - let id = graph(&mut s, "pie"); - s.execute("graph.setType", &json!({"id": id.0, "legend": false})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "csv": ",Keep,Drop\nQ1,4,0\nQ2,2,0"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap(); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Keep"))); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); - let keep = group(n, "Keep").id; - s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(group(n, "Keep").extras().series_index, Some(0)); - assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); - } - - #[test] - fn csv_parsing() { - let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n"); - assert_eq!(s, vec!["2024", "2025"]); - assert_eq!(c, vec!["Q1", "Q2"]); - assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]); - let (s, c, r) = super::parse_csv("1,2\n3,4"); - assert!(s.is_empty() && c.is_empty()); - assert_eq!(r.len(), 2); - assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0)); - } - - #[test] - fn numeric_csv_cells_round_trip_without_rounding_or_saturation() { - let mut g = GraphSpec::default(); - g.set_cells(vec![vec![Some(1.2345), Some(-1.2345)], vec![Some(1e-6), Some(-1e-6)], vec![Some(1e20), Some(-1e20)], vec![Some(0.0), None]]); - let csv = super::to_csv(&g); - let (_, _, cells) = super::parse_csv(&csv); - assert_eq!(cells, g.cells(), "{csv}"); - } - - #[test] - fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]); - let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]); - let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]); - let r = s - .execute( - "graph.create", - &json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}), - ) - .unwrap(); - let id = r["id"].as_u64().unwrap(); - let before = s.execute("graph.setData", &json!({"id": id})).unwrap(); - assert_eq!(before["series"], series); - assert_eq!(before["categories"], categories); - s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap(); - let after = s.execute("graph.setData", &json!({"id": id})).unwrap(); - assert_eq!(after["series"], before["series"], "{}", before["csv"]); - assert_eq!(after["categories"], before["categories"], "{}", before["csv"]); - assert_eq!(after["rows"], before["rows"], "{}", before["csv"]); - } - - #[test] - fn empty_cells_are_blank_and_quoted_numbers_are_labels() { - // Quoted years label the rows; empty and non-numeric cells are blank values, not zeros. - let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4"); - assert_eq!(s, vec!["a", "b"]); - assert_eq!(c, vec!["2023", "2024", "2025"]); - assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]); - // Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same. - let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() }; - g.set_cells(r); - assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]); - let csv = super::to_csv(&g); - assert!(csv.contains("\"2024\",,"), "{csv}"); - let (s2, c2, r2) = super::parse_csv(&csv); - assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells())); - } - - #[test] - fn a_row_with_an_empty_label_keeps_its_values_in_place() { - // The label column is the first column of every row once any row has a label. - let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4"); - assert_eq!(c, vec!["Q1", ""]); - assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]); - // And a data window round trip keeps it (the empty label is written as ""). - let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() }; - g.set_cells(r); - assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells()); - // Without series or labels, a first row starting with a blank is still data after a round trip. - let mut g = GraphSpec::default(); - g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]); - let (s, c, r) = super::parse_csv(&super::to_csv(&g)); - assert!(s.is_empty() && c.is_empty()); - assert_eq!(r, g.cells()); - // A label with spaces around a number stays a label, spaces and all. - let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() }; - g.set_cells(vec![vec![Some(1.0)]]); - assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]); - } - - #[test] - fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = - s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]])); - // A label followed by empty cells is a header with no series names, as before blanks existed. - let (s, c, r) = super::parse_csv("Year,,\n2023,1,2"); - assert!(s.is_empty()); - assert_eq!(c, Vec::::new()); - assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]); - } - - #[test] - fn a_huge_ragged_grid_is_capped() { - let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() }; - assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES)); - } - - #[test] - fn a_label_only_row_stays_with_blank_values() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = - s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); - assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]); - assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); - } - - #[test] - fn blank_cells_leave_out_their_columns_and_break_lines() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]); - let r = s - .execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows})) - .unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len(); - // Three columns each (one blank), plus the legend swatch. - assert_eq!((count(&s, 0), count(&s, 1)), (4, 4)); - s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); - assert_eq!((count(&s, 0), count(&s, 1)), (4, 4)); - // Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three - // markers and the legend swatch. - s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - assert_eq!(count(&s, 0), 1 + 3 + 1); - // Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch. - assert_eq!(count(&s, 1), 1 + 3 + 1); - // A data window round trip keeps the blanks. - let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); - assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}"); - let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); - assert_eq!(spec.cells()[1], vec![None, Some(2.0)]); - // In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads - // numbers (as zeros); a graph without blanks writes no `blanks` at all. - let v = serde_json::to_value(&spec).unwrap(); - assert_eq!(v["rows"][1], json!([0.0, 2.0])); - assert_eq!(v["blanks"], json!([[1, 0], [2, 1]])); - assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none()); - } - - fn spec(s: &Session, id: NodeId) -> GraphSpec { - s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone() - } - - /// Bounds of a series' marks, without its legend swatch (the last child). - fn marks(s: &Session, id: NodeId, index: u32) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let ch = series(&n, index).children().unwrap().to_vec(); - ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap()).collect() - } - - #[test] - fn a_series_given_its_own_type_is_drawn_as_that_type_in_front_of_the_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - let one_column = marks(&s, id, 0)[0].width(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Column, GraphKind::Column, GraphKind::Line)); - // The columns left take the whole cluster; the line runs through the category centres, one marker a value. - let cols = marks(&s, id, 0); - assert_eq!(cols.len(), 3); - assert!((cols[0].width() - 2.0 * one_column).abs() < 1e-6, "{} vs {one_column}", cols[0].width()); - let line = marks(&s, id, 1); - assert_eq!(line.len(), 4, "a line and three markers"); - assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6); - // The line series is drawn after (in front of) the column series. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); - assert_eq!(order, [0, 1]); - s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); - assert_eq!(order, [1, 0]); - // Back to the graph's own type: nothing is stored for that series. - assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]); - // One undo per change. - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line), Some(GraphKind::Line)]); - } - - #[test] - fn group_selecting_a_series_retypes_only_that_series() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let grp = series(&n, 1).id; - s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); - // The dialog opens on the series' type, and its OK (every option sent back) keeps it. - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column"); - s.execute("graph.setType", &json!({"type": "area"})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.kind, g.series_kind(1)), (GraphKind::Column, GraphKind::Area)); - let mut all = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!(all["type"], "area"); - all["ticks"] = json!(4); - s.execute("graph.setType", &all).unwrap(); - assert_eq!(spec(&s, id).series_kind(1), GraphKind::Area); - // The series stays selected through each change. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; - assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]); - // A series and something outside it selected, or an explicit `id`: the whole graph. - s.execute("select.set", &json!({"ids": [grp.0, id.0]})).unwrap(); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column"); - s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); - assert_eq!(s.execute("graph.setType", &json!({"id": id.0})).unwrap()["type"], "column"); - // The whole graph selected: the graph's type changes, the series keeps its own. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area)); - } - - #[test] - fn types_that_do_not_share_a_value_axis_are_refused_or_ignored() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - for ty in ["pie", "radar", "scatter", "bar"] { - assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": ty})).is_err(), "{ty}"); - } - for bad in [json!([2]), json!([]), json!([-1]), json!(["x"]), json!([1e30]), json!({}), json!(1)] { - assert!(s.execute("graph.setType", &json!({"seriesIndexes": bad, "type": "line"})).is_err(), "{bad}"); - } - assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1]})).is_err(), "no type"); - assert!(spec(&s, id).series_kinds.is_empty()); - // A series type the graph's new type can't take is drawn as the graph's type, and kept for when it can. - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); - let g = spec(&s, id); - assert_eq!(g.series_kind(1), GraphKind::Pie); - s.execute("graph.setType", &json!({"type": "column"})).unwrap(); - assert_eq!(spec(&s, id).series_kind(1), GraphKind::Line); - } - - #[test] - fn stacked_columns_take_one_slot_and_the_value_axis_covers_their_total() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b,c\nQ1,10,20,30\nQ2,10,20,30"; - let id = NodeId( - s.execute("graph.create", &json!({"type": "stackedColumn", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line"})).unwrap(); - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!((a[0].x0 - b[0].x0).abs() < 1e-6 && (a[0].y0 - b[0].y1).abs() < 1e-6, "b sits on a"); - // a + b stack to 30, the line's highest value is 30: the axis ends at 30 and the stack reaches the top. - assert!((b[0].y0 - 0.0).abs() < 1e-6, "{:?}", b[0]); - // With a plain column too, the stack is the cluster's last slot, beside it. - s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "column"})).unwrap(); - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!((a[0].width() - b[0].width()).abs() < 1e-6 && b[0].x0 > a[0].x1, "{:?} {:?}", a[0], b[0]); - } - - #[test] - fn areas_mixed_with_columns_put_their_points_at_the_category_centres() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); - let (area, cols) = (marks(&s, id, 0)[0], marks(&s, id, 1)); - assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}"); - // The area is drawn behind the columns. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); - assert_eq!(order, [0, 1]); - // An area graph alone still spans the plot. - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap(); - assert!((marks(&s, id, 0)[0].x0 - 100.0).abs() < 1e-6); - } - - #[test] - fn bar_graphs_take_stacked_bar_series() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5"; - let id = NodeId( - s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err()); - s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap(); - let (a, b, c) = (marks(&s, id, 0), marks(&s, id, 1), marks(&s, id, 2)); - // b and c stack along x in the cluster's second slot, below a's bar; c, negative, runs left of the zero line. - assert!((a[0].height() - b[0].height()).abs() < 1e-6 && b[0].y0 > a[0].y1, "{:?} {:?}", a[0], b[0]); - assert!((b[0].y0 - c[0].y0).abs() < 1e-6 && (c[0].x1 - b[0].x0).abs() < 1e-6, "{:?} {:?}", b[0], c[0]); - } - - #[test] - fn series_types_survive_save_and_open_and_older_files_have_none() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]); - let g: GraphSpec = serde_json::from_value(json!({"kind": "line", "rows": [[1.0, 2.0]]})).unwrap(); - assert!(g.series_kinds.is_empty()); - assert!(serde_json::to_value(&g).unwrap().get("seriesKinds").is_none()); - // A stored type the graph can't take, or past its series, is drawn as the graph's type. - let g: GraphSpec = serde_json::from_value(json!({"kind": "pie", "seriesKinds": ["line", null, "column"], "rows": [[1.0]]})).unwrap(); - assert_eq!((g.series_kind(0), g.series_kind(5)), (GraphKind::Pie, GraphKind::Pie)); - } - - /// The value axis labels: text and bounds of the texts in the Axes group that read as numbers. - fn value_labels(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_geom::Rect)> { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter_map(|c| match &c.kind { - NodeKind::Text(t) => Some((t.plain_text(), c.geometric_bounds().unwrap())), - _ => None, - }) - .filter(|(t, _)| t.parse::().is_ok()) - .collect() - } - - fn two_scale_graph(s: &mut Session) -> NodeId { - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b\nQ1,10,1000\nQ2,5,500"; - NodeId( - s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ) - } - - #[test] - fn the_value_axis_goes_on_the_left_the_right_or_both_sides() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - let left: Vec = value_labels(&s, id).into_iter().inspect(|(_, b)| assert!(b.x1 < 100.0, "{b:?}")).map(|(t, _)| t).collect(); - assert_eq!(left, ["0", "200", "400", "600", "800", "1000"]); - s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap(); - let right = value_labels(&s, id); - assert!(right.iter().all(|(_, b)| b.x0 > 400.0), "{right:?}"); - assert_eq!(right.iter().map(|(t, _)| t.as_str()).collect::>(), left); - // Both sides, one scale: the same labels on each side, and the legend moves past the right ones. - s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap(); - let both = value_labels(&s, id); - assert_eq!(both.len(), 12); - let edge = both.iter().map(|(_, b)| b.x1).fold(0.0, f64::max); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let swatch = series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap(); - assert!(swatch.x0 > edge, "{swatch:?} vs {edge}"); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both"); - assert!(s.execute("graph.setType", &json!({"valueAxis": "top"})).is_err()); - } - - #[test] - fn separate_scales_give_the_right_axis_series_a_scale_of_their_own() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - // Assigned, but drawn on one scale until separate scales are on with both sides. - assert_eq!(spec(&s, id).right_series, [1]); - s.execute("graph.setType", &json!({"separateScales": true})).unwrap(); - assert!((marks(&s, id, 0)[0].height() - 2.0).abs() < 1e-6, "a's 10 of 1000"); - s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap(); - // a's 10 and b's 1000 both reach the top: 0..10 on the left, 0..1000 on the right. - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!((a[0].y0 - 100.0).abs() < 1e-6 && (b[0].y0 - 100.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]); - let labels = value_labels(&s, id); - let side = |right: bool| labels.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::>(); - assert_eq!(side(false), ["0", "2", "4", "6", "8", "10"]); - assert_eq!(side(true), ["0", "200", "400", "600", "800", "1000"]); - // The right axis' own tick values. - s.execute("graph.setType", &json!({"rightAxisMin": 0, "rightAxisMax": 2000, "rightTicks": 4})).unwrap(); - assert!((marks(&s, id, 1)[0].y0 - 200.0).abs() < 1e-6, "1000 of 2000"); - assert!((marks(&s, id, 0)[0].y0 - 100.0).abs() < 1e-6, "the left axis is unchanged"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["separateScales"].clone(), v["rightTicks"].clone(), v["rightAxisMax"].clone()), (json!(true), json!(4), json!(2000.0))); - // Stacked series stack on their own axis, each axis' stack in a slot of its own. - s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!((a[0].y1 - 300.0).abs() < 1e-6 && (b[0].y1 - 300.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]); - assert!(b[0].x0 >= a[0].x1 - 1e-6 && (a[0].width() - b[0].width()).abs() < 1e-6, "side by side: {:?} {:?}", a[0], b[0]); - // A line on the right axis follows the right scale. - s.execute("graph.setType", &json!({"type": "column"})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - let line = marks(&s, id, 1); - assert!((line[1].center().y - 200.0).abs() < 1e-6 && (line[2].center().y - 250.0).abs() < 1e-6, "1000 and 500 of 2000: {line:?}"); - } - - #[test] - fn group_selected_series_go_on_the_chosen_axis() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; - s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "left"); - assert!(s.execute("graph.setType", &json!({"valueAxis": "both"})).is_err(), "a series takes one axis"); - s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.right_series.clone(), g.value_axis), (vec![1], vectorcraft_doc::ValueAxisSide::Left)); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "right"); - s.execute("graph.setType", &json!({"valueAxis": "left"})).unwrap(); - assert!(spec(&s, id).right_series.is_empty()); - } - - #[test] - fn bar_graphs_keep_their_value_axis_along_the_bottom() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "bar"); - let before = (value_labels(&s, id), marks(&s, id, 0)); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err(); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - assert_eq!((value_labels(&s, id), marks(&s, id, 0)), before); - } - - #[test] - fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert_eq!( - (g.value_axis, g.separate_scales, g.right_series, g.right_axis_max), - (vectorcraft_doc::ValueAxisSide::Both, true, vec![1], Some(2000.0)) - ); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - for k in ["valueAxis", "separateScales", "rightSeries", "rightTicks", "rightAxisMin", "rightAxisMax"] { - assert!(v.get(k).is_none(), "{k}"); - } - } - - #[test] - fn the_dialog_round_trip_keeps_series_on_both_axes_where_they_are() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b,c\nQ1,10,1000,3\nQ2,5,500,4"; - let id = NodeId( - s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - for side in ["both", "left"] { - s.execute("graph.setType", &json!({"valueAxis": side, "separateScales": true})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - s.execute("select.set", &json!({"ids": [series(&n, 0).id.0, series(&n, 1).id.0]})).unwrap(); - let fields = s.execute("graph.setType", &json!({})).unwrap(); - assert!(fields["valueAxis"].is_null(), "{side}: {fields}"); - s.execute("graph.setType", &fields).unwrap(); - let g = spec(&s, id); - assert_eq!((g.right_series, g.value_axis.id()), (vec![1], side), "{side}"); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - } - } - - #[test] - fn an_axis_with_no_series_shows_the_other_scale() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); - let labels = |s: &Session| { - let l = value_labels(s, id); - let side = |right: bool| l.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::>(); - (side(false), side(true)) - }; - let (left, right) = labels(&s); - assert_eq!(left, right); - assert_eq!(right.last().unwrap(), "1000"); - // Every series on the right: the left axis shows the right scale. - s.execute("graph.setType", &json!({"seriesIndexes": [0, 1], "valueAxis": "right", "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); - let (left, right) = labels(&s); - assert_eq!(left, right); - assert_eq!(right.last().unwrap(), "2000"); - } - - #[test] - fn right_axis_columns_start_at_the_right_scales_zero() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let csv = ",a,b\nQ1,10,-500\nQ2,5,500"; - let id = NodeId( - s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err(); - s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - // Right scale -600..600 (zero at y 200), left 0..10 (zero at y 300): b's columns meet at the right zero. - let b = marks(&s, id, 1); - assert!((b[0].y0 - 200.0).abs() < 1e-6 && (b[1].y1 - 200.0).abs() < 1e-6, "{b:?}"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let at_200 = group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter(|c| matches!(c.kind, NodeKind::Path { .. })) - .filter_map(|c| c.geometric_bounds()) - .any(|bb| bb.height() < 1e-6 && (bb.y0 - 200.0).abs() < 1e-6 && (bb.width() - 300.0).abs() < 1e-6); - assert!(at_200, "a zero line for the right scale"); - } - - #[test] - fn data_without_a_right_axis_series_drops_its_assignment() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - s.execute("graph.setData", &json!({"csv": ",a\nQ1,10\nQ2,5"})).unwrap(); - assert!(spec(&s, id).right_series.is_empty()); - s.execute("graph.setData", &json!({"csv": ",a,b\nQ1,10,1000\nQ2,5,500"})).unwrap(); - assert!(spec(&s, id).right_series.is_empty(), "a new series starts on the left"); - } - - /// Bounds of the straight lines in the Axes group. - fn axis_lines(s: &Session, id: NodeId) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter(|c| matches!(c.kind, NodeKind::Path { .. })) - .filter_map(|c| c.geometric_bounds()) - .collect() - } - - fn axis_texts(s: &Session, id: NodeId) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter_map(|c| match &c.kind { - NodeKind::Text(t) => Some(t.plain_text()), - _ => None, - }) - .collect() - } - - #[test] - fn value_axis_tick_marks_take_a_length_and_a_count_per_division() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - let short = |s: &Session, x0: f64| axis_lines(s, id).iter().filter(|b| (b.x0 - x0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count(); - let across = |s: &Session| axis_lines(s, id).iter().filter(|b| b.height() < 1e-6 && (b.width() - 300.0).abs() < 1e-6).count(); - // Short (the default): one at each of the six labels; the zero line runs across. - assert_eq!((short(&s, 96.0), across(&s)), (6, 1)); - s.execute("graph.setType", &json!({"tickLength": "full"})).unwrap(); - // Across the plot at 200…1000; the one at 0 would be the zero line again. - assert_eq!((short(&s, 96.0), across(&s)), (0, 6)); - s.execute("graph.setType", &json!({"tickLength": "none"})).unwrap(); - assert_eq!((short(&s, 96.0), across(&s)), (0, 1)); - // Two per division: one more halfway between labels, none past the last label. - s.execute("graph.setType", &json!({"tickLength": "short", "tickMarks": 2})).unwrap(); - assert_eq!(short(&s, 96.0), 11); - assert!(axis_lines(&s, id).iter().any(|b| (b.x0 - 96.0).abs() < 1e-6 && (b.y0 - 280.0).abs() < 1e-6), "100 of 0..1000 sits at y 280"); - assert_eq!(axis_texts(&s, id).iter().filter(|t| t.parse::().is_ok()).count(), 6, "labels only at divisions"); - // The right axis has its own. - s.execute("graph.setType", &json!({"valueAxis": "both", "rightTickLength": "none"})).unwrap(); - assert_eq!((short(&s, 96.0), short(&s, 400.0)), (11, 0)); - s.execute("graph.setType", &json!({"rightTickLength": "short", "rightTickMarks": 4})).unwrap(); - assert_eq!(short(&s, 400.0), 21); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["tickLength"].clone(), v["tickMarks"].clone(), v["rightTickMarks"].clone()), (json!("short"), json!(2), json!(4))); - assert!(s.execute("graph.setType", &json!({"tickLength": "long"})).is_err()); - s.execute("graph.setType", &json!({"tickMarks": 1_000_000})).unwrap(); - assert_eq!(spec(&s, id).tick_marks, 20); - } - - #[test] - fn category_tick_marks_go_at_or_between_the_labels() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - let below = |s: &Session| { - let mut xs: Vec = axis_lines(s, id) - .iter() - .filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6) - .map(|b| b.x0) - .collect(); - xs.sort_by(f64::total_cmp); - xs - }; - // None by default, as before. - assert!(below(&s).is_empty()); - s.execute("graph.setType", &json!({"categoryTickLength": "short"})).unwrap(); - assert_eq!(below(&s), [175.0, 325.0]); - s.execute("graph.setType", &json!({"ticksBetweenLabels": true})).unwrap(); - assert_eq!(below(&s), [100.0, 250.0, 400.0]); - s.execute("graph.setType", &json!({"categoryTickMarks": 2})).unwrap(); - assert_eq!(below(&s), [100.0, 175.0, 250.0, 325.0, 400.0]); - s.execute("graph.setType", &json!({"categoryTickLength": "full"})).unwrap(); - let full = axis_lines(&s, id).iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6 && b.x0 > 100.0 + 1e-6).count(); - assert_eq!(full, 4, "inside the plot: 175, 250, 325 and 400"); - // Bar graphs: the category axis is the left one. - let bar = graph(&mut s, "bar"); - s.execute("graph.setType", &json!({"id": bar.0, "categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap(); - let left = axis_lines(&s, bar).iter().filter(|b| b.height() < 1e-6 && (b.x0 - 96.0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count(); - assert_eq!(left, 4, "three categories, four edges"); - } - - #[test] - fn value_axis_numbers_take_a_prefix_and_a_suffix() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"prefix": "$", "suffix": " M", "valueAxis": "both", "separateScales": true})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right", "rightSuffix": "%"})).unwrap(); - let texts = axis_texts(&s, id); - assert!(texts.contains(&"$10 M".to_string()) && texts.contains(&"$0 M".to_string()), "{texts:?}"); - assert!(texts.contains(&"1000%".to_string()) && !texts.contains(&"$1000 M".to_string()), "{texts:?}"); - // One short line: control characters dropped, the length capped. - s.execute("graph.setType", &json!({"prefix": format!("a\nb{}", "x".repeat(500))})).unwrap(); - let p = spec(&s, id).prefix; - assert!(p.starts_with("ab") && p.chars().count() == 64, "{p}"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["suffix"].clone(), v["rightSuffix"].clone()), (json!(" M"), json!("%"))); - } - - #[test] - fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute( - "graph.setType", - &json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}), - ) - .unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert_eq!( - (g.tick_length, g.tick_marks, g.category_tick_length, g.ticks_between_labels, g.suffix.as_str()), - (vectorcraft_doc::TickLength::Full, 3, vectorcraft_doc::TickLength::Short, true, "%") - ); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - for k in [ - "tickLength", - "tickMarks", - "rightTickLength", - "rightTickMarks", - "categoryTickLength", - "categoryTickMarks", - "ticksBetweenLabels", - "prefix", - "suffix", - "rightPrefix", - "rightSuffix", - ] { - assert!(v.get(k).is_none(), "{k}"); - } - // A file's huge values draw within the caps. - let g: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]], "tickMarks": 1_000_000_000u64, "prefix": "y".repeat(10_000)})).unwrap(); - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let art = super::generate(&mut d, &g); - let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap().children().unwrap().to_vec(); - assert!(axes.len() < 200, "{} axis parts", axes.len()); - for t in axes.iter().filter_map(|n| match &n.kind { - NodeKind::Text(t) => Some(t.plain_text()), - _ => None, - }) { - assert!(t.chars().count() < 80, "{} characters", t.chars().count()); - } - } - - #[test] - fn between_labels_ticks_on_an_edge_to_edge_graph_fall_halfway_between_its_labels() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "area"); - s.execute("graph.setType", &json!({"categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap(); - // Labels at 100, 250 and 400 (the plot's edges and middle): ticks at 175 and 325. - let mut xs: Vec = axis_lines(&s, id) - .iter() - .filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6) - .map(|b| b.x0) - .collect(); - xs.sort_by(f64::total_cmp); - assert_eq!(xs, [175.0, 325.0]); - } - - #[test] - fn bar_graph_value_ticks_run_up_the_plot_and_long_labels_push_the_legend() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "bar"); - let legend_x = |s: &Session| { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap().x0 - }; - let before = legend_x(&s); - assert!((before - 414.0).abs() < 1e-6, "a short last label leaves the legend where it was: {before}"); - s.execute("graph.setType", &json!({"tickLength": "full", "tickMarks": 2})).unwrap(); - let up: Vec<_> = axis_lines(&s, id).into_iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6).collect(); - // -2..6 in 2s: five labels and four halves; the one at 0 is the zero line already, the left end the axis. - assert_eq!(up.len(), 9, "{up:?}"); - s.execute("graph.setType", &json!({"suffix": " million tonnes"})).unwrap(); - let last = value_label_edge(&s, id); - assert!(legend_x(&s) >= last + 6.0 - 1e-6, "{} vs {last}", legend_x(&s)); - } - - /// The right end of the Axes group's texts. - fn value_label_edge(s: &Session, id: NodeId) -> f64 { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter(|c| matches!(c.kind, NodeKind::Text(_))) - .filter_map(|c| c.geometric_bounds()) - .map(|b| b.x1) - .fold(f64::MIN, f64::max) - } - - #[test] - fn counts_come_back_from_the_dialog_as_floats_and_are_capped() { - let mut s = Session::new(); - let id = two_scale_graph(&mut s); - s.execute("graph.setType", &json!({"tickMarks": 3.0, "ticks": 4.0, "categoryTickMarks": 2.6})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.tick_marks, g.ticks, g.category_tick_marks), (3, 4, 3)); - s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300, "rightTicks": -2.0})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0)); - // A file's values come back as drawn. - let mut fields = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!(fields["rightTickMarks"], 20); - fields["tickLength"] = json!("full"); - s.execute("graph.setType", &fields).unwrap(); - assert_eq!(spec(&s, id).tick_length, vectorcraft_doc::TickLength::Full); - } - - #[test] - fn a_long_category_axis_is_ticked_all_the_way_within_the_cap() { - let rows: Vec> = (0..5000).map(|i| vec![i as f64]).collect(); - let g = GraphSpec { rows, category_tick_length: vectorcraft_doc::TickLength::Short, category_tick_marks: 20, ..GraphSpec::default() }; - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let art = super::generate(&mut d, &g); - let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap(); - let ticks: Vec = axes - .children() - .unwrap() - .iter() - .filter_map(|c| c.geometric_bounds()) - .filter(|b| b.width() < 1e-9 && (b.height() - 4.0).abs() < 1e-9) - .map(|b| b.x0) - .collect(); - assert!(ticks.len() <= 10_000 && ticks.len() >= 5000, "{}", ticks.len()); - assert!(ticks.iter().copied().fold(f64::MIN, f64::max) > 199.0, "the last category is ticked"); - } - - /// A graph design: a 10 × 10 rectangle at the back and a 20-wide ellipse over it, saved as `name`. - fn save_design(s: &mut Session, name: &str) { - let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - let e = s.execute("shape.ellipse", &json!({"x": 595, "y": 600, "width": 20, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [r, e]})).unwrap(); - s.execute("graph.design", &json!({"new": name})).unwrap(); - } - - /// The marks of series `index` (legend swatch last) and their bounds. - fn series_parts(s: &Session, id: NodeId, index: u32) -> Vec { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - series(&n, index).children().unwrap().iter().map(|c| (**c).clone()).collect() - } - - #[test] - fn a_marker_design_draws_each_data_point_sized_by_its_backmost_object() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); - let id = graph(&mut s, "line"); - let before = series_parts(&s, id, 1); - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; - s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - let after = series_parts(&s, id, 1); - assert_eq!(after.len(), before.len(), "a line, three markers and the swatch"); - for (old, new) in before.iter().zip(&after).skip(1) { - let (o, n) = (old.geometric_bounds().unwrap(), new.geometric_bounds().unwrap()); - assert!(matches!(new.kind, NodeKind::Group { .. }), "the design's art"); - // The 10-point rectangle fills the marker's square, the 20-wide ellipse twice as wide. - assert!((n.center() - o.center()).hypot() < 1e-6, "{o:?} {n:?}"); - assert!((n.width() - 2.0 * o.width()).abs() < 1e-6 && (n.height() - o.height()).abs() < 1e-6, "{o:?} {n:?}"); - } - // The series stays selected; the other series keeps its squares; the query names the design. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; - assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - assert_eq!(s.execute("graph.marker", &json!({})).unwrap()["design"], "Pill"); - assert_eq!(spec(&s, id).series_markers, [None, Some("Pill".to_string())]); - // Back to the default square. - s.execute("graph.marker", &json!({"design": null})).unwrap(); - assert!(spec(&s, id).series_markers.is_empty()); - assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - assert!(s.execute("graph.marker", &json!({"design": "Nope"})).is_err()); - assert!(s.execute("graph.marker", &json!({"design": 3})).is_err()); - } - - #[test] - fn marker_designs_apply_to_markers_not_to_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - // Every series (none picked): the line series' markers and swatch; the columns and their swatch stay. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - let line = series_parts(&s, id, 1); - assert!(line.iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })), "markers and swatch"); - let swatch = line.last().unwrap().geometric_bounds().unwrap(); - assert!((swatch.width() - 16.0).abs() < 1e-6 && (swatch.height() - 8.0).abs() < 1e-6, "{swatch:?}"); - // Scatter and radar graphs draw them too. - for ty in ["scatter", "radar"] { - let g = graph(&mut s, ty); - s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - assert!(series_parts(&s, g, 0).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "{ty}"); - } - } - - #[test] - fn graph_designs_are_saved_pasted_and_deleted() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "nothing selected"); - save_design(&mut s, "Pill"); - let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "the name is taken"); - assert!(s.execute("graph.design", &json!({"new": " "})).is_err(), "no name"); - // Paste Design: a copy of the art, selected, with ids of its own. - let pasted = NodeId(s.execute("graph.design", &json!({"paste": "Pill"})).unwrap()["id"].as_u64().unwrap()); - assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [pasted]); - let art = s.doc().unwrap().doc.graph_designs[0].art.clone(); - let copy = s.doc().unwrap().doc.node(pasted).unwrap().clone(); - assert_ne!(copy.id, art.id); - assert_eq!(copy.geometric_bounds(), art.geometric_bounds()); - assert!(s.execute("graph.design", &json!({"paste": "Nope"})).is_err()); - // Deleting a design draws its graphs' default markers again; undo brings both back. - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); - assert!(s.doc().unwrap().doc.graph_designs.is_empty()); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(series_parts(&s, id, 0).iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. }))); - assert!(s.execute("graph.design", &json!({"delete": "Nope"})).is_err()); - } - - #[test] - fn designs_and_markers_survive_save_and_open() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); - assert_eq!(spec(&s, id).series_markers, [Some("Pill".to_string())]); - assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesMarkers").is_none()); - // A design whose art has no area draws the default square. - let g = GraphSpec { kind: GraphKind::Line, series_markers: vec![Some("Dot".into())], ..GraphSpec::default() }; - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) }); - let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); - assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - } - - #[test] - fn a_graph_saved_as_a_design_is_plain_art_even_as_its_own_marker() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.design", &json!({"new": "Chart"})).unwrap(); - let art = s.doc().unwrap().doc.graph_designs[1].art.clone(); - let mut graphs = 0; - art.walk(&mut |n| graphs += usize::from(n.extras().graph.is_some() || n.extras().series_index.is_some())); - assert_eq!(graphs, 0, "no graph inside a design"); - // The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit. - s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap(); - s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); - let mut found = 0; - s.doc().unwrap().doc.walk(|n| found += usize::from(n.extras().graph.is_some())); - assert_eq!(found, 1); - // A deleted design reads as none in the query, so the dialog opens on the default marker. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": null})).unwrap(); - let v = s.execute("graph.marker", &json!({"seriesIndexes": [1, 0]})).unwrap(); - assert!(v["design"].is_null(), "{v}"); - } - - #[test] - fn designs_too_big_for_a_graph_are_refused_and_drawn_as_squares() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // 1,500 objects at each of 200 points is past the budget. - let ids: Vec = (0..1500) - .map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap()) - .collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("graph.design", &json!({"new": "Big"})).unwrap(); - let rows: Vec> = (0..200).map(|i| vec![i as f64]).collect(); - let id = NodeId( - s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - assert!(s.execute("graph.marker", &json!({"id": id.0, "design": "Big"})).is_err()); - assert!(spec(&s, id).series_markers.is_empty()); - // A file asking for it anyway draws the default squares. - let mut g = spec(&s, id); - g.series_markers = vec![Some("Big".into())]; - let mut d = (*s.doc().unwrap().doc).clone(); - let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); - assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - // Too many objects for one design. - let ids: Vec = (0..2001) - .map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap()) - .collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - assert!(s.execute("graph.design", &json!({"new": "Huge"})).is_err()); - } - - #[test] - fn a_files_designs_are_tidied() { - let mut d = vectorcraft_doc::Document::new(100.0, 100.0); - let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![])); - let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); - graphy.extras_mut().graph = Some(Box::new(GraphSpec::default())); - for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] { - let a = art(&mut d); - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a }); - } - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "G".into(), art: std::sync::Arc::new(graphy) }); - d.tidy_graph_designs(); - let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect(); - assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]); - assert!(d.graph_designs[2].art.extras().graph.is_none()); - } - - #[test] - fn painting_a_series_drawn_with_a_design_leaves_the_design_alone() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_design(&mut s, "Pill"); - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); - // The design's paths keep the design's paint, before and after the graph is drawn again. - let design_fill = |s: &Session| { - let parts = series_parts(s, id, 0); - let mut fills = vec![]; - parts[1].walk(&mut |n| { - if let NodeKind::Path { .. } = n.kind { - fills.push(n.appearance.fill_paint().color().map(|c| c.to_hex())); - } - }); - fills - }; - let painted = design_fill(&s); - assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}"); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); - assert_eq!(design_fill(&s), painted); - // A design of one path too. - let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - s.execute("graph.design", &json!({"new": "Square"})).unwrap(); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.marker", &json!({"design": "Square"})).unwrap(); - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); - assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]); - } - - #[test] - fn graph_design_params_are_names() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - for p in [json!({"new": 5}), json!({"paste": null}), json!({"delete": ["a"]})] { - assert!(s.execute("graph.design", &p).is_err(), "{p}"); - } - } - - #[test] - fn document_wide_passes_see_design_art() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("paint.setFill", &json!({"ids": [r], "color": "#123456"})).unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - s.execute("graph.design", &json!({"new": "Blue"})).unwrap(); - s.execute("edit.clear", &json!({})).unwrap(); - let mut seen = false; - s.doc().unwrap().doc.visit_paints(&mut |p| seen |= p.color().is_some_and(|c| c.to_hex() == "#123456")); - assert!(seen, "the colour is used by the design"); - } - - #[test] - fn new_design_rename_delete_and_select_unused() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - // New Design names it "New Design", numbered when that's taken. - assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design"); - assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design 2"); - let id = graph(&mut s, "line"); - s.execute("graph.marker", &json!({"id": id.0, "design": "New Design"})).unwrap(); - let v = s.execute("graph.design", &json!({"selectUnused": true})).unwrap(); - assert_eq!(v["unused"], json!(["New Design 2"])); - // Renaming a design the graph draws with: the graph follows, still drawing it. - s.execute("graph.design", &json!({"rename": "New Design", "to": "Dot"})).unwrap(); - assert_eq!(spec(&s, id).series_markers, [Some("Dot".to_string()), Some("Dot".to_string())]); - assert!(s.execute("graph.design", &json!({"rename": "Dot", "to": "New Design 2"})).is_err(), "taken"); - assert!(s.execute("graph.design", &json!({"rename": "Nope", "to": "X"})).is_err()); - // Delete several at once. - s.execute("graph.design", &json!({"delete": ["Dot", "New Design 2"]})).unwrap(); - assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!([])); - assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err()); - } - - /// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle - /// over the rest, with a horizontal guide line between them when `guide`, saved as `name`. - fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) { - let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| { - s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap() - }; - let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)]; - ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top)); - if guide { - let line = - s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [line]})).unwrap(); - s.execute("view.guides.make", &json!({})).unwrap(); - ids.push(line); - } - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("graph.design", &json!({"new": name})).unwrap(); - } - - /// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero - /// line at y = 300. - fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId { - let rows: Vec> = values.iter().map(|v| vec![*v]).collect(); - let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows}); - let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap()); - s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap(); - id - } - - /// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it. - fn frames(n: &vectorcraft_doc::Node) -> Vec { - n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect() - } - - fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool { - [a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6) - } - - #[test] - fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); - let id = one_series(&mut s, "column", &[35.0]); - let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); - assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}"); - s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap(); - // 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value. - let column = series_parts(&s, id, 0)[0].clone(); - let parts = column.children().unwrap(); - assert_eq!(parts.len(), 4, "3.5 designs"); - let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1]; - let copies = frames(&column); - assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}"); - let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) }; - assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy"); - let (clip, copy) = - (children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap()); - assert_eq!(clip.y0, 160.0, "cut off at the value"); - // Its sides and its foot, strokes included, are not cut. - assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}"); - // Nor is the brush art of a brushed path inside a group of the design. - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0)); - let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); - let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0); - for item in &mut look.items { - if let vectorcraft_doc::AppearanceItem::Stroke(st) = item { - st.brush = Some("Wide".into()); - } - } - let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look); - let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]); - let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]); - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) }); - let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() }; - let g = GraphSpec { - rows: vec![vec![35.0]], - axis_min: Some(0.0), - axis_max: Some(50.0), - series_columns: vec![Some(options)], - ..GraphSpec::default() - }; - let out = super::generate(&mut d, &g); - let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); - let last = column.children().unwrap().last().unwrap().clone(); - let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) }; - let clip = children[0].geometric_bounds().unwrap(); - let mut reach = None; - children[1].walk(&mut |n| { - if vectorcraft_brush::has_brush(n) { - reach = vectorcraft_render::painted_bounds(&d, n); - } - }); - let reach = reach.unwrap(); - assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}"); - // Scale: the last copy half as tall instead. - s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap(); - let column = series_parts(&s, id, 0)[0].clone(); - let copies = frames(&column); - assert_eq!(copies.len(), 4); - assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}"); - let c = &spec(&s, id).series_columns[0]; - assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale))); - // A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line. - let bars = one_series(&mut s, "bar", &[-15.0]); - s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); - s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap(); - let bar = series_parts(&s, bars, 0)[0].clone(); - let copies = frames(&bar); - // The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250. - let (zero, unit) = (250.0, 3.0); - assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}"); - assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}"); - } - - #[test] - fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // 10 wide and 20 tall, the guide 5 points from the top. - save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true); - let id = one_series(&mut s, "column", &[35.0, 20.0]); - s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap(); - let parts = series_parts(&s, id, 0); - for (column, value_end) in parts.iter().zip([160.0, 220.0]) { - // The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands - // 12 points (2.4 times 5) below the value end. - let halves = column.children().unwrap(); - assert_eq!(halves.len(), 2, "{halves:?}"); - let half = |i: usize| { - let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) }; - (children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec()) - }; - let ((above, kept), (below, stretched)) = (half(0), half(1)); - let at = value_end + 12.0; - assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}"); - assert_eq!(kept.len(), 3, "the guide is not drawn"); - // Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end. - let top = kept[1].geometric_bounds().unwrap(); - assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}"); - // Below it the design is stretched down to the zero line. - let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap()); - assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}"); - assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}"); - } - // Vertically Scaled stretches the whole design, the top part included. - s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap(); - let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec(); - assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6); - // Without a guide a sliding design is scaled vertically too. - save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false); - s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap(); - let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec(); - assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6); - // Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before - // the line, are passed over. - let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); - let mut ids = vec![ - id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()), - id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()), - ]; - let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap()); - let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap()); - let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap()); - s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap(); - s.execute("view.guides.make", &json!({})).unwrap(); - ids.extend([square, tick, line]); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("graph.design", &json!({"new": "Boxed"})).unwrap(); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap(); - let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec(); - let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap(); - assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}"); - } - - #[test] - fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); - let id = one_series(&mut s, "column", &[35.0, 20.0]); - s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap(); - let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap(); - for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) { - let frame = frames(column)[0]; - assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}"); - assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6); - } - assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column"); - // A design twice as wide as it is tall reaches past the plot, and the legend starts right of it. - save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false); - let wide = one_series(&mut s, "column", &[20.0, 35.0]); - s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap(); - let parts = series_parts(&s, wide, 0); - let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap()); - assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns"); - // The legend swatch shows the design, its frame filling the 8-point square. - let swatch = series_parts(&s, id, 0).last().unwrap().clone(); - let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap(); - assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}"); - // Painting the series leaves the design's paint alone. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); - let mut red = false; - s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000")); - assert!(!red); - } - - #[test] - fn column_designs_survive_save_and_open_and_follow_their_design() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); - let id = graph(&mut s, "stackedColumn"); - s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - // Every series takes the design; only the stacked column series draws it. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}); - s.execute("graph.column", &p).unwrap(); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch"); - assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series"); - let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap(); - assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"})); - let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale }; - assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]); - assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none()); - // Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again. - s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap(); - assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token")); - assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([])); - s.execute("graph.design", &json!({"delete": "Token"})).unwrap(); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null()); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. }))); - // Null goes back to plain columns. - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap(); - assert!(spec(&s, id).series_columns.is_empty()); - } - - #[test] - fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); - let id = one_series(&mut s, "column", &[35.0]); - for p in [ - json!({"design": "Nope"}), - json!({"design": 3}), - json!({"design": "Coin", "columnType": "diagonal"}), - json!({"design": "Coin", "each": 0}), - json!({"design": "Coin", "each": -1}), - json!({"design": "Coin", "each": "10"}), - json!({"design": "Coin", "fractions": "round"}), - ] { - let mut p = p; - p["id"] = json!(id.0); - assert!(s.execute("graph.column", &p).is_err(), "{p}"); - } - assert!(spec(&s, id).series_columns.is_empty()); - // 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns. - let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001}); - assert!(s.execute("graph.column", &p).is_err()); - let mut g = spec(&s, id); - let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop }; - g.series_columns = vec![Some(options.clone())]; - let mut d = (*s.doc().unwrap().doc).clone(); - let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); - assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); - // Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each - // 0.1 is past the budget too. - let far = one_series(&mut s, "column", &[0.0, 0.0]); - s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap(); - assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err()); - assert!(spec(&s, far).series_columns.is_empty()); - // A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas: - // 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy. - for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] { - g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })]; - let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); - let column = s0.children().unwrap()[0].clone(); - assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}"); - assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}"); - assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped"); - } - } - - /// A column design with totals: a 10 × 10 rectangle at the back, type "%31" set from 2 points above its top, and a - /// group of type "Votes" inside it and type "%02 and %00" set from 4 points below it, with a horizontal guide line 5 - /// points from the top when `guide`, saved as `name`. Returns the points the two totals are set from. - fn save_totals_design(s: &mut Session, name: &str, guide: bool) -> [vectorcraft_geom::Point; 2] { - let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); - let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); - let above = id_of(s.execute("text.create", &json!({"x": 600, "y": 598, "text": "%31", "size": 4})).unwrap()); - let votes = id_of(s.execute("text.create", &json!({"x": 601, "y": 606, "text": "Votes", "size": 3})).unwrap()); - let below = id_of(s.execute("text.create", &json!({"x": 600, "y": 614, "text": "%02 and %00", "size": 4})).unwrap()); - let origin = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.xf * vectorcraft_geom::Point::ZERO, - k => panic!("type: {k:?}"), - }; - let points = [origin(s, above), origin(s, below)]; - s.execute("select.set", &json!({"ids": [votes, below]})).unwrap(); - let inner = id_of(s.execute("object.group", &json!({})).unwrap()); - let mut ids = vec![frame, above, inner]; - if guide { - let line = id_of(s.execute("shape.line", &json!({"x1": 600, "y1": 605, "x2": 610, "y2": 605})).unwrap()); - s.execute("select.set", &json!({"ids": [line]})).unwrap(); - s.execute("view.guides.make", &json!({})).unwrap(); - ids.push(line); - } - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("graph.design", &json!({"new": name})).unwrap(); - points - } - - /// The text of each piece of type inside `n`, in paint order. - fn texts_in(n: &vectorcraft_doc::Node) -> Vec { - let mut out = vec![]; - n.walk(&mut |c| { - if let NodeKind::Text(t) = &c.kind { - out.push(t.plain_text()); - } - }); - out - } - - /// The type among `nodes`, each as its text and the point it is set from; each must be type at its own size. - fn totals_of(nodes: &[std::sync::Arc]) -> Vec<(String, vectorcraft_geom::Point)> { - nodes - .iter() - .map(|n| match &n.kind { - NodeKind::Text(t) => { - let [a, b, c, d, x, y] = t.xf.as_coeffs(); - assert_eq!([a, b, c, d], [1.0, 0.0, 0.0, 1.0], "a total keeps its size: {:?}", t.xf); - (t.plain_text(), vectorcraft_geom::Point::new(x, y)) - } - k => panic!("a total: {k:?}"), - }) - .collect() - } - - fn close(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool { - (a - b).hypot() < 1e-6 - } - - #[test] - fn column_design_totals_show_each_columns_value_at_their_own_size() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let [above, below] = save_totals_design(&mut s, "Ballot", false); - let id = one_series(&mut s, "column", &[35.0, 7.256]); - s.execute("graph.column", &json!({"id": id.0, "design": "Ballot"})).unwrap(); - let check = |s: &Session, want: [[&str; 2]; 2]| { - let parts = series_parts(s, id, 0); - for (column, want) in parts.iter().zip(want) { - let children = column.children().unwrap(); - // The design's copy keeps the type without a total, stretched with the design, and leaves the totals out. - assert_eq!(texts_in(&children[0]), ["Votes"]); - let frame = frames(column)[0]; - let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); - let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); - let totals = totals_of(&children[1..]); - assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), want); - // Each set from where the design's copy puts the point it was set from. - assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); - } - // The legend swatch keeps the type without a total and leaves the totals out. - assert_eq!(texts_in(parts.last().unwrap()), ["Votes"]); - }; - check(&s, [["035.0", "35.00 and 35"], ["007.3", "7.26 and 7"]]); - // New data draws the new values. - s.execute("graph.setData", &json!({"id": id.0, "rows": [[20], [0.004]]})).unwrap(); - check(&s, [["020.0", "20.00 and 20"], ["000.0", "0.00 and 0"]]); - // The design in the Graph Design list is left as it was. - assert_eq!(texts_in(&s.doc().unwrap().doc.graph_designs[0].art), ["%31", "Votes", "%02 and %00"]); - // A bar below zero shows its minus sign, its totals moved with the design along the bar. - let bars = one_series(&mut s, "bar", &[-15.0]); - s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); - s.execute("graph.column", &json!({"id": bars.0, "design": "Ballot"})).unwrap(); - let bar = series_parts(&s, bars, 0)[0].clone(); - let frame = frames(&bar)[0]; - let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); - let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); - let totals = totals_of(&bar.children().unwrap()[1..]); - assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["-015.0", "-15.00 and -15"]); - assert!((frame.x1 - 250.0).abs() < 1e-6 && close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); - // A design that is a single piece of type with a total shows the value alone, and its legend swatch is the - // default square. - let one = s.execute("text.create", &json!({"x": 600, "y": 700, "text": "%00", "size": 4})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [one]})).unwrap(); - s.execute("graph.design", &json!({"new": "Count"})).unwrap(); - let count = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": count.0, "design": "Count"})).unwrap(); - let parts = series_parts(&s, count, 0); - assert_eq!(texts_in(&parts[0]), ["35"]); - assert_eq!(totals_of(&parts[0].children().unwrap()[1..]).len(), 1); - assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); - // Repeating, with a last copy to chop, it still shows the value. - s.execute("graph.column", &json!({"id": count.0, "design": "Count", "columnType": "repeating", "each": 10})).unwrap(); - let column = series_parts(&s, count, 0)[0].clone(); - assert!(matches!(column.kind, NodeKind::Group { .. }) && texts_in(&column) == ["35"], "{:?}", column.kind); - } - - #[test] - fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_beside_its_part() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let [above, below] = save_totals_design(&mut s, "Ballot", true); - let id = one_series(&mut s, "column", &[35.0]); - let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); - s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "repeating", "each": 10})).unwrap(); - let column = series_parts(&s, id, 0)[0].clone(); - let children = column.children().unwrap(); - // Three whole copies and the one cut off at the value, none with a total; then the two totals, not cut. - assert_eq!(children.len(), 6, "{children:?}"); - assert!(matches!(children[3].kind, NodeKind::Group { clip: true, .. }), "the last copy is clipped"); - assert!(children[..4].iter().all(|c| texts_in(c) == ["Votes"])); - // Set as in a whole copy that ends at the value: 40 points tall from y 160, as wide as the column. - let sx = plain.width() / 10.0; - let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(plain.x0 + (p.x - 600.0) * sx, 160.0 + (p.y - 600.0) * 4.0); - let totals = totals_of(&children[4..]); - assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["035.0", "35.00 and 35"]); - assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?}"); - // Sliding: the total above the guide moves with the part kept in proportion at the value end, the one below the - // design with the part stretched down to the zero line. - s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "sliding"})).unwrap(); - let column = series_parts(&s, id, 0)[0].clone(); - let children = column.children().unwrap(); - assert_eq!(children.len(), 4, "two halves and two totals: {children:?}"); - let k = (140.0 - 5.0 * sx) / 5.0; - let totals = totals_of(&children[2..]); - let kept = vectorcraft_geom::Point::new(plain.x0 + (above.x - 600.0) * sx, 160.0 + (above.y - 600.0) * sx); - let stretched = vectorcraft_geom::Point::new(plain.x0 + (below.x - 600.0) * sx, 300.0 + (below.y - 610.0) * k); - assert!(close(totals[0].1, kept) && close(totals[1].1, stretched), "{totals:?} {kept:?} {stretched:?}"); - // A total's pattern fill moves with it. - let mut d = vectorcraft_doc::Document::new(800.0, 800.0); - let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); - let frame = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); - let origin = vectorcraft_geom::Point::new(0.0, -2.0); - let t = vectorcraft_doc::TextObject::point(origin, "%00", CharStyle::default()); - let mut total = vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); - let dots = Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY }; - total.appearance = vectorcraft_doc::Appearance::basic(dots, Paint::None, 0.0).into(); - let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]); - d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) }); - let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() }; - let g = GraphSpec { - rows: vec![vec![35.0]], - axis_min: Some(0.0), - axis_max: Some(50.0), - series_columns: vec![Some(options)], - ..GraphSpec::default() - }; - let out = super::generate(&mut d, &g); - let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); - let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0); - assert_eq!(text, "35"); - let Paint::Pattern { xf, .. } = column.children().unwrap()[1].appearance.fill_paint() else { panic!("a pattern fill") }; - assert!((xf * origin - at).hypot() < 1e-6 && (at - origin).hypot() > 1.0, "{xf:?} {at:?}"); - } - - #[test] - fn totals_inside_a_group_with_a_look_of_its_own_show_the_value_where_they_are() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); - let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); - let total = id_of(s.execute("text.create", &json!({"x": 600, "y": 605, "text": "%00", "size": 4})).unwrap()); - s.execute("select.set", &json!({"ids": [total]})).unwrap(); - let faded = id_of(s.execute("object.group", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [faded]})).unwrap(); - s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); - s.execute("select.set", &json!({"ids": [frame, faded]})).unwrap(); - s.execute("graph.design", &json!({"new": "Faded"})).unwrap(); - let id = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": id.0, "design": "Faded", "columnType": "repeating", "each": 10})).unwrap(); - // The total stays in its half-opaque group: each of the four copies shows the value there. - let parts = series_parts(&s, id, 0); - let copies = parts[0].children().unwrap(); - assert_eq!(copies.len(), 4, "{copies:?}"); - let mut faded_groups = 0; - for c in copies { - assert_eq!(texts_in(c), ["35"]); - c.walk(&mut |n| faded_groups += usize::from(matches!(n.kind, NodeKind::Group { .. }) && (n.opacity - 0.5).abs() < 1e-6)); - } - assert_eq!(faded_groups, 4); - // The legend swatch shows the design without it. - let swatch = parts.last().unwrap(); - assert!(matches!(swatch.kind, NodeKind::Group { .. }) && texts_in(swatch).is_empty(), "{:?}", swatch.kind); - // Type with a total that clips other art clips it with the value in each copy, and the legend swatch leaves out - // what it clips. - let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); - let inside = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); - let mask = id_of(s.execute("text.create", &json!({"x": 600, "y": 658, "text": "%00", "size": 8})).unwrap()); - s.execute("select.set", &json!({"ids": [inside, mask]})).unwrap(); - let clipped = id_of(s.execute("object.clippingMask.make", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [frame, clipped]})).unwrap(); - s.execute("graph.design", &json!({"new": "Masked"})).unwrap(); - let masked = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": masked.0, "design": "Masked"})).unwrap(); - let parts = series_parts(&s, masked, 0); - let clip_groups = |n: &vectorcraft_doc::Node| { - let mut out = vec![]; - n.walk(&mut |c| { - if let NodeKind::Group { clip: true, children } = &c.kind { - out.push(children.first().map(|m| texts_in(m)).unwrap_or_default()); - } - }); - out - }; - assert_eq!(clip_groups(&parts[0]), [["35"]], "the copy's clipping type shows the value"); - let swatch = parts.last().unwrap(); - let mut drawn = 0; - swatch.walk(&mut |n| drawn += usize::from(!matches!(n.kind, NodeKind::Group { .. }))); - assert!(clip_groups(swatch).is_empty() && drawn == 1, "only the frame: {swatch:?}"); - // Totals that are a blend's key objects stay in the blend and show the value there, and the legend swatch leaves - // them out. - let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 680, "width": 10, "height": 10})).unwrap()); - let first = id_of(s.execute("text.create", &json!({"x": 600, "y": 685, "text": "%00", "size": 4})).unwrap()); - let second = id_of(s.execute("text.create", &json!({"x": 606, "y": 685, "text": "%00", "size": 4})).unwrap()); - s.execute("select.set", &json!({"ids": [first, second]})).unwrap(); - let blend = id_of(s.execute("object.blend.make", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [frame, blend]})).unwrap(); - s.execute("graph.design", &json!({"new": "Blended"})).unwrap(); - let blended = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": blended.0, "design": "Blended"})).unwrap(); - let parts = series_parts(&s, blended, 0); - let mut blends = vec![]; - parts[0].walk(&mut |n| { - if matches!(n.kind, NodeKind::Blend { .. }) { - blends.push(texts_in(n)); - } - }); - assert_eq!(blends, [["35", "35"]], "{:?}", parts[0]); - assert!(texts_in(parts.last().unwrap()).is_empty(), "{:?}", parts.last().unwrap()); - // A design that is that blend alone gets the default legend swatch. - s.execute("select.set", &json!({"ids": [blend]})).unwrap(); - s.execute("graph.design", &json!({"new": "Keys"})).unwrap(); - let keys = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": keys.0, "design": "Keys"})).unwrap(); - let parts = series_parts(&s, keys, 0); - assert_eq!(texts_in(&parts[0]), ["35", "35"]); - assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); - // A design of a total inside two plain groups shows the value, and its legend swatch is the default square. - let total = id_of(s.execute("text.create", &json!({"x": 700, "y": 700, "text": "%00", "size": 4})).unwrap()); - s.execute("select.set", &json!({"ids": [total]})).unwrap(); - let inner = id_of(s.execute("object.group", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [inner]})).unwrap(); - let outer = id_of(s.execute("object.group", &json!({})).unwrap()); - s.execute("select.set", &json!({"ids": [outer]})).unwrap(); - s.execute("graph.design", &json!({"new": "Nested"})).unwrap(); - let nested = one_series(&mut s, "column", &[35.0]); - s.execute("graph.column", &json!({"id": nested.0, "design": "Nested"})).unwrap(); - let parts = series_parts(&s, nested, 0); - assert_eq!(texts_in(&parts[0]), ["35"]); - assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); - } - - #[test] - fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() { - for (v, before, after, want) in [ - (122.0, 3, 0, "122"), - (122.0, 0, 0, "122"), - (5.0, 3, 1, "005.0"), - (1234.0, 2, 0, "1234"), - (2.5, 0, 0, "3"), - (-2.5, 0, 0, "-3"), - (0.25, 0, 0, "0"), - (0.25, 0, 2, "0.25"), - (1.005, 0, 2, "1.01"), - (0.1 + 0.2, 0, 2, "0.30"), - (9.995, 0, 2, "10.00"), - (99.96, 0, 1, "100.0"), - (-0.004, 0, 2, "0.00"), - (-7.25, 2, 1, "-07.3"), - (7.0, 0, 9, "7.000000000"), - (1e21, 0, 0, "1000000000000000000000"), - ] { - assert_eq!(super::total_text(v, before, after), want, "{v} %{before}{after}"); - } - // Each `%` and two digits, left to right; any other `%` stays. - let style = CharStyle::default; - let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "%31 and %%12, %1x, 50%", style()); - assert!(super::fill_totals(&mut t, 7.25)); - assert_eq!(t.plain_text(), "007.3 and %7.25, %1x, 50%"); - // A total split between two runs takes the style of its `%`. - let (small, big) = (CharStyle { size: 10.0, ..style() }, CharStyle { size: 20.0, ..style() }); - t.runs = vec![vectorcraft_doc::TextRun::new("a %3", small), vectorcraft_doc::TextRun::new("1 b", big)]; - assert!(super::fill_totals(&mut t, 7.25)); - assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::>(), [("a 007.3", 10.0), (" b", 20.0)]); - // Type without a total is left as it is. - let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Votes: 50%", style()); - assert!(!super::fill_totals(&mut t, 7.25)); - assert_eq!(t.plain_text(), "Votes: 50%"); - } - - fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId { - NodeId( - s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), - ) - } - - /// Where each wedge of series `index` starts: its angle from 12 o'clock, clockwise, in degrees (the legend - /// swatch, last, left out). - fn wedge_starts(s: &Session, id: NodeId, index: u32) -> Vec { - let parts = series_parts(s, id, index); - parts[..parts.len() - 1] - .iter() - .filter_map(|n| match &n.kind { - NodeKind::Path { path, .. } => { - let bp = path.to_bezpath(); - let pts: Vec<_> = bp.elements().iter().filter_map(|e| e.end_point()).collect(); - let (c, p) = (pts.first()?, pts.get(1)?); - let deg = (p.x - c.x).atan2(-(p.y - c.y)).to_degrees(); - Some(if deg < -1e-6 { deg + 360.0 } else { deg }) - } - _ => None, - }) - .collect() - } - - #[test] - fn a_bar_in_a_label_breaks_the_line() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "column", ",Total|Sales|2023,b\nQ1|first,3,2\nQ2,5,4"); - let texts = axis_texts(&s, id); - assert!(texts.contains(&"Q1\nfirst".to_string()), "{texts:?}"); - // The legend: a label of three lines, and the next row below all three. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let legend: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); - let NodeKind::Text(t) = &legend[0].kind else { panic!() }; - assert_eq!(t.plain_text(), "Total\nSales\n2023"); - let (a, b) = ( - series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(), - series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(), - ); - assert!((b.y0 - a.y0 - 3.0 * 9.0 * 1.8).abs() < 1e-6, "{a:?} {b:?}"); - } - - #[test] - fn transpose_swaps_rows_and_columns_and_switch_xy_swaps_scatter_pairs() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "column", ",a,b\nQ1,1,2\nQ2,3,"); - let before = spec(&s, id); - s.execute("graph.setData", &json!({"transpose": true})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.series.clone(), g.categories.clone()), (vec!["Q1".to_string(), "Q2".into()], vec!["a".to_string(), "b".into()])); - assert_eq!(g.cells(), [[Some(1.0), Some(3.0)], [Some(2.0), None]]); - s.execute("graph.setData", &json!({"transpose": true})).unwrap(); - assert_eq!(spec(&s, id).cells(), before.cells()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(spec(&s, id).cells(), g.cells()); - assert!(s.execute("graph.setData", &json!({"switchXY": true})).is_err(), "scatter graphs only"); - let sc = make(&mut s, "scatter", ",y1,x1,y2,x2\nP,1,10,2,20"); - s.execute("graph.setData", &json!({"switchXY": true})).unwrap(); - let g = spec(&s, sc); - assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]); - assert_eq!(g.series, ["x1", "y1", "x2", "y2"]); - } - - #[test] - fn pie_wedges_sort_largest_first_in_each_pie_or_in_the_first_pies_order() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",a,b,c\nP1,1,3,2\nP2,3,1,2"); - // None (data order): a starts at 12 o'clock in both pies. - assert_eq!(wedge_starts(&s, id, 0).iter().map(|d| d.round()).collect::>(), [0.0, 0.0]); - s.execute("graph.setType", &json!({"pieSort": "all"})).unwrap(); - // P1: b (3) first; P2: a (3) first. - let (a, b) = (wedge_starts(&s, id, 0), wedge_starts(&s, id, 1)); - assert!(b[0].abs() < 1e-6 && a[1].abs() < 1e-6, "{a:?} {b:?}"); - s.execute("graph.setType", &json!({"pieSort": "first"})).unwrap(); - // P1's order (b, c, a) in both: b first in P2 too. - let b = wedge_starts(&s, id, 1); - assert!(b.iter().all(|d| d.abs() < 1e-6), "{b:?}"); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["pieSort"], "first"); - assert!(s.execute("graph.setType", &json!({"pieSort": "random"})).is_err()); - } - - #[test] - fn pies_sized_by_their_totals_side_by_side_or_stacked() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20"); - let pie = |s: &Session, c: usize| { - let wedges = |i: u32| { - let parts = series_parts(s, id, i); - parts[..parts.len() - 1].to_vec() - }; - let rects: Vec<_> = (0..2).flat_map(wedges).filter_map(|n| n.geometric_bounds()).collect(); - rects.into_iter().skip(c).step_by(2).reduce(|a, b| a.union(b)).unwrap() - }; - let even = (pie(&s, 0), pie(&s, 1)); - assert!((even.0.width() - even.1.width()).abs() < 1e-6); - s.execute("graph.setType", &json!({"piePosition": "ratio"})).unwrap(); - let (small, big) = (pie(&s, 0), pie(&s, 1)); - // Areas in proportion to the totals: 10 and 40, so radii 1 : 2. - assert!((big.width() / small.width() - 2.0).abs() < 1e-6, "{small:?} {big:?}"); - assert!((big.width() - even.1.width()).abs() < 1e-6, "the biggest keeps the full size"); - s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap(); - // Drawn largest first, so the big pie's wedges come first in each series. - let (big, small) = (pie(&s, 0), pie(&s, 1)); - assert!((small.center() - big.center()).hypot() < 1e-6 && (big.width() / small.width() - 2.0).abs() < 1e-6); - // The big pie is drawn first, under the small one. - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let wedges: Vec = series(&n, 0).children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.width()).collect(); - assert!(wedges[0] > wedges[1], "{wedges:?}"); - } - - #[test] - fn legends_in_wedges_put_the_series_labels_inside_the_pie() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",North,South|East\nP,1,1"); - s.execute("graph.setType", &json!({"pieLegend": "wedges"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let pie = - series(&n, 0).children().unwrap()[0].geometric_bounds().unwrap().union(series(&n, 1).children().unwrap()[0].geometric_bounds().unwrap()); - let labels: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); - assert_eq!(labels.len(), 2); - for l in &labels { - let b = l.geometric_bounds().unwrap(); - assert!(pie.contains(b.center()), "{b:?} inside {pie:?}"); - } - // No swatches beside the pie: each series group holds only its wedge. - assert_eq!((series(&n, 0).children().unwrap().len(), series(&n, 1).children().unwrap().len()), (1, 1)); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["pieLegend"].clone(), v["piePosition"].clone()), (json!("wedges"), json!("even"))); - assert!(v.get("legend").is_none(), "a pie's Legend stands for the checkbox"); - // The labels are drawn over the wedges, and on the black first wedge in white. - let order: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect(); - assert_eq!(order.last().map(String::as_str), Some("Legend"), "{order:?}"); - let colour = |l: &vectorcraft_doc::Node| match &l.kind { - NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(), - _ => panic!(), - }; - assert_eq!((colour(&labels[0]), colour(&labels[1])), ("#ffffff".to_string(), "#000000".to_string())); - // No Legend wins over a legend checkbox sent along (as the dialog does), and Standard comes back without wedges. - s.execute("graph.setType", &json!({"pieLegend": "none", "legend": true})).unwrap(); - assert!(!spec(&s, id).legend); - s.execute("graph.setType", &json!({"legend": true})).unwrap(); - assert!(!spec(&s, id).pie_legend_in_wedges); - assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err()); - let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft"); - s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap(); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert_eq!( - (g.pie_legend_in_wedges, g.pie_position, g.pie_sort), - (true, vectorcraft_doc::PiePosition::Stacked, vectorcraft_doc::PieSort::All) - ); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - for k in ["pieLegendInWedges", "piePosition", "pieSort"] { - assert!(v.get(k).is_none(), "{k}"); - } - } - - #[test] - fn stacked_pies_are_rings_so_none_covers_another() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20\nSame,20,20"); - s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - // Series b's wedges: of the two equal big pies one is left out, the other a ring outside the small pie - // (clear of the centre), and the small pie's whole. - let b: Vec<_> = series(&n, 1).children().unwrap().iter().map(|c| (**c).clone()).collect(); - assert_eq!(b.len(), 3, "two wedges and the swatch"); - let small = b[1].geometric_bounds().unwrap(); - let NodeKind::Path { path, .. } = &b[0].kind else { panic!() }; - let c = small.center(); - let near: Vec = path.to_bezpath().elements().iter().filter_map(|e| e.end_point()).map(|p| (p - c).hypot()).collect(); - let min = near.iter().copied().fold(f64::MAX, f64::min); - assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}"); - // The names are drawn over the pies: after every series group. - let kids = n.children().unwrap(); - let last_series = kids.iter().rposition(|c| c.extras().series_index.is_some()).unwrap(); - let names: Vec = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); - assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name"); - assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}"); - } - - #[test] - fn transpose_keeps_within_the_series_cap_and_drops_stale_axis_series() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let rows: Vec> = (0..300).map(|i| vec![i as f64]).collect(); - let id = NodeId( - s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - assert!(s.execute("graph.setData", &json!({"transpose": true})).is_err(), "300 categories can't become series"); - assert_eq!(spec(&s, id).cells().len(), 300); - let g = make(&mut s, "column", ",a,b,c\nQ1,1,2,3"); - s.execute("graph.setType", &json!({"seriesIndexes": [2], "valueAxis": "right"})).unwrap(); - s.execute("graph.setData", &json!({"transpose": true})).unwrap(); - assert!(spec(&s, g).right_series.is_empty(), "one series left"); - // An all-blank series becomes no category. - let h = make(&mut s, "column", ",a,b\nQ1,1,\nQ2,2,"); - s.execute("graph.setData", &json!({"transpose": true})).unwrap(); - assert_eq!(spec(&s, h).categories, ["a", "b"], "b keeps its label"); - assert!(s.execute("graph.setData", &json!({"id": h.0, "transpose": false})).unwrap().get("csv").is_some(), "no change: the query"); - } - - #[test] - fn switch_xy_moves_the_labels_with_their_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "scatter", ",y1,x1,y2\nP,1,10,2,20"); - s.execute("graph.setData", &json!({"switchXY": true})).unwrap(); - let g = spec(&s, id); - assert_eq!(g.series, ["x1", "y1", "", "y2"]); - assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]); - } - - #[test] - fn a_label_of_only_bars_is_blank_and_non_pie_graphs_list_no_pie_options() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "column", ",||,b\nQ1,1,2"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert_eq!(group(&n, "Legend").children().unwrap().len(), 1, "only b"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert!(v.get("pieLegend").is_none() && v.get("legend").is_some()); - // Bar and radar labels with a `|` centre their lines on the category. - for ty in ["bar", "radar"] { - let g = make(&mut s, ty, ",a\nOne|Two|Three,1\nQ2,2\nQ3,3"); - assert!(axis_texts(&s, g).contains(&"One\nTwo\nThree".to_string()), "{ty}"); - } - } - - #[test] - fn drop_shadows_sit_behind_the_columns_lines_and_wedges() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let kids = n.children().unwrap(); - let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap(); - assert!(kids.iter().position(|c| c.extras().series_index.is_some()).unwrap() > at, "behind the series"); - let shadow = &kids[at]; - assert!((shadow.opacity - 0.35).abs() < 1e-6); - let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); - let cols: Vec<_> = (0..2).flat_map(|i| marks(&s, id, i)).collect(); - assert_eq!(copies.len(), cols.len(), "one per column, none for the swatches"); - for c in &cols { - assert!(copies.iter().any(|b| (b.x0 - c.x0 - 2.0).abs() < 1e-6 && (b.y0 - c.y0 - 2.0).abs() < 1e-6), "{c:?}"); - } - let black = shadow.children().unwrap().iter().all(|c| c.appearance.fill_paint().color().map(|c| c.to_hex()) == Some("#000000".into())); - assert!(black); - // Lines cast one too (not their markers); pies one per wedge. - let line = graph(&mut s, "line"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(line).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 2, "one per series line"); - let pie = graph(&mut s, "pie"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 5, "the wedges with a value above 0"); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["dropShadow"], true); - s.execute("graph.setType", &json!({"dropShadow": false})).unwrap(); - let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow"))); - } - - #[test] - fn the_legend_across_top_runs_in_rows_above_the_plot() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); - let swatch = |s: &Session, i: u32| series_parts(s, id, i).last().unwrap().geometric_bounds().unwrap(); - let (a, b) = (swatch(&s, 0), swatch(&s, 1)); - assert!(a.y1 <= 100.0 - 8.0 + 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && b.x0 > a.x1 && (a.x0 - 100.0).abs() < 1e-6, "{a:?} {b:?}"); - assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["legendAcrossTop"], true); - // Long labels wrap into rows within the plot's width, the last row just above it. - let labels: Vec = (0..6).map(|i| format!("A rather long series name {i}")).collect(); - let csv = format!(",{}\nQ1,1,2,3,4,5,6", labels.join(",")); - s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); - let ys: Vec = (0..6).map(|i| swatch(&s, i).y0).collect(); - assert!(ys.windows(2).any(|w| w[1] > w[0]), "a second row: {ys:?}"); - assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}"); - let xs: Vec = (0..6).map(|i| swatch(&s, i).x1).collect(); - assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}"); - let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert!(g.legend_across_top && g.drop_shadow); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - assert!(v.get("dropShadow").is_none() && v.get("legendAcrossTop").is_none()); - } - - #[test] - fn each_layer_keeps_its_shadows_just_behind_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "column", ",a,b,c\nQ1,3,2,1\nQ2,5,,2\nQ3,4,6,3"); - s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); - s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n - .children() - .unwrap() - .iter() - .map(|c| c.extras().series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))) - .collect(); - // Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns - // and the line. - let at = |k: &str| order.iter().position(|o| o == k).unwrap(); - let shadows: Vec = order.iter().enumerate().filter(|(_, o)| *o == "Drop Shadow").map(|(i, _)| i).collect(); - assert_eq!(shadows.len(), 2, "{order:?}"); - assert!(at("s0") < shadows[0] && shadows[0] < at("s1") && at("s1") < shadows[1] && shadows[1] < at("s2"), "{order:?}"); - // b's blank leaves two columns; a line with a blank casts a shadow per run. - let line = make(&mut s, "line", ",a\nQ1,1\nQ2,\nQ3,3\nQ4,4"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(line).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1, "Q1 alone draws no line; Q3–Q4 one"); - // Scatter and radar lines cast one too; a stacked segment of 0 doesn't. - for (ty, want) in [("scatter", 1), ("radar", 2)] { - let g = graph(&mut s, ty); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(g).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), want, "{ty}"); - } - let st = make(&mut s, "stackedColumn", ",a,b\nQ1,0,2"); - s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); - let n = s.doc().unwrap().doc.node(st).unwrap().clone(); - assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1); - } - - #[test] - fn the_legend_across_top_keeps_above_labels_over_the_plot() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "radar", ",a,b\nTop|of|chart,1,2\nQ2,2,3\nQ3,3,1"); - s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let axes_top = group(&n, "Axes").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y0).fold(f64::MAX, f64::min); - let legend_bottom = - group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y1).fold(f64::MIN, f64::max); - assert!(legend_bottom < axes_top, "{legend_bottom} above {axes_top}"); - // No legend: nothing across the top either. - s.execute("graph.setType", &json!({"legend": false})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Legend"))); - // Labels of a row other than the last stay within the plot's width. - let labels: Vec = (0..6).map(|i| format!("A rather long series name {i}")).collect(); - let col = make(&mut s, "column", &format!(",{}\nQ1,1,2,3,4,5,6", labels.join(","))); - s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); - let n = s.doc().unwrap().doc.node(col).unwrap().clone(); - let texts: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); - let last_row = texts.iter().map(|b| b.y0).fold(f64::MIN, f64::max); - assert!(texts.iter().filter(|b| b.y0 < last_row - 1e-6).all(|b| b.x1 <= 400.0 + 1e-6), "{texts:?}"); - } - - #[test] - fn widths_past_100_overlap_and_the_first_row_or_column_goes_in_front() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"columnWidth": 250, "clusterWidth": 300})).unwrap(); - let g = spec(&s, id); - assert_eq!((g.column_width, g.cluster_width), (250.0, 300.0)); - let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); - assert!(a[0].x1 > b[0].x0, "the columns of a cluster overlap: {:?} {:?}", a[0], b[0]); - assert!(b[0].x1 > a[1].x0, "and so do the clusters: {:?} {:?}", b[0], a[1]); - assert_eq!(s.execute("graph.setType", &json!({"clusterWidth": 5000})).map(|_| spec(&s, id).cluster_width).unwrap(), 1000.0); - let order = |s: &Session| -> Vec { - s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.extras().series_index).collect() - }; - assert_eq!(order(&s), [0, 1]); - s.execute("graph.setType", &json!({"firstColumnInFront": true})).unwrap(); - assert_eq!(order(&s), [1, 0], "the first series drawn last, on top"); - // First Row in Front: each series' first category drawn last. - let xs = |s: &Session| marks(s, id, 0).iter().map(|b| b.x0).collect::>(); - let before = xs(&s); - assert!(before.windows(2).all(|w| w[0] < w[1])); - s.execute("graph.setType", &json!({"firstRowInFront": true})).unwrap(); - let after = xs(&s); - assert!(after.windows(2).all(|w| w[0] > w[1]), "{after:?}"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert_eq!((v["firstRowInFront"].clone(), v["firstColumnInFront"].clone()), (json!(true), json!(true))); - } - - #[test] - fn filled_lines_are_bands_in_the_series_colour() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "line", ",a\nQ1,2\nQ2,2\nQ3,2"); - let thin = marks(&s, id, 0)[0]; - assert!(thin.height() < 1e-6, "a flat 1 pt line"); - s.execute("graph.setType", &json!({"filledLines": true})).unwrap(); - let parts = series_parts(&s, id, 0); - let band = parts[0].geometric_bounds().unwrap(); - assert!((band.height() - 3.0).abs() < 1e-6 && (band.width() - thin.width()).abs() < 1e-6, "{band:?}"); - assert_eq!(parts[0].appearance.fill_paint().color().unwrap().to_hex(), "#000000", "the first series' colour"); - assert_eq!(parts[0].appearance.stroke_width(), 0.25); - // A series colour set with Group Selection fills the band. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#ff0000"})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [2]]})).unwrap(); - assert_eq!(series_parts(&s, id, 0)[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); - let path = vectorcraft_testkit::temp_dir("graph-filled").join("filled.vectorcraft"); - s.execute("graph.setType", &json!({"firstRowInFront": true, "firstColumnInFront": true})).unwrap(); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - let g = spec(&s, id); - assert!(g.filled_lines && g.first_row_in_front && g.first_column_in_front); - let v = serde_json::to_value(GraphSpec::default()).unwrap(); - for k in ["filledLines", "firstRowInFront", "firstColumnInFront"] { - assert!(v.get(k).is_none(), "{k}"); - } - } - - #[test] - fn filled_lines_keep_the_line_colour_and_fall_back_to_thin_lines() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2"); - // A colour given to the thin line (its stroke) fills the band. - let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; - s.execute("paint.setStroke", &json!({"ids": [grp.0], "color": "#00ff00"})).unwrap(); - s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); - let band = &series_parts(&s, id, 0)[0]; - assert_eq!(band.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); - // A radar series of zeros has every point at the centre: no band to make, so a thin line. - let radar = make(&mut s, "radar", ",a\nQ1,0\nQ2,0\nQ3,0"); - s.execute("graph.setType", &json!({"id": radar.0, "filledLines": true})).unwrap(); - let line = &series_parts(&s, radar, 0)[0]; - assert!(line.appearance.fill_paint().is_none(), "a stroked line"); - // Past the budget, thin lines. - let rows: Vec> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect(); - let big = NodeId( - s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] - .as_u64() - .unwrap(), - ); - s.execute("graph.setType", &json!({"id": big.0, "filledLines": true, "markPoints": false})).unwrap(); - assert!(series_parts(&s, big, 0)[0].appearance.fill_paint().is_none()); - } - - #[test] - fn overlapping_columns_keep_clear_of_the_legend_and_options_follow_the_graph_type() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("graph.setType", &json!({"columnWidth": 1000, "clusterWidth": 1000})).unwrap(); - let reach = (0..2).flat_map(|i| marks(&s, id, i)).map(|b| b.x1).fold(f64::MIN, f64::max); - assert!(reach > 400.0, "past the plot: {reach}"); - let swatch = series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(); - assert!(swatch.x0 >= reach + 14.0 - 1e-6, "{swatch:?} after {reach}"); - let v = s.execute("graph.setType", &json!({})).unwrap(); - assert!(v.get("firstRowInFront").is_some() && v.get("filledLines").is_none()); - let line = graph(&mut s, "line"); - let v = s.execute("graph.setType", &json!({"id": line.0})).unwrap(); - assert!(v.get("firstRowInFront").is_none() && v.get("filledLines").is_some()); - } - - /// The Axes and Legend texts with their size and fill, after the graph is drawn again. - fn label_looks(s: &mut Session, id: NodeId) -> Vec<(String, f64, String)> { - s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let mut out = vec![]; - for g in ["Axes", "Legend"] { - for c in group(&n, g).children().unwrap() { - if let NodeKind::Text(t) = &c.kind { - let st = &t.runs[0].style; - out.push((t.plain_text(), st.size, st.fill.color().map(|c| c.to_hex()).unwrap_or_default())); - } - } - } - out - } - - fn text_id(s: &Session, id: NodeId, grp: &str, text: &str) -> NodeId { - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, grp).children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap().id - } - - #[test] - fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 12})).unwrap(); - let looks = label_looks(&mut s, id); - assert!(looks.iter().all(|(_, size, _)| (*size - 12.0).abs() < 1e-6), "{looks:?}"); - let g = spec(&s, id); - assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_some() && g.label_styles.legend.is_some()); - // One undo takes the data change back, the next the formatting and the kept styles together. - s.execute("edit.undo", &json!({})).unwrap(); - assert!(spec(&s, id).label_styles.values.is_some()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default()); - } - - #[test] - fn formatting_one_label_keeps_that_kind_of_label() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - // A legend label (Group Selection), recoloured with the Character panel's fill. - let l = text_id(&s, id, "Legend", "2025"); - s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); - s.execute("text.setStyle", &json!({"fill": "#ff0000"})).unwrap(); - // A category label, resized. - let c = text_id(&s, id, "Axes", "Q2"); - s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 14})).unwrap(); - // A value axis number, painted with the Appearance fill. - let v = text_id(&s, id, "Axes", "4"); - s.execute("paint.setFill", &json!({"ids": [v.0], "color": "#0000ff"})).unwrap(); - let looks = label_looks(&mut s, id); - let look = |t: &str| looks.iter().find(|(x, _, _)| x == t).cloned().unwrap(); - assert_eq!(look("2024"), ("2024".into(), 9.0, "#ff0000".into()), "every legend label"); - assert_eq!(look("Q1").1, 14.0, "every category label"); - assert_eq!(look("Q1").2, "#000000"); - assert_eq!(look("0").2, "#0000ff", "every value axis number"); - assert_eq!(look("0").1, 9.0); - // Kept through save and open; nothing written when unset. - let path = vectorcraft_testkit::temp_dir("graph-labels").join("labels.vectorcraft"); - s.execute("document.save", &json!({"path": path})).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 14.0); - assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); - } - - #[test] - fn pie_names_and_wedge_labels_keep_their_style() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = make(&mut s, "pie", ",a,b\nP1,1,2"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let name = n.children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "P1")).unwrap().id; - s.execute("select.set", &json!({"ids": [name.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 16})).unwrap(); - assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 16.0); - s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); - let w = text_id(&s, id, "Legend", "a"); - s.execute("select.set", &json!({"ids": [w.0]})).unwrap(); - s.execute("text.setStyle", &json!({"fill": "#00ff00"})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1]]})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let fills: Vec = group(&n, "Legend") - .children() - .unwrap() - .iter() - .filter_map(|c| match &c.kind { - NodeKind::Text(t) => t.runs[0].style.fill.color().map(|c| c.to_hex()), - _ => None, - }) - .collect(); - assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge"); - } - - #[test] - fn labels_are_told_apart_by_where_they_come_not_by_their_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - // Categories named like the value axis numbers. - let id = make(&mut s, "column", ",a\n2,1\n4,3\n6,5"); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let texts: Vec<(NodeId, String)> = group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter_map(|c| match &c.kind { - NodeKind::Text(t) => Some((c.id, t.plain_text())), - _ => None, - }) - .collect(); - // The value axis "2" comes before the category "2". - let value_two = texts.iter().find(|(_, t)| t == "2").unwrap().0; - s.execute("select.set", &json!({"ids": [value_two.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 7})).unwrap(); - let g = spec(&s, id); - assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none(), "{:?}", g.label_styles); - // A scatter graph's numbers are all values. - let sc = graph(&mut s, "scatter"); - s.execute("select.set", &json!({"ids": [sc.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 8})).unwrap(); - let g = spec(&s, sc); - assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none()); - // Part of a label's text: nothing kept. - let col = graph(&mut s, "column"); - let q = text_id(&s, col, "Axes", "Q2"); - s.execute("select.set", &json!({"ids": [q.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 20, "start": 0, "end": 1})).unwrap(); - assert!(spec(&s, col).label_styles.categories.is_none()); - // Only the change is kept: a size given to the whole graph leaves the label colours as they were. - let pie = make(&mut s, "pie", ",a,b\nP,1,1"); - s.execute("graph.setType", &json!({"id": pie.0, "pieLegend": "wedges"})).unwrap(); - s.execute("select.set", &json!({"ids": [pie.0]})).unwrap(); - s.execute("text.setStyle", &json!({"size": 11})).unwrap(); - let legend = spec(&s, pie).label_styles.legend.unwrap(); - assert_eq!((legend.size, legend.fill.color().unwrap().to_hex()), (11.0, "#000000".into())); - s.execute("graph.setData", &json!({"id": pie.0, "rows": [[2, 1]]})).unwrap(); - let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); - let fill = match &group(&n, "Legend").children().unwrap()[0].kind { - NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(), - _ => panic!(), - }; - assert_eq!(fill, "#ffffff", "still white on the black wedge"); - } - - #[test] - fn character_formats_are_kept_and_bigger_labels_space_out() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let id = graph(&mut s, "column"); - s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - s.execute("text.setFormat", &json!({"underline": true, "baselineShift": 1})).unwrap(); - let g = spec(&s, id); - let st = g.label_styles.legend.unwrap(); - assert!(st.underline && (st.baseline_shift - 1.0).abs() < 1e-9); - // 20 pt legend labels: the rows move apart so they don't overlap. - s.execute("text.setStyle", &json!({"size": 20})).unwrap(); - s.execute("graph.setData", &json!({"id": id.0, "csv": ",a,b,c\nQ1,1,2,3"})).unwrap(); - let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let mut rows: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); - rows.sort_by(|a, b| a.y0.total_cmp(&b.y0)); - assert!(rows.windows(2).all(|w| w[1].y0 >= w[0].y1 - 1e-6), "{rows:?}"); - // A file's absurd style is kept within the Character limits. - let mut g = spec(&s, id); - g.label_styles.values = Some(CharStyle { size: 1e300, tracking: f64::INFINITY, ..CharStyle::default() }); - let mut d = (*s.doc().unwrap().doc).clone(); - let art = super::generate(&mut d, &g); - let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap(); - for t in axes.children().unwrap() { - if let NodeKind::Text(t) = &t.kind { - assert!(t.runs[0].style.size <= 1296.0 && t.runs[0].style.tracking.is_finite()); - } - } - } -} +mod tests; diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs new file mode 100644 index 000000000..e6d33bfaa --- /dev/null +++ b/crates/engine/src/cmd/graph/tests.rs @@ -0,0 +1,2763 @@ +use serde_json::json; +use vectorcraft_color::Paint; +use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind}; + +use crate::{NodeId, Session}; + +#[test] +fn fmt_value_does_not_saturate_beyond_i64() { + assert_eq!(super::fmt_value(1e19), "10000000000000000000"); + assert_eq!(super::fmt_value(2e19), "20000000000000000000"); + assert_eq!(super::fmt_value(-2e19), "-20000000000000000000"); + assert_eq!(super::fmt_value(42.0), "42"); + assert_eq!(super::fmt_value(-0.0), "0"); + assert_eq!(super::fmt_value(2.5), "2.5"); +} + +fn graph(s: &mut Session, ty: &str) -> NodeId { + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + NodeId( + s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ) +} + +fn group<'a>(n: &'a vectorcraft_doc::Node, name: &str) -> &'a vectorcraft_doc::Node { + n.children().unwrap().iter().find(|c| c.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no {name}")) +} + +/// The generated group stored for series `index`, not a sibling that happens to share its name. +fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node { + n.children().unwrap().iter().find(|c| c.extras().series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) +} + +#[test] +fn every_graph_type_generates_series_groups() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { + let id = graph(&mut s, ty); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(n.extras().graph.is_some(), "{ty}"); + if ty != "scatter" { + assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}"); + assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}"); + } + assert!(n.geometric_bounds().unwrap().width() > 100.0, "{ty}"); + } +} + +#[test] +fn column_heights_follow_data_and_data_edits_regenerate_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let heights = |s: &Session| -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + // Bars only (the legend swatch is the 8 × 8 square at the end). + let g = group(&n, "2025"); + let ch = g.children().unwrap(); + ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap().height()).collect() + }; + let h = heights(&s); + assert!((h[2] / h[0] - 3.0).abs() < 1e-6, "{h:?}"); + // Move the graph, then change the data: it stays where it was moved. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); + let x0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let v = s.execute("graph.setData", &json!({})).unwrap(); + assert!(v["csv"].as_str().unwrap().contains("Q2,5,4")); + s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap(); + let h2 = heights(&s); + assert!((h2[1] / h2[0] - 4.0).abs() < 1e-6, "{h2:?}"); + assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0); + // Type change keeps the data; undo restores the columns. + s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column")); + // Selecting a bar inside still targets the graph. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bar = group(&n, "2024").children().unwrap()[0].id; + s.execute("select.set", &json!({"ids": [bar.0]})).unwrap(); + assert!(s.execute("graph.setData", &json!({})).is_ok()); + assert!(matches!(n.kind, NodeKind::Group { .. })); +} + +/// The x of every marker of series `index`, left to right. +fn marker_xs(s: &Session, id: NodeId, index: u32) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut xs: Vec = series(&n, index) + .children() + .unwrap() + .iter() + .map(|c| c.geometric_bounds().unwrap()) + .filter(|b| (b.width() - 5.0).abs() < 1e-6) + .map(|b| b.center().x) + .collect(); + xs.sort_by(f64::total_cmp); + xs +} + +#[test] +fn edge_to_edge_lines_off_puts_line_points_at_category_centres() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // 300 pt wide, three categories: centres at 150, 250, 350 (off by default); edges at 100 and 400. + let id = graph(&mut s, "line"); + assert_eq!(marker_xs(&s, id, 0), vec![150.0, 250.0, 350.0]); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["edgeToEdge"], json!(false)); + s.execute("graph.setType", &json!({"edgeToEdge": true})).unwrap(); + assert_eq!(marker_xs(&s, id, 0), vec![100.0, 250.0, 400.0]); + // A graph saved before the option existed keeps its edge-to-edge lines. + let old: GraphSpec = serde_json::from_value(json!({"kind": "line"})).unwrap(); + assert!(old.edge_to_edge); +} + +#[test] +fn radar_graphs_follow_mark_and_connect_data_points() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "radar"); + let parts = |s: &Session| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap().len(); + // The ring, three markers and the legend swatch. + assert_eq!(parts(&s), 5); + s.execute("graph.setType", &json!({"markPoints": false})).unwrap(); + assert_eq!(parts(&s), 2); + s.execute("graph.setType", &json!({"markPoints": true, "connectPoints": false})).unwrap(); + assert_eq!(parts(&s), 4); +} + +#[test] +fn an_overridden_value_axis_is_used_exactly() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = s.execute("graph.create", &json!({"type": "column", "x": 20, "y": 20, "width": 170, "height": 74, "series": ["a"], "categories": ["x", "y"], "rows": [[4839], [10488]]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let tallest = |s: &Session| -> f64 { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bars = series(&n, 0).children().unwrap(); + bars.iter().map(|c| c.geometric_bounds().unwrap().height()).fold(0.0, f64::max) + }; + // Calculated: a nice axis (0 to 12500 here), so the 10488 column falls short of 10488 / 12000 of the plot. + assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0); + s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 12000})).unwrap(); + assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() < 1e-6, "{}", tallest(&s)); + let o = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((o["axisMin"].as_f64(), o["axisMax"].as_f64()), (Some(0.0), Some(12000.0))); + // One bound only keeps the calculated (nice) axis around it. + s.execute("graph.setType", &json!({"axisMax": null})).unwrap(); + assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0); +} + +#[test] +fn a_scatter_x_axis_that_overflows_does_not_hang() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // Finite x values whose rounded axis end is infinite: this used to add ticks forever. Now the + // command returns: points that far off the plot can't be drawn, which is an error. + let csv = ",y1,x1\nP,1,1.7e308\nQ,2,1.5e308"; + let refused = |r: crate::Result| assert!(r.unwrap_err().to_string().contains("exceed the canvas")); + refused(s.execute("graph.create", &json!({"type": "scatter", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv}))); + // The same data through Graph Data, on an ordinary scatter graph: it stays as it was. + let axes = |s: &Session, id: NodeId| group(s.doc().unwrap().doc.node(id).unwrap(), "Axes").children().unwrap().len(); + let id = make(&mut s, "scatter", ",y1,x1\nP,1,10\nQ,2,20"); + let before = axes(&s, id); + refused(s.execute("graph.setData", &json!({"id": id.0, "csv": csv}))); + assert_eq!(axes(&s, id), before); +} + +#[test] +fn tick_values_end_on_the_maximum_and_never_run_away() { + // Counted, not accumulated: an offset range keeps its last tick. + let t = super::tick_values(1e10, 1e10 + 1.0, 0.01); + assert_eq!(t.len(), 101); + assert!((t[100] - (1e10 + 1.0)).abs() < 1e-3); + // A range too wide for f64, or a step too small to move, gives no ticks or a capped list. + assert!(super::tick_values(-1.7e308, 1.7e308, f64::INFINITY).is_empty()); + assert!(super::tick_values(1e20, 1e20 + 16384.0, 163.84).len() <= 1001); + assert!(super::tick_values(0.0, 1.0, 0.0).is_empty()); + // An override whose span overflows falls back to the calculated axis instead of hanging. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + graph(&mut s, "column"); + s.execute("graph.setType", &json!({"axisMin": -1.7e308, "axisMax": 1.7e308})).unwrap(); +} + +#[test] +fn group_selection_paint_becomes_the_series_colour_and_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for ty in GraphKind::ALL { + let id = graph(&mut s, ty.id()); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); + assert_eq!(spec, restored); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); + let selected_group = group(s.doc().unwrap().doc.node(id).unwrap(), "2024"); + assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}"); + s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); + s.execute("graph.setType", &json!({"type": "line"})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap(); + assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); + let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); + assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}"); + } +} + +#[test] +fn painting_a_series_is_one_undoable_edit() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap(); + assert_eq!( + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), + "#e8573f" + ); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints.is_empty()); + assert_eq!( + group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(), + Some(super::default_series_color(0)) + ); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!( + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), + "#e8573f" + ); +} + +/// A fill that a series' marks or a value axis number have already is one undo step when the graph +/// keeps no fill for them (as after a command that paints only the art): the art stays as it was +/// and the graph keeps the fill. Given again, the fill records no undo step. +#[test] +fn a_fill_the_art_has_already_is_kept_by_the_graph_in_one_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + let label = text_id(&s, id, "Axes", "4"); + for target in [series_id, label] { + let fill = json!({"ids": [target.0], "color": "#e8573f"}); + s.execute("paint.setFill", &fill).unwrap(); + // The graph keeps nothing, as after a command that paints only the art. + s.edit("Forget", |d, _| { + let g = d.node_mut(id).unwrap().extras_if_any().unwrap().graph.as_deref_mut().unwrap(); + g.series_paints.clear(); + g.label_styles = vectorcraft_doc::LabelStyles::default(); + Ok(()) + }) + .unwrap(); + let art = s.doc().unwrap().doc.node(target).unwrap().clone(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("paint.setFill", &fill).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{target:?}"); + assert_eq!(s.doc().unwrap().doc.node(target).unwrap(), &art, "the art is as it was"); + let g = spec(&s, id); + let kept = if target == series_id { g.series_paints[0].fill.clone() } else { g.label_styles.values.map(|st| st.fill) }; + assert_eq!(kept.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#e8573f".into()), "{target:?}"); + s.execute("paint.setFill", &fill).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing left to keep: {target:?}"); + } +} + +#[test] +fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; + s.execute("paint.setFill", &json!({"ids": [bar.0], "color": "#12ab34"})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let node = s.doc().unwrap().doc.node(id).unwrap(); + let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0); + assert_eq!(node.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); + assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green))); +} + +/// A series index read from a file past the graph's series is not a series: painting its art +/// paints the art alone and stores nothing (no allocation sized by the file). +#[test] +fn a_series_index_past_the_graphs_series_is_ignored() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.edit("Junk", |d, _| { + d.node_mut(series_id).unwrap().extras_mut().series_index = Some(u32::MAX); + Ok(()) + }) + .unwrap(); + let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; + s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap(); + let node = s.doc().unwrap().doc.node(id).unwrap(); + assert!(node.extras().graph.as_ref().unwrap().series_paints.is_empty()); + assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into())); +} + +#[test] +fn painting_a_line_series_stroke_sets_its_persistent_colour() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap(); + let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0); + assert_eq!( + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), + Some(blue) + ); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none()); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!( + group(s.doc().unwrap().doc.node(id).unwrap(), "2024") + .children() + .unwrap() + .iter() + .any(|n| n.appearance.stroke_paint().color() == Some(blue)) + ); +} + +#[test] +fn series_colours_survive_native_save_open_and_export() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let first = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id; + s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap(); + let dir = vectorcraft_testkit::temp_dir("graph-palette"); + let path = dir.join("palette.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let paints = &s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints; + assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::>(), vec!["#ff0000", "#008080"]); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + let swatch = group(n, "2025").children().unwrap().last().unwrap(); + assert_eq!(swatch.appearance.fill_paint().color().unwrap().to_hex(), "#008080"); + let third = group(n, "Series 3").children().unwrap().first().unwrap(); + assert_eq!(third.appearance.fill_paint().color().unwrap().to_hex(), "#cccccc"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["text"].as_str().unwrap(); + assert!(svg.contains("#ff0000") && svg.contains("#008080")); + let png = dir.join("palette.png"); + s.execute("document.export", &json!({"format": "png", "path": png})).unwrap(); + let pixels = image::open(&png).unwrap().to_rgba8(); + assert!(pixels.pixels().any(|p| p.0 == [255, 0, 0, 255])); + assert!(pixels.pixels().any(|p| p.0 == [0, 128, 128, 255])); + let old: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]]})).unwrap(); + assert!(old.series_paints.is_empty(), "older files keep the greyscale default"); +} + +#[test] +fn a_column_stroke_stays_a_stroke_and_a_marker_fill_does_not_recolour_its_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + assert_eq!(spec.series_paints[0].stroke, Some(blue.clone())); + assert!(spec.series_paints[0].fill.is_none()); + let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; + assert_eq!(bar.appearance.fill_paint().color(), Some(super::default_series_color(0))); + assert_eq!(bar.appearance.stroke_paint(), blue); + + let id = graph(&mut s, "line"); + let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id; + s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + assert!(spec.series_paints[0].stroke.is_none()); + let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); + assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0))); + assert_eq!(series[1].appearance.fill_paint().color(), Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))); +} + +#[test] +fn a_gradient_fill_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; + s.execute( + "paint.setFill", + &json!({"ids": [series_id.0], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), + ) + .unwrap(); + s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_)))); + let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; + assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_))); +} + +#[test] +fn a_series_named_legend_keeps_its_own_index_and_an_empty_series_is_omitted() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let csv = ",Legend,Axes\nQ1,3,2\nQ2,5,4"; + let id = NodeId(s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "csv": csv})).unwrap()["id"].as_u64().unwrap()); + let legend_id = { + let n = s.doc().unwrap().doc.node(id).unwrap(); + let legend = series(n, 0); + assert_eq!(legend.name.as_deref(), Some("Legend")); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.extras().series_index.is_none())); + legend.id + }; + s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap(); + s.execute("graph.setData", &json!({"csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); + assert!(n.extras().graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); + assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + assert_eq!(series(n, 1).name.as_deref(), Some("Axes")); + assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + + let id = graph(&mut s, "pie"); + s.execute("graph.setType", &json!({"id": id.0, "legend": false})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",Keep,Drop\nQ1,4,0\nQ2,2,0"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Keep"))); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); + let keep = group(n, "Keep").id; + s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert_eq!(group(n, "Keep").extras().series_index, Some(0)); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); +} + +#[test] +fn csv_parsing() { + let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n"); + assert_eq!(s, vec!["2024", "2025"]); + assert_eq!(c, vec!["Q1", "Q2"]); + assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]); + let (s, c, r) = super::parse_csv("1,2\n3,4"); + assert!(s.is_empty() && c.is_empty()); + assert_eq!(r.len(), 2); + assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0)); +} + +#[test] +fn numeric_csv_cells_round_trip_without_rounding_or_saturation() { + let mut g = GraphSpec::default(); + g.set_cells(vec![vec![Some(1.2345), Some(-1.2345)], vec![Some(1e-6), Some(-1e-6)], vec![Some(1e20), Some(-1e20)], vec![Some(0.0), None]]); + let csv = super::to_csv(&g); + let (_, _, cells) = super::parse_csv(&csv); + assert_eq!(cells, g.cells(), "{csv}"); +} + +#[test] +fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]); + let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]); + let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]); + let r = s + .execute( + "graph.create", + &json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}), + ) + .unwrap(); + let id = r["id"].as_u64().unwrap(); + let before = s.execute("graph.setData", &json!({"id": id})).unwrap(); + assert_eq!(before["series"], series); + assert_eq!(before["categories"], categories); + s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap(); + let after = s.execute("graph.setData", &json!({"id": id})).unwrap(); + assert_eq!(after["series"], before["series"], "{}", before["csv"]); + assert_eq!(after["categories"], before["categories"], "{}", before["csv"]); + assert_eq!(after["rows"], before["rows"], "{}", before["csv"]); +} + +#[test] +fn empty_cells_are_blank_and_quoted_numbers_are_labels() { + // Quoted years label the rows; empty and non-numeric cells are blank values, not zeros. + let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4"); + assert_eq!(s, vec!["a", "b"]); + assert_eq!(c, vec!["2023", "2024", "2025"]); + assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]); + // Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same. + let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() }; + g.set_cells(r); + assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]); + let csv = super::to_csv(&g); + assert!(csv.contains("\"2024\",,"), "{csv}"); + let (s2, c2, r2) = super::parse_csv(&csv); + assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells())); +} + +#[test] +fn a_row_with_an_empty_label_keeps_its_values_in_place() { + // The label column is the first column of every row once any row has a label. + let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4"); + assert_eq!(c, vec!["Q1", ""]); + assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]); + // And a data window round trip keeps it (the empty label is written as ""). + let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() }; + g.set_cells(r); + assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells()); + // Without series or labels, a first row starting with a blank is still data after a round trip. + let mut g = GraphSpec::default(); + g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]); + let (s, c, r) = super::parse_csv(&super::to_csv(&g)); + assert!(s.is_empty() && c.is_empty()); + assert_eq!(r, g.cells()); + // A label with spaces around a number stays a label, spaces and all. + let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() }; + g.set_cells(vec![vec![Some(1.0)]]); + assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]); +} + +#[test] +fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = + s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]])); + // A label followed by empty cells is a header with no series names, as before blanks existed. + let (s, c, r) = super::parse_csv("Year,,\n2023,1,2"); + assert!(s.is_empty()); + assert_eq!(c, Vec::::new()); + assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]); +} + +#[test] +fn a_huge_ragged_grid_is_capped() { + let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() }; + assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES)); +} + +#[test] +fn a_label_only_row_stays_with_blank_values() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = + s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); + assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]); + assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); +} + +#[test] +fn blank_cells_leave_out_their_columns_and_break_lines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]); + let r = s + .execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows})) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len(); + // Three columns each (one blank), plus the legend swatch. + assert_eq!((count(&s, 0), count(&s, 1)), (4, 4)); + s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); + assert_eq!((count(&s, 0), count(&s, 1)), (4, 4)); + // Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three + // markers and the legend swatch. + s.execute("graph.setType", &json!({"type": "line"})).unwrap(); + assert_eq!(count(&s, 0), 1 + 3 + 1); + // Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch. + assert_eq!(count(&s, 1), 1 + 3 + 1); + // A data window round trip keeps the blanks. + let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); + assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}"); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); + assert_eq!(spec.cells()[1], vec![None, Some(2.0)]); + // In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads + // numbers (as zeros); a graph without blanks writes no `blanks` at all. + let v = serde_json::to_value(&spec).unwrap(); + assert_eq!(v["rows"][1], json!([0.0, 2.0])); + assert_eq!(v["blanks"], json!([[1, 0], [2, 1]])); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none()); +} + +fn spec(s: &Session, id: NodeId) -> GraphSpec { + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone() +} + +/// Bounds of a series' marks, without its legend swatch (the last child). +fn marks(s: &Session, id: NodeId, index: u32) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let ch = series(&n, index).children().unwrap().to_vec(); + ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap()).collect() +} + +#[test] +fn a_series_given_its_own_type_is_drawn_as_that_type_in_front_of_the_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let one_column = marks(&s, id, 0)[0].width(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Column, GraphKind::Column, GraphKind::Line)); + // The columns left take the whole cluster; the line runs through the category centres, one marker a value. + let cols = marks(&s, id, 0); + assert_eq!(cols.len(), 3); + assert!((cols[0].width() - 2.0 * one_column).abs() < 1e-6, "{} vs {one_column}", cols[0].width()); + let line = marks(&s, id, 1); + assert_eq!(line.len(), 4, "a line and three markers"); + assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6); + // The line series is drawn after (in front of) the column series. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); + assert_eq!(order, [0, 1]); + s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); + assert_eq!(order, [1, 0]); + // Back to the graph's own type: nothing is stored for that series. + assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]); + // One undo per change. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line), Some(GraphKind::Line)]); +} + +#[test] +fn group_selecting_a_series_retypes_only_that_series() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let grp = series(&n, 1).id; + s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); + // The dialog opens on the series' type, and its OK (every option sent back) keeps it. + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column"); + s.execute("graph.setType", &json!({"type": "area"})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.kind, g.series_kind(1)), (GraphKind::Column, GraphKind::Area)); + let mut all = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!(all["type"], "area"); + all["ticks"] = json!(4); + s.execute("graph.setType", &all).unwrap(); + assert_eq!(spec(&s, id).series_kind(1), GraphKind::Area); + // The series stays selected through each change. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]); + // A series and something outside it selected, or an explicit `id`: the whole graph. + s.execute("select.set", &json!({"ids": [grp.0, id.0]})).unwrap(); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column"); + s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); + assert_eq!(s.execute("graph.setType", &json!({"id": id.0})).unwrap()["type"], "column"); + // The whole graph selected: the graph's type changes, the series keeps its own. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setType", &json!({"type": "line"})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area)); +} + +#[test] +fn types_that_do_not_share_a_value_axis_are_refused_or_ignored() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + for ty in ["pie", "radar", "scatter", "bar"] { + assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": ty})).is_err(), "{ty}"); + } + for bad in [json!([2]), json!([]), json!([-1]), json!(["x"]), json!([1e30]), json!({}), json!(1)] { + assert!(s.execute("graph.setType", &json!({"seriesIndexes": bad, "type": "line"})).is_err(), "{bad}"); + } + assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1]})).is_err(), "no type"); + assert!(spec(&s, id).series_kinds.is_empty()); + // A series type the graph's new type can't take is drawn as the graph's type, and kept for when it can. + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.series_kind(1), GraphKind::Pie); + s.execute("graph.setType", &json!({"type": "column"})).unwrap(); + assert_eq!(spec(&s, id).series_kind(1), GraphKind::Line); +} + +#[test] +fn stacked_columns_take_one_slot_and_the_value_axis_covers_their_total() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b,c\nQ1,10,20,30\nQ2,10,20,30"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "stackedColumn", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line"})).unwrap(); + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!((a[0].x0 - b[0].x0).abs() < 1e-6 && (a[0].y0 - b[0].y1).abs() < 1e-6, "b sits on a"); + // a + b stack to 30, the line's highest value is 30: the axis ends at 30 and the stack reaches the top. + assert!((b[0].y0 - 0.0).abs() < 1e-6, "{:?}", b[0]); + // With a plain column too, the stack is the cluster's last slot, beside it. + s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "column"})).unwrap(); + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!((a[0].width() - b[0].width()).abs() < 1e-6 && b[0].x0 > a[0].x1, "{:?} {:?}", a[0], b[0]); +} + +#[test] +fn areas_mixed_with_columns_put_their_points_at_the_category_centres() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); + let (area, cols) = (marks(&s, id, 0)[0], marks(&s, id, 1)); + assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}"); + // The area is drawn behind the columns. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); + assert_eq!(order, [0, 1]); + // An area graph alone still spans the plot. + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap(); + assert!((marks(&s, id, 0)[0].x0 - 100.0).abs() < 1e-6); +} + +#[test] +fn bar_graphs_take_stacked_bar_series() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err()); + s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap(); + let (a, b, c) = (marks(&s, id, 0), marks(&s, id, 1), marks(&s, id, 2)); + // b and c stack along x in the cluster's second slot, below a's bar; c, negative, runs left of the zero line. + assert!((a[0].height() - b[0].height()).abs() < 1e-6 && b[0].y0 > a[0].y1, "{:?} {:?}", a[0], b[0]); + assert!((b[0].y0 - c[0].y0).abs() < 1e-6 && (c[0].x1 - b[0].x0).abs() < 1e-6, "{:?} {:?}", b[0], c[0]); +} + +#[test] +fn series_types_survive_save_and_open_and_older_files_have_none() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]); + let g: GraphSpec = serde_json::from_value(json!({"kind": "line", "rows": [[1.0, 2.0]]})).unwrap(); + assert!(g.series_kinds.is_empty()); + assert!(serde_json::to_value(&g).unwrap().get("seriesKinds").is_none()); + // A stored type the graph can't take, or past its series, is drawn as the graph's type. + let g: GraphSpec = serde_json::from_value(json!({"kind": "pie", "seriesKinds": ["line", null, "column"], "rows": [[1.0]]})).unwrap(); + assert_eq!((g.series_kind(0), g.series_kind(5)), (GraphKind::Pie, GraphKind::Pie)); +} + +/// The value axis labels: text and bounds of the texts in the Axes group that read as numbers. +fn value_labels(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_geom::Rect)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter_map(|c| match &c.kind { + NodeKind::Text(t) => Some((t.plain_text(), c.geometric_bounds().unwrap())), + _ => None, + }) + .filter(|(t, _)| t.parse::().is_ok()) + .collect() +} + +fn two_scale_graph(s: &mut Session) -> NodeId { + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b\nQ1,10,1000\nQ2,5,500"; + NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ) +} + +#[test] +fn the_value_axis_goes_on_the_left_the_right_or_both_sides() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + let left: Vec = value_labels(&s, id).into_iter().inspect(|(_, b)| assert!(b.x1 < 100.0, "{b:?}")).map(|(t, _)| t).collect(); + assert_eq!(left, ["0", "200", "400", "600", "800", "1000"]); + s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap(); + let right = value_labels(&s, id); + assert!(right.iter().all(|(_, b)| b.x0 > 400.0), "{right:?}"); + assert_eq!(right.iter().map(|(t, _)| t.as_str()).collect::>(), left); + // Both sides, one scale: the same labels on each side, and the legend moves past the right ones. + s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap(); + let both = value_labels(&s, id); + assert_eq!(both.len(), 12); + let edge = both.iter().map(|(_, b)| b.x1).fold(0.0, f64::max); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let swatch = series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap(); + assert!(swatch.x0 > edge, "{swatch:?} vs {edge}"); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both"); + assert!(s.execute("graph.setType", &json!({"valueAxis": "top"})).is_err()); +} + +#[test] +fn separate_scales_give_the_right_axis_series_a_scale_of_their_own() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + // Assigned, but drawn on one scale until separate scales are on with both sides. + assert_eq!(spec(&s, id).right_series, [1]); + s.execute("graph.setType", &json!({"separateScales": true})).unwrap(); + assert!((marks(&s, id, 0)[0].height() - 2.0).abs() < 1e-6, "a's 10 of 1000"); + s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap(); + // a's 10 and b's 1000 both reach the top: 0..10 on the left, 0..1000 on the right. + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!((a[0].y0 - 100.0).abs() < 1e-6 && (b[0].y0 - 100.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]); + let labels = value_labels(&s, id); + let side = |right: bool| labels.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::>(); + assert_eq!(side(false), ["0", "2", "4", "6", "8", "10"]); + assert_eq!(side(true), ["0", "200", "400", "600", "800", "1000"]); + // The right axis' own tick values. + s.execute("graph.setType", &json!({"rightAxisMin": 0, "rightAxisMax": 2000, "rightTicks": 4})).unwrap(); + assert!((marks(&s, id, 1)[0].y0 - 200.0).abs() < 1e-6, "1000 of 2000"); + assert!((marks(&s, id, 0)[0].y0 - 100.0).abs() < 1e-6, "the left axis is unchanged"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["separateScales"].clone(), v["rightTicks"].clone(), v["rightAxisMax"].clone()), (json!(true), json!(4), json!(2000.0))); + // Stacked series stack on their own axis, each axis' stack in a slot of its own. + s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!((a[0].y1 - 300.0).abs() < 1e-6 && (b[0].y1 - 300.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]); + assert!(b[0].x0 >= a[0].x1 - 1e-6 && (a[0].width() - b[0].width()).abs() < 1e-6, "side by side: {:?} {:?}", a[0], b[0]); + // A line on the right axis follows the right scale. + s.execute("graph.setType", &json!({"type": "column"})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + let line = marks(&s, id, 1); + assert!((line[1].center().y - 200.0).abs() < 1e-6 && (line[2].center().y - 250.0).abs() < 1e-6, "1000 and 500 of 2000: {line:?}"); +} + +#[test] +fn group_selected_series_go_on_the_chosen_axis() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "left"); + assert!(s.execute("graph.setType", &json!({"valueAxis": "both"})).is_err(), "a series takes one axis"); + s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.right_series.clone(), g.value_axis), (vec![1], vectorcraft_doc::ValueAxisSide::Left)); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "right"); + s.execute("graph.setType", &json!({"valueAxis": "left"})).unwrap(); + assert!(spec(&s, id).right_series.is_empty()); +} + +#[test] +fn bar_graphs_keep_their_value_axis_along_the_bottom() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "bar"); + let before = (value_labels(&s, id), marks(&s, id, 0)); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err(); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + assert_eq!((value_labels(&s, id), marks(&s, id, 0)), before); +} + +#[test] +fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!( + (g.value_axis, g.separate_scales, g.right_series, g.right_axis_max), + (vectorcraft_doc::ValueAxisSide::Both, true, vec![1], Some(2000.0)) + ); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + for k in ["valueAxis", "separateScales", "rightSeries", "rightTicks", "rightAxisMin", "rightAxisMax"] { + assert!(v.get(k).is_none(), "{k}"); + } +} + +#[test] +fn the_dialog_round_trip_keeps_series_on_both_axes_where_they_are() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b,c\nQ1,10,1000,3\nQ2,5,500,4"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + for side in ["both", "left"] { + s.execute("graph.setType", &json!({"valueAxis": side, "separateScales": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + s.execute("select.set", &json!({"ids": [series(&n, 0).id.0, series(&n, 1).id.0]})).unwrap(); + let fields = s.execute("graph.setType", &json!({})).unwrap(); + assert!(fields["valueAxis"].is_null(), "{side}: {fields}"); + s.execute("graph.setType", &fields).unwrap(); + let g = spec(&s, id); + assert_eq!((g.right_series, g.value_axis.id()), (vec![1], side), "{side}"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + } +} + +#[test] +fn an_axis_with_no_series_shows_the_other_scale() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + let labels = |s: &Session| { + let l = value_labels(s, id); + let side = |right: bool| l.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::>(); + (side(false), side(true)) + }; + let (left, right) = labels(&s); + assert_eq!(left, right); + assert_eq!(right.last().unwrap(), "1000"); + // Every series on the right: the left axis shows the right scale. + s.execute("graph.setType", &json!({"seriesIndexes": [0, 1], "valueAxis": "right", "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); + let (left, right) = labels(&s); + assert_eq!(left, right); + assert_eq!(right.last().unwrap(), "2000"); +} + +#[test] +fn right_axis_columns_start_at_the_right_scales_zero() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",a,b\nQ1,10,-500\nQ2,5,500"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err(); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + // Right scale -600..600 (zero at y 200), left 0..10 (zero at y 300): b's columns meet at the right zero. + let b = marks(&s, id, 1); + assert!((b[0].y0 - 200.0).abs() < 1e-6 && (b[1].y1 - 200.0).abs() < 1e-6, "{b:?}"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let at_200 = group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter(|c| matches!(c.kind, NodeKind::Path { .. })) + .filter_map(|c| c.geometric_bounds()) + .any(|bb| bb.height() < 1e-6 && (bb.y0 - 200.0).abs() < 1e-6 && (bb.width() - 300.0).abs() < 1e-6); + assert!(at_200, "a zero line for the right scale"); +} + +#[test] +fn data_without_a_right_axis_series_drops_its_assignment() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + s.execute("graph.setData", &json!({"csv": ",a\nQ1,10\nQ2,5"})).unwrap(); + assert!(spec(&s, id).right_series.is_empty()); + s.execute("graph.setData", &json!({"csv": ",a,b\nQ1,10,1000\nQ2,5,500"})).unwrap(); + assert!(spec(&s, id).right_series.is_empty(), "a new series starts on the left"); +} + +/// Bounds of the straight lines in the Axes group. +fn axis_lines(s: &Session, id: NodeId) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter(|c| matches!(c.kind, NodeKind::Path { .. })) + .filter_map(|c| c.geometric_bounds()) + .collect() +} + +fn axis_texts(s: &Session, id: NodeId) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter_map(|c| match &c.kind { + NodeKind::Text(t) => Some(t.plain_text()), + _ => None, + }) + .collect() +} + +#[test] +fn value_axis_tick_marks_take_a_length_and_a_count_per_division() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + let short = |s: &Session, x0: f64| axis_lines(s, id).iter().filter(|b| (b.x0 - x0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count(); + let across = |s: &Session| axis_lines(s, id).iter().filter(|b| b.height() < 1e-6 && (b.width() - 300.0).abs() < 1e-6).count(); + // Short (the default): one at each of the six labels; the zero line runs across. + assert_eq!((short(&s, 96.0), across(&s)), (6, 1)); + s.execute("graph.setType", &json!({"tickLength": "full"})).unwrap(); + // Across the plot at 200…1000; the one at 0 would be the zero line again. + assert_eq!((short(&s, 96.0), across(&s)), (0, 6)); + s.execute("graph.setType", &json!({"tickLength": "none"})).unwrap(); + assert_eq!((short(&s, 96.0), across(&s)), (0, 1)); + // Two per division: one more halfway between labels, none past the last label. + s.execute("graph.setType", &json!({"tickLength": "short", "tickMarks": 2})).unwrap(); + assert_eq!(short(&s, 96.0), 11); + assert!(axis_lines(&s, id).iter().any(|b| (b.x0 - 96.0).abs() < 1e-6 && (b.y0 - 280.0).abs() < 1e-6), "100 of 0..1000 sits at y 280"); + assert_eq!(axis_texts(&s, id).iter().filter(|t| t.parse::().is_ok()).count(), 6, "labels only at divisions"); + // The right axis has its own. + s.execute("graph.setType", &json!({"valueAxis": "both", "rightTickLength": "none"})).unwrap(); + assert_eq!((short(&s, 96.0), short(&s, 400.0)), (11, 0)); + s.execute("graph.setType", &json!({"rightTickLength": "short", "rightTickMarks": 4})).unwrap(); + assert_eq!(short(&s, 400.0), 21); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["tickLength"].clone(), v["tickMarks"].clone(), v["rightTickMarks"].clone()), (json!("short"), json!(2), json!(4))); + assert!(s.execute("graph.setType", &json!({"tickLength": "long"})).is_err()); + s.execute("graph.setType", &json!({"tickMarks": 1_000_000})).unwrap(); + assert_eq!(spec(&s, id).tick_marks, 20); +} + +#[test] +fn category_tick_marks_go_at_or_between_the_labels() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + let below = |s: &Session| { + let mut xs: Vec = axis_lines(s, id) + .iter() + .filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6) + .map(|b| b.x0) + .collect(); + xs.sort_by(f64::total_cmp); + xs + }; + // None by default, as before. + assert!(below(&s).is_empty()); + s.execute("graph.setType", &json!({"categoryTickLength": "short"})).unwrap(); + assert_eq!(below(&s), [175.0, 325.0]); + s.execute("graph.setType", &json!({"ticksBetweenLabels": true})).unwrap(); + assert_eq!(below(&s), [100.0, 250.0, 400.0]); + s.execute("graph.setType", &json!({"categoryTickMarks": 2})).unwrap(); + assert_eq!(below(&s), [100.0, 175.0, 250.0, 325.0, 400.0]); + s.execute("graph.setType", &json!({"categoryTickLength": "full"})).unwrap(); + let full = axis_lines(&s, id).iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6 && b.x0 > 100.0 + 1e-6).count(); + assert_eq!(full, 4, "inside the plot: 175, 250, 325 and 400"); + // Bar graphs: the category axis is the left one. + let bar = graph(&mut s, "bar"); + s.execute("graph.setType", &json!({"id": bar.0, "categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap(); + let left = axis_lines(&s, bar).iter().filter(|b| b.height() < 1e-6 && (b.x0 - 96.0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count(); + assert_eq!(left, 4, "three categories, four edges"); +} + +#[test] +fn value_axis_numbers_take_a_prefix_and_a_suffix() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"prefix": "$", "suffix": " M", "valueAxis": "both", "separateScales": true})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right", "rightSuffix": "%"})).unwrap(); + let texts = axis_texts(&s, id); + assert!(texts.contains(&"$10 M".to_string()) && texts.contains(&"$0 M".to_string()), "{texts:?}"); + assert!(texts.contains(&"1000%".to_string()) && !texts.contains(&"$1000 M".to_string()), "{texts:?}"); + // One short line: control characters dropped, the length capped. + s.execute("graph.setType", &json!({"prefix": format!("a\nb{}", "x".repeat(500))})).unwrap(); + let p = spec(&s, id).prefix; + assert!(p.starts_with("ab") && p.chars().count() == 64, "{p}"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["suffix"].clone(), v["rightSuffix"].clone()), (json!(" M"), json!("%"))); +} + +#[test] +fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute( + "graph.setType", + &json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}), + ) + .unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!( + (g.tick_length, g.tick_marks, g.category_tick_length, g.ticks_between_labels, g.suffix.as_str()), + (vectorcraft_doc::TickLength::Full, 3, vectorcraft_doc::TickLength::Short, true, "%") + ); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + for k in [ + "tickLength", + "tickMarks", + "rightTickLength", + "rightTickMarks", + "categoryTickLength", + "categoryTickMarks", + "ticksBetweenLabels", + "prefix", + "suffix", + "rightPrefix", + "rightSuffix", + ] { + assert!(v.get(k).is_none(), "{k}"); + } + // A file's huge values draw within the caps. + let g: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]], "tickMarks": 1_000_000_000u64, "prefix": "y".repeat(10_000)})).unwrap(); + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let art = super::generate(&mut d, &g); + let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap().children().unwrap().to_vec(); + assert!(axes.len() < 200, "{} axis parts", axes.len()); + for t in axes.iter().filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.plain_text()), + _ => None, + }) { + assert!(t.chars().count() < 80, "{} characters", t.chars().count()); + } +} + +#[test] +fn between_labels_ticks_on_an_edge_to_edge_graph_fall_halfway_between_its_labels() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "area"); + s.execute("graph.setType", &json!({"categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap(); + // Labels at 100, 250 and 400 (the plot's edges and middle): ticks at 175 and 325. + let mut xs: Vec = axis_lines(&s, id) + .iter() + .filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6) + .map(|b| b.x0) + .collect(); + xs.sort_by(f64::total_cmp); + assert_eq!(xs, [175.0, 325.0]); +} + +#[test] +fn bar_graph_value_ticks_run_up_the_plot_and_long_labels_push_the_legend() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "bar"); + let legend_x = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap().x0 + }; + let before = legend_x(&s); + assert!((before - 414.0).abs() < 1e-6, "a short last label leaves the legend where it was: {before}"); + s.execute("graph.setType", &json!({"tickLength": "full", "tickMarks": 2})).unwrap(); + let up: Vec<_> = axis_lines(&s, id).into_iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6).collect(); + // -2..6 in 2s: five labels and four halves; the one at 0 is the zero line already, the left end the axis. + assert_eq!(up.len(), 9, "{up:?}"); + s.execute("graph.setType", &json!({"suffix": " million tonnes"})).unwrap(); + let last = value_label_edge(&s, id); + assert!(legend_x(&s) >= last + 6.0 - 1e-6, "{} vs {last}", legend_x(&s)); +} + +/// The right end of the Axes group's texts. +fn value_label_edge(s: &Session, id: NodeId) -> f64 { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter(|c| matches!(c.kind, NodeKind::Text(_))) + .filter_map(|c| c.geometric_bounds()) + .map(|b| b.x1) + .fold(f64::MIN, f64::max) +} + +#[test] +fn counts_come_back_from_the_dialog_as_floats_and_are_capped() { + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + s.execute("graph.setType", &json!({"tickMarks": 3.0, "ticks": 4.0, "categoryTickMarks": 2.6})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.tick_marks, g.ticks, g.category_tick_marks), (3, 4, 3)); + s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300, "rightTicks": -2.0})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0)); + // A file's values come back as drawn. + let mut fields = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!(fields["rightTickMarks"], 20); + fields["tickLength"] = json!("full"); + s.execute("graph.setType", &fields).unwrap(); + assert_eq!(spec(&s, id).tick_length, vectorcraft_doc::TickLength::Full); +} + +#[test] +fn a_long_category_axis_is_ticked_all_the_way_within_the_cap() { + let rows: Vec> = (0..5000).map(|i| vec![i as f64]).collect(); + let g = GraphSpec { rows, category_tick_length: vectorcraft_doc::TickLength::Short, category_tick_marks: 20, ..GraphSpec::default() }; + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let art = super::generate(&mut d, &g); + let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap(); + let ticks: Vec = axes + .children() + .unwrap() + .iter() + .filter_map(|c| c.geometric_bounds()) + .filter(|b| b.width() < 1e-9 && (b.height() - 4.0).abs() < 1e-9) + .map(|b| b.x0) + .collect(); + assert!(ticks.len() <= 10_000 && ticks.len() >= 5000, "{}", ticks.len()); + assert!(ticks.iter().copied().fold(f64::MIN, f64::max) > 199.0, "the last category is ticked"); +} + +/// A graph design: a 10 × 10 rectangle at the back and a 20-wide ellipse over it, saved as `name`. +fn save_design(s: &mut Session, name: &str) { + let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let e = s.execute("shape.ellipse", &json!({"x": 595, "y": 600, "width": 20, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r, e]})).unwrap(); + s.execute("graph.design", &json!({"new": name})).unwrap(); +} + +/// The marks of series `index` (legend swatch last) and their bounds. +fn series_parts(s: &Session, id: NodeId, index: u32) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + series(&n, index).children().unwrap().iter().map(|c| (**c).clone()).collect() +} + +#[test] +fn a_marker_design_draws_each_data_point_sized_by_its_backmost_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); + let id = graph(&mut s, "line"); + let before = series_parts(&s, id, 1); + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + s.execute("select.set", &json!({"ids": [grp.0]})).unwrap(); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + let after = series_parts(&s, id, 1); + assert_eq!(after.len(), before.len(), "a line, three markers and the swatch"); + for (old, new) in before.iter().zip(&after).skip(1) { + let (o, n) = (old.geometric_bounds().unwrap(), new.geometric_bounds().unwrap()); + assert!(matches!(new.kind, NodeKind::Group { .. }), "the design's art"); + // The 10-point rectangle fills the marker's square, the 20-wide ellipse twice as wide. + assert!((n.center() - o.center()).hypot() < 1e-6, "{o:?} {n:?}"); + assert!((n.width() - 2.0 * o.width()).abs() < 1e-6 && (n.height() - o.height()).abs() < 1e-6, "{o:?} {n:?}"); + } + // The series stays selected; the other series keeps its squares; the query names the design. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + assert_eq!(s.execute("graph.marker", &json!({})).unwrap()["design"], "Pill"); + assert_eq!(spec(&s, id).series_markers, [None, Some("Pill".to_string())]); + // Back to the default square. + s.execute("graph.marker", &json!({"design": null})).unwrap(); + assert!(spec(&s, id).series_markers.is_empty()); + assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + assert!(s.execute("graph.marker", &json!({"design": "Nope"})).is_err()); + assert!(s.execute("graph.marker", &json!({"design": 3})).is_err()); +} + +#[test] +fn marker_designs_apply_to_markers_not_to_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + // Every series (none picked): the line series' markers and swatch; the columns and their swatch stay. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + let line = series_parts(&s, id, 1); + assert!(line.iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })), "markers and swatch"); + let swatch = line.last().unwrap().geometric_bounds().unwrap(); + assert!((swatch.width() - 16.0).abs() < 1e-6 && (swatch.height() - 8.0).abs() < 1e-6, "{swatch:?}"); + // Scatter and radar graphs draw them too. + for ty in ["scatter", "radar"] { + let g = graph(&mut s, ty); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + assert!(series_parts(&s, g, 0).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "{ty}"); + } +} + +#[test] +fn graph_designs_are_saved_pasted_and_deleted() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "nothing selected"); + save_design(&mut s, "Pill"); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "the name is taken"); + assert!(s.execute("graph.design", &json!({"new": " "})).is_err(), "no name"); + // Paste Design: a copy of the art, selected, with ids of its own. + let pasted = NodeId(s.execute("graph.design", &json!({"paste": "Pill"})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [pasted]); + let art = s.doc().unwrap().doc.graph_designs[0].art.clone(); + let copy = s.doc().unwrap().doc.node(pasted).unwrap().clone(); + assert_ne!(copy.id, art.id); + assert_eq!(copy.geometric_bounds(), art.geometric_bounds()); + assert!(s.execute("graph.design", &json!({"paste": "Nope"})).is_err()); + // Deleting a design draws its graphs' default markers again; undo brings both back. + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); + assert!(s.doc().unwrap().doc.graph_designs.is_empty()); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(series_parts(&s, id, 0).iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. }))); + assert!(s.execute("graph.design", &json!({"delete": "Nope"})).is_err()); +} + +#[test] +fn designs_and_markers_survive_save_and_open() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); + assert_eq!(spec(&s, id).series_markers, [Some("Pill".to_string())]); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesMarkers").is_none()); + // A design whose art has no area draws the default square. + let g = GraphSpec { kind: GraphKind::Line, series_markers: vec![Some("Dot".into())], ..GraphSpec::default() }; + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) }); + let art = super::generate(&mut d, &g); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); +} + +#[test] +fn a_graph_saved_as_a_design_is_plain_art_even_as_its_own_marker() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.design", &json!({"new": "Chart"})).unwrap(); + let art = s.doc().unwrap().doc.graph_designs[1].art.clone(); + let mut graphs = 0; + art.walk(&mut |n| graphs += usize::from(n.extras().graph.is_some() || n.extras().series_index.is_some())); + assert_eq!(graphs, 0, "no graph inside a design"); + // The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit. + s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap(); + s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); + let mut found = 0; + s.doc().unwrap().doc.walk(|n| found += usize::from(n.extras().graph.is_some())); + assert_eq!(found, 1); + // A deleted design reads as none in the query, so the dialog opens on the default marker. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": null})).unwrap(); + let v = s.execute("graph.marker", &json!({"seriesIndexes": [1, 0]})).unwrap(); + assert!(v["design"].is_null(), "{v}"); +} + +#[test] +fn designs_too_big_for_a_graph_are_refused_and_drawn_as_squares() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // 1,500 objects at each of 200 points is past the budget. + let ids: Vec = (0..1500) + .map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap()) + .collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": "Big"})).unwrap(); + let rows: Vec> = (0..200).map(|i| vec![i as f64]).collect(); + let id = NodeId( + s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + assert!(s.execute("graph.marker", &json!({"id": id.0, "design": "Big"})).is_err()); + assert!(spec(&s, id).series_markers.is_empty()); + // A file asking for it anyway draws the default squares. + let mut g = spec(&s, id); + g.series_markers = vec![Some("Big".into())]; + let mut d = (*s.doc().unwrap().doc).clone(); + let art = super::generate(&mut d, &g); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + // Too many objects for one design. + let ids: Vec = (0..2001) + .map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap()) + .collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + assert!(s.execute("graph.design", &json!({"new": "Huge"})).is_err()); +} + +#[test] +fn a_files_designs_are_tidied() { + let mut d = vectorcraft_doc::Document::new(100.0, 100.0); + let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![])); + let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); + graphy.extras_mut().graph = Some(Box::new(GraphSpec::default())); + for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] { + let a = art(&mut d); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a }); + } + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "G".into(), art: std::sync::Arc::new(graphy) }); + d.tidy_graph_designs(); + let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect(); + assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]); + assert!(d.graph_designs[2].art.extras().graph.is_none()); +} + +#[test] +fn painting_a_series_drawn_with_a_design_leaves_the_design_alone() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"design": "Pill"})).unwrap(); + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); + // The design's paths keep the design's paint, before and after the graph is drawn again. + let design_fill = |s: &Session| { + let parts = series_parts(s, id, 0); + let mut fills = vec![]; + parts[1].walk(&mut |n| { + if let NodeKind::Path { .. } = n.kind { + fills.push(n.appearance.fill_paint().color().map(|c| c.to_hex())); + } + }); + fills + }; + let painted = design_fill(&s); + assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(design_fill(&s), painted); + // A design of one path too. + let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("graph.design", &json!({"new": "Square"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.marker", &json!({"design": "Square"})).unwrap(); + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); + assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]); +} + +#[test] +fn graph_design_params_are_names() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + for p in [json!({"new": 5}), json!({"paste": null}), json!({"delete": ["a"]})] { + assert!(s.execute("graph.design", &p).is_err(), "{p}"); + } +} + +#[test] +fn document_wide_passes_see_design_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [r], "color": "#123456"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("graph.design", &json!({"new": "Blue"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let mut seen = false; + s.doc().unwrap().doc.visit_paints(&mut |p| seen |= p.color().is_some_and(|c| c.to_hex() == "#123456")); + assert!(seen, "the colour is used by the design"); +} + +#[test] +fn new_design_rename_delete_and_select_unused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + // New Design names it "New Design", numbered when that's taken. + assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design"); + assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design 2"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"id": id.0, "design": "New Design"})).unwrap(); + let v = s.execute("graph.design", &json!({"selectUnused": true})).unwrap(); + assert_eq!(v["unused"], json!(["New Design 2"])); + // Renaming a design the graph draws with: the graph follows, still drawing it. + s.execute("graph.design", &json!({"rename": "New Design", "to": "Dot"})).unwrap(); + assert_eq!(spec(&s, id).series_markers, [Some("Dot".to_string()), Some("Dot".to_string())]); + assert!(s.execute("graph.design", &json!({"rename": "Dot", "to": "New Design 2"})).is_err(), "taken"); + assert!(s.execute("graph.design", &json!({"rename": "Nope", "to": "X"})).is_err()); + // Delete several at once. + s.execute("graph.design", &json!({"delete": ["Dot", "New Design 2"]})).unwrap(); + assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!([])); + assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err()); +} + +/// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle +/// over the rest, with a horizontal guide line between them when `guide`, saved as `name`. +fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) { + let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| { + s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap() + }; + let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)]; + ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top)); + if guide { + let line = + s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [line]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + ids.push(line); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": name})).unwrap(); +} + +/// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero +/// line at y = 300. +fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId { + let rows: Vec> = values.iter().map(|v| vec![*v]).collect(); + let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows}); + let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap()); + s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap(); + id +} + +/// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it. +fn frames(n: &vectorcraft_doc::Node) -> Vec { + n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect() +} + +fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool { + [a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6) +} + +#[test] +fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); + let id = one_series(&mut s, "column", &[35.0]); + let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); + assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}"); + s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap(); + // 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value. + let column = series_parts(&s, id, 0)[0].clone(); + let parts = column.children().unwrap(); + assert_eq!(parts.len(), 4, "3.5 designs"); + let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1]; + let copies = frames(&column); + assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}"); + let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) }; + assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy"); + let (clip, copy) = + (children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap()); + assert_eq!(clip.y0, 160.0, "cut off at the value"); + // Its sides and its foot, strokes included, are not cut. + assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}"); + // Nor is the brush art of a brushed path inside a group of the design. + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0)); + let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); + let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0); + for item in &mut look.items { + if let vectorcraft_doc::AppearanceItem::Stroke(st) = item { + st.brush = Some("Wide".into()); + } + } + let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look); + let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]); + let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) }); + let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() }; + let g = GraphSpec { + rows: vec![vec![35.0]], + axis_min: Some(0.0), + axis_max: Some(50.0), + series_columns: vec![Some(options)], + ..GraphSpec::default() + }; + let out = super::generate(&mut d, &g); + let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); + let last = column.children().unwrap().last().unwrap().clone(); + let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) }; + let clip = children[0].geometric_bounds().unwrap(); + let mut reach = None; + children[1].walk(&mut |n| { + if vectorcraft_brush::has_brush(n) { + reach = vectorcraft_render::painted_bounds(&d, n); + } + }); + let reach = reach.unwrap(); + assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}"); + // Scale: the last copy half as tall instead. + s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap(); + let column = series_parts(&s, id, 0)[0].clone(); + let copies = frames(&column); + assert_eq!(copies.len(), 4); + assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}"); + let c = &spec(&s, id).series_columns[0]; + assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale))); + // A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line. + let bars = one_series(&mut s, "bar", &[-15.0]); + s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); + s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap(); + let bar = series_parts(&s, bars, 0)[0].clone(); + let copies = frames(&bar); + // The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250. + let (zero, unit) = (250.0, 3.0); + assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}"); + assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}"); +} + +#[test] +fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // 10 wide and 20 tall, the guide 5 points from the top. + save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true); + let id = one_series(&mut s, "column", &[35.0, 20.0]); + s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap(); + let parts = series_parts(&s, id, 0); + for (column, value_end) in parts.iter().zip([160.0, 220.0]) { + // The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands + // 12 points (2.4 times 5) below the value end. + let halves = column.children().unwrap(); + assert_eq!(halves.len(), 2, "{halves:?}"); + let half = |i: usize| { + let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) }; + (children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec()) + }; + let ((above, kept), (below, stretched)) = (half(0), half(1)); + let at = value_end + 12.0; + assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}"); + assert_eq!(kept.len(), 3, "the guide is not drawn"); + // Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end. + let top = kept[1].geometric_bounds().unwrap(); + assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}"); + // Below it the design is stretched down to the zero line. + let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap()); + assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}"); + assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}"); + } + // Vertically Scaled stretches the whole design, the top part included. + s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap(); + let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec(); + assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6); + // Without a guide a sliding design is scaled vertically too. + save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false); + s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap(); + let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec(); + assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6); + // Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before + // the line, are passed over. + let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let mut ids = vec![ + id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()), + id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()), + ]; + let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap()); + let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap()); + let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap()); + s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + ids.extend([square, tick, line]); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": "Boxed"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap(); + let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec(); + let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}"); +} + +#[test] +fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); + let id = one_series(&mut s, "column", &[35.0, 20.0]); + s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap(); + let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap(); + for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) { + let frame = frames(column)[0]; + assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}"); + assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6); + } + assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column"); + // A design twice as wide as it is tall reaches past the plot, and the legend starts right of it. + save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false); + let wide = one_series(&mut s, "column", &[20.0, 35.0]); + s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap(); + let parts = series_parts(&s, wide, 0); + let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap()); + assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns"); + // The legend swatch shows the design, its frame filling the 8-point square. + let swatch = series_parts(&s, id, 0).last().unwrap().clone(); + let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap(); + assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}"); + // Painting the series leaves the design's paint alone. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap(); + let mut red = false; + s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000")); + assert!(!red); +} + +#[test] +fn column_designs_survive_save_and_open_and_follow_their_design() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); + let id = graph(&mut s, "stackedColumn"); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); + // Every series takes the design; only the stacked column series draws it. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}); + s.execute("graph.column", &p).unwrap(); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch"); + assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series"); + let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap(); + assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"})); + let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale }; + assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none()); + // Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again. + s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap(); + assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token")); + assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([])); + s.execute("graph.design", &json!({"delete": "Token"})).unwrap(); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. }))); + // Null goes back to plain columns. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap(); + assert!(spec(&s, id).series_columns.is_empty()); +} + +#[test] +fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false); + let id = one_series(&mut s, "column", &[35.0]); + for p in [ + json!({"design": "Nope"}), + json!({"design": 3}), + json!({"design": "Coin", "columnType": "diagonal"}), + json!({"design": "Coin", "each": 0}), + json!({"design": "Coin", "each": -1}), + json!({"design": "Coin", "each": "10"}), + json!({"design": "Coin", "fractions": "round"}), + ] { + let mut p = p; + p["id"] = json!(id.0); + assert!(s.execute("graph.column", &p).is_err(), "{p}"); + } + assert!(spec(&s, id).series_columns.is_empty()); + // 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns. + let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001}); + assert!(s.execute("graph.column", &p).is_err()); + let mut g = spec(&s, id); + let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop }; + g.series_columns = vec![Some(options.clone())]; + let mut d = (*s.doc().unwrap().doc).clone(); + let art = super::generate(&mut d, &g); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + // Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each + // 0.1 is past the budget too. + let far = one_series(&mut s, "column", &[0.0, 0.0]); + s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap(); + assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err()); + assert!(spec(&s, far).series_columns.is_empty()); + // A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas: + // 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy. + for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] { + g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })]; + let art = super::generate(&mut d, &g); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + let column = s0.children().unwrap()[0].clone(); + assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}"); + assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}"); + assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped"); + } +} + +/// A column design with totals: a 10 × 10 rectangle at the back, type "%31" set from 2 points above its top, and a +/// group of type "Votes" inside it and type "%02 and %00" set from 4 points below it, with a horizontal guide line 5 +/// points from the top when `guide`, saved as `name`. Returns the points the two totals are set from. +fn save_totals_design(s: &mut Session, name: &str, guide: bool) -> [vectorcraft_geom::Point; 2] { + let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); + let above = id_of(s.execute("text.create", &json!({"x": 600, "y": 598, "text": "%31", "size": 4})).unwrap()); + let votes = id_of(s.execute("text.create", &json!({"x": 601, "y": 606, "text": "Votes", "size": 3})).unwrap()); + let below = id_of(s.execute("text.create", &json!({"x": 600, "y": 614, "text": "%02 and %00", "size": 4})).unwrap()); + let origin = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.xf * vectorcraft_geom::Point::ZERO, + k => panic!("type: {k:?}"), + }; + let points = [origin(s, above), origin(s, below)]; + s.execute("select.set", &json!({"ids": [votes, below]})).unwrap(); + let inner = id_of(s.execute("object.group", &json!({})).unwrap()); + let mut ids = vec![frame, above, inner]; + if guide { + let line = id_of(s.execute("shape.line", &json!({"x1": 600, "y1": 605, "x2": 610, "y2": 605})).unwrap()); + s.execute("select.set", &json!({"ids": [line]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + ids.push(line); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": name})).unwrap(); + points +} + +/// The text of each piece of type inside `n`, in paint order. +fn texts_in(n: &vectorcraft_doc::Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + out.push(t.plain_text()); + } + }); + out +} + +/// The type among `nodes`, each as its text and the point it is set from; each must be type at its own size. +fn totals_of(nodes: &[std::sync::Arc]) -> Vec<(String, vectorcraft_geom::Point)> { + nodes + .iter() + .map(|n| match &n.kind { + NodeKind::Text(t) => { + let [a, b, c, d, x, y] = t.xf.as_coeffs(); + assert_eq!([a, b, c, d], [1.0, 0.0, 0.0, 1.0], "a total keeps its size: {:?}", t.xf); + (t.plain_text(), vectorcraft_geom::Point::new(x, y)) + } + k => panic!("a total: {k:?}"), + }) + .collect() +} + +fn close(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool { + (a - b).hypot() < 1e-6 +} + +#[test] +fn column_design_totals_show_each_columns_value_at_their_own_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let [above, below] = save_totals_design(&mut s, "Ballot", false); + let id = one_series(&mut s, "column", &[35.0, 7.256]); + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot"})).unwrap(); + let check = |s: &Session, want: [[&str; 2]; 2]| { + let parts = series_parts(s, id, 0); + for (column, want) in parts.iter().zip(want) { + let children = column.children().unwrap(); + // The design's copy keeps the type without a total, stretched with the design, and leaves the totals out. + assert_eq!(texts_in(&children[0]), ["Votes"]); + let frame = frames(column)[0]; + let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); + let totals = totals_of(&children[1..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), want); + // Each set from where the design's copy puts the point it was set from. + assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); + } + // The legend swatch keeps the type without a total and leaves the totals out. + assert_eq!(texts_in(parts.last().unwrap()), ["Votes"]); + }; + check(&s, [["035.0", "35.00 and 35"], ["007.3", "7.26 and 7"]]); + // New data draws the new values. + s.execute("graph.setData", &json!({"id": id.0, "rows": [[20], [0.004]]})).unwrap(); + check(&s, [["020.0", "20.00 and 20"], ["000.0", "0.00 and 0"]]); + // The design in the Graph Design list is left as it was. + assert_eq!(texts_in(&s.doc().unwrap().doc.graph_designs[0].art), ["%31", "Votes", "%02 and %00"]); + // A bar below zero shows its minus sign, its totals moved with the design along the bar. + let bars = one_series(&mut s, "bar", &[-15.0]); + s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); + s.execute("graph.column", &json!({"id": bars.0, "design": "Ballot"})).unwrap(); + let bar = series_parts(&s, bars, 0)[0].clone(); + let frame = frames(&bar)[0]; + let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); + let totals = totals_of(&bar.children().unwrap()[1..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["-015.0", "-15.00 and -15"]); + assert!((frame.x1 - 250.0).abs() < 1e-6 && close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); + // A design that is a single piece of type with a total shows the value alone, and its legend swatch is the + // default square. + let one = s.execute("text.create", &json!({"x": 600, "y": 700, "text": "%00", "size": 4})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [one]})).unwrap(); + s.execute("graph.design", &json!({"new": "Count"})).unwrap(); + let count = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": count.0, "design": "Count"})).unwrap(); + let parts = series_parts(&s, count, 0); + assert_eq!(texts_in(&parts[0]), ["35"]); + assert_eq!(totals_of(&parts[0].children().unwrap()[1..]).len(), 1); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); + // Repeating, with a last copy to chop, it still shows the value. + s.execute("graph.column", &json!({"id": count.0, "design": "Count", "columnType": "repeating", "each": 10})).unwrap(); + let column = series_parts(&s, count, 0)[0].clone(); + assert!(matches!(column.kind, NodeKind::Group { .. }) && texts_in(&column) == ["35"], "{:?}", column.kind); +} + +#[test] +fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_beside_its_part() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let [above, below] = save_totals_design(&mut s, "Ballot", true); + let id = one_series(&mut s, "column", &[35.0]); + let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "repeating", "each": 10})).unwrap(); + let column = series_parts(&s, id, 0)[0].clone(); + let children = column.children().unwrap(); + // Three whole copies and the one cut off at the value, none with a total; then the two totals, not cut. + assert_eq!(children.len(), 6, "{children:?}"); + assert!(matches!(children[3].kind, NodeKind::Group { clip: true, .. }), "the last copy is clipped"); + assert!(children[..4].iter().all(|c| texts_in(c) == ["Votes"])); + // Set as in a whole copy that ends at the value: 40 points tall from y 160, as wide as the column. + let sx = plain.width() / 10.0; + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(plain.x0 + (p.x - 600.0) * sx, 160.0 + (p.y - 600.0) * 4.0); + let totals = totals_of(&children[4..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["035.0", "35.00 and 35"]); + assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?}"); + // Sliding: the total above the guide moves with the part kept in proportion at the value end, the one below the + // design with the part stretched down to the zero line. + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "sliding"})).unwrap(); + let column = series_parts(&s, id, 0)[0].clone(); + let children = column.children().unwrap(); + assert_eq!(children.len(), 4, "two halves and two totals: {children:?}"); + let k = (140.0 - 5.0 * sx) / 5.0; + let totals = totals_of(&children[2..]); + let kept = vectorcraft_geom::Point::new(plain.x0 + (above.x - 600.0) * sx, 160.0 + (above.y - 600.0) * sx); + let stretched = vectorcraft_geom::Point::new(plain.x0 + (below.x - 600.0) * sx, 300.0 + (below.y - 610.0) * k); + assert!(close(totals[0].1, kept) && close(totals[1].1, stretched), "{totals:?} {kept:?} {stretched:?}"); + // A total's pattern fill moves with it. + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + let frame = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); + let origin = vectorcraft_geom::Point::new(0.0, -2.0); + let t = vectorcraft_doc::TextObject::point(origin, "%00", CharStyle::default()); + let mut total = vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let dots = Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + total.appearance = vectorcraft_doc::Appearance::basic(dots, Paint::None, 0.0).into(); + let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) }); + let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() }; + let g = GraphSpec { + rows: vec![vec![35.0]], + axis_min: Some(0.0), + axis_max: Some(50.0), + series_columns: vec![Some(options)], + ..GraphSpec::default() + }; + let out = super::generate(&mut d, &g); + let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); + let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0); + assert_eq!(text, "35"); + let Paint::Pattern { xf, .. } = column.children().unwrap()[1].appearance.fill_paint() else { panic!("a pattern fill") }; + assert!((xf * origin - at).hypot() < 1e-6 && (at - origin).hypot() > 1.0, "{xf:?} {at:?}"); +} + +#[test] +fn totals_inside_a_group_with_a_look_of_its_own_show_the_value_where_they_are() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); + let total = id_of(s.execute("text.create", &json!({"x": 600, "y": 605, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [total]})).unwrap(); + let faded = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [faded]})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + s.execute("select.set", &json!({"ids": [frame, faded]})).unwrap(); + s.execute("graph.design", &json!({"new": "Faded"})).unwrap(); + let id = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": id.0, "design": "Faded", "columnType": "repeating", "each": 10})).unwrap(); + // The total stays in its half-opaque group: each of the four copies shows the value there. + let parts = series_parts(&s, id, 0); + let copies = parts[0].children().unwrap(); + assert_eq!(copies.len(), 4, "{copies:?}"); + let mut faded_groups = 0; + for c in copies { + assert_eq!(texts_in(c), ["35"]); + c.walk(&mut |n| faded_groups += usize::from(matches!(n.kind, NodeKind::Group { .. }) && (n.opacity - 0.5).abs() < 1e-6)); + } + assert_eq!(faded_groups, 4); + // The legend swatch shows the design without it. + let swatch = parts.last().unwrap(); + assert!(matches!(swatch.kind, NodeKind::Group { .. }) && texts_in(swatch).is_empty(), "{:?}", swatch.kind); + // Type with a total that clips other art clips it with the value in each copy, and the legend swatch leaves out + // what it clips. + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); + let inside = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); + let mask = id_of(s.execute("text.create", &json!({"x": 600, "y": 658, "text": "%00", "size": 8})).unwrap()); + s.execute("select.set", &json!({"ids": [inside, mask]})).unwrap(); + let clipped = id_of(s.execute("object.clippingMask.make", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [frame, clipped]})).unwrap(); + s.execute("graph.design", &json!({"new": "Masked"})).unwrap(); + let masked = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": masked.0, "design": "Masked"})).unwrap(); + let parts = series_parts(&s, masked, 0); + let clip_groups = |n: &vectorcraft_doc::Node| { + let mut out = vec![]; + n.walk(&mut |c| { + if let NodeKind::Group { clip: true, children } = &c.kind { + out.push(children.first().map(|m| texts_in(m)).unwrap_or_default()); + } + }); + out + }; + assert_eq!(clip_groups(&parts[0]), [["35"]], "the copy's clipping type shows the value"); + let swatch = parts.last().unwrap(); + let mut drawn = 0; + swatch.walk(&mut |n| drawn += usize::from(!matches!(n.kind, NodeKind::Group { .. }))); + assert!(clip_groups(swatch).is_empty() && drawn == 1, "only the frame: {swatch:?}"); + // Totals that are a blend's key objects stay in the blend and show the value there, and the legend swatch leaves + // them out. + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 680, "width": 10, "height": 10})).unwrap()); + let first = id_of(s.execute("text.create", &json!({"x": 600, "y": 685, "text": "%00", "size": 4})).unwrap()); + let second = id_of(s.execute("text.create", &json!({"x": 606, "y": 685, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [first, second]})).unwrap(); + let blend = id_of(s.execute("object.blend.make", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [frame, blend]})).unwrap(); + s.execute("graph.design", &json!({"new": "Blended"})).unwrap(); + let blended = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": blended.0, "design": "Blended"})).unwrap(); + let parts = series_parts(&s, blended, 0); + let mut blends = vec![]; + parts[0].walk(&mut |n| { + if matches!(n.kind, NodeKind::Blend { .. }) { + blends.push(texts_in(n)); + } + }); + assert_eq!(blends, [["35", "35"]], "{:?}", parts[0]); + assert!(texts_in(parts.last().unwrap()).is_empty(), "{:?}", parts.last().unwrap()); + // A design that is that blend alone gets the default legend swatch. + s.execute("select.set", &json!({"ids": [blend]})).unwrap(); + s.execute("graph.design", &json!({"new": "Keys"})).unwrap(); + let keys = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": keys.0, "design": "Keys"})).unwrap(); + let parts = series_parts(&s, keys, 0); + assert_eq!(texts_in(&parts[0]), ["35", "35"]); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); + // A design of a total inside two plain groups shows the value, and its legend swatch is the default square. + let total = id_of(s.execute("text.create", &json!({"x": 700, "y": 700, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [total]})).unwrap(); + let inner = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [inner]})).unwrap(); + let outer = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [outer]})).unwrap(); + s.execute("graph.design", &json!({"new": "Nested"})).unwrap(); + let nested = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": nested.0, "design": "Nested"})).unwrap(); + let parts = series_parts(&s, nested, 0); + assert_eq!(texts_in(&parts[0]), ["35"]); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); +} + +#[test] +fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() { + for (v, before, after, want) in [ + (122.0, 3, 0, "122"), + (122.0, 0, 0, "122"), + (5.0, 3, 1, "005.0"), + (1234.0, 2, 0, "1234"), + (2.5, 0, 0, "3"), + (-2.5, 0, 0, "-3"), + (0.25, 0, 0, "0"), + (0.25, 0, 2, "0.25"), + (1.005, 0, 2, "1.01"), + (0.1 + 0.2, 0, 2, "0.30"), + (9.995, 0, 2, "10.00"), + (99.96, 0, 1, "100.0"), + (-0.004, 0, 2, "0.00"), + (-7.25, 2, 1, "-07.3"), + (7.0, 0, 9, "7.000000000"), + (1e21, 0, 0, "1000000000000000000000"), + ] { + assert_eq!(super::total_text(v, before, after), want, "{v} %{before}{after}"); + } + // Each `%` and two digits, left to right; any other `%` stays. + let style = CharStyle::default; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "%31 and %%12, %1x, 50%", style()); + assert!(super::fill_totals(&mut t, 7.25)); + assert_eq!(t.plain_text(), "007.3 and %7.25, %1x, 50%"); + // A total split between two runs takes the style of its `%`. + let (small, big) = (CharStyle { size: 10.0, ..style() }, CharStyle { size: 20.0, ..style() }); + t.runs = vec![vectorcraft_doc::TextRun::new("a %3", small), vectorcraft_doc::TextRun::new("1 b", big)]; + assert!(super::fill_totals(&mut t, 7.25)); + assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::>(), [("a 007.3", 10.0), (" b", 20.0)]); + // Type without a total is left as it is. + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Votes: 50%", style()); + assert!(!super::fill_totals(&mut t, 7.25)); + assert_eq!(t.plain_text(), "Votes: 50%"); +} + +fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId { + NodeId( + s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ) +} + +/// Where each wedge of series `index` starts: its angle from 12 o'clock, clockwise, in degrees (the legend +/// swatch, last, left out). +fn wedge_starts(s: &Session, id: NodeId, index: u32) -> Vec { + let parts = series_parts(s, id, index); + parts[..parts.len() - 1] + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Path { path, .. } => { + let bp = path.to_bezpath(); + let pts: Vec<_> = bp.elements().iter().filter_map(|e| e.end_point()).collect(); + let (c, p) = (pts.first()?, pts.get(1)?); + let deg = (p.x - c.x).atan2(-(p.y - c.y)).to_degrees(); + Some(if deg < -1e-6 { deg + 360.0 } else { deg }) + } + _ => None, + }) + .collect() +} + +#[test] +fn a_bar_in_a_label_breaks_the_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "column", ",Total|Sales|2023,b\nQ1|first,3,2\nQ2,5,4"); + let texts = axis_texts(&s, id); + assert!(texts.contains(&"Q1\nfirst".to_string()), "{texts:?}"); + // The legend: a label of three lines, and the next row below all three. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let legend: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); + let NodeKind::Text(t) = &legend[0].kind else { panic!() }; + assert_eq!(t.plain_text(), "Total\nSales\n2023"); + let (a, b) = ( + series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(), + series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(), + ); + assert!((b.y0 - a.y0 - 3.0 * 9.0 * 1.8).abs() < 1e-6, "{a:?} {b:?}"); +} + +#[test] +fn transpose_swaps_rows_and_columns_and_switch_xy_swaps_scatter_pairs() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "column", ",a,b\nQ1,1,2\nQ2,3,"); + let before = spec(&s, id); + s.execute("graph.setData", &json!({"transpose": true})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.series.clone(), g.categories.clone()), (vec!["Q1".to_string(), "Q2".into()], vec!["a".to_string(), "b".into()])); + assert_eq!(g.cells(), [[Some(1.0), Some(3.0)], [Some(2.0), None]]); + s.execute("graph.setData", &json!({"transpose": true})).unwrap(); + assert_eq!(spec(&s, id).cells(), before.cells()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).cells(), g.cells()); + assert!(s.execute("graph.setData", &json!({"switchXY": true})).is_err(), "scatter graphs only"); + let sc = make(&mut s, "scatter", ",y1,x1,y2,x2\nP,1,10,2,20"); + s.execute("graph.setData", &json!({"switchXY": true})).unwrap(); + let g = spec(&s, sc); + assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]); + assert_eq!(g.series, ["x1", "y1", "x2", "y2"]); +} + +#[test] +fn pie_wedges_sort_largest_first_in_each_pie_or_in_the_first_pies_order() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b,c\nP1,1,3,2\nP2,3,1,2"); + // None (data order): a starts at 12 o'clock in both pies. + assert_eq!(wedge_starts(&s, id, 0).iter().map(|d| d.round()).collect::>(), [0.0, 0.0]); + s.execute("graph.setType", &json!({"pieSort": "all"})).unwrap(); + // P1: b (3) first; P2: a (3) first. + let (a, b) = (wedge_starts(&s, id, 0), wedge_starts(&s, id, 1)); + assert!(b[0].abs() < 1e-6 && a[1].abs() < 1e-6, "{a:?} {b:?}"); + s.execute("graph.setType", &json!({"pieSort": "first"})).unwrap(); + // P1's order (b, c, a) in both: b first in P2 too. + let b = wedge_starts(&s, id, 1); + assert!(b.iter().all(|d| d.abs() < 1e-6), "{b:?}"); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["pieSort"], "first"); + assert!(s.execute("graph.setType", &json!({"pieSort": "random"})).is_err()); +} + +#[test] +fn pies_sized_by_their_totals_side_by_side_or_stacked() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20"); + let pie = |s: &Session, c: usize| { + let wedges = |i: u32| { + let parts = series_parts(s, id, i); + parts[..parts.len() - 1].to_vec() + }; + let rects: Vec<_> = (0..2).flat_map(wedges).filter_map(|n| n.geometric_bounds()).collect(); + rects.into_iter().skip(c).step_by(2).reduce(|a, b| a.union(b)).unwrap() + }; + let even = (pie(&s, 0), pie(&s, 1)); + assert!((even.0.width() - even.1.width()).abs() < 1e-6); + s.execute("graph.setType", &json!({"piePosition": "ratio"})).unwrap(); + let (small, big) = (pie(&s, 0), pie(&s, 1)); + // Areas in proportion to the totals: 10 and 40, so radii 1 : 2. + assert!((big.width() / small.width() - 2.0).abs() < 1e-6, "{small:?} {big:?}"); + assert!((big.width() - even.1.width()).abs() < 1e-6, "the biggest keeps the full size"); + s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap(); + // Drawn largest first, so the big pie's wedges come first in each series. + let (big, small) = (pie(&s, 0), pie(&s, 1)); + assert!((small.center() - big.center()).hypot() < 1e-6 && (big.width() / small.width() - 2.0).abs() < 1e-6); + // The big pie is drawn first, under the small one. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let wedges: Vec = series(&n, 0).children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.width()).collect(); + assert!(wedges[0] > wedges[1], "{wedges:?}"); +} + +#[test] +fn legends_in_wedges_put_the_series_labels_inside_the_pie() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",North,South|East\nP,1,1"); + s.execute("graph.setType", &json!({"pieLegend": "wedges"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let pie = + series(&n, 0).children().unwrap()[0].geometric_bounds().unwrap().union(series(&n, 1).children().unwrap()[0].geometric_bounds().unwrap()); + let labels: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); + assert_eq!(labels.len(), 2); + for l in &labels { + let b = l.geometric_bounds().unwrap(); + assert!(pie.contains(b.center()), "{b:?} inside {pie:?}"); + } + // No swatches beside the pie: each series group holds only its wedge. + assert_eq!((series(&n, 0).children().unwrap().len(), series(&n, 1).children().unwrap().len()), (1, 1)); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["pieLegend"].clone(), v["piePosition"].clone()), (json!("wedges"), json!("even"))); + assert!(v.get("legend").is_none(), "a pie's Legend stands for the checkbox"); + // The labels are drawn over the wedges, and on the black first wedge in white. + let order: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect(); + assert_eq!(order.last().map(String::as_str), Some("Legend"), "{order:?}"); + let colour = |l: &vectorcraft_doc::Node| match &l.kind { + NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(), + _ => panic!(), + }; + assert_eq!((colour(&labels[0]), colour(&labels[1])), ("#ffffff".to_string(), "#000000".to_string())); + // No Legend wins over a legend checkbox sent along (as the dialog does), and Standard comes back without wedges. + s.execute("graph.setType", &json!({"pieLegend": "none", "legend": true})).unwrap(); + assert!(!spec(&s, id).legend); + s.execute("graph.setType", &json!({"legend": true})).unwrap(); + assert!(!spec(&s, id).pie_legend_in_wedges); + assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err()); + let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft"); + s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap(); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!( + (g.pie_legend_in_wedges, g.pie_position, g.pie_sort), + (true, vectorcraft_doc::PiePosition::Stacked, vectorcraft_doc::PieSort::All) + ); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + for k in ["pieLegendInWedges", "piePosition", "pieSort"] { + assert!(v.get(k).is_none(), "{k}"); + } +} + +#[test] +fn stacked_pies_are_rings_so_none_covers_another() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20\nSame,20,20"); + s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + // Series b's wedges: of the two equal big pies one is left out, the other a ring outside the small pie + // (clear of the centre), and the small pie's whole. + let b: Vec<_> = series(&n, 1).children().unwrap().iter().map(|c| (**c).clone()).collect(); + assert_eq!(b.len(), 3, "two wedges and the swatch"); + let small = b[1].geometric_bounds().unwrap(); + let NodeKind::Path { path, .. } = &b[0].kind else { panic!() }; + let c = small.center(); + let near: Vec = path.to_bezpath().elements().iter().filter_map(|e| e.end_point()).map(|p| (p - c).hypot()).collect(); + let min = near.iter().copied().fold(f64::MAX, f64::min); + assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}"); + // The names are drawn over the pies: after every series group. + let kids = n.children().unwrap(); + let last_series = kids.iter().rposition(|c| c.extras().series_index.is_some()).unwrap(); + let names: Vec = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); + assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name"); + assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}"); +} + +#[test] +fn transpose_keeps_within_the_series_cap_and_drops_stale_axis_series() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let rows: Vec> = (0..300).map(|i| vec![i as f64]).collect(); + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + assert!(s.execute("graph.setData", &json!({"transpose": true})).is_err(), "300 categories can't become series"); + assert_eq!(spec(&s, id).cells().len(), 300); + let g = make(&mut s, "column", ",a,b,c\nQ1,1,2,3"); + s.execute("graph.setType", &json!({"seriesIndexes": [2], "valueAxis": "right"})).unwrap(); + s.execute("graph.setData", &json!({"transpose": true})).unwrap(); + assert!(spec(&s, g).right_series.is_empty(), "one series left"); + // An all-blank series becomes no category. + let h = make(&mut s, "column", ",a,b\nQ1,1,\nQ2,2,"); + s.execute("graph.setData", &json!({"transpose": true})).unwrap(); + assert_eq!(spec(&s, h).categories, ["a", "b"], "b keeps its label"); + assert!(s.execute("graph.setData", &json!({"id": h.0, "transpose": false})).unwrap().get("csv").is_some(), "no change: the query"); +} + +#[test] +fn switch_xy_moves_the_labels_with_their_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "scatter", ",y1,x1,y2\nP,1,10,2,20"); + s.execute("graph.setData", &json!({"switchXY": true})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.series, ["x1", "y1", "", "y2"]); + assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]); +} + +#[test] +fn a_label_of_only_bars_is_blank_and_non_pie_graphs_list_no_pie_options() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "column", ",||,b\nQ1,1,2"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert_eq!(group(&n, "Legend").children().unwrap().len(), 1, "only b"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert!(v.get("pieLegend").is_none() && v.get("legend").is_some()); + // Bar and radar labels with a `|` centre their lines on the category. + for ty in ["bar", "radar"] { + let g = make(&mut s, ty, ",a\nOne|Two|Three,1\nQ2,2\nQ3,3"); + assert!(axis_texts(&s, g).contains(&"One\nTwo\nThree".to_string()), "{ty}"); + } +} + +#[test] +fn drop_shadows_sit_behind_the_columns_lines_and_wedges() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let kids = n.children().unwrap(); + let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap(); + assert!(kids.iter().position(|c| c.extras().series_index.is_some()).unwrap() > at, "behind the series"); + let shadow = &kids[at]; + assert!((shadow.opacity - 0.35).abs() < 1e-6); + let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); + let cols: Vec<_> = (0..2).flat_map(|i| marks(&s, id, i)).collect(); + assert_eq!(copies.len(), cols.len(), "one per column, none for the swatches"); + for c in &cols { + assert!(copies.iter().any(|b| (b.x0 - c.x0 - 2.0).abs() < 1e-6 && (b.y0 - c.y0 - 2.0).abs() < 1e-6), "{c:?}"); + } + let black = shadow.children().unwrap().iter().all(|c| c.appearance.fill_paint().color().map(|c| c.to_hex()) == Some("#000000".into())); + assert!(black); + // Lines cast one too (not their markers); pies one per wedge. + let line = graph(&mut s, "line"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(line).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 2, "one per series line"); + let pie = graph(&mut s, "pie"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 5, "the wedges with a value above 0"); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["dropShadow"], true); + s.execute("graph.setType", &json!({"dropShadow": false})).unwrap(); + let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow"))); +} + +#[test] +fn the_legend_across_top_runs_in_rows_above_the_plot() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let swatch = |s: &Session, i: u32| series_parts(s, id, i).last().unwrap().geometric_bounds().unwrap(); + let (a, b) = (swatch(&s, 0), swatch(&s, 1)); + assert!(a.y1 <= 100.0 - 8.0 + 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && b.x0 > a.x1 && (a.x0 - 100.0).abs() < 1e-6, "{a:?} {b:?}"); + assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["legendAcrossTop"], true); + // Long labels wrap into rows within the plot's width, the last row just above it. + let labels: Vec = (0..6).map(|i| format!("A rather long series name {i}")).collect(); + let csv = format!(",{}\nQ1,1,2,3,4,5,6", labels.join(",")); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let ys: Vec = (0..6).map(|i| swatch(&s, i).y0).collect(); + assert!(ys.windows(2).any(|w| w[1] > w[0]), "a second row: {ys:?}"); + assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}"); + let xs: Vec = (0..6).map(|i| swatch(&s, i).x1).collect(); + assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}"); + let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert!(g.legend_across_top && g.drop_shadow); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + assert!(v.get("dropShadow").is_none() && v.get("legendAcrossTop").is_none()); +} + +#[test] +fn each_layer_keeps_its_shadows_just_behind_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "column", ",a,b,c\nQ1,3,2,1\nQ2,5,,2\nQ3,4,6,3"); + s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); + s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let order: Vec = n + .children() + .unwrap() + .iter() + .map(|c| c.extras().series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))) + .collect(); + // Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns + // and the line. + let at = |k: &str| order.iter().position(|o| o == k).unwrap(); + let shadows: Vec = order.iter().enumerate().filter(|(_, o)| *o == "Drop Shadow").map(|(i, _)| i).collect(); + assert_eq!(shadows.len(), 2, "{order:?}"); + assert!(at("s0") < shadows[0] && shadows[0] < at("s1") && at("s1") < shadows[1] && shadows[1] < at("s2"), "{order:?}"); + // b's blank leaves two columns; a line with a blank casts a shadow per run. + let line = make(&mut s, "line", ",a\nQ1,1\nQ2,\nQ3,3\nQ4,4"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(line).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1, "Q1 alone draws no line; Q3–Q4 one"); + // Scatter and radar lines cast one too; a stacked segment of 0 doesn't. + for (ty, want) in [("scatter", 1), ("radar", 2)] { + let g = graph(&mut s, ty); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(g).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), want, "{ty}"); + } + let st = make(&mut s, "stackedColumn", ",a,b\nQ1,0,2"); + s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); + let n = s.doc().unwrap().doc.node(st).unwrap().clone(); + assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1); +} + +#[test] +fn the_legend_across_top_keeps_above_labels_over_the_plot() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "radar", ",a,b\nTop|of|chart,1,2\nQ2,2,3\nQ3,3,1"); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let axes_top = group(&n, "Axes").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y0).fold(f64::MAX, f64::min); + let legend_bottom = + group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y1).fold(f64::MIN, f64::max); + assert!(legend_bottom < axes_top, "{legend_bottom} above {axes_top}"); + // No legend: nothing across the top either. + s.execute("graph.setType", &json!({"legend": false})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Legend"))); + // Labels of a row other than the last stay within the plot's width. + let labels: Vec = (0..6).map(|i| format!("A rather long series name {i}")).collect(); + let col = make(&mut s, "column", &format!(",{}\nQ1,1,2,3,4,5,6", labels.join(","))); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let n = s.doc().unwrap().doc.node(col).unwrap().clone(); + let texts: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); + let last_row = texts.iter().map(|b| b.y0).fold(f64::MIN, f64::max); + assert!(texts.iter().filter(|b| b.y0 < last_row - 1e-6).all(|b| b.x1 <= 400.0 + 1e-6), "{texts:?}"); +} + +#[test] +fn widths_past_100_overlap_and_the_first_row_or_column_goes_in_front() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"columnWidth": 250, "clusterWidth": 300})).unwrap(); + let g = spec(&s, id); + assert_eq!((g.column_width, g.cluster_width), (250.0, 300.0)); + let (a, b) = (marks(&s, id, 0), marks(&s, id, 1)); + assert!(a[0].x1 > b[0].x0, "the columns of a cluster overlap: {:?} {:?}", a[0], b[0]); + assert!(b[0].x1 > a[1].x0, "and so do the clusters: {:?} {:?}", b[0], a[1]); + assert_eq!(s.execute("graph.setType", &json!({"clusterWidth": 5000})).map(|_| spec(&s, id).cluster_width).unwrap(), 1000.0); + let order = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.extras().series_index).collect() + }; + assert_eq!(order(&s), [0, 1]); + s.execute("graph.setType", &json!({"firstColumnInFront": true})).unwrap(); + assert_eq!(order(&s), [1, 0], "the first series drawn last, on top"); + // First Row in Front: each series' first category drawn last. + let xs = |s: &Session| marks(s, id, 0).iter().map(|b| b.x0).collect::>(); + let before = xs(&s); + assert!(before.windows(2).all(|w| w[0] < w[1])); + s.execute("graph.setType", &json!({"firstRowInFront": true})).unwrap(); + let after = xs(&s); + assert!(after.windows(2).all(|w| w[0] > w[1]), "{after:?}"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert_eq!((v["firstRowInFront"].clone(), v["firstColumnInFront"].clone()), (json!(true), json!(true))); +} + +#[test] +fn filled_lines_are_bands_in_the_series_colour() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "line", ",a\nQ1,2\nQ2,2\nQ3,2"); + let thin = marks(&s, id, 0)[0]; + assert!(thin.height() < 1e-6, "a flat 1 pt line"); + s.execute("graph.setType", &json!({"filledLines": true})).unwrap(); + let parts = series_parts(&s, id, 0); + let band = parts[0].geometric_bounds().unwrap(); + assert!((band.height() - 3.0).abs() < 1e-6 && (band.width() - thin.width()).abs() < 1e-6, "{band:?}"); + assert_eq!(parts[0].appearance.fill_paint().color().unwrap().to_hex(), "#000000", "the first series' colour"); + assert_eq!(parts[0].appearance.stroke_width(), 0.25); + // A series colour set with Group Selection fills the band. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#ff0000"})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [2]]})).unwrap(); + assert_eq!(series_parts(&s, id, 0)[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + let path = vectorcraft_testkit::temp_dir("graph-filled").join("filled.vectorcraft"); + s.execute("graph.setType", &json!({"firstRowInFront": true, "firstColumnInFront": true})).unwrap(); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert!(g.filled_lines && g.first_row_in_front && g.first_column_in_front); + let v = serde_json::to_value(GraphSpec::default()).unwrap(); + for k in ["filledLines", "firstRowInFront", "firstColumnInFront"] { + assert!(v.get(k).is_none(), "{k}"); + } +} + +#[test] +fn filled_lines_keep_the_line_colour_and_fall_back_to_thin_lines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2"); + // A colour given to the thin line (its stroke) fills the band. + let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("paint.setStroke", &json!({"ids": [grp.0], "color": "#00ff00"})).unwrap(); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let band = &series_parts(&s, id, 0)[0]; + assert_eq!(band.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); + // A radar series of zeros has every point at the centre: no band to make, so a thin line. + let radar = make(&mut s, "radar", ",a\nQ1,0\nQ2,0\nQ3,0"); + s.execute("graph.setType", &json!({"id": radar.0, "filledLines": true})).unwrap(); + let line = &series_parts(&s, radar, 0)[0]; + assert!(line.appearance.fill_paint().is_none(), "a stroked line"); + // Past the budget, thin lines. + let rows: Vec> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect(); + let big = NodeId( + s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"id": big.0, "filledLines": true, "markPoints": false})).unwrap(); + assert!(series_parts(&s, big, 0)[0].appearance.fill_paint().is_none()); +} + +#[test] +fn overlapping_columns_keep_clear_of_the_legend_and_options_follow_the_graph_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"columnWidth": 1000, "clusterWidth": 1000})).unwrap(); + let reach = (0..2).flat_map(|i| marks(&s, id, i)).map(|b| b.x1).fold(f64::MIN, f64::max); + assert!(reach > 400.0, "past the plot: {reach}"); + let swatch = series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(); + assert!(swatch.x0 >= reach + 14.0 - 1e-6, "{swatch:?} after {reach}"); + let v = s.execute("graph.setType", &json!({})).unwrap(); + assert!(v.get("firstRowInFront").is_some() && v.get("filledLines").is_none()); + let line = graph(&mut s, "line"); + let v = s.execute("graph.setType", &json!({"id": line.0})).unwrap(); + assert!(v.get("firstRowInFront").is_none() && v.get("filledLines").is_some()); +} + +/// The Axes and Legend texts with their size and fill, after the graph is drawn again. +fn label_looks(s: &mut Session, id: NodeId) -> Vec<(String, f64, String)> { + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for g in ["Axes", "Legend"] { + for c in group(&n, g).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + let st = &t.runs[0].style; + out.push((t.plain_text(), st.size, st.fill.color().map(|c| c.to_hex()).unwrap_or_default())); + } + } + } + out +} + +fn text_id(s: &Session, id: NodeId, grp: &str, text: &str) -> NodeId { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + group(&n, grp).children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap().id +} + +#[test] +fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 12})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().all(|(_, size, _)| (*size - 12.0).abs() < 1e-6), "{looks:?}"); + let g = spec(&s, id); + assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_some() && g.label_styles.legend.is_some()); + // One undo takes the data change back, the next the formatting and the kept styles together. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).label_styles.values.is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default()); +} + +#[test] +fn formatting_one_label_keeps_that_kind_of_label() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // A legend label (Group Selection), recoloured with the Character panel's fill. + let l = text_id(&s, id, "Legend", "2025"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setStyle", &json!({"fill": "#ff0000"})).unwrap(); + // A category label, resized. + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + // A value axis number, painted with the Appearance fill. + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setFill", &json!({"ids": [v.0], "color": "#0000ff"})).unwrap(); + let looks = label_looks(&mut s, id); + let look = |t: &str| looks.iter().find(|(x, _, _)| x == t).cloned().unwrap(); + assert_eq!(look("2024"), ("2024".into(), 9.0, "#ff0000".into()), "every legend label"); + assert_eq!(look("Q1").1, 14.0, "every category label"); + assert_eq!(look("Q1").2, "#000000"); + assert_eq!(look("0").2, "#0000ff", "every value axis number"); + assert_eq!(look("0").1, 9.0); + // Kept through save and open; nothing written when unset. + let path = vectorcraft_testkit::temp_dir("graph-labels").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 14.0); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); +} + +#[test] +fn pie_names_and_wedge_labels_keep_their_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nP1,1,2"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let name = n.children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "P1")).unwrap().id; + s.execute("select.set", &json!({"ids": [name.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 16})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 16.0); + s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); + let w = text_id(&s, id, "Legend", "a"); + s.execute("select.set", &json!({"ids": [w.0]})).unwrap(); + s.execute("text.setStyle", &json!({"fill": "#00ff00"})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let fills: Vec = group(&n, "Legend") + .children() + .unwrap() + .iter() + .filter_map(|c| match &c.kind { + NodeKind::Text(t) => t.runs[0].style.fill.color().map(|c| c.to_hex()), + _ => None, + }) + .collect(); + assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge"); +} + +#[test] +fn labels_are_told_apart_by_where_they_come_not_by_their_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + // Categories named like the value axis numbers. + let id = make(&mut s, "column", ",a\n2,1\n4,3\n6,5"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let texts: Vec<(NodeId, String)> = group(&n, "Axes") + .children() + .unwrap() + .iter() + .filter_map(|c| match &c.kind { + NodeKind::Text(t) => Some((c.id, t.plain_text())), + _ => None, + }) + .collect(); + // The value axis "2" comes before the category "2". + let value_two = texts.iter().find(|(_, t)| t == "2").unwrap().0; + s.execute("select.set", &json!({"ids": [value_two.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 7})).unwrap(); + let g = spec(&s, id); + assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none(), "{:?}", g.label_styles); + // A scatter graph's numbers are all values. + let sc = graph(&mut s, "scatter"); + s.execute("select.set", &json!({"ids": [sc.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 8})).unwrap(); + let g = spec(&s, sc); + assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none()); + // Part of a label's text: nothing kept. + let col = graph(&mut s, "column"); + let q = text_id(&s, col, "Axes", "Q2"); + s.execute("select.set", &json!({"ids": [q.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 20, "start": 0, "end": 1})).unwrap(); + assert!(spec(&s, col).label_styles.categories.is_none()); + // Only the change is kept: a size given to the whole graph leaves the label colours as they were. + let pie = make(&mut s, "pie", ",a,b\nP,1,1"); + s.execute("graph.setType", &json!({"id": pie.0, "pieLegend": "wedges"})).unwrap(); + s.execute("select.set", &json!({"ids": [pie.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 11})).unwrap(); + let legend = spec(&s, pie).label_styles.legend.unwrap(); + assert_eq!((legend.size, legend.fill.color().unwrap().to_hex()), (11.0, "#000000".into())); + s.execute("graph.setData", &json!({"id": pie.0, "rows": [[2, 1]]})).unwrap(); + let n = s.doc().unwrap().doc.node(pie).unwrap().clone(); + let fill = match &group(&n, "Legend").children().unwrap()[0].kind { + NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(), + _ => panic!(), + }; + assert_eq!(fill, "#ffffff", "still white on the black wedge"); +} + +#[test] +fn character_formats_are_kept_and_bigger_labels_space_out() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setFormat", &json!({"underline": true, "baselineShift": 1})).unwrap(); + let g = spec(&s, id); + let st = g.label_styles.legend.unwrap(); + assert!(st.underline && (st.baseline_shift - 1.0).abs() < 1e-9); + // 20 pt legend labels: the rows move apart so they don't overlap. + s.execute("text.setStyle", &json!({"size": 20})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",a,b,c\nQ1,1,2,3"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut rows: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); + rows.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + assert!(rows.windows(2).all(|w| w[1].y0 >= w[0].y1 - 1e-6), "{rows:?}"); + // A file's absurd style is kept within the Character limits. + let mut g = spec(&s, id); + g.label_styles.values = Some(CharStyle { size: 1e300, tracking: f64::INFINITY, ..CharStyle::default() }); + let mut d = (*s.doc().unwrap().doc).clone(); + let art = super::generate(&mut d, &g); + let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap(); + for t in axes.children().unwrap() { + if let NodeKind::Text(t) = &t.kind { + assert!(t.runs[0].style.size <= 1296.0 && t.runs[0].style.tracking.is_finite()); + } + } +} From 6aa67a5736774e853306811993e576d9b694c922 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:17 +0000 Subject: [PATCH 014/199] refactor(ui-egui): move canvas.rs tests to canvas/tests.rs --- crates/ui-egui/src/canvas.rs | 7185 +--------------------------- crates/ui-egui/src/canvas/tests.rs | 7182 +++++++++++++++++++++++++++ 2 files changed, 7183 insertions(+), 7184 deletions(-) create mode 100644 crates/ui-egui/src/canvas/tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 089966777..ed1be0766 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4000,7187 +4000,4 @@ const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner"; const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth"; #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_engine::Session; - use vectorcraft_geom::Shape as _; - - /// A rendered raster becomes the image egui's own conversion makes of it, half-transparent - /// edges and all; one whose bytes don't match its size becomes an empty image of that size. - #[test] - fn rendered_rasters_become_the_same_egui_images() { - let mut s = Session::new(); - s.execute("file.new", &serde_json::json!({})).unwrap(); - s.execute("shape.ellipse", &serde_json::json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap(); - s.execute("paint.setFill", &serde_json::json!({"color": "#3366cc"})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let img = vectorcraft_render::Renderer::new().render(&doc, 97, 61, Affine::IDENTITY, &vectorcraft_render::RenderOptions::default()); - assert!(img.pixels.as_chunks::<4>().0.iter().any(|p| p[3] != 0 && p[3] != 255), "partly covered pixels"); - let egui = egui::ColorImage::from_rgba_premultiplied([97, 61], &img.pixels); - assert_eq!(color_image(&img), egui); - let short = vectorcraft_render::Rendered { width: 3, height: 2, pixels: vec![255; 5] }; - assert_eq!(color_image(&short), egui::ColorImage::new([3, 2], vec![egui::Color32::TRANSPARENT; 6])); - } - - /// With VECTORCRAFT_SYSTEM_CURSORS set the glyphs aren't drawn and the system's cursors stand in: those of - /// the Pen and the other drawing tools are the system crosshair already, so Use Precise Cursors makes no - /// difference to them there. - #[test] - fn system_cursors_show_the_same_crosshair_with_precise_cursors_on_or_off() { - for c in [ - Cursor::Pen, - Cursor::PenDrawing, - Cursor::PenAdd, - Cursor::PenDelete, - Cursor::PenClose, - Cursor::PenContinue, - Cursor::PenJoin, - Cursor::PenConvert, - Cursor::Eyedropper, - Cursor::Slice, - Cursor::Blend, - Cursor::BlendObject, - Cursor::BlendAnchor, - Cursor::ShapeBuilder, - Cursor::ShapeBuilderErase, - Cursor::PencilNew, - Cursor::PencilStraight, - Cursor::PencilContinue, - ] { - assert_eq!(cursor_icon(c), egui::CursorIcon::Crosshair, "{c:?}"); - assert_eq!(cursor_icon(c), cursor_icon(c.precise()), "{c:?}"); - } - } - - /// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one - /// reads x where the canvas has it. - #[test] - fn rulers_cover_their_length_at_any_rotation() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let full = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); - let [top, left, _] = ruler_rects(full); - for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] { - let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() }; - let scale = RulerScale::document(Unit::Points, Point::ZERO); - let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); - out.textures_delta.clear(); - // The ticks' positions along each ruler. - let along = |ruler: egui::Rect, x: bool| -> Vec { - let mut at: Vec = out - .shapes - .iter() - .filter(|c| c.clip_rect == ruler) - .filter_map(|c| match &c.shape { - Shape::LineSegment { points: [a, b], .. } if x && a.x == b.x => Some(a.x), - Shape::LineSegment { points: [a, b], .. } if !x && a.y == b.y => Some(a.y), - _ => None, - }) - .collect(); - at.sort_by(f32::total_cmp); - at - }; - for (ruler, x) in [(top, true), (left, false)] { - let at = along(ruler, x); - let (start, end) = if x { (ruler.left(), ruler.right()) } else { (ruler.top(), ruler.bottom()) }; - let (Some(first), Some(last)) = (at.first(), at.last()) else { panic!("{deg}°: no ticks") }; - assert!(first - start < 20.0 && end - last < 20.0, "{deg}°: ticks from {first} to {last} on {start}..{end}"); - assert!(at.windows(2).all(|w| w[1] - w[0] < 20.0), "{deg}°: a gap in the ticks"); - } - if deg == 0.0 { - let origin = xf.to_screen(Point::ZERO).x; - assert!(along(top, true).iter().any(|x| (x - origin).abs() < 0.01), "a tick at x = 0"); - } - } - } - - #[test] - fn ruler_origin_zero_ticks_follow_the_origin_at_any_rotation_and_unit() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let [top, left, _] = ruler_rects(full); - let origin = Point::new(350.25, 250.5); - for deg in [0.0_f64, 30.0, 90.0, 200.0] { - for unit in [Unit::Points, Unit::Millimeters] { - let xf = Xf { rect: full, zoom: 1.7, center: Point::new(300.0, 200.0), rot: deg.to_radians() }; - let scale = RulerScale::document(unit, origin); - let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); - out.textures_delta.clear(); - let at = xf.to_screen(origin); - for (r, horizontal) in [(top, true), (left, false)] { - let zeros: Vec<_> = out - .shapes - .iter() - .filter(|c| c.clip_rect == r) - .filter_map(|c| match &c.shape { - Shape::Text(t) if t.galley.text() == "0" => Some(t.pos), - _ => None, - }) - .collect(); - // Left-ruler labels are separate digits: a zero in "100" isn't the zero label. - assert!( - zeros.iter().any(|zero| { - let delta = if horizontal { zero.x - 2.0 - at.x } else { zero.y - 2.0 - at.y }; - delta.abs() < 0.02 - }), - "{deg}°, {unit:?}: zeros at {zeros:?}, origin {at:?}" - ); - } - } - } - } - - /// View › Rulers › Show Video Rulers counts the artboard in video pixels from its top-left - /// corner, a pixel as wide as the artboard's Video Ruler Pixel Aspect Ratio. At 0.9 a - /// 720 × 480 pt artboard is 800 pixels wide and 480 tall: the top ruler reads 0 at its left - /// edge and 800 at its right, labelled every 100 pixels (90 points on screen at 100%), and the - /// left ruler counts a pixel a point, from 0 at the top edge to a tick at the bottom one. At 1 - /// (square pixels) the artboard is 720 pixels wide. - #[test] - fn video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 720, "height": 480})).unwrap(); - s.execute("artboard.setProps", &json!({"index": 0, "videoRulerPixelAspectRatio": 0.9})).unwrap(); - let ab = s.active().unwrap().doc.artboards[0].clone(); - let r = ab.rect; - let scale = RulerScale::video(&ab); - let (x, y) = (vectorcraft_geom::Vec2::new(1.0, 0.0), vectorcraft_geom::Vec2::new(0.0, 1.0)); - let close = |a: f64, b: f64| (a - b).abs() < 1e-9; - assert_eq!((scale.unit, scale.origin), (Unit::Pixels, Point::new(r.x0, r.y0))); - assert!(close(scale.along(Point::new(r.x1, r.y1), x), 800.0) && close(scale.along(Point::new(r.x1, r.y1), y), 480.0)); - assert!(close(scale.along(Point::new(r.x0 + 9.0, r.y0), x), 10.0), "a pixel is 0.9 pt wide"); - assert!(close(scale.zoom_along(2.0, x), 1.8) && close(scale.zoom_along(2.0, y), 2.0)); - let square = RulerScale { stretch: [1.0, 1.0], ..scale }; - assert!(close(square.along(Point::new(r.x1, r.y0), x), 720.0)); - // Drawn at 100%. - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let [top, left, _] = ruler_rects(full); - let xf = Xf { rect: full, zoom: 1.0, center: r.center(), rot: 0.0 }; - let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); - out.textures_delta.clear(); - let labels = |ruler: egui::Rect| -> Vec<(String, Pos2)> { - out.shapes - .iter() - .filter(|c| c.clip_rect == ruler) - .filter_map(|c| match &c.shape { - Shape::Text(t) => Some((t.galley.text().to_string(), t.pos)), - _ => None, - }) - .collect() - }; - let (l, rgt) = (xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1))); - let at = |labels: &[(String, Pos2)], text: &str, x: f32| labels.iter().any(|(t, p)| t == text && (p.x - 2.0 - x).abs() < 0.02); - let tops = labels(top); - assert!(at(&tops, "0", l.x) && at(&tops, "800", rgt.x), "{tops:?} from {l:?} to {rgt:?}"); - assert!(at(&tops, "100", l.x + 90.0) && !tops.iter().any(|(t, _)| t == "50"), "{tops:?}"); - assert!(!tops.iter().any(|(t, _)| t == "720"), "points aren't pixels: {tops:?}"); - let lefts = labels(left); - assert!(lefts.iter().any(|(t, p)| t == "0" && (p.y - 2.0 - l.y).abs() < 0.02), "{lefts:?}"); - let ticks: Vec = out - .shapes - .iter() - .filter(|c| c.clip_rect == left) - .filter_map(|c| match &c.shape { - Shape::LineSegment { points: [a, b], .. } if a.y == b.y => Some(a.y), - _ => None, - }) - .collect(); - assert!(ticks.iter().any(|y| (y - rgt.y).abs() < 0.02), "a tick at 480 px: {ticks:?}"); - } - - #[test] - fn shaper_selection_highlights_only_the_visible_result() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap(); - let line = vectorcraft_doc::NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap()); - for x in [0.0, 150.0] { - s.execute("shaper.scribble", &json!({"points":[[60.0+x,140],[70.0+x,160],[80.0+x,140],[90.0+x,160]]})).unwrap(); - } - let st = s.active().unwrap(); - let g = st.doc.node(st.selection.objects[0]).unwrap(); - assert!(g.extras().shaper.is_some()); - let symbols = st.doc.symbol_boxes(); - let bounds = node_outline(g, &symbols).bounding_box(); - assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}"); - assert_eq!(node_outline(st.doc.node(line).unwrap(), &symbols).bounding_box().x0, 50.0, "an isolated original still shows its full outline"); - } - - /// A clip group made from scratch: a 320 × 40 image and a 300 × 200 ellipse, both reaching - /// outside a 100 × 100 square at (100, 100) that clips them, and a rectangle beside them. The - /// task bar and rulers are off. → the app, the clip group, the ellipse and the rectangle. - fn clip_group_app() -> (VectorcraftApp, serde_json::Value, serde_json::Value, serde_json::Value) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let img = image::RgbaImage::from_pixel(320, 40, image::Rgba([200, 120, 40, 255])); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let data = vectorcraft_format::base64_encode(&png); - app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap(); - let image = json!(app.session.active().unwrap().selection.objects[0].0); - let ellipse = app.run("shape.ellipse", json!({"x": 0, "y": 50, "width": 300, "height": 200})).unwrap()["id"].clone(); - let square = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [image, ellipse, square]})).unwrap(); - let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); - let beside = app.run("shape.rectangle", json!({"x": 330, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); - (app, group, ellipse, beside) - } - - /// The screen points of what one frame draws in `color` with lines `width` px wide: outlines and - /// the bounding box, and with `width` 1 the anchors and the bounding box's handles too. - fn drawn_in(app: &mut VectorcraftApp, ctx: &egui::Context, color: Color32, width: f32) -> Vec { - let solid = egui::epaint::ColorMode::Solid(color); - let mut at = vec![]; - for s in shapes(app, ctx) { - match s { - Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == width => at.extend(ps.points), - Shape::Rect(r) if width == 1.0 && (r.fill == color || (r.stroke.color == color && r.stroke.width == width)) => { - at.extend([r.rect.min, r.rect.max]); - } - _ => {} - } - } - at - } - - /// #2422: a selected clip group shows its clipping path's outline and anchors, the bounding - /// box of what shows, and not the art the path clips; so do its hover highlight and its key - /// object outline. - #[test] - fn a_selected_clip_group_shows_its_clipping_path_not_its_hidden_art() { - let (mut app, group, _, beside) = clip_group_app(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap()))); - // The square on screen. `inside` allows 8 px around it for the anchors and the bounding box's handles. - let clip = egui::Rect::from_two_pos(xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(200.0, 200.0))); - // Each case that draws nothing, or draws outside the square. - let mut wrong = vec![]; - let mut inside = |at: Vec, what: &str| { - if at.is_empty() || !at.iter().all(|q| clip.expand(8.0).contains(*q)) { - wrong.push(format!("{what}: {at:?}")); - } - }; - for tool in ["selection", "directSelection", "groupSelection"] { - app.select_tool(tool); - app.run("select.set", json!({"ids": [group]})).unwrap(); - inside(drawn_in(&mut app, &ctx, color, 1.0), tool); - } - app.select_tool("selection"); - app.run("select.set", json!({"ids": [group, beside]})).unwrap(); - app.run("select.key", json!({"id": group})).unwrap(); - inside(drawn_in(&mut app, &ctx, color, KEY_OUTLINE), "the key object"); - app.run("select.none", json!({})).unwrap(); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 150.0)))]); - inside(drawn_in(&mut app, &ctx, color, 1.5), "the hover highlight"); - assert!(wrong.is_empty(), "drawn outside {clip:?}:\n{}", wrong.join("\n")); - } - - /// A group of four clip groups, each a placed image larger than the rounded square that clips - /// it: one click with the Selection tool selects the group, which shows the four squares' - /// outlines and no image's rectangle, and a bounding box that is the union of the squares. - #[test] - fn a_group_of_clipped_images_shows_its_masks_and_their_bounds() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let img = image::RgbaImage::from_pixel(160, 160, image::Rgba([40, 120, 200, 255])); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let data = vectorcraft_format::base64_encode(&png); - // Masks 100 × 100 at x = 20, 150, 280, 410; each image reaches 30 past its mask on every side. - let masks: Vec = (0..4).map(|i| Rect::new(20.0 + 130.0 * i as f64, 100.0, 120.0 + 130.0 * i as f64, 200.0)).collect(); - let mut clips = vec![]; - for m in &masks { - app.run("file.place", json!({"name": "photo.png", "dataBase64": data, "link": false, "at": [m.center().x, m.center().y]})).unwrap(); - let image = json!(app.session.active().unwrap().selection.objects[0].0); - let mask = app.run("shape.rectangle", json!({"x": m.x0, "y": m.y0, "width": 100, "height": 100, "radius": 16})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [image, mask]})).unwrap(); - clips.push(app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone()); - } - app.run("select.set", json!({"ids": clips})).unwrap(); - let group = vectorcraft_doc::NodeId(app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap()); - app.run("select.none", json!({})).unwrap(); - let union = masks.iter().copied().reduce(|a, b| a.union(b)).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - // One click on a visible part of the second image selects the whole group. - app.select_tool("selection"); - let view = app.view_info(); - for kind in [PointerKind::Down, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, 200.0, 150.0), view).unwrap(); - } - assert_eq!(app.session.active().unwrap().selection.objects, vec![group]); - // The bounding box, the bounds and the transform box fit the masks, not the images. - let close = - |r: Option| r.is_some_and(|r| [r.x0 - union.x0, r.y0 - union.y0, r.x1 - union.x1, r.y1 - union.y1].iter().all(|d| d.abs() < 1e-9)); - let st = app.session.active().unwrap(); - assert!(close(st.doc.node(group).unwrap().geometric_bounds()), "the group's bounds"); - assert!(close(app.selection_box().map(|b| b.rect)), "the bounding box"); - // Everything drawn in the layer's colour lies on a mask: on its outline, its anchors or the - // bounding box round them (8 px for the anchors and the box's handles). - let color = c32(app.session.active().unwrap().doc.layer_color(group)); - let screen = |r: Rect| egui::Rect::from_two_pos(xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1))); - let drawn = drawn_in(&mut app, &ctx, color, 1.0); - assert!(!drawn.is_empty()); - let near_a_mask = |q: &Pos2| masks.iter().any(|m| screen(*m).expand(8.0).contains(*q)); - let on_the_box = |q: &Pos2| { - let b = screen(union); - b.expand(8.0).contains(*q) && !b.shrink(8.0).contains(*q) - }; - let stray: Vec<_> = drawn.iter().filter(|q| !near_a_mask(q) && !on_the_box(q)).collect(); - assert!(stray.is_empty(), "drawn off the masks: {stray:?}"); - // Each mask's outline is drawn: points on its left and right edges. - for m in &masks { - let r = screen(*m); - let on = |x: f32| drawn.iter().any(|q| (q.x - x).abs() < 1.0 && q.y > r.min.y + 16.0 && q.y < r.max.y - 16.0); - assert!(on(r.min.x) && on(r.max.x), "the outline of {m:?}"); - } - // No image's rectangle: nothing along the images' top edge, 30 points above the masks. - let top = xf.to_screen(Point::new(0.0, union.y0 - 30.0)).y; - assert!(!drawn.iter().any(|q| (q.y - top).abs() < 0.5), "the images' top edge at y {top}"); - } - - /// #2422: a clip group whose hidden art has more anchors than the overlay draws (#525) still - /// shows its clipping path's anchors and hover highlight. - #[test] - fn a_clip_group_over_a_huge_path_shows_its_clipping_path() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let n = OVERLAY_MAX_ANCHORS + 10; - let points = (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::(); - let huge = app.run("path.create", json!({"d": format!("M20 20 {points} Z")})).unwrap()["id"].clone(); - let square = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [huge, square]})).unwrap(); - let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); - let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap()))); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - app.select_tool("selection"); - app.run("select.set", json!({"ids": [group]})).unwrap(); - let anchors = shapes(&mut app, &ctx).iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == color)).count(); - app.run("select.none", json!({})).unwrap(); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 150.0)))]); - let hovered = !drawn_in(&mut app, &ctx, color, 1.5).is_empty(); - assert_eq!((anchors, hovered), (4, true), "the square's four anchors, and the hover highlight"); - } - - /// #2422: the art inside a clip group still shows its whole outline when it is picked on its - /// own: by Edit Contents, by a Direct Selection or Group Selection click, and in isolation mode; - /// so does its Group Selection hover highlight. - #[test] - fn the_art_a_clip_group_clips_shows_whole_when_picked() { - let (mut app, group, ellipse, _) = clip_group_app(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap()))); - // Something is drawn at the ellipse's left and right ends, outside the square. - let reaches = |at: &[Pos2], what: &str| { - let (left, right) = (xf.to_screen(Point::new(0.0, 150.0)).x, xf.to_screen(Point::new(300.0, 150.0)).x); - assert!(at.iter().any(|q| (q.x - left).abs() < 2.0) && at.iter().any(|q| (q.x - right).abs() < 2.0), "{what}: {at:?}"); - }; - app.select_tool("selection"); - app.run("select.set", json!({"ids": [group]})).unwrap(); - app.run("object.clippingMask.editContents", json!({})).unwrap(); - reaches(&drawn_in(&mut app, &ctx, color, 1.0), "Edit Contents"); - app.run("select.none", json!({})).unwrap(); - app.select_tool("directSelection"); - let view = app.view_info(); - for kind in [PointerKind::Down, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, 150.0, 120.0), view).unwrap(); - } - assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(ellipse.as_u64().unwrap())]); - reaches(&drawn_in(&mut app, &ctx, color, 1.0), "a Direct Selection click"); - app.run("select.none", json!({})).unwrap(); - app.select_tool("groupSelection"); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 120.0)))]); - reaches(&drawn_in(&mut app, &ctx, color, 1.5), "the Group Selection hover highlight"); - for kind in [PointerKind::Down, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, 150.0, 120.0), view).unwrap(); - } - assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(ellipse.as_u64().unwrap())]); - reaches(&drawn_in(&mut app, &ctx, color, 1.0), "a Group Selection click"); - app.select_tool("selection"); - app.run("object.isolate", json!({"id": group})).unwrap(); - app.run("select.all", json!({})).unwrap(); - reaches(&drawn_in(&mut app, &ctx, color, 1.0), "isolation mode"); - } - - /// A Shaper Group's mode widget, drawn for the Shaper and Selection tools with selection edges - /// shown or hidden: its arrow points right in Face Selection mode and left in Construction mode. - #[test] - fn the_shaper_widget_points_to_the_other_mode() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - for x in [0, 50] { - app.session.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(); - } - let points = json!({"points": [[25, 20], [75, 35], [30, 50], [125, 65]]}); - let g = app.session.execute("shaper.scribble", &points).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - // For each widget drawn, whether its arrow's tip is right of the circle's center. - let right = |app: &mut VectorcraftApp| -> Vec { - let drawn = shapes(app, &ctx); - let r = vectorcraft_tools::shaperwidget::RADIUS_PX; - let centers: Vec = drawn - .iter() - .filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill == Color32::WHITE).then_some(c.center) } else { None }) - .collect(); - drawn - .iter() - .filter_map(|s| match s { - Shape::Path(p) if p.points.len() == 3 => centers.iter().find(|c| c.distance(p.points[0]) <= r).map(|c| p.points[0].x > c.x), - _ => None, - }) - .collect() - }; - app.select_tool("shaper"); - assert_eq!(right(&mut app), vec![true], "the scribble selects the group"); - app.session.execute("object.isolate", &json!({"id": g})).unwrap(); - assert_eq!(right(&mut app), vec![false]); - app.select_tool("selection"); - assert_eq!(right(&mut app), vec![false]); - // View › Hide Edges keeps the widget, which still answers clicks. - app.ui.view.edges = false; - assert_eq!(right(&mut app), vec![false]); - app.select_tool("pen"); - assert!(right(&mut app).is_empty()); - } - - #[test] - fn a_symbol_instance_is_outlined_around_its_art() { - // A symbol holding the square of the SVG import tests' `Star` symbol, 20 × 20 from (0, 0), - // and an instance of it at (20, 30). - let svg = r##""##; - let mut s = Session::new(); - s.execute("document.open", &json!({"name": "star.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); - let st = s.active().unwrap(); - let mut inst = None; - st.doc.walk(|n| { - if matches!(n.kind, NodeKind::SymbolInstance { .. }) { - inst = Some(n); - } - }); - assert_eq!(node_outline(inst.unwrap(), &st.doc.symbol_boxes()).bounding_box(), Rect::new(20.0, 30.0, 40.0, 50.0)); - } - - /// One headless canvas frame on an 800 × 600 window. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - } - - /// The Image Trace panel's eye-button hold ends when the pointer is released, even when the - /// panel stopped drawing while the button was held, so a later press on the canvas does not - /// show the source image. - #[test] - fn a_release_ends_the_image_trace_hold_without_the_panel() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let made = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); - let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap()); - let ctx = egui::Context::default(); - let at = pos2(400.0, 300.0); - let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), press(true)]); - app.ui.trace_hold_source = Some(std::sync::Arc::new(vec![id])); - frame(&mut app, &ctx, vec![]); - assert!(app.ui.trace_hold_source.is_some(), "held while the button is down"); - frame(&mut app, &ctx, vec![press(false)]); - assert_eq!(app.ui.trace_hold_source, None, "the release ends the hold"); - frame(&mut app, &ctx, vec![press(true)]); - assert_eq!(app.ui.trace_hold_source, None, "a later press does not show the source image"); - frame(&mut app, &ctx, vec![press(false)]); - } - - /// The baseline marker of selected point type runs under the line, wherever its alignment - /// puts the anchor: under centred and right-aligned type too, not from the anchor rightwards - /// (#2879). - #[test] - fn point_type_baselines_run_under_the_line_whatever_its_alignment() { - for justify in ["left", "center", "right"] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let made = app.run("text.create", json!({"x": 300, "y": 150, "text": "Baseline marker", "size": 24, "justify": justify})).unwrap(); - let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap()); - app.run("select.set", json!({"ids": [id.0]})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let st = app.session.active().unwrap(); - let b = st.doc.node(id).unwrap().geometric_bounds().unwrap(); - let color = c32(st.doc.layer_color(id)); - let y = xf.to_screen(Point::new(0.0, 150.0)).y; - let (left, right) = (xf.to_screen(Point::new(b.x0, 150.0)).x, xf.to_screen(Point::new(b.x1, 150.0)).x); - let on: Vec = shapes(&mut app, &ctx) - .into_iter() - .filter_map(|s| match s { - Shape::LineSegment { points, stroke } if stroke.color == color => Some(points), - _ => None, - }) - .flatten() - .filter(|q| (q.y - y).abs() < 0.5) - .map(|q| q.x) - .collect(); - let (lo, hi) = on.iter().fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); - assert!((lo - left).abs() < 3.0 && (hi - right).abs() < 3.0, "{justify}: the baseline runs {lo}..{hi}, the type {left}..{right}"); - } - } - - /// A click with the Selection tool inside an artboard makes it the active one, and Paste in - /// Place then pastes onto it, where the objects were on their own artboard (#693). - #[test] - fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); - app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + 10.0, "y": boards[0].y0 + 15.0, "width": 20, "height": 20})).unwrap(); - app.run("edit.copy", json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.select_tool("selection"); - let view = app.view_info(); - let at = boards[2].center(); - for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view); - } - assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one"); - let ids: Vec = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"] - .as_array() - .unwrap() - .iter() - .map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap())) - .collect(); - let b = app.session.active().unwrap().doc.bounds_of(&ids, false).unwrap(); - assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0)); - } - - /// A Shift-click with the Selection tool on an empty spot inside an artboard makes it the active - /// one and keeps the selection, and Align to Artboard then aligns the objects to it unless the - /// call names another artboard. - #[test] - fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); - let mut rect = |x: f64, y: f64| { - let r = app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + x, "y": boards[0].y0 + y, "width": 20, "height": 20})); - vectorcraft_doc::NodeId(r.unwrap()["id"].as_u64().unwrap()) - }; - let ids = [rect(10.0, 15.0), rect(50.0, 40.0)]; - app.session.execute("select.set", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.select_tool("selection"); - let view = app.view_info(); - let shift = Mods { shift: true, ..Default::default() }; - for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view); - } - assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one"); - assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays"); - let lefts = |app: &VectorcraftApp| ids.map(|id| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0); - app.run("object.align", json!({"horizontal": "left", "to": "artboard"})).unwrap(); - assert_eq!(lefts(&app), [boards[2].x0; 2], "the left edges are on the active artboard's"); - app.run("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap(); - assert_eq!(lefts(&app), [boards[1].x0; 2], "an artboard the caller names is kept"); - } - - /// The canvas outlines the active artboard in black, 1 px wide, and outlines the other - /// artboards in gray, 0.6 px wide. In the test, the active artboard is Artboard 1 at first, - /// then the one Go to Artboard went to, and then, with several artboards selected by the - /// Artboard tool, the tool's active one (the one Shift-clicked last). - #[test] - fn the_active_artboard_is_outlined_in_black() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - let ctx = egui::Context::default(); - // → each artboard's edge as drawn: (width, color). - let edges = |app: &mut VectorcraftApp| { - let s = shapes(app, &ctx); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let on = |p: &[Pos2], q: &[Pos2]| p.len() == q.len() && p.iter().zip(q).all(|(a, b)| (*a - *b).length() < 0.01); - let boards: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); - boards - .iter() - .map(|r| { - let q = xf.quad(*r); - s.iter() - .find_map(|s| match s { - Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT && on(&p.points, &q) => { - Some((p.stroke.width, p.stroke.color.clone())) - } - _ => None, - }) - .expect("the artboard's edge") - }) - .collect::>() - }; - let (black, gray) = (egui::epaint::ColorMode::Solid(Color32::from_gray(0)), egui::epaint::ColorMode::Solid(Color32::from_gray(120))); - let active = |i: usize| -> Vec<(f32, egui::epaint::ColorMode)> { - (0..3).map(|j| if j == i { (1.0, black.clone()) } else { (0.6, gray.clone()) }).collect() - }; - assert_eq!(edges(&mut app), active(0)); - app.run("view.goToArtboard", json!({"index": 1})).unwrap(); - assert_eq!(edges(&mut app), active(1), "Artboard 2 is active: its edge is black and Artboard 1's edge is gray"); - // The Artboard tool starts on Artboard 2; a Shift-click on Artboard 3 selects it too and - // makes it the active one. - app.select_tool("artboard"); - let at = app.session.active().unwrap().doc.artboards[2].rect.center(); - let view = app.view_info(); - let shift = Mods { shift: true, ..Default::default() }; - for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, pressure: 1.0 }, view); - } - assert_eq!(app.session.tool_options()["selected"], json!([2, 1])); - assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected"); - } - - // Regression test for #824: with the Artboard tool, the active artboard's name was drawn twice, - // once by the canvas as for every artboard and once, a little higher and in blue, by the tool. - // Now each artboard is named once, and the active one's name sits where the others' do. - #[test] - fn the_artboard_tool_names_its_active_artboard_once() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); - app.select_tool("artboard"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let drawn = shapes(&mut app, &ctx); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - // Each name drawn: how far its left edge is right of the artboard's corner, and its bottom above it. - let named = |name: &str, board: usize| -> Vec<(f32, f32)> { - let r = app.session.active().unwrap().doc.artboards[board].rect; - let corner = xf.to_screen(Point::new(r.x0, r.y0)); - drawn - .iter() - .filter_map(|s| match s { - Shape::Text(t) if t.galley.text() == name => Some((t.pos.x - corner.x, corner.y - (t.pos.y + t.galley.size().y))), - _ => None, - }) - .collect() - }; - let (active, other) = (named("01 - Artboard 1", 0), named("02 - Artboard 2", 1)); - assert_eq!(other.len(), 1, "the other artboard is named once: {other:?}"); - assert_eq!(active.len(), 1, "the active artboard is named once: {active:?}"); - assert!((active[0].0 - other[0].0).abs() < 0.6, "flush with the artboard's left edge like the other name: {active:?} vs {other:?}"); - assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}"); - } - - /// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the - /// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing. - #[test] - fn two_finger_tap_undoes_and_three_finger_tap_redoes() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap(); - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len(); - let c = app.canvas_rect.unwrap().center(); - let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - // `fingers` fingers tap at the centre, the first also pressing as the pointer and moving a - // little (as a rectangle drag would begin). - let tap = |app: &mut VectorcraftApp, fingers: u64| { - let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0); - frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]); - frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]); - frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect()); - frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]); - frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect()); - frame(app, &ctx, vec![]); - }; - tap(&mut app, 2); - assert_eq!(count(&app), 0, "the rectangle undone, none drawn"); - tap(&mut app, 3); - assert_eq!(count(&app), 1, "redone"); - app.session.prefs.touch_gestures = false; - tap(&mut app, 2); - assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone"); - } - - /// The Artboard tool's label holds the artboard's name (never translated); the tools' own - /// labels are interface text. - #[test] - fn artboard_labels_name_the_artboard() { - assert!(names_an_artboard("01 - Layers") && names_an_artboard("12 - Artboard 12 - copy")); - assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer")); - } - - /// Double-clicking the name drawn above an artboard edits that name and does not fit a new - /// artboard or open Artboard Options. - #[test] - fn double_clicking_an_artboard_name_edits_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 80, "name": "Far"})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); - app.select_tool("artboard"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let mut target = None; - let _ = - ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { - let ab = &app.session.active().unwrap().doc.artboards[0]; - target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); - }); - let at = target.unwrap(); - assert!(rect.contains(at), "the name is in the working area, at {at:?} canvas {rect:?}"); - let before = app.session.active().unwrap().doc.artboards.len(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let click = |app: &mut VectorcraftApp| { - frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); - frame(app, &ctx, vec![button(true)]); - frame(app, &ctx, vec![button(false)]); - }; - click(&mut app); - click(&mut app); - assert!(ctx.data(|d| d.get_temp::(name_edit_id()).is_some()), "the name is being edited"); - assert_eq!(app.session.active().unwrap().doc.artboards.len(), before, "clicking the name does not fit an artboard"); - assert!(app.ui.dialog.is_none(), "Artboard Options stays closed"); - commit_artboard_name(&mut app, 0, "Poster"); - assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster"); - commit_artboard_name(&mut app, 0, " "); - assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster", "an empty name is left as it is"); - } - - /// Double-clicking the name drawn above a locked artboard doesn't edit it (the Artboards panel - /// still renames the artboard), fit an artboard or open Artboard Options. - #[test] - fn double_clicking_a_locked_artboards_name_leaves_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); - app.session.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); - app.select_tool("artboard"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let mut target = None; - let _ = - ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { - let ab = &app.session.active().unwrap().doc.artboards[0]; - target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); - }); - let at = target.unwrap(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for _ in 0..2 { - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); - frame(&mut app, &ctx, vec![button(true)]); - frame(&mut app, &ctx, vec![button(false)]); - } - assert!(ctx.data(|d| d.get_temp::(name_edit_id()).is_none()), "the name isn't edited"); - assert_eq!(app.session.active().unwrap().doc.artboards.len(), 1); - assert!(app.ui.dialog.is_none(), "Artboard Options stays closed"); - } - - /// The canvas artboard name field keeps its own inset: egui 0.37 insets a text field by the theme's button - /// padding unless told otherwise, but this field sits over the canvas label, so its glyphs must not move when - /// that padding changes (an ordinary themed field's do, as the probe shows). - #[test] - fn artboard_name_field_glyphs_stay_put_when_button_padding_changes() { - /// Where the frame paints `text` (a field's text starts at its galley's top left). - fn text_at(shapes: &[Shape], text: &str) -> Pos2 { - shapes - .iter() - .find_map(|s| match s { - Shape::Text(t) if t.galley.text() == text => Some(t.pos), - _ => None, - }) - .unwrap_or_else(|| panic!("no {text:?} text")) - } - /// Build raw input with a fixed 800 by 600 screen so headless frames keep a stable canvas texture - /// size. - fn raw() -> egui::RawInput { - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() } - } - /// The name field's text origin relative to its rectangle with the theme's button padding `padding`: the - /// field opened as a double click opens it, measured in the steady frame after it takes focus. - fn editor_inset(padding: Vec2) -> Vec2 { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("artboard.setProps", &json!({"index": 0, "name": "Inset"})).unwrap(); - app.select_tool("artboard"); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, crate::theme::Brightness::default()); - ctx.global_style_mut(|s| s.spacing.button_padding = padding); - ctx.run_ui(raw(), |ui| begin_artboard_name_edit(ui, &app, 0)).textures_delta.clear(); - // The first frame opens and focuses the field; the next one is its steady state. - frame(&mut app, &ctx, vec![]); - let (shapes, mut delta) = frame_output(&mut app, &ctx); - delta.clear(); - let origin = text_at(&shapes, "Inset"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let mut field = None; - ctx.run_ui(raw(), |ui| field = artboard_name_field_rect(&app, ui, &xf, 0).map(|r| r.min)).textures_delta.clear(); - origin - field.unwrap() - } - /// An ordinary themed field's text origin relative to its rectangle: the padding moves it. - fn plain_inset(padding: Vec2) -> Vec2 { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, crate::theme::Brightness::default()); - ctx.global_style_mut(|s| s.spacing.button_padding = padding); - let mut text = String::from("Probe"); - let rect = egui::Rect::from_min_size(pos2(40.0, 40.0), vec2(140.0, 18.0)); - let mut out = ctx.run_ui(raw(), |ui| { - ui.put(rect, egui::TextEdit::singleline(&mut text).id(egui::Id::unique("inset-probe")).font(egui::FontId::proportional(11.0))); - }); - out.textures_delta.clear(); - let shapes: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); - text_at(&shapes, "Probe") - rect.min - } - let (old, themed) = (vec2(4.0, 2.0), vec2(6.0, 2.0)); - let (editor_old, editor_themed) = (editor_inset(old), editor_inset(themed)); - let (plain_old, plain_themed) = (plain_inset(old), plain_inset(themed)); - assert!((plain_themed.x - plain_old.x).abs() > 1.0, "the probe field follows the padding: {plain_old:?} → {plain_themed:?}"); - assert!((editor_themed - editor_old).length() < 0.01, "the artboard name moves with the button padding: {editor_old:?} → {editor_themed:?}"); - } - - /// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when - /// longer, and an artboard too narrow shows none. - #[test] - fn artboard_labels_fit_their_artboard() { - let ctx = egui::Context::default(); - let drawn = |width: f32| { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - artboard_label(ui.painter(), pos2(50.0, 50.0), width, "01 - A long artboard name", Color32::WHITE) - }); - out.textures_delta.clear(); - out.shapes - .into_iter() - .filter_map(|c| match c.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.galley.size().x)), - _ => None, - }) - .collect::>() - }; - let full = drawn(400.0); - assert_eq!(full.first().map(|(s, _)| s.as_str()), Some("01 - A long artboard name")); - // Laid out no wider than the artboard (the galley keeps the whole text; it draws "…"). - let (_, w) = drawn(30.0).first().cloned().unwrap(); - assert!(full[0].1 > 30.0 && w <= 30.0 + 0.5, "{full:?} {w}"); - assert!(drawn(10.0).is_empty()); - } - - fn middle(pos: Pos2, pressed: bool) -> egui::Event { - egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() } - } - - /// One headless frame with the canvas under a 40-point bar, as it is under the document tabs. - fn frame_under_bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - ui.add_space(40.0); - show(app, ui); - }); - out.textures_delta.clear(); - } - - #[test] - fn a_click_just_outside_the_canvas_does_not_reach_the_tool() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // A square in the middle of the artboard, where the view is centred. - let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame_under_bar(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let click = |app: &mut VectorcraftApp, p: Pos2| { - let button = - |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame_under_bar(app, &ctx, vec![egui::Event::PointerMoved(p)]); - frame_under_bar(app, &ctx, vec![button(true)]); - frame_under_bar(app, &ctx, vec![button(false)]); - }; - let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); - // Two pixels above the canvas, within egui's interaction radius: nothing happens. - click(&mut app, pos2(rect.center().x, rect.top() - 2.0)); - assert!(selected(&app).is_empty(), "a click above the canvas selected {:?}", selected(&app)); - // At the canvas's centre: the square. - click(&mut app, rect.center()); - assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]); - } - - #[test] - fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.ui.view.rulers = true; - app.ui.view.guides = false; - // A drawing tool: the drag must not draw. - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| { - frame(app, &ctx, vec![egui::Event::PointerMoved(from)]); - frame(app, &ctx, vec![button(from, true)]); - frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); - frame(app, &ctx, vec![button(to, false)]); - }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); - // From the top ruler: a horizontal guide where the button was released. - let to = pos2(rect.center().x, rect.top() + 120.0); - drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to); - assert_eq!(guides(&app), [(false, xf.to_doc(to).y)]); - assert!(app.ui.view.guides, "the new guide shows"); - // From the left ruler: a vertical one. - let to2 = pos2(rect.left() + 200.0, rect.center().y); - drag(&mut app, pos2(rect.left() - RULER / 2.0, rect.center().y), to2); - assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(to2).x)]); - // Released back on the ruler: no guide. - drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), pos2(rect.center().x + 40.0, rect.top() - 4.0)); - assert_eq!(guides(&app).len(), 2); - assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing"); - } - - #[test] - fn new_ruler_guides_follow_alt_through_preview_release_and_undo() { - use egui::{Event, Modifiers}; - for source_vertical in [false, true] { - for initially_alt in [false, true] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("document.setRulerOrigin", &json!({"x": 17, "y": 23})).unwrap(); - app.ui.view.rulers = true; - app.ui.view.smart_guides = false; - app.ui.view.snap_to_point = false; - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let from = - if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) }; - let at = xf.to_screen(Point::new(80.0, 120.0)); - assert!(rect.contains(at)); - let held = |alt| if alt { Modifiers::ALT } else { Modifiers::NONE }; - let button = |pos, pressed, alt| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: held(alt) }; - let before = app.session.active().unwrap().doc.clone(); - let undo = app.session.active().unwrap().history.undo.len(); - frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(initially_alt)), Event::PointerMoved(from)]); - frame(&mut app, &ctx, vec![button(from, true, initially_alt)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); - let assert_guide = |app: &VectorcraftApp, alt: bool, pos: Point| { - let guides = &app.session.active().unwrap().doc.guides; - assert_eq!(guides.len(), 1); - assert_eq!(guides[0].vertical, source_vertical != alt); - let expected = if source_vertical != alt { pos.x } else { pos.y }; - assert!((guides[0].pos - expected).abs() < 1e-4, "{:?}, expected {expected}", guides[0]); - assert_eq!(guides[0].artboard, None); - }; - assert_guide(&app, initially_alt, Point::new(80.0, 120.0)); - // Modifier changes with no pointer movement refresh the existing preview. - for alt in [!initially_alt, initially_alt] { - frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(alt))]); - assert_guide(&app, alt, Point::new(80.0, 120.0)); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo); - } - // Equal coordinates must not hide an orientation change in the preview. - let equal = xf.to_screen(Point::new(120.0, 120.0)); - frame(&mut app, &ctx, vec![Event::PointerMoved(equal)]); - frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(!initially_alt))]); - assert_guide(&app, !initially_alt, Point::new(120.0, 120.0)); - let release = Point::new(147.0, 183.0); - let to = xf.to_screen(release); - frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(to), button(to, false, true)]); - assert_guide(&app, true, release); - let st = app.session.active().unwrap(); - assert_eq!(st.history.undo.len(), undo + 1); - assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); - assert_eq!(st.doc.ruler_origin, before.ruler_origin); - assert_eq!(st.doc.art_bounds(), before.art_bounds(), "the rectangle tool never drew"); - let committed = st.doc.clone(); - // Canvas guides still span the viewport after changing orientation. - let painted = shapes(&mut app, &ctx); - let window = app.canvas_rect.unwrap(); - let position = Xf::new(window, app.view().unwrap()).to_screen(release); - let ends = if source_vertical { - [pos2(window.left(), position.y), pos2(window.right(), position.y)] - } else { - [pos2(position.x, window.top()), pos2(position.x, window.bottom())] - }; - assert!(painted.iter().any(|s| matches!(s, Shape::LineSegment { points, .. } - if (points[0] - ends[0]).length() < 0.01 && (points[1] - ends[1]).length() < 0.01))); - app.session.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(&*app.session.active().unwrap().doc, &*before); - app.session.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(&*app.session.active().unwrap().doc, &*committed); - } - } - } - - #[test] - fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { - use egui::{Event, Modifiers}; - for source_vertical in [false, true] { - for tool in ["rectangle", "artboard"] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("document.setRulerOrigin", &json!({"x": 17, "y": 23})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap(); - app.session.execute("edit.undo", &json!({})).unwrap(); - app.ui.view.rulers = true; - app.ui.view.smart_guides = false; - app.ui.view.snap_to_point = false; - app.select_tool(tool); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let from = - if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) }; - let at = xf.to_screen(Point::new(80.0, 120.0)); - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT }; - let before = app.session.active().unwrap().doc.clone(); - let history = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) - }; - let before_history = history(&app); - assert_eq!(before_history.1, 1); - frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(from)]); - frame(&mut app, &ctx, vec![button(from, true)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); - let guides = &app.session.active().unwrap().doc.guides; - assert_eq!(guides.len(), 1); - assert_eq!(guides[0].vertical, !source_vertical); - let artboard = (tool == "artboard").then_some(before.artboards[0].id); - assert_eq!(guides[0].artboard, artboard); - // Returning to the source ruler cancels the preview and keeps the existing Redo. - frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); - assert_eq!(&*app.session.active().unwrap().doc, &*before); - assert_eq!(history(&app), before_history); - app.session.execute("edit.redo", &json!({})).unwrap(); - assert!(app.session.active().unwrap().doc.art_bounds().is_some()); - assert!(app.session.active().unwrap().doc.guides.is_empty()); - } - } - } - - /// #451: a guide dragged out of a ruler lands on the art's side midpoints (halving an - /// artboard-sized rectangle) and on the artboard's edges; an artboard guide is drawn across - /// its artboard only. - #[test] - fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - app.ui.view.rulers = true; - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| { - frame(app, &ctx, vec![egui::Event::PointerMoved(from)]); - frame(app, &ctx, vec![button(from, true)]); - frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); - frame(app, &ctx, vec![button(to, false)]); - }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); - // 3 px off the middle of the top and bottom sides: onto the line through them. - let mid = xf.to_screen(Point::new(200.0, 150.0)); - drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0)); - // 3 px inside the artboard's bottom edge: onto it. - let bottom = xf.to_screen(Point::new(100.0, 300.0)); - drag(&mut app, pos2(bottom.x, rect.top() - RULER / 2.0), bottom - vec2(0.0, 3.0)); - assert_eq!(guides(&app), [(true, 200.0), (false, 300.0)]); - assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "New Guide"); - // An artboard guide runs from the artboard's left edge to its right edge. - app.session.execute("guide.add", &json!({"vertical": false, "pos": 75, "artboard": 0})).unwrap(); - let s = shapes(&mut app, &ctx); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let (a, b) = (xf.to_screen(Point::new(0.0, 75.0)), xf.to_screen(Point::new(400.0, 75.0))); - let at = |p: Pos2, q: Pos2| (p - q).length() < 0.01; - assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, .. } if at(points[0], a) && at(points[1], b))), "across the artboard"); - // A canvas guide crosses the whole window. - let y = xf.to_screen(Point::new(0.0, 300.0)).y; - let window = app.canvas_rect.unwrap(); - assert!( - s.iter().any( - |s| matches!(s, Shape::LineSegment { points, .. } if points[0] == pos2(window.left(), y) && points[1] == pos2(window.right(), y)) - ) - ); - } - - /// #414: guides dragged out of the rulers are picked and dragged with the Selection tool - /// (highlighted while selected), go with Delete or Backspace, and dragged back onto their - /// ruler. - #[test] - fn the_selection_tool_moves_and_deletes_ruler_guides() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // A square under the guides: the press takes the guide. - app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 300, "height": 200})).unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let art = app.session.active().unwrap().doc.art_bounds(); - app.ui.view.rulers = true; - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| { - frame(app, &ctx, vec![egui::Event::PointerMoved(from)]); - frame(app, &ctx, vec![button(from, true)]); - frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); - frame(app, &ctx, vec![button(to, false)]); - }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); - // Two guides out of the rulers. - let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y)); - drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h); - drag(&mut app, pos2(rect.left() - RULER / 2.0, v.y), v); - assert_eq!(guides(&app), [(false, xf.to_doc(h).y), (true, xf.to_doc(v).x)]); - // Drag the horizontal one down 30 px (across the vertical one: the nearer is picked). - let to = pos2(h.x + 10.0, h.y + 30.0); - drag(&mut app, h, to); - let st = app.session.active().unwrap(); - assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]); - assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![])); - assert_eq!(st.history.undo.last().unwrap().label, "Move Guide"); - let picked = Tokens::get(&ctx).selection; - let s = shapes(&mut app, &ctx); - assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == picked && points[0].y == to.y)), "highlighted"); - // Backspace deletes the selected guide; Delete too. - let press = |app: &mut VectorcraftApp, key| { - let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); - out.textures_delta.clear(); - }; - press(&mut app, egui::Key::Backspace); - assert_eq!(guides(&app), [(true, xf.to_doc(v).x)]); - app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap(); - press(&mut app, egui::Key::Delete); - assert!(guides(&app).is_empty()); - app.session.execute("edit.undo", &json!({})).unwrap(); - // Hide Guides deselects them. - app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap(); - app.run("view.guides", json!({})).unwrap(); - assert!(app.session.active().unwrap().selection.guides.is_empty()); - app.run("view.guides", json!({})).unwrap(); - // Dragged back onto its ruler, the vertical guide goes. - drag(&mut app, v, pos2(rect.left() - RULER / 2.0, v.y + 20.0)); - assert!(guides(&app).is_empty(), "{:?}", guides(&app)); - assert_eq!(app.session.active().unwrap().doc.art_bounds(), art, "the square stayed"); - } - - #[test] - fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let cmd_frame = |app: &mut VectorcraftApp, events: Vec| { - let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let events = std::iter::once(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)).chain(events).collect(); - let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - }; - let button = - |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND }; - // A Cmd drag from `a` to `b` with the Pen tool; → the tool that did it. - let cmd_drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| { - app.select_tool("pen"); - cmd_frame(app, vec![egui::Event::PointerMoved(a), button(a, true)]); - let used = app.session.tool_id().to_string(); - cmd_frame(app, vec![egui::Event::PointerMoved(b)]); - cmd_frame(app, vec![button(b, false)]); - assert_eq!(app.session.tool_id(), "pen", "back to the Pen tool"); - used - }; - let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); - // Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once - // the rectangle isn't selected whole, which would move all its anchors). - app.select_tool("directSelection"); - app.session.execute("select.none", &json!({})).unwrap(); - let corner = xf.to_screen(Point::new(200.0, 150.0)); - assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection"); - let b = bounds(&app); - assert_eq!((b.x0, b.y0), (100.0, 100.0)); - assert!((b.x1 - xf.to_doc(corner + vec2(20.0, 20.0)).x).abs() < 1e-6, "{b:?}"); - // Group Selection, then Selection: each is the one used. - app.select_tool("groupSelection"); - let inside = xf.to_screen(Point::new(130.0, 120.0)); - assert_eq!(cmd_drag(&mut app, inside, inside), "groupSelection"); - app.select_tool("selection"); - assert_eq!(cmd_drag(&mut app, inside, inside), "selection"); - } - - /// #525: a selection with more anchors than the overlay draws (a traced photo) shows its bounds, - /// not every outline and anchor, which made a mesh larger than the GPU takes; an ordinary one - /// still shows its outline and anchors. - #[test] - fn a_huge_selection_shows_its_bounds_instead_of_every_outline() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let points = |n: usize| (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::(); - let ctx = egui::Context::default(); - let mut drawn = |n: usize| { - app.session.execute("path.create", &json!({"d": format!("M20 20 {} Z", points(n))})).unwrap(); - frame(&mut app, &ctx, vec![]); - let shapes = shapes(&mut app, &ctx); - let points: usize = shapes.iter().map(|s| if let Shape::Path(ps) = s { ps.points.len() } else { 0 }).sum(); - let squares = shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count(); - (points, squares) - }; - let (points, squares) = drawn(OVERLAY_MAX_ANCHORS + 10); - assert!(points < 100 && squares < 100, "{points} outline points, {squares} squares"); - let (points, squares) = drawn(200); - assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares"); - } - - /// #2460: type, images, symbol instances, gradient meshes and envelopes count toward the - /// points the overlay outlines, as paths' anchors do. 32,768 of one kind (an opened PDF can - /// hold as many type lines, images or mesh pieces) add only the selection's bounds to the - /// frame's mesh, under 100 vertices; one alone adds its outline. - #[test] - fn a_huge_selection_of_type_images_symbols_or_meshes_shows_its_bounds() { - for kind in ["point type", "area type", "type on a path", "image", "symbol", "mesh", "envelope"] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.ui.task_bar = false; - // The Rectangle tool draws nothing of its own (no bounding box, no type on a path - // brackets): only the selection's overlay changes the frame. - app.select_tool("rectangle"); - match kind { - "point type" => { - app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type"})).unwrap(); - } - "area type" => { - let area = json!({"width": 150, "height": 20}); - app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type", "area": area})).unwrap(); - } - "type on a path" => { - let path = app.session.execute("path.create", &json!({"d": "M20 100 L140 100 L260 100 L380 100"})).unwrap()["id"].clone(); - app.session.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Type"})).unwrap(); - } - "image" => { - let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png()); - app.run("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); - } - "symbol" => { - app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); - app.session.execute("symbol.new", &json!({})).unwrap(); - } - "mesh" => { - app.session.execute("shape.rectangle", &json!({"x": 150, "y": 50, "width": 30, "height": 20})).unwrap(); - app.session.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); - } - _ => { - app.session.execute("shape.rectangle", &json!({"x": 150, "y": 50, "width": 30, "height": 20})).unwrap(); - app.session.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap(); - } - } - let ctx = egui::Context::default(); - // The vertices that selecting everything adds to the frame's mesh. - let overlay = |app: &mut VectorcraftApp| { - app.session.execute("select.none", &json!({})).unwrap(); - let none = frame_vertices(app, &ctx); - app.session.execute("select.all", &json!({})).unwrap(); - frame_vertices(app, &ctx).saturating_sub(none) - }; - let one = overlay(&mut app); - assert!(one > 0, "{kind}: one shows its outline"); - for _ in 0..15 { - app.session.execute("select.all", &json!({})).unwrap(); - app.run("edit.duplicate", json!({})).unwrap(); - } - let many = overlay(&mut app); - assert_eq!(app.session.active().unwrap().selection.objects.len(), 32_768, "{kind}"); - assert!(many < 100, "{kind}: 32,768 show the selection's bounds, not {many} vertices"); - } - } - - /// #2460: a group of 32,768 images is too much to outline: under the pointer it is not - /// highlighted, and as the key object its outline is its bounds. - #[test] - fn a_group_of_too_many_images_has_no_hover_outline_and_a_key_outline_of_its_bounds() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png()); - app.run("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); - for _ in 0..15 { - app.session.execute("select.all", &json!({})).unwrap(); - app.run("edit.duplicate", json!({})).unwrap(); - } - app.session.execute("select.all", &json!({})).unwrap(); - let group = app.session.execute("object.group", &json!({})).unwrap()["id"].clone(); - app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap(); - app.select_tool("selection"); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let over = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(200.0, 150.0)); - let lines = |app: &mut VectorcraftApp, width: f32| { - frame(app, &ctx, vec![egui::Event::PointerMoved(over)]); - shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == width)).count() - }; - assert_eq!(lines(&mut app, 1.5), 0, "no hover outline"); - app.session.execute("select.all", &json!({})).unwrap(); - app.session.execute("select.key", &json!({"id": group})).unwrap(); - assert_eq!(lines(&mut app, KEY_OUTLINE), 1, "the key object's bounds"); - } - - /// #1111: a selected gradient mesh shows its mesh lines and points to the selection tools, not - /// only to the Mesh tool. - #[test] - fn a_selected_gradient_mesh_shows_its_lines_and_points() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 200, "height": 100})).unwrap(); - app.session.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); - let ctx = egui::Context::default(); - let drawn = |app: &mut VectorcraftApp| { - frame(app, &ctx, vec![]); - let shapes = shapes(app, &ctx); - let lines = shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.points.len() > 1)).count(); - (lines, shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count()) - }; - let (none_lines, none_points) = { - app.session.execute("select.none", &json!({})).unwrap(); - drawn(&mut app) - }; - for tool in ["selection", "directSelection"] { - app.select_tool(tool); - app.session.execute("select.all", &json!({})).unwrap(); - let (lines, points) = drawn(&mut app); - // The outline, two inner lines each way; the 3 × 3 points. - assert!(lines >= none_lines + 3 && points >= none_points + 9, "{tool}: {lines} lines, {points} points"); - } - } - - /// #541: the key object's outline is drawn thicker than the rest of the selection's. - #[test] - fn the_key_object_has_a_thicker_outline() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let a = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); - let b = app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 60, "height": 40})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let thick = - |app: &mut VectorcraftApp| shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == KEY_OUTLINE)).count(); - assert_eq!(thick(&mut app), 0, "no key yet"); - app.session.execute("select.key", &json!({"id": b})).unwrap(); - assert!(thick(&mut app) > 0, "the key's outline"); - } - - /// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened. - fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { - let (shapes, mut delta) = frame_output(app, ctx); - delta.clear(); - shapes - } - - /// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the - /// GPU takes in one buffer. - fn frame_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { - frame(app, ctx, vec![]); - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 }; - ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum() - } - - /// One headless canvas frame → the shapes drawn (`Shape::Vec`s flattened) and the textures - /// uploaded by it (the art's raster among them, when it was re-rendered). - fn frame_output(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Vec, egui::TexturesDelta) { - fn flat(s: Shape, out: &mut Vec) { - match s { - Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), - s => out.push(s), - } - } - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - let out = ctx.run_ui(raw, |ui| show(app, ui)); - let mut v = vec![]; - out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v)); - (v, out.textures_delta) - } - - /// An edit of one object renders the canvas again only where it changed, into its texture; a - /// change of the view renders all of it. - #[test] - fn an_edit_renders_only_its_part_of_the_canvas() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width":400,"height":300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - app.canvas.worker_started = true; // every frame on this thread, however long it takes - let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap(); - app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); - app.run("select.none", json!({})).unwrap(); - let ctx = egui::Context::default(); - // Where the canvas texture changed in a frame, and how much of it. - let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { - let tex = app.canvas.texture.as_ref().map(|t| t.id()); - let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); - delta.clear(); - set - }; - let (_, delta) = frame_output(&mut app, &ctx); - assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); - app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap(); - let (_, delta) = frame_output(&mut app, &ctx); - let (pos, size) = canvas(&app, delta).expect("the edit shows"); - let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap(); - assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}"); - // An edit with other render options than the frame shown's renders all of it. - app.session.prefs.pattern_tile_edge_color = "#ff00ff".into(); - app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap(); - let (_, delta) = frame_output(&mut app, &ctx); - assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it"); - app.run("view.setZoom", json!({"zoom": 2})).unwrap(); - let (_, delta) = frame_output(&mut app, &ctx); - assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); - } - - /// A placed document's bitmap made in the background renders the canvas again only where the - /// objects showing its file are: both copies of the file are in the part rendered again. - #[test] - fn a_ready_placed_document_bitmap_renders_only_its_part_of_the_canvas() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width":400,"height":300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - app.canvas.worker_started = true; // every frame on this thread, however long it takes - // A document of one 60 × 30 artboard with a rectangle, placed linked twice. - let mut src = Session::new(); - src.execute("file.new", &json!({"width": 60, "height": 30})).unwrap(); - src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 30})).unwrap(); - let dir = std::env::temp_dir().join(format!("vectorcraft-placed-redraw-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("card.vectorcraft"); - std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap(); - let path = path.to_string_lossy().into_owned(); - let ids: Vec = [[60, 50], [140, 50]] - .iter() - .map(|at| { - let r = app.session.execute("file.place", &json!({"path": path, "at": at})).unwrap(); - assert_eq!(r["linked"], true, "{r}"); - r["ids"][0].as_u64().unwrap() - }) - .collect(); - app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); - app.run("select.none", json!({})).unwrap(); - let ctx = egui::Context::default(); - // Where the canvas texture changed in a frame, and how much of it. - let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { - let tex = app.canvas.texture.as_ref().map(|t| t.id()); - let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); - delta.clear(); - set - }; - let (_, delta) = frame_output(&mut app, &ctx); - assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); - let t = std::time::Instant::now(); - while vectorcraft_render::placed_document::busy() && t.elapsed().as_secs() < 20 { - std::thread::sleep(std::time::Duration::from_millis(5)); - } - let (_, delta) = frame_output(&mut app, &ctx); - let (pos, size) = canvas(&app, delta).expect("the bitmap shows"); - let [px, py] = pos.expect("a part of the canvas"); - let key = app.canvas.key.clone().unwrap(); - let view = Affine::translate((f64::from(key.w) / 2.0, f64::from(key.h) / 2.0)) - * Affine::rotate(key.rot.to_radians()) - * Affine::scale(key.zoom * f64::from(key.ppp)) - * Affine::translate((-key.cx, -key.cy)); - let doc = &app.session.active().unwrap().doc; - for id in ids { - let b = doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); - let [x0, y0, x1, y1] = vectorcraft_render::view_pixels(b, view, key.w, key.h).unwrap(); - let inside = x0 as usize >= px && y0 as usize >= py && x1 as usize <= px + size[0] && y1 as usize <= py + size[1]; - assert!(inside, "object {id} at {:?} in the part at {:?} of size {size:?}", [x0, y0, x1, y1], [px, py]); - } - assert!(size[0] * size[1] * 2 <= key.w as usize * key.h as usize, "a part of it: {size:?}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the - /// mask's coverage wherever its art reaches, past its object's bounds. - #[test] - fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width":400,"height":300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - app.canvas.worker_started = true; // every frame on this thread, however long it takes - let rect = - |app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone(); - let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0)); - app.run("select.set", json!({"ids": [obj, mask]})).unwrap(); - app.run("transparency.makeOpacityMask", json!({})).unwrap(); - app.run("transparency.viewOpacityMask", json!({})).unwrap(); - let ctx = egui::Context::default(); - let (_, mut delta) = frame_output(&mut app, &ctx); - delta.clear(); - app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap(); - let (_, mut delta) = frame_output(&mut app, &ctx); - let tex = app.canvas.texture.as_ref().map(|t| t.id()); - let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos); - delta.clear(); - assert_eq!(pos, Some(None), "all of it"); - } - - /// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles. - #[test] - fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width":400,"height":300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":200,"height":150})).unwrap()["id"].as_u64().unwrap(); - app.run("select.none", json!({})).unwrap(); - app.select_tool("directSelection"); - let view = app.view_info(); - for kind in [PointerKind::Down, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, 150.0, 50.0), view).unwrap(); - } - let before = app.session.doc().unwrap().doc.clone(); - let color = c32(before.layer_color(vectorcraft_doc::NodeId(id))); - let selected = selected_anchor(color); - let ctx = egui::Context::default(); - let painted = shapes(&mut app, &ctx); - assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 0); - assert_eq!( - painted.iter().filter(|s| matches!(s, Shape::Path(p) if p.stroke.width == 2.0 && p.points.len() == 2)).count(), - 1, - "only the chosen straight edge has emphasis" - ); - assert!(!painted.iter().any(|s| matches!(s, Shape::Circle(_))), "segment picks have no corner widgets"); - assert_eq!(app.session.doc().unwrap().doc, before); - app.run("select.anchors", json!({"id":id,"anchors":[[0,0],[0,1]]})).unwrap(); - let painted = shapes(&mut app, &ctx); - assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 2); - app.run( - "path.create", - json!({"anchors":[{"x":50,"y":260,"out":[100,220]},{"x":200,"y":260,"in":[150,220],"out":[250,300]},{"x":330,"y":260,"in":[280,300]}]}), - ) - .unwrap(); - app.run("select.none", json!({})).unwrap(); - for kind in [PointerKind::Down, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, 125.0, 230.0), view).unwrap(); - } - let before = app.session.doc().unwrap().doc.clone(); - let painted = shapes(&mut app, &ctx); - assert_eq!( - painted.iter().filter(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color)).count(), - 2, - "only the chosen curve's two controls show" - ); - app.ui.view.edges = false; - let painted = shapes(&mut app, &ctx); - assert!(!painted.iter().any(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color))); - assert_eq!(app.session.doc().unwrap().doc, before); - } - - /// A group, a compound path or a clip group selected whole has every anchor selected, and Direct - /// Selection, the Pen and the anchor point tools draw its anchors filled, as they draw the anchors - /// of a path selected whole. They do the same after Group Selection clicks select a path and then its group. - /// Direction handles still show only on a path selected itself. - #[test] - fn the_anchors_of_a_container_selected_whole_draw_selected() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let rect = |app: &mut VectorcraftApp, x: i32, y: i32| { - app.run("shape.rectangle", json!({"x": x, "y": y, "width": 60, "height": 60})).unwrap()["id"].clone() - }; - let in_group = rect(&mut app, 20, 20); - let ellipse = app.run("shape.ellipse", json!({"x": 100, "y": 20, "width": 60, "height": 60})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [in_group, ellipse]})).unwrap(); - let group = app.run("object.group", json!({})).unwrap()["id"].clone(); - let (c1, c2) = (rect(&mut app, 200, 20), rect(&mut app, 220, 40)); - app.run("select.set", json!({"ids": [c1, c2]})).unwrap(); - let compound = app.run("object.compoundPath.make", json!({})).unwrap()["id"].clone(); - let hidden = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 200, "height": 100})).unwrap()["id"].clone(); - let square = rect(&mut app, 60, 170); - app.run("select.set", json!({"ids": [hidden, square]})).unwrap(); - let clip = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); - let lone = app.run("shape.ellipse", json!({"x": 300, "y": 150, "width": 60, "height": 60})).unwrap()["id"].clone(); - let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(lone.as_u64().unwrap()))); - let ctx = egui::Context::default(); - // Filled and hollow anchor squares, and direction handle lines, drawn in the layer color. - let looks = |app: &mut VectorcraftApp| { - let painted = shapes(app, &ctx); - let small = |r: &egui::epaint::RectShape| r.rect.width() < 12.0; - let filled = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == color)).count(); - let hollow = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == Color32::WHITE)).count(); - let handles = painted.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count(); - (filled, hollow, handles) - }; - let mut wrong = vec![]; - for tool in ["directSelection", "pen", "addAnchor", "deleteAnchor", "anchorPoint"] { - app.select_tool(tool); - // What is selected, and what it shows: the lone ellipse selected whole is the reference - // (four anchors with two handles each, and its center point, filled too). - for (what, ids, want) in [ - ("a path", json!([lone]), (5, 0, 8)), - ("a group", json!([group]), (8, 0, 0)), - ("a compound path", json!([compound]), (8, 0, 0)), - ("a clip group", json!([clip]), (4, 0, 0)), - ] { - app.run("select.set", json!({ "ids": ids })).unwrap(); - let got = looks(&mut app); - if got != want { - wrong.push(format!("{tool}, {what}: (filled, hollow, handles) {got:?}, want {want:?}")); - } - } - } - app.run("select.none", json!({})).unwrap(); - app.select_tool("groupSelection"); - let view = app.view_info(); - for _ in 0..2 { - for kind in [PointerKind::Down, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, 20.0, 50.0), view).unwrap(); - } - } - let id = |v: &serde_json::Value| vectorcraft_doc::NodeId(v.as_u64().unwrap()); - assert_eq!(app.session.active().unwrap().selection.objects, vec![id(&in_group), id(&group)]); - app.select_tool("directSelection"); - // The rectangle's anchors and center point, then the group's eight anchors, four of them over the rectangle's. - let got = looks(&mut app); - if got != (13, 0, 0) { - wrong.push(format!("a Group Selection click on a path, then on its group: {got:?}, want (13, 0, 0)")); - } - assert!(wrong.is_empty(), "{}", wrong.join("\n")); - } - - /// View › Pixel Preview: magnified, the art shows as the document pixels it rasterizes to (one - /// per point, hard-edged), not as smooth vectors; zoomed out or out of Pixel Preview the canvas - /// renders for the screen again. - #[test] - fn pixel_preview_shows_the_document_pixels() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // Its 1 pt stroke covers a quarter of document pixel 99 and most of pixel 100. - app.session.execute("shape.rectangle", &json!({"x": 100.25, "y": 50, "width": 200, "height": 200})).unwrap(); - app.canvas.worker_started = true; // render on this thread - let ctx = egui::Context::default(); - // The art's raster uploaded by one frame: (width, height, hard-edged, pixels). - let upload = |app: &mut VectorcraftApp| { - let (_, mut delta) = frame_output(app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let up = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { - let egui::ImageData::Color(img) = &d.image; - (img.width(), img.height(), d.options.magnification == egui::TextureFilter::Nearest, img.pixels.clone()) - }); - delta.clear(); - up.expect("the art re-rendered") - }; - let screen = upload(&mut app); // the first frame fits the view to the artboard - assert!(screen.0 > 600 && !screen.2, "out of Pixel Preview: the screen's pixels, smoothed"); - app.ui.view.pixel_preview = true; - let view = app.view_mut().unwrap(); - (view.zoom, view.center) = (8.0, Point::new(100.0, 150.0)); - let (w, h, hard, px) = upload(&mut app); - assert!(hard, "hard-edged pixels"); - assert!(w <= screen.0 / 8 + 2 && h <= screen.1 / 8 + 2, "one pixel per point: {w} × {h}"); - let [x0, y0, x1, y1] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered"); - assert_eq!(((x1 - x0) as usize, (y1 - y0) as usize), (w, h)); - // Each document pixel is one texel, anti-aliased by how much of it the art covers. - let alpha = |x: i64| px[((150 - y0) * w as i64 + x - x0) as usize].a(); - assert!((40..=90).contains(&alpha(99)), "a quarter of pixel 99: {}", alpha(99)); - assert!(alpha(100) > 200, "most of pixel 100: {}", alpha(100)); - assert_eq!((alpha(98), alpha(101)), (0, 255)); - // The pixels are placed over the document rect they cover. - let s = shapes(&mut app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let Some(Shape::Mesh(mesh)) = s.iter().find(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)) else { panic!("the art") }; - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = xf.to_screen(Point::new(x0 as f64, y0 as f64)); - assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos); - // Zoomed out, a document pixel is no bigger than a screen pixel: rendered for the screen. - app.view_mut().unwrap().zoom = 0.5; - let (w, _, hard, _) = upload(&mut app); - assert!(w > 600 && !hard, "zoomed out: the screen's pixels"); - } - - /// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template - /// draws it again, dimmed as before; each re-renders at once. - #[test] - fn hide_template_leaves_template_layers_off_the_canvas() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; // render on this thread - app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); - let layer = app.session.active().unwrap().doc.layers[0].id.0; - app.session.execute("layer.template", &json!({"ids": [layer], "on": true})).unwrap(); - let ctx = egui::Context::default(); - // How many pixels of the art's raster one frame uploads are painted, None when it was not - // re-rendered. - let painted = |app: &mut VectorcraftApp| -> Option { - let (_, mut delta) = frame_output(app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { - let egui::ImageData::Color(img) = &d.image; - img.pixels.iter().filter(|c| c.a() != 0).count() - }); - delta.clear(); - n - }; - let shown = painted(&mut app).expect("the first frame renders the art"); - assert!(shown > 1000, "the template layer's art is drawn: {shown} pixels"); - assert_eq!(app.run("view.template", json!({})).unwrap(), json!(false)); - assert_eq!(painted(&mut app), Some(0), "Hide Template re-renders without the template layer"); - assert_eq!(painted(&mut app), None, "nothing changed: no re-render"); - assert_eq!(app.run("view.template", json!({})).unwrap(), json!(true)); - assert_eq!(painted(&mut app), Some(shown), "Show Template draws it again as before"); - } - - /// General › Anti-aliased Artwork (#394): on (the default), the art's edges are smoothed on - /// screen; off, every pixel is either painted or not, and turning it either way re-renders. - #[test] - fn anti_aliased_artwork_preference_smooths_the_canvas() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // Render on this thread: the worker's frame would land later than the test looks. - app.canvas.worker_started = true; - // A disc: its edge crosses pixels at every angle. - app.session.execute("shape.ellipse", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - // The alphas of the art's raster uploaded by one frame, None when it was not re-rendered. - let alphas = |app: &mut VectorcraftApp| -> Option> { - let (_, mut delta) = frame_output(app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let alphas = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { - let egui::ImageData::Color(img) = &d.image; - img.pixels.iter().map(|c| c.a()).collect() - }); - delta.clear(); - alphas - }; - let partial = |a: &[u8]| a.iter().filter(|&&a| a != 0 && a != 255).count(); - let on = alphas(&mut app).expect("the first frame renders the art"); - assert!(partial(&on) > 50, "anti-aliased: edge pixels partly covered ({} of them)", partial(&on)); - assert!(alphas(&mut app).is_none(), "nothing changed: no re-render"); - app.session.execute("prefs.set", &json!({"key": "antiAliasedArtwork", "value": false})).unwrap(); - let off = alphas(&mut app).expect("the preference change re-renders"); - assert_eq!(partial(&off), 0, "hard edges: every pixel painted or not"); - assert!(off.iter().filter(|&&a| a == 255).count() > 1000, "the disc is still painted"); - app.session.execute("prefs.set", &json!({"key": "antiAliasedArtwork", "value": true})).unwrap(); - assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again"); - } - - /// Type › Highlight Substituted Fonts: on (the default), type in a missing font has pink - /// behind it on the canvas; off, it does not, and turning it either way re-renders. - #[test] - fn highlight_substituted_fonts_preference_reaches_the_canvas() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; // render on this thread - app.session.execute("text.create", &json!({"x": 20, "y": 120, "text": "Hamburg", "size": 60, "font": "Missing Family"})).unwrap(); - let ctx = egui::Context::default(); - // How many pink pixels the art's raster uploaded by one frame has, None when it was not - // re-rendered. - let pink = |app: &mut VectorcraftApp| -> Option { - let (_, mut delta) = frame_output(app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { - let egui::ImageData::Color(img) = &d.image; - img.pixels.iter().filter(|c| c.a() != 0 && c.r() > c.g().saturating_add(30) && c.b() > c.g()).count() - }); - delta.clear(); - n - }; - let on = pink(&mut app).expect("the first frame renders the art"); - assert!(on > 100, "on by default: the text has pink behind it ({on} pixels)"); - assert!(app.canvas.shown.as_ref().is_some_and(|(_, o)| o.highlight_substituted_fonts)); - app.session.execute("prefs.set", &json!({"key": "highlightSubstitutedFonts", "value": false})).unwrap(); - assert_eq!(pink(&mut app), Some(0), "the preference off re-renders without the highlight"); - assert!(app.canvas.key.as_ref().is_some_and(|k| !k.highlight_substituted_fonts)); - app.session.execute("prefs.set", &json!({"key": "highlightSubstitutedFonts", "value": true})).unwrap(); - assert_eq!(pink(&mut app), Some(on), "on again, as before"); - } - - /// Images whose pixels can't be decoded draw as crossed boxes on the canvas, and the status - /// bar reports each of them once: two such images give one message, and later frames that - /// draw them again give none. - #[test] - fn undecodable_images_are_marked_and_reported_once() { - use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; // render on this thread - // PNGs of noisy pixels cut off halfway: their headers read, their pixels don't decode. - let cut = |w: u32| { - let img = image::RgbaImage::from_fn(w, 41, |x, y| image::Rgba([(x * 37 + y * 101) as u8, (x * y) as u8, (x ^ y) as u8, 255])); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - png.truncate(png.len() / 2); - png - }; - { - let st = app.session.doc_mut().unwrap(); - let d = std::sync::Arc::make_mut(&mut st.doc); - for (i, w) in [53u32, 59].into_iter().enumerate() { - let key = format!("cut{i}"); - d.images.insert(key.clone(), ImageBlob::new("image/png", cut(w))); - let im = ImageObject { - key, - width: w, - height: 41, - xf: Affine::translate((40.0 + 150.0 * i as f64, 100.0)), - link: None, - placement: Default::default(), - pixels: None, - }; - let (id, layer) = (d.alloc_id(), d.layers[0].id); - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); - } - st.revision += 1; - } - let ctx = egui::Context::default(); - app.status(""); - frame(&mut app, &ctx, vec![]); - assert!(app.canvas.shown.as_ref().is_some_and(|(_, o)| o.mark_undecoded_images), "the canvas marks them"); - assert_eq!(app.ui.status, "2 image(s) couldn't be decoded and show as crossed boxes"); - app.status(""); - app.session.doc_mut().unwrap().revision += 1; - frame(&mut app, &ctx, vec![]); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.ui.status, "", "said once"); - } - - /// A drag of an object with raster effects (#1257): the drag's frames render while - /// interacting and draw the object's drop shadow and filtered content from the rasters of the - /// frame before; the frame after the drag renders without `interacting`, on the render worker, - /// and draws what a fresh renderer draws. - #[test] - fn dragging_an_object_with_raster_effects_draws_its_rasters_moved() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; // render on this thread - let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone(); - for effect in ["stylize.dropShadow", "texture.craquelure"] { - app.run("effect.apply", json!({"effect": effect, "ids": [id]})).unwrap(); - } - let ctx = egui::Context::default(); - // The art's raster uploaded by one frame, None when it was not re-rendered. - let upload = |app: &mut VectorcraftApp| -> Option> { - let (_, mut delta) = frame_output(app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { - let egui::ImageData::Color(img) = &d.image; - img.pixels.clone() - }); - delta.clear(); - px - }; - upload(&mut app).expect("the first frame renders the art"); - app.select_tool("selection"); - let view = app.view_info(); - app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap(); - for step in [1.0, 2.0, 3.0] { - app.session.pointer(&PointerEvent::new(PointerKind::Drag, 160.0 + 7.3 * step, 125.0 + 2.1 * step), view).unwrap(); - upload(&mut app).expect("each frame of the drag renders"); - assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "step {step}"); - assert_eq!(app.canvas.renderer.stats.moved, 2, "the shadow and the filtered content, step {step}"); - assert!(app.canvas.moved); - } - // The frame after the drag runs the effects again: on the worker, while the canvas shows - // the drag's last frame. - app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); - app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap(); - assert!(upload(&mut app).is_none(), "not rendered on this thread"); - assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker"); - let until = std::time::Instant::now() + std::time::Duration::from_secs(60); - let mut after = None; - while after.is_none() && std::time::Instant::now() < until { - std::thread::sleep(std::time::Duration::from_millis(5)); - after = upload(&mut app); - } - let after = after.expect("the worker's frame arrives"); - assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting)); - assert!(!app.canvas.moved); - app.canvas.worker = None; - app.canvas.renderer = vectorcraft_render::Renderer::new(); - app.canvas.key = None; - assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws"); - } - - /// A tool's drag on the canvas renders with `dragging` (#2558). At 3200% a rectangle with - /// Texturizer covers more than the raster budget, so no raster is kept for it, and each frame - /// of a Selection tool drag draws its filtered content at the drag's lower resolution. The - /// frame after the drag draws what a fresh renderer draws. A dialog's preview, an interaction - /// without a drag, renders without `dragging` and draws the same. - #[test] - fn a_drag_on_the_canvas_draws_raster_effects_at_a_lower_resolution() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; // render on this thread - let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150})).unwrap()["id"].clone(); - app.run("effect.apply", json!({"effect": "texture.texturizer", "ids": [id]})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - // One headless frame on a 1920 × 1200 window (a view larger than the drag's budget) with - // `events` → the art's raster it uploaded, None when it was not re-rendered. - let run = |app: &mut VectorcraftApp, events: Vec| -> Option> { - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1200.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - let art = app.canvas.texture.as_ref().map(|t| t.id()); - let px = out.textures_delta.set.iter().find(|e| Some(*e.0) == art).and_then(|e| e.1.last()).map(|d| { - let egui::ImageData::Color(img) = &d.image; - img.pixels.clone() - }); - out.textures_delta.clear(); - px - }; - run(&mut app, vec![]); - if let Some(vm) = app.view_mut() { - (vm.zoom, vm.center) = (32.0, Point::new(200.0, 155.0)); - } - let still = run(&mut app, vec![]).expect("the zoomed view renders"); - assert_eq!(app.canvas.renderer.stats.previewed, 0, "a still frame"); - let at = app.canvas_rect.unwrap().center(); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - run(&mut app, vec![egui::Event::PointerMoved(at)]); - run(&mut app, vec![button(at, true)]); - for step in [1.0, 2.0] { - let drawn = run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]).expect("each frame of the drag renders"); - assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}"); - assert_eq!(app.canvas.renderer.stats.previewed, 1, "the filtered content, step {step}"); - assert!(app.canvas.moved); - assert!(drawn != still, "the rectangle moved, step {step}"); - } - let after = run(&mut app, vec![button(at + vec2(26.0, 14.0), false)]).expect("the frame after the drag renders"); - assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting && !k.dragging)); - assert_eq!(app.canvas.renderer.stats.previewed, 0, "the frame after the drag"); - assert!(!app.canvas.moved); - app.canvas.renderer = vectorcraft_render::Renderer::new(); - app.canvas.key = None; - assert!(run(&mut app, vec![]) == Some(after.clone()), "the frame after the drag is what a fresh renderer draws"); - // A dialog's preview: an interaction, and no drag. - app.session.begin_interaction("Texturizer").unwrap(); - assert!(run(&mut app, vec![]) == Some(after), "a dialog's preview draws the effect at full resolution"); - assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && !k.dragging)); - assert_eq!(app.canvas.renderer.stats.previewed, 0); - } - - /// Dragging a small square over an A4 page filled by a rectangle blurred by 500 pt, at 200%: - /// the blur reaches far past the raster budget, so the still frame keeps the backdrop's raster - /// cropped to the view, and each frame of a Selection tool drag draws it from there instead of - /// previewing it at the drag's lower resolution: on this thread, and on the worker, where the - /// frames of a heavy document render. - #[test] - fn a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame() { - for on_worker in [false, true] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap(); - app.canvas.worker_started = true; // no worker unless `on_worker` - let back = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone(); - app.run("paint.setFill", json!({"ids": [back], "color": "#1a4ce6"})).unwrap(); - app.run("effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 500}, "ids": [back]})).unwrap(); - let square = app.run("shape.rectangle", json!({"x": 260, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [square]})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - // One headless frame with `events`, then, on the worker, frames until the frame it renders - // for them is shown. - let run = |app: &mut VectorcraftApp, events: Vec| { - let frame = |app: &mut VectorcraftApp, events| { - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 870.0))), - events, - ..Default::default() - }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - }; - frame(app, events); - let until = std::time::Instant::now() + std::time::Duration::from_secs(60); - while app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) && std::time::Instant::now() < until { - std::thread::sleep(std::time::Duration::from_millis(5)); - frame(app, vec![]); - } - }; - let moved = |app: &VectorcraftApp| if on_worker { app.canvas.moved } else { app.canvas.renderer.stats.moved > 0 }; - run(&mut app, vec![]); - if let Some(vm) = app.view_mut() { - (vm.zoom, vm.center) = (2.0, Point::new(290.0, 410.0)); - } - if on_worker { - app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); - app.canvas.last_ms = 50.0; - } - run(&mut app, vec![]); - assert_eq!(app.canvas.from_worker, on_worker); - assert!(!moved(&app), "a still frame, on the worker: {on_worker}"); - let at = app.canvas_rect.unwrap().center(); - let button = - |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - run(&mut app, vec![egui::Event::PointerMoved(at)]); - run(&mut app, vec![button(at, true)]); - for step in [1.0, 2.0, 3.0] { - run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]); - assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}, on the worker: {on_worker}"); - assert_eq!(app.canvas.from_worker, on_worker, "step {step}"); - assert!( - !moved(&app), - "the blur drawn from the still frame's raster, step {step}, on the worker: {on_worker} ({} ms)", - app.perf.render_ms - ); - } - run(&mut app, vec![button(at + vec2(39.0, 21.0), false)]); - let info = app.run("document.inspect", json!({})).unwrap(); - let children = info["layers"][0]["children"].as_array().unwrap().clone(); - let x0 = |id: &serde_json::Value| children.iter().find(|c| c["id"] == *id).unwrap()["bounds"]["x"].as_f64().unwrap(); - assert!(x0(&square) > 270.0 && x0(&back) == 0.0, "the square moved, the backdrop did not"); - app.canvas.worker = None; - } - } - - /// An interaction's frames stay on the worker once it drew the frame shown, however fast that - /// frame was: the worker's renderer keeps the effect rasters that made it fast, and this - /// thread's renderer, without them, would draw the next frame slowly. Without a frame from the - /// worker, a fast frame renders on this thread as before. - #[test] - fn an_interaction_stays_on_the_worker_that_drew_the_frame_shown() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); - app.canvas.worker_started = true; // the first frame renders on this thread - let ctx = egui::Context::default(); - shapes(&mut app, &ctx); - for from_worker in [true, false] { - app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); - (app.canvas.from_worker, app.canvas.last_ms) = (from_worker, 1.0); - app.session.begin_interaction("Preview").unwrap(); - shapes(&mut app, &ctx); - let pending = app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.interacting); - assert_eq!(pending, from_worker, "sent to the worker: {from_worker}"); - if !from_worker { - assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "rendered on this thread"); - } - app.session.cancel_interaction().unwrap(); - app.canvas.worker = None; - shapes(&mut app, &ctx); - } - } - - /// The frame after an interaction renders without `interacting` also when the interaction - /// leaves the document as it was (#1257): here a drag back to where it started. - #[test] - fn a_drag_back_to_its_start_renders_again_after_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; // render on this thread - let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone(); - app.run("effect.apply", json!({"effect": "texture.craquelure", "ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - shapes(&mut app, &ctx); - app.select_tool("selection"); - let view = app.view_info(); - app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap(); - for (x, y) in [(173.3, 129.1), (160.0, 125.0)] { - app.session.pointer(&PointerEvent::new(PointerKind::Drag, x, y), view).unwrap(); - shapes(&mut app, &ctx); - assert_eq!(app.canvas.renderer.stats.moved, 1, "the filtered content at ({x}, {y})"); - } - let revision = app.session.active().unwrap().revision; - app.session.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 125.0), view).unwrap(); - assert_eq!(app.session.active().unwrap().revision, revision, "the drag left the document as it was"); - shapes(&mut app, &ctx); - assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting), "rendered again"); - assert_eq!(app.canvas.renderer.stats.moved, 0); - assert!(!app.canvas.moved); - } - - /// Cache metadata, worker requests and view transforms use the renderer's bounded raster size. - /// Keep the worker controlled so huge virtual viewports never allocate huge test images. - #[test] - fn canvas_render_jobs_use_renderer_limited_dimensions() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; - app.ui.view.rulers = false; - let frame = |app: &mut VectorcraftApp, size, ppp| { - let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() }; - input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp); - let mut output = ctx.run_ui(input, |ui| show(app, ui)); - output.textures_delta.clear(); - }; - frame(&mut app, vec2(400.0, 300.0), 1.0); - let (worker, jobs, _completed) = crate::render_worker::Worker::test_channel(); - app.canvas.worker = Some(worker); - app.canvas.last_ms = 50.0; - for (size, ppp, wide) in [(vec2(70000.25, 32.0), 1.0, true), (vec2(32.0, 70000.25), 1.0, false), (vec2(40000.25, 32.0), 2.0, true)] { - frame(&mut app, size, ppp); - let job = jobs.try_recv().expect("oversized viewport queued on controlled worker"); - assert_eq!((job.key.w, job.key.h), (job.w, job.h)); - assert_eq!(if wide { job.w } else { job.h }, u32::from(u16::MAX)); - assert!((1..=u32::from(u16::MAX)).contains(&job.w)); - assert!((1..=u32::from(u16::MAX)).contains(&job.h)); - let center = job.view * Point::new(job.key.cx, job.key.cy); - assert!((center.x - f64::from(job.w) / 2.0).abs() < 0.001); - assert!((center.y - f64::from(job.h) / 2.0).abs() < 0.001); - } - } - - /// A cached texture keeps the renderer's document mapping when the viewport changes. The - /// controlled worker cannot finish early, even if egui repeats a layout pass. - #[test] - fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() { - for (old_rot, new_rot, old_ppp, new_ppp, pixel, resize_only) in [ - // Isolate the original panel/window resize: zoom, pan, rotation and DPI stay fixed. - (0.0, 0.0, 1.0, 1.0, false, true), - (30.0, 30.0, 1.25, 1.25, false, true), - (0.0, 0.0, 1.0, 1.0, false, false), - (30.0, 30.0, 1.25, 1.25, false, false), - (30.0, 75.0, 1.25, 1.25, false, false), - (30.0, 75.0, 1.25, 2.0, false, false), - (30.0, 30.0, 2.0, 1.25, false, false), - (30.0, 75.0, 1.25, 2.0, true, false), - ] { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; - app.ui.view.pixel_preview = pixel; - let frame = |app: &mut VectorcraftApp, size: egui::Vec2, ppp: f32| { - let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(31.25, 19.5), size)), ..Default::default() }; - // Monitor DPI is viewport input, not egui's UI zoom (which rescales the last - // screen rect and would override the simultaneous resize under test). - input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp); - let mut out = ctx.run_ui(input, |ui| show(app, ui)); - assert!((out.pixels_per_point - ppp).abs() < 0.001); - out.textures_delta.clear(); - let tex = app.canvas.texture.as_ref().expect("rendered canvas texture").id(); - out.shapes - .into_iter() - .find_map(|s| match s.shape { - Shape::Mesh(m) if m.texture_id == tex => Some(m), - _ => None, - }) - .expect("the canvas texture is drawn") - }; - frame(&mut app, vec2(800.25, 600.35), old_ppp); - { - let v = app.view_mut().unwrap(); - v.rotation = old_rot; - v.zoom = 2.5; - v.center = Point::new(210.0, 160.0); - } - frame(&mut app, vec2(800.25, 600.35), old_ppp); - let old = app.canvas.key.clone().unwrap(); - let (worker, jobs, completed) = crate::render_worker::Worker::test_channel(); - app.canvas.worker = Some(worker); - app.canvas.last_ms = 50.0; - { - let v = app.view_mut().unwrap(); - v.rotation = new_rot; - if !resize_only { - v.zoom = 3.25; - v.center = Point::new(225.0, 170.0); - } - } - let size = vec2(602.5, 720.1); - let mesh = frame(&mut app, size, new_ppp); - assert_eq!(app.canvas.key.as_ref(), Some(&old), "the cached frame is still displayed"); - let job = jobs.try_recv().expect("the replacement frame was submitted"); - assert_eq!(job.key.ppp, new_ppp); - assert_eq!(job.key.rot, new_rot); - if resize_only { - assert_eq!((job.key.zoom, job.key.cx, job.key.cy, job.key.rot, job.key.ppp), (old.zoom, old.cx, old.cy, old.rot, old.ppp)); - } - assert_ne!((job.key.w, job.key.h), (old.w, old.h)); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let render_view = Affine::translate((old.w as f64 / 2.0, old.h as f64 / 2.0)) - * Affine::rotate(old.rot.to_radians()) - * Affine::scale(old.zoom * old.ppp as f64) - * Affine::translate((-old.cx, -old.cy)); - assert_eq!(mesh.vertices.len(), 4); - for vertex in &mesh.vertices { - assert!([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)].contains(&vertex.uv)); - let doc = if let Some([x0, y0, x1, y1]) = old.pixel { - Point::new(x0 as f64 + vertex.uv.x as f64 * (x1 - x0) as f64, y0 as f64 + vertex.uv.y as f64 * (y1 - y0) as f64) - } else { - render_view.inverse() * Point::new(vertex.uv.x as f64 * old.w as f64, vertex.uv.y as f64 * old.h as f64) - }; - assert!( - (vertex.pos - xf.to_screen(doc)).length() < 0.002, - "{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}" - ); - } - // Deliver the actual replacement and verify that it replaces the cached key. - let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); - let expected = job.key.clone(); - completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap(); - frame(&mut app, size, new_ppp); - assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); - } - } - - /// When the view turns while the worker renders the frame at the new angle (Reset Rotate View on - /// a heavy document), the canvas draws the last frame turned until that frame arrives: each - /// corner goes where the new view puts the document point that the corner showed. The new frame - /// is then drawn unturned. In Pixel Preview the last frame's pixels stay over the document rect - /// they cover. - #[test] - fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); - app.canvas.worker_started = true; // render on this thread - let ctx = egui::Context::default(); - shapes(&mut app, &ctx); // the first frame fits the view to the artboard - app.view_mut().unwrap().rotation = 30.0; - shapes(&mut app, &ctx); - let rect = app.canvas_rect.unwrap(); - let old = Xf::new(rect, app.view().unwrap()); - // `art` runs one frame and finds the mesh that draws the art's texture. - let art = |app: &mut VectorcraftApp| { - let s = shapes(app, &ctx); - let tex = app.canvas.texture.as_ref().unwrap().id(); - s.into_iter().find_map(|s| match s { - Shape::Mesh(m) if m.texture_id == tex => Some(m), - _ => None, - }) - }; - // With a worker and a last frame that took 50 ms, the next frame renders on the worker. - app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); - app.canvas.last_ms = 50.0; - app.run("view.rotateReset", json!({})).unwrap(); - let mesh = art(&mut app).expect("the last frame, turned"); - assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.rot == 0.0), "sent to the worker"); - let new = Xf::new(rect, app.view().unwrap()); - let corners = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()]; - assert_eq!(mesh.vertices.len(), 4); - for ((v, corner), uv) in mesh.vertices.iter().zip(corners).zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) { - let at = new.to_screen(old.to_doc(corner)); - assert!((v.pos - at).length() < 0.01 && v.uv == uv, "{v:?}: texture corner {uv:?} at {at:?}"); - } - // The point where the last frame drew the rectangle's top-left corner lands where the new view puts that corner. - let p = Point::new(100.0, 50.0); - let uv = (old.to_screen(p) - rect.min) / rect.size(); - let [a, b, _, d] = [0, 1, 2, 3].map(|i| mesh.vertices[i].pos); - assert!((a + (b - a) * uv.x + (d - a) * uv.y - new.to_screen(p)).length() < 0.01, "the rectangle's corner"); - // The worker's frame at 0° then covers the canvas unturned. - let until = std::time::Instant::now() + std::time::Duration::from_secs(60); - while app.canvas.key.as_ref().is_some_and(|k| k.rot != 0.0) && std::time::Instant::now() < until { - std::thread::sleep(std::time::Duration::from_millis(5)); - shapes(&mut app, &ctx); - } - assert!(app.canvas.key.as_ref().is_some_and(|k| k.rot == 0.0), "the worker's frame arrives"); - let b = art(&mut app).expect("the worker's frame").calc_bounds(); - assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}"); - // In Pixel Preview the last frame's pixels are drawn over the document rect they cover. - app.canvas.worker = None; - app.ui.view.pixel_preview = true; - app.view_mut().unwrap().zoom = 8.0; - shapes(&mut app, &ctx); - let [x0, y0, ..] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered"); - app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); - app.canvas.last_ms = 50.0; - app.view_mut().unwrap().rotation = 30.0; - let mesh = art(&mut app).expect("the last frame's pixels"); - let at = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(x0 as f64, y0 as f64)); - assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos); - app.canvas.worker = None; - } - - /// With the web build's setting (`waits_for_still`, no render worker), a heavy document's canvas - /// is not rendered while the view moves: each frame draws the frame shown moved and scaled with - /// the view and asks for a frame within STILL_SECS. The canvas renders once the view has been - /// still for STILL_SECS, and at once after an edit. With a worker's build settings the same frames render at once, as - /// before. - #[test] - fn without_a_worker_a_heavy_canvas_is_reprojected_until_the_view_is_still() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); - assert!(!app.canvas.waits_for_still, "off the web build"); - app.canvas.worker_started = true; // no worker, as in the web build - app.canvas.waits_for_still = true; - let ctx = egui::Context::default(); - // One frame → (the bounds of the mesh that draws the art's texture, the repaint delay asked for). - let frame = |app: &mut VectorcraftApp| { - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - let tex = app.canvas.texture.as_ref().unwrap().id(); - let art = out.shapes.iter().find_map(|c| match &c.shape { - Shape::Mesh(m) if m.texture_id == tex => Some(m.calc_bounds()), - _ => None, - }); - (art.expect("the art's texture is drawn"), out.viewport_output[&egui::ViewportId::ROOT].repaint_delay) - }; - frame(&mut app); // the first frame fits the view to the artboard - frame(&mut app); - let rect = app.canvas_rect.unwrap(); - // A light document renders every change of view at once. - app.canvas.last_ms = 1.0; - app.view_mut().unwrap().center.x += 10.0; - frame(&mut app); - let shown = app.canvas.key.clone().unwrap(); - assert_eq!(shown.cx, app.view().unwrap().center.x, "rendered at once"); - // A heavy one draws the frame shown moved and scaled with the view. - app.canvas.last_ms = 50.0; - let old = Xf::new(rect, app.view().unwrap()); - for (dx, zoom) in [(10.0, 1.0), (10.0, 1.5), (-25.0, 1.5)] { - let v = app.view_mut().unwrap(); - v.center.x += dx; - v.zoom *= zoom; - let (b, delay) = frame(&mut app); - assert_eq!(app.canvas.key.as_ref(), Some(&shown), "not rendered while the view moves"); - let new = Xf::new(rect, app.view().unwrap()); - let (a, z) = (new.to_screen(old.to_doc(rect.min)), new.to_screen(old.to_doc(rect.max))); - assert!((b.min - a).length() < 0.01 && (b.max - z).length() < 0.01, "{b:?}: the frame shown at {a:?} to {z:?}"); - assert!(delay <= std::time::Duration::from_secs_f64(STILL_SECS), "a frame within STILL_SECS: {delay:?}"); - } - // The view stays still: frames at 60 fps (egui's input time without a clock) wait until - // STILL_SECS has passed, and the next one renders. - let mut frames = 0; - let b = loop { - frames += 1; - let (b, _) = frame(&mut app); - if app.canvas.key.as_ref() != Some(&shown) || frames == 60 { - break b; - } - }; - assert!((12..=13).contains(&frames), "rendered after about 12 frames: {frames}"); - let now = app.canvas.key.clone().unwrap(); - assert_eq!((now.cx, now.zoom), (app.view().unwrap().center.x, app.view().unwrap().zoom), "rendered for the view"); - assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}"); - // An edit renders at once, even while the view moves. - app.canvas.last_ms = 50.0; - app.view_mut().unwrap().center.y += 10.0; - frame(&mut app); - assert_eq!(app.canvas.key.as_ref(), Some(&now), "waiting"); - app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); - frame(&mut app); - let k = app.canvas.key.clone().unwrap(); - assert_eq!((k.revision, k.cy), (app.session.active().unwrap().revision, app.view().unwrap().center.y), "rendered after the edit"); - // With a worker's build settings and no worker (VECTORCRAFT_SYNC_RENDER), a heavy document - // renders every change of view at once. - app.canvas.waits_for_still = false; - app.canvas.last_ms = 50.0; - app.view_mut().unwrap().center.x += 10.0; - frame(&mut app); - assert_eq!(app.canvas.key.as_ref().map(|k| k.cx), Some(app.view().unwrap().center.x), "rendered at once"); - } - - /// [`still_wait`] waits while only the view changes on a slow canvas: each change starts - /// STILL_SECS again. It does not wait for a light document, a still view, a clock that went - /// back, an edit, a switch into Pixel Preview or a canvas with no frame shown. - #[test] - fn the_canvas_waits_only_while_the_view_of_a_slow_frame_changes() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; // render on this thread - shapes(&mut app, &egui::Context::default()); - let shown = app.canvas.key.clone().unwrap(); - let mut still = Some((shown.clone(), 0.0)); - let moved = CacheKey { cx: shown.cx + 5.0, zoom: shown.zoom * 2.0, ..shown.clone() }; - assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0), Some(STILL_SECS), "the view moved"); - assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0625), Some(STILL_SECS - 0.0625), "still for 62.5 ms"); - let turned = CacheKey { rot: 15.0, w: shown.w + 40, ppp: 2.0, ..moved.clone() }; - assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.0), Some(STILL_SECS), "turned and resized: again"); - assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.25), None, "still for longer than STILL_SECS"); - assert_eq!(still_wait(&mut still, Some(&shown), &turned, false, 11.0), None, "a light document"); - assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 10.5), None, "the clock went back"); - let edited = CacheKey { revision: shown.revision + 1, ..turned.clone() }; - assert_eq!(still_wait(&mut still, Some(&shown), &edited, true, 12.0), None, "an edit"); - let pixels = CacheKey { pixel: Some([0, 0, 10, 10]), ..moved.clone() }; - assert_eq!(still_wait(&mut still, Some(&shown), &pixels, true, 13.0), None, "into Pixel Preview"); - let shown_pixels = CacheKey { pixel: Some([5, 5, 20, 20]), ..shown.clone() }; - assert_eq!(still_wait(&mut still, Some(&shown_pixels), &pixels, true, 13.0), Some(STILL_SECS), "Pixel Preview, moved"); - assert_eq!(still_wait(&mut still, None, &moved, true, 14.0), None, "no frame shown"); - assert_eq!(still_wait(&mut still, Some(&shown), &shown, true, 15.0), None, "the frame shown"); - } - - /// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel - /// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the - /// canvas always does out of Pixel Preview. - #[test] - fn pixel_preview_smooths_bitmaps_only_when_asked() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.canvas.worker_started = true; // render on this thread - let ctx = egui::Context::default(); - let smooth = |app: &mut VectorcraftApp| { - let (_, mut delta) = frame_output(app, &ctx); - delta.clear(); - app.canvas.key.as_ref().map(|k| k.smooth_images).unwrap() - }; - assert!(smooth(&mut app), "out of Pixel Preview"); - app.ui.view.pixel_preview = true; - app.view_mut().unwrap().zoom = 8.0; - assert!(!smooth(&mut app), "Pixel Preview, off by default"); - app.run("prefs.set", json!({"key": "antiAliasedBitmaps", "value": true})).unwrap(); - assert!(smooth(&mut app), "Pixel Preview, on"); - } - - /// Appearance of Black › On Screen (#394): Display All Blacks Accurately (the default) shows - /// pure CMYK black through the CMYK profile, a dark grey; Display All Blacks as Rich Black - /// shows it as RGB black. A rich black shows the same either way. - #[test] - fn appearance_of_black_on_screen_shows_pure_black_as_rgb_black() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap(); - app.canvas.worker_started = true; // render on this thread - // Larger than the view: the middle of the art's raster is its fill. - let id = app.run("shape.rectangle", json!({"x": -5000, "y": -5000, "width": 10000, "height": 10000})).unwrap()["id"].clone(); - let fill = |app: &mut VectorcraftApp, cmyk: [u8; 4]| { - let [c, m, y, k] = cmyk; - app.run("paint.setFill", json!({"ids": [id], "color": {"c": c, "m": m, "y": y, "k": k}})).unwrap(); - }; - let ctx = egui::Context::default(); - // The middle pixel of the art's raster uploaded by one frame, None when it was not re-rendered. - let middle = |app: &mut VectorcraftApp| -> Option<[u8; 4]> { - let (_, mut delta) = frame_output(app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { - let egui::ImageData::Color(img) = &d.image; - let [w, h] = img.size; - img.pixels[h / 2 * w + w / 2].to_array() - }); - delta.clear(); - px - }; - let shown = |cmyk: [f32; 4]| vectorcraft_color::Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0); - let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1); - - fill(&mut app, [0, 0, 0, 100]); - let accurate = middle(&mut app).expect("the first frame renders the art"); - assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0])), "accurate: through the CMYK profile, {accurate:?}"); - assert!(accurate[..3].iter().all(|v| *v > 10), "accurate: a dark grey, {accurate:?}"); - app.run("prefs.set", json!({"key": "blackOnScreen", "value": "rich"})).unwrap(); - assert_eq!(middle(&mut app).expect("the preference change re-renders"), [0, 0, 0, 255], "rich: RGB black"); - - fill(&mut app, [60, 40, 40, 100]); - let rich = middle(&mut app).expect("re-rendered"); - assert!(near(rich, shown([0.6, 0.4, 0.4, 1.0])), "a rich black is not pure black: {rich:?}"); - - fill(&mut app, [0, 0, 0, 100]); - app.run("prefs.set", json!({"key": "blackOnScreen", "value": "accurate"})).unwrap(); - assert!(near(middle(&mut app).expect("re-rendered"), accurate), "accurate again"); - } - - /// Guides & Grid › Show Pixel Grid (Above 600% Zoom) (#394): in Pixel Preview at 600% zoom and - /// above, a line at every document pixel over the art; none below 600%, out of Pixel Preview, - /// or with the option off. - #[test] - fn pixel_grid_shows_in_pixel_preview_from_600_percent() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); - app.canvas.worker_started = true; // render on this thread - let ctx = egui::Context::default(); - shapes(&mut app, &ctx); // the first frame fits the view to the artboard - // → (the pixel grid's lines, whether the first of them is drawn over the art). - let pixel_lines = |app: &mut VectorcraftApp| { - let s = shapes(app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art"); - let is_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == PIXEL_GRID); - let lines: Vec = s.iter().enumerate().filter(|(_, s)| is_line(s)).map(|(i, _)| i).collect(); - (lines.len(), lines.first().is_some_and(|&i| i > image)) - }; - app.ui.view.pixel_preview = true; - app.view_mut().unwrap().zoom = 8.0; - let (n, over) = pixel_lines(&mut app); - assert!(n >= 150, "a line every 8 px across the 800 × 600 canvas: {n}"); - assert!(over, "over the art"); - app.view_mut().unwrap().zoom = 5.0; - assert_eq!(pixel_lines(&mut app).0, 0, "below 600%: none"); - app.view_mut().unwrap().zoom = 6.0; - assert!(pixel_lines(&mut app).0 >= 200, "from 600%"); - app.ui.view.pixel_preview = false; - assert_eq!(pixel_lines(&mut app).0, 0, "out of Pixel Preview: none"); - app.ui.view.pixel_preview = true; - app.session.execute("prefs.set", &json!({"key": "showPixelGrid", "value": false})).unwrap(); - assert_eq!(pixel_lines(&mut app).0, 0, "the option off: none"); - // A rotated view: the lines turn with the pixels, across the whole canvas. - app.session.execute("prefs.set", &json!({"key": "showPixelGrid", "value": true})).unwrap(); - app.view_mut().unwrap().rotation = 30.0; - let s = shapes(&mut app, &ctx); - let turned: Vec<_> = s - .iter() - .filter_map(|s| match s { - Shape::LineSegment { points: [a, b], stroke } if stroke.color == PIXEL_GRID => Some((b.x - a.x).atan2(b.y - a.y).to_degrees().abs()), - _ => None, - }) - .collect(); - assert!(turned.len() >= 200, "{}", turned.len()); - assert!(turned.iter().all(|a| [30.0, 60.0, 120.0, 150.0].iter().any(|t| (a - t).abs() < 0.5)), "turned 30°: {turned:?}"); - } - - /// Guides & Grid (#394): the grid in Grid Color, behind the art or, with Grids In Back off, - /// over it, as dots in the Dots style; the guides in Guides Color and Style. - #[test] - fn grid_and_guides_take_their_preferences() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); - app.ui.view.grid = true; - app.ui.view.guides = true; - let (red, green) = (Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 255, 0)); - app.session.execute("prefs.set", &json!({"values": {"gridColor": "#ff0000", "guideColor": "#00ff00"}})).unwrap(); - let ctx = egui::Context::default(); - let red_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == red); - // → (index of the first red gridline, index of the art's image). - let order = |app: &mut VectorcraftApp| { - let s = shapes(app, &ctx); - let art = app.canvas.texture.as_ref().unwrap().id(); - let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art"); - (s.iter().position(red_line).expect("a gridline in Grid Color"), image, s) - }; - let (grid, art, s) = order(&mut app); - assert!(grid < art, "Grids In Back: under the art"); - assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == green)), "the guide in Guides Color"); - app.session.execute("prefs.set", &json!({"key": "gridsInBack", "value": false})).unwrap(); - let (grid, art, _) = order(&mut app); - assert!(grid > art, "Grids In Back off: over the art"); - app.session.execute("prefs.set", &json!({"values": {"gridStyle": "dots", "guideStyle": "dots"}})).unwrap(); - let s = shapes(&mut app, &ctx); - assert!(!s.iter().any(red_line), "no gridlines in the Dots style"); - let red_dots = |s: &Shape| matches!(s, Shape::Mesh(m) if m.vertices.len() > 40 && m.vertices.iter().all(|v| v.color == red)); - assert!(s.iter().any(red_dots), "the grid's dots"); - assert!(s.iter().any(|s| matches!(s, Shape::Circle(c) if c.fill == green)), "the guide dotted"); - } - - /// A canvas guide (on no artboard) is drawn along its document line in a rotated view, where it - /// snaps and is picked: both ends of each guide's line are on x = 100 or y = 120. At 0° the - /// vertical one runs from the canvas's top to its bottom. - #[test] - fn canvas_guides_turn_with_the_view() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); - app.session.execute("guide.add", &json!({"vertical": false, "pos": 120})).unwrap(); - app.session.execute("prefs.set", &json!({"key": "guideColor", "value": "#123456"})).unwrap(); - app.ui.view.guides = true; - let color = Color32::from_rgb(0x12, 0x34, 0x56); - let ctx = egui::Context::default(); - let lines = |app: &mut VectorcraftApp| -> Vec<[Pos2; 2]> { - shapes(app, &ctx) - .into_iter() - .filter_map(|s| if let Shape::LineSegment { points, stroke } = s { (stroke.color == color).then_some(points) } else { None }) - .collect() - }; - let s = lines(&mut app); - let rect = app.canvas_rect.unwrap(); - let vertical = s.iter().find(|[a, b]| (a.x - b.x).abs() < 1e-3).expect("the vertical guide"); - assert!((vertical[0].y - rect.top()).abs() < 0.5 && (vertical[1].y - rect.bottom()).abs() < 0.5, "0°: {vertical:?} in {rect:?}"); - app.run("view.rotate", json!({"angle": 30})).unwrap(); - let s = lines(&mut app); - let xf = Xf::new(rect, app.view().unwrap()); - assert_eq!(s.len(), 2, "{s:?}"); - let on = |v: f64, along_x: bool| { - s.iter().any(|ends| ends.iter().all(|e| ((if along_x { xf.to_doc(*e).x } else { xf.to_doc(*e).y }) - v).abs() < 1e-3)) - }; - assert!(on(100.0, true), "x = 100: {s:?}"); - assert!(on(120.0, false), "y = 120: {s:?}"); - } - - /// Selection & Anchor Display › Size (#394): anchors and the bounding box's handles grow. - #[test] - fn anchor_size_scales_anchors_and_handles() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - // The sizes of the squares drawn (anchors and handles are squares). - let sizes = |app: &mut VectorcraftApp| { - let mut v: Vec = shapes(app, &ctx).iter().filter_map(|s| if let Shape::Rect(r) = s { Some(r.rect.width()) } else { None }).collect(); - v.sort_by(f32::total_cmp); - v.dedup(); - v - }; - let small = sizes(&mut app); - assert!(small.contains(&4.0) && small.contains(&6.0), "anchors 4, handles 6 by default: {small:?}"); - app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap(); - let big = sizes(&mut app); - assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}"); - } - - #[test] - fn mirror_axis_tracks_selection_options_and_view_without_editing_art() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap(); - let id = app.run("object.repeat.mirror", json!({})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - let axis = |app: &mut VectorcraftApp| { - let color = Tokens::get(&ctx).selection; - shapes(app, &ctx) - .into_iter() - .filter_map(|s| match s { - Shape::LineSegment { points, stroke } if stroke.color == color && stroke.width == 2.0 => Some(points), - _ => None, - }) - .collect::>() - }; - let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); - let lines = axis(&mut app); - assert_eq!(lines.len(), 1); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - assert!(lines[0][0].distance(xf.to_screen(Point::new(170.0, 100.0)) - vec2(0.0, 24.0)) < 0.01); - assert!(lines[0][1].distance(xf.to_screen(Point::new(170.0, 180.0)) + vec2(0.0, 24.0)) < 0.01); - assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), before); - app.ui.view.edges = false; - assert!(axis(&mut app).is_empty()); - app.ui.view.edges = true; - app.run("select.set", json!({"ids": []})).unwrap(); - assert!(axis(&mut app).is_empty()); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert_eq!(axis(&mut app), lines); - app.run("object.repeat.options", json!({"angle": 0, "center": [180, 140]})).unwrap(); - let horizontal = axis(&mut app); - assert_eq!(horizontal.len(), 1); - assert!((horizontal[0][0].y - horizontal[0][1].y).abs() < 0.01); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(axis(&mut app), lines); - app.view_mut().unwrap().rotation = 45.0; - let rotated = axis(&mut app); - let d = rotated[0][1] - rotated[0][0]; - assert!((d.x.abs() - d.y.abs()).abs() < 0.01); - app.run("object.repeat.release", json!({})).unwrap(); - assert!(axis(&mut app).is_empty()); - } - - #[test] - fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap(); - app.run("object.repeat.mirror", json!({})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - app.view_mut().unwrap().zoom = 1.5; - app.view_mut().unwrap().rotation = 30.0; - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let axis = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, app.view().unwrap().zoom).unwrap() - }; - let initial = axis(&app); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let from = xf.to_screen(initial.center); - let to = from + vec2(40.0, 15.0); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); - assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001, "the mirrored artwork previews during the drag"); - frame(&mut app, &ctx, vec![button(to, false)]); - assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(axis(&app).center, initial.center); - // Both rotation handles remain a usable size at this zoom and view angle. - let drawn = shapes(&mut app, &ctx); - for end in initial.ends { - assert!(drawn.iter().any(|s| matches!(s, Shape::Circle(c) - if c.center.distance(xf.to_screen(end)) < 0.01 && c.radius == vectorcraft_tools::mirror::HANDLE_RADIUS))); - } - } - - /// A selected grid repeat draws its row handle below the grid and a slider between its first - /// two columns (none with the bounding box hidden). Dragging the row handle down two tiles in - /// a zoomed view makes five rows without scaling the source, as one undo step, and the slider - /// sets the horizontal spacing (#2691). - #[test] - fn grid_repeat_handles_set_rows_and_spacing_in_one_undo_step_each() { - use vectorcraft_tools::repeat_grid::{GridHandles, HANDLE_RADIUS}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 40, "height": 40})).unwrap(); - app.run("object.repeat.grid", json!({"hSpacing": 10, "vSpacing": 10})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let ctx = egui::Context::default(); - // The first frame fits the artboard in the window; then zoom to 1.5 px a point. - frame(&mut app, &ctx, vec![]); - app.view_mut().unwrap().zoom = 1.5; - frame(&mut app, &ctx, vec![]); - assert_eq!(app.view().unwrap().zoom, 1.5); - // Rows, columns, horizontal spacing and the source's bounds. - let grid = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - let n = st.doc.node(st.selection.objects[0]).unwrap(); - let vectorcraft_doc::NodeKind::Repeat(spec) = &n.kind else { panic!("a repeat") }; - let vectorcraft_doc::RepeatKind::Grid { rows, cols, h_spacing, .. } = spec.kind else { panic!("a grid repeat") }; - (rows, cols, h_spacing, spec.source_bounds().unwrap()) - }; - let handles = |app: &mut VectorcraftApp| { - let frame = app.selection_box(); - let st = app.session.active().unwrap(); - GridHandles::of(&st.doc, &st.selection, frame, app.view().unwrap().zoom).unwrap() - }; - let before = grid(&app); - assert_eq!((before.0, before.1, before.2), (3, 3, 10.0)); - let g = handles(&mut app); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let drawn = shapes(&mut app, &ctx); - let circle_at = |drawn: &[Shape], at: Pos2| { - drawn.iter().any(|s| matches!(s, Shape::Circle(c) if c.center.distance(at) < 0.01 && c.radius == HANDLE_RADIUS)) - }; - assert!(circle_at(&drawn, xf.to_screen(g.rows_at)), "the row handle is drawn"); - assert!(circle_at(&drawn, xf.to_screen(g.corner_at)), "the corner handle is drawn"); - app.ui.view.bounding_box = false; - assert!(!circle_at(&shapes(&mut app, &ctx), xf.to_screen(g.rows_at)), "hidden with the bounding box"); - app.ui.view.bounding_box = true; - frame(&mut app, &ctx, vec![]); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let drag = |app: &mut VectorcraftApp, from: Pos2, by: egui::Vec2| { - frame(app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); - for k in 1..=4 { - frame(app, &ctx, vec![egui::Event::PointerMoved(from + by * k as f32 / 4.0)]); - } - frame(app, &ctx, vec![button(from + by, false)]); - }; - // Two tiles of 50 pt down, at 1.5 px a point. - drag(&mut app, xf.to_screen(g.rows_at), vec2(0.0, 150.0)); - let after = grid(&app); - assert_eq!((after.0, after.1, after.2), (5, 3, 10.0)); - assert_eq!(after.3, before.3, "the source keeps its size"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(grid(&app).0, 3, "one undo step"); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(grid(&app).0, 5); - // The slider between the first two columns, 6 px (4 pt) right: it stays halfway across a - // gap 8 pt wider, 18 pt of spacing. - frame(&mut app, &ctx, vec![]); - let g = handles(&mut app); - drag(&mut app, xf.to_screen(g.h_slider.unwrap()), vec2(6.0, 0.0)); - assert_eq!(grid(&app).2, 18.0); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!((grid(&app).0, grid(&app).2), (5, 10.0), "the spacing's own undo step"); - } - - #[test] - fn top_bounding_box_handle_scales_a_default_mirror_repeat() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); - app.run("object.repeat.mirror", json!({})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - - let initial = app.selection_bounds().unwrap(); - let bx = app.selection_box().unwrap(); - let top = bx.to_doc() * vectorcraft_tools::bbox::Handle::Top.pos(bx.rect); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let from = xf.to_screen(top); - let to = from + vec2(0.0, -30.0); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); - frame(&mut app, &ctx, vec![button(to, false)]); - - let scaled = app.selection_bounds().unwrap(); - assert!(scaled.height() > initial.height() + 10.0, "Top handle should scale the repeat: {initial:?} -> {scaled:?}"); - assert!((scaled.width() - initial.width()).abs() < 0.01, "Top handle should not scale width: {initial:?} -> {scaled:?}"); - } - - /// #1236: Curvature edits points, so existing paths show one circular marker per point and - /// no direction handles; switching back to Direct Selection keeps its normal handles. - #[test] - fn curvature_points_of_an_existing_path_have_no_direction_handles() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = vectorcraft_doc::NodeId( - app.session - .execute( - "path.curvature", - &json!({"points": [ - {"x": 60, "y": 200}, {"x": 120, "y": 80}, {"x": 210, "y": 160}, {"x": 280, "y": 60}, {"x": 350, "y": 180} - ]}), - ) - .unwrap()["id"] - .as_u64() - .unwrap(), - ); - let document = app.session.active().unwrap().doc.clone(); - let ctx = egui::Context::default(); - app.select_tool("curvature"); - let drawn = shapes(&mut app, &ctx); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let points: Vec<_> = document.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| xf.to_screen(a.p)).collect(); - let marker = |shape: &Shape| matches!(shape, Shape::Circle(c) if points.iter().any(|p| p.distance(c.center) < 0.01)); - assert_eq!(drawn.iter().filter(|s| marker(s)).count(), 5, "one circular marker at every curve point"); - assert!( - !drawn.iter().any(|s| matches!(s, Shape::Rect(r) if points.iter().any(|p| p.distance(r.rect.center()) < 0.01))), - "no square at a curve point" - ); - let color = c32(document.layer_color(id)); - let handles = |shapes: &[Shape]| shapes.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count(); - assert_eq!(handles(&drawn), 0, "Curvature hides the direction lines"); - app.session.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"})).unwrap(); - let partial = shapes(&mut app, &ctx); - assert_eq!(partial.iter().filter(|s| marker(s)).count(), 5, "other curve points stay visible"); - assert_eq!(handles(&partial), 0, "a single selected point still shows no direction lines"); - app.ui.view.edges = false; - assert_eq!(shapes(&mut app, &ctx).iter().filter(|s| marker(s)).count(), 0, "Hide Edges still hides an existing path's markers"); - app.ui.view.edges = true; - app.select_tool("directSelection"); - assert_eq!(handles(&shapes(&mut app, &ctx)), 2, "Direct Selection keeps the selected point's two handles"); - app.select_tool("pen"); - assert_eq!(handles(&shapes(&mut app, &ctx)), 2, "Pen keeps the selected point's two handles"); - assert_eq!( - serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), - serde_json::to_value(&document).unwrap(), - "tool switches and painting don't change the curve" - ); - } - - /// While drawing, Curvature's current-point feedback takes the place of generic selection - /// markers: no duplicate squares or handles, and the path is unchanged by drawing a frame. - #[test] - fn curvature_drawing_shows_its_points_once() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - shapes(&mut app, &ctx); - app.select_tool("curvature"); - for (x, y) in [(60.0, 200.0), (120.0, 80.0), (210.0, 160.0), (280.0, 60.0), (350.0, 180.0)] { - let view = app.view_info(); - for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { - app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, x, y), view).unwrap(); - } - } - let st = app.session.active().unwrap(); - let id = st.selection.objects[0]; - let document = st.doc.clone(); - let selection = st.selection.clone(); - let drawn = shapes(&mut app, &ctx); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let points: Vec<_> = document.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| xf.to_screen(a.p)).collect(); - let markers: Vec<_> = drawn - .iter() - .filter_map(|s| match s { - Shape::Circle(c) if points.iter().any(|p| p.distance(c.center) < 0.01) => Some(c), - _ => None, - }) - .collect(); - assert_eq!(markers.len(), 5, "one marker per point, including the current point"); - assert_eq!(markers.iter().filter(|c| c.fill != Color32::WHITE).count(), 1, "only the last touched point is filled"); - assert!( - !drawn.iter().any(|s| matches!(s, Shape::Rect(r) if points.iter().any(|p| p.distance(r.rect.center()) < 0.01))), - "neither tool nor selection draws anchor squares" - ); - let color = c32(document.layer_color(id)); - assert!(!drawn.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)), "no direction lines"); - app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap(); - assert_eq!( - shapes(&mut app, &ctx) - .iter() - .filter(|s| matches!(s, Shape::Circle(c) if c.radius == 5.0 && points.iter().any(|p| p.distance(c.center) < 0.01))) - .count(), - 5, - "point markers respect Anchor Size" - ); - assert_eq!(app.session.active().unwrap().selection, selection); - assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), serde_json::to_value(&document).unwrap()); - } - - /// Selection & Anchor Display › Handles and Show handles when multiple anchors are selected - /// (#394): handle ends drawn solid, hollow or large; with the latter off, a second selected - /// anchor hides the handles. - #[test] - fn handle_style_and_handles_of_multiple_anchors() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); - app.session.select_tool("directSelection", app.view_info()).unwrap(); - let ctx = egui::Context::default(); - let select = |app: &mut VectorcraftApp, anchors: serde_json::Value| { - app.session.execute("select.anchors", &json!({"id": id, "anchors": anchors, "mode": "set"})).unwrap(); - }; - // The handle ends: (radius, filled white). - let dots = |app: &mut VectorcraftApp| -> Vec<(f32, bool)> { - shapes(app, &ctx) - .iter() - .filter_map(|s| match s { - Shape::Circle(c) if c.fill != Color32::TRANSPARENT => Some((c.radius, c.fill == Color32::WHITE)), - _ => None, - }) - .collect() - }; - select(&mut app, json!([[0, 0]])); - assert_eq!(dots(&mut app), [(2.75, false); 2], "solid by default"); - app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "hollow"})).unwrap(); - assert_eq!(dots(&mut app), [(2.75, true); 2]); - app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "large"})).unwrap(); - assert_eq!(dots(&mut app), [(4.25, false); 2]); - select(&mut app, json!([[0, 0], [0, 1]])); - assert_eq!(dots(&mut app).len(), 4, "both anchors' handles"); - app.session.execute("prefs.set", &json!({"key": "showHandlesMultipleAnchors", "value": false})).unwrap(); - assert!(dots(&mut app).is_empty(), "off: none for two anchors"); - select(&mut app, json!([[0, 1]])); - assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them"); - } - - /// #3219: a selected path made into a guide is deselected when guides are hidden, so the - /// selection overlay cannot reveal the hidden path. - #[test] - fn hide_guides_deselects_selected_guide_paths() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - app.run("view.guides.make", json!({})).unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(id)]); - - app.run("view.guides", json!({})).unwrap(); - - assert!(app.session.active().unwrap().selection.objects.is_empty()); - } - - /// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer does not - /// show with View › Snap to Grid or View › Pixel Preview on, even with Smart Guides and the option on. - /// #3219: guide paths hidden by View > Hide Guides are not hittable and cannot reveal - /// themselves through Smart Guides object highlighting. - #[test] - fn hidden_guide_paths_have_no_hover_highlight() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - app.run("view.guides.make", json!({})).unwrap(); - app.run("select.none", json!({})).unwrap(); - app.select_tool("selection"); - app.ui.view.guides = false; - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(100.0, 150.0)))]); - assert!(drawn_in(&mut app, &ctx, color, 1.5).is_empty(), "a hidden guide has no hover highlight"); - } - - #[test] - fn snap_to_grid_and_pixel_preview_hide_the_hover_outline() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0)); - let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)))); - let outlines = |app: &mut VectorcraftApp| { - frame(app, &ctx, vec![egui::Event::PointerMoved(over)]); - shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count() - }; - assert!(app.ui.view.smart_guides && app.session.prefs.object_highlighting); - assert!(outlines(&mut app) > 0, "highlighted with Snap to Grid and Pixel Preview off"); - app.run("view.snapToGrid", json!({})).unwrap(); - assert!(app.ui.view.snap_to_grid); - assert_eq!(outlines(&mut app), 0, "Snap to Grid on"); - app.run("view.snapToGrid", json!({})).unwrap(); - assert!(outlines(&mut app) > 0, "Snap to Grid off again"); - app.run("view.pixelPreview", json!({})).unwrap(); - assert!(app.ui.view.pixel_preview); - assert_eq!(outlines(&mut app), 0, "Pixel Preview on"); - app.run("view.pixelPreview", json!({})).unwrap(); - assert!(outlines(&mut app) > 0, "Pixel Preview off again"); - } - - /// #2452: while the view scrolls under a resting pointer, the art under it isn't hit-tested: - /// no hover outline, and the tool's last cursor stays. Once the view has been still for - /// [`HOVER_STILL_SECS`], the outline comes back without the pointer moving. - #[test] - fn a_moving_view_leaves_the_art_under_the_pointer_unhighlighted_until_it_stops() { - fn flat(s: &Shape, out: &mut Vec) { - match s { - Shape::Vec(v) => v.iter().for_each(|s| flat(s, out)), - s => out.push(s.clone()), - } - } - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)))); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let mut time = 0.0; - let mut outlines = |app: &mut VectorcraftApp, events: Vec, dt: f64| { - time += dt; - let mut out = - ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - let mut shapes = vec![]; - out.shapes.iter().for_each(|c| flat(&c.shape, &mut shapes)); - shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count() - }; - outlines(&mut app, vec![], 0.0); - let at = app.canvas_rect.unwrap().center(); - assert!(outlines(&mut app, vec![egui::Event::PointerMoved(at)], 0.016) > 0, "the art under the resting pointer is highlighted"); - let wheel = |dy: f32| egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, dy), - phase: egui::TouchPhase::Move, - modifiers: Default::default(), - source: egui::MouseWheelSource::Unknown, - }; - for k in 0..12 { - let before = app.view().unwrap().center; - let shown = outlines(&mut app, vec![wheel(if k % 2 == 0 { 4.0 } else { -4.0 })], 0.016); - assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view"); - assert_eq!(shown, 0, "frame {k}: no hover outline while the view moves"); - } - assert_eq!(outlines(&mut app, vec![], HOVER_STILL_SECS / 2.0), 0, "the view has only just stopped"); - assert!(outlines(&mut app, vec![], HOVER_STILL_SECS) > 0, "still again: the outline comes back"); - // Selected, the rectangle gives the move cursor near its top edge. Scrolled up past the - // pointer, it stays the move cursor until the view stops, then the arrow. - app.session.execute("select.all", &json!({})).unwrap(); - outlines(&mut app, vec![egui::Event::PointerMoved(at - vec2(0.0, 200.0))], 0.016); - let cursor = |app: &VectorcraftApp| app.canvas.hover_cursor.map(|(_, _, c)| c); - assert_eq!(cursor(&app), Some(Cursor::Move)); - for k in 0..12 { - let before = app.view().unwrap().center; - outlines(&mut app, vec![wheel(-80.0)], 0.016); - assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view"); - assert_eq!(cursor(&app), Some(Cursor::Move), "frame {k}: the cursor stays while the view moves"); - } - // The wheel's smooth scrolling runs on for some frames; then the view rests. A frame of it - // that scrolls less than half a device pixel leaves the view where it is. - for _ in 0..200 { - let before = app.view().unwrap().center; - outlines(&mut app, vec![], 0.016); - if app.view().unwrap().center == before && !ctx.input(|i| i.is_scrolling()) { - break; - } - } - assert_eq!(cursor(&app), Some(Cursor::Move), "the view has only just stopped"); - outlines(&mut app, vec![], HOVER_STILL_SECS + 0.01); - assert_eq!(cursor(&app), Some(Cursor::Arrow), "still again: the pointer at {:?} is past the rectangle", app.hover_doc); - } - - /// The wheel, a scroll bar and the Hand tool move the view by whole device pixels at 1.25 - /// device pixels a point, in a zoomed and turned view: every frame's render view is the first - /// one's panned by whole pixels, which the renderer's cropped effect rasters need to be drawn - /// again, and the wheel's and the scroll bar's scrolls add up to what they were given within a - /// device pixel, as does the drag. - #[test] - fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { - const PPP: f32 = 1.25; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let mut time = 0.0; - let mut frame = |app: &mut VectorcraftApp, events: Vec| { - time += 0.016; - let mut input = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; - input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(PPP); - ctx.run_ui(input, |ui| show(app, ui)).textures_delta.clear(); - }; - frame(&mut app, vec![]); - { - let v = app.view_mut().unwrap(); - v.zoom = 4.0; - v.rotation = 30.0; - v.center = Point::new(200.3, 150.7); - } - frame(&mut app, vec![]); - // The pan of the canvas's render view (the translation of the view it renders through, less - // the half of its size), in device pixels. - let pan = |app: &VectorcraftApp| { - let v = app.view().unwrap(); - (Affine::rotate(v.rotation.to_radians()) * Affine::scale(v.zoom * f64::from(PPP)) * Affine::translate(-v.center.to_vec2())).translation() - }; - let wheel = |d: egui::Vec2| egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: d, - phase: egui::TouchPhase::Move, - modifiers: Default::default(), - source: egui::MouseWheelSource::Unknown, - }; - let whole = |by: vectorcraft_geom::Vec2| (by - vectorcraft_geom::Vec2::new(by.x.round(), by.y.round())).hypot() < 1e-6; - let rect = app.canvas_rect.unwrap(); - let track = scroll_tracks(rect, screen)[0].center(); - let deltas = [vec2(0.37, 11.13), vec2(-12.71, 0.0), vec2(0.0, 7.45), vec2(21.9, -3.83), vec2(0.21, 0.21)]; - for (name, at) in [("the wheel", rect.center()), ("the horizontal scroll bar", track)] { - frame(&mut app, vec![egui::Event::PointerMoved(at)]); - let start = pan(&app); - let mut given = vectorcraft_geom::Vec2::ZERO; - for (k, d) in deltas.iter().enumerate() { - frame(&mut app, vec![wheel(*d)]); - // Over the scroll bar, the wheel scrolls along it. - given += if at == track { - vectorcraft_geom::Vec2::new(f64::from(d.x + d.y), 0.0) - } else { - vectorcraft_geom::Vec2::new(f64::from(d.x), f64::from(d.y)) - }; - let by = pan(&app) - start; - assert!(whole(by), "{name}, scroll {k}: the view moved {by:?} device pixels"); - } - // The wheel's smooth scrolling runs on for some frames. - for k in 0..60 { - frame(&mut app, vec![]); - let by = pan(&app) - start; - assert!(whole(by), "{name}, frame {k} after the scrolls: the view moved {by:?} device pixels"); - } - let by = pan(&app) - start; - let want = given * f64::from(PPP); - assert!(by.hypot() > 10.0, "{name}: the view moved {by:?} device pixels"); - assert!((by.x - want.x).abs() <= 1.0 && (by.y - want.y).abs() <= 1.0, "{name}: moved {by:?} device pixels for {want:?}"); - } - // The horizontal scroll bar's thumb dragged to points between device pixels: the view moves - // `ratio` points for each point the thumb moves, the other way. - let (_, span, from) = scroll_bar(&mut app, 0); - let ratio = f64::from(span.ratio(scroll_tracks(rect, screen)[0].width())); - assert!(ratio > 1.0, "at 400% the view moves further than the thumb: {ratio}"); - frame(&mut app, vec![egui::Event::PointerMoved(from)]); - let start = pan(&app); - frame(&mut app, vec![primary(from, true)]); - for (k, dx) in [7.37_f32, 12.81, 3.33].into_iter().enumerate() { - frame(&mut app, vec![egui::Event::PointerMoved(from + vec2(dx, 0.0))]); - let by = pan(&app) - start; - assert!(whole(by), "thumb drag {k}: the view moved {by:?} device pixels"); - let want = -f64::from(dx) * ratio * f64::from(PPP); - assert!((by.x - want).abs() <= 1.0 && by.y.abs() <= 1.0, "thumb drag {k}: moved {by:?} device pixels for {want}"); - } - frame(&mut app, vec![primary(from + vec2(3.33, 0.0), false)]); - // A Hand tool drag to points between device pixels. - app.select_tool("hand"); - let p0 = rect.center() + vec2(0.3, 0.1); - frame(&mut app, vec![egui::Event::PointerMoved(p0)]); - let start = pan(&app); - frame(&mut app, vec![primary(p0, true)]); - for (k, d) in [vec2(13.37, -7.61), vec2(40.19, 3.33), vec2(-5.55, 22.71)].into_iter().enumerate() { - frame(&mut app, vec![egui::Event::PointerMoved(p0 + d)]); - let by = pan(&app) - start; - assert!(whole(by), "drag {k}: the view moved {by:?} device pixels"); - let want = vectorcraft_geom::Vec2::new(f64::from(d.x), f64::from(d.y)) * f64::from(PPP); - assert!((by.x - want.x).abs() <= 1.0 && (by.y - want.y).abs() <= 1.0, "drag {k}: moved {by:?} device pixels for {want:?}"); - } - frame(&mut app, vec![primary(p0 + vec2(-5.55, 22.71), false)]); - } - - /// The part of a device pixel a scroll leaves is added to the next scroll of the same view, and - /// dropped when the view was zoomed, turned or moved in between, when the next scroll is in - /// another document, or when the device pixels a point changed. - #[test] - fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { - let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0 }; - let mut v = view; - let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default()); - assert_eq!(v.center, view.center, "less than half a pixel does not move the view"); - assert!((rest.x - 0.4).abs() < 1e-6 && (rest.y + 0.3).abs() < 1e-6); - let mut again = v; - scroll_by(&mut again, 1, 1.0, vec2(0.4, -0.3), rest); - assert_eq!(again.center, Point::new(99.0, 101.0), "the second scroll adds the first's part"); - let changed = [ - ("zoomed", View { zoom: 2.0, ..v }, 1, 1.0), - ("turned", View { rotation: 90.0, ..v }, 1, 1.0), - ("fitted or moved", View { center: Point::new(150.0, 120.0), ..v }, 1, 1.0), - ("another document", v, 2, 1.0), - ("another display scale", v, 1, 1.25), - ]; - for (name, before, doc, ppp) in changed { - let mut after = before; - let left = scroll_by(&mut after, doc, ppp, vec2(0.4 / ppp, -0.3 / ppp), rest); - assert_eq!(after.center, before.center, "{name}: the first scroll's part is dropped"); - assert!((left.x - 0.4).abs() < 1e-6 && (left.y + 0.3).abs() < 1e-6, "{name}: {left:?}"); - } - } - - /// View › Snap to Glyph toggles through the View menu command (#3124). - #[test] - fn snap_to_glyph_menu_command_toggles_the_view_flag() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - assert!(!app.ui.view.snap_to_glyph); - app.run("view.snapToGlyph", json!({})).unwrap(); - assert!(app.ui.view.snap_to_glyph); - app.run("view.snapToGlyph", json!({})).unwrap(); - assert!(!app.ui.view.snap_to_glyph); - } - - /// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer - /// shows with Smart Guides on and the option on, and not otherwise. - #[test] - fn object_highlighting_preference_hides_the_hover_outline() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0)); - let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)))); - // The hover outline: 1.5 px lines in the layer colour. - let outlines = |app: &mut VectorcraftApp| { - frame(app, &ctx, vec![egui::Event::PointerMoved(over)]); - shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count() - }; - assert!(outlines(&mut app) > 0, "highlighted by default"); - app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": false})).unwrap(); - assert_eq!(outlines(&mut app), 0, "the option off"); - app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": true})).unwrap(); - app.ui.view.smart_guides = false; - assert_eq!(outlines(&mut app), 0, "Smart Guides off"); - } - - /// Hide Corner Widget for angles greater than (#394): a rectangle's 90° corners lose their - /// widgets below 90°. - #[test] - fn corner_widgets_hide_above_the_preference_angle() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - let widgets = |app: &mut VectorcraftApp| { - shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count() - }; - assert_eq!(widgets(&mut app), 4); - app.session.execute("prefs.set", &json!({"key": "hideCornerWidgetAbove", "value": 80})).unwrap(); - assert_eq!(widgets(&mut app), 0); - } - - /// View › Hide Edges hides a live rectangle's corner widgets, and the Selection tool then - /// neither shows the corner cursor over one's place nor rounds the corners from it. With - /// edges shown, a drag from the same widget rounds them. - #[test] - fn hidden_corner_widgets_do_not_answer_the_pointer() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - let id = vectorcraft_doc::NodeId(id); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.select_tool("selection"); - let widget = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap() - }; - let radius = |app: &VectorcraftApp| { - let c = vectorcraft_doc::LiveCorners::of(app.session.active().unwrap().doc.node(id).unwrap()).unwrap(); - c.style(&c.all()).0 - }; - let drag = |app: &mut VectorcraftApp, from: Point| { - let (view, to) = (app.view_info(), from + vectorcraft_geom::Vec2::new(10.0, 10.0)); - for (kind, at) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { - app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap(); - } - }; - app.ui.view.edges = false; - let at = widget(&app); - let view = app.view_info(); - assert_ne!(app.session.cursor(at, Mods::default(), view), Cursor::CornerRadius); - drag(&mut app, at); - assert_eq!(radius(&app), Some(0.0), "a press on a hidden widget does not round the corners"); - app.ui.view.edges = true; - let at = widget(&app); - let view = app.view_info(); - assert_eq!(app.session.cursor(at, Mods::default(), view), Cursor::CornerRadius); - drag(&mut app, at); - assert!(radius(&app).is_some_and(|r| r > 0.0), "{:?}", radius(&app)); - } - - /// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection - /// tool then answers neither: a double-click on the widget's place leaves point type as it is, - /// and a drag from a corner handle's place does not scale. With edges shown, both answer. - #[test] - fn a_hidden_bounding_box_and_type_widget_do_not_answer_the_pointer() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap(); - let id = vectorcraft_doc::NodeId(id); - app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.select_tool("selection"); - let bx = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - st.doc.oriented_bounds(&st.selection.objects, app.session.prefs.use_preview_bounds).unwrap() - }; - let widget = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &bx(app), app.view_info().zoom).unwrap() - }; - let area = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(id).unwrap().kind { - NodeKind::Text(t) => matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }), - k => panic!("not type: {k:?}"), - }; - let double_click = |app: &mut VectorcraftApp, at: Point| { - let view = app.view_info(); - for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { - app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap(); - } - }; - let width = |app: &VectorcraftApp| bx(app).rect.width(); - let scale = |app: &mut VectorcraftApp| { - let b = bx(app); - let corner = b.to_doc() * Point::new(b.rect.x1, b.rect.y1); - let (view, to) = (app.view_info(), corner + vectorcraft_geom::Vec2::new(30.0, 0.0)); - for (kind, at) in [(PointerKind::Down, corner), (PointerKind::Drag, to), (PointerKind::Up, to)] { - app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap(); - } - app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - }; - let w = widget(&app); - app.ui.view.edges = false; - let view = app.view_info(); - assert_ne!(app.session.cursor(w.at, Mods::default(), view), Cursor::TypeWidget); - double_click(&mut app, w.at); - assert!(!area(&app), "a double-click on the hidden widget leaves point type"); - app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); - let before = width(&app); - scale(&mut app); - assert!((width(&app) - before).abs() < 1e-6, "a drag from a hidden handle does not scale: {before} -> {}", width(&app)); - app.ui.view.edges = true; - let w = widget(&app); - let view = app.view_info(); - assert_eq!(app.session.cursor(w.at, Mods::default(), view), Cursor::TypeWidget); - scale(&mut app); - assert!(width(&app) > before + 1.0, "a drag from the handle scales: {before} -> {}", width(&app)); - let w = widget(&app); - double_click(&mut app, w.at); - assert!(area(&app), "a double-click on the widget converts to area type"); - } - - /// View → Show Live Paint Gaps draws the gaps of each selected Live Paint group in the group's - /// Gap Preview Color, and nothing for a group or path that is not a selected Live Paint group; - /// Hide Live Paint Gaps and Hide Edges hide them. - #[test] - fn show_live_paint_gaps_draws_the_selected_groups_gaps_in_their_preview_color() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // Open paths round a square, each ending 2 pt short of its start: one Small Gap each. - let square = |app: &mut VectorcraftApp, x: i64| { - let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(dx, y)| json!({"x": x + dx, "y": y})); - app.session.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap() - }; - let mut groups = vec![]; - for (x, color) in [(0, "#123456"), (150, "#654321")] { - let p = square(&mut app, x); - app.session.execute("select.set", &json!({"ids": [p]})).unwrap(); - let g = app.session.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("livePaint.gapOptions", &json!({"id": g, "color": color})).unwrap(); - groups.push(g); - } - let plain = square(&mut app, 300); - let ctx = egui::Context::default(); - let gaps = |app: &mut VectorcraftApp, rgb: [u8; 3]| { - let solid = egui::epaint::ColorMode::Solid(c32(rgb)); - shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == 2.0)).count() - }; - let (first, second) = ([0x12, 0x34, 0x56], [0x65, 0x43, 0x21]); - // The color the plain path's gaps would be drawn in. - let plain_color = vectorcraft_doc::GapOptions::default().color; - app.session.execute("select.set", &json!({"ids": [groups[0], plain]})).unwrap(); - assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 0), "off until the item is chosen"); - // View → Show Live Paint Gaps runs the command. - let tree = crate::menus::menu_tree(); - let (_, view) = tree.iter().find(|(t, _)| *t == "View").unwrap(); - assert!(view.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Show Live Paint Gaps", "view.livePaintGaps", _))), "{view:?}"); - crate::menus::invoke(&mut app, "view.livePaintGaps", json!({})); - assert!(app.ui.view.live_paint_gaps); - assert_eq!( - (gaps(&mut app, first), gaps(&mut app, second), gaps(&mut app, plain_color)), - (1, 0, 0), - "the selected group only; the plain path shows none" - ); - app.session.execute("select.set", &json!({"ids": [groups[1]]})).unwrap(); - assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 1)); - // The lines follow the group's Gap Preview Color and Paint Stops At size. - app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "color": "#123456"})).unwrap(); - assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (1, 0)); - app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": 1})).unwrap(); - assert_eq!(gaps(&mut app, first), 0, "a 2 pt gap is wider than 1 pt"); - app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": "small"})).unwrap(); - app.ui.view.edges = false; - assert_eq!(gaps(&mut app, first), 0, "Hide Edges hides them"); - app.ui.view.edges = true; - assert_eq!(gaps(&mut app, first), 1); - app.run("view.livePaintGaps", json!({})).unwrap(); - assert!(!app.ui.view.live_paint_gaps); - assert_eq!(gaps(&mut app, first), 0, "View → Hide Live Paint Gaps"); - // The setting is kept with the interface state; a state saved before it existed loads with it off. - app.ui.view.live_paint_gaps = true; - let mut saved = serde_json::to_value(&app.ui).unwrap(); - assert_eq!(saved["view"]["live_paint_gaps"], json!(true)); - saved["view"].as_object_mut().unwrap().remove("live_paint_gaps"); - assert!(!serde_json::from_value::(saved).unwrap().view.live_paint_gaps); - } - - /// #511: a star shows a widget in each of its ten corners with Direct Selection (none with - /// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them. - #[test] - fn a_star_shows_corner_widgets_with_direct_selection() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.star", &json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap(); - let ctx = egui::Context::default(); - let widgets = |app: &mut VectorcraftApp| { - shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count() - }; - app.select_tool("selection"); - assert_eq!(widgets(&mut app), 0); - app.select_tool("directSelection"); - assert_eq!(widgets(&mut app), 10); - app.run("view.cornerWidget", json!({})).unwrap(); - assert_eq!(widgets(&mut app), 0); - } - - /// The type widget beside selected type's bounding box: hollow (white) on point type, filled - /// in the layer colour on area type. - #[test] - fn the_type_widget_shows_which_kind_the_type_is() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let layer = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))); - let ctx = egui::Context::default(); - let widget = |app: &mut VectorcraftApp| -> Vec { - let r = vectorcraft_tools::typewidget::RADIUS_PX; - shapes(app, &ctx) - .iter() - .filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill != Color32::TRANSPARENT).then_some(c.fill) } else { None }) - .collect() - }; - assert_eq!(widget(&mut app), vec![Color32::WHITE]); - app.session.execute("type.convertToAreaType", &json!({})).unwrap(); - assert_eq!(widget(&mut app), vec![layer]); - app.ui.view.bounding_box = false; - assert!(widget(&mut app).is_empty(), "no bounding box, no widget"); - } - - /// A selected live ellipse shows its two pie widgets with the Selection tool while the - /// bounding box shows. - #[test] - fn a_selected_ellipse_shows_its_pie_widgets() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); - app.session.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap(); - let ctx = egui::Context::default(); - let widgets = |app: &mut VectorcraftApp| { - let r = vectorcraft_tools::pie::RADIUS_PX; - shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == r && c.fill == Color32::WHITE)).count() - }; - app.select_tool("selection"); - assert_eq!(widgets(&mut app), 2); - app.select_tool("directSelection"); - assert_eq!(widgets(&mut app), 0); - app.select_tool("selection"); - app.run("view.boundingBox", json!({})).unwrap(); - assert_eq!(widgets(&mut app), 0, "no bounding box, no widgets"); - } - - /// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. - fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { - let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| hidden_chars_overlay(app, ui.painter(), xf)); - out.textures_delta.clear(); - out.shapes.into_iter().map(|c| c.shape).collect() - } - - /// Show Hidden Characters lays out type once: a frame keeps the marks of the frame before, and - /// after an edit only the object edited is laid out again. - #[test] - fn show_hidden_characters_lays_out_only_type_that_changed() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ids: Vec<_> = (0..3) - .map(|i| app.session.execute("text.create", &json!({"x": 20, "y": 40 + 80 * i, "text": "a b\nc d"})).unwrap()["id"].as_u64().unwrap()) - .map(vectorcraft_doc::NodeId) - .collect(); - let ctx = egui::Context::default(); - let xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 }; - // `frame` draws one frame and returns each object's marks and whether that frame found the - // fonts and hyphenation exceptions of the frame before. - let frame = |app: &mut VectorcraftApp| { - let inputs = app.canvas.hidden_chars.inputs.clone(); - hidden_chars(app, &ctx, &xf); - let kept = &app.canvas.hidden_chars; - (kept.inputs == inputs, ids.iter().map(|id| kept.marks[id].1.clone()).collect::>()) - }; - // `frames` draws a frame, runs `edit` and draws the next frame. Tests running alongside add - // fonts and set hyphenation exceptions, which lays all type out again: `frames` draws both - // frames again until neither the fonts nor the exceptions changed in between. - let frames = |app: &mut VectorcraftApp, edit: &dyn Fn(&mut VectorcraftApp)| { - (0..100) - .find_map(|_| { - let (_, before) = frame(app); - edit(app); - let (same, after) = frame(app); - same.then_some((before, after)) - }) - .unwrap() - }; - let same = |a: &std::sync::Arc, b: &std::sync::Arc| std::sync::Arc::ptr_eq(a, b); - let (before, after) = frames(&mut app, &|_| {}); - assert!(before.iter().zip(&after).all(|(a, b)| same(a, b)), "a frame lays out no type"); - let (before, after) = frames(&mut app, &|app| { - app.session.execute("text.setText", &json!({"id": ids[1].0, "text": "a b c"})).unwrap(); - }); - assert!(!same(&before[1], &after[1]), "the edited object is laid out again"); - assert!(same(&before[0], &after[0]) && same(&before[2], &after[2]), "the others are not"); - } - - /// The marks Show Hidden Characters keeps follow the type: after an edit, a zoom and a font - /// that arrives, a frame draws what a new overlay draws, and the marks moved. - #[test] - fn show_hidden_characters_follows_edits_zooms_and_fonts() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // The type is set in Bold of a family that is not installed and whose name starts with a - // dot; the font menus do not list such names. Source Sans 3 Bold stands in for it until the - // family arrives below with a Regular face only. - let text = json!({"x": 20, "y": 60, "text": "one two\nthree four", "font": ".Hidden Marks", "style": "Bold"}); - let id = app.session.execute("text.create", &text).unwrap()["id"].clone(); - let ctx = egui::Context::default(); - let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 }; - // `drawn` draws a frame with what the overlay kept and checks it against a new overlay. - let drawn = |app: &mut VectorcraftApp, xf: &Xf| { - let kept = hidden_chars(app, &ctx, xf); - app.canvas.hidden_chars = Default::default(); - assert_eq!(hidden_chars(app, &ctx, xf), kept, "as a new overlay draws them"); - kept - }; - let start = drawn(&mut app, &xf); - assert!(start.len() > 2, "{start:?}"); - app.session.execute("text.setText", &json!({"id": id, "text": "one two three\nfour"})).unwrap(); - let edited = drawn(&mut app, &xf); - assert_ne!(edited, start, "the edit moved the marks"); - xf.zoom = 2.0; - let zoomed = drawn(&mut app, &xf); - assert_ne!(zoomed, edited, "zoomed in"); - assert!(vectorcraft_text::FontDb::global().add_font(vectorcraft_testkit::fonts::renamed(".Hidden Marks")) > 0, "the family is new"); - assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived"); - } - - /// In a rotated view Show Hidden Characters draws the marks of type whose turned outline reaches - /// into the canvas, also when the box between two of its corners on screen does not. - #[test] - fn show_hidden_characters_draws_type_partly_on_a_rotated_canvas() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "a b c d e\nf g h"})).unwrap()["id"].as_u64().unwrap(); - let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); - let ctx = egui::Context::default(); - let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 2.0, center: b.center(), rot: 45f64.to_radians() }; - // The screen moves so that the canvas's left edge falls halfway between the right side of - // the box and the outline's rightmost corner, level with that corner. - let q = xf.quad(b); - let right = q.iter().copied().max_by(|a, c| a.x.total_cmp(&c.x)).unwrap(); - let shift = vec2(-(xf.rect_to_screen(b).right() + right.x) / 2.0, xf.rect.center().y - right.y); - xf.center -= xf.delta_to_doc(shift); - assert!(!xf.rect_to_screen(b).intersects(xf.rect), "the box is off the canvas"); - assert!(xf.quad(b).iter().any(|c| xf.rect.contains(*c)), "a corner is on it"); - assert!(!hidden_chars(&mut app, &ctx, &xf).is_empty(), "the marks are drawn"); - } - - /// The shapes Type → Show Hidden Characters draws in one canvas frame ([`show`]). - fn hidden_char_marks(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { - let blue = Color32::from_rgb(0x4f, 0x9d, 0xff); - shapes(app, ctx) - .into_iter() - .filter(|s| matches!(s, Shape::Circle(c) if c.fill == blue) || matches!(s, Shape::Text(t) if t.fallback_color == blue)) - .collect() - } - - /// Show Hidden Characters puts a dot over each space of a list paragraph and none over its - /// bullet, which stands for no character. - #[test] - fn hidden_characters_mark_no_space_at_a_bullet() { - let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, " Item two", vectorcraft_doc::CharStyle::default()); - let plain = HiddenMarks::of(&t).spaces.len(); - t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); - assert_eq!((plain, HiddenMarks::of(&t).spaces.len()), (2, 2)); - } - - /// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before. - /// Turning it off drops the marks with their text nodes, and turning it on again draws the - /// same marks. - #[test] - fn show_hidden_characters_off_releases_the_text_nodes() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let text = json!({"x": 20, "y": 60, "text": "one two\nthree four"}); - let id = vectorcraft_doc::NodeId(app.session.execute("text.create", &text).unwrap()["id"].as_u64().unwrap()); - let ctx = egui::Context::default(); - app.run("type.hiddenCharacters", json!({})).unwrap(); - let on = hidden_char_marks(&mut app, &ctx); - assert!(on.len() > 2, "{on:?}"); - // Tests running alongside add fonts and set hyphenation exceptions, which lays all type out - // again: frames are drawn until neither changed between two of them. - let kept = (0..100).any(|_| { - let (inputs, marks) = (app.canvas.hidden_chars.inputs.clone(), app.canvas.hidden_chars.marks[&id].1.clone()); - hidden_char_marks(&mut app, &ctx); - let after = &app.canvas.hidden_chars; - after.inputs == inputs && std::sync::Arc::ptr_eq(&after.marks[&id].1, &marks) - }); - assert!(kept, "a frame keeps the marks of the frame before"); - app.run("type.hiddenCharacters", json!({})).unwrap(); - assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "off"); - assert!(app.canvas.hidden_chars.marks.is_empty(), "no node is kept"); - app.run("type.hiddenCharacters", json!({})).unwrap(); - assert_eq!(hidden_char_marks(&mut app, &ctx), on, "on again: the same marks"); - } - - /// Closing the document drops the marks Show Hidden Characters kept with its text nodes, and - /// the same type in a new document shows the same marks. - #[test] - fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - app.run("type.hiddenCharacters", json!({})).unwrap(); - let open = |app: &mut VectorcraftApp| { - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "one two\nthree four"})).unwrap(); - hidden_char_marks(app, &ctx) - }; - let first = open(&mut app); - assert!(first.len() > 2, "{first:?}"); - assert_eq!(app.canvas.hidden_chars.marks.len(), 1, "the marks are kept with the text object's node"); - app.session.execute("file.close", &json!({})).unwrap(); - assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "the Home screen"); - assert!(app.canvas.hidden_chars.marks.is_empty(), "no node of the closed document is kept"); - assert_eq!(open(&mut app), first, "the same type in a new document"); - } - - /// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off, - /// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel - /// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a - /// trackpad pinch zoom about the pointer either way. - #[test] - fn zoom_with_mouse_wheel_preference() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - // A canvas frame through the host's input hook (it feeds the control channel's input). - let run = |app: &mut VectorcraftApp| { - let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - app.raw_input_hook(&ctx, &mut raw); - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - }; - run(&mut app); - let rect = app.canvas_rect.unwrap(); - let at = rect.center() + vec2(60.0, 40.0); - // One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how - // far the point under `at` moved). - let turn = |app: &mut VectorcraftApp, mods: &str| { - let before = *app.view().unwrap(); - if mods == "pinch" { - app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); - } else { - let mut p = json!({"x": at.x, "y": at.y, "dy": 1}); - for m in mods.split('+').filter(|m| !m.is_empty()) { - p[m] = json!(true); - } - crate::tests_synthetic::control(app, &ctx, "ui.wheel", p); - } - // egui spreads a notch over a few frames. - for _ in 0..40 { - run(app); - } - let after = *app.view().unwrap(); - let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); - (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) - }; - let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; - let (zoom, scroll, _) = turn(&mut app, ""); - assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})"); - assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); - assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer"); - assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer"); - app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); - assert!(zooms_in(turn(&mut app, "")), "on: the wheel zooms in about the pointer"); - assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel"); - assert!(zooms_in(turn(&mut app, "pinch")), "on: and a pinch"); - let (zoom, scroll, _) = turn(&mut app, "shift"); - assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: Shift-wheel scrolls up ({zoom}, {scroll:?})"); - let (zoom, scroll, _) = turn(&mut app, "cmd"); - assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); - } - - /// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a - /// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom. - #[test] - fn wheel_notches_zoom_in_a_glide() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); - let ctx = egui::Context::default(); - let run = |app: &mut VectorcraftApp| { - let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - app.raw_input_hook(&ctx, &mut raw); - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - }; - run(&mut app); - let at = app.canvas_rect.unwrap().center(); - let before = app.view().unwrap().zoom; - crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1})); - // The first frame the zoom moves in (the event may land a frame later). - let mut first = 1.0; - for _ in 0..3 { - run(&mut app); - first = app.view().unwrap().zoom / before; - if first != 1.0 { - break; - } - } - for _ in 0..60 { - run(&mut app); - } - let done = app.view().unwrap().zoom / before; - assert!(done > 1.01, "the notch zooms in ({done})"); - assert!(first > 1.0 && first < done, "the first frame takes only part of it ({first} of {done})"); - run(&mut app); - assert_eq!(app.view().unwrap().zoom / before, done, "and it stops"); - } - - /// In each frame where a wheel scroll moves the view, the hover point (read by the rulers' - /// pointer marks, the Info panel, the hover highlight and the tool cursor) is the document point - /// under the pointer in the view the canvas draws in that frame. - #[test] - fn the_hover_point_follows_a_wheel_scroll_in_the_frame_it_moves() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let at = rect.center() + vec2(60.0, 40.0); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); - let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at); - let wheel = egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, -60.0), - phase: egui::TouchPhase::Move, - modifiers: Default::default(), - source: egui::MouseWheelSource::Unknown, - }; - let mut events = vec![wheel]; - let mut moved = 0; - for k in 0..30 { - let before = app.view().unwrap().center; - frame(&mut app, &ctx, std::mem::take(&mut events)); - moved += usize::from(app.view().unwrap().center != before); - let hover = app.hover_doc.unwrap(); - assert!(hover.distance(under(&app)) < 1e-9, "frame {k}: {hover:?}, under the pointer {:?}", under(&app)); - } - assert!(moved > 0, "the wheel scrolls the view"); - } - - /// While a middle-button pan or a Rotate View drag moves the view, the hover point is the - /// document point under the pointer in the view the canvas draws in that frame. - #[test] - fn the_hover_point_follows_a_view_drag_in_the_frame_it_moves() { - for (tool, button) in [("rectangle", egui::PointerButton::Middle), ("rotateView", egui::PointerButton::Primary)] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool(tool); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let press = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; - let a = rect.center() + vec2(100.0, 20.0); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); - frame(&mut app, &ctx, vec![press(a, true)]); - let mut p = a; - for k in 1..=3 { - p = a + vec2(-20.0, 30.0) * k as f32; - let before = *app.view().unwrap(); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); - let after = *app.view().unwrap(); - assert!(after.center != before.center || after.rotation != before.rotation, "{tool}: the drag moves the view"); - let under = Xf::new(rect, &after).to_doc(p); - let hover = app.hover_doc.unwrap(); - assert!(hover.distance(under) < 1e-9, "{tool}, step {k}: {hover:?}, under the pointer {under:?}"); - } - frame(&mut app, &ctx, vec![press(p, false)]); - } - } - - /// A tool drag goes on at the document point under the pointer in the view a wheel scroll moved - /// to, in the frame the wheel scrolls. - #[test] - fn a_tool_drag_follows_a_wheel_scroll_in_the_frame_it_moves() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // With Zoom with Mouse Wheel on, Shift-wheel scrolls up and down in the frame it comes in. - app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); - app.ui.view.smart_guides = false; - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let (a, b) = (rect.center() - vec2(80.0, 60.0), rect.center() + vec2(40.0, 30.0)); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); - frame(&mut app, &ctx, vec![primary(a, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); - let before = *app.view().unwrap(); - let wheel = egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, -40.0), - phase: egui::TouchPhase::Move, - modifiers: egui::Modifiers::SHIFT, - source: egui::MouseWheelSource::Unknown, - }; - frame(&mut app, &ctx, vec![wheel]); - let after = *app.view().unwrap(); - assert!(after.center != before.center, "Shift-wheel scrolls the view"); - let want = Xf::new(rect, &after).to_doc(b); - let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); - let r = bounds(&app); - assert!((r.x1 - want.x).abs() < 1e-6 && ((r.y0 - want.y).abs() < 1e-6 || (r.y1 - want.y).abs() < 1e-6), "{r:?} reaches {want:?}"); - frame(&mut app, &ctx, vec![primary(b, false)]); - assert_eq!(bounds(&app), r, "the rectangle drawn"); - } - - /// Two fingers dragged together on a touch screen pan the canvas with them, under either - /// Zoom with Mouse Wheel setting, and draw nothing (#449). - #[test] - fn two_fingers_dragged_together_pan_the_canvas() { - for wheel_zooms in [false, true] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap(); - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let c = app.canvas_rect.unwrap().center(); - let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; - let fingers = |phase, d: egui::Vec2| vec![touch(1, phase, c - vec2(40.0, 0.0) + d), touch(2, phase, c + vec2(40.0, 0.0) + d)]; - let before = *app.view().unwrap(); - // egui-winit moves the pointer with the first finger, as here. - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]); - frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, Vec2::ZERO)); - for k in 1..=3 { - frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, vec2(10.0, 8.0) * k as f32)); - } - frame(&mut app, &ctx, fingers(egui::TouchPhase::End, vec2(30.0, 24.0))); - let after = *app.view().unwrap(); - let moved = (after.center - before.center) * after.zoom; - assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "no zoom: {wheel_zooms}"); - assert!((moved.x + 30.0).abs() < 0.5 && (moved.y + 24.0).abs() < 0.5, "the content follows the fingers ({wheel_zooms}): {moved:?}"); - assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 0, "nothing drawn"); - } - } - - /// A canvas frame at input time `t` (seconds) with `events`, for the trackpad rotation tests. - fn timed_frame(app: &mut VectorcraftApp, ctx: &egui::Context, t: f64, events: Vec) { - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), - time: Some(t), - events, - ..Default::default() - }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - } - - /// General › Trackpad Gesture to Rotate View, on by default: a two-finger rotation on a Mac - /// trackpad (egui's `Event::Rotate`, clockwise radians) turns the view about the pointer, also - /// in the frame of a pinch, which zooms about the same point. With the preference off, the view - /// does not turn. - #[test] - fn a_trackpad_rotation_turns_the_view_about_the_pointer() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - timed_frame(&mut app, &ctx, 0.0, vec![]); - let rect = app.canvas_rect.unwrap(); - let at = rect.center() + vec2(60.0, 40.0); - timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at)]); - let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at); - let before = (*app.view().unwrap(), under(&app)); - timed_frame(&mut app, &ctx, 0.2, vec![egui::Event::Rotate(0.25)]); - let after = *app.view().unwrap(); - assert!((after.rotation - before.0.rotation - 0.25f64.to_degrees()).abs() < 1e-4, "turned by 14.32°: {}", after.rotation); - assert!((after.zoom - before.0.zoom).abs() < 1e-12, "no zoom"); - assert!(under(&app).distance(before.1) < 1e-6, "the point under the pointer stays: {:?}", under(&app)); - // A pinch and a rotation in one frame, in a new gesture. - let before = (*app.view().unwrap(), under(&app)); - timed_frame(&mut app, &ctx, 1.0, vec![egui::Event::Zoom(1.25), egui::Event::Rotate(0.25)]); - let after = *app.view().unwrap(); - assert!((after.zoom / before.0.zoom - 1.25).abs() < 1e-6, "the pinch zooms: {}", after.zoom / before.0.zoom); - assert!((after.rotation - before.0.rotation - 0.25f64.to_degrees()).abs() < 1e-4, "and the rotation turns: {}", after.rotation); - assert!(under(&app).distance(before.1) < 1e-6, "about the same point: {:?}", under(&app)); - app.session.execute("prefs.set", &json!({"key": "trackpadRotateView", "value": false})).unwrap(); - let before = *app.view().unwrap(); - timed_frame(&mut app, &ctx, 2.0, vec![egui::Event::Rotate(0.25)]); - timed_frame(&mut app, &ctx, 2.1, vec![egui::Event::Rotate(0.25)]); - let after = *app.view().unwrap(); - assert!(after.rotation == before.rotation && after.center == before.center, "off: the view does not turn ({})", after.rotation); - } - - /// A trackpad rotation turns the view only once it reaches 5°, so a pinch that twists a little - /// only zooms. It then turns the view by all of it and follows each later rotation event, until - /// a quarter of a second passes without one. - #[test] - fn a_trackpad_rotation_turns_nothing_until_it_reaches_five_degrees() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - timed_frame(&mut app, &ctx, 0.0, vec![]); - let at = app.canvas_rect.unwrap().center() + vec2(60.0, 40.0); - timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at)]); - let rotate = |deg: f32| egui::Event::Rotate(deg.to_radians()); - let zoom = app.view().unwrap().zoom; - timed_frame(&mut app, &ctx, 1.0, vec![egui::Event::Zoom(1.25), rotate(1.0)]); - assert!(app.view().unwrap().zoom > zoom * 1.2, "the pinch zooms"); - timed_frame(&mut app, &ctx, 1.1, vec![egui::Event::Zoom(1.25), rotate(1.0)]); - timed_frame(&mut app, &ctx, 1.2, vec![egui::Event::Zoom(1.25), rotate(1.0)]); - assert_eq!(app.view().unwrap().rotation, 0.0, "3° in all does not turn the view"); - timed_frame(&mut app, &ctx, 1.3, vec![rotate(3.0)]); - assert!((app.view().unwrap().rotation - 6.0).abs() < 1e-4, "6° in all turns by 6°: {}", app.view().unwrap().rotation); - timed_frame(&mut app, &ctx, 1.4, vec![rotate(-1.0)]); - assert!((app.view().unwrap().rotation - 5.0).abs() < 1e-4, "then each event turns it: {}", app.view().unwrap().rotation); - timed_frame(&mut app, &ctx, 2.0, vec![rotate(3.0)]); - assert!((app.view().unwrap().rotation - 5.0).abs() < 1e-4, "a new rotation starts under 5° again: {}", app.view().unwrap().rotation); - } - - /// Rotation events that come while two fingers are on a touch screen (Safari on an iPad sends - /// them with the touches) do not turn the view. - #[test] - fn a_rotation_with_fingers_on_a_touch_screen_turns_nothing() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - timed_frame(&mut app, &ctx, 0.0, vec![]); - let c = app.canvas_rect.unwrap().center(); - let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; - timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]); - let before = app.view().unwrap().rotation; - let start = vec![touch(1, egui::TouchPhase::Start, c - vec2(40.0, 0.0)), touch(2, egui::TouchPhase::Start, c + vec2(40.0, 0.0))]; - timed_frame(&mut app, &ctx, 0.2, [start, vec![egui::Event::Rotate(0.25)]].concat()); - assert_eq!(app.view().unwrap().rotation, before, "the view does not turn"); - } - - /// General › Display Print Size at 100% Zoom (#394): `view.actualSize` is one document point per - /// screen point with it off, and 96 screen points per document inch with it on. - #[test] - fn view_actual_size_follows_display_print_size() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(460.0, 260.0))); - app.run("view.setZoom", json!({"zoom": 250})).unwrap(); - app.run("view.actualSize", json!({})).unwrap(); - assert!((app.view().unwrap().zoom - 1.0).abs() < 1e-12, "off by default: 100% is 1:1"); - app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap(); - app.run("view.actualSize", json!({})).unwrap(); - assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12, "an inch is 96 screen points"); - } - - /// Fit Artboard in Window fits the artboard as a rotated view shows it: at 45° every corner is - /// on the canvas and one is within the 30-point margin of an edge. At 0° the zoom is unchanged. - #[test] - fn fit_artboard_fits_the_artboard_turned_with_the_view() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 612, "height": 792})).unwrap(); - let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0)); - app.canvas_rect = Some(rect); - app.run("view.fitArtboard", json!({})).unwrap(); - assert!((app.view().unwrap().zoom - (940.0 / 612.0_f64).min(740.0 / 792.0)).abs() < 1e-12, "0°"); - app.run("view.rotate", json!({"angle": 45})).unwrap(); - app.run("view.fitArtboard", json!({})).unwrap(); - let board = app.session.active().unwrap().doc.artboards[0].rect; - let q = Xf::new(rect, app.view().unwrap()).quad(board); - assert!(q.iter().all(|c| rect.contains(*c)), "the turned artboard is on the canvas: {q:?}"); - let gap = q - .iter() - .map(|c| (c.x - rect.left()).min(rect.right() - c.x).min(c.y - rect.top()).min(rect.bottom() - c.y)) - .fold(f32::INFINITY, f32::min); - assert!(gap < 31.0, "and fills it but for the margin: {gap}"); - } - - /// Performance › Animated Zoom (#394): the Zoom tool dragged sideways zooms about where it was - /// pressed (right in, left out), held still it zooms on, and a click still steps. Off, or with - /// GPU Performance off, a drag zooms to the area dragged across. - #[test] - fn animated_zoom_scrubs_and_holds_else_the_zoom_tool_zooms_to_an_area() { - use egui::Event; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("zoom"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let at = rect.center() - vec2(100.0, 50.0); - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - // Press at `at`, move by `by` and hold for `frames` frames (a 60th of a second each), let go. - let press = |app: &mut VectorcraftApp, by: egui::Vec2, frames: usize| { - frame(app, &ctx, vec![Event::PointerMoved(at)]); - frame(app, &ctx, vec![button(at, true)]); - frame(app, &ctx, vec![Event::PointerMoved(at + by)]); - for _ in 0..frames { - frame(app, &ctx, vec![]); - } - frame(app, &ctx, vec![button(at + by, false)]); - }; - let doc_at = |app: &VectorcraftApp, p: Pos2| Xf::new(rect, app.view().unwrap()).to_doc(p); - let pinned = doc_at(&app, at); - let z0 = app.view().unwrap().zoom; - press(&mut app, vec2(100.0, 0.0), 0); - let z1 = app.view().unwrap().zoom; - assert!((z1 / z0 - 2.0).abs() < 1e-6, "100 points right doubles the zoom: {z0} → {z1}"); - assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press"); - press(&mut app, vec2(-100.0, 0.0), 0); - assert!((app.view().unwrap().zoom - z0).abs() < 1e-6, "and back to the left"); - press(&mut app, vec2(0.0, 0.0), 0); - assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z0, true), "a click steps"); - let z2 = app.view().unwrap().zoom; - press(&mut app, vec2(0.0, 0.0), 40); - assert!(app.view().unwrap().zoom > z2 * 1.2, "held, it zooms on"); - assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press"); - // Off (or without GPU Performance): the area dragged across fills the view. - for key in ["animatedZoom", "gpuPerformance"] { - app.run("prefs.reset", json!({})).unwrap(); - app.run("prefs.set", json!({"key": key, "value": false})).unwrap(); - app.view_mut().unwrap().zoom = z0; - press(&mut app, vec2(100.0, 50.0), 0); - let z = app.view().unwrap().zoom; - assert!((z - z0 * (rect.width() / 100.0).min(rect.height() / 50.0) as f64).abs() < 1e-6, "{key} off: {z0} → {z}"); - } - } - - /// With Animated Zoom off, the Zoom tool zooms a rotated view to the area dragged across: at 90° - /// and 180° a 100 × 50-point marquee fills the canvas as it does at 0°, centered on the canvas. - #[test] - fn the_zoom_tool_zooms_a_rotated_view_to_the_area_dragged_across() { - use egui::Event; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); - app.select_tool("zoom"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let at = rect.center() - vec2(100.0, 50.0); - let to = at + vec2(100.0, 50.0); - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for angle in [90.0, 180.0] { - app.run("view.rotate", json!({ "angle": angle })).unwrap(); - let z0 = app.view().unwrap().zoom; - let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(50.0, 25.0)); - frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); - frame(&mut app, &ctx, vec![button(at, true)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); - frame(&mut app, &ctx, vec![button(to, false)]); - let v = app.view().unwrap(); - let want = z0 * f64::from((rect.width() / 100.0).min(rect.height() / 50.0)); - assert!((v.zoom - want).abs() < 1e-6, "{angle}°: {z0} → {}, not {want}", v.zoom); - assert!(v.center.distance(middle) < 1e-6, "{angle}°: the marquee's center goes to the canvas's center"); - app.view_mut().unwrap().zoom = z0; - } - } - - /// In a rotated view the Selection tool's marquee is drawn where the tool selects: through the - /// screen corners of the document rectangle between the press and the pointer. - #[test] - fn the_selection_marquee_is_drawn_where_it_selects_in_a_rotated_view() { - use egui::Event; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.run("view.rotate", json!({"angle": 45})).unwrap(); - let rect = app.canvas_rect.unwrap(); - let (from, to) = (rect.center() - vec2(60.0, 20.0), rect.center() + vec2(60.0, 40.0)); - frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); - frame( - &mut app, - &ctx, - vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }], - ); - frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); - let xf = Xf::new(rect, app.view().unwrap()); - let want = xf.quad(Rect::from_points(xf.to_doc(from), xf.to_doc(to))); - let drawn: Vec> = shapes(&mut app, &ctx) - .into_iter() - .filter_map(|s| match s { - Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points), - _ => None, - }) - .collect(); - let [line] = &drawn[..] else { panic!("one marquee: {drawn:?}") }; - assert_eq!(line.len(), 5, "closed: {line:?}"); - assert!(line.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "{line:?} is not {want:?}"); - assert_eq!(line[4], line[0]); - } - - /// User Interface › Large Tabs (#394): the document tabs are taller, so the canvas starts lower. - #[test] - fn large_tabs_make_the_document_tabs_taller() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let canvas_top = |app: &mut VectorcraftApp| { - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - crate::chrome::doc_tabs(app, ui); - show(app, ui); - }); - out.textures_delta.clear(); - app.canvas_rect.unwrap().top() - }; - let small = canvas_top(&mut app); - app.run("prefs.set", json!({"key": "largeTabs", "value": true})).unwrap(); - assert_eq!(canvas_top(&mut app) - small, 9.0, "44 points instead of 35"); - } - - #[test] - fn cmd_space_zooms_and_space_pans_whatever_the_tool() { - use egui::{Event, Modifiers}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let at = app.canvas_rect.unwrap().center(); - let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; - let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; - // A drag from `at` to `to` with Space and `held` down. - let drag = |app: &mut VectorcraftApp, held: Modifiers, to: Pos2| { - frame(app, &ctx, vec![Event::ModifiersChanged(held), space(true, held), Event::PointerMoved(at)]); - frame(app, &ctx, vec![button(at, true, held)]); - frame(app, &ctx, vec![Event::PointerMoved(to)]); - frame(app, &ctx, vec![button(to, false, held)]); - frame(app, &ctx, vec![space(false, held), Event::ModifiersChanged(Modifiers::NONE)]); - }; - let z0 = app.view().unwrap().zoom; - drag(&mut app, Modifiers::COMMAND, at); - let z1 = app.view().unwrap().zoom; - assert_eq!(z1, crate::state::next_zoom(z0, true), "Cmd+Space click zooms in"); - drag(&mut app, Modifiers::COMMAND | Modifiers::ALT, at); - assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z1, false), "Cmd+Alt+Space click zooms out"); - assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); - // Space alone pans, with the Zoom tool too. - app.select_tool("zoom"); - let before = *app.view().unwrap(); - drag(&mut app, Modifiers::NONE, at + vec2(30.0, 0.0)); - let after = *app.view().unwrap(); - assert_eq!(after.zoom, before.zoom); - assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}"); - } - - #[test] - fn space_shift_drag_rotates_the_view_whatever_the_tool() { - use egui::{Event, Modifiers}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let c = app.canvas_rect.unwrap().center(); - let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; - let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; - // `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`. - let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| { - let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] }; - frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat()); - frame(app, &ctx, vec![button(from, true, held)]); - frame(app, &ctx, vec![Event::PointerMoved(to)]); - frame(app, &ctx, vec![button(to, false, held)]); - frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat()); - }; - // `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right. - let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin()); - let before = *app.view().unwrap(); - drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0)); - let after = *app.view().unwrap(); - assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}"); - assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center"); - assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); - // Shift does not round the angle: 90° and 20° more make 110°, not 105°. - drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0)); - assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view()); - // Space alone still pans, and Cmd+Shift+Space still zooms. - let v = *app.view().unwrap(); - drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0)); - let panned = *app.view().unwrap(); - assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom)); - assert_ne!(panned.center, v.center, "Space pans"); - drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c); - assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in"); - assert_eq!(app.view().unwrap().rotation, v.rotation); - // The Rotate View tool's Shift still rounds to 15°. - app.select_tool("rotateView"); - app.view_mut().unwrap().rotation = 0.0; - drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0)); - assert_eq!(app.view().unwrap().rotation, 15.0); - drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0)); - assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view()); - } - - #[test] - fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() { - use egui::{CursorIcon, Event, Modifiers}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let c = app.canvas_rect.unwrap().center(); - // `shown` returns the system cursor the canvas sets over a window without the focus. On macOS - // the window paints the glyph only with the focus, and on the other desktop builds (not - // Wayland, which the tests do not set) the window sets the system cursor next to the bitmap. - let shown = |app: &mut VectorcraftApp, events: Vec| { - let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); - let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.platform_output.cursor_icon - }; - let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }; - assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias); - assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias); - // Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand. - shown(&mut app, vec![button(c, true)]); - assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias); - assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab); - // Cmd+Shift+Space zooms. - assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn); - assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias); - app.select_tool("rotateView"); - assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool"); - } - - #[test] - fn a_rotate_view_drag_shows_a_dial_with_the_angle() { - use egui::{Event, Modifiers}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("rotateView"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let c = app.canvas_rect.unwrap().center(); - let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; - let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; - // `dial` returns, from the shapes of one frame, the center of the dial's circle, the needle's - // direction in whole degrees clockwise from straight up, and the angle text. - let dial = |app: &mut VectorcraftApp| { - let s = shapes(app, &ctx); - let circle = s.iter().find_map(|s| if let Shape::Circle(k) = s { (k.radius == DIAL_RADIUS).then_some(k.center) } else { None }); - let needle = s.iter().find_map(|s| match s { - Shape::LineSegment { points: [a, b], stroke } if stroke.width == 2.0 && *a == c => { - Some((b.x - a.x).atan2(a.y - b.y).to_degrees().round() as i32) - } - _ => None, - }); - let text = - s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None }); - (circle, needle, text) - }; - assert_eq!(dial(&mut app), (None, None, None), "no dial before a drag"); - // The Rotate View tool: a drag from 0° to 30° about the canvas center. - let (right, at_30, below) = (c + vec2(100.0, 0.0), c + vec2(86.6, 50.0), c + vec2(0.0, 100.0)); - frame(&mut app, &ctx, vec![Event::PointerMoved(right)]); - frame(&mut app, &ctx, vec![button(right, true, Modifiers::NONE)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(at_30)]); - assert_eq!(dial(&mut app), (Some(c), Some(30), Some("30°".to_string()))); - frame(&mut app, &ctx, vec![button(at_30, false, Modifiers::NONE)]); - assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release"); - assert!((app.view().unwrap().rotation - 30.0).abs() < 1e-2, "{:?}", app.view()); - // A Space+Shift drag with another tool shows it too: 30° and 90° more make 120°. - app.select_tool("rectangle"); - frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT), Event::PointerMoved(right)]); - frame(&mut app, &ctx, vec![button(right, true, Modifiers::SHIFT)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(below)]); - assert_eq!(dial(&mut app), (Some(c), Some(120), Some("120°".to_string()))); - assert!((app.view().unwrap().rotation - 120.0).abs() < 1e-2, "{:?}", app.view()); - // Presentation Mode turned on during the drag hides the dial. - let mode = app.ui.screen_mode; - app.ui.screen_mode = 3; - assert_eq!(dial(&mut app), (None, None, None), "no dial in Presentation Mode"); - app.ui.screen_mode = mode; - frame(&mut app, &ctx, vec![button(below, false, Modifiers::SHIFT)]); - frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]); - assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release"); - } - - #[test] - fn space_pressed_mid_drag_moves_what_the_tool_draws() { - use egui::{Event, Modifiers}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.ui.view.smart_guides = false; - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - // Press at `a`, drag to `b`, then hold Space while moving on by `by` and let go of both. - let draw = |app: &mut VectorcraftApp, a: Pos2, b: Pos2, by: egui::Vec2| { - frame(app, &ctx, vec![Event::PointerMoved(a)]); - frame(app, &ctx, vec![button(a, true)]); - frame(app, &ctx, vec![Event::PointerMoved(b)]); - frame(app, &ctx, vec![space(true)]); - frame(app, &ctx, vec![Event::PointerMoved(b + by)]); - frame(app, &ctx, vec![space(false)]); - frame(app, &ctx, vec![button(b + by, false)]); - }; - let near = |p: Point, q: Point| p.distance(q) < 1e-3; - let at = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0); - let center = app.view().unwrap().center; - // The rectangle moves at its size instead of growing, and the view doesn't pan. - app.select_tool("rectangle"); - draw(&mut app, at, at + vec2(80.0, 40.0), vec2(30.0, 20.0)); - assert_eq!(app.view().unwrap().center, center, "Space during a drag doesn't pan"); - let st = app.session.active().unwrap(); - let r = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().bounds().unwrap(); - assert!( - near(Point::new(r.x0, r.y0), xf.to_doc(at + vec2(30.0, 20.0))) && near(Point::new(r.x1, r.y1), xf.to_doc(at + vec2(110.0, 60.0))), - "{r:?}" - ); - // The Pen's anchor being dragged out moves, handles and all. - app.session.execute("select.none", &json!({})).unwrap(); - app.select_tool("pen"); - let a = at + vec2(0.0, 120.0); - draw(&mut app, a, a + vec2(40.0, 0.0), vec2(20.0, 10.0)); - let st = app.session.active().unwrap(); - let anchor = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0]; - assert!(near(anchor.p, xf.to_doc(a + vec2(20.0, 10.0))) && near(anchor.h_out, xf.to_doc(a + vec2(60.0, 10.0))), "{anchor:?}"); - } - - #[test] - fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - let timed = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let mut out = - ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - }; - timed(&mut app, 0.0, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("not text"), - }; - // Double-click the middle of the text. - let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); - let at = xf.to_screen(b.center()); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]); - timed(&mut app, 1.1, vec![]); - // The Type tool edits the text, with the caret inside it, where it was clicked: typing goes - // into the text, not to tool shortcuts (X would swap fill and stroke). - assert_eq!(app.session.tool_id(), "type"); - assert!(app.session.tool_wants_text()); - let o = app.session.tool_options(); - assert_eq!(o["editing"], json!(id)); - let caret = o["caret"].as_u64().unwrap(); - assert!((1..11).contains(&caret), "caret {caret}"); - app.session.tool_text("X", app.view_info()).unwrap(); - let t = text(&app); - assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}"); - } - - /// #2119: type copied with an Alt-drag, double-clicked, all of its text selected and typed over - /// keeps its font, size and colour: through an IME (the marked text cleared, then committed by - /// Return, as Windows sends them) and with Backspace then typing. Whole app frames, one input - /// event per frame. - fn retype_all_of_a_copied_type(ime: bool) { - use egui::{Event, Key, Modifiers}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let original = app - .session - .execute("text.create", &json!({"x": 60, "y": 100, "text": "Chair 123", "size": 36, "font": "Georgia", "color": "#808080"})) - .unwrap()["id"] - .as_u64() - .unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - let time = std::cell::Cell::new(0.0); - let frame = |app: &mut VectorcraftApp, events: Vec| { - time.set(time.get() + 0.03); - let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time.get()), events, ..Default::default() }, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - }; - for _ in 0..3 { - frame(&mut app, vec![]); - } - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let node = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); - let styles = |app: &VectorcraftApp, id: u64| match &node(app, id).kind { - NodeKind::Text(t) => { - t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.size, r.style.fill.clone())).collect::>() - } - _ => panic!("not text"), - }; - let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; - let key = |key, pressed, modifiers| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; - // 1. Alt-drag the type: a copy, selected. - let from = xf.to_screen(node(&app, original).geometric_bounds().unwrap().center()); - let to = from + vec2(0.0, 120.0 * xf.zoom as f32); - frame(&mut app, vec![Event::PointerMoved(from), Event::ModifiersChanged(Modifiers::ALT)]); - frame(&mut app, vec![button(from, true, Modifiers::ALT)]); - for k in 1..=6 { - frame(&mut app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]); - } - frame(&mut app, vec![button(to, false, Modifiers::ALT)]); - frame(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]); - let copy = app.session.active().unwrap().selection.objects[0].0; - assert_ne!(copy, original); - let style = styles(&app, original)[0].clone(); - // 2. Double-click it at once: the Type tool edits it. - let at = xf.to_screen(node(&app, copy).geometric_bounds().unwrap().center()); - frame(&mut app, vec![Event::PointerMoved(at)]); - for pressed in [true, false, true, false] { - frame(&mut app, vec![button(at, pressed, Modifiers::NONE)]); - } - frame(&mut app, vec![]); - assert_eq!(app.session.tool_options()["editing"], json!(copy)); - // 3. Select all of its text and type over it. - frame(&mut app, vec![key(Key::A, true, Modifiers::COMMAND)]); - frame(&mut app, vec![key(Key::A, false, Modifiers::COMMAND)]); - if ime { - frame(&mut app, vec![preedit("t", 1)]); - frame(&mut app, vec![preedit("table", 5)]); - let marked = styles(&app, copy); - assert_eq!(marked.iter().map(|r| (&r.1, r.2, &r.3)).collect::>(), vec![(&style.1, style.2, &style.3)], "composing: {marked:?}"); - // Return commits the composition. - frame(&mut app, vec![preedit("", 0), Event::Ime(egui::ImeEvent::Commit("table".into()))]); - } else { - frame(&mut app, vec![key(Key::Backspace, true, Modifiers::NONE)]); - frame(&mut app, vec![key(Key::Backspace, false, Modifiers::NONE)]); - frame(&mut app, vec![Event::Text("table".into())]); - frame(&mut app, vec![key(Key::Enter, true, Modifiers::NONE)]); - frame(&mut app, vec![key(Key::Enter, false, Modifiers::NONE)]); - } - frame(&mut app, vec![]); - let after = styles(&app, copy); - assert!(after.iter().any(|r| r.0.starts_with("table")), "ime={ime}: typed over: {after:?}"); - assert!( - after.iter().all(|r| (&r.1, r.2, &r.3) == (&style.1, style.2, &style.3)), - "ime={ime}: the copy keeps {:?} {} {:?}, got {after:?}", - style.1, - style.2, - style.3 - ); - } - - #[test] - fn retyping_all_of_a_copied_type_through_an_ime_keeps_its_style() { - retype_all_of_a_copied_type(true); - } - - #[test] - fn retyping_all_of_a_copied_type_after_backspace_keeps_its_style() { - retype_all_of_a_copied_type(false); - } - - #[test] - fn double_clicking_type_with_the_direct_selection_tool_puts_the_caret_there() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - app.select_tool("directSelection"); - let ctx = egui::Context::default(); - let timed = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| { - crate::shortcuts::handle(app, ui.ctx()); - show(app, ui) - }); - out.textures_delta.clear(); - }; - timed(&mut app, 0.0, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("not text"), - }; - // Double-click the middle of the text with Direct Selection. - let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); - let at = xf.to_screen(b.center()); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]); - timed(&mut app, 1.1, vec![]); - // The Type tool edits the text, with the caret inside it, where it was clicked: typing goes - // into the text, not to tool shortcuts. - assert_eq!(app.session.tool_id(), "type"); - assert!(app.session.tool_wants_text()); - let o = app.session.tool_options(); - assert_eq!(o["editing"], json!(id)); - let caret = o["caret"].as_u64().unwrap(); - assert!((1..11).contains(&caret), "caret {caret}"); - app.session.tool_text("X", app.view_info()).unwrap(); - let t = text(&app); - assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}"); - - // Pressing Escape exits text editing and returns to Direct Selection tool. - let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; - timed(&mut app, 1.2, vec![esc(true), esc(false)]); - assert_eq!(app.session.tool_id(), "directSelection"); - assert!(!app.session.tool_wants_text()); - } - - #[test] - fn escape_while_editing_in_isolation_returns_to_selection_tool_and_keeps_isolation_until_second_escape() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let tid = - app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Nested hello", "size": 24})).unwrap()["id"].as_u64().unwrap(); - // Group the text inside a group. - app.session.execute("select.all", &json!({})).unwrap(); - app.session.execute("object.group", &json!({})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - let timed = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| { - crate::shortcuts::handle(app, ui.ctx()); - show(app, ui) - }); - out.textures_delta.clear(); - }; - timed(&mut app, 0.0, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(tid)).unwrap().geometric_bounds().unwrap(); - let at = xf.to_screen(b.center()); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; - - // 1st double-click on group enters isolation mode. - timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]); - timed(&mut app, 1.1, vec![]); - assert!(app.session.active().unwrap().isolation.is_some(), "should be in isolation mode"); - assert_eq!(app.session.tool_id(), "selection"); - - // 2nd double-click on the text inside the isolated group enters text editing. - timed(&mut app, 2.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]); - timed(&mut app, 2.1, vec![]); - assert_eq!(app.session.tool_id(), "type"); - assert!(app.session.tool_wants_text()); - assert!(app.session.active().unwrap().isolation.is_some(), "isolation mode still active while editing text"); - - // 1st Escape exits text edit mode, returns to Selection tool, but keeps isolation mode. - timed(&mut app, 3.0, vec![esc(true), esc(false)]); - timed(&mut app, 3.1, vec![]); - assert_eq!(app.session.tool_id(), "selection"); - assert!(!app.session.tool_wants_text()); - assert!(app.session.active().unwrap().isolation.is_some(), "1st Escape must NOT end isolation mode"); - - // 2nd Escape exits isolation mode. - timed(&mut app, 4.0, vec![esc(true), esc(false)]); - timed(&mut app, 4.1, vec![]); - assert_eq!(app.session.tool_id(), "selection"); - assert!(app.session.active().unwrap().isolation.is_none(), "2nd Escape must exit isolation mode"); - } - - #[test] - fn a_symbol_dropped_on_the_canvas_is_placed_there() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); - let name = app.session.execute("symbol.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - // Released over empty canvas: an instance centred there. - let at = xf.to_screen(Point::new(250.0, 180.0)); - egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Symbol(name.clone())); - let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]); - let st = app.session.active().unwrap(); - let placed = st.selection.objects.first().copied().unwrap(); - let n = st.doc.node(placed).unwrap(); - assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name)); - let c = n.geometric_bounds().unwrap().center(); - assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}"); - } - - #[test] - fn a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // Dropped on art too, it is placed, not applied to the art. - app.session.execute("shape.rectangle", &json!({"x": 200, "y": 150, "width": 100, "height": 60})).unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let undo = app.session.active().unwrap().history.undo.len(); - let at = xf.to_screen(Point::new(250.0, 180.0)); - egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::LibrarySymbol { library: "basic-shapes".into(), name: "Star".into() }); - let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]); - let st = app.session.active().unwrap(); - assert_eq!(st.doc.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Star"]); - assert_eq!(st.history.undo.len(), undo + 1, "added and placed as one undo step"); - assert_eq!(st.history.undo.last().unwrap().label, "Add to Symbols"); - let placed = st.selection.objects.first().copied().unwrap(); - let n = st.doc.node(placed).unwrap(); - assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Star")); - // The registration point (the centre) is where it was dropped, in document coordinates - // (the pointer's screen position, which rounds 250, 180 a little). - let size = st.doc.unknown.get(vectorcraft_doc::SYMBOL_SIZES).and_then(|m| m.get("Star")).cloned().unwrap(); - let b = n.geometric_bounds().unwrap(); - assert!((b.width() - size[0].as_f64().unwrap()).abs() < 1e-6, "{b:?} {size}"); - assert!((b.center() - xf.to_doc(at)).hypot() < 1e-6 && (b.center() - Point::new(250.0, 180.0)).hypot() < 0.1, "{b:?}"); - app.run("edit.undo", json!({})).unwrap(); - let st = app.session.active().unwrap(); - assert!(st.doc.symbols.is_empty() && st.doc.node(placed).is_none()); - } - - #[test] - fn a_brush_dropped_on_a_path_is_applied_to_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = - app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let brush = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().and_then(|s| s.brush.clone()) - }; - let drop = |app: &mut VectorcraftApp, at: Point| { - let at = xf.to_screen(at); - egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: "Arrow".into(), def: json!({}) }); - let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); - }; - // Off the path: nothing. - drop(&mut app, Point::new(200.0, 250.0)); - assert_eq!(brush(&app), None); - // On it: the path's stroke takes the brush. - drop(&mut app, Point::new(200.0, 150.0)); - assert_eq!(brush(&app).as_deref(), Some("Arrow")); - } - - #[test] - fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = - app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let drop = |app: &mut VectorcraftApp, at: Point| { - let at = xf.to_screen(at); - egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::LibraryBrush { library: "scatter-shapes".into(), name: "Sparkles".into() }); - let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); - }; - let undo = app.session.active().unwrap().history.undo.len(); - // Off the path: nothing. - drop(&mut app, Point::new(200.0, 250.0)); - assert!(vectorcraft_brush::find(&app.session.active().unwrap().doc, "Sparkles").is_none()); - // On it: added, with the symbol it scatters, and applied, as one step. - drop(&mut app, Point::new(200.0, 150.0)); - let st = app.session.active().unwrap(); - assert!(vectorcraft_brush::find(&st.doc, "Sparkles").is_some() && st.doc.symbols.iter().any(|s| s.name == "Sparkle"), "{}", app.ui.status); - let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap(); - assert_eq!(n.appearance.stroke().and_then(|s| s.brush.clone()).as_deref(), Some("Sparkles")); - assert_eq!(st.history.undo.len(), undo + 1); - assert_eq!(st.history.undo.last().unwrap().label, "Add to Brushes"); - } - - #[test] - fn middle_drag_pans_the_view_with_any_tool() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - // A drawing tool: the middle button must pan, never draw. - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(400.0, 300.0))]); - let before = *app.view().unwrap(); - frame(&mut app, &ctx, vec![middle(pos2(400.0, 300.0), true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(450.0, 330.0))]); - frame(&mut app, &ctx, vec![middle(pos2(450.0, 330.0), false)]); - let after = *app.view().unwrap(); - assert_eq!(after.zoom, before.zoom); - assert!((after.center.x - (before.center.x - 50.0 / before.zoom)).abs() < 1e-6); - assert!((after.center.y - (before.center.y - 30.0 / before.zoom)).abs() < 1e-6); - // Released: moving no longer pans, and nothing was drawn. - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(300.0, 200.0))]); - assert_eq!(app.view().unwrap().center, after.center); - assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); - } - - #[test] - fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() { - use vectorcraft_color::{Color, GradientGeom, GradientPaint, Paint}; - use widgets::{PanelDrag, SwatchRows}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let drop = |app: &mut VectorcraftApp, d: PanelDrag, at: Point| { - let at = xf.to_screen(at); - egui::DragAndDrop::set_payload(&ctx, d); - let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); - }; - let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); - let red = Paint::solid(Color::from_hex("#ed1c24").unwrap()); - let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false }; - let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows), library: None }; - // Off the art nothing happens. - drop(&mut app, swatch.clone(), Point::new(300.0, 250.0)); - assert_eq!(fill(&app), Paint::solid(Color::WHITE)); - drop(&mut app, swatch, Point::new(100.0, 100.0)); - assert_eq!(fill(&app), red, "paint.setFill with the hit id"); - assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was"); - // A proxy's paint works the same. - drop(&mut app, PanelDrag::paint(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), Point::new(60.0, 60.0)); - assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); - // A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on. - let mut g = GradientPaint::new(Default::default()); - g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }); - drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0)); - assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none())); - } - - #[test] - fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() { - use vectorcraft_doc::NodeId; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 400, "height": 300})); - let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap(); - run(&mut app, "effect.apply", json!({"effect": "distort.twist"})); - run(&mut app, "transparency.set", json!({"opacity": 40})); - let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap(); - run(&mut app, "select.set", json!({ "ids": [a] })); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - // The Appearance panel's thumbnail (its drag payload) released over the second rectangle. - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let drop = |app: &mut VectorcraftApp, at: Point| { - let at = xf.to_screen(at); - egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Appearance(NodeId(a))); - frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); - let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - frame(app, &ctx, vec![up]); - }; - drop(&mut app, Point::new(250.0, 70.0)); - let doc = &app.session.active().unwrap().doc; - let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap()); - assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity)); - assert_eq!(nb.appearance.effects[0].id, "distort.twist"); - // Dropped on empty canvas or on the source itself: nothing changes. - let undo_len = app.session.active().unwrap().history.undo.len(); - drop(&mut app, Point::new(350.0, 250.0)); - drop(&mut app, Point::new(70.0, 70.0)); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len); - } - - #[test] - fn a_graphic_style_dropped_on_art_applies_to_the_object_hit() { - use vectorcraft_doc::NodeId; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 400, "height": 300})); - let a = NodeId(run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap()); - let b = NodeId(run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap()); - run(&mut app, "select.set", json!({ "ids": [a.0] })); - let name = app.session.active().unwrap().doc.graphic_styles[2].name.clone(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = xf.to_screen(Point::new(250.0, 70.0)); - egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::GraphicStyle(name.clone())); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); - let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![up]); - let st = app.session.active().unwrap(); - let g = st.doc.graphic_style(&name).unwrap(); - // `graphicStyle.apply` with the hit object's id: linked, the selection left alone. - assert_eq!(st.doc.node(b).unwrap().extras().graphic_style, Some(g.id)); - assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance); - assert_eq!(st.doc.node(a).unwrap().extras().graphic_style, None); - assert_eq!(st.selection.objects, [a]); - } - - #[test] - fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - let id = vectorcraft_doc::NodeId(id); - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let before = app.session.active().unwrap().doc.clone(); - let undo = app.session.active().unwrap().history.undo.len(); - let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { - pos, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; - let start = xf.to_screen(Point::new(100.0, 100.0)); - let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; - let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); - // Moving on the canvas moves the art... - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); - assert!(app.session.active().unwrap().interaction.is_some()); - // ...over a panel it waits where it was, and the panels get the art. - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); - assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels"); - assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved"); - assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id]))); - // Released over the panel, the move goes back and nothing is recorded. - frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]); - let st = app.session.active().unwrap(); - assert!(st.interaction.is_none()); - assert_eq!(*st.doc, *before); - assert_eq!(st.history.undo.len(), undo); - } - - /// #2826: a move taken over a panel waits where it left the canvas, and goes on with the - /// pointer when it comes back — it isn't dropped or put back. - #[test] - fn art_moved_off_the_canvas_resumes_when_the_pointer_returns() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let undo = app.session.active().unwrap().history.undo.len(); - let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { - pos, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; - let start = xf.to_screen(Point::new(100.0, 100.0)); - let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0; - let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); - assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the move is live"); - // Over a panel the move waits where it last moved, and stays live. - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); - assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels"); - assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved"); - // Back on the canvas it goes on with the pointer, 60 pt right. - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(60.0, 0.0))]); - assert!(app.session.active().unwrap().interaction.is_some(), "the move is live again"); - assert!((left(&app) - moved_to(start + vec2(60.0, 0.0))).abs() < 0.01, "the move resumes with the pointer"); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(70.0, 0.0))]); - frame(&mut app, &ctx, vec![button(start + vec2(70.0, 0.0), false)]); - let st = app.session.active().unwrap(); - assert!((left(&app) - moved_to(start + vec2(70.0, 0.0))).abs() < 0.01, "released where the pointer was"); - assert_eq!(st.history.undo.len(), undo + 1, "one move, one undo step"); - } - - /// One frame of keyboard handling, canvas and the end-of-pass IME observation, as the app runs - /// them; returns the IME output. - fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { - VectorcraftApp::install_ime_reset_hooks(ctx); - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - crate::ime::begin_ui(app, ui.ctx()); - crate::shortcuts::handle(app, ui.ctx()); - show(app, ui); - crate::ime::end_of_pass(app, ui.ctx()); - crate::ime::finish_ui(app, ui.ctx()); - }); - out.textures_delta.clear(); - out.platform_output.ime - } - - fn preedit(t: &str, chars: usize) -> egui::Event { - egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) }) - } - - fn plain(app: &VectorcraftApp, id: u64) -> String { - match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("not text"), - } - } - - /// #2826: the app ends a move released over a panel before the panels draw, so a panel takes - /// the art as it was, and no move is left open. - #[test] - fn a_move_released_over_a_panel_ends_before_the_panels_draw() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let before = app.session.active().unwrap().doc.clone(); - let undo = app.session.active().unwrap().history.undo.len(); - let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { - pos, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; - let start = xf.to_screen(Point::new(100.0, 100.0)); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); - assert!(app.session.active().unwrap().interaction.is_some()); - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), - events: vec![button(pos2(850.0, 100.0), false)], - ..Default::default() - }; - let mut out = ctx.run_ui(raw, |ui| { - crate::canvas::release_over_panel(&mut app, ui.ctx()); - assert!(app.session.active().unwrap().interaction.is_none(), "the move is ended"); - assert_eq!(*app.session.active().unwrap().doc, *before, "the art is back where it was"); - assert_eq!( - egui::DragAndDrop::payload::(ui.ctx()).as_deref(), - Some(&widgets::PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])), - "the panel still gets the art" - ); - assert!(ui.ctx().data(|d| d.get_temp::(drag_id())).is_none(), "no drag is left"); - }); - out.textures_delta.clear(); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo); - } - - #[test] - fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - // The type placed starts empty (Fill New Type Objects With Placeholder Text off). - app.session.prefs.placeholder_text = false; - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - // Not editing: the IME stays off (single-key tool shortcuts keep working). - assert!(typing_frame(&mut app, &ctx, vec![]).is_none()); - app.select_tool("type"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = xf.to_screen(Point::new(100.0, 100.0)); - let click = - |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); - typing_frame(&mut app, &ctx, vec![click(at, false)]); - assert!(app.session.tool_wants_text()); - let id = app.session.active().unwrap().selection.objects[0].0; - // Editing: the IME is allowed, its window at the caret (the text's baseline at y = 100). - let ime = typing_frame(&mut app, &ctx, vec![]).expect("IME allowed while editing"); - assert!(ime.rect.contains(pos2(ime.rect.center().x, at.y)) && (ime.rect.center().x - at.x).abs() < 2.0, "{:?} vs {at:?}", ime.rect); - assert!(!ime.should_interrupt_composition); - // gagaku → がが → 雅楽, then commit (the macOS sequence). - typing_frame(&mut app, &ctx, vec![preedit("g", 1)]); - typing_frame(&mut app, &ctx, vec![preedit("が", 1), preedit("がg", 2)]); - typing_frame(&mut app, &ctx, vec![preedit("ががく", 3)]); - assert_eq!(plain(&app, id), "ががく"); - // While composing: plain text events, the clipboard and Undo (the native menu's ⌘Z - // reaches `menus::invoke`) all wait. - typing_frame(&mut app, &ctx, vec![egui::Event::Text("x".into()), egui::Event::Paste("y".into())]); - crate::menus::invoke(&mut app, "edit.undo", json!({})); - assert!(!crate::menus::enabled(&app, "edit.undo")); - assert_eq!(plain(&app, id), "ががく"); - let ime2 = - typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })]) - .unwrap(); - assert!(ime2.rect.center().x <= ime.rect.center().x + 1.0, "the window stays at the clause being converted"); - typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅楽".into()))]); - assert_eq!(plain(&app, id), "雅楽"); - assert!(!app.session.tool_composing()); - // After the commit, text and Undo work again. - typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]); - assert_eq!(plain(&app, id), "雅楽!"); - crate::menus::invoke(&mut app, "edit.undo", json!({})); - assert_eq!(plain(&app, id), ""); - } - - /// Vietnamese Telex via the Windows IME (TSF): the preedit grows digit by digit, then an empty - /// preedit plus a single Commit lands the amount once, not duplicated. - #[test] - fn vietnamese_telex_digits_commit_once() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.prefs.placeholder_text = false; - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - typing_frame(&mut app, &ctx, vec![]); - app.select_tool("type"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = xf.to_screen(Point::new(100.0, 100.0)); - let click = - |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); - typing_frame(&mut app, &ctx, vec![click(at, false)]); - let id = app.session.active().unwrap().selection.objects[0].0; - typing_frame(&mut app, &ctx, vec![]); - typing_frame(&mut app, &ctx, vec![preedit("3", 1)]); - typing_frame(&mut app, &ctx, vec![preedit("31", 2)]); - typing_frame(&mut app, &ctx, vec![preedit("316", 3)]); - typing_frame(&mut app, &ctx, vec![preedit("3160", 4)]); - typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("3160".into()))]); - assert_eq!(plain(&app, id), "3160"); - assert!(!app.session.tool_composing()); - } - - /// Keyboard-hook IMEs (UniKey/OpenKey) type Vietnamese by injecting VK_PACKET characters and - /// Backspaces, which egui surfaces as interleaved Event::Text and Event::Key(Backspace). When a - /// tone key fires, the Backspaces and the re-injected characters arrive in one frame; the Type - /// tool must apply them in order to build "tiếng" (captured from a real UniKey session), not - /// insert every character first and delete afterwards. - #[test] - fn hook_ime_backspace_and_reinject_builds_vietnamese() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.prefs.placeholder_text = false; - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - typing_frame(&mut app, &ctx, vec![]); - app.select_tool("type"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = xf.to_screen(Point::new(100.0, 100.0)); - let click = - |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); - typing_frame(&mut app, &ctx, vec![click(at, false)]); - let id = app.session.active().unwrap().selection.objects[0].0; - typing_frame(&mut app, &ctx, vec![]); - let txt = |s: &str| egui::Event::Text(s.into()); - let bs = || egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - // When a tone key fires, the Backspaces and the re-injected VK_PACKET characters land - // together in one poll (one egui frame). - typing_frame(&mut app, &ctx, vec![txt("t")]); - typing_frame(&mut app, &ctx, vec![txt("i")]); - typing_frame(&mut app, &ctx, vec![txt("e")]); - typing_frame(&mut app, &ctx, vec![bs(), txt("ê")]); // 2nd "e" -> ê - typing_frame(&mut app, &ctx, vec![txt("n")]); - typing_frame(&mut app, &ctx, vec![txt("g")]); - typing_frame(&mut app, &ctx, vec![bs(), bs(), bs(), txt("ế"), txt("n"), txt("g")]); // "s" tone - assert_eq!(plain(&app, id), "tiếng"); - } - - /// Clear History waits while an IME composes, as Undo does: the marked text and the steps stay. - #[test] - fn clear_history_waits_while_the_ime_composes() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.prefs.placeholder_text = false; - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap(); - let ctx = egui::Context::default(); - typing_frame(&mut app, &ctx, vec![]); - app.select_tool("type"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = xf.to_screen(Point::new(100.0, 100.0)); - let click = - |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); - typing_frame(&mut app, &ctx, vec![click(at, false)]); - let id = app.session.active().unwrap().selection.objects[0].0; - typing_frame(&mut app, &ctx, vec![preedit("が", 1)]); - assert!(app.session.tool_composing()); - assert!(!crate::menus::enabled(&app, "edit.clearHistory")); - crate::menus::invoke(&mut app, "edit.clearHistory", json!({})); - assert!(app.session.tool_composing(), "the composition goes on"); - assert!(!app.session.active().unwrap().history.undo.is_empty(), "the steps stay"); - typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅".into()))]); - assert_eq!(plain(&app, id), "雅"); - assert!(crate::menus::enabled(&app, "edit.clearHistory")); - } - - #[test] - fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let other = app.session.execute("text.create", &json!({"x": 300, "y": 250, "text": "Other", "size": 24})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - typing_frame(&mut app, &ctx, vec![]); - app.select_tool("type"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(310.0, 245.0))); - let click = - |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]); - typing_frame(&mut app, &ctx, vec![click(a, false)]); - let id = app.session.active().unwrap().selection.objects[0].0; - typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]); - // macOS sends nothing for a click away; the tool keeps the marked text and the IME is - // told to drop its composition. A press on other type edits that type at once. - let ime = typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]); - assert_eq!(plain(&app, id), "しょうこ"); - assert!(!app.session.tool_composing()); - assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]); - let ime = ime.expect("editing the other text"); - assert!(ime.should_interrupt_composition); - let request_id = ime.reset_request.expect("the final destination publishes its reset request"); - assert!(app.ime_marked.is_none()); - assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text"); - typing_frame(&mut app, &ctx, vec![click(b, false)]); - // Interrupted once, not every frame. - assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition); - assert!(!app.take_ime_discard()); - // A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks - // for no IME, and the host tells the system IME to drop its composition. - let blank = xf.to_screen(Point::new(150.0, 200.0)); - typing_frame(&mut app, &ctx, vec![egui::Event::ImeResetCompleted { request_id }, preedit("が", 1)]); - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]); - let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]); - assert!(plain(&app, other).contains('が'), "{}", plain(&app, other)); - assert!(!app.session.tool_composing() && !app.session.tool_wants_text()); - assert!(ime.is_none()); - assert!(app.ime_marked.is_none()); - assert!(app.take_ime_discard()); - } - - #[test] - fn ime_edge_orders_never_eat_committed_text_or_lose_the_composition() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - typing_frame(&mut app, &ctx, vec![]); - app.select_tool("type"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = xf.to_screen(Point::new(100.0, 100.0)); - let click = - |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); - typing_frame(&mut app, &ctx, vec![click(at, false)]); - let id = app.session.active().unwrap().selection.objects[0].0; - typing_frame(&mut app, &ctx, vec![egui::Event::Text("雅".into())]); - let backspace = - |pressed| egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; - // The IME empties its marked text and the Backspace comes in the same frame. - typing_frame(&mut app, &ctx, vec![preedit("が", 1)]); - typing_frame(&mut app, &ctx, vec![preedit("", 0), backspace(true), backspace(false)]); - assert_eq!(plain(&app, id), "雅", "the committed character stays"); - // A bare line-break commit ends the composition with the marked text, no newline. - typing_frame(&mut app, &ctx, vec![preedit("がく", 2)]); - typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("\n".into()))]); - assert!(!app.session.tool_composing()); - assert_eq!(plain(&app, id), "雅がく"); - assert!(app.take_ime_discard()); - // Switching apps mid-composition: the composition goes on when the window comes back. - typing_frame(&mut app, &ctx, vec![preedit("らく", 2)]); - typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(false)]); - typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(true), preedit("らくか", 3)]); - assert!(app.session.tool_composing()); - assert!(!app.take_ime_discard()); - assert_eq!(plain(&app, id), "雅がくらくか"); - } - - /// Reset to a blank document, select the Type tool and click document point (100, 100), returning - /// the screen position used by IME fixtures. - fn type_at(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { - app.session.prefs.placeholder_text = false; - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - typing_frame(app, ctx, vec![]); - app.select_tool("type"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = xf.to_screen(Point::new(100.0, 100.0)); - let click = - |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - typing_frame(app, ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); - typing_frame(app, ctx, vec![click(at, false)]); - at - } - - /// The outgoing descriptor carries the committed logical text and UTF-8 offsets, keeps a - /// reversed selection reversed, and labels only accepted IME mutations `InputMethod`. - #[test] - fn ime_output_carries_text_offsets_and_explicit_cause() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - type_at(&mut app, &ctx); - let empty = typing_frame(&mut app, &ctx, vec![]).expect("the Type tool owns the IME"); - let payload = empty.surrounding_text.available().expect("an empty document is available"); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0)); - assert_eq!(empty.change_cause, egui::output::ImeChangeCause::Other); - - let typed = typing_frame(&mut app, &ctx, vec![egui::Event::Text("Héllo".into())]).unwrap(); - let payload = typed.surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 6, 6)); - assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other, "ordinary typing is application-origin"); - - let shift_left = - || egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }; - // "Héllo" has five characters: four Shift+Lefts stop at the start of its two-byte é, - // leaving cursor 1 < anchor 6. - let mut reversed = None; - for _ in 0..4 { - reversed = typing_frame(&mut app, &ctx, vec![shift_left()]); - } - let reversed = reversed.unwrap(); - let payload = reversed.surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 1, 6), "cursor > anchor stays reversed"); - - // One accepted IME deletion (one byte before the selection) then the commit: both receipts - // chain, and the commit still replaces the preserved selection ("éllo"). - let ime = typing_frame( - &mut app, - &ctx, - vec![ - egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), - egui::Event::Ime(egui::ImeEvent::Commit("Z".into())), - ], - ) - .unwrap(); - let payload = ime.surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Z", 1, 1)); - assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); - - let ordinary = typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]).unwrap(); - assert_eq!(ordinary.change_cause, egui::output::ImeChangeCause::Other, "typing after a commit is Other"); - } - - /// A pre-edit is excluded from the outgoing text; committing the composition is the accepted - /// IME mutation that earns `InputMethod`. - #[test] - fn ime_output_excludes_the_preedit_and_marks_the_commit() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - type_at(&mut app, &ctx); - typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]); - let marked = typing_frame(&mut app, &ctx, vec![preedit("xy", 2)]).unwrap(); - let payload = marked.surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("ab", 2, 2), "the marked text is not committed text"); - let committed = typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("xy".into()))]).unwrap(); - let payload = committed.surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abxy", 4, 4)); - assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod); - } - - /// An application edit anywhere in the frame dominates an IME receipt in it. - #[test] - fn ime_output_marks_mixed_application_and_ime_mutations_other() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - type_at(&mut app, &ctx); - typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]); - let ime = typing_frame( - &mut app, - &ctx, - vec![ - egui::Event::Text("x".into()), - egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), - egui::Event::Ime(egui::ImeEvent::Commit("y".into())), - ], - ) - .unwrap(); - let payload = ime.surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("aby", 3, 3)); - assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other, "application edit before the IME dominates"); - - let pasted = typing_frame(&mut app, &ctx, vec![egui::Event::Paste("c".into())]).unwrap(); - let payload = pasted.surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abyc", 4, 4)); - assert_eq!(pasted.change_cause, egui::output::ImeChangeCause::Other, "paste is application-origin"); - - // The other order: an accepted IME commit followed by an application edit in the same - // frame is still `Other`. - let ime_then_text = - typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("y".into())), egui::Event::Text("x".into())]).unwrap(); - let payload = ime_then_text.surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abycyx", 6, 6)); - assert_eq!(ime_then_text.change_cause, egui::output::ImeChangeCause::Other, "application edit after the IME dominates"); - } - - /// Leaving and re-entering the same text object keeps the owner but starts a new generation. - #[test] - fn ime_owner_replacement_starts_a_new_generation() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.prefs.placeholder_text = false; - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Same", "size": 24})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - typing_frame(&mut app, &ctx, vec![]); - app.select_tool("type"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let center = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().center(); - let at = xf.to_screen(center); - let click = - |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); - typing_frame(&mut app, &ctx, vec![click(at, false)]); - let first = typing_frame(&mut app, &ctx, vec![]).expect("the text is being edited"); - assert_eq!(first.owner, egui::Id::unique(("vectorcraft-type-ime", app.session.active().unwrap().uid, id))); - let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; - typing_frame(&mut app, &ctx, vec![escape(true)]); - typing_frame(&mut app, &ctx, vec![escape(false)]); - assert!(typing_frame(&mut app, &ctx, vec![]).is_none(), "Escape ends the edit context"); - app.select_tool("type"); - typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); - typing_frame(&mut app, &ctx, vec![click(at, false)]); - let second = typing_frame(&mut app, &ctx, vec![]).expect("re-entering starts another context"); - assert_eq!(second.owner, first.owner, "the owner key is the document and text object"); - assert_ne!(second.context_generation, first.context_generation, "a replacement epoch resets once"); - } - - /// One tracker serves several native viewports: each gets its own generation for the same - /// editor, and an unchanged second pass in either keeps it. - #[test] - fn ime_context_is_scoped_per_viewport() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - type_at(&mut app, &ctx); - let aux = egui::ViewportId::from_hash_of("vectorcraft-ime-aux"); - let frame = |app: &mut VectorcraftApp, viewport: egui::ViewportId| { - let mut raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), - viewport_id: viewport, - ..Default::default() - }; - raw.viewports.entry(viewport).or_default(); - let mut out = ctx.run_ui(raw, |ui| { - crate::shortcuts::handle(app, ui.ctx()); - show(app, ui); - crate::ime::end_of_pass(app, ui.ctx()); - }); - out.textures_delta.clear(); - out.platform_output.ime.expect("the Type tool owns the IME in both viewports") - }; - let root = egui::ViewportId::ROOT; - let root_first = frame(&mut app, root); - let aux_first = frame(&mut app, aux); - assert_ne!(root_first.context_generation, aux_first.context_generation, "viewport state must not collide"); - let root_second = frame(&mut app, root); - let aux_second = frame(&mut app, aux); - assert_eq!(root_second.context_generation, root_first.context_generation); - assert_eq!(aux_second.context_generation, aux_first.context_generation); - } - - /// A minimized/occluded viewport has no IME owner: the descriptor disappears, and the next - /// visible pass starts a new generation instead of inheriting the old context. - #[test] - fn hidden_viewport_observes_no_ime_owner() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - type_at(&mut app, &ctx); - let root = egui::ViewportId::ROOT; - let visible = typing_frame(&mut app, &ctx, vec![]).unwrap(); - let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - raw.viewports.entry(root).or_default().minimized = Some(true); - let mut out = ctx.run_ui(raw, |ui| { - crate::shortcuts::handle(&mut app, ui.ctx()); - show(&mut app, ui); - crate::ime::end_of_pass(&mut app, ui.ctx()); - }); - out.textures_delta.clear(); - assert!(out.platform_output.ime.is_none(), "a hidden viewport has no IME owner"); - let again = typing_frame(&mut app, &ctx, vec![]).expect("the visible editor comes back"); - assert_ne!(again.context_generation, visible.context_generation, "refocus starts a new generation"); - } - - /// A frame that is discarded and drawn again sees the mutation in both passes, and its - /// unchanged second pass keeps the first pass's `InputMethod` cause and generation. - #[test] - fn ime_observation_runs_in_every_pass_of_a_discarded_frame() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - type_at(&mut app, &ctx); - let mut first = true; - let mut seen = vec![]; - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - if first { - first = false; - let view = app.view_info(); - let target = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - app.session.tool_text("x", view).unwrap(); - crate::ime::note_ime_apply(&mut app, ui.ctx(), view, target, before); - ui.ctx().request_discard("ime observation second pass"); - } - crate::shortcuts::handle(&mut app, ui.ctx()); - show(&mut app, ui); - crate::ime::end_of_pass(&mut app, ui.ctx()); - seen.push(ui.ctx().output(|o| o.ime.clone()).expect("IME output in this pass")); - }); - out.textures_delta.clear(); - assert_eq!(seen.len(), 2, "the frame really ran two passes"); - assert_eq!(seen[0].context_generation, seen[1].context_generation, "the discarded pass is not a new context"); - assert_eq!(seen[0].change_cause, egui::output::ImeChangeCause::InputMethod); - assert_eq!(seen[1].change_cause, egui::output::ImeChangeCause::InputMethod, "an unchanged pass retains the frame cause"); - let payload = seen[1].surrounding_text.available().unwrap(); - assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("x", 1, 1)); - } - - #[test] - fn tool_drag_moved_without_pointer_delta() { - let ctx = egui::Context::default(); - let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); - let v = View::default(); - let xf = Xf::new(rect, &v); - let a = pos2(100.0, 100.0); - let b = pos2(150.0, 120.0); - let mut first = false; - let mut second = false; - let mut out = - ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { - let pointer = ui.input(|i| i.pointer.clone()); - first = tool_drag_moved(ui, &xf, a, &pointer); - second = tool_drag_moved(ui, &xf, a, &pointer); - }); - out.textures_delta.clear(); - assert!(first, "the first drag sample at a position runs"); - assert!(!second, "the same position without a delta is not sent again"); - let mut moved = false; - out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| { - let pointer = ui.input(|i| i.pointer.clone()); - moved = tool_drag_moved(ui, &xf, b, &pointer); - }); - out.textures_delta.clear(); - assert!(moved, "a new document position runs even when egui reports no delta"); - } - - /// #834 review: a press held still is not a drag. Before the press has dragged, the first held - /// frame without a delta at the press's own position sends nothing, so a click stays a click; - /// the first move after it does. - #[test] - fn stationary_press_is_not_a_drag() { - let ctx = egui::Context::default(); - let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); - let v = View::default(); - let xf = Xf::new(rect, &v); - let a = pos2(100.0, 100.0); - let b = pos2(150.0, 120.0); - let mut out = - ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { - ui.data_mut(|d| { - d.remove::(tool_drag_doc_id()); - d.insert_temp(tool_press_doc_id(), xf.to_doc(a)); - }); - }); - out.textures_delta.clear(); - let mut held = true; - out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { - let pointer = ui.input(|i| i.pointer.clone()); - assert_eq!(pointer.delta(), egui::Vec2::ZERO); - held = tool_drag_moved(ui, &xf, a, &pointer); - }); - out.textures_delta.clear(); - assert!(!held, "a press held still at its own position is not sent as a drag"); - let mut moved = false; - out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| { - let pointer = ui.input(|i| i.pointer.clone()); - moved = tool_drag_moved(ui, &xf, b, &pointer); - }); - out.textures_delta.clear(); - assert!(moved, "the first move after the press is a drag"); - } - - /// #834: a bezier drag's preview follows every position the pointer is seen at. egui's frame - /// delta is zero in the frame the pointer comes back into the window after leaving it - /// (`PointerGone` forgets where it was), so a drag gated on it kept the preview where the - /// pointer left until the next move; a move in the release's own frame was lost the same way, - /// and the release committed the stale shape. A click's jitter in its release frame stays a - /// click. - #[test] - fn a_bezier_drag_preview_follows_the_pointer_back_into_the_window_and_to_the_release() { - use egui::{Event, Modifiers}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.ui.view.smart_guides = false; - app.ui.view.snap_to_point = false; - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - let near = |p: Point, q: Pos2| p.distance(xf.to_doc(q)) < 1e-3; - let first_anchor = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0] - }; - // The Pen pulls a handle out of a new anchor: the pointer leaves the window mid-drag, comes - // back further on and stops there, then moves once more in the frame it lets go. - app.select_tool("pen"); - let a = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0); - frame(&mut app, &ctx, vec![Event::PointerMoved(a)]); - frame(&mut app, &ctx, vec![button(a, true)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(40.0, 0.0))]); - assert!(near(first_anchor(&app).h_out, a + vec2(40.0, 0.0)), "{:?}", first_anchor(&app)); - frame(&mut app, &ctx, vec![Event::PointerGone]); - frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(80.0, 0.0))]); - assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "back in the window: {:?}", first_anchor(&app)); - frame(&mut app, &ctx, vec![]); - assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "still: {:?}", first_anchor(&app)); - let end = a + vec2(100.0, 20.0); - frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); - assert!(near(first_anchor(&app).h_out, end), "released: {:?}", first_anchor(&app)); - assert!(app.session.active().unwrap().interaction.is_none(), "committed"); - // Direct Selection moving that anchor: the same path through the engine's previews. - app.select_tool("directSelection"); - let anchor_at = xf.to_screen(first_anchor(&app).p); - frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at)]); - frame(&mut app, &ctx, vec![button(anchor_at, true)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(30.0, 30.0))]); - assert!(near(first_anchor(&app).p, anchor_at + vec2(30.0, 30.0)), "{:?}", first_anchor(&app)); - frame(&mut app, &ctx, vec![Event::PointerGone]); - frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(60.0, 30.0))]); - assert!(near(first_anchor(&app).p, anchor_at + vec2(60.0, 30.0)), "back in the window: {:?}", first_anchor(&app)); - let end = anchor_at + vec2(60.0, 50.0); - frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); - assert!(near(first_anchor(&app).p, end), "released: {:?}", first_anchor(&app)); - // A click whose release frame brings a little jitter is still a click: the Pen places a - // corner anchor (no handle pulled out by the jitter) and the anchor stays where pressed. - app.session.execute("select.none", &json!({})).unwrap(); - app.select_tool("pen"); - let c = app.canvas_rect.unwrap().center() + vec2(120.0, 80.0); - frame(&mut app, &ctx, vec![Event::PointerMoved(c)]); - frame(&mut app, &ctx, vec![button(c, true), Event::PointerMoved(c + vec2(0.6, 0.2)), button(c + vec2(0.6, 0.2), false)]); - frame(&mut app, &ctx, vec![]); - // (egui hands the press the frame's last pointer position, so it lands within the jitter.) - let a = first_anchor(&app); - assert!(a.p.distance(xf.to_doc(c)) < 1.0 && a.h_out == a.p && a.h_in == a.p, "corner anchor where pressed: {a:?}"); - assert!(app.session.active().unwrap().interaction.is_none(), "committed"); - } - - #[test] - fn frame_moves_keeps_touch_feed_and_mouse_samples_with_their_pressure() { - let ctx = egui::Context::default(); - let a = pos2(10.0, 20.0); - let b = pos2(20.0, 30.0); - let c = pos2(30.0, 40.0); - let events = vec![egui::Event::PointerMoved(a), egui::Event::PointerMoved(b), egui::Event::PointerMoved(c)]; - let mut mouse = vec![]; - let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| mouse = frame_moves(ui, None)); - out.textures_delta.clear(); - // A mouse keeps its moves too, without a pressure. - assert_eq!(mouse, [(a, None), (b, None)], "the last move follows the normal pointer path"); - // A macOS pen: the feed kept a pressure for each move, the last for the last. - let feed = StylusFeed::default(); - for p in [0.9, 0.3, 0.0, 0.5] { - feed.set_move(crate::stylus::PenSample { pressure: p, eraser: false }); - } - let mut samples = vec![]; - let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed))); - out.textures_delta.clear(); - assert_eq!(samples, [(a, Some(0.3)), (b, None)], "paired from the end; a lift's 0 is no pressure"); - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed))); - out.textures_delta.clear(); - assert_eq!(samples, [(a, None), (b, None)], "the moves were taken: a mouse's moves again"); - let events = [pen(egui::TouchPhase::Move, a, 0.2), pen(egui::TouchPhase::Move, b, 0.8), pen(egui::TouchPhase::Move, c, 0.6)].concat(); - let mut samples = vec![]; - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None)); - out.textures_delta.clear(); - assert_eq!(samples, [(a, Some(0.2)), (b, Some(0.8))], "the last sample follows the normal pointer path"); - let events = (0..200).flat_map(|n| pen(egui::TouchPhase::Move, pos2(n as f32, 0.0), 0.5)).collect(); - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None)); - out.textures_delta.clear(); - assert!(samples.len() <= 64, "a stalled frame stays bounded"); - } - - #[test] - fn long_touch_drops_a_tool_press_without_committing_art() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width":800,"height":600})).unwrap(); - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - let tick = |app: &mut VectorcraftApp, time, events| { - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), - time: Some(time), - events, - ..Default::default() - }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - }; - tick(&mut app, 0.0, vec![]); - let pos = app.canvas_rect.unwrap().center(); - tick(&mut app, 0.1, pen(egui::TouchPhase::Start, pos, 0.5)); - assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Tool))); - tick(&mut app, 1.5, vec![]); - assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Gesture)), "long touch drops the tool drag"); - assert!(app.session.active().unwrap().interaction.is_none()); - tick(&mut app, 1.6, pen(egui::TouchPhase::End, pos, 0.5)); - assert!(app.session.active().unwrap().doc.art_bounds().is_none(), "releasing cannot commit the cancelled press"); - } - - /// #2826: a second finger drops a move waiting over a panel, so its interaction doesn't - /// outlive the drag that began it. - #[test] - fn a_second_finger_drops_a_move_waiting_over_a_panel() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.prefs.touch_gestures = true; - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let before = app.session.active().unwrap().doc.clone(); - let start = xf.to_screen(Point::new(100.0, 100.0)); - // One finger presses on the art, moves it, and leaves the canvas for a panel. - frame(&mut app, &ctx, pen(egui::TouchPhase::Start, start, 0.5)); - frame(&mut app, &ctx, pen(egui::TouchPhase::Move, start + vec2(40.0, 0.0), 0.5)); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); - assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Art))); - assert!(app.session.active().unwrap().interaction.is_some()); - assert!(egui::DragAndDrop::payload::(&ctx).is_some()); - // A second finger lands: the move is dropped, not left live without its pointer. - let second = egui::Event::Touch { - device_id: egui::TouchDeviceId(1), - id: egui::TouchId(2), - phase: egui::TouchPhase::Start, - pos: pos2(400.0, 300.0), - force: Some(0.5), - }; - frame(&mut app, &ctx, vec![second]); - assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Gesture))); - let st = app.session.active().unwrap(); - assert!(st.interaction.is_none(), "the move is dropped"); - assert_eq!(*st.doc, *before, "the art is back where it was"); - assert!(egui::DragAndDrop::payload::(&ctx).is_none(), "no panel drag is left"); - // Lifting the first finger commits nothing. - frame(&mut app, &ctx, pen(egui::TouchPhase::End, start + vec2(40.0, 0.0), 0.5)); - assert_eq!(*app.session.active().unwrap().doc, *before); - } - - /// The events eframe's winit integration sends for a pen (Windows Ink) or a finger: a touch - /// with its pressure, and the mouse it stands in for (#491). - fn pen(phase: egui::TouchPhase, pos: Pos2, force: f32) -> Vec { - let touch = egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: Some(force) }; - let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - match phase { - egui::TouchPhase::Start => vec![touch, egui::Event::PointerMoved(pos), button(true)], - egui::TouchPhase::Move => vec![touch, egui::Event::PointerMoved(pos)], - egui::TouchPhase::End | egui::TouchPhase::Cancel => vec![touch, button(false), egui::Event::PointerGone], - } - } - - /// A pen stroke across `pts` (document points), one frame per sample, with its pressures. - fn pen_stroke(app: &mut VectorcraftApp, ctx: &egui::Context, pts: &[(f64, f64, f32)]) { - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let last = pts.len() - 1; - for (i, &(x, y, force)) in pts.iter().enumerate() { - let phase = if i == 0 { egui::TouchPhase::Start } else { egui::TouchPhase::Move }; - frame(app, ctx, pen(phase, xf.to_screen(Point::new(x, y)), force)); - if i == last { - frame(app, ctx, pen(egui::TouchPhase::End, xf.to_screen(Point::new(x, y)), force)); - } - } - } - - /// #491: a pen or a finger draws as the mouse does, and its pressure reaches the tools that - /// use it; the pointer leaving with the lift doesn't move the stroke's end. - #[test] - fn pen_and_touch_input_drive_the_tools_with_their_pressure() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.select_tool("pencil"); - pen_stroke(&mut app, &ctx, &[(100.0, 100.0, 0.5), (140.0, 120.0, 0.6), (180.0, 150.0, 0.7), (220.0, 200.0, 0.4)]); - let b = app.session.active().unwrap().doc.art_bounds().expect("the pen drew a path"); - assert!(b.x0 > 90.0 && b.y0 > 90.0 && b.x1 < 230.0 && b.y1 < 210.0 && b.x1 > 200.0, "the stroke stays where the pen went: {b:?}"); - - // Bloat with Use Pressure Pen: each sample carries the pen's pressure. - app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap(); - app.select_tool("bloat"); - app.session.set_tool_option("usePressure", &json!(true)); - pen_stroke(&mut app, &ctx, &[(500.0, 350.0, 0.2), (500.0, 400.0, 0.5), (500.0, 420.0, 0.8)]); - let (cmd, p) = app.session.journal.last().unwrap().clone(); - assert_eq!(cmd, "object.liquify"); - let pressures: Vec = p["points"].as_array().unwrap().iter().map(|s| (s[2].as_f64().unwrap() * 100.0).round() / 100.0).collect(); - assert_eq!(pressures.first(), Some(&0.2), "the press's own pressure: {pressures:?}"); - assert!(pressures.contains(&0.5) && pressures.contains(&0.8), "{pressures:?}"); - } - - /// #764: on Linux under X11 a pen reaches egui as a mouse, and the host reads its pressure - /// itself (`Services::stylus`): the Paintbrush and Liquify's Use Pressure Pen get that - /// pressure, and once a mouse moves (`None`) a stroke presses fully. - #[test] - fn the_hosts_pen_pressure_drives_the_tools_under_mouse_events() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let feed = StylusFeed::default(); - app.services.stylus = Some(feed.clone()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - // One frame per sample: the reader has set the pressure before the frame's mouse events. - let stroke = |app: &mut VectorcraftApp, pts: &[(f64, f64, Option)]| { - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let button = - |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let mut at = Pos2::ZERO; - for (i, &(x, y, pressure)) in pts.iter().enumerate() { - feed.set(pressure); - at = xf.to_screen(Point::new(x, y)); - let mut events = vec![egui::Event::PointerMoved(at)]; - if i == 0 { - events.push(button(at, true)); - } - frame(app, &ctx, events); - } - frame(app, &ctx, vec![button(at, false)]); - }; - let pressures = |p: &serde_json::Value| -> Vec { - // A point without a third value was pressed fully. - let pressure = |s: &serde_json::Value| s.get(2).and_then(serde_json::Value::as_f64).map_or(1.0, |f| (f * 100.0).round() / 100.0); - p["points"].as_array().unwrap().iter().map(pressure).collect() - }; - - app.select_tool("paintbrush"); - stroke(&mut app, &[(100.0, 100.0, Some(0.3)), (140.0, 120.0, Some(0.6)), (180.0, 150.0, Some(0.9))]); - let (cmd, p) = app.session.journal.last().unwrap().clone(); - assert_eq!(cmd, "path.freehand"); - assert_eq!(pressures(&p), [0.3, 0.6, 0.9], "{p}"); - // The lift reaches the feed before the frame of the stroke's last move: that move keeps the - // pressure the pen last touched with, not the lift's 0. - stroke(&mut app, &[(100.0, 300.0, Some(0.3)), (140.0, 320.0, Some(0.5)), (180.0, 350.0, Some(0.0))]); - let (cmd, p) = app.session.journal.last().unwrap().clone(); - assert_eq!(cmd, "path.freehand"); - assert_eq!(pressures(&p), [0.3, 0.5, 0.5], "{p}"); - - app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap(); - app.select_tool("bloat"); - app.session.set_tool_option("usePressure", &json!(true)); - stroke(&mut app, &[(500.0, 350.0, Some(0.2)), (500.0, 400.0, Some(0.5)), (500.0, 420.0, Some(0.8))]); - let (cmd, p) = app.session.journal.last().unwrap().clone(); - assert_eq!(cmd, "object.liquify"); - let pen = pressures(&p); - assert_eq!(pen.first(), Some(&0.2), "the press's own pressure: {pen:?}"); - assert!(pen.contains(&0.5) && pen.contains(&0.8), "{pen:?}"); - - // A mouse moved last: no pen pressure. - stroke(&mut app, &[(350.0, 350.0, None), (350.0, 400.0, None), (350.0, 420.0, None)]); - let (cmd, p) = app.session.journal.last().unwrap().clone(); - assert_eq!(cmd, "object.liquify"); - assert!(pressures(&p).iter().all(|f| *f == 1.0), "{p}"); - - // A tap whose touch and lift both reach the feed before the frame that handles its press - // presses with the tap's pressure, not the lift's 0. - for p in [0.0, 0.4, 0.7, 0.0] { - feed.set(Some(p)); - } - let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(450.0, 400.0)); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]); - frame(&mut app, &ctx, vec![button(false)]); - let (cmd, p) = app.session.journal.last().unwrap().clone(); - assert_eq!(cmd, "object.liquify"); - assert_eq!(pressures(&p).first(), Some(&0.7), "{p}"); - } - - /// An app with an 800 × 600 document whose host reads the pen itself (`Services::stylus`), - /// after its first frame, with the pointer in the window. - fn pen_app() -> (VectorcraftApp, StylusFeed, egui::Context) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let feed = StylusFeed::default(); - app.services.stylus = Some(feed.clone()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))]); - (app, feed, ctx) - } - - /// The pen turned to its eraser end, with `pressure`. - fn eraser_end(pressure: f32) -> Option { - Some(crate::stylus::PenSample { pressure, eraser: true }) - } - - /// #764: turning the pen to its eraser end selects the Eraser, and turning it back to the tip - /// selects the tool before it, as the reference app's help describes ("Erase parts of a path - /// using a stylus pen"). Each turn acts once. - #[test] - fn the_eraser_end_selects_the_eraser_and_the_tip_brings_back_the_tool() { - let (mut app, feed, ctx) = pen_app(); - app.select_tool("paintbrush"); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "paintbrush", "the tip hovers"); - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser", "the eraser end hovers"); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser"); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "paintbrush", "the tip brings back the tool before"); - assert_eq!(app.ui.group_tool.get(vectorcraft_tools::catalog::group_of("paintbrush").unwrap()).map(String::as_str), Some("paintbrush")); - - // A turn acts once: a tool that becomes active while the eraser end stays in stays, and - // the tip doesn't replace a tool other than the Eraser. - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser"); - app.session.select_tool("pencil", app.view_info()).unwrap(); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "pencil"); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "pencil"); - } - - /// #764: the eraser end erases what it crosses, also when it comes in and presses in one - /// frame; the tool before it does not paint a stroke. - #[test] - fn the_eraser_end_erases() { - let (mut app, feed, ctx) = pen_app(); - app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 100, "height": 100})).unwrap(); - app.select_tool("paintbrush"); - let steps = app.session.journal.len(); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let [a, b, c] = [280.0, 350.0, 420.0].map(|x| xf.to_screen(Point::new(x, 250.0))); - feed.set_sample(eraser_end(0.6)); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c)]); - frame(&mut app, &ctx, vec![button(c, false)]); - let cmds: Vec<&str> = app.session.journal.since(steps).iter().map(|(c, _)| c.as_str()).collect(); - assert_eq!(cmds, ["path.eraseRegion"], "one Eraser step, no Paintbrush stroke"); - assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 2, "the rectangle cut in two"); - } - - /// #764: a turn of the pen while a press is held takes effect after the release. - #[test] - fn a_flip_during_a_press_waits_for_the_release() { - let (mut app, feed, ctx) = pen_app(); - app.select_tool("paintbrush"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let [a, b, c] = [100.0, 150.0, 200.0].map(|x| xf.to_screen(Point::new(x, 100.0))); - feed.set(Some(0.5)); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]); - feed.set_sample(eraser_end(0.5)); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); - assert_eq!(app.session.tool_id(), "paintbrush"); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c), button(c, false)]); - assert_eq!(app.session.tool_id(), "paintbrush"); - assert_eq!(app.session.journal.last().unwrap().0, "path.freehand", "the stroke ends with the Paintbrush"); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser", "the frame after the release"); - } - - /// #764: a tool chosen while the eraser end stands in (the toolbar and shortcuts call - /// `select_tool`; menus and MCP run `tool.select`) stays when the tip comes back. - #[test] - fn a_tool_chosen_while_the_eraser_end_stands_in_stays() { - let (mut app, feed, ctx) = pen_app(); - app.select_tool("paintbrush"); - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser"); - app.select_tool("rectangle"); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "rectangle"); - - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser"); - app.run("tool.select", json!({"tool": "eraser"})).unwrap(); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser", "the Eraser chosen again stays too"); - } - - /// #764: a mouse moving while the eraser end stands in does not change the tool, and an Eraser - /// chosen before the eraser end comes in stays when the tip comes back. - #[test] - fn a_mouse_and_an_explicit_eraser_change_nothing() { - let (mut app, feed, ctx) = pen_app(); - app.select_tool("paintbrush"); - feed.set(None); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "paintbrush", "a mouse"); - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - feed.set(None); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser", "a mouse between the turns"); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "paintbrush"); - - app.select_tool("eraser"); - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser", "chosen before the eraser end came in"); - } - - /// #764: the host reads the pen over every window. A turn made while the pointer is outside - /// this window takes effect when the pointer comes in, and a turn there and back does not - /// change the tool. - #[test] - fn a_turn_over_another_window_waits_for_the_pointer() { - let (mut app, feed, ctx) = pen_app(); - app.select_tool("paintbrush"); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - let inside = egui::Event::PointerMoved(Pos2::new(400.0, 300.0)); - frame(&mut app, &ctx, vec![egui::Event::PointerGone]); - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "paintbrush", "the pointer is in another window"); - frame(&mut app, &ctx, vec![inside.clone()]); - assert_eq!(app.session.tool_id(), "eraser", "the pointer came in"); - - frame(&mut app, &ctx, vec![egui::Event::PointerGone]); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser"); - frame(&mut app, &ctx, vec![inside.clone()]); - assert_eq!(app.session.tool_id(), "paintbrush"); - - frame(&mut app, &ctx, vec![egui::Event::PointerGone]); - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![inside]); - assert_eq!(app.session.tool_id(), "paintbrush", "turned there and back outside"); - } - - /// #764: the tip brings back the Perspective Grid tool as it was, with a grid hidden by Hide - /// Grid still hidden (choosing the tool afresh shows the grid). - #[test] - fn the_tip_brings_back_the_perspective_grid_tool_without_showing_the_grid() { - let (mut app, feed, ctx) = pen_app(); - let visible = - |app: &VectorcraftApp| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&app.session.active().unwrap().doc).visible; - app.select_tool("perspectiveGrid"); - assert!(visible(&app)); - app.session.execute("perspective.grid.show", &json!({"visible": false})).unwrap(); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - feed.set_sample(eraser_end(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "eraser"); - feed.set(Some(0.0)); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.tool_id(), "perspectiveGrid"); - assert!(!visible(&app), "the grid stays hidden"); - } - - /// #3328: every position a mouse reports within one frame reaches the Pencil, not only the - /// frame's last one, so a fast stroke keeps the hand's movement. - #[test] - fn a_mouse_stroke_keeps_every_move_of_a_frame() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.select_tool("pencil"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); - let button = - |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; - let start = at(100.0, 100.0); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); - // One frame of a fast stroke: a zigzag of 20 moves, 6 pt apart. - let zigzag: Vec<_> = (1..=20).map(|i| at(100.0 + 6.0 * f64::from(i), if i % 2 == 0 { 100.0 } else { 112.0 })).collect(); - frame(&mut app, &ctx, zigzag.iter().map(|p| egui::Event::PointerMoved(*p)).collect()); - let end = *zigzag.last().unwrap(); - frame(&mut app, &ctx, vec![button(end, false)]); - let (cmd, p) = app.session.journal.last().unwrap().clone(); - assert_eq!(cmd, "path.freehand"); - let pts = p["points"].as_array().unwrap(); - let expected: Vec<_> = std::iter::once(start) - .chain(zigzag.iter().copied()) - .map(|p| { - let p = xf.to_doc(p); - json!([p.x, p.y]) - }) - .collect(); - assert_eq!(pts, expected.as_slice(), "the stroke keeps every move of the frame, in order"); - } - - /// A frame whose moves end where the last frame's did still reaches the Pencil: the stroke went - /// out and came back in between. - #[test] - fn a_mouse_stroke_keeps_a_frame_that_ends_where_it_started() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.select_tool("pencil"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); - let button = - |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; - let (start, a) = (at(100.0, 100.0), at(160.0, 100.0)); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); - // Out to a bump and back to `a`, all in one frame. - let bump = [at(170.0, 80.0), at(180.0, 70.0), at(170.0, 80.0), a]; - frame(&mut app, &ctx, bump.iter().map(|p| egui::Event::PointerMoved(*p)).collect()); - frame(&mut app, &ctx, vec![button(a, false)]); - let (_, p) = app.session.journal.last().unwrap().clone(); - let n = p["points"].as_array().unwrap().len(); - assert!(n >= 5, "the bump's moves are kept: {n} points"); - } - - /// Only a tool that builds a stroke from the drag's samples gets every move of a frame (#4227): - /// the Selection tool and the other tools get one drag a frame, at the pointer's position, so a - /// costly drag preview runs once. - #[test] - fn only_the_freehand_tools_get_every_move_of_a_frame() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - let ctx = egui::Context::default(); - let events: Vec<_> = (0..20).map(|i| egui::Event::PointerMoved(pos2(100.0 + i as f32 * 5.0, 100.0))).collect(); - for (tool, every) in - [("pencil", true), ("paintbrush", true), ("blobBrush", true), ("selection", false), ("directSelection", false), ("width", false)] - { - app.select_tool(tool); - let mut moves = vec![]; - let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| moves = tool_frame_moves(ui, &app)); - out.textures_delta.clear(); - assert_eq!(moves.len(), if every { 19 } else { 0 }, "{tool}"); - } - } - - /// #491: a pen tap whose press and lift come in one frame selects what it tapped, and the - /// lift (the pointer gone with it) doesn't drag the selection to the canvas's centre. - #[test] - fn a_pen_tap_within_one_frame_clicks_where_it_tapped() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.none", &json!({})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - app.select_tool("selection"); - let before = app.session.active().unwrap().doc.art_bounds(); - let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(150.0, 150.0)); - let mut tap = pen(egui::TouchPhase::Start, at, 0.5); - tap.extend(pen(egui::TouchPhase::End, at, 0.5)); - frame(&mut app, &ctx, tap); - frame(&mut app, &ctx, vec![]); - let doc = app.session.active().unwrap(); - assert_eq!(doc.selection.objects, vec![vectorcraft_doc::NodeId(id)]); - assert_eq!(doc.doc.art_bounds(), before, "the tap moved nothing"); - } - - /// The Contextual Task Bar shows Recolor Artwork (a palette) when the selection has colours, - /// and it opens the Recolor Artwork dialog; art painted with None has no button. - #[test] - fn the_task_bar_recolors_coloured_art() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone(); - app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - let click = |app: &mut VectorcraftApp| { - frame(app, &ctx, vec![]); - frame(app, &ctx, vec![]); - let bar = task_bar_rect(&ctx).expect("the task bar shows"); - // Right to left: the frame's margin, More Options, Lock, then Recolor. - let at = pos2(bar.right() - 8.0 - 30.0 - 6.0 - 30.0 - 6.0 - 15.0, bar.center().y); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); - frame(app, &ctx, vec![button(true)]); - frame(app, &ctx, vec![button(false)]); - }; - let st = app.session.active().unwrap(); - assert!(has_colors(&st.doc, &st.selection.objects)); - click(&mut app); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::recolor::KIND)); - - app.ui.dialog = None; - app.session.execute("paint.setFill", &json!({"ids": [id], "none": true})).unwrap(); - app.session.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap(); - let st = app.session.active().unwrap(); - assert!(!has_colors(&st.doc, &st.selection.objects), "None paints have no colours"); - click(&mut app); - assert!(app.ui.dialog.is_none(), "no Recolor button without colours"); - } - - /// The Contextual Task Bar shows Image Trace first for a selected image: it traces the image - /// with the Default preset and leaves the Image Trace panel as it was. For the Image Trace - /// object it made, the first button is Expand, which keeps only the traced paths. - #[test] - fn the_task_bar_traces_an_image_then_expands_the_trace() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let data = vectorcraft_format::base64_encode(&png); - app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - let click_first = |app: &mut VectorcraftApp| { - frame(app, &ctx, vec![]); - frame(app, &ctx, vec![]); - let bar = task_bar_rect(&ctx).expect("the task bar shows"); - // Left to right: the frame's margin, the handle and its spacing, then the first button. - let at = pos2(bar.left() + 8.0 + 4.0 + 6.0 + 12.0, bar.center().y); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); - frame(app, &ctx, vec![button(true)]); - frame(app, &ctx, vec![button(false)]); - }; - let first = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().unwrap() - }; - assert!(matches!(first(&app).kind, NodeKind::Image(_))); - click_first(&mut app); - assert_eq!(first(&app).name.as_deref(), Some("Image Trace"), "the image was traced"); - assert_eq!(app.ui.open_panel, None, "the Image Trace panel is left as it was"); - - click_first(&mut app); - let expanded = first(&app); - assert_ne!(expanded.name.as_deref(), Some("Image Trace"), "the trace was expanded"); - let mut images = 0; - expanded.walk(&mut |n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); - assert_eq!(images, 0, "only the traced paths are kept"); - } - - /// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with - /// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as - /// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up. - fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> { - egui_extras::install_image_loaders(ctx); - frame(app, ctx, vec![]); - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - let Some(bar) = task_bar_rect(ctx) else { return vec![] }; - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => { - Some((t.galley.text().to_string(), t.visual_bounding_rect())) - } - _ => None, - }) - .collect() - } - - /// With the Artboard tool, the Contextual Task Bar sits under the selected artboards and offers - /// Resize, Duplicate, Rearrange All (with two or more artboards) and Export instead of the - /// selected objects' actions. With the Selection tool it offers the objects' actions again, and - /// with nothing selected it doesn't show. - #[test] - fn the_task_bar_offers_the_artboard_tools_actions() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - app.select_tool("selection"); - let ctx = egui::Context::default(); - let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); - assert!(names(&mut app).is_empty() && task_bar_rect(&ctx).is_none(), "nothing selected: no bar"); - - app.select_tool("artboard"); - crate::panels::artboards::select(&mut app, 1); - // Artboard 2 in the middle of the window, at 100%. - let b = app.session.active().unwrap().doc.artboards[1].rect; - let view = app.view_mut().unwrap(); - (view.center, view.zoom) = (b.center(), 1.0); - assert_eq!(names(&mut app), ["Resize", "Duplicate", "Rearrange All", "Export"]); - // Under artboard 2. - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let bar = task_bar_rect(&ctx).unwrap(); - let center = xf.to_screen(b.center()); - assert!(bar.top() > xf.to_screen(Point::new(b.x0, b.y1)).y && bar.x_range().contains(center.x), "{bar:?} under {b:?}"); - - // A selected object with the Selection tool: its own actions, none of the artboards'. - app.select_tool("selection"); - app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); - assert_eq!(names(&mut app), ["Edit Path", "Offset Path", "Simplify", "Duplicate"]); - - // One artboard: nothing to rearrange. - app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); - app.select_tool("artboard"); - assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]); - } - - /// The Artboard tool's Contextual Task Bar buttons act on its selected artboards: Resize opens - /// Artboard Options for the active one, Rearrange All opens Rearrange All Artboards, Export opens - /// Export for Screens with the selected artboards checked, and Duplicate copies each selected - /// artboard in one undo step and selects the copies. - #[test] - fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - app.select_tool("artboard"); - crate::panels::artboards::select(&mut app, 2); - app.session.set_tool_option("selected", &json!([2, 0])); - let ctx = egui::Context::default(); - let press = |app: &mut VectorcraftApp, label: &str| { - let labels = task_bar_labels(app, &ctx); - let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); - frame(app, &ctx, vec![button(true)]); - frame(app, &ctx, vec![button(false)]); - app.ui.dialog.take() - }; - let d = press(&mut app, "Resize").expect("Artboard Options"); - assert_eq!((d.kind.as_str(), d.f64("index", 9.0)), ("artboardOptions", 2.0)); - let d = press(&mut app, "Rearrange All").expect("Rearrange All Artboards"); - assert_eq!(d.kind, crate::dialogs::rearrange_artboards::KIND); - let d = press(&mut app, "Export").expect("Export for Screens"); - assert_eq!((d.kind.as_str(), &d.fields["boards"]), (crate::dialogs::EXPORT_FOR_SCREENS, &json!([true, false, true]))); - - let boards = - |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| (a.name.clone(), a.rect.size())).collect::>(); - let before = boards(&app); - assert!(press(&mut app, "Duplicate").is_none()); - let after = boards(&app); - assert_eq!(after[..3], before[..]); - assert_eq!(after[3..], [("Artboard 1 copy".to_string(), before[0].1), ("Artboard 3 copy".to_string(), before[2].1)]); - assert_eq!(app.session.tool_options()["selected"], json!([4, 3]), "the copies are selected, artboard 3's copy active"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(boards(&app), before, "one undo step"); - } - - /// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect, - /// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens - /// the path there and Connect closes it again, as the Control bar's anchor buttons do. - #[test] - fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("directSelection"); - app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); - let ctx = egui::Context::default(); - let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); - assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]); - let press = |app: &mut VectorcraftApp, label: &str| { - let labels = task_bar_labels(app, &ctx); - let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); - frame(app, &ctx, vec![button(true)]); - frame(app, &ctx, vec![button(false)]); - }; - let subpath = |app: &VectorcraftApp| { - app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone() - }; - let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone(); - press(&mut app, "Smooth"); - let a = subpath(&app).anchors[1]; - assert!(a.has_in() && a.has_out(), "smooth: {a:?}"); - assert_eq!(last_undo(&app), "Convert Anchor Points"); - press(&mut app, "Corner"); - let a = subpath(&app).anchors[1]; - assert!(!a.has_in() && !a.has_out(), "corner: {a:?}"); - press(&mut app, "Cut Path"); - assert_eq!(last_undo(&app), "Cut Path"); - let sp = subpath(&app); - assert!(!sp.closed && sp.anchors.len() == 5, "the rectangle opens at the anchor: {sp:?}"); - press(&mut app, "Connect"); - assert_eq!(last_undo(&app), "Join"); - let sp = subpath(&app); - assert!(sp.closed && sp.anchors.len() == 4, "the ends join again: {sp:?}"); - } - - /// The Contextual Task Bar's handle drags it: unpinned it keeps that offset as it follows the - /// selection, pinned it stays put when the selection changes, unpinned again it follows from - /// there, and Reset Bar Position puts it back under the selection. It never leaves the canvas, - /// also when the window shrinks. - #[test] - fn the_task_bar_is_dragged_by_its_handle_pinned_and_reset() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let mut square = - |x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone(); - let (a, b) = (square(120.0, 40.0), square(160.0, 150.0)); - app.select_tool("selection"); - let ctx = egui::Context::default(); - let bar = || task_bar_rect(&ctx).expect("the task bar shows"); - let select = |app: &mut VectorcraftApp, id: &serde_json::Value| { - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - frame(app, &ctx, vec![]); - frame(app, &ctx, vec![]); - bar().min - }; - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let drag = |app: &mut VectorcraftApp, by: Vec2| { - let grip = pos2(bar().left() + 11.0, bar().center().y); - frame(app, &ctx, vec![egui::Event::PointerMoved(grip)]); - frame(app, &ctx, vec![button(grip, true)]); - frame(app, &ctx, vec![egui::Event::PointerMoved(grip + by)]); - frame(app, &ctx, vec![button(grip + by, false)]); - frame(app, &ctx, vec![]); - bar().min - }; - let near = |p: Pos2, q: Pos2| assert!((p - q).length() < 1.5, "{p:?} is not at {q:?}"); - let (under_a, under_b) = (select(&mut app, &a), select(&mut app, &b)); - assert!(under_b.y > under_a.y + 100.0, "each bar sits under its selection"); - - // Dragged unpinned: it keeps its offset from the selection. - select(&mut app, &a); - let by = vec2(40.0, -30.0); - near(drag(&mut app, by), under_a + by); - near(select(&mut app, &b), under_b + by); - assert!(!app.ui.task_bar_place.pinned); - - // Pinned: it stays where it is across selection changes, and the handle still moves it. - assert_eq!(app.run("window.taskBar.pin", json!({})).unwrap(), json!(true)); - assert_eq!(crate::menus::checked(&app, "window.taskBar.pin", &json!({})), Some(true)); - near(select(&mut app, &a), under_b + by); - let spot = drag(&mut app, vec2(-20.0, 10.0)); - near(spot, under_b + by + vec2(-20.0, 10.0)); - near(select(&mut app, &b), spot); - - // Unpinned: it follows the selection from where it is, kept on the canvas. - assert_eq!(app.run("window.taskBar.pin", json!({"pinned": false})).unwrap(), json!(false)); - near(select(&mut app, &a), spot); - let canvas = app.canvas_rect.unwrap(); - let last = canvas.max - vec2(8.0, 8.0) - bar().size(); - near(select(&mut app, &b), (under_b + (spot - under_a)).min(last)); - assert!(app.run("window.taskBar.pin", json!({"pinned": "yes"})).is_err()); - - // Kept on the canvas: dragged far off, then pinned in the corner of a shrinking window. - near(drag(&mut app, vec2(-2000.0, -2000.0)), canvas.min + vec2(8.0, 8.0)); - app.run("window.taskBar.pin", json!({"pinned": true})).unwrap(); - drag(&mut app, vec2(2000.0, 2000.0)); - near(bar().max, canvas.max - vec2(8.0, 8.0)); - let small = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(640.0, 480.0))), ..Default::default() }; - for _ in 0..2 { - let mut out = ctx.run_ui(small.clone(), |ui| show(&mut app, ui)); - out.textures_delta.clear(); - } - let canvas = app.canvas_rect.unwrap(); - assert!(canvas.width() < 700.0, "the canvas shrank: {canvas:?}"); - near(bar().max, canvas.max - vec2(8.0, 8.0)); - - // Reset: back under the selection, unpinned. - frame(&mut app, &ctx, vec![]); - app.run("window.taskBar.reset", json!({})).unwrap(); - assert!(!app.ui.task_bar_place.pinned); - near(select(&mut app, &a), under_a); - near(select(&mut app, &b), under_b); - } - - /// In a view turned 180° the Contextual Task Bar shows under the selection's lowest corner on - /// screen, not over the selection. - #[test] - fn the_task_bar_stays_under_the_selection_in_a_rotated_view() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 170, "y": 90, "width": 60, "height": 120})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - app.select_tool("selection"); - app.run("view.rotate", json!({"angle": 180})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - frame(&mut app, &ctx, vec![]); - let bar = task_bar_rect(&ctx).expect("the task bar shows"); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let lowest = xf.quad(app.selection_bounds().unwrap()).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); - assert!(bar.top() > lowest, "the bar {bar:?} starts below {lowest}"); - } - - fn ruler_guide_origin_ui(alt: bool, cancel: bool) { - use egui::{Event, Modifiers}; - for (origin, preview, release) in [ - (Point::new(17.0, 23.0), Point::new(222.0, 238.0), Point::new(232.0, 243.0)), - (Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(233.0, 242.0)), - (Point::ZERO, Point::new(225.0, 235.0), Point::new(230.0, 245.0)), - ] { - for source_vertical in [false, true] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("document.setRulerOrigin", json!({"x": origin.x, "y": origin.y})).unwrap(); - app.run("shape.rectangle", json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap(); - let redo = app.session.active().unwrap().doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - app.session.active_mut().unwrap().mark_saved(); - app.ui.view.rulers = true; - app.ui.view.smart_guides = false; - app.ui.view.snap_to_point = false; - app.ui.view.snap_to_grid = false; - app.ui.view.snap_to_pixel = false; - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let view = app.view_mut().unwrap(); - view.zoom = 1.0; - view.rotation = 0.0; - view.center = Point::new(200.0, 150.0); - frame(&mut app, &ctx, vec![]); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let from = - if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) }; - let to = xf.to_screen(Point::new(223.0, 237.0)); - let end = xf.to_screen(Point::new(231.0, 244.0)); - assert!(rect.contains(to) && rect.contains(end)); - let mods = if alt { Modifiers::SHIFT | Modifiers::ALT } else { Modifiers::SHIFT }; - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: mods }; - let before = app.session.active().unwrap().doc.clone(); - let selection = app.session.active().unwrap().selection.clone(); - let history = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) - }; - let original_history = history(&app); - assert_eq!(original_history.1, 1); - frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods), Event::PointerMoved(from)]); - frame(&mut app, &ctx, vec![button(from, true)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); - let check = |app: &VectorcraftApp, switched: bool, expected: Point| { - let guides = &app.session.active().unwrap().doc.guides; - assert_eq!(guides.len(), 1); - let vertical = source_vertical != switched; - assert_eq!(guides[0].vertical, vertical); - let want = if vertical { expected.x } else { expected.y }; - assert!((guides[0].pos - want).abs() < 1e-4, "origin={origin:?} vertical={vertical}: {:?}, want {want}", guides[0]); - }; - assert_eq!(history(&app).0, original_history.0, "preview has no undo entry"); - if cancel { - // Cancellation is a baseline positive control, independent of snap correctness. - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1); - frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); - assert_eq!(app.session.active().unwrap().doc, before); - assert_eq!(app.session.active().unwrap().selection, selection); - assert_eq!(history(&app), original_history); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, redo); - continue; - } - check(&app, alt, preview); - if alt { - // Used only by the private #1782 compatibility regression, not standalone RED. - frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]); - check(&app, false, preview); - frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods)]); - check(&app, true, preview); - } - frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); - check(&app, alt, release); - let st = app.session.active().unwrap(); - assert_eq!(st.doc.ruler_origin_for(0), origin); - assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork"); - assert_eq!(st.selection, selection); - assert_eq!(history(&app), (original_history.0 + 1, 0, true)); - assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); - let after = st.doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, before); - assert_eq!(history(&app), (original_history.0, 1, false)); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, after); - } - } - } - - #[test] - fn ruler_guide_shift_origin_preview_release_and_history() { - ruler_guide_origin_ui(false, false); - } - - #[test] - fn ruler_guide_shift_origin_cancel_preserves_redo() { - ruler_guide_origin_ui(false, true); - } - - #[test] - fn ruler_guide_origin_display_fallback_accepts_only_bounded_finite_pairs() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({})).unwrap(); - // The global zero point as stored (artboard rulers check their own, see `ruler_origin_for`). - app.run("view.globalRulers", json!({"global": true})).unwrap(); - let max = vectorcraft_engine::MAX_COORD; - for (origin, expected) in [ - (Point::ZERO, Point::ZERO), - (Point::new(17.0, -23.0), Point::new(17.0, -23.0)), - (Point::new(max, -max), Point::new(max, -max)), - (Point::new(f64::NAN, 23.0), Point::ZERO), - (Point::new(17.0, f64::INFINITY), Point::ZERO), - (Point::new(max + 1.0, 23.0), Point::ZERO), - (Point::new(17.0, -max - 1.0), Point::ZERO), - ] { - std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).ruler_origin = origin; - assert_eq!(app.ruler_origin(), expected); - } - } - - #[test] - fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { - use egui::Event; - for (vertical, target, shown) in [(true, 222.0, 205.0), (false, 238.0, 215.0)] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("document.setRulerOrigin", json!({"x": 17, "y": 23})).unwrap(); - app.run("guide.add", json!({"vertical": vertical, "pos": 100})).unwrap(); - app.ui.view.guides = true; - app.ui.view.smart_guides = false; - app.ui.view.snap_to_point = false; - app.ui.view.snap_to_grid = false; - app.ui.view.snap_to_pixel = false; - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let view = app.view_mut().unwrap(); - view.zoom = 1.0; - view.rotation = 0.0; - view.center = Point::new(200.0, 150.0); - frame(&mut app, &ctx, vec![]); - app.session.active_mut().unwrap().mark_saved(); - let before = app.session.active().unwrap().doc.clone(); - let undo = app.session.active().unwrap().history.undo.len(); - let rect = app.canvas_rect.unwrap(); - let xf = Xf::new(rect, app.view().unwrap()); - let from = xf.to_screen(if vertical { Point::new(100.0, 50.0) } else { Point::new(50.0, 100.0) }); - let to = xf.to_screen(if vertical { Point::new(target, 50.0) } else { Point::new(50.0, target) }); - assert!(rect.contains(from) && rect.contains(to)); - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); - frame(&mut app, &ctx, vec![button(from, true)]); - frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4); - assert_eq!(app.session.active().unwrap().selection.guides, vec![0]); - // Observe the real selected-guide field; no expected strings are inserted into UI state. - let fields = egui::Context::default(); - crate::theme::install_fonts(&fields); - let mut texts = vec![]; - for _ in 0..3 { - let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() }; - let mut out = fields.run_ui(input, |ui| crate::panels::transform::show(&mut app, ui)); - out.textures_delta.clear(); - fn collect(shape: &egui::Shape, texts: &mut Vec) { - match shape { - egui::Shape::Text(t) => texts.push(t.galley.text().to_string()), - egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, texts)), - _ => {} - } - } - texts.clear(); - out.shapes.iter().for_each(|s| collect(&s.shape, &mut texts)); - } - assert!(texts.iter().any(|t| t == &Unit::Points.format(shown)), "{texts:?}"); - let view = app.view_info(); - let overlays = app.session.overlays(view); - let want = format!("{}: {shown:.2} pt", if vertical { "X" } else { "Y" }); - assert!( - overlays.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Measure { text, .. } if text == &want)), - "{overlays:?}, field={shown}" - ); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo); - frame(&mut app, &ctx, vec![button(to, false)]); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, before); - } - } - - /// Distance Guides on a canvas at 100%: a 400 × 300 pt document with a 50 pt square at (50, 50), - /// selected, and with `other` another 100 pt to its right, the Selection tool and Smart Guides - /// on → the app, its context, the view, the screen point over the middle of where the other - /// square is (or would be) and the selected square's id. - fn distance_scene(other: bool) -> (VectorcraftApp, egui::Context, Xf, Pos2, serde_json::Value) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let a = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 50, "height": 50})).unwrap()["id"].clone(); - if other { - app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap(); - } - app.run("select.set", json!({"ids": [a]})).unwrap(); - app.ui.view.smart_guides = true; - app.select_tool("selection"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let view = app.view_mut().unwrap(); - view.zoom = 1.0; - view.rotation = 0.0; - view.center = Point::new(200.0, 150.0); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let over = xf.to_screen(Point::new(225.0, 75.0)); - (app, ctx, xf, over, a) - } - - /// One frame with `events` → the shapes drawn, flattened. The modifiers held stay from one - /// frame to the next until an `Event::ModifiersChanged`. - fn distance_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { - distance_frame_covered(app, ctx, events, None) - } - - /// What opens over the canvas in [`distance_frame_covered`]. - #[derive(Clone, Copy)] - enum Cover { - /// A floating window (a floating panel, a menu, a popup) over this rectangle. - Panel(egui::Rect), - /// A modal dialog, with the backdrop that keeps the rest of the app from the pointer. - Dialog, - } - - /// [`distance_frame`] with `cover` drawn over the canvas. - fn distance_frame_covered(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, cover: Option) -> Vec { - let raw = egui::RawInput { screen_rect: Some(WINDOW), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - show(app, ui); - match cover { - Some(Cover::Panel(r)) => { - egui::Area::new(egui::Id::unique("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| { - ui.allocate_space(r.size()); - }); - } - Some(Cover::Dialog) => { - crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::unique("test-dialog"), 0.0, 8, |ui| ui.label("A dialog")); - } - None => {} - } - }); - out.textures_delta.clear(); - fn flat(s: Shape, out: &mut Vec) { - match s { - Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), - s => out.push(s), - } - } - let mut v = vec![]; - out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v)); - v - } - - /// The texts drawn in `s`. - fn texts(s: &[Shape]) -> Vec { - s.iter().filter_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()) } else { None }).collect() - } - - /// The label "100.00 pt" drawn in `s` and the pill of the smart guide colour it sits in, if any. - fn gap_label(s: &[Shape]) -> Option<(egui::Rect, Option)> { - let magenta = c32(vectorcraft_tools::guides::MAGENTA); - let text = - s.iter().find_map(|s| if let Shape::Text(t) = s { (t.galley.text() == "100.00 pt").then(|| t.visual_bounding_rect()) } else { None })?; - let pill = s - .iter() - .find_map(|s| if let Shape::Rect(r) = s { (r.fill == magenta && r.rect.contains(text.center())).then_some(r.rect) } else { None }); - Some((text, pill)) - } - - /// Distance Guides on screen: with a square selected and Alt held over another to its right, - /// the frame draws the measured line between their facing edges with the gap's length in a - /// pill of the smart guide colour on its middle; without Alt, or with Alt let go, nothing. - #[test] - fn alt_over_another_object_draws_the_distance_guide_label() { - use egui::{Event, Modifiers}; - let (mut app, ctx, xf, over, _) = distance_scene(true); - let mut run = |events| distance_frame(&mut app, &ctx, events); - run(vec![Event::PointerMoved(over)]); - assert_eq!(gap_label(&run(vec![])), None, "without Alt"); - run(vec![Event::ModifiersChanged(Modifiers::ALT)]); - let s = run(vec![]); - let (text, pill) = gap_label(&s).expect("the gap's length is drawn"); - let pill = pill.expect("in a pill of the smart guide colour"); - let mid = xf.to_screen(Point::new(150.0, 75.0)); - assert!(pill.center().distance(mid) < 1.0 && pill.contains_rect(text), "{pill:?} on {mid:?}"); - let (from, to) = (xf.to_screen(Point::new(100.0, 75.0)), xf.to_screen(Point::new(200.0, 75.0))); - let magenta = c32(vectorcraft_tools::guides::MAGENTA); - let measured = s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if *points == [from, to] && stroke.color == magenta)); - assert!(measured, "the measured line between the facing edges"); - run(vec![Event::ModifiersChanged(Modifiers::NONE)]); - assert_eq!(gap_label(&run(vec![])), None, "Alt let go"); - } - - /// The Distance Guides go when the pointer stops hovering for the tool with Alt still held: it - /// leaves the canvas for a panel or out of the window, or a press pans (Alt+Space) and takes it - /// over the panels; they come back when it hovers the other square again. - #[test] - fn distance_guides_go_when_the_hover_ends() { - use egui::{Event, Modifiers}; - let (mut app, ctx, xf, over, _) = distance_scene(true); - let outside = pos2(xf.rect.max.x + 10.0, xf.rect.center().y); - assert!(WINDOW.contains(outside) && !xf.rect.contains(outside), "a panel beside the canvas"); - let mut run = |events| distance_frame(&mut app, &ctx, events); - run(vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]); - assert!(gap_label(&run(vec![])).is_some(), "shown over the other square"); - run(vec![Event::PointerMoved(outside)]); - assert_eq!(gap_label(&run(vec![])), None, "gone over a panel, Alt still held"); - run(vec![Event::PointerMoved(over)]); - assert!(gap_label(&run(vec![])).is_some(), "back over the other square"); - run(vec![Event::PointerGone]); - assert_eq!(gap_label(&run(vec![])), None, "gone out of the window"); - // Back over the square, Space held with Alt pans: they go at the press, and stay gone while - // the pan takes the pointer out over a panel. - let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::ALT }; - let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT }; - run(vec![Event::PointerMoved(over)]); - run(vec![space(true)]); - assert!(gap_label(&run(vec![])).is_some(), "Space held: still shown"); - run(vec![button(over, true)]); - assert_eq!(gap_label(&run(vec![])), None, "gone at the pan's press"); - run(vec![Event::PointerMoved(over + vec2(20.0, 0.0))]); - run(vec![Event::PointerMoved(outside)]); - assert_eq!(gap_label(&run(vec![])), None, "still gone after panning out over a panel"); - run(vec![button(outside, false), space(false)]); - } - - /// With Alt held and the pointer still, the Distance Guides follow what lies under it: they go - /// under a floating panel or a modal dialog and come back when it closes; scrolled so that - /// empty artboard lies under the pointer, they measure to the artboard; when an edit puts an - /// object under the pointer, they measure to it; and the Selection tool switched to shows them - /// at once. - #[test] - fn distance_guides_follow_what_lies_under_a_still_pointer() { - use egui::{Event, Modifiers}; - let (mut app, ctx, _, over, _) = distance_scene(true); - let run = |app: &mut VectorcraftApp, events, cover| distance_frame_covered(app, &ctx, events, cover); - run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)], None); - assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "shown over the other square"); - // A floating panel, then a modal dialog, opens over the still pointer. - for cover in [Cover::Panel(egui::Rect::from_center_size(over, vec2(120.0, 120.0))), Cover::Dialog] { - run(&mut app, vec![], Some(cover)); - run(&mut app, vec![], Some(cover)); - assert_eq!(gap_label(&run(&mut app, vec![], Some(cover))), None, "gone under it"); - // A frame for egui to drop the closed window, one for the hover to come back. - run(&mut app, vec![], None); - run(&mut app, vec![], None); - assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "back once it closes"); - } - // Another tool and back to the Selection tool (its shortcut, the toolbar): a new Selection - // tool, hovered at once. - app.select_tool("rectangle"); - run(&mut app, vec![], None); - assert_eq!(gap_label(&run(&mut app, vec![], None)), None, "the Rectangle tool shows none"); - app.select_tool("selection"); - run(&mut app, vec![], None); - assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "the Selection tool again"); - // The view scrolls 150 pt (the wheel, a gesture, the keys): empty artboard is under the - // pointer, and the selected square is 300 pt from the artboard's right edge. - app.view_mut().unwrap().center.x += 150.0; - run(&mut app, vec![], None); - let s = run(&mut app, vec![], None); - assert_eq!(gap_label(&s), None, "no longer over the other square"); - assert!(texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s)); - - // A square made under the still pointer, the first selected again (an edit, an undo). - let (mut app, ctx, _, over, a) = distance_scene(false); - let run = |app: &mut VectorcraftApp, events| distance_frame(app, &ctx, events); - run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]); - let s = run(&mut app, vec![]); - assert!(gap_label(&s).is_none() && texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s)); - app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap(); - app.run("select.set", json!({"ids": [a]})).unwrap(); - run(&mut app, vec![]); - assert!(gap_label(&run(&mut app, vec![])).is_some(), "to the new square"); - // Another document (a new tab) under the still pointer: nothing selected there, none. - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - run(&mut app, vec![]); - assert_eq!(gap_label(&run(&mut app, vec![])), None, "another document"); - } - - /// The 800 × 600 window [`frame`] draws the canvas in. - const WINDOW: egui::Rect = egui::Rect { min: Pos2::ZERO, max: Pos2 { x: 800.0, y: 600.0 } }; - - /// A 400 × 300 pt document at 400%, turned `rotation` degrees, with the Rectangle tool: a press - /// that reached the tool would draw. - fn zoomed_in(rotation: f64) -> (VectorcraftApp, egui::Context) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("rectangle"); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let v = app.view_mut().unwrap(); - v.zoom = 4.0; - v.rotation = rotation; - frame(&mut app, &ctx, vec![]); - (app, ctx) - } - - /// Where the document's point `p` is on screen. - fn on_screen(app: &VectorcraftApp, p: Point) -> Pos2 { - Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(p) - } - - /// Scroll bar `k` (0 horizontal, 1 vertical): its track, its span and the thumb's centre. - fn scroll_bar(app: &mut VectorcraftApp, k: usize) -> (egui::Rect, Span, Pos2) { - let rect = app.canvas_rect.unwrap(); - let track = scroll_tracks(rect, WINDOW)[k]; - let span = scroll_spans(&Xf::new(rect, app.view().unwrap()), app.content_bounds())[k]; - let len = if k == 0 { track.width() } else { track.height() }; - let (at, size) = span.thumb(len); - let c = if k == 0 { pos2(track.left() + at + size / 2.0, track.center().y) } else { pos2(track.center().x, track.top() + at + size / 2.0) }; - (track, span, c) - } - - fn primary(pos: Pos2, pressed: bool) -> egui::Event { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } - } - - /// #2322: the Normal and Full Screen with Menu Bar modes keep the scroll bars along the right - /// and bottom edges, beside the rulers; Full Screen and Presentation Mode give the canvas the - /// whole window. - #[test] - fn scroll_bars_take_the_right_and_bottom_edges_in_the_windowed_screen_modes() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.ui.view.rulers = true; - let ctx = egui::Context::default(); - for (mode, max) in - [(0, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (1, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (2, WINDOW.max), (3, WINDOW.max)] - { - app.ui.screen_mode = mode; - frame(&mut app, &ctx, vec![]); - assert_eq!(app.canvas_rect.unwrap().max, max, "screen mode {mode}"); - } - } - - /// #2322: the thumb is as long, against its track, as the view against the art and the - /// artboards with half a window around them; zoomed in it is shorter. - #[test] - fn a_scroll_bar_thumb_is_as_long_as_the_view_is_against_the_content() { - assert_eq!(Span { lo: 0.0, hi: 100.0, v0: 0.0, v1: 100.0 }.thumb(50.0), (0.0, 50.0), "all in view: the whole track"); - assert_eq!(Span { lo: 0.0, hi: 400.0, v0: 150.0, v1: 250.0 }.thumb(100.0), (37.5, 25.0)); - assert_eq!(Span { lo: 0.0, hi: 1000.0, v0: 450.0, v1: 550.0 }.thumb(100.0), (40.0, 20.0), "never shorter than MIN_THUMB"); - let (mut app, ctx) = zoomed_in(0.0); - let len = |app: &mut VectorcraftApp| { - let (track, span, _) = scroll_bar(app, 0); - span.thumb(track.width()).1 - }; - let at_400 = len(&mut app); - app.view_mut().unwrap().zoom = 8.0; - frame(&mut app, &ctx, vec![]); - assert!(len(&mut app) < at_400, "zoomed in, the thumb is shorter"); - } - - /// #2322: dragging a thumb scrolls the view along the screen's axis, in a turned view too, by - /// as much as the thumb's travel stands for; the drag never reaches the tool under the bar. - #[test] - fn dragging_a_scroll_bar_thumb_scrolls_the_view_and_draws_nothing() { - for rotation in [0.0, 90.0, 30.0] { - for k in 0..2 { - let (mut app, ctx) = zoomed_in(rotation); - let p = Point::new(200.0, 150.0); - let before = on_screen(&app, p); - let (track, span, from) = scroll_bar(&mut app, k); - let len = if k == 0 { track.width() } else { track.height() }; - let step = if k == 0 { vec2(40.0, 0.0) } else { vec2(0.0, 40.0) }; - let moved = (40.0 * span.ratio(len)).clamp(span.lo - span.v0, span.hi - span.v1); - assert!(moved > 40.0, "at 400% the view moves further than the thumb: {moved}"); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from)]); - frame(&mut app, &ctx, vec![primary(from, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step / 2.0)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step)]); - frame(&mut app, &ctx, vec![primary(from + step, false)]); - frame(&mut app, &ctx, vec![]); - assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool under the bar drew nothing"); - let after = on_screen(&app, p); - let want = before - step.normalized() * moved; - assert!((after - want).length() < 0.5, "rotation {rotation}, bar {k}: {before:?} -> {after:?}, want {want:?}"); - } - } - } - - /// #2322: a click on the track beyond the thumb scrolls a page (most of the window) towards it. - #[test] - fn a_click_on_a_scroll_bar_track_scrolls_a_page() { - let (mut app, ctx) = zoomed_in(0.0); - let p = Point::new(200.0, 150.0); - let before = on_screen(&app, p); - let (track, _, _) = scroll_bar(&mut app, 0); - let page = 0.9 * app.canvas_rect.unwrap().width(); - let at = pos2(track.right() - 3.0, track.center().y); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); - frame(&mut app, &ctx, vec![primary(at, true)]); - frame(&mut app, &ctx, vec![primary(at, false)]); - frame(&mut app, &ctx, vec![]); - let after = on_screen(&app, p); - assert!((after.x - (before.x - page)).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "{before:?} -> {after:?}, page {page}"); - assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); - } - - /// #2322: the wheel over the horizontal bar scrolls sideways, over the vertical one up and - /// down (over the canvas it scrolls up and down). - #[test] - fn the_wheel_over_a_scroll_bar_scrolls_along_it() { - for k in 0..2 { - let (mut app, ctx) = zoomed_in(0.0); - let p = Point::new(200.0, 150.0); - let before = on_screen(&app, p); - let (track, _, _) = scroll_bar(&mut app, k); - let at = track.center(); - let wheel = egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, -30.0), - phase: egui::TouchPhase::Move, - modifiers: Default::default(), - source: egui::MouseWheelSource::Unknown, - }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); - frame(&mut app, &ctx, vec![wheel]); - for _ in 0..30 { - frame(&mut app, &ctx, vec![]); - } - let after = on_screen(&app, p); - let want = if k == 0 { before - vec2(30.0, 0.0) } else { before - vec2(0.0, 30.0) }; - assert!((after - want).length() < 0.5, "bar {k}: {before:?} -> {after:?}, want {want:?}"); - } - } - #[test] - fn live_trace_selection_highlight_excludes_the_retained_source_rectangle() { - use std::sync::Arc; - use vectorcraft_doc::{Appearance, ImageObject, NodeId, TraceView}; - let mut source = Node::new( - NodeId(2), - NodeKind::Image(Box::new(ImageObject { - key: "synthetic".into(), - width: 200, - height: 200, - xf: Affine::IDENTITY, - link: None, - placement: Default::default(), - pixels: None, - })), - ); - source.visible = false; - let art = - Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)), Appearance::default()); - let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art)]); - let doc = vectorcraft_doc::Document::new(300.0, 300.0); - let symbols = doc.symbol_boxes(); - for view in TraceView::ALL { - group.extras_mut().trace = Some(Box::new(json!({"view":view.id()}))); - let mut visited = vec![]; - walk_drawn(&group, &mut |n| visited.push(n.id)); - assert_eq!(visited.contains(&NodeId(2)), view.shows_source()); - let want = if view.shows_source() { - vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0) - } else { - vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0) - }; - assert_eq!(node_outline(&group, &symbols).bounding_box(), want); - } - group.extras_mut().trace = None; - assert_eq!(node_outline(&group, &symbols).bounding_box(), vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0)); - } - - /// Hovering over point type shows only its baseline (not the transform bounding box), while - /// selecting it shows both the bounding box with handles and the baseline. - #[test] - fn point_type_hover_shows_baseline_only_and_selection_shows_both_box_and_baseline() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); - app.ui.task_bar = false; - app.ui.view.rulers = false; - let made = app.run("text.create", json!({"x": 200, "y": 150, "text": "Lorem ipsum", "size": 36})).unwrap(); - let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap()); - app.run("select.none", json!({})).unwrap(); - app.select_tool("selection"); - - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let color = c32(app.session.active().unwrap().doc.layer_color(id)); - let layer_mode = egui::epaint::ColorMode::Solid(color); - - // Hover over the text: hover highlight shows the baseline (1.5 px stroke), NOT a bounding box. - let over = xf.to_screen(Point::new(220.0, 140.0)); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); - let hover_shapes = shapes(&mut app, &ctx); - - // There should be a 1.5px path outline for the hover highlight. - let hover_outlines: Vec<_> = - hover_shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer_mode)).collect(); - assert!(!hover_outlines.is_empty(), "hover highlight shows baseline"); - - let is_handle = |s: &Shape| matches!(s, Shape::Rect(r) if (r.rect.width() - 6.0).abs() < 1.0 && (r.rect.height() - 6.0).abs() < 1.0 && r.fill == Color32::WHITE); - - // There should be NO bounding box resize handles (size 6x6 white squares) while just hovering. - let handles: Vec<_> = hover_shapes.iter().filter(|s| is_handle(s)).collect(); - assert!(handles.is_empty(), "hovering does not show bounding box handles: {handles:?}"); - - // Now select the text (click): both the bounding box (with handles) and the baseline are shown. - app.run("select.set", json!({"ids": [id.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - let sel_shapes = shapes(&mut app, &ctx); - - // Bounding box handles (8 handles around the text) should now be present. - let handles: Vec<_> = sel_shapes.iter().filter(|s| is_handle(s)).collect(); - assert_eq!(handles.len(), 8, "selected text shows 8 bounding box handles"); - - // Baseline (Shape::LineSegment at y=150) should also be present. - let y = xf.to_screen(Point::new(0.0, 150.0)).y; - let baselines: Vec<_> = sel_shapes - .iter() - .filter(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == color && (points[0].y - y).abs() < 0.5)) - .collect(); - assert!(!baselines.is_empty(), "selected text shows baseline alongside bounding box"); - } - - /// #2572: Show Text Threads links a thread's frames from the out port of one to the in port of - /// the next, where the tools draw those ports, each with the arrow of a threaded port; nothing - /// is drawn while no frame of the thread is selected. - #[test] - fn text_threads_run_from_each_out_port_to_the_next_in_port() { - use vectorcraft_tools::text::{Port, port_centre}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); - let text = "Words that fill more than the frame holds. ".repeat(10); - let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone(); - let b = app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap()["id"].clone(); - let frame = |app: &VectorcraftApp, id: &serde_json::Value| match &app - .session - .active() - .unwrap() - .doc - .node(vectorcraft_doc::NodeId(id.as_u64().unwrap())) - .unwrap() - .kind - { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("not text"), - }; - let zoom = 2.0; - let (out, input) = (port_centre(&frame(&app, &a), Port::Out, 0.5).unwrap(), port_centre(&frame(&app, &b), Port::In, 0.5).unwrap()); - let o = thread_overlays(&app, zoom); - assert!(o.iter().any(|x| matches!(x, Overlay::Line { a, b, .. } if *a == out && *b == input)), "the thread line: {o:?}"); - let boxes: Vec = - o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect(); - assert_eq!(boxes.len(), 2); - assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}"); - // Two arrows: three strokes each, beside the thread line. - assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7); - app.run("select.none", json!({})).unwrap(); - assert!(thread_overlays(&app, zoom).is_empty()); - } - - /// A double-click with the Selection tool on the out port of a threaded frame breaks the - /// thread there: no new frame is threaded on, and the story flows back into the first frame. - #[test] - fn a_double_click_on_a_threaded_out_port_breaks_the_thread() { - use vectorcraft_tools::text::{Port, port_centre}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); - let text = "Words that fill more than the frame holds. ".repeat(10); - let a = - app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); - app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap(); - app.run("select.set", json!({"ids": [a]})).unwrap(); - app.select_tool("selection"); - let doc = |app: &VectorcraftApp| app.session.active().unwrap().doc.clone(); - let before = doc(&app); - let NodeKind::Text(t) = &before.node(vectorcraft_doc::NodeId(a)).unwrap().kind else { panic!("not text") }; - let view = app.view_info(); - let at = port_centre(t, Port::Out, 1.0 / view.zoom).unwrap(); - let count = doc(&app).layers[0].children().map_or(0, |c| c.len()); - for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { - crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, at.x, at.y), view); - } - let d = doc(&app); - assert!(d.text_threads.is_empty(), "the thread is broken"); - assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), count, "no new frame"); - let NodeKind::Text(t) = &d.node(vectorcraft_doc::NodeId(a)).unwrap().kind else { panic!("not text") }; - assert_eq!(t.plain_text(), text, "the story flows into the first frame"); - assert_eq!(app.session.tool_id(), "selection"); - } - - /// With more than `PORTS_MAX` type objects selected the tools draw no ports, and Show Text - /// Threads draws only its lines, no port boxes. - #[test] - fn text_threads_of_a_huge_selection_have_lines_but_no_ports() { - use vectorcraft_tools::text::PORTS_MAX; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); - let text = "Words that fill more than the frame holds. ".repeat(10); - let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone(); - app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap(); - app.run("text.create", json!({"x": 20, "y": 300, "text": "x", "area": {"width": 50, "height": 20}})).unwrap(); - while app.session.active().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) <= PORTS_MAX { - app.run("select.all", json!({})).unwrap(); - app.run("edit.duplicate", json!({})).unwrap(); - } - app.run("select.all", json!({})).unwrap(); - let o = thread_overlays(&app, 1.0); - assert!(o.iter().any(|x| matches!(x, Overlay::Line { .. })), "the thread lines"); - assert!(!o.iter().any(|x| matches!(x, Overlay::Highlight { .. } | Overlay::Path { .. })), "no port boxes"); - } - - /// A frame of a thread in perspective has no ports: Show Text Threads draws no port box on it - /// and no line to or from it; the links between frames with ports keep theirs. - #[test] - fn text_threads_skip_the_links_of_a_frame_in_perspective() { - use vectorcraft_tools::text::{Port, port_centre}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); - let text = "Words that fill more than the frame holds. ".repeat(20); - let id = |v: serde_json::Value| vectorcraft_doc::NodeId(v["id"].as_u64().unwrap()); - let a = id(app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()); - let b = id(app.run("text.thread.add", json!({"from": a.0, "at": [200, 20]})).unwrap()); - let c = id(app.run("text.thread.add", json!({"from": b.0, "at": [20, 200]})).unwrap()); - let d = id(app.run("text.thread.add", json!({"from": c.0, "at": [200, 200]})).unwrap()); - app.session - .edit("Perspective", |doc, _| { - if let Some(n) = doc.node_mut(b) { - n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment::new("left", 0.0))); - } - Ok(()) - }) - .unwrap(); - app.run("select.set", json!({"ids": [a.0, b.0, c.0, d.0]})).unwrap(); - let st = app.session.active().unwrap(); - assert_eq!(st.doc.text_threads, [vec![a, b, c, d]], "b is still threaded"); - let frame = |id| match &st.doc.node(id).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("not text"), - }; - let (out, input) = (port_centre(&frame(c), Port::Out, 1.0).unwrap(), port_centre(&frame(d), Port::In, 1.0).unwrap()); - let o = thread_overlays(&app, 1.0); - let boxes: Vec = - o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect(); - assert_eq!(boxes.len(), 2, "only c's out port and d's in port: {boxes:?}"); - assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}"); - // One thread line (c to d) and its two arrows of three strokes each. - assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7); - } -} +mod tests; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs new file mode 100644 index 000000000..5ba194a9d --- /dev/null +++ b/crates/ui-egui/src/canvas/tests.rs @@ -0,0 +1,7182 @@ +use super::*; +use vectorcraft_engine::Session; +use vectorcraft_geom::Shape as _; + +/// A rendered raster becomes the image egui's own conversion makes of it, half-transparent +/// edges and all; one whose bytes don't match its size becomes an empty image of that size. +#[test] +fn rendered_rasters_become_the_same_egui_images() { + let mut s = Session::new(); + s.execute("file.new", &serde_json::json!({})).unwrap(); + s.execute("shape.ellipse", &serde_json::json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap(); + s.execute("paint.setFill", &serde_json::json!({"color": "#3366cc"})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 97, 61, Affine::IDENTITY, &vectorcraft_render::RenderOptions::default()); + assert!(img.pixels.as_chunks::<4>().0.iter().any(|p| p[3] != 0 && p[3] != 255), "partly covered pixels"); + let egui = egui::ColorImage::from_rgba_premultiplied([97, 61], &img.pixels); + assert_eq!(color_image(&img), egui); + let short = vectorcraft_render::Rendered { width: 3, height: 2, pixels: vec![255; 5] }; + assert_eq!(color_image(&short), egui::ColorImage::new([3, 2], vec![egui::Color32::TRANSPARENT; 6])); +} + +/// With VECTORCRAFT_SYSTEM_CURSORS set the glyphs aren't drawn and the system's cursors stand in: those of +/// the Pen and the other drawing tools are the system crosshair already, so Use Precise Cursors makes no +/// difference to them there. +#[test] +fn system_cursors_show_the_same_crosshair_with_precise_cursors_on_or_off() { + for c in [ + Cursor::Pen, + Cursor::PenDrawing, + Cursor::PenAdd, + Cursor::PenDelete, + Cursor::PenClose, + Cursor::PenContinue, + Cursor::PenJoin, + Cursor::PenConvert, + Cursor::Eyedropper, + Cursor::Slice, + Cursor::Blend, + Cursor::BlendObject, + Cursor::BlendAnchor, + Cursor::ShapeBuilder, + Cursor::ShapeBuilderErase, + Cursor::PencilNew, + Cursor::PencilStraight, + Cursor::PencilContinue, + ] { + assert_eq!(cursor_icon(c), egui::CursorIcon::Crosshair, "{c:?}"); + assert_eq!(cursor_icon(c), cursor_icon(c.precise()), "{c:?}"); + } +} + +/// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one +/// reads x where the canvas has it. +#[test] +fn rulers_cover_their_length_at_any_rotation() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let full = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let [top, left, _] = ruler_rects(full); + for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] { + let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() }; + let scale = RulerScale::document(Unit::Points, Point::ZERO); + let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); + out.textures_delta.clear(); + // The ticks' positions along each ruler. + let along = |ruler: egui::Rect, x: bool| -> Vec { + let mut at: Vec = out + .shapes + .iter() + .filter(|c| c.clip_rect == ruler) + .filter_map(|c| match &c.shape { + Shape::LineSegment { points: [a, b], .. } if x && a.x == b.x => Some(a.x), + Shape::LineSegment { points: [a, b], .. } if !x && a.y == b.y => Some(a.y), + _ => None, + }) + .collect(); + at.sort_by(f32::total_cmp); + at + }; + for (ruler, x) in [(top, true), (left, false)] { + let at = along(ruler, x); + let (start, end) = if x { (ruler.left(), ruler.right()) } else { (ruler.top(), ruler.bottom()) }; + let (Some(first), Some(last)) = (at.first(), at.last()) else { panic!("{deg}°: no ticks") }; + assert!(first - start < 20.0 && end - last < 20.0, "{deg}°: ticks from {first} to {last} on {start}..{end}"); + assert!(at.windows(2).all(|w| w[1] - w[0] < 20.0), "{deg}°: a gap in the ticks"); + } + if deg == 0.0 { + let origin = xf.to_screen(Point::ZERO).x; + assert!(along(top, true).iter().any(|x| (x - origin).abs() < 0.01), "a tick at x = 0"); + } + } +} + +#[test] +fn ruler_origin_zero_ticks_follow_the_origin_at_any_rotation_and_unit() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let [top, left, _] = ruler_rects(full); + let origin = Point::new(350.25, 250.5); + for deg in [0.0_f64, 30.0, 90.0, 200.0] { + for unit in [Unit::Points, Unit::Millimeters] { + let xf = Xf { rect: full, zoom: 1.7, center: Point::new(300.0, 200.0), rot: deg.to_radians() }; + let scale = RulerScale::document(unit, origin); + let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); + out.textures_delta.clear(); + let at = xf.to_screen(origin); + for (r, horizontal) in [(top, true), (left, false)] { + let zeros: Vec<_> = out + .shapes + .iter() + .filter(|c| c.clip_rect == r) + .filter_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == "0" => Some(t.pos), + _ => None, + }) + .collect(); + // Left-ruler labels are separate digits: a zero in "100" isn't the zero label. + assert!( + zeros.iter().any(|zero| { + let delta = if horizontal { zero.x - 2.0 - at.x } else { zero.y - 2.0 - at.y }; + delta.abs() < 0.02 + }), + "{deg}°, {unit:?}: zeros at {zeros:?}, origin {at:?}" + ); + } + } + } +} + +/// View › Rulers › Show Video Rulers counts the artboard in video pixels from its top-left +/// corner, a pixel as wide as the artboard's Video Ruler Pixel Aspect Ratio. At 0.9 a +/// 720 × 480 pt artboard is 800 pixels wide and 480 tall: the top ruler reads 0 at its left +/// edge and 800 at its right, labelled every 100 pixels (90 points on screen at 100%), and the +/// left ruler counts a pixel a point, from 0 at the top edge to a tick at the bottom one. At 1 +/// (square pixels) the artboard is 720 pixels wide. +#[test] +fn video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 720, "height": 480})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "videoRulerPixelAspectRatio": 0.9})).unwrap(); + let ab = s.active().unwrap().doc.artboards[0].clone(); + let r = ab.rect; + let scale = RulerScale::video(&ab); + let (x, y) = (vectorcraft_geom::Vec2::new(1.0, 0.0), vectorcraft_geom::Vec2::new(0.0, 1.0)); + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert_eq!((scale.unit, scale.origin), (Unit::Pixels, Point::new(r.x0, r.y0))); + assert!(close(scale.along(Point::new(r.x1, r.y1), x), 800.0) && close(scale.along(Point::new(r.x1, r.y1), y), 480.0)); + assert!(close(scale.along(Point::new(r.x0 + 9.0, r.y0), x), 10.0), "a pixel is 0.9 pt wide"); + assert!(close(scale.zoom_along(2.0, x), 1.8) && close(scale.zoom_along(2.0, y), 2.0)); + let square = RulerScale { stretch: [1.0, 1.0], ..scale }; + assert!(close(square.along(Point::new(r.x1, r.y0), x), 720.0)); + // Drawn at 100%. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let [top, left, _] = ruler_rects(full); + let xf = Xf { rect: full, zoom: 1.0, center: r.center(), rot: 0.0 }; + let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); + out.textures_delta.clear(); + let labels = |ruler: egui::Rect| -> Vec<(String, Pos2)> { + out.shapes + .iter() + .filter(|c| c.clip_rect == ruler) + .filter_map(|c| match &c.shape { + Shape::Text(t) => Some((t.galley.text().to_string(), t.pos)), + _ => None, + }) + .collect() + }; + let (l, rgt) = (xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1))); + let at = |labels: &[(String, Pos2)], text: &str, x: f32| labels.iter().any(|(t, p)| t == text && (p.x - 2.0 - x).abs() < 0.02); + let tops = labels(top); + assert!(at(&tops, "0", l.x) && at(&tops, "800", rgt.x), "{tops:?} from {l:?} to {rgt:?}"); + assert!(at(&tops, "100", l.x + 90.0) && !tops.iter().any(|(t, _)| t == "50"), "{tops:?}"); + assert!(!tops.iter().any(|(t, _)| t == "720"), "points aren't pixels: {tops:?}"); + let lefts = labels(left); + assert!(lefts.iter().any(|(t, p)| t == "0" && (p.y - 2.0 - l.y).abs() < 0.02), "{lefts:?}"); + let ticks: Vec = out + .shapes + .iter() + .filter(|c| c.clip_rect == left) + .filter_map(|c| match &c.shape { + Shape::LineSegment { points: [a, b], .. } if a.y == b.y => Some(a.y), + _ => None, + }) + .collect(); + assert!(ticks.iter().any(|y| (y - rgt.y).abs() < 0.02), "a tick at 480 px: {ticks:?}"); +} + +#[test] +fn shaper_selection_highlights_only_the_visible_result() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap(); + let line = vectorcraft_doc::NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap()); + for x in [0.0, 150.0] { + s.execute("shaper.scribble", &json!({"points":[[60.0+x,140],[70.0+x,160],[80.0+x,140],[90.0+x,160]]})).unwrap(); + } + let st = s.active().unwrap(); + let g = st.doc.node(st.selection.objects[0]).unwrap(); + assert!(g.extras().shaper.is_some()); + let symbols = st.doc.symbol_boxes(); + let bounds = node_outline(g, &symbols).bounding_box(); + assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}"); + assert_eq!(node_outline(st.doc.node(line).unwrap(), &symbols).bounding_box().x0, 50.0, "an isolated original still shows its full outline"); +} + +/// A clip group made from scratch: a 320 × 40 image and a 300 × 200 ellipse, both reaching +/// outside a 100 × 100 square at (100, 100) that clips them, and a rectangle beside them. The +/// task bar and rulers are off. → the app, the clip group, the ellipse and the rectangle. +fn clip_group_app() -> (VectorcraftApp, serde_json::Value, serde_json::Value, serde_json::Value) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let img = image::RgbaImage::from_pixel(320, 40, image::Rgba([200, 120, 40, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap(); + let image = json!(app.session.active().unwrap().selection.objects[0].0); + let ellipse = app.run("shape.ellipse", json!({"x": 0, "y": 50, "width": 300, "height": 200})).unwrap()["id"].clone(); + let square = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [image, ellipse, square]})).unwrap(); + let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); + let beside = app.run("shape.rectangle", json!({"x": 330, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); + (app, group, ellipse, beside) +} + +/// The screen points of what one frame draws in `color` with lines `width` px wide: outlines and +/// the bounding box, and with `width` 1 the anchors and the bounding box's handles too. +fn drawn_in(app: &mut VectorcraftApp, ctx: &egui::Context, color: Color32, width: f32) -> Vec { + let solid = egui::epaint::ColorMode::Solid(color); + let mut at = vec![]; + for s in shapes(app, ctx) { + match s { + Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == width => at.extend(ps.points), + Shape::Rect(r) if width == 1.0 && (r.fill == color || (r.stroke.color == color && r.stroke.width == width)) => { + at.extend([r.rect.min, r.rect.max]); + } + _ => {} + } + } + at +} + +/// #2422: a selected clip group shows its clipping path's outline and anchors, the bounding +/// box of what shows, and not the art the path clips; so do its hover highlight and its key +/// object outline. +#[test] +fn a_selected_clip_group_shows_its_clipping_path_not_its_hidden_art() { + let (mut app, group, _, beside) = clip_group_app(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap()))); + // The square on screen. `inside` allows 8 px around it for the anchors and the bounding box's handles. + let clip = egui::Rect::from_two_pos(xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(200.0, 200.0))); + // Each case that draws nothing, or draws outside the square. + let mut wrong = vec![]; + let mut inside = |at: Vec, what: &str| { + if at.is_empty() || !at.iter().all(|q| clip.expand(8.0).contains(*q)) { + wrong.push(format!("{what}: {at:?}")); + } + }; + for tool in ["selection", "directSelection", "groupSelection"] { + app.select_tool(tool); + app.run("select.set", json!({"ids": [group]})).unwrap(); + inside(drawn_in(&mut app, &ctx, color, 1.0), tool); + } + app.select_tool("selection"); + app.run("select.set", json!({"ids": [group, beside]})).unwrap(); + app.run("select.key", json!({"id": group})).unwrap(); + inside(drawn_in(&mut app, &ctx, color, KEY_OUTLINE), "the key object"); + app.run("select.none", json!({})).unwrap(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 150.0)))]); + inside(drawn_in(&mut app, &ctx, color, 1.5), "the hover highlight"); + assert!(wrong.is_empty(), "drawn outside {clip:?}:\n{}", wrong.join("\n")); +} + +/// A group of four clip groups, each a placed image larger than the rounded square that clips +/// it: one click with the Selection tool selects the group, which shows the four squares' +/// outlines and no image's rectangle, and a bounding box that is the union of the squares. +#[test] +fn a_group_of_clipped_images_shows_its_masks_and_their_bounds() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let img = image::RgbaImage::from_pixel(160, 160, image::Rgba([40, 120, 200, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + // Masks 100 × 100 at x = 20, 150, 280, 410; each image reaches 30 past its mask on every side. + let masks: Vec = (0..4).map(|i| Rect::new(20.0 + 130.0 * i as f64, 100.0, 120.0 + 130.0 * i as f64, 200.0)).collect(); + let mut clips = vec![]; + for m in &masks { + app.run("file.place", json!({"name": "photo.png", "dataBase64": data, "link": false, "at": [m.center().x, m.center().y]})).unwrap(); + let image = json!(app.session.active().unwrap().selection.objects[0].0); + let mask = app.run("shape.rectangle", json!({"x": m.x0, "y": m.y0, "width": 100, "height": 100, "radius": 16})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [image, mask]})).unwrap(); + clips.push(app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone()); + } + app.run("select.set", json!({"ids": clips})).unwrap(); + let group = vectorcraft_doc::NodeId(app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap()); + app.run("select.none", json!({})).unwrap(); + let union = masks.iter().copied().reduce(|a, b| a.union(b)).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // One click on a visible part of the second image selects the whole group. + app.select_tool("selection"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 200.0, 150.0), view).unwrap(); + } + assert_eq!(app.session.active().unwrap().selection.objects, vec![group]); + // The bounding box, the bounds and the transform box fit the masks, not the images. + let close = + |r: Option| r.is_some_and(|r| [r.x0 - union.x0, r.y0 - union.y0, r.x1 - union.x1, r.y1 - union.y1].iter().all(|d| d.abs() < 1e-9)); + let st = app.session.active().unwrap(); + assert!(close(st.doc.node(group).unwrap().geometric_bounds()), "the group's bounds"); + assert!(close(app.selection_box().map(|b| b.rect)), "the bounding box"); + // Everything drawn in the layer's colour lies on a mask: on its outline, its anchors or the + // bounding box round them (8 px for the anchors and the box's handles). + let color = c32(app.session.active().unwrap().doc.layer_color(group)); + let screen = |r: Rect| egui::Rect::from_two_pos(xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1))); + let drawn = drawn_in(&mut app, &ctx, color, 1.0); + assert!(!drawn.is_empty()); + let near_a_mask = |q: &Pos2| masks.iter().any(|m| screen(*m).expand(8.0).contains(*q)); + let on_the_box = |q: &Pos2| { + let b = screen(union); + b.expand(8.0).contains(*q) && !b.shrink(8.0).contains(*q) + }; + let stray: Vec<_> = drawn.iter().filter(|q| !near_a_mask(q) && !on_the_box(q)).collect(); + assert!(stray.is_empty(), "drawn off the masks: {stray:?}"); + // Each mask's outline is drawn: points on its left and right edges. + for m in &masks { + let r = screen(*m); + let on = |x: f32| drawn.iter().any(|q| (q.x - x).abs() < 1.0 && q.y > r.min.y + 16.0 && q.y < r.max.y - 16.0); + assert!(on(r.min.x) && on(r.max.x), "the outline of {m:?}"); + } + // No image's rectangle: nothing along the images' top edge, 30 points above the masks. + let top = xf.to_screen(Point::new(0.0, union.y0 - 30.0)).y; + assert!(!drawn.iter().any(|q| (q.y - top).abs() < 0.5), "the images' top edge at y {top}"); +} + +/// #2422: a clip group whose hidden art has more anchors than the overlay draws (#525) still +/// shows its clipping path's anchors and hover highlight. +#[test] +fn a_clip_group_over_a_huge_path_shows_its_clipping_path() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let n = OVERLAY_MAX_ANCHORS + 10; + let points = (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::(); + let huge = app.run("path.create", json!({"d": format!("M20 20 {points} Z")})).unwrap()["id"].clone(); + let square = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [huge, square]})).unwrap(); + let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); + let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap()))); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let anchors = shapes(&mut app, &ctx).iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == color)).count(); + app.run("select.none", json!({})).unwrap(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 150.0)))]); + let hovered = !drawn_in(&mut app, &ctx, color, 1.5).is_empty(); + assert_eq!((anchors, hovered), (4, true), "the square's four anchors, and the hover highlight"); +} + +/// #2422: the art inside a clip group still shows its whole outline when it is picked on its +/// own: by Edit Contents, by a Direct Selection or Group Selection click, and in isolation mode; +/// so does its Group Selection hover highlight. +#[test] +fn the_art_a_clip_group_clips_shows_whole_when_picked() { + let (mut app, group, ellipse, _) = clip_group_app(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap()))); + // Something is drawn at the ellipse's left and right ends, outside the square. + let reaches = |at: &[Pos2], what: &str| { + let (left, right) = (xf.to_screen(Point::new(0.0, 150.0)).x, xf.to_screen(Point::new(300.0, 150.0)).x); + assert!(at.iter().any(|q| (q.x - left).abs() < 2.0) && at.iter().any(|q| (q.x - right).abs() < 2.0), "{what}: {at:?}"); + }; + app.select_tool("selection"); + app.run("select.set", json!({"ids": [group]})).unwrap(); + app.run("object.clippingMask.editContents", json!({})).unwrap(); + reaches(&drawn_in(&mut app, &ctx, color, 1.0), "Edit Contents"); + app.run("select.none", json!({})).unwrap(); + app.select_tool("directSelection"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 150.0, 120.0), view).unwrap(); + } + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(ellipse.as_u64().unwrap())]); + reaches(&drawn_in(&mut app, &ctx, color, 1.0), "a Direct Selection click"); + app.run("select.none", json!({})).unwrap(); + app.select_tool("groupSelection"); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 120.0)))]); + reaches(&drawn_in(&mut app, &ctx, color, 1.5), "the Group Selection hover highlight"); + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 150.0, 120.0), view).unwrap(); + } + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(ellipse.as_u64().unwrap())]); + reaches(&drawn_in(&mut app, &ctx, color, 1.0), "a Group Selection click"); + app.select_tool("selection"); + app.run("object.isolate", json!({"id": group})).unwrap(); + app.run("select.all", json!({})).unwrap(); + reaches(&drawn_in(&mut app, &ctx, color, 1.0), "isolation mode"); +} + +/// A Shaper Group's mode widget, drawn for the Shaper and Selection tools with selection edges +/// shown or hidden: its arrow points right in Face Selection mode and left in Construction mode. +#[test] +fn the_shaper_widget_points_to_the_other_mode() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for x in [0, 50] { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(); + } + let points = json!({"points": [[25, 20], [75, 35], [30, 50], [125, 65]]}); + let g = app.session.execute("shaper.scribble", &points).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + // For each widget drawn, whether its arrow's tip is right of the circle's center. + let right = |app: &mut VectorcraftApp| -> Vec { + let drawn = shapes(app, &ctx); + let r = vectorcraft_tools::shaperwidget::RADIUS_PX; + let centers: Vec = drawn + .iter() + .filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill == Color32::WHITE).then_some(c.center) } else { None }) + .collect(); + drawn + .iter() + .filter_map(|s| match s { + Shape::Path(p) if p.points.len() == 3 => centers.iter().find(|c| c.distance(p.points[0]) <= r).map(|c| p.points[0].x > c.x), + _ => None, + }) + .collect() + }; + app.select_tool("shaper"); + assert_eq!(right(&mut app), vec![true], "the scribble selects the group"); + app.session.execute("object.isolate", &json!({"id": g})).unwrap(); + assert_eq!(right(&mut app), vec![false]); + app.select_tool("selection"); + assert_eq!(right(&mut app), vec![false]); + // View › Hide Edges keeps the widget, which still answers clicks. + app.ui.view.edges = false; + assert_eq!(right(&mut app), vec![false]); + app.select_tool("pen"); + assert!(right(&mut app).is_empty()); +} + +#[test] +fn a_symbol_instance_is_outlined_around_its_art() { + // A symbol holding the square of the SVG import tests' `Star` symbol, 20 × 20 from (0, 0), + // and an instance of it at (20, 30). + let svg = r##""##; + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "star.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let st = s.active().unwrap(); + let mut inst = None; + st.doc.walk(|n| { + if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + inst = Some(n); + } + }); + assert_eq!(node_outline(inst.unwrap(), &st.doc.symbol_boxes()).bounding_box(), Rect::new(20.0, 30.0, 40.0, 50.0)); +} + +/// One headless canvas frame on an 800 × 600 window. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); +} + +/// The Image Trace panel's eye-button hold ends when the pointer is released, even when the +/// panel stopped drawing while the button was held, so a later press on the canvas does not +/// show the source image. +#[test] +fn a_release_ends_the_image_trace_hold_without_the_panel() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let made = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap()); + let ctx = egui::Context::default(); + let at = pos2(400.0, 300.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), press(true)]); + app.ui.trace_hold_source = Some(std::sync::Arc::new(vec![id])); + frame(&mut app, &ctx, vec![]); + assert!(app.ui.trace_hold_source.is_some(), "held while the button is down"); + frame(&mut app, &ctx, vec![press(false)]); + assert_eq!(app.ui.trace_hold_source, None, "the release ends the hold"); + frame(&mut app, &ctx, vec![press(true)]); + assert_eq!(app.ui.trace_hold_source, None, "a later press does not show the source image"); + frame(&mut app, &ctx, vec![press(false)]); +} + +/// The baseline marker of selected point type runs under the line, wherever its alignment +/// puts the anchor: under centred and right-aligned type too, not from the anchor rightwards +/// (#2879). +#[test] +fn point_type_baselines_run_under_the_line_whatever_its_alignment() { + for justify in ["left", "center", "right"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let made = app.run("text.create", json!({"x": 300, "y": 150, "text": "Baseline marker", "size": 24, "justify": justify})).unwrap(); + let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let st = app.session.active().unwrap(); + let b = st.doc.node(id).unwrap().geometric_bounds().unwrap(); + let color = c32(st.doc.layer_color(id)); + let y = xf.to_screen(Point::new(0.0, 150.0)).y; + let (left, right) = (xf.to_screen(Point::new(b.x0, 150.0)).x, xf.to_screen(Point::new(b.x1, 150.0)).x); + let on: Vec = shapes(&mut app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::LineSegment { points, stroke } if stroke.color == color => Some(points), + _ => None, + }) + .flatten() + .filter(|q| (q.y - y).abs() < 0.5) + .map(|q| q.x) + .collect(); + let (lo, hi) = on.iter().fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + assert!((lo - left).abs() < 3.0 && (hi - right).abs() < 3.0, "{justify}: the baseline runs {lo}..{hi}, the type {left}..{right}"); + } +} + +/// A click with the Selection tool inside an artboard makes it the active one, and Paste in +/// Place then pastes onto it, where the objects were on their own artboard (#693). +#[test] +fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + 10.0, "y": boards[0].y0 + 15.0, "width": 20, "height": 20})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let view = app.view_info(); + let at = boards[2].center(); + for kind in [PointerKind::Down, PointerKind::Up] { + dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view); + } + assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one"); + let ids: Vec = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"] + .as_array() + .unwrap() + .iter() + .map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap())) + .collect(); + let b = app.session.active().unwrap().doc.bounds_of(&ids, false).unwrap(); + assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0)); +} + +/// A Shift-click with the Selection tool on an empty spot inside an artboard makes it the active +/// one and keeps the selection, and Align to Artboard then aligns the objects to it unless the +/// call names another artboard. +#[test] +fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + let mut rect = |x: f64, y: f64| { + let r = app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + x, "y": boards[0].y0 + y, "width": 20, "height": 20})); + vectorcraft_doc::NodeId(r.unwrap()["id"].as_u64().unwrap()) + }; + let ids = [rect(10.0, 15.0), rect(50.0, 40.0)]; + app.session.execute("select.set", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let view = app.view_info(); + let shift = Mods { shift: true, ..Default::default() }; + for kind in [PointerKind::Down, PointerKind::Up] { + dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view); + } + assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one"); + assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays"); + let lefts = |app: &VectorcraftApp| ids.map(|id| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0); + app.run("object.align", json!({"horizontal": "left", "to": "artboard"})).unwrap(); + assert_eq!(lefts(&app), [boards[2].x0; 2], "the left edges are on the active artboard's"); + app.run("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap(); + assert_eq!(lefts(&app), [boards[1].x0; 2], "an artboard the caller names is kept"); +} + +/// The canvas outlines the active artboard in black, 1 px wide, and outlines the other +/// artboards in gray, 0.6 px wide. In the test, the active artboard is Artboard 1 at first, +/// then the one Go to Artboard went to, and then, with several artboards selected by the +/// Artboard tool, the tool's active one (the one Shift-clicked last). +#[test] +fn the_active_artboard_is_outlined_in_black() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let ctx = egui::Context::default(); + // → each artboard's edge as drawn: (width, color). + let edges = |app: &mut VectorcraftApp| { + let s = shapes(app, &ctx); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let on = |p: &[Pos2], q: &[Pos2]| p.len() == q.len() && p.iter().zip(q).all(|(a, b)| (*a - *b).length() < 0.01); + let boards: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + boards + .iter() + .map(|r| { + let q = xf.quad(*r); + s.iter() + .find_map(|s| match s { + Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT && on(&p.points, &q) => { + Some((p.stroke.width, p.stroke.color.clone())) + } + _ => None, + }) + .expect("the artboard's edge") + }) + .collect::>() + }; + let (black, gray) = (egui::epaint::ColorMode::Solid(Color32::from_gray(0)), egui::epaint::ColorMode::Solid(Color32::from_gray(120))); + let active = |i: usize| -> Vec<(f32, egui::epaint::ColorMode)> { + (0..3).map(|j| if j == i { (1.0, black.clone()) } else { (0.6, gray.clone()) }).collect() + }; + assert_eq!(edges(&mut app), active(0)); + app.run("view.goToArtboard", json!({"index": 1})).unwrap(); + assert_eq!(edges(&mut app), active(1), "Artboard 2 is active: its edge is black and Artboard 1's edge is gray"); + // The Artboard tool starts on Artboard 2; a Shift-click on Artboard 3 selects it too and + // makes it the active one. + app.select_tool("artboard"); + let at = app.session.active().unwrap().doc.artboards[2].rect.center(); + let view = app.view_info(); + let shift = Mods { shift: true, ..Default::default() }; + for kind in [PointerKind::Down, PointerKind::Up] { + dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, pressure: 1.0 }, view); + } + assert_eq!(app.session.tool_options()["selected"], json!([2, 1])); + assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected"); +} + +// Regression test for #824: with the Artboard tool, the active artboard's name was drawn twice, +// once by the canvas as for every artboard and once, a little higher and in blue, by the tool. +// Now each artboard is named once, and the active one's name sits where the others' do. +#[test] +fn the_artboard_tool_names_its_active_artboard_once() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let drawn = shapes(&mut app, &ctx); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // Each name drawn: how far its left edge is right of the artboard's corner, and its bottom above it. + let named = |name: &str, board: usize| -> Vec<(f32, f32)> { + let r = app.session.active().unwrap().doc.artboards[board].rect; + let corner = xf.to_screen(Point::new(r.x0, r.y0)); + drawn + .iter() + .filter_map(|s| match s { + Shape::Text(t) if t.galley.text() == name => Some((t.pos.x - corner.x, corner.y - (t.pos.y + t.galley.size().y))), + _ => None, + }) + .collect() + }; + let (active, other) = (named("01 - Artboard 1", 0), named("02 - Artboard 2", 1)); + assert_eq!(other.len(), 1, "the other artboard is named once: {other:?}"); + assert_eq!(active.len(), 1, "the active artboard is named once: {active:?}"); + assert!((active[0].0 - other[0].0).abs() < 0.6, "flush with the artboard's left edge like the other name: {active:?} vs {other:?}"); + assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}"); +} + +/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the +/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing. +#[test] +fn two_finger_tap_undoes_and_three_finger_tap_redoes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len(); + let c = app.canvas_rect.unwrap().center(); + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // `fingers` fingers tap at the centre, the first also pressing as the pointer and moving a + // little (as a rectangle drag would begin). + let tap = |app: &mut VectorcraftApp, fingers: u64| { + let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]); + frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect()); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]); + frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect()); + frame(app, &ctx, vec![]); + }; + tap(&mut app, 2); + assert_eq!(count(&app), 0, "the rectangle undone, none drawn"); + tap(&mut app, 3); + assert_eq!(count(&app), 1, "redone"); + app.session.prefs.touch_gestures = false; + tap(&mut app, 2); + assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone"); +} + +/// The Artboard tool's label holds the artboard's name (never translated); the tools' own +/// labels are interface text. +#[test] +fn artboard_labels_name_the_artboard() { + assert!(names_an_artboard("01 - Layers") && names_an_artboard("12 - Artboard 12 - copy")); + assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer")); +} + +/// Double-clicking the name drawn above an artboard edits that name and does not fit a new +/// artboard or open Artboard Options. +#[test] +fn double_clicking_an_artboard_name_edits_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 80, "name": "Far"})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let mut target = None; + let _ = + ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { + let ab = &app.session.active().unwrap().doc.artboards[0]; + target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); + }); + let at = target.unwrap(); + assert!(rect.contains(at), "the name is in the working area, at {at:?} canvas {rect:?}"); + let before = app.session.active().unwrap().doc.artboards.len(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let click = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + click(&mut app); + click(&mut app); + assert!(ctx.data(|d| d.get_temp::(name_edit_id()).is_some()), "the name is being edited"); + assert_eq!(app.session.active().unwrap().doc.artboards.len(), before, "clicking the name does not fit an artboard"); + assert!(app.ui.dialog.is_none(), "Artboard Options stays closed"); + commit_artboard_name(&mut app, 0, "Poster"); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster"); + commit_artboard_name(&mut app, 0, " "); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster", "an empty name is left as it is"); +} + +/// Double-clicking the name drawn above a locked artboard doesn't edit it (the Artboards panel +/// still renames the artboard), fit an artboard or open Artboard Options. +#[test] +fn double_clicking_a_locked_artboards_name_leaves_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); + app.session.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let mut target = None; + let _ = + ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { + let ab = &app.session.active().unwrap().doc.artboards[0]; + target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); + }); + let at = target.unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for _ in 0..2 { + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + } + assert!(ctx.data(|d| d.get_temp::(name_edit_id()).is_none()), "the name isn't edited"); + assert_eq!(app.session.active().unwrap().doc.artboards.len(), 1); + assert!(app.ui.dialog.is_none(), "Artboard Options stays closed"); +} + +/// The canvas artboard name field keeps its own inset: egui 0.37 insets a text field by the theme's button +/// padding unless told otherwise, but this field sits over the canvas label, so its glyphs must not move when +/// that padding changes (an ordinary themed field's do, as the probe shows). +#[test] +fn artboard_name_field_glyphs_stay_put_when_button_padding_changes() { + /// Where the frame paints `text` (a field's text starts at its galley's top left). + fn text_at(shapes: &[Shape], text: &str) -> Pos2 { + shapes + .iter() + .find_map(|s| match s { + Shape::Text(t) if t.galley.text() == text => Some(t.pos), + _ => None, + }) + .unwrap_or_else(|| panic!("no {text:?} text")) + } + /// Build raw input with a fixed 800 by 600 screen so headless frames keep a stable canvas texture + /// size. + fn raw() -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() } + } + /// The name field's text origin relative to its rectangle with the theme's button padding `padding`: the + /// field opened as a double click opens it, measured in the steady frame after it takes focus. + fn editor_inset(padding: Vec2) -> Vec2 { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("artboard.setProps", &json!({"index": 0, "name": "Inset"})).unwrap(); + app.select_tool("artboard"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Brightness::default()); + ctx.global_style_mut(|s| s.spacing.button_padding = padding); + ctx.run_ui(raw(), |ui| begin_artboard_name_edit(ui, &app, 0)).textures_delta.clear(); + // The first frame opens and focuses the field; the next one is its steady state. + frame(&mut app, &ctx, vec![]); + let (shapes, mut delta) = frame_output(&mut app, &ctx); + delta.clear(); + let origin = text_at(&shapes, "Inset"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let mut field = None; + ctx.run_ui(raw(), |ui| field = artboard_name_field_rect(&app, ui, &xf, 0).map(|r| r.min)).textures_delta.clear(); + origin - field.unwrap() + } + /// An ordinary themed field's text origin relative to its rectangle: the padding moves it. + fn plain_inset(padding: Vec2) -> Vec2 { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Brightness::default()); + ctx.global_style_mut(|s| s.spacing.button_padding = padding); + let mut text = String::from("Probe"); + let rect = egui::Rect::from_min_size(pos2(40.0, 40.0), vec2(140.0, 18.0)); + let mut out = ctx.run_ui(raw(), |ui| { + ui.put(rect, egui::TextEdit::singleline(&mut text).id(egui::Id::unique("inset-probe")).font(egui::FontId::proportional(11.0))); + }); + out.textures_delta.clear(); + let shapes: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); + text_at(&shapes, "Probe") - rect.min + } + let (old, themed) = (vec2(4.0, 2.0), vec2(6.0, 2.0)); + let (editor_old, editor_themed) = (editor_inset(old), editor_inset(themed)); + let (plain_old, plain_themed) = (plain_inset(old), plain_inset(themed)); + assert!((plain_themed.x - plain_old.x).abs() > 1.0, "the probe field follows the padding: {plain_old:?} → {plain_themed:?}"); + assert!((editor_themed - editor_old).length() < 0.01, "the artboard name moves with the button padding: {editor_old:?} → {editor_themed:?}"); +} + +/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when +/// longer, and an artboard too narrow shows none. +#[test] +fn artboard_labels_fit_their_artboard() { + let ctx = egui::Context::default(); + let drawn = |width: f32| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + artboard_label(ui.painter(), pos2(50.0, 50.0), width, "01 - A long artboard name", Color32::WHITE) + }); + out.textures_delta.clear(); + out.shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.galley.size().x)), + _ => None, + }) + .collect::>() + }; + let full = drawn(400.0); + assert_eq!(full.first().map(|(s, _)| s.as_str()), Some("01 - A long artboard name")); + // Laid out no wider than the artboard (the galley keeps the whole text; it draws "…"). + let (_, w) = drawn(30.0).first().cloned().unwrap(); + assert!(full[0].1 > 30.0 && w <= 30.0 + 0.5, "{full:?} {w}"); + assert!(drawn(10.0).is_empty()); +} + +fn middle(pos: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() } +} + +/// One headless frame with the canvas under a 40-point bar, as it is under the document tabs. +fn frame_under_bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.add_space(40.0); + show(app, ui); + }); + out.textures_delta.clear(); +} + +#[test] +fn a_click_just_outside_the_canvas_does_not_reach_the_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // A square in the middle of the artboard, where the view is centred. + let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame_under_bar(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let click = |app: &mut VectorcraftApp, p: Pos2| { + let button = + |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame_under_bar(app, &ctx, vec![egui::Event::PointerMoved(p)]); + frame_under_bar(app, &ctx, vec![button(true)]); + frame_under_bar(app, &ctx, vec![button(false)]); + }; + let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); + // Two pixels above the canvas, within egui's interaction radius: nothing happens. + click(&mut app, pos2(rect.center().x, rect.top() - 2.0)); + assert!(selected(&app).is_empty(), "a click above the canvas selected {:?}", selected(&app)); + // At the canvas's centre: the square. + click(&mut app, rect.center()); + assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]); +} + +#[test] +fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.rulers = true; + app.ui.view.guides = false; + // A drawing tool: the drag must not draw. + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| { + frame(app, &ctx, vec![egui::Event::PointerMoved(from)]); + frame(app, &ctx, vec![button(from, true)]); + frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false)]); + }; + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + // From the top ruler: a horizontal guide where the button was released. + let to = pos2(rect.center().x, rect.top() + 120.0); + drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to); + assert_eq!(guides(&app), [(false, xf.to_doc(to).y)]); + assert!(app.ui.view.guides, "the new guide shows"); + // From the left ruler: a vertical one. + let to2 = pos2(rect.left() + 200.0, rect.center().y); + drag(&mut app, pos2(rect.left() - RULER / 2.0, rect.center().y), to2); + assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(to2).x)]); + // Released back on the ruler: no guide. + drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), pos2(rect.center().x + 40.0, rect.top() - 4.0)); + assert_eq!(guides(&app).len(), 2); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing"); +} + +#[test] +fn new_ruler_guides_follow_alt_through_preview_release_and_undo() { + use egui::{Event, Modifiers}; + for source_vertical in [false, true] { + for initially_alt in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("document.setRulerOrigin", &json!({"x": 17, "y": 23})).unwrap(); + app.ui.view.rulers = true; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let from = + if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) }; + let at = xf.to_screen(Point::new(80.0, 120.0)); + assert!(rect.contains(at)); + let held = |alt| if alt { Modifiers::ALT } else { Modifiers::NONE }; + let button = |pos, pressed, alt| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: held(alt) }; + let before = app.session.active().unwrap().doc.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(initially_alt)), Event::PointerMoved(from)]); + frame(&mut app, &ctx, vec![button(from, true, initially_alt)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + let assert_guide = |app: &VectorcraftApp, alt: bool, pos: Point| { + let guides = &app.session.active().unwrap().doc.guides; + assert_eq!(guides.len(), 1); + assert_eq!(guides[0].vertical, source_vertical != alt); + let expected = if source_vertical != alt { pos.x } else { pos.y }; + assert!((guides[0].pos - expected).abs() < 1e-4, "{:?}, expected {expected}", guides[0]); + assert_eq!(guides[0].artboard, None); + }; + assert_guide(&app, initially_alt, Point::new(80.0, 120.0)); + // Modifier changes with no pointer movement refresh the existing preview. + for alt in [!initially_alt, initially_alt] { + frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(alt))]); + assert_guide(&app, alt, Point::new(80.0, 120.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + } + // Equal coordinates must not hide an orientation change in the preview. + let equal = xf.to_screen(Point::new(120.0, 120.0)); + frame(&mut app, &ctx, vec![Event::PointerMoved(equal)]); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(!initially_alt))]); + assert_guide(&app, !initially_alt, Point::new(120.0, 120.0)); + let release = Point::new(147.0, 183.0); + let to = xf.to_screen(release); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(to), button(to, false, true)]); + assert_guide(&app, true, release); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); + assert_eq!(st.doc.ruler_origin, before.ruler_origin); + assert_eq!(st.doc.art_bounds(), before.art_bounds(), "the rectangle tool never drew"); + let committed = st.doc.clone(); + // Canvas guides still span the viewport after changing orientation. + let painted = shapes(&mut app, &ctx); + let window = app.canvas_rect.unwrap(); + let position = Xf::new(window, app.view().unwrap()).to_screen(release); + let ends = if source_vertical { + [pos2(window.left(), position.y), pos2(window.right(), position.y)] + } else { + [pos2(position.x, window.top()), pos2(position.x, window.bottom())] + }; + assert!(painted.iter().any(|s| matches!(s, Shape::LineSegment { points, .. } + if (points[0] - ends[0]).length() < 0.01 && (points[1] - ends[1]).length() < 0.01))); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(&*app.session.active().unwrap().doc, &*before); + app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(&*app.session.active().unwrap().doc, &*committed); + } + } +} + +#[test] +fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { + use egui::{Event, Modifiers}; + for source_vertical in [false, true] { + for tool in ["rectangle", "artboard"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("document.setRulerOrigin", &json!({"x": 17, "y": 23})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap(); + app.session.execute("edit.undo", &json!({})).unwrap(); + app.ui.view.rulers = true; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + app.select_tool(tool); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let from = + if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) }; + let at = xf.to_screen(Point::new(80.0, 120.0)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT }; + let before = app.session.active().unwrap().doc.clone(); + let history = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) + }; + let before_history = history(&app); + assert_eq!(before_history.1, 1); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(from)]); + frame(&mut app, &ctx, vec![button(from, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + let guides = &app.session.active().unwrap().doc.guides; + assert_eq!(guides.len(), 1); + assert_eq!(guides[0].vertical, !source_vertical); + let artboard = (tool == "artboard").then_some(before.artboards[0].id); + assert_eq!(guides[0].artboard, artboard); + // Returning to the source ruler cancels the preview and keeps the existing Redo. + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); + assert_eq!(&*app.session.active().unwrap().doc, &*before); + assert_eq!(history(&app), before_history); + app.session.execute("edit.redo", &json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.art_bounds().is_some()); + assert!(app.session.active().unwrap().doc.guides.is_empty()); + } + } +} + +/// #451: a guide dragged out of a ruler lands on the art's side midpoints (halving an +/// artboard-sized rectangle) and on the artboard's edges; an artboard guide is drawn across +/// its artboard only. +#[test] +fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + app.ui.view.rulers = true; + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| { + frame(app, &ctx, vec![egui::Event::PointerMoved(from)]); + frame(app, &ctx, vec![button(from, true)]); + frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false)]); + }; + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + // 3 px off the middle of the top and bottom sides: onto the line through them. + let mid = xf.to_screen(Point::new(200.0, 150.0)); + drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0)); + // 3 px inside the artboard's bottom edge: onto it. + let bottom = xf.to_screen(Point::new(100.0, 300.0)); + drag(&mut app, pos2(bottom.x, rect.top() - RULER / 2.0), bottom - vec2(0.0, 3.0)); + assert_eq!(guides(&app), [(true, 200.0), (false, 300.0)]); + assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "New Guide"); + // An artboard guide runs from the artboard's left edge to its right edge. + app.session.execute("guide.add", &json!({"vertical": false, "pos": 75, "artboard": 0})).unwrap(); + let s = shapes(&mut app, &ctx); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let (a, b) = (xf.to_screen(Point::new(0.0, 75.0)), xf.to_screen(Point::new(400.0, 75.0))); + let at = |p: Pos2, q: Pos2| (p - q).length() < 0.01; + assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, .. } if at(points[0], a) && at(points[1], b))), "across the artboard"); + // A canvas guide crosses the whole window. + let y = xf.to_screen(Point::new(0.0, 300.0)).y; + let window = app.canvas_rect.unwrap(); + assert!( + s.iter().any( + |s| matches!(s, Shape::LineSegment { points, .. } if points[0] == pos2(window.left(), y) && points[1] == pos2(window.right(), y)) + ) + ); +} + +/// #414: guides dragged out of the rulers are picked and dragged with the Selection tool +/// (highlighted while selected), go with Delete or Backspace, and dragged back onto their +/// ruler. +#[test] +fn the_selection_tool_moves_and_deletes_ruler_guides() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // A square under the guides: the press takes the guide. + app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 300, "height": 200})).unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let art = app.session.active().unwrap().doc.art_bounds(); + app.ui.view.rulers = true; + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| { + frame(app, &ctx, vec![egui::Event::PointerMoved(from)]); + frame(app, &ctx, vec![button(from, true)]); + frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false)]); + }; + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + // Two guides out of the rulers. + let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y)); + drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h); + drag(&mut app, pos2(rect.left() - RULER / 2.0, v.y), v); + assert_eq!(guides(&app), [(false, xf.to_doc(h).y), (true, xf.to_doc(v).x)]); + // Drag the horizontal one down 30 px (across the vertical one: the nearer is picked). + let to = pos2(h.x + 10.0, h.y + 30.0); + drag(&mut app, h, to); + let st = app.session.active().unwrap(); + assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]); + assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![])); + assert_eq!(st.history.undo.last().unwrap().label, "Move Guide"); + let picked = Tokens::get(&ctx).selection; + let s = shapes(&mut app, &ctx); + assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == picked && points[0].y == to.y)), "highlighted"); + // Backspace deletes the selected guide; Delete too. + let press = |app: &mut VectorcraftApp, key| { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); + }; + press(&mut app, egui::Key::Backspace); + assert_eq!(guides(&app), [(true, xf.to_doc(v).x)]); + app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + press(&mut app, egui::Key::Delete); + assert!(guides(&app).is_empty()); + app.session.execute("edit.undo", &json!({})).unwrap(); + // Hide Guides deselects them. + app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + app.run("view.guides", json!({})).unwrap(); + assert!(app.session.active().unwrap().selection.guides.is_empty()); + app.run("view.guides", json!({})).unwrap(); + // Dragged back onto its ruler, the vertical guide goes. + drag(&mut app, v, pos2(rect.left() - RULER / 2.0, v.y + 20.0)); + assert!(guides(&app).is_empty(), "{:?}", guides(&app)); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), art, "the square stayed"); +} + +#[test] +fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let cmd_frame = |app: &mut VectorcraftApp, events: Vec| { + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let events = std::iter::once(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)).chain(events).collect(); + let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND }; + // A Cmd drag from `a` to `b` with the Pen tool; → the tool that did it. + let cmd_drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| { + app.select_tool("pen"); + cmd_frame(app, vec![egui::Event::PointerMoved(a), button(a, true)]); + let used = app.session.tool_id().to_string(); + cmd_frame(app, vec![egui::Event::PointerMoved(b)]); + cmd_frame(app, vec![button(b, false)]); + assert_eq!(app.session.tool_id(), "pen", "back to the Pen tool"); + used + }; + let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + // Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once + // the rectangle isn't selected whole, which would move all its anchors). + app.select_tool("directSelection"); + app.session.execute("select.none", &json!({})).unwrap(); + let corner = xf.to_screen(Point::new(200.0, 150.0)); + assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection"); + let b = bounds(&app); + assert_eq!((b.x0, b.y0), (100.0, 100.0)); + assert!((b.x1 - xf.to_doc(corner + vec2(20.0, 20.0)).x).abs() < 1e-6, "{b:?}"); + // Group Selection, then Selection: each is the one used. + app.select_tool("groupSelection"); + let inside = xf.to_screen(Point::new(130.0, 120.0)); + assert_eq!(cmd_drag(&mut app, inside, inside), "groupSelection"); + app.select_tool("selection"); + assert_eq!(cmd_drag(&mut app, inside, inside), "selection"); +} + +/// #525: a selection with more anchors than the overlay draws (a traced photo) shows its bounds, +/// not every outline and anchor, which made a mesh larger than the GPU takes; an ordinary one +/// still shows its outline and anchors. +#[test] +fn a_huge_selection_shows_its_bounds_instead_of_every_outline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let points = |n: usize| (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::(); + let ctx = egui::Context::default(); + let mut drawn = |n: usize| { + app.session.execute("path.create", &json!({"d": format!("M20 20 {} Z", points(n))})).unwrap(); + frame(&mut app, &ctx, vec![]); + let shapes = shapes(&mut app, &ctx); + let points: usize = shapes.iter().map(|s| if let Shape::Path(ps) = s { ps.points.len() } else { 0 }).sum(); + let squares = shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count(); + (points, squares) + }; + let (points, squares) = drawn(OVERLAY_MAX_ANCHORS + 10); + assert!(points < 100 && squares < 100, "{points} outline points, {squares} squares"); + let (points, squares) = drawn(200); + assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares"); +} + +/// #2460: type, images, symbol instances, gradient meshes and envelopes count toward the +/// points the overlay outlines, as paths' anchors do. 32,768 of one kind (an opened PDF can +/// hold as many type lines, images or mesh pieces) add only the selection's bounds to the +/// frame's mesh, under 100 vertices; one alone adds its outline. +#[test] +fn a_huge_selection_of_type_images_symbols_or_meshes_shows_its_bounds() { + for kind in ["point type", "area type", "type on a path", "image", "symbol", "mesh", "envelope"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + // The Rectangle tool draws nothing of its own (no bounding box, no type on a path + // brackets): only the selection's overlay changes the frame. + app.select_tool("rectangle"); + match kind { + "point type" => { + app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type"})).unwrap(); + } + "area type" => { + let area = json!({"width": 150, "height": 20}); + app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type", "area": area})).unwrap(); + } + "type on a path" => { + let path = app.session.execute("path.create", &json!({"d": "M20 100 L140 100 L260 100 L380 100"})).unwrap()["id"].clone(); + app.session.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Type"})).unwrap(); + } + "image" => { + let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png()); + app.run("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); + } + "symbol" => { + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); + app.session.execute("symbol.new", &json!({})).unwrap(); + } + "mesh" => { + app.session.execute("shape.rectangle", &json!({"x": 150, "y": 50, "width": 30, "height": 20})).unwrap(); + app.session.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); + } + _ => { + app.session.execute("shape.rectangle", &json!({"x": 150, "y": 50, "width": 30, "height": 20})).unwrap(); + app.session.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap(); + } + } + let ctx = egui::Context::default(); + // The vertices that selecting everything adds to the frame's mesh. + let overlay = |app: &mut VectorcraftApp| { + app.session.execute("select.none", &json!({})).unwrap(); + let none = frame_vertices(app, &ctx); + app.session.execute("select.all", &json!({})).unwrap(); + frame_vertices(app, &ctx).saturating_sub(none) + }; + let one = overlay(&mut app); + assert!(one > 0, "{kind}: one shows its outline"); + for _ in 0..15 { + app.session.execute("select.all", &json!({})).unwrap(); + app.run("edit.duplicate", json!({})).unwrap(); + } + let many = overlay(&mut app); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 32_768, "{kind}"); + assert!(many < 100, "{kind}: 32,768 show the selection's bounds, not {many} vertices"); + } +} + +/// #2460: a group of 32,768 images is too much to outline: under the pointer it is not +/// highlighted, and as the key object its outline is its bounds. +#[test] +fn a_group_of_too_many_images_has_no_hover_outline_and_a_key_outline_of_its_bounds() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png()); + app.run("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); + for _ in 0..15 { + app.session.execute("select.all", &json!({})).unwrap(); + app.run("edit.duplicate", json!({})).unwrap(); + } + app.session.execute("select.all", &json!({})).unwrap(); + let group = app.session.execute("object.group", &json!({})).unwrap()["id"].clone(); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap(); + app.select_tool("selection"); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let over = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(200.0, 150.0)); + let lines = |app: &mut VectorcraftApp, width: f32| { + frame(app, &ctx, vec![egui::Event::PointerMoved(over)]); + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == width)).count() + }; + assert_eq!(lines(&mut app, 1.5), 0, "no hover outline"); + app.session.execute("select.all", &json!({})).unwrap(); + app.session.execute("select.key", &json!({"id": group})).unwrap(); + assert_eq!(lines(&mut app, KEY_OUTLINE), 1, "the key object's bounds"); +} + +/// #1111: a selected gradient mesh shows its mesh lines and points to the selection tools, not +/// only to the Mesh tool. +#[test] +fn a_selected_gradient_mesh_shows_its_lines_and_points() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 200, "height": 100})).unwrap(); + app.session.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let ctx = egui::Context::default(); + let drawn = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![]); + let shapes = shapes(app, &ctx); + let lines = shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.points.len() > 1)).count(); + (lines, shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count()) + }; + let (none_lines, none_points) = { + app.session.execute("select.none", &json!({})).unwrap(); + drawn(&mut app) + }; + for tool in ["selection", "directSelection"] { + app.select_tool(tool); + app.session.execute("select.all", &json!({})).unwrap(); + let (lines, points) = drawn(&mut app); + // The outline, two inner lines each way; the 3 × 3 points. + assert!(lines >= none_lines + 3 && points >= none_points + 9, "{tool}: {lines} lines, {points} points"); + } +} + +/// #541: the key object's outline is drawn thicker than the rest of the selection's. +#[test] +fn the_key_object_has_a_thicker_outline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let a = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); + let b = app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 60, "height": 40})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let thick = + |app: &mut VectorcraftApp| shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == KEY_OUTLINE)).count(); + assert_eq!(thick(&mut app), 0, "no key yet"); + app.session.execute("select.key", &json!({"id": b})).unwrap(); + assert!(thick(&mut app) > 0, "the key's outline"); +} + +/// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened. +fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let (shapes, mut delta) = frame_output(app, ctx); + delta.clear(); + shapes +} + +/// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the +/// GPU takes in one buffer. +fn frame_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { + frame(app, ctx, vec![]); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 }; + ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum() +} + +/// One headless canvas frame → the shapes drawn (`Shape::Vec`s flattened) and the textures +/// uploaded by it (the art's raster among them, when it was re-rendered). +fn frame_output(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Vec, egui::TexturesDelta) { + fn flat(s: Shape, out: &mut Vec) { + match s { + Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), + s => out.push(s), + } + } + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let out = ctx.run_ui(raw, |ui| show(app, ui)); + let mut v = vec![]; + out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v)); + (v, out.textures_delta) +} + +/// An edit of one object renders the canvas again only where it changed, into its texture; a +/// change of the view renders all of it. +#[test] +fn an_edit_renders_only_its_part_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // Where the canvas texture changed in a frame, and how much of it. + let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); + delta.clear(); + set + }; + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + let (pos, size) = canvas(&app, delta).expect("the edit shows"); + let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap(); + assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}"); + // An edit with other render options than the frame shown's renders all of it. + app.session.prefs.pattern_tile_edge_color = "#ff00ff".into(); + app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it"); + app.run("view.setZoom", json!({"zoom": 2})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); +} + +/// A placed document's bitmap made in the background renders the canvas again only where the +/// objects showing its file are: both copies of the file are in the part rendered again. +#[test] +fn a_ready_placed_document_bitmap_renders_only_its_part_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + // A document of one 60 × 30 artboard with a rectangle, placed linked twice. + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 60, "height": 30})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 30})).unwrap(); + let dir = std::env::temp_dir().join(format!("vectorcraft-placed-redraw-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("card.vectorcraft"); + std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap(); + let path = path.to_string_lossy().into_owned(); + let ids: Vec = [[60, 50], [140, 50]] + .iter() + .map(|at| { + let r = app.session.execute("file.place", &json!({"path": path, "at": at})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + r["ids"][0].as_u64().unwrap() + }) + .collect(); + app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // Where the canvas texture changed in a frame, and how much of it. + let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); + delta.clear(); + set + }; + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); + let t = std::time::Instant::now(); + while vectorcraft_render::placed_document::busy() && t.elapsed().as_secs() < 20 { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let (_, delta) = frame_output(&mut app, &ctx); + let (pos, size) = canvas(&app, delta).expect("the bitmap shows"); + let [px, py] = pos.expect("a part of the canvas"); + let key = app.canvas.key.clone().unwrap(); + let view = Affine::translate((f64::from(key.w) / 2.0, f64::from(key.h) / 2.0)) + * Affine::rotate(key.rot.to_radians()) + * Affine::scale(key.zoom * f64::from(key.ppp)) + * Affine::translate((-key.cx, -key.cy)); + let doc = &app.session.active().unwrap().doc; + for id in ids { + let b = doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + let [x0, y0, x1, y1] = vectorcraft_render::view_pixels(b, view, key.w, key.h).unwrap(); + let inside = x0 as usize >= px && y0 as usize >= py && x1 as usize <= px + size[0] && y1 as usize <= py + size[1]; + assert!(inside, "object {id} at {:?} in the part at {:?} of size {size:?}", [x0, y0, x1, y1], [px, py]); + } + assert!(size[0] * size[1] * 2 <= key.w as usize * key.h as usize, "a part of it: {size:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the +/// mask's coverage wherever its art reaches, past its object's bounds. +#[test] +fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let rect = + |app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone(); + let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0)); + app.run("select.set", json!({"ids": [obj, mask]})).unwrap(); + app.run("transparency.makeOpacityMask", json!({})).unwrap(); + app.run("transparency.viewOpacityMask", json!({})).unwrap(); + let ctx = egui::Context::default(); + let (_, mut delta) = frame_output(&mut app, &ctx); + delta.clear(); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap(); + let (_, mut delta) = frame_output(&mut app, &ctx); + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos); + delta.clear(); + assert_eq!(pos, Some(None), "all of it"); +} + +/// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles. +#[test] +fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":200,"height":150})).unwrap()["id"].as_u64().unwrap(); + app.run("select.none", json!({})).unwrap(); + app.select_tool("directSelection"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 150.0, 50.0), view).unwrap(); + } + let before = app.session.doc().unwrap().doc.clone(); + let color = c32(before.layer_color(vectorcraft_doc::NodeId(id))); + let selected = selected_anchor(color); + let ctx = egui::Context::default(); + let painted = shapes(&mut app, &ctx); + assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 0); + assert_eq!( + painted.iter().filter(|s| matches!(s, Shape::Path(p) if p.stroke.width == 2.0 && p.points.len() == 2)).count(), + 1, + "only the chosen straight edge has emphasis" + ); + assert!(!painted.iter().any(|s| matches!(s, Shape::Circle(_))), "segment picks have no corner widgets"); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("select.anchors", json!({"id":id,"anchors":[[0,0],[0,1]]})).unwrap(); + let painted = shapes(&mut app, &ctx); + assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 2); + app.run( + "path.create", + json!({"anchors":[{"x":50,"y":260,"out":[100,220]},{"x":200,"y":260,"in":[150,220],"out":[250,300]},{"x":330,"y":260,"in":[280,300]}]}), + ) + .unwrap(); + app.run("select.none", json!({})).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 125.0, 230.0), view).unwrap(); + } + let before = app.session.doc().unwrap().doc.clone(); + let painted = shapes(&mut app, &ctx); + assert_eq!( + painted.iter().filter(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color)).count(), + 2, + "only the chosen curve's two controls show" + ); + app.ui.view.edges = false; + let painted = shapes(&mut app, &ctx); + assert!(!painted.iter().any(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color))); + assert_eq!(app.session.doc().unwrap().doc, before); +} + +/// A group, a compound path or a clip group selected whole has every anchor selected, and Direct +/// Selection, the Pen and the anchor point tools draw its anchors filled, as they draw the anchors +/// of a path selected whole. They do the same after Group Selection clicks select a path and then its group. +/// Direction handles still show only on a path selected itself. +#[test] +fn the_anchors_of_a_container_selected_whole_draw_selected() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let rect = |app: &mut VectorcraftApp, x: i32, y: i32| { + app.run("shape.rectangle", json!({"x": x, "y": y, "width": 60, "height": 60})).unwrap()["id"].clone() + }; + let in_group = rect(&mut app, 20, 20); + let ellipse = app.run("shape.ellipse", json!({"x": 100, "y": 20, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [in_group, ellipse]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].clone(); + let (c1, c2) = (rect(&mut app, 200, 20), rect(&mut app, 220, 40)); + app.run("select.set", json!({"ids": [c1, c2]})).unwrap(); + let compound = app.run("object.compoundPath.make", json!({})).unwrap()["id"].clone(); + let hidden = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 200, "height": 100})).unwrap()["id"].clone(); + let square = rect(&mut app, 60, 170); + app.run("select.set", json!({"ids": [hidden, square]})).unwrap(); + let clip = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); + let lone = app.run("shape.ellipse", json!({"x": 300, "y": 150, "width": 60, "height": 60})).unwrap()["id"].clone(); + let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(lone.as_u64().unwrap()))); + let ctx = egui::Context::default(); + // Filled and hollow anchor squares, and direction handle lines, drawn in the layer color. + let looks = |app: &mut VectorcraftApp| { + let painted = shapes(app, &ctx); + let small = |r: &egui::epaint::RectShape| r.rect.width() < 12.0; + let filled = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == color)).count(); + let hollow = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == Color32::WHITE)).count(); + let handles = painted.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count(); + (filled, hollow, handles) + }; + let mut wrong = vec![]; + for tool in ["directSelection", "pen", "addAnchor", "deleteAnchor", "anchorPoint"] { + app.select_tool(tool); + // What is selected, and what it shows: the lone ellipse selected whole is the reference + // (four anchors with two handles each, and its center point, filled too). + for (what, ids, want) in [ + ("a path", json!([lone]), (5, 0, 8)), + ("a group", json!([group]), (8, 0, 0)), + ("a compound path", json!([compound]), (8, 0, 0)), + ("a clip group", json!([clip]), (4, 0, 0)), + ] { + app.run("select.set", json!({ "ids": ids })).unwrap(); + let got = looks(&mut app); + if got != want { + wrong.push(format!("{tool}, {what}: (filled, hollow, handles) {got:?}, want {want:?}")); + } + } + } + app.run("select.none", json!({})).unwrap(); + app.select_tool("groupSelection"); + let view = app.view_info(); + for _ in 0..2 { + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 20.0, 50.0), view).unwrap(); + } + } + let id = |v: &serde_json::Value| vectorcraft_doc::NodeId(v.as_u64().unwrap()); + assert_eq!(app.session.active().unwrap().selection.objects, vec![id(&in_group), id(&group)]); + app.select_tool("directSelection"); + // The rectangle's anchors and center point, then the group's eight anchors, four of them over the rectangle's. + let got = looks(&mut app); + if got != (13, 0, 0) { + wrong.push(format!("a Group Selection click on a path, then on its group: {got:?}, want (13, 0, 0)")); + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +} + +/// View › Pixel Preview: magnified, the art shows as the document pixels it rasterizes to (one +/// per point, hard-edged), not as smooth vectors; zoomed out or out of Pixel Preview the canvas +/// renders for the screen again. +#[test] +fn pixel_preview_shows_the_document_pixels() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Its 1 pt stroke covers a quarter of document pixel 99 and most of pixel 100. + app.session.execute("shape.rectangle", &json!({"x": 100.25, "y": 50, "width": 200, "height": 200})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let ctx = egui::Context::default(); + // The art's raster uploaded by one frame: (width, height, hard-edged, pixels). + let upload = |app: &mut VectorcraftApp| { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let up = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + (img.width(), img.height(), d.options.magnification == egui::TextureFilter::Nearest, img.pixels.clone()) + }); + delta.clear(); + up.expect("the art re-rendered") + }; + let screen = upload(&mut app); // the first frame fits the view to the artboard + assert!(screen.0 > 600 && !screen.2, "out of Pixel Preview: the screen's pixels, smoothed"); + app.ui.view.pixel_preview = true; + let view = app.view_mut().unwrap(); + (view.zoom, view.center) = (8.0, Point::new(100.0, 150.0)); + let (w, h, hard, px) = upload(&mut app); + assert!(hard, "hard-edged pixels"); + assert!(w <= screen.0 / 8 + 2 && h <= screen.1 / 8 + 2, "one pixel per point: {w} × {h}"); + let [x0, y0, x1, y1] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered"); + assert_eq!(((x1 - x0) as usize, (y1 - y0) as usize), (w, h)); + // Each document pixel is one texel, anti-aliased by how much of it the art covers. + let alpha = |x: i64| px[((150 - y0) * w as i64 + x - x0) as usize].a(); + assert!((40..=90).contains(&alpha(99)), "a quarter of pixel 99: {}", alpha(99)); + assert!(alpha(100) > 200, "most of pixel 100: {}", alpha(100)); + assert_eq!((alpha(98), alpha(101)), (0, 255)); + // The pixels are placed over the document rect they cover. + let s = shapes(&mut app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let Some(Shape::Mesh(mesh)) = s.iter().find(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)) else { panic!("the art") }; + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(x0 as f64, y0 as f64)); + assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos); + // Zoomed out, a document pixel is no bigger than a screen pixel: rendered for the screen. + app.view_mut().unwrap().zoom = 0.5; + let (w, _, hard, _) = upload(&mut app); + assert!(w > 600 && !hard, "zoomed out: the screen's pixels"); +} + +/// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template +/// draws it again, dimmed as before; each re-renders at once. +#[test] +fn hide_template_leaves_template_layers_off_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + let layer = app.session.active().unwrap().doc.layers[0].id.0; + app.session.execute("layer.template", &json!({"ids": [layer], "on": true})).unwrap(); + let ctx = egui::Context::default(); + // How many pixels of the art's raster one frame uploads are painted, None when it was not + // re-rendered. + let painted = |app: &mut VectorcraftApp| -> Option { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.iter().filter(|c| c.a() != 0).count() + }); + delta.clear(); + n + }; + let shown = painted(&mut app).expect("the first frame renders the art"); + assert!(shown > 1000, "the template layer's art is drawn: {shown} pixels"); + assert_eq!(app.run("view.template", json!({})).unwrap(), json!(false)); + assert_eq!(painted(&mut app), Some(0), "Hide Template re-renders without the template layer"); + assert_eq!(painted(&mut app), None, "nothing changed: no re-render"); + assert_eq!(app.run("view.template", json!({})).unwrap(), json!(true)); + assert_eq!(painted(&mut app), Some(shown), "Show Template draws it again as before"); +} + +/// General › Anti-aliased Artwork (#394): on (the default), the art's edges are smoothed on +/// screen; off, every pixel is either painted or not, and turning it either way re-renders. +#[test] +fn anti_aliased_artwork_preference_smooths_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Render on this thread: the worker's frame would land later than the test looks. + app.canvas.worker_started = true; + // A disc: its edge crosses pixels at every angle. + app.session.execute("shape.ellipse", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + // The alphas of the art's raster uploaded by one frame, None when it was not re-rendered. + let alphas = |app: &mut VectorcraftApp| -> Option> { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let alphas = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.iter().map(|c| c.a()).collect() + }); + delta.clear(); + alphas + }; + let partial = |a: &[u8]| a.iter().filter(|&&a| a != 0 && a != 255).count(); + let on = alphas(&mut app).expect("the first frame renders the art"); + assert!(partial(&on) > 50, "anti-aliased: edge pixels partly covered ({} of them)", partial(&on)); + assert!(alphas(&mut app).is_none(), "nothing changed: no re-render"); + app.session.execute("prefs.set", &json!({"key": "antiAliasedArtwork", "value": false})).unwrap(); + let off = alphas(&mut app).expect("the preference change re-renders"); + assert_eq!(partial(&off), 0, "hard edges: every pixel painted or not"); + assert!(off.iter().filter(|&&a| a == 255).count() > 1000, "the disc is still painted"); + app.session.execute("prefs.set", &json!({"key": "antiAliasedArtwork", "value": true})).unwrap(); + assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again"); +} + +/// Type › Highlight Substituted Fonts: on (the default), type in a missing font has pink +/// behind it on the canvas; off, it does not, and turning it either way re-renders. +#[test] +fn highlight_substituted_fonts_preference_reaches_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + app.session.execute("text.create", &json!({"x": 20, "y": 120, "text": "Hamburg", "size": 60, "font": "Missing Family"})).unwrap(); + let ctx = egui::Context::default(); + // How many pink pixels the art's raster uploaded by one frame has, None when it was not + // re-rendered. + let pink = |app: &mut VectorcraftApp| -> Option { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.iter().filter(|c| c.a() != 0 && c.r() > c.g().saturating_add(30) && c.b() > c.g()).count() + }); + delta.clear(); + n + }; + let on = pink(&mut app).expect("the first frame renders the art"); + assert!(on > 100, "on by default: the text has pink behind it ({on} pixels)"); + assert!(app.canvas.shown.as_ref().is_some_and(|(_, o)| o.highlight_substituted_fonts)); + app.session.execute("prefs.set", &json!({"key": "highlightSubstitutedFonts", "value": false})).unwrap(); + assert_eq!(pink(&mut app), Some(0), "the preference off re-renders without the highlight"); + assert!(app.canvas.key.as_ref().is_some_and(|k| !k.highlight_substituted_fonts)); + app.session.execute("prefs.set", &json!({"key": "highlightSubstitutedFonts", "value": true})).unwrap(); + assert_eq!(pink(&mut app), Some(on), "on again, as before"); +} + +/// Images whose pixels can't be decoded draw as crossed boxes on the canvas, and the status +/// bar reports each of them once: two such images give one message, and later frames that +/// draw them again give none. +#[test] +fn undecodable_images_are_marked_and_reported_once() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + // PNGs of noisy pixels cut off halfway: their headers read, their pixels don't decode. + let cut = |w: u32| { + let img = image::RgbaImage::from_fn(w, 41, |x, y| image::Rgba([(x * 37 + y * 101) as u8, (x * y) as u8, (x ^ y) as u8, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png.truncate(png.len() / 2); + png + }; + { + let st = app.session.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + for (i, w) in [53u32, 59].into_iter().enumerate() { + let key = format!("cut{i}"); + d.images.insert(key.clone(), ImageBlob::new("image/png", cut(w))); + let im = ImageObject { + key, + width: w, + height: 41, + xf: Affine::translate((40.0 + 150.0 * i as f64, 100.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let (id, layer) = (d.alloc_id(), d.layers[0].id); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + } + st.revision += 1; + } + let ctx = egui::Context::default(); + app.status(""); + frame(&mut app, &ctx, vec![]); + assert!(app.canvas.shown.as_ref().is_some_and(|(_, o)| o.mark_undecoded_images), "the canvas marks them"); + assert_eq!(app.ui.status, "2 image(s) couldn't be decoded and show as crossed boxes"); + app.status(""); + app.session.doc_mut().unwrap().revision += 1; + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.status, "", "said once"); +} + +/// A drag of an object with raster effects (#1257): the drag's frames render while +/// interacting and draw the object's drop shadow and filtered content from the rasters of the +/// frame before; the frame after the drag renders without `interacting`, on the render worker, +/// and draws what a fresh renderer draws. +#[test] +fn dragging_an_object_with_raster_effects_draws_its_rasters_moved() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone(); + for effect in ["stylize.dropShadow", "texture.craquelure"] { + app.run("effect.apply", json!({"effect": effect, "ids": [id]})).unwrap(); + } + let ctx = egui::Context::default(); + // The art's raster uploaded by one frame, None when it was not re-rendered. + let upload = |app: &mut VectorcraftApp| -> Option> { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.clone() + }); + delta.clear(); + px + }; + upload(&mut app).expect("the first frame renders the art"); + app.select_tool("selection"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap(); + for step in [1.0, 2.0, 3.0] { + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 160.0 + 7.3 * step, 125.0 + 2.1 * step), view).unwrap(); + upload(&mut app).expect("each frame of the drag renders"); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "step {step}"); + assert_eq!(app.canvas.renderer.stats.moved, 2, "the shadow and the filtered content, step {step}"); + assert!(app.canvas.moved); + } + // The frame after the drag runs the effects again: on the worker, while the canvas shows + // the drag's last frame. + app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap(); + assert!(upload(&mut app).is_none(), "not rendered on this thread"); + assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker"); + let until = std::time::Instant::now() + std::time::Duration::from_secs(60); + let mut after = None; + while after.is_none() && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(5)); + after = upload(&mut app); + } + let after = after.expect("the worker's frame arrives"); + assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting)); + assert!(!app.canvas.moved); + app.canvas.worker = None; + app.canvas.renderer = vectorcraft_render::Renderer::new(); + app.canvas.key = None; + assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws"); +} + +/// A tool's drag on the canvas renders with `dragging` (#2558). At 3200% a rectangle with +/// Texturizer covers more than the raster budget, so no raster is kept for it, and each frame +/// of a Selection tool drag draws its filtered content at the drag's lower resolution. The +/// frame after the drag draws what a fresh renderer draws. A dialog's preview, an interaction +/// without a drag, renders without `dragging` and draws the same. +#[test] +fn a_drag_on_the_canvas_draws_raster_effects_at_a_lower_resolution() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150})).unwrap()["id"].clone(); + app.run("effect.apply", json!({"effect": "texture.texturizer", "ids": [id]})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + // One headless frame on a 1920 × 1200 window (a view larger than the drag's budget) with + // `events` → the art's raster it uploaded, None when it was not re-rendered. + let run = |app: &mut VectorcraftApp, events: Vec| -> Option> { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + let art = app.canvas.texture.as_ref().map(|t| t.id()); + let px = out.textures_delta.set.iter().find(|e| Some(*e.0) == art).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.clone() + }); + out.textures_delta.clear(); + px + }; + run(&mut app, vec![]); + if let Some(vm) = app.view_mut() { + (vm.zoom, vm.center) = (32.0, Point::new(200.0, 155.0)); + } + let still = run(&mut app, vec![]).expect("the zoomed view renders"); + assert_eq!(app.canvas.renderer.stats.previewed, 0, "a still frame"); + let at = app.canvas_rect.unwrap().center(); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + run(&mut app, vec![egui::Event::PointerMoved(at)]); + run(&mut app, vec![button(at, true)]); + for step in [1.0, 2.0] { + let drawn = run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]).expect("each frame of the drag renders"); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}"); + assert_eq!(app.canvas.renderer.stats.previewed, 1, "the filtered content, step {step}"); + assert!(app.canvas.moved); + assert!(drawn != still, "the rectangle moved, step {step}"); + } + let after = run(&mut app, vec![button(at + vec2(26.0, 14.0), false)]).expect("the frame after the drag renders"); + assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting && !k.dragging)); + assert_eq!(app.canvas.renderer.stats.previewed, 0, "the frame after the drag"); + assert!(!app.canvas.moved); + app.canvas.renderer = vectorcraft_render::Renderer::new(); + app.canvas.key = None; + assert!(run(&mut app, vec![]) == Some(after.clone()), "the frame after the drag is what a fresh renderer draws"); + // A dialog's preview: an interaction, and no drag. + app.session.begin_interaction("Texturizer").unwrap(); + assert!(run(&mut app, vec![]) == Some(after), "a dialog's preview draws the effect at full resolution"); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && !k.dragging)); + assert_eq!(app.canvas.renderer.stats.previewed, 0); +} + +/// Dragging a small square over an A4 page filled by a rectangle blurred by 500 pt, at 200%: +/// the blur reaches far past the raster budget, so the still frame keeps the backdrop's raster +/// cropped to the view, and each frame of a Selection tool drag draws it from there instead of +/// previewing it at the drag's lower resolution: on this thread, and on the worker, where the +/// frames of a heavy document render. +#[test] +fn a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame() { + for on_worker in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap(); + app.canvas.worker_started = true; // no worker unless `on_worker` + let back = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [back], "color": "#1a4ce6"})).unwrap(); + app.run("effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 500}, "ids": [back]})).unwrap(); + let square = app.run("shape.rectangle", json!({"x": 260, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [square]})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + // One headless frame with `events`, then, on the worker, frames until the frame it renders + // for them is shown. + let run = |app: &mut VectorcraftApp, events: Vec| { + let frame = |app: &mut VectorcraftApp, events| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 870.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + frame(app, events); + let until = std::time::Instant::now() + std::time::Duration::from_secs(60); + while app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(5)); + frame(app, vec![]); + } + }; + let moved = |app: &VectorcraftApp| if on_worker { app.canvas.moved } else { app.canvas.renderer.stats.moved > 0 }; + run(&mut app, vec![]); + if let Some(vm) = app.view_mut() { + (vm.zoom, vm.center) = (2.0, Point::new(290.0, 410.0)); + } + if on_worker { + app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + app.canvas.last_ms = 50.0; + } + run(&mut app, vec![]); + assert_eq!(app.canvas.from_worker, on_worker); + assert!(!moved(&app), "a still frame, on the worker: {on_worker}"); + let at = app.canvas_rect.unwrap().center(); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + run(&mut app, vec![egui::Event::PointerMoved(at)]); + run(&mut app, vec![button(at, true)]); + for step in [1.0, 2.0, 3.0] { + run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}, on the worker: {on_worker}"); + assert_eq!(app.canvas.from_worker, on_worker, "step {step}"); + assert!( + !moved(&app), + "the blur drawn from the still frame's raster, step {step}, on the worker: {on_worker} ({} ms)", + app.perf.render_ms + ); + } + run(&mut app, vec![button(at + vec2(39.0, 21.0), false)]); + let info = app.run("document.inspect", json!({})).unwrap(); + let children = info["layers"][0]["children"].as_array().unwrap().clone(); + let x0 = |id: &serde_json::Value| children.iter().find(|c| c["id"] == *id).unwrap()["bounds"]["x"].as_f64().unwrap(); + assert!(x0(&square) > 270.0 && x0(&back) == 0.0, "the square moved, the backdrop did not"); + app.canvas.worker = None; + } +} + +/// An interaction's frames stay on the worker once it drew the frame shown, however fast that +/// frame was: the worker's renderer keeps the effect rasters that made it fast, and this +/// thread's renderer, without them, would draw the next frame slowly. Without a frame from the +/// worker, a fast frame renders on this thread as before. +#[test] +fn an_interaction_stays_on_the_worker_that_drew_the_frame_shown() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + app.canvas.worker_started = true; // the first frame renders on this thread + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); + for from_worker in [true, false] { + app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + (app.canvas.from_worker, app.canvas.last_ms) = (from_worker, 1.0); + app.session.begin_interaction("Preview").unwrap(); + shapes(&mut app, &ctx); + let pending = app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.interacting); + assert_eq!(pending, from_worker, "sent to the worker: {from_worker}"); + if !from_worker { + assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "rendered on this thread"); + } + app.session.cancel_interaction().unwrap(); + app.canvas.worker = None; + shapes(&mut app, &ctx); + } +} + +/// The frame after an interaction renders without `interacting` also when the interaction +/// leaves the document as it was (#1257): here a drag back to where it started. +#[test] +fn a_drag_back_to_its_start_renders_again_after_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone(); + app.run("effect.apply", json!({"effect": "texture.craquelure", "ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); + app.select_tool("selection"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap(); + for (x, y) in [(173.3, 129.1), (160.0, 125.0)] { + app.session.pointer(&PointerEvent::new(PointerKind::Drag, x, y), view).unwrap(); + shapes(&mut app, &ctx); + assert_eq!(app.canvas.renderer.stats.moved, 1, "the filtered content at ({x}, {y})"); + } + let revision = app.session.active().unwrap().revision; + app.session.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 125.0), view).unwrap(); + assert_eq!(app.session.active().unwrap().revision, revision, "the drag left the document as it was"); + shapes(&mut app, &ctx); + assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting), "rendered again"); + assert_eq!(app.canvas.renderer.stats.moved, 0); + assert!(!app.canvas.moved); +} + +/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size. +/// Keep the worker controlled so huge virtual viewports never allocate huge test images. +#[test] +fn canvas_render_jobs_use_renderer_limited_dimensions() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; + app.ui.view.rulers = false; + let frame = |app: &mut VectorcraftApp, size, ppp| { + let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() }; + input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp); + let mut output = ctx.run_ui(input, |ui| show(app, ui)); + output.textures_delta.clear(); + }; + frame(&mut app, vec2(400.0, 300.0), 1.0); + let (worker, jobs, _completed) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + app.canvas.last_ms = 50.0; + for (size, ppp, wide) in [(vec2(70000.25, 32.0), 1.0, true), (vec2(32.0, 70000.25), 1.0, false), (vec2(40000.25, 32.0), 2.0, true)] { + frame(&mut app, size, ppp); + let job = jobs.try_recv().expect("oversized viewport queued on controlled worker"); + assert_eq!((job.key.w, job.key.h), (job.w, job.h)); + assert_eq!(if wide { job.w } else { job.h }, u32::from(u16::MAX)); + assert!((1..=u32::from(u16::MAX)).contains(&job.w)); + assert!((1..=u32::from(u16::MAX)).contains(&job.h)); + let center = job.view * Point::new(job.key.cx, job.key.cy); + assert!((center.x - f64::from(job.w) / 2.0).abs() < 0.001); + assert!((center.y - f64::from(job.h) / 2.0).abs() < 0.001); + } +} + +/// A cached texture keeps the renderer's document mapping when the viewport changes. The +/// controlled worker cannot finish early, even if egui repeats a layout pass. +#[test] +fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() { + for (old_rot, new_rot, old_ppp, new_ppp, pixel, resize_only) in [ + // Isolate the original panel/window resize: zoom, pan, rotation and DPI stay fixed. + (0.0, 0.0, 1.0, 1.0, false, true), + (30.0, 30.0, 1.25, 1.25, false, true), + (0.0, 0.0, 1.0, 1.0, false, false), + (30.0, 30.0, 1.25, 1.25, false, false), + (30.0, 75.0, 1.25, 1.25, false, false), + (30.0, 75.0, 1.25, 2.0, false, false), + (30.0, 30.0, 2.0, 1.25, false, false), + (30.0, 75.0, 1.25, 2.0, true, false), + ] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; + app.ui.view.pixel_preview = pixel; + let frame = |app: &mut VectorcraftApp, size: egui::Vec2, ppp: f32| { + let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(31.25, 19.5), size)), ..Default::default() }; + // Monitor DPI is viewport input, not egui's UI zoom (which rescales the last + // screen rect and would override the simultaneous resize under test). + input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp); + let mut out = ctx.run_ui(input, |ui| show(app, ui)); + assert!((out.pixels_per_point - ppp).abs() < 0.001); + out.textures_delta.clear(); + let tex = app.canvas.texture.as_ref().expect("rendered canvas texture").id(); + out.shapes + .into_iter() + .find_map(|s| match s.shape { + Shape::Mesh(m) if m.texture_id == tex => Some(m), + _ => None, + }) + .expect("the canvas texture is drawn") + }; + frame(&mut app, vec2(800.25, 600.35), old_ppp); + { + let v = app.view_mut().unwrap(); + v.rotation = old_rot; + v.zoom = 2.5; + v.center = Point::new(210.0, 160.0); + } + frame(&mut app, vec2(800.25, 600.35), old_ppp); + let old = app.canvas.key.clone().unwrap(); + let (worker, jobs, completed) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + app.canvas.last_ms = 50.0; + { + let v = app.view_mut().unwrap(); + v.rotation = new_rot; + if !resize_only { + v.zoom = 3.25; + v.center = Point::new(225.0, 170.0); + } + } + let size = vec2(602.5, 720.1); + let mesh = frame(&mut app, size, new_ppp); + assert_eq!(app.canvas.key.as_ref(), Some(&old), "the cached frame is still displayed"); + let job = jobs.try_recv().expect("the replacement frame was submitted"); + assert_eq!(job.key.ppp, new_ppp); + assert_eq!(job.key.rot, new_rot); + if resize_only { + assert_eq!((job.key.zoom, job.key.cx, job.key.cy, job.key.rot, job.key.ppp), (old.zoom, old.cx, old.cy, old.rot, old.ppp)); + } + assert_ne!((job.key.w, job.key.h), (old.w, old.h)); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let render_view = Affine::translate((old.w as f64 / 2.0, old.h as f64 / 2.0)) + * Affine::rotate(old.rot.to_radians()) + * Affine::scale(old.zoom * old.ppp as f64) + * Affine::translate((-old.cx, -old.cy)); + assert_eq!(mesh.vertices.len(), 4); + for vertex in &mesh.vertices { + assert!([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)].contains(&vertex.uv)); + let doc = if let Some([x0, y0, x1, y1]) = old.pixel { + Point::new(x0 as f64 + vertex.uv.x as f64 * (x1 - x0) as f64, y0 as f64 + vertex.uv.y as f64 * (y1 - y0) as f64) + } else { + render_view.inverse() * Point::new(vertex.uv.x as f64 * old.w as f64, vertex.uv.y as f64 * old.h as f64) + }; + assert!( + (vertex.pos - xf.to_screen(doc)).length() < 0.002, + "{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}" + ); + } + // Deliver the actual replacement and verify that it replaces the cached key. + let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); + let expected = job.key.clone(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap(); + frame(&mut app, size, new_ppp); + assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); + } +} + +/// When the view turns while the worker renders the frame at the new angle (Reset Rotate View on +/// a heavy document), the canvas draws the last frame turned until that frame arrives: each +/// corner goes where the new view puts the document point that the corner showed. The new frame +/// is then drawn unturned. In Pixel Preview the last frame's pixels stay over the document rect +/// they cover. +#[test] +fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); // the first frame fits the view to the artboard + app.view_mut().unwrap().rotation = 30.0; + shapes(&mut app, &ctx); + let rect = app.canvas_rect.unwrap(); + let old = Xf::new(rect, app.view().unwrap()); + // `art` runs one frame and finds the mesh that draws the art's texture. + let art = |app: &mut VectorcraftApp| { + let s = shapes(app, &ctx); + let tex = app.canvas.texture.as_ref().unwrap().id(); + s.into_iter().find_map(|s| match s { + Shape::Mesh(m) if m.texture_id == tex => Some(m), + _ => None, + }) + }; + // With a worker and a last frame that took 50 ms, the next frame renders on the worker. + app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + app.canvas.last_ms = 50.0; + app.run("view.rotateReset", json!({})).unwrap(); + let mesh = art(&mut app).expect("the last frame, turned"); + assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.rot == 0.0), "sent to the worker"); + let new = Xf::new(rect, app.view().unwrap()); + let corners = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()]; + assert_eq!(mesh.vertices.len(), 4); + for ((v, corner), uv) in mesh.vertices.iter().zip(corners).zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) { + let at = new.to_screen(old.to_doc(corner)); + assert!((v.pos - at).length() < 0.01 && v.uv == uv, "{v:?}: texture corner {uv:?} at {at:?}"); + } + // The point where the last frame drew the rectangle's top-left corner lands where the new view puts that corner. + let p = Point::new(100.0, 50.0); + let uv = (old.to_screen(p) - rect.min) / rect.size(); + let [a, b, _, d] = [0, 1, 2, 3].map(|i| mesh.vertices[i].pos); + assert!((a + (b - a) * uv.x + (d - a) * uv.y - new.to_screen(p)).length() < 0.01, "the rectangle's corner"); + // The worker's frame at 0° then covers the canvas unturned. + let until = std::time::Instant::now() + std::time::Duration::from_secs(60); + while app.canvas.key.as_ref().is_some_and(|k| k.rot != 0.0) && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(5)); + shapes(&mut app, &ctx); + } + assert!(app.canvas.key.as_ref().is_some_and(|k| k.rot == 0.0), "the worker's frame arrives"); + let b = art(&mut app).expect("the worker's frame").calc_bounds(); + assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}"); + // In Pixel Preview the last frame's pixels are drawn over the document rect they cover. + app.canvas.worker = None; + app.ui.view.pixel_preview = true; + app.view_mut().unwrap().zoom = 8.0; + shapes(&mut app, &ctx); + let [x0, y0, ..] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered"); + app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + app.canvas.last_ms = 50.0; + app.view_mut().unwrap().rotation = 30.0; + let mesh = art(&mut app).expect("the last frame's pixels"); + let at = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(x0 as f64, y0 as f64)); + assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos); + app.canvas.worker = None; +} + +/// With the web build's setting (`waits_for_still`, no render worker), a heavy document's canvas +/// is not rendered while the view moves: each frame draws the frame shown moved and scaled with +/// the view and asks for a frame within STILL_SECS. The canvas renders once the view has been +/// still for STILL_SECS, and at once after an edit. With a worker's build settings the same frames render at once, as +/// before. +#[test] +fn without_a_worker_a_heavy_canvas_is_reprojected_until_the_view_is_still() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + assert!(!app.canvas.waits_for_still, "off the web build"); + app.canvas.worker_started = true; // no worker, as in the web build + app.canvas.waits_for_still = true; + let ctx = egui::Context::default(); + // One frame → (the bounds of the mesh that draws the art's texture, the repaint delay asked for). + let frame = |app: &mut VectorcraftApp| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let tex = app.canvas.texture.as_ref().unwrap().id(); + let art = out.shapes.iter().find_map(|c| match &c.shape { + Shape::Mesh(m) if m.texture_id == tex => Some(m.calc_bounds()), + _ => None, + }); + (art.expect("the art's texture is drawn"), out.viewport_output[&egui::ViewportId::ROOT].repaint_delay) + }; + frame(&mut app); // the first frame fits the view to the artboard + frame(&mut app); + let rect = app.canvas_rect.unwrap(); + // A light document renders every change of view at once. + app.canvas.last_ms = 1.0; + app.view_mut().unwrap().center.x += 10.0; + frame(&mut app); + let shown = app.canvas.key.clone().unwrap(); + assert_eq!(shown.cx, app.view().unwrap().center.x, "rendered at once"); + // A heavy one draws the frame shown moved and scaled with the view. + app.canvas.last_ms = 50.0; + let old = Xf::new(rect, app.view().unwrap()); + for (dx, zoom) in [(10.0, 1.0), (10.0, 1.5), (-25.0, 1.5)] { + let v = app.view_mut().unwrap(); + v.center.x += dx; + v.zoom *= zoom; + let (b, delay) = frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&shown), "not rendered while the view moves"); + let new = Xf::new(rect, app.view().unwrap()); + let (a, z) = (new.to_screen(old.to_doc(rect.min)), new.to_screen(old.to_doc(rect.max))); + assert!((b.min - a).length() < 0.01 && (b.max - z).length() < 0.01, "{b:?}: the frame shown at {a:?} to {z:?}"); + assert!(delay <= std::time::Duration::from_secs_f64(STILL_SECS), "a frame within STILL_SECS: {delay:?}"); + } + // The view stays still: frames at 60 fps (egui's input time without a clock) wait until + // STILL_SECS has passed, and the next one renders. + let mut frames = 0; + let b = loop { + frames += 1; + let (b, _) = frame(&mut app); + if app.canvas.key.as_ref() != Some(&shown) || frames == 60 { + break b; + } + }; + assert!((12..=13).contains(&frames), "rendered after about 12 frames: {frames}"); + let now = app.canvas.key.clone().unwrap(); + assert_eq!((now.cx, now.zoom), (app.view().unwrap().center.x, app.view().unwrap().zoom), "rendered for the view"); + assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}"); + // An edit renders at once, even while the view moves. + app.canvas.last_ms = 50.0; + app.view_mut().unwrap().center.y += 10.0; + frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&now), "waiting"); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + frame(&mut app); + let k = app.canvas.key.clone().unwrap(); + assert_eq!((k.revision, k.cy), (app.session.active().unwrap().revision, app.view().unwrap().center.y), "rendered after the edit"); + // With a worker's build settings and no worker (VECTORCRAFT_SYNC_RENDER), a heavy document + // renders every change of view at once. + app.canvas.waits_for_still = false; + app.canvas.last_ms = 50.0; + app.view_mut().unwrap().center.x += 10.0; + frame(&mut app); + assert_eq!(app.canvas.key.as_ref().map(|k| k.cx), Some(app.view().unwrap().center.x), "rendered at once"); +} + +/// [`still_wait`] waits while only the view changes on a slow canvas: each change starts +/// STILL_SECS again. It does not wait for a light document, a still view, a clock that went +/// back, an edit, a switch into Pixel Preview or a canvas with no frame shown. +#[test] +fn the_canvas_waits_only_while_the_view_of_a_slow_frame_changes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + shapes(&mut app, &egui::Context::default()); + let shown = app.canvas.key.clone().unwrap(); + let mut still = Some((shown.clone(), 0.0)); + let moved = CacheKey { cx: shown.cx + 5.0, zoom: shown.zoom * 2.0, ..shown.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0), Some(STILL_SECS), "the view moved"); + assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0625), Some(STILL_SECS - 0.0625), "still for 62.5 ms"); + let turned = CacheKey { rot: 15.0, w: shown.w + 40, ppp: 2.0, ..moved.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.0), Some(STILL_SECS), "turned and resized: again"); + assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.25), None, "still for longer than STILL_SECS"); + assert_eq!(still_wait(&mut still, Some(&shown), &turned, false, 11.0), None, "a light document"); + assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 10.5), None, "the clock went back"); + let edited = CacheKey { revision: shown.revision + 1, ..turned.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown), &edited, true, 12.0), None, "an edit"); + let pixels = CacheKey { pixel: Some([0, 0, 10, 10]), ..moved.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown), &pixels, true, 13.0), None, "into Pixel Preview"); + let shown_pixels = CacheKey { pixel: Some([5, 5, 20, 20]), ..shown.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown_pixels), &pixels, true, 13.0), Some(STILL_SECS), "Pixel Preview, moved"); + assert_eq!(still_wait(&mut still, None, &moved, true, 14.0), None, "no frame shown"); + assert_eq!(still_wait(&mut still, Some(&shown), &shown, true, 15.0), None, "the frame shown"); +} + +/// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel +/// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the +/// canvas always does out of Pixel Preview. +#[test] +fn pixel_preview_smooths_bitmaps_only_when_asked() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let ctx = egui::Context::default(); + let smooth = |app: &mut VectorcraftApp| { + let (_, mut delta) = frame_output(app, &ctx); + delta.clear(); + app.canvas.key.as_ref().map(|k| k.smooth_images).unwrap() + }; + assert!(smooth(&mut app), "out of Pixel Preview"); + app.ui.view.pixel_preview = true; + app.view_mut().unwrap().zoom = 8.0; + assert!(!smooth(&mut app), "Pixel Preview, off by default"); + app.run("prefs.set", json!({"key": "antiAliasedBitmaps", "value": true})).unwrap(); + assert!(smooth(&mut app), "Pixel Preview, on"); +} + +/// Appearance of Black › On Screen (#394): Display All Blacks Accurately (the default) shows +/// pure CMYK black through the CMYK profile, a dark grey; Display All Blacks as Rich Black +/// shows it as RGB black. A rich black shows the same either way. +#[test] +fn appearance_of_black_on_screen_shows_pure_black_as_rgb_black() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap(); + app.canvas.worker_started = true; // render on this thread + // Larger than the view: the middle of the art's raster is its fill. + let id = app.run("shape.rectangle", json!({"x": -5000, "y": -5000, "width": 10000, "height": 10000})).unwrap()["id"].clone(); + let fill = |app: &mut VectorcraftApp, cmyk: [u8; 4]| { + let [c, m, y, k] = cmyk; + app.run("paint.setFill", json!({"ids": [id], "color": {"c": c, "m": m, "y": y, "k": k}})).unwrap(); + }; + let ctx = egui::Context::default(); + // The middle pixel of the art's raster uploaded by one frame, None when it was not re-rendered. + let middle = |app: &mut VectorcraftApp| -> Option<[u8; 4]> { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + let [w, h] = img.size; + img.pixels[h / 2 * w + w / 2].to_array() + }); + delta.clear(); + px + }; + let shown = |cmyk: [f32; 4]| vectorcraft_color::Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0); + let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1); + + fill(&mut app, [0, 0, 0, 100]); + let accurate = middle(&mut app).expect("the first frame renders the art"); + assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0])), "accurate: through the CMYK profile, {accurate:?}"); + assert!(accurate[..3].iter().all(|v| *v > 10), "accurate: a dark grey, {accurate:?}"); + app.run("prefs.set", json!({"key": "blackOnScreen", "value": "rich"})).unwrap(); + assert_eq!(middle(&mut app).expect("the preference change re-renders"), [0, 0, 0, 255], "rich: RGB black"); + + fill(&mut app, [60, 40, 40, 100]); + let rich = middle(&mut app).expect("re-rendered"); + assert!(near(rich, shown([0.6, 0.4, 0.4, 1.0])), "a rich black is not pure black: {rich:?}"); + + fill(&mut app, [0, 0, 0, 100]); + app.run("prefs.set", json!({"key": "blackOnScreen", "value": "accurate"})).unwrap(); + assert!(near(middle(&mut app).expect("re-rendered"), accurate), "accurate again"); +} + +/// Guides & Grid › Show Pixel Grid (Above 600% Zoom) (#394): in Pixel Preview at 600% zoom and +/// above, a line at every document pixel over the art; none below 600%, out of Pixel Preview, +/// or with the option off. +#[test] +fn pixel_grid_shows_in_pixel_preview_from_600_percent() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); // the first frame fits the view to the artboard + // → (the pixel grid's lines, whether the first of them is drawn over the art). + let pixel_lines = |app: &mut VectorcraftApp| { + let s = shapes(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art"); + let is_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == PIXEL_GRID); + let lines: Vec = s.iter().enumerate().filter(|(_, s)| is_line(s)).map(|(i, _)| i).collect(); + (lines.len(), lines.first().is_some_and(|&i| i > image)) + }; + app.ui.view.pixel_preview = true; + app.view_mut().unwrap().zoom = 8.0; + let (n, over) = pixel_lines(&mut app); + assert!(n >= 150, "a line every 8 px across the 800 × 600 canvas: {n}"); + assert!(over, "over the art"); + app.view_mut().unwrap().zoom = 5.0; + assert_eq!(pixel_lines(&mut app).0, 0, "below 600%: none"); + app.view_mut().unwrap().zoom = 6.0; + assert!(pixel_lines(&mut app).0 >= 200, "from 600%"); + app.ui.view.pixel_preview = false; + assert_eq!(pixel_lines(&mut app).0, 0, "out of Pixel Preview: none"); + app.ui.view.pixel_preview = true; + app.session.execute("prefs.set", &json!({"key": "showPixelGrid", "value": false})).unwrap(); + assert_eq!(pixel_lines(&mut app).0, 0, "the option off: none"); + // A rotated view: the lines turn with the pixels, across the whole canvas. + app.session.execute("prefs.set", &json!({"key": "showPixelGrid", "value": true})).unwrap(); + app.view_mut().unwrap().rotation = 30.0; + let s = shapes(&mut app, &ctx); + let turned: Vec<_> = s + .iter() + .filter_map(|s| match s { + Shape::LineSegment { points: [a, b], stroke } if stroke.color == PIXEL_GRID => Some((b.x - a.x).atan2(b.y - a.y).to_degrees().abs()), + _ => None, + }) + .collect(); + assert!(turned.len() >= 200, "{}", turned.len()); + assert!(turned.iter().all(|a| [30.0, 60.0, 120.0, 150.0].iter().any(|t| (a - t).abs() < 0.5)), "turned 30°: {turned:?}"); +} + +/// Guides & Grid (#394): the grid in Grid Color, behind the art or, with Grids In Back off, +/// over it, as dots in the Dots style; the guides in Guides Color and Style. +#[test] +fn grid_and_guides_take_their_preferences() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); + app.ui.view.grid = true; + app.ui.view.guides = true; + let (red, green) = (Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 255, 0)); + app.session.execute("prefs.set", &json!({"values": {"gridColor": "#ff0000", "guideColor": "#00ff00"}})).unwrap(); + let ctx = egui::Context::default(); + let red_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == red); + // → (index of the first red gridline, index of the art's image). + let order = |app: &mut VectorcraftApp| { + let s = shapes(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art"); + (s.iter().position(red_line).expect("a gridline in Grid Color"), image, s) + }; + let (grid, art, s) = order(&mut app); + assert!(grid < art, "Grids In Back: under the art"); + assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == green)), "the guide in Guides Color"); + app.session.execute("prefs.set", &json!({"key": "gridsInBack", "value": false})).unwrap(); + let (grid, art, _) = order(&mut app); + assert!(grid > art, "Grids In Back off: over the art"); + app.session.execute("prefs.set", &json!({"values": {"gridStyle": "dots", "guideStyle": "dots"}})).unwrap(); + let s = shapes(&mut app, &ctx); + assert!(!s.iter().any(red_line), "no gridlines in the Dots style"); + let red_dots = |s: &Shape| matches!(s, Shape::Mesh(m) if m.vertices.len() > 40 && m.vertices.iter().all(|v| v.color == red)); + assert!(s.iter().any(red_dots), "the grid's dots"); + assert!(s.iter().any(|s| matches!(s, Shape::Circle(c) if c.fill == green)), "the guide dotted"); +} + +/// A canvas guide (on no artboard) is drawn along its document line in a rotated view, where it +/// snaps and is picked: both ends of each guide's line are on x = 100 or y = 120. At 0° the +/// vertical one runs from the canvas's top to its bottom. +#[test] +fn canvas_guides_turn_with_the_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); + app.session.execute("guide.add", &json!({"vertical": false, "pos": 120})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "guideColor", "value": "#123456"})).unwrap(); + app.ui.view.guides = true; + let color = Color32::from_rgb(0x12, 0x34, 0x56); + let ctx = egui::Context::default(); + let lines = |app: &mut VectorcraftApp| -> Vec<[Pos2; 2]> { + shapes(app, &ctx) + .into_iter() + .filter_map(|s| if let Shape::LineSegment { points, stroke } = s { (stroke.color == color).then_some(points) } else { None }) + .collect() + }; + let s = lines(&mut app); + let rect = app.canvas_rect.unwrap(); + let vertical = s.iter().find(|[a, b]| (a.x - b.x).abs() < 1e-3).expect("the vertical guide"); + assert!((vertical[0].y - rect.top()).abs() < 0.5 && (vertical[1].y - rect.bottom()).abs() < 0.5, "0°: {vertical:?} in {rect:?}"); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + let s = lines(&mut app); + let xf = Xf::new(rect, app.view().unwrap()); + assert_eq!(s.len(), 2, "{s:?}"); + let on = |v: f64, along_x: bool| { + s.iter().any(|ends| ends.iter().all(|e| ((if along_x { xf.to_doc(*e).x } else { xf.to_doc(*e).y }) - v).abs() < 1e-3)) + }; + assert!(on(100.0, true), "x = 100: {s:?}"); + assert!(on(120.0, false), "y = 120: {s:?}"); +} + +/// Selection & Anchor Display › Size (#394): anchors and the bounding box's handles grow. +#[test] +fn anchor_size_scales_anchors_and_handles() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + // The sizes of the squares drawn (anchors and handles are squares). + let sizes = |app: &mut VectorcraftApp| { + let mut v: Vec = shapes(app, &ctx).iter().filter_map(|s| if let Shape::Rect(r) = s { Some(r.rect.width()) } else { None }).collect(); + v.sort_by(f32::total_cmp); + v.dedup(); + v + }; + let small = sizes(&mut app); + assert!(small.contains(&4.0) && small.contains(&6.0), "anchors 4, handles 6 by default: {small:?}"); + app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap(); + let big = sizes(&mut app); + assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}"); +} + +#[test] +fn mirror_axis_tracks_selection_options_and_view_without_editing_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap(); + let id = app.run("object.repeat.mirror", json!({})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + let axis = |app: &mut VectorcraftApp| { + let color = Tokens::get(&ctx).selection; + shapes(app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::LineSegment { points, stroke } if stroke.color == color && stroke.width == 2.0 => Some(points), + _ => None, + }) + .collect::>() + }; + let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let lines = axis(&mut app); + assert_eq!(lines.len(), 1); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + assert!(lines[0][0].distance(xf.to_screen(Point::new(170.0, 100.0)) - vec2(0.0, 24.0)) < 0.01); + assert!(lines[0][1].distance(xf.to_screen(Point::new(170.0, 180.0)) + vec2(0.0, 24.0)) < 0.01); + assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), before); + app.ui.view.edges = false; + assert!(axis(&mut app).is_empty()); + app.ui.view.edges = true; + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(axis(&mut app).is_empty()); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert_eq!(axis(&mut app), lines); + app.run("object.repeat.options", json!({"angle": 0, "center": [180, 140]})).unwrap(); + let horizontal = axis(&mut app); + assert_eq!(horizontal.len(), 1); + assert!((horizontal[0][0].y - horizontal[0][1].y).abs() < 0.01); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(axis(&mut app), lines); + app.view_mut().unwrap().rotation = 45.0; + let rotated = axis(&mut app); + let d = rotated[0][1] - rotated[0][0]; + assert!((d.x.abs() - d.y.abs()).abs() < 0.01); + app.run("object.repeat.release", json!({})).unwrap(); + assert!(axis(&mut app).is_empty()); +} + +#[test] +fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap(); + app.run("object.repeat.mirror", json!({})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.view_mut().unwrap().zoom = 1.5; + app.view_mut().unwrap().rotation = 30.0; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let axis = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, app.view().unwrap().zoom).unwrap() + }; + let initial = axis(&app); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let from = xf.to_screen(initial.center); + let to = from + vec2(40.0, 15.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); + assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001, "the mirrored artwork previews during the drag"); + frame(&mut app, &ctx, vec![button(to, false)]); + assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(axis(&app).center, initial.center); + // Both rotation handles remain a usable size at this zoom and view angle. + let drawn = shapes(&mut app, &ctx); + for end in initial.ends { + assert!(drawn.iter().any(|s| matches!(s, Shape::Circle(c) + if c.center.distance(xf.to_screen(end)) < 0.01 && c.radius == vectorcraft_tools::mirror::HANDLE_RADIUS))); + } +} + +/// A selected grid repeat draws its row handle below the grid and a slider between its first +/// two columns (none with the bounding box hidden). Dragging the row handle down two tiles in +/// a zoomed view makes five rows without scaling the source, as one undo step, and the slider +/// sets the horizontal spacing (#2691). +#[test] +fn grid_repeat_handles_set_rows_and_spacing_in_one_undo_step_each() { + use vectorcraft_tools::repeat_grid::{GridHandles, HANDLE_RADIUS}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 40, "height": 40})).unwrap(); + app.run("object.repeat.grid", json!({"hSpacing": 10, "vSpacing": 10})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + // The first frame fits the artboard in the window; then zoom to 1.5 px a point. + frame(&mut app, &ctx, vec![]); + app.view_mut().unwrap().zoom = 1.5; + frame(&mut app, &ctx, vec![]); + assert_eq!(app.view().unwrap().zoom, 1.5); + // Rows, columns, horizontal spacing and the source's bounds. + let grid = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + let n = st.doc.node(st.selection.objects[0]).unwrap(); + let vectorcraft_doc::NodeKind::Repeat(spec) = &n.kind else { panic!("a repeat") }; + let vectorcraft_doc::RepeatKind::Grid { rows, cols, h_spacing, .. } = spec.kind else { panic!("a grid repeat") }; + (rows, cols, h_spacing, spec.source_bounds().unwrap()) + }; + let handles = |app: &mut VectorcraftApp| { + let frame = app.selection_box(); + let st = app.session.active().unwrap(); + GridHandles::of(&st.doc, &st.selection, frame, app.view().unwrap().zoom).unwrap() + }; + let before = grid(&app); + assert_eq!((before.0, before.1, before.2), (3, 3, 10.0)); + let g = handles(&mut app); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let drawn = shapes(&mut app, &ctx); + let circle_at = |drawn: &[Shape], at: Pos2| { + drawn.iter().any(|s| matches!(s, Shape::Circle(c) if c.center.distance(at) < 0.01 && c.radius == HANDLE_RADIUS)) + }; + assert!(circle_at(&drawn, xf.to_screen(g.rows_at)), "the row handle is drawn"); + assert!(circle_at(&drawn, xf.to_screen(g.corner_at)), "the corner handle is drawn"); + app.ui.view.bounding_box = false; + assert!(!circle_at(&shapes(&mut app, &ctx), xf.to_screen(g.rows_at)), "hidden with the bounding box"); + app.ui.view.bounding_box = true; + frame(&mut app, &ctx, vec![]); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let drag = |app: &mut VectorcraftApp, from: Pos2, by: egui::Vec2| { + frame(app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); + for k in 1..=4 { + frame(app, &ctx, vec![egui::Event::PointerMoved(from + by * k as f32 / 4.0)]); + } + frame(app, &ctx, vec![button(from + by, false)]); + }; + // Two tiles of 50 pt down, at 1.5 px a point. + drag(&mut app, xf.to_screen(g.rows_at), vec2(0.0, 150.0)); + let after = grid(&app); + assert_eq!((after.0, after.1, after.2), (5, 3, 10.0)); + assert_eq!(after.3, before.3, "the source keeps its size"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(grid(&app).0, 3, "one undo step"); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(grid(&app).0, 5); + // The slider between the first two columns, 6 px (4 pt) right: it stays halfway across a + // gap 8 pt wider, 18 pt of spacing. + frame(&mut app, &ctx, vec![]); + let g = handles(&mut app); + drag(&mut app, xf.to_screen(g.h_slider.unwrap()), vec2(6.0, 0.0)); + assert_eq!(grid(&app).2, 18.0); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!((grid(&app).0, grid(&app).2), (5, 10.0), "the spacing's own undo step"); +} + +#[test] +fn top_bounding_box_handle_scales_a_default_mirror_repeat() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("object.repeat.mirror", json!({})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + + let initial = app.selection_bounds().unwrap(); + let bx = app.selection_box().unwrap(); + let top = bx.to_doc() * vectorcraft_tools::bbox::Handle::Top.pos(bx.rect); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let from = xf.to_screen(top); + let to = from + vec2(0.0, -30.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![button(to, false)]); + + let scaled = app.selection_bounds().unwrap(); + assert!(scaled.height() > initial.height() + 10.0, "Top handle should scale the repeat: {initial:?} -> {scaled:?}"); + assert!((scaled.width() - initial.width()).abs() < 0.01, "Top handle should not scale width: {initial:?} -> {scaled:?}"); +} + +/// #1236: Curvature edits points, so existing paths show one circular marker per point and +/// no direction handles; switching back to Direct Selection keeps its normal handles. +#[test] +fn curvature_points_of_an_existing_path_have_no_direction_handles() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = vectorcraft_doc::NodeId( + app.session + .execute( + "path.curvature", + &json!({"points": [ + {"x": 60, "y": 200}, {"x": 120, "y": 80}, {"x": 210, "y": 160}, {"x": 280, "y": 60}, {"x": 350, "y": 180} + ]}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + let document = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + app.select_tool("curvature"); + let drawn = shapes(&mut app, &ctx); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let points: Vec<_> = document.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| xf.to_screen(a.p)).collect(); + let marker = |shape: &Shape| matches!(shape, Shape::Circle(c) if points.iter().any(|p| p.distance(c.center) < 0.01)); + assert_eq!(drawn.iter().filter(|s| marker(s)).count(), 5, "one circular marker at every curve point"); + assert!( + !drawn.iter().any(|s| matches!(s, Shape::Rect(r) if points.iter().any(|p| p.distance(r.rect.center()) < 0.01))), + "no square at a curve point" + ); + let color = c32(document.layer_color(id)); + let handles = |shapes: &[Shape]| shapes.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count(); + assert_eq!(handles(&drawn), 0, "Curvature hides the direction lines"); + app.session.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"})).unwrap(); + let partial = shapes(&mut app, &ctx); + assert_eq!(partial.iter().filter(|s| marker(s)).count(), 5, "other curve points stay visible"); + assert_eq!(handles(&partial), 0, "a single selected point still shows no direction lines"); + app.ui.view.edges = false; + assert_eq!(shapes(&mut app, &ctx).iter().filter(|s| marker(s)).count(), 0, "Hide Edges still hides an existing path's markers"); + app.ui.view.edges = true; + app.select_tool("directSelection"); + assert_eq!(handles(&shapes(&mut app, &ctx)), 2, "Direct Selection keeps the selected point's two handles"); + app.select_tool("pen"); + assert_eq!(handles(&shapes(&mut app, &ctx)), 2, "Pen keeps the selected point's two handles"); + assert_eq!( + serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), + serde_json::to_value(&document).unwrap(), + "tool switches and painting don't change the curve" + ); +} + +/// While drawing, Curvature's current-point feedback takes the place of generic selection +/// markers: no duplicate squares or handles, and the path is unchanged by drawing a frame. +#[test] +fn curvature_drawing_shows_its_points_once() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); + app.select_tool("curvature"); + for (x, y) in [(60.0, 200.0), (120.0, 80.0), (210.0, 160.0), (280.0, 60.0), (350.0, 180.0)] { + let view = app.view_info(); + for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { + app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, x, y), view).unwrap(); + } + } + let st = app.session.active().unwrap(); + let id = st.selection.objects[0]; + let document = st.doc.clone(); + let selection = st.selection.clone(); + let drawn = shapes(&mut app, &ctx); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let points: Vec<_> = document.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| xf.to_screen(a.p)).collect(); + let markers: Vec<_> = drawn + .iter() + .filter_map(|s| match s { + Shape::Circle(c) if points.iter().any(|p| p.distance(c.center) < 0.01) => Some(c), + _ => None, + }) + .collect(); + assert_eq!(markers.len(), 5, "one marker per point, including the current point"); + assert_eq!(markers.iter().filter(|c| c.fill != Color32::WHITE).count(), 1, "only the last touched point is filled"); + assert!( + !drawn.iter().any(|s| matches!(s, Shape::Rect(r) if points.iter().any(|p| p.distance(r.rect.center()) < 0.01))), + "neither tool nor selection draws anchor squares" + ); + let color = c32(document.layer_color(id)); + assert!(!drawn.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)), "no direction lines"); + app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap(); + assert_eq!( + shapes(&mut app, &ctx) + .iter() + .filter(|s| matches!(s, Shape::Circle(c) if c.radius == 5.0 && points.iter().any(|p| p.distance(c.center) < 0.01))) + .count(), + 5, + "point markers respect Anchor Size" + ); + assert_eq!(app.session.active().unwrap().selection, selection); + assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), serde_json::to_value(&document).unwrap()); +} + +/// Selection & Anchor Display › Handles and Show handles when multiple anchors are selected +/// (#394): handle ends drawn solid, hollow or large; with the latter off, a second selected +/// anchor hides the handles. +#[test] +fn handle_style_and_handles_of_multiple_anchors() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + app.session.select_tool("directSelection", app.view_info()).unwrap(); + let ctx = egui::Context::default(); + let select = |app: &mut VectorcraftApp, anchors: serde_json::Value| { + app.session.execute("select.anchors", &json!({"id": id, "anchors": anchors, "mode": "set"})).unwrap(); + }; + // The handle ends: (radius, filled white). + let dots = |app: &mut VectorcraftApp| -> Vec<(f32, bool)> { + shapes(app, &ctx) + .iter() + .filter_map(|s| match s { + Shape::Circle(c) if c.fill != Color32::TRANSPARENT => Some((c.radius, c.fill == Color32::WHITE)), + _ => None, + }) + .collect() + }; + select(&mut app, json!([[0, 0]])); + assert_eq!(dots(&mut app), [(2.75, false); 2], "solid by default"); + app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "hollow"})).unwrap(); + assert_eq!(dots(&mut app), [(2.75, true); 2]); + app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "large"})).unwrap(); + assert_eq!(dots(&mut app), [(4.25, false); 2]); + select(&mut app, json!([[0, 0], [0, 1]])); + assert_eq!(dots(&mut app).len(), 4, "both anchors' handles"); + app.session.execute("prefs.set", &json!({"key": "showHandlesMultipleAnchors", "value": false})).unwrap(); + assert!(dots(&mut app).is_empty(), "off: none for two anchors"); + select(&mut app, json!([[0, 1]])); + assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them"); +} + +/// #3219: a selected path made into a guide is deselected when guides are hidden, so the +/// selection overlay cannot reveal the hidden path. +#[test] +fn hide_guides_deselects_selected_guide_paths() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("view.guides.make", json!({})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(id)]); + + app.run("view.guides", json!({})).unwrap(); + + assert!(app.session.active().unwrap().selection.objects.is_empty()); +} + +/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer does not +/// show with View › Snap to Grid or View › Pixel Preview on, even with Smart Guides and the option on. +/// #3219: guide paths hidden by View > Hide Guides are not hittable and cannot reveal +/// themselves through Smart Guides object highlighting. +#[test] +fn hidden_guide_paths_have_no_hover_highlight() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("view.guides.make", json!({})).unwrap(); + app.run("select.none", json!({})).unwrap(); + app.select_tool("selection"); + app.ui.view.guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(100.0, 150.0)))]); + assert!(drawn_in(&mut app, &ctx, color, 1.5).is_empty(), "a hidden guide has no hover highlight"); +} + +#[test] +fn snap_to_grid_and_pixel_preview_hide_the_hover_outline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0)); + let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)))); + let outlines = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![egui::Event::PointerMoved(over)]); + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count() + }; + assert!(app.ui.view.smart_guides && app.session.prefs.object_highlighting); + assert!(outlines(&mut app) > 0, "highlighted with Snap to Grid and Pixel Preview off"); + app.run("view.snapToGrid", json!({})).unwrap(); + assert!(app.ui.view.snap_to_grid); + assert_eq!(outlines(&mut app), 0, "Snap to Grid on"); + app.run("view.snapToGrid", json!({})).unwrap(); + assert!(outlines(&mut app) > 0, "Snap to Grid off again"); + app.run("view.pixelPreview", json!({})).unwrap(); + assert!(app.ui.view.pixel_preview); + assert_eq!(outlines(&mut app), 0, "Pixel Preview on"); + app.run("view.pixelPreview", json!({})).unwrap(); + assert!(outlines(&mut app) > 0, "Pixel Preview off again"); +} + +/// #2452: while the view scrolls under a resting pointer, the art under it isn't hit-tested: +/// no hover outline, and the tool's last cursor stays. Once the view has been still for +/// [`HOVER_STILL_SECS`], the outline comes back without the pointer moving. +#[test] +fn a_moving_view_leaves_the_art_under_the_pointer_unhighlighted_until_it_stops() { + fn flat(s: &Shape, out: &mut Vec) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| flat(s, out)), + s => out.push(s.clone()), + } + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)))); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut time = 0.0; + let mut outlines = |app: &mut VectorcraftApp, events: Vec, dt: f64| { + time += dt; + let mut out = + ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut shapes = vec![]; + out.shapes.iter().for_each(|c| flat(&c.shape, &mut shapes)); + shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count() + }; + outlines(&mut app, vec![], 0.0); + let at = app.canvas_rect.unwrap().center(); + assert!(outlines(&mut app, vec![egui::Event::PointerMoved(at)], 0.016) > 0, "the art under the resting pointer is highlighted"); + let wheel = |dy: f32| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + for k in 0..12 { + let before = app.view().unwrap().center; + let shown = outlines(&mut app, vec![wheel(if k % 2 == 0 { 4.0 } else { -4.0 })], 0.016); + assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view"); + assert_eq!(shown, 0, "frame {k}: no hover outline while the view moves"); + } + assert_eq!(outlines(&mut app, vec![], HOVER_STILL_SECS / 2.0), 0, "the view has only just stopped"); + assert!(outlines(&mut app, vec![], HOVER_STILL_SECS) > 0, "still again: the outline comes back"); + // Selected, the rectangle gives the move cursor near its top edge. Scrolled up past the + // pointer, it stays the move cursor until the view stops, then the arrow. + app.session.execute("select.all", &json!({})).unwrap(); + outlines(&mut app, vec![egui::Event::PointerMoved(at - vec2(0.0, 200.0))], 0.016); + let cursor = |app: &VectorcraftApp| app.canvas.hover_cursor.map(|(_, _, c)| c); + assert_eq!(cursor(&app), Some(Cursor::Move)); + for k in 0..12 { + let before = app.view().unwrap().center; + outlines(&mut app, vec![wheel(-80.0)], 0.016); + assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view"); + assert_eq!(cursor(&app), Some(Cursor::Move), "frame {k}: the cursor stays while the view moves"); + } + // The wheel's smooth scrolling runs on for some frames; then the view rests. A frame of it + // that scrolls less than half a device pixel leaves the view where it is. + for _ in 0..200 { + let before = app.view().unwrap().center; + outlines(&mut app, vec![], 0.016); + if app.view().unwrap().center == before && !ctx.input(|i| i.is_scrolling()) { + break; + } + } + assert_eq!(cursor(&app), Some(Cursor::Move), "the view has only just stopped"); + outlines(&mut app, vec![], HOVER_STILL_SECS + 0.01); + assert_eq!(cursor(&app), Some(Cursor::Arrow), "still again: the pointer at {:?} is past the rectangle", app.hover_doc); +} + +/// The wheel, a scroll bar and the Hand tool move the view by whole device pixels at 1.25 +/// device pixels a point, in a zoomed and turned view: every frame's render view is the first +/// one's panned by whole pixels, which the renderer's cropped effect rasters need to be drawn +/// again, and the wheel's and the scroll bar's scrolls add up to what they were given within a +/// device pixel, as does the drag. +#[test] +fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { + const PPP: f32 = 1.25; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.016; + let mut input = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; + input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(PPP); + ctx.run_ui(input, |ui| show(app, ui)).textures_delta.clear(); + }; + frame(&mut app, vec![]); + { + let v = app.view_mut().unwrap(); + v.zoom = 4.0; + v.rotation = 30.0; + v.center = Point::new(200.3, 150.7); + } + frame(&mut app, vec![]); + // The pan of the canvas's render view (the translation of the view it renders through, less + // the half of its size), in device pixels. + let pan = |app: &VectorcraftApp| { + let v = app.view().unwrap(); + (Affine::rotate(v.rotation.to_radians()) * Affine::scale(v.zoom * f64::from(PPP)) * Affine::translate(-v.center.to_vec2())).translation() + }; + let wheel = |d: egui::Vec2| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: d, + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + let whole = |by: vectorcraft_geom::Vec2| (by - vectorcraft_geom::Vec2::new(by.x.round(), by.y.round())).hypot() < 1e-6; + let rect = app.canvas_rect.unwrap(); + let track = scroll_tracks(rect, screen)[0].center(); + let deltas = [vec2(0.37, 11.13), vec2(-12.71, 0.0), vec2(0.0, 7.45), vec2(21.9, -3.83), vec2(0.21, 0.21)]; + for (name, at) in [("the wheel", rect.center()), ("the horizontal scroll bar", track)] { + frame(&mut app, vec![egui::Event::PointerMoved(at)]); + let start = pan(&app); + let mut given = vectorcraft_geom::Vec2::ZERO; + for (k, d) in deltas.iter().enumerate() { + frame(&mut app, vec![wheel(*d)]); + // Over the scroll bar, the wheel scrolls along it. + given += if at == track { + vectorcraft_geom::Vec2::new(f64::from(d.x + d.y), 0.0) + } else { + vectorcraft_geom::Vec2::new(f64::from(d.x), f64::from(d.y)) + }; + let by = pan(&app) - start; + assert!(whole(by), "{name}, scroll {k}: the view moved {by:?} device pixels"); + } + // The wheel's smooth scrolling runs on for some frames. + for k in 0..60 { + frame(&mut app, vec![]); + let by = pan(&app) - start; + assert!(whole(by), "{name}, frame {k} after the scrolls: the view moved {by:?} device pixels"); + } + let by = pan(&app) - start; + let want = given * f64::from(PPP); + assert!(by.hypot() > 10.0, "{name}: the view moved {by:?} device pixels"); + assert!((by.x - want.x).abs() <= 1.0 && (by.y - want.y).abs() <= 1.0, "{name}: moved {by:?} device pixels for {want:?}"); + } + // The horizontal scroll bar's thumb dragged to points between device pixels: the view moves + // `ratio` points for each point the thumb moves, the other way. + let (_, span, from) = scroll_bar(&mut app, 0); + let ratio = f64::from(span.ratio(scroll_tracks(rect, screen)[0].width())); + assert!(ratio > 1.0, "at 400% the view moves further than the thumb: {ratio}"); + frame(&mut app, vec![egui::Event::PointerMoved(from)]); + let start = pan(&app); + frame(&mut app, vec![primary(from, true)]); + for (k, dx) in [7.37_f32, 12.81, 3.33].into_iter().enumerate() { + frame(&mut app, vec![egui::Event::PointerMoved(from + vec2(dx, 0.0))]); + let by = pan(&app) - start; + assert!(whole(by), "thumb drag {k}: the view moved {by:?} device pixels"); + let want = -f64::from(dx) * ratio * f64::from(PPP); + assert!((by.x - want).abs() <= 1.0 && by.y.abs() <= 1.0, "thumb drag {k}: moved {by:?} device pixels for {want}"); + } + frame(&mut app, vec![primary(from + vec2(3.33, 0.0), false)]); + // A Hand tool drag to points between device pixels. + app.select_tool("hand"); + let p0 = rect.center() + vec2(0.3, 0.1); + frame(&mut app, vec![egui::Event::PointerMoved(p0)]); + let start = pan(&app); + frame(&mut app, vec![primary(p0, true)]); + for (k, d) in [vec2(13.37, -7.61), vec2(40.19, 3.33), vec2(-5.55, 22.71)].into_iter().enumerate() { + frame(&mut app, vec![egui::Event::PointerMoved(p0 + d)]); + let by = pan(&app) - start; + assert!(whole(by), "drag {k}: the view moved {by:?} device pixels"); + let want = vectorcraft_geom::Vec2::new(f64::from(d.x), f64::from(d.y)) * f64::from(PPP); + assert!((by.x - want.x).abs() <= 1.0 && (by.y - want.y).abs() <= 1.0, "drag {k}: moved {by:?} device pixels for {want:?}"); + } + frame(&mut app, vec![primary(p0 + vec2(-5.55, 22.71), false)]); +} + +/// The part of a device pixel a scroll leaves is added to the next scroll of the same view, and +/// dropped when the view was zoomed, turned or moved in between, when the next scroll is in +/// another document, or when the device pixels a point changed. +#[test] +fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { + let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0 }; + let mut v = view; + let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default()); + assert_eq!(v.center, view.center, "less than half a pixel does not move the view"); + assert!((rest.x - 0.4).abs() < 1e-6 && (rest.y + 0.3).abs() < 1e-6); + let mut again = v; + scroll_by(&mut again, 1, 1.0, vec2(0.4, -0.3), rest); + assert_eq!(again.center, Point::new(99.0, 101.0), "the second scroll adds the first's part"); + let changed = [ + ("zoomed", View { zoom: 2.0, ..v }, 1, 1.0), + ("turned", View { rotation: 90.0, ..v }, 1, 1.0), + ("fitted or moved", View { center: Point::new(150.0, 120.0), ..v }, 1, 1.0), + ("another document", v, 2, 1.0), + ("another display scale", v, 1, 1.25), + ]; + for (name, before, doc, ppp) in changed { + let mut after = before; + let left = scroll_by(&mut after, doc, ppp, vec2(0.4 / ppp, -0.3 / ppp), rest); + assert_eq!(after.center, before.center, "{name}: the first scroll's part is dropped"); + assert!((left.x - 0.4).abs() < 1e-6 && (left.y + 0.3).abs() < 1e-6, "{name}: {left:?}"); + } +} + +/// View › Snap to Glyph toggles through the View menu command (#3124). +#[test] +fn snap_to_glyph_menu_command_toggles_the_view_flag() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(!app.ui.view.snap_to_glyph); + app.run("view.snapToGlyph", json!({})).unwrap(); + assert!(app.ui.view.snap_to_glyph); + app.run("view.snapToGlyph", json!({})).unwrap(); + assert!(!app.ui.view.snap_to_glyph); +} + +/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer +/// shows with Smart Guides on and the option on, and not otherwise. +#[test] +fn object_highlighting_preference_hides_the_hover_outline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0)); + let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)))); + // The hover outline: 1.5 px lines in the layer colour. + let outlines = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![egui::Event::PointerMoved(over)]); + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count() + }; + assert!(outlines(&mut app) > 0, "highlighted by default"); + app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": false})).unwrap(); + assert_eq!(outlines(&mut app), 0, "the option off"); + app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": true})).unwrap(); + app.ui.view.smart_guides = false; + assert_eq!(outlines(&mut app), 0, "Smart Guides off"); +} + +/// Hide Corner Widget for angles greater than (#394): a rectangle's 90° corners lose their +/// widgets below 90°. +#[test] +fn corner_widgets_hide_above_the_preference_angle() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + let widgets = |app: &mut VectorcraftApp| { + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count() + }; + assert_eq!(widgets(&mut app), 4); + app.session.execute("prefs.set", &json!({"key": "hideCornerWidgetAbove", "value": 80})).unwrap(); + assert_eq!(widgets(&mut app), 0); +} + +/// View › Hide Edges hides a live rectangle's corner widgets, and the Selection tool then +/// neither shows the corner cursor over one's place nor rounds the corners from it. With +/// edges shown, a drag from the same widget rounds them. +#[test] +fn hidden_corner_widgets_do_not_answer_the_pointer() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = vectorcraft_doc::NodeId(id); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let widget = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap() + }; + let radius = |app: &VectorcraftApp| { + let c = vectorcraft_doc::LiveCorners::of(app.session.active().unwrap().doc.node(id).unwrap()).unwrap(); + c.style(&c.all()).0 + }; + let drag = |app: &mut VectorcraftApp, from: Point| { + let (view, to) = (app.view_info(), from + vectorcraft_geom::Vec2::new(10.0, 10.0)); + for (kind, at) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap(); + } + }; + app.ui.view.edges = false; + let at = widget(&app); + let view = app.view_info(); + assert_ne!(app.session.cursor(at, Mods::default(), view), Cursor::CornerRadius); + drag(&mut app, at); + assert_eq!(radius(&app), Some(0.0), "a press on a hidden widget does not round the corners"); + app.ui.view.edges = true; + let at = widget(&app); + let view = app.view_info(); + assert_eq!(app.session.cursor(at, Mods::default(), view), Cursor::CornerRadius); + drag(&mut app, at); + assert!(radius(&app).is_some_and(|r| r > 0.0), "{:?}", radius(&app)); +} + +/// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection +/// tool then answers neither: a double-click on the widget's place leaves point type as it is, +/// and a drag from a corner handle's place does not scale. With edges shown, both answer. +#[test] +fn a_hidden_bounding_box_and_type_widget_do_not_answer_the_pointer() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap(); + let id = vectorcraft_doc::NodeId(id); + app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let bx = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + st.doc.oriented_bounds(&st.selection.objects, app.session.prefs.use_preview_bounds).unwrap() + }; + let widget = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &bx(app), app.view_info().zoom).unwrap() + }; + let area = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }), + k => panic!("not type: {k:?}"), + }; + let double_click = |app: &mut VectorcraftApp, at: Point| { + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap(); + } + }; + let width = |app: &VectorcraftApp| bx(app).rect.width(); + let scale = |app: &mut VectorcraftApp| { + let b = bx(app); + let corner = b.to_doc() * Point::new(b.rect.x1, b.rect.y1); + let (view, to) = (app.view_info(), corner + vectorcraft_geom::Vec2::new(30.0, 0.0)); + for (kind, at) in [(PointerKind::Down, corner), (PointerKind::Drag, to), (PointerKind::Up, to)] { + app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap(); + } + app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + }; + let w = widget(&app); + app.ui.view.edges = false; + let view = app.view_info(); + assert_ne!(app.session.cursor(w.at, Mods::default(), view), Cursor::TypeWidget); + double_click(&mut app, w.at); + assert!(!area(&app), "a double-click on the hidden widget leaves point type"); + app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let before = width(&app); + scale(&mut app); + assert!((width(&app) - before).abs() < 1e-6, "a drag from a hidden handle does not scale: {before} -> {}", width(&app)); + app.ui.view.edges = true; + let w = widget(&app); + let view = app.view_info(); + assert_eq!(app.session.cursor(w.at, Mods::default(), view), Cursor::TypeWidget); + scale(&mut app); + assert!(width(&app) > before + 1.0, "a drag from the handle scales: {before} -> {}", width(&app)); + let w = widget(&app); + double_click(&mut app, w.at); + assert!(area(&app), "a double-click on the widget converts to area type"); +} + +/// View → Show Live Paint Gaps draws the gaps of each selected Live Paint group in the group's +/// Gap Preview Color, and nothing for a group or path that is not a selected Live Paint group; +/// Hide Live Paint Gaps and Hide Edges hide them. +#[test] +fn show_live_paint_gaps_draws_the_selected_groups_gaps_in_their_preview_color() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Open paths round a square, each ending 2 pt short of its start: one Small Gap each. + let square = |app: &mut VectorcraftApp, x: i64| { + let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(dx, y)| json!({"x": x + dx, "y": y})); + app.session.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap() + }; + let mut groups = vec![]; + for (x, color) in [(0, "#123456"), (150, "#654321")] { + let p = square(&mut app, x); + app.session.execute("select.set", &json!({"ids": [p]})).unwrap(); + let g = app.session.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("livePaint.gapOptions", &json!({"id": g, "color": color})).unwrap(); + groups.push(g); + } + let plain = square(&mut app, 300); + let ctx = egui::Context::default(); + let gaps = |app: &mut VectorcraftApp, rgb: [u8; 3]| { + let solid = egui::epaint::ColorMode::Solid(c32(rgb)); + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == 2.0)).count() + }; + let (first, second) = ([0x12, 0x34, 0x56], [0x65, 0x43, 0x21]); + // The color the plain path's gaps would be drawn in. + let plain_color = vectorcraft_doc::GapOptions::default().color; + app.session.execute("select.set", &json!({"ids": [groups[0], plain]})).unwrap(); + assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 0), "off until the item is chosen"); + // View → Show Live Paint Gaps runs the command. + let tree = crate::menus::menu_tree(); + let (_, view) = tree.iter().find(|(t, _)| *t == "View").unwrap(); + assert!(view.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Show Live Paint Gaps", "view.livePaintGaps", _))), "{view:?}"); + crate::menus::invoke(&mut app, "view.livePaintGaps", json!({})); + assert!(app.ui.view.live_paint_gaps); + assert_eq!( + (gaps(&mut app, first), gaps(&mut app, second), gaps(&mut app, plain_color)), + (1, 0, 0), + "the selected group only; the plain path shows none" + ); + app.session.execute("select.set", &json!({"ids": [groups[1]]})).unwrap(); + assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 1)); + // The lines follow the group's Gap Preview Color and Paint Stops At size. + app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "color": "#123456"})).unwrap(); + assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (1, 0)); + app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": 1})).unwrap(); + assert_eq!(gaps(&mut app, first), 0, "a 2 pt gap is wider than 1 pt"); + app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": "small"})).unwrap(); + app.ui.view.edges = false; + assert_eq!(gaps(&mut app, first), 0, "Hide Edges hides them"); + app.ui.view.edges = true; + assert_eq!(gaps(&mut app, first), 1); + app.run("view.livePaintGaps", json!({})).unwrap(); + assert!(!app.ui.view.live_paint_gaps); + assert_eq!(gaps(&mut app, first), 0, "View → Hide Live Paint Gaps"); + // The setting is kept with the interface state; a state saved before it existed loads with it off. + app.ui.view.live_paint_gaps = true; + let mut saved = serde_json::to_value(&app.ui).unwrap(); + assert_eq!(saved["view"]["live_paint_gaps"], json!(true)); + saved["view"].as_object_mut().unwrap().remove("live_paint_gaps"); + assert!(!serde_json::from_value::(saved).unwrap().view.live_paint_gaps); +} + +/// #511: a star shows a widget in each of its ten corners with Direct Selection (none with +/// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them. +#[test] +fn a_star_shows_corner_widgets_with_direct_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.star", &json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap(); + let ctx = egui::Context::default(); + let widgets = |app: &mut VectorcraftApp| { + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count() + }; + app.select_tool("selection"); + assert_eq!(widgets(&mut app), 0); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app), 10); + app.run("view.cornerWidget", json!({})).unwrap(); + assert_eq!(widgets(&mut app), 0); +} + +/// The type widget beside selected type's bounding box: hollow (white) on point type, filled +/// in the layer colour on area type. +#[test] +fn the_type_widget_shows_which_kind_the_type_is() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let layer = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))); + let ctx = egui::Context::default(); + let widget = |app: &mut VectorcraftApp| -> Vec { + let r = vectorcraft_tools::typewidget::RADIUS_PX; + shapes(app, &ctx) + .iter() + .filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill != Color32::TRANSPARENT).then_some(c.fill) } else { None }) + .collect() + }; + assert_eq!(widget(&mut app), vec![Color32::WHITE]); + app.session.execute("type.convertToAreaType", &json!({})).unwrap(); + assert_eq!(widget(&mut app), vec![layer]); + app.ui.view.bounding_box = false; + assert!(widget(&mut app).is_empty(), "no bounding box, no widget"); +} + +/// A selected live ellipse shows its two pie widgets with the Selection tool while the +/// bounding box shows. +#[test] +fn a_selected_ellipse_shows_its_pie_widgets() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); + app.session.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap(); + let ctx = egui::Context::default(); + let widgets = |app: &mut VectorcraftApp| { + let r = vectorcraft_tools::pie::RADIUS_PX; + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == r && c.fill == Color32::WHITE)).count() + }; + app.select_tool("selection"); + assert_eq!(widgets(&mut app), 2); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app), 0); + app.select_tool("selection"); + app.run("view.boundingBox", json!({})).unwrap(); + assert_eq!(widgets(&mut app), 0, "no bounding box, no widgets"); +} + +/// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. +fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { + let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| hidden_chars_overlay(app, ui.painter(), xf)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() +} + +/// Show Hidden Characters lays out type once: a frame keeps the marks of the frame before, and +/// after an edit only the object edited is laid out again. +#[test] +fn show_hidden_characters_lays_out_only_type_that_changed() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ids: Vec<_> = (0..3) + .map(|i| app.session.execute("text.create", &json!({"x": 20, "y": 40 + 80 * i, "text": "a b\nc d"})).unwrap()["id"].as_u64().unwrap()) + .map(vectorcraft_doc::NodeId) + .collect(); + let ctx = egui::Context::default(); + let xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 }; + // `frame` draws one frame and returns each object's marks and whether that frame found the + // fonts and hyphenation exceptions of the frame before. + let frame = |app: &mut VectorcraftApp| { + let inputs = app.canvas.hidden_chars.inputs.clone(); + hidden_chars(app, &ctx, &xf); + let kept = &app.canvas.hidden_chars; + (kept.inputs == inputs, ids.iter().map(|id| kept.marks[id].1.clone()).collect::>()) + }; + // `frames` draws a frame, runs `edit` and draws the next frame. Tests running alongside add + // fonts and set hyphenation exceptions, which lays all type out again: `frames` draws both + // frames again until neither the fonts nor the exceptions changed in between. + let frames = |app: &mut VectorcraftApp, edit: &dyn Fn(&mut VectorcraftApp)| { + (0..100) + .find_map(|_| { + let (_, before) = frame(app); + edit(app); + let (same, after) = frame(app); + same.then_some((before, after)) + }) + .unwrap() + }; + let same = |a: &std::sync::Arc, b: &std::sync::Arc| std::sync::Arc::ptr_eq(a, b); + let (before, after) = frames(&mut app, &|_| {}); + assert!(before.iter().zip(&after).all(|(a, b)| same(a, b)), "a frame lays out no type"); + let (before, after) = frames(&mut app, &|app| { + app.session.execute("text.setText", &json!({"id": ids[1].0, "text": "a b c"})).unwrap(); + }); + assert!(!same(&before[1], &after[1]), "the edited object is laid out again"); + assert!(same(&before[0], &after[0]) && same(&before[2], &after[2]), "the others are not"); +} + +/// The marks Show Hidden Characters keeps follow the type: after an edit, a zoom and a font +/// that arrives, a frame draws what a new overlay draws, and the marks moved. +#[test] +fn show_hidden_characters_follows_edits_zooms_and_fonts() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // The type is set in Bold of a family that is not installed and whose name starts with a + // dot; the font menus do not list such names. Source Sans 3 Bold stands in for it until the + // family arrives below with a Regular face only. + let text = json!({"x": 20, "y": 60, "text": "one two\nthree four", "font": ".Hidden Marks", "style": "Bold"}); + let id = app.session.execute("text.create", &text).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 }; + // `drawn` draws a frame with what the overlay kept and checks it against a new overlay. + let drawn = |app: &mut VectorcraftApp, xf: &Xf| { + let kept = hidden_chars(app, &ctx, xf); + app.canvas.hidden_chars = Default::default(); + assert_eq!(hidden_chars(app, &ctx, xf), kept, "as a new overlay draws them"); + kept + }; + let start = drawn(&mut app, &xf); + assert!(start.len() > 2, "{start:?}"); + app.session.execute("text.setText", &json!({"id": id, "text": "one two three\nfour"})).unwrap(); + let edited = drawn(&mut app, &xf); + assert_ne!(edited, start, "the edit moved the marks"); + xf.zoom = 2.0; + let zoomed = drawn(&mut app, &xf); + assert_ne!(zoomed, edited, "zoomed in"); + assert!(vectorcraft_text::FontDb::global().add_font(vectorcraft_testkit::fonts::renamed(".Hidden Marks")) > 0, "the family is new"); + assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived"); +} + +/// In a rotated view Show Hidden Characters draws the marks of type whose turned outline reaches +/// into the canvas, also when the box between two of its corners on screen does not. +#[test] +fn show_hidden_characters_draws_type_partly_on_a_rotated_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "a b c d e\nf g h"})).unwrap()["id"].as_u64().unwrap(); + let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + let ctx = egui::Context::default(); + let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 2.0, center: b.center(), rot: 45f64.to_radians() }; + // The screen moves so that the canvas's left edge falls halfway between the right side of + // the box and the outline's rightmost corner, level with that corner. + let q = xf.quad(b); + let right = q.iter().copied().max_by(|a, c| a.x.total_cmp(&c.x)).unwrap(); + let shift = vec2(-(xf.rect_to_screen(b).right() + right.x) / 2.0, xf.rect.center().y - right.y); + xf.center -= xf.delta_to_doc(shift); + assert!(!xf.rect_to_screen(b).intersects(xf.rect), "the box is off the canvas"); + assert!(xf.quad(b).iter().any(|c| xf.rect.contains(*c)), "a corner is on it"); + assert!(!hidden_chars(&mut app, &ctx, &xf).is_empty(), "the marks are drawn"); +} + +/// The shapes Type → Show Hidden Characters draws in one canvas frame ([`show`]). +fn hidden_char_marks(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let blue = Color32::from_rgb(0x4f, 0x9d, 0xff); + shapes(app, ctx) + .into_iter() + .filter(|s| matches!(s, Shape::Circle(c) if c.fill == blue) || matches!(s, Shape::Text(t) if t.fallback_color == blue)) + .collect() +} + +/// Show Hidden Characters puts a dot over each space of a list paragraph and none over its +/// bullet, which stands for no character. +#[test] +fn hidden_characters_mark_no_space_at_a_bullet() { + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, " Item two", vectorcraft_doc::CharStyle::default()); + let plain = HiddenMarks::of(&t).spaces.len(); + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + assert_eq!((plain, HiddenMarks::of(&t).spaces.len()), (2, 2)); +} + +/// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before. +/// Turning it off drops the marks with their text nodes, and turning it on again draws the +/// same marks. +#[test] +fn show_hidden_characters_off_releases_the_text_nodes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let text = json!({"x": 20, "y": 60, "text": "one two\nthree four"}); + let id = vectorcraft_doc::NodeId(app.session.execute("text.create", &text).unwrap()["id"].as_u64().unwrap()); + let ctx = egui::Context::default(); + app.run("type.hiddenCharacters", json!({})).unwrap(); + let on = hidden_char_marks(&mut app, &ctx); + assert!(on.len() > 2, "{on:?}"); + // Tests running alongside add fonts and set hyphenation exceptions, which lays all type out + // again: frames are drawn until neither changed between two of them. + let kept = (0..100).any(|_| { + let (inputs, marks) = (app.canvas.hidden_chars.inputs.clone(), app.canvas.hidden_chars.marks[&id].1.clone()); + hidden_char_marks(&mut app, &ctx); + let after = &app.canvas.hidden_chars; + after.inputs == inputs && std::sync::Arc::ptr_eq(&after.marks[&id].1, &marks) + }); + assert!(kept, "a frame keeps the marks of the frame before"); + app.run("type.hiddenCharacters", json!({})).unwrap(); + assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "off"); + assert!(app.canvas.hidden_chars.marks.is_empty(), "no node is kept"); + app.run("type.hiddenCharacters", json!({})).unwrap(); + assert_eq!(hidden_char_marks(&mut app, &ctx), on, "on again: the same marks"); +} + +/// Closing the document drops the marks Show Hidden Characters kept with its text nodes, and +/// the same type in a new document shows the same marks. +#[test] +fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("type.hiddenCharacters", json!({})).unwrap(); + let open = |app: &mut VectorcraftApp| { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "one two\nthree four"})).unwrap(); + hidden_char_marks(app, &ctx) + }; + let first = open(&mut app); + assert!(first.len() > 2, "{first:?}"); + assert_eq!(app.canvas.hidden_chars.marks.len(), 1, "the marks are kept with the text object's node"); + app.session.execute("file.close", &json!({})).unwrap(); + assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "the Home screen"); + assert!(app.canvas.hidden_chars.marks.is_empty(), "no node of the closed document is kept"); + assert_eq!(open(&mut app), first, "the same type in a new document"); +} + +/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off, +/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel +/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a +/// trackpad pinch zoom about the pointer either way. +#[test] +fn zoom_with_mouse_wheel_preference() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // A canvas frame through the host's input hook (it feeds the control channel's input). + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + // One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how + // far the point under `at` moved). + let turn = |app: &mut VectorcraftApp, mods: &str| { + let before = *app.view().unwrap(); + if mods == "pinch" { + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + } else { + let mut p = json!({"x": at.x, "y": at.y, "dy": 1}); + for m in mods.split('+').filter(|m| !m.is_empty()) { + p[m] = json!(true); + } + crate::tests_synthetic::control(app, &ctx, "ui.wheel", p); + } + // egui spreads a notch over a few frames. + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) + }; + let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; + let (zoom, scroll, _) = turn(&mut app, ""); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})"); + assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer"); + assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer"); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + assert!(zooms_in(turn(&mut app, "")), "on: the wheel zooms in about the pointer"); + assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel"); + assert!(zooms_in(turn(&mut app, "pinch")), "on: and a pinch"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: Shift-wheel scrolls up ({zoom}, {scroll:?})"); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); +} + +/// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a +/// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom. +#[test] +fn wheel_notches_zoom_in_a_glide() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let at = app.canvas_rect.unwrap().center(); + let before = app.view().unwrap().zoom; + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1})); + // The first frame the zoom moves in (the event may land a frame later). + let mut first = 1.0; + for _ in 0..3 { + run(&mut app); + first = app.view().unwrap().zoom / before; + if first != 1.0 { + break; + } + } + for _ in 0..60 { + run(&mut app); + } + let done = app.view().unwrap().zoom / before; + assert!(done > 1.01, "the notch zooms in ({done})"); + assert!(first > 1.0 && first < done, "the first frame takes only part of it ({first} of {done})"); + run(&mut app); + assert_eq!(app.view().unwrap().zoom / before, done, "and it stops"); +} + +/// In each frame where a wheel scroll moves the view, the hover point (read by the rulers' +/// pointer marks, the Info panel, the hover highlight and the tool cursor) is the document point +/// under the pointer in the view the canvas draws in that frame. +#[test] +fn the_hover_point_follows_a_wheel_scroll_in_the_frame_it_moves() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + let mut events = vec![wheel]; + let mut moved = 0; + for k in 0..30 { + let before = app.view().unwrap().center; + frame(&mut app, &ctx, std::mem::take(&mut events)); + moved += usize::from(app.view().unwrap().center != before); + let hover = app.hover_doc.unwrap(); + assert!(hover.distance(under(&app)) < 1e-9, "frame {k}: {hover:?}, under the pointer {:?}", under(&app)); + } + assert!(moved > 0, "the wheel scrolls the view"); +} + +/// While a middle-button pan or a Rotate View drag moves the view, the hover point is the +/// document point under the pointer in the view the canvas draws in that frame. +#[test] +fn the_hover_point_follows_a_view_drag_in_the_frame_it_moves() { + for (tool, button) in [("rectangle", egui::PointerButton::Middle), ("rotateView", egui::PointerButton::Primary)] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool(tool); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let press = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + let a = rect.center() + vec2(100.0, 20.0); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); + frame(&mut app, &ctx, vec![press(a, true)]); + let mut p = a; + for k in 1..=3 { + p = a + vec2(-20.0, 30.0) * k as f32; + let before = *app.view().unwrap(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); + let after = *app.view().unwrap(); + assert!(after.center != before.center || after.rotation != before.rotation, "{tool}: the drag moves the view"); + let under = Xf::new(rect, &after).to_doc(p); + let hover = app.hover_doc.unwrap(); + assert!(hover.distance(under) < 1e-9, "{tool}, step {k}: {hover:?}, under the pointer {under:?}"); + } + frame(&mut app, &ctx, vec![press(p, false)]); + } +} + +/// A tool drag goes on at the document point under the pointer in the view a wheel scroll moved +/// to, in the frame the wheel scrolls. +#[test] +fn a_tool_drag_follows_a_wheel_scroll_in_the_frame_it_moves() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // With Zoom with Mouse Wheel on, Shift-wheel scrolls up and down in the frame it comes in. + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + app.ui.view.smart_guides = false; + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let (a, b) = (rect.center() - vec2(80.0, 60.0), rect.center() + vec2(40.0, 30.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); + frame(&mut app, &ctx, vec![primary(a, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); + let before = *app.view().unwrap(); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::SHIFT, + source: egui::MouseWheelSource::Unknown, + }; + frame(&mut app, &ctx, vec![wheel]); + let after = *app.view().unwrap(); + assert!(after.center != before.center, "Shift-wheel scrolls the view"); + let want = Xf::new(rect, &after).to_doc(b); + let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + let r = bounds(&app); + assert!((r.x1 - want.x).abs() < 1e-6 && ((r.y0 - want.y).abs() < 1e-6 || (r.y1 - want.y).abs() < 1e-6), "{r:?} reaches {want:?}"); + frame(&mut app, &ctx, vec![primary(b, false)]); + assert_eq!(bounds(&app), r, "the rectangle drawn"); +} + +/// Two fingers dragged together on a touch screen pan the canvas with them, under either +/// Zoom with Mouse Wheel setting, and draw nothing (#449). +#[test] +fn two_fingers_dragged_together_pan_the_canvas() { + for wheel_zooms in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let fingers = |phase, d: egui::Vec2| vec![touch(1, phase, c - vec2(40.0, 0.0) + d), touch(2, phase, c + vec2(40.0, 0.0) + d)]; + let before = *app.view().unwrap(); + // egui-winit moves the pointer with the first finger, as here. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]); + frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, Vec2::ZERO)); + for k in 1..=3 { + frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, vec2(10.0, 8.0) * k as f32)); + } + frame(&mut app, &ctx, fingers(egui::TouchPhase::End, vec2(30.0, 24.0))); + let after = *app.view().unwrap(); + let moved = (after.center - before.center) * after.zoom; + assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "no zoom: {wheel_zooms}"); + assert!((moved.x + 30.0).abs() < 0.5 && (moved.y + 24.0).abs() < 0.5, "the content follows the fingers ({wheel_zooms}): {moved:?}"); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 0, "nothing drawn"); + } +} + +/// A canvas frame at input time `t` (seconds) with `events`, for the trackpad rotation tests. +fn timed_frame(app: &mut VectorcraftApp, ctx: &egui::Context, t: f64, events: Vec) { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(t), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); +} + +/// General › Trackpad Gesture to Rotate View, on by default: a two-finger rotation on a Mac +/// trackpad (egui's `Event::Rotate`, clockwise radians) turns the view about the pointer, also +/// in the frame of a pinch, which zooms about the same point. With the preference off, the view +/// does not turn. +#[test] +fn a_trackpad_rotation_turns_the_view_about_the_pointer() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + timed_frame(&mut app, &ctx, 0.0, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at)]); + let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at); + let before = (*app.view().unwrap(), under(&app)); + timed_frame(&mut app, &ctx, 0.2, vec![egui::Event::Rotate(0.25)]); + let after = *app.view().unwrap(); + assert!((after.rotation - before.0.rotation - 0.25f64.to_degrees()).abs() < 1e-4, "turned by 14.32°: {}", after.rotation); + assert!((after.zoom - before.0.zoom).abs() < 1e-12, "no zoom"); + assert!(under(&app).distance(before.1) < 1e-6, "the point under the pointer stays: {:?}", under(&app)); + // A pinch and a rotation in one frame, in a new gesture. + let before = (*app.view().unwrap(), under(&app)); + timed_frame(&mut app, &ctx, 1.0, vec![egui::Event::Zoom(1.25), egui::Event::Rotate(0.25)]); + let after = *app.view().unwrap(); + assert!((after.zoom / before.0.zoom - 1.25).abs() < 1e-6, "the pinch zooms: {}", after.zoom / before.0.zoom); + assert!((after.rotation - before.0.rotation - 0.25f64.to_degrees()).abs() < 1e-4, "and the rotation turns: {}", after.rotation); + assert!(under(&app).distance(before.1) < 1e-6, "about the same point: {:?}", under(&app)); + app.session.execute("prefs.set", &json!({"key": "trackpadRotateView", "value": false})).unwrap(); + let before = *app.view().unwrap(); + timed_frame(&mut app, &ctx, 2.0, vec![egui::Event::Rotate(0.25)]); + timed_frame(&mut app, &ctx, 2.1, vec![egui::Event::Rotate(0.25)]); + let after = *app.view().unwrap(); + assert!(after.rotation == before.rotation && after.center == before.center, "off: the view does not turn ({})", after.rotation); +} + +/// A trackpad rotation turns the view only once it reaches 5°, so a pinch that twists a little +/// only zooms. It then turns the view by all of it and follows each later rotation event, until +/// a quarter of a second passes without one. +#[test] +fn a_trackpad_rotation_turns_nothing_until_it_reaches_five_degrees() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + timed_frame(&mut app, &ctx, 0.0, vec![]); + let at = app.canvas_rect.unwrap().center() + vec2(60.0, 40.0); + timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at)]); + let rotate = |deg: f32| egui::Event::Rotate(deg.to_radians()); + let zoom = app.view().unwrap().zoom; + timed_frame(&mut app, &ctx, 1.0, vec![egui::Event::Zoom(1.25), rotate(1.0)]); + assert!(app.view().unwrap().zoom > zoom * 1.2, "the pinch zooms"); + timed_frame(&mut app, &ctx, 1.1, vec![egui::Event::Zoom(1.25), rotate(1.0)]); + timed_frame(&mut app, &ctx, 1.2, vec![egui::Event::Zoom(1.25), rotate(1.0)]); + assert_eq!(app.view().unwrap().rotation, 0.0, "3° in all does not turn the view"); + timed_frame(&mut app, &ctx, 1.3, vec![rotate(3.0)]); + assert!((app.view().unwrap().rotation - 6.0).abs() < 1e-4, "6° in all turns by 6°: {}", app.view().unwrap().rotation); + timed_frame(&mut app, &ctx, 1.4, vec![rotate(-1.0)]); + assert!((app.view().unwrap().rotation - 5.0).abs() < 1e-4, "then each event turns it: {}", app.view().unwrap().rotation); + timed_frame(&mut app, &ctx, 2.0, vec![rotate(3.0)]); + assert!((app.view().unwrap().rotation - 5.0).abs() < 1e-4, "a new rotation starts under 5° again: {}", app.view().unwrap().rotation); +} + +/// Rotation events that come while two fingers are on a touch screen (Safari on an iPad sends +/// them with the touches) do not turn the view. +#[test] +fn a_rotation_with_fingers_on_a_touch_screen_turns_nothing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + timed_frame(&mut app, &ctx, 0.0, vec![]); + let c = app.canvas_rect.unwrap().center(); + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]); + let before = app.view().unwrap().rotation; + let start = vec![touch(1, egui::TouchPhase::Start, c - vec2(40.0, 0.0)), touch(2, egui::TouchPhase::Start, c + vec2(40.0, 0.0))]; + timed_frame(&mut app, &ctx, 0.2, [start, vec![egui::Event::Rotate(0.25)]].concat()); + assert_eq!(app.view().unwrap().rotation, before, "the view does not turn"); +} + +/// General › Display Print Size at 100% Zoom (#394): `view.actualSize` is one document point per +/// screen point with it off, and 96 screen points per document inch with it on. +#[test] +fn view_actual_size_follows_display_print_size() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(460.0, 260.0))); + app.run("view.setZoom", json!({"zoom": 250})).unwrap(); + app.run("view.actualSize", json!({})).unwrap(); + assert!((app.view().unwrap().zoom - 1.0).abs() < 1e-12, "off by default: 100% is 1:1"); + app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap(); + app.run("view.actualSize", json!({})).unwrap(); + assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12, "an inch is 96 screen points"); +} + +/// Fit Artboard in Window fits the artboard as a rotated view shows it: at 45° every corner is +/// on the canvas and one is within the 30-point margin of an edge. At 0° the zoom is unchanged. +#[test] +fn fit_artboard_fits_the_artboard_turned_with_the_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 612, "height": 792})).unwrap(); + let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0)); + app.canvas_rect = Some(rect); + app.run("view.fitArtboard", json!({})).unwrap(); + assert!((app.view().unwrap().zoom - (940.0 / 612.0_f64).min(740.0 / 792.0)).abs() < 1e-12, "0°"); + app.run("view.rotate", json!({"angle": 45})).unwrap(); + app.run("view.fitArtboard", json!({})).unwrap(); + let board = app.session.active().unwrap().doc.artboards[0].rect; + let q = Xf::new(rect, app.view().unwrap()).quad(board); + assert!(q.iter().all(|c| rect.contains(*c)), "the turned artboard is on the canvas: {q:?}"); + let gap = q + .iter() + .map(|c| (c.x - rect.left()).min(rect.right() - c.x).min(c.y - rect.top()).min(rect.bottom() - c.y)) + .fold(f32::INFINITY, f32::min); + assert!(gap < 31.0, "and fills it but for the margin: {gap}"); +} + +/// Performance › Animated Zoom (#394): the Zoom tool dragged sideways zooms about where it was +/// pressed (right in, left out), held still it zooms on, and a click still steps. Off, or with +/// GPU Performance off, a drag zooms to the area dragged across. +#[test] +fn animated_zoom_scrubs_and_holds_else_the_zoom_tool_zooms_to_an_area() { + use egui::Event; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Press at `at`, move by `by` and hold for `frames` frames (a 60th of a second each), let go. + let press = |app: &mut VectorcraftApp, by: egui::Vec2, frames: usize| { + frame(app, &ctx, vec![Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(at, true)]); + frame(app, &ctx, vec![Event::PointerMoved(at + by)]); + for _ in 0..frames { + frame(app, &ctx, vec![]); + } + frame(app, &ctx, vec![button(at + by, false)]); + }; + let doc_at = |app: &VectorcraftApp, p: Pos2| Xf::new(rect, app.view().unwrap()).to_doc(p); + let pinned = doc_at(&app, at); + let z0 = app.view().unwrap().zoom; + press(&mut app, vec2(100.0, 0.0), 0); + let z1 = app.view().unwrap().zoom; + assert!((z1 / z0 - 2.0).abs() < 1e-6, "100 points right doubles the zoom: {z0} → {z1}"); + assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press"); + press(&mut app, vec2(-100.0, 0.0), 0); + assert!((app.view().unwrap().zoom - z0).abs() < 1e-6, "and back to the left"); + press(&mut app, vec2(0.0, 0.0), 0); + assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z0, true), "a click steps"); + let z2 = app.view().unwrap().zoom; + press(&mut app, vec2(0.0, 0.0), 40); + assert!(app.view().unwrap().zoom > z2 * 1.2, "held, it zooms on"); + assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press"); + // Off (or without GPU Performance): the area dragged across fills the view. + for key in ["animatedZoom", "gpuPerformance"] { + app.run("prefs.reset", json!({})).unwrap(); + app.run("prefs.set", json!({"key": key, "value": false})).unwrap(); + app.view_mut().unwrap().zoom = z0; + press(&mut app, vec2(100.0, 50.0), 0); + let z = app.view().unwrap().zoom; + assert!((z - z0 * (rect.width() / 100.0).min(rect.height() / 50.0) as f64).abs() < 1e-6, "{key} off: {z0} → {z}"); + } +} + +/// With Animated Zoom off, the Zoom tool zooms a rotated view to the area dragged across: at 90° +/// and 180° a 100 × 50-point marquee fills the canvas as it does at 0°, centered on the canvas. +#[test] +fn the_zoom_tool_zooms_a_rotated_view_to_the_area_dragged_across() { + use egui::Event; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let to = at + vec2(100.0, 50.0); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for angle in [90.0, 180.0] { + app.run("view.rotate", json!({ "angle": angle })).unwrap(); + let z0 = app.view().unwrap().zoom; + let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(50.0, 25.0)); + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![button(to, false)]); + let v = app.view().unwrap(); + let want = z0 * f64::from((rect.width() / 100.0).min(rect.height() / 50.0)); + assert!((v.zoom - want).abs() < 1e-6, "{angle}°: {z0} → {}, not {want}", v.zoom); + assert!(v.center.distance(middle) < 1e-6, "{angle}°: the marquee's center goes to the canvas's center"); + app.view_mut().unwrap().zoom = z0; + } +} + +/// In a rotated view the Selection tool's marquee is drawn where the tool selects: through the +/// screen corners of the document rectangle between the press and the pointer. +#[test] +fn the_selection_marquee_is_drawn_where_it_selects_in_a_rotated_view() { + use egui::Event; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.run("view.rotate", json!({"angle": 45})).unwrap(); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center() - vec2(60.0, 20.0), rect.center() + vec2(60.0, 40.0)); + frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); + frame( + &mut app, + &ctx, + vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }], + ); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + let xf = Xf::new(rect, app.view().unwrap()); + let want = xf.quad(Rect::from_points(xf.to_doc(from), xf.to_doc(to))); + let drawn: Vec> = shapes(&mut app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points), + _ => None, + }) + .collect(); + let [line] = &drawn[..] else { panic!("one marquee: {drawn:?}") }; + assert_eq!(line.len(), 5, "closed: {line:?}"); + assert!(line.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "{line:?} is not {want:?}"); + assert_eq!(line[4], line[0]); +} + +/// User Interface › Large Tabs (#394): the document tabs are taller, so the canvas starts lower. +#[test] +fn large_tabs_make_the_document_tabs_taller() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas_top = |app: &mut VectorcraftApp| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + crate::chrome::doc_tabs(app, ui); + show(app, ui); + }); + out.textures_delta.clear(); + app.canvas_rect.unwrap().top() + }; + let small = canvas_top(&mut app); + app.run("prefs.set", json!({"key": "largeTabs", "value": true})).unwrap(); + assert_eq!(canvas_top(&mut app) - small, 9.0, "44 points instead of 35"); +} + +#[test] +fn cmd_space_zooms_and_space_pans_whatever_the_tool() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let at = app.canvas_rect.unwrap().center(); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + // A drag from `at` to `to` with Space and `held` down. + let drag = |app: &mut VectorcraftApp, held: Modifiers, to: Pos2| { + frame(app, &ctx, vec![Event::ModifiersChanged(held), space(true, held), Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(at, true, held)]); + frame(app, &ctx, vec![Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false, held)]); + frame(app, &ctx, vec![space(false, held), Event::ModifiersChanged(Modifiers::NONE)]); + }; + let z0 = app.view().unwrap().zoom; + drag(&mut app, Modifiers::COMMAND, at); + let z1 = app.view().unwrap().zoom; + assert_eq!(z1, crate::state::next_zoom(z0, true), "Cmd+Space click zooms in"); + drag(&mut app, Modifiers::COMMAND | Modifiers::ALT, at); + assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z1, false), "Cmd+Alt+Space click zooms out"); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); + // Space alone pans, with the Zoom tool too. + app.select_tool("zoom"); + let before = *app.view().unwrap(); + drag(&mut app, Modifiers::NONE, at + vec2(30.0, 0.0)); + let after = *app.view().unwrap(); + assert_eq!(after.zoom, before.zoom); + assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}"); +} + +#[test] +fn space_shift_drag_rotates_the_view_whatever_the_tool() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + // `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`. + let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| { + let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] }; + frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat()); + frame(app, &ctx, vec![button(from, true, held)]); + frame(app, &ctx, vec![Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false, held)]); + frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat()); + }; + // `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right. + let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin()); + let before = *app.view().unwrap(); + drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0)); + let after = *app.view().unwrap(); + assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}"); + assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center"); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); + // Shift does not round the angle: 90° and 20° more make 110°, not 105°. + drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0)); + assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view()); + // Space alone still pans, and Cmd+Shift+Space still zooms. + let v = *app.view().unwrap(); + drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0)); + let panned = *app.view().unwrap(); + assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom)); + assert_ne!(panned.center, v.center, "Space pans"); + drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c); + assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in"); + assert_eq!(app.view().unwrap().rotation, v.rotation); + // The Rotate View tool's Shift still rounds to 15°. + app.select_tool("rotateView"); + app.view_mut().unwrap().rotation = 0.0; + drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0)); + assert_eq!(app.view().unwrap().rotation, 15.0); + drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0)); + assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view()); +} + +#[test] +fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() { + use egui::{CursorIcon, Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + // `shown` returns the system cursor the canvas sets over a window without the focus. On macOS + // the window paints the glyph only with the focus, and on the other desktop builds (not + // Wayland, which the tests do not set) the window sets the system cursor next to the bitmap. + let shown = |app: &mut VectorcraftApp, events: Vec| { + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.cursor_icon + }; + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }; + assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias); + // Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand. + shown(&mut app, vec![button(c, true)]); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias); + assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab); + // Cmd+Shift+Space zooms. + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn); + assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias); + app.select_tool("rotateView"); + assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool"); +} + +#[test] +fn a_rotate_view_drag_shows_a_dial_with_the_angle() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rotateView"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + // `dial` returns, from the shapes of one frame, the center of the dial's circle, the needle's + // direction in whole degrees clockwise from straight up, and the angle text. + let dial = |app: &mut VectorcraftApp| { + let s = shapes(app, &ctx); + let circle = s.iter().find_map(|s| if let Shape::Circle(k) = s { (k.radius == DIAL_RADIUS).then_some(k.center) } else { None }); + let needle = s.iter().find_map(|s| match s { + Shape::LineSegment { points: [a, b], stroke } if stroke.width == 2.0 && *a == c => { + Some((b.x - a.x).atan2(a.y - b.y).to_degrees().round() as i32) + } + _ => None, + }); + let text = + s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None }); + (circle, needle, text) + }; + assert_eq!(dial(&mut app), (None, None, None), "no dial before a drag"); + // The Rotate View tool: a drag from 0° to 30° about the canvas center. + let (right, at_30, below) = (c + vec2(100.0, 0.0), c + vec2(86.6, 50.0), c + vec2(0.0, 100.0)); + frame(&mut app, &ctx, vec![Event::PointerMoved(right)]); + frame(&mut app, &ctx, vec![button(right, true, Modifiers::NONE)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at_30)]); + assert_eq!(dial(&mut app), (Some(c), Some(30), Some("30°".to_string()))); + frame(&mut app, &ctx, vec![button(at_30, false, Modifiers::NONE)]); + assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release"); + assert!((app.view().unwrap().rotation - 30.0).abs() < 1e-2, "{:?}", app.view()); + // A Space+Shift drag with another tool shows it too: 30° and 90° more make 120°. + app.select_tool("rectangle"); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT), Event::PointerMoved(right)]); + frame(&mut app, &ctx, vec![button(right, true, Modifiers::SHIFT)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(below)]); + assert_eq!(dial(&mut app), (Some(c), Some(120), Some("120°".to_string()))); + assert!((app.view().unwrap().rotation - 120.0).abs() < 1e-2, "{:?}", app.view()); + // Presentation Mode turned on during the drag hides the dial. + let mode = app.ui.screen_mode; + app.ui.screen_mode = 3; + assert_eq!(dial(&mut app), (None, None, None), "no dial in Presentation Mode"); + app.ui.screen_mode = mode; + frame(&mut app, &ctx, vec![button(below, false, Modifiers::SHIFT)]); + frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release"); +} + +#[test] +fn space_pressed_mid_drag_moves_what_the_tool_draws() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.smart_guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // Press at `a`, drag to `b`, then hold Space while moving on by `by` and let go of both. + let draw = |app: &mut VectorcraftApp, a: Pos2, b: Pos2, by: egui::Vec2| { + frame(app, &ctx, vec![Event::PointerMoved(a)]); + frame(app, &ctx, vec![button(a, true)]); + frame(app, &ctx, vec![Event::PointerMoved(b)]); + frame(app, &ctx, vec![space(true)]); + frame(app, &ctx, vec![Event::PointerMoved(b + by)]); + frame(app, &ctx, vec![space(false)]); + frame(app, &ctx, vec![button(b + by, false)]); + }; + let near = |p: Point, q: Point| p.distance(q) < 1e-3; + let at = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0); + let center = app.view().unwrap().center; + // The rectangle moves at its size instead of growing, and the view doesn't pan. + app.select_tool("rectangle"); + draw(&mut app, at, at + vec2(80.0, 40.0), vec2(30.0, 20.0)); + assert_eq!(app.view().unwrap().center, center, "Space during a drag doesn't pan"); + let st = app.session.active().unwrap(); + let r = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().bounds().unwrap(); + assert!( + near(Point::new(r.x0, r.y0), xf.to_doc(at + vec2(30.0, 20.0))) && near(Point::new(r.x1, r.y1), xf.to_doc(at + vec2(110.0, 60.0))), + "{r:?}" + ); + // The Pen's anchor being dragged out moves, handles and all. + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("pen"); + let a = at + vec2(0.0, 120.0); + draw(&mut app, a, a + vec2(40.0, 0.0), vec2(20.0, 10.0)); + let st = app.session.active().unwrap(); + let anchor = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0]; + assert!(near(anchor.p, xf.to_doc(a + vec2(20.0, 10.0))) && near(anchor.h_out, xf.to_doc(a + vec2(60.0, 10.0))), "{anchor:?}"); +} + +#[test] +fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let timed = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + timed(&mut app, 0.0, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + }; + // Double-click the middle of the text. + let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + let at = xf.to_screen(b.center()); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]); + timed(&mut app, 1.1, vec![]); + // The Type tool edits the text, with the caret inside it, where it was clicked: typing goes + // into the text, not to tool shortcuts (X would swap fill and stroke). + assert_eq!(app.session.tool_id(), "type"); + assert!(app.session.tool_wants_text()); + let o = app.session.tool_options(); + assert_eq!(o["editing"], json!(id)); + let caret = o["caret"].as_u64().unwrap(); + assert!((1..11).contains(&caret), "caret {caret}"); + app.session.tool_text("X", app.view_info()).unwrap(); + let t = text(&app); + assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}"); +} + +/// #2119: type copied with an Alt-drag, double-clicked, all of its text selected and typed over +/// keeps its font, size and colour: through an IME (the marked text cleared, then committed by +/// Return, as Windows sends them) and with Backspace then typing. Whole app frames, one input +/// event per frame. +fn retype_all_of_a_copied_type(ime: bool) { + use egui::{Event, Key, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let original = app + .session + .execute("text.create", &json!({"x": 60, "y": 100, "text": "Chair 123", "size": 36, "font": "Georgia", "color": "#808080"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let time = std::cell::Cell::new(0.0); + let frame = |app: &mut VectorcraftApp, events: Vec| { + time.set(time.get() + 0.03); + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time.get()), events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let node = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let styles = |app: &VectorcraftApp, id: u64| match &node(app, id).kind { + NodeKind::Text(t) => { + t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.size, r.style.fill.clone())).collect::>() + } + _ => panic!("not text"), + }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let key = |key, pressed, modifiers| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + // 1. Alt-drag the type: a copy, selected. + let from = xf.to_screen(node(&app, original).geometric_bounds().unwrap().center()); + let to = from + vec2(0.0, 120.0 * xf.zoom as f32); + frame(&mut app, vec![Event::PointerMoved(from), Event::ModifiersChanged(Modifiers::ALT)]); + frame(&mut app, vec![button(from, true, Modifiers::ALT)]); + for k in 1..=6 { + frame(&mut app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]); + } + frame(&mut app, vec![button(to, false, Modifiers::ALT)]); + frame(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]); + let copy = app.session.active().unwrap().selection.objects[0].0; + assert_ne!(copy, original); + let style = styles(&app, original)[0].clone(); + // 2. Double-click it at once: the Type tool edits it. + let at = xf.to_screen(node(&app, copy).geometric_bounds().unwrap().center()); + frame(&mut app, vec![Event::PointerMoved(at)]); + for pressed in [true, false, true, false] { + frame(&mut app, vec![button(at, pressed, Modifiers::NONE)]); + } + frame(&mut app, vec![]); + assert_eq!(app.session.tool_options()["editing"], json!(copy)); + // 3. Select all of its text and type over it. + frame(&mut app, vec![key(Key::A, true, Modifiers::COMMAND)]); + frame(&mut app, vec![key(Key::A, false, Modifiers::COMMAND)]); + if ime { + frame(&mut app, vec![preedit("t", 1)]); + frame(&mut app, vec![preedit("table", 5)]); + let marked = styles(&app, copy); + assert_eq!(marked.iter().map(|r| (&r.1, r.2, &r.3)).collect::>(), vec![(&style.1, style.2, &style.3)], "composing: {marked:?}"); + // Return commits the composition. + frame(&mut app, vec![preedit("", 0), Event::Ime(egui::ImeEvent::Commit("table".into()))]); + } else { + frame(&mut app, vec![key(Key::Backspace, true, Modifiers::NONE)]); + frame(&mut app, vec![key(Key::Backspace, false, Modifiers::NONE)]); + frame(&mut app, vec![Event::Text("table".into())]); + frame(&mut app, vec![key(Key::Enter, true, Modifiers::NONE)]); + frame(&mut app, vec![key(Key::Enter, false, Modifiers::NONE)]); + } + frame(&mut app, vec![]); + let after = styles(&app, copy); + assert!(after.iter().any(|r| r.0.starts_with("table")), "ime={ime}: typed over: {after:?}"); + assert!( + after.iter().all(|r| (&r.1, r.2, &r.3) == (&style.1, style.2, &style.3)), + "ime={ime}: the copy keeps {:?} {} {:?}, got {after:?}", + style.1, + style.2, + style.3 + ); +} + +#[test] +fn retyping_all_of_a_copied_type_through_an_ime_keeps_its_style() { + retype_all_of_a_copied_type(true); +} + +#[test] +fn retyping_all_of_a_copied_type_after_backspace_keeps_its_style() { + retype_all_of_a_copied_type(false); +} + +#[test] +fn double_clicking_type_with_the_direct_selection_tool_puts_the_caret_there() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("directSelection"); + let ctx = egui::Context::default(); + let timed = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui) + }); + out.textures_delta.clear(); + }; + timed(&mut app, 0.0, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + }; + // Double-click the middle of the text with Direct Selection. + let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + let at = xf.to_screen(b.center()); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]); + timed(&mut app, 1.1, vec![]); + // The Type tool edits the text, with the caret inside it, where it was clicked: typing goes + // into the text, not to tool shortcuts. + assert_eq!(app.session.tool_id(), "type"); + assert!(app.session.tool_wants_text()); + let o = app.session.tool_options(); + assert_eq!(o["editing"], json!(id)); + let caret = o["caret"].as_u64().unwrap(); + assert!((1..11).contains(&caret), "caret {caret}"); + app.session.tool_text("X", app.view_info()).unwrap(); + let t = text(&app); + assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}"); + + // Pressing Escape exits text editing and returns to Direct Selection tool. + let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + timed(&mut app, 1.2, vec![esc(true), esc(false)]); + assert_eq!(app.session.tool_id(), "directSelection"); + assert!(!app.session.tool_wants_text()); +} + +#[test] +fn escape_while_editing_in_isolation_returns_to_selection_tool_and_keeps_isolation_until_second_escape() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let tid = + app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Nested hello", "size": 24})).unwrap()["id"].as_u64().unwrap(); + // Group the text inside a group. + app.session.execute("select.all", &json!({})).unwrap(); + app.session.execute("object.group", &json!({})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let timed = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui) + }); + out.textures_delta.clear(); + }; + timed(&mut app, 0.0, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(tid)).unwrap().geometric_bounds().unwrap(); + let at = xf.to_screen(b.center()); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + + // 1st double-click on group enters isolation mode. + timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]); + timed(&mut app, 1.1, vec![]); + assert!(app.session.active().unwrap().isolation.is_some(), "should be in isolation mode"); + assert_eq!(app.session.tool_id(), "selection"); + + // 2nd double-click on the text inside the isolated group enters text editing. + timed(&mut app, 2.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]); + timed(&mut app, 2.1, vec![]); + assert_eq!(app.session.tool_id(), "type"); + assert!(app.session.tool_wants_text()); + assert!(app.session.active().unwrap().isolation.is_some(), "isolation mode still active while editing text"); + + // 1st Escape exits text edit mode, returns to Selection tool, but keeps isolation mode. + timed(&mut app, 3.0, vec![esc(true), esc(false)]); + timed(&mut app, 3.1, vec![]); + assert_eq!(app.session.tool_id(), "selection"); + assert!(!app.session.tool_wants_text()); + assert!(app.session.active().unwrap().isolation.is_some(), "1st Escape must NOT end isolation mode"); + + // 2nd Escape exits isolation mode. + timed(&mut app, 4.0, vec![esc(true), esc(false)]); + timed(&mut app, 4.1, vec![]); + assert_eq!(app.session.tool_id(), "selection"); + assert!(app.session.active().unwrap().isolation.is_none(), "2nd Escape must exit isolation mode"); +} + +#[test] +fn a_symbol_dropped_on_the_canvas_is_placed_there() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); + let name = app.session.execute("symbol.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // Released over empty canvas: an instance centred there. + let at = xf.to_screen(Point::new(250.0, 180.0)); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Symbol(name.clone())); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]); + let st = app.session.active().unwrap(); + let placed = st.selection.objects.first().copied().unwrap(); + let n = st.doc.node(placed).unwrap(); + assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name)); + let c = n.geometric_bounds().unwrap().center(); + assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}"); +} + +#[test] +fn a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Dropped on art too, it is placed, not applied to the art. + app.session.execute("shape.rectangle", &json!({"x": 200, "y": 150, "width": 100, "height": 60})).unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let undo = app.session.active().unwrap().history.undo.len(); + let at = xf.to_screen(Point::new(250.0, 180.0)); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::LibrarySymbol { library: "basic-shapes".into(), name: "Star".into() }); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Star"]); + assert_eq!(st.history.undo.len(), undo + 1, "added and placed as one undo step"); + assert_eq!(st.history.undo.last().unwrap().label, "Add to Symbols"); + let placed = st.selection.objects.first().copied().unwrap(); + let n = st.doc.node(placed).unwrap(); + assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Star")); + // The registration point (the centre) is where it was dropped, in document coordinates + // (the pointer's screen position, which rounds 250, 180 a little). + let size = st.doc.unknown.get(vectorcraft_doc::SYMBOL_SIZES).and_then(|m| m.get("Star")).cloned().unwrap(); + let b = n.geometric_bounds().unwrap(); + assert!((b.width() - size[0].as_f64().unwrap()).abs() < 1e-6, "{b:?} {size}"); + assert!((b.center() - xf.to_doc(at)).hypot() < 1e-6 && (b.center() - Point::new(250.0, 180.0)).hypot() < 0.1, "{b:?}"); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert!(st.doc.symbols.is_empty() && st.doc.node(placed).is_none()); +} + +#[test] +fn a_brush_dropped_on_a_path_is_applied_to_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = + app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let brush = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().and_then(|s| s.brush.clone()) + }; + let drop = |app: &mut VectorcraftApp, at: Point| { + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: "Arrow".into(), def: json!({}) }); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); + }; + // Off the path: nothing. + drop(&mut app, Point::new(200.0, 250.0)); + assert_eq!(brush(&app), None); + // On it: the path's stroke takes the brush. + drop(&mut app, Point::new(200.0, 150.0)); + assert_eq!(brush(&app).as_deref(), Some("Arrow")); +} + +#[test] +fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = + app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let drop = |app: &mut VectorcraftApp, at: Point| { + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::LibraryBrush { library: "scatter-shapes".into(), name: "Sparkles".into() }); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); + }; + let undo = app.session.active().unwrap().history.undo.len(); + // Off the path: nothing. + drop(&mut app, Point::new(200.0, 250.0)); + assert!(vectorcraft_brush::find(&app.session.active().unwrap().doc, "Sparkles").is_none()); + // On it: added, with the symbol it scatters, and applied, as one step. + drop(&mut app, Point::new(200.0, 150.0)); + let st = app.session.active().unwrap(); + assert!(vectorcraft_brush::find(&st.doc, "Sparkles").is_some() && st.doc.symbols.iter().any(|s| s.name == "Sparkle"), "{}", app.ui.status); + let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.clone()).as_deref(), Some("Sparkles")); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Add to Brushes"); +} + +#[test] +fn middle_drag_pans_the_view_with_any_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // A drawing tool: the middle button must pan, never draw. + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(400.0, 300.0))]); + let before = *app.view().unwrap(); + frame(&mut app, &ctx, vec![middle(pos2(400.0, 300.0), true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(450.0, 330.0))]); + frame(&mut app, &ctx, vec![middle(pos2(450.0, 330.0), false)]); + let after = *app.view().unwrap(); + assert_eq!(after.zoom, before.zoom); + assert!((after.center.x - (before.center.x - 50.0 / before.zoom)).abs() < 1e-6); + assert!((after.center.y - (before.center.y - 30.0 / before.zoom)).abs() < 1e-6); + // Released: moving no longer pans, and nothing was drawn. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(300.0, 200.0))]); + assert_eq!(app.view().unwrap().center, after.center); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); +} + +#[test] +fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() { + use vectorcraft_color::{Color, GradientGeom, GradientPaint, Paint}; + use widgets::{PanelDrag, SwatchRows}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let drop = |app: &mut VectorcraftApp, d: PanelDrag, at: Point| { + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, d); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); + }; + let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + let red = Paint::solid(Color::from_hex("#ed1c24").unwrap()); + let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false }; + let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows), library: None }; + // Off the art nothing happens. + drop(&mut app, swatch.clone(), Point::new(300.0, 250.0)); + assert_eq!(fill(&app), Paint::solid(Color::WHITE)); + drop(&mut app, swatch, Point::new(100.0, 100.0)); + assert_eq!(fill(&app), red, "paint.setFill with the hit id"); + assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was"); + // A proxy's paint works the same. + drop(&mut app, PanelDrag::paint(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), Point::new(60.0, 60.0)); + assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + // A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on. + let mut g = GradientPaint::new(Default::default()); + g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }); + drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0)); + assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none())); +} + +#[test] +fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() { + use vectorcraft_doc::NodeId; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 400, "height": 300})); + let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap(); + run(&mut app, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut app, "transparency.set", json!({"opacity": 40})); + let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap(); + run(&mut app, "select.set", json!({ "ids": [a] })); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + // The Appearance panel's thumbnail (its drag payload) released over the second rectangle. + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let drop = |app: &mut VectorcraftApp, at: Point| { + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Appearance(NodeId(a))); + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![up]); + }; + drop(&mut app, Point::new(250.0, 70.0)); + let doc = &app.session.active().unwrap().doc; + let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap()); + assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity)); + assert_eq!(nb.appearance.effects[0].id, "distort.twist"); + // Dropped on empty canvas or on the source itself: nothing changes. + let undo_len = app.session.active().unwrap().history.undo.len(); + drop(&mut app, Point::new(350.0, 250.0)); + drop(&mut app, Point::new(70.0, 70.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len); +} + +#[test] +fn a_graphic_style_dropped_on_art_applies_to_the_object_hit() { + use vectorcraft_doc::NodeId; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 400, "height": 300})); + let a = NodeId(run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + let b = NodeId(run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(&mut app, "select.set", json!({ "ids": [a.0] })); + let name = app.session.active().unwrap().doc.graphic_styles[2].name.clone(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(250.0, 70.0)); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::GraphicStyle(name.clone())); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![up]); + let st = app.session.active().unwrap(); + let g = st.doc.graphic_style(&name).unwrap(); + // `graphicStyle.apply` with the hit object's id: linked, the selection left alone. + assert_eq!(st.doc.node(b).unwrap().extras().graphic_style, Some(g.id)); + assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance); + assert_eq!(st.doc.node(a).unwrap().extras().graphic_style, None); + assert_eq!(st.selection.objects, [a]); +} + +#[test] +fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = vectorcraft_doc::NodeId(id); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); + // Moving on the canvas moves the art... + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); + assert!(app.session.active().unwrap().interaction.is_some()); + // ...over a panel it waits where it was, and the panels get the art. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); + assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels"); + assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved"); + assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id]))); + // Released over the panel, the move goes back and nothing is recorded. + frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(*st.doc, *before); + assert_eq!(st.history.undo.len(), undo); +} + +/// #2826: a move taken over a panel waits where it left the canvas, and goes on with the +/// pointer when it comes back — it isn't dropped or put back. +#[test] +fn art_moved_off_the_canvas_resumes_when_the_pointer_returns() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let undo = app.session.active().unwrap().history.undo.len(); + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0; + let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); + assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the move is live"); + // Over a panel the move waits where it last moved, and stays live. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); + assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels"); + assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved"); + // Back on the canvas it goes on with the pointer, 60 pt right. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(60.0, 0.0))]); + assert!(app.session.active().unwrap().interaction.is_some(), "the move is live again"); + assert!((left(&app) - moved_to(start + vec2(60.0, 0.0))).abs() < 0.01, "the move resumes with the pointer"); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(70.0, 0.0))]); + frame(&mut app, &ctx, vec![button(start + vec2(70.0, 0.0), false)]); + let st = app.session.active().unwrap(); + assert!((left(&app) - moved_to(start + vec2(70.0, 0.0))).abs() < 0.01, "released where the pointer was"); + assert_eq!(st.history.undo.len(), undo + 1, "one move, one undo step"); +} + +/// One frame of keyboard handling, canvas and the end-of-pass IME observation, as the app runs +/// them; returns the IME output. +fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { + VectorcraftApp::install_ime_reset_hooks(ctx); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + crate::ime::begin_ui(app, ui.ctx()); + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui); + crate::ime::end_of_pass(app, ui.ctx()); + crate::ime::finish_ui(app, ui.ctx()); + }); + out.textures_delta.clear(); + out.platform_output.ime +} + +fn preedit(t: &str, chars: usize) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) }) +} + +fn plain(app: &VectorcraftApp, id: u64) -> String { + match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } +} + +/// #2826: the app ends a move released over a panel before the panels draw, so a panel takes +/// the art as it was, and no move is left open. +#[test] +fn a_move_released_over_a_panel_ends_before_the_panels_draw() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); + assert!(app.session.active().unwrap().interaction.is_some()); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events: vec![button(pos2(850.0, 100.0), false)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + crate::canvas::release_over_panel(&mut app, ui.ctx()); + assert!(app.session.active().unwrap().interaction.is_none(), "the move is ended"); + assert_eq!(*app.session.active().unwrap().doc, *before, "the art is back where it was"); + assert_eq!( + egui::DragAndDrop::payload::(ui.ctx()).as_deref(), + Some(&widgets::PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])), + "the panel still gets the art" + ); + assert!(ui.ctx().data(|d| d.get_temp::(drag_id())).is_none(), "no drag is left"); + }); + out.textures_delta.clear(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); +} + +#[test] +fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + // The type placed starts empty (Fill New Type Objects With Placeholder Text off). + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // Not editing: the IME stays off (single-key tool shortcuts keep working). + assert!(typing_frame(&mut app, &ctx, vec![]).is_none()); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + assert!(app.session.tool_wants_text()); + let id = app.session.active().unwrap().selection.objects[0].0; + // Editing: the IME is allowed, its window at the caret (the text's baseline at y = 100). + let ime = typing_frame(&mut app, &ctx, vec![]).expect("IME allowed while editing"); + assert!(ime.rect.contains(pos2(ime.rect.center().x, at.y)) && (ime.rect.center().x - at.x).abs() < 2.0, "{:?} vs {at:?}", ime.rect); + assert!(!ime.should_interrupt_composition); + // gagaku → がが → 雅楽, then commit (the macOS sequence). + typing_frame(&mut app, &ctx, vec![preedit("g", 1)]); + typing_frame(&mut app, &ctx, vec![preedit("が", 1), preedit("がg", 2)]); + typing_frame(&mut app, &ctx, vec![preedit("ががく", 3)]); + assert_eq!(plain(&app, id), "ががく"); + // While composing: plain text events, the clipboard and Undo (the native menu's ⌘Z + // reaches `menus::invoke`) all wait. + typing_frame(&mut app, &ctx, vec![egui::Event::Text("x".into()), egui::Event::Paste("y".into())]); + crate::menus::invoke(&mut app, "edit.undo", json!({})); + assert!(!crate::menus::enabled(&app, "edit.undo")); + assert_eq!(plain(&app, id), "ががく"); + let ime2 = + typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })]) + .unwrap(); + assert!(ime2.rect.center().x <= ime.rect.center().x + 1.0, "the window stays at the clause being converted"); + typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅楽".into()))]); + assert_eq!(plain(&app, id), "雅楽"); + assert!(!app.session.tool_composing()); + // After the commit, text and Undo work again. + typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]); + assert_eq!(plain(&app, id), "雅楽!"); + crate::menus::invoke(&mut app, "edit.undo", json!({})); + assert_eq!(plain(&app, id), ""); +} + +/// Vietnamese Telex via the Windows IME (TSF): the preedit grows digit by digit, then an empty +/// preedit plus a single Commit lands the amount once, not duplicated. +#[test] +fn vietnamese_telex_digits_commit_once() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let id = app.session.active().unwrap().selection.objects[0].0; + typing_frame(&mut app, &ctx, vec![]); + typing_frame(&mut app, &ctx, vec![preedit("3", 1)]); + typing_frame(&mut app, &ctx, vec![preedit("31", 2)]); + typing_frame(&mut app, &ctx, vec![preedit("316", 3)]); + typing_frame(&mut app, &ctx, vec![preedit("3160", 4)]); + typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("3160".into()))]); + assert_eq!(plain(&app, id), "3160"); + assert!(!app.session.tool_composing()); +} + +/// Keyboard-hook IMEs (UniKey/OpenKey) type Vietnamese by injecting VK_PACKET characters and +/// Backspaces, which egui surfaces as interleaved Event::Text and Event::Key(Backspace). When a +/// tone key fires, the Backspaces and the re-injected characters arrive in one frame; the Type +/// tool must apply them in order to build "tiếng" (captured from a real UniKey session), not +/// insert every character first and delete afterwards. +#[test] +fn hook_ime_backspace_and_reinject_builds_vietnamese() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let id = app.session.active().unwrap().selection.objects[0].0; + typing_frame(&mut app, &ctx, vec![]); + let txt = |s: &str| egui::Event::Text(s.into()); + let bs = || egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + // When a tone key fires, the Backspaces and the re-injected VK_PACKET characters land + // together in one poll (one egui frame). + typing_frame(&mut app, &ctx, vec![txt("t")]); + typing_frame(&mut app, &ctx, vec![txt("i")]); + typing_frame(&mut app, &ctx, vec![txt("e")]); + typing_frame(&mut app, &ctx, vec![bs(), txt("ê")]); // 2nd "e" -> ê + typing_frame(&mut app, &ctx, vec![txt("n")]); + typing_frame(&mut app, &ctx, vec![txt("g")]); + typing_frame(&mut app, &ctx, vec![bs(), bs(), bs(), txt("ế"), txt("n"), txt("g")]); // "s" tone + assert_eq!(plain(&app, id), "tiếng"); +} + +/// Clear History waits while an IME composes, as Undo does: the marked text and the steps stay. +#[test] +fn clear_history_waits_while_the_ime_composes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let id = app.session.active().unwrap().selection.objects[0].0; + typing_frame(&mut app, &ctx, vec![preedit("が", 1)]); + assert!(app.session.tool_composing()); + assert!(!crate::menus::enabled(&app, "edit.clearHistory")); + crate::menus::invoke(&mut app, "edit.clearHistory", json!({})); + assert!(app.session.tool_composing(), "the composition goes on"); + assert!(!app.session.active().unwrap().history.undo.is_empty(), "the steps stay"); + typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅".into()))]); + assert_eq!(plain(&app, id), "雅"); + assert!(crate::menus::enabled(&app, "edit.clearHistory")); +} + +#[test] +fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let other = app.session.execute("text.create", &json!({"x": 300, "y": 250, "text": "Other", "size": 24})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(310.0, 245.0))); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]); + typing_frame(&mut app, &ctx, vec![click(a, false)]); + let id = app.session.active().unwrap().selection.objects[0].0; + typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]); + // macOS sends nothing for a click away; the tool keeps the marked text and the IME is + // told to drop its composition. A press on other type edits that type at once. + let ime = typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]); + assert_eq!(plain(&app, id), "しょうこ"); + assert!(!app.session.tool_composing()); + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]); + let ime = ime.expect("editing the other text"); + assert!(ime.should_interrupt_composition); + let request_id = ime.reset_request.expect("the final destination publishes its reset request"); + assert!(app.ime_marked.is_none()); + assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text"); + typing_frame(&mut app, &ctx, vec![click(b, false)]); + // Interrupted once, not every frame. + assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition); + assert!(!app.take_ime_discard()); + // A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks + // for no IME, and the host tells the system IME to drop its composition. + let blank = xf.to_screen(Point::new(150.0, 200.0)); + typing_frame(&mut app, &ctx, vec![egui::Event::ImeResetCompleted { request_id }, preedit("が", 1)]); + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]); + let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]); + assert!(plain(&app, other).contains('が'), "{}", plain(&app, other)); + assert!(!app.session.tool_composing() && !app.session.tool_wants_text()); + assert!(ime.is_none()); + assert!(app.ime_marked.is_none()); + assert!(app.take_ime_discard()); +} + +#[test] +fn ime_edge_orders_never_eat_committed_text_or_lose_the_composition() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let id = app.session.active().unwrap().selection.objects[0].0; + typing_frame(&mut app, &ctx, vec![egui::Event::Text("雅".into())]); + let backspace = + |pressed| egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + // The IME empties its marked text and the Backspace comes in the same frame. + typing_frame(&mut app, &ctx, vec![preedit("が", 1)]); + typing_frame(&mut app, &ctx, vec![preedit("", 0), backspace(true), backspace(false)]); + assert_eq!(plain(&app, id), "雅", "the committed character stays"); + // A bare line-break commit ends the composition with the marked text, no newline. + typing_frame(&mut app, &ctx, vec![preedit("がく", 2)]); + typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("\n".into()))]); + assert!(!app.session.tool_composing()); + assert_eq!(plain(&app, id), "雅がく"); + assert!(app.take_ime_discard()); + // Switching apps mid-composition: the composition goes on when the window comes back. + typing_frame(&mut app, &ctx, vec![preedit("らく", 2)]); + typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(false)]); + typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(true), preedit("らくか", 3)]); + assert!(app.session.tool_composing()); + assert!(!app.take_ime_discard()); + assert_eq!(plain(&app, id), "雅がくらくか"); +} + +/// Reset to a blank document, select the Type tool and click document point (100, 100), returning +/// the screen position used by IME fixtures. +fn type_at(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + typing_frame(app, ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(app, ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(app, ctx, vec![click(at, false)]); + at +} + +/// The outgoing descriptor carries the committed logical text and UTF-8 offsets, keeps a +/// reversed selection reversed, and labels only accepted IME mutations `InputMethod`. +#[test] +fn ime_output_carries_text_offsets_and_explicit_cause() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + let empty = typing_frame(&mut app, &ctx, vec![]).expect("the Type tool owns the IME"); + let payload = empty.surrounding_text.available().expect("an empty document is available"); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0)); + assert_eq!(empty.change_cause, egui::output::ImeChangeCause::Other); + + let typed = typing_frame(&mut app, &ctx, vec![egui::Event::Text("Héllo".into())]).unwrap(); + let payload = typed.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 6, 6)); + assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other, "ordinary typing is application-origin"); + + let shift_left = + || egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }; + // "Héllo" has five characters: four Shift+Lefts stop at the start of its two-byte é, + // leaving cursor 1 < anchor 6. + let mut reversed = None; + for _ in 0..4 { + reversed = typing_frame(&mut app, &ctx, vec![shift_left()]); + } + let reversed = reversed.unwrap(); + let payload = reversed.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 1, 6), "cursor > anchor stays reversed"); + + // One accepted IME deletion (one byte before the selection) then the commit: both receipts + // chain, and the commit still replaces the preserved selection ("éllo"). + let ime = typing_frame( + &mut app, + &ctx, + vec![ + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), + egui::Event::Ime(egui::ImeEvent::Commit("Z".into())), + ], + ) + .unwrap(); + let payload = ime.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Z", 1, 1)); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + + let ordinary = typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]).unwrap(); + assert_eq!(ordinary.change_cause, egui::output::ImeChangeCause::Other, "typing after a commit is Other"); +} + +/// A pre-edit is excluded from the outgoing text; committing the composition is the accepted +/// IME mutation that earns `InputMethod`. +#[test] +fn ime_output_excludes_the_preedit_and_marks_the_commit() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]); + let marked = typing_frame(&mut app, &ctx, vec![preedit("xy", 2)]).unwrap(); + let payload = marked.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("ab", 2, 2), "the marked text is not committed text"); + let committed = typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("xy".into()))]).unwrap(); + let payload = committed.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abxy", 4, 4)); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod); +} + +/// An application edit anywhere in the frame dominates an IME receipt in it. +#[test] +fn ime_output_marks_mixed_application_and_ime_mutations_other() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]); + let ime = typing_frame( + &mut app, + &ctx, + vec![ + egui::Event::Text("x".into()), + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), + egui::Event::Ime(egui::ImeEvent::Commit("y".into())), + ], + ) + .unwrap(); + let payload = ime.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("aby", 3, 3)); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other, "application edit before the IME dominates"); + + let pasted = typing_frame(&mut app, &ctx, vec![egui::Event::Paste("c".into())]).unwrap(); + let payload = pasted.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abyc", 4, 4)); + assert_eq!(pasted.change_cause, egui::output::ImeChangeCause::Other, "paste is application-origin"); + + // The other order: an accepted IME commit followed by an application edit in the same + // frame is still `Other`. + let ime_then_text = + typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("y".into())), egui::Event::Text("x".into())]).unwrap(); + let payload = ime_then_text.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abycyx", 6, 6)); + assert_eq!(ime_then_text.change_cause, egui::output::ImeChangeCause::Other, "application edit after the IME dominates"); +} + +/// Leaving and re-entering the same text object keeps the owner but starts a new generation. +#[test] +fn ime_owner_replacement_starts_a_new_generation() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Same", "size": 24})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let center = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().center(); + let at = xf.to_screen(center); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let first = typing_frame(&mut app, &ctx, vec![]).expect("the text is being edited"); + assert_eq!(first.owner, egui::Id::unique(("vectorcraft-type-ime", app.session.active().unwrap().uid, id))); + let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![escape(true)]); + typing_frame(&mut app, &ctx, vec![escape(false)]); + assert!(typing_frame(&mut app, &ctx, vec![]).is_none(), "Escape ends the edit context"); + app.select_tool("type"); + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let second = typing_frame(&mut app, &ctx, vec![]).expect("re-entering starts another context"); + assert_eq!(second.owner, first.owner, "the owner key is the document and text object"); + assert_ne!(second.context_generation, first.context_generation, "a replacement epoch resets once"); +} + +/// One tracker serves several native viewports: each gets its own generation for the same +/// editor, and an unchanged second pass in either keeps it. +#[test] +fn ime_context_is_scoped_per_viewport() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + let aux = egui::ViewportId::from_hash_of("vectorcraft-ime-aux"); + let frame = |app: &mut VectorcraftApp, viewport: egui::ViewportId| { + let mut raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + viewport_id: viewport, + ..Default::default() + }; + raw.viewports.entry(viewport).or_default(); + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui); + crate::ime::end_of_pass(app, ui.ctx()); + }); + out.textures_delta.clear(); + out.platform_output.ime.expect("the Type tool owns the IME in both viewports") + }; + let root = egui::ViewportId::ROOT; + let root_first = frame(&mut app, root); + let aux_first = frame(&mut app, aux); + assert_ne!(root_first.context_generation, aux_first.context_generation, "viewport state must not collide"); + let root_second = frame(&mut app, root); + let aux_second = frame(&mut app, aux); + assert_eq!(root_second.context_generation, root_first.context_generation); + assert_eq!(aux_second.context_generation, aux_first.context_generation); +} + +/// A minimized/occluded viewport has no IME owner: the descriptor disappears, and the next +/// visible pass starts a new generation instead of inheriting the old context. +#[test] +fn hidden_viewport_observes_no_ime_owner() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + let root = egui::ViewportId::ROOT; + let visible = typing_frame(&mut app, &ctx, vec![]).unwrap(); + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + raw.viewports.entry(root).or_default().minimized = Some(true); + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + show(&mut app, ui); + crate::ime::end_of_pass(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + assert!(out.platform_output.ime.is_none(), "a hidden viewport has no IME owner"); + let again = typing_frame(&mut app, &ctx, vec![]).expect("the visible editor comes back"); + assert_ne!(again.context_generation, visible.context_generation, "refocus starts a new generation"); +} + +/// A frame that is discarded and drawn again sees the mutation in both passes, and its +/// unchanged second pass keeps the first pass's `InputMethod` cause and generation. +#[test] +fn ime_observation_runs_in_every_pass_of_a_discarded_frame() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + let mut first = true; + let mut seen = vec![]; + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if first { + first = false; + let view = app.view_info(); + let target = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + app.session.tool_text("x", view).unwrap(); + crate::ime::note_ime_apply(&mut app, ui.ctx(), view, target, before); + ui.ctx().request_discard("ime observation second pass"); + } + crate::shortcuts::handle(&mut app, ui.ctx()); + show(&mut app, ui); + crate::ime::end_of_pass(&mut app, ui.ctx()); + seen.push(ui.ctx().output(|o| o.ime.clone()).expect("IME output in this pass")); + }); + out.textures_delta.clear(); + assert_eq!(seen.len(), 2, "the frame really ran two passes"); + assert_eq!(seen[0].context_generation, seen[1].context_generation, "the discarded pass is not a new context"); + assert_eq!(seen[0].change_cause, egui::output::ImeChangeCause::InputMethod); + assert_eq!(seen[1].change_cause, egui::output::ImeChangeCause::InputMethod, "an unchanged pass retains the frame cause"); + let payload = seen[1].surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("x", 1, 1)); +} + +#[test] +fn tool_drag_moved_without_pointer_delta() { + let ctx = egui::Context::default(); + let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let v = View::default(); + let xf = Xf::new(rect, &v); + let a = pos2(100.0, 100.0); + let b = pos2(150.0, 120.0); + let mut first = false; + let mut second = false; + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { + let pointer = ui.input(|i| i.pointer.clone()); + first = tool_drag_moved(ui, &xf, a, &pointer); + second = tool_drag_moved(ui, &xf, a, &pointer); + }); + out.textures_delta.clear(); + assert!(first, "the first drag sample at a position runs"); + assert!(!second, "the same position without a delta is not sent again"); + let mut moved = false; + out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| { + let pointer = ui.input(|i| i.pointer.clone()); + moved = tool_drag_moved(ui, &xf, b, &pointer); + }); + out.textures_delta.clear(); + assert!(moved, "a new document position runs even when egui reports no delta"); +} + +/// #834 review: a press held still is not a drag. Before the press has dragged, the first held +/// frame without a delta at the press's own position sends nothing, so a click stays a click; +/// the first move after it does. +#[test] +fn stationary_press_is_not_a_drag() { + let ctx = egui::Context::default(); + let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let v = View::default(); + let xf = Xf::new(rect, &v); + let a = pos2(100.0, 100.0); + let b = pos2(150.0, 120.0); + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { + ui.data_mut(|d| { + d.remove::(tool_drag_doc_id()); + d.insert_temp(tool_press_doc_id(), xf.to_doc(a)); + }); + }); + out.textures_delta.clear(); + let mut held = true; + out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + let pointer = ui.input(|i| i.pointer.clone()); + assert_eq!(pointer.delta(), egui::Vec2::ZERO); + held = tool_drag_moved(ui, &xf, a, &pointer); + }); + out.textures_delta.clear(); + assert!(!held, "a press held still at its own position is not sent as a drag"); + let mut moved = false; + out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| { + let pointer = ui.input(|i| i.pointer.clone()); + moved = tool_drag_moved(ui, &xf, b, &pointer); + }); + out.textures_delta.clear(); + assert!(moved, "the first move after the press is a drag"); +} + +/// #834: a bezier drag's preview follows every position the pointer is seen at. egui's frame +/// delta is zero in the frame the pointer comes back into the window after leaving it +/// (`PointerGone` forgets where it was), so a drag gated on it kept the preview where the +/// pointer left until the next move; a move in the release's own frame was lost the same way, +/// and the release committed the stale shape. A click's jitter in its release frame stays a +/// click. +#[test] +fn a_bezier_drag_preview_follows_the_pointer_back_into_the_window_and_to_the_release() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let near = |p: Point, q: Pos2| p.distance(xf.to_doc(q)) < 1e-3; + let first_anchor = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0] + }; + // The Pen pulls a handle out of a new anchor: the pointer leaves the window mid-drag, comes + // back further on and stops there, then moves once more in the frame it lets go. + app.select_tool("pen"); + let a = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(a)]); + frame(&mut app, &ctx, vec![button(a, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(40.0, 0.0))]); + assert!(near(first_anchor(&app).h_out, a + vec2(40.0, 0.0)), "{:?}", first_anchor(&app)); + frame(&mut app, &ctx, vec![Event::PointerGone]); + frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(80.0, 0.0))]); + assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "back in the window: {:?}", first_anchor(&app)); + frame(&mut app, &ctx, vec![]); + assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "still: {:?}", first_anchor(&app)); + let end = a + vec2(100.0, 20.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); + assert!(near(first_anchor(&app).h_out, end), "released: {:?}", first_anchor(&app)); + assert!(app.session.active().unwrap().interaction.is_none(), "committed"); + // Direct Selection moving that anchor: the same path through the engine's previews. + app.select_tool("directSelection"); + let anchor_at = xf.to_screen(first_anchor(&app).p); + frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at)]); + frame(&mut app, &ctx, vec![button(anchor_at, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(30.0, 30.0))]); + assert!(near(first_anchor(&app).p, anchor_at + vec2(30.0, 30.0)), "{:?}", first_anchor(&app)); + frame(&mut app, &ctx, vec![Event::PointerGone]); + frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(60.0, 30.0))]); + assert!(near(first_anchor(&app).p, anchor_at + vec2(60.0, 30.0)), "back in the window: {:?}", first_anchor(&app)); + let end = anchor_at + vec2(60.0, 50.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); + assert!(near(first_anchor(&app).p, end), "released: {:?}", first_anchor(&app)); + // A click whose release frame brings a little jitter is still a click: the Pen places a + // corner anchor (no handle pulled out by the jitter) and the anchor stays where pressed. + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("pen"); + let c = app.canvas_rect.unwrap().center() + vec2(120.0, 80.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(c)]); + frame(&mut app, &ctx, vec![button(c, true), Event::PointerMoved(c + vec2(0.6, 0.2)), button(c + vec2(0.6, 0.2), false)]); + frame(&mut app, &ctx, vec![]); + // (egui hands the press the frame's last pointer position, so it lands within the jitter.) + let a = first_anchor(&app); + assert!(a.p.distance(xf.to_doc(c)) < 1.0 && a.h_out == a.p && a.h_in == a.p, "corner anchor where pressed: {a:?}"); + assert!(app.session.active().unwrap().interaction.is_none(), "committed"); +} + +#[test] +fn frame_moves_keeps_touch_feed_and_mouse_samples_with_their_pressure() { + let ctx = egui::Context::default(); + let a = pos2(10.0, 20.0); + let b = pos2(20.0, 30.0); + let c = pos2(30.0, 40.0); + let events = vec![egui::Event::PointerMoved(a), egui::Event::PointerMoved(b), egui::Event::PointerMoved(c)]; + let mut mouse = vec![]; + let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| mouse = frame_moves(ui, None)); + out.textures_delta.clear(); + // A mouse keeps its moves too, without a pressure. + assert_eq!(mouse, [(a, None), (b, None)], "the last move follows the normal pointer path"); + // A macOS pen: the feed kept a pressure for each move, the last for the last. + let feed = StylusFeed::default(); + for p in [0.9, 0.3, 0.0, 0.5] { + feed.set_move(crate::stylus::PenSample { pressure: p, eraser: false }); + } + let mut samples = vec![]; + let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed))); + out.textures_delta.clear(); + assert_eq!(samples, [(a, Some(0.3)), (b, None)], "paired from the end; a lift's 0 is no pressure"); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed))); + out.textures_delta.clear(); + assert_eq!(samples, [(a, None), (b, None)], "the moves were taken: a mouse's moves again"); + let events = [pen(egui::TouchPhase::Move, a, 0.2), pen(egui::TouchPhase::Move, b, 0.8), pen(egui::TouchPhase::Move, c, 0.6)].concat(); + let mut samples = vec![]; + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None)); + out.textures_delta.clear(); + assert_eq!(samples, [(a, Some(0.2)), (b, Some(0.8))], "the last sample follows the normal pointer path"); + let events = (0..200).flat_map(|n| pen(egui::TouchPhase::Move, pos2(n as f32, 0.0), 0.5)).collect(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None)); + out.textures_delta.clear(); + assert!(samples.len() <= 64, "a stalled frame stays bounded"); +} + +#[test] +fn long_touch_drops_a_tool_press_without_committing_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width":800,"height":600})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + let tick = |app: &mut VectorcraftApp, time, events| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + tick(&mut app, 0.0, vec![]); + let pos = app.canvas_rect.unwrap().center(); + tick(&mut app, 0.1, pen(egui::TouchPhase::Start, pos, 0.5)); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Tool))); + tick(&mut app, 1.5, vec![]); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Gesture)), "long touch drops the tool drag"); + assert!(app.session.active().unwrap().interaction.is_none()); + tick(&mut app, 1.6, pen(egui::TouchPhase::End, pos, 0.5)); + assert!(app.session.active().unwrap().doc.art_bounds().is_none(), "releasing cannot commit the cancelled press"); +} + +/// #2826: a second finger drops a move waiting over a panel, so its interaction doesn't +/// outlive the drag that began it. +#[test] +fn a_second_finger_drops_a_move_waiting_over_a_panel() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.touch_gestures = true; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let start = xf.to_screen(Point::new(100.0, 100.0)); + // One finger presses on the art, moves it, and leaves the canvas for a panel. + frame(&mut app, &ctx, pen(egui::TouchPhase::Start, start, 0.5)); + frame(&mut app, &ctx, pen(egui::TouchPhase::Move, start + vec2(40.0, 0.0), 0.5)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Art))); + assert!(app.session.active().unwrap().interaction.is_some()); + assert!(egui::DragAndDrop::payload::(&ctx).is_some()); + // A second finger lands: the move is dropped, not left live without its pointer. + let second = egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(2), + phase: egui::TouchPhase::Start, + pos: pos2(400.0, 300.0), + force: Some(0.5), + }; + frame(&mut app, &ctx, vec![second]); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Gesture))); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none(), "the move is dropped"); + assert_eq!(*st.doc, *before, "the art is back where it was"); + assert!(egui::DragAndDrop::payload::(&ctx).is_none(), "no panel drag is left"); + // Lifting the first finger commits nothing. + frame(&mut app, &ctx, pen(egui::TouchPhase::End, start + vec2(40.0, 0.0), 0.5)); + assert_eq!(*app.session.active().unwrap().doc, *before); +} + +/// The events eframe's winit integration sends for a pen (Windows Ink) or a finger: a touch +/// with its pressure, and the mouse it stands in for (#491). +fn pen(phase: egui::TouchPhase, pos: Pos2, force: f32) -> Vec { + let touch = egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: Some(force) }; + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + match phase { + egui::TouchPhase::Start => vec![touch, egui::Event::PointerMoved(pos), button(true)], + egui::TouchPhase::Move => vec![touch, egui::Event::PointerMoved(pos)], + egui::TouchPhase::End | egui::TouchPhase::Cancel => vec![touch, button(false), egui::Event::PointerGone], + } +} + +/// A pen stroke across `pts` (document points), one frame per sample, with its pressures. +fn pen_stroke(app: &mut VectorcraftApp, ctx: &egui::Context, pts: &[(f64, f64, f32)]) { + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let last = pts.len() - 1; + for (i, &(x, y, force)) in pts.iter().enumerate() { + let phase = if i == 0 { egui::TouchPhase::Start } else { egui::TouchPhase::Move }; + frame(app, ctx, pen(phase, xf.to_screen(Point::new(x, y)), force)); + if i == last { + frame(app, ctx, pen(egui::TouchPhase::End, xf.to_screen(Point::new(x, y)), force)); + } + } +} + +/// #491: a pen or a finger draws as the mouse does, and its pressure reaches the tools that +/// use it; the pointer leaving with the lift doesn't move the stroke's end. +#[test] +fn pen_and_touch_input_drive_the_tools_with_their_pressure() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("pencil"); + pen_stroke(&mut app, &ctx, &[(100.0, 100.0, 0.5), (140.0, 120.0, 0.6), (180.0, 150.0, 0.7), (220.0, 200.0, 0.4)]); + let b = app.session.active().unwrap().doc.art_bounds().expect("the pen drew a path"); + assert!(b.x0 > 90.0 && b.y0 > 90.0 && b.x1 < 230.0 && b.y1 < 210.0 && b.x1 > 200.0, "the stroke stays where the pen went: {b:?}"); + + // Bloat with Use Pressure Pen: each sample carries the pen's pressure. + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap(); + app.select_tool("bloat"); + app.session.set_tool_option("usePressure", &json!(true)); + pen_stroke(&mut app, &ctx, &[(500.0, 350.0, 0.2), (500.0, 400.0, 0.5), (500.0, 420.0, 0.8)]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + let pressures: Vec = p["points"].as_array().unwrap().iter().map(|s| (s[2].as_f64().unwrap() * 100.0).round() / 100.0).collect(); + assert_eq!(pressures.first(), Some(&0.2), "the press's own pressure: {pressures:?}"); + assert!(pressures.contains(&0.5) && pressures.contains(&0.8), "{pressures:?}"); +} + +/// #764: on Linux under X11 a pen reaches egui as a mouse, and the host reads its pressure +/// itself (`Services::stylus`): the Paintbrush and Liquify's Use Pressure Pen get that +/// pressure, and once a mouse moves (`None`) a stroke presses fully. +#[test] +fn the_hosts_pen_pressure_drives_the_tools_under_mouse_events() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let feed = StylusFeed::default(); + app.services.stylus = Some(feed.clone()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + // One frame per sample: the reader has set the pressure before the frame's mouse events. + let stroke = |app: &mut VectorcraftApp, pts: &[(f64, f64, Option)]| { + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut at = Pos2::ZERO; + for (i, &(x, y, pressure)) in pts.iter().enumerate() { + feed.set(pressure); + at = xf.to_screen(Point::new(x, y)); + let mut events = vec![egui::Event::PointerMoved(at)]; + if i == 0 { + events.push(button(at, true)); + } + frame(app, &ctx, events); + } + frame(app, &ctx, vec![button(at, false)]); + }; + let pressures = |p: &serde_json::Value| -> Vec { + // A point without a third value was pressed fully. + let pressure = |s: &serde_json::Value| s.get(2).and_then(serde_json::Value::as_f64).map_or(1.0, |f| (f * 100.0).round() / 100.0); + p["points"].as_array().unwrap().iter().map(pressure).collect() + }; + + app.select_tool("paintbrush"); + stroke(&mut app, &[(100.0, 100.0, Some(0.3)), (140.0, 120.0, Some(0.6)), (180.0, 150.0, Some(0.9))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "path.freehand"); + assert_eq!(pressures(&p), [0.3, 0.6, 0.9], "{p}"); + // The lift reaches the feed before the frame of the stroke's last move: that move keeps the + // pressure the pen last touched with, not the lift's 0. + stroke(&mut app, &[(100.0, 300.0, Some(0.3)), (140.0, 320.0, Some(0.5)), (180.0, 350.0, Some(0.0))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "path.freehand"); + assert_eq!(pressures(&p), [0.3, 0.5, 0.5], "{p}"); + + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap(); + app.select_tool("bloat"); + app.session.set_tool_option("usePressure", &json!(true)); + stroke(&mut app, &[(500.0, 350.0, Some(0.2)), (500.0, 400.0, Some(0.5)), (500.0, 420.0, Some(0.8))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + let pen = pressures(&p); + assert_eq!(pen.first(), Some(&0.2), "the press's own pressure: {pen:?}"); + assert!(pen.contains(&0.5) && pen.contains(&0.8), "{pen:?}"); + + // A mouse moved last: no pen pressure. + stroke(&mut app, &[(350.0, 350.0, None), (350.0, 400.0, None), (350.0, 420.0, None)]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + assert!(pressures(&p).iter().all(|f| *f == 1.0), "{p}"); + + // A tap whose touch and lift both reach the feed before the frame that handles its press + // presses with the tap's pressure, not the lift's 0. + for p in [0.0, 0.4, 0.7, 0.0] { + feed.set(Some(p)); + } + let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(450.0, 400.0)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + assert_eq!(pressures(&p).first(), Some(&0.7), "{p}"); +} + +/// An app with an 800 × 600 document whose host reads the pen itself (`Services::stylus`), +/// after its first frame, with the pointer in the window. +fn pen_app() -> (VectorcraftApp, StylusFeed, egui::Context) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let feed = StylusFeed::default(); + app.services.stylus = Some(feed.clone()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))]); + (app, feed, ctx) +} + +/// The pen turned to its eraser end, with `pressure`. +fn eraser_end(pressure: f32) -> Option { + Some(crate::stylus::PenSample { pressure, eraser: true }) +} + +/// #764: turning the pen to its eraser end selects the Eraser, and turning it back to the tip +/// selects the tool before it, as the reference app's help describes ("Erase parts of a path +/// using a stylus pen"). Each turn acts once. +#[test] +fn the_eraser_end_selects_the_eraser_and_the_tip_brings_back_the_tool() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the tip hovers"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the eraser end hovers"); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the tip brings back the tool before"); + assert_eq!(app.ui.group_tool.get(vectorcraft_tools::catalog::group_of("paintbrush").unwrap()).map(String::as_str), Some("paintbrush")); + + // A turn acts once: a tool that becomes active while the eraser end stays in stays, and + // the tip doesn't replace a tool other than the Eraser. + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.session.select_tool("pencil", app.view_info()).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "pencil"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "pencil"); +} + +/// #764: the eraser end erases what it crosses, also when it comes in and presses in one +/// frame; the tool before it does not paint a stroke. +#[test] +fn the_eraser_end_erases() { + let (mut app, feed, ctx) = pen_app(); + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 100, "height": 100})).unwrap(); + app.select_tool("paintbrush"); + let steps = app.session.journal.len(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let [a, b, c] = [280.0, 350.0, 420.0].map(|x| xf.to_screen(Point::new(x, 250.0))); + feed.set_sample(eraser_end(0.6)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c)]); + frame(&mut app, &ctx, vec![button(c, false)]); + let cmds: Vec<&str> = app.session.journal.since(steps).iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(cmds, ["path.eraseRegion"], "one Eraser step, no Paintbrush stroke"); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 2, "the rectangle cut in two"); +} + +/// #764: a turn of the pen while a press is held takes effect after the release. +#[test] +fn a_flip_during_a_press_waits_for_the_release() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let [a, b, c] = [100.0, 150.0, 200.0].map(|x| xf.to_screen(Point::new(x, 100.0))); + feed.set(Some(0.5)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]); + feed.set_sample(eraser_end(0.5)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); + assert_eq!(app.session.tool_id(), "paintbrush"); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c), button(c, false)]); + assert_eq!(app.session.tool_id(), "paintbrush"); + assert_eq!(app.session.journal.last().unwrap().0, "path.freehand", "the stroke ends with the Paintbrush"); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the frame after the release"); +} + +/// #764: a tool chosen while the eraser end stands in (the toolbar and shortcuts call +/// `select_tool`; menus and MCP run `tool.select`) stays when the tip comes back. +#[test] +fn a_tool_chosen_while_the_eraser_end_stands_in_stays() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.select_tool("rectangle"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "rectangle"); + + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.run("tool.select", json!({"tool": "eraser"})).unwrap(); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the Eraser chosen again stays too"); +} + +/// #764: a mouse moving while the eraser end stands in does not change the tool, and an Eraser +/// chosen before the eraser end comes in stays when the tip comes back. +#[test] +fn a_mouse_and_an_explicit_eraser_change_nothing() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(None); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "a mouse"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(None); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "a mouse between the turns"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush"); + + app.select_tool("eraser"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "chosen before the eraser end came in"); +} + +/// #764: the host reads the pen over every window. A turn made while the pointer is outside +/// this window takes effect when the pointer comes in, and a turn there and back does not +/// change the tool. +#[test] +fn a_turn_over_another_window_waits_for_the_pointer() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + let inside = egui::Event::PointerMoved(Pos2::new(400.0, 300.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the pointer is in another window"); + frame(&mut app, &ctx, vec![inside.clone()]); + assert_eq!(app.session.tool_id(), "eraser", "the pointer came in"); + + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + frame(&mut app, &ctx, vec![inside.clone()]); + assert_eq!(app.session.tool_id(), "paintbrush"); + + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![inside]); + assert_eq!(app.session.tool_id(), "paintbrush", "turned there and back outside"); +} + +/// #764: the tip brings back the Perspective Grid tool as it was, with a grid hidden by Hide +/// Grid still hidden (choosing the tool afresh shows the grid). +#[test] +fn the_tip_brings_back_the_perspective_grid_tool_without_showing_the_grid() { + let (mut app, feed, ctx) = pen_app(); + let visible = + |app: &VectorcraftApp| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&app.session.active().unwrap().doc).visible; + app.select_tool("perspectiveGrid"); + assert!(visible(&app)); + app.session.execute("perspective.grid.show", &json!({"visible": false})).unwrap(); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "perspectiveGrid"); + assert!(!visible(&app), "the grid stays hidden"); +} + +/// #3328: every position a mouse reports within one frame reaches the Pencil, not only the +/// frame's last one, so a fast stroke keeps the hand's movement. +#[test] +fn a_mouse_stroke_keeps_every_move_of_a_frame() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("pencil"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); + let button = + |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let start = at(100.0, 100.0); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); + // One frame of a fast stroke: a zigzag of 20 moves, 6 pt apart. + let zigzag: Vec<_> = (1..=20).map(|i| at(100.0 + 6.0 * f64::from(i), if i % 2 == 0 { 100.0 } else { 112.0 })).collect(); + frame(&mut app, &ctx, zigzag.iter().map(|p| egui::Event::PointerMoved(*p)).collect()); + let end = *zigzag.last().unwrap(); + frame(&mut app, &ctx, vec![button(end, false)]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "path.freehand"); + let pts = p["points"].as_array().unwrap(); + let expected: Vec<_> = std::iter::once(start) + .chain(zigzag.iter().copied()) + .map(|p| { + let p = xf.to_doc(p); + json!([p.x, p.y]) + }) + .collect(); + assert_eq!(pts, expected.as_slice(), "the stroke keeps every move of the frame, in order"); +} + +/// A frame whose moves end where the last frame's did still reaches the Pencil: the stroke went +/// out and came back in between. +#[test] +fn a_mouse_stroke_keeps_a_frame_that_ends_where_it_started() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("pencil"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); + let button = + |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let (start, a) = (at(100.0, 100.0), at(160.0, 100.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); + // Out to a bump and back to `a`, all in one frame. + let bump = [at(170.0, 80.0), at(180.0, 70.0), at(170.0, 80.0), a]; + frame(&mut app, &ctx, bump.iter().map(|p| egui::Event::PointerMoved(*p)).collect()); + frame(&mut app, &ctx, vec![button(a, false)]); + let (_, p) = app.session.journal.last().unwrap().clone(); + let n = p["points"].as_array().unwrap().len(); + assert!(n >= 5, "the bump's moves are kept: {n} points"); +} + +/// Only a tool that builds a stroke from the drag's samples gets every move of a frame (#4227): +/// the Selection tool and the other tools get one drag a frame, at the pointer's position, so a +/// costly drag preview runs once. +#[test] +fn only_the_freehand_tools_get_every_move_of_a_frame() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + let events: Vec<_> = (0..20).map(|i| egui::Event::PointerMoved(pos2(100.0 + i as f32 * 5.0, 100.0))).collect(); + for (tool, every) in + [("pencil", true), ("paintbrush", true), ("blobBrush", true), ("selection", false), ("directSelection", false), ("width", false)] + { + app.select_tool(tool); + let mut moves = vec![]; + let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| moves = tool_frame_moves(ui, &app)); + out.textures_delta.clear(); + assert_eq!(moves.len(), if every { 19 } else { 0 }, "{tool}"); + } +} + +/// #491: a pen tap whose press and lift come in one frame selects what it tapped, and the +/// lift (the pointer gone with it) doesn't drag the selection to the canvas's centre. +#[test] +fn a_pen_tap_within_one_frame_clicks_where_it_tapped() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let before = app.session.active().unwrap().doc.art_bounds(); + let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(150.0, 150.0)); + let mut tap = pen(egui::TouchPhase::Start, at, 0.5); + tap.extend(pen(egui::TouchPhase::End, at, 0.5)); + frame(&mut app, &ctx, tap); + frame(&mut app, &ctx, vec![]); + let doc = app.session.active().unwrap(); + assert_eq!(doc.selection.objects, vec![vectorcraft_doc::NodeId(id)]); + assert_eq!(doc.doc.art_bounds(), before, "the tap moved nothing"); +} + +/// The Contextual Task Bar shows Recolor Artwork (a palette) when the selection has colours, +/// and it opens the Recolor Artwork dialog; art painted with None has no button. +#[test] +fn the_task_bar_recolors_coloured_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let click = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![]); + frame(app, &ctx, vec![]); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + // Right to left: the frame's margin, More Options, Lock, then Recolor. + let at = pos2(bar.right() - 8.0 - 30.0 - 6.0 - 30.0 - 6.0 - 15.0, bar.center().y); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + let st = app.session.active().unwrap(); + assert!(has_colors(&st.doc, &st.selection.objects)); + click(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::recolor::KIND)); + + app.ui.dialog = None; + app.session.execute("paint.setFill", &json!({"ids": [id], "none": true})).unwrap(); + app.session.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap(); + let st = app.session.active().unwrap(); + assert!(!has_colors(&st.doc, &st.selection.objects), "None paints have no colours"); + click(&mut app); + assert!(app.ui.dialog.is_none(), "no Recolor button without colours"); +} + +/// The Contextual Task Bar shows Image Trace first for a selected image: it traces the image +/// with the Default preset and leaves the Image Trace panel as it was. For the Image Trace +/// object it made, the first button is Expand, which keeps only the traced paths. +#[test] +fn the_task_bar_traces_an_image_then_expands_the_trace() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let click_first = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![]); + frame(app, &ctx, vec![]); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + // Left to right: the frame's margin, the handle and its spacing, then the first button. + let at = pos2(bar.left() + 8.0 + 4.0 + 6.0 + 12.0, bar.center().y); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + let first = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().unwrap() + }; + assert!(matches!(first(&app).kind, NodeKind::Image(_))); + click_first(&mut app); + assert_eq!(first(&app).name.as_deref(), Some("Image Trace"), "the image was traced"); + assert_eq!(app.ui.open_panel, None, "the Image Trace panel is left as it was"); + + click_first(&mut app); + let expanded = first(&app); + assert_ne!(expanded.name.as_deref(), Some("Image Trace"), "the trace was expanded"); + let mut images = 0; + expanded.walk(&mut |n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); + assert_eq!(images, 0, "only the traced paths are kept"); +} + +/// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with +/// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as +/// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up. +fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> { + egui_extras::install_image_loaders(ctx); + frame(app, ctx, vec![]); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let Some(bar) = task_bar_rect(ctx) else { return vec![] }; + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => { + Some((t.galley.text().to_string(), t.visual_bounding_rect())) + } + _ => None, + }) + .collect() +} + +/// With the Artboard tool, the Contextual Task Bar sits under the selected artboards and offers +/// Resize, Duplicate, Rearrange All (with two or more artboards) and Export instead of the +/// selected objects' actions. With the Selection tool it offers the objects' actions again, and +/// with nothing selected it doesn't show. +#[test] +fn the_task_bar_offers_the_artboard_tools_actions() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); + assert!(names(&mut app).is_empty() && task_bar_rect(&ctx).is_none(), "nothing selected: no bar"); + + app.select_tool("artboard"); + crate::panels::artboards::select(&mut app, 1); + // Artboard 2 in the middle of the window, at 100%. + let b = app.session.active().unwrap().doc.artboards[1].rect; + let view = app.view_mut().unwrap(); + (view.center, view.zoom) = (b.center(), 1.0); + assert_eq!(names(&mut app), ["Resize", "Duplicate", "Rearrange All", "Export"]); + // Under artboard 2. + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let bar = task_bar_rect(&ctx).unwrap(); + let center = xf.to_screen(b.center()); + assert!(bar.top() > xf.to_screen(Point::new(b.x0, b.y1)).y && bar.x_range().contains(center.x), "{bar:?} under {b:?}"); + + // A selected object with the Selection tool: its own actions, none of the artboards'. + app.select_tool("selection"); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + assert_eq!(names(&mut app), ["Edit Path", "Offset Path", "Simplify", "Duplicate"]); + + // One artboard: nothing to rearrange. + app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + app.select_tool("artboard"); + assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]); +} + +/// The Artboard tool's Contextual Task Bar buttons act on its selected artboards: Resize opens +/// Artboard Options for the active one, Rearrange All opens Rearrange All Artboards, Export opens +/// Export for Screens with the selected artboards checked, and Duplicate copies each selected +/// artboard in one undo step and selects the copies. +#[test] +fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + crate::panels::artboards::select(&mut app, 2); + app.session.set_tool_option("selected", &json!([2, 0])); + let ctx = egui::Context::default(); + let press = |app: &mut VectorcraftApp, label: &str| { + let labels = task_bar_labels(app, &ctx); + let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + app.ui.dialog.take() + }; + let d = press(&mut app, "Resize").expect("Artboard Options"); + assert_eq!((d.kind.as_str(), d.f64("index", 9.0)), ("artboardOptions", 2.0)); + let d = press(&mut app, "Rearrange All").expect("Rearrange All Artboards"); + assert_eq!(d.kind, crate::dialogs::rearrange_artboards::KIND); + let d = press(&mut app, "Export").expect("Export for Screens"); + assert_eq!((d.kind.as_str(), &d.fields["boards"]), (crate::dialogs::EXPORT_FOR_SCREENS, &json!([true, false, true]))); + + let boards = + |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| (a.name.clone(), a.rect.size())).collect::>(); + let before = boards(&app); + assert!(press(&mut app, "Duplicate").is_none()); + let after = boards(&app); + assert_eq!(after[..3], before[..]); + assert_eq!(after[3..], [("Artboard 1 copy".to_string(), before[0].1), ("Artboard 3 copy".to_string(), before[2].1)]); + assert_eq!(app.session.tool_options()["selected"], json!([4, 3]), "the copies are selected, artboard 3's copy active"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(boards(&app), before, "one undo step"); +} + +/// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect, +/// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens +/// the path there and Connect closes it again, as the Control bar's anchor buttons do. +#[test] +fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("directSelection"); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); + let ctx = egui::Context::default(); + let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); + assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]); + let press = |app: &mut VectorcraftApp, label: &str| { + let labels = task_bar_labels(app, &ctx); + let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + let subpath = |app: &VectorcraftApp| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone() + }; + let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone(); + press(&mut app, "Smooth"); + let a = subpath(&app).anchors[1]; + assert!(a.has_in() && a.has_out(), "smooth: {a:?}"); + assert_eq!(last_undo(&app), "Convert Anchor Points"); + press(&mut app, "Corner"); + let a = subpath(&app).anchors[1]; + assert!(!a.has_in() && !a.has_out(), "corner: {a:?}"); + press(&mut app, "Cut Path"); + assert_eq!(last_undo(&app), "Cut Path"); + let sp = subpath(&app); + assert!(!sp.closed && sp.anchors.len() == 5, "the rectangle opens at the anchor: {sp:?}"); + press(&mut app, "Connect"); + assert_eq!(last_undo(&app), "Join"); + let sp = subpath(&app); + assert!(sp.closed && sp.anchors.len() == 4, "the ends join again: {sp:?}"); +} + +/// The Contextual Task Bar's handle drags it: unpinned it keeps that offset as it follows the +/// selection, pinned it stays put when the selection changes, unpinned again it follows from +/// there, and Reset Bar Position puts it back under the selection. It never leaves the canvas, +/// also when the window shrinks. +#[test] +fn the_task_bar_is_dragged_by_its_handle_pinned_and_reset() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut square = + |x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone(); + let (a, b) = (square(120.0, 40.0), square(160.0, 150.0)); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let bar = || task_bar_rect(&ctx).expect("the task bar shows"); + let select = |app: &mut VectorcraftApp, id: &serde_json::Value| { + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + frame(app, &ctx, vec![]); + frame(app, &ctx, vec![]); + bar().min + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let drag = |app: &mut VectorcraftApp, by: Vec2| { + let grip = pos2(bar().left() + 11.0, bar().center().y); + frame(app, &ctx, vec![egui::Event::PointerMoved(grip)]); + frame(app, &ctx, vec![button(grip, true)]); + frame(app, &ctx, vec![egui::Event::PointerMoved(grip + by)]); + frame(app, &ctx, vec![button(grip + by, false)]); + frame(app, &ctx, vec![]); + bar().min + }; + let near = |p: Pos2, q: Pos2| assert!((p - q).length() < 1.5, "{p:?} is not at {q:?}"); + let (under_a, under_b) = (select(&mut app, &a), select(&mut app, &b)); + assert!(under_b.y > under_a.y + 100.0, "each bar sits under its selection"); + + // Dragged unpinned: it keeps its offset from the selection. + select(&mut app, &a); + let by = vec2(40.0, -30.0); + near(drag(&mut app, by), under_a + by); + near(select(&mut app, &b), under_b + by); + assert!(!app.ui.task_bar_place.pinned); + + // Pinned: it stays where it is across selection changes, and the handle still moves it. + assert_eq!(app.run("window.taskBar.pin", json!({})).unwrap(), json!(true)); + assert_eq!(crate::menus::checked(&app, "window.taskBar.pin", &json!({})), Some(true)); + near(select(&mut app, &a), under_b + by); + let spot = drag(&mut app, vec2(-20.0, 10.0)); + near(spot, under_b + by + vec2(-20.0, 10.0)); + near(select(&mut app, &b), spot); + + // Unpinned: it follows the selection from where it is, kept on the canvas. + assert_eq!(app.run("window.taskBar.pin", json!({"pinned": false})).unwrap(), json!(false)); + near(select(&mut app, &a), spot); + let canvas = app.canvas_rect.unwrap(); + let last = canvas.max - vec2(8.0, 8.0) - bar().size(); + near(select(&mut app, &b), (under_b + (spot - under_a)).min(last)); + assert!(app.run("window.taskBar.pin", json!({"pinned": "yes"})).is_err()); + + // Kept on the canvas: dragged far off, then pinned in the corner of a shrinking window. + near(drag(&mut app, vec2(-2000.0, -2000.0)), canvas.min + vec2(8.0, 8.0)); + app.run("window.taskBar.pin", json!({"pinned": true})).unwrap(); + drag(&mut app, vec2(2000.0, 2000.0)); + near(bar().max, canvas.max - vec2(8.0, 8.0)); + let small = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(640.0, 480.0))), ..Default::default() }; + for _ in 0..2 { + let mut out = ctx.run_ui(small.clone(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + } + let canvas = app.canvas_rect.unwrap(); + assert!(canvas.width() < 700.0, "the canvas shrank: {canvas:?}"); + near(bar().max, canvas.max - vec2(8.0, 8.0)); + + // Reset: back under the selection, unpinned. + frame(&mut app, &ctx, vec![]); + app.run("window.taskBar.reset", json!({})).unwrap(); + assert!(!app.ui.task_bar_place.pinned); + near(select(&mut app, &a), under_a); + near(select(&mut app, &b), under_b); +} + +/// In a view turned 180° the Contextual Task Bar shows under the selection's lowest corner on +/// screen, not over the selection. +#[test] +fn the_task_bar_stays_under_the_selection_in_a_rotated_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 170, "y": 90, "width": 60, "height": 120})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + app.select_tool("selection"); + app.run("view.rotate", json!({"angle": 180})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let lowest = xf.quad(app.selection_bounds().unwrap()).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); + assert!(bar.top() > lowest, "the bar {bar:?} starts below {lowest}"); +} + +fn ruler_guide_origin_ui(alt: bool, cancel: bool) { + use egui::{Event, Modifiers}; + for (origin, preview, release) in [ + (Point::new(17.0, 23.0), Point::new(222.0, 238.0), Point::new(232.0, 243.0)), + (Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(233.0, 242.0)), + (Point::ZERO, Point::new(225.0, 235.0), Point::new(230.0, 245.0)), + ] { + for source_vertical in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("document.setRulerOrigin", json!({"x": origin.x, "y": origin.y})).unwrap(); + app.run("shape.rectangle", json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap(); + let redo = app.session.active().unwrap().doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + app.session.active_mut().unwrap().mark_saved(); + app.ui.view.rulers = true; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + app.ui.view.snap_to_grid = false; + app.ui.view.snap_to_pixel = false; + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 1.0; + view.rotation = 0.0; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let from = + if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) }; + let to = xf.to_screen(Point::new(223.0, 237.0)); + let end = xf.to_screen(Point::new(231.0, 244.0)); + assert!(rect.contains(to) && rect.contains(end)); + let mods = if alt { Modifiers::SHIFT | Modifiers::ALT } else { Modifiers::SHIFT }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: mods }; + let before = app.session.active().unwrap().doc.clone(); + let selection = app.session.active().unwrap().selection.clone(); + let history = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) + }; + let original_history = history(&app); + assert_eq!(original_history.1, 1); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods), Event::PointerMoved(from)]); + frame(&mut app, &ctx, vec![button(from, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + let check = |app: &VectorcraftApp, switched: bool, expected: Point| { + let guides = &app.session.active().unwrap().doc.guides; + assert_eq!(guides.len(), 1); + let vertical = source_vertical != switched; + assert_eq!(guides[0].vertical, vertical); + let want = if vertical { expected.x } else { expected.y }; + assert!((guides[0].pos - want).abs() < 1e-4, "origin={origin:?} vertical={vertical}: {:?}, want {want}", guides[0]); + }; + assert_eq!(history(&app).0, original_history.0, "preview has no undo entry"); + if cancel { + // Cancellation is a baseline positive control, independent of snap correctness. + assert_eq!(app.session.active().unwrap().doc.guides.len(), 1); + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(app.session.active().unwrap().selection, selection); + assert_eq!(history(&app), original_history); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, redo); + continue; + } + check(&app, alt, preview); + if alt { + // Used only by the private #1782 compatibility regression, not standalone RED. + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + check(&app, false, preview); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods)]); + check(&app, true, preview); + } + frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); + check(&app, alt, release); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.ruler_origin_for(0), origin); + assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork"); + assert_eq!(st.selection, selection); + assert_eq!(history(&app), (original_history.0 + 1, 0, true)); + assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); + let after = st.doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(history(&app), (original_history.0, 1, false)); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, after); + } + } +} + +#[test] +fn ruler_guide_shift_origin_preview_release_and_history() { + ruler_guide_origin_ui(false, false); +} + +#[test] +fn ruler_guide_shift_origin_cancel_preserves_redo() { + ruler_guide_origin_ui(false, true); +} + +#[test] +fn ruler_guide_origin_display_fallback_accepts_only_bounded_finite_pairs() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + // The global zero point as stored (artboard rulers check their own, see `ruler_origin_for`). + app.run("view.globalRulers", json!({"global": true})).unwrap(); + let max = vectorcraft_engine::MAX_COORD; + for (origin, expected) in [ + (Point::ZERO, Point::ZERO), + (Point::new(17.0, -23.0), Point::new(17.0, -23.0)), + (Point::new(max, -max), Point::new(max, -max)), + (Point::new(f64::NAN, 23.0), Point::ZERO), + (Point::new(17.0, f64::INFINITY), Point::ZERO), + (Point::new(max + 1.0, 23.0), Point::ZERO), + (Point::new(17.0, -max - 1.0), Point::ZERO), + ] { + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).ruler_origin = origin; + assert_eq!(app.ruler_origin(), expected); + } +} + +#[test] +fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { + use egui::Event; + for (vertical, target, shown) in [(true, 222.0, 205.0), (false, 238.0, 215.0)] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("document.setRulerOrigin", json!({"x": 17, "y": 23})).unwrap(); + app.run("guide.add", json!({"vertical": vertical, "pos": 100})).unwrap(); + app.ui.view.guides = true; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + app.ui.view.snap_to_grid = false; + app.ui.view.snap_to_pixel = false; + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 1.0; + view.rotation = 0.0; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + app.session.active_mut().unwrap().mark_saved(); + let before = app.session.active().unwrap().doc.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let from = xf.to_screen(if vertical { Point::new(100.0, 50.0) } else { Point::new(50.0, 100.0) }); + let to = xf.to_screen(if vertical { Point::new(target, 50.0) } else { Point::new(50.0, target) }); + assert!(rect.contains(from) && rect.contains(to)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); + frame(&mut app, &ctx, vec![button(from, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4); + assert_eq!(app.session.active().unwrap().selection.guides, vec![0]); + // Observe the real selected-guide field; no expected strings are inserted into UI state. + let fields = egui::Context::default(); + crate::theme::install_fonts(&fields); + let mut texts = vec![]; + for _ in 0..3 { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() }; + let mut out = fields.run_ui(input, |ui| crate::panels::transform::show(&mut app, ui)); + out.textures_delta.clear(); + fn collect(shape: &egui::Shape, texts: &mut Vec) { + match shape { + egui::Shape::Text(t) => texts.push(t.galley.text().to_string()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, texts)), + _ => {} + } + } + texts.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut texts)); + } + assert!(texts.iter().any(|t| t == &Unit::Points.format(shown)), "{texts:?}"); + let view = app.view_info(); + let overlays = app.session.overlays(view); + let want = format!("{}: {shown:.2} pt", if vertical { "X" } else { "Y" }); + assert!( + overlays.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Measure { text, .. } if text == &want)), + "{overlays:?}, field={shown}" + ); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + frame(&mut app, &ctx, vec![button(to, false)]); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before); + } +} + +/// Distance Guides on a canvas at 100%: a 400 × 300 pt document with a 50 pt square at (50, 50), +/// selected, and with `other` another 100 pt to its right, the Selection tool and Smart Guides +/// on → the app, its context, the view, the screen point over the middle of where the other +/// square is (or would be) and the selected square's id. +fn distance_scene(other: bool) -> (VectorcraftApp, egui::Context, Xf, Pos2, serde_json::Value) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 50, "height": 50})).unwrap()["id"].clone(); + if other { + app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap(); + } + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.ui.view.smart_guides = true; + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 1.0; + view.rotation = 0.0; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let over = xf.to_screen(Point::new(225.0, 75.0)); + (app, ctx, xf, over, a) +} + +/// One frame with `events` → the shapes drawn, flattened. The modifiers held stay from one +/// frame to the next until an `Event::ModifiersChanged`. +fn distance_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { + distance_frame_covered(app, ctx, events, None) +} + +/// What opens over the canvas in [`distance_frame_covered`]. +#[derive(Clone, Copy)] +enum Cover { + /// A floating window (a floating panel, a menu, a popup) over this rectangle. + Panel(egui::Rect), + /// A modal dialog, with the backdrop that keeps the rest of the app from the pointer. + Dialog, +} + +/// [`distance_frame`] with `cover` drawn over the canvas. +fn distance_frame_covered(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, cover: Option) -> Vec { + let raw = egui::RawInput { screen_rect: Some(WINDOW), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + show(app, ui); + match cover { + Some(Cover::Panel(r)) => { + egui::Area::new(egui::Id::unique("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| { + ui.allocate_space(r.size()); + }); + } + Some(Cover::Dialog) => { + crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::unique("test-dialog"), 0.0, 8, |ui| ui.label("A dialog")); + } + None => {} + } + }); + out.textures_delta.clear(); + fn flat(s: Shape, out: &mut Vec) { + match s { + Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), + s => out.push(s), + } + } + let mut v = vec![]; + out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v)); + v +} + +/// The texts drawn in `s`. +fn texts(s: &[Shape]) -> Vec { + s.iter().filter_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()) } else { None }).collect() +} + +/// The label "100.00 pt" drawn in `s` and the pill of the smart guide colour it sits in, if any. +fn gap_label(s: &[Shape]) -> Option<(egui::Rect, Option)> { + let magenta = c32(vectorcraft_tools::guides::MAGENTA); + let text = + s.iter().find_map(|s| if let Shape::Text(t) = s { (t.galley.text() == "100.00 pt").then(|| t.visual_bounding_rect()) } else { None })?; + let pill = s + .iter() + .find_map(|s| if let Shape::Rect(r) = s { (r.fill == magenta && r.rect.contains(text.center())).then_some(r.rect) } else { None }); + Some((text, pill)) +} + +/// Distance Guides on screen: with a square selected and Alt held over another to its right, +/// the frame draws the measured line between their facing edges with the gap's length in a +/// pill of the smart guide colour on its middle; without Alt, or with Alt let go, nothing. +#[test] +fn alt_over_another_object_draws_the_distance_guide_label() { + use egui::{Event, Modifiers}; + let (mut app, ctx, xf, over, _) = distance_scene(true); + let mut run = |events| distance_frame(&mut app, &ctx, events); + run(vec![Event::PointerMoved(over)]); + assert_eq!(gap_label(&run(vec![])), None, "without Alt"); + run(vec![Event::ModifiersChanged(Modifiers::ALT)]); + let s = run(vec![]); + let (text, pill) = gap_label(&s).expect("the gap's length is drawn"); + let pill = pill.expect("in a pill of the smart guide colour"); + let mid = xf.to_screen(Point::new(150.0, 75.0)); + assert!(pill.center().distance(mid) < 1.0 && pill.contains_rect(text), "{pill:?} on {mid:?}"); + let (from, to) = (xf.to_screen(Point::new(100.0, 75.0)), xf.to_screen(Point::new(200.0, 75.0))); + let magenta = c32(vectorcraft_tools::guides::MAGENTA); + let measured = s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if *points == [from, to] && stroke.color == magenta)); + assert!(measured, "the measured line between the facing edges"); + run(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(gap_label(&run(vec![])), None, "Alt let go"); +} + +/// The Distance Guides go when the pointer stops hovering for the tool with Alt still held: it +/// leaves the canvas for a panel or out of the window, or a press pans (Alt+Space) and takes it +/// over the panels; they come back when it hovers the other square again. +#[test] +fn distance_guides_go_when_the_hover_ends() { + use egui::{Event, Modifiers}; + let (mut app, ctx, xf, over, _) = distance_scene(true); + let outside = pos2(xf.rect.max.x + 10.0, xf.rect.center().y); + assert!(WINDOW.contains(outside) && !xf.rect.contains(outside), "a panel beside the canvas"); + let mut run = |events| distance_frame(&mut app, &ctx, events); + run(vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]); + assert!(gap_label(&run(vec![])).is_some(), "shown over the other square"); + run(vec![Event::PointerMoved(outside)]); + assert_eq!(gap_label(&run(vec![])), None, "gone over a panel, Alt still held"); + run(vec![Event::PointerMoved(over)]); + assert!(gap_label(&run(vec![])).is_some(), "back over the other square"); + run(vec![Event::PointerGone]); + assert_eq!(gap_label(&run(vec![])), None, "gone out of the window"); + // Back over the square, Space held with Alt pans: they go at the press, and stay gone while + // the pan takes the pointer out over a panel. + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::ALT }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT }; + run(vec![Event::PointerMoved(over)]); + run(vec![space(true)]); + assert!(gap_label(&run(vec![])).is_some(), "Space held: still shown"); + run(vec![button(over, true)]); + assert_eq!(gap_label(&run(vec![])), None, "gone at the pan's press"); + run(vec![Event::PointerMoved(over + vec2(20.0, 0.0))]); + run(vec![Event::PointerMoved(outside)]); + assert_eq!(gap_label(&run(vec![])), None, "still gone after panning out over a panel"); + run(vec![button(outside, false), space(false)]); +} + +/// With Alt held and the pointer still, the Distance Guides follow what lies under it: they go +/// under a floating panel or a modal dialog and come back when it closes; scrolled so that +/// empty artboard lies under the pointer, they measure to the artboard; when an edit puts an +/// object under the pointer, they measure to it; and the Selection tool switched to shows them +/// at once. +#[test] +fn distance_guides_follow_what_lies_under_a_still_pointer() { + use egui::{Event, Modifiers}; + let (mut app, ctx, _, over, _) = distance_scene(true); + let run = |app: &mut VectorcraftApp, events, cover| distance_frame_covered(app, &ctx, events, cover); + run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)], None); + assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "shown over the other square"); + // A floating panel, then a modal dialog, opens over the still pointer. + for cover in [Cover::Panel(egui::Rect::from_center_size(over, vec2(120.0, 120.0))), Cover::Dialog] { + run(&mut app, vec![], Some(cover)); + run(&mut app, vec![], Some(cover)); + assert_eq!(gap_label(&run(&mut app, vec![], Some(cover))), None, "gone under it"); + // A frame for egui to drop the closed window, one for the hover to come back. + run(&mut app, vec![], None); + run(&mut app, vec![], None); + assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "back once it closes"); + } + // Another tool and back to the Selection tool (its shortcut, the toolbar): a new Selection + // tool, hovered at once. + app.select_tool("rectangle"); + run(&mut app, vec![], None); + assert_eq!(gap_label(&run(&mut app, vec![], None)), None, "the Rectangle tool shows none"); + app.select_tool("selection"); + run(&mut app, vec![], None); + assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "the Selection tool again"); + // The view scrolls 150 pt (the wheel, a gesture, the keys): empty artboard is under the + // pointer, and the selected square is 300 pt from the artboard's right edge. + app.view_mut().unwrap().center.x += 150.0; + run(&mut app, vec![], None); + let s = run(&mut app, vec![], None); + assert_eq!(gap_label(&s), None, "no longer over the other square"); + assert!(texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s)); + + // A square made under the still pointer, the first selected again (an edit, an undo). + let (mut app, ctx, _, over, a) = distance_scene(false); + let run = |app: &mut VectorcraftApp, events| distance_frame(app, &ctx, events); + run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]); + let s = run(&mut app, vec![]); + assert!(gap_label(&s).is_none() && texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s)); + app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + run(&mut app, vec![]); + assert!(gap_label(&run(&mut app, vec![])).is_some(), "to the new square"); + // Another document (a new tab) under the still pointer: nothing selected there, none. + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + run(&mut app, vec![]); + assert_eq!(gap_label(&run(&mut app, vec![])), None, "another document"); +} + +/// The 800 × 600 window [`frame`] draws the canvas in. +const WINDOW: egui::Rect = egui::Rect { min: Pos2::ZERO, max: Pos2 { x: 800.0, y: 600.0 } }; + +/// A 400 × 300 pt document at 400%, turned `rotation` degrees, with the Rectangle tool: a press +/// that reached the tool would draw. +fn zoomed_in(rotation: f64) -> (VectorcraftApp, egui::Context) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let v = app.view_mut().unwrap(); + v.zoom = 4.0; + v.rotation = rotation; + frame(&mut app, &ctx, vec![]); + (app, ctx) +} + +/// Where the document's point `p` is on screen. +fn on_screen(app: &VectorcraftApp, p: Point) -> Pos2 { + Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(p) +} + +/// Scroll bar `k` (0 horizontal, 1 vertical): its track, its span and the thumb's centre. +fn scroll_bar(app: &mut VectorcraftApp, k: usize) -> (egui::Rect, Span, Pos2) { + let rect = app.canvas_rect.unwrap(); + let track = scroll_tracks(rect, WINDOW)[k]; + let span = scroll_spans(&Xf::new(rect, app.view().unwrap()), app.content_bounds())[k]; + let len = if k == 0 { track.width() } else { track.height() }; + let (at, size) = span.thumb(len); + let c = if k == 0 { pos2(track.left() + at + size / 2.0, track.center().y) } else { pos2(track.center().x, track.top() + at + size / 2.0) }; + (track, span, c) +} + +fn primary(pos: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } +} + +/// #2322: the Normal and Full Screen with Menu Bar modes keep the scroll bars along the right +/// and bottom edges, beside the rulers; Full Screen and Presentation Mode give the canvas the +/// whole window. +#[test] +fn scroll_bars_take_the_right_and_bottom_edges_in_the_windowed_screen_modes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.rulers = true; + let ctx = egui::Context::default(); + for (mode, max) in + [(0, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (1, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (2, WINDOW.max), (3, WINDOW.max)] + { + app.ui.screen_mode = mode; + frame(&mut app, &ctx, vec![]); + assert_eq!(app.canvas_rect.unwrap().max, max, "screen mode {mode}"); + } +} + +/// #2322: the thumb is as long, against its track, as the view against the art and the +/// artboards with half a window around them; zoomed in it is shorter. +#[test] +fn a_scroll_bar_thumb_is_as_long_as_the_view_is_against_the_content() { + assert_eq!(Span { lo: 0.0, hi: 100.0, v0: 0.0, v1: 100.0 }.thumb(50.0), (0.0, 50.0), "all in view: the whole track"); + assert_eq!(Span { lo: 0.0, hi: 400.0, v0: 150.0, v1: 250.0 }.thumb(100.0), (37.5, 25.0)); + assert_eq!(Span { lo: 0.0, hi: 1000.0, v0: 450.0, v1: 550.0 }.thumb(100.0), (40.0, 20.0), "never shorter than MIN_THUMB"); + let (mut app, ctx) = zoomed_in(0.0); + let len = |app: &mut VectorcraftApp| { + let (track, span, _) = scroll_bar(app, 0); + span.thumb(track.width()).1 + }; + let at_400 = len(&mut app); + app.view_mut().unwrap().zoom = 8.0; + frame(&mut app, &ctx, vec![]); + assert!(len(&mut app) < at_400, "zoomed in, the thumb is shorter"); +} + +/// #2322: dragging a thumb scrolls the view along the screen's axis, in a turned view too, by +/// as much as the thumb's travel stands for; the drag never reaches the tool under the bar. +#[test] +fn dragging_a_scroll_bar_thumb_scrolls_the_view_and_draws_nothing() { + for rotation in [0.0, 90.0, 30.0] { + for k in 0..2 { + let (mut app, ctx) = zoomed_in(rotation); + let p = Point::new(200.0, 150.0); + let before = on_screen(&app, p); + let (track, span, from) = scroll_bar(&mut app, k); + let len = if k == 0 { track.width() } else { track.height() }; + let step = if k == 0 { vec2(40.0, 0.0) } else { vec2(0.0, 40.0) }; + let moved = (40.0 * span.ratio(len)).clamp(span.lo - span.v0, span.hi - span.v1); + assert!(moved > 40.0, "at 400% the view moves further than the thumb: {moved}"); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, vec![primary(from, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step / 2.0)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step)]); + frame(&mut app, &ctx, vec![primary(from + step, false)]); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool under the bar drew nothing"); + let after = on_screen(&app, p); + let want = before - step.normalized() * moved; + assert!((after - want).length() < 0.5, "rotation {rotation}, bar {k}: {before:?} -> {after:?}, want {want:?}"); + } + } +} + +/// #2322: a click on the track beyond the thumb scrolls a page (most of the window) towards it. +#[test] +fn a_click_on_a_scroll_bar_track_scrolls_a_page() { + let (mut app, ctx) = zoomed_in(0.0); + let p = Point::new(200.0, 150.0); + let before = on_screen(&app, p); + let (track, _, _) = scroll_bar(&mut app, 0); + let page = 0.9 * app.canvas_rect.unwrap().width(); + let at = pos2(track.right() - 3.0, track.center().y); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![primary(at, true)]); + frame(&mut app, &ctx, vec![primary(at, false)]); + frame(&mut app, &ctx, vec![]); + let after = on_screen(&app, p); + assert!((after.x - (before.x - page)).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "{before:?} -> {after:?}, page {page}"); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); +} + +/// #2322: the wheel over the horizontal bar scrolls sideways, over the vertical one up and +/// down (over the canvas it scrolls up and down). +#[test] +fn the_wheel_over_a_scroll_bar_scrolls_along_it() { + for k in 0..2 { + let (mut app, ctx) = zoomed_in(0.0); + let p = Point::new(200.0, 150.0); + let before = on_screen(&app, p); + let (track, _, _) = scroll_bar(&mut app, k); + let at = track.center(); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -30.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![wheel]); + for _ in 0..30 { + frame(&mut app, &ctx, vec![]); + } + let after = on_screen(&app, p); + let want = if k == 0 { before - vec2(30.0, 0.0) } else { before - vec2(0.0, 30.0) }; + assert!((after - want).length() < 0.5, "bar {k}: {before:?} -> {after:?}, want {want:?}"); + } +} +#[test] +fn live_trace_selection_highlight_excludes_the_retained_source_rectangle() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, ImageObject, NodeId, TraceView}; + let mut source = Node::new( + NodeId(2), + NodeKind::Image(Box::new(ImageObject { + key: "synthetic".into(), + width: 200, + height: 200, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + })), + ); + source.visible = false; + let art = + Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)), Appearance::default()); + let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art)]); + let doc = vectorcraft_doc::Document::new(300.0, 300.0); + let symbols = doc.symbol_boxes(); + for view in TraceView::ALL { + group.extras_mut().trace = Some(Box::new(json!({"view":view.id()}))); + let mut visited = vec![]; + walk_drawn(&group, &mut |n| visited.push(n.id)); + assert_eq!(visited.contains(&NodeId(2)), view.shows_source()); + let want = if view.shows_source() { + vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0) + } else { + vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0) + }; + assert_eq!(node_outline(&group, &symbols).bounding_box(), want); + } + group.extras_mut().trace = None; + assert_eq!(node_outline(&group, &symbols).bounding_box(), vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0)); +} + +/// Hovering over point type shows only its baseline (not the transform bounding box), while +/// selecting it shows both the bounding box with handles and the baseline. +#[test] +fn point_type_hover_shows_baseline_only_and_selection_shows_both_box_and_baseline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let made = app.run("text.create", json!({"x": 200, "y": 150, "text": "Lorem ipsum", "size": 36})).unwrap(); + let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap()); + app.run("select.none", json!({})).unwrap(); + app.select_tool("selection"); + + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let color = c32(app.session.active().unwrap().doc.layer_color(id)); + let layer_mode = egui::epaint::ColorMode::Solid(color); + + // Hover over the text: hover highlight shows the baseline (1.5 px stroke), NOT a bounding box. + let over = xf.to_screen(Point::new(220.0, 140.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); + let hover_shapes = shapes(&mut app, &ctx); + + // There should be a 1.5px path outline for the hover highlight. + let hover_outlines: Vec<_> = + hover_shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer_mode)).collect(); + assert!(!hover_outlines.is_empty(), "hover highlight shows baseline"); + + let is_handle = |s: &Shape| matches!(s, Shape::Rect(r) if (r.rect.width() - 6.0).abs() < 1.0 && (r.rect.height() - 6.0).abs() < 1.0 && r.fill == Color32::WHITE); + + // There should be NO bounding box resize handles (size 6x6 white squares) while just hovering. + let handles: Vec<_> = hover_shapes.iter().filter(|s| is_handle(s)).collect(); + assert!(handles.is_empty(), "hovering does not show bounding box handles: {handles:?}"); + + // Now select the text (click): both the bounding box (with handles) and the baseline are shown. + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + let sel_shapes = shapes(&mut app, &ctx); + + // Bounding box handles (8 handles around the text) should now be present. + let handles: Vec<_> = sel_shapes.iter().filter(|s| is_handle(s)).collect(); + assert_eq!(handles.len(), 8, "selected text shows 8 bounding box handles"); + + // Baseline (Shape::LineSegment at y=150) should also be present. + let y = xf.to_screen(Point::new(0.0, 150.0)).y; + let baselines: Vec<_> = sel_shapes + .iter() + .filter(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == color && (points[0].y - y).abs() < 0.5)) + .collect(); + assert!(!baselines.is_empty(), "selected text shows baseline alongside bounding box"); +} + +/// #2572: Show Text Threads links a thread's frames from the out port of one to the in port of +/// the next, where the tools draw those ports, each with the arrow of a threaded port; nothing +/// is drawn while no frame of the thread is selected. +#[test] +fn text_threads_run_from_each_out_port_to_the_next_in_port() { + use vectorcraft_tools::text::{Port, port_centre}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let text = "Words that fill more than the frame holds. ".repeat(10); + let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone(); + let b = app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap()["id"].clone(); + let frame = |app: &VectorcraftApp, id: &serde_json::Value| match &app + .session + .active() + .unwrap() + .doc + .node(vectorcraft_doc::NodeId(id.as_u64().unwrap())) + .unwrap() + .kind + { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + }; + let zoom = 2.0; + let (out, input) = (port_centre(&frame(&app, &a), Port::Out, 0.5).unwrap(), port_centre(&frame(&app, &b), Port::In, 0.5).unwrap()); + let o = thread_overlays(&app, zoom); + assert!(o.iter().any(|x| matches!(x, Overlay::Line { a, b, .. } if *a == out && *b == input)), "the thread line: {o:?}"); + let boxes: Vec = + o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect(); + assert_eq!(boxes.len(), 2); + assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}"); + // Two arrows: three strokes each, beside the thread line. + assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7); + app.run("select.none", json!({})).unwrap(); + assert!(thread_overlays(&app, zoom).is_empty()); +} + +/// A double-click with the Selection tool on the out port of a threaded frame breaks the +/// thread there: no new frame is threaded on, and the story flows back into the first frame. +#[test] +fn a_double_click_on_a_threaded_out_port_breaks_the_thread() { + use vectorcraft_tools::text::{Port, port_centre}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let text = "Words that fill more than the frame holds. ".repeat(10); + let a = + app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.select_tool("selection"); + let doc = |app: &VectorcraftApp| app.session.active().unwrap().doc.clone(); + let before = doc(&app); + let NodeKind::Text(t) = &before.node(vectorcraft_doc::NodeId(a)).unwrap().kind else { panic!("not text") }; + let view = app.view_info(); + let at = port_centre(t, Port::Out, 1.0 / view.zoom).unwrap(); + let count = doc(&app).layers[0].children().map_or(0, |c| c.len()); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, at.x, at.y), view); + } + let d = doc(&app); + assert!(d.text_threads.is_empty(), "the thread is broken"); + assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), count, "no new frame"); + let NodeKind::Text(t) = &d.node(vectorcraft_doc::NodeId(a)).unwrap().kind else { panic!("not text") }; + assert_eq!(t.plain_text(), text, "the story flows into the first frame"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// With more than `PORTS_MAX` type objects selected the tools draw no ports, and Show Text +/// Threads draws only its lines, no port boxes. +#[test] +fn text_threads_of_a_huge_selection_have_lines_but_no_ports() { + use vectorcraft_tools::text::PORTS_MAX; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let text = "Words that fill more than the frame holds. ".repeat(10); + let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone(); + app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 300, "text": "x", "area": {"width": 50, "height": 20}})).unwrap(); + while app.session.active().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) <= PORTS_MAX { + app.run("select.all", json!({})).unwrap(); + app.run("edit.duplicate", json!({})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + let o = thread_overlays(&app, 1.0); + assert!(o.iter().any(|x| matches!(x, Overlay::Line { .. })), "the thread lines"); + assert!(!o.iter().any(|x| matches!(x, Overlay::Highlight { .. } | Overlay::Path { .. })), "no port boxes"); +} + +/// A frame of a thread in perspective has no ports: Show Text Threads draws no port box on it +/// and no line to or from it; the links between frames with ports keep theirs. +#[test] +fn text_threads_skip_the_links_of_a_frame_in_perspective() { + use vectorcraft_tools::text::{Port, port_centre}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let text = "Words that fill more than the frame holds. ".repeat(20); + let id = |v: serde_json::Value| vectorcraft_doc::NodeId(v["id"].as_u64().unwrap()); + let a = id(app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()); + let b = id(app.run("text.thread.add", json!({"from": a.0, "at": [200, 20]})).unwrap()); + let c = id(app.run("text.thread.add", json!({"from": b.0, "at": [20, 200]})).unwrap()); + let d = id(app.run("text.thread.add", json!({"from": c.0, "at": [200, 200]})).unwrap()); + app.session + .edit("Perspective", |doc, _| { + if let Some(n) = doc.node_mut(b) { + n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment::new("left", 0.0))); + } + Ok(()) + }) + .unwrap(); + app.run("select.set", json!({"ids": [a.0, b.0, c.0, d.0]})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.text_threads, [vec![a, b, c, d]], "b is still threaded"); + let frame = |id| match &st.doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + }; + let (out, input) = (port_centre(&frame(c), Port::Out, 1.0).unwrap(), port_centre(&frame(d), Port::In, 1.0).unwrap()); + let o = thread_overlays(&app, 1.0); + let boxes: Vec = + o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect(); + assert_eq!(boxes.len(), 2, "only c's out port and d's in port: {boxes:?}"); + assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}"); + // One thread line (c to d) and its two arrows of three strokes each. + assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7); +} From 732d4ded79a7b4bd0633829d0ec3e17a8a6da231 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:19 +0000 Subject: [PATCH 015/199] refactor(tools): move select.rs tests to select/tests.rs --- crates/tools/src/select.rs | 1682 +----------------------------- crates/tools/src/select/tests.rs | 1679 +++++++++++++++++++++++++++++ 2 files changed, 1680 insertions(+), 1681 deletions(-) create mode 100644 crates/tools/src/select/tests.rs diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index bcf12adf2..1ea9723b9 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1121,1684 +1121,4 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { - PointerEvent::new(kind, x, y) - } - - /// A Selection tool press on a clip group's mask edge, where the art it clips lies over it, - /// selects the clip group, as a press on the art inside does. - #[test] - fn a_press_on_a_masks_edge_selects_its_clip_group() { - let (d, g, _, _) = doc_with_masked_square(); - let (s, p) = (Selection::default(), paint()); - for x in [500.0, 501.5, 450.0] { - let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, x, 450.0)); - assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([g.0]))), "at {x}: {a:?}"); - } - // Just outside the mask, beyond the tolerance, the clipped-away art isn't there. - let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 520.0, 450.0)); - assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}"); - } - - /// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands - /// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it. - #[test] - fn a_move_grabbed_by_an_anchor_lands_it_on_another_anchor() { - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - let tri = d.alloc_id(); - let pts = [Point::new(350.3, 250.0), Point::new(300.0, 320.0), Point::new(420.0, 330.0)]; - let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(tri, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - let (none, mut picked) = (Selection::default(), Selection::default()); - picked.add(id); - let p = paint(); - let mut t = SelectionTool::default(); - // Grab the rectangle (not selected yet: no bounding box) by its bottom-right corner - // (200, 200); the press selects it, then drop it beside the tip. - let before = cx(&d, &none, &p); - t.pointer(&before, &ev(PointerKind::Move, 201.0, 201.0)); - t.pointer(&before, &ev(PointerKind::Down, 201.0, 201.0)); - let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 350.0, 249.8)); - let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; - let (x, y) = (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()); - assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y); - } - - /// #3716: a press a few pixels from an anchor grabs the object by that anchor, but the drag - /// threshold counts from where the pointer went down. A click that wobbles by a pixel selects - /// the object without moving it, instead of jumping the anchor onto the pointer. - #[test] - fn a_click_near_an_anchor_selects_without_moving() { - let (d, id) = doc_with_rect(); - let (none, mut picked) = (Selection::default(), Selection::default()); - picked.add(id); - let p = paint(); - let mut t = SelectionTool::default(); - // 5.7 px from the corner (200, 200): within reach of it, beyond the drag threshold. - let a = t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 196.0, 196.0)); - assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([id.0]))), "{a:?}"); - let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 197.0, 196.0)); - assert!(a.is_empty(), "a 1 px wobble moves the object: {a:?}"); - let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Up, 197.0, 196.0)); - assert!(!a.iter().any(|a| matches!(a, Action::Commit)), "{a:?}"); - // A drag past the threshold from the press still moves it by the corner (#812). - let mut t = SelectionTool::default(); - t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 196.0, 196.0)); - let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 230.0, 196.0)); - let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; - assert_eq!((v["matrix"][4].as_f64(), v["matrix"][5].as_f64()), (Some(30.0), Some(-4.0)), "the corner follows the pointer: {a:?}"); - } - - /// A corner-radius drag is a transform, so the sharp selection box hides while the radius changes. - #[test] - fn a_corner_drag_counts_as_transforming_once_it_moves() { - use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; - let live = LiveShape::Rectangle { - w: 100.0, - h: 100.0, - radii: [0.0; 4], - kinds: Default::default(), - radii_relative: false, - xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), - }; - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let id = d.alloc_id(); - let mut n = Node::path(id, live.to_path(), Appearance::default_art()); - if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(Box::new(live)); - } - d.insert(Some(d.layers[0].id), 0, n).unwrap(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - assert!(!t.transforming()); - t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0)); - assert!(!t.transforming(), "a press is still a click"); - t.pointer(&cx, &ev(PointerKind::Drag, 175.0, 176.0)); - assert!(t.transforming()); - } - - /// A type-on-path bracket drag is a transform, so the sharp selection box hides while the span moves. - #[test] - fn a_bracket_drag_counts_as_transforming_once_it_moves() { - let (d, id) = crate::testutil::doc_with_path_type(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - assert!(!t.transforming()); - t.pointer(&cx, &ev(PointerKind::Down, 280.0, 290.0)); - assert!(!t.transforming(), "a press is still a click"); - t.pointer(&cx, &ev(PointerKind::Drag, 320.0, 290.0)); - assert!(t.transforming()); - } - - /// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a - /// press there grabs the object by that point. - #[test] - fn hovering_labels_the_centre_and_anchors_and_a_press_grabs_by_them() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let label = |t: &SelectionTool| { - t.overlays(&cx).iter().find_map(|o| match o { - Overlay::Label { p, text, .. } => Some((*p, text.clone())), - _ => None, - }) - }; - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0)); - assert_eq!(label(&t), Some((Point::new(150.0, 150.0), "center".to_string()))); - t.pointer(&cx, &ev(PointerKind::Move, 102.0, 101.0)); - assert_eq!(label(&t), Some((Point::new(100.0, 100.0), "anchor".to_string()))); - t.pointer(&cx, &ev(PointerKind::Move, 130.0, 170.0)); - assert_eq!(label(&t), None, "away from both"); - // Grabbed by its centre, the object's centre follows the pointer. - t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0)); - t.pointer(&cx, &ev(PointerKind::Down, 152.0, 151.0)); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 250.0)); - assert!(a.iter().any(|a| matches!(a, Action::Preview(_, v) if v["matrix"][4] == 100.0 && v["matrix"][5] == 100.0)), "{a:?}"); - // With Smart Guides off, nothing is labelled. - let off = ToolContext { smart_guides: false, ..cx }; - let mut t = SelectionTool::default(); - t.pointer(&off, &ev(PointerKind::Move, 152.0, 151.0)); - assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Label { .. }))); - } - - /// [`doc_with_rect`] (100, 100)–(200, 200), selected, and a second rectangle `other`. - fn two_rects(other: Rect) -> (vectorcraft_doc::Document, NodeId, NodeId) { - let (mut d, a) = doc_with_rect(); - let l = d.layers[0].id; - let b = d.alloc_id(); - let path = vectorcraft_geom::shapes::rectangle(other); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - (d, a, b) - } - - fn alt(kind: PointerKind, x: f64, y: f64) -> PointerEvent { - ev(kind, x, y).with_mods(Mods { alt: true, ..Mods::default() }) - } - - /// A Distance Guides measured line: its ends and its label. - type DistanceLine = (Point, Point, String); - - /// The Distance Guides overlays: each measured line, and the dashed lines out to it, in order. - fn distances(t: &SelectionTool, cx: &ToolContext) -> (Vec, Vec<(Point, Point)>) { - let o = t.overlays(cx); - let lines = o.iter().filter_map(|o| if let Overlay::Distance { a, b, text, .. } = o { Some((*a, *b, text.clone())) } else { None }); - let dashed = o.iter().filter_map(|o| if let Overlay::Line { a, b, dashed: true, .. } = o { Some((*a, *b)) } else { None }); - (lines.collect(), dashed.collect()) - } - - fn line(a: (f64, f64), b: (f64, f64), text: &str) -> DistanceLine { - (Point::new(a.0, a.1), Point::new(b.0, b.1), text.to_string()) - } - - /// Alt held over an object beside the selection shows the horizontal gap between their - /// facing edges across the middle of where they overlap, and the vertical offsets of their - /// top and bottom edges across the selection's middle, with dashed lines out to the other - /// object's edges (helpx: Use Distance Guides for accurate placement). - #[test] - fn alt_over_an_object_beside_the_selection_measures_the_gap_and_the_edges() { - let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); - let s = Selection { objects: vec![a], ..Default::default() }; - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); - let (lines, dashed) = distances(&t, &cx); - assert_eq!( - lines, - vec![ - line((200.0, 150.0), (300.0, 150.0), "100.00 pt"), - line((150.0, 100.0), (150.0, 120.0), "20.00 pt"), - line((150.0, 200.0), (150.0, 180.0), "20.00 pt"), - ] - ); - let p = Point::new; - assert_eq!(dashed, vec![(p(300.0, 120.0), p(150.0, 120.0)), (p(300.0, 180.0), p(150.0, 180.0))]); - assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Distance { color, .. } if *color != cx.smart_guide_color))); - } - - /// Below the selection: the vertical gap, and the left and right edges' offsets. - #[test] - fn alt_over_an_object_below_the_selection_measures_the_vertical_gap() { - let (d, a, _) = two_rects(Rect::new(120.0, 300.0, 180.0, 400.0)); - let s = Selection { objects: vec![a], ..Default::default() }; - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&cx, &alt(PointerKind::Move, 150.0, 350.0)); - let (lines, dashed) = distances(&t, &cx); - assert_eq!( - lines, - vec![ - line((100.0, 150.0), (120.0, 150.0), "20.00 pt"), - line((200.0, 150.0), (180.0, 150.0), "20.00 pt"), - line((150.0, 200.0), (150.0, 300.0), "100.00 pt"), - ] - ); - assert_eq!(dashed.len(), 2, "out to the left and right edges of the object below"); - } - - /// Alt held over an empty part of the artboard: the selection's distance from the artboard's - /// left, right, top and bottom edges, in the General unit. - #[test] - fn alt_over_the_artboard_measures_to_its_four_edges_in_the_general_unit() { - let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); - let s = Selection { objects: vec![a], ..Default::default() }; - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&cx, &alt(PointerKind::Move, 50.0, 450.0)); - let (lines, dashed) = distances(&t, &cx); - assert_eq!( - lines, - vec![ - line((100.0, 150.0), (0.0, 150.0), "100.00 pt"), - line((200.0, 150.0), (500.0, 150.0), "300.00 pt"), - line((150.0, 100.0), (150.0, 0.0), "100.00 pt"), - line((150.0, 200.0), (150.0, 500.0), "300.00 pt"), - ] - ); - assert!(dashed.is_empty()); - let mm = ToolContext { unit: vectorcraft_doc::Unit::Millimeters, ..cx }; - t.pointer(&mm, &alt(PointerKind::Move, 50.0, 450.0)); - let texts: Vec = distances(&t, &mm).0.into_iter().map(|l| l.2).collect(); - assert_eq!(texts, ["35.28 mm", "105.83 mm", "35.28 mm", "105.83 mm"]); - // Off the artboard, over nothing: no distances. - t.pointer(&cx, &alt(PointerKind::Move, 600.0, 600.0)); - assert!(distances(&t, &cx).0.is_empty()); - } - - /// No Distance Guides without Alt, with nothing selected, over the selection itself, with - /// Smart Guides or Invoke Distance Guides off, or once Alt is let go. - #[test] - fn distance_guides_need_alt_a_selection_and_smart_guides() { - let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); - let s = Selection { objects: vec![a], ..Default::default() }; - let none = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let shown = |cx: &ToolContext, e: PointerEvent| { - let mut t = SelectionTool::default(); - t.pointer(cx, &e); - !distances(&t, cx).0.is_empty() - }; - assert!(shown(&cx, alt(PointerKind::Move, 350.0, 150.0))); - assert!(!shown(&cx, ev(PointerKind::Move, 350.0, 150.0)), "without Alt"); - assert!(!shown(&cx, ev(PointerKind::Move, 50.0, 450.0)), "without Alt, over the artboard"); - assert!(!shown(&crate::testutil::cx(&d, &none, &p), alt(PointerKind::Move, 350.0, 150.0)), "nothing selected"); - assert!(!shown(&cx, alt(PointerKind::Move, 150.0, 150.0)), "over the selection"); - assert!( - !shown(&ToolContext { smart_guides: false, ..crate::testutil::cx(&d, &s, &p) }, alt(PointerKind::Move, 350.0, 150.0)), - "View › Smart Guides off" - ); - assert!( - !shown(&ToolContext { distance_guides: false, ..crate::testutil::cx(&d, &s, &p) }, alt(PointerKind::Move, 350.0, 150.0)), - "the preference off" - ); - // Letting Alt go (the canvas sends a hover with the new modifiers) takes them away. - let mut t = SelectionTool::default(); - t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); - t.pointer(&cx, &ev(PointerKind::Move, 350.0, 150.0)); - assert!(distances(&t, &cx).0.is_empty()); - // So does the hover ending (the pointer leaving the canvas) with Alt still held. - t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); - assert!(!distances(&t, &cx).0.is_empty()); - assert!(t.wants_rehover(), "an Alt hover is hovered again when what lies under it changes"); - t.hover_ended(&cx); - assert!(distances(&t, &cx).0.is_empty()); - assert!(!t.wants_rehover()); - t.pointer(&cx, &ev(PointerKind::Move, 350.0, 150.0)); - assert!(!t.wants_rehover(), "nor without Alt"); - // A press ends them too. - t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); - t.pointer(&cx, &alt(PointerKind::Down, 350.0, 150.0)); - assert!(distances(&t, &cx).0.is_empty()); - } - - #[test] - fn click_selects_object() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - let a = t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); - assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)).is_empty()); - } - - /// #414: a press on a ruler guide picks it over the art, and drags it. - #[test] - fn a_press_on_a_ruler_guide_picks_it_over_the_art() { - let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - assert_eq!(t.cursor(&cx, Point::new(160.0, 151.0), Mods::default()), Cursor::ResizeV); - let a = t.pointer(&cx, &ev(PointerKind::Down, 160.0, 151.0)); - assert_eq!(a, vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); - assert!(t.busy()); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 171.0)); - assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": false}))); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 160.0, 171.0)), vec![Action::Commit]); - assert!(!t.busy()); - // A double-click there doesn't go through to the art. - assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty()); - } - - /// A Cmd/Ctrl+Shift double-click releases the ruler guide or the guide path under the pointer; - /// without both keys, or while guides are hidden or locked, nothing is released. - #[test] - fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; - *guide = true; - let s = Selection::default(); - let p = paint(); - let both = Mods { cmd: true, shift: true, ..Mods::default() }; - let dbl = |c: &ToolContext, x: f64, y: f64, m: Mods| SelectionTool::default().pointer(c, &ev(PointerKind::DoubleClick, x, y).with_mods(m)); - let releases = |a: &[Action]| a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "view.guides.release")); - let c = cx(&d, &s, &p); - assert_eq!(dbl(&c, 350.0, 301.0, both), vec![Action::Exec("view.guides.release".into(), json!({"index": 0}))]); - assert_eq!(dbl(&c, 200.0, 150.0, both), vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]); - assert!(dbl(&c, 350.0, 301.0, Mods::default()).is_empty(), "a plain double-click on a ruler guide"); - for m in [Mods { cmd: true, ..Mods::default() }, Mods { shift: true, ..Mods::default() }, Mods::default()] { - assert!(!releases(&dbl(&c, 200.0, 150.0, m)), "{m:?}"); - } - let locked = ToolContext { guides: false, ..cx(&d, &s, &p) }; - assert!(!releases(&dbl(&locked, 350.0, 301.0, both))); - assert!(!releases(&dbl(&locked, 200.0, 150.0, both))); - // A double-click on art that is not a guide does not release it. - let (plain, _) = doc_with_rect(); - assert!(!releases(&dbl(&cx(&plain, &s, &p), 200.0, 150.0, both))); - } - - /// Just outside each corner of the box the rotate cursor bows toward that corner, its arc - /// opening toward the box: at the top right it opens to the bottom left. - #[test] - fn the_rotate_cursor_wraps_the_corner_it_is_at() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let t = SelectionTool::default(); - // The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally. - for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] { - assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})"); - } - assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre"); - assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up"); - } - - #[test] - fn turned_cursors_step_with_the_view() { - assert_eq!(Cursor::ResizeH.turned(90.0), Cursor::ResizeV); - assert_eq!(Cursor::ResizeNeSw.turned(45.0), Cursor::ResizeH); - assert_eq!(Cursor::ResizeH.turned(45.0), Cursor::ResizeNwSe); - assert_eq!(Cursor::ResizeNwSe.turned(-135.0), Cursor::ResizeV); - assert_eq!(Cursor::RotateToward(0).turned(90.0), Cursor::RotateToward(2)); - assert_eq!(Cursor::RotateToward(1).turned(-45.0), Cursor::RotateToward(0)); - assert_eq!(Cursor::RotateToward(7).turned(180.0), Cursor::RotateToward(3)); - assert_eq!(Cursor::ResizeV.turned(20.0), Cursor::ResizeV, "under 22.5° it stays"); - assert_eq!(Cursor::Pen.turned(90.0), Cursor::Pen); - assert_eq!(Cursor::Rotate.turned(90.0), Cursor::Rotate); - assert_eq!(Cursor::ResizeH.turned(f64::NAN), Cursor::ResizeH); - // `rotate_about_in_view` turns the pointer's direction by the view before it rounds: a - // pointer 15° clockwise from straight up, in a view turned 15°, shows 30° from straight up - // on screen, which rounds to the top right. `turned` rounds the 15° view to 0°. - let (sn, cs) = 15f64.to_radians().sin_cos(); - let q = Point::new(sn, -cs); - assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, q, 15.0), Cursor::RotateToward(1)); - assert_eq!(Cursor::rotate_about(Point::ORIGIN, q).turned(15.0), Cursor::RotateToward(0)); - assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, q, 0.0), Cursor::rotate_about(Point::ORIGIN, q)); - assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::new(0.0, -5.0), 90.0), Cursor::RotateToward(2)); - assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::new(0.0, -5.0), f64::NAN), Cursor::RotateToward(0)); - assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::ORIGIN, 90.0), Cursor::Rotate, "on the center"); - } - - /// In a rotated view the resize and rotate cursors turn with the view. - #[test] - fn box_cursors_turn_with_the_view() { - let (mut d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - let mut c = cx(&d, &s, &p); - c.screen = turned_view(90.0); - assert!((c.view_angle() - 90.0).abs() < 1e-9); - let t = SelectionTool::default(); - // The box runs from (100, 100) to (200, 200). Its right side shows at the bottom, and its - // top shows on the right. - assert_eq!(t.cursor(&c, Point::new(200.0, 150.0), Mods::default()), Cursor::ResizeV, "right-middle"); - assert_eq!(t.cursor(&c, Point::new(150.0, 100.0), Mods::default()), Cursor::ResizeH, "top-middle"); - assert_eq!(t.cursor(&c, Point::new(207.0, 93.0), Mods::default()), Cursor::RotateToward(3), "out from the top right"); - // While a rotate drags, the cursor turns with the view too: straight up the page shows to - // the right. - let mut r = SelectionTool::default(); - assert_eq!(r.pointer(&c, &ev(PointerKind::Down, 207.0, 93.0)), vec![Action::Begin("Rotate".into())]); - assert_eq!(r.cursor(&c, Point::new(150.0, 80.0), Mods::default()), Cursor::RotateToward(2), "rotating"); - // A horizontal ruler guide shows upright and moves left and right on screen. - assert_eq!(t.cursor(&c, Point::new(400.0, 300.0), Mods::default()), Cursor::ResizeH, "guide"); - // A box turned 20° in a view turned 25° shows 5° off level: the right handle shows the - // left-right arrow. - let (center, a) = (Point::new(150.0, 150.0), 20f64.to_radians()); - let turn = Affine::translate(center.to_vec2()) * Affine::rotate(-a) * Affine::translate(-center.to_vec2()); - d.node_mut(id).unwrap().transform(turn, false); - let mut c = cx(&d, &s, &p); - c.screen = turned_view(25.0); - let right = Point::new(150.0 + 50.0 * a.cos(), 150.0 - 50.0 * a.sin()); - assert_eq!(t.cursor(&c, right, Mods::default()), Cursor::ResizeH, "20° box, 25° view"); - } - - #[test] - fn drag_moves_with_single_undo() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty()); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0)); - assert_eq!(a[0], Action::Begin("Move".into())); - assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][4] == 10.0)); - let a = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 150.0)); - assert_eq!(a, vec![Action::Commit]); - } - - /// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps - /// to a ruler guide within the Snap to Point distance; not with Snap to Point off. - #[test] - fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { - let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)]; - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let dx = |c: &ToolContext, to: f64| { - let mut t = SelectionTool::default(); - t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0)); - let a = t.pointer(c, &ev(PointerKind::Drag, to, 150.0)); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - v["matrix"][4].as_f64().unwrap() - }; - let c = cx(&d, &s, &p); - assert!(c.smart_guides && c.snap_to_point); - assert!((dx(&c, 172.0) - 23.4).abs() < 1e-9, "onto the guide"); - assert_eq!(dx(&c, 165.0), 15.0, "out of reach"); - assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0); - } - - /// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as - /// gathered before the previews moved it); released, the move doesn't. - #[test] - fn an_alt_copy_snaps_to_the_original_left_behind() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, id) = doc_with_rect(); - // An artboard whose edges and centre are out of reach. - d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let mut t = SelectionTool::default(); - let dx = |a: &[Action]| { - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - v["matrix"][4].as_f64().unwrap() - }; - t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0)); - assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0); - // The previews moved the rectangle: the tool sees it 50 pt along. - let mut moved = d.clone(); - let l = moved.layers[0].id; - moved.remove(id).unwrap(); - let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0)); - moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap(); - let c = cx(&moved, &s, &p); - let alt = Mods { alt: true, ..Mods::default() }; - // The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it. - assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0); - // Without Alt nothing stays behind there. - assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0); - } - - /// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right - /// one); Alt, which leaves a copy behind, the double arrowhead. - #[test] - fn a_move_shows_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, _) = doc_with_rect(); - d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); - let l = d.layers[0].id; - let b = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)); - d.insert(Some(l), 1, Node::path(b, sq, Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.add(b); - let p = paint(); - let c = cx(&d, &s, &p); - let none = Mods::default(); - let mut t = SelectionTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 350.0, 350.0)); - t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0)); - assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), none), Cursor::Arrow); - t.pointer(&c, &ev(PointerKind::Drag, 253.0, 371.0)); - assert_eq!(t.cursor(&c, Point::new(253.0, 371.0), none), Cursor::ArrowHollow, "lined up with A"); - let alt = Mods { alt: true, ..none }; - t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0).with_mods(alt)); - assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), alt), Cursor::SelectionCopy); - } - - /// #740, #1816: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) - /// by whichever of its edges is nearest a gridline, on each axis. - #[test] - fn a_move_lands_the_nearest_edge_on_the_grid() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = ToolContext { snap_to_grid: true, smart_guides: true, ..cx(&d, &s, &p) }; - let moved = |to: (f64, f64)| { - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); - let a = t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1)); - match a.get(1) { - Some(Action::Preview(_, v)) => (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), - other => panic!("{other:?}"), - } - }; - // Bounds 100–200 moved by (13, 4): the right and bottom edges (213, 204) are nearest - // gridlines (216, 207). - let (x, y) = moved((163.0, 154.0)); - assert!((x - 16.0).abs() < 1e-9 && (y - 7.0).abs() < 1e-9, "{x} {y}"); - // Moved by (7, 0): the right edge is on a gridline (207) and the top goes up to one (99). - let (x, y) = moved((157.0, 150.0)); - assert!((x - 7.0).abs() < 1e-9 && (y + 1.0).abs() < 1e-9, "{x} {y}"); - } - - /// The selection Shift-dragged from (150, 150) to `to`: the move it previews, and its guide - /// lines. - fn shift_moved(cx: &ToolContext, to: (f64, f64)) -> (Vec2, Vec<(Point, Point)>) { - let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); - let mut t = SelectionTool::default(); - t.pointer(cx, &shift(PointerKind::Down, 150.0, 150.0)); - let a = t.pointer(cx, &shift(PointerKind::Drag, to.0, to.1)); - let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; - let lines = t.overlays(cx).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect(); - (Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), lines) - } - - /// `d` is `want` within 1e-9 on each axis. - fn moved_by(d: Vec2, want: (f64, f64)) -> bool { - (d.x - want.0).abs() < 1e-9 && (d.y - want.1).abs() < 1e-9 - } - - /// A Shift-constrained move snapped to the grid stays on its line: a level or upright one lands - /// on the grid along its own axis only, and a diagonal one slides along the diagonal until an - /// edge is on a gridline on the axis that needs the shorter way there. - #[test] - fn a_shift_move_snapped_to_the_grid_stays_on_its_line() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - // Level by 13.04: the right edge (213.04) lands on the gridline 216, and the top stays at - // 100, between the gridlines 99 and 108. - let (m, _) = shift_moved(&cx, (163.0, 151.0)); - assert!(moved_by(m, (16.0, 0.0)), "{m:?}"); - let (m, _) = shift_moved(&cx, (151.0, 163.0)); - assert!(moved_by(m, (0.0, 16.0)), "{m:?}"); - // Up and to the right by 13 on each axis: the bottom edge (187) is 2 from the gridline 189, - // and the right edge (213) is 3 from 216, so the move goes back along the diagonal by 2 on - // each axis. - let (m, _) = shift_moved(&cx, (163.0, 137.0)); - assert!(moved_by(m, (11.0, -11.0)), "{m:?}"); - } - - /// #1816: on a 20 pt grid (gridlines every 100, 5 subdivisions), the 100 pt square at 100–200 - /// sits on the grid with its center half a step off. A move by (8, 8) brings the left and top - /// edges back onto the gridlines at 100, so the square stays where it was; a move by (31, 29) - /// lands its edges on the nearest gridlines (140, 120). The center does not snap. - #[test] - fn a_move_keeps_an_object_on_the_grid_when_its_center_is_off_it() { - let (mut d, id) = doc_with_rect(); - d.grid = vectorcraft_doc::GridPrefs { spacing: 100.0, subdivisions: 5 }; - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - let moved = |to: (f64, f64)| { - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); - let a = t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1)); - let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; - Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()) - }; - let m = moved((158.0, 158.0)); - assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); - let m = moved((181.0, 179.0)); - assert!(moved_by(m, (40.0, 20.0)), "{m:?}"); - // With Shift, level and diagonal moves by 8 come back to where they started too. - let (m, _) = shift_moved(&cx, (158.0, 150.0)); - assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); - let (m, _) = shift_moved(&cx, (158.0, 158.0)); - assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); - } - - /// A Shift-constrained move with Snap to Pixel on puts the top-left on a whole pixel on the - /// axis its line runs along, and on a diagonal on the axis that needs the shorter way there. - #[test] - fn a_shift_move_snapped_to_pixels_stays_on_its_line() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) }; - // Pressed at (150, 150), the square is grabbed by its center (150.3, 150.4). - let (m, _) = shift_moved(&cx, (163.0, 151.0)); - assert!(moved_by(m, (12.7, 0.0)), "{m:?}"); - let (m, _) = shift_moved(&cx, (151.0, 163.0)); - assert!(moved_by(m, (0.0, 12.6)), "{m:?}"); - // By 13.16 on each axis, the top-left (113.46, 113.56) is 0.44 from a whole pixel on y and - // 0.46 on x. - let (m, _) = shift_moved(&cx, (163.0, 164.0)); - assert!(moved_by(m, (13.6, 13.6)), "{m:?}"); - } - - /// A Shift-constrained move that Smart Guides line up with another object stays on its line: - /// a level or upright one lines up along its own axis only, with the guide of that axis, and - /// a diagonal one slides along the diagonal to the target that needs the shorter way. - #[test] - fn a_shift_move_lined_up_by_smart_guides_stays_on_its_line() { - let with = |r: Rect| { - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - let other = d.alloc_id(); - d.insert( - Some(l), - 1, - vectorcraft_doc::Node::path(other, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art()), - ) - .unwrap(); - (d, id) - }; - let p = paint(); - // Level by 100.02: the right edge lines up with the other square's left (300). That - // square's top (203), 3 below the selection's bottom and within reach, does not pull it - // down. - let (d, id) = with(Rect::new(300.0, 203.0, 350.0, 253.0)); - let mut s = Selection::default(); - s.add(id); - let (m, lines) = shift_moved(&cx(&d, &s, &p), (250.0, 152.0)); - assert!(moved_by(m, (100.0, 0.0)), "{m:?}"); - assert!(!lines.is_empty() && lines.iter().all(|(a, b)| a.x == b.x), "only upright guides: {lines:?}"); - let (d, id) = with(Rect::new(203.0, 300.0, 253.0, 350.0)); - let mut s = Selection::default(); - s.add(id); - let (m, lines) = shift_moved(&cx(&d, &s, &p), (152.0, 250.0)); - assert!(moved_by(m, (0.0, 100.0)), "{m:?}"); - assert!(!lines.is_empty() && lines.iter().all(|(a, b)| a.y == b.y), "only level guides: {lines:?}"); - // By 110.5 on each axis: the right edge (310.5) is 0.5 from the other square's left and the - // bottom 3 from its top, so the move goes on by 0.5 on each axis. - let (d, id) = with(Rect::new(311.0, 313.5, 361.0, 363.5)); - let mut s = Selection::default(); - s.add(id); - let (m, lines) = shift_moved(&cx(&d, &s, &p), (260.5, 260.5)); - assert!(moved_by(m, (111.0, 111.0)), "{m:?}"); - assert!(matches!(lines[..], [(a, b)] if a.x == 311.0 && b.x == 311.0), "{lines:?}"); - } - - /// A level Shift-constrained move that Spacing Guides space evenly keeps its line: the gap to - /// the next square becomes 60, the gap between the other two, and their tops (102), 2 below - /// the selection's, do not pull it down. - #[test] - fn a_shift_move_spaced_by_smart_guides_stays_on_its_line() { - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - for x in [330.0, 440.0] { - let other = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(x, 102.0, x + 50.0, 202.0)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - } - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let (m, lines) = shift_moved(&cx(&d, &s, &p), (221.0, 151.0)); - assert!(moved_by(m, (70.0, 0.0)), "{m:?}"); - assert!(!lines.is_empty(), "the equal gaps are marked"); - } - - /// A Shift-constrained move that snaps by its grabbed point stays on its line: with Smart - /// Guides off and Snap to Point on, it stops where the line passes nearest an anchor, or - /// where it crosses a ruler guide; with Smart Guides on, Snap to Point still slides it along - /// the line onto a ruler guide. - #[test] - fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - let other = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 151.5, 350.0, 201.5)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 420.5)]; - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; - // The grabbed point (299, 150) is 1.8 from the anchor (300, 151.5): it stops at (300, 150), - // the point of the line nearest the anchor. - let (m, _) = shift_moved(&off, (299.0, 151.0)); - assert!(moved_by(m, (150.0, 0.0)), "{m:?}"); - // On the diagonal, 1 short of the ruler guide at 420.5: it goes on to it on both axes. - let (m, _) = shift_moved(&off, (419.5, 419.5)); - assert!(moved_by(m, (270.5, 270.5)), "{m:?}"); - // Smart Guides on, the selection alone with a ruler guide 1.5 below the grabbed point. - let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(false, 330.5)]; - let mut s = Selection::default(); - s.add(id); - let (m, _) = shift_moved(&cx(&d, &s, &p), (329.0, 329.0)); - assert!(moved_by(m, (180.5, 180.5)), "{m:?}"); - } - - #[test] - fn shift_drag_on_a_selected_object_moves_it_constrained() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); - // Pressing keeps the selection: nothing is toggled yet. - assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty()); - let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0)); - assert_eq!(a[0], Action::Begin("Move".into())); - assert!( - matches!(&a[1], Action::Preview(c, v) if c == "object.transform" - && v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0), - "moved right, constrained to horizontal: {a:?}" - ); - assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]); - } - - #[test] - fn shift_click_toggles_an_object_on_release() { - let (d, id) = doc_with_rect(); - let p = paint(); - let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); - let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]; - // Selected: it leaves the selection when the click is released. - let mut s = Selection::default(); - s.add(id); - let cx1 = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty()); - assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle); - // Not selected: it joins the selection at once, ready to be dragged. - let s = Selection::default(); - let cx2 = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle); - assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty()); - } - - /// #541: a click on one object of a selection of several makes it the key object; a click on - /// the key again lets it go. A drag moves them all, a lone object has no key unless ruler - /// guides are selected with it. - #[test] - fn a_click_on_a_selected_object_makes_it_the_key() { - let (d, id) = doc_with_area_type(); - let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); - let p = paint(); - let click = |s: &Selection, x, y| { - let c = cx(&d, s, &p); - let mut t = SelectionTool::default(); - assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty()); - t.pointer(&c, &ev(PointerKind::Up, x, y)) - }; - let mut s = Selection::default(); - s.add(rect); - s.add(id); - assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); - s.key = Some(rect); - assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key"); - // A drag moves the selection and leaves the key alone. - let c = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)); - t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0)); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 150.0)), vec![Action::Commit]); - // One object selected: a click on it does nothing, unless ruler guides are selected - // with it for it to be the key of (#2143). - let mut one = Selection::default(); - one.add(rect); - assert!(click(&one, 150.0, 150.0).is_empty()); - one.guides = vec![0]; - assert_eq!(click(&one, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); - } - - /// In isolation mode a click makes only a selected top-level object the key, as `select.key` - /// takes no other: a click on a sublayer's art inside an isolated layer picks the sublayer, - /// a click inside an isolated group that is also selected picks an object inside it, and a - /// click inside an isolated compound path picks a member. - #[test] - fn a_click_makes_only_a_selected_top_level_object_the_key() { - use vectorcraft_doc::{Appearance, Document, LayerColor, Node, NodeKind}; - use vectorcraft_geom::shapes; - let p = paint(); - let click = |d: &Document, s: &Selection, isolation: NodeId| { - let c = ToolContext { isolation: Some(isolation), ..cx(d, s, &p) }; - let mut t = SelectionTool::default(); - assert!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)).is_empty()); - t.pointer(&c, &ev(PointerKind::Up, 150.0, 150.0)) - }; - let path = |id: NodeId, r: Rect| Node::path(id, shapes::rectangle(r), Appearance::default_art()); - let (under, beside) = (Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(300.0, 300.0, 340.0, 340.0)); - // An isolated layer whose art under the click is in a sublayer. - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (sub, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); - d.insert(Some(sub), 0, path(a, under)).unwrap(); - d.insert(Some(l), 1, path(b, beside)).unwrap(); - let mut s = Selection::default(); - s.add(sub); - s.add(b); - assert!(click(&d, &s, l).is_empty(), "a sublayer is not a key object"); - // An isolated group that is also selected. - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 0, Node::group(g, vec![])).unwrap(); - d.insert(Some(g), 0, path(a, under)).unwrap(); - d.insert(Some(g), 1, path(b, beside)).unwrap(); - let mut s = Selection::default(); - s.add(a); - s.add(g); - assert!(click(&d, &s, g).is_empty(), "an object inside the selected group is not a key object"); - s.key = Some(a); - assert_eq!(click(&d, &s, g), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key"); - // With the group not selected, the object becomes the key. - let mut s = Selection::default(); - s.add(a); - s.add(b); - assert_eq!(click(&d, &s, g), vec![Action::Exec("select.key".into(), json!({"id": a.0}))]); - // An isolated compound path. Make Compound Path clears its members' appearance, so these - // members have none, and the click is on a's outline. - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (c, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let member = |id: NodeId, r: Rect| std::sync::Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default())); - let children = vec![member(a, Rect::new(150.0, 100.0, 250.0, 200.0)), member(b, beside)]; - let mut compound = Node::new(c, NodeKind::Compound { children, rule: Default::default() }); - compound.appearance = Appearance::default_art().into(); - d.insert(Some(l), 0, compound).unwrap(); - let mut s = Selection::default(); - s.add(a); - s.add(b); - assert!(click(&d, &s, c).is_empty(), "a compound-path member is not a key object"); - } - - #[test] - fn marquee_selects() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); - assert_eq!(t.overlays(&cx).len(), 1); - let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); - } - - /// Shift-drag a marquee: the objects it reaches toggle (#483), so selected ones can be - /// taken out of the selection; a Shift-click on empty canvas leaves the selection alone. - #[test] - fn shift_marquee_toggles() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - let shift = Mods { shift: true, ..Default::default() }; - t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift)); - t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0).with_mods(shift)); - let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0]}))]); - t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift)); - assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty()); - } - - /// E pressed during a marquee drag makes the marquee take only the objects entirely inside it - /// (a group whole), and E pressed again makes it take what it touches. Only a marquee takes E. - #[test] - fn e_during_a_marquee_takes_only_the_objects_inside() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, inside) = doc_with_rect(); - let l = d.layers[0].id; - let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); - let (touched, g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 1, rect(touched, Rect::new(250.0, 100.0, 350.0, 200.0))).unwrap(); - // A group with one member inside the marquee and one outside it. - d.insert(Some(l), 2, Node::group(g, vec![])).unwrap(); - d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap(); - d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let e = ToolKey::Letter('e'); - let drag = |presses: usize, mods: Mods| { - let mut t = SelectionTool::default(); - assert!(!t.claims_key(&cx, e), "no marquee yet"); - t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(mods)); - t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0).with_mods(mods)); - for _ in 0..presses { - assert!(t.claims_key(&cx, e)); - assert!(t.key(&cx, e, Mods::default()).is_empty()); - } - let out = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0).with_mods(mods)); - assert!(!t.claims_key(&cx, e), "the marquee is over"); - out - }; - let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(ids) }))]; - assert_eq!(drag(0, Mods::default()), set(&[inside, touched, g])); - assert_eq!(drag(1, Mods::default()), set(&[inside])); - assert_eq!(drag(2, Mods::default()), set(&[inside, touched, g]), "E again: what it touches"); - let shift = Mods { shift: true, ..Mods::default() }; - assert_eq!(drag(1, shift), vec![Action::Exec("select.toggle".into(), json!({ "ids": [inside.0] }))]); - // The next marquee starts out taking what it touches. - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0)); - t.key(&cx, e, Mods::default()); - t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0)); - t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0)), set(&[inside, touched, g])); - // A press on an object starts a move, which does not take E. - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); - assert!(t.busy() && !t.claims_key(&cx, e)); - } - - /// With E pressed during the drag, a marquee takes only the artboard guides that lie entirely - /// inside it, and no canvas guide, which runs across the whole canvas. - #[test] - fn e_during_a_marquee_takes_only_the_guides_inside() { - let (mut d, id) = doc_with_rect(); - let board = d.artboards[0].id; - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); - d.guides.push(vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let marquee = |to: f64| { - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, -10.0, -10.0)); - t.pointer(&cx, &ev(PointerKind::Drag, to, to)); - t.key(&cx, ToolKey::Letter('e'), Mods::default()); - t.pointer(&cx, &ev(PointerKind::Up, to, to)) - }; - let guides = |a: &[Action]| a.iter().find_map(|a| matches!(a, Action::Exec(c, _) if c == "guide.select").then(|| a.clone())); - // The artboard runs from 0 to 500: a marquee to (510, 510) holds its guide. - let a = marquee(510.0); - assert_eq!(a.first(), Some(&Action::Exec("select.set".into(), json!({ "ids": [id.0] })))); - assert_eq!(guides(&a), Some(Action::Exec("guide.select".into(), json!({ "indexes": [1], "toggle": true })))); - // A marquee to (250, 250) crosses both guides and holds neither. - assert_eq!(guides(&marquee(250.0)), None); - } - - #[test] - fn marquee_selects_ruler_guides() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); - let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)); - assert_eq!( - a, - vec![ - Action::Exec("select.set".into(), json!({"ids": [id.0]})), - Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), - ] - ); - } - - #[test] - fn marquee_selects_only_guide() { - let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 20.0)); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 30.0)); - let a = t.pointer(&cx, &ev(PointerKind::Up, 30.0, 30.0)); - assert_eq!( - a, - vec![Action::Exec("select.set".into(), json!({"ids": []})), Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),] - ); - } - - #[test] - fn marquee_ignores_locked_guides() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); - let s = Selection::default(); - let p = paint(); - let mut cx_locked = cx(&d, &s, &p); - cx_locked.guides = false; - let mut t = SelectionTool::default(); - t.pointer(&cx_locked, &ev(PointerKind::Down, 50.0, 50.0)); - t.pointer(&cx_locked, &ev(PointerKind::Drag, 120.0, 120.0)); - let a = t.pointer(&cx_locked, &ev(PointerKind::Up, 120.0, 120.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); - } - - /// #3036: with Lock Guides on, a guide object neither takes the press nor stops a marquee - /// from starting over it; with guides unlocked, the press still picks it. - #[test] - fn marquee_passes_through_locked_guide_objects() { - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - let g = d.alloc_id(); - let pts = [Point::new(20.0, 20.0), Point::new(400.0, 20.0), Point::new(400.0, 400.0), Point::new(20.0, 400.0)]; - let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); - let mut guide = vectorcraft_doc::Node::path(g, path, vectorcraft_doc::Appearance::default_art()); - let vectorcraft_doc::NodeKind::Path { guide: flag, .. } = &mut guide.kind else { panic!("a path") }; - *flag = true; - d.insert(Some(l), 1, guide).unwrap(); - let s = Selection::default(); - let p = paint(); - let locked = ToolContext { guides: false, ..cx(&d, &s, &p) }; - for (x, y) in [(20.0, 60.0), (50.0, 50.0)] { - let mut t = SelectionTool::default(); - assert!(t.pointer(&locked, &ev(PointerKind::Down, x, y)).is_empty(), "press at ({x}, {y})"); - t.pointer(&locked, &ev(PointerKind::Drag, 250.0, 250.0)); - assert!(matches!(t.state, State::Marquee { .. }), "press at ({x}, {y})"); - // The rectangle is rubber-band selected (the engine leaves locked guides out). - let a = t.pointer(&locked, &ev(PointerKind::Up, 250.0, 250.0)); - let Some(Action::Exec(c, v)) = a.first() else { panic!("press at ({x}, {y}): {a:?}") }; - assert_eq!(c, "select.set", "press at ({x}, {y})"); - assert!(v["ids"].as_array().unwrap().contains(&json!(id.0)), "press at ({x}, {y}): {a:?}"); - } - let unlocked = ToolContext { guides: true, ..cx(&d, &s, &p) }; - let mut t = SelectionTool::default(); - assert_eq!(t.pointer(&unlocked, &ev(PointerKind::Down, 20.0, 60.0)), vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); - } - - #[test] - fn handle_drag_scales() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), Mods::default()), Cursor::ResizeNwSe); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)), vec![Action::Begin("Scale".into())]); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0)); - assert!(matches!(&a[0], Action::Preview(_, v) if v["matrix"][0] == 2.0)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); - } - - #[test] - fn handle_drag_snaps_to_the_other_objects_with_smart_guides() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let r = Rect::new(250.0, 100.0, 300.0, 150.0); - d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let mut c = cx(&d, &s, &p); - let height = |a: &[Action]| { - let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; - v["matrix"][3].as_f64().unwrap() * 50.0 - }; - // The bottom handle, dragged near the neighbour's bottom edge (y = 200), lands on it. - let mut t = SelectionTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); - let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0)); - assert!((height(&a) - 100.0).abs() < 1e-9, "{a:?}"); - assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. }))); - t.pointer(&c, &ev(PointerKind::Up, 275.0, 197.0)); - assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. }))); - // Smart Guides off: the edge follows the pointer. - c.smart_guides = false; - t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); - let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0)); - assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}"); - } - - /// The box the 100–200 rectangle's bounds become under the scale a handle drag previews. - fn scaled(a: &[Action]) -> Rect { - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); - Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]).transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 200.0)) - } - - fn near(a: Rect, b: Rect) -> bool { - [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9) - } - - /// #2054: with Snap to Grid on, a handle drag lands what the handle moves on the nearest - /// gridline (every 9 pt by default) while the opposite side stays put; Shift keeps the - /// proportions, and Alt keeps the center. - #[test] - fn handle_drag_lands_what_it_moves_on_the_grid() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - let drag = |from: (f64, f64), to: (f64, f64), mods: Mods| { - let mut t = SelectionTool::default(); - assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)), vec![Action::Begin("Scale".into())]); - scaled(&t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods))) - }; - let none = Mods::default(); - // The right side, dragged to x = 213, lands on the gridline at 216. - let r = drag((200.0, 150.0), (213.0, 150.0), none); - assert!(near(r, Rect::new(100.0, 100.0, 216.0, 200.0)), "{r:?}"); - // The bottom-right corner, dragged to (213, 204), lands on (216, 207). - let r = drag((200.0, 200.0), (213.0, 204.0), none); - assert!(near(r, Rect::new(100.0, 100.0, 216.0, 207.0)), "{r:?}"); - // Shift: the corner, at (213, 213) in proportion, lands on (216, 216), still square. - let r = drag((200.0, 200.0), (213.0, 204.0), Mods { shift: true, ..none }); - assert!(near(r, Rect::new(100.0, 100.0, 216.0, 216.0)), "{r:?}"); - // Alt: the right side lands on 216 and the left side mirrors it about the center. - let r = drag((200.0, 150.0), (213.0, 150.0), Mods { alt: true, ..none }); - assert!(near(r, Rect::new(84.0, 100.0, 216.0, 200.0)), "{r:?}"); - } - - /// #2054: with Snap to Pixel on, what a handle drag moves lands on whole pixels. - #[test] - fn handle_drag_lands_what_it_moves_on_whole_pixels() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let c = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) }; - let mut t = SelectionTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0)); - let r = scaled(&t.pointer(&c, &ev(PointerKind::Drag, 212.4, 230.7))); - assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}"); - } - - /// #2054: with Smart Guides off, View › Snap to Point lands a dragged handle on another - /// object's anchor or a ruler guide within its distance (2 px); with Smart Guides on, the - /// ruler guides still pull it within the Snapping Tolerance (4 px), as a move does (#1534). - #[test] - fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - let other = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0)); - d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)]; - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let drag = |c: &ToolContext, from: (f64, f64), to: (f64, f64)| { - let mut t = SelectionTool::default(); - t.pointer(c, &ev(PointerKind::Down, from.0, from.1)); - scaled(&t.pointer(c, &ev(PointerKind::Drag, to.0, to.1))) - }; - let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; - assert!(off.snap_to_point); - // The corner, 1.4 px from the other square's corner, lands on it. - let r = drag(&off, (200.0, 200.0), (249.0, 251.0)); - assert!(near(r, Rect::new(100.0, 100.0, 250.0, 250.0)), "{r:?}"); - // The right side, 1 px from the ruler guide, lands on it. - let r = drag(&off, (200.0, 150.0), (231.5, 150.0)); - assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}"); - // Snap to Point off: the side follows the pointer. - let r = drag(&ToolContext { snap_to_point: false, ..off }, (200.0, 150.0), (231.5, 150.0)); - assert!(near(r, Rect::new(100.0, 100.0, 231.5, 200.0)), "{r:?}"); - // Smart Guides on: the ruler guide, 2.5 px away, pulls the side too. - let r = drag(&cx(&d, &s, &p), (200.0, 150.0), (233.0, 150.0)); - assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}"); - } - - /// Smart Guides › Measurement Labels, Transform Tools and Snapping Tolerance (#394): the - /// readouts while moving and while scaling each follow their option; the tolerance sets how - /// far a dragged handle is pulled. - #[test] - fn smart_guide_display_preferences_drive_the_readouts_and_the_pull() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 100.0, 300.0, 150.0)), Appearance::default_art())) - .unwrap(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let measures = |t: &SelectionTool, c: &ToolContext| t.overlays(c).iter().filter(|o| matches!(o, Overlay::Measure { .. })).count(); - let height = |a: &[Action]| { - let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; - v["matrix"][3].as_f64().unwrap() * 50.0 - }; - // Moving: the offset readout is a measurement label. - for on in [true, false] { - let c = ToolContext { measurement_labels: on, ..cx(&d, &s, &p) }; - let mut t = SelectionTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 275.0, 125.0)); - t.pointer(&c, &ev(PointerKind::Drag, 285.0, 135.0)); - assert_eq!(measures(&t, &c), usize::from(on), "measurement labels {on}"); - t.pointer(&c, &ev(PointerKind::Up, 285.0, 135.0)); - } - // Scaling by a handle: the size readout belongs to Transform Tools. - for on in [true, false] { - let c = ToolContext { transform_tools_guides: on, ..cx(&d, &s, &p) }; - let mut t = SelectionTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); - t.pointer(&c, &ev(PointerKind::Drag, 275.0, 180.0)); - assert_eq!(measures(&t, &c), usize::from(on), "transform tools {on}"); - t.pointer(&c, &ev(PointerKind::Up, 275.0, 180.0)); - } - // Snapping Tolerance: 6 px from the neighbour's bottom edge is beyond 4, within 8. - for (tol, want) in [(4.0, 94.0), (8.0, 100.0), (0.0, 94.0)] { - let c = ToolContext { snapping_tolerance: tol, ..cx(&d, &s, &p) }; - let mut t = SelectionTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); - let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 194.0)); - assert!((height(&a) - want).abs() < 1e-9, "tolerance {tol}: {a:?}"); - t.pointer(&c, &ev(PointerKind::Up, 275.0, 194.0)); - } - } - - #[test] - fn handle_drag_of_a_turned_box_snaps_on_the_page() { - use vectorcraft_doc::{Appearance, Node}; - let matrix = |a: &[Action]| { - let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; - let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); - Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]) - }; - // A 50 pt square beside the 100..200 rect, turned about its centre (275, 125). - for (turn, low) in [(90.0_f64, 150.0), (45.0, 125.0 + 25.0 * std::f64::consts::SQRT_2)] { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let r = Rect::new(250.0, 100.0, 300.0, 150.0); - d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); - let c = Point::new(275.0, 125.0); - d.node_mut(id) - .unwrap() - .transform(Affine::translate(c.to_vec2()) * Affine::rotate(turn.to_radians()) * Affine::translate(-c.to_vec2()), false); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let bx = selection_box(&cx).unwrap(); - assert_ne!(bx.angle, 0.0); - // The handle at the lowest point of the shape: a side at 90°, a corner at 45°. - let grab = Point::new(275.0, low); - assert!(Handle::ALL.iter().any(|h| (bx.to_doc() * h.pos(bx.rect)).distance(grab) < 1e-6), "{bx:?}"); - let mut t = SelectionTool::default(); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, grab.x, grab.y)), vec![Action::Begin("Scale".into())]); - // Dragged near the neighbour's bottom edge (y = 200), it lands on it. - let a = t.pointer(&cx, &ev(PointerKind::Drag, 275.0, 197.0)); - let landed = matrix(&a) * grab; - assert!((landed.y - 200.0).abs() < 1e-6 && (landed.x - 275.0).abs() < 1e-6, "{turn}: {landed:?}"); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { .. }))); - t.pointer(&cx, &ev(PointerKind::Up, 275.0, 197.0)); - } - } - - #[test] - fn handle_drag_on_area_type_resizes_its_frame() { - let (d, text) = doc_with_area_type(); - let p = paint(); - let mut s = Selection::default(); - s.add(text); - let cx1 = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - // Bottom-right handle of the 120 × 40 frame at (300, 300). - assert_eq!(t.pointer(&cx1, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Resize Type Area".into())]); - let a = t.pointer(&cx1, &ev(PointerKind::Drag, 460.0, 400.0)); - assert!(matches!(&a[0], Action::Preview(c, v) if c == "object.transform" && v["typeAreas"] == true), "{a:?}"); - assert_eq!(t.pointer(&cx1, &ev(PointerKind::Up, 460.0, 400.0)), vec![Action::Commit]); - // With another object the step is a scale, still resizing the type area. - let rect = d.layers[0].children().unwrap()[0].id; - s.add(rect); - let cx2 = cx(&d, &s, &p); - assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Scale".into())]); - let a = t.pointer(&cx2, &ev(PointerKind::Drag, 460.0, 400.0)); - assert!(matches!(&a[0], Action::Preview(_, v) if v["typeAreas"] == true), "{a:?}"); - t.pointer(&cx2, &ev(PointerKind::Up, 460.0, 400.0)); - // Without area type the drag scales as before. - let mut s = Selection::default(); - s.add(rect); - let cx3 = cx(&d, &s, &p); - t.pointer(&cx3, &ev(PointerKind::Down, 200.0, 200.0)); - let a = t.pointer(&cx3, &ev(PointerKind::Drag, 300.0, 300.0)); - assert!(matches!(&a[0], Action::Preview(_, v) if v.get("typeAreas").is_none()), "{a:?}"); - } - - #[test] - fn turned_box_handles_turn_and_scale_along_the_object() { - let (mut d, id) = doc_with_rect(); - let c = Point::new(150.0, 150.0); - let turn = Affine::translate(c.to_vec2()) * Affine::rotate(-std::f64::consts::FRAC_PI_4) * Affine::translate(-c.to_vec2()); - d.node_mut(id).unwrap().transform(turn, false); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - // The Right handle sits up and to the right, where a diagonal cursor fits it. - let k = 50.0 * std::f64::consts::FRAC_1_SQRT_2; - let right = Point::new(150.0 + k, 150.0 - k); - let mut t = SelectionTool::default(); - assert_eq!(t.cursor(&cx, right, Mods::default()), Cursor::ResizeNeSw); - // The page box's corner is no handle any more. - assert_ne!(t.cursor(&cx, Point::new(150.0 + 2.0 * k, 150.0 - 2.0 * k), Mods::default()), Cursor::ResizeNeSw); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y)), vec![Action::Begin("Scale".into())]); - let a = t.pointer(&cx, &ev(PointerKind::Drag, right.x + k, right.y - k)); - let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; - // Doubling the width along 45° about the left side: the left handle stays put. - let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); - let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]); - let left = Point::new(150.0 - k, 150.0 + k); - assert!((m * left).distance(left) < 1e-9); - assert!((m * right).distance(Point::new(right.x + k, right.y - k)) < 1e-9); - } - - #[test] - fn click_empty_deselects() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]); - } - - /// [`doc_with_area_type`] with more text than its frame holds, selected; the rectangle's id and - /// where the frame's out port is. - fn overflowing_area_type() -> (vectorcraft_doc::Document, NodeId, NodeId, Point) { - let (mut d, id) = doc_with_area_type(); - let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id; - let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") }; - let style = vectorcraft_doc::CharStyle { size: 20.0, ..Default::default() }; - t.runs = vec![vectorcraft_doc::TextRun::new("Words that fill more than the frame holds. ".repeat(20), style)]; - // On the frame's right side, above its bottom-right corner (120 × 40 at 100%). - let port = t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX); - (d, id, rect, port) - } - - /// Type on a path whose text overflows has its out port just past its end bracket, along the - /// path: a click there loads the pointer with its text, as area type's does. - #[test] - fn the_out_port_of_type_on_a_path_loads_its_text() { - let (mut d, id) = doc_with_area_type(); - let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") }; - let line = vectorcraft_geom::kurbo::Line::new((0.0, 200.0), (100.0, 200.0)); - t.kind = vectorcraft_doc::TextKind::OnPath { - path: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::kurbo::Shape::to_path(&line, 0.1)), - start: 0.0, - end: Some(0.8), - }; - t.xf = vectorcraft_geom::Affine::IDENTITY; - t.runs = vec![vectorcraft_doc::TextRun::new("Words that run past the end of the path. ".repeat(5), vectorcraft_doc::CharStyle::default())]; - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - // The end bracket stands at 80, 200; the port past it, along the path. - let port = Point::new(80.0 + crate::text::PORT_RISE_PX, 200.0); - assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty()); - assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty()); - assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); - assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty()); - assert_eq!( - t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), - vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))] - ); - } - - /// #2572: a click on the out port loads the pointer with the text; a click on empty canvas then - /// threads it into a new frame there, a click on a closed path into the path, a drag into the - /// rectangle it draws. - #[test] - fn a_click_on_the_out_port_loads_the_text_and_the_next_click_or_drag_threads_it() { - let (d, id, rect, port) = overflowing_area_type(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)]; - let load = |t: &mut SelectionTool| { - assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty()); - assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty()); - assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); - }; - load(&mut t); - assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty()); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0]}))); - assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "the text is placed"); - load(&mut t); - t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0}))); - load(&mut t); - t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); - assert!(t.overlays(&cx).contains(&Overlay::Marquee(Rect::new(20.0, 20.0, 80.0, 90.0))), "the new frame's outline"); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0]}))); - // The bounding box's corner handle below the port still resizes the frame. - let corner = Point::new(port.x, port.y + crate::text::PORT_RISE_PX); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]); - } - - /// Loaded text clicked just inside a layer's clipping mask, where art is drawn over the mask's - /// edge, threads into that art, not into the unpainted mask: placing text acts on what is drawn, - /// so it takes the ordinary hit, not the selection tools' clipping-path outline. - #[test] - fn loaded_text_threads_into_the_art_on_a_layer_masks_edge() { - let (mut d, id, _, port) = overflowing_area_type(); - let art = d.alloc_id(); - let picture = vectorcraft_doc::Node::path( - art, - vectorcraft_geom::shapes::rectangle(Rect::new(350.0, 350.0, 550.0, 550.0)), - vectorcraft_doc::Appearance::default_art(), - ); - add_masked_layer(&mut d, Rect::new(400.0, 400.0, 500.0, 500.0), picture); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - assert!(cx.selection_hit_options().clip_outlines, "the selection tools would take the mask here"); - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); - t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); - t.pointer(&cx, &ev(PointerKind::Down, 498.5, 450.0)); - assert_eq!( - t.pointer(&cx, &ev(PointerKind::Up, 498.5, 450.0)), - vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "to": art.0}))] - ); - } - - /// A double-click on type where a layer's clipping mask edge runs through it edits the type, - /// though a press there selects the mask: what a double-click edits is what is drawn. - #[test] - fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() { - let (d, _, at) = doc_with_type_on_a_layer_masks_edge(); - let (s, p) = (Selection::default(), paint()); - let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::DoubleClick, at.x, at.y)); - assert_eq!(a, vec![Action::SwitchTool("type".into())]); - } - - /// A Cmd/Ctrl+Shift double-click releases a guide path where it crosses a layer's clipping - /// mask edge (its left side, x = 450, just inside the mask's bottom): the mask, unpainted and - /// never a guide, doesn't stand in front of it. - #[test] - fn a_guide_on_a_layer_masks_edge_is_released() { - let (mut d, _) = doc_with_rect(); - let id = d.alloc_id(); - let mut guide_path = - vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(450.0, 350.0, 550.0, 550.0)), Default::default()); - let vectorcraft_doc::NodeKind::Path { guide, .. } = &mut guide_path.kind else { panic!("a path") }; - *guide = true; - add_masked_layer(&mut d, Rect::new(400.0, 400.0, 500.0, 500.0), guide_path); - let (s, p) = (Selection::default(), paint()); - let both = Mods { cmd: true, shift: true, ..Mods::default() }; - let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::DoubleClick, 450.0, 498.5).with_mods(both)); - assert_eq!(a, vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]); - } - - /// Escape drops the loaded text, and the next click is an ordinary one. - #[test] - fn escape_drops_the_loaded_text() { - let (d, id, _, port) = overflowing_area_type(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); - t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); - assert!(t.claims_key(&cx, ToolKey::Escape), "Escape is the loaded pointer's"); - assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); - assert!(!t.claims_key(&cx, ToolKey::Escape)); - assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); - t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), vec![Action::Exec("select.none".into(), json!({}))]); - } - - /// [`doc_with_area_type`], its text fitting its frame; where its in and out ports are. - fn fitting_area_type() -> (vectorcraft_doc::Document, NodeId, Point, Point) { - let (d, id) = doc_with_area_type(); - let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") }; - assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).overflow, "the text fits"); - // On the left side below the top-left corner, on the right side above the bottom-right one. - let (input, output) = (t.xf * Point::new(0.0, crate::text::PORT_RISE_PX), t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX)); - (d, id, input, output) - } - - /// #2572: selected area type has both ports to click whether its text fits or not; other - /// points of its frame, and type that isn't selected, have none. - #[test] - fn every_selected_area_type_has_an_in_and_an_out_port() { - use crate::text::{Port, port_at}; - let (d, id, input, output) = fitting_area_type(); - let p = paint(); - let mut s = Selection::default(); - assert_eq!(port_at(&cx(&d, &s, &p), output), None, "not selected"); - s.set([id]); - let cx = cx(&d, &s, &p); - assert_eq!(port_at(&cx, output), Some((id, Port::Out))); - assert_eq!(port_at(&cx, input), Some((id, Port::In))); - assert_eq!(port_at(&cx, Point::new(input.x + 4.0, input.y + 4.0)), Some((id, Port::In)), "the box around it"); - assert_eq!(port_at(&cx, Point::new(input.x + 12.0, input.y)), None); - assert_eq!(port_at(&cx, Point::new(output.x, output.y + crate::text::PORT_RISE_PX)), None, "the corner"); - } - - /// #2572: a click on the out port of type that fits loads its text as well; the next click - /// threads it into a new frame after it. - #[test] - fn a_click_on_the_out_port_of_type_that_fits_starts_threading() { - let (d, id, _, output) = fitting_area_type(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - assert!(t.pointer(&cx, &ev(PointerKind::Down, output.x, output.y)).is_empty()); - assert!(t.pointer(&cx, &ev(PointerKind::Up, output.x, output.y)).is_empty()); - assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); - t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); - assert_eq!( - t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), - vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))] - ); - } - - /// #2572: a click on the in port loads the text to thread backwards: the next click on empty - /// canvas, on a closed path or a drag makes the frame the story flows from into this one - /// (`before`). The corner handle above the in port still resizes the frame. - #[test] - fn a_click_on_the_in_port_threads_into_a_frame_before_it() { - let (d, id, input, _) = fitting_area_type(); - let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id; - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = SelectionTool::default(); - let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)]; - let load = |t: &mut SelectionTool| { - assert!(t.pointer(&cx, &ev(PointerKind::Down, input.x, input.y)).is_empty()); - assert!(t.pointer(&cx, &ev(PointerKind::Up, input.x, input.y)).is_empty()); - assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); - }; - load(&mut t); - t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0], "before": true}))); - load(&mut t); - t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0, "before": true}))); - load(&mut t); - t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0], "before": true}))); - let corner = Point::new(input.x, input.y - crate::text::PORT_RISE_PX); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]); - } - - /// A double-click on a threaded port breaks the thread there, its in port before the frame - /// and its out port after it; it doesn't thread the loaded text into a new frame. A - /// double-click on a port with no thread does nothing (it doesn't switch to the Type tool). - #[test] - fn a_double_click_on_a_threaded_port_breaks_the_thread() { - let (mut d, id, input, output) = fitting_area_type(); - let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id; - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let double_click = |d: &vectorcraft_doc::Document, at: Point| { - let cx = cx(d, &s, &p); - let mut t = SelectionTool::default(); - let kinds = [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick]; - let out: Vec = kinds.into_iter().flat_map(|k| t.pointer(&cx, &ev(k, at.x, at.y))).collect(); - assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "no text stays loaded"); - out - }; - let breaks = |port: &str| vec![Action::Exec("text.thread.break".into(), json!({"id": id.0, "port": port}))]; - d.text_threads = vec![vec![id, rect]]; - assert_eq!(double_click(&d, output), breaks("out")); - assert_eq!(double_click(&d, input), vec![], "no frame before it"); - d.text_threads = vec![vec![rect, id]]; - assert_eq!(double_click(&d, input), breaks("in")); - assert_eq!(double_click(&d, output), vec![], "no frame after it"); - d.text_threads.clear(); - assert_eq!(double_click(&d, output), vec![]); - } - - /// The memo answers for the revision, the selected objects and Use Preview Bounds it measured - /// for, and measures again when any of them changes. - #[test] - fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() { - let (d, id) = doc_with_rect(); - let mut moved = d.clone(); - if let Some(n) = moved.node_mut(id) { - n.transform(Affine::translate((50.0, 0.0)), false); - } - let (mut s, p, memo) = (Selection::default(), paint(), PreviewMemo::default()); - s.set([id]); - let at = |d, s, revision, preview_bounds| { - let c = ToolContext { revision, preview_bounds, preview_memo: Some(&memo), ..cx(d, s, &p) }; - (selection_bounds(&c).map(|r| r.x1), object_bounds(&c, id).map(|r| r.x1)) - }; - assert_eq!(at(&d, &s, (1, 1), false), (Some(200.0), Some(200.0))); - assert_eq!(memo.measurements(), 2); - // The same revision with the moved square: the memo answers with what it measured. - assert_eq!(at(&moved, &s, (1, 1), false), (Some(200.0), Some(200.0))); - assert_eq!(memo.measurements(), 2); - assert_eq!(at(&moved, &s, (1, 2), false), (Some(250.0), Some(250.0)), "a new revision"); - assert_eq!(memo.measurements(), 4); - assert_eq!(at(&moved, &s, (1, 2), true), (Some(250.5), Some(250.5)), "Use Preview Bounds counts the stroke"); - assert_eq!(memo.measurements(), 6); - let none = Selection::default(); - assert_eq!(at(&moved, &none, (1, 2), true), (None, Some(250.5)), "another selection"); - assert_eq!(memo.measurements(), 8); - // Without a memo, every call measures. - let c = cx(&moved, &s, &p); - assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0)); - } -} +mod tests; diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs new file mode 100644 index 000000000..32798d896 --- /dev/null +++ b/crates/tools/src/select/tests.rs @@ -0,0 +1,1679 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) +} + +/// A Selection tool press on a clip group's mask edge, where the art it clips lies over it, +/// selects the clip group, as a press on the art inside does. +#[test] +fn a_press_on_a_masks_edge_selects_its_clip_group() { + let (d, g, _, _) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + for x in [500.0, 501.5, 450.0] { + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, x, 450.0)); + assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([g.0]))), "at {x}: {a:?}"); + } + // Just outside the mask, beyond the tolerance, the clipped-away art isn't there. + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 520.0, 450.0)); + assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}"); +} + +/// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands +/// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it. +#[test] +fn a_move_grabbed_by_an_anchor_lands_it_on_another_anchor() { + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + let tri = d.alloc_id(); + let pts = [Point::new(350.3, 250.0), Point::new(300.0, 320.0), Point::new(420.0, 330.0)]; + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(tri, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (none, mut picked) = (Selection::default(), Selection::default()); + picked.add(id); + let p = paint(); + let mut t = SelectionTool::default(); + // Grab the rectangle (not selected yet: no bounding box) by its bottom-right corner + // (200, 200); the press selects it, then drop it beside the tip. + let before = cx(&d, &none, &p); + t.pointer(&before, &ev(PointerKind::Move, 201.0, 201.0)); + t.pointer(&before, &ev(PointerKind::Down, 201.0, 201.0)); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 350.0, 249.8)); + let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; + let (x, y) = (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()); + assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y); +} + +/// #3716: a press a few pixels from an anchor grabs the object by that anchor, but the drag +/// threshold counts from where the pointer went down. A click that wobbles by a pixel selects +/// the object without moving it, instead of jumping the anchor onto the pointer. +#[test] +fn a_click_near_an_anchor_selects_without_moving() { + let (d, id) = doc_with_rect(); + let (none, mut picked) = (Selection::default(), Selection::default()); + picked.add(id); + let p = paint(); + let mut t = SelectionTool::default(); + // 5.7 px from the corner (200, 200): within reach of it, beyond the drag threshold. + let a = t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 196.0, 196.0)); + assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([id.0]))), "{a:?}"); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 197.0, 196.0)); + assert!(a.is_empty(), "a 1 px wobble moves the object: {a:?}"); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Up, 197.0, 196.0)); + assert!(!a.iter().any(|a| matches!(a, Action::Commit)), "{a:?}"); + // A drag past the threshold from the press still moves it by the corner (#812). + let mut t = SelectionTool::default(); + t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 196.0, 196.0)); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 230.0, 196.0)); + let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; + assert_eq!((v["matrix"][4].as_f64(), v["matrix"][5].as_f64()), (Some(30.0), Some(-4.0)), "the corner follows the pointer: {a:?}"); +} + +/// A corner-radius drag is a transform, so the sharp selection box hides while the radius changes. +#[test] +fn a_corner_drag_counts_as_transforming_once_it_moves() { + use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + }; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let id = d.alloc_id(); + let mut n = Node::path(id, live.to_path(), Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(live)); + } + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(!t.transforming()); + t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0)); + assert!(!t.transforming(), "a press is still a click"); + t.pointer(&cx, &ev(PointerKind::Drag, 175.0, 176.0)); + assert!(t.transforming()); +} + +/// A type-on-path bracket drag is a transform, so the sharp selection box hides while the span moves. +#[test] +fn a_bracket_drag_counts_as_transforming_once_it_moves() { + let (d, id) = crate::testutil::doc_with_path_type(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(!t.transforming()); + t.pointer(&cx, &ev(PointerKind::Down, 280.0, 290.0)); + assert!(!t.transforming(), "a press is still a click"); + t.pointer(&cx, &ev(PointerKind::Drag, 320.0, 290.0)); + assert!(t.transforming()); +} + +/// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a +/// press there grabs the object by that point. +#[test] +fn hovering_labels_the_centre_and_anchors_and_a_press_grabs_by_them() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let label = |t: &SelectionTool| { + t.overlays(&cx).iter().find_map(|o| match o { + Overlay::Label { p, text, .. } => Some((*p, text.clone())), + _ => None, + }) + }; + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0)); + assert_eq!(label(&t), Some((Point::new(150.0, 150.0), "center".to_string()))); + t.pointer(&cx, &ev(PointerKind::Move, 102.0, 101.0)); + assert_eq!(label(&t), Some((Point::new(100.0, 100.0), "anchor".to_string()))); + t.pointer(&cx, &ev(PointerKind::Move, 130.0, 170.0)); + assert_eq!(label(&t), None, "away from both"); + // Grabbed by its centre, the object's centre follows the pointer. + t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0)); + t.pointer(&cx, &ev(PointerKind::Down, 152.0, 151.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 250.0)); + assert!(a.iter().any(|a| matches!(a, Action::Preview(_, v) if v["matrix"][4] == 100.0 && v["matrix"][5] == 100.0)), "{a:?}"); + // With Smart Guides off, nothing is labelled. + let off = ToolContext { smart_guides: false, ..cx }; + let mut t = SelectionTool::default(); + t.pointer(&off, &ev(PointerKind::Move, 152.0, 151.0)); + assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Label { .. }))); +} + +/// [`doc_with_rect`] (100, 100)–(200, 200), selected, and a second rectangle `other`. +fn two_rects(other: Rect) -> (vectorcraft_doc::Document, NodeId, NodeId) { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let b = d.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(other); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, a, b) +} + +fn alt(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + ev(kind, x, y).with_mods(Mods { alt: true, ..Mods::default() }) +} + +/// A Distance Guides measured line: its ends and its label. +type DistanceLine = (Point, Point, String); + +/// The Distance Guides overlays: each measured line, and the dashed lines out to it, in order. +fn distances(t: &SelectionTool, cx: &ToolContext) -> (Vec, Vec<(Point, Point)>) { + let o = t.overlays(cx); + let lines = o.iter().filter_map(|o| if let Overlay::Distance { a, b, text, .. } = o { Some((*a, *b, text.clone())) } else { None }); + let dashed = o.iter().filter_map(|o| if let Overlay::Line { a, b, dashed: true, .. } = o { Some((*a, *b)) } else { None }); + (lines.collect(), dashed.collect()) +} + +fn line(a: (f64, f64), b: (f64, f64), text: &str) -> DistanceLine { + (Point::new(a.0, a.1), Point::new(b.0, b.1), text.to_string()) +} + +/// Alt held over an object beside the selection shows the horizontal gap between their +/// facing edges across the middle of where they overlap, and the vertical offsets of their +/// top and bottom edges across the selection's middle, with dashed lines out to the other +/// object's edges (helpx: Use Distance Guides for accurate placement). +#[test] +fn alt_over_an_object_beside_the_selection_measures_the_gap_and_the_edges() { + let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); + let s = Selection { objects: vec![a], ..Default::default() }; + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); + let (lines, dashed) = distances(&t, &cx); + assert_eq!( + lines, + vec![ + line((200.0, 150.0), (300.0, 150.0), "100.00 pt"), + line((150.0, 100.0), (150.0, 120.0), "20.00 pt"), + line((150.0, 200.0), (150.0, 180.0), "20.00 pt"), + ] + ); + let p = Point::new; + assert_eq!(dashed, vec![(p(300.0, 120.0), p(150.0, 120.0)), (p(300.0, 180.0), p(150.0, 180.0))]); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Distance { color, .. } if *color != cx.smart_guide_color))); +} + +/// Below the selection: the vertical gap, and the left and right edges' offsets. +#[test] +fn alt_over_an_object_below_the_selection_measures_the_vertical_gap() { + let (d, a, _) = two_rects(Rect::new(120.0, 300.0, 180.0, 400.0)); + let s = Selection { objects: vec![a], ..Default::default() }; + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &alt(PointerKind::Move, 150.0, 350.0)); + let (lines, dashed) = distances(&t, &cx); + assert_eq!( + lines, + vec![ + line((100.0, 150.0), (120.0, 150.0), "20.00 pt"), + line((200.0, 150.0), (180.0, 150.0), "20.00 pt"), + line((150.0, 200.0), (150.0, 300.0), "100.00 pt"), + ] + ); + assert_eq!(dashed.len(), 2, "out to the left and right edges of the object below"); +} + +/// Alt held over an empty part of the artboard: the selection's distance from the artboard's +/// left, right, top and bottom edges, in the General unit. +#[test] +fn alt_over_the_artboard_measures_to_its_four_edges_in_the_general_unit() { + let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); + let s = Selection { objects: vec![a], ..Default::default() }; + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &alt(PointerKind::Move, 50.0, 450.0)); + let (lines, dashed) = distances(&t, &cx); + assert_eq!( + lines, + vec![ + line((100.0, 150.0), (0.0, 150.0), "100.00 pt"), + line((200.0, 150.0), (500.0, 150.0), "300.00 pt"), + line((150.0, 100.0), (150.0, 0.0), "100.00 pt"), + line((150.0, 200.0), (150.0, 500.0), "300.00 pt"), + ] + ); + assert!(dashed.is_empty()); + let mm = ToolContext { unit: vectorcraft_doc::Unit::Millimeters, ..cx }; + t.pointer(&mm, &alt(PointerKind::Move, 50.0, 450.0)); + let texts: Vec = distances(&t, &mm).0.into_iter().map(|l| l.2).collect(); + assert_eq!(texts, ["35.28 mm", "105.83 mm", "35.28 mm", "105.83 mm"]); + // Off the artboard, over nothing: no distances. + t.pointer(&cx, &alt(PointerKind::Move, 600.0, 600.0)); + assert!(distances(&t, &cx).0.is_empty()); +} + +/// No Distance Guides without Alt, with nothing selected, over the selection itself, with +/// Smart Guides or Invoke Distance Guides off, or once Alt is let go. +#[test] +fn distance_guides_need_alt_a_selection_and_smart_guides() { + let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); + let s = Selection { objects: vec![a], ..Default::default() }; + let none = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shown = |cx: &ToolContext, e: PointerEvent| { + let mut t = SelectionTool::default(); + t.pointer(cx, &e); + !distances(&t, cx).0.is_empty() + }; + assert!(shown(&cx, alt(PointerKind::Move, 350.0, 150.0))); + assert!(!shown(&cx, ev(PointerKind::Move, 350.0, 150.0)), "without Alt"); + assert!(!shown(&cx, ev(PointerKind::Move, 50.0, 450.0)), "without Alt, over the artboard"); + assert!(!shown(&crate::testutil::cx(&d, &none, &p), alt(PointerKind::Move, 350.0, 150.0)), "nothing selected"); + assert!(!shown(&cx, alt(PointerKind::Move, 150.0, 150.0)), "over the selection"); + assert!( + !shown(&ToolContext { smart_guides: false, ..crate::testutil::cx(&d, &s, &p) }, alt(PointerKind::Move, 350.0, 150.0)), + "View › Smart Guides off" + ); + assert!( + !shown(&ToolContext { distance_guides: false, ..crate::testutil::cx(&d, &s, &p) }, alt(PointerKind::Move, 350.0, 150.0)), + "the preference off" + ); + // Letting Alt go (the canvas sends a hover with the new modifiers) takes them away. + let mut t = SelectionTool::default(); + t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); + t.pointer(&cx, &ev(PointerKind::Move, 350.0, 150.0)); + assert!(distances(&t, &cx).0.is_empty()); + // So does the hover ending (the pointer leaving the canvas) with Alt still held. + t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); + assert!(!distances(&t, &cx).0.is_empty()); + assert!(t.wants_rehover(), "an Alt hover is hovered again when what lies under it changes"); + t.hover_ended(&cx); + assert!(distances(&t, &cx).0.is_empty()); + assert!(!t.wants_rehover()); + t.pointer(&cx, &ev(PointerKind::Move, 350.0, 150.0)); + assert!(!t.wants_rehover(), "nor without Alt"); + // A press ends them too. + t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); + t.pointer(&cx, &alt(PointerKind::Down, 350.0, 150.0)); + assert!(distances(&t, &cx).0.is_empty()); +} + +#[test] +fn click_selects_object() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let a = t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)).is_empty()); +} + +/// #414: a press on a ruler guide picks it over the art, and drags it. +#[test] +fn a_press_on_a_ruler_guide_picks_it_over_the_art() { + let (mut d, _) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&cx, Point::new(160.0, 151.0), Mods::default()), Cursor::ResizeV); + let a = t.pointer(&cx, &ev(PointerKind::Down, 160.0, 151.0)); + assert_eq!(a, vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); + assert!(t.busy()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 171.0)); + assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": false}))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 160.0, 171.0)), vec![Action::Commit]); + assert!(!t.busy()); + // A double-click there doesn't go through to the art. + assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty()); +} + +/// A Cmd/Ctrl+Shift double-click releases the ruler guide or the guide path under the pointer; +/// without both keys, or while guides are hidden or locked, nothing is released. +#[test] +fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; + *guide = true; + let s = Selection::default(); + let p = paint(); + let both = Mods { cmd: true, shift: true, ..Mods::default() }; + let dbl = |c: &ToolContext, x: f64, y: f64, m: Mods| SelectionTool::default().pointer(c, &ev(PointerKind::DoubleClick, x, y).with_mods(m)); + let releases = |a: &[Action]| a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "view.guides.release")); + let c = cx(&d, &s, &p); + assert_eq!(dbl(&c, 350.0, 301.0, both), vec![Action::Exec("view.guides.release".into(), json!({"index": 0}))]); + assert_eq!(dbl(&c, 200.0, 150.0, both), vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]); + assert!(dbl(&c, 350.0, 301.0, Mods::default()).is_empty(), "a plain double-click on a ruler guide"); + for m in [Mods { cmd: true, ..Mods::default() }, Mods { shift: true, ..Mods::default() }, Mods::default()] { + assert!(!releases(&dbl(&c, 200.0, 150.0, m)), "{m:?}"); + } + let locked = ToolContext { guides: false, ..cx(&d, &s, &p) }; + assert!(!releases(&dbl(&locked, 350.0, 301.0, both))); + assert!(!releases(&dbl(&locked, 200.0, 150.0, both))); + // A double-click on art that is not a guide does not release it. + let (plain, _) = doc_with_rect(); + assert!(!releases(&dbl(&cx(&plain, &s, &p), 200.0, 150.0, both))); +} + +/// Just outside each corner of the box the rotate cursor bows toward that corner, its arc +/// opening toward the box: at the top right it opens to the bottom left. +#[test] +fn the_rotate_cursor_wraps_the_corner_it_is_at() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let t = SelectionTool::default(); + // The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally. + for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] { + assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})"); + } + assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre"); + assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up"); +} + +#[test] +fn turned_cursors_step_with_the_view() { + assert_eq!(Cursor::ResizeH.turned(90.0), Cursor::ResizeV); + assert_eq!(Cursor::ResizeNeSw.turned(45.0), Cursor::ResizeH); + assert_eq!(Cursor::ResizeH.turned(45.0), Cursor::ResizeNwSe); + assert_eq!(Cursor::ResizeNwSe.turned(-135.0), Cursor::ResizeV); + assert_eq!(Cursor::RotateToward(0).turned(90.0), Cursor::RotateToward(2)); + assert_eq!(Cursor::RotateToward(1).turned(-45.0), Cursor::RotateToward(0)); + assert_eq!(Cursor::RotateToward(7).turned(180.0), Cursor::RotateToward(3)); + assert_eq!(Cursor::ResizeV.turned(20.0), Cursor::ResizeV, "under 22.5° it stays"); + assert_eq!(Cursor::Pen.turned(90.0), Cursor::Pen); + assert_eq!(Cursor::Rotate.turned(90.0), Cursor::Rotate); + assert_eq!(Cursor::ResizeH.turned(f64::NAN), Cursor::ResizeH); + // `rotate_about_in_view` turns the pointer's direction by the view before it rounds: a + // pointer 15° clockwise from straight up, in a view turned 15°, shows 30° from straight up + // on screen, which rounds to the top right. `turned` rounds the 15° view to 0°. + let (sn, cs) = 15f64.to_radians().sin_cos(); + let q = Point::new(sn, -cs); + assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, q, 15.0), Cursor::RotateToward(1)); + assert_eq!(Cursor::rotate_about(Point::ORIGIN, q).turned(15.0), Cursor::RotateToward(0)); + assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, q, 0.0), Cursor::rotate_about(Point::ORIGIN, q)); + assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::new(0.0, -5.0), 90.0), Cursor::RotateToward(2)); + assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::new(0.0, -5.0), f64::NAN), Cursor::RotateToward(0)); + assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::ORIGIN, 90.0), Cursor::Rotate, "on the center"); +} + +/// In a rotated view the resize and rotate cursors turn with the view. +#[test] +fn box_cursors_turn_with_the_view() { + let (mut d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + let mut c = cx(&d, &s, &p); + c.screen = turned_view(90.0); + assert!((c.view_angle() - 90.0).abs() < 1e-9); + let t = SelectionTool::default(); + // The box runs from (100, 100) to (200, 200). Its right side shows at the bottom, and its + // top shows on the right. + assert_eq!(t.cursor(&c, Point::new(200.0, 150.0), Mods::default()), Cursor::ResizeV, "right-middle"); + assert_eq!(t.cursor(&c, Point::new(150.0, 100.0), Mods::default()), Cursor::ResizeH, "top-middle"); + assert_eq!(t.cursor(&c, Point::new(207.0, 93.0), Mods::default()), Cursor::RotateToward(3), "out from the top right"); + // While a rotate drags, the cursor turns with the view too: straight up the page shows to + // the right. + let mut r = SelectionTool::default(); + assert_eq!(r.pointer(&c, &ev(PointerKind::Down, 207.0, 93.0)), vec![Action::Begin("Rotate".into())]); + assert_eq!(r.cursor(&c, Point::new(150.0, 80.0), Mods::default()), Cursor::RotateToward(2), "rotating"); + // A horizontal ruler guide shows upright and moves left and right on screen. + assert_eq!(t.cursor(&c, Point::new(400.0, 300.0), Mods::default()), Cursor::ResizeH, "guide"); + // A box turned 20° in a view turned 25° shows 5° off level: the right handle shows the + // left-right arrow. + let (center, a) = (Point::new(150.0, 150.0), 20f64.to_radians()); + let turn = Affine::translate(center.to_vec2()) * Affine::rotate(-a) * Affine::translate(-center.to_vec2()); + d.node_mut(id).unwrap().transform(turn, false); + let mut c = cx(&d, &s, &p); + c.screen = turned_view(25.0); + let right = Point::new(150.0 + 50.0 * a.cos(), 150.0 - 50.0 * a.sin()); + assert_eq!(t.cursor(&c, right, Mods::default()), Cursor::ResizeH, "20° box, 25° view"); +} + +#[test] +fn drag_moves_with_single_undo() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][4] == 10.0)); + let a = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 150.0)); + assert_eq!(a, vec![Action::Commit]); +} + +/// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps +/// to a ruler guide within the Snap to Point distance; not with Snap to Point off. +#[test] +fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { + let (mut d, id) = doc_with_rect(); + d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)]; + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let dx = |c: &ToolContext, to: f64| { + let mut t = SelectionTool::default(); + t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(c, &ev(PointerKind::Drag, to, 150.0)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + v["matrix"][4].as_f64().unwrap() + }; + let c = cx(&d, &s, &p); + assert!(c.smart_guides && c.snap_to_point); + assert!((dx(&c, 172.0) - 23.4).abs() < 1e-9, "onto the guide"); + assert_eq!(dx(&c, 165.0), 15.0, "out of reach"); + assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0); +} + +/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as +/// gathered before the previews moved it); released, the move doesn't. +#[test] +fn an_alt_copy_snaps_to_the_original_left_behind() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, id) = doc_with_rect(); + // An artboard whose edges and centre are out of reach. + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut t = SelectionTool::default(); + let dx = |a: &[Action]| { + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + v["matrix"][4].as_f64().unwrap() + }; + t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0); + // The previews moved the rectangle: the tool sees it 50 pt along. + let mut moved = d.clone(); + let l = moved.layers[0].id; + moved.remove(id).unwrap(); + let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0)); + moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap(); + let c = cx(&moved, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + // The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it. + assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0); + // Without Alt nothing stays behind there. + assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0); +} + +/// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right +/// one); Alt, which leaves a copy behind, the double arrowhead. +#[test] +fn a_move_shows_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let l = d.layers[0].id; + let b = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)); + d.insert(Some(l), 1, Node::path(b, sq, Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(b); + let p = paint(); + let c = cx(&d, &s, &p); + let none = Mods::default(); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 350.0, 350.0)); + t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0)); + assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), none), Cursor::Arrow); + t.pointer(&c, &ev(PointerKind::Drag, 253.0, 371.0)); + assert_eq!(t.cursor(&c, Point::new(253.0, 371.0), none), Cursor::ArrowHollow, "lined up with A"); + let alt = Mods { alt: true, ..none }; + t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0).with_mods(alt)); + assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), alt), Cursor::SelectionCopy); +} + +/// #740, #1816: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) +/// by whichever of its edges is nearest a gridline, on each axis. +#[test] +fn a_move_lands_the_nearest_edge_on_the_grid() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = ToolContext { snap_to_grid: true, smart_guides: true, ..cx(&d, &s, &p) }; + let moved = |to: (f64, f64)| { + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1)); + match a.get(1) { + Some(Action::Preview(_, v)) => (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), + other => panic!("{other:?}"), + } + }; + // Bounds 100–200 moved by (13, 4): the right and bottom edges (213, 204) are nearest + // gridlines (216, 207). + let (x, y) = moved((163.0, 154.0)); + assert!((x - 16.0).abs() < 1e-9 && (y - 7.0).abs() < 1e-9, "{x} {y}"); + // Moved by (7, 0): the right edge is on a gridline (207) and the top goes up to one (99). + let (x, y) = moved((157.0, 150.0)); + assert!((x - 7.0).abs() < 1e-9 && (y + 1.0).abs() < 1e-9, "{x} {y}"); +} + +/// The selection Shift-dragged from (150, 150) to `to`: the move it previews, and its guide +/// lines. +fn shift_moved(cx: &ToolContext, to: (f64, f64)) -> (Vec2, Vec<(Point, Point)>) { + let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); + let mut t = SelectionTool::default(); + t.pointer(cx, &shift(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(cx, &shift(PointerKind::Drag, to.0, to.1)); + let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; + let lines = t.overlays(cx).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect(); + (Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), lines) +} + +/// `d` is `want` within 1e-9 on each axis. +fn moved_by(d: Vec2, want: (f64, f64)) -> bool { + (d.x - want.0).abs() < 1e-9 && (d.y - want.1).abs() < 1e-9 +} + +/// A Shift-constrained move snapped to the grid stays on its line: a level or upright one lands +/// on the grid along its own axis only, and a diagonal one slides along the diagonal until an +/// edge is on a gridline on the axis that needs the shorter way there. +#[test] +fn a_shift_move_snapped_to_the_grid_stays_on_its_line() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + // Level by 13.04: the right edge (213.04) lands on the gridline 216, and the top stays at + // 100, between the gridlines 99 and 108. + let (m, _) = shift_moved(&cx, (163.0, 151.0)); + assert!(moved_by(m, (16.0, 0.0)), "{m:?}"); + let (m, _) = shift_moved(&cx, (151.0, 163.0)); + assert!(moved_by(m, (0.0, 16.0)), "{m:?}"); + // Up and to the right by 13 on each axis: the bottom edge (187) is 2 from the gridline 189, + // and the right edge (213) is 3 from 216, so the move goes back along the diagonal by 2 on + // each axis. + let (m, _) = shift_moved(&cx, (163.0, 137.0)); + assert!(moved_by(m, (11.0, -11.0)), "{m:?}"); +} + +/// #1816: on a 20 pt grid (gridlines every 100, 5 subdivisions), the 100 pt square at 100–200 +/// sits on the grid with its center half a step off. A move by (8, 8) brings the left and top +/// edges back onto the gridlines at 100, so the square stays where it was; a move by (31, 29) +/// lands its edges on the nearest gridlines (140, 120). The center does not snap. +#[test] +fn a_move_keeps_an_object_on_the_grid_when_its_center_is_off_it() { + let (mut d, id) = doc_with_rect(); + d.grid = vectorcraft_doc::GridPrefs { spacing: 100.0, subdivisions: 5 }; + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let moved = |to: (f64, f64)| { + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1)); + let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; + Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()) + }; + let m = moved((158.0, 158.0)); + assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); + let m = moved((181.0, 179.0)); + assert!(moved_by(m, (40.0, 20.0)), "{m:?}"); + // With Shift, level and diagonal moves by 8 come back to where they started too. + let (m, _) = shift_moved(&cx, (158.0, 150.0)); + assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); + let (m, _) = shift_moved(&cx, (158.0, 158.0)); + assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); +} + +/// A Shift-constrained move with Snap to Pixel on puts the top-left on a whole pixel on the +/// axis its line runs along, and on a diagonal on the axis that needs the shorter way there. +#[test] +fn a_shift_move_snapped_to_pixels_stays_on_its_line() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) }; + // Pressed at (150, 150), the square is grabbed by its center (150.3, 150.4). + let (m, _) = shift_moved(&cx, (163.0, 151.0)); + assert!(moved_by(m, (12.7, 0.0)), "{m:?}"); + let (m, _) = shift_moved(&cx, (151.0, 163.0)); + assert!(moved_by(m, (0.0, 12.6)), "{m:?}"); + // By 13.16 on each axis, the top-left (113.46, 113.56) is 0.44 from a whole pixel on y and + // 0.46 on x. + let (m, _) = shift_moved(&cx, (163.0, 164.0)); + assert!(moved_by(m, (13.6, 13.6)), "{m:?}"); +} + +/// A Shift-constrained move that Smart Guides line up with another object stays on its line: +/// a level or upright one lines up along its own axis only, with the guide of that axis, and +/// a diagonal one slides along the diagonal to the target that needs the shorter way. +#[test] +fn a_shift_move_lined_up_by_smart_guides_stays_on_its_line() { + let with = |r: Rect| { + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + let other = d.alloc_id(); + d.insert( + Some(l), + 1, + vectorcraft_doc::Node::path(other, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art()), + ) + .unwrap(); + (d, id) + }; + let p = paint(); + // Level by 100.02: the right edge lines up with the other square's left (300). That + // square's top (203), 3 below the selection's bottom and within reach, does not pull it + // down. + let (d, id) = with(Rect::new(300.0, 203.0, 350.0, 253.0)); + let mut s = Selection::default(); + s.add(id); + let (m, lines) = shift_moved(&cx(&d, &s, &p), (250.0, 152.0)); + assert!(moved_by(m, (100.0, 0.0)), "{m:?}"); + assert!(!lines.is_empty() && lines.iter().all(|(a, b)| a.x == b.x), "only upright guides: {lines:?}"); + let (d, id) = with(Rect::new(203.0, 300.0, 253.0, 350.0)); + let mut s = Selection::default(); + s.add(id); + let (m, lines) = shift_moved(&cx(&d, &s, &p), (152.0, 250.0)); + assert!(moved_by(m, (0.0, 100.0)), "{m:?}"); + assert!(!lines.is_empty() && lines.iter().all(|(a, b)| a.y == b.y), "only level guides: {lines:?}"); + // By 110.5 on each axis: the right edge (310.5) is 0.5 from the other square's left and the + // bottom 3 from its top, so the move goes on by 0.5 on each axis. + let (d, id) = with(Rect::new(311.0, 313.5, 361.0, 363.5)); + let mut s = Selection::default(); + s.add(id); + let (m, lines) = shift_moved(&cx(&d, &s, &p), (260.5, 260.5)); + assert!(moved_by(m, (111.0, 111.0)), "{m:?}"); + assert!(matches!(lines[..], [(a, b)] if a.x == 311.0 && b.x == 311.0), "{lines:?}"); +} + +/// A level Shift-constrained move that Spacing Guides space evenly keeps its line: the gap to +/// the next square becomes 60, the gap between the other two, and their tops (102), 2 below +/// the selection's, do not pull it down. +#[test] +fn a_shift_move_spaced_by_smart_guides_stays_on_its_line() { + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + for x in [330.0, 440.0] { + let other = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(x, 102.0, x + 50.0, 202.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + } + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let (m, lines) = shift_moved(&cx(&d, &s, &p), (221.0, 151.0)); + assert!(moved_by(m, (70.0, 0.0)), "{m:?}"); + assert!(!lines.is_empty(), "the equal gaps are marked"); +} + +/// A Shift-constrained move that snaps by its grabbed point stays on its line: with Smart +/// Guides off and Snap to Point on, it stops where the line passes nearest an anchor, or +/// where it crosses a ruler guide; with Smart Guides on, Snap to Point still slides it along +/// the line onto a ruler guide. +#[test] +fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + let other = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 151.5, 350.0, 201.5)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + d.guides = vec![vectorcraft_doc::Guide::new(true, 420.5)]; + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + // The grabbed point (299, 150) is 1.8 from the anchor (300, 151.5): it stops at (300, 150), + // the point of the line nearest the anchor. + let (m, _) = shift_moved(&off, (299.0, 151.0)); + assert!(moved_by(m, (150.0, 0.0)), "{m:?}"); + // On the diagonal, 1 short of the ruler guide at 420.5: it goes on to it on both axes. + let (m, _) = shift_moved(&off, (419.5, 419.5)); + assert!(moved_by(m, (270.5, 270.5)), "{m:?}"); + // Smart Guides on, the selection alone with a ruler guide 1.5 below the grabbed point. + let (mut d, id) = doc_with_rect(); + d.guides = vec![vectorcraft_doc::Guide::new(false, 330.5)]; + let mut s = Selection::default(); + s.add(id); + let (m, _) = shift_moved(&cx(&d, &s, &p), (329.0, 329.0)); + assert!(moved_by(m, (180.5, 180.5)), "{m:?}"); +} + +#[test] +fn shift_drag_on_a_selected_object_moves_it_constrained() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); + // Pressing keeps the selection: nothing is toggled yet. + assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty()); + let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + assert!( + matches!(&a[1], Action::Preview(c, v) if c == "object.transform" + && v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0), + "moved right, constrained to horizontal: {a:?}" + ); + assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]); +} + +#[test] +fn shift_click_toggles_an_object_on_release() { + let (d, id) = doc_with_rect(); + let p = paint(); + let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); + let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]; + // Selected: it leaves the selection when the click is released. + let mut s = Selection::default(); + s.add(id); + let cx1 = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty()); + assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle); + // Not selected: it joins the selection at once, ready to be dragged. + let s = Selection::default(); + let cx2 = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle); + assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty()); +} + +/// #541: a click on one object of a selection of several makes it the key object; a click on +/// the key again lets it go. A drag moves them all, a lone object has no key unless ruler +/// guides are selected with it. +#[test] +fn a_click_on_a_selected_object_makes_it_the_key() { + let (d, id) = doc_with_area_type(); + let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let p = paint(); + let click = |s: &Selection, x, y| { + let c = cx(&d, s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty()); + t.pointer(&c, &ev(PointerKind::Up, x, y)) + }; + let mut s = Selection::default(); + s.add(rect); + s.add(id); + assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); + s.key = Some(rect); + assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key"); + // A drag moves the selection and leaves the key alone. + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)); + t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 150.0)), vec![Action::Commit]); + // One object selected: a click on it does nothing, unless ruler guides are selected + // with it for it to be the key of (#2143). + let mut one = Selection::default(); + one.add(rect); + assert!(click(&one, 150.0, 150.0).is_empty()); + one.guides = vec![0]; + assert_eq!(click(&one, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); +} + +/// In isolation mode a click makes only a selected top-level object the key, as `select.key` +/// takes no other: a click on a sublayer's art inside an isolated layer picks the sublayer, +/// a click inside an isolated group that is also selected picks an object inside it, and a +/// click inside an isolated compound path picks a member. +#[test] +fn a_click_makes_only_a_selected_top_level_object_the_key() { + use vectorcraft_doc::{Appearance, Document, LayerColor, Node, NodeKind}; + use vectorcraft_geom::shapes; + let p = paint(); + let click = |d: &Document, s: &Selection, isolation: NodeId| { + let c = ToolContext { isolation: Some(isolation), ..cx(d, s, &p) }; + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)).is_empty()); + t.pointer(&c, &ev(PointerKind::Up, 150.0, 150.0)) + }; + let path = |id: NodeId, r: Rect| Node::path(id, shapes::rectangle(r), Appearance::default_art()); + let (under, beside) = (Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(300.0, 300.0, 340.0, 340.0)); + // An isolated layer whose art under the click is in a sublayer. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (sub, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + d.insert(Some(sub), 0, path(a, under)).unwrap(); + d.insert(Some(l), 1, path(b, beside)).unwrap(); + let mut s = Selection::default(); + s.add(sub); + s.add(b); + assert!(click(&d, &s, l).is_empty(), "a sublayer is not a key object"); + // An isolated group that is also selected. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::group(g, vec![])).unwrap(); + d.insert(Some(g), 0, path(a, under)).unwrap(); + d.insert(Some(g), 1, path(b, beside)).unwrap(); + let mut s = Selection::default(); + s.add(a); + s.add(g); + assert!(click(&d, &s, g).is_empty(), "an object inside the selected group is not a key object"); + s.key = Some(a); + assert_eq!(click(&d, &s, g), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key"); + // With the group not selected, the object becomes the key. + let mut s = Selection::default(); + s.add(a); + s.add(b); + assert_eq!(click(&d, &s, g), vec![Action::Exec("select.key".into(), json!({"id": a.0}))]); + // An isolated compound path. Make Compound Path clears its members' appearance, so these + // members have none, and the click is on a's outline. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (c, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let member = |id: NodeId, r: Rect| std::sync::Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default())); + let children = vec![member(a, Rect::new(150.0, 100.0, 250.0, 200.0)), member(b, beside)]; + let mut compound = Node::new(c, NodeKind::Compound { children, rule: Default::default() }); + compound.appearance = Appearance::default_art().into(); + d.insert(Some(l), 0, compound).unwrap(); + let mut s = Selection::default(); + s.add(a); + s.add(b); + assert!(click(&d, &s, c).is_empty(), "a compound-path member is not a key object"); +} + +#[test] +fn marquee_selects() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); + assert_eq!(t.overlays(&cx).len(), 1); + let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); +} + +/// Shift-drag a marquee: the objects it reaches toggle (#483), so selected ones can be +/// taken out of the selection; a Shift-click on empty canvas leaves the selection alone. +#[test] +fn shift_marquee_toggles() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let shift = Mods { shift: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0).with_mods(shift)); + let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0]}))]); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift)); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty()); +} + +/// E pressed during a marquee drag makes the marquee take only the objects entirely inside it +/// (a group whole), and E pressed again makes it take what it touches. Only a marquee takes E. +#[test] +fn e_during_a_marquee_takes_only_the_objects_inside() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, inside) = doc_with_rect(); + let l = d.layers[0].id; + let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); + let (touched, g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 1, rect(touched, Rect::new(250.0, 100.0, 350.0, 200.0))).unwrap(); + // A group with one member inside the marquee and one outside it. + d.insert(Some(l), 2, Node::group(g, vec![])).unwrap(); + d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap(); + d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let e = ToolKey::Letter('e'); + let drag = |presses: usize, mods: Mods| { + let mut t = SelectionTool::default(); + assert!(!t.claims_key(&cx, e), "no marquee yet"); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(mods)); + t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0).with_mods(mods)); + for _ in 0..presses { + assert!(t.claims_key(&cx, e)); + assert!(t.key(&cx, e, Mods::default()).is_empty()); + } + let out = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0).with_mods(mods)); + assert!(!t.claims_key(&cx, e), "the marquee is over"); + out + }; + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(ids) }))]; + assert_eq!(drag(0, Mods::default()), set(&[inside, touched, g])); + assert_eq!(drag(1, Mods::default()), set(&[inside])); + assert_eq!(drag(2, Mods::default()), set(&[inside, touched, g]), "E again: what it touches"); + let shift = Mods { shift: true, ..Mods::default() }; + assert_eq!(drag(1, shift), vec![Action::Exec("select.toggle".into(), json!({ "ids": [inside.0] }))]); + // The next marquee starts out taking what it touches. + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0)); + t.key(&cx, e, Mods::default()); + t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0)); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0)), set(&[inside, touched, g])); + // A press on an object starts a move, which does not take E. + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + assert!(t.busy() && !t.claims_key(&cx, e)); +} + +/// With E pressed during the drag, a marquee takes only the artboard guides that lie entirely +/// inside it, and no canvas guide, which runs across the whole canvas. +#[test] +fn e_during_a_marquee_takes_only_the_guides_inside() { + let (mut d, id) = doc_with_rect(); + let board = d.artboards[0].id; + d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); + d.guides.push(vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let marquee = |to: f64| { + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, -10.0, -10.0)); + t.pointer(&cx, &ev(PointerKind::Drag, to, to)); + t.key(&cx, ToolKey::Letter('e'), Mods::default()); + t.pointer(&cx, &ev(PointerKind::Up, to, to)) + }; + let guides = |a: &[Action]| a.iter().find_map(|a| matches!(a, Action::Exec(c, _) if c == "guide.select").then(|| a.clone())); + // The artboard runs from 0 to 500: a marquee to (510, 510) holds its guide. + let a = marquee(510.0); + assert_eq!(a.first(), Some(&Action::Exec("select.set".into(), json!({ "ids": [id.0] })))); + assert_eq!(guides(&a), Some(Action::Exec("guide.select".into(), json!({ "indexes": [1], "toggle": true })))); + // A marquee to (250, 250) crosses both guides and holds neither. + assert_eq!(guides(&marquee(250.0)), None); +} + +#[test] +fn marquee_selects_ruler_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); + let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)); + assert_eq!( + a, + vec![ + Action::Exec("select.set".into(), json!({"ids": [id.0]})), + Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), + ] + ); +} + +#[test] +fn marquee_selects_only_guide() { + let (mut d, _) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(true, 20.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 30.0)); + let a = t.pointer(&cx, &ev(PointerKind::Up, 30.0, 30.0)); + assert_eq!( + a, + vec![Action::Exec("select.set".into(), json!({"ids": []})), Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),] + ); +} + +#[test] +fn marquee_ignores_locked_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + let s = Selection::default(); + let p = paint(); + let mut cx_locked = cx(&d, &s, &p); + cx_locked.guides = false; + let mut t = SelectionTool::default(); + t.pointer(&cx_locked, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx_locked, &ev(PointerKind::Drag, 120.0, 120.0)); + let a = t.pointer(&cx_locked, &ev(PointerKind::Up, 120.0, 120.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); +} + +/// #3036: with Lock Guides on, a guide object neither takes the press nor stops a marquee +/// from starting over it; with guides unlocked, the press still picks it. +#[test] +fn marquee_passes_through_locked_guide_objects() { + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + let g = d.alloc_id(); + let pts = [Point::new(20.0, 20.0), Point::new(400.0, 20.0), Point::new(400.0, 400.0), Point::new(20.0, 400.0)]; + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let mut guide = vectorcraft_doc::Node::path(g, path, vectorcraft_doc::Appearance::default_art()); + let vectorcraft_doc::NodeKind::Path { guide: flag, .. } = &mut guide.kind else { panic!("a path") }; + *flag = true; + d.insert(Some(l), 1, guide).unwrap(); + let s = Selection::default(); + let p = paint(); + let locked = ToolContext { guides: false, ..cx(&d, &s, &p) }; + for (x, y) in [(20.0, 60.0), (50.0, 50.0)] { + let mut t = SelectionTool::default(); + assert!(t.pointer(&locked, &ev(PointerKind::Down, x, y)).is_empty(), "press at ({x}, {y})"); + t.pointer(&locked, &ev(PointerKind::Drag, 250.0, 250.0)); + assert!(matches!(t.state, State::Marquee { .. }), "press at ({x}, {y})"); + // The rectangle is rubber-band selected (the engine leaves locked guides out). + let a = t.pointer(&locked, &ev(PointerKind::Up, 250.0, 250.0)); + let Some(Action::Exec(c, v)) = a.first() else { panic!("press at ({x}, {y}): {a:?}") }; + assert_eq!(c, "select.set", "press at ({x}, {y})"); + assert!(v["ids"].as_array().unwrap().contains(&json!(id.0)), "press at ({x}, {y}): {a:?}"); + } + let unlocked = ToolContext { guides: true, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&unlocked, &ev(PointerKind::Down, 20.0, 60.0)), vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); +} + +#[test] +fn handle_drag_scales() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), Mods::default()), Cursor::ResizeNwSe); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)), vec![Action::Begin("Scale".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0)); + assert!(matches!(&a[0], Action::Preview(_, v) if v["matrix"][0] == 2.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); +} + +#[test] +fn handle_drag_snaps_to_the_other_objects_with_smart_guides() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut c = cx(&d, &s, &p); + let height = |a: &[Action]| { + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + v["matrix"][3].as_f64().unwrap() * 50.0 + }; + // The bottom handle, dragged near the neighbour's bottom edge (y = 200), lands on it. + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0)); + assert!((height(&a) - 100.0).abs() < 1e-9, "{a:?}"); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. }))); + t.pointer(&c, &ev(PointerKind::Up, 275.0, 197.0)); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. }))); + // Smart Guides off: the edge follows the pointer. + c.smart_guides = false; + t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0)); + assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}"); +} + +/// The box the 100–200 rectangle's bounds become under the scale a handle drag previews. +fn scaled(a: &[Action]) -> Rect { + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]).transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 200.0)) +} + +fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9) +} + +/// #2054: with Snap to Grid on, a handle drag lands what the handle moves on the nearest +/// gridline (every 9 pt by default) while the opposite side stays put; Shift keeps the +/// proportions, and Alt keeps the center. +#[test] +fn handle_drag_lands_what_it_moves_on_the_grid() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let drag = |from: (f64, f64), to: (f64, f64), mods: Mods| { + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)), vec![Action::Begin("Scale".into())]); + scaled(&t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods))) + }; + let none = Mods::default(); + // The right side, dragged to x = 213, lands on the gridline at 216. + let r = drag((200.0, 150.0), (213.0, 150.0), none); + assert!(near(r, Rect::new(100.0, 100.0, 216.0, 200.0)), "{r:?}"); + // The bottom-right corner, dragged to (213, 204), lands on (216, 207). + let r = drag((200.0, 200.0), (213.0, 204.0), none); + assert!(near(r, Rect::new(100.0, 100.0, 216.0, 207.0)), "{r:?}"); + // Shift: the corner, at (213, 213) in proportion, lands on (216, 216), still square. + let r = drag((200.0, 200.0), (213.0, 204.0), Mods { shift: true, ..none }); + assert!(near(r, Rect::new(100.0, 100.0, 216.0, 216.0)), "{r:?}"); + // Alt: the right side lands on 216 and the left side mirrors it about the center. + let r = drag((200.0, 150.0), (213.0, 150.0), Mods { alt: true, ..none }); + assert!(near(r, Rect::new(84.0, 100.0, 216.0, 200.0)), "{r:?}"); +} + +/// #2054: with Snap to Pixel on, what a handle drag moves lands on whole pixels. +#[test] +fn handle_drag_lands_what_it_moves_on_whole_pixels() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0)); + let r = scaled(&t.pointer(&c, &ev(PointerKind::Drag, 212.4, 230.7))); + assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}"); +} + +/// #2054: with Smart Guides off, View › Snap to Point lands a dragged handle on another +/// object's anchor or a ruler guide within its distance (2 px); with Smart Guides on, the +/// ruler guides still pull it within the Snapping Tolerance (4 px), as a move does (#1534). +#[test] +fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + let other = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0)); + d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap(); + d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)]; + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let drag = |c: &ToolContext, from: (f64, f64), to: (f64, f64)| { + let mut t = SelectionTool::default(); + t.pointer(c, &ev(PointerKind::Down, from.0, from.1)); + scaled(&t.pointer(c, &ev(PointerKind::Drag, to.0, to.1))) + }; + let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + assert!(off.snap_to_point); + // The corner, 1.4 px from the other square's corner, lands on it. + let r = drag(&off, (200.0, 200.0), (249.0, 251.0)); + assert!(near(r, Rect::new(100.0, 100.0, 250.0, 250.0)), "{r:?}"); + // The right side, 1 px from the ruler guide, lands on it. + let r = drag(&off, (200.0, 150.0), (231.5, 150.0)); + assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}"); + // Snap to Point off: the side follows the pointer. + let r = drag(&ToolContext { snap_to_point: false, ..off }, (200.0, 150.0), (231.5, 150.0)); + assert!(near(r, Rect::new(100.0, 100.0, 231.5, 200.0)), "{r:?}"); + // Smart Guides on: the ruler guide, 2.5 px away, pulls the side too. + let r = drag(&cx(&d, &s, &p), (200.0, 150.0), (233.0, 150.0)); + assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}"); +} + +/// Smart Guides › Measurement Labels, Transform Tools and Snapping Tolerance (#394): the +/// readouts while moving and while scaling each follow their option; the tolerance sets how +/// far a dragged handle is pulled. +#[test] +fn smart_guide_display_preferences_drive_the_readouts_and_the_pull() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 100.0, 300.0, 150.0)), Appearance::default_art())) + .unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let measures = |t: &SelectionTool, c: &ToolContext| t.overlays(c).iter().filter(|o| matches!(o, Overlay::Measure { .. })).count(); + let height = |a: &[Action]| { + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + v["matrix"][3].as_f64().unwrap() * 50.0 + }; + // Moving: the offset readout is a measurement label. + for on in [true, false] { + let c = ToolContext { measurement_labels: on, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 275.0, 125.0)); + t.pointer(&c, &ev(PointerKind::Drag, 285.0, 135.0)); + assert_eq!(measures(&t, &c), usize::from(on), "measurement labels {on}"); + t.pointer(&c, &ev(PointerKind::Up, 285.0, 135.0)); + } + // Scaling by a handle: the size readout belongs to Transform Tools. + for on in [true, false] { + let c = ToolContext { transform_tools_guides: on, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); + t.pointer(&c, &ev(PointerKind::Drag, 275.0, 180.0)); + assert_eq!(measures(&t, &c), usize::from(on), "transform tools {on}"); + t.pointer(&c, &ev(PointerKind::Up, 275.0, 180.0)); + } + // Snapping Tolerance: 6 px from the neighbour's bottom edge is beyond 4, within 8. + for (tol, want) in [(4.0, 94.0), (8.0, 100.0), (0.0, 94.0)] { + let c = ToolContext { snapping_tolerance: tol, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 194.0)); + assert!((height(&a) - want).abs() < 1e-9, "tolerance {tol}: {a:?}"); + t.pointer(&c, &ev(PointerKind::Up, 275.0, 194.0)); + } +} + +#[test] +fn handle_drag_of_a_turned_box_snaps_on_the_page() { + use vectorcraft_doc::{Appearance, Node}; + let matrix = |a: &[Action]| { + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]) + }; + // A 50 pt square beside the 100..200 rect, turned about its centre (275, 125). + for (turn, low) in [(90.0_f64, 150.0), (45.0, 125.0 + 25.0 * std::f64::consts::SQRT_2)] { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + let c = Point::new(275.0, 125.0); + d.node_mut(id) + .unwrap() + .transform(Affine::translate(c.to_vec2()) * Affine::rotate(turn.to_radians()) * Affine::translate(-c.to_vec2()), false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let bx = selection_box(&cx).unwrap(); + assert_ne!(bx.angle, 0.0); + // The handle at the lowest point of the shape: a side at 90°, a corner at 45°. + let grab = Point::new(275.0, low); + assert!(Handle::ALL.iter().any(|h| (bx.to_doc() * h.pos(bx.rect)).distance(grab) < 1e-6), "{bx:?}"); + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, grab.x, grab.y)), vec![Action::Begin("Scale".into())]); + // Dragged near the neighbour's bottom edge (y = 200), it lands on it. + let a = t.pointer(&cx, &ev(PointerKind::Drag, 275.0, 197.0)); + let landed = matrix(&a) * grab; + assert!((landed.y - 200.0).abs() < 1e-6 && (landed.x - 275.0).abs() < 1e-6, "{turn}: {landed:?}"); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { .. }))); + t.pointer(&cx, &ev(PointerKind::Up, 275.0, 197.0)); + } +} + +#[test] +fn handle_drag_on_area_type_resizes_its_frame() { + let (d, text) = doc_with_area_type(); + let p = paint(); + let mut s = Selection::default(); + s.add(text); + let cx1 = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + // Bottom-right handle of the 120 × 40 frame at (300, 300). + assert_eq!(t.pointer(&cx1, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Resize Type Area".into())]); + let a = t.pointer(&cx1, &ev(PointerKind::Drag, 460.0, 400.0)); + assert!(matches!(&a[0], Action::Preview(c, v) if c == "object.transform" && v["typeAreas"] == true), "{a:?}"); + assert_eq!(t.pointer(&cx1, &ev(PointerKind::Up, 460.0, 400.0)), vec![Action::Commit]); + // With another object the step is a scale, still resizing the type area. + let rect = d.layers[0].children().unwrap()[0].id; + s.add(rect); + let cx2 = cx(&d, &s, &p); + assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Scale".into())]); + let a = t.pointer(&cx2, &ev(PointerKind::Drag, 460.0, 400.0)); + assert!(matches!(&a[0], Action::Preview(_, v) if v["typeAreas"] == true), "{a:?}"); + t.pointer(&cx2, &ev(PointerKind::Up, 460.0, 400.0)); + // Without area type the drag scales as before. + let mut s = Selection::default(); + s.add(rect); + let cx3 = cx(&d, &s, &p); + t.pointer(&cx3, &ev(PointerKind::Down, 200.0, 200.0)); + let a = t.pointer(&cx3, &ev(PointerKind::Drag, 300.0, 300.0)); + assert!(matches!(&a[0], Action::Preview(_, v) if v.get("typeAreas").is_none()), "{a:?}"); +} + +#[test] +fn turned_box_handles_turn_and_scale_along_the_object() { + let (mut d, id) = doc_with_rect(); + let c = Point::new(150.0, 150.0); + let turn = Affine::translate(c.to_vec2()) * Affine::rotate(-std::f64::consts::FRAC_PI_4) * Affine::translate(-c.to_vec2()); + d.node_mut(id).unwrap().transform(turn, false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + // The Right handle sits up and to the right, where a diagonal cursor fits it. + let k = 50.0 * std::f64::consts::FRAC_1_SQRT_2; + let right = Point::new(150.0 + k, 150.0 - k); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&cx, right, Mods::default()), Cursor::ResizeNeSw); + // The page box's corner is no handle any more. + assert_ne!(t.cursor(&cx, Point::new(150.0 + 2.0 * k, 150.0 - 2.0 * k), Mods::default()), Cursor::ResizeNeSw); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y)), vec![Action::Begin("Scale".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, right.x + k, right.y - k)); + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + // Doubling the width along 45° about the left side: the left handle stays put. + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]); + let left = Point::new(150.0 - k, 150.0 + k); + assert!((m * left).distance(left) < 1e-9); + assert!((m * right).distance(Point::new(right.x + k, right.y - k)) < 1e-9); +} + +#[test] +fn click_empty_deselects() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]); +} + +/// [`doc_with_area_type`] with more text than its frame holds, selected; the rectangle's id and +/// where the frame's out port is. +fn overflowing_area_type() -> (vectorcraft_doc::Document, NodeId, NodeId, Point) { + let (mut d, id) = doc_with_area_type(); + let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id; + let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") }; + let style = vectorcraft_doc::CharStyle { size: 20.0, ..Default::default() }; + t.runs = vec![vectorcraft_doc::TextRun::new("Words that fill more than the frame holds. ".repeat(20), style)]; + // On the frame's right side, above its bottom-right corner (120 × 40 at 100%). + let port = t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX); + (d, id, rect, port) +} + +/// Type on a path whose text overflows has its out port just past its end bracket, along the +/// path: a click there loads the pointer with its text, as area type's does. +#[test] +fn the_out_port_of_type_on_a_path_loads_its_text() { + let (mut d, id) = doc_with_area_type(); + let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") }; + let line = vectorcraft_geom::kurbo::Line::new((0.0, 200.0), (100.0, 200.0)); + t.kind = vectorcraft_doc::TextKind::OnPath { + path: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::kurbo::Shape::to_path(&line, 0.1)), + start: 0.0, + end: Some(0.8), + }; + t.xf = vectorcraft_geom::Affine::IDENTITY; + t.runs = vec![vectorcraft_doc::TextRun::new("Words that run past the end of the path. ".repeat(5), vectorcraft_doc::CharStyle::default())]; + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + // The end bracket stands at 80, 200; the port past it, along the path. + let port = Point::new(80.0 + crate::text::PORT_RISE_PX, 200.0); + assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty()); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), + vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))] + ); +} + +/// #2572: a click on the out port loads the pointer with the text; a click on empty canvas then +/// threads it into a new frame there, a click on a closed path into the path, a drag into the +/// rectangle it draws. +#[test] +fn a_click_on_the_out_port_loads_the_text_and_the_next_click_or_drag_threads_it() { + let (d, id, rect, port) = overflowing_area_type(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)]; + let load = |t: &mut SelectionTool| { + assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + }; + load(&mut t); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty()); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0]}))); + assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "the text is placed"); + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0}))); + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); + assert!(t.overlays(&cx).contains(&Overlay::Marquee(Rect::new(20.0, 20.0, 80.0, 90.0))), "the new frame's outline"); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0]}))); + // The bounding box's corner handle below the port still resizes the frame. + let corner = Point::new(port.x, port.y + crate::text::PORT_RISE_PX); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]); +} + +/// Loaded text clicked just inside a layer's clipping mask, where art is drawn over the mask's +/// edge, threads into that art, not into the unpainted mask: placing text acts on what is drawn, +/// so it takes the ordinary hit, not the selection tools' clipping-path outline. +#[test] +fn loaded_text_threads_into_the_art_on_a_layer_masks_edge() { + let (mut d, id, _, port) = overflowing_area_type(); + let art = d.alloc_id(); + let picture = vectorcraft_doc::Node::path( + art, + vectorcraft_geom::shapes::rectangle(Rect::new(350.0, 350.0, 550.0, 550.0)), + vectorcraft_doc::Appearance::default_art(), + ); + add_masked_layer(&mut d, Rect::new(400.0, 400.0, 500.0, 500.0), picture); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + assert!(cx.selection_hit_options().clip_outlines, "the selection tools would take the mask here"); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); + t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); + t.pointer(&cx, &ev(PointerKind::Down, 498.5, 450.0)); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Up, 498.5, 450.0)), + vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "to": art.0}))] + ); +} + +/// A double-click on type where a layer's clipping mask edge runs through it edits the type, +/// though a press there selects the mask: what a double-click edits is what is drawn. +#[test] +fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() { + let (d, _, at) = doc_with_type_on_a_layer_masks_edge(); + let (s, p) = (Selection::default(), paint()); + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::DoubleClick, at.x, at.y)); + assert_eq!(a, vec![Action::SwitchTool("type".into())]); +} + +/// A Cmd/Ctrl+Shift double-click releases a guide path where it crosses a layer's clipping +/// mask edge (its left side, x = 450, just inside the mask's bottom): the mask, unpainted and +/// never a guide, doesn't stand in front of it. +#[test] +fn a_guide_on_a_layer_masks_edge_is_released() { + let (mut d, _) = doc_with_rect(); + let id = d.alloc_id(); + let mut guide_path = + vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(450.0, 350.0, 550.0, 550.0)), Default::default()); + let vectorcraft_doc::NodeKind::Path { guide, .. } = &mut guide_path.kind else { panic!("a path") }; + *guide = true; + add_masked_layer(&mut d, Rect::new(400.0, 400.0, 500.0, 500.0), guide_path); + let (s, p) = (Selection::default(), paint()); + let both = Mods { cmd: true, shift: true, ..Mods::default() }; + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::DoubleClick, 450.0, 498.5).with_mods(both)); + assert_eq!(a, vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]); +} + +/// Escape drops the loaded text, and the next click is an ordinary one. +#[test] +fn escape_drops_the_loaded_text() { + let (d, id, _, port) = overflowing_area_type(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); + t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); + assert!(t.claims_key(&cx, ToolKey::Escape), "Escape is the loaded pointer's"); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.claims_key(&cx, ToolKey::Escape)); + assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), vec![Action::Exec("select.none".into(), json!({}))]); +} + +/// [`doc_with_area_type`], its text fitting its frame; where its in and out ports are. +fn fitting_area_type() -> (vectorcraft_doc::Document, NodeId, Point, Point) { + let (d, id) = doc_with_area_type(); + let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") }; + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).overflow, "the text fits"); + // On the left side below the top-left corner, on the right side above the bottom-right one. + let (input, output) = (t.xf * Point::new(0.0, crate::text::PORT_RISE_PX), t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX)); + (d, id, input, output) +} + +/// #2572: selected area type has both ports to click whether its text fits or not; other +/// points of its frame, and type that isn't selected, have none. +#[test] +fn every_selected_area_type_has_an_in_and_an_out_port() { + use crate::text::{Port, port_at}; + let (d, id, input, output) = fitting_area_type(); + let p = paint(); + let mut s = Selection::default(); + assert_eq!(port_at(&cx(&d, &s, &p), output), None, "not selected"); + s.set([id]); + let cx = cx(&d, &s, &p); + assert_eq!(port_at(&cx, output), Some((id, Port::Out))); + assert_eq!(port_at(&cx, input), Some((id, Port::In))); + assert_eq!(port_at(&cx, Point::new(input.x + 4.0, input.y + 4.0)), Some((id, Port::In)), "the box around it"); + assert_eq!(port_at(&cx, Point::new(input.x + 12.0, input.y)), None); + assert_eq!(port_at(&cx, Point::new(output.x, output.y + crate::text::PORT_RISE_PX)), None, "the corner"); +} + +/// #2572: a click on the out port of type that fits loads its text as well; the next click +/// threads it into a new frame after it. +#[test] +fn a_click_on_the_out_port_of_type_that_fits_starts_threading() { + let (d, id, _, output) = fitting_area_type(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, output.x, output.y)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, output.x, output.y)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), + vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))] + ); +} + +/// #2572: a click on the in port loads the text to thread backwards: the next click on empty +/// canvas, on a closed path or a drag makes the frame the story flows from into this one +/// (`before`). The corner handle above the in port still resizes the frame. +#[test] +fn a_click_on_the_in_port_threads_into_a_frame_before_it() { + let (d, id, input, _) = fitting_area_type(); + let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id; + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)]; + let load = |t: &mut SelectionTool| { + assert!(t.pointer(&cx, &ev(PointerKind::Down, input.x, input.y)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, input.x, input.y)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + }; + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0], "before": true}))); + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0, "before": true}))); + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0], "before": true}))); + let corner = Point::new(input.x, input.y - crate::text::PORT_RISE_PX); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]); +} + +/// A double-click on a threaded port breaks the thread there, its in port before the frame +/// and its out port after it; it doesn't thread the loaded text into a new frame. A +/// double-click on a port with no thread does nothing (it doesn't switch to the Type tool). +#[test] +fn a_double_click_on_a_threaded_port_breaks_the_thread() { + let (mut d, id, input, output) = fitting_area_type(); + let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id; + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let double_click = |d: &vectorcraft_doc::Document, at: Point| { + let cx = cx(d, &s, &p); + let mut t = SelectionTool::default(); + let kinds = [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick]; + let out: Vec = kinds.into_iter().flat_map(|k| t.pointer(&cx, &ev(k, at.x, at.y))).collect(); + assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "no text stays loaded"); + out + }; + let breaks = |port: &str| vec![Action::Exec("text.thread.break".into(), json!({"id": id.0, "port": port}))]; + d.text_threads = vec![vec![id, rect]]; + assert_eq!(double_click(&d, output), breaks("out")); + assert_eq!(double_click(&d, input), vec![], "no frame before it"); + d.text_threads = vec![vec![rect, id]]; + assert_eq!(double_click(&d, input), breaks("in")); + assert_eq!(double_click(&d, output), vec![], "no frame after it"); + d.text_threads.clear(); + assert_eq!(double_click(&d, output), vec![]); +} + +/// The memo answers for the revision, the selected objects and Use Preview Bounds it measured +/// for, and measures again when any of them changes. +#[test] +fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() { + let (d, id) = doc_with_rect(); + let mut moved = d.clone(); + if let Some(n) = moved.node_mut(id) { + n.transform(Affine::translate((50.0, 0.0)), false); + } + let (mut s, p, memo) = (Selection::default(), paint(), PreviewMemo::default()); + s.set([id]); + let at = |d, s, revision, preview_bounds| { + let c = ToolContext { revision, preview_bounds, preview_memo: Some(&memo), ..cx(d, s, &p) }; + (selection_bounds(&c).map(|r| r.x1), object_bounds(&c, id).map(|r| r.x1)) + }; + assert_eq!(at(&d, &s, (1, 1), false), (Some(200.0), Some(200.0))); + assert_eq!(memo.measurements(), 2); + // The same revision with the moved square: the memo answers with what it measured. + assert_eq!(at(&moved, &s, (1, 1), false), (Some(200.0), Some(200.0))); + assert_eq!(memo.measurements(), 2); + assert_eq!(at(&moved, &s, (1, 2), false), (Some(250.0), Some(250.0)), "a new revision"); + assert_eq!(memo.measurements(), 4); + assert_eq!(at(&moved, &s, (1, 2), true), (Some(250.5), Some(250.5)), "Use Preview Bounds counts the stroke"); + assert_eq!(memo.measurements(), 6); + let none = Selection::default(); + assert_eq!(at(&moved, &none, (1, 2), true), (None, Some(250.5)), "another selection"); + assert_eq!(memo.measurements(), 8); + // Without a memo, every call measures. + let c = cx(&moved, &s, &p); + assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0)); +} From 2377df07eecff31715bfbe42c2206b43bce18392 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:21 +0000 Subject: [PATCH 016/199] refactor(ui-egui): move panels/layers.rs tests to panels/layers/tests.rs --- crates/ui-egui/src/panels/layers.rs | 1510 +-------------------- crates/ui-egui/src/panels/layers/tests.rs | 1507 ++++++++++++++++++++ 2 files changed, 1508 insertions(+), 1509 deletions(-) create mode 100644 crates/ui-egui/src/panels/layers/tests.rs diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0ffc69a60..0cd69aa02 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1764,1512 +1764,4 @@ pub fn set_filter(app: &mut VectorcraftApp, p: &Value) -> Result } #[cfg(test)] -mod tests { - use serde_json::json; - use vectorcraft_engine::Session; - - use super::*; - - /// Filled target circles drawn by one headless frame of the panel. - fn filled_targets(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 3.2 && cs.fill != Color32::TRANSPARENT)).count() - } - - /// A generated `` name is translated where painted; an unnamed text object's text never - /// is, even when it reads like one. - #[test] - fn only_generated_names_are_translated() { - let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 100, "height": 100})); - let rect = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); - let text = run(&mut app, "text.create", json!({"x": 10, "y": 40, "text": ""}))["id"].as_u64().unwrap(); - let doc = &app.session.active().unwrap().doc; - let names = |id: u64| { - let n = doc.node(NodeId(id)).unwrap(); - (n.display_name(), painted_name(n, &n.display_name(), zh)) - }; - assert_eq!(names(text), ("".to_string(), "".to_string())); - let (stored, painted) = names(rect); - let inner = stored.trim_start_matches('<').trim_end_matches('>'); - assert_eq!(painted, format!("<{}>", crate::i18n::tr(zh, inner))); - } - - #[test] - fn target_circle_fills_for_non_default_transparency() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 100, "height": 100})); - let id = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); - run(&mut app, "select.set", json!({ "ids": [id] })); - let ctx = egui::Context::default(); - assert_eq!(filled_targets(&mut app, &ctx), 0); - run(&mut app, "transparency.set", json!({"blend": "multiply"})); - assert_eq!(filled_targets(&mut app, &ctx), 1, "a Multiply object"); - run(&mut app, "transparency.set", json!({"blend": "normal", "knockout": true})); - assert_eq!(filled_targets(&mut app, &ctx), 1, "a knockout group"); - run(&mut app, "transparency.set", json!({"knockout": false})); - run(&mut app, "appearance.addStroke", json!({})); - assert_eq!(filled_targets(&mut app, &ctx), 1, "two strokes"); - } - - #[test] - fn a_layer_clipping_mask_underlines_its_clipping_path() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 100, "height": 100})); - run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})); - run(&mut app, "shape.rectangle", json!({"x": 25, "y": 25, "width": 50, "height": 50})); - run(&mut app, "select.none", json!({})); - let ctx = egui::Context::default(); - let text = Tokens::get(&ctx).text; - // Line segments in the text colour: the underlines. - let underlines = |app: &mut VectorcraftApp| { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::LineSegment { stroke, .. } if stroke.color == text)).count() - }; - assert_eq!(underlines(&mut app), 0); - assert_eq!(run(&mut app, "layer.clippingMask.toggle", json!({}))["clip"], true); - assert_eq!(underlines(&mut app), 1); - run(&mut app, "layer.clippingMask.toggle", json!({})); - assert_eq!(underlines(&mut app), 0); - } - - /// One headless frame of the panel with `events` (Alt held when `alt`): the centres of the - /// target circles, top row first, and the text-coloured line segments (underlines) drawn. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, alt: bool) -> (Vec, usize) { - let mut events = events; - events.insert(0, egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() })); - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - let text = Tokens::get(ctx).text; - let mut circles = vec![]; - let mut lines = 0; - for c in &out.shapes { - match &c.shape { - egui::Shape::Circle(cs) if cs.radius == 5.0 => circles.push(cs.center), - egui::Shape::LineSegment { stroke, .. } if stroke.color == text => lines += 1, - _ => {} - } - } - circles.sort_by(|a, b| a.y.total_cmp(&b.y)); - (circles, lines) - } - - fn button(pos: egui::Pos2, pressed: bool) -> egui::Event { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } - } - - /// Drag from `a` to `b` over a few frames (Alt held when `alt`). - fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool) { - let steps = [ - vec![egui::Event::PointerMoved(a)], - vec![button(a, true)], - vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], - vec![egui::Event::PointerMoved(b)], - vec![button(b, false)], - vec![], - ]; - for e in steps { - frame(app, ctx, e, alt); - } - } - - #[test] - fn alt_clicking_an_eye_or_lock_toggles_the_other_layers() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 200, "height": 200})); - run(&mut app, "layer.new", json!({})); - run(&mut app, "layer.new", json!({})); - let ctx = egui::Context::default(); - // Rows top first: Layers 3, 2, 1. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 3); - let state = - |app: &VectorcraftApp| -> Vec<(bool, bool)> { app.session.active().unwrap().doc.layers.iter().map(|l| (l.visible, l.locked)).collect() }; - let click = |app: &mut VectorcraftApp, at: egui::Pos2, alt: bool| { - frame(app, &ctx, vec![egui::Event::PointerMoved(at)], alt); - frame(app, &ctx, vec![button(at, true)], alt); - frame(app, &ctx, vec![button(at, false)], alt); - }; - let (eye, lock) = (egui::pos2(12.0, circles[1].y), egui::pos2(37.0, circles[1].y)); - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - let before = undo(&app); - // Alt-click Layer 2's eye: Layers 1 and 3 hide in one step; again, they show. - click(&mut app, eye, true); - assert_eq!(state(&app), [(false, false), (true, false), (false, false)]); - assert_eq!(undo(&app), before + 1); - click(&mut app, eye, true); - assert_eq!(state(&app), [(true, false); 3]); - // Alt-click its lock: the others lock; again, every layer unlocks. - click(&mut app, lock, true); - assert_eq!(state(&app), [(true, true), (true, false), (true, true)]); - assert_eq!(app.session.active().unwrap().history.undo.last().map(|u| u.label.as_str()), Some("Lock Others")); - // A plain click still toggles only that layer. - click(&mut app, eye, false); - assert_eq!(state(&app), [(true, true), (false, false), (true, true)]); - click(&mut app, lock, true); - assert_eq!(state(&app), [(true, false), (false, false), (true, false)]); - // A sublayer's row stands for its top-level layer: the other layers hide. - click(&mut app, eye, false); - let layer2 = app.session.active().unwrap().doc.layers[1].id.0; - let sub = run(&mut app, "layer.newSublayer", json!({"parent": layer2})); - // Rows: Layer 3, Layer 2, its sublayer, Layer 1. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 4); - click(&mut app, egui::pos2(12.0, circles[2].y), true); - assert_eq!(state(&app), [(false, false), (true, false), (false, false)]); - let doc = &app.session.active().unwrap().doc; - assert!(doc.node(NodeId(sub["id"].as_u64().unwrap())).unwrap().visible); - } - - #[test] - fn ctrl_alt_clicking_an_eye_switches_the_other_layers_between_outline_and_preview() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 200, "height": 200})); - run(&mut app, "layer.new", json!({})); - run(&mut app, "layer.new", json!({})); - let ctx = egui::Context::default(); - // Rows top first: Layers 3, 2, 1. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 3); - // Each layer bottom first: (visible, preview). - let state = |app: &VectorcraftApp| -> Vec<(bool, bool)> { - let doc = &app.session.active().unwrap().doc; - doc.layers.iter().map(|l| (l.visible, !matches!(l.kind, NodeKind::Layer { preview: false, .. }))).collect() - }; - let last = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|u| u.label.clone()); - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - let eye = |i: usize| egui::pos2(12.0, circles[i].y); - let ctrl_alt = egui::Modifiers { alt: true, command: true, ..Default::default() }; - let before = undo(&app); - // Ctrl/Cmd+Alt-click Layer 1's eye: Layers 2 and 3 go to Outline in one step, and stay visible. - click_with(&mut app, &ctx, eye(2), ctrl_alt); - assert_eq!(state(&app), [(true, true), (true, false), (true, false)]); - assert_eq!(undo(&app), before + 1); - assert_eq!(last(&app).as_deref(), Some("Outline Others")); - // Again: every layer previews, in one step. - click_with(&mut app, &ctx, eye(2), ctrl_alt); - assert_eq!(state(&app), [(true, true); 3]); - assert_eq!(undo(&app), before + 2); - assert_eq!(last(&app).as_deref(), Some("Preview All Layers")); - // On a layer in Outline whose others preview: the others go to Outline and it previews. - click_with(&mut app, &ctx, eye(0), egui::Modifiers::COMMAND); - assert_eq!(state(&app), [(true, true), (true, true), (true, false)]); - click_with(&mut app, &ctx, eye(0), ctrl_alt); - assert_eq!(state(&app), [(true, false), (true, false), (true, true)]); - } - - /// A document with two rectangles on one layer → (app, layer, [bottom, top]). - fn two_rects() -> (VectorcraftApp, NodeId, [NodeId; 2]) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 200, "height": 200})); - let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); - let b = run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); - run(&mut app, "select.none", json!({})); - let layer = app.session.active().unwrap().doc.layers[0].id; - (app, layer, [NodeId(a), NodeId(b)]) - } - - #[test] - fn row_thumbnails_wait_for_the_end_of_a_drag() { - let (mut app, _, _) = two_rects(); - app.session.execute("select.all", &json!({})).unwrap(); - let ctx = egui::Context::default(); - let renders = || THUMBS.with(|c| c.borrow().renders); - frame(&mut app, &ctx, vec![], false); - let first = renders(); - assert!(first >= 1, "the rows' thumbnails render at once"); - frame(&mut app, &ctx, vec![], false); - assert_eq!(renders(), first, "and stay"); - // A move drag: the layer (and the moved objects) change on every preview. - app.session.begin_interaction("Move").unwrap(); - for i in 1..=10 { - app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, i, 0]})).unwrap(); - frame(&mut app, &ctx, vec![], false); - } - assert_eq!(renders(), first, "nothing renders while the drag goes on"); - app.session.commit_interaction().unwrap(); - for _ in 0..12 { - frame(&mut app, &ctx, vec![], false); - } - assert!(renders() > first, "the changed rows render after it"); - let after = renders(); - frame(&mut app, &ctx, vec![], false); - assert_eq!(renders(), after, "once"); - } - - #[test] - fn clicking_a_target_circle_targets_the_layer() { - let (mut app, layer, [a, b]) = two_rects(); - let ctx = egui::Context::default(); - // Rows top down: the layer, the top rectangle, the bottom one. - let (c, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(c.len(), 3); - for e in [egui::Event::PointerMoved(c[0]), button(c[0], true), button(c[0], false)] { - frame(&mut app, &ctx, vec![e], false); - } - let st = app.session.active().unwrap(); - assert_eq!(st.selection.target, Some(layer)); - assert_eq!(st.selection.objects, vec![a, b]); - assert_eq!(super::super::appearance::object_label(&app), "Layer", "the Appearance panel lists the layer"); - app.session.execute("transparency.set", &json!({"opacity": 25})).unwrap(); - assert!((app.session.active().unwrap().doc.node(layer).unwrap().opacity - 0.25).abs() < 1e-6); - // The layer's circle is filled now; clicking an object's circle targets that object. - assert_eq!(filled_targets(&mut app, &ctx), 1); - for e in [egui::Event::PointerMoved(c[2]), button(c[2], true), button(c[2], false)] { - frame(&mut app, &ctx, vec![e], false); - } - let st = app.session.active().unwrap(); - assert_eq!((st.selection.target, st.selection.objects.clone()), (Some(a), vec![a])); - // Shift-clicking another object's circle adds it to the selection (#901), and again takes - // it out; nothing is targeted meanwhile. - click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT); - let st = app.session.active().unwrap(); - assert_eq!((st.selection.target, st.selection.objects.clone()), (None, vec![a, b])); - click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT); - assert_eq!(app.session.active().unwrap().selection.objects, vec![a]); - } - - #[test] - fn dragging_a_target_circle_moves_or_copies_the_appearance_and_the_trash_clears_it() { - let (mut app, layer, [a, b]) = two_rects(); - app.session.execute("transparency.set", &json!({"ids": [a.0], "opacity": 30})).unwrap(); - let ctx = egui::Context::default(); - let (c, _) = frame(&mut app, &ctx, vec![], false); - let opacity = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().opacity; - // Alt-drag the bottom rectangle's circle onto the top one's: both have it. - drag(&mut app, &ctx, c[2], c[1], true); - assert!((opacity(&app, b) - 0.3).abs() < 1e-6 && (opacity(&app, a) - 0.3).abs() < 1e-6); - // A plain drag onto the layer's circle moves it there. - drag(&mut app, &ctx, c[1], c[0], false); - assert!((opacity(&app, layer) - 0.3).abs() < 1e-6 && opacity(&app, b) == 1.0); - // Dropped on the trash, the layer's appearance is cleared (and nothing is deleted). - let st = app.session.active().unwrap(); - let count = st.doc.node_count(); - let trash = trash_pos(&mut app, &ctx); - drag(&mut app, &ctx, c[0], trash, false); - let st = app.session.active().unwrap(); - assert_eq!(st.doc.node(layer).unwrap().opacity, 1.0); - assert_eq!(st.doc.node_count(), count); - } - - /// The trash button: the bottom bar's rightmost button. - fn trash_pos(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { - bar_buttons(app, ctx)[0] - } - - #[test] - fn dragging_rows_onto_the_trash_deletes_them() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - app.session.execute("layer.new", &json!({})).unwrap(); - let ctx = egui::Context::default(); - // Rows top first: Layer 2, Layer 1. Grab Layer 2 by its name, left of its target circle. - let (c, _) = frame(&mut app, &ctx, vec![], false); - let top = app.session.active().unwrap().doc.layers[1].id; - let trash = trash_pos(&mut app, &ctx); - drag(&mut app, &ctx, c[0] - vec2(100.0, 0.0), trash, false); - let doc = &app.session.active().unwrap().doc; - assert_eq!(doc.layers.len(), 1); - assert!(doc.node(top).is_none()); - } - - #[test] - fn renaming_a_layer_commits_on_enter_and_escape_cancels() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let layer = app.session.doc().unwrap().doc.layers[0].id; - let name = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].display_name(); - let ctx = egui::Context::default(); - let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - let select_all = - egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; - // What a double-click on the row does: the name field opens with the name. - let open = |app: &mut VectorcraftApp| { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("layers-rename"), (layer.0, name(app)))); - frame(app, &ctx, vec![], false); - frame(app, &ctx, vec![], false); - }; - open(&mut app); - frame(&mut app, &ctx, vec![select_all.clone(), egui::Event::Text("Sky".into())], false); - frame(&mut app, &ctx, vec![key(egui::Key::Enter)], false); - frame(&mut app, &ctx, vec![], false); - assert_eq!(name(&app), "Sky", "Enter keeps the new name"); - assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("layers-rename"))).is_none(), "and closes the field"); - open(&mut app); - frame(&mut app, &ctx, vec![select_all, egui::Event::Text("Ground".into())], false); - frame(&mut app, &ctx, vec![key(egui::Key::Escape)], false); - frame(&mut app, &ctx, vec![], false); - assert_eq!(name(&app), "Sky", "Escape cancels"); - } - - /// Shift+Enter in the name field renames the layer and keeps the field open, the new name - /// selected; Enter then renames it again and closes the field. - #[test] - fn shift_enter_renames_a_layer_and_keeps_the_field_open() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let layer = app.session.doc().unwrap().doc.layers[0].id; - let name = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].display_name(); - let ctx = egui::Context::default(); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - let field = rename_id().with("field"); - ctx.data_mut(|d| d.insert_temp(rename_id(), (layer.0, name(&app)))); - frame(&mut app, &ctx, vec![], false); - frame(&mut app, &ctx, vec![], false); - frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Sky".into())], false); - frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::SHIFT)], false); - assert_eq!(name(&app), "Sky", "Shift+Enter applies the name"); - assert!(ctx.memory(|m| m.has_focus(field)), "the field keeps the focus"); - assert!(ctx.data(|d| d.get_temp::<(u64, String)>(rename_id())).is_some(), "and stays open"); - frame(&mut app, &ctx, vec![], false); - let selected = egui::TextEdit::load_state(&ctx, field).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); - assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 3)), "the name shown is selected"); - frame(&mut app, &ctx, vec![egui::Event::Text("Sea".into())], false); - frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], false); - frame(&mut app, &ctx, vec![], false); - assert_eq!(name(&app), "Sea", "typing replaced it, and Enter applies that"); - assert!(ctx.data(|d| d.get_temp::<(u64, String)>(rename_id())).is_none(), "and closes the field"); - } - - #[test] - fn an_open_rename_does_not_follow_into_another_document() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let layer = app.session.doc().unwrap().doc.layers[0].id; - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![], false); - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("layers-rename"), (layer.0, "Renamed in the first".to_string()))); - frame(&mut app, &ctx, vec![], false); - // A second document, whose first layer has the same node id, comes to the front. - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc.layers[0].id, layer); - let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - frame(&mut app, &ctx, vec![], false); - frame(&mut app, &ctx, vec![enter], false); - frame(&mut app, &ctx, vec![], false); - assert_eq!(app.session.doc().unwrap().doc.layers[0].display_name(), "Layer 1"); - assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("layers-rename"))).is_none()); - } - - #[test] - fn masks_underline_dashed_and_mask_editing_lists_only_the_mask() { - let (mut app, _, [a, _]) = two_rects(); - let ctx = egui::Context::default(); - let (_, before) = frame(&mut app, &ctx, vec![], false); - app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); - app.session.execute("transparency.makeOpacityMask", &json!({})).unwrap(); - // Editing the new (empty) mask: one row, ``. - let (c, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(c.len(), 1); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); - assert!(texts.iter().any(|(t, _)| t == ""), "{texts:?}"); - app.session.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); - let (c, after) = frame(&mut app, &ctx, vec![], false); - assert_eq!(c.len(), 3); - assert!(after > before + 1, "a dashed underline: {before} → {after}"); - } - - // ---- rows at every depth (layers, sublayers, groups, objects) ---- - - /// Layer 1 holding rectangle `c` and a sublayer holding rectangle `a` and a group of - /// rectangle `b`, every row open, nothing selected → (app, [layer, sub, g, b, a, c]): the rows - /// top down. - fn nested() -> (VectorcraftApp, [NodeId; 6]) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap()); - run(&mut app, "file.new", json!({"width": 400, "height": 400})); - let layer = app.session.active().unwrap().doc.layers[0].id; - let c = id(run(&mut app, "shape.rectangle", json!({"x": 300, "y": 0, "width": 20, "height": 20}))); - let sub = id(run(&mut app, "layer.newSublayer", json!({"name": "Sub"}))); - let a = id(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))); - let b = id(run(&mut app, "shape.rectangle", json!({"x": 100, "y": 0, "width": 20, "height": 20}))); - run(&mut app, "select.set", json!({"ids": [b.0]})); - let g = id(run(&mut app, "object.group", json!({}))); - run(&mut app, "select.none", json!({})); - expand(&mut app, &json!({})).unwrap(); - (app, [layer, sub, g, b, a, c]) - } - - /// Press and release at `at` with `mods` held, over three frames. - fn click_with(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, mods: egui::Modifiers) { - for e in [egui::Event::PointerMoved(at), button(at, true), button(at, false)] { - let mut out = - ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(mods), e], ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - } - } - - fn rows_of(app: &VectorcraftApp) -> Vec { - app.session.active().unwrap().highlighted_rows() - } - - #[test] - fn rows_at_every_depth_are_clickable() { - let (mut app, [layer, sub, g, b, a, c]) = nested(); - let ctx = egui::Context::default(); - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 6, "a row for the layer, the sublayer, the group, both rectangles in it and the one on the layer"); - let name = |i: usize| egui::pos2(150.0, circles[i].y); - for (i, row, current) in [(1, sub, sub), (2, g, sub), (3, b, sub), (4, a, sub), (5, c, layer), (0, layer, layer)] { - click_with(&mut app, &ctx, name(i), egui::Modifiers::NONE); - let st = app.session.active().unwrap(); - assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)), "row {i}"); - } - // New art goes into the current sublayer once its row is clicked. - click_with(&mut app, &ctx, name(1), egui::Modifiers::NONE); - let n = app.session.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - assert_eq!(app.session.active().unwrap().doc.parent_of(NodeId(n)), Some(sub)); - app.session.execute("edit.undo", &json!({})).unwrap(); - // Shift-click highlights the range down to the row; Ctrl/Cmd-click toggles one. - click_with(&mut app, &ctx, name(4), egui::Modifiers::SHIFT); - assert_eq!(rows_of(&app), vec![sub, g, b, a]); - click_with(&mut app, &ctx, name(2), egui::Modifiers::COMMAND); - assert_eq!(rows_of(&app), vec![sub, b, a]); - click_with(&mut app, &ctx, name(5), egui::Modifiers::COMMAND); - assert_eq!(rows_of(&app), vec![sub, b, a, c]); - // Delete with the panel's keyboard: the highlighted rows go. - let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - frame(&mut app, &ctx, vec![del], false); - let d = &app.session.active().unwrap().doc; - assert!(d.node(sub).is_none() && d.node(c).is_none()); - assert_eq!(d.layers.len(), 1); - } - - /// A layer's ruler guides are rows in it, above its art: a click selects the guide, a drag onto - /// another layer's row moves it there, and a guide on a locked layer is left alone. - #[test] - fn guide_rows_select_and_move_their_guides() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 200, "height": 200})); - run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); - run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); - run(&mut app, "select.none", json!({})); - let second = NodeId(run(&mut app, "layer.new", json!({}))["id"].as_u64().unwrap()); - expand(&mut app, &json!({})).unwrap(); - let ctx = egui::Context::default(); - // Rows top down: Layer 2, Layer 1, its guide (no target circle), the rectangle. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 3); - let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0); - let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); - click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); - assert_eq!(guides(&app), [0], "its row selects it"); - // Dragged onto Layer 2's row, it goes on Layer 2, in one undo step. - let undo = app.session.active().unwrap().history.undo.len(); - drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false); - let st = app.session.active().unwrap(); - assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1)); - // Now under Layer 2: locked, its row neither selects nor drags it. - run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true})); - assert!(guides(&app).is_empty(), "deselected with its layer locked"); - let (circles, _) = frame(&mut app, &ctx, vec![], false); - let guide_row = egui::pos2(150.0, (circles[0].y + circles[1].y) / 2.0); - click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); - assert!(guides(&app).is_empty()); - drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false); - assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second)); - } - - /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows - /// holding selected art). - fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - let size = |w: f32| { - out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if (r.rect.width() - w).abs() < 0.01 && (r.rect.height() - w).abs() < 0.01 && r.fill != Color32::TRANSPARENT)).count() - }; - (size(7.0), size(4.0)) - } - - #[test] - fn selection_squares_show_and_select_a_rows_art() { - let (mut app, [layer, sub, g, b, a, c]) = nested(); - let ctx = egui::Context::default(); - assert_eq!(squares(&mut app, &ctx), (0, 0)); - app.session.execute("select.set", &json!({"ids": [g.0]})).unwrap(); - assert_eq!(squares(&mut app, &ctx), (1, 2), "the group's square, and small ones on the sublayer and the layer"); - let (circles, _) = frame(&mut app, &ctx, vec![], false); - let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); - let sel = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); - click_with(&mut app, &ctx, square(4), egui::Modifiers::NONE); - assert_eq!(sel(&app), vec![a]); - click_with(&mut app, &ctx, square(5), egui::Modifiers::SHIFT); - assert_eq!(sel(&app), vec![a, c]); - click_with(&mut app, &ctx, square(1), egui::Modifiers::NONE); - assert_eq!(sel(&app), vec![a, g], "a sublayer's square selects its art"); - click_with(&mut app, &ctx, square(0), egui::Modifiers::NONE); - assert_eq!(sel(&app), vec![c, a, g], "a layer's, its sublayers' too"); - let _ = (layer, sub, b); - } - - #[test] - fn eye_and_lock_toggle_nested_rows_and_drag_down_the_column() { - let (mut app, [_, sub, g, b, a, _]) = nested(); - let ctx = egui::Context::default(); - let (circles, _) = frame(&mut app, &ctx, vec![], false); - let (eye, lock) = (|i: usize| egui::pos2(12.0, circles[i].y), |i: usize| egui::pos2(37.0, circles[i].y)); - let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone(); - click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE); - assert!(!node(&app, b).visible, "an object in a group in a sublayer hides"); - click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE); - click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE); - assert!(node(&app, g).locked, "a group locks"); - click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE); - // Ctrl/Cmd-click a layer's eye: Outline, and back. - click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND); - assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: false, .. })); - click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND); - assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: true, .. })); - // Drag down the eye column from the group to the rectangle below: all three hide, one step. - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - let before = undo(&app); - let steps = [ - vec![egui::Event::PointerMoved(eye(2))], - vec![button(eye(2), true)], - vec![egui::Event::PointerMoved(eye(2) + vec2(0.0, 6.0))], - vec![egui::Event::PointerMoved(eye(3))], - vec![egui::Event::PointerMoved(eye(4))], - vec![button(eye(4), false)], - vec![], - ]; - for e in steps { - frame(&mut app, &ctx, e, false); - } - assert!(!node(&app, g).visible && !node(&app, b).visible && !node(&app, a).visible); - assert!(node(&app, sub).visible, "rows it didn't pass stay"); - assert_eq!(undo(&app), before + 1, "one undo step"); - assert!(!app.session.in_interaction()); - app.session.execute("edit.undo", &json!({})).unwrap(); - assert!(node(&app, g).visible && node(&app, b).visible && node(&app, a).visible); - } - - #[test] - fn triangles_open_and_close_rows_and_alt_does_everything_inside() { - let (mut app, [layer, sub, ..]) = nested(); - let ctx = egui::Context::default(); - let count = |app: &mut VectorcraftApp| frame(app, &ctx, vec![], false).0.len(); - assert_eq!(count(&mut app), 6); - let (circles, _) = frame(&mut app, &ctx, vec![], false); - let triangle = |depth: f32, y: f32| egui::pos2(2.0 * COLUMN + 8.0 + depth * INDENT + 7.0, y); - // Close the sublayer: its group, the group's rectangle and its own rectangle go. - click_with(&mut app, &ctx, triangle(1.0, circles[1].y), egui::Modifiers::NONE); - assert_eq!(count(&mut app), 3); - // Alt-click the layer's: everything inside closes, and opens again. - click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT); - assert_eq!(count(&mut app), 1); - click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT); - assert_eq!(count(&mut app), 6); - // Show Layers Only (Panel Options). - app.ui.layers_panel.layers_only = true; - assert_eq!(count(&mut app), 2); - app.ui.layers_panel = PanelOptions { row_size: ROW_LARGE, ..Default::default() }; - let (c, _) = frame(&mut app, &ctx, vec![], false); - assert!((c[1].y - c[0].y - ROW_LARGE).abs() < 0.5, "large rows"); - let _ = (layer, sub); - } - - // ---- Search All and the Filter ---- - - /// Layer 1 holding a path, a rectangle, type reading "Sky line", a group of an ellipse with an - /// effect, a compound path, a guide, an embedded and a linked image and a ruler guide, and on top - /// a sublayer "Sub" holding a hidden path named "Sky" and a locked path; only Layer 1 open and - /// nothing selected → (app, the rows' ids by name). - fn kinds() -> (VectorcraftApp, HashMap<&'static str, NodeId>) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap()); - let star = |app: &mut VectorcraftApp, x: u32| id(run(app, "shape.star", json!({"cx": x, "cy": 50, "radius1": 20, "radius2": 10}))); - run(&mut app, "file.new", json!({"width": 400, "height": 400})); - let mut ids = HashMap::from([("layer", app.session.active().unwrap().doc.layers[0].id)]); - ids.insert("path", star(&mut app, 50)); - ids.insert("rect", id(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 100, "width": 20, "height": 20})))); - ids.insert("text", id(run(&mut app, "text.create", json!({"x": 10, "y": 200, "text": "Sky line"})))); - let ellipse = id(run(&mut app, "shape.ellipse", json!({"x": 100, "y": 100, "width": 20, "height": 20}))); - run(&mut app, "effect.apply", json!({"effect": "distort.twist", "ids": [ellipse.0]})); - run(&mut app, "select.set", json!({"ids": [ellipse.0]})); - ids.insert("group", id(run(&mut app, "object.group", json!({})))); - ids.insert("ellipse", ellipse); - let parts = [star(&mut app, 150), star(&mut app, 160)]; - run(&mut app, "select.set", json!({"ids": [parts[0].0, parts[1].0]})); - ids.insert("compound", id(run(&mut app, "object.compoundPath.make", json!({})))); - let guide = id(run(&mut app, "shape.arc", json!({"x1": 0, "y1": 300, "x2": 50, "y2": 350}))); - run(&mut app, "select.set", json!({"ids": [guide.0]})); - run(&mut app, "view.guides.make", json!({})); - ids.insert("guide", guide); - run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); - let mut png = vec![]; - image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255])) - .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .unwrap(); - let placed = |app: &mut VectorcraftApp, p: serde_json::Value| NodeId(run(app, "file.place", p)["ids"][0].as_u64().unwrap()); - ids.insert("embedded", placed(&mut app, json!({"name": "pic.png", "dataBase64": vectorcraft_format::base64_encode(&png)}))); - // One file for every test that calls this, written by the first. - static LINKED: std::sync::OnceLock = std::sync::OnceLock::new(); - let file = LINKED.get_or_init(|| { - let file = vectorcraft_testkit::temp_dir("layers-filter").join("linked.png"); - std::fs::write(&file, &png).unwrap(); - file - }); - ids.insert("linked", placed(&mut app, json!({"path": file.to_string_lossy()}))); - ids.insert("sub", id(run(&mut app, "layer.newSublayer", json!({"name": "Sub"})))); - let hidden = star(&mut app, 250); - run(&mut app, "layer.setProps", json!({"ids": [hidden.0], "name": "Sky", "visible": false})); - let locked = star(&mut app, 300); - run(&mut app, "layer.setProps", json!({"ids": [locked.0], "locked": true})); - ids.extend([("hidden", hidden), ("locked", locked)]); - run(&mut app, "select.none", json!({})); - (app, ids) - } - - /// The rows [`list_rows`] lists for `query` and `filter`, by their names in `ids` ("ruler guide" - /// for a guide's row). - fn listed(app: &VectorcraftApp, ids: &HashMap<&'static str, NodeId>, query: &str, filter: &Filter) -> Vec<&'static str> { - let st = app.session.active().unwrap(); - let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), query, filter); - let name = |r: &Row| match r.guide { - Some(_) => "ruler guide", - None => ids.iter().find(|(_, id)| **id == r.node.id).map_or("?", |(name, _)| *name), - }; - rows.iter().map(name).collect() - } - - fn only(k: ObjectKind) -> Filter { - Filter { objects: vec![k], layers: vec![] } - } - - /// Each object kind keeps exactly the rows of that kind, with the rows holding them, open or - /// closed. - #[test] - fn each_object_kind_keeps_its_rows_and_the_rows_holding_them() { - let (app, ids) = kinds(); - for (k, want) in [ - (ObjectKind::Paths, vec!["layer", "sub", "locked", "hidden", "compound", "path"]), - (ObjectKind::Shapes, vec!["layer", "group", "ellipse", "rect"]), - (ObjectKind::Text, vec!["layer", "text"]), - (ObjectKind::Images, vec!["layer", "linked", "embedded"]), - (ObjectKind::Effects, vec!["layer", "group", "ellipse"]), - (ObjectKind::Groups, vec!["layer", "group"]), - (ObjectKind::LinkedFiles, vec!["layer", "linked"]), - (ObjectKind::Guides, vec!["layer", "ruler guide", "guide"]), - ] { - assert_eq!(listed(&app, &ids, "", &only(k)), want, "{k:?}"); - } - } - - /// By Layer keeps the rows that are locked or hidden themselves: in a hidden, locked sublayer a - /// row stays only for its own lock or eye. Two choices in a section take either; a choice in - /// each section takes rows that are both. - #[test] - fn row_states_keep_locked_or_hidden_rows_and_combine_with_kinds() { - let (mut app, ids) = kinds(); - let f = |objects: Vec, layers: Vec| Filter { objects, layers }; - assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Hidden])), ["layer", "sub", "hidden"]); - assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Locked])), ["layer", "sub", "locked"]); - assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Locked, RowState::Hidden])), ["layer", "sub", "locked", "hidden"]); - assert_eq!(listed(&app, &ids, "", &f(vec![ObjectKind::Text, ObjectKind::Images], vec![])), ["layer", "linked", "embedded", "text"]); - assert_eq!(listed(&app, &ids, "", &f(vec![ObjectKind::Paths], vec![RowState::Hidden])), ["layer", "sub", "hidden"]); - assert!(listed(&app, &ids, "", &f(vec![ObjectKind::Shapes], vec![RowState::Hidden])).is_empty()); - // Once Sub is hidden and locked, the rows in it stay only for their own lock or eye. - app.session.execute("layer.setProps", &json!({"ids": [ids["sub"].0], "visible": false, "locked": true})).unwrap(); - assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Hidden])), ["layer", "sub", "hidden"]); - assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Locked])), ["layer", "sub", "locked"]); - } - - /// The filter and the search combine: a row stays when its name holds the text and the filter - /// keeps it, or when it holds such a row. - #[test] - fn the_filter_combines_with_the_search() { - let (app, ids) = kinds(); - assert_eq!(listed(&app, &ids, "sky", &Filter::default()), ["layer", "sub", "hidden", "text"]); - assert_eq!(listed(&app, &ids, "sky", &only(ObjectKind::Text)), ["layer", "text"]); - assert_eq!(listed(&app, &ids, "sky", &Filter { objects: vec![], layers: vec![RowState::Hidden] }), ["layer", "sub", "hidden"]); - assert!(listed(&app, &ids, "sky", &only(ObjectKind::Groups)).is_empty()); - } - - /// (target circles, lit 24 pt buttons) one frame of the panel draws. - fn circles_and_lit_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { - let lit = Tokens::get(ctx).tool_active; - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - let circles = out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 5.0)).count(); - let buttons = - out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == lit && (r.rect.width() - 24.0).abs() < 0.01)).count(); - (circles, buttons) - } - - /// `ui.layersFilter` has the panel list its rows again at once and lights the Filter button - /// while a filter is on; clearing it lists every row again. The document, its open rows and the - /// selection stay as they were. - #[test] - fn the_panel_lists_its_rows_again_when_the_filter_changes() { - let (mut app, ids) = kinds(); - app.session.execute("select.set", &json!({"ids": [ids["rect"].0]})).unwrap(); - let ctx = egui::Context::default(); - let before = circles_and_lit_buttons(&mut app, &ctx); - assert_eq!(before.1, 0, "the Filter button is not lit"); - let st = app.session.active().unwrap(); - let (revision, open) = (st.revision, st.layers_open.generation()); - let r = app.run("ui.layersFilter", json!({"objects": ["text"]})).unwrap(); - assert_eq!(r, json!({"objects": ["text"], "layers": []})); - assert_eq!(circles_and_lit_buttons(&mut app, &ctx), (2, 1), "Layer 1 and the type; the button is lit"); - app.run("ui.layersFilter", json!({"layers": ["hidden"]})).unwrap(); - assert_eq!(circles_and_lit_buttons(&mut app, &ctx), (0, 1), "no type is hidden"); - app.run("ui.layersFilter", json!({"objects": [], "layers": []})).unwrap(); - assert_eq!(circles_and_lit_buttons(&mut app, &ctx), before, "every row again"); - let st = app.session.active().unwrap(); - assert_eq!((st.revision, st.layers_open.generation(), st.selection.objects.clone()), (revision, open, vec![ids["rect"]])); - } - - /// Clicks in the Filter button's popup tick a kind and a state, and a second click clears the - /// kind, while the popup stays open; Clear All turns the filter off. - #[test] - fn the_filter_popup_ticks_kinds_and_states_and_clear_all_turns_the_filter_off() { - let (mut app, _) = kinds(); - let ctx = egui::Context::default(); - // The Filter button: the 24 pt square lit while a filter is on. - app.run("ui.layersFilter", json!({"layers": ["locked"]})).unwrap(); - let lit = Tokens::get(&ctx).tool_active; - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); - out.textures_delta.clear(); - let filter = out - .shapes - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Rect(r) if r.fill == lit && (r.rect.width() - 24.0).abs() < 0.01 => Some(r.rect.center()), - _ => None, - }) - .unwrap(); - app.run("ui.layersFilter", json!({"layers": []})).unwrap(); - click_with(&mut app, &ctx, filter, egui::Modifiers::NONE); - // Where the popup draws `label` (after a frame for it to settle). - let at = |app: &mut VectorcraftApp, label: &str| { - super::super::tests_appearance::frame_events(&ctx, app, vec![], show); - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], show); - texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{label} not in {texts:?}")) - }; - let text = at(&mut app, "Text"); - click_with(&mut app, &ctx, text, egui::Modifiers::NONE); - assert_eq!(app.ui.layers_filter, Filter { objects: vec![ObjectKind::Text], layers: vec![] }); - let hidden = at(&mut app, "Hidden"); - click_with(&mut app, &ctx, hidden, egui::Modifiers::NONE); - assert_eq!(app.ui.layers_filter, Filter { objects: vec![ObjectKind::Text], layers: vec![RowState::Hidden] }); - click_with(&mut app, &ctx, text, egui::Modifiers::NONE); - assert_eq!(app.ui.layers_filter, Filter { objects: vec![], layers: vec![RowState::Hidden] }, "a second click takes it out"); - let clear = at(&mut app, "Clear All"); - click_with(&mut app, &ctx, clear, egui::Modifiers::NONE); - assert!(!app.ui.layers_filter.is_on()); - } - - /// `ui.layersFilter` reads its lists by name in the popup's order, keeps a list it is not given, - /// and changes nothing when a list holds anything else. - #[test] - fn the_filter_command_reads_its_lists_by_name() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let r = app.run("ui.layersFilter", json!({"objects": ["guides", "paths", "paths"], "layers": ["hidden"]})).unwrap(); - assert_eq!(r, json!({"objects": ["paths", "guides"], "layers": ["hidden"]})); - let r = app.run("ui.layersFilter", json!({"layers": []})).unwrap(); - assert_eq!(r, json!({"objects": ["paths", "guides"], "layers": []})); - for bad in [json!({"objects": ["paths", "layers"]}), json!({"objects": "paths"}), json!({"objects": [], "layers": ["visible"]})] { - let e = app.run("ui.layersFilter", bad.clone()).unwrap_err(); - let key = if bad["layers"].is_null() { "objects" } else { "layers" }; - assert!(e.starts_with(&format!("`{key}` must be an array of these names: ")), "{bad}: {e}"); - } - assert_eq!(app.run("ui.layersFilter", json!({})).unwrap(), json!({"objects": ["paths", "guides"], "layers": []}), "unchanged"); - } - - #[test] - fn dragging_a_layer_into_the_empty_space_under_the_rows_moves_it_to_the_bottom() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - app.session.execute("layer.new", &json!({"top": true})).unwrap(); - let ids = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>(); - let [bottom, top] = ids(&app)[..] else { panic!("two layers") }; - let ctx = egui::Context::default(); - // Rows: the top layer, the bottom layer, then empty space. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 2); - drag(&mut app, &ctx, egui::pos2(150.0, circles[0].y), egui::pos2(150.0, circles[1].y + 3.0 * ROW_MEDIUM), false); - assert_eq!(ids(&app), vec![top, bottom], "the top layer is now the bottom one"); - } - - #[test] - fn dragging_rows_moves_them_into_other_layers_and_alt_copies() { - let (mut app, [layer, sub, g, _, a, c]) = nested(); - let ctx = egui::Context::default(); - let l2 = NodeId(app.session.execute("layer.new", &json!({"top": true, "name": "Top"})).unwrap()["id"].as_u64().unwrap()); - // Rows: Top, Layer 1, Sub, g, b, a, c. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 7); - let name = |i: usize| egui::pos2(150.0, circles[i].y); - let parent = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.parent_of(id); - // Rectangle a onto the middle of the Top layer's row: into it. - drag(&mut app, &ctx, name(5), name(0), false); - assert_eq!(parent(&app, a), Some(l2)); - // Rows: Top, a, Layer 1, Sub, g, b, c. The sublayer onto the upper edge of Layer 1's row: - // above it, a top-level layer now. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 7); - drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[2].y - ROW_MEDIUM * 0.4), false); - assert_eq!(parent(&app, sub), None); - let d = &app.session.active().unwrap().doc; - assert_eq!(d.layers.iter().map(|l| l.id).collect::>(), vec![layer, sub, l2]); - // Rows: Top, a, Sub, g, b, Layer 1, c. Alt-drag the group into Layer 1: a copy goes there, - // the group stays. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - let count = app.session.active().unwrap().doc.node_count(); - drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[5].y), true); - assert_eq!(parent(&app, g), Some(sub)); - assert_eq!(app.session.active().unwrap().doc.node_count(), count + 2, "the group and its rectangle, copied"); - let _ = (layer, c); - } - - /// The centres of the bottom bar's buttons, right to left from under the right end of its top - /// divider: Delete, New Layer, New Sublayer, Make/Release Clipping Mask, Locate Object. - fn bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - let divider = Tokens::get(ctx).divider; - let corner = out - .shapes - .iter() - .rev() - .find_map(|c| match &c.shape { - egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]), - _ => None, - }) - .unwrap(); - let step = 24.0 + ctx.global_style().spacing.item_spacing.x; - (0..5).map(|k| corner + vec2(-12.0 - k as f32 * step, 15.0)).collect() - } - - /// How many texts reading `s` the frame after `events` paints. - fn texts(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, s: &str) -> usize { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == s)).count() - } - - /// Drag the row at `a` to `b` and drop it there. Returns how many times its `name` was painted - /// while it hung over `b`. - fn drag_row(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, name: &str) -> usize { - frame(app, ctx, vec![egui::Event::PointerMoved(a)], false); - frame(app, ctx, vec![button(a, true)], false); - frame(app, ctx, vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], false); - let shown = texts(app, ctx, vec![egui::Event::PointerMoved(b)], name); - frame(app, ctx, vec![button(b, false)], false); - frame(app, ctx, vec![], false); - shown - } - - #[test] - fn dropping_rows_on_new_layer_duplicates_them() { - let (mut app, first, _) = two_rects(); - let second = app.session.execute("layer.new", &json!({"name": "Top"})).unwrap()["id"].as_u64().map(NodeId).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let layers = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>(); - // Rows top down: Top, Layer 1 (expanded: its two rectangles). - let (c, _) = frame(&mut app, &ctx, vec![], false); - let buttons = bar_buttons(&mut app, &ctx); - // Layer 1 dropped on New Layer: a copy of it right above it. - let shown = drag_row(&mut app, &ctx, egui::pos2(150.0, c[1].y), buttons[1], "Layer 1"); - assert_eq!(shown, 2, "the row and its ghost under the pointer"); - let after = layers(&app); - assert_eq!(after.len(), 3); - assert_eq!((after[0], after[2]), (first, second)); - assert_eq!(app.session.active().unwrap().doc.node(after[1]).unwrap().children().map(Vec::len), Some(2)); - assert_eq!(texts(&mut app, &ctx, vec![], "Layer 1"), 1, "the ghost is gone after the drop"); - // One undo step takes the copy away. - app.session.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(layers(&app), vec![first, second]); - // The other bottom-bar buttons are no drop targets. - drag_row(&mut app, &ctx, egui::pos2(150.0, c[0].y), buttons[2], "Top"); - assert_eq!(layers(&app), vec![first, second]); - } - - /// Cmd+Alt+L is New Layer Options… by default. On Windows and Linux the key is Ctrl+Alt+L, and on - /// macOS it is Option+Command+L. Cmd+L is still New Layer. No other command, tool or panel has - /// Cmd+Alt+L by default. - #[test] - fn cmd_alt_l_is_new_layer_options() { - use crate::shortcut_editor::{command_for_key, default_command_shortcut}; - assert_eq!(default_command_shortcut("ui.newLayer"), Some("Cmd+Alt+L")); - assert_eq!(command_for_key("Cmd+Alt+L"), Some("ui.newLayer")); - assert_eq!(command_for_key("Cmd+L"), Some("layer.new")); - let chord = crate::shortcuts::parse("Cmd+Alt+L").expect("parses"); - assert_eq!((chord.modifiers, chord.logical_key), (egui::Modifiers::COMMAND | egui::Modifiers::ALT, egui::Key::L)); - let shown = crate::menus::pretty_shortcut("Cmd+Alt+L"); - assert_eq!(shown, if cfg!(target_os = "macos") { "⌥⌘L" } else { "Ctrl+Alt+L" }); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let listed = app.run("shortcuts.list", json!({"query": "ui.newLayer"})).unwrap(); - let rows: Vec<_> = listed.as_array().expect("rows").iter().map(|r| (r["label"].clone(), r["default"].clone())).collect(); - assert_eq!(rows, [(json!("New Layer Options…"), json!("Cmd+Alt+L"))], "Keyboard Shortcuts lists it"); - assert_eq!(app.run("shortcuts.conflicts", json!({})).unwrap(), json!([]), "no other default has the key"); - } - - /// Cmd+Alt+L, pressed through the shortcut handler, opens Layer Options for a new layer as an - /// Alt-click on the panel's New Layer button does, and no layer is added until OK. OK adds the - /// layer above the current layer, as after the Alt-click, and Cancel adds none. Cmd+L still adds - /// a layer without the dialog. - #[test] - fn cmd_alt_l_opens_new_layer_options_as_alt_clicking_new_layer_does() { - fn press(app: &mut VectorcraftApp, modifiers: egui::Modifiers) { - let ctx = egui::Context::default(); - let ev = egui::Event::Key { key: egui::Key::L, physical_key: None, pressed: true, repeat: false, modifiers }; - let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); - out.textures_delta.clear(); - } - /// Layer 1 with two rectangles under a layer named Top, with Layer 1 current. - fn two_layers() -> VectorcraftApp { - let (mut app, first, _) = two_rects(); - app.session.execute("layer.new", &json!({"name": "Top"})).unwrap(); - app.session.execute("layer.setCurrent", &json!({"id": first.0})).unwrap(); - app - } - let names = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.display_name()).collect::>(); - let dialog = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.clone())); - let mut keyed = two_layers(); - press(&mut keyed, egui::Modifiers::COMMAND | egui::Modifiers::ALT); - let mut clicked = two_layers(); - let ctx = egui::Context::default(); - let new_layer = bar_buttons(&mut clicked, &ctx)[1]; - click_with(&mut clicked, &ctx, new_layer, egui::Modifiers::ALT); - let opened = dialog(&keyed).expect("the key opens a dialog"); - assert_eq!((opened.0.as_str(), opened.1.get("mode")), ("layerOptions", Some(&json!("new")))); - assert_eq!(dialog(&clicked), Some(opened), "the key opens what the Alt-click opens"); - assert_eq!(names(&keyed), ["Layer 1", "Top"], "no layer is added until OK"); - for app in [&mut keyed, &mut clicked] { - crate::dialogs::confirm(app).unwrap(); - } - assert_eq!(names(&keyed), ["Layer 1", "Layer 3", "Top"]); - assert_eq!(names(&clicked), names(&keyed)); - // Cancel adds no layer. - press(&mut keyed, egui::Modifiers::COMMAND | egui::Modifiers::ALT); - assert!(keyed.ui.dialog.is_some()); - crate::dialogs::cancel(&mut keyed); - assert!(keyed.ui.dialog.is_none()); - assert_eq!(names(&keyed), ["Layer 1", "Layer 3", "Top"]); - press(&mut keyed, egui::Modifiers::COMMAND); - assert!(keyed.ui.dialog.is_none(), "Cmd+L opens no dialog"); - assert_eq!(names(&keyed).len(), 4); - } - - #[test] - fn the_selected_art_square_drags_the_selection_to_another_layer() { - let (mut app, [_, sub, g, _, a, c]) = nested(); - let ctx = egui::Context::default(); - app.session.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap(); - let (circles, _) = frame(&mut app, &ctx, vec![], false); - let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); - // From the sublayer's (small) square onto Layer 1's row. - drag(&mut app, &ctx, square(1), egui::pos2(150.0, circles[0].y), false); - let d = &app.session.active().unwrap().doc; - assert_eq!(d.node(d.layers[0].id).unwrap().children().unwrap().iter().map(|n| n.id).collect::>(), vec![c, sub, a, g]); - assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "still selected"); - } - - #[test] - fn the_panel_menu_lists_every_operation() { - let (mut app, _) = nested(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - for label in [ - "New Layer…", - "New Sublayer…", - "Duplicate Selection", - "Delete Selection", - "Options for Selection…", - "Make/Release Clipping Mask", - "Enter Isolation Mode", - "Locate Object", - "Save Selection", - "Merge Selected", - "Flatten Artwork", - "Collect in New Layer", - "Collect for Export", - "Release to Layers (Sequence)", - "Release to Layers (Build)", - "Reverse Order", - "Template", - "Show All Layers", - "Preview All Layers", - "Unlock All Layers", - "Paste Remembers Layers", - "Panel Options…", - ] { - assert!(texts.iter().any(|(t, _)| t.trim() == label), "{label} missing from {texts:?}"); - } - } - - /// ≡ › Save Selection lists the saved selections: choosing one recalls it, Update Selection - /// makes it hold the selected objects, and Save Selection… opens the name dialog. - #[test] - fn the_save_selection_submenu_recalls_updates_and_saves_selections() { - let (mut app, _, [a, b]) = two_rects(); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - app.run("select.save", json!({"name": "Left"})).unwrap(); - app.run("select.set", json!({"ids": [b.0]})).unwrap(); - app.run("select.save", json!({"name": "Right"})).unwrap(); - app.run("select.none", json!({})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let frame = |app: &mut VectorcraftApp, events: Vec| super::super::tests_appearance::frame_events(&ctx, app, events, menu); - // Opens the submenu (when closed) and clicks the item labeled `label` in it. - let choose = |app: &mut VectorcraftApp, label: &str| { - let at = |texts: &[(String, egui::Rect)], label: &str| texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()); - let mut texts = frame(app, vec![]); - if at(&texts, label).is_none() { - let sub = at(&texts, "Save Selection").expect("the Save Selection submenu"); - for e in [egui::Event::PointerMoved(sub), button(sub, true), button(sub, false)] { - frame(app, vec![e]); - } - texts = frame(app, vec![]); - } - let item = at(&texts, label).unwrap_or_else(|| panic!("{label} missing from {texts:?}")); - for e in [egui::Event::PointerMoved(item), button(item, true), button(item, false)] { - frame(app, vec![e]); - } - }; - let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); - choose(&mut app, "Left"); - assert_eq!(selected(&app), vec![a], "choosing a name recalls it"); - app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap(); - choose(&mut app, "Update Selection"); - let saved = app.session.active().unwrap().doc.saved_selections.clone(); - assert_eq!(saved.iter().map(|s| (s.name.as_str(), s.objects.len())).collect::>(), vec![("Left", 2), ("Right", 1)]); - assert!(app.ui.dialog.is_none()); - choose(&mut app, "Save Selection…"); - let d = app.ui.dialog.clone().expect("Save Selection dialog"); - assert_eq!((d.str("__command"), d.str("name")), ("select.save".into(), "Selection 1".into())); - } - - /// With the dock collapsed, Layers shows in a floating flyout: its rows work there too. - #[test] - fn rows_work_in_the_floating_flyout() { - let (mut app, [_, sub, ..]) = nested(); - app.ui.dock_collapsed = true; - app.ui.open_panel = Some("layers".into()); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0)); - let mut time = 0.0; - let mut frame = |app: &mut VectorcraftApp, events: Vec| { - time += 0.05; - let input = egui::RawInput { time: Some(time), events, screen_rect: Some(screen), ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| crate::panel_docking::show(app, ui)); - out.textures_delta.clear(); - let mut c: Vec = out - .shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Circle(cs) if cs.radius == 5.0 => Some(cs.center), - _ => None, - }) - .collect(); - c.sort_by(|a, b| a.y.total_cmp(&b.y)); - c - }; - for _ in 0..3 { - frame(&mut app, vec![]); - } - let circles = frame(&mut app, vec![]); - assert_eq!(circles.len(), 6, "every row shows in the flyout"); - assert!(circles.iter().all(|c| screen.contains(*c))); - let at = circles[1] - vec2(120.0, 0.0); - frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]); - frame(&mut app, vec![button(at, false)]); - frame(&mut app, vec![]); - assert_eq!(rows_of(&app), vec![sub]); - assert_eq!(app.session.active().unwrap().current_layer(), Some(sub)); - } - - // ---- Locate Selected Objects (Panel Options) ---- - - /// A layer of `n` groups of one path each, with only the layer open, as a document opens → - /// (app, layer, the paths in stacking order, so the first is in the bottom row). The path in - /// group `i` is named `Path {i}`. - fn groups_of_one(n: usize) -> (VectorcraftApp, NodeId, Vec) { - use vectorcraft_doc::Appearance; - use vectorcraft_geom::{Rect, shapes}; - let mut d = Document::new(1000.0, 1000.0); - let mut groups = vec![]; - let mut paths = vec![]; - for i in 0..n { - let r = Rect::new(0.0, i as f64, 1.0, i as f64 + 1.0); - let mut path = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); - path.name = Some(format!("Path {i}")); - paths.push(path.id); - groups.push(Arc::new(Node::group(d.alloc_id(), vec![Arc::new(path)]))); - } - *Arc::make_mut(&mut d.layers[0]).children_mut().unwrap() = groups; - let layer = d.layers[0].id; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.add_document(d, None); - (app, layer, paths) - } - - /// One frame of the panel in a 300 × 800 window, one second after the previous frame: the - /// centers of the target circles, top row first, and whether `name` is drawn. - fn tall_frame(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) -> (Vec, bool) { - let time = ctx.input(|i| i.time) + 1.0; - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 800.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - let found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == name)); - let mut circles: Vec = out - .shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Circle(cs) if cs.radius == 5.0 => Some(cs.center), - _ => None, - }) - .collect(); - circles.sort_by(|a, b| a.y.total_cmp(&b.y)); - (circles, found) - } - - /// Without Locate Selected Objects (the default), an object selected outside the panel leaves - /// the open rows and the scroll position as they were. - #[test] - fn without_the_option_a_selection_leaves_the_rows_and_the_scroll_as_they_were() { - let (mut app, layer, paths) = groups_of_one(500); - let ctx = egui::Context::default(); - let (before, _) = tall_frame(&mut app, &ctx, ""); - // The bottom row's path: out of view, in a closed group. - app.session.execute("select.set", &json!({"ids": [paths[0].0]})).unwrap(); - for _ in 0..4 { - let (circles, found) = tall_frame(&mut app, &ctx, "Path 0"); - assert!(!found); - assert_eq!(circles, before, "nothing opens or scrolls"); - } - let st = app.session.active().unwrap(); - let group = st.doc.parent_of(paths[0]).unwrap(); - assert!(st.layers_open.contains(layer) && !st.layers_open.contains(group)); - } - - /// With Locate Selected Objects on, an object selected outside the panel is located as Locate - /// Object locates it: the layer and the group around it open and its row is scrolled into - /// view. The highlighted row stays as it was. - #[test] - fn with_the_option_a_selected_object_is_opened_and_scrolled_into_view() { - let (mut app, layer, paths) = groups_of_one(500); - app.ui.layers_panel.locate_selected = true; - let ctx = egui::Context::default(); - let top = app.session.active().unwrap().doc.parent_of(paths[499]).unwrap(); - app.session.execute("layer.highlight", &json!({"ids": [top.0]})).unwrap(); - tall_frame(&mut app, &ctx, ""); - expand(&mut app, &json!({"ids": [layer.0], "open": false})).unwrap(); - assert_eq!(tall_frame(&mut app, &ctx, "").0.len(), 1, "only the layer's row"); - app.session.execute("select.set", &json!({"ids": [paths[0].0]})).unwrap(); - let found = (0..4).any(|_| tall_frame(&mut app, &ctx, "Path 0").1); - let st = app.session.active().unwrap(); - let group = st.doc.parent_of(paths[0]).unwrap(); - assert!(st.layers_open.contains(layer) && st.layers_open.contains(group), "the layer and the group open"); - assert!(found, "the row is scrolled into view"); - assert_eq!(st.highlighted_rows(), [top], "the highlighted row stays"); - } - - /// With Locate Selected Objects on, the panel's own clicks locate nothing: a Shift-click - /// highlights the rows down to the one it clicks, and Shift-clicking selection squares selects - /// art in a closed sublayer without opening it. An object then selected outside the panel is - /// located, and the highlighted rows stay. - #[test] - fn with_the_option_the_panels_own_clicks_locate_nothing() { - let (mut app, [layer, sub, g, b, _, c]) = nested(); - app.ui.layers_panel.locate_selected = true; - expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); - let ctx = egui::Context::default(); - // Rows top down: Layer 1, Sub (closed), c. - let (circles, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(circles.len(), 3); - let name = |i: usize| egui::pos2(150.0, circles[i].y); - let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); - click_with(&mut app, &ctx, name(0), egui::Modifiers::NONE); - click_with(&mut app, &ctx, name(2), egui::Modifiers::SHIFT); - assert_eq!(rows_of(&app), [layer, sub, c], "both rows clicked and the one between them"); - click_with(&mut app, &ctx, square(2), egui::Modifiers::SHIFT); - click_with(&mut app, &ctx, square(1), egui::Modifiers::SHIFT); - let st = app.session.active().unwrap(); - assert!(st.selection.objects.len() >= 3 && st.selection.objects.contains(&c), "c and Sub's art are selected"); - let (after, _) = frame(&mut app, &ctx, vec![], false); - assert_eq!(after, circles, "no row opens and nothing scrolls"); - assert_eq!(rows_of(&app), [layer, sub, c]); - // Selected outside the panel: b's sublayer and group open, and the rows stay highlighted. - app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 6); - assert_eq!(rows_of(&app), [layer, sub, c]); - } - - /// With Locate Selected Objects on, a selection the panel menu changes is not located: Delete - /// Selection deletes the highlighted object, and the object left selected stays in its closed - /// sublayer and group. - #[test] - fn with_the_option_the_panel_menu_locates_nothing() { - let (mut app, [_, sub, g, b, _, c]) = nested(); - app.ui.layers_panel.locate_selected = true; - app.session.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - frame(&mut app, &ctx, vec![], false); - expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); - app.session.execute("layer.highlight", &json!({"ids": [c.0]})).unwrap(); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "Layer 1, Sub (closed) and c"); - let menu_frame = |app: &mut VectorcraftApp, events| super::super::tests_appearance::frame_events(&ctx, app, events, menu); - let at = menu_frame(&mut app, vec![]).iter().find(|(t, _)| t.trim() == "Delete Selection").map(|(_, r)| r.center()).unwrap(); - for e in [egui::Event::PointerMoved(at), button(at, true), button(at, false)] { - menu_frame(&mut app, vec![e]); - } - assert_eq!(app.session.active().unwrap().selection.objects, [b], "c is deleted and b stays selected"); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 2, "Sub stays closed"); - } - - /// With Locate Selected Objects on, a saved selection recalled from the panel menu's Save - /// Selection submenu is not located: the recalled object stays in its closed sublayer and group. - #[test] - fn with_the_option_a_selection_recalled_from_the_panel_menu_is_not_located() { - let (mut app, [_, sub, g, b, _, c]) = nested(); - app.ui.layers_panel.locate_selected = true; - app.run("select.set", json!({"ids": [b.0]})).unwrap(); - app.run("select.save", json!({"name": "Kept"})).unwrap(); - app.run("select.set", json!({"ids": [c.0]})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - frame(&mut app, &ctx, vec![], false); - expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "Layer 1, Sub (closed) and c"); - let menu_frame = |app: &mut VectorcraftApp, events| super::super::tests_appearance::frame_events(&ctx, app, events, menu); - let at = |texts: &[(String, egui::Rect)], label: &str| texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()); - let sub_menu = at(&menu_frame(&mut app, vec![]), "Save Selection").expect("the Save Selection submenu"); - for e in [egui::Event::PointerMoved(sub_menu), button(sub_menu, true), button(sub_menu, false)] { - menu_frame(&mut app, vec![e]); - } - let item = at(&menu_frame(&mut app, vec![]), "Kept").expect("the saved selection's item"); - for e in [egui::Event::PointerMoved(item), button(item, true), button(item, false)] { - menu_frame(&mut app, vec![e]); - } - assert_eq!(app.session.active().unwrap().selection.objects, [b], "the saved selection is recalled"); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "Sub stays closed"); - } - - /// With Locate Selected Objects on, the object selected in a document that becomes active is - /// not located; an object selected after that is. Neither document has highlighted rows, so - /// the switch reveals no row of its own. - #[test] - fn with_the_option_a_document_becoming_active_locates_nothing() { - let (mut app, [_, sub, g, b, a, _]) = nested(); - app.ui.layers_panel.locate_selected = true; - app.session.execute("layer.highlight", &json!({"ids": []})).unwrap(); - app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![], false); - expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - frame(&mut app, &ctx, vec![], false); - app.session.execute("document.activate", &json!({"index": 0})).unwrap(); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "b's sublayer and group stay closed"); - app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 5, "a's sublayer opens"); - } - - /// With Locate Selected Objects on, the copies an Alt-drag selects are located once the drag - /// ends, not while it lasts. - #[test] - fn with_the_option_a_selection_made_during_a_drag_is_located_when_it_ends() { - let (mut app, [_, sub, g, b, ..]) = nested(); - app.ui.layers_panel.locate_selected = true; - app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![], false); - expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); - app.session.begin_interaction("Move").unwrap(); - for i in 1..=3 { - app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, i * 10, 0], "copy": true})).unwrap(); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "nothing opens while the drag lasts"); - } - app.session.commit_interaction().unwrap(); - let st = app.session.active().unwrap(); - let copy = st.selection.objects.last().copied().unwrap(); - assert!(copy != b && st.doc.parent_of(copy) == Some(g), "the copy is selected, in b's group"); - assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 7, "Sub and the group open on the copy's row"); - } - - /// With Locate Selected Objects on, art selected outside the panel in another layer is - /// located: the selection makes that layer the active one, and the panel scrolls to the art's - /// row, not to the layer's row. - #[test] - fn with_the_option_the_located_object_is_revealed_instead_of_its_active_layer() { - let (mut app, layer, paths) = groups_of_one(500); - app.ui.layers_panel.locate_selected = true; - app.session.execute("layer.new", &json!({})).unwrap(); - assert_ne!(app.session.active().unwrap().current_layer(), Some(layer)); - let ctx = egui::Context::default(); - tall_frame(&mut app, &ctx, ""); - app.session.execute("select.set", &json!({"ids": [paths[0].0]})).unwrap(); - assert_eq!(app.session.active().unwrap().current_layer(), Some(layer), "the path's layer is the active one"); - let found = (0..4).any(|_| tall_frame(&mut app, &ctx, "Path 0").1); - let st = app.session.active().unwrap(); - let group = st.doc.parent_of(paths[0]).unwrap(); - assert!(st.layers_open.contains(group), "the group opens"); - assert!(found, "the path's row is scrolled into view"); - } - - /// With Locate Selected Objects and Show Layers Only on, art selected outside the panel has no - /// row, so the panel scrolls to the row of the layer that the selection makes the active one. - #[test] - fn with_the_option_and_show_layers_only_the_active_layer_is_revealed() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.ui.layers_panel.locate_selected = true; - app.ui.layers_panel.layers_only = true; - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 100, "height": 100})); - let rect = |app: &mut VectorcraftApp| run(app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].as_u64().unwrap(); - let bottom = rect(&mut app); - for _ in 0..59 { - run(&mut app, "layer.new", json!({})); - rect(&mut app); - } - run(&mut app, "select.none", json!({})); - let doc = app.session.active().unwrap().doc.clone(); - let name = doc.node(doc.layer_of(NodeId(bottom)).unwrap()).unwrap().display_name(); - let ctx = egui::Context::default(); - assert!(!(0..4).any(|_| tall_frame(&mut app, &ctx, &name).1), "the bottom layer's row is out of view"); - run(&mut app, "select.set", json!({"ids": [bottom]})); - assert!((0..4).any(|_| tall_frame(&mut app, &ctx, &name).1), "the active layer's row is scrolled into view"); - } - - /// With Locate Selected Objects on, art selected outside the panel that Show Layers Only or the - /// filter leaves without a row opens nothing, so its group stays closed once the panel lists - /// every row again. Art the filter keeps has its group opened. - #[test] - fn with_the_option_art_the_panel_does_not_list_opens_nothing() { - let (mut app, _, paths) = groups_of_one(500); - app.ui.layers_panel.locate_selected = true; - let ctx = egui::Context::default(); - let open = |app: &VectorcraftApp, i: usize| { - let st = app.session.active().unwrap(); - st.layers_open.contains(st.doc.parent_of(paths[i]).unwrap()) - }; - let locate = |app: &mut VectorcraftApp, i: usize| { - app.session.execute("select.set", &json!({"ids": [paths[i].0]})).unwrap(); - for _ in 0..4 { - tall_frame(app, &ctx, ""); - } - }; - tall_frame(&mut app, &ctx, ""); - app.ui.layers_panel.layers_only = true; - locate(&mut app, 0); - app.ui.layers_panel.layers_only = false; - tall_frame(&mut app, &ctx, ""); - assert!(!open(&app, 0), "Show Layers Only: the group stays closed"); - app.ui.layers_filter = Filter { objects: vec![ObjectKind::Text], layers: vec![] }; - locate(&mut app, 1); - assert!(!open(&app, 1), "a filter that leaves the path out: the group stays closed"); - app.ui.layers_filter = Filter { objects: vec![ObjectKind::Paths], layers: vec![] }; - locate(&mut app, 2); - assert!(open(&app, 2), "a filter that keeps the path: the group opens"); - } - - /// A layer of 2,000 groups of 10 paths with every row open: one frame lays out only the rows - /// in view, and a row highlighted far below them is scrolled to. - #[test] - fn only_the_rows_in_view_are_laid_out() { - use vectorcraft_doc::Appearance; - use vectorcraft_geom::{Rect, shapes}; - let mut d = Document::new(1000.0, 1000.0); - let mut groups = vec![]; - let mut needle = None; - for g in 0..2000 { - let kids = (0..10) - .map(|k| { - let r = Rect::new(k as f64, g as f64 * 0.1, k as f64 + 1.0, g as f64 * 0.1 + 1.0); - let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); - if g == 0 && k == 0 { - n.name = Some("Needle".into()); - needle = Some(n.id); - } - Arc::new(n) - }) - .collect(); - groups.push(Arc::new(Node::group(d.alloc_id(), kids))); - } - *Arc::make_mut(&mut d.layers[0]).children_mut().unwrap() = groups; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.add_document(d, None); - expand(&mut app, &json!({})).unwrap(); - let ctx = egui::Context::default(); - // (shapes drawn, whether the needle's name is among them); a second passes per frame, so - // scrolling ends. - let mut time = 0.0; - let mut frame = |app: &mut VectorcraftApp| { - time += 1.0; - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 800.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - let found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Needle")); - (out.shapes.len(), found) - }; - let (shapes, found) = frame(&mut app); - // 22,001 rows; about 30 fit. - assert!(shapes < 1000 && !found, "{shapes} shapes"); - let needle = needle.unwrap(); - app.session.execute("layer.highlight", &json!({"ids": [needle.0], "mode": "set"})).unwrap(); - let found = (0..4).any(|_| frame(&mut app).1); - assert_eq!(app.session.active().unwrap().highlighted_rows(), [needle]); - assert!(found, "the highlighted row is scrolled into view"); - } - - /// Selecting art on the canvas scrolls its layer (now the active one) into view, but not away - /// from a row highlighted in the panel. - #[test] - fn a_canvas_selection_reveals_its_active_layer() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 100, "height": 100})); - let mut rects = vec![run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].as_u64().unwrap()]; - for _ in 0..59 { - run(&mut app, "layer.new", json!({})); - rects.push(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].as_u64().unwrap()); - } - run(&mut app, "select.none", json!({})); - let doc = app.session.active().unwrap().doc.clone(); - let name = |id: u64| doc.node(doc.layer_of(NodeId(id)).unwrap()).unwrap().display_name(); - let (bottom, top) = (name(rects[0]), name(rects[59])); - let ctx = egui::Context::default(); - let mut time = 0.0; - // Whether a row with this name is drawn after a few frames. - let mut shown = |app: &mut VectorcraftApp, s: &str| { - let mut found = false; - for _ in 0..4 { - time += 1.0; - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 400.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == s)); - } - found - }; - assert!(shown(&mut app, &top) && !shown(&mut app, &bottom)); - run(&mut app, "select.set", json!({"ids": [rects[0]]})); - assert_eq!(app.session.active().unwrap().current_layer(), doc.layer_of(NodeId(rects[0]))); - assert!(shown(&mut app, &bottom), "the active layer is scrolled into view"); - // A row highlighted in the panel stays in view when the active layer changes. - let top_layer = doc.layer_of(NodeId(rects[59])).unwrap(); - run(&mut app, "layer.highlight", json!({"ids": [top_layer.0], "mode": "set"})); - assert!(shown(&mut app, &top)); - app.session.active_mut().unwrap().active_layer = doc.layer_of(NodeId(rects[0])); - assert!(shown(&mut app, &top) && !shown(&mut app, &bottom), "the highlighted row keeps the view"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs new file mode 100644 index 000000000..7df8a486e --- /dev/null +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -0,0 +1,1507 @@ +use serde_json::json; +use vectorcraft_engine::Session; + +use super::*; + +/// Filled target circles drawn by one headless frame of the panel. +fn filled_targets(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 3.2 && cs.fill != Color32::TRANSPARENT)).count() +} + +/// A generated `` name is translated where painted; an unnamed text object's text never +/// is, even when it reads like one. +#[test] +fn only_generated_names_are_translated() { + let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let rect = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + let text = run(&mut app, "text.create", json!({"x": 10, "y": 40, "text": ""}))["id"].as_u64().unwrap(); + let doc = &app.session.active().unwrap().doc; + let names = |id: u64| { + let n = doc.node(NodeId(id)).unwrap(); + (n.display_name(), painted_name(n, &n.display_name(), zh)) + }; + assert_eq!(names(text), ("".to_string(), "".to_string())); + let (stored, painted) = names(rect); + let inner = stored.trim_start_matches('<').trim_end_matches('>'); + assert_eq!(painted, format!("<{}>", crate::i18n::tr(zh, inner))); +} + +#[test] +fn target_circle_fills_for_non_default_transparency() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let id = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [id] })); + let ctx = egui::Context::default(); + assert_eq!(filled_targets(&mut app, &ctx), 0); + run(&mut app, "transparency.set", json!({"blend": "multiply"})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "a Multiply object"); + run(&mut app, "transparency.set", json!({"blend": "normal", "knockout": true})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "a knockout group"); + run(&mut app, "transparency.set", json!({"knockout": false})); + run(&mut app, "appearance.addStroke", json!({})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "two strokes"); +} + +#[test] +fn a_layer_clipping_mask_underlines_its_clipping_path() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})); + run(&mut app, "shape.rectangle", json!({"x": 25, "y": 25, "width": 50, "height": 50})); + run(&mut app, "select.none", json!({})); + let ctx = egui::Context::default(); + let text = Tokens::get(&ctx).text; + // Line segments in the text colour: the underlines. + let underlines = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::LineSegment { stroke, .. } if stroke.color == text)).count() + }; + assert_eq!(underlines(&mut app), 0); + assert_eq!(run(&mut app, "layer.clippingMask.toggle", json!({}))["clip"], true); + assert_eq!(underlines(&mut app), 1); + run(&mut app, "layer.clippingMask.toggle", json!({})); + assert_eq!(underlines(&mut app), 0); +} + +/// One headless frame of the panel with `events` (Alt held when `alt`): the centres of the +/// target circles, top row first, and the text-coloured line segments (underlines) drawn. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, alt: bool) -> (Vec, usize) { + let mut events = events; + events.insert(0, egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() })); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let text = Tokens::get(ctx).text; + let mut circles = vec![]; + let mut lines = 0; + for c in &out.shapes { + match &c.shape { + egui::Shape::Circle(cs) if cs.radius == 5.0 => circles.push(cs.center), + egui::Shape::LineSegment { stroke, .. } if stroke.color == text => lines += 1, + _ => {} + } + } + circles.sort_by(|a, b| a.y.total_cmp(&b.y)); + (circles, lines) +} + +fn button(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } +} + +/// Drag from `a` to `b` over a few frames (Alt held when `alt`). +fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool) { + let steps = [ + vec![egui::Event::PointerMoved(a)], + vec![button(a, true)], + vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(b)], + vec![button(b, false)], + vec![], + ]; + for e in steps { + frame(app, ctx, e, alt); + } +} + +#[test] +fn alt_clicking_an_eye_or_lock_toggles_the_other_layers() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + run(&mut app, "layer.new", json!({})); + run(&mut app, "layer.new", json!({})); + let ctx = egui::Context::default(); + // Rows top first: Layers 3, 2, 1. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 3); + let state = + |app: &VectorcraftApp| -> Vec<(bool, bool)> { app.session.active().unwrap().doc.layers.iter().map(|l| (l.visible, l.locked)).collect() }; + let click = |app: &mut VectorcraftApp, at: egui::Pos2, alt: bool| { + frame(app, &ctx, vec![egui::Event::PointerMoved(at)], alt); + frame(app, &ctx, vec![button(at, true)], alt); + frame(app, &ctx, vec![button(at, false)], alt); + }; + let (eye, lock) = (egui::pos2(12.0, circles[1].y), egui::pos2(37.0, circles[1].y)); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = undo(&app); + // Alt-click Layer 2's eye: Layers 1 and 3 hide in one step; again, they show. + click(&mut app, eye, true); + assert_eq!(state(&app), [(false, false), (true, false), (false, false)]); + assert_eq!(undo(&app), before + 1); + click(&mut app, eye, true); + assert_eq!(state(&app), [(true, false); 3]); + // Alt-click its lock: the others lock; again, every layer unlocks. + click(&mut app, lock, true); + assert_eq!(state(&app), [(true, true), (true, false), (true, true)]); + assert_eq!(app.session.active().unwrap().history.undo.last().map(|u| u.label.as_str()), Some("Lock Others")); + // A plain click still toggles only that layer. + click(&mut app, eye, false); + assert_eq!(state(&app), [(true, true), (false, false), (true, true)]); + click(&mut app, lock, true); + assert_eq!(state(&app), [(true, false), (false, false), (true, false)]); + // A sublayer's row stands for its top-level layer: the other layers hide. + click(&mut app, eye, false); + let layer2 = app.session.active().unwrap().doc.layers[1].id.0; + let sub = run(&mut app, "layer.newSublayer", json!({"parent": layer2})); + // Rows: Layer 3, Layer 2, its sublayer, Layer 1. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 4); + click(&mut app, egui::pos2(12.0, circles[2].y), true); + assert_eq!(state(&app), [(false, false), (true, false), (false, false)]); + let doc = &app.session.active().unwrap().doc; + assert!(doc.node(NodeId(sub["id"].as_u64().unwrap())).unwrap().visible); +} + +#[test] +fn ctrl_alt_clicking_an_eye_switches_the_other_layers_between_outline_and_preview() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + run(&mut app, "layer.new", json!({})); + run(&mut app, "layer.new", json!({})); + let ctx = egui::Context::default(); + // Rows top first: Layers 3, 2, 1. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 3); + // Each layer bottom first: (visible, preview). + let state = |app: &VectorcraftApp| -> Vec<(bool, bool)> { + let doc = &app.session.active().unwrap().doc; + doc.layers.iter().map(|l| (l.visible, !matches!(l.kind, NodeKind::Layer { preview: false, .. }))).collect() + }; + let last = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|u| u.label.clone()); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let eye = |i: usize| egui::pos2(12.0, circles[i].y); + let ctrl_alt = egui::Modifiers { alt: true, command: true, ..Default::default() }; + let before = undo(&app); + // Ctrl/Cmd+Alt-click Layer 1's eye: Layers 2 and 3 go to Outline in one step, and stay visible. + click_with(&mut app, &ctx, eye(2), ctrl_alt); + assert_eq!(state(&app), [(true, true), (true, false), (true, false)]); + assert_eq!(undo(&app), before + 1); + assert_eq!(last(&app).as_deref(), Some("Outline Others")); + // Again: every layer previews, in one step. + click_with(&mut app, &ctx, eye(2), ctrl_alt); + assert_eq!(state(&app), [(true, true); 3]); + assert_eq!(undo(&app), before + 2); + assert_eq!(last(&app).as_deref(), Some("Preview All Layers")); + // On a layer in Outline whose others preview: the others go to Outline and it previews. + click_with(&mut app, &ctx, eye(0), egui::Modifiers::COMMAND); + assert_eq!(state(&app), [(true, true), (true, true), (true, false)]); + click_with(&mut app, &ctx, eye(0), ctrl_alt); + assert_eq!(state(&app), [(true, false), (true, false), (true, true)]); +} + +/// A document with two rectangles on one layer → (app, layer, [bottom, top]). +fn two_rects() -> (VectorcraftApp, NodeId, [NodeId; 2]) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + let b = run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "select.none", json!({})); + let layer = app.session.active().unwrap().doc.layers[0].id; + (app, layer, [NodeId(a), NodeId(b)]) +} + +#[test] +fn row_thumbnails_wait_for_the_end_of_a_drag() { + let (mut app, _, _) = two_rects(); + app.session.execute("select.all", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let renders = || THUMBS.with(|c| c.borrow().renders); + frame(&mut app, &ctx, vec![], false); + let first = renders(); + assert!(first >= 1, "the rows' thumbnails render at once"); + frame(&mut app, &ctx, vec![], false); + assert_eq!(renders(), first, "and stay"); + // A move drag: the layer (and the moved objects) change on every preview. + app.session.begin_interaction("Move").unwrap(); + for i in 1..=10 { + app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, i, 0]})).unwrap(); + frame(&mut app, &ctx, vec![], false); + } + assert_eq!(renders(), first, "nothing renders while the drag goes on"); + app.session.commit_interaction().unwrap(); + for _ in 0..12 { + frame(&mut app, &ctx, vec![], false); + } + assert!(renders() > first, "the changed rows render after it"); + let after = renders(); + frame(&mut app, &ctx, vec![], false); + assert_eq!(renders(), after, "once"); +} + +#[test] +fn clicking_a_target_circle_targets_the_layer() { + let (mut app, layer, [a, b]) = two_rects(); + let ctx = egui::Context::default(); + // Rows top down: the layer, the top rectangle, the bottom one. + let (c, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(c.len(), 3); + for e in [egui::Event::PointerMoved(c[0]), button(c[0], true), button(c[0], false)] { + frame(&mut app, &ctx, vec![e], false); + } + let st = app.session.active().unwrap(); + assert_eq!(st.selection.target, Some(layer)); + assert_eq!(st.selection.objects, vec![a, b]); + assert_eq!(super::super::appearance::object_label(&app), "Layer", "the Appearance panel lists the layer"); + app.session.execute("transparency.set", &json!({"opacity": 25})).unwrap(); + assert!((app.session.active().unwrap().doc.node(layer).unwrap().opacity - 0.25).abs() < 1e-6); + // The layer's circle is filled now; clicking an object's circle targets that object. + assert_eq!(filled_targets(&mut app, &ctx), 1); + for e in [egui::Event::PointerMoved(c[2]), button(c[2], true), button(c[2], false)] { + frame(&mut app, &ctx, vec![e], false); + } + let st = app.session.active().unwrap(); + assert_eq!((st.selection.target, st.selection.objects.clone()), (Some(a), vec![a])); + // Shift-clicking another object's circle adds it to the selection (#901), and again takes + // it out; nothing is targeted meanwhile. + click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT); + let st = app.session.active().unwrap(); + assert_eq!((st.selection.target, st.selection.objects.clone()), (None, vec![a, b])); + click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT); + assert_eq!(app.session.active().unwrap().selection.objects, vec![a]); +} + +#[test] +fn dragging_a_target_circle_moves_or_copies_the_appearance_and_the_trash_clears_it() { + let (mut app, layer, [a, b]) = two_rects(); + app.session.execute("transparency.set", &json!({"ids": [a.0], "opacity": 30})).unwrap(); + let ctx = egui::Context::default(); + let (c, _) = frame(&mut app, &ctx, vec![], false); + let opacity = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().opacity; + // Alt-drag the bottom rectangle's circle onto the top one's: both have it. + drag(&mut app, &ctx, c[2], c[1], true); + assert!((opacity(&app, b) - 0.3).abs() < 1e-6 && (opacity(&app, a) - 0.3).abs() < 1e-6); + // A plain drag onto the layer's circle moves it there. + drag(&mut app, &ctx, c[1], c[0], false); + assert!((opacity(&app, layer) - 0.3).abs() < 1e-6 && opacity(&app, b) == 1.0); + // Dropped on the trash, the layer's appearance is cleared (and nothing is deleted). + let st = app.session.active().unwrap(); + let count = st.doc.node_count(); + let trash = trash_pos(&mut app, &ctx); + drag(&mut app, &ctx, c[0], trash, false); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.node(layer).unwrap().opacity, 1.0); + assert_eq!(st.doc.node_count(), count); +} + +/// The trash button: the bottom bar's rightmost button. +fn trash_pos(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { + bar_buttons(app, ctx)[0] +} + +#[test] +fn dragging_rows_onto_the_trash_deletes_them() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("layer.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top first: Layer 2, Layer 1. Grab Layer 2 by its name, left of its target circle. + let (c, _) = frame(&mut app, &ctx, vec![], false); + let top = app.session.active().unwrap().doc.layers[1].id; + let trash = trash_pos(&mut app, &ctx); + drag(&mut app, &ctx, c[0] - vec2(100.0, 0.0), trash, false); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert!(doc.node(top).is_none()); +} + +#[test] +fn renaming_a_layer_commits_on_enter_and_escape_cancels() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + let name = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].display_name(); + let ctx = egui::Context::default(); + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let select_all = + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + // What a double-click on the row does: the name field opens with the name. + let open = |app: &mut VectorcraftApp| { + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("layers-rename"), (layer.0, name(app)))); + frame(app, &ctx, vec![], false); + frame(app, &ctx, vec![], false); + }; + open(&mut app); + frame(&mut app, &ctx, vec![select_all.clone(), egui::Event::Text("Sky".into())], false); + frame(&mut app, &ctx, vec![key(egui::Key::Enter)], false); + frame(&mut app, &ctx, vec![], false); + assert_eq!(name(&app), "Sky", "Enter keeps the new name"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("layers-rename"))).is_none(), "and closes the field"); + open(&mut app); + frame(&mut app, &ctx, vec![select_all, egui::Event::Text("Ground".into())], false); + frame(&mut app, &ctx, vec![key(egui::Key::Escape)], false); + frame(&mut app, &ctx, vec![], false); + assert_eq!(name(&app), "Sky", "Escape cancels"); +} + +/// Shift+Enter in the name field renames the layer and keeps the field open, the new name +/// selected; Enter then renames it again and closes the field. +#[test] +fn shift_enter_renames_a_layer_and_keeps_the_field_open() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + let name = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].display_name(); + let ctx = egui::Context::default(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let field = rename_id().with("field"); + ctx.data_mut(|d| d.insert_temp(rename_id(), (layer.0, name(&app)))); + frame(&mut app, &ctx, vec![], false); + frame(&mut app, &ctx, vec![], false); + frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Sky".into())], false); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::SHIFT)], false); + assert_eq!(name(&app), "Sky", "Shift+Enter applies the name"); + assert!(ctx.memory(|m| m.has_focus(field)), "the field keeps the focus"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(rename_id())).is_some(), "and stays open"); + frame(&mut app, &ctx, vec![], false); + let selected = egui::TextEdit::load_state(&ctx, field).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 3)), "the name shown is selected"); + frame(&mut app, &ctx, vec![egui::Event::Text("Sea".into())], false); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], false); + frame(&mut app, &ctx, vec![], false); + assert_eq!(name(&app), "Sea", "typing replaced it, and Enter applies that"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(rename_id())).is_none(), "and closes the field"); +} + +#[test] +fn an_open_rename_does_not_follow_into_another_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![], false); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("layers-rename"), (layer.0, "Renamed in the first".to_string()))); + frame(&mut app, &ctx, vec![], false); + // A second document, whose first layer has the same node id, comes to the front. + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.layers[0].id, layer); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![], false); + frame(&mut app, &ctx, vec![enter], false); + frame(&mut app, &ctx, vec![], false); + assert_eq!(app.session.doc().unwrap().doc.layers[0].display_name(), "Layer 1"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("layers-rename"))).is_none()); +} + +#[test] +fn masks_underline_dashed_and_mask_editing_lists_only_the_mask() { + let (mut app, _, [a, _]) = two_rects(); + let ctx = egui::Context::default(); + let (_, before) = frame(&mut app, &ctx, vec![], false); + app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + app.session.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + // Editing the new (empty) mask: one row, ``. + let (c, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(c.len(), 1); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(t, _)| t == ""), "{texts:?}"); + app.session.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + let (c, after) = frame(&mut app, &ctx, vec![], false); + assert_eq!(c.len(), 3); + assert!(after > before + 1, "a dashed underline: {before} → {after}"); +} + +// ---- rows at every depth (layers, sublayers, groups, objects) ---- + +/// Layer 1 holding rectangle `c` and a sublayer holding rectangle `a` and a group of +/// rectangle `b`, every row open, nothing selected → (app, [layer, sub, g, b, a, c]): the rows +/// top down. +fn nested() -> (VectorcraftApp, [NodeId; 6]) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap()); + run(&mut app, "file.new", json!({"width": 400, "height": 400})); + let layer = app.session.active().unwrap().doc.layers[0].id; + let c = id(run(&mut app, "shape.rectangle", json!({"x": 300, "y": 0, "width": 20, "height": 20}))); + let sub = id(run(&mut app, "layer.newSublayer", json!({"name": "Sub"}))); + let a = id(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))); + let b = id(run(&mut app, "shape.rectangle", json!({"x": 100, "y": 0, "width": 20, "height": 20}))); + run(&mut app, "select.set", json!({"ids": [b.0]})); + let g = id(run(&mut app, "object.group", json!({}))); + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + (app, [layer, sub, g, b, a, c]) +} + +/// Press and release at `at` with `mods` held, over three frames. +fn click_with(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, mods: egui::Modifiers) { + for e in [egui::Event::PointerMoved(at), button(at, true), button(at, false)] { + let mut out = + ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(mods), e], ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + } +} + +fn rows_of(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().highlighted_rows() +} + +#[test] +fn rows_at_every_depth_are_clickable() { + let (mut app, [layer, sub, g, b, a, c]) = nested(); + let ctx = egui::Context::default(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 6, "a row for the layer, the sublayer, the group, both rectangles in it and the one on the layer"); + let name = |i: usize| egui::pos2(150.0, circles[i].y); + for (i, row, current) in [(1, sub, sub), (2, g, sub), (3, b, sub), (4, a, sub), (5, c, layer), (0, layer, layer)] { + click_with(&mut app, &ctx, name(i), egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)), "row {i}"); + } + // New art goes into the current sublayer once its row is clicked. + click_with(&mut app, &ctx, name(1), egui::Modifiers::NONE); + let n = app.session.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(app.session.active().unwrap().doc.parent_of(NodeId(n)), Some(sub)); + app.session.execute("edit.undo", &json!({})).unwrap(); + // Shift-click highlights the range down to the row; Ctrl/Cmd-click toggles one. + click_with(&mut app, &ctx, name(4), egui::Modifiers::SHIFT); + assert_eq!(rows_of(&app), vec![sub, g, b, a]); + click_with(&mut app, &ctx, name(2), egui::Modifiers::COMMAND); + assert_eq!(rows_of(&app), vec![sub, b, a]); + click_with(&mut app, &ctx, name(5), egui::Modifiers::COMMAND); + assert_eq!(rows_of(&app), vec![sub, b, a, c]); + // Delete with the panel's keyboard: the highlighted rows go. + let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![del], false); + let d = &app.session.active().unwrap().doc; + assert!(d.node(sub).is_none() && d.node(c).is_none()); + assert_eq!(d.layers.len(), 1); +} + +/// A layer's ruler guides are rows in it, above its art: a click selects the guide, a drag onto +/// another layer's row moves it there, and a guide on a locked layer is left alone. +#[test] +fn guide_rows_select_and_move_their_guides() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + run(&mut app, "select.none", json!({})); + let second = NodeId(run(&mut app, "layer.new", json!({}))["id"].as_u64().unwrap()); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 2, Layer 1, its guide (no target circle), the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 3); + let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + assert_eq!(guides(&app), [0], "its row selects it"); + // Dragged onto Layer 2's row, it goes on Layer 2, in one undo step. + let undo = app.session.active().unwrap().history.undo.len(); + drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1)); + // Now under Layer 2: locked, its row neither selects nor drags it. + run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true})); + assert!(guides(&app).is_empty(), "deselected with its layer locked"); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let guide_row = egui::pos2(150.0, (circles[0].y + circles[1].y) / 2.0); + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + assert!(guides(&app).is_empty()); + drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false); + assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second)); +} + +/// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows +/// holding selected art). +fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let size = |w: f32| { + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if (r.rect.width() - w).abs() < 0.01 && (r.rect.height() - w).abs() < 0.01 && r.fill != Color32::TRANSPARENT)).count() + }; + (size(7.0), size(4.0)) +} + +#[test] +fn selection_squares_show_and_select_a_rows_art() { + let (mut app, [layer, sub, g, b, a, c]) = nested(); + let ctx = egui::Context::default(); + assert_eq!(squares(&mut app, &ctx), (0, 0)); + app.session.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + assert_eq!(squares(&mut app, &ctx), (1, 2), "the group's square, and small ones on the sublayer and the layer"); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); + let sel = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); + click_with(&mut app, &ctx, square(4), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![a]); + click_with(&mut app, &ctx, square(5), egui::Modifiers::SHIFT); + assert_eq!(sel(&app), vec![a, c]); + click_with(&mut app, &ctx, square(1), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![a, g], "a sublayer's square selects its art"); + click_with(&mut app, &ctx, square(0), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![c, a, g], "a layer's, its sublayers' too"); + let _ = (layer, sub, b); +} + +#[test] +fn eye_and_lock_toggle_nested_rows_and_drag_down_the_column() { + let (mut app, [_, sub, g, b, a, _]) = nested(); + let ctx = egui::Context::default(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let (eye, lock) = (|i: usize| egui::pos2(12.0, circles[i].y), |i: usize| egui::pos2(37.0, circles[i].y)); + let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone(); + click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE); + assert!(!node(&app, b).visible, "an object in a group in a sublayer hides"); + click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE); + click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE); + assert!(node(&app, g).locked, "a group locks"); + click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE); + // Ctrl/Cmd-click a layer's eye: Outline, and back. + click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND); + assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: false, .. })); + click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND); + assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: true, .. })); + // Drag down the eye column from the group to the rectangle below: all three hide, one step. + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = undo(&app); + let steps = [ + vec![egui::Event::PointerMoved(eye(2))], + vec![button(eye(2), true)], + vec![egui::Event::PointerMoved(eye(2) + vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(eye(3))], + vec![egui::Event::PointerMoved(eye(4))], + vec![button(eye(4), false)], + vec![], + ]; + for e in steps { + frame(&mut app, &ctx, e, false); + } + assert!(!node(&app, g).visible && !node(&app, b).visible && !node(&app, a).visible); + assert!(node(&app, sub).visible, "rows it didn't pass stay"); + assert_eq!(undo(&app), before + 1, "one undo step"); + assert!(!app.session.in_interaction()); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&app, g).visible && node(&app, b).visible && node(&app, a).visible); +} + +#[test] +fn triangles_open_and_close_rows_and_alt_does_everything_inside() { + let (mut app, [layer, sub, ..]) = nested(); + let ctx = egui::Context::default(); + let count = |app: &mut VectorcraftApp| frame(app, &ctx, vec![], false).0.len(); + assert_eq!(count(&mut app), 6); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let triangle = |depth: f32, y: f32| egui::pos2(2.0 * COLUMN + 8.0 + depth * INDENT + 7.0, y); + // Close the sublayer: its group, the group's rectangle and its own rectangle go. + click_with(&mut app, &ctx, triangle(1.0, circles[1].y), egui::Modifiers::NONE); + assert_eq!(count(&mut app), 3); + // Alt-click the layer's: everything inside closes, and opens again. + click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT); + assert_eq!(count(&mut app), 1); + click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT); + assert_eq!(count(&mut app), 6); + // Show Layers Only (Panel Options). + app.ui.layers_panel.layers_only = true; + assert_eq!(count(&mut app), 2); + app.ui.layers_panel = PanelOptions { row_size: ROW_LARGE, ..Default::default() }; + let (c, _) = frame(&mut app, &ctx, vec![], false); + assert!((c[1].y - c[0].y - ROW_LARGE).abs() < 0.5, "large rows"); + let _ = (layer, sub); +} + +// ---- Search All and the Filter ---- + +/// Layer 1 holding a path, a rectangle, type reading "Sky line", a group of an ellipse with an +/// effect, a compound path, a guide, an embedded and a linked image and a ruler guide, and on top +/// a sublayer "Sub" holding a hidden path named "Sky" and a locked path; only Layer 1 open and +/// nothing selected → (app, the rows' ids by name). +fn kinds() -> (VectorcraftApp, HashMap<&'static str, NodeId>) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap()); + let star = |app: &mut VectorcraftApp, x: u32| id(run(app, "shape.star", json!({"cx": x, "cy": 50, "radius1": 20, "radius2": 10}))); + run(&mut app, "file.new", json!({"width": 400, "height": 400})); + let mut ids = HashMap::from([("layer", app.session.active().unwrap().doc.layers[0].id)]); + ids.insert("path", star(&mut app, 50)); + ids.insert("rect", id(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 100, "width": 20, "height": 20})))); + ids.insert("text", id(run(&mut app, "text.create", json!({"x": 10, "y": 200, "text": "Sky line"})))); + let ellipse = id(run(&mut app, "shape.ellipse", json!({"x": 100, "y": 100, "width": 20, "height": 20}))); + run(&mut app, "effect.apply", json!({"effect": "distort.twist", "ids": [ellipse.0]})); + run(&mut app, "select.set", json!({"ids": [ellipse.0]})); + ids.insert("group", id(run(&mut app, "object.group", json!({})))); + ids.insert("ellipse", ellipse); + let parts = [star(&mut app, 150), star(&mut app, 160)]; + run(&mut app, "select.set", json!({"ids": [parts[0].0, parts[1].0]})); + ids.insert("compound", id(run(&mut app, "object.compoundPath.make", json!({})))); + let guide = id(run(&mut app, "shape.arc", json!({"x1": 0, "y1": 300, "x2": 50, "y2": 350}))); + run(&mut app, "select.set", json!({"ids": [guide.0]})); + run(&mut app, "view.guides.make", json!({})); + ids.insert("guide", guide); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let placed = |app: &mut VectorcraftApp, p: serde_json::Value| NodeId(run(app, "file.place", p)["ids"][0].as_u64().unwrap()); + ids.insert("embedded", placed(&mut app, json!({"name": "pic.png", "dataBase64": vectorcraft_format::base64_encode(&png)}))); + // One file for every test that calls this, written by the first. + static LINKED: std::sync::OnceLock = std::sync::OnceLock::new(); + let file = LINKED.get_or_init(|| { + let file = vectorcraft_testkit::temp_dir("layers-filter").join("linked.png"); + std::fs::write(&file, &png).unwrap(); + file + }); + ids.insert("linked", placed(&mut app, json!({"path": file.to_string_lossy()}))); + ids.insert("sub", id(run(&mut app, "layer.newSublayer", json!({"name": "Sub"})))); + let hidden = star(&mut app, 250); + run(&mut app, "layer.setProps", json!({"ids": [hidden.0], "name": "Sky", "visible": false})); + let locked = star(&mut app, 300); + run(&mut app, "layer.setProps", json!({"ids": [locked.0], "locked": true})); + ids.extend([("hidden", hidden), ("locked", locked)]); + run(&mut app, "select.none", json!({})); + (app, ids) +} + +/// The rows [`list_rows`] lists for `query` and `filter`, by their names in `ids` ("ruler guide" +/// for a guide's row). +fn listed(app: &VectorcraftApp, ids: &HashMap<&'static str, NodeId>, query: &str, filter: &Filter) -> Vec<&'static str> { + let st = app.session.active().unwrap(); + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), query, filter); + let name = |r: &Row| match r.guide { + Some(_) => "ruler guide", + None => ids.iter().find(|(_, id)| **id == r.node.id).map_or("?", |(name, _)| *name), + }; + rows.iter().map(name).collect() +} + +fn only(k: ObjectKind) -> Filter { + Filter { objects: vec![k], layers: vec![] } +} + +/// Each object kind keeps exactly the rows of that kind, with the rows holding them, open or +/// closed. +#[test] +fn each_object_kind_keeps_its_rows_and_the_rows_holding_them() { + let (app, ids) = kinds(); + for (k, want) in [ + (ObjectKind::Paths, vec!["layer", "sub", "locked", "hidden", "compound", "path"]), + (ObjectKind::Shapes, vec!["layer", "group", "ellipse", "rect"]), + (ObjectKind::Text, vec!["layer", "text"]), + (ObjectKind::Images, vec!["layer", "linked", "embedded"]), + (ObjectKind::Effects, vec!["layer", "group", "ellipse"]), + (ObjectKind::Groups, vec!["layer", "group"]), + (ObjectKind::LinkedFiles, vec!["layer", "linked"]), + (ObjectKind::Guides, vec!["layer", "ruler guide", "guide"]), + ] { + assert_eq!(listed(&app, &ids, "", &only(k)), want, "{k:?}"); + } +} + +/// By Layer keeps the rows that are locked or hidden themselves: in a hidden, locked sublayer a +/// row stays only for its own lock or eye. Two choices in a section take either; a choice in +/// each section takes rows that are both. +#[test] +fn row_states_keep_locked_or_hidden_rows_and_combine_with_kinds() { + let (mut app, ids) = kinds(); + let f = |objects: Vec, layers: Vec| Filter { objects, layers }; + assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Hidden])), ["layer", "sub", "hidden"]); + assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Locked])), ["layer", "sub", "locked"]); + assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Locked, RowState::Hidden])), ["layer", "sub", "locked", "hidden"]); + assert_eq!(listed(&app, &ids, "", &f(vec![ObjectKind::Text, ObjectKind::Images], vec![])), ["layer", "linked", "embedded", "text"]); + assert_eq!(listed(&app, &ids, "", &f(vec![ObjectKind::Paths], vec![RowState::Hidden])), ["layer", "sub", "hidden"]); + assert!(listed(&app, &ids, "", &f(vec![ObjectKind::Shapes], vec![RowState::Hidden])).is_empty()); + // Once Sub is hidden and locked, the rows in it stay only for their own lock or eye. + app.session.execute("layer.setProps", &json!({"ids": [ids["sub"].0], "visible": false, "locked": true})).unwrap(); + assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Hidden])), ["layer", "sub", "hidden"]); + assert_eq!(listed(&app, &ids, "", &f(vec![], vec![RowState::Locked])), ["layer", "sub", "locked"]); +} + +/// The filter and the search combine: a row stays when its name holds the text and the filter +/// keeps it, or when it holds such a row. +#[test] +fn the_filter_combines_with_the_search() { + let (app, ids) = kinds(); + assert_eq!(listed(&app, &ids, "sky", &Filter::default()), ["layer", "sub", "hidden", "text"]); + assert_eq!(listed(&app, &ids, "sky", &only(ObjectKind::Text)), ["layer", "text"]); + assert_eq!(listed(&app, &ids, "sky", &Filter { objects: vec![], layers: vec![RowState::Hidden] }), ["layer", "sub", "hidden"]); + assert!(listed(&app, &ids, "sky", &only(ObjectKind::Groups)).is_empty()); +} + +/// (target circles, lit 24 pt buttons) one frame of the panel draws. +fn circles_and_lit_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { + let lit = Tokens::get(ctx).tool_active; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let circles = out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 5.0)).count(); + let buttons = + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == lit && (r.rect.width() - 24.0).abs() < 0.01)).count(); + (circles, buttons) +} + +/// `ui.layersFilter` has the panel list its rows again at once and lights the Filter button +/// while a filter is on; clearing it lists every row again. The document, its open rows and the +/// selection stay as they were. +#[test] +fn the_panel_lists_its_rows_again_when_the_filter_changes() { + let (mut app, ids) = kinds(); + app.session.execute("select.set", &json!({"ids": [ids["rect"].0]})).unwrap(); + let ctx = egui::Context::default(); + let before = circles_and_lit_buttons(&mut app, &ctx); + assert_eq!(before.1, 0, "the Filter button is not lit"); + let st = app.session.active().unwrap(); + let (revision, open) = (st.revision, st.layers_open.generation()); + let r = app.run("ui.layersFilter", json!({"objects": ["text"]})).unwrap(); + assert_eq!(r, json!({"objects": ["text"], "layers": []})); + assert_eq!(circles_and_lit_buttons(&mut app, &ctx), (2, 1), "Layer 1 and the type; the button is lit"); + app.run("ui.layersFilter", json!({"layers": ["hidden"]})).unwrap(); + assert_eq!(circles_and_lit_buttons(&mut app, &ctx), (0, 1), "no type is hidden"); + app.run("ui.layersFilter", json!({"objects": [], "layers": []})).unwrap(); + assert_eq!(circles_and_lit_buttons(&mut app, &ctx), before, "every row again"); + let st = app.session.active().unwrap(); + assert_eq!((st.revision, st.layers_open.generation(), st.selection.objects.clone()), (revision, open, vec![ids["rect"]])); +} + +/// Clicks in the Filter button's popup tick a kind and a state, and a second click clears the +/// kind, while the popup stays open; Clear All turns the filter off. +#[test] +fn the_filter_popup_ticks_kinds_and_states_and_clear_all_turns_the_filter_off() { + let (mut app, _) = kinds(); + let ctx = egui::Context::default(); + // The Filter button: the 24 pt square lit while a filter is on. + app.run("ui.layersFilter", json!({"layers": ["locked"]})).unwrap(); + let lit = Tokens::get(&ctx).tool_active; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let filter = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == lit && (r.rect.width() - 24.0).abs() < 0.01 => Some(r.rect.center()), + _ => None, + }) + .unwrap(); + app.run("ui.layersFilter", json!({"layers": []})).unwrap(); + click_with(&mut app, &ctx, filter, egui::Modifiers::NONE); + // Where the popup draws `label` (after a frame for it to settle). + let at = |app: &mut VectorcraftApp, label: &str| { + super::super::tests_appearance::frame_events(&ctx, app, vec![], show); + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], show); + texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{label} not in {texts:?}")) + }; + let text = at(&mut app, "Text"); + click_with(&mut app, &ctx, text, egui::Modifiers::NONE); + assert_eq!(app.ui.layers_filter, Filter { objects: vec![ObjectKind::Text], layers: vec![] }); + let hidden = at(&mut app, "Hidden"); + click_with(&mut app, &ctx, hidden, egui::Modifiers::NONE); + assert_eq!(app.ui.layers_filter, Filter { objects: vec![ObjectKind::Text], layers: vec![RowState::Hidden] }); + click_with(&mut app, &ctx, text, egui::Modifiers::NONE); + assert_eq!(app.ui.layers_filter, Filter { objects: vec![], layers: vec![RowState::Hidden] }, "a second click takes it out"); + let clear = at(&mut app, "Clear All"); + click_with(&mut app, &ctx, clear, egui::Modifiers::NONE); + assert!(!app.ui.layers_filter.is_on()); +} + +/// `ui.layersFilter` reads its lists by name in the popup's order, keeps a list it is not given, +/// and changes nothing when a list holds anything else. +#[test] +fn the_filter_command_reads_its_lists_by_name() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let r = app.run("ui.layersFilter", json!({"objects": ["guides", "paths", "paths"], "layers": ["hidden"]})).unwrap(); + assert_eq!(r, json!({"objects": ["paths", "guides"], "layers": ["hidden"]})); + let r = app.run("ui.layersFilter", json!({"layers": []})).unwrap(); + assert_eq!(r, json!({"objects": ["paths", "guides"], "layers": []})); + for bad in [json!({"objects": ["paths", "layers"]}), json!({"objects": "paths"}), json!({"objects": [], "layers": ["visible"]})] { + let e = app.run("ui.layersFilter", bad.clone()).unwrap_err(); + let key = if bad["layers"].is_null() { "objects" } else { "layers" }; + assert!(e.starts_with(&format!("`{key}` must be an array of these names: ")), "{bad}: {e}"); + } + assert_eq!(app.run("ui.layersFilter", json!({})).unwrap(), json!({"objects": ["paths", "guides"], "layers": []}), "unchanged"); +} + +#[test] +fn dragging_a_layer_into_the_empty_space_under_the_rows_moves_it_to_the_bottom() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("layer.new", &json!({"top": true})).unwrap(); + let ids = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>(); + let [bottom, top] = ids(&app)[..] else { panic!("two layers") }; + let ctx = egui::Context::default(); + // Rows: the top layer, the bottom layer, then empty space. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 2); + drag(&mut app, &ctx, egui::pos2(150.0, circles[0].y), egui::pos2(150.0, circles[1].y + 3.0 * ROW_MEDIUM), false); + assert_eq!(ids(&app), vec![top, bottom], "the top layer is now the bottom one"); +} + +#[test] +fn dragging_rows_moves_them_into_other_layers_and_alt_copies() { + let (mut app, [layer, sub, g, _, a, c]) = nested(); + let ctx = egui::Context::default(); + let l2 = NodeId(app.session.execute("layer.new", &json!({"top": true, "name": "Top"})).unwrap()["id"].as_u64().unwrap()); + // Rows: Top, Layer 1, Sub, g, b, a, c. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 7); + let name = |i: usize| egui::pos2(150.0, circles[i].y); + let parent = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.parent_of(id); + // Rectangle a onto the middle of the Top layer's row: into it. + drag(&mut app, &ctx, name(5), name(0), false); + assert_eq!(parent(&app, a), Some(l2)); + // Rows: Top, a, Layer 1, Sub, g, b, c. The sublayer onto the upper edge of Layer 1's row: + // above it, a top-level layer now. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 7); + drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[2].y - ROW_MEDIUM * 0.4), false); + assert_eq!(parent(&app, sub), None); + let d = &app.session.active().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| l.id).collect::>(), vec![layer, sub, l2]); + // Rows: Top, a, Sub, g, b, Layer 1, c. Alt-drag the group into Layer 1: a copy goes there, + // the group stays. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let count = app.session.active().unwrap().doc.node_count(); + drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[5].y), true); + assert_eq!(parent(&app, g), Some(sub)); + assert_eq!(app.session.active().unwrap().doc.node_count(), count + 2, "the group and its rectangle, copied"); + let _ = (layer, c); +} + +/// The centres of the bottom bar's buttons, right to left from under the right end of its top +/// divider: Delete, New Layer, New Sublayer, Make/Release Clipping Mask, Locate Object. +fn bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let divider = Tokens::get(ctx).divider; + let corner = out + .shapes + .iter() + .rev() + .find_map(|c| match &c.shape { + egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]), + _ => None, + }) + .unwrap(); + let step = 24.0 + ctx.global_style().spacing.item_spacing.x; + (0..5).map(|k| corner + vec2(-12.0 - k as f32 * step, 15.0)).collect() +} + +/// How many texts reading `s` the frame after `events` paints. +fn texts(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, s: &str) -> usize { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == s)).count() +} + +/// Drag the row at `a` to `b` and drop it there. Returns how many times its `name` was painted +/// while it hung over `b`. +fn drag_row(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, name: &str) -> usize { + frame(app, ctx, vec![egui::Event::PointerMoved(a)], false); + frame(app, ctx, vec![button(a, true)], false); + frame(app, ctx, vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], false); + let shown = texts(app, ctx, vec![egui::Event::PointerMoved(b)], name); + frame(app, ctx, vec![button(b, false)], false); + frame(app, ctx, vec![], false); + shown +} + +#[test] +fn dropping_rows_on_new_layer_duplicates_them() { + let (mut app, first, _) = two_rects(); + let second = app.session.execute("layer.new", &json!({"name": "Top"})).unwrap()["id"].as_u64().map(NodeId).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let layers = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>(); + // Rows top down: Top, Layer 1 (expanded: its two rectangles). + let (c, _) = frame(&mut app, &ctx, vec![], false); + let buttons = bar_buttons(&mut app, &ctx); + // Layer 1 dropped on New Layer: a copy of it right above it. + let shown = drag_row(&mut app, &ctx, egui::pos2(150.0, c[1].y), buttons[1], "Layer 1"); + assert_eq!(shown, 2, "the row and its ghost under the pointer"); + let after = layers(&app); + assert_eq!(after.len(), 3); + assert_eq!((after[0], after[2]), (first, second)); + assert_eq!(app.session.active().unwrap().doc.node(after[1]).unwrap().children().map(Vec::len), Some(2)); + assert_eq!(texts(&mut app, &ctx, vec![], "Layer 1"), 1, "the ghost is gone after the drop"); + // One undo step takes the copy away. + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layers(&app), vec![first, second]); + // The other bottom-bar buttons are no drop targets. + drag_row(&mut app, &ctx, egui::pos2(150.0, c[0].y), buttons[2], "Top"); + assert_eq!(layers(&app), vec![first, second]); +} + +/// Cmd+Alt+L is New Layer Options… by default. On Windows and Linux the key is Ctrl+Alt+L, and on +/// macOS it is Option+Command+L. Cmd+L is still New Layer. No other command, tool or panel has +/// Cmd+Alt+L by default. +#[test] +fn cmd_alt_l_is_new_layer_options() { + use crate::shortcut_editor::{command_for_key, default_command_shortcut}; + assert_eq!(default_command_shortcut("ui.newLayer"), Some("Cmd+Alt+L")); + assert_eq!(command_for_key("Cmd+Alt+L"), Some("ui.newLayer")); + assert_eq!(command_for_key("Cmd+L"), Some("layer.new")); + let chord = crate::shortcuts::parse("Cmd+Alt+L").expect("parses"); + assert_eq!((chord.modifiers, chord.logical_key), (egui::Modifiers::COMMAND | egui::Modifiers::ALT, egui::Key::L)); + let shown = crate::menus::pretty_shortcut("Cmd+Alt+L"); + assert_eq!(shown, if cfg!(target_os = "macos") { "⌥⌘L" } else { "Ctrl+Alt+L" }); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let listed = app.run("shortcuts.list", json!({"query": "ui.newLayer"})).unwrap(); + let rows: Vec<_> = listed.as_array().expect("rows").iter().map(|r| (r["label"].clone(), r["default"].clone())).collect(); + assert_eq!(rows, [(json!("New Layer Options…"), json!("Cmd+Alt+L"))], "Keyboard Shortcuts lists it"); + assert_eq!(app.run("shortcuts.conflicts", json!({})).unwrap(), json!([]), "no other default has the key"); +} + +/// Cmd+Alt+L, pressed through the shortcut handler, opens Layer Options for a new layer as an +/// Alt-click on the panel's New Layer button does, and no layer is added until OK. OK adds the +/// layer above the current layer, as after the Alt-click, and Cancel adds none. Cmd+L still adds +/// a layer without the dialog. +#[test] +fn cmd_alt_l_opens_new_layer_options_as_alt_clicking_new_layer_does() { + fn press(app: &mut VectorcraftApp, modifiers: egui::Modifiers) { + let ctx = egui::Context::default(); + let ev = egui::Event::Key { key: egui::Key::L, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); + } + /// Layer 1 with two rectangles under a layer named Top, with Layer 1 current. + fn two_layers() -> VectorcraftApp { + let (mut app, first, _) = two_rects(); + app.session.execute("layer.new", &json!({"name": "Top"})).unwrap(); + app.session.execute("layer.setCurrent", &json!({"id": first.0})).unwrap(); + app + } + let names = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.display_name()).collect::>(); + let dialog = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.clone())); + let mut keyed = two_layers(); + press(&mut keyed, egui::Modifiers::COMMAND | egui::Modifiers::ALT); + let mut clicked = two_layers(); + let ctx = egui::Context::default(); + let new_layer = bar_buttons(&mut clicked, &ctx)[1]; + click_with(&mut clicked, &ctx, new_layer, egui::Modifiers::ALT); + let opened = dialog(&keyed).expect("the key opens a dialog"); + assert_eq!((opened.0.as_str(), opened.1.get("mode")), ("layerOptions", Some(&json!("new")))); + assert_eq!(dialog(&clicked), Some(opened), "the key opens what the Alt-click opens"); + assert_eq!(names(&keyed), ["Layer 1", "Top"], "no layer is added until OK"); + for app in [&mut keyed, &mut clicked] { + crate::dialogs::confirm(app).unwrap(); + } + assert_eq!(names(&keyed), ["Layer 1", "Layer 3", "Top"]); + assert_eq!(names(&clicked), names(&keyed)); + // Cancel adds no layer. + press(&mut keyed, egui::Modifiers::COMMAND | egui::Modifiers::ALT); + assert!(keyed.ui.dialog.is_some()); + crate::dialogs::cancel(&mut keyed); + assert!(keyed.ui.dialog.is_none()); + assert_eq!(names(&keyed), ["Layer 1", "Layer 3", "Top"]); + press(&mut keyed, egui::Modifiers::COMMAND); + assert!(keyed.ui.dialog.is_none(), "Cmd+L opens no dialog"); + assert_eq!(names(&keyed).len(), 4); +} + +#[test] +fn the_selected_art_square_drags_the_selection_to_another_layer() { + let (mut app, [_, sub, g, _, a, c]) = nested(); + let ctx = egui::Context::default(); + app.session.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); + // From the sublayer's (small) square onto Layer 1's row. + drag(&mut app, &ctx, square(1), egui::pos2(150.0, circles[0].y), false); + let d = &app.session.active().unwrap().doc; + assert_eq!(d.node(d.layers[0].id).unwrap().children().unwrap().iter().map(|n| n.id).collect::>(), vec![c, sub, a, g]); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "still selected"); +} + +#[test] +fn the_panel_menu_lists_every_operation() { + let (mut app, _) = nested(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + for label in [ + "New Layer…", + "New Sublayer…", + "Duplicate Selection", + "Delete Selection", + "Options for Selection…", + "Make/Release Clipping Mask", + "Enter Isolation Mode", + "Locate Object", + "Save Selection", + "Merge Selected", + "Flatten Artwork", + "Collect in New Layer", + "Collect for Export", + "Release to Layers (Sequence)", + "Release to Layers (Build)", + "Reverse Order", + "Template", + "Show All Layers", + "Preview All Layers", + "Unlock All Layers", + "Paste Remembers Layers", + "Panel Options…", + ] { + assert!(texts.iter().any(|(t, _)| t.trim() == label), "{label} missing from {texts:?}"); + } +} + +/// ≡ › Save Selection lists the saved selections: choosing one recalls it, Update Selection +/// makes it hold the selected objects, and Save Selection… opens the name dialog. +#[test] +fn the_save_selection_submenu_recalls_updates_and_saves_selections() { + let (mut app, _, [a, b]) = two_rects(); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + app.run("select.save", json!({"name": "Left"})).unwrap(); + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + app.run("select.save", json!({"name": "Right"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| super::super::tests_appearance::frame_events(&ctx, app, events, menu); + // Opens the submenu (when closed) and clicks the item labeled `label` in it. + let choose = |app: &mut VectorcraftApp, label: &str| { + let at = |texts: &[(String, egui::Rect)], label: &str| texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()); + let mut texts = frame(app, vec![]); + if at(&texts, label).is_none() { + let sub = at(&texts, "Save Selection").expect("the Save Selection submenu"); + for e in [egui::Event::PointerMoved(sub), button(sub, true), button(sub, false)] { + frame(app, vec![e]); + } + texts = frame(app, vec![]); + } + let item = at(&texts, label).unwrap_or_else(|| panic!("{label} missing from {texts:?}")); + for e in [egui::Event::PointerMoved(item), button(item, true), button(item, false)] { + frame(app, vec![e]); + } + }; + let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); + choose(&mut app, "Left"); + assert_eq!(selected(&app), vec![a], "choosing a name recalls it"); + app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap(); + choose(&mut app, "Update Selection"); + let saved = app.session.active().unwrap().doc.saved_selections.clone(); + assert_eq!(saved.iter().map(|s| (s.name.as_str(), s.objects.len())).collect::>(), vec![("Left", 2), ("Right", 1)]); + assert!(app.ui.dialog.is_none()); + choose(&mut app, "Save Selection…"); + let d = app.ui.dialog.clone().expect("Save Selection dialog"); + assert_eq!((d.str("__command"), d.str("name")), ("select.save".into(), "Selection 1".into())); +} + +/// With the dock collapsed, Layers shows in a floating flyout: its rows work there too. +#[test] +fn rows_work_in_the_floating_flyout() { + let (mut app, [_, sub, ..]) = nested(); + app.ui.dock_collapsed = true; + app.ui.open_panel = Some("layers".into()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0)); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let input = egui::RawInput { time: Some(time), events, screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::panel_docking::show(app, ui)); + out.textures_delta.clear(); + let mut c: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Circle(cs) if cs.radius == 5.0 => Some(cs.center), + _ => None, + }) + .collect(); + c.sort_by(|a, b| a.y.total_cmp(&b.y)); + c + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let circles = frame(&mut app, vec![]); + assert_eq!(circles.len(), 6, "every row shows in the flyout"); + assert!(circles.iter().all(|c| screen.contains(*c))); + let at = circles[1] - vec2(120.0, 0.0); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]); + frame(&mut app, vec![button(at, false)]); + frame(&mut app, vec![]); + assert_eq!(rows_of(&app), vec![sub]); + assert_eq!(app.session.active().unwrap().current_layer(), Some(sub)); +} + +// ---- Locate Selected Objects (Panel Options) ---- + +/// A layer of `n` groups of one path each, with only the layer open, as a document opens → +/// (app, layer, the paths in stacking order, so the first is in the bottom row). The path in +/// group `i` is named `Path {i}`. +fn groups_of_one(n: usize) -> (VectorcraftApp, NodeId, Vec) { + use vectorcraft_doc::Appearance; + use vectorcraft_geom::{Rect, shapes}; + let mut d = Document::new(1000.0, 1000.0); + let mut groups = vec![]; + let mut paths = vec![]; + for i in 0..n { + let r = Rect::new(0.0, i as f64, 1.0, i as f64 + 1.0); + let mut path = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); + path.name = Some(format!("Path {i}")); + paths.push(path.id); + groups.push(Arc::new(Node::group(d.alloc_id(), vec![Arc::new(path)]))); + } + *Arc::make_mut(&mut d.layers[0]).children_mut().unwrap() = groups; + let layer = d.layers[0].id; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.add_document(d, None); + (app, layer, paths) +} + +/// One frame of the panel in a 300 × 800 window, one second after the previous frame: the +/// centers of the target circles, top row first, and whether `name` is drawn. +fn tall_frame(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) -> (Vec, bool) { + let time = ctx.input(|i| i.time) + 1.0; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 800.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == name)); + let mut circles: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Circle(cs) if cs.radius == 5.0 => Some(cs.center), + _ => None, + }) + .collect(); + circles.sort_by(|a, b| a.y.total_cmp(&b.y)); + (circles, found) +} + +/// Without Locate Selected Objects (the default), an object selected outside the panel leaves +/// the open rows and the scroll position as they were. +#[test] +fn without_the_option_a_selection_leaves_the_rows_and_the_scroll_as_they_were() { + let (mut app, layer, paths) = groups_of_one(500); + let ctx = egui::Context::default(); + let (before, _) = tall_frame(&mut app, &ctx, ""); + // The bottom row's path: out of view, in a closed group. + app.session.execute("select.set", &json!({"ids": [paths[0].0]})).unwrap(); + for _ in 0..4 { + let (circles, found) = tall_frame(&mut app, &ctx, "Path 0"); + assert!(!found); + assert_eq!(circles, before, "nothing opens or scrolls"); + } + let st = app.session.active().unwrap(); + let group = st.doc.parent_of(paths[0]).unwrap(); + assert!(st.layers_open.contains(layer) && !st.layers_open.contains(group)); +} + +/// With Locate Selected Objects on, an object selected outside the panel is located as Locate +/// Object locates it: the layer and the group around it open and its row is scrolled into +/// view. The highlighted row stays as it was. +#[test] +fn with_the_option_a_selected_object_is_opened_and_scrolled_into_view() { + let (mut app, layer, paths) = groups_of_one(500); + app.ui.layers_panel.locate_selected = true; + let ctx = egui::Context::default(); + let top = app.session.active().unwrap().doc.parent_of(paths[499]).unwrap(); + app.session.execute("layer.highlight", &json!({"ids": [top.0]})).unwrap(); + tall_frame(&mut app, &ctx, ""); + expand(&mut app, &json!({"ids": [layer.0], "open": false})).unwrap(); + assert_eq!(tall_frame(&mut app, &ctx, "").0.len(), 1, "only the layer's row"); + app.session.execute("select.set", &json!({"ids": [paths[0].0]})).unwrap(); + let found = (0..4).any(|_| tall_frame(&mut app, &ctx, "Path 0").1); + let st = app.session.active().unwrap(); + let group = st.doc.parent_of(paths[0]).unwrap(); + assert!(st.layers_open.contains(layer) && st.layers_open.contains(group), "the layer and the group open"); + assert!(found, "the row is scrolled into view"); + assert_eq!(st.highlighted_rows(), [top], "the highlighted row stays"); +} + +/// With Locate Selected Objects on, the panel's own clicks locate nothing: a Shift-click +/// highlights the rows down to the one it clicks, and Shift-clicking selection squares selects +/// art in a closed sublayer without opening it. An object then selected outside the panel is +/// located, and the highlighted rows stay. +#[test] +fn with_the_option_the_panels_own_clicks_locate_nothing() { + let (mut app, [layer, sub, g, b, _, c]) = nested(); + app.ui.layers_panel.locate_selected = true; + expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 1, Sub (closed), c. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 3); + let name = |i: usize| egui::pos2(150.0, circles[i].y); + let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); + click_with(&mut app, &ctx, name(0), egui::Modifiers::NONE); + click_with(&mut app, &ctx, name(2), egui::Modifiers::SHIFT); + assert_eq!(rows_of(&app), [layer, sub, c], "both rows clicked and the one between them"); + click_with(&mut app, &ctx, square(2), egui::Modifiers::SHIFT); + click_with(&mut app, &ctx, square(1), egui::Modifiers::SHIFT); + let st = app.session.active().unwrap(); + assert!(st.selection.objects.len() >= 3 && st.selection.objects.contains(&c), "c and Sub's art are selected"); + let (after, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(after, circles, "no row opens and nothing scrolls"); + assert_eq!(rows_of(&app), [layer, sub, c]); + // Selected outside the panel: b's sublayer and group open, and the rows stay highlighted. + app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 6); + assert_eq!(rows_of(&app), [layer, sub, c]); +} + +/// With Locate Selected Objects on, a selection the panel menu changes is not located: Delete +/// Selection deletes the highlighted object, and the object left selected stays in its closed +/// sublayer and group. +#[test] +fn with_the_option_the_panel_menu_locates_nothing() { + let (mut app, [_, sub, g, b, _, c]) = nested(); + app.ui.layers_panel.locate_selected = true; + app.session.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![], false); + expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); + app.session.execute("layer.highlight", &json!({"ids": [c.0]})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "Layer 1, Sub (closed) and c"); + let menu_frame = |app: &mut VectorcraftApp, events| super::super::tests_appearance::frame_events(&ctx, app, events, menu); + let at = menu_frame(&mut app, vec![]).iter().find(|(t, _)| t.trim() == "Delete Selection").map(|(_, r)| r.center()).unwrap(); + for e in [egui::Event::PointerMoved(at), button(at, true), button(at, false)] { + menu_frame(&mut app, vec![e]); + } + assert_eq!(app.session.active().unwrap().selection.objects, [b], "c is deleted and b stays selected"); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 2, "Sub stays closed"); +} + +/// With Locate Selected Objects on, a saved selection recalled from the panel menu's Save +/// Selection submenu is not located: the recalled object stays in its closed sublayer and group. +#[test] +fn with_the_option_a_selection_recalled_from_the_panel_menu_is_not_located() { + let (mut app, [_, sub, g, b, _, c]) = nested(); + app.ui.layers_panel.locate_selected = true; + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + app.run("select.save", json!({"name": "Kept"})).unwrap(); + app.run("select.set", json!({"ids": [c.0]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![], false); + expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "Layer 1, Sub (closed) and c"); + let menu_frame = |app: &mut VectorcraftApp, events| super::super::tests_appearance::frame_events(&ctx, app, events, menu); + let at = |texts: &[(String, egui::Rect)], label: &str| texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()); + let sub_menu = at(&menu_frame(&mut app, vec![]), "Save Selection").expect("the Save Selection submenu"); + for e in [egui::Event::PointerMoved(sub_menu), button(sub_menu, true), button(sub_menu, false)] { + menu_frame(&mut app, vec![e]); + } + let item = at(&menu_frame(&mut app, vec![]), "Kept").expect("the saved selection's item"); + for e in [egui::Event::PointerMoved(item), button(item, true), button(item, false)] { + menu_frame(&mut app, vec![e]); + } + assert_eq!(app.session.active().unwrap().selection.objects, [b], "the saved selection is recalled"); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "Sub stays closed"); +} + +/// With Locate Selected Objects on, the object selected in a document that becomes active is +/// not located; an object selected after that is. Neither document has highlighted rows, so +/// the switch reveals no row of its own. +#[test] +fn with_the_option_a_document_becoming_active_locates_nothing() { + let (mut app, [_, sub, g, b, a, _]) = nested(); + app.ui.layers_panel.locate_selected = true; + app.session.execute("layer.highlight", &json!({"ids": []})).unwrap(); + app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![], false); + expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + frame(&mut app, &ctx, vec![], false); + app.session.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "b's sublayer and group stay closed"); + app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 5, "a's sublayer opens"); +} + +/// With Locate Selected Objects on, the copies an Alt-drag selects are located once the drag +/// ends, not while it lasts. +#[test] +fn with_the_option_a_selection_made_during_a_drag_is_located_when_it_ends() { + let (mut app, [_, sub, g, b, ..]) = nested(); + app.ui.layers_panel.locate_selected = true; + app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![], false); + expand(&mut app, &json!({"ids": [sub.0, g.0], "open": false})).unwrap(); + app.session.begin_interaction("Move").unwrap(); + for i in 1..=3 { + app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, i * 10, 0], "copy": true})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 3, "nothing opens while the drag lasts"); + } + app.session.commit_interaction().unwrap(); + let st = app.session.active().unwrap(); + let copy = st.selection.objects.last().copied().unwrap(); + assert!(copy != b && st.doc.parent_of(copy) == Some(g), "the copy is selected, in b's group"); + assert_eq!(frame(&mut app, &ctx, vec![], false).0.len(), 7, "Sub and the group open on the copy's row"); +} + +/// With Locate Selected Objects on, art selected outside the panel in another layer is +/// located: the selection makes that layer the active one, and the panel scrolls to the art's +/// row, not to the layer's row. +#[test] +fn with_the_option_the_located_object_is_revealed_instead_of_its_active_layer() { + let (mut app, layer, paths) = groups_of_one(500); + app.ui.layers_panel.locate_selected = true; + app.session.execute("layer.new", &json!({})).unwrap(); + assert_ne!(app.session.active().unwrap().current_layer(), Some(layer)); + let ctx = egui::Context::default(); + tall_frame(&mut app, &ctx, ""); + app.session.execute("select.set", &json!({"ids": [paths[0].0]})).unwrap(); + assert_eq!(app.session.active().unwrap().current_layer(), Some(layer), "the path's layer is the active one"); + let found = (0..4).any(|_| tall_frame(&mut app, &ctx, "Path 0").1); + let st = app.session.active().unwrap(); + let group = st.doc.parent_of(paths[0]).unwrap(); + assert!(st.layers_open.contains(group), "the group opens"); + assert!(found, "the path's row is scrolled into view"); +} + +/// With Locate Selected Objects and Show Layers Only on, art selected outside the panel has no +/// row, so the panel scrolls to the row of the layer that the selection makes the active one. +#[test] +fn with_the_option_and_show_layers_only_the_active_layer_is_revealed() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.layers_panel.locate_selected = true; + app.ui.layers_panel.layers_only = true; + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let rect = |app: &mut VectorcraftApp| run(app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].as_u64().unwrap(); + let bottom = rect(&mut app); + for _ in 0..59 { + run(&mut app, "layer.new", json!({})); + rect(&mut app); + } + run(&mut app, "select.none", json!({})); + let doc = app.session.active().unwrap().doc.clone(); + let name = doc.node(doc.layer_of(NodeId(bottom)).unwrap()).unwrap().display_name(); + let ctx = egui::Context::default(); + assert!(!(0..4).any(|_| tall_frame(&mut app, &ctx, &name).1), "the bottom layer's row is out of view"); + run(&mut app, "select.set", json!({"ids": [bottom]})); + assert!((0..4).any(|_| tall_frame(&mut app, &ctx, &name).1), "the active layer's row is scrolled into view"); +} + +/// With Locate Selected Objects on, art selected outside the panel that Show Layers Only or the +/// filter leaves without a row opens nothing, so its group stays closed once the panel lists +/// every row again. Art the filter keeps has its group opened. +#[test] +fn with_the_option_art_the_panel_does_not_list_opens_nothing() { + let (mut app, _, paths) = groups_of_one(500); + app.ui.layers_panel.locate_selected = true; + let ctx = egui::Context::default(); + let open = |app: &VectorcraftApp, i: usize| { + let st = app.session.active().unwrap(); + st.layers_open.contains(st.doc.parent_of(paths[i]).unwrap()) + }; + let locate = |app: &mut VectorcraftApp, i: usize| { + app.session.execute("select.set", &json!({"ids": [paths[i].0]})).unwrap(); + for _ in 0..4 { + tall_frame(app, &ctx, ""); + } + }; + tall_frame(&mut app, &ctx, ""); + app.ui.layers_panel.layers_only = true; + locate(&mut app, 0); + app.ui.layers_panel.layers_only = false; + tall_frame(&mut app, &ctx, ""); + assert!(!open(&app, 0), "Show Layers Only: the group stays closed"); + app.ui.layers_filter = Filter { objects: vec![ObjectKind::Text], layers: vec![] }; + locate(&mut app, 1); + assert!(!open(&app, 1), "a filter that leaves the path out: the group stays closed"); + app.ui.layers_filter = Filter { objects: vec![ObjectKind::Paths], layers: vec![] }; + locate(&mut app, 2); + assert!(open(&app, 2), "a filter that keeps the path: the group opens"); +} + +/// A layer of 2,000 groups of 10 paths with every row open: one frame lays out only the rows +/// in view, and a row highlighted far below them is scrolled to. +#[test] +fn only_the_rows_in_view_are_laid_out() { + use vectorcraft_doc::Appearance; + use vectorcraft_geom::{Rect, shapes}; + let mut d = Document::new(1000.0, 1000.0); + let mut groups = vec![]; + let mut needle = None; + for g in 0..2000 { + let kids = (0..10) + .map(|k| { + let r = Rect::new(k as f64, g as f64 * 0.1, k as f64 + 1.0, g as f64 * 0.1 + 1.0); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); + if g == 0 && k == 0 { + n.name = Some("Needle".into()); + needle = Some(n.id); + } + Arc::new(n) + }) + .collect(); + groups.push(Arc::new(Node::group(d.alloc_id(), kids))); + } + *Arc::make_mut(&mut d.layers[0]).children_mut().unwrap() = groups; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.add_document(d, None); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // (shapes drawn, whether the needle's name is among them); a second passes per frame, so + // scrolling ends. + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp| { + time += 1.0; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 800.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Needle")); + (out.shapes.len(), found) + }; + let (shapes, found) = frame(&mut app); + // 22,001 rows; about 30 fit. + assert!(shapes < 1000 && !found, "{shapes} shapes"); + let needle = needle.unwrap(); + app.session.execute("layer.highlight", &json!({"ids": [needle.0], "mode": "set"})).unwrap(); + let found = (0..4).any(|_| frame(&mut app).1); + assert_eq!(app.session.active().unwrap().highlighted_rows(), [needle]); + assert!(found, "the highlighted row is scrolled into view"); +} + +/// Selecting art on the canvas scrolls its layer (now the active one) into view, but not away +/// from a row highlighted in the panel. +#[test] +fn a_canvas_selection_reveals_its_active_layer() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let mut rects = vec![run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].as_u64().unwrap()]; + for _ in 0..59 { + run(&mut app, "layer.new", json!({})); + rects.push(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].as_u64().unwrap()); + } + run(&mut app, "select.none", json!({})); + let doc = app.session.active().unwrap().doc.clone(); + let name = |id: u64| doc.node(doc.layer_of(NodeId(id)).unwrap()).unwrap().display_name(); + let (bottom, top) = (name(rects[0]), name(rects[59])); + let ctx = egui::Context::default(); + let mut time = 0.0; + // Whether a row with this name is drawn after a few frames. + let mut shown = |app: &mut VectorcraftApp, s: &str| { + let mut found = false; + for _ in 0..4 { + time += 1.0; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == s)); + } + found + }; + assert!(shown(&mut app, &top) && !shown(&mut app, &bottom)); + run(&mut app, "select.set", json!({"ids": [rects[0]]})); + assert_eq!(app.session.active().unwrap().current_layer(), doc.layer_of(NodeId(rects[0]))); + assert!(shown(&mut app, &bottom), "the active layer is scrolled into view"); + // A row highlighted in the panel stays in view when the active layer changes. + let top_layer = doc.layer_of(NodeId(rects[59])).unwrap(); + run(&mut app, "layer.highlight", json!({"ids": [top_layer.0], "mode": "set"})); + assert!(shown(&mut app, &top)); + app.session.active_mut().unwrap().active_layer = doc.layer_of(NodeId(rects[0])); + assert!(shown(&mut app, &top) && !shown(&mut app, &bottom), "the highlighted row keeps the view"); +} From 42e11117e2d5173f2a3bc4730e5fa1737c4cd9ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:23 +0000 Subject: [PATCH 017/199] refactor(ui-egui): move panels/properties.rs tests to panels/properties/tests.rs --- crates/ui-egui/src/panels/properties.rs | 1448 +---------------- crates/ui-egui/src/panels/properties/tests.rs | 1445 ++++++++++++++++ 2 files changed, 1446 insertions(+), 1447 deletions(-) create mode 100644 crates/ui-egui/src/panels/properties/tests.rs diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6274e07d8..f43e49936 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -905,1450 +905,4 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use egui::{Event, PointerButton, Pos2, Rect, vec2}; - use vectorcraft_engine::Session; - - use super::*; - - /// One frame of the panel with `events` → the texts painted, with their rects. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { - panel_frame(app, ctx, events, show) - } - - /// One frame of `draw` with `events` → the texts painted, with their rects. - fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<(String, Rect)> { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| draw(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| { - if let egui::Shape::Text(t) = &s.shape { - Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))) - } else { - None - } - }) - .collect() - } - - /// Clicks the text `label` where the last frame painted it, over two frames (press, release). - fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)], label: &str) { - let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")); - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, ctx, vec![Event::PointerMoved(at), b(true)]); - frame(app, ctx, vec![b(false)]); - } - - /// One selected clip group has Edit Clipping Path and Edit Contents at the top of the panel, - /// under its Clip Group heading and before every section, and they select its clipping path or - /// its clipped art; one that clips nothing has Edit Clipping Path alone. A plain group, or a clip - /// group selected with another object, has neither. - #[test] - fn one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let art = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 140})).unwrap()["id"].clone(); - let mask = app.run("shape.ellipse", json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [art, mask]})).unwrap(); - let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [group]})).unwrap(); - let ctx = egui::Context::default(); - let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.iter().map(|n| json!(n.0)).collect::>(); - let labels = |texts: &[(String, Rect)]| texts.iter().map(|(t, _)| t.clone()).collect::>(); - let texts = frame(&mut app, &ctx, vec![]); - let shown = labels(&texts); - let at = |label: &str| shown.iter().position(|t| t == label).unwrap_or_else(|| panic!("{label} in {shown:?}")); - assert_eq!(at("Clip Group"), 0, "{shown:?}"); - assert!(at("Edit Clipping Path") == 1 && at("Edit Contents") == 2, "{shown:?}"); - click(&mut app, &ctx, &texts, "Edit Contents"); - assert_eq!(selected(&app), std::slice::from_ref(&art), "Edit Contents selects the clipped art"); - app.run("select.set", json!({"ids": [group]})).unwrap(); - let texts = frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, &texts, "Edit Clipping Path"); - assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path"); - let neither = |app: &mut VectorcraftApp| { - let shown = labels(&frame(app, &ctx, vec![])); - !shown.iter().any(|t| t == "Edit Clipping Path" || t == "Edit Contents") - }; - // The clip group with another object, whichever is selected first. - let other = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [group, other]})).unwrap(); - assert!(neither(&mut app), "a clip group and another object"); - app.run("select.set", json!({"ids": [other, group]})).unwrap(); - assert!(neither(&mut app), "another object and a clip group"); - // A plain group. - let b = app.run("shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [other, b]})).unwrap(); - let plain = app.run("object.group", json!({})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [plain]})).unwrap(); - assert!(labels(&frame(&mut app, &ctx, vec![])).iter().any(|t| t == "Ungroup")); - assert!(neither(&mut app), "a plain group"); - // A clip group whose clipped art is deleted: Edit Clipping Path alone. - app.run("select.set", json!({"ids": [art]})).unwrap(); - app.run("edit.clear", json!({})).unwrap(); - app.run("select.set", json!({"ids": [group]})).unwrap(); - let texts = frame(&mut app, &ctx, vec![]); - let shown = labels(&texts); - assert_eq!(shown.iter().position(|t| t == "Edit Clipping Path"), Some(1), "{shown:?}"); - assert!(!shown.iter().any(|t| t == "Edit Contents"), "nothing to edit: {shown:?}"); - click(&mut app, &ctx, &texts, "Edit Clipping Path"); - assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path"); - } - - /// Start Global Edit in Quick Actions starts it on the one selected path, the button and the - /// Select menu item then read Stop Global Edit, and Escape (the app's key handling) stops it. - /// Selected type has no Start Global Edit button. - #[test] - fn start_global_edit_quick_action_and_escape() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let a = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone(); - app.run("shape.rectangle", json!({"x": 200, "y": 20, "width": 80, "height": 80})).unwrap(); - app.run("select.set", json!({"ids": [a]})).unwrap(); - let ctx = egui::Context::default(); - // Tall enough for Quick Actions at the foot of the panel. - let tall = |app: &mut VectorcraftApp, events: Vec| { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), - _ => None, - }) - .collect::>() - }; - let on = |app: &VectorcraftApp| app.session.active().unwrap().global_edit.is_some(); - let label = |app: &VectorcraftApp| crate::menus::dynamic_label(app, "select.globalEdit", "Start Global Edit"); - assert_eq!(label(&app), "Start Global Edit"); - let texts = tall(&mut app, vec![]); - let at = texts.iter().find(|(t, _)| t == "Start Global Edit").map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{texts:?}")); - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - tall(&mut app, vec![Event::PointerMoved(at), b(true)]); - tall(&mut app, vec![b(false)]); - assert!(on(&app), "the Quick Action starts Global Edit"); - assert_eq!(app.session.active().unwrap().global_edit.as_ref().unwrap().members.len(), 2); - assert!(tall(&mut app, vec![]).iter().any(|(t, _)| t == "Stop Global Edit")); - assert_eq!(label(&app), "Stop Global Edit"); - let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - let raw = - egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events: vec![escape], ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); - out.textures_delta.clear(); - assert!(!on(&app), "Escape stops Global Edit"); - assert_eq!(label(&app), "Start Global Edit"); - assert_eq!(app.session.active().unwrap().selection.objects.iter().map(|n| json!(n.0)).collect::>(), [a]); - let t = app.run("text.create", json!({"x": 50, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [t]})).unwrap(); - assert!(!crate::menus::enabled(&app, "select.globalEdit")); - assert!(!tall(&mut app, vec![]).iter().any(|(t, _)| t == "Start Global Edit"), "type has no Global Edit"); - } - - /// The options' Result(s) count is found once per document revision, options and selected - /// object, not every frame; another selected object or other options find it again. - #[test] - fn the_global_edit_result_count_is_cached() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let a = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone(); - app.run("shape.rectangle", json!({"x": 200, "y": 20, "width": 80, "height": 80})).unwrap(); - let e = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 40, "height": 40})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [a]})).unwrap(); - let ctx = egui::Context::default(); - assert_eq!(global_edit_count(&app, &ctx), 2); - // The next frame reads the cache: a count planted under the same key is what it shows. - let (key, _) = pstate::(&ctx, "props-global-edit-count").unwrap(); - set_pstate::(&ctx, "props-global-edit-count", Some((key, 7))); - assert_eq!(global_edit_count(&app, &ctx), 7); - app.run("select.set", json!({"ids": [e]})).unwrap(); - assert_eq!(global_edit_count(&app, &ctx), 1, "another selected object"); - app.run("select.set", json!({"ids": [a]})).unwrap(); - assert_eq!(global_edit_count(&app, &ctx), 2); - app.run("select.globalEditOptions", json!({"size": true})).unwrap(); - assert_eq!(global_edit_count(&app, &ctx), 1, "other options"); - } - - // Regression test for #1910: the Properties panel's flip buttons are named as the reference - // app's Properties panel names them, not Flip Horizontal and Flip Vertical (the Transform - // panel menu's names). - #[test] - fn the_flip_buttons_keep_the_reference_properties_names() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - let tree = crate::widgets::accessibility_tests::tree(|ui| transform_section(&mut app, ui)); - let names: Vec<&str> = tree.iter().filter_map(|n| n.label()).collect(); - for want in ["Flip Along Horizontal Axis", "Flip Along Vertical Axis"] { - assert!(names.contains(&want), "{want} in {names:?}"); - } - for old in ["Flip Horizontal", "Flip Vertical"] { - assert!(!names.contains(&old), "{old} in {names:?}"); - } - } - - /// #3451: the Properties panel Transform section shows ellipse pie angles and Invert Pie. - #[test] - fn transform_section_shows_ellipse_pie_properties() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap(); - let ctx = egui::Context::default(); - let labels: Vec = panel_frame(&mut app, &ctx, vec![], transform_section).into_iter().map(|(t, _)| t).collect(); - for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] { - assert!(labels.iter().any(|s| s == t), "{t} in {labels:?}"); - } - app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap(); - let labels: Vec = panel_frame(&mut app, &ctx, vec![], transform_section).into_iter().map(|(t, _)| t).collect(); - assert!(labels.iter().any(|s| s == "30°") && labels.iter().any(|s| s == "120°"), "{labels:?}"); - } - - /// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every - /// theme, as the Preferences ones do (#1080), and toggle when clicked. - #[test] - fn the_document_checkboxes_show_their_boxes_and_toggle() { - for brightness in crate::theme::Brightness::ALL { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, brightness); - frame(&mut app, &ctx, vec![]); - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); - out.textures_delta.clear(); - let boxes = out - .shapes - .iter() - .filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.stroke.width > 0.0 && (r.rect.width() - 13.0).abs() < 0.5 && (r.rect.height() - 13.0).abs() < 0.5)) - .count(); - assert!(boxes >= 3, "{brightness:?}: {boxes} checkbox outlines"); - } - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let texts = frame(&mut app, &ctx, vec![]); - let (grid, strokes) = (app.ui.view.snap_to_grid, app.session.prefs.scale_strokes); - click(&mut app, &ctx, &texts, "Snap to Grid"); - assert_eq!(app.ui.view.snap_to_grid, !grid); - let texts = frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, &texts, "Scale Strokes & Effects"); - assert_eq!(app.session.prefs.scale_strokes, !strokes); - } - - /// #921: with the Gradient tool, a Gradient section: the type, and for a freeform gradient - /// Draw as Points or Lines radio buttons. - #[test] - fn the_gradient_tool_shows_the_gradient_type_and_draw() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); - app.run("paint.setFill", json!({"gradient": {}})).unwrap(); - let ctx = egui::Context::default(); - let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); - let texts = frame(&mut app, &ctx, vec![]); - assert!(!has(&texts, "Gradient"), "only with the Gradient tool"); - app.select_tool("gradient"); - let texts = frame(&mut app, &ctx, vec![]); - assert!(has(&texts, "Gradient") && has(&texts, "Type:") && !has(&texts, "Draw:"), "{texts:?}"); - app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); - frame(&mut app, &ctx, vec![]); - let texts = frame(&mut app, &ctx, vec![]); - assert!(has(&texts, "Draw:") && has(&texts, "Points") && has(&texts, "Lines"), "{texts:?}"); - click(&mut app, &ctx, &texts, "Lines"); - let fill = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); - let vectorcraft_color::Paint::Gradient(g) = fill else { panic!("{fill:?}") }; - assert_eq!(super::super::gradient::shown_points(&g).mode, vectorcraft_color::FreeformMode::Lines); - } - - /// #696: the number fields are all as wide: the Transform fields, the rotation, the corner - /// radius, Opacity and the stroke weight. - #[test] - fn the_number_fields_are_all_as_wide() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let texts = frame(&mut app, &ctx, vec![]); - // The field each value is shown in: the narrowest widget around its text that isn't the - // text alone. - let field = |value: &str| { - let at = texts.iter().find(|(t, _)| t == value).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{value}: {texts:?}")); - ctx.viewport(|vp| { - vp.prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .filter(|w| w.rect.contains(at) && w.rect.width() > 30.0 && w.rect.height() < 40.0) - .map(|w| w.rect.width()) - .fold(f32::INFINITY, f32::min) - }) - }; - let widths = [field("35 pt"), field("0°"), field("0 pt"), field("100%")]; - assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}"); - } - - /// #1512: the link behind Corner Radius. On, one field sets every corner; off, a field per - /// corner shows each corner's own radius. - #[test] - fn the_corner_radius_link_shows_a_field_per_corner() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); - app.run("object.setLiveShape", json!({"radius": 7, "corners": [2]})).unwrap(); - let ctx = egui::Context::default(); - let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); - frame(&mut app, &ctx, vec![]); - let texts = frame(&mut app, &ctx, vec![]); - assert!(app.session.prefs.single_corner_radius, "linked at first"); - assert!(has(&texts, "Corner Radius:") && !has(&texts, "Top Left:") && !has(&texts, "7 pt"), "{texts:?}"); - app.run("prefs.set", json!({"key": "singleCornerRadius", "value": false})).unwrap(); - frame(&mut app, &ctx, vec![]); - let texts = frame(&mut app, &ctx, vec![]); - for l in ["Corner Radius:", "Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"] { - assert!(has(&texts, l), "{l}: {texts:?}"); - } - assert_eq!(texts.iter().filter(|(t, _)| t == "7 pt").count(), 1, "the bottom-right corner: {texts:?}"); - // The fields sit in line, in the order top left, top right, bottom right, bottom left. - let at = |s: &str| texts.iter().find(|(t, _)| t == s).unwrap().1; - assert!(at("Top Left:").top() < at("Top Right:").top() && at("Top Right:").top() < at("Bottom Right:").top()); - assert!((at("7 pt").top() - at("Bottom Right:").top()).abs() < 6.0, "{texts:?}"); - } - - /// The Transform section's More Options holds Scale Corners and Scale Strokes & Effects, and - /// stays open while they are toggled. - #[test] - fn transform_more_options_toggles_scale_corners_and_strokes() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); - let ctx = egui::Context::default(); - let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); - frame(&mut app, &ctx, vec![]); - let texts = frame(&mut app, &ctx, vec![]); - assert!(!has(&texts, "Scale Corners"), "closed at first"); - // The button is an icon at the end of the angle row, under the H: field: sweep that row. - let h = texts.iter().find(|(t, _)| t == "H:").unwrap().1; - let mut open = None; - 'sweep: for dy in (12..60).step_by(6) { - for x in (120..300).step_by(6) { - let at = egui::pos2(x as f32, h.bottom() + dy as f32); - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![Event::PointerMoved(at), b(true)]); - frame(&mut app, &ctx, vec![b(false)]); - let texts = frame(&mut app, &ctx, vec![]); - if has(&texts, "Scale Corners") { - open = Some(texts); - break 'sweep; - } - } - } - let texts = open.expect("More Options opens the popup"); - let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes); - click(&mut app, &ctx, &texts, "Scale Corners"); - assert_eq!(app.session.prefs.scale_corners, !sc); - let texts = frame(&mut app, &ctx, vec![]); - assert!(has(&texts, "Scale Strokes & Effects"), "still open after a toggle"); - click(&mut app, &ctx, &texts, "Scale Strokes & Effects"); - assert_eq!(app.session.prefs.scale_strokes, !ss); - } - - /// Both panels forward the Move Artwork toggle when their actual X field is edited. - #[test] - fn artboard_coordinate_fields_move_art_in_properties_and_transform() { - for draw in [show as fn(&mut VectorcraftApp, &mut Ui), super::super::transform::show] { - for move_art in [false, true] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("artboard"); - app.session.set_tool_option("moveArt", &json!(move_art)); - let ctx = egui::Context::default(); - panel_frame(&mut app, &ctx, vec![], draw); - let texts = panel_frame(&mut app, &ctx, vec![], draw); - let label = texts.iter().find(|(t, _)| t == "X:").unwrap().1; - let at = egui::pos2(label.right() + 40.0, label.center().y); - let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - panel_frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)], draw); - panel_frame(&mut app, &ctx, vec![press(false)], draw); - panel_frame( - &mut app, - &ctx, - vec![Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], - draw, - ); - let d = &app.session.doc().unwrap().doc; - assert_eq!(d.artboards[0].rect.x0, 1.0, "the focused X field steps its position"); - assert_eq!(d.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0, if move_art { 11.0 } else { 10.0 }); - app.run("edit.undo", json!({})).unwrap(); - let d = &app.session.doc().unwrap().doc; - assert_eq!(d.artboards[0].rect.x0, 0.0); - assert_eq!(d.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0, 10.0); - } - } - } - - /// Three landscape artboards, with Artboards 1 and 3 selected with the Artboard tool (Artboard - /// 1 active): 200 × 100, 200 × 100 and 300 × 150. - fn two_selected_artboards() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - app.run("artboard.setProps", json!({"index": 2, "width": 300, "height": 150})).unwrap(); - app.select_tool("artboard"); - super::super::artboards::select(&mut app, 0); - app.session.set_tool_option("selected", &json!([0, 2])); - app - } - - /// Each artboard's (x0, y0, width, height). - fn boards(app: &VectorcraftApp) -> Vec<(f64, f64, f64, f64)> { - app.session.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height())).collect() - } - - /// #1482: with Artboards 1 and 3 selected, a W typed into Properties or the Transform panel - /// resizes both in one undo step, and Artboard 2 keeps its size. Properties keeps each top-left - /// corner; the Transform panel resizes each about its own center, its default reference point. - #[test] - fn artboard_width_resizes_every_selected_artboard_in_properties_and_transform() { - for (draw, centered) in [(show as fn(&mut VectorcraftApp, &mut Ui), false), (super::super::transform::show, true)] { - let mut app = two_selected_artboards(); - let before = boards(&app); - let undo = app.session.doc().unwrap().history.undo.len(); - let ctx = egui::Context::default(); - panel_frame(&mut app, &ctx, vec![], draw); - let texts = panel_frame(&mut app, &ctx, vec![], draw); - let label = texts.iter().find(|(t, _)| t == "W:").unwrap().1; - let at = egui::pos2(label.right() + 40.0, label.center().y); - let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - panel_frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)], draw); - panel_frame(&mut app, &ctx, vec![press(false)], draw); - panel_frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw); - panel_frame(&mut app, &ctx, vec![Event::Text("250".into())], draw); - panel_frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw); - panel_frame(&mut app, &ctx, vec![], draw); - let mut want = before.clone(); - for (i, shift) in [(0, 25.0), (2, -25.0)] { - want[i].2 = 250.0; - if centered { - want[i].0 -= shift; - } - } - assert_eq!(boards(&app), want, "centered: {centered}"); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(boards(&app), before); - } - } - - /// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit. - /// #775: type's Paragraph section has Hyphenate, and its checkbox turns it on. - #[test] - fn type_paragraph_section_hyphenates() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("text.create", json!({"x": 10, "y": 50, "text": "Paragraph"})).unwrap(); - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let texts = frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, &texts, "Hyphenate"); - let hyphenate = |app: &VectorcraftApp| crate::panels::character::text_style(app).unwrap().1.hyphenate; - assert!(hyphenate(&app)); - } - - /// Variable fonts get the same Variable Font button in Properties as in the Character panel. - #[test] - fn properties_variable_font_button_for_variable_type() { - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; - vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone(); - let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone(); - let ctx = egui::Context::default(); - app.run("select.set", json!({"ids": [plain]})).unwrap(); - assert!(!frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Variable Font")); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Variable Font")); - } - - #[test] - fn edit_artboards_adds_artboards_and_exits() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - let texts = frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, &texts, "Edit Artboards"); - assert_eq!(app.session.tool_id(), "artboard"); - let texts = frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(t, _)| t == "Artboard 1") && texts.iter().any(|(t, _)| t == "200 pt"), "{texts:?}"); - click(&mut app, &ctx, &texts, "New Artboard"); - let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len(); - assert_eq!(boards(&app), 2); - let texts = frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(t, _)| t == "Artboard 2"), "the new artboard is the active one: {texts:?}"); - click(&mut app, &ctx, &texts, "Delete Artboard"); - assert_eq!(boards(&app), 1); - let texts = frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, &texts, "Exit"); - assert_eq!(app.session.tool_id(), "selection"); - } - - /// Typing a name into Edit Artboards' name field and pressing Enter renames the active artboard - /// alone, trimmed as the Artboards panel's rename trims it, with one command and one undo step; - /// a click elsewhere in the panel renames it too. Pressing Escape or leaving the field empty - /// keeps the name and runs no command. - #[test] - fn edit_artboards_renames_the_active_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - app.select_tool("artboard"); - super::super::artboards::select(&mut app, 1); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.artboards.clone(); - let undo = app.session.doc().unwrap().history.undo.len(); - let ctx = egui::Context::default(); - let key = |key, modifiers| vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]; - // Click the field showing Artboard 2's name, select all of it, type `typed`, then send each - // frame's events of `end` → the number of commands run. - let rename = |app: &mut VectorcraftApp, typed: Vec, end: Vec>| { - frame(app, &ctx, vec![]); - let texts = frame(app, &ctx, vec![]); - let name = app.session.doc().unwrap().doc.artboards[1].name.clone(); - click(app, &ctx, &texts, &name); - frame(app, &ctx, key(egui::Key::A, egui::Modifiers::COMMAND)); - let commands = app.run_count; - frame(app, &ctx, typed); - for events in end { - frame(app, &ctx, events); - } - frame(app, &ctx, vec![]); - app.run_count.wrapping_sub(commands) - }; - let enter = || vec![key(egui::Key::Enter, egui::Modifiers::NONE)]; - let text = |s: &str| vec![Event::Text(s.into())]; - assert_eq!(rename(&mut app, text("Cover"), vec![key(egui::Key::Escape, egui::Modifiers::NONE)]), 0, "Escape"); - assert_eq!(rename(&mut app, key(egui::Key::Backspace, egui::Modifiers::NONE), enter()), 0, "an empty field"); - assert_eq!(app.session.doc().unwrap().doc.artboards, before); - assert_eq!(rename(&mut app, text(" Cover "), enter()), 1); - let mut renamed = before.clone(); - renamed[1].name = "Cover".into(); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc.artboards, renamed, "Artboard 2 alone is renamed"); - assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); - app.run("edit.undo", json!({})).unwrap(); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc.artboards, before); - assert!(!st.is_dirty()); - // The click elsewhere is a press and release on the panel's "Artboard" heading, outside - // the field. - let at = frame(&mut app, &ctx, vec![]).iter().find(|(t, _)| t == "Artboard").map(|(_, r)| r.center()).unwrap(); - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - assert_eq!(rename(&mut app, text("Back"), vec![vec![Event::PointerMoved(at), b(true)], vec![b(false)]]), 1, "a click elsewhere"); - renamed[1].name = "Back".into(); - assert_eq!(app.session.doc().unwrap().doc.artboards, renamed); - } - - /// A click on Edit Artboards' Artboard Options icon opens Artboard Options for the active - /// artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). - #[test] - fn edit_artboards_opens_the_active_artboards_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - app.select_tool("artboard"); - super::super::artboards::select(&mut app, 1); - app.run("tool.options", json!({"tool": "artboard"})).unwrap(); - let want = app.ui.dialog.take().unwrap().fields; - let ctx = egui::Context::default(); - frame(&mut app, &ctx, vec![]); - let texts = frame(&mut app, &ctx, vec![]); - // The icon is the rightmost clickable widget on the name's line. - let name = texts.iter().find(|(t, _)| t == "Artboard 2").map(|(_, r)| *r).unwrap(); - let icon = ctx.viewport(|vp| { - vp.prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(name.center().y)) - .map(|w| w.rect) - .max_by(|a, b| a.left().total_cmp(&b.left())) - .unwrap() - .center() - }); - let commands = app.run_count; - let press = |pressed| Event::PointerButton { pos: icon, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![Event::PointerMoved(icon), press(true)]); - frame(&mut app, &ctx, vec![press(false)]); - assert_eq!(app.run_count, commands.wrapping_add(1), "one command"); - let d = app.ui.dialog.take().expect("Artboard Options"); - assert_eq!((d.kind.as_str(), d.f64("index", 9.0), d.str("name").as_str()), (crate::dialogs::artboard_options::KIND, 1.0, "Artboard 2")); - assert_eq!(d.fields, want, "the fields the tool's double-click shows"); - assert_eq!(app.session.tool_id(), "artboard"); - } - - /// Edit Artboards' Fill (Properties › Artboards › Fill): the list sets the active artboard's - /// background color to Black and back to None, one command and one undo step each, and shows the - /// choice. Color Picker… in the list and a click on the chip open the Color Picker for that - /// artboard. - #[test] - fn edit_artboards_fill_sets_the_background_color() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); - app.select_tool("artboard"); - super::super::artboards::select(&mut app, 1); - let colors = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards.iter().map(|a| a.color).collect::>(); - let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); - let ctx = egui::Context::default(); - // Open the list showing `shown` and choose `option` → the number of commands run. - let choose = |app: &mut VectorcraftApp, shown: &str, option: &str| { - frame(app, &ctx, vec![]); - let texts = frame(app, &ctx, vec![]); - click(app, &ctx, &texts, shown); - let texts = frame(app, &ctx, vec![]); - let commands = app.run_count; - click(app, &ctx, &texts, option); - app.run_count.wrapping_sub(commands) - }; - let before = undo(&app); - assert_eq!(choose(&mut app, "None", "Black"), 1, "one command"); - assert_eq!(colors(&app), [None, Some(Color::BLACK)]); - assert_eq!(undo(&app), before + 1, "one undo step"); - assert_eq!(choose(&mut app, "Black", "None"), 1); - assert_eq!(colors(&app), [None, None]); - choose(&mut app, "None", "Color Picker…"); - let d = app.ui.dialog.take().expect("the Color Picker"); - assert_eq!((d.kind.as_str(), d.fields.get("artboard")), ("colorPicker", Some(&json!(1)))); - // The chip is the clickable widget left of the list on the Fill: row. - let texts = frame(&mut app, &ctx, vec![]); - let row = texts.iter().find(|(t, _)| t == "Fill:").map(|(_, r)| *r).unwrap(); - let chip = ctx.viewport(|vp| { - vp.prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) - .map(|w| w.rect) - .min_by(|a, b| a.left().total_cmp(&b.left())) - .unwrap() - .center() - }); - let press = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![Event::PointerMoved(chip), press(true)]); - frame(&mut app, &ctx, vec![press(false)]); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields.get("artboard")), Some(Some(&json!(1))), "the chip opens it too"); - } - - fn artboard_deletion_app(count: usize) -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": count})).unwrap(); - for i in 0..count { - app.run("shape.rectangle", json!({"x": i * 250 + 10, "y": 10, "width": 30, "height": 20})).unwrap(); - } - app.select_tool("artboard"); - super::super::artboards::select(&mut app, 0); - app - } - - fn artboard_deletion_click( - app: &mut VectorcraftApp, - ctx: &egui::Context, - draw: fn(&mut VectorcraftApp, &mut Ui), - label: Option<&str>, - modifiers: egui::Modifiers, - ) { - let at = if let Some(label) = label { - panel_frame(app, ctx, vec![], draw); - let texts = panel_frame(app, ctx, vec![], draw); - texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) - } else { - // The trash is the last 24-point footer button. Use the drawn panel bounds, as the Asset Export bin test uses its footer. - let mut bounds = Rect::NOTHING; - for _ in 0..2 { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - draw(app, ui); - bounds = ui.min_rect(); - }); - out.textures_delta.clear(); - } - assert!(bounds.width() >= 24.0 && bounds.height() >= 24.0); - bounds.right_bottom() - vec2(12.0, 12.0) - }; - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; - panel_frame(app, ctx, vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(at), button(true)], draw); - panel_frame(app, ctx, vec![button(false)], draw); - panel_frame(app, ctx, vec![Event::ModifiersChanged(egui::Modifiers::NONE)], draw); - } - - fn select_artboard_rows(app: &mut VectorcraftApp, ctx: &egui::Context, indices: &[usize]) { - for (at, i) in indices.iter().enumerate() { - let modifiers = if at == 0 { egui::Modifiers::NONE } else { egui::Modifiers::COMMAND }; - artboard_deletion_click(app, ctx, super::super::artboards::show, Some(&format!("Artboard {}", i + 1)), modifiers); - } - let count = app.session.doc().unwrap().doc.artboards.len(); - assert_eq!(super::super::artboards::picked(app, count), indices, "the rows are selected before deletion"); - } - - fn assert_artboard_deletion(draw: fn(&mut VectorcraftApp, &mut Ui), label: Option<&str>) { - for indices in [vec![0, 2], vec![2], vec![3], vec![2, 3], vec![0, 1, 2, 3]] { - let mut app = artboard_deletion_app(4); - let ctx = egui::Context::default(); - select_artboard_rows(&mut app, &ctx, &indices); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.clone(); - let undo = app.session.doc().unwrap().history.undo.len(); - let all_selected = indices == [0, 1, 2, 3]; - let expected: Vec<_> = if all_selected { - assert_eq!(app.view().unwrap().artboard, 3, "the active last board must not replace the first-board survivor oracle"); - vec![&before.artboards[0]] - } else { - before.artboards.iter().enumerate().filter(|(i, _)| !indices.contains(i)).map(|(_, a)| a).collect() - }; - let expected = serde_json::to_value(expected).unwrap(); - let artwork = serde_json::to_value(&before.layers).unwrap(); - - let commands = app.run_count; - artboard_deletion_click(&mut app, &ctx, draw, label, egui::Modifiers::NONE); - let st = app.session.doc().unwrap(); - assert_eq!( - serde_json::to_value(&st.doc.artboards).unwrap(), - expected, - "selected deletion keeps the expected IDs; all-selected keeps original board 1" - ); - assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual deletion control"); - assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork, "artwork IDs, geometry and appearance stay intact"); - assert_eq!(st.history.undo.len(), undo + 1, "one deletion is one undo step"); - assert!(st.is_dirty()); - let active = app.view().unwrap().artboard; - if all_selected { - assert_eq!(active, 0); - } - assert!(active < st.doc.artboards.len(), "the active artboard remains in range"); - assert_eq!(app.session.tool_options()["active"], json!(active)); - assert_eq!(super::super::artboards::picked(&app, st.doc.artboards.len()), [active]); - assert_eq!(app.session.tool_options()["selected"], json!([active]), "raw tool selection contains only the remaining active board"); - - app.run("edit.undo", json!({})).unwrap(); - let st = app.session.doc().unwrap(); - assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), serde_json::to_value(&before.artboards).unwrap()); - assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork); - assert_eq!(st.history.undo.len(), undo); - assert!(!st.is_dirty(), "one undo returns to the saved document"); - app.run("edit.redo", json!({})).unwrap(); - let st = app.session.doc().unwrap(); - assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), expected, "redo removes the same artboard IDs"); - assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork); - assert_eq!(st.history.undo.len(), undo + 1); - assert!(st.is_dirty()); - } - } - - fn assert_artboard_deletion_guard(draw: fn(&mut VectorcraftApp, &mut Ui), label: Option<&str>) { - let mut app = artboard_deletion_app(1); - // Leave a redo entry to ensure a disabled action preserves both history branches. - app.run("artboard.setProps", json!({"index": 0, "name": "Temporary"})).unwrap(); - app.run("edit.undo", json!({})).unwrap(); - let ctx = egui::Context::default(); - select_artboard_rows(&mut app, &ctx, &[0]); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.clone(); - let st = app.session.doc().unwrap(); - let history = (st.history.undo.len(), st.history.redo.len()); - assert!(history.1 > 0); - let active = app.view().unwrap().artboard; - let options = app.session.tool_options(); - - let commands = app.run_count; - artboard_deletion_click(&mut app, &ctx, draw, label, egui::Modifiers::NONE); - assert_eq!(app.run_count, commands, "the disabled last-board control dispatches no command"); - let st = app.session.doc().unwrap(); - assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), serde_json::to_value(&before.artboards).unwrap()); - assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), serde_json::to_value(&before.layers).unwrap()); - assert_eq!((st.history.undo.len(), st.history.redo.len()), history); - assert!(!st.is_dirty(), "deleting the last artboard is a no-op"); - assert_eq!(app.view().unwrap().artboard, active); - assert_eq!(app.session.tool_options(), options); - } - - #[test] - fn artboard_menu_deletes_selected_artboards_in_one_undo_step() { - assert_artboard_deletion(super::super::artboards::menu, Some("Delete Artboards")); - } - - #[test] - fn properties_deletes_selected_artboards_in_one_undo_step() { - assert_artboard_deletion(show, Some("Delete Artboard")); - } - - #[test] - fn artboard_menu_preserves_last_artboard() { - assert_artboard_deletion_guard(super::super::artboards::menu, Some("Delete Artboards")); - } - - #[test] - fn properties_preserves_last_artboard() { - assert_artboard_deletion_guard(show, Some("Delete Artboard")); - } - - #[test] - fn artboard_trash_deletes_selected_artboards_in_one_undo_step() { - assert_artboard_deletion(super::super::artboards::show, None); - } - - #[test] - fn artboard_trash_preserves_last_artboard() { - assert_artboard_deletion_guard(super::super::artboards::show, None); - } - - /// Click the actual panel footer's New button (24 points, with a 4-point gap before Trash). - fn artboard_history_new(app: &mut VectorcraftApp, ctx: &egui::Context) { - let mut bounds = Rect::NOTHING; - for _ in 0..2 { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - super::super::artboards::show(app, ui); - bounds = ui.min_rect(); - }); - out.textures_delta.clear(); - } - let count = app.session.doc().unwrap().doc.artboards.len(); - let commands = app.run_count; - let at = bounds.right_bottom() - vec2(40.0, 12.0); - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; - panel_frame(app, ctx, vec![Event::PointerMoved(at), button(true)], super::super::artboards::show); - panel_frame(app, ctx, vec![button(false)], super::super::artboards::show); - assert_eq!(app.run_count, commands.wrapping_add(1), "the actual New button dispatches once"); - assert_eq!(app.session.doc().unwrap().doc.artboards.len(), count + 1); - assert_eq!(app.session.tool_options()["active"], json!(count)); - assert_eq!(app.view().unwrap().artboard, count); - } - - /// Keep the panel and its natural focus in the same context throughout Undo and deletion. - fn artboard_history_key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { - for pressed in [true, false] { - let events = vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }]; - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - crate::shortcuts::handle(app, ui.ctx()); - super::super::artboards::show(app, ui); - }); - out.textures_delta.clear(); - } - } - - fn artboard_history_delete_after_undo(key: Option, duplicate: bool) { - let mut app = artboard_deletion_app(2); - app.session.set_tool_option("moveArt", &json!(false)); - app.session.set_tool_option("scaleArt", &json!(true)); - app.session.doc_mut().unwrap().mark_saved(); - let st = app.session.doc().unwrap(); - let before = st.doc.clone(); - let selection = st.selection.clone(); - assert!(!selection.is_empty(), "selected artwork catches a Clear fallthrough"); - let undo = st.history.undo.len(); - let ctx = egui::Context::default(); - if duplicate { - let commands = app.run_count; - artboard_deletion_click(&mut app, &ctx, super::super::artboards::menu, Some("Duplicate Artboards"), egui::Modifiers::NONE); - assert_eq!(app.run_count, commands.wrapping_add(1)); - assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3); - assert_eq!(app.session.tool_options()["active"], json!(2)); - assert_eq!(app.view().unwrap().artboard, 2); - } else { - artboard_history_new(&mut app, &ctx); - } - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc, before, "the real Undo shortcut restores the original document"); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); - assert!(!st.is_dirty()); - // Capture the state before another panel or pointer can repair it. Still exercise deletion - // on RED: keyboard deletion used to do nothing; Properties already clamped its own target. - let after_undo = (app.view().unwrap().artboard, app.session.tool_options()); - if let Some(key) = key { - artboard_history_key(&mut app, &ctx, key, egui::Modifiers::NONE); - } else { - let texts = panel_frame(&mut app, &ctx, vec![], show); - assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name)); - artboard_deletion_click(&mut app, &ctx, show, Some("Delete Artboard"), egui::Modifiers::NONE); - } - let st = app.session.doc().unwrap(); - assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "deletion after Undo removes the shown second board"); - assert_eq!(st.doc.layers, before.layers, "ordinary artwork survives without geometry or style changes"); - assert_eq!(st.selection, selection, "deletion never falls through to Clear"); - assert_eq!(after_undo.0, 1, "Undo normalizes the navigator before another gesture"); - assert_eq!(after_undo.1, json!({"active": 1, "selected": [1], "moveArt": false, "scaleArt": true})); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); - assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); - assert!(st.is_dirty()); - assert_eq!(app.view().unwrap().artboard, 0); - assert_eq!(app.session.tool_options(), json!({"active": 0, "selected": [0], "moveArt": false, "scaleArt": true})); - let deleted = st.doc.clone(); - - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc, before); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); - assert!(!st.is_dirty()); - // Valid current selection stays put; history does not restore an earlier artboard selection. - assert_eq!(app.session.tool_options()["selected"], json!([0])); - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc, deleted); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); - assert!(st.is_dirty()); - } - - #[test] - fn artboard_history_new_undo_delete_uses_remaining_board() { - artboard_history_delete_after_undo(Some(egui::Key::Delete), false); - } - - #[test] - fn artboard_history_new_undo_backspace_uses_remaining_board() { - artboard_history_delete_after_undo(Some(egui::Key::Backspace), false); - } - - #[test] - fn artboard_history_new_undo_properties_agrees_with_tool() { - artboard_history_delete_after_undo(None, false); - } - - #[test] - fn artboard_history_duplicate_undo_delete_uses_remaining_board() { - artboard_history_delete_after_undo(Some(egui::Key::Delete), true); - } - - #[test] - fn artboard_history_undo_prunes_only_removed_indices() { - for order in [[0, 1, 2], [0, 2, 1]] { - let mut app = artboard_deletion_app(2); - app.session.set_tool_option("moveArt", &json!(false)); - app.session.set_tool_option("scaleArt", &json!(true)); - app.session.doc_mut().unwrap().mark_saved(); - let st = app.session.doc().unwrap(); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let undo = st.history.undo.len(); - let ctx = egui::Context::default(); - artboard_history_new(&mut app, &ctx); - let created = app.session.doc().unwrap().doc.clone(); - for (at, i) in order.into_iter().enumerate() { - let modifiers = if at == 0 { egui::Modifiers::NONE } else { egui::Modifiers::COMMAND }; - artboard_deletion_click(&mut app, &ctx, super::super::artboards::show, Some(&format!("Artboard {}", i + 1)), modifiers); - } - let options = app.session.tool_options(); - let expected = if order[2] == 2 { json!([0, 1]) } else { json!([1, 0]) }; - assert_eq!(options["selected"], if order[2] == 2 { json!([2, 0, 1]) } else { json!([1, 0, 2]) }); - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); - let mut normalized = options; - normalized["active"] = expected[0].clone(); - normalized["selected"] = expected; - assert_eq!(app.session.tool_options(), normalized, "only the removed index is pruned, preserving surviving order and options"); - assert_eq!(json!(app.view().unwrap().artboard), normalized["active"]); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc, before); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); - assert!(!st.is_dirty()); - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); - assert_eq!(app.session.doc().unwrap().doc, created); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(app.session.tool_options(), normalized, "Redo does not replace an already valid multiselection"); - } - } - - #[test] - fn artboard_history_valid_multiselection_survives_undo_redo() { - let mut app = artboard_deletion_app(3); - app.session.set_tool_option("moveArt", &json!(false)); - app.session.set_tool_option("scaleArt", &json!(true)); - let ctx = egui::Context::default(); - select_artboard_rows(&mut app, &ctx, &[0, 1]); - let options = app.session.tool_options(); - assert_eq!(options["selected"], json!([1, 0])); - let view = app.view().unwrap().artboard; - app.session.doc_mut().unwrap().mark_saved(); - let st = app.session.doc().unwrap(); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let undo = st.history.undo.len(); - app.run("object.move", json!({"dx": 7, "dy": 9})).unwrap(); - let moved = app.session.doc().unwrap().doc.clone(); - assert_ne!(moved, before); - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); - app.sync_views(); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc, before); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); - assert!(!st.is_dirty()); - assert_eq!(app.session.tool_options(), options); - assert_eq!(app.view().unwrap().artboard, view); - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); - app.sync_views(); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc, moved); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); - assert!(st.is_dirty()); - assert_eq!(app.session.tool_options(), options); - assert_eq!(app.view().unwrap().artboard, view); - } - - /// #681: Rearrange All opens Rearrange All Artboards in one click, from the Document section - /// (only with several artboards) and from Edit Artboards' Quick Actions. - #[test] - fn rearrange_all_opens_the_dialog() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - assert!(!frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Rearrange All"), "one artboard: nothing to rearrange"); - app.run("artboard.new", json!({})).unwrap(); - let texts = frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, &texts, "Rearrange All"); - let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); - assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::rearrange_artboards::KIND)); - app.ui.dialog = None; - app.select_tool("artboard"); - let texts = frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, &texts, "Rearrange All"); - assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::rearrange_artboards::KIND)); - } - - /// #671: Edit Artboards shows the artboard's preset (Custom when it matches none) and its - /// orientation; the other orientation swaps its width and height, its corner staying put. - #[test] - fn edit_artboards_shows_the_preset_and_flips_the_orientation() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"preset": "Letter"})).unwrap(); - app.run("artboard.setProps", json!({"index": 0, "x": 10, "y": 20})).unwrap(); - app.select_tool("artboard"); - let ctx = egui::Context::default(); - let texts = frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(t, _)| t == "Preset:") && texts.iter().any(|(t, _)| t == "Letter"), "{texts:?}"); - // The row's clickable widgets, left to right: the preset combo, Portrait, Landscape. - let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap(); - let buttons: Vec = ctx.viewport(|vp| { - let mut r: Vec = vp - .prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) - .map(|w| w.rect) - .collect(); - r.sort_by(|a, b| a.left().total_cmp(&b.left())); - r - }); - let landscape = buttons.last().unwrap().center(); - let press = |pressed| Event::PointerButton { pos: landscape, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![Event::PointerMoved(landscape), press(true)]); - frame(&mut app, &ctx, vec![press(false)]); - let rect = app.session.active().unwrap().doc.artboards[0].rect; - assert_eq!((rect.x0, rect.y0, rect.width(), rect.height()), (10.0, 20.0, 792.0, 612.0), "landscape, same corner"); - let texts = frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(t, _)| t == "Letter"), "still Letter, turned: {texts:?}"); - app.run("artboard.setProps", json!({"index": 0, "width": 333})).unwrap(); - assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Custom")); - } - - /// The center of Edit Artboards' Portrait (`landscape` false) or Landscape button. - fn orientation_button(ctx: &egui::Context, texts: &[(String, Rect)], landscape: bool) -> Pos2 { - let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap(); - ctx.viewport(|vp| { - let mut r: Vec = vp - .prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) - .map(|w| w.rect) - .collect(); - r.sort_by(|a, b| a.left().total_cmp(&b.left())); - r[r.len() - if landscape { 1 } else { 2 }].center() - }) - } - - /// #1482: with Artboards 1 and 3 selected, Portrait swaps the width and height of both in one - /// undo step, each keeping its top-left corner; Artboard 2 stays landscape. An artboard already - /// in that orientation keeps its size. - #[test] - fn edit_artboards_orientation_turns_every_selected_artboard() { - let mut app = two_selected_artboards(); - let before = boards(&app); - let undo = app.session.doc().unwrap().history.undo.len(); - let ctx = egui::Context::default(); - let portrait = |app: &mut VectorcraftApp| { - frame(app, &ctx, vec![]); - let texts = frame(app, &ctx, vec![]); - let at = orientation_button(&ctx, &texts, false); - let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, &ctx, vec![Event::PointerMoved(at), press(true)]); - frame(app, &ctx, vec![press(false)]); - }; - portrait(&mut app); - let mut want = before.clone(); - for i in [0, 2] { - (want[i].2, want[i].3) = (before[i].3, before[i].2); - } - assert_eq!(boards(&app), want); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(boards(&app), before); - // Artboard 3 portrait already: Portrait turns Artboard 1 alone. - app.run("artboard.setProps", json!({"index": 2, "width": 150, "height": 300})).unwrap(); - let before = boards(&app); - portrait(&mut app); - let mut want = before.clone(); - (want[0].2, want[0].3) = (100.0, 200.0); - assert_eq!(boards(&app), want); - } - - /// #1482: with Artboards 1 and 3 selected, a preset chosen in Edit Artboards sizes both in one - /// undo step, each in its own orientation and keeping its top-left corner: Letter (612 × 792 pt) - /// stays portrait for Artboard 1 and turns landscape for Artboard 3. Artboard 2 keeps its size. - #[test] - fn edit_artboards_preset_sizes_every_selected_artboard() { - let mut app = two_selected_artboards(); - app.run("artboard.setProps", json!({"index": 0, "width": 100, "height": 200})).unwrap(); - let before = boards(&app); - let undo = app.session.doc().unwrap().history.undo.len(); - let ctx = egui::Context::default(); - // A frame on a wide screen, with the texts of nested shapes too (the open list and its - // submenu), and the center of `label` among them (menu items carry a check-mark column). - let run = |app: &mut VectorcraftApp, events: Vec| { - fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) { - match s { - egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), - egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), - _ => {} - } - } - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 700.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - let mut v = vec![]; - out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); - v - }; - let at = |texts: &[(String, Rect)], label: &str| { - texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) - }; - let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - run(&mut app, vec![]); - let custom = at(&run(&mut app, vec![]), "Custom"); - run(&mut app, vec![Event::PointerMoved(custom), press(custom, true)]); - run(&mut app, vec![press(custom, false)]); - // Hovering Print opens its submenu. - let print = at(&run(&mut app, vec![]), "Print"); - run(&mut app, vec![Event::PointerMoved(print)]); - let letter = at(&run(&mut app, vec![]), "Letter"); - run(&mut app, vec![Event::PointerMoved(letter), press(letter, true)]); - run(&mut app, vec![press(letter, false)]); - let mut want = before.clone(); - (want[0].2, want[0].3) = (612.0, 792.0); - (want[2].2, want[2].3) = (792.0, 612.0); - assert_eq!(boards(&app), want); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - } - - /// #602: Edit Artboards shows Move and Scale Artwork with Artboard; with Scale on, a new size - /// (here the other orientation) takes the art on the artboard along. - #[test] - fn edit_artboards_scales_the_art_with_scale_artwork_with_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let r = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("artboard"); - let ctx = egui::Context::default(); - let texts = frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(t, _)| t == "Move Artwork with Artboard"), "{texts:?}"); - assert_eq!(app.session.tool_options()["scaleArt"], false); - click(&mut app, &ctx, &texts, "Scale Artwork with Artboard"); - assert_eq!(app.session.tool_options()["scaleArt"], true); - // Portrait: 100 × 200, the art scaled by ½ across and 2 down. - let texts = frame(&mut app, &ctx, vec![]); - let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap(); - let portrait = ctx.viewport(|vp| { - let mut r: Vec = vp - .prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) - .map(|w| w.rect) - .collect(); - r.sort_by(|a, b| a.left().total_cmp(&b.left())); - r[r.len() - 2].center() - }); - let press = |pressed| Event::PointerButton { pos: portrait, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![Event::PointerMoved(portrait), press(true)]); - frame(&mut app, &ctx, vec![press(false)]); - let d = &app.session.active().unwrap().doc; - let ab = d.artboards[0].rect; - assert_eq!((ab.width(), ab.height()), (100.0, 200.0)); - let b = d.node(vectorcraft_doc::NodeId(r)).unwrap().geometric_bounds().unwrap(); - assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 40.0, 20.0, 80.0)); - } - - /// One frame of the panel 1600 points tall, so Quick Actions at its foot are laid out, with - /// `events` → the texts painted, with their rects. - fn tall(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), - _ => None, - }) - .collect() - } - - /// Clicks `at` in the tall panel over two frames (press, release), then lets a popup it opens - /// lay out → the texts of the frame after. - fn press(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) -> Vec<(String, Rect)> { - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - tall(app, ctx, vec![Event::PointerMoved(at), b(true)]); - tall(app, ctx, vec![b(false)]); - tall(app, ctx, vec![]); - tall(app, ctx, vec![]) - } - - /// Where the tall panel painted `label`. - fn text_at(texts: &[(String, Rect)], label: &str) -> Rect { - texts.iter().find(|(t, _)| t == label).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) - } - - fn shows(texts: &[(String, Rect)], label: &str) -> bool { - texts.iter().any(|(t, _)| t == label) - } - - fn undo_depth(app: &VectorcraftApp) -> usize { - app.session.doc().unwrap().history.undo.len() - } - - fn new_app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app - } - - fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> serde_json::Value { - app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].clone() - } - - /// One blend selected with the Blend tool has Blend Options in Quick Actions, which opens the - /// Blend Options dialog on it; OK is one undo step. Not with another tool, nor for a blend - /// selected with another object. - #[test] - fn blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool() { - let mut app = new_app(); - let (a, b) = (rect(&mut app, 20.0, 20.0, 40.0, 40.0), rect(&mut app, 200.0, 20.0, 40.0, 40.0)); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - let blend = app.run("object.blend.make", json!({})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [blend]})).unwrap(); - let ctx = egui::Context::default(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "not with the Selection tool"); - app.select_tool("blend"); - app.run("select.set", json!({"ids": [blend]})).unwrap(); - let texts = tall(&mut app, &ctx, vec![]); - let depth = undo_depth(&app); - press(&mut app, &ctx, text_at(&texts, "Blend Options").center()); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::blend_options::KIND)); - assert_eq!(undo_depth(&app), depth, "opening the dialog changes nothing"); - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("spacing".into(), json!("steps")); - d.fields.insert("steps".into(), json!(3)); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(undo_depth(&app), depth + 1, "OK is one undo step"); - let c = rect(&mut app, 20.0, 200.0, 40.0, 40.0); - app.select_tool("blend"); - app.run("select.set", json!({"ids": [blend, c]})).unwrap(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a blend and another object"); - app.run("select.set", json!({"ids": [c]})).unwrap(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a rectangle"); - } - - /// A Live Paint group selected with a path has Merge Live Paint in Quick Actions, which adds - /// the path to the group in one undo step. Not for the group alone, nor for plain paths, nor - /// with type selected, which Live Paint refuses. - #[test] - fn merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more() { - let mut app = new_app(); - let (a, b) = (rect(&mut app, 20.0, 20.0, 100.0, 60.0), rect(&mut app, 60.0, 40.0, 100.0, 60.0)); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - let group = app.run("livePaint.make", json!({})).unwrap()["id"].clone(); - let (c, d) = (rect(&mut app, 140.0, 30.0, 60.0, 60.0), rect(&mut app, 300.0, 200.0, 40.0, 40.0)); - let text = app.run("text.create", json!({"x": 50, "y": 250, "text": "Hi"})).unwrap()["id"].clone(); - let ctx = egui::Context::default(); - for (ids, why) in [ - (json!([group]), "the group alone"), - (json!([c, d]), "two plain paths"), - (json!([group, text]), "the group and type"), - (json!([group, c, text]), "the group, a path and type"), - ] { - app.run("select.set", json!({"ids": ids})).unwrap(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), "Merge Live Paint"), "{why}"); - } - app.run("select.set", json!({"ids": [group, c]})).unwrap(); - let c_id = vectorcraft_doc::NodeId(c.as_u64().unwrap()); - let parent = app.session.active().unwrap().doc.parent_of(c_id); - let texts = tall(&mut app, &ctx, vec![]); - let depth = undo_depth(&app); - press(&mut app, &ctx, text_at(&texts, "Merge Live Paint").center()); - assert_eq!(undo_depth(&app), depth + 1, "one undo step"); - let st = app.session.active().unwrap(); - assert_eq!(st.selection.objects.iter().map(|n| json!(n.0)).collect::>(), std::slice::from_ref(&group)); - assert!(st.doc.node(c_id).is_none() || st.doc.parent_of(c_id) != parent, "the path is merged into the group"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc.parent_of(c_id), parent, "Undo takes it out again"); - } - - /// A stroke with a variable width shows its Width Profile in the Appearance section, as every - /// stroke does with the Width tool; choosing another profile is one undo step. A uniform stroke - /// shows none with other tools. - #[test] - fn the_width_profile_shows_for_a_variable_width_or_the_width_tool() { - let mut app = new_app(); - let line = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 300, "y2": 100})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [line]})).unwrap(); - let ctx = egui::Context::default(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), "Profile:"), "a uniform stroke"); - app.select_tool("width"); - app.run("select.set", json!({"ids": [line]})).unwrap(); - assert!(shows(&tall(&mut app, &ctx, vec![]), "Profile:"), "the Width tool"); - app.select_tool("selection"); - app.run("select.set", json!({"ids": [line]})).unwrap(); - app.run("stroke.set", json!({"profile": "lens"})).unwrap(); - let texts = tall(&mut app, &ctx, vec![]); - // The list sits right of its label. - let spacing = ctx.global_style().spacing.item_spacing.x; - let texts = press(&mut app, &ctx, text_at(&texts, "Profile:").right_center() + vec2(spacing + 50.0, 0.0)); - let depth = undo_depth(&app); - press(&mut app, &ctx, text_at(&texts, "Taper Start").center()); - assert_eq!(undo_depth(&app), depth + 1, "one undo step"); - let profile = app - .session - .active() - .unwrap() - .doc - .node(vectorcraft_doc::NodeId(line.as_u64().unwrap())) - .unwrap() - .appearance - .stroke() - .unwrap() - .profile - .clone(); - assert_eq!(WidthProfile::id_of(profile.as_ref()), "taperStart"); - } - - /// With Direct Selection, a path whose corner is selected shows that corner's kind and radius; - /// each edit sets that corner alone in one undo step. Not with the Selection tool, nor with the - /// whole path selected; a live rectangle keeps its own Corner Radius row instead. - #[test] - fn direct_selected_corners_show_their_kind_and_radius() { - let mut app = new_app(); - let star = app.run("shape.star", json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - app.run("select.anchors", json!({"id": star, "anchors": [[0, 2]], "mode": "set"})).unwrap(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), "Corner:"), "not with the Selection tool"); - app.select_tool("directSelection"); - app.run("select.anchors", json!({"id": star, "anchors": [[0, 2]], "mode": "set"})).unwrap(); - let texts = tall(&mut app, &ctx, vec![]); - assert!(shows(&texts, "Corner:") && shows(&texts, "Corner Radius:"), "{texts:?}"); - let corners = |app: &VectorcraftApp| { - let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(star)).unwrap().clone(); - let lc = LiveCorners::of(&n).unwrap(); - (lc.radius(0), lc.radius(2), lc.kind(0), lc.kind(2)) - }; - // The radius: the field right of its label. - let label = text_at(&texts, "Corner Radius:"); - let field = texts.iter().find(|(t, r)| t == "0 pt" && r.left() > label.right() && (r.center().y - label.center().y).abs() < 6.0).unwrap().1; - press(&mut app, &ctx, field.center()); - let depth = undo_depth(&app); - let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - tall(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); - tall(&mut app, &ctx, vec![Event::Text("6".into())]); - tall(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); - let texts = tall(&mut app, &ctx, vec![]); - assert_eq!(undo_depth(&app), depth + 1, "one undo step"); - let round = vectorcraft_geom::shapes::CornerKind::Round; - assert_eq!(corners(&app), (0.0, 6.0, round, round)); - // The kind, from its list. - let texts = press(&mut app, &ctx, text_at(&texts, "Round").center()); - press(&mut app, &ctx, text_at(&texts, "Chamfer").center()); - assert_eq!(undo_depth(&app), depth + 2, "one undo step"); - assert_eq!(corners(&app), (0.0, 6.0, round, vectorcraft_geom::shapes::CornerKind::Chamfer)); - // The whole path selected. - app.run("select.set", json!({"ids": [star]})).unwrap(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), "Corner:"), "no corner selected"); - // A live rectangle's corner: its own Corner Radius row, once. - let r = rect(&mut app, 20.0, 20.0, 60.0, 40.0).as_u64().unwrap(); - app.run("select.anchors", json!({"id": r, "anchors": [[0, 1]], "mode": "set"})).unwrap(); - let texts = tall(&mut app, &ctx, vec![]); - assert!(!shows(&texts, "Corner:"), "{texts:?}"); - assert_eq!(texts.iter().filter(|(t, _)| t == "Corner Radius:").count(), 1, "{texts:?}"); - } - - /// One effect on the one selected object shows its name and a Delete icon beside the fx button, - /// which removes it in one undo step, the object's own or its stroke's. With several effects the - /// icon opens the Appearance panel; without one, or with several objects selected, no name. - #[test] - fn one_effect_has_a_delete_icon_in_appearance() { - let mut app = new_app(); - let id = rect(&mut app, 20.0, 20.0, 80.0, 60.0); - let node = vectorcraft_doc::NodeId(id.as_u64().unwrap()); - app.run("select.set", json!({"ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - let spacing = ctx.global_style().spacing.item_spacing.x; - let effects = |app: &VectorcraftApp| { - let n = app.session.active().unwrap().doc.node(node).unwrap().clone(); - n.appearance.effects.len() + n.appearance.items.iter().map(|i| i.effects().len()).sum::() - }; - let name = super::super::appearance::shown_effect_label("stylize.dropShadow"); - assert!(!shows(&tall(&mut app, &ctx, vec![]), &name), "no effect"); - // The object's own effect. - app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); - let texts = tall(&mut app, &ctx, vec![]); - let depth = undo_depth(&app); - press(&mut app, &ctx, text_at(&texts, &name).right_center() + vec2(spacing + 11.0, 0.0)); - assert_eq!((effects(&app), undo_depth(&app)), (0, depth + 1), "removed in one undo step"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(effects(&app), 1); - // The stroke's effect. - app.run("edit.undo", json!({})).unwrap(); - let stroke = app.session.active().unwrap().doc.node(node).unwrap().appearance.items.iter().position(|i| !i.is_fill()).unwrap(); - app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})).unwrap(); - let texts = tall(&mut app, &ctx, vec![]); - let depth = undo_depth(&app); - press(&mut app, &ctx, text_at(&texts, &name).right_center() + vec2(spacing + 11.0, 0.0)); - assert_eq!((effects(&app), undo_depth(&app)), (0, depth + 1), "the stroke's effect removed in one undo step"); - // Two effects: the icon opens the Appearance panel. - app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); - app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})).unwrap(); - let texts = tall(&mut app, &ctx, vec![]); - assert!(!shows(&texts, &name), "{texts:?}"); - let depth = undo_depth(&app); - press(&mut app, &ctx, text_at(&texts, "fx").center() + vec2(17.0 + spacing + 11.0, 0.0)); - assert_eq!(app.ui.open_panel.as_deref(), Some("appearance")); - assert_eq!((effects(&app), undo_depth(&app)), (2, depth)); - // Two objects with one effect each: no name. - app.run("edit.undo", json!({})).unwrap(); - let other = rect(&mut app, 200.0, 20.0, 40.0, 40.0); - app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); - app.run("select.set", json!({"ids": [id, other]})).unwrap(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), &name), "two objects"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs new file mode 100644 index 000000000..81286123f --- /dev/null +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -0,0 +1,1445 @@ +use egui::{Event, PointerButton, Pos2, Rect, vec2}; +use vectorcraft_engine::Session; + +use super::*; + +/// One frame of the panel with `events` → the texts painted, with their rects. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + panel_frame(app, ctx, events, show) +} + +/// One frame of `draw` with `events` → the texts painted, with their rects. +fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<(String, Rect)> { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| draw(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| { + if let egui::Shape::Text(t) = &s.shape { + Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))) + } else { + None + } + }) + .collect() +} + +/// Clicks the text `label` where the last frame painted it, over two frames (press, release). +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)], label: &str) { + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at), b(true)]); + frame(app, ctx, vec![b(false)]); +} + +/// One selected clip group has Edit Clipping Path and Edit Contents at the top of the panel, +/// under its Clip Group heading and before every section, and they select its clipping path or +/// its clipped art; one that clips nothing has Edit Clipping Path alone. A plain group, or a clip +/// group selected with another object, has neither. +#[test] +fn one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let art = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 140})).unwrap()["id"].clone(); + let mask = app.run("shape.ellipse", json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [art, mask]})).unwrap(); + let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let ctx = egui::Context::default(); + let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.iter().map(|n| json!(n.0)).collect::>(); + let labels = |texts: &[(String, Rect)]| texts.iter().map(|(t, _)| t.clone()).collect::>(); + let texts = frame(&mut app, &ctx, vec![]); + let shown = labels(&texts); + let at = |label: &str| shown.iter().position(|t| t == label).unwrap_or_else(|| panic!("{label} in {shown:?}")); + assert_eq!(at("Clip Group"), 0, "{shown:?}"); + assert!(at("Edit Clipping Path") == 1 && at("Edit Contents") == 2, "{shown:?}"); + click(&mut app, &ctx, &texts, "Edit Contents"); + assert_eq!(selected(&app), std::slice::from_ref(&art), "Edit Contents selects the clipped art"); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Edit Clipping Path"); + assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path"); + let neither = |app: &mut VectorcraftApp| { + let shown = labels(&frame(app, &ctx, vec![])); + !shown.iter().any(|t| t == "Edit Clipping Path" || t == "Edit Contents") + }; + // The clip group with another object, whichever is selected first. + let other = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [group, other]})).unwrap(); + assert!(neither(&mut app), "a clip group and another object"); + app.run("select.set", json!({"ids": [other, group]})).unwrap(); + assert!(neither(&mut app), "another object and a clip group"); + // A plain group. + let b = app.run("shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [other, b]})).unwrap(); + let plain = app.run("object.group", json!({})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [plain]})).unwrap(); + assert!(labels(&frame(&mut app, &ctx, vec![])).iter().any(|t| t == "Ungroup")); + assert!(neither(&mut app), "a plain group"); + // A clip group whose clipped art is deleted: Edit Clipping Path alone. + app.run("select.set", json!({"ids": [art]})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let texts = frame(&mut app, &ctx, vec![]); + let shown = labels(&texts); + assert_eq!(shown.iter().position(|t| t == "Edit Clipping Path"), Some(1), "{shown:?}"); + assert!(!shown.iter().any(|t| t == "Edit Contents"), "nothing to edit: {shown:?}"); + click(&mut app, &ctx, &texts, "Edit Clipping Path"); + assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path"); +} + +/// Start Global Edit in Quick Actions starts it on the one selected path, the button and the +/// Select menu item then read Stop Global Edit, and Escape (the app's key handling) stops it. +/// Selected type has no Start Global Edit button. +#[test] +fn start_global_edit_quick_action_and_escape() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("shape.rectangle", json!({"x": 200, "y": 20, "width": 80, "height": 80})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let ctx = egui::Context::default(); + // Tall enough for Quick Actions at the foot of the panel. + let tall = |app: &mut VectorcraftApp, events: Vec| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + _ => None, + }) + .collect::>() + }; + let on = |app: &VectorcraftApp| app.session.active().unwrap().global_edit.is_some(); + let label = |app: &VectorcraftApp| crate::menus::dynamic_label(app, "select.globalEdit", "Start Global Edit"); + assert_eq!(label(&app), "Start Global Edit"); + let texts = tall(&mut app, vec![]); + let at = texts.iter().find(|(t, _)| t == "Start Global Edit").map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{texts:?}")); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + tall(&mut app, vec![Event::PointerMoved(at), b(true)]); + tall(&mut app, vec![b(false)]); + assert!(on(&app), "the Quick Action starts Global Edit"); + assert_eq!(app.session.active().unwrap().global_edit.as_ref().unwrap().members.len(), 2); + assert!(tall(&mut app, vec![]).iter().any(|(t, _)| t == "Stop Global Edit")); + assert_eq!(label(&app), "Stop Global Edit"); + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let raw = + egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events: vec![escape], ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + out.textures_delta.clear(); + assert!(!on(&app), "Escape stops Global Edit"); + assert_eq!(label(&app), "Start Global Edit"); + assert_eq!(app.session.active().unwrap().selection.objects.iter().map(|n| json!(n.0)).collect::>(), [a]); + let t = app.run("text.create", json!({"x": 50, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [t]})).unwrap(); + assert!(!crate::menus::enabled(&app, "select.globalEdit")); + assert!(!tall(&mut app, vec![]).iter().any(|(t, _)| t == "Start Global Edit"), "type has no Global Edit"); +} + +/// The options' Result(s) count is found once per document revision, options and selected +/// object, not every frame; another selected object or other options find it again. +#[test] +fn the_global_edit_result_count_is_cached() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("shape.rectangle", json!({"x": 200, "y": 20, "width": 80, "height": 80})).unwrap(); + let e = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let ctx = egui::Context::default(); + assert_eq!(global_edit_count(&app, &ctx), 2); + // The next frame reads the cache: a count planted under the same key is what it shows. + let (key, _) = pstate::(&ctx, "props-global-edit-count").unwrap(); + set_pstate::(&ctx, "props-global-edit-count", Some((key, 7))); + assert_eq!(global_edit_count(&app, &ctx), 7); + app.run("select.set", json!({"ids": [e]})).unwrap(); + assert_eq!(global_edit_count(&app, &ctx), 1, "another selected object"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(global_edit_count(&app, &ctx), 2); + app.run("select.globalEditOptions", json!({"size": true})).unwrap(); + assert_eq!(global_edit_count(&app, &ctx), 1, "other options"); +} + +// Regression test for #1910: the Properties panel's flip buttons are named as the reference +// app's Properties panel names them, not Flip Horizontal and Flip Vertical (the Transform +// panel menu's names). +#[test] +fn the_flip_buttons_keep_the_reference_properties_names() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let tree = crate::widgets::accessibility_tests::tree(|ui| transform_section(&mut app, ui)); + let names: Vec<&str> = tree.iter().filter_map(|n| n.label()).collect(); + for want in ["Flip Along Horizontal Axis", "Flip Along Vertical Axis"] { + assert!(names.contains(&want), "{want} in {names:?}"); + } + for old in ["Flip Horizontal", "Flip Vertical"] { + assert!(!names.contains(&old), "{old} in {names:?}"); + } +} + +/// #3451: the Properties panel Transform section shows ellipse pie angles and Invert Pie. +#[test] +fn transform_section_shows_ellipse_pie_properties() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap(); + let ctx = egui::Context::default(); + let labels: Vec = panel_frame(&mut app, &ctx, vec![], transform_section).into_iter().map(|(t, _)| t).collect(); + for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] { + assert!(labels.iter().any(|s| s == t), "{t} in {labels:?}"); + } + app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap(); + let labels: Vec = panel_frame(&mut app, &ctx, vec![], transform_section).into_iter().map(|(t, _)| t).collect(); + assert!(labels.iter().any(|s| s == "30°") && labels.iter().any(|s| s == "120°"), "{labels:?}"); +} + +/// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every +/// theme, as the Preferences ones do (#1080), and toggle when clicked. +#[test] +fn the_document_checkboxes_show_their_boxes_and_toggle() { + for brightness in crate::theme::Brightness::ALL { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, brightness); + frame(&mut app, &ctx, vec![]); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let boxes = out + .shapes + .iter() + .filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.stroke.width > 0.0 && (r.rect.width() - 13.0).abs() < 0.5 && (r.rect.height() - 13.0).abs() < 0.5)) + .count(); + assert!(boxes >= 3, "{brightness:?}: {boxes} checkbox outlines"); + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame(&mut app, &ctx, vec![]); + let (grid, strokes) = (app.ui.view.snap_to_grid, app.session.prefs.scale_strokes); + click(&mut app, &ctx, &texts, "Snap to Grid"); + assert_eq!(app.ui.view.snap_to_grid, !grid); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Scale Strokes & Effects"); + assert_eq!(app.session.prefs.scale_strokes, !strokes); +} + +/// #921: with the Gradient tool, a Gradient section: the type, and for a freeform gradient +/// Draw as Points or Lines radio buttons. +#[test] +fn the_gradient_tool_shows_the_gradient_type_and_draw() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"gradient": {}})).unwrap(); + let ctx = egui::Context::default(); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + let texts = frame(&mut app, &ctx, vec![]); + assert!(!has(&texts, "Gradient"), "only with the Gradient tool"); + app.select_tool("gradient"); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Gradient") && has(&texts, "Type:") && !has(&texts, "Draw:"), "{texts:?}"); + app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Draw:") && has(&texts, "Points") && has(&texts, "Lines"), "{texts:?}"); + click(&mut app, &ctx, &texts, "Lines"); + let fill = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + let vectorcraft_color::Paint::Gradient(g) = fill else { panic!("{fill:?}") }; + assert_eq!(super::super::gradient::shown_points(&g).mode, vectorcraft_color::FreeformMode::Lines); +} + +/// #696: the number fields are all as wide: the Transform fields, the rotation, the corner +/// radius, Opacity and the stroke weight. +#[test] +fn the_number_fields_are_all_as_wide() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + // The field each value is shown in: the narrowest widget around its text that isn't the + // text alone. + let field = |value: &str| { + let at = texts.iter().find(|(t, _)| t == value).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{value}: {texts:?}")); + ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.contains(at) && w.rect.width() > 30.0 && w.rect.height() < 40.0) + .map(|w| w.rect.width()) + .fold(f32::INFINITY, f32::min) + }) + }; + let widths = [field("35 pt"), field("0°"), field("0 pt"), field("100%")]; + assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}"); +} + +/// #1512: the link behind Corner Radius. On, one field sets every corner; off, a field per +/// corner shows each corner's own radius. +#[test] +fn the_corner_radius_link_shows_a_field_per_corner() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + app.run("object.setLiveShape", json!({"radius": 7, "corners": [2]})).unwrap(); + let ctx = egui::Context::default(); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + assert!(app.session.prefs.single_corner_radius, "linked at first"); + assert!(has(&texts, "Corner Radius:") && !has(&texts, "Top Left:") && !has(&texts, "7 pt"), "{texts:?}"); + app.run("prefs.set", json!({"key": "singleCornerRadius", "value": false})).unwrap(); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + for l in ["Corner Radius:", "Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"] { + assert!(has(&texts, l), "{l}: {texts:?}"); + } + assert_eq!(texts.iter().filter(|(t, _)| t == "7 pt").count(), 1, "the bottom-right corner: {texts:?}"); + // The fields sit in line, in the order top left, top right, bottom right, bottom left. + let at = |s: &str| texts.iter().find(|(t, _)| t == s).unwrap().1; + assert!(at("Top Left:").top() < at("Top Right:").top() && at("Top Right:").top() < at("Bottom Right:").top()); + assert!((at("7 pt").top() - at("Bottom Right:").top()).abs() < 6.0, "{texts:?}"); +} + +/// The Transform section's More Options holds Scale Corners and Scale Strokes & Effects, and +/// stays open while they are toggled. +#[test] +fn transform_more_options_toggles_scale_corners_and_strokes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); + let ctx = egui::Context::default(); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + assert!(!has(&texts, "Scale Corners"), "closed at first"); + // The button is an icon at the end of the angle row, under the H: field: sweep that row. + let h = texts.iter().find(|(t, _)| t == "H:").unwrap().1; + let mut open = None; + 'sweep: for dy in (12..60).step_by(6) { + for x in (120..300).step_by(6) { + let at = egui::pos2(x as f32, h.bottom() + dy as f32); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), b(true)]); + frame(&mut app, &ctx, vec![b(false)]); + let texts = frame(&mut app, &ctx, vec![]); + if has(&texts, "Scale Corners") { + open = Some(texts); + break 'sweep; + } + } + } + let texts = open.expect("More Options opens the popup"); + let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes); + click(&mut app, &ctx, &texts, "Scale Corners"); + assert_eq!(app.session.prefs.scale_corners, !sc); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Scale Strokes & Effects"), "still open after a toggle"); + click(&mut app, &ctx, &texts, "Scale Strokes & Effects"); + assert_eq!(app.session.prefs.scale_strokes, !ss); +} + +/// Both panels forward the Move Artwork toggle when their actual X field is edited. +#[test] +fn artboard_coordinate_fields_move_art_in_properties_and_transform() { + for draw in [show as fn(&mut VectorcraftApp, &mut Ui), super::super::transform::show] { + for move_art in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("artboard"); + app.session.set_tool_option("moveArt", &json!(move_art)); + let ctx = egui::Context::default(); + panel_frame(&mut app, &ctx, vec![], draw); + let texts = panel_frame(&mut app, &ctx, vec![], draw); + let label = texts.iter().find(|(t, _)| t == "X:").unwrap().1; + let at = egui::pos2(label.right() + 40.0, label.center().y); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + panel_frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)], draw); + panel_frame(&mut app, &ctx, vec![press(false)], draw); + panel_frame( + &mut app, + &ctx, + vec![Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + draw, + ); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.artboards[0].rect.x0, 1.0, "the focused X field steps its position"); + assert_eq!(d.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0, if move_art { 11.0 } else { 10.0 }); + app.run("edit.undo", json!({})).unwrap(); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.artboards[0].rect.x0, 0.0); + assert_eq!(d.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0, 10.0); + } + } +} + +/// Three landscape artboards, with Artboards 1 and 3 selected with the Artboard tool (Artboard +/// 1 active): 200 × 100, 200 × 100 and 300 × 150. +fn two_selected_artboards() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + app.run("artboard.setProps", json!({"index": 2, "width": 300, "height": 150})).unwrap(); + app.select_tool("artboard"); + super::super::artboards::select(&mut app, 0); + app.session.set_tool_option("selected", &json!([0, 2])); + app +} + +/// Each artboard's (x0, y0, width, height). +fn boards(app: &VectorcraftApp) -> Vec<(f64, f64, f64, f64)> { + app.session.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height())).collect() +} + +/// #1482: with Artboards 1 and 3 selected, a W typed into Properties or the Transform panel +/// resizes both in one undo step, and Artboard 2 keeps its size. Properties keeps each top-left +/// corner; the Transform panel resizes each about its own center, its default reference point. +#[test] +fn artboard_width_resizes_every_selected_artboard_in_properties_and_transform() { + for (draw, centered) in [(show as fn(&mut VectorcraftApp, &mut Ui), false), (super::super::transform::show, true)] { + let mut app = two_selected_artboards(); + let before = boards(&app); + let undo = app.session.doc().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + panel_frame(&mut app, &ctx, vec![], draw); + let texts = panel_frame(&mut app, &ctx, vec![], draw); + let label = texts.iter().find(|(t, _)| t == "W:").unwrap().1; + let at = egui::pos2(label.right() + 40.0, label.center().y); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + panel_frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)], draw); + panel_frame(&mut app, &ctx, vec![press(false)], draw); + panel_frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw); + panel_frame(&mut app, &ctx, vec![Event::Text("250".into())], draw); + panel_frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw); + panel_frame(&mut app, &ctx, vec![], draw); + let mut want = before.clone(); + for (i, shift) in [(0, 25.0), (2, -25.0)] { + want[i].2 = 250.0; + if centered { + want[i].0 -= shift; + } + } + assert_eq!(boards(&app), want, "centered: {centered}"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(boards(&app), before); + } +} + +/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit. +/// #775: type's Paragraph section has Hyphenate, and its checkbox turns it on. +#[test] +fn type_paragraph_section_hyphenates() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 50, "text": "Paragraph"})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Hyphenate"); + let hyphenate = |app: &VectorcraftApp| crate::panels::character::text_style(app).unwrap().1.hyphenate; + assert!(hyphenate(&app)); +} + +/// Variable fonts get the same Variable Font button in Properties as in the Character panel. +#[test] +fn properties_variable_font_button_for_variable_type() { + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone(); + let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + app.run("select.set", json!({"ids": [plain]})).unwrap(); + assert!(!frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Variable Font")); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Variable Font")); +} + +#[test] +fn edit_artboards_adds_artboards_and_exits() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Edit Artboards"); + assert_eq!(app.session.tool_id(), "artboard"); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "Artboard 1") && texts.iter().any(|(t, _)| t == "200 pt"), "{texts:?}"); + click(&mut app, &ctx, &texts, "New Artboard"); + let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len(); + assert_eq!(boards(&app), 2); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "Artboard 2"), "the new artboard is the active one: {texts:?}"); + click(&mut app, &ctx, &texts, "Delete Artboard"); + assert_eq!(boards(&app), 1); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Exit"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// Typing a name into Edit Artboards' name field and pressing Enter renames the active artboard +/// alone, trimmed as the Artboards panel's rename trims it, with one command and one undo step; +/// a click elsewhere in the panel renames it too. Pressing Escape or leaving the field empty +/// keeps the name and runs no command. +#[test] +fn edit_artboards_renames_the_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + super::super::artboards::select(&mut app, 1); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.artboards.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + let key = |key, modifiers| vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]; + // Click the field showing Artboard 2's name, select all of it, type `typed`, then send each + // frame's events of `end` → the number of commands run. + let rename = |app: &mut VectorcraftApp, typed: Vec, end: Vec>| { + frame(app, &ctx, vec![]); + let texts = frame(app, &ctx, vec![]); + let name = app.session.doc().unwrap().doc.artboards[1].name.clone(); + click(app, &ctx, &texts, &name); + frame(app, &ctx, key(egui::Key::A, egui::Modifiers::COMMAND)); + let commands = app.run_count; + frame(app, &ctx, typed); + for events in end { + frame(app, &ctx, events); + } + frame(app, &ctx, vec![]); + app.run_count.wrapping_sub(commands) + }; + let enter = || vec![key(egui::Key::Enter, egui::Modifiers::NONE)]; + let text = |s: &str| vec![Event::Text(s.into())]; + assert_eq!(rename(&mut app, text("Cover"), vec![key(egui::Key::Escape, egui::Modifiers::NONE)]), 0, "Escape"); + assert_eq!(rename(&mut app, key(egui::Key::Backspace, egui::Modifiers::NONE), enter()), 0, "an empty field"); + assert_eq!(app.session.doc().unwrap().doc.artboards, before); + assert_eq!(rename(&mut app, text(" Cover "), enter()), 1); + let mut renamed = before.clone(); + renamed[1].name = "Cover".into(); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.artboards, renamed, "Artboard 2 alone is renamed"); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.artboards, before); + assert!(!st.is_dirty()); + // The click elsewhere is a press and release on the panel's "Artboard" heading, outside + // the field. + let at = frame(&mut app, &ctx, vec![]).iter().find(|(t, _)| t == "Artboard").map(|(_, r)| r.center()).unwrap(); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + assert_eq!(rename(&mut app, text("Back"), vec![vec![Event::PointerMoved(at), b(true)], vec![b(false)]]), 1, "a click elsewhere"); + renamed[1].name = "Back".into(); + assert_eq!(app.session.doc().unwrap().doc.artboards, renamed); +} + +/// A click on Edit Artboards' Artboard Options icon opens Artboard Options for the active +/// artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). +#[test] +fn edit_artboards_opens_the_active_artboards_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + super::super::artboards::select(&mut app, 1); + app.run("tool.options", json!({"tool": "artboard"})).unwrap(); + let want = app.ui.dialog.take().unwrap().fields; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + // The icon is the rightmost clickable widget on the name's line. + let name = texts.iter().find(|(t, _)| t == "Artboard 2").map(|(_, r)| *r).unwrap(); + let icon = ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(name.center().y)) + .map(|w| w.rect) + .max_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap() + .center() + }); + let commands = app.run_count; + let press = |pressed| Event::PointerButton { pos: icon, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(icon), press(true)]); + frame(&mut app, &ctx, vec![press(false)]); + assert_eq!(app.run_count, commands.wrapping_add(1), "one command"); + let d = app.ui.dialog.take().expect("Artboard Options"); + assert_eq!((d.kind.as_str(), d.f64("index", 9.0), d.str("name").as_str()), (crate::dialogs::artboard_options::KIND, 1.0, "Artboard 2")); + assert_eq!(d.fields, want, "the fields the tool's double-click shows"); + assert_eq!(app.session.tool_id(), "artboard"); +} + +/// Edit Artboards' Fill (Properties › Artboards › Fill): the list sets the active artboard's +/// background color to Black and back to None, one command and one undo step each, and shows the +/// choice. Color Picker… in the list and a click on the chip open the Color Picker for that +/// artboard. +#[test] +fn edit_artboards_fill_sets_the_background_color() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + app.select_tool("artboard"); + super::super::artboards::select(&mut app, 1); + let colors = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards.iter().map(|a| a.color).collect::>(); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + // Open the list showing `shown` and choose `option` → the number of commands run. + let choose = |app: &mut VectorcraftApp, shown: &str, option: &str| { + frame(app, &ctx, vec![]); + let texts = frame(app, &ctx, vec![]); + click(app, &ctx, &texts, shown); + let texts = frame(app, &ctx, vec![]); + let commands = app.run_count; + click(app, &ctx, &texts, option); + app.run_count.wrapping_sub(commands) + }; + let before = undo(&app); + assert_eq!(choose(&mut app, "None", "Black"), 1, "one command"); + assert_eq!(colors(&app), [None, Some(Color::BLACK)]); + assert_eq!(undo(&app), before + 1, "one undo step"); + assert_eq!(choose(&mut app, "Black", "None"), 1); + assert_eq!(colors(&app), [None, None]); + choose(&mut app, "None", "Color Picker…"); + let d = app.ui.dialog.take().expect("the Color Picker"); + assert_eq!((d.kind.as_str(), d.fields.get("artboard")), ("colorPicker", Some(&json!(1)))); + // The chip is the clickable widget left of the list on the Fill: row. + let texts = frame(&mut app, &ctx, vec![]); + let row = texts.iter().find(|(t, _)| t == "Fill:").map(|(_, r)| *r).unwrap(); + let chip = ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) + .map(|w| w.rect) + .min_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap() + .center() + }); + let press = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(chip), press(true)]); + frame(&mut app, &ctx, vec![press(false)]); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields.get("artboard")), Some(Some(&json!(1))), "the chip opens it too"); +} + +fn artboard_deletion_app(count: usize) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": count})).unwrap(); + for i in 0..count { + app.run("shape.rectangle", json!({"x": i * 250 + 10, "y": 10, "width": 30, "height": 20})).unwrap(); + } + app.select_tool("artboard"); + super::super::artboards::select(&mut app, 0); + app +} + +fn artboard_deletion_click( + app: &mut VectorcraftApp, + ctx: &egui::Context, + draw: fn(&mut VectorcraftApp, &mut Ui), + label: Option<&str>, + modifiers: egui::Modifiers, +) { + let at = if let Some(label) = label { + panel_frame(app, ctx, vec![], draw); + let texts = panel_frame(app, ctx, vec![], draw); + texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) + } else { + // The trash is the last 24-point footer button. Use the drawn panel bounds, as the Asset Export bin test uses its footer. + let mut bounds = Rect::NOTHING; + for _ in 0..2 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + draw(app, ui); + bounds = ui.min_rect(); + }); + out.textures_delta.clear(); + } + assert!(bounds.width() >= 24.0 && bounds.height() >= 24.0); + bounds.right_bottom() - vec2(12.0, 12.0) + }; + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + panel_frame(app, ctx, vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(at), button(true)], draw); + panel_frame(app, ctx, vec![button(false)], draw); + panel_frame(app, ctx, vec![Event::ModifiersChanged(egui::Modifiers::NONE)], draw); +} + +fn select_artboard_rows(app: &mut VectorcraftApp, ctx: &egui::Context, indices: &[usize]) { + for (at, i) in indices.iter().enumerate() { + let modifiers = if at == 0 { egui::Modifiers::NONE } else { egui::Modifiers::COMMAND }; + artboard_deletion_click(app, ctx, super::super::artboards::show, Some(&format!("Artboard {}", i + 1)), modifiers); + } + let count = app.session.doc().unwrap().doc.artboards.len(); + assert_eq!(super::super::artboards::picked(app, count), indices, "the rows are selected before deletion"); +} + +fn assert_artboard_deletion(draw: fn(&mut VectorcraftApp, &mut Ui), label: Option<&str>) { + for indices in [vec![0, 2], vec![2], vec![3], vec![2, 3], vec![0, 1, 2, 3]] { + let mut app = artboard_deletion_app(4); + let ctx = egui::Context::default(); + select_artboard_rows(&mut app, &ctx, &indices); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let all_selected = indices == [0, 1, 2, 3]; + let expected: Vec<_> = if all_selected { + assert_eq!(app.view().unwrap().artboard, 3, "the active last board must not replace the first-board survivor oracle"); + vec![&before.artboards[0]] + } else { + before.artboards.iter().enumerate().filter(|(i, _)| !indices.contains(i)).map(|(_, a)| a).collect() + }; + let expected = serde_json::to_value(expected).unwrap(); + let artwork = serde_json::to_value(&before.layers).unwrap(); + + let commands = app.run_count; + artboard_deletion_click(&mut app, &ctx, draw, label, egui::Modifiers::NONE); + let st = app.session.doc().unwrap(); + assert_eq!( + serde_json::to_value(&st.doc.artboards).unwrap(), + expected, + "selected deletion keeps the expected IDs; all-selected keeps original board 1" + ); + assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual deletion control"); + assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork, "artwork IDs, geometry and appearance stay intact"); + assert_eq!(st.history.undo.len(), undo + 1, "one deletion is one undo step"); + assert!(st.is_dirty()); + let active = app.view().unwrap().artboard; + if all_selected { + assert_eq!(active, 0); + } + assert!(active < st.doc.artboards.len(), "the active artboard remains in range"); + assert_eq!(app.session.tool_options()["active"], json!(active)); + assert_eq!(super::super::artboards::picked(&app, st.doc.artboards.len()), [active]); + assert_eq!(app.session.tool_options()["selected"], json!([active]), "raw tool selection contains only the remaining active board"); + + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), serde_json::to_value(&before.artboards).unwrap()); + assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork); + assert_eq!(st.history.undo.len(), undo); + assert!(!st.is_dirty(), "one undo returns to the saved document"); + app.run("edit.redo", json!({})).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), expected, "redo removes the same artboard IDs"); + assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork); + assert_eq!(st.history.undo.len(), undo + 1); + assert!(st.is_dirty()); + } +} + +fn assert_artboard_deletion_guard(draw: fn(&mut VectorcraftApp, &mut Ui), label: Option<&str>) { + let mut app = artboard_deletion_app(1); + // Leave a redo entry to ensure a disabled action preserves both history branches. + app.run("artboard.setProps", json!({"index": 0, "name": "Temporary"})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + let ctx = egui::Context::default(); + select_artboard_rows(&mut app, &ctx, &[0]); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let st = app.session.doc().unwrap(); + let history = (st.history.undo.len(), st.history.redo.len()); + assert!(history.1 > 0); + let active = app.view().unwrap().artboard; + let options = app.session.tool_options(); + + let commands = app.run_count; + artboard_deletion_click(&mut app, &ctx, draw, label, egui::Modifiers::NONE); + assert_eq!(app.run_count, commands, "the disabled last-board control dispatches no command"); + let st = app.session.doc().unwrap(); + assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), serde_json::to_value(&before.artboards).unwrap()); + assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), serde_json::to_value(&before.layers).unwrap()); + assert_eq!((st.history.undo.len(), st.history.redo.len()), history); + assert!(!st.is_dirty(), "deleting the last artboard is a no-op"); + assert_eq!(app.view().unwrap().artboard, active); + assert_eq!(app.session.tool_options(), options); +} + +#[test] +fn artboard_menu_deletes_selected_artboards_in_one_undo_step() { + assert_artboard_deletion(super::super::artboards::menu, Some("Delete Artboards")); +} + +#[test] +fn properties_deletes_selected_artboards_in_one_undo_step() { + assert_artboard_deletion(show, Some("Delete Artboard")); +} + +#[test] +fn artboard_menu_preserves_last_artboard() { + assert_artboard_deletion_guard(super::super::artboards::menu, Some("Delete Artboards")); +} + +#[test] +fn properties_preserves_last_artboard() { + assert_artboard_deletion_guard(show, Some("Delete Artboard")); +} + +#[test] +fn artboard_trash_deletes_selected_artboards_in_one_undo_step() { + assert_artboard_deletion(super::super::artboards::show, None); +} + +#[test] +fn artboard_trash_preserves_last_artboard() { + assert_artboard_deletion_guard(super::super::artboards::show, None); +} + +/// Click the actual panel footer's New button (24 points, with a 4-point gap before Trash). +fn artboard_history_new(app: &mut VectorcraftApp, ctx: &egui::Context) { + let mut bounds = Rect::NOTHING; + for _ in 0..2 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + super::super::artboards::show(app, ui); + bounds = ui.min_rect(); + }); + out.textures_delta.clear(); + } + let count = app.session.doc().unwrap().doc.artboards.len(); + let commands = app.run_count; + let at = bounds.right_bottom() - vec2(40.0, 12.0); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + panel_frame(app, ctx, vec![Event::PointerMoved(at), button(true)], super::super::artboards::show); + panel_frame(app, ctx, vec![button(false)], super::super::artboards::show); + assert_eq!(app.run_count, commands.wrapping_add(1), "the actual New button dispatches once"); + assert_eq!(app.session.doc().unwrap().doc.artboards.len(), count + 1); + assert_eq!(app.session.tool_options()["active"], json!(count)); + assert_eq!(app.view().unwrap().artboard, count); +} + +/// Keep the panel and its natural focus in the same context throughout Undo and deletion. +fn artboard_history_key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { + for pressed in [true, false] { + let events = vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }]; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + super::super::artboards::show(app, ui); + }); + out.textures_delta.clear(); + } +} + +fn artboard_history_delete_after_undo(key: Option, duplicate: bool) { + let mut app = artboard_deletion_app(2); + app.session.set_tool_option("moveArt", &json!(false)); + app.session.set_tool_option("scaleArt", &json!(true)); + app.session.doc_mut().unwrap().mark_saved(); + let st = app.session.doc().unwrap(); + let before = st.doc.clone(); + let selection = st.selection.clone(); + assert!(!selection.is_empty(), "selected artwork catches a Clear fallthrough"); + let undo = st.history.undo.len(); + let ctx = egui::Context::default(); + if duplicate { + let commands = app.run_count; + artboard_deletion_click(&mut app, &ctx, super::super::artboards::menu, Some("Duplicate Artboards"), egui::Modifiers::NONE); + assert_eq!(app.run_count, commands.wrapping_add(1)); + assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3); + assert_eq!(app.session.tool_options()["active"], json!(2)); + assert_eq!(app.view().unwrap().artboard, 2); + } else { + artboard_history_new(&mut app, &ctx); + } + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc, before, "the real Undo shortcut restores the original document"); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); + assert!(!st.is_dirty()); + // Capture the state before another panel or pointer can repair it. Still exercise deletion + // on RED: keyboard deletion used to do nothing; Properties already clamped its own target. + let after_undo = (app.view().unwrap().artboard, app.session.tool_options()); + if let Some(key) = key { + artboard_history_key(&mut app, &ctx, key, egui::Modifiers::NONE); + } else { + let texts = panel_frame(&mut app, &ctx, vec![], show); + assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name)); + artboard_deletion_click(&mut app, &ctx, show, Some("Delete Artboard"), egui::Modifiers::NONE); + } + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "deletion after Undo removes the shown second board"); + assert_eq!(st.doc.layers, before.layers, "ordinary artwork survives without geometry or style changes"); + assert_eq!(st.selection, selection, "deletion never falls through to Clear"); + assert_eq!(after_undo.0, 1, "Undo normalizes the navigator before another gesture"); + assert_eq!(after_undo.1, json!({"active": 1, "selected": [1], "moveArt": false, "scaleArt": true})); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); + assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); + assert!(st.is_dirty()); + assert_eq!(app.view().unwrap().artboard, 0); + assert_eq!(app.session.tool_options(), json!({"active": 0, "selected": [0], "moveArt": false, "scaleArt": true})); + let deleted = st.doc.clone(); + + artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); + assert!(!st.is_dirty()); + // Valid current selection stays put; history does not restore an earlier artboard selection. + assert_eq!(app.session.tool_options()["selected"], json!([0])); + artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc, deleted); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); + assert!(st.is_dirty()); +} + +#[test] +fn artboard_history_new_undo_delete_uses_remaining_board() { + artboard_history_delete_after_undo(Some(egui::Key::Delete), false); +} + +#[test] +fn artboard_history_new_undo_backspace_uses_remaining_board() { + artboard_history_delete_after_undo(Some(egui::Key::Backspace), false); +} + +#[test] +fn artboard_history_new_undo_properties_agrees_with_tool() { + artboard_history_delete_after_undo(None, false); +} + +#[test] +fn artboard_history_duplicate_undo_delete_uses_remaining_board() { + artboard_history_delete_after_undo(Some(egui::Key::Delete), true); +} + +#[test] +fn artboard_history_undo_prunes_only_removed_indices() { + for order in [[0, 1, 2], [0, 2, 1]] { + let mut app = artboard_deletion_app(2); + app.session.set_tool_option("moveArt", &json!(false)); + app.session.set_tool_option("scaleArt", &json!(true)); + app.session.doc_mut().unwrap().mark_saved(); + let st = app.session.doc().unwrap(); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let undo = st.history.undo.len(); + let ctx = egui::Context::default(); + artboard_history_new(&mut app, &ctx); + let created = app.session.doc().unwrap().doc.clone(); + for (at, i) in order.into_iter().enumerate() { + let modifiers = if at == 0 { egui::Modifiers::NONE } else { egui::Modifiers::COMMAND }; + artboard_deletion_click(&mut app, &ctx, super::super::artboards::show, Some(&format!("Artboard {}", i + 1)), modifiers); + } + let options = app.session.tool_options(); + let expected = if order[2] == 2 { json!([0, 1]) } else { json!([1, 0]) }; + assert_eq!(options["selected"], if order[2] == 2 { json!([2, 0, 1]) } else { json!([1, 0, 2]) }); + artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); + let mut normalized = options; + normalized["active"] = expected[0].clone(); + normalized["selected"] = expected; + assert_eq!(app.session.tool_options(), normalized, "only the removed index is pruned, preserving surviving order and options"); + assert_eq!(json!(app.view().unwrap().artboard), normalized["active"]); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); + assert!(!st.is_dirty()); + artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(app.session.doc().unwrap().doc, created); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.tool_options(), normalized, "Redo does not replace an already valid multiselection"); + } +} + +#[test] +fn artboard_history_valid_multiselection_survives_undo_redo() { + let mut app = artboard_deletion_app(3); + app.session.set_tool_option("moveArt", &json!(false)); + app.session.set_tool_option("scaleArt", &json!(true)); + let ctx = egui::Context::default(); + select_artboard_rows(&mut app, &ctx, &[0, 1]); + let options = app.session.tool_options(); + assert_eq!(options["selected"], json!([1, 0])); + let view = app.view().unwrap().artboard; + app.session.doc_mut().unwrap().mark_saved(); + let st = app.session.doc().unwrap(); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let undo = st.history.undo.len(); + app.run("object.move", json!({"dx": 7, "dy": 9})).unwrap(); + let moved = app.session.doc().unwrap().doc.clone(); + assert_ne!(moved, before); + artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); + app.sync_views(); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); + assert!(!st.is_dirty()); + assert_eq!(app.session.tool_options(), options); + assert_eq!(app.view().unwrap().artboard, view); + artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + app.sync_views(); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc, moved); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); + assert!(st.is_dirty()); + assert_eq!(app.session.tool_options(), options); + assert_eq!(app.view().unwrap().artboard, view); +} + +/// #681: Rearrange All opens Rearrange All Artboards in one click, from the Document section +/// (only with several artboards) and from Edit Artboards' Quick Actions. +#[test] +fn rearrange_all_opens_the_dialog() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + assert!(!frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Rearrange All"), "one artboard: nothing to rearrange"); + app.run("artboard.new", json!({})).unwrap(); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Rearrange All"); + let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); + assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::rearrange_artboards::KIND)); + app.ui.dialog = None; + app.select_tool("artboard"); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Rearrange All"); + assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::rearrange_artboards::KIND)); +} + +/// #671: Edit Artboards shows the artboard's preset (Custom when it matches none) and its +/// orientation; the other orientation swaps its width and height, its corner staying put. +#[test] +fn edit_artboards_shows_the_preset_and_flips_the_orientation() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"preset": "Letter"})).unwrap(); + app.run("artboard.setProps", json!({"index": 0, "x": 10, "y": 20})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "Preset:") && texts.iter().any(|(t, _)| t == "Letter"), "{texts:?}"); + // The row's clickable widgets, left to right: the preset combo, Portrait, Landscape. + let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap(); + let buttons: Vec = ctx.viewport(|vp| { + let mut r: Vec = vp + .prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) + .map(|w| w.rect) + .collect(); + r.sort_by(|a, b| a.left().total_cmp(&b.left())); + r + }); + let landscape = buttons.last().unwrap().center(); + let press = |pressed| Event::PointerButton { pos: landscape, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(landscape), press(true)]); + frame(&mut app, &ctx, vec![press(false)]); + let rect = app.session.active().unwrap().doc.artboards[0].rect; + assert_eq!((rect.x0, rect.y0, rect.width(), rect.height()), (10.0, 20.0, 792.0, 612.0), "landscape, same corner"); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "Letter"), "still Letter, turned: {texts:?}"); + app.run("artboard.setProps", json!({"index": 0, "width": 333})).unwrap(); + assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Custom")); +} + +/// The center of Edit Artboards' Portrait (`landscape` false) or Landscape button. +fn orientation_button(ctx: &egui::Context, texts: &[(String, Rect)], landscape: bool) -> Pos2 { + let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap(); + ctx.viewport(|vp| { + let mut r: Vec = vp + .prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) + .map(|w| w.rect) + .collect(); + r.sort_by(|a, b| a.left().total_cmp(&b.left())); + r[r.len() - if landscape { 1 } else { 2 }].center() + }) +} + +/// #1482: with Artboards 1 and 3 selected, Portrait swaps the width and height of both in one +/// undo step, each keeping its top-left corner; Artboard 2 stays landscape. An artboard already +/// in that orientation keeps its size. +#[test] +fn edit_artboards_orientation_turns_every_selected_artboard() { + let mut app = two_selected_artboards(); + let before = boards(&app); + let undo = app.session.doc().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + let portrait = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![]); + let texts = frame(app, &ctx, vec![]); + let at = orientation_button(&ctx, &texts, false); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![Event::PointerMoved(at), press(true)]); + frame(app, &ctx, vec![press(false)]); + }; + portrait(&mut app); + let mut want = before.clone(); + for i in [0, 2] { + (want[i].2, want[i].3) = (before[i].3, before[i].2); + } + assert_eq!(boards(&app), want); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(boards(&app), before); + // Artboard 3 portrait already: Portrait turns Artboard 1 alone. + app.run("artboard.setProps", json!({"index": 2, "width": 150, "height": 300})).unwrap(); + let before = boards(&app); + portrait(&mut app); + let mut want = before.clone(); + (want[0].2, want[0].3) = (100.0, 200.0); + assert_eq!(boards(&app), want); +} + +/// #1482: with Artboards 1 and 3 selected, a preset chosen in Edit Artboards sizes both in one +/// undo step, each in its own orientation and keeping its top-left corner: Letter (612 × 792 pt) +/// stays portrait for Artboard 1 and turns landscape for Artboard 3. Artboard 2 keeps its size. +#[test] +fn edit_artboards_preset_sizes_every_selected_artboard() { + let mut app = two_selected_artboards(); + app.run("artboard.setProps", json!({"index": 0, "width": 100, "height": 200})).unwrap(); + let before = boards(&app); + let undo = app.session.doc().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + // A frame on a wide screen, with the texts of nested shapes too (the open list and its + // submenu), and the center of `label` among them (menu items carry a check-mark column). + let run = |app: &mut VectorcraftApp, events: Vec| { + fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 700.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + }; + let at = |texts: &[(String, Rect)], label: &str| { + texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) + }; + let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + run(&mut app, vec![]); + let custom = at(&run(&mut app, vec![]), "Custom"); + run(&mut app, vec![Event::PointerMoved(custom), press(custom, true)]); + run(&mut app, vec![press(custom, false)]); + // Hovering Print opens its submenu. + let print = at(&run(&mut app, vec![]), "Print"); + run(&mut app, vec![Event::PointerMoved(print)]); + let letter = at(&run(&mut app, vec![]), "Letter"); + run(&mut app, vec![Event::PointerMoved(letter), press(letter, true)]); + run(&mut app, vec![press(letter, false)]); + let mut want = before.clone(); + (want[0].2, want[0].3) = (612.0, 792.0); + (want[2].2, want[2].3) = (792.0, 612.0); + assert_eq!(boards(&app), want); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); +} + +/// #602: Edit Artboards shows Move and Scale Artwork with Artboard; with Scale on, a new size +/// (here the other orientation) takes the art on the artboard along. +#[test] +fn edit_artboards_scales_the_art_with_scale_artwork_with_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let r = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "Move Artwork with Artboard"), "{texts:?}"); + assert_eq!(app.session.tool_options()["scaleArt"], false); + click(&mut app, &ctx, &texts, "Scale Artwork with Artboard"); + assert_eq!(app.session.tool_options()["scaleArt"], true); + // Portrait: 100 × 200, the art scaled by ½ across and 2 down. + let texts = frame(&mut app, &ctx, vec![]); + let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap(); + let portrait = ctx.viewport(|vp| { + let mut r: Vec = vp + .prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) + .map(|w| w.rect) + .collect(); + r.sort_by(|a, b| a.left().total_cmp(&b.left())); + r[r.len() - 2].center() + }); + let press = |pressed| Event::PointerButton { pos: portrait, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(portrait), press(true)]); + frame(&mut app, &ctx, vec![press(false)]); + let d = &app.session.active().unwrap().doc; + let ab = d.artboards[0].rect; + assert_eq!((ab.width(), ab.height()), (100.0, 200.0)); + let b = d.node(vectorcraft_doc::NodeId(r)).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 40.0, 20.0, 80.0)); +} + +/// One frame of the panel 1600 points tall, so Quick Actions at its foot are laid out, with +/// `events` → the texts painted, with their rects. +fn tall(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + _ => None, + }) + .collect() +} + +/// Clicks `at` in the tall panel over two frames (press, release), then lets a popup it opens +/// lay out → the texts of the frame after. +fn press(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) -> Vec<(String, Rect)> { + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + tall(app, ctx, vec![Event::PointerMoved(at), b(true)]); + tall(app, ctx, vec![b(false)]); + tall(app, ctx, vec![]); + tall(app, ctx, vec![]) +} + +/// Where the tall panel painted `label`. +fn text_at(texts: &[(String, Rect)], label: &str) -> Rect { + texts.iter().find(|(t, _)| t == label).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) +} + +fn shows(texts: &[(String, Rect)], label: &str) -> bool { + texts.iter().any(|(t, _)| t == label) +} + +fn undo_depth(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +fn new_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app +} + +fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> serde_json::Value { + app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].clone() +} + +/// One blend selected with the Blend tool has Blend Options in Quick Actions, which opens the +/// Blend Options dialog on it; OK is one undo step. Not with another tool, nor for a blend +/// selected with another object. +#[test] +fn blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool() { + let mut app = new_app(); + let (a, b) = (rect(&mut app, 20.0, 20.0, 40.0, 40.0), rect(&mut app, 200.0, 20.0, 40.0, 40.0)); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let blend = app.run("object.blend.make", json!({})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [blend]})).unwrap(); + let ctx = egui::Context::default(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "not with the Selection tool"); + app.select_tool("blend"); + app.run("select.set", json!({"ids": [blend]})).unwrap(); + let texts = tall(&mut app, &ctx, vec![]); + let depth = undo_depth(&app); + press(&mut app, &ctx, text_at(&texts, "Blend Options").center()); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::blend_options::KIND)); + assert_eq!(undo_depth(&app), depth, "opening the dialog changes nothing"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("spacing".into(), json!("steps")); + d.fields.insert("steps".into(), json!(3)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(undo_depth(&app), depth + 1, "OK is one undo step"); + let c = rect(&mut app, 20.0, 200.0, 40.0, 40.0); + app.select_tool("blend"); + app.run("select.set", json!({"ids": [blend, c]})).unwrap(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a blend and another object"); + app.run("select.set", json!({"ids": [c]})).unwrap(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a rectangle"); +} + +/// A Live Paint group selected with a path has Merge Live Paint in Quick Actions, which adds +/// the path to the group in one undo step. Not for the group alone, nor for plain paths, nor +/// with type selected, which Live Paint refuses. +#[test] +fn merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more() { + let mut app = new_app(); + let (a, b) = (rect(&mut app, 20.0, 20.0, 100.0, 60.0), rect(&mut app, 60.0, 40.0, 100.0, 60.0)); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let group = app.run("livePaint.make", json!({})).unwrap()["id"].clone(); + let (c, d) = (rect(&mut app, 140.0, 30.0, 60.0, 60.0), rect(&mut app, 300.0, 200.0, 40.0, 40.0)); + let text = app.run("text.create", json!({"x": 50, "y": 250, "text": "Hi"})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + for (ids, why) in [ + (json!([group]), "the group alone"), + (json!([c, d]), "two plain paths"), + (json!([group, text]), "the group and type"), + (json!([group, c, text]), "the group, a path and type"), + ] { + app.run("select.set", json!({"ids": ids})).unwrap(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Merge Live Paint"), "{why}"); + } + app.run("select.set", json!({"ids": [group, c]})).unwrap(); + let c_id = vectorcraft_doc::NodeId(c.as_u64().unwrap()); + let parent = app.session.active().unwrap().doc.parent_of(c_id); + let texts = tall(&mut app, &ctx, vec![]); + let depth = undo_depth(&app); + press(&mut app, &ctx, text_at(&texts, "Merge Live Paint").center()); + assert_eq!(undo_depth(&app), depth + 1, "one undo step"); + let st = app.session.active().unwrap(); + assert_eq!(st.selection.objects.iter().map(|n| json!(n.0)).collect::>(), std::slice::from_ref(&group)); + assert!(st.doc.node(c_id).is_none() || st.doc.parent_of(c_id) != parent, "the path is merged into the group"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.parent_of(c_id), parent, "Undo takes it out again"); +} + +/// A stroke with a variable width shows its Width Profile in the Appearance section, as every +/// stroke does with the Width tool; choosing another profile is one undo step. A uniform stroke +/// shows none with other tools. +#[test] +fn the_width_profile_shows_for_a_variable_width_or_the_width_tool() { + let mut app = new_app(); + let line = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 300, "y2": 100})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [line]})).unwrap(); + let ctx = egui::Context::default(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Profile:"), "a uniform stroke"); + app.select_tool("width"); + app.run("select.set", json!({"ids": [line]})).unwrap(); + assert!(shows(&tall(&mut app, &ctx, vec![]), "Profile:"), "the Width tool"); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [line]})).unwrap(); + app.run("stroke.set", json!({"profile": "lens"})).unwrap(); + let texts = tall(&mut app, &ctx, vec![]); + // The list sits right of its label. + let spacing = ctx.global_style().spacing.item_spacing.x; + let texts = press(&mut app, &ctx, text_at(&texts, "Profile:").right_center() + vec2(spacing + 50.0, 0.0)); + let depth = undo_depth(&app); + press(&mut app, &ctx, text_at(&texts, "Taper Start").center()); + assert_eq!(undo_depth(&app), depth + 1, "one undo step"); + let profile = app + .session + .active() + .unwrap() + .doc + .node(vectorcraft_doc::NodeId(line.as_u64().unwrap())) + .unwrap() + .appearance + .stroke() + .unwrap() + .profile + .clone(); + assert_eq!(WidthProfile::id_of(profile.as_ref()), "taperStart"); +} + +/// With Direct Selection, a path whose corner is selected shows that corner's kind and radius; +/// each edit sets that corner alone in one undo step. Not with the Selection tool, nor with the +/// whole path selected; a live rectangle keeps its own Corner Radius row instead. +#[test] +fn direct_selected_corners_show_their_kind_and_radius() { + let mut app = new_app(); + let star = app.run("shape.star", json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + app.run("select.anchors", json!({"id": star, "anchors": [[0, 2]], "mode": "set"})).unwrap(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Corner:"), "not with the Selection tool"); + app.select_tool("directSelection"); + app.run("select.anchors", json!({"id": star, "anchors": [[0, 2]], "mode": "set"})).unwrap(); + let texts = tall(&mut app, &ctx, vec![]); + assert!(shows(&texts, "Corner:") && shows(&texts, "Corner Radius:"), "{texts:?}"); + let corners = |app: &VectorcraftApp| { + let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(star)).unwrap().clone(); + let lc = LiveCorners::of(&n).unwrap(); + (lc.radius(0), lc.radius(2), lc.kind(0), lc.kind(2)) + }; + // The radius: the field right of its label. + let label = text_at(&texts, "Corner Radius:"); + let field = texts.iter().find(|(t, r)| t == "0 pt" && r.left() > label.right() && (r.center().y - label.center().y).abs() < 6.0).unwrap().1; + press(&mut app, &ctx, field.center()); + let depth = undo_depth(&app); + let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + tall(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + tall(&mut app, &ctx, vec![Event::Text("6".into())]); + tall(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + let texts = tall(&mut app, &ctx, vec![]); + assert_eq!(undo_depth(&app), depth + 1, "one undo step"); + let round = vectorcraft_geom::shapes::CornerKind::Round; + assert_eq!(corners(&app), (0.0, 6.0, round, round)); + // The kind, from its list. + let texts = press(&mut app, &ctx, text_at(&texts, "Round").center()); + press(&mut app, &ctx, text_at(&texts, "Chamfer").center()); + assert_eq!(undo_depth(&app), depth + 2, "one undo step"); + assert_eq!(corners(&app), (0.0, 6.0, round, vectorcraft_geom::shapes::CornerKind::Chamfer)); + // The whole path selected. + app.run("select.set", json!({"ids": [star]})).unwrap(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Corner:"), "no corner selected"); + // A live rectangle's corner: its own Corner Radius row, once. + let r = rect(&mut app, 20.0, 20.0, 60.0, 40.0).as_u64().unwrap(); + app.run("select.anchors", json!({"id": r, "anchors": [[0, 1]], "mode": "set"})).unwrap(); + let texts = tall(&mut app, &ctx, vec![]); + assert!(!shows(&texts, "Corner:"), "{texts:?}"); + assert_eq!(texts.iter().filter(|(t, _)| t == "Corner Radius:").count(), 1, "{texts:?}"); +} + +/// One effect on the one selected object shows its name and a Delete icon beside the fx button, +/// which removes it in one undo step, the object's own or its stroke's. With several effects the +/// icon opens the Appearance panel; without one, or with several objects selected, no name. +#[test] +fn one_effect_has_a_delete_icon_in_appearance() { + let mut app = new_app(); + let id = rect(&mut app, 20.0, 20.0, 80.0, 60.0); + let node = vectorcraft_doc::NodeId(id.as_u64().unwrap()); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let spacing = ctx.global_style().spacing.item_spacing.x; + let effects = |app: &VectorcraftApp| { + let n = app.session.active().unwrap().doc.node(node).unwrap().clone(); + n.appearance.effects.len() + n.appearance.items.iter().map(|i| i.effects().len()).sum::() + }; + let name = super::super::appearance::shown_effect_label("stylize.dropShadow"); + assert!(!shows(&tall(&mut app, &ctx, vec![]), &name), "no effect"); + // The object's own effect. + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + let texts = tall(&mut app, &ctx, vec![]); + let depth = undo_depth(&app); + press(&mut app, &ctx, text_at(&texts, &name).right_center() + vec2(spacing + 11.0, 0.0)); + assert_eq!((effects(&app), undo_depth(&app)), (0, depth + 1), "removed in one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(effects(&app), 1); + // The stroke's effect. + app.run("edit.undo", json!({})).unwrap(); + let stroke = app.session.active().unwrap().doc.node(node).unwrap().appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})).unwrap(); + let texts = tall(&mut app, &ctx, vec![]); + let depth = undo_depth(&app); + press(&mut app, &ctx, text_at(&texts, &name).right_center() + vec2(spacing + 11.0, 0.0)); + assert_eq!((effects(&app), undo_depth(&app)), (0, depth + 1), "the stroke's effect removed in one undo step"); + // Two effects: the icon opens the Appearance panel. + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})).unwrap(); + let texts = tall(&mut app, &ctx, vec![]); + assert!(!shows(&texts, &name), "{texts:?}"); + let depth = undo_depth(&app); + press(&mut app, &ctx, text_at(&texts, "fx").center() + vec2(17.0 + spacing + 11.0, 0.0)); + assert_eq!(app.ui.open_panel.as_deref(), Some("appearance")); + assert_eq!((effects(&app), undo_depth(&app)), (2, depth)); + // Two objects with one effect each: no name. + app.run("edit.undo", json!({})).unwrap(); + let other = rect(&mut app, 200.0, 20.0, 40.0, 40.0); + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + app.run("select.set", json!({"ids": [id, other]})).unwrap(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), &name), "two objects"); +} From 14540d6a4411177c4865b87a2509692b26a37dac Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:25 +0000 Subject: [PATCH 018/199] refactor(engine): move cmd/typecmd.rs area_tests to cmd/typecmd/area_tests.rs --- crates/engine/src/cmd/typecmd.rs | 1414 +------------------ crates/engine/src/cmd/typecmd/area_tests.rs | 1411 ++++++++++++++++++ 2 files changed, 1412 insertions(+), 1413 deletions(-) create mode 100644 crates/engine/src/cmd/typecmd/area_tests.rs diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 886381cd5..e0cb73a1c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1114,1419 +1114,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod area_tests { - use super::*; - - /// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box - /// `document.inspect` reports for it, so a script can place it without a helper object. - #[test] - fn text_create_returns_the_inspect_bounds() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); - let inspected = |s: &mut Session, id: &Value| { - let doc = s.execute("document.inspect", &json!({})).unwrap(); - fn find(v: &Value, id: &Value) -> Option { - if &v["id"] == id { - return Some(v["bounds"].clone()); - } - v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id)) - } - find(&doc, id).unwrap() - }; - let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap(); - assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}"); - assert_eq!(made["bounds"], inspected(&mut s, &made["id"])); - let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap(); - let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap(); - assert_eq!(area["bounds"], inspected(&mut s, &area["id"])); - } - - /// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on - /// it, or ending there), and `text.setStyle` moves it the same way afterwards. - #[test] - fn text_create_justify_anchors_point_type_on_its_point() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); - let bounds = |s: &Session, id: &Value| { - let id = NodeId(id.as_u64().unwrap()); - let st = s.doc().unwrap(); - st.doc.bounds_of(&[id], false).unwrap() - }; - let make = |s: &mut Session, extra: Value| { - let mut p = json!({"x": 300, "y": 100, "text": "Centred title", "size": 24}); - for (k, v) in extra.as_object().unwrap() { - p[k] = v.clone(); - } - s.execute("text.create", &p).unwrap()["id"].clone() - }; - let id = make(&mut s, json!({})); - let left = bounds(&s, &id); - assert!(left.width() > 10.0, "{left:?}"); - assert!((left.x0 - 300.0).abs() < 1.0, "left (the default) starts at x: {left:?}"); - let id = make(&mut s, json!({"justify": "center"})); - let centre = bounds(&s, &id); - assert!((centre.center().x - 300.0).abs() < 0.5, "centred on x: {centre:?}"); - assert!((centre.width() - left.width()).abs() < 0.5); - let right = make(&mut s, json!({"justify": "right"})); - let r = bounds(&s, &right); - assert!((r.x1 - 300.0).abs() < 1.0, "ends at x: {r:?}"); - let got = |s: &Session, id: &Value| match &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { - NodeKind::Text(t) => t.para.justify, - _ => panic!("not text"), - }; - assert_eq!(got(&s, &right), Justify::Right); - // Changed afterwards, it moves to the same place as made centred. - s.execute("text.setStyle", &json!({"id": right, "justify": "center"})).unwrap(); - let moved = bounds(&s, &right); - assert!((moved.center().x - 300.0).abs() < 0.5, "{moved:?}"); - assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "justify": "middle"})).is_err()); - // Type in or on a path takes it too. - let path = s.execute("path.create", &json!({"d": "M50 300 L550 300"})).unwrap()["id"].clone(); - let on = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "On a path", "justify": "center"})).unwrap(); - assert_eq!(got(&s, &on["id"]), Justify::Center); - } - - #[test] - fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { - use vectorcraft_doc::Kinsoku; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あカッ", "area": {"width": 40, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para.clone(), - _ => panic!("text"), - }; - // New type: Hard, today's set. - assert_eq!(para(&s).kinsoku, Kinsoku::Hard); - assert!(serde_json::to_value(para(&s)).unwrap().get("kinsoku").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"kinsoku": "strict"})).is_err()); - s.execute("text.setFormat", &json!({"kinsoku": "soft"})).unwrap(); - assert_eq!(para(&s).kinsoku, Kinsoku::Soft); - assert_eq!(serde_json::to_value(para(&s)).unwrap()["kinsoku"], "soft"); - s.execute("text.setFormat", &json!({"kinsoku": "none"})).unwrap(); - assert_eq!(para(&s).kinsoku, Kinsoku::None); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(para(&s).kinsoku, Kinsoku::Hard); - // Documents from before it read as Hard. - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); - assert_eq!(old.kinsoku, Kinsoku::Hard); - } - - #[test] - fn burasagari_is_set_per_paragraph_and_saved_only_when_on() { - use vectorcraft_doc::Burasagari; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "雅楽、笙。", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para.clone(), - _ => panic!("text"), - }; - // New type: Standard. - assert_eq!(para(&s).burasagari, Burasagari::Standard); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"burasagari": "strong"})).is_err()); - s.execute("text.setFormat", &json!({"burasagari": "forced"})).unwrap(); - assert_eq!(para(&s).burasagari, Burasagari::Forced); - assert_eq!(serde_json::to_value(para(&s)).unwrap()["burasagari"], "forced"); - s.execute("text.setFormat", &json!({"burasagari": "none"})).unwrap(); - assert_eq!(para(&s).burasagari, Burasagari::None); - // Saved only when on; documents from before it read as None. - assert!(serde_json::to_value(para(&s)).unwrap().get("burasagari").is_none()); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(para(&s).burasagari, Burasagari::Standard); - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); - assert_eq!(old.burasagari, Burasagari::None); - } - - /// Type › Optical Margin Alignment: `type.opticalMarginAlignment` sets or toggles the flag on - /// the selected type in one undo step, the opening quotation mark then hangs left of the - /// frame, and the flag is saved only when on and survives a native save. - #[test] - fn optical_margin_alignment_is_set_per_object_and_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s - .execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted” words", "area": {"width": 300, "height": 100}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - // The left edge of the glyphs in text space, where the frame's left edge is at 0. - let left = |s: &Session| text(s).cached_bounds.unwrap().x0; - // New type: off, and not saved. - assert!(!text(&s).optical_margin); - assert!(serde_json::to_value(text(&s)).unwrap().get("opticalMarginAlignment").is_none()); - let inside = left(&s); - assert!(inside >= 0.0, "{inside}"); - // Nothing but a rectangle selected: an error. - let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [rect]})).unwrap(); - assert!(s.execute("type.opticalMarginAlignment", &json!({})).is_err()); - // Toggled on: the quotation mark hangs outside the frame. - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], true); - assert!(text(&s).optical_margin); - assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); - assert_eq!(serde_json::to_value(text(&s)).unwrap()["opticalMarginAlignment"], true); - // `on` sets it; without `on` it toggles. - assert_eq!(s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap()["on"], true); - assert!(text(&s).optical_margin); - assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); - assert!(!text(&s).optical_margin); - assert_eq!(left(&s), inside); - // One undo step each. - s.execute("edit.undo", &json!({})).unwrap(); - assert!(text(&s).optical_margin); - // Saved and reopened as it was; a document from before it reads as off. - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - assert!(t.optical_margin); - let mut old = serde_json::to_value(text(&s)).unwrap(); - old.as_object_mut().unwrap().remove("opticalMarginAlignment"); - assert!(!serde_json::from_value::(old).unwrap().optical_margin); - } - - /// Character panel menu › System Layout: `type.systemLayout` sets the document and every text - /// object in one undo step. New type inherits it. Off by default and in a document from before - /// the flag, and saved only when on. On, each positive advance is a whole number of points. - #[test] - fn system_layout_sets_the_document_and_every_text_object() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); - let other = s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap(); - let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let setup = |s: &Session| s.doc().unwrap().doc.setup.clone(); - let advances = |s: &Session| { - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).glyphs.iter().map(|g| g.advance).collect::>() - }; - assert!(!setup(&s).system_layout); - assert!(!text(&s, id).system_layout && !text(&s, other).system_layout); - assert!(serde_json::to_value(setup(&s)).unwrap().get("systemLayout").is_none()); - assert!(serde_json::to_value(text(&s, id)).unwrap().get("systemLayout").is_none()); - let fractional = advances(&s); - assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); - assert_eq!(s.execute("type.systemLayout", &json!({})).unwrap()["on"], true); - assert!(setup(&s).system_layout); - assert!(text(&s, id).system_layout && text(&s, other).system_layout); - let whole = advances(&s); - assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); - assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); - assert_eq!(serde_json::to_value(setup(&s)).unwrap()["systemLayout"], true); - assert_eq!(serde_json::to_value(text(&s, id)).unwrap()["systemLayout"], true); - // New type inherits the document's setting. - let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap(); - assert!(text(&s, made).system_layout); - // A boolean given as text is an error, and the setting stays on. - assert!(s.execute("type.systemLayout", &json!({"on": "no"})).is_err()); - assert!(setup(&s).system_layout && text(&s, id).system_layout); - assert_eq!(s.execute("type.systemLayout", &json!({"on": false})).unwrap()["on"], false); - assert!(!setup(&s).system_layout && !text(&s, id).system_layout && !text(&s, made).system_layout); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(setup(&s).system_layout && text(&s, id).system_layout); - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert!(back.setup.system_layout); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - assert!(t.system_layout); - let mut old = serde_json::to_value(setup(&s)).unwrap(); - old.as_object_mut().unwrap().remove("systemLayout"); - assert!(!serde_json::from_value::(old).unwrap().system_layout); - } - - /// Character panel menu › Fractional Widths: `type.fractionalWidths` sets or toggles the flag - /// on the selected type in one undo step. New type and a document from before the flag are on, - /// and the flag is saved only when off. Off, each positive advance is a whole number of points. - #[test] - fn fractional_widths_is_set_per_object_and_saved_only_when_off() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let advances = - |s: &Session| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).glyphs.iter().map(|g| g.advance).collect::>(); - // New type: on, and not saved. - assert!(text(&s).fractional_widths); - assert!(serde_json::to_value(text(&s)).unwrap().get("fractionalWidths").is_none()); - let fractional = advances(&s); - assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); - // Nothing but a rectangle selected: an error. - let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [rect]})).unwrap(); - assert!(s.execute("type.fractionalWidths", &json!({})).is_err()); - // Toggled off: whole-point advances, and the key is written. - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], false); - assert!(!text(&s).fractional_widths); - let whole = advances(&s); - assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); - assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); - assert_eq!(serde_json::to_value(text(&s)).unwrap()["fractionalWidths"], false); - // A boolean given as text is an error, and the flag stays off. - assert!(s.execute("type.fractionalWidths", &json!({"on": "no"})).is_err()); - assert!(!text(&s).fractional_widths); - assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], true); - assert!(text(&s).fractional_widths); - assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); - assert!(!text(&s).fractional_widths); - // One undo step. - s.execute("edit.undo", &json!({})).unwrap(); - assert!(text(&s).fractional_widths); - // Saved and reopened as it was; a document from before it reads as on. - s.execute("type.fractionalWidths", &json!({"on": false})).unwrap(); - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - assert!(!t.fractional_widths); - let mut old = serde_json::to_value(text(&s)).unwrap(); - old.as_object_mut().unwrap().remove("fractionalWidths"); - assert!(serde_json::from_value::(old).unwrap().fractional_widths); - } - - /// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation - /// options, rounded and clamped, in one undo step; they are saved only when not the defaults, - /// and paragraph styles carry them. - #[test] - fn hyphenation_options_are_set_per_paragraph_and_saved_only_when_not_default() { - use vectorcraft_doc::HyphenationOptions; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), - _ => panic!("text"), - }; - assert_eq!(paras(&s).0.hyphenation, HyphenationOptions::default()); - assert!(serde_json::to_value(paras(&s).0).unwrap().get("hyphenation").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute( - "text.setFormat", - &json!({"wordsLongerThan": 7.6, "afterFirst": 0, "beforeLast": 40, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, "start": 5, "end": 5}), - ) - .unwrap(); - let want = HyphenationOptions { words_longer_than: 8, after_first: 1, before_last: 15, hyphen_limit: 2, zone: 18.0, capitalized: false }; - let (first, second) = paras(&s); - assert_eq!((first.hyphenation, &second.hyphenation), (HyphenationOptions::default(), &want), "only the paragraph the caret is in"); - assert_eq!(serde_json::to_value(&second).unwrap()["hyphenation"]["hyphen_limit"], 2); - let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap(); - assert_eq!(back.hyphenation, want); - s.execute("text.setFormat", &json!({"hyphenLimit": -3, "hyphenationZone": 5000})).unwrap(); - assert_eq!((paras(&s).0.hyphenation.hyphen_limit, paras(&s).1.hyphenation.zone), (0, 1296.0)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(paras(&s).1.hyphenation, HyphenationOptions::default()); - // A paragraph style carries them; an older file's paragraph reads the defaults. - s.execute("paraStyle.new", &json!({"name": "Loose", "attrs": {"hyphenation": {"words_longer_than": 9}}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Loose", "id": id})).unwrap(); - assert_eq!(paras(&s).0.hyphenation, HyphenationOptions { words_longer_than: 9, ..HyphenationOptions::default() }); - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap(); - assert_eq!(old.hyphenation, HyphenationOptions::default()); - } - - /// Paragraph panel menu › Roman Hanging Punctuation: `text.setFormat {romanHangingPunctuation}` - /// sets it per paragraph in one undo step, the opening quotation mark then hangs left of the - /// frame, it is saved only when on, and paragraph styles carry it. - #[test] - fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s - .execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted”\n“Two”", "area": {"width": 300, "height": 200}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let left = |s: &Session| text(s).cached_bounds.unwrap().x0; - assert!(!text(&s).para_at(0).roman_hanging); - assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("roman_hanging").is_none()); - let inside = left(&s); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - // Only the first paragraph (the caret's range). - s.execute("text.setFormat", &json!({"romanHangingPunctuation": true, "start": 0, "end": 0})).unwrap(); - assert!(text(&s).para_at(0).roman_hanging && !text(&s).para_at(1).roman_hanging); - assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); - assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["roman_hanging"], true); - let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap(); - assert!(back.roman_hanging); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!text(&s).para_at(0).roman_hanging); - assert!((left(&s) - inside).abs() < 1e-9); - // A paragraph style carries it. - s.execute("paraStyle.new", &json!({"name": "Hung", "attrs": {"roman_hanging": true}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Hung", "id": id})).unwrap(); - assert!(text(&s).para_at(1).roman_hanging); - } - - /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one - /// undo step, the paragraph is saved with it only when it is not None and opens with it, an - /// unknown value changes nothing, and paragraph styles carry it. - #[test] - fn insert_kashidas_is_set_per_paragraph_and_saved_only_when_set() { - use vectorcraft_doc::text::Kashida; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s - .execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "كتب محمد\nعلي", "area": {"width": 300, "height": 200}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - assert_eq!(text(&s).para_at(0).kashida, Kashida::None); - assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kashida").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - // Only the first paragraph (the caret's range). - s.execute("text.setFormat", &json!({"kashida": "medium", "start": 0, "end": 0})).unwrap(); - assert_eq!((text(&s).para_at(0).kashida, text(&s).para_at(1).kashida), (Kashida::Medium, Kashida::None)); - assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kashida"], "medium"); - let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!((back.para_at(0).kashida, back.para_at(1).kashida), (Kashida::Medium, Kashida::None)); - assert!(s.execute("text.setFormat", &json!({"kashida": "wide"})).is_err()); - assert_eq!(text(&s).para_at(0).kashida, Kashida::Medium, "an unknown value changes nothing"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s).para_at(0).kashida, Kashida::None); - // A paragraph style carries it. - s.execute("paraStyle.new", &json!({"name": "Stretched", "attrs": {"kashida": "stylistic"}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Stretched", "id": id})).unwrap(); - assert_eq!(text(&s).para_at(1).kashida, Kashida::Stylistic); - } - - /// Paragraph panel › Bullets: `text.setFormat {list, bullet}` makes the paragraphs it touches a - /// bulleted list and refuses unknown values. A paragraph that joins a list with no left or - /// first-line indent gets 18 pt and -18 pt, unless the call sets an indent, and one with indents - /// keeps them. The list is saved only when set and opens with the file, Return continues it, - /// "none" turns it off, and paragraph styles set it and turn it off. - #[test] - fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { - use vectorcraft_doc::text::{Bullet, ParaList}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = - s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let list = |s: &Session, i: usize| text(s).para_at(i).list; - let indents = |s: &Session, i: usize| (text(s).para_at(i).left_indent, text(s).para_at(i).first_line_indent); - let dot = Some(ParaList::bulleted(Bullet::Dot)); - assert_eq!(list(&s, 0), None); - assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("list").is_none(), "no list is saved without one"); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"start": 4, "end": 4, "leftIndent": 30, "firstLineIndent": 5})).unwrap(); - // The first two paragraphs: the first gets the hanging indent, the second keeps its indents. - s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 4})).unwrap(); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dot, dot, None)); - assert_eq!((indents(&s, 0), indents(&s, 1), indents(&s, 2)), ((18.0, -18.0), (30.0, 5.0), (0.0, 0.0))); - // `bullet` picks the bullet, and `list: "bullet"` keeps it; the indents stay as they are. - s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); - s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 0})).unwrap(); - let square = Some(ParaList::bulleted(Bullet::Square)); - assert_eq!((list(&s, 0), indents(&s, 0)), (square, (18.0, -18.0))); - for bad in [json!({"list": "numbers"}), json!({"bullet": "star"}), json!({"list": 1}), json!({"bullet": true})] { - assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); - } - assert_eq!((list(&s, 0), list(&s, 2)), (square, None), "an unknown value changes nothing"); - assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); - // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. - let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; - assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); - let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); - // Return at the end of a list item starts another one. - s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 3, "insert": "\nOne more"})).unwrap(); - assert_eq!((list(&s, 1), indents(&s, 1)), (square, (18.0, -18.0))); - s.execute("edit.undo", &json!({})).unwrap(); - // "none" turns the list off and leaves the indents. - s.execute("text.setFormat", &json!({"bullet": "none", "start": 0, "end": 0})).unwrap(); - assert_eq!((list(&s, 0), indents(&s, 0)), (None, (18.0, -18.0))); - s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); - assert_eq!(list(&s, 1), None); - // Indents the call sets win over the hanging indent. - s.execute("text.setFormat", &json!({"list": "bullet", "leftIndent": 0, "firstLineIndent": 0, "start": 8, "end": 8})).unwrap(); - assert_eq!((list(&s, 2), indents(&s, 2)), (dot, (0.0, 0.0))); - // A style that holds a list sets it; a style captured from a paragraph without one turns it off. - s.execute("paraStyle.new", &json!({"name": "Listed", "attrs": {"list": {"bullet": "dash"}}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Listed", "id": id})).unwrap(); - let dash = Some(ParaList::bulleted(Bullet::Dash)); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dash, dash, dash)); - let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); - s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); - } - - /// Numbered lists: `list: "number"` numbers the paragraphs 1, 2, 3 and `number` picks the - /// style; a list switched between bullets and numbers keeps its bullet and its indents; an - /// unknown value is an error; the list is saved, reopened and carried by paragraph styles. - #[test] - fn numbered_lists_are_set_per_paragraph_saved_and_styled() { - use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = - s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let list = |s: &Session, i: usize| text(s).para_at(i).list; - let numbers = |s: &Session| { - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::>() - }; - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"list": "number"})).unwrap(); - let arabic = Some(ParaList::numbered(NumberStyle::Arabic)); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (arabic, arabic, arabic)); - assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); - assert_eq!(numbers(&s), ["1.", "2.", "3."]); - // `number` picks the style, `list: "number"` keeps it, and the count runs on through both. - s.execute("text.setFormat", &json!({"number": "upperRoman", "start": 0, "end": 0})).unwrap(); - s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 0})).unwrap(); - let roman = Some(ParaList::numbered(NumberStyle::UpperRoman)); - assert_eq!(list(&s, 0), roman); - assert_eq!(numbers(&s), ["I.", "2.", "3."]); - // A bullet makes it bulleted, a number style numbered again, and `list: "bullet"` brings - // back the bullet it had; the indents stay as they are. - s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); - assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square))); - s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap(); - let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) }); - assert_eq!(list(&s, 0), letters); - assert_eq!(numbers(&s), ["a.", "2.", "3."]); - assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); - for bad in [json!({"number": "roman"}), json!({"number": 1}), json!({"list": "numbered"}), json!({"number": true})] { - assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); - } - assert_eq!((list(&s, 0), list(&s, 1)), (letters, arabic), "an unknown value changes nothing"); - assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "number": "lowerLetters"})); - let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (letters, arabic, arabic)); - // Return at the end of a numbered item starts the next number. - s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nFour"})).unwrap(); - assert_eq!(numbers(&s), ["a.", "2.", "3.", "4."]); - s.execute("edit.undo", &json!({})).unwrap(); - // "none" turns the list off; the next numbered paragraph starts again at 1. - s.execute("text.setFormat", &json!({"number": "none", "start": 4, "end": 4})).unwrap(); - assert_eq!(list(&s, 1), None); - assert_eq!(numbers(&s), ["a.", "1."]); - // A style that holds a numbered list sets it, a style captured from a numbered paragraph - // carries its list, and one captured from a paragraph without a list turns it off. - s.execute("paraStyle.new", &json!({"name": "Roman", "attrs": {"list": {"number": "upperRoman"}}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Roman", "id": id})).unwrap(); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (roman, roman, roman)); - assert_eq!(numbers(&s), ["I.", "II.", "III."]); - let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); - s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); - s.execute("paraStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Captured", "id": plain})).unwrap(); - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(plain)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!(t.para_at(0).list, roman); - s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); - assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); - } - - /// The glyphs the layout of type `id` draws, each with its glyph, where it is drawn and the - /// transform it is drawn with (in thousandths), from the top line down and left to right. - fn drawn_glyphs(s: &Session, id: u64) -> Vec<(u32, i64, i64, [i64; 6])> { - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let milli = |v: f64| (v * 1000.0).round() as i64; - let mut v: Vec<(u32, i64, i64, [i64; 6])> = lay - .glyphs - .iter() - .filter(|g| !g.outline.elements().is_empty()) - .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y), g.xf.as_coeffs().map(milli))) - .collect(); - v.sort_by_key(|&(_, x, y, _)| (y, x)); - v - } - - /// Type › Bullets and Numbering › Convert to Text: `text.convertListToText` writes each list - /// paragraph's marker before its text as characters in the marker's style, followed by a tab, - /// turns the list off, and gives the paragraph a plain left tab stop at the left indent where - /// the marker's tab went to it, so every glyph is drawn where it was: bullets in area type (a - /// wrapped item, an empty item, a right-aligned stop at the indent and a stop before it, both - /// with leaders), numbers narrower and wider than the hanging indent (one reaching a stop with - /// a leader past it), and point and vertical type. A paragraph with a tab of its own keeps its - /// stops past the indent. The characters can be styled and deleted, and one undo brings the - /// lists back. - #[test] - fn convert_to_text_writes_the_markers_where_they_were_drawn() { - use vectorcraft_doc::text::{Bullet, ParaList}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let markers = |s: &Session, id: u64| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.len(); - let tabs = - |s: &Session, id: u64, k: usize| text(s, id).para_at(k).tabs.iter().map(|t| (t.position, t.align, t.leader.clone())).collect::>(); - let long = "The quick brown fox jumps over the lazy dog and runs far away into the woods beyond the hill"; - let id = s - .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\n{long}\n\nFour\nFive"), "area": {"width": 200, "height": 400}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let (four, five) = (long.len() + 6, long.len() + 11); - s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "bullet": "square", "start": four, "end": four})).unwrap(); - // A right-aligned stop with a leader at the fourth item's left indent, and a stop with a - // leader before the fifth item's: the bullets' tabs go to the indent and to that stop and - // draw no leader. - s.execute("text.tabs.set", &json!({"ids": [id], "start": four, "end": four, "stops": [{"position": 18, "align": "right", "leader": "."}]})) - .unwrap(); - s.execute("text.tabs.set", &json!({"ids": [id], "start": five, "end": five, "stops": [{"position": 10, "leader": "."}]})).unwrap(); - // The first character larger, raised and underlined: the bullet takes its size alone. The - // fourth item's first character is larger than the break before it. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "size": 20, "baselineShift": 4, "underline": true})).unwrap(); - s.execute("text.setRangeStyle", &json!({"id": id, "start": four, "end": four + 1, "size": 20})).unwrap(); - let listed = text(&s, id); - let before = drawn_glyphs(&s, id); - assert_eq!(markers(&s, id), 4, "the empty item has no bullet"); - let reply = s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - let inserted = |at: usize| json!({"id": id, "at": at, "length": 4}); - assert_eq!(reply, json!({"ids": [id], "converted": 5, "inserted": [inserted(0), inserted(4), inserted(four), inserted(five)]})); - assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was"); - let t = text(&s, id); - assert_eq!(t.plain_text(), format!("\u{2022}\tOne\n\u{2022}\t{long}\n\n\u{25AA}\tFour\n\u{2022}\tFive")); - assert!((0..5).all(|k| t.para_at(k).list.is_none()), "no paragraph is in a list"); - assert_eq!(markers(&s, id), 0); - let left = vectorcraft_doc::TabAlign::Left; - let plain_stop = |position| vec![(position, left, String::new())]; - assert_eq!( - (tabs(&s, id, 0), tabs(&s, id, 1), tabs(&s, id, 2), tabs(&s, id, 3), tabs(&s, id, 4)), - (plain_stop(18.0), plain_stop(18.0), vec![], plain_stop(18.0), plain_stop(10.0)), - "a plain left stop at the left indent (in place of the right-aligned one), none in the empty item, and the stop the \ - fifth item's tab went to without its leader" - ); - let bullet = &t.runs[0]; - assert_eq!( - (bullet.text.as_str(), bullet.style.size, bullet.style.baseline_shift, bullet.style.underline), - ("\u{2022}\t", 20.0, 0.0, false), - "the bullet has the first character's size without its baseline shift and underline" - ); - // The characters are text: they take a style and can be deleted. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 30})).unwrap(); - assert_eq!(text(&s, id).runs[0].style.size, 30.0); - s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 4, "insert": ""})).unwrap(); - assert!(text(&s, id).plain_text().starts_with("One\n\u{2022}\t")); - for _ in 0..3 { - s.execute("edit.undo", &json!({})).unwrap(); - } - let back = text(&s, id); - assert_eq!(back.para_at(3).list, Some(ParaList::bulleted(Bullet::Square))); - assert_eq!((back.runs, back.para, back.paras), (listed.runs, listed.para, listed.paras), "one undo step"); - assert_eq!(drawn_glyphs(&s, id), before); - // Numbers 1 to 9 at 20 pt are narrower than the hanging indent, 10 is wider and takes the - // text to the next default stop, and 11 to its stop at 72 pt, whose leader the number's tab - // draws neither before nor after. - let items = (1..=11).map(|i| format!("Item {i}")).collect::>().join("\n"); - let id = - s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); - let eleven = 9 * 7 + 8; - s.execute("text.tabs.set", &json!({"ids": [id], "start": eleven, "end": eleven, "stops": [{"position": 72, "leader": "."}]})).unwrap(); - let before = drawn_glyphs(&s, id); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(drawn_glyphs(&s, id), before, "numbers"); - let expected = (1..=11).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n"); - assert_eq!(text(&s, id).plain_text(), expected); - assert_eq!((tabs(&s, id, 0), tabs(&s, id, 9), tabs(&s, id, 10)), (plain_stop(18.0), vec![], plain_stop(72.0))); - // A paragraph with a tab of its own keeps its stops past the left indent, which that tab - // can reach. - let id = s.execute("text.create", &json!({"x": 10, "y": 650, "size": 30, "text": "Item\tvalue"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); - s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 72, "leader": "."}, {"position": 200}]})).unwrap(); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(text(&s, id).plain_text(), "1.\tItem\tvalue"); - assert_eq!(tabs(&s, id, 0), [(72.0, left, ".".to_string()), (200.0, left, String::new())]); - // A 30 pt number's tab to a right-aligned stop aligns the whole paragraph there, trailing - // space included, and a converted tab aligns the rest of its line: a plain left stop where - // the number's tab ended keeps the text in place. A right-aligned stop before the left stop - // the number's tab reached, which the wrapped paragraph's width kept the number's tab from, - // would catch the converted tab, which measures only the first line: it goes. The lines are - // compared with the Single-line Composer, as a paragraph with a tab is broken line by line. - let right = vectorcraft_doc::TabAlign::Right; - for (text_in, width, stops, kept) in [ - ("One ", 300, json!([{"position": 200, "align": "right"}]), vec![(200.0, right, String::new())]), - ("The quick brown fox jumps over the lazy dog", 230, json!([{"position": 100, "align": "right"}, {"position": 150}]), vec![]), - ] { - let id = s - .execute("text.create", &json!({"x": 10, "y": 600, "size": 30, "text": text_in, "area": {"width": width, "height": 300}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "number", "composer": "singleLine"})).unwrap(); - s.execute("text.tabs.set", &json!({"ids": [id], "stops": stops})).unwrap(); - let before = drawn_glyphs(&s, id); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(drawn_glyphs(&s, id), before, "{text_in:?}"); - let after = tabs(&s, id, 0); - assert_eq!( - (after.first().map(|t| (t.1, t.2.as_str())), after.get(1..).map(<[_]>::to_vec)), - (Some((left, "")), Some(kept)), - "{text_in:?}" - ); - } - // Overset items, which the layout leaves out, get the stop by the same rule: a 30 pt number is - // wider than the hanging indent, a 12 pt bullet is not. - for (size, list, text_after, stop) in - [(30, "number", "1.\tOne\n2.\tTwo", vec![]), (12, "bullet", "\u{2022}\tOne\n\u{2022}\tTwo", plain_stop(18.0))] - { - let id = s - .execute("text.create", &json!({"x": 10, "y": 500, "size": size, "text": "One\nTwo", "area": {"width": 200, "height": size + 10}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": list})).unwrap(); - assert_eq!(markers(&s, id), 1, "the second item is overset"); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!((text(&s, id).plain_text().as_str(), tabs(&s, id, 1)), (text_after, stop), "{size} pt"); - } - // Point type and vertical type. - for vertical in [false, true] { - let id = - s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); - let before = drawn_glyphs(&s, id); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(drawn_glyphs(&s, id), before, "vertical {vertical}"); - assert_eq!(text(&s, id).plain_text(), "1.\tOne\n2.\tTwo"); - } - // Type with no list paragraph: nothing to convert and no undo step. - let plain = s.execute("text.create", &json!({"x": 10, "y": 700, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - assert_eq!(s.execute("text.convertListToText", &json!({"ids": [plain]})).unwrap(), json!({"ids": [], "converted": 0, "inserted": []})); - assert_eq!(s.doc().unwrap().history.undo.len(), steps); - } - - /// A right-to-left list paragraph converted to text starts with the number, its period and a - /// tab, which are read first: the line draws its glyphs in the order it drew them with the - /// marker, the number at the right with its period at its left. Letters and roman numerals - /// would make the paragraph run left to right, so the paragraph gets the direction it ran in; - /// digits leave the direction unset. - #[test] - fn a_right_to_left_list_paragraph_converts_to_text_read_first() { - use vectorcraft_doc::ParaDirection; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - for (number, text, direction) in [ - ("arabic", "1.\tשלום\n2.\tעולם", None), - ("upperLetters", "A.\tשלום\nB.\tעולם", Some(ParaDirection::RightToLeft)), - ("upperRoman", "I.\tשלום\nII.\tעולם", Some(ParaDirection::RightToLeft)), - ] { - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "שלום\nעולם", "area": {"width": 200, "height": 200}})).unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "number": number})).unwrap(); - // `drawn_glyphs` lists the first line first, from left to right. - let order = |s: &Session| { - let glyphs = drawn_glyphs(s, id); - let top = glyphs.first().map_or(0, |g| g.2); - glyphs.into_iter().filter(|g| g.2 == top).map(|g| g.0).collect::>() - }; - let before = order(&s); - s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert_eq!(t.plain_text(), text); - assert!(t.para_at(0).list.is_none() && t.para_at(1).list.is_none()); - let directions = (t.para_at(0).direction, t.para_at(1).direction); - assert_eq!(order(&s), before, "{number}: the first line's glyphs from left to right"); - assert_eq!(directions, (direction, direction), "{number}"); - } - } - - /// Convert to Text in threaded type writes the numbers the story counted through its frames, - /// and both frames draw every glyph where it was. A paragraph that continues into the next - /// frame gets its number once, where it starts, also when only the later frame is named, and - /// empty frames after a converted paragraph leave its list with it. - #[test] - fn threaded_numbers_convert_to_text_as_the_story_counted_them() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("text"), - }; - let numbers = |s: &Session, id: u64| { - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() - }; - let thread = |s: &mut Session, text: &str| { - let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - (a, b) - }; - // Whole paragraphs: the first frame ends with a paragraph break. - let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::>().join("\n")); - let first = numbers(&s, a).len(); - assert!((2..10).contains(&first) && plain(&s, a).ends_with('\n'), "both frames hold items: {first}"); - let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); - let reply = s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap(); - assert_eq!(reply["converted"], 10, "each paragraph once"); - assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames"); - let story = (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n"); - assert_eq!(plain(&s, a) + &plain(&s, b), story); - assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}", n = first + 1))); - // The empty part after the first frame's last break and the second frame's first item are - // one paragraph: a caret at the end of the first frame converts that item. - let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::>().join("\n")); - let end = plain(&s, a).len(); - let reply = s.execute("text.convertListToText", &json!({"ids": [a], "start": end, "end": end})).unwrap(); - assert_eq!(reply["converted"], 1); - assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}\nItem", n = first + 1)), "{}", plain(&s, b)); - // A paragraph split between the frames, with only the second frame named. - let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); - let (a, b) = thread(&mut s, &format!("One\nTwo\n{long}\nFour")); - assert!(!plain(&s, a).ends_with('\n'), "the third paragraph goes on in the second frame"); - // A paragraph with a tab is broken line by line, so the lines are compared with the - // Single-line Composer, which breaks the listed paragraph the same way. - s.execute("text.setFormat", &json!({"ids": [a, b], "composer": "singleLine"})).unwrap(); - let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); - s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); - assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames, with the paragraph split between them"); - assert_eq!(plain(&s, a) + &plain(&s, b), format!("One\nTwo\n3.\t{long}\n4.\tFour")); - assert_eq!(numbers(&s, a), ["1.", "2."], "the paragraphs of the first frame alone stay in the list"); - let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; - assert!(second.para_at(0).list.is_none(), "the third paragraph's part in the second frame leaves the list too"); - // One item that fits in the first of three frames: the two empty frames after it leave the - // list too, so the re-flowed thread draws no number before the converted one. - let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Item", "area": {"width": 150, "height": 60}})).unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); - let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b, c], "list": "number"})).unwrap(); - assert_eq!((plain(&s, b), plain(&s, c), numbers(&s, a)), (String::new(), String::new(), vec!["1.".to_string()])); - assert_eq!(s.execute("text.convertListToText", &json!({"ids": [a]})).unwrap()["converted"], 1); - assert_eq!(plain(&s, a), "1.\tItem"); - assert_eq!(numbers(&s, a), Vec::::new(), "no number drawn before the converted one"); - for id in [a, b, c] { - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - assert!(t.para_at(0).list.is_none(), "frame {id} leaves the list"); - } - // An overset 30 pt number, wider than the hanging indent, in a last frame that starts - // inside a paragraph: its tab goes to the next stop, so it gets no stop at the left indent. - let a = s - .execute("text.create", &json!({"x": 10, "y": 10, "size": 30, "text": format!("{long}\nTwo"), "area": {"width": 150, "height": 80}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 80})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; - assert!(second.first_para_continues && second.plain_text().ends_with("\nTwo"), "{:?}", second.plain_text()); - assert_eq!(numbers(&s, b).len(), 0, "the second item is overset"); - s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); - let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; - assert!(second.plain_text().ends_with("\n2.\tTwo"), "{:?}", second.plain_text()); - assert!(second.para_at(1).tabs.is_empty(), "no stop at the left indent: {:?}", second.para_at(1).tabs); - } - - /// `text.convertListToText` sent while the Type tool edits (as from MCP or the control channel) - /// moves the tool's anchor and caret past the characters it inserts at or before them, so that - /// neither lands inside a bullet. In threaded type, when the text before the caret moves to the - /// frame before, the caret goes with its character into that frame, which becomes the - /// selection. - #[test] - fn convert_to_text_moves_the_type_tools_selection() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("text"), - }; - let selection = |s: &Session| { - let o = s.tool_options(); - (o["editing"].as_u64(), o["anchor"].as_u64(), o["caret"].as_u64()) - }; - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.select_tool("type", Default::default()).unwrap(); - // From after the O of One to after the T of Two. - s.set_tool_option("textSelection", &json!({"editing": id, "anchor": 1, "caret": 5})); - s.execute("text.convertListToText", &json!({})).unwrap(); - let text = plain(&s, id); - assert_eq!(text, "\u{2022}\tOne\n\u{2022}\tTwo"); - assert_eq!(selection(&s), (Some(id), Some(5), Some(13)), "the anchor after the O, the caret after the T"); - assert!(text.is_char_boundary(5) && text.is_char_boundary(13)); - - let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); - let a = s - .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\nTwo\n{long}\nFour"), "area": {"width": 150, "height": 60}})) - .unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); - s.select_tool("selection", Default::default()).unwrap(); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - assert!(plain(&s, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&s, b)); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.select_tool("type", Default::default()).unwrap(); - // The caret after the d of dog, at the start of the second frame. - s.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1})); - s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); - let first = plain(&s, a); - assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}"); - let after_d = (first.len() - "og. ".len()) as u64; - assert_eq!(selection(&s), (Some(a), Some(after_d), Some(after_d)), "the caret is after the d of dog in the first frame"); - assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); - } - - /// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. - #[test] - fn create_outlines_makes_a_path_of_the_bullet() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let outlined = |s: &mut Session, list: bool| { - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One"})).unwrap()["id"].as_u64().unwrap(); - if list { - s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); - } - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); - s.doc().unwrap().doc.node(NodeId(g)).unwrap().children().unwrap().len() - }; - let plain = outlined(&mut s, false); - assert_eq!(outlined(&mut s, true), plain + 1, "one more path: the bullet"); - } - - /// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and - /// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their - /// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They - /// are saved only when not the defaults, and paragraph styles carry them. - #[test] - fn justification_options_are_set_per_paragraph_and_saved_only_when_not_default() { - use vectorcraft_doc::{JustificationOptions, SingleWordJustify}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), - _ => panic!("text"), - }; - assert_eq!(paras(&s).0.justification, JustificationOptions::default()); - assert!(serde_json::to_value(paras(&s).0).unwrap().get("justification").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute( - "text.setFormat", - &json!({ - "wordSpacing": [60, 90, 2000], "letterSpacing": {"max": 10}, "glyphScaling": [95, 100, 105], - "autoLeading": 150, "singleWordJustification": "alignCenter", "start": 5, "end": 5 - }), - ) - .unwrap(); - let want = JustificationOptions { - word_spacing: [60.0, 90.0, 1000.0], - letter_spacing: [0.0, 0.0, 10.0], - glyph_scaling: [95.0, 100.0, 105.0], - auto_leading: 150.0, - single_word: SingleWordJustify::AlignCenter, - }; - let (first, second) = paras(&s); - assert_eq!((first.justification, &second.justification), (JustificationOptions::default(), &want), "only the paragraph the caret is in"); - assert_eq!(serde_json::to_value(&second).unwrap()["justification"]["single_word"], "alignCenter"); - let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap(); - assert_eq!(back.justification, want); - // Out of order: nothing changes, and the error names the setting. - let e = s.execute("text.setFormat", &json!({"wordSpacing": {"desired": 85}, "letterSpacing": {"min": 5}})).unwrap_err().to_string(); - assert!(e.contains("letterSpacing"), "{e}"); - assert_eq!(paras(&s).1.justification, want); - assert!(s.execute("text.setFormat", &json!({"glyphScaling": [100, 100]})).is_err()); - assert!(s.execute("text.setFormat", &json!({"singleWordJustification": "spread"})).is_err()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(paras(&s).1.justification, JustificationOptions::default()); - // A paragraph style carries them; an older file's paragraph reads the defaults. - s.execute("paraStyle.new", &json!({"name": "Airy", "attrs": {"justification": {"auto_leading": 200.0}}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Airy", "id": id})).unwrap(); - assert_eq!(paras(&s).0.justification, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() }); - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap(); - assert_eq!(old.justification, JustificationOptions::default()); - } - - /// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts - /// with em box top-to-top leading and em box centre alignment, and new styles made from nothing - /// carry them; params override; the values are journaled; imported text keeps the Roman - /// baseline. - #[test] - fn new_type_takes_the_japanese_defaults_while_the_interface_is_japanese() { - use vectorcraft_doc::{CharAlign, LeadingModel}; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let made = |s: &mut Session, cmd: &str, p: Value| -> (LeadingModel, CharAlign) { - let id = s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap(); - match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (t.para.leading_model, t.runs[0].style.char_align), - _ => panic!("text"), - } - }; - let roman = (LeadingModel::RomanBaseline, CharAlign::RomanBaseline); - let japanese = (LeadingModel::EmBoxTop, CharAlign::EmBoxCenter); - let point = json!({"x": 10, "y": 50, "text": "雅楽"}); - assert!(!s.japanese_interface()); - assert_eq!(made(&mut s, "text.create", point.clone()), roman, "headless, `auto`: the Roman defaults"); - s.ui_language = Some("ja".into()); - assert_eq!(made(&mut s, "text.create", point.clone()), japanese); - assert_eq!(made(&mut s, "text.create", json!({"x": 10, "y": 90, "text": "笙", "area": {"width": 100, "height": 50}})), japanese); - // Journaled: a replay in another language does the same. - let (_, logged) = s.journal.last().unwrap().clone(); - assert_eq!((logged["leadingModel"].as_str(), logged["charAlign"].as_str()), (Some("emBoxTop"), Some("emBoxCenter"))); - // Params override. - assert_eq!( - made(&mut s, "text.create", json!({"x": 10, "y": 120, "text": "a", "leadingModel": "romanBaseline", "charAlign": "romanBaseline"})), - roman - ); - assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "leadingModel": "middle"})).is_err()); - // Area type in a path. - let path = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 100, "height": 80})).unwrap()["id"].clone(); - assert_eq!(made(&mut s, "text.createInPath", json!({"path": path, "mode": "area", "text": "篳篥"})), japanese); - // A preference set to Japanese counts without a UI; another language doesn't. - s.ui_language = None; - s.prefs.interface_language = "ja".into(); - assert_eq!(made(&mut s, "text.create", point.clone()), japanese); - s.ui_language = Some("en".into()); - assert_eq!(made(&mut s, "text.create", point.clone()), roman); - // New styles made from nothing. - s.execute("select.set", &json!({"ids": []})).unwrap(); - s.ui_language = Some("ja".into()); - let style = |s: &mut Session, kind: &str| { - let name = s.execute(&format!("{kind}.new"), &json!({})).unwrap()["name"].as_str().unwrap().to_string(); - let list = s.execute(&format!("{kind}.list"), &json!({})).unwrap(); - list["styles"].as_array().unwrap().iter().find(|st| st["name"] == name.as_str()).unwrap()["attrs"].clone() - }; - assert_eq!(style(&mut s, "paraStyle"), json!({"leading_model": "emBoxTop"})); - assert_eq!(style(&mut s, "charStyle"), json!({"charAlign": "emBoxCenter"})); - s.ui_language = Some("en".into()); - assert_eq!(style(&mut s, "paraStyle"), json!({})); - // Imported text keeps the Roman baseline. - s.ui_language = Some("ja".into()); - let svg = r#"雅楽"#; - s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); - let mut found = vec![]; - s.doc().unwrap().doc.walk(|n| { - if let NodeKind::Text(t) = &n.kind { - found.push((t.para.leading_model, t.runs[0].style.char_align)); - } - }); - assert_eq!(found, [roman]); - } - - #[test] - fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() { - use vectorcraft_doc::LeadingModel; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - // Exercise ink bounds with bundled glyphs; Japanese outlines require optional craft-fonts. - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A\nB", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); - let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - assert_eq!(text(&s).para.leading_model, LeadingModel::RomanBaseline); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"leadingModel": "middle"})).is_err()); - let before = text(&s).cached_bounds; - s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap(); - let t = text(&s); - assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop); - // The fallback used without craft-fonts has no glyph metrics to move the first line. - if !vectorcraft_text::CRAFT_FONTS.is_empty() { - assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top"); - } - assert_eq!(serde_json::to_value(&t.para).unwrap()["leading_model"], "emBoxTop"); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(serde_json::to_value(&text(&s).para).unwrap().get("leading_model").is_none()); - } - - #[test] - fn character_alignment_is_a_character_attribute_saved_only_when_set() { - use vectorcraft_doc::CharAlign; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert_eq!(runs(&s)[0].style.char_align, CharAlign::RomanBaseline); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charAlign").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"charAlign": "middle"})).is_err()); - s.execute("text.setFormat", &json!({"charAlign": "emBoxCenter"})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.char_align == CharAlign::EmBoxCenter)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], "emBoxCenter"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(runs(&s)[0].style.char_align, CharAlign::RomanBaseline); - // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). - assert!(s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "top"})).is_err()); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "emBoxTop"})).unwrap(); - let aligns: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.char_align)).collect(); - assert_eq!(aligns, [("雅".to_string(), CharAlign::RomanBaseline), ("楽".to_string(), CharAlign::EmBoxTop)]); - // The ideographic character face's top and bottom. - for (key, want) in [("icfTop", CharAlign::IcfTop), ("icfBottom", CharAlign::IcfBottom)] { - s.execute("text.setFormat", &json!({"charAlign": key})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.char_align == want)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], key); - } - } - - /// Character Direction (Character panel menu, Middle Eastern options): a character attribute - /// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when - /// set and kept by a native save. - #[test] - fn character_direction_is_a_character_attribute_saved_only_when_set() { - use vectorcraft_doc::ParaDirection; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(); - assert_eq!(runs(&s)[0].style.char_direction, None); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] { - s.execute("text.setFormat", &json!({"charDirection": key})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.char_direction == want)); - let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); - assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key)); - } - let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string(); - assert!(err.contains("`charDirection` must be"), "{err}"); - // Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes). - s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); - assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none())); - s.execute("edit.redo", &json!({})).unwrap(); - // Saved and reopened as it was. - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect(); - assert_eq!(reopened, set(&s)); - // A character style captured from the characters carries it, Default included. - assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight"); - assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null)); - } - - /// Proportional Metrics (#966): a character attribute, saved only when on. - #[test] - fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert!(!runs(&s)[0].style.proportional_metrics); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.proportional_metrics)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!runs(&s)[0].style.proportional_metrics); - // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). - s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap(); - let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect(); - assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]); - // Styles written without it (documents from before it) read as off. - let old = serde_json::to_value(&runs(&s)[0].style).unwrap(); - assert!(old.get("proportionalMetrics").is_none()); - assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); - } - - /// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only - /// when on. Documents written before it read as off. - #[test] - fn tate_chu_yoko_is_a_character_attribute_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "2026年"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert!(!runs(&s)[0].style.tate_chu_yoko); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("tateChuYoko").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"tateChuYoko": true})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.tate_chu_yoko)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["tateChuYoko"], true); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!runs(&s)[0].style.tate_chu_yoko); - // The year only (2026 is 4 bytes); 年 stays unmarked. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "tateChuYoko": true})).unwrap(); - let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.tate_chu_yoko)).collect(); - assert_eq!(on, [("2026".to_string(), true), ("年".to_string(), false)]); - let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); - assert!(old.get("tateChuYoko").is_none()); - assert!(!serde_json::from_value::(old).unwrap().tate_chu_yoko); - s.execute("text.setFormat", &json!({"tateChuYoko": false})).unwrap(); - assert!(runs(&s).iter().all(|r| !r.style.tate_chu_yoko)); - } - - /// No Break is a character attribute, saved only when on. - #[test] - fn no_break_is_a_character_attribute_saved_only_when_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert!(!runs(&s)[0].style.no_break); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("noBreak").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"noBreak": true})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.no_break)); - assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["noBreak"], true); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!runs(&s)[0].style.no_break); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "noBreak": true})).unwrap(); - let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.no_break)).collect(); - assert_eq!(on, [("A".to_string(), true), ("b".to_string(), false)]); - let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); - assert!(old.get("noBreak").is_none()); - assert!(!serde_json::from_value::(old).unwrap().no_break); - s.execute("text.setFormat", &json!({"noBreak": false})).unwrap(); - assert!(runs(&s).iter().all(|r| !r.style.no_break)); - } - - /// Digits (Character panel menu, Middle Eastern options): a character attribute, saved only - /// when set; the text keeps the digits that were typed. - #[test] - fn digits_are_a_character_attribute_saved_only_when_set() { - use vectorcraft_doc::Digits; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "صفحة 12"})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - assert_eq!(runs(&s)[0].style.digits, Digits::Default); - assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("digits").is_none()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - for (key, want) in [("hindi", Digits::Hindi), ("farsi", Digits::Farsi), ("arabic", Digits::Arabic), ("default", Digits::Default)] { - s.execute("text.setFormat", &json!({"digits": key})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.digits == want)); - let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); - assert_eq!(saved.get("digits").and_then(|v| v.as_str()), (want != Digits::Default).then_some(key)); - // The text is as typed: only the glyphs change. - assert_eq!(runs(&s).iter().map(|r| r.text.as_str()).collect::(), "صفحة 12"); - } - s.execute("text.setFormat", &json!({"digits": "hindi"})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(runs(&s)[0].style.digits, Digits::Default); - let err = s.execute("text.setFormat", &json!({"digits": "roman"})).unwrap_err().to_string(); - assert!(err.contains("`digits` must be"), "{err}"); - // Characters selected with the Type tool: only the range takes it (صفحة is 8 bytes). - s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 11, "digits": "farsi"})).unwrap(); - let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.digits)).collect(); - assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]); - } - - /// Variable Font settings (Character panel › Variable Font): a character attribute, saved only - /// when set, each value clamped to its axis's range, and cleared by another font or style. - #[test] - fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { - let _fonts = crate::adding_fonts(); - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font}; - vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "lIn lIn", "font": FAMILY, "size": 40})).unwrap()["id"].as_u64().unwrap(); - let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!("text"), - }; - let saved = |s: &Session| serde_json::to_value(&runs(s)[0].style).unwrap().get("fontAxes").cloned(); - let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap().width(); - assert_eq!(saved(&s), None); - let regular = width(&s); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wght": 650.0}))); - assert!(width(&s) > regular * 1.1, "the type is set heavier and wider: {regular} -> {}", width(&s)); - // Past the axis's range: its maximum; an axis the font hasn't: left out; others kept. - s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000, "opsz": 12, "wdth": 80}})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wdth": 80.0, "wght": 700.0}))); - // Null goes back to the style's own setting, and so does a value equal to it. - s.execute("text.setFormat", &json!({"fontAxes": {"wght": null}})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wdth": 80.0}))); - s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 100}})).unwrap(); - assert_eq!(saved(&s), None); - assert!((width(&s) - regular).abs() < 1e-6); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wdth": 80.0}))); - // Characters selected with the Type tool: only the range takes it. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "fontAxes": {"wght": 550}})).unwrap(); - let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.font_axes.clone())).collect(); - let axes = |v: &[(&str, f64)]| v.iter().map(|(t, v)| (t.to_string(), *v)).collect::>(); - assert_eq!(set, [("lIn ".to_string(), axes(&[("wdth", 80.0)])), ("lIn".to_string(), axes(&[("wdth", 80.0), ("wght", 550.0)]))]); - // A saved file keeps them. - let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; - assert_eq!(t.runs, runs(&s)); - // Another style or font clears them: their settings were the old face's. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "style": "Bold"})).unwrap(); - assert_eq!(runs(&s).last().map(|r| (r.style.font_style.as_str(), r.style.font_axes.is_empty())), Some(("Bold", true))); - s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); - s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap(); - s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap(); - assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); - // A static font has no axes to set. - s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); - assert_eq!(saved(&s), None); - // A missing font keeps them as given, for when it is installed. - s.execute("text.setStyle", &json!({"font": "No Such Variable Sans"})).unwrap(); - s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000}})).unwrap(); - assert_eq!(saved(&s), Some(json!({"wght": 2000.0}))); - for (bad, says) in - [(json!({"weight": 650}), "not an axis tag"), (json!({"wght": "heavy"}), "must be a number"), (json!(650), "must be an object")] - { - let err = s.execute("text.setFormat", &json!({"fontAxes": bad})).unwrap_err().to_string(); - assert!(err.contains(says), "{err}"); - } - // The axes and the setting of a style, as the sliders show them. - let list = s.execute("text.fontList", &json!({"family": FAMILY, "style": "Bold"})).unwrap(); - assert_eq!( - list["axes"], - json!([ - {"tag": "wght", "name": "Weight", "min": 400.0, "default": 400.0, "max": 700.0, "value": 700.0, "hidden": false}, - {"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false}, - ]) - ); - assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([])); - } - - #[test] - fn new_type_is_composed_with_line_end_half_width_punctuation() { - use vectorcraft_doc::Mojikumi; - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽。"})).unwrap()["id"].as_u64().unwrap(); - let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para.clone(), - _ => panic!("text"), - }; - assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - assert!(s.execute("text.setFormat", &json!({"mojikumi": "everything"})).is_err()); - s.execute("text.setFormat", &json!({"mojikumi": "none"})).unwrap(); - assert_eq!(para(&s).mojikumi, Mojikumi::None); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); - // Saved only when set; documents from before it read as None. - let json = serde_json::to_value(para(&s)).unwrap(); - assert_eq!(json["mojikumi"], "lineEndHalf"); - let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); - assert_eq!(old.mojikumi, Mojikumi::None); - assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none()); - } - - #[test] - fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap(); - let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); - let wide = bounds(&s); - assert!(wide.width() > wide.height()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("type.orientation.vertical", &json!({})).unwrap(); - let tall = bounds(&s); - assert!(tall.height() > tall.width(), "{tall:?}"); - assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(bounds(&s), wide); - } - - #[test] - fn area_options_columns_change_layout() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let text = "word ".repeat(80); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 120}})).unwrap()["id"] - .as_u64() - .unwrap(); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let q = s.execute("text.areaOptions", &json!({})).unwrap(); - assert_eq!((q["rows"].as_u64(), q["columns"].as_u64()), (Some(1), Some(1))); - let lay = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t), - _ => panic!(), - }; - assert_eq!(lay(&s).frames.len(), 1); - let r = s.execute("text.areaOptions", &json!({"columns": 3, "gutter": 12, "inset": 4, "firstBaseline": "capHeight"})).unwrap(); - assert_eq!(r["firstBaseline"], "capHeight"); - let l = lay(&s); - assert_eq!(l.frames.len(), 3, "three column cells"); - // Glyphs land in more than one column. - let xs: Vec = l.glyphs.iter().map(|g| g.origin.x).collect(); - assert!(xs.iter().any(|x| *x > 110.0 + 10.0) && xs.iter().any(|x| *x < 100.0)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(lay(&s).frames.len(), 1); - assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err()); - } -} +mod area_tests; /// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. /// The third value is where the first line ends in the plain text. diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs new file mode 100644 index 000000000..6b3e44960 --- /dev/null +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -0,0 +1,1411 @@ +use super::*; + +/// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box +/// `document.inspect` reports for it, so a script can place it without a helper object. +#[test] +fn text_create_returns_the_inspect_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let inspected = |s: &mut Session, id: &Value| { + let doc = s.execute("document.inspect", &json!({})).unwrap(); + fn find(v: &Value, id: &Value) -> Option { + if &v["id"] == id { + return Some(v["bounds"].clone()); + } + v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id)) + } + find(&doc, id).unwrap() + }; + let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap(); + assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}"); + assert_eq!(made["bounds"], inspected(&mut s, &made["id"])); + let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap(); + let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap(); + assert_eq!(area["bounds"], inspected(&mut s, &area["id"])); +} + +/// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on +/// it, or ending there), and `text.setStyle` moves it the same way afterwards. +#[test] +fn text_create_justify_anchors_point_type_on_its_point() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let bounds = |s: &Session, id: &Value| { + let id = NodeId(id.as_u64().unwrap()); + let st = s.doc().unwrap(); + st.doc.bounds_of(&[id], false).unwrap() + }; + let make = |s: &mut Session, extra: Value| { + let mut p = json!({"x": 300, "y": 100, "text": "Centred title", "size": 24}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + s.execute("text.create", &p).unwrap()["id"].clone() + }; + let id = make(&mut s, json!({})); + let left = bounds(&s, &id); + assert!(left.width() > 10.0, "{left:?}"); + assert!((left.x0 - 300.0).abs() < 1.0, "left (the default) starts at x: {left:?}"); + let id = make(&mut s, json!({"justify": "center"})); + let centre = bounds(&s, &id); + assert!((centre.center().x - 300.0).abs() < 0.5, "centred on x: {centre:?}"); + assert!((centre.width() - left.width()).abs() < 0.5); + let right = make(&mut s, json!({"justify": "right"})); + let r = bounds(&s, &right); + assert!((r.x1 - 300.0).abs() < 1.0, "ends at x: {r:?}"); + let got = |s: &Session, id: &Value| match &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { + NodeKind::Text(t) => t.para.justify, + _ => panic!("not text"), + }; + assert_eq!(got(&s, &right), Justify::Right); + // Changed afterwards, it moves to the same place as made centred. + s.execute("text.setStyle", &json!({"id": right, "justify": "center"})).unwrap(); + let moved = bounds(&s, &right); + assert!((moved.center().x - 300.0).abs() < 0.5, "{moved:?}"); + assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "justify": "middle"})).is_err()); + // Type in or on a path takes it too. + let path = s.execute("path.create", &json!({"d": "M50 300 L550 300"})).unwrap()["id"].clone(); + let on = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "On a path", "justify": "center"})).unwrap(); + assert_eq!(got(&s, &on["id"]), Justify::Center); +} + +#[test] +fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { + use vectorcraft_doc::Kinsoku; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あカッ", "area": {"width": 40, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + // New type: Hard, today's set. + assert_eq!(para(&s).kinsoku, Kinsoku::Hard); + assert!(serde_json::to_value(para(&s)).unwrap().get("kinsoku").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"kinsoku": "strict"})).is_err()); + s.execute("text.setFormat", &json!({"kinsoku": "soft"})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::Soft); + assert_eq!(serde_json::to_value(para(&s)).unwrap()["kinsoku"], "soft"); + s.execute("text.setFormat", &json!({"kinsoku": "none"})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::None); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::Hard); + // Documents from before it read as Hard. + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.kinsoku, Kinsoku::Hard); +} + +#[test] +fn burasagari_is_set_per_paragraph_and_saved_only_when_on() { + use vectorcraft_doc::Burasagari; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "雅楽、笙。", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + // New type: Standard. + assert_eq!(para(&s).burasagari, Burasagari::Standard); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"burasagari": "strong"})).is_err()); + s.execute("text.setFormat", &json!({"burasagari": "forced"})).unwrap(); + assert_eq!(para(&s).burasagari, Burasagari::Forced); + assert_eq!(serde_json::to_value(para(&s)).unwrap()["burasagari"], "forced"); + s.execute("text.setFormat", &json!({"burasagari": "none"})).unwrap(); + assert_eq!(para(&s).burasagari, Burasagari::None); + // Saved only when on; documents from before it read as None. + assert!(serde_json::to_value(para(&s)).unwrap().get("burasagari").is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).burasagari, Burasagari::Standard); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.burasagari, Burasagari::None); +} + +/// Type › Optical Margin Alignment: `type.opticalMarginAlignment` sets or toggles the flag on +/// the selected type in one undo step, the opening quotation mark then hangs left of the +/// frame, and the flag is saved only when on and survives a native save. +#[test] +fn optical_margin_alignment_is_set_per_object_and_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted” words", "area": {"width": 300, "height": 100}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + // The left edge of the glyphs in text space, where the frame's left edge is at 0. + let left = |s: &Session| text(s).cached_bounds.unwrap().x0; + // New type: off, and not saved. + assert!(!text(&s).optical_margin); + assert!(serde_json::to_value(text(&s)).unwrap().get("opticalMarginAlignment").is_none()); + let inside = left(&s); + assert!(inside >= 0.0, "{inside}"); + // Nothing but a rectangle selected: an error. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + assert!(s.execute("type.opticalMarginAlignment", &json!({})).is_err()); + // Toggled on: the quotation mark hangs outside the frame. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], true); + assert!(text(&s).optical_margin); + assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); + assert_eq!(serde_json::to_value(text(&s)).unwrap()["opticalMarginAlignment"], true); + // `on` sets it; without `on` it toggles. + assert_eq!(s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap()["on"], true); + assert!(text(&s).optical_margin); + assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); + assert!(!text(&s).optical_margin); + assert_eq!(left(&s), inside); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(text(&s).optical_margin); + // Saved and reopened as it was; a document from before it reads as off. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.optical_margin); + let mut old = serde_json::to_value(text(&s)).unwrap(); + old.as_object_mut().unwrap().remove("opticalMarginAlignment"); + assert!(!serde_json::from_value::(old).unwrap().optical_margin); +} + +/// Character panel menu › System Layout: `type.systemLayout` sets the document and every text +/// object in one undo step. New type inherits it. Off by default and in a document from before +/// the flag, and saved only when on. On, each positive advance is a whole number of points. +#[test] +fn system_layout_sets_the_document_and_every_text_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); + let other = s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let setup = |s: &Session| s.doc().unwrap().doc.setup.clone(); + let advances = |s: &Session| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).glyphs.iter().map(|g| g.advance).collect::>() + }; + assert!(!setup(&s).system_layout); + assert!(!text(&s, id).system_layout && !text(&s, other).system_layout); + assert!(serde_json::to_value(setup(&s)).unwrap().get("systemLayout").is_none()); + assert!(serde_json::to_value(text(&s, id)).unwrap().get("systemLayout").is_none()); + let fractional = advances(&s); + assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); + assert_eq!(s.execute("type.systemLayout", &json!({})).unwrap()["on"], true); + assert!(setup(&s).system_layout); + assert!(text(&s, id).system_layout && text(&s, other).system_layout); + let whole = advances(&s); + assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); + assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); + assert_eq!(serde_json::to_value(setup(&s)).unwrap()["systemLayout"], true); + assert_eq!(serde_json::to_value(text(&s, id)).unwrap()["systemLayout"], true); + // New type inherits the document's setting. + let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap(); + assert!(text(&s, made).system_layout); + // A boolean given as text is an error, and the setting stays on. + assert!(s.execute("type.systemLayout", &json!({"on": "no"})).is_err()); + assert!(setup(&s).system_layout && text(&s, id).system_layout); + assert_eq!(s.execute("type.systemLayout", &json!({"on": false})).unwrap()["on"], false); + assert!(!setup(&s).system_layout && !text(&s, id).system_layout && !text(&s, made).system_layout); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(setup(&s).system_layout && text(&s, id).system_layout); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(back.setup.system_layout); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.system_layout); + let mut old = serde_json::to_value(setup(&s)).unwrap(); + old.as_object_mut().unwrap().remove("systemLayout"); + assert!(!serde_json::from_value::(old).unwrap().system_layout); +} + +/// Character panel menu › Fractional Widths: `type.fractionalWidths` sets or toggles the flag +/// on the selected type in one undo step. New type and a document from before the flag are on, +/// and the flag is saved only when off. Off, each positive advance is a whole number of points. +#[test] +fn fractional_widths_is_set_per_object_and_saved_only_when_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let advances = + |s: &Session| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).glyphs.iter().map(|g| g.advance).collect::>(); + // New type: on, and not saved. + assert!(text(&s).fractional_widths); + assert!(serde_json::to_value(text(&s)).unwrap().get("fractionalWidths").is_none()); + let fractional = advances(&s); + assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); + // Nothing but a rectangle selected: an error. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + assert!(s.execute("type.fractionalWidths", &json!({})).is_err()); + // Toggled off: whole-point advances, and the key is written. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], false); + assert!(!text(&s).fractional_widths); + let whole = advances(&s); + assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); + assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); + assert_eq!(serde_json::to_value(text(&s)).unwrap()["fractionalWidths"], false); + // A boolean given as text is an error, and the flag stays off. + assert!(s.execute("type.fractionalWidths", &json!({"on": "no"})).is_err()); + assert!(!text(&s).fractional_widths); + assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], true); + assert!(text(&s).fractional_widths); + assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); + assert!(!text(&s).fractional_widths); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(text(&s).fractional_widths); + // Saved and reopened as it was; a document from before it reads as on. + s.execute("type.fractionalWidths", &json!({"on": false})).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(!t.fractional_widths); + let mut old = serde_json::to_value(text(&s)).unwrap(); + old.as_object_mut().unwrap().remove("fractionalWidths"); + assert!(serde_json::from_value::(old).unwrap().fractional_widths); +} + +/// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation +/// options, rounded and clamped, in one undo step; they are saved only when not the defaults, +/// and paragraph styles carry them. +#[test] +fn hyphenation_options_are_set_per_paragraph_and_saved_only_when_not_default() { + use vectorcraft_doc::HyphenationOptions; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), + _ => panic!("text"), + }; + assert_eq!(paras(&s).0.hyphenation, HyphenationOptions::default()); + assert!(serde_json::to_value(paras(&s).0).unwrap().get("hyphenation").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute( + "text.setFormat", + &json!({"wordsLongerThan": 7.6, "afterFirst": 0, "beforeLast": 40, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, "start": 5, "end": 5}), + ) + .unwrap(); + let want = HyphenationOptions { words_longer_than: 8, after_first: 1, before_last: 15, hyphen_limit: 2, zone: 18.0, capitalized: false }; + let (first, second) = paras(&s); + assert_eq!((first.hyphenation, &second.hyphenation), (HyphenationOptions::default(), &want), "only the paragraph the caret is in"); + assert_eq!(serde_json::to_value(&second).unwrap()["hyphenation"]["hyphen_limit"], 2); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap(); + assert_eq!(back.hyphenation, want); + s.execute("text.setFormat", &json!({"hyphenLimit": -3, "hyphenationZone": 5000})).unwrap(); + assert_eq!((paras(&s).0.hyphenation.hyphen_limit, paras(&s).1.hyphenation.zone), (0, 1296.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paras(&s).1.hyphenation, HyphenationOptions::default()); + // A paragraph style carries them; an older file's paragraph reads the defaults. + s.execute("paraStyle.new", &json!({"name": "Loose", "attrs": {"hyphenation": {"words_longer_than": 9}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Loose", "id": id})).unwrap(); + assert_eq!(paras(&s).0.hyphenation, HyphenationOptions { words_longer_than: 9, ..HyphenationOptions::default() }); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap(); + assert_eq!(old.hyphenation, HyphenationOptions::default()); +} + +/// Paragraph panel menu › Roman Hanging Punctuation: `text.setFormat {romanHangingPunctuation}` +/// sets it per paragraph in one undo step, the opening quotation mark then hangs left of the +/// frame, it is saved only when on, and paragraph styles carry it. +#[test] +fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted”\n“Two”", "area": {"width": 300, "height": 200}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let left = |s: &Session| text(s).cached_bounds.unwrap().x0; + assert!(!text(&s).para_at(0).roman_hanging); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("roman_hanging").is_none()); + let inside = left(&s); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"romanHangingPunctuation": true, "start": 0, "end": 0})).unwrap(); + assert!(text(&s).para_at(0).roman_hanging && !text(&s).para_at(1).roman_hanging); + assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["roman_hanging"], true); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap(); + assert!(back.roman_hanging); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(0).roman_hanging); + assert!((left(&s) - inside).abs() < 1e-9); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Hung", "attrs": {"roman_hanging": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Hung", "id": id})).unwrap(); + assert!(text(&s).para_at(1).roman_hanging); +} + +/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one +/// undo step, the paragraph is saved with it only when it is not None and opens with it, an +/// unknown value changes nothing, and paragraph styles carry it. +#[test] +fn insert_kashidas_is_set_per_paragraph_and_saved_only_when_set() { + use vectorcraft_doc::text::Kashida; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "كتب محمد\nعلي", "area": {"width": 300, "height": 200}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para_at(0).kashida, Kashida::None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kashida").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"kashida": "medium", "start": 0, "end": 0})).unwrap(); + assert_eq!((text(&s).para_at(0).kashida, text(&s).para_at(1).kashida), (Kashida::Medium, Kashida::None)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kashida"], "medium"); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).kashida, back.para_at(1).kashida), (Kashida::Medium, Kashida::None)); + assert!(s.execute("text.setFormat", &json!({"kashida": "wide"})).is_err()); + assert_eq!(text(&s).para_at(0).kashida, Kashida::Medium, "an unknown value changes nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s).para_at(0).kashida, Kashida::None); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Stretched", "attrs": {"kashida": "stylistic"}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Stretched", "id": id})).unwrap(); + assert_eq!(text(&s).para_at(1).kashida, Kashida::Stylistic); +} + +/// Paragraph panel › Bullets: `text.setFormat {list, bullet}` makes the paragraphs it touches a +/// bulleted list and refuses unknown values. A paragraph that joins a list with no left or +/// first-line indent gets 18 pt and -18 pt, unless the call sets an indent, and one with indents +/// keeps them. The list is saved only when set and opens with the file, Return continues it, +/// "none" turns it off, and paragraph styles set it and turn it off. +#[test] +fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let indents = |s: &Session, i: usize| (text(s).para_at(i).left_indent, text(s).para_at(i).first_line_indent); + let dot = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!(list(&s, 0), None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("list").is_none(), "no list is saved without one"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"start": 4, "end": 4, "leftIndent": 30, "firstLineIndent": 5})).unwrap(); + // The first two paragraphs: the first gets the hanging indent, the second keeps its indents. + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 4})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dot, dot, None)); + assert_eq!((indents(&s, 0), indents(&s, 1), indents(&s, 2)), ((18.0, -18.0), (30.0, 5.0), (0.0, 0.0))); + // `bullet` picks the bullet, and `list: "bullet"` keeps it; the indents stay as they are. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 0})).unwrap(); + let square = Some(ParaList::bulleted(Bullet::Square)); + assert_eq!((list(&s, 0), indents(&s, 0)), (square, (18.0, -18.0))); + for bad in [json!({"list": "numbers"}), json!({"bullet": "star"}), json!({"list": 1}), json!({"bullet": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!((list(&s, 0), list(&s, 2)), (square, None), "an unknown value changes nothing"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); + // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. + let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); + // Return at the end of a list item starts another one. + s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 3, "insert": "\nOne more"})).unwrap(); + assert_eq!((list(&s, 1), indents(&s, 1)), (square, (18.0, -18.0))); + s.execute("edit.undo", &json!({})).unwrap(); + // "none" turns the list off and leaves the indents. + s.execute("text.setFormat", &json!({"bullet": "none", "start": 0, "end": 0})).unwrap(); + assert_eq!((list(&s, 0), indents(&s, 0)), (None, (18.0, -18.0))); + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + assert_eq!(list(&s, 1), None); + // Indents the call sets win over the hanging indent. + s.execute("text.setFormat", &json!({"list": "bullet", "leftIndent": 0, "firstLineIndent": 0, "start": 8, "end": 8})).unwrap(); + assert_eq!((list(&s, 2), indents(&s, 2)), (dot, (0.0, 0.0))); + // A style that holds a list sets it; a style captured from a paragraph without one turns it off. + s.execute("paraStyle.new", &json!({"name": "Listed", "attrs": {"list": {"bullet": "dash"}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Listed", "id": id})).unwrap(); + let dash = Some(ParaList::bulleted(Bullet::Dash)); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dash, dash, dash)); + let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); +} + +/// Numbered lists: `list: "number"` numbers the paragraphs 1, 2, 3 and `number` picks the +/// style; a list switched between bullets and numbers keeps its bullet and its indents; an +/// unknown value is an error; the list is saved, reopened and carried by paragraph styles. +#[test] +fn numbered_lists_are_set_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let numbers = |s: &Session| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::>() + }; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number"})).unwrap(); + let arabic = Some(ParaList::numbered(NumberStyle::Arabic)); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (arabic, arabic, arabic)); + assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); + assert_eq!(numbers(&s), ["1.", "2.", "3."]); + // `number` picks the style, `list: "number"` keeps it, and the count runs on through both. + s.execute("text.setFormat", &json!({"number": "upperRoman", "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 0})).unwrap(); + let roman = Some(ParaList::numbered(NumberStyle::UpperRoman)); + assert_eq!(list(&s, 0), roman); + assert_eq!(numbers(&s), ["I.", "2.", "3."]); + // A bullet makes it bulleted, a number style numbered again, and `list: "bullet"` brings + // back the bullet it had; the indents stay as they are. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square))); + s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap(); + let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) }); + assert_eq!(list(&s, 0), letters); + assert_eq!(numbers(&s), ["a.", "2.", "3."]); + assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); + for bad in [json!({"number": "roman"}), json!({"number": 1}), json!({"list": "numbered"}), json!({"number": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!((list(&s, 0), list(&s, 1)), (letters, arabic), "an unknown value changes nothing"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "number": "lowerLetters"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (letters, arabic, arabic)); + // Return at the end of a numbered item starts the next number. + s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nFour"})).unwrap(); + assert_eq!(numbers(&s), ["a.", "2.", "3.", "4."]); + s.execute("edit.undo", &json!({})).unwrap(); + // "none" turns the list off; the next numbered paragraph starts again at 1. + s.execute("text.setFormat", &json!({"number": "none", "start": 4, "end": 4})).unwrap(); + assert_eq!(list(&s, 1), None); + assert_eq!(numbers(&s), ["a.", "1."]); + // A style that holds a numbered list sets it, a style captured from a numbered paragraph + // carries its list, and one captured from a paragraph without a list turns it off. + s.execute("paraStyle.new", &json!({"name": "Roman", "attrs": {"list": {"number": "upperRoman"}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Roman", "id": id})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (roman, roman, roman)); + assert_eq!(numbers(&s), ["I.", "II.", "III."]); + let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Captured", "id": plain})).unwrap(); + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(plain)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!(t.para_at(0).list, roman); + s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); +} + +/// The glyphs the layout of type `id` draws, each with its glyph, where it is drawn and the +/// transform it is drawn with (in thousandths), from the top line down and left to right. +fn drawn_glyphs(s: &Session, id: u64) -> Vec<(u32, i64, i64, [i64; 6])> { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let milli = |v: f64| (v * 1000.0).round() as i64; + let mut v: Vec<(u32, i64, i64, [i64; 6])> = lay + .glyphs + .iter() + .filter(|g| !g.outline.elements().is_empty()) + .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y), g.xf.as_coeffs().map(milli))) + .collect(); + v.sort_by_key(|&(_, x, y, _)| (y, x)); + v +} + +/// Type › Bullets and Numbering › Convert to Text: `text.convertListToText` writes each list +/// paragraph's marker before its text as characters in the marker's style, followed by a tab, +/// turns the list off, and gives the paragraph a plain left tab stop at the left indent where +/// the marker's tab went to it, so every glyph is drawn where it was: bullets in area type (a +/// wrapped item, an empty item, a right-aligned stop at the indent and a stop before it, both +/// with leaders), numbers narrower and wider than the hanging indent (one reaching a stop with +/// a leader past it), and point and vertical type. A paragraph with a tab of its own keeps its +/// stops past the indent. The characters can be styled and deleted, and one undo brings the +/// lists back. +#[test] +fn convert_to_text_writes_the_markers_where_they_were_drawn() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let markers = |s: &Session, id: u64| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.len(); + let tabs = + |s: &Session, id: u64, k: usize| text(s, id).para_at(k).tabs.iter().map(|t| (t.position, t.align, t.leader.clone())).collect::>(); + let long = "The quick brown fox jumps over the lazy dog and runs far away into the woods beyond the hill"; + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\n{long}\n\nFour\nFive"), "area": {"width": 200, "height": 400}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let (four, five) = (long.len() + 6, long.len() + 11); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "bullet": "square", "start": four, "end": four})).unwrap(); + // A right-aligned stop with a leader at the fourth item's left indent, and a stop with a + // leader before the fifth item's: the bullets' tabs go to the indent and to that stop and + // draw no leader. + s.execute("text.tabs.set", &json!({"ids": [id], "start": four, "end": four, "stops": [{"position": 18, "align": "right", "leader": "."}]})) + .unwrap(); + s.execute("text.tabs.set", &json!({"ids": [id], "start": five, "end": five, "stops": [{"position": 10, "leader": "."}]})).unwrap(); + // The first character larger, raised and underlined: the bullet takes its size alone. The + // fourth item's first character is larger than the break before it. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "size": 20, "baselineShift": 4, "underline": true})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": four, "end": four + 1, "size": 20})).unwrap(); + let listed = text(&s, id); + let before = drawn_glyphs(&s, id); + assert_eq!(markers(&s, id), 4, "the empty item has no bullet"); + let reply = s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + let inserted = |at: usize| json!({"id": id, "at": at, "length": 4}); + assert_eq!(reply, json!({"ids": [id], "converted": 5, "inserted": [inserted(0), inserted(4), inserted(four), inserted(five)]})); + assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was"); + let t = text(&s, id); + assert_eq!(t.plain_text(), format!("\u{2022}\tOne\n\u{2022}\t{long}\n\n\u{25AA}\tFour\n\u{2022}\tFive")); + assert!((0..5).all(|k| t.para_at(k).list.is_none()), "no paragraph is in a list"); + assert_eq!(markers(&s, id), 0); + let left = vectorcraft_doc::TabAlign::Left; + let plain_stop = |position| vec![(position, left, String::new())]; + assert_eq!( + (tabs(&s, id, 0), tabs(&s, id, 1), tabs(&s, id, 2), tabs(&s, id, 3), tabs(&s, id, 4)), + (plain_stop(18.0), plain_stop(18.0), vec![], plain_stop(18.0), plain_stop(10.0)), + "a plain left stop at the left indent (in place of the right-aligned one), none in the empty item, and the stop the \ + fifth item's tab went to without its leader" + ); + let bullet = &t.runs[0]; + assert_eq!( + (bullet.text.as_str(), bullet.style.size, bullet.style.baseline_shift, bullet.style.underline), + ("\u{2022}\t", 20.0, 0.0, false), + "the bullet has the first character's size without its baseline shift and underline" + ); + // The characters are text: they take a style and can be deleted. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 30})).unwrap(); + assert_eq!(text(&s, id).runs[0].style.size, 30.0); + s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 4, "insert": ""})).unwrap(); + assert!(text(&s, id).plain_text().starts_with("One\n\u{2022}\t")); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + let back = text(&s, id); + assert_eq!(back.para_at(3).list, Some(ParaList::bulleted(Bullet::Square))); + assert_eq!((back.runs, back.para, back.paras), (listed.runs, listed.para, listed.paras), "one undo step"); + assert_eq!(drawn_glyphs(&s, id), before); + // Numbers 1 to 9 at 20 pt are narrower than the hanging indent, 10 is wider and takes the + // text to the next default stop, and 11 to its stop at 72 pt, whose leader the number's tab + // draws neither before nor after. + let items = (1..=11).map(|i| format!("Item {i}")).collect::>().join("\n"); + let id = + s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + let eleven = 9 * 7 + 8; + s.execute("text.tabs.set", &json!({"ids": [id], "start": eleven, "end": eleven, "stops": [{"position": 72, "leader": "."}]})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "numbers"); + let expected = (1..=11).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n"); + assert_eq!(text(&s, id).plain_text(), expected); + assert_eq!((tabs(&s, id, 0), tabs(&s, id, 9), tabs(&s, id, 10)), (plain_stop(18.0), vec![], plain_stop(72.0))); + // A paragraph with a tab of its own keeps its stops past the left indent, which that tab + // can reach. + let id = s.execute("text.create", &json!({"x": 10, "y": 650, "size": 30, "text": "Item\tvalue"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 72, "leader": "."}, {"position": 200}]})).unwrap(); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(text(&s, id).plain_text(), "1.\tItem\tvalue"); + assert_eq!(tabs(&s, id, 0), [(72.0, left, ".".to_string()), (200.0, left, String::new())]); + // A 30 pt number's tab to a right-aligned stop aligns the whole paragraph there, trailing + // space included, and a converted tab aligns the rest of its line: a plain left stop where + // the number's tab ended keeps the text in place. A right-aligned stop before the left stop + // the number's tab reached, which the wrapped paragraph's width kept the number's tab from, + // would catch the converted tab, which measures only the first line: it goes. The lines are + // compared with the Single-line Composer, as a paragraph with a tab is broken line by line. + let right = vectorcraft_doc::TabAlign::Right; + for (text_in, width, stops, kept) in [ + ("One ", 300, json!([{"position": 200, "align": "right"}]), vec![(200.0, right, String::new())]), + ("The quick brown fox jumps over the lazy dog", 230, json!([{"position": 100, "align": "right"}, {"position": 150}]), vec![]), + ] { + let id = s + .execute("text.create", &json!({"x": 10, "y": 600, "size": 30, "text": text_in, "area": {"width": width, "height": 300}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number", "composer": "singleLine"})).unwrap(); + s.execute("text.tabs.set", &json!({"ids": [id], "stops": stops})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "{text_in:?}"); + let after = tabs(&s, id, 0); + assert_eq!( + (after.first().map(|t| (t.1, t.2.as_str())), after.get(1..).map(<[_]>::to_vec)), + (Some((left, "")), Some(kept)), + "{text_in:?}" + ); + } + // Overset items, which the layout leaves out, get the stop by the same rule: a 30 pt number is + // wider than the hanging indent, a 12 pt bullet is not. + for (size, list, text_after, stop) in + [(30, "number", "1.\tOne\n2.\tTwo", vec![]), (12, "bullet", "\u{2022}\tOne\n\u{2022}\tTwo", plain_stop(18.0))] + { + let id = s + .execute("text.create", &json!({"x": 10, "y": 500, "size": size, "text": "One\nTwo", "area": {"width": 200, "height": size + 10}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": list})).unwrap(); + assert_eq!(markers(&s, id), 1, "the second item is overset"); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!((text(&s, id).plain_text().as_str(), tabs(&s, id, 1)), (text_after, stop), "{size} pt"); + } + // Point type and vertical type. + for vertical in [false, true] { + let id = + s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "vertical {vertical}"); + assert_eq!(text(&s, id).plain_text(), "1.\tOne\n2.\tTwo"); + } + // Type with no list paragraph: nothing to convert and no undo step. + let plain = s.execute("text.create", &json!({"x": 10, "y": 700, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("text.convertListToText", &json!({"ids": [plain]})).unwrap(), json!({"ids": [], "converted": 0, "inserted": []})); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + +/// A right-to-left list paragraph converted to text starts with the number, its period and a +/// tab, which are read first: the line draws its glyphs in the order it drew them with the +/// marker, the number at the right with its period at its left. Letters and roman numerals +/// would make the paragraph run left to right, so the paragraph gets the direction it ran in; +/// digits leave the direction unset. +#[test] +fn a_right_to_left_list_paragraph_converts_to_text_read_first() { + use vectorcraft_doc::ParaDirection; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + for (number, text, direction) in [ + ("arabic", "1.\tשלום\n2.\tעולם", None), + ("upperLetters", "A.\tשלום\nB.\tעולם", Some(ParaDirection::RightToLeft)), + ("upperRoman", "I.\tשלום\nII.\tעולם", Some(ParaDirection::RightToLeft)), + ] { + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "שלום\nעולם", "area": {"width": 200, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "number": number})).unwrap(); + // `drawn_glyphs` lists the first line first, from left to right. + let order = |s: &Session| { + let glyphs = drawn_glyphs(s, id); + let top = glyphs.first().map_or(0, |g| g.2); + glyphs.into_iter().filter(|g| g.2 == top).map(|g| g.0).collect::>() + }; + let before = order(&s); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!(t.plain_text(), text); + assert!(t.para_at(0).list.is_none() && t.para_at(1).list.is_none()); + let directions = (t.para_at(0).direction, t.para_at(1).direction); + assert_eq!(order(&s), before, "{number}: the first line's glyphs from left to right"); + assert_eq!(directions, (direction, direction), "{number}"); + } +} + +/// Convert to Text in threaded type writes the numbers the story counted through its frames, +/// and both frames draw every glyph where it was. A paragraph that continues into the next +/// frame gets its number once, where it starts, also when only the later frame is named, and +/// empty frames after a converted paragraph leave its list with it. +#[test] +fn threaded_numbers_convert_to_text_as_the_story_counted_them() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + let numbers = |s: &Session, id: u64| { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() + }; + let thread = |s: &mut Session, text: &str| { + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + (a, b) + }; + // Whole paragraphs: the first frame ends with a paragraph break. + let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::>().join("\n")); + let first = numbers(&s, a).len(); + assert!((2..10).contains(&first) && plain(&s, a).ends_with('\n'), "both frames hold items: {first}"); + let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); + let reply = s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(reply["converted"], 10, "each paragraph once"); + assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames"); + let story = (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n"); + assert_eq!(plain(&s, a) + &plain(&s, b), story); + assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}", n = first + 1))); + // The empty part after the first frame's last break and the second frame's first item are + // one paragraph: a caret at the end of the first frame converts that item. + let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::>().join("\n")); + let end = plain(&s, a).len(); + let reply = s.execute("text.convertListToText", &json!({"ids": [a], "start": end, "end": end})).unwrap(); + assert_eq!(reply["converted"], 1); + assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}\nItem", n = first + 1)), "{}", plain(&s, b)); + // A paragraph split between the frames, with only the second frame named. + let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); + let (a, b) = thread(&mut s, &format!("One\nTwo\n{long}\nFour")); + assert!(!plain(&s, a).ends_with('\n'), "the third paragraph goes on in the second frame"); + // A paragraph with a tab is broken line by line, so the lines are compared with the + // Single-line Composer, which breaks the listed paragraph the same way. + s.execute("text.setFormat", &json!({"ids": [a, b], "composer": "singleLine"})).unwrap(); + let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); + s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); + assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames, with the paragraph split between them"); + assert_eq!(plain(&s, a) + &plain(&s, b), format!("One\nTwo\n3.\t{long}\n4.\tFour")); + assert_eq!(numbers(&s, a), ["1.", "2."], "the paragraphs of the first frame alone stay in the list"); + let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert!(second.para_at(0).list.is_none(), "the third paragraph's part in the second frame leaves the list too"); + // One item that fits in the first of three frames: the two empty frames after it leave the + // list too, so the re-flowed thread draws no number before the converted one. + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Item", "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b, c], "list": "number"})).unwrap(); + assert_eq!((plain(&s, b), plain(&s, c), numbers(&s, a)), (String::new(), String::new(), vec!["1.".to_string()])); + assert_eq!(s.execute("text.convertListToText", &json!({"ids": [a]})).unwrap()["converted"], 1); + assert_eq!(plain(&s, a), "1.\tItem"); + assert_eq!(numbers(&s, a), Vec::::new(), "no number drawn before the converted one"); + for id in [a, b, c] { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert!(t.para_at(0).list.is_none(), "frame {id} leaves the list"); + } + // An overset 30 pt number, wider than the hanging indent, in a last frame that starts + // inside a paragraph: its tab goes to the next stop, so it gets no stop at the left indent. + let a = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 30, "text": format!("{long}\nTwo"), "area": {"width": 150, "height": 80}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 80})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert!(second.first_para_continues && second.plain_text().ends_with("\nTwo"), "{:?}", second.plain_text()); + assert_eq!(numbers(&s, b).len(), 0, "the second item is overset"); + s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); + let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert!(second.plain_text().ends_with("\n2.\tTwo"), "{:?}", second.plain_text()); + assert!(second.para_at(1).tabs.is_empty(), "no stop at the left indent: {:?}", second.para_at(1).tabs); +} + +/// `text.convertListToText` sent while the Type tool edits (as from MCP or the control channel) +/// moves the tool's anchor and caret past the characters it inserts at or before them, so that +/// neither lands inside a bullet. In threaded type, when the text before the caret moves to the +/// frame before, the caret goes with its character into that frame, which becomes the +/// selection. +#[test] +fn convert_to_text_moves_the_type_tools_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + let selection = |s: &Session| { + let o = s.tool_options(); + (o["editing"].as_u64(), o["anchor"].as_u64(), o["caret"].as_u64()) + }; + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.select_tool("type", Default::default()).unwrap(); + // From after the O of One to after the T of Two. + s.set_tool_option("textSelection", &json!({"editing": id, "anchor": 1, "caret": 5})); + s.execute("text.convertListToText", &json!({})).unwrap(); + let text = plain(&s, id); + assert_eq!(text, "\u{2022}\tOne\n\u{2022}\tTwo"); + assert_eq!(selection(&s), (Some(id), Some(5), Some(13)), "the anchor after the O, the caret after the T"); + assert!(text.is_char_boundary(5) && text.is_char_boundary(13)); + + let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); + let a = s + .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\nTwo\n{long}\nFour"), "area": {"width": 150, "height": 60}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.select_tool("selection", Default::default()).unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + assert!(plain(&s, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&s, b)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.select_tool("type", Default::default()).unwrap(); + // The caret after the d of dog, at the start of the second frame. + s.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1})); + s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap(); + let first = plain(&s, a); + assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}"); + let after_d = (first.len() - "og. ".len()) as u64; + assert_eq!(selection(&s), (Some(a), Some(after_d), Some(after_d)), "the caret is after the d of dog in the first frame"); + assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); +} + +/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. +#[test] +fn create_outlines_makes_a_path_of_the_bullet() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let outlined = |s: &mut Session, list: bool| { + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One"})).unwrap()["id"].as_u64().unwrap(); + if list { + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + } + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + s.doc().unwrap().doc.node(NodeId(g)).unwrap().children().unwrap().len() + }; + let plain = outlined(&mut s, false); + assert_eq!(outlined(&mut s, true), plain + 1, "one more path: the bullet"); +} + +/// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and +/// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their +/// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They +/// are saved only when not the defaults, and paragraph styles carry them. +#[test] +fn justification_options_are_set_per_paragraph_and_saved_only_when_not_default() { + use vectorcraft_doc::{JustificationOptions, SingleWordJustify}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), + _ => panic!("text"), + }; + assert_eq!(paras(&s).0.justification, JustificationOptions::default()); + assert!(serde_json::to_value(paras(&s).0).unwrap().get("justification").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute( + "text.setFormat", + &json!({ + "wordSpacing": [60, 90, 2000], "letterSpacing": {"max": 10}, "glyphScaling": [95, 100, 105], + "autoLeading": 150, "singleWordJustification": "alignCenter", "start": 5, "end": 5 + }), + ) + .unwrap(); + let want = JustificationOptions { + word_spacing: [60.0, 90.0, 1000.0], + letter_spacing: [0.0, 0.0, 10.0], + glyph_scaling: [95.0, 100.0, 105.0], + auto_leading: 150.0, + single_word: SingleWordJustify::AlignCenter, + }; + let (first, second) = paras(&s); + assert_eq!((first.justification, &second.justification), (JustificationOptions::default(), &want), "only the paragraph the caret is in"); + assert_eq!(serde_json::to_value(&second).unwrap()["justification"]["single_word"], "alignCenter"); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap(); + assert_eq!(back.justification, want); + // Out of order: nothing changes, and the error names the setting. + let e = s.execute("text.setFormat", &json!({"wordSpacing": {"desired": 85}, "letterSpacing": {"min": 5}})).unwrap_err().to_string(); + assert!(e.contains("letterSpacing"), "{e}"); + assert_eq!(paras(&s).1.justification, want); + assert!(s.execute("text.setFormat", &json!({"glyphScaling": [100, 100]})).is_err()); + assert!(s.execute("text.setFormat", &json!({"singleWordJustification": "spread"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paras(&s).1.justification, JustificationOptions::default()); + // A paragraph style carries them; an older file's paragraph reads the defaults. + s.execute("paraStyle.new", &json!({"name": "Airy", "attrs": {"justification": {"auto_leading": 200.0}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Airy", "id": id})).unwrap(); + assert_eq!(paras(&s).0.justification, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() }); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap(); + assert_eq!(old.justification, JustificationOptions::default()); +} + +/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts +/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing +/// carry them; params override; the values are journaled; imported text keeps the Roman +/// baseline. +#[test] +fn new_type_takes_the_japanese_defaults_while_the_interface_is_japanese() { + use vectorcraft_doc::{CharAlign, LeadingModel}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let made = |s: &mut Session, cmd: &str, p: Value| -> (LeadingModel, CharAlign) { + let id = s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap(); + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para.leading_model, t.runs[0].style.char_align), + _ => panic!("text"), + } + }; + let roman = (LeadingModel::RomanBaseline, CharAlign::RomanBaseline); + let japanese = (LeadingModel::EmBoxTop, CharAlign::EmBoxCenter); + let point = json!({"x": 10, "y": 50, "text": "雅楽"}); + assert!(!s.japanese_interface()); + assert_eq!(made(&mut s, "text.create", point.clone()), roman, "headless, `auto`: the Roman defaults"); + s.ui_language = Some("ja".into()); + assert_eq!(made(&mut s, "text.create", point.clone()), japanese); + assert_eq!(made(&mut s, "text.create", json!({"x": 10, "y": 90, "text": "笙", "area": {"width": 100, "height": 50}})), japanese); + // Journaled: a replay in another language does the same. + let (_, logged) = s.journal.last().unwrap().clone(); + assert_eq!((logged["leadingModel"].as_str(), logged["charAlign"].as_str()), (Some("emBoxTop"), Some("emBoxCenter"))); + // Params override. + assert_eq!( + made(&mut s, "text.create", json!({"x": 10, "y": 120, "text": "a", "leadingModel": "romanBaseline", "charAlign": "romanBaseline"})), + roman + ); + assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "leadingModel": "middle"})).is_err()); + // Area type in a path. + let path = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 100, "height": 80})).unwrap()["id"].clone(); + assert_eq!(made(&mut s, "text.createInPath", json!({"path": path, "mode": "area", "text": "篳篥"})), japanese); + // A preference set to Japanese counts without a UI; another language doesn't. + s.ui_language = None; + s.prefs.interface_language = "ja".into(); + assert_eq!(made(&mut s, "text.create", point.clone()), japanese); + s.ui_language = Some("en".into()); + assert_eq!(made(&mut s, "text.create", point.clone()), roman); + // New styles made from nothing. + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.ui_language = Some("ja".into()); + let style = |s: &mut Session, kind: &str| { + let name = s.execute(&format!("{kind}.new"), &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + let list = s.execute(&format!("{kind}.list"), &json!({})).unwrap(); + list["styles"].as_array().unwrap().iter().find(|st| st["name"] == name.as_str()).unwrap()["attrs"].clone() + }; + assert_eq!(style(&mut s, "paraStyle"), json!({"leading_model": "emBoxTop"})); + assert_eq!(style(&mut s, "charStyle"), json!({"charAlign": "emBoxCenter"})); + s.ui_language = Some("en".into()); + assert_eq!(style(&mut s, "paraStyle"), json!({})); + // Imported text keeps the Roman baseline. + s.ui_language = Some("ja".into()); + let svg = r#"雅楽"#; + s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let mut found = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + found.push((t.para.leading_model, t.runs[0].style.char_align)); + } + }); + assert_eq!(found, [roman]); +} + +#[test] +fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() { + use vectorcraft_doc::LeadingModel; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + // Exercise ink bounds with bundled glyphs; Japanese outlines require optional craft-fonts. + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A\nB", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para.leading_model, LeadingModel::RomanBaseline); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"leadingModel": "middle"})).is_err()); + let before = text(&s).cached_bounds; + s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap(); + let t = text(&s); + assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop); + // The fallback used without craft-fonts has no glyph metrics to move the first line. + if !vectorcraft_text::CRAFT_FONTS.is_empty() { + assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top"); + } + assert_eq!(serde_json::to_value(&t.para).unwrap()["leading_model"], "emBoxTop"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(serde_json::to_value(&text(&s).para).unwrap().get("leading_model").is_none()); +} + +#[test] +fn character_alignment_is_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::CharAlign; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert_eq!(runs(&s)[0].style.char_align, CharAlign::RomanBaseline); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charAlign").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"charAlign": "middle"})).is_err()); + s.execute("text.setFormat", &json!({"charAlign": "emBoxCenter"})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_align == CharAlign::EmBoxCenter)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], "emBoxCenter"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(runs(&s)[0].style.char_align, CharAlign::RomanBaseline); + // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "top"})).is_err()); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "emBoxTop"})).unwrap(); + let aligns: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.char_align)).collect(); + assert_eq!(aligns, [("雅".to_string(), CharAlign::RomanBaseline), ("楽".to_string(), CharAlign::EmBoxTop)]); + // The ideographic character face's top and bottom. + for (key, want) in [("icfTop", CharAlign::IcfTop), ("icfBottom", CharAlign::IcfBottom)] { + s.execute("text.setFormat", &json!({"charAlign": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_align == want)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], key); + } +} + +/// Character Direction (Character panel menu, Middle Eastern options): a character attribute +/// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when +/// set and kept by a native save. +#[test] +fn character_direction_is_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::ParaDirection; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(); + assert_eq!(runs(&s)[0].style.char_direction, None); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] { + s.execute("text.setFormat", &json!({"charDirection": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction == want)); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key)); + } + let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string(); + assert!(err.contains("`charDirection` must be"), "{err}"); + // Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none())); + s.execute("edit.redo", &json!({})).unwrap(); + // Saved and reopened as it was. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect(); + assert_eq!(reopened, set(&s)); + // A character style captured from the characters carries it, Default included. + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight"); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null)); +} + +/// Proportional Metrics (#966): a character attribute, saved only when on. +#[test] +fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.proportional_metrics); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.proportional_metrics)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.proportional_metrics); + // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect(); + assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]); + // Styles written without it (documents from before it) read as off. + let old = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert!(old.get("proportionalMetrics").is_none()); + assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); +} + +/// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only +/// when on. Documents written before it read as off. +#[test] +fn tate_chu_yoko_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "2026年"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.tate_chu_yoko); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("tateChuYoko").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"tateChuYoko": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.tate_chu_yoko)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["tateChuYoko"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.tate_chu_yoko); + // The year only (2026 is 4 bytes); 年 stays unmarked. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "tateChuYoko": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.tate_chu_yoko)).collect(); + assert_eq!(on, [("2026".to_string(), true), ("年".to_string(), false)]); + let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); + assert!(old.get("tateChuYoko").is_none()); + assert!(!serde_json::from_value::(old).unwrap().tate_chu_yoko); + s.execute("text.setFormat", &json!({"tateChuYoko": false})).unwrap(); + assert!(runs(&s).iter().all(|r| !r.style.tate_chu_yoko)); +} + +/// No Break is a character attribute, saved only when on. +#[test] +fn no_break_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.no_break); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("noBreak").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"noBreak": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.no_break)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["noBreak"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.no_break); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "noBreak": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.no_break)).collect(); + assert_eq!(on, [("A".to_string(), true), ("b".to_string(), false)]); + let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); + assert!(old.get("noBreak").is_none()); + assert!(!serde_json::from_value::(old).unwrap().no_break); + s.execute("text.setFormat", &json!({"noBreak": false})).unwrap(); + assert!(runs(&s).iter().all(|r| !r.style.no_break)); +} + +/// Digits (Character panel menu, Middle Eastern options): a character attribute, saved only +/// when set; the text keeps the digits that were typed. +#[test] +fn digits_are_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::Digits; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "صفحة 12"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert_eq!(runs(&s)[0].style.digits, Digits::Default); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("digits").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [("hindi", Digits::Hindi), ("farsi", Digits::Farsi), ("arabic", Digits::Arabic), ("default", Digits::Default)] { + s.execute("text.setFormat", &json!({"digits": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.digits == want)); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved.get("digits").and_then(|v| v.as_str()), (want != Digits::Default).then_some(key)); + // The text is as typed: only the glyphs change. + assert_eq!(runs(&s).iter().map(|r| r.text.as_str()).collect::(), "صفحة 12"); + } + s.execute("text.setFormat", &json!({"digits": "hindi"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(runs(&s)[0].style.digits, Digits::Default); + let err = s.execute("text.setFormat", &json!({"digits": "roman"})).unwrap_err().to_string(); + assert!(err.contains("`digits` must be"), "{err}"); + // Characters selected with the Type tool: only the range takes it (صفحة is 8 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 11, "digits": "farsi"})).unwrap(); + let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.digits)).collect(); + assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]); +} + +/// Variable Font settings (Character panel › Variable Font): a character attribute, saved only +/// when set, each value clamped to its axis's range, and cleared by another font or style. +#[test] +fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font}; + vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "lIn lIn", "font": FAMILY, "size": 40})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let saved = |s: &Session| serde_json::to_value(&runs(s)[0].style).unwrap().get("fontAxes").cloned(); + let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap().width(); + assert_eq!(saved(&s), None); + let regular = width(&s); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wght": 650.0}))); + assert!(width(&s) > regular * 1.1, "the type is set heavier and wider: {regular} -> {}", width(&s)); + // Past the axis's range: its maximum; an axis the font hasn't: left out; others kept. + s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000, "opsz": 12, "wdth": 80}})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wdth": 80.0, "wght": 700.0}))); + // Null goes back to the style's own setting, and so does a value equal to it. + s.execute("text.setFormat", &json!({"fontAxes": {"wght": null}})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wdth": 80.0}))); + s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 100}})).unwrap(); + assert_eq!(saved(&s), None); + assert!((width(&s) - regular).abs() < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wdth": 80.0}))); + // Characters selected with the Type tool: only the range takes it. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "fontAxes": {"wght": 550}})).unwrap(); + let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.font_axes.clone())).collect(); + let axes = |v: &[(&str, f64)]| v.iter().map(|(t, v)| (t.to_string(), *v)).collect::>(); + assert_eq!(set, [("lIn ".to_string(), axes(&[("wdth", 80.0)])), ("lIn".to_string(), axes(&[("wdth", 80.0), ("wght", 550.0)]))]); + // A saved file keeps them. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.runs, runs(&s)); + // Another style or font clears them: their settings were the old face's. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "style": "Bold"})).unwrap(); + assert_eq!(runs(&s).last().map(|r| (r.style.font_style.as_str(), r.style.font_axes.is_empty())), Some(("Bold", true))); + s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); + s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap(); + s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); + // A static font has no axes to set. + s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); + assert_eq!(saved(&s), None); + // A missing font keeps them as given, for when it is installed. + s.execute("text.setStyle", &json!({"font": "No Such Variable Sans"})).unwrap(); + s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000}})).unwrap(); + assert_eq!(saved(&s), Some(json!({"wght": 2000.0}))); + for (bad, says) in + [(json!({"weight": 650}), "not an axis tag"), (json!({"wght": "heavy"}), "must be a number"), (json!(650), "must be an object")] + { + let err = s.execute("text.setFormat", &json!({"fontAxes": bad})).unwrap_err().to_string(); + assert!(err.contains(says), "{err}"); + } + // The axes and the setting of a style, as the sliders show them. + let list = s.execute("text.fontList", &json!({"family": FAMILY, "style": "Bold"})).unwrap(); + assert_eq!( + list["axes"], + json!([ + {"tag": "wght", "name": "Weight", "min": 400.0, "default": 400.0, "max": 700.0, "value": 700.0, "hidden": false}, + {"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false}, + ]) + ); + assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([])); +} + +#[test] +fn new_type_is_composed_with_line_end_half_width_punctuation() { + use vectorcraft_doc::Mojikumi; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽。"})).unwrap()["id"].as_u64().unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"mojikumi": "everything"})).is_err()); + s.execute("text.setFormat", &json!({"mojikumi": "none"})).unwrap(); + assert_eq!(para(&s).mojikumi, Mojikumi::None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); + // Saved only when set; documents from before it read as None. + let json = serde_json::to_value(para(&s)).unwrap(); + assert_eq!(json["mojikumi"], "lineEndHalf"); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.mojikumi, Mojikumi::None); + assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none()); +} + +#[test] +fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap(); + let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); + let wide = bounds(&s); + assert!(wide.width() > wide.height()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.orientation.vertical", &json!({})).unwrap(); + let tall = bounds(&s); + assert!(tall.height() > tall.width(), "{tall:?}"); + assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(bounds(&s), wide); +} + +#[test] +fn area_options_columns_change_layout() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let text = "word ".repeat(80); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 120}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["rows"].as_u64(), q["columns"].as_u64()), (Some(1), Some(1))); + let lay = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t), + _ => panic!(), + }; + assert_eq!(lay(&s).frames.len(), 1); + let r = s.execute("text.areaOptions", &json!({"columns": 3, "gutter": 12, "inset": 4, "firstBaseline": "capHeight"})).unwrap(); + assert_eq!(r["firstBaseline"], "capHeight"); + let l = lay(&s); + assert_eq!(l.frames.len(), 3, "three column cells"); + // Glyphs land in more than one column. + let xs: Vec = l.glyphs.iter().map(|g| g.origin.x).collect(); + assert!(xs.iter().any(|x| *x > 110.0 + 10.0) && xs.iter().any(|x| *x < 100.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(lay(&s).frames.len(), 1); + assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err()); +} From 078f7b9aa7288fa615bd871ce8a5f69214ceb710 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:26 +0000 Subject: [PATCH 019/199] refactor(ui-egui): move chrome.rs tests to chrome/tests.rs --- crates/ui-egui/src/chrome.rs | 1372 +--------------------------- crates/ui-egui/src/chrome/tests.rs | 1368 +++++++++++++++++++++++++++ 2 files changed, 1369 insertions(+), 1371 deletions(-) create mode 100644 crates/ui-egui/src/chrome/tests.rs diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5eeda2590..3a51ee4f4 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1176,1377 +1176,7 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - - /// With more tabs than fit, the strip starts late enough to show the active one (#746). - #[test] - fn the_active_tab_stays_in_view() { - let widths = [100.0; 10]; - assert_eq!(super::first_tab_shown(&widths, Some(2), 350.0), 0, "it fits from the start"); - assert_eq!(super::first_tab_shown(&widths, Some(9), 350.0), 7, "the last three tabs shown"); - assert_eq!(super::first_tab_shown(&widths, None, 350.0), 0); - assert_eq!(super::first_tab_shown(&widths, Some(4), 50.0), 4, "a tab wider than the room still shows"); - } - use std::sync::Arc; - - use super::{StatusReadoutWidths, status_readouts}; - use crate::theme::Tokens; - use serde_json::json; - use vectorcraft_engine::Session; - - #[test] - fn hints_name_keys_as_the_menus_do() { - let text = |tool: &str| super::hint_segments(tool).into_iter().map(|(s, _)| s).collect::(); - for tool in vectorcraft_tools::catalog::all_tools().map(|t| t.id) { - let hint = text(tool); - assert!(!hint.contains("Option"), "{tool}: {hint}"); - assert!(cfg!(target_os = "macos") || !hint.contains("Cmd"), "{tool}: {hint}"); - } - let pretty = crate::menus::pretty_shortcut; - assert!(text("selection").contains(&pretty("Alt+Drag"))); - assert!(text("rectangle").contains(&pretty("Alt+Drag"))); - assert!(text("zoom").contains(&pretty("Alt+Click"))); - assert!(text("rotate").contains(&pretty("Alt+Click"))); - assert!(text("eyedropper").contains(&pretty("Alt+Click"))); - assert!(text("perspectiveSelection").contains(&pretty("Alt+Drag")) && text("perspectiveSelection").contains("perpendicular")); - assert!(text("paintbrush").contains(&pretty("Cmd+Shift+/")), "the Search Commands shortcut"); - if !cfg!(target_os = "macos") { - assert!(text("zoom").contains("Alt+Click") && text("paintbrush").contains("Ctrl+Shift+/")); - } - } - - #[derive(Debug)] - struct StatusText { - text: String, - at: egui::Pos2, - clip: egui::Rect, - bounds: egui::Rect, - elided: bool, - } - - /// Collect status-bar text layout from a headless frame without pointer events. - fn status_readout_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32) -> Vec { - status_readout_frame_events(app, ctx, width, vec![]) - } - - /// Collect status-bar text, positions, clips and elision state with explicit pointer events. - fn status_readout_frame_events(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> Vec { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some(StatusText { - text: t.galley.job.text.clone(), - at: t.pos, - clip: s.clip_rect, - bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), - elided: t.galley.elided, - }), - _ => None, - }) - .collect() - } - - /// Find a required text record by prefix, failing the test when it is absent. - fn status_text<'a>(texts: &'a [StatusText], prefix: &str) -> &'a StatusText { - texts.iter().find(|t| t.text.starts_with(prefix)).unwrap_or_else(|| panic!("missing {prefix:?}: {texts:?}")) - } - - /// Prepare an English test document with the app fonts and a status message. - fn status_readout_app() -> (crate::VectorcraftApp, egui::Context) { - crate::i18n::set_current(crate::i18n::Lang::EN); - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - app.ui.status = "Saved recovery data for Untitled-1".to_owned(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, crate::theme::Brightness::Dark); - (app, ctx) - } - - /// Draw the production readouts and observe the cached measurement object across frames. - fn status_width_cache_frame(app: &crate::VectorcraftApp, ctx: &egui::Context, width: f32, ppp: f32) -> Arc { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); - let mut input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; - input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); - let mut cache = None; - let mut output = ctx.run_ui(input, |ui| { - let t = Tokens::get(ui.ctx()); - status_readouts(app, ui, &t); - cache = ui.data(|data| data.get_temp::>(ui.scope_id().with("status-readout-widths"))); - }); - output.textures_delta.clear(); - cache.expect("production status readouts populated their cache") - } - - /// Live values, hover state and window width reuse the same measurement object. - #[test] - fn status_width_measurements_are_reused_across_frames() { - let (mut app, ctx) = status_readout_app(); - let first = status_width_cache_frame(&app, &ctx, 1400.0, 1.0); - for width in [1400.0_f32, 500.0, 2200.0] { - app.perf = crate::Perf { render_ms: f64::from(width), frame_ms: f64::from(width) / 3.0, fps: 120.0 }; - app.hover_doc = Some(vectorcraft_geom::Point::new(-width as f64, width as f64)); - let cached = status_width_cache_frame(&app, &ctx, width, 1.0); - assert!(Arc::ptr_eq(&first, &cached), "live state or window size remeasured the samples"); - } - app.hover_doc = None; - assert!(Arc::ptr_eq(&first, &status_width_cache_frame(&app, &ctx, 1400.0, 1.0))); - } - - /// Each independent cache-key component refreshes widths, then subsequent frames reuse them. - #[test] - fn status_width_measurements_refresh_for_language_units_and_dpi() { - let (mut app, ctx) = status_readout_app(); - let mut previous = status_width_cache_frame(&app, &ctx, 4000.0, 1.0); - let french = crate::i18n::Lang::from_code("fr").unwrap(); - for (lang, unit, ppp) in [ - (french, vectorcraft_doc::Unit::Points, 1.0), - (french, vectorcraft_doc::Unit::Millimeters, 1.0), - (french, vectorcraft_doc::Unit::Millimeters, 1.5), - (crate::i18n::Lang::EN, vectorcraft_doc::Unit::Millimeters, 1.5), - (crate::i18n::Lang::EN, vectorcraft_doc::Unit::Points, 1.5), - (crate::i18n::Lang::EN, vectorcraft_doc::Unit::Points, 1.0), - ] { - crate::i18n::set_current(lang); - app.session.execute("document.setUnits", &json!({"units": unit.key()})).unwrap(); - let fresh = status_width_cache_frame(&app, &ctx, 4000.0, ppp); - assert_eq!(fresh.key, (lang, unit, ppp)); - assert!(!Arc::ptr_eq(&previous, &fresh), "changed language/units/DPI reused stale widths"); - assert!(fresh.values.iter().all(|w| w.is_finite() && *w > 0.0)); - assert!(Arc::ptr_eq(&fresh, &status_width_cache_frame(&app, &ctx, 4000.0, ppp))); - previous = fresh; - } - } - - /// These assertions require every target to be present, and cover the old padding limits. - #[test] - fn the_status_bar_stands_still_while_its_readouts_change() { - let (mut app, ctx) = status_readout_app(); - let mut baseline = None; - let mut message_baseline = None; - for (render, frame, fps, x, y) in [ - (233.4, 4.9, 80.0, -662.25, 1647.9), - (9.8, 1.0, 120.0, 7.0, 5.0), - (100000.0, 10000.0, 1000.0, -1000.0, 100000.0), - (99999.9, 9999.9, 999.0, -999.99, 9999.99), - ] { - app.perf = crate::Perf { render_ms: render, frame_ms: frame, fps }; - app.hover_doc = Some(vectorcraft_geom::Point::new(x, y)); - let texts = status_readout_frame(&mut app, &ctx, 1400.0); - let message = status_text(&texts, "Saved recovery data"); - let extent = (message.at, message.clip, message.bounds, message.elided); - if let Some(first) = message_baseline { - assert_eq!(extent, first, "message extent changed with readout values"); - } else { - message_baseline = Some(extent); - } - for (prefix, value) in [ - ("render", format!("{render:.1} ms")), - ("ui ", format!("{frame:.1} ms")), - ("X:", app.session.general_unit().readout(x)), - ("Y:", app.session.general_unit().readout(y)), - ] { - let text = status_text(&texts, prefix); - assert!(text.text.contains(&value), "{text:?} must contain {value:?}"); - assert!(text.bounds.width() <= text.clip.width() + 0.01, "field overflow: {text:?}"); - } - let fps_text = format!("{fps:.0} fps"); - let fps_label = status_text(&texts, &fps_text); - assert!(fps_label.bounds.width() <= fps_label.clip.width() + 0.01); - let positions = ["Saved recovery data", "render", "ui ", "X:", "Y:", fps_text.as_str()].map(|p| status_text(&texts, p).at); - if let Some(first) = baseline { - assert_eq!(positions, first, "readout origins changed"); - } else { - baseline = Some(positions); - } - } - app.perf.render_ms = 1000000.0; - let tree = crate::widgets::accessibility_tests::tree(|ui| super::status_bar(&mut app, ui)); - assert!( - tree.iter().any(|n| n.role() == egui::accesskit::Role::Label && n.value() == Some("render 1000000.0 ms")), - "screen readers receive the complete value" - ); - } - - /// Canvas hover entry and exit keep the status message at the same origin. - #[test] - fn status_messages_keep_their_origin_when_the_pointer_enters_and_leaves() { - let (mut app, ctx) = status_readout_app(); - let mut first = None; - for hover in [None, Some(vectorcraft_geom::Point::new(7.0, 5.0)), None] { - app.hover_doc = hover; - let texts = status_readout_frame(&mut app, &ctx, 1400.0); - let message = status_text(&texts, "Saved recovery data"); - assert!(!message.elided); - let position = (message.at, message.clip); - if let Some(first) = first { - assert_eq!(position, first); - } else { - first = Some(position); - } - assert_eq!(texts.iter().any(|t| t.text.starts_with("X:")), hover.is_some()); - } - } - - /// Hovering a coordinate preserves its document-scoped value for inspection. - #[test] - fn hovering_a_coordinate_retains_its_full_value_for_inspection() { - let (mut app, ctx) = status_readout_app(); - app.hover_doc = Some(vectorcraft_geom::Point::new(1e12, -1e12)); - let first = status_readout_frame(&mut app, &ctx, 1400.0); - let x = status_text(&first, "X:"); - assert!(x.elided); - let expected = x.text.clone(); - app.hover_doc = None; - let inspect = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(x.clip.center())]); - assert_eq!(status_text(&inspect, "X:").text, expected); - let gone = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerGone]); - assert!(!gone.iter().any(|t| t.text.starts_with("X:"))); - // A different document must not inherit the previous document's coordinate. - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let other = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(x.clip.center())]); - assert!(!other.iter().any(|t| t.text.starts_with("X:"))); - } - - /// Narrow windows hide optional readouts while keeping the status message visible. - #[test] - fn narrow_status_bars_preserve_the_message_and_hide_optional_readouts() { - let (mut app, ctx) = status_readout_app(); - app.hover_doc = Some(vectorcraft_geom::Point::new(7.0, 5.0)); - let mut origin = None; - for (width, coords, perf) in [(1400.0, true, true), (1000.0, true, false), (800.0, false, false)] { - let texts = status_readout_frame(&mut app, &ctx, width); - let message = status_text(&texts, "Saved recovery data"); - assert!(!message.elided, "{width}: message was truncated: {message:?}"); - if let Some(origin) = origin { - assert_eq!(message.at, origin); - } else { - origin = Some(message.at); - } - assert_eq!(texts.iter().any(|t| t.text.starts_with("X:")), coords, "{width}"); - assert_eq!(texts.iter().any(|t| t.text.starts_with("render")), perf, "{width}"); - let tool = status_text(&texts, "Selection"); - assert!(message.clip.left() >= tool.bounds.right(), "readouts must not overlap navigation: {texts:?}"); - for field in texts.iter().filter(|t| t.text.starts_with("X:") || t.text.starts_with("Y:") || t.text.starts_with("render")) { - assert!(field.clip.left() >= tool.bounds.right()); - assert!(!field.clip.intersects(message.clip)); - } - } - // Still narrower bars may truncate a long message but must not draw over navigation. - let texts = status_readout_frame(&mut app, &ctx, 600.0); - let message = status_text(&texts, "Saved recovery data"); - assert!(message.clip.left() >= status_text(&texts, "Selection").bounds.right()); - } - - /// Busy-job transitions preserve message placement, labels and accessibility. - #[test] - fn background_activity_keeps_the_status_message_origin() { - let (mut app, ctx) = status_readout_app(); - let before = status_readout_frame(&mut app, &ctx, 1400.0); - let before = status_text(&before, "Saved recovery data"); - let (tx, rx) = std::sync::mpsc::channel(); - app.services.write_shared = Some(std::sync::Arc::new(|_, _| Ok(()))); - crate::background::run( - &mut app, - true, - "Saving test.vectorcraft".to_owned(), - None, - move |_| { - rx.recv().unwrap(); - Ok(json!({})) - }, - |app, result| { - app.status("Saved recovery data for Untitled-1"); - result - }, - ) - .unwrap(); - let busy = status_readout_frame(&mut app, &ctx, 1400.0); - let message = status_text(&busy, "Saving test.vectorcraft"); - assert_eq!((before.at, before.clip), (message.at, message.clip)); - crate::background::run(&mut app, true, "Exporting test.svg".to_owned(), None, |_| Ok(json!({})), |_, result| result).unwrap(); - let several = status_readout_frame(&mut app, &ctx, 1400.0); - let message = status_text(&several, "Saving test.vectorcraft"); - assert!(message.text.ends_with("(+1)")); - assert_eq!((before.at, before.clip), (message.at, message.clip)); - let tree = crate::widgets::accessibility_tests::tree(|ui| super::status_bar(&mut app, ui)); - assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::ProgressIndicator), "the busy spinner remains accessible"); - tx.send(()).unwrap(); - crate::background::wait_all(&mut app); - let after = status_readout_frame(&mut app, &ctx, 1400.0); - let after = status_text(&after, "Saved recovery data"); - assert_eq!((before.at, before.clip), (after.at, after.clip)); - } - - /// Ordinary signed coordinates fit without elision in every supported unit. - #[test] - fn ordinary_coordinates_are_readable_in_every_unit() { - let (mut app, ctx) = status_readout_app(); - for unit in vectorcraft_doc::Unit::ALL { - app.session.execute("document.setUnits", &json!({"units": unit.key()})).unwrap(); - for (x, y) in [(-1234.56, 1234.56), (-9999.99, 9999.99), (9999.99, -9999.99)] { - let (x, y) = (unit.to_pt(x), unit.to_pt(y)); - app.hover_doc = Some(vectorcraft_geom::Point::new(x, y)); - let texts = status_readout_frame(&mut app, &ctx, 1400.0); - for (axis, value) in [("X:", x), ("Y:", y)] { - let field = status_text(&texts, axis); - assert_eq!(field.text, format!("{axis} {}", unit.readout(value))); - assert!(!field.elided, "{unit:?}: {field:?}"); - assert!(field.clip.contains_rect(field.bounds), "{unit:?}: {field:?}"); - } - } - } - } - - /// Localized diagnostics remain readable without moving neighboring fields. - #[test] - fn localized_diagnostics_are_readable_and_keep_their_field_bounds() { - let (mut app, ctx) = status_readout_app(); - for lang in crate::i18n::Lang::all() { - crate::i18n::set_current(lang); - let render = crate::i18n::tr_ctx(lang, "diagnostics", "render"); - let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui"); - let mut baseline = None; - for (render_ms, frame_ms, fps, readable) in [(233.4, 4.9, 80.0, true), (9999.9, 9999.9, 9999.0, true), (1e12, 1e12, 1e12, false)] { - app.perf = crate::Perf { render_ms, frame_ms, fps }; - let texts = status_readout_frame(&mut app, &ctx, 4000.0); - let fields = [format!("{render} {render_ms:.1} ms"), format!("{ui_name} {frame_ms:.1} ms"), format!("{fps:.0} fps")]; - let bounds = fields.map(|value| { - let field = status_text(&texts, &value); - assert_eq!(field.text, value); - assert_eq!(!field.elided, readable, "{}: {field:?}", lang.code()); - assert!(field.bounds.width() <= field.clip.width() + 0.01, "{}: {field:?}", lang.code()); - (field.at, field.clip) - }); - if let Some(first) = baseline { - assert_eq!(bounds, first, "{}: field allocation changed", lang.code()); - } else { - baseline = Some(bounds); - } - } - // Even long translations must yield their whole diagnostics group in a narrow - // window rather than overlapping the message or navigation controls. - let texts = status_readout_frame(&mut app, &ctx, 800.0); - assert!(!texts.iter().any(|t| t.text == format!("{render} {:.1} ms", app.perf.render_ms)), "{}", lang.code()); - } - crate::i18n::set_current(crate::i18n::Lang::EN); - } - - /// A document tab's × sits where the platform puts it (#673): after the title on Windows, - /// Linux and the web, before it on macOS; clicking it closes that document. - #[test] - fn a_tabs_close_box_sits_where_the_platform_puts_it() { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 200.0)); - let frame = |app: &mut crate::VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::doc_tabs(app, ui)); - out.textures_delta.clear(); - let mut titles = vec![]; - for c in &out.shapes { - // The titles, not the × icons. - if let egui::Shape::Text(t) = &c.shape - && t.visual_bounding_rect().width() > 30.0 - { - titles.push(t.visual_bounding_rect()); - } - } - titles.sort_by(|a, b| a.left().total_cmp(&b.left())); - titles - }; - let titles = frame(&mut app, vec![]); - assert_eq!(titles.len(), 2, "{titles:?}"); - // The first tab's ×: 20 pt past its title's end, or 16 pt before its start. - let first = titles[0]; - let x = if super::CLOSE_BEFORE_TITLE { first.left() - 16.0 } else { first.right() + 20.0 }; - let at = egui::pos2(x, first.center().y); - let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, vec![egui::Event::PointerMoved(at), press(true)]); - frame(&mut app, vec![press(false)]); - assert_eq!(app.session.documents().len(), 1, "the × closed the first document"); - assert!(super::CLOSE_BEFORE_TITLE == cfg!(target_os = "macos")); - } - - /// One headless frame of the status bar; returns the artboard navigator's buttons, left to - /// right: first, previous, next, last. - fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); - out.textures_delta.clear(); - let size = egui::vec2(18.0, 18.0); - let mut r: Vec = - ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()); - r.sort_by(|a, b| a.left().total_cmp(&b.left())); - r - } - - /// Artboard navigation updates the active artboard and fitted view together. - #[test] - fn the_artboard_navigator_goes_to_artboards_and_fitting_shows_the_current_one() { - use vectorcraft_geom::Point; - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - // Three 200 × 100 artboards in a row, centred at x = 100, 320 and 540. - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("artboard.new", json!({})).unwrap(); - app.run("artboard.new", json!({})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let buttons = navigator(&mut app, &ctx, vec![]); - assert_eq!(buttons.len(), 4); - let click = |app: &mut crate::VectorcraftApp, r: egui::Rect| { - let b = |pressed| egui::Event::PointerButton { - pos: r.center(), - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; - navigator(app, &ctx, vec![egui::Event::PointerMoved(r.center())]); - navigator(app, &ctx, vec![b(true)]); - navigator(app, &ctx, vec![b(false)]); - }; - let at = |app: &crate::VectorcraftApp| (app.view().unwrap().artboard, app.view().unwrap().center); - click(&mut app, buttons[2]); - assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "next"); - click(&mut app, buttons[3]); - assert_eq!(at(&app), (2, Point::new(540.0, 50.0)), "last"); - click(&mut app, buttons[1]); - assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "previous"); - // Fit Artboard in Window and Actual Size show the navigator's artboard. - app.run("view.setZoom", json!({"zoom": 300, "center": [0, 0]})).unwrap(); - app.run("view.fitArtboard", json!({})).unwrap(); - assert_eq!(at(&app), (1, Point::new(320.0, 50.0))); - app.run("view.actualSize", json!({})).unwrap(); - assert_eq!((at(&app).1, app.view().unwrap().zoom), (Point::new(320.0, 50.0), 1.0)); - click(&mut app, buttons[0]); - assert_eq!(at(&app), (0, Point::new(100.0, 50.0)), "first"); - // The command also takes an index, clamped to the last artboard. - assert_eq!(app.run("view.goToArtboard", json!({"index": 9})), Ok(json!({"index": 2}))); - assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err()); - } - - /// storytold/vectorcraft#2719: status-bar 100% matches Actual Size when Display Print Size is on. - #[test] - fn set_zoom_100_matches_actual_size_with_display_print_size() { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap(); - app.run("view.setZoom", json!({"zoom": 100})).unwrap(); - assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12); - } - - /// Screen readers name the artboard navigator's four buttons, the ones that lead nowhere from - /// the first artboard disabled. - #[test] - fn screen_readers_name_the_artboard_navigators_buttons() { - use crate::widgets::accessibility_tests::{named, tree}; - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - let tree = tree(|ui| super::status_bar(&mut app, ui)); - for (label, enabled) in [("First Artboard", false), ("Previous Artboard", false), ("Next Artboard", true), ("Last Artboard", true)] { - let n = named(&tree, label); - assert_eq!((n.role(), n.is_disabled()), (egui::accesskit::Role::Button, !enabled), "{label}"); - } - } - - /// One headless frame of the status bar, with room above it for the artboard list; returns the - /// texts painted and where they are. - fn status_texts(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 600.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), - _ => None, - }) - .collect() - } - - /// Read the painted coordinates while checking that drawing never changes the source - /// position, document, selection or either branch of its edit history. - fn assert_status_coordinates(app: &mut crate::VectorcraftApp, expected: Option<&str>) { - let hover = app.hover_doc; - let before = - app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let texts = status_texts(app, &ctx, vec![]); - let coordinates: Vec<&str> = - texts.iter().filter_map(|(text, _)| (text.starts_with("X: ") || text.starts_with("Y: ")).then_some(text.as_str())).collect(); - let expected: Vec<&str> = expected.into_iter().flat_map(|text| text.split(" ")).collect(); - assert_eq!(coordinates, expected, "{texts:?}"); - assert_eq!(app.hover_doc, hover, "tools still receive the absolute document position"); - let after = - app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); - assert_eq!(after, before, "painting is read-only"); - } - - #[test] - fn status_coordinates_follow_global_ruler_origins_and_general_units() { - use vectorcraft_geom::Point; - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("view.globalRulers", json!({"global": true})).unwrap(); - app.session.active_mut().unwrap().mark_saved(); - app.hover_doc = Some(Point::new(72.0, 144.0)); - assert_status_coordinates(&mut app, Some("X: 72.00 pt Y: 144.00 pt")); - app.run("document.setRulerOrigin", json!({"x": 36, "y": 72})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 36.00 pt Y: 72.00 pt")); - app.run("document.setRulerOrigin", json!({"x": -72, "y": 216})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 144.00 pt Y: -72.00 pt")); - app.run("document.setUnits", json!({"units": "Inches"})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 2.00 in Y: -1.00 in")); - app.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 50.80 mm Y: -25.40 mm")); - app.run("document.resetRulerOrigin", json!({})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 25.40 mm Y: 50.80 mm")); - // Keep a nonempty Redo branch as well as Undo while painting. - app.run("edit.undo", json!({})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 50.80 mm Y: -25.40 mm")); - } - - #[test] - fn status_coordinates_follow_the_active_artboard_and_ruler_mode() { - use vectorcraft_geom::Point; - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap(); - app.hover_doc = Some(Point::new(760.0, -210.0)); - app.run("document.setRulerOrigin", json!({"artboard": 0, "x": 25, "y": 30})).unwrap(); - app.run("view.goToArtboard", json!({"index": 0})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 735.00 pt Y: -240.00 pt")); - app.run("view.goToArtboard", json!({"index": 1})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 60.00 pt Y: 40.00 pt")); - app.run("document.setRulerOrigin", json!({"artboard": 1, "x": 800, "y": -190})).unwrap(); - assert_status_coordinates(&mut app, Some("X: -40.00 pt Y: -20.00 pt")); - app.run("view.globalRulers", json!({"global": true})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 760.00 pt Y: -210.00 pt")); - app.run("document.setRulerOrigin", json!({"x": -20, "y": -10})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 780.00 pt Y: -200.00 pt")); - app.run("view.globalRulers", json!({"global": false})).unwrap(); - assert_status_coordinates(&mut app, Some("X: -40.00 pt Y: -20.00 pt")); - app.run("document.resetRulerOrigin", json!({"artboard": 1})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 60.00 pt Y: 40.00 pt")); - app.run("view.goToArtboard", json!({"index": 0})).unwrap(); - assert_status_coordinates(&mut app, Some("X: 735.00 pt Y: -240.00 pt")); - } - - #[test] - fn status_coordinates_handle_missing_pointer_or_document() { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - assert_status_coordinates(&mut app, None); - app.hover_doc = Some(vectorcraft_geom::Point::new(42.0, -21.0)); - assert_status_coordinates(&mut app, Some("X: 42.00 pt Y: -21.00 pt")); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("document.setRulerOrigin", json!({"x": 10, "y": 20})).unwrap(); - app.hover_doc = None; - assert_status_coordinates(&mut app, None); - } - - #[test] - fn status_coordinates_keep_the_inspected_point_relative_after_an_origin_change() { - let (mut app, ctx) = status_readout_app(); - app.run("view.globalRulers", json!({"global": true})).unwrap(); - app.run("document.setRulerOrigin", json!({"x": 36, "y": 72})).unwrap(); - app.hover_doc = Some(vectorcraft_geom::Point::new(72.0, 144.0)); - let first = status_readout_frame(&mut app, &ctx, 1400.0); - let at = status_text(&first, "X:").bounds.center(); - app.hover_doc = None; - let inspecting = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(at)]); - assert_eq!(status_text(&inspecting, "X:").text, "X: 36.00 pt"); - assert_eq!(status_text(&inspecting, "Y:").text, "Y: 72.00 pt"); - app.run("document.setRulerOrigin", json!({"x": -72, "y": 216})).unwrap(); - let before = app.session.active().unwrap().doc.clone(); - let inspecting = status_readout_frame(&mut app, &ctx, 1400.0); - assert_eq!(status_text(&inspecting, "X:").text, "X: 144.00 pt"); - assert_eq!(status_text(&inspecting, "Y:").text, "Y: -72.00 pt"); - assert_eq!(app.hover_doc, None, "inspection never changes the absolute canvas hover state"); - assert_eq!(app.session.active().unwrap().doc, before, "painting is read-only"); - let gone = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerGone]); - assert!(!gone.iter().any(|field| field.text.starts_with("X:") || field.text.starts_with("Y:"))); - } - - /// A click at `at` in the status bar; returns the texts of the frame after it. - fn status_click(app: &mut crate::VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) -> Vec<(String, egui::Rect)> { - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - status_texts(app, ctx, vec![event]); - } - status_texts(app, ctx, vec![]) - } - - /// The navigator shows the active artboard's number and name, and its list shows every artboard - /// by number and name. Choosing one goes to it as the navigator's buttons do - /// (`view.goToArtboard`): it becomes the active artboard and the Artboard tool's, the view - /// centers on it, and the list closes. - #[test] - fn the_artboard_navigator_lists_the_artboards_by_number_and_name() { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - app.run("artboard.setProps", json!({"index": 0, "name": "Front"})).unwrap(); - app.run("artboard.setProps", json!({"index": 1, "name": "Back"})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); - app.select_tool("artboard"); - app.run("view.goToArtboard", json!({"index": 1})).unwrap(); - let first = app.session.active().unwrap().doc.artboards[0].rect.center(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let texts = status_texts(&mut app, &ctx, vec![]); - let field = texts.iter().find(|(s, _)| s == "2 Back").map(|(_, r)| *r).expect("the field shows artboard 2's number and name"); - assert!(!texts.iter().any(|(s, _)| s == "Front"), "the list is closed: {texts:?}"); - let texts = status_click(&mut app, &ctx, field.center()); - let at = |s: &str| texts.iter().find(|(t, _)| t == s).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {s:?} in {texts:?}")); - let rows: Vec = [("1", "Front"), ("2", "Back"), ("3", "Artboard 3")] - .into_iter() - .map(|(number, name)| { - let (n, r) = (at(number), at(name)); - assert!(r.y_range().contains(n.center().y) && n.right() < r.left(), "{number} before {name} on one row: {texts:?}"); - r - }) - .collect(); - assert!(rows.windows(2).all(|w| w[0].bottom() <= w[1].top()), "the rows in the artboards' order: {rows:?}"); - let texts = status_click(&mut app, &ctx, at("Front").center()); - assert_eq!((app.view().unwrap().artboard, app.view().unwrap().center), (0, first), "Front, fitted in the window"); - assert_eq!(app.session.tool_options()["active"], json!(0)); - assert!(texts.iter().any(|(s, _)| s == "1 Front"), "{texts:?}"); - assert!(!texts.iter().any(|(s, _)| s == "Back"), "the list closed: {texts:?}"); - } - - /// With 1,000 artboards the list lays out at most 12 rows. It opens on the active - /// artboard's row, a swipe scrolls it to the last artboard, and choosing that row goes there. - #[test] - fn the_artboard_list_lays_out_only_the_rows_near_the_view() { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 100, "height": 100, "artboards": 1000})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); - app.run("view.goToArtboard", json!({"index": 699})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let rows = |texts: &[(String, egui::Rect)]| texts.iter().filter(|(s, _)| s.starts_with("Artboard ")).cloned().collect::>(); - let texts = status_texts(&mut app, &ctx, vec![]); - let field = texts.iter().find(|(s, _)| s == "700 Artboard 700").map(|(_, r)| *r).expect("the field"); - let shown = rows(&status_click(&mut app, &ctx, field.center())); - assert!((1..=12).contains(&shown.len()), "{} rows laid out", shown.len()); - let active = shown.iter().find(|(s, _)| s == "Artboard 700").map(|(_, r)| *r).expect("the active artboard's row in view"); - let swipe = |phase, dy| egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(0.0, dy), - phase, - modifiers: Default::default(), - source: egui::MouseWheelSource::Unknown, - }; - status_texts( - &mut app, - &ctx, - vec![egui::Event::PointerMoved(active.center()), swipe(egui::TouchPhase::Start, 0.0), swipe(egui::TouchPhase::Move, -100_000.0)], - ); - let shown = rows(&status_texts(&mut app, &ctx, vec![swipe(egui::TouchPhase::End, 0.0)])); - assert!((1..=12).contains(&shown.len()), "{} rows laid out", shown.len()); - let last = shown.iter().find(|(s, _)| s == "Artboard 1000").map(|(_, r)| *r).expect("the last row after the swipe"); - status_click(&mut app, &ctx, last.center()); - assert_eq!(app.view().unwrap().artboard, 999); - } - - fn artboard_navigation_app(tool: &str) -> (crate::VectorcraftApp, vectorcraft_doc::NodeId) { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); - app.select_tool(tool); - crate::i18n::set_current(crate::i18n::Lang::EN); - (app, vectorcraft_doc::NodeId(id)) - } - - /// Click the actual status-bar Next control, without sending a canvas pointer event. - fn artboard_navigation_next(app: &mut crate::VectorcraftApp, ctx: &egui::Context) { - let buttons = navigator(app, ctx, vec![]); - assert_eq!(buttons.len(), 4); - let at = buttons[2].center(); - assert!(!app.canvas_rect.unwrap().contains(at)); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - navigator(app, ctx, vec![event]); - } - } - - fn artboard_navigation_properties_frame( - app: &mut crate::VectorcraftApp, - ctx: &egui::Context, - events: Vec, - ) -> Vec<(String, egui::Rect)> { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { - egui::Panel::right("navigation-properties").exact_size(300.0).show(ui, |ui| crate::panels::properties::show(app, ui)); - }); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), - _ => None, - }) - .collect() - } - - /// None clicks Properties' Delete Artboard; Some routes the real key through shortcuts. - fn artboard_navigation_delete(key: Option) { - for multiple in [false, true] { - let (mut app, id) = artboard_navigation_app("artboard"); - let selected = if multiple { json!([0, 2]) } else { json!([0]) }; - app.session.set_tool_option("selected", &selected); - assert_eq!(app.view().unwrap().artboard, 0); - assert_eq!(app.session.tool_options()["active"], json!(0)); - assert_eq!(app.session.tool_options()["selected"], selected); - app.session.active_mut().unwrap().mark_saved(); - let st = app.session.active().unwrap(); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let undo = st.history.undo.len(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - - artboard_navigation_next(&mut app, &ctx); - - assert_eq!(app.view().unwrap().artboard, 1, "Next shows board 2"); - assert_eq!(app.session.tool_options()["active"], json!(1), "Next must change the destructive target before deletion"); - assert_eq!(app.session.tool_options()["selected"], json!([1]), "navigation replaces the old artboard multiselection"); - assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center()); - let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "navigation only changes view and tool state"); - assert_eq!(st.selection, selection, "ordinary artwork stays selected"); - assert_eq!(st.history.undo.len(), undo); - assert!(st.history.redo.is_empty()); - assert!(!st.is_dirty()); - - if let Some(key) = key { - let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![ev], ..Default::default() }, |ui| { - crate::shortcuts::handle(&mut app, ui.ctx()); - super::status_bar(&mut app, ui); - }); - out.textures_delta.clear(); - } else { - artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name), "Properties shows the navigated board"); - let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, rect)| rect.center()).expect("Properties Delete button"); - assert!(!app.canvas_rect.unwrap().contains(at)); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); - } - } - - let st = app.session.active().unwrap(); - assert_eq!( - st.doc.artboards, - vec![before.artboards[0].clone(), before.artboards[2].clone()], - "first and third original board records survive" - ); - assert_eq!(st.doc.node(id), before.node(id), "selected artwork survives unchanged"); - assert_eq!(st.selection, selection); - assert_eq!(st.history.undo.len(), undo + 1); - assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); - assert!(st.history.redo.is_empty()); - assert!(st.is_dirty()); - let deleted = st.doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "one Undo restores every board and the artwork"); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); - assert!(!st.is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, deleted); - } - } - - #[test] - fn artboard_navigation_next_then_delete_uses_shown_board() { - artboard_navigation_delete(Some(egui::Key::Delete)); - } - - #[test] - fn artboard_navigation_next_then_backspace_uses_shown_board() { - artboard_navigation_delete(Some(egui::Key::Backspace)); - } - - #[test] - fn artboard_navigation_next_then_properties_delete_uses_shown_board() { - artboard_navigation_delete(None); - } - - /// Navigating artboards preserves other tools, their selections and undo history. - #[test] - fn artboard_navigation_preserves_other_tools_selection_and_history() { - for tool in ["selection", "rectangle"] { - let (mut app, _) = artboard_navigation_app(tool); - app.run("object.move", json!({"dx": 7, "dy": 9})).unwrap(); - let redo = app.session.active().unwrap().doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - app.session.active_mut().unwrap().mark_saved(); - let st = app.session.active().unwrap(); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let history = (st.history.undo.len(), st.history.redo.len()); - assert_eq!(history.1, 1); - let options = app.session.tool_options(); - - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - artboard_navigation_next(&mut app, &ctx); - - assert_eq!(app.view().unwrap().artboard, 1); - assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center()); - assert_eq!(app.session.tool_id(), tool); - assert_eq!(app.session.tool_options(), options); - let st = app.session.active().unwrap(); - assert_eq!(st.doc, before); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), history); - assert!(!st.is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, redo, "navigation preserves the original Redo branch"); - } - } - - #[test] - fn the_tab_title_names_the_opacity_mask_while_it_is_edited() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); - let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false, false); - assert!(title(&s).ends_with("(RGB/Preview)"), "{}", title(&s)); - s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); - assert!(title(&s).ends_with("(/Opacity Mask)"), "{}", title(&s)); - s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); - assert!(title(&s).ends_with("(RGB/Preview)")); - } - - /// One headless frame of the Control bar; returns the isolation button's rectangle, if it shows. - fn isolation_button(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::control_bar(app, ui)); - out.textures_delta.clear(); - ctx.viewport(|vp| vp.prev_pass.widgets.get(egui::Id::unique(super::ISOLATION_BUTTON)).map(|w| w.rect)) - } - - /// Entering isolation mode leaves the canvas where it was: the breadcrumb bar is drawn over the - /// canvas and takes no layout height from it (#3126). - #[test] - fn the_isolation_bar_does_not_take_layout_height_from_the_canvas() { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); - let top = |app: &mut crate::VectorcraftApp| { - let mut below = 0.0; - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { - super::doc_tabs(app, ui); - below = ui.available_rect_before_wrap().top(); - let area = ui.available_rect_before_wrap(); - super::isolation_bar(app, ui, area); - assert_eq!(ui.available_rect_before_wrap().top(), below, "the bar takes no layout space"); - }); - out.textures_delta.clear(); - below - }; - let before = top(&mut app); - app.run("object.isolate", json!({"id": id})).unwrap(); - assert!(app.session.active().unwrap().isolation.is_some()); - assert_eq!(top(&mut app), before, "isolation mode leaves the canvas where it was"); - } - - /// The Control bar's Isolate Selected Object isolates a path inside a group that Direct - /// Selection selected, and Exit Isolation Mode leaves with the path selected. Type, symbol - /// instances, blends, two objects and an envelope's contents get no isolate button, and pattern - /// editing gets no exit button. - #[test] - fn the_control_bar_isolates_the_selected_object_and_exits_isolation_mode() { - use vectorcraft_doc::NodeId; - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); - let rect = |app: &mut crate::VectorcraftApp, x: f64| { - app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap() - }; - let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 50.0)); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - app.run("object.group", json!({})).unwrap(); - app.select_tool("directSelection"); - app.run("select.anchors", json!({"id": a, "anchors": [[0, 0]]})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let click = |app: &mut crate::VectorcraftApp| { - let r = isolation_button(app, &ctx, vec![]).expect("the bar shows the isolation button"); - let press = |pressed| egui::Event::PointerButton { - pos: r.center(), - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; - for e in [egui::Event::PointerMoved(r.center()), press(true), press(false)] { - isolation_button(app, &ctx, vec![e]); - } - }; - click(&mut app); - assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a)), "Isolate Selected Object isolates the path"); - click(&mut app); - let st = app.session.active().unwrap(); - assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![NodeId(a)]), "Exit Isolation Mode selects the path"); - app.select_tool("selection"); - // Type, a symbol instance, a blend and two objects. - let text = app.run("text.create", json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - let c = rect(&mut app, 100.0); - app.run("select.set", json!({"ids": [c]})).unwrap(); - let symbol = app.run("symbol.new", json!({"name": "Box"})).unwrap()["id"].as_u64().unwrap(); - let (d, e) = (rect(&mut app, 150.0), rect(&mut app, 250.0)); - let blend = app.run("object.blend.make", json!({"ids": [d, e], "steps": 1})).unwrap()["id"].as_u64().unwrap(); - for ids in [json!([text]), json!([symbol]), json!([blend]), json!([text, symbol])] { - app.run("select.set", json!({ "ids": ids })).unwrap(); - assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "{ids}"); - } - // An envelope's contents, while Edit Contents is on. - let g = rect(&mut app, 350.0); - app.run("select.set", json!({"ids": [g]})).unwrap(); - app.run("object.envelope.makeWithWarp", json!({})).unwrap(); - app.run("object.envelope.editContents", json!({"editing": true})).unwrap(); - assert_eq!(app.session.active().unwrap().selection.objects, vec![NodeId(g)]); - assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "the envelope's controls show instead"); - let f = rect(&mut app, 300.0); - app.run("select.set", json!({"ids": [f]})).unwrap(); - app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); - assert!(app.session.active().unwrap().isolation.is_some()); - assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "the isolation bar ends pattern editing"); - } - - /// The Zoom tool's hint follows Performance › Animated Zoom (#394): a drag zooms as it goes, - /// or (off) zooms into the area dragged across. - #[test] - fn the_zoom_hint_follows_animated_zoom() { - fn texts(s: &egui::Shape, out: &mut String) { - match s { - egui::Shape::Text(t) => out.push_str(t.galley.text()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.select_tool("zoom"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let hint = |app: &mut crate::VectorcraftApp| { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::hint_bar(app, ui)); - out.textures_delta.clear(); - let mut shown = String::new(); - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - shown - }; - let on = hint(&mut app); - assert!(on.contains(" right or left to zoom in or out") && on.contains("Hold"), "{on}"); - app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); - let off = hint(&mut app); - assert!(off.contains(" to zoom into an area") && !off.contains("Hold"), "{off}"); - } - - fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.custom_titlebar = custom_titlebar; - // The preference that makes the shell launch without its own title bar. - app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap(); - app - } - - #[test] - fn without_the_preference_the_mark_stays() { - // The default (e.g. the web build): no custom bar, no preference. - let app = crate::VectorcraftApp::new(Session::new(), Default::default()); - assert!(!super::system_title_bar(&app)); - } - - #[test] - fn window_title_with_no_document_is_the_app_name() { - assert_eq!(super::window_title(&title_test_app(true)), "VectorCraft"); - } - - #[test] - fn window_title_follows_the_active_file() { - let mut app = title_test_app(true); - app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap(); - assert_eq!(super::window_title(&app), "foo.svg \u{2014} VectorCraft"); - // Unsaved changes gain a `*` prefix, like the `*` in the document tabs. - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - assert_eq!(super::window_title(&app), "*foo.svg \u{2014} VectorCraft"); - } - - #[test] - fn system_mode_hides_the_mark() { - for custom in [true, false] { - let mut app = title_test_app(custom); - app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(&mut app, ui)); - let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count(); - out.textures_delta.clear(); - let marks: Vec = out - .shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx) => Some(m.calc_bounds()), - _ => None, - }) - .collect(); - let system = super::system_title_bar(&app); - assert_eq!(system, !custom && !cfg!(target_os = "macos")); - assert_eq!(marks.is_empty(), system, "mark in system mode (custom={custom})"); - assert_eq!(uploads, if system { 0 } else { 1 }, "mark texture in system mode (custom={custom})"); - } - } - - #[test] - fn sync_window_title_sends_only_on_change() { - let mut app = title_test_app(true); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - super::sync_window_title(&mut app, &ctx); - assert_eq!(app.last_window_title, "VectorCraft"); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(); - assert!(cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(t) if t == "VectorCraft")), "{cmds:?}"); - // Same document state again: the cached title stops a repeat command. - super::sync_window_title(&mut app, &ctx); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(); - assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(_))), "repeat Title: {cmds:?}"); - app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap(); - super::sync_window_title(&mut app, &ctx); - assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft"); - } - /// Draw actual tabs, including the same per-frame view synchronization as the app. - fn artboard_document_tabs_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { - app.sync_views(); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::doc_tabs(app, ui)); - out.textures_delta.clear(); - let mut titles: Vec<_> = out - .shapes - .iter() - .filter_map(|shape| match &shape.shape { - egui::Shape::Text(t) if t.galley.text().contains(" @ ") => Some(t.visual_bounding_rect()), - _ => None, - }) - .collect(); - titles.sort_by(|a, b| a.left().total_cmp(&b.left())); - titles - } - - fn artboard_document_click_tab(app: &mut crate::VectorcraftApp, ctx: &egui::Context, index: usize) { - let titles = artboard_document_tabs_frame(app, ctx, vec![]); - assert_eq!(titles.len(), 2, "both real document tabs must be visible"); - let at = titles[index].center(); - assert!(!app.canvas_rect.unwrap().contains(at)); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - artboard_document_tabs_frame(app, ctx, vec![event]); - } - assert_eq!(app.session.active_index(), Some(index), "the actual tab click must activate its document"); - } - - fn artboard_document_app() -> (crate::VectorcraftApp, egui::Context) { - let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); - app.run("artboard.setProps", json!({"index": 0, "name": "Keep"})).unwrap(); - app.run("artboard.setProps", json!({"index": 1, "name": "Shown"})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); - app.run("symbol.new", json!({"name": "Tab symbol"})).unwrap(); - app.run("shape.rectangle", json!({"x": 230, "y": 10, "width": 30, "height": 20})).unwrap(); - app.run("guide.add", json!({"artboard": 0, "pos": 20})).unwrap(); - app.run("guide.add", json!({"artboard": 1, "pos": 250, "vertical": true})).unwrap(); - app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); - // Neither tab nor Properties clicks enter the canvas; a canvas move could hide the bug. - app.canvas_rect = Some(egui::Rect::from_min_max(egui::pos2(0.0, 40.0), egui::pos2(800.0, 260.0))); - crate::i18n::set_current(crate::i18n::Lang::EN); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - artboard_document_click_tab(&mut app, &ctx, 0); - (app, ctx) - } - - #[test] - fn artboard_document_tab_roundtrip_properties_delete_uses_shown_board() { - let (mut app, ctx) = artboard_document_app(); - app.select_tool("artboard"); - app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); - app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); - artboard_navigation_next(&mut app, &ctx); - assert_eq!(app.view().unwrap().artboard, 1); - assert_eq!(app.session.tool_options()["selected"], json!([1]), "start with the tool and navigator aligned"); - app.session.active_mut().unwrap().mark_saved(); - let before = app.session.active().unwrap().doc.clone(); - let selection = app.session.active().unwrap().selection.clone(); - let undo = app.session.active().unwrap().history.undo.len(); - let other = app.session.documents()[1].doc.clone(); - let other_history = (app.session.documents()[1].history.undo.len(), app.session.documents()[1].history.redo.len()); - - // No direct set_active, tool reselection, or selected-option repair after this round trip. - artboard_document_click_tab(&mut app, &ctx, 1); - artboard_document_click_tab(&mut app, &ctx, 0); - assert_eq!(app.view().unwrap().artboard, 1, "the document keeps its displayed artboard"); - assert_eq!(app.session.active().unwrap().doc, before, "switching tabs is not a document edit"); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo); - assert!(!app.session.active().unwrap().is_dirty()); - let selected_before_delete = app.session.tool_options()["selected"].clone(); - let active_before_delete = app.session.tool_options()["active"].clone(); - assert_eq!(app.session.tool_options()["moveArt"], json!(false)); - assert_eq!(app.session.tool_options()["scaleArt"], json!(true)); - - artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(label, _)| label == "Shown"), "Properties must display the second board before deletion"); - let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, r)| r.center()).expect("actual Properties Delete button"); - assert!(!app.canvas_rect.unwrap().contains(at)); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let commands = app.run_count; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); - } - let st = app.session.active().unwrap(); - assert_eq!( - st.doc.artboards.iter().map(|a| a.name.as_str()).collect::>(), - vec!["Keep"], - "Properties Delete after a document tab round trip must remove the shown board" - ); - assert_eq!(selected_before_delete, json!([1])); - assert_eq!(active_before_delete, json!(1)); - assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "the original first board survives"); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()], "only the deleted board's guide is removed"); - assert_eq!(st.doc.layers, before.layers, "artwork and symbol instances stay unchanged"); - assert_eq!(st.doc.symbols, before.symbols, "symbol definitions stay unchanged"); - assert_eq!(st.selection, selection); - assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual Properties control"); - assert_eq!(st.history.undo.len(), undo + 1); - assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); - assert!(st.history.redo.is_empty()); - assert!(st.is_dirty()); - let deleted = st.doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, before, "one Undo restores the correct board, guides and art"); - assert!(!app.session.active().unwrap().is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, deleted); - assert_eq!(app.session.documents()[1].doc, other); - assert_eq!((app.session.documents()[1].history.undo.len(), app.session.documents()[1].history.redo.len()), other_history); - } - - #[test] - fn artboard_document_tab_same_document_preserves_multiselection_and_options() { - let (mut app, ctx) = artboard_document_app(); - app.select_tool("artboard"); - artboard_navigation_next(&mut app, &ctx); - app.run("tool.setOption", json!({"key": "selected", "value": [1, 0]})).unwrap(); - app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); - app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); - let options = app.session.tool_options(); - let before = app.session.active().unwrap().doc.clone(); - let history = (app.session.active().unwrap().history.undo.len(), app.session.active().unwrap().history.redo.len()); - artboard_document_click_tab(&mut app, &ctx, 0); - for _ in 0..3 { - artboard_document_tabs_frame(&mut app, &ctx, vec![]); - } - assert_eq!(app.session.tool_options(), options, "an already-active tab must not collapse valid multiselection or options"); - assert_eq!(app.view().unwrap().artboard, 1); - assert_eq!(app.session.active().unwrap().doc, before); - assert_eq!((app.session.active().unwrap().history.undo.len(), app.session.active().unwrap().history.redo.len()), history); - } - - #[test] - fn artboard_document_tab_roundtrip_preserves_other_tools() { - for tool in ["selection", "rectangle"] { - let (mut app, ctx) = artboard_document_app(); - app.select_tool(tool); - artboard_navigation_next(&mut app, &ctx); - let before = app.session.active().unwrap().doc.clone(); - let selection = app.session.active().unwrap().selection.clone(); - let options = app.session.tool_options(); - let history = (app.session.active().unwrap().history.undo.len(), app.session.active().unwrap().history.redo.len()); - artboard_document_click_tab(&mut app, &ctx, 1); - artboard_document_click_tab(&mut app, &ctx, 0); - assert_eq!(app.session.tool_id(), tool); - assert_eq!(app.session.tool_options(), options); - assert_eq!(app.view().unwrap().artboard, 1); - assert_eq!(app.session.active().unwrap().doc, before); - assert_eq!(app.session.active().unwrap().selection, selection); - assert_eq!((app.session.active().unwrap().history.undo.len(), app.session.active().unwrap().history.redo.len()), history); - } - } - - #[test] - fn artboard_close_active_tab_properties_delete_uses_shown_board() { - let (mut app, ctx) = artboard_document_app(); - let history = |st: &vectorcraft_engine::DocState| { - let entries = |items: &[vectorcraft_engine::HistoryEntry]| { - items.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone())).collect::>() - }; - (entries(&st.history.undo), entries(&st.history.redo)) - }; - let outside_canvas = |app: &crate::VectorcraftApp| { - let p = ctx.input(|i| i.pointer.hover_pos()).expect("a real chrome or panel pointer position"); - assert!(!app.canvas_rect.unwrap().contains(p), "the pointer must stay outside the canvas: {p:?}"); - }; - app.select_tool("artboard"); - app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); - app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); - artboard_navigation_next(&mut app, &ctx); - outside_canvas(&app); - assert_eq!(app.view().unwrap().artboard, 1); - assert_eq!(app.session.tool_options()["selected"], json!([1]), "the initial navigator action targets Shown"); - app.session.active_mut().unwrap().mark_saved(); - let st = app.session.active().unwrap(); - let (uid, revision) = (st.uid, st.revision); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let history_before = history(st); - let history_limits = (st.history.limit, st.history.byte_budget); - assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); - assert_eq!(before.guides.len(), 2); - assert!(!selection.objects.is_empty()); - assert!(history_before.1.is_empty()); - assert!(!st.is_dirty()); - let mut expected = before.as_ref().clone(); - expected.artboards = vec![before.artboards[0].clone()]; - expected.guides = vec![before.guides[0].clone()]; - - // Route from the status bar along the panel edge to the tab strip, never through canvas. - for at in [egui::pos2(1000.0, 280.0), egui::pos2(1000.0, 20.0)] { - artboard_document_tabs_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); - outside_canvas(&app); - } - artboard_document_click_tab(&mut app, &ctx, 1); - outside_canvas(&app); - app.session.active_mut().unwrap().mark_saved(); - let other_uid = app.session.active().unwrap().uid; - assert_ne!(other_uid, uid); - assert!(app.session.documents().iter().all(|d| !d.is_dirty()), "both ordinary documents are in saved state"); - assert!(app.ui.dialog.is_none()); - eprintln!("ARTBOARD_CLOSE_TARGET_SETUP_READY"); - - // The actual B tab x, using the existing close-box test's title-relative hit position. - let titles = artboard_document_tabs_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); - assert_eq!(titles.len(), 2); - let title = titles[1]; - let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 }; - let at = egui::pos2(x, title.center().y); - assert!(!app.canvas_rect.unwrap().contains(at)); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - artboard_document_tabs_frame(&mut app, &ctx, vec![event]); - outside_canvas(&app); - } - assert_eq!(app.session.documents().len(), 1, "B tab x must really close B"); - assert_eq!(app.session.active_index(), Some(0)); - assert_eq!(app.session.active().unwrap().uid, uid, "the survivor is the original A, not a replacement document"); - assert!(app.session.documents().iter().all(|d| d.uid != other_uid)); - assert!(app.ui.dialog.is_none(), "a clean close must not leave a save/discard modal"); - for _ in 0..2 { - artboard_document_tabs_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); - } - let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "closing B must not edit A"); - assert_eq!(st.selection, selection); - assert_eq!(history(st), history_before, "closing B preserves all A Undo/Redo entries"); - assert_eq!(st.revision, revision); - assert_eq!((st.history.limit, st.history.byte_budget), history_limits); - assert!(!st.is_dirty()); - assert_eq!(app.view().unwrap().artboard, 1); - eprintln!("ARTBOARD_CLOSE_TARGET_CLOSED_B"); - - // Cross the tab strip to the Properties panel before moving down to its Delete control. - app.sync_views(); - artboard_navigation_properties_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(1000.0, 20.0))]); - outside_canvas(&app); - app.sync_views(); - let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); - assert!(texts.iter().any(|(label, _)| label == "Shown"), "actual Properties must display Shown before deletion"); - assert_eq!(app.view().unwrap().artboard, 1); - let tool_before_delete = app.session.tool_options(); - eprintln!("ARTBOARD_CLOSE_TARGET_SHOWN_DISPLAYED tool={tool_before_delete}"); - let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, r)| r.center()).expect("actual Properties Delete button"); - assert!(at.x > app.canvas_rect.unwrap().right(), "Delete is in the right panel"); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let commands = app.run_count; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - app.sync_views(); - artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); - outside_canvas(&app); - } - let st = app.session.active().unwrap(); - assert_eq!(app.run_count, commands.wrapping_add(1), "the actual Properties click executes one command"); - assert_eq!(st.doc.artboards.len(), 1, "the real Delete action removed one board"); - assert_eq!(st.history.undo.len(), history_before.0.len() + 1); - assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); - eprintln!("ARTBOARD_CLOSE_TARGET_DELETE_CLICKED tool_before={tool_before_delete}"); - // Do not assert tool alignment before this destructive-action oracle: it must expose the wrong deletion. - assert_eq!( - st.doc.artboards.iter().map(|a| a.name.as_str()).collect::>(), - vec!["Keep"], - "ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B" - ); - assert_eq!(st.doc.as_ref(), &expected, "only Shown and its attached guide may be removed from the full document"); - assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged"); - assert_eq!(st.doc.symbols, before.symbols); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()]); - assert_eq!(st.selection, selection); - assert_eq!(st.uid, uid); - assert_eq!(st.revision, revision + 1); - let history_after = history(st); - assert_eq!(&history_after.0[..history_before.0.len()], history_before.0.as_slice()); - let entry = history_after.0.last().unwrap(); - assert_eq!(entry.1, before); - assert_eq!(entry.2.as_ref(), &selection); - assert!(history_after.1.is_empty()); - assert!(st.is_dirty()); - let deleted = st.doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - let st = app.session.active().unwrap(); - assert_eq!(st.uid, uid); - assert_eq!(st.doc, before, "one Undo restores the full saved A including both boards, guides and artwork"); - assert_eq!(st.selection, selection); - assert_eq!(history(st).0, history_before.0); - assert_eq!(st.history.redo.len(), 1); - assert_eq!(st.history.redo[0].label, "Delete Artboard"); - assert_eq!(st.history.redo[0].doc, deleted); - assert_eq!(st.history.redo[0].selection.as_ref(), &selection); - assert!(!st.is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - let st = app.session.active().unwrap(); - assert_eq!(st.uid, uid); - assert_eq!(st.doc.as_ref(), &expected, "one Redo repeats only the correct deletion"); - assert_eq!(st.selection, selection); - assert_eq!(history(st), history_after); - assert_eq!((st.history.limit, st.history.byte_budget), history_limits); - assert!(st.is_dirty()); - assert_eq!(app.session.documents().len(), 1, "Undo/Redo do not reopen B"); - eprintln!("ARTBOARD_CLOSE_TARGET_MODEL_AND_HISTORY_PRESERVED"); - } -} +mod tests; #[cfg(test)] mod visual_regressions { diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs new file mode 100644 index 000000000..bd6ab4717 --- /dev/null +++ b/crates/ui-egui/src/chrome/tests.rs @@ -0,0 +1,1368 @@ +/// With more tabs than fit, the strip starts late enough to show the active one (#746). +#[test] +fn the_active_tab_stays_in_view() { + let widths = [100.0; 10]; + assert_eq!(super::first_tab_shown(&widths, Some(2), 350.0), 0, "it fits from the start"); + assert_eq!(super::first_tab_shown(&widths, Some(9), 350.0), 7, "the last three tabs shown"); + assert_eq!(super::first_tab_shown(&widths, None, 350.0), 0); + assert_eq!(super::first_tab_shown(&widths, Some(4), 50.0), 4, "a tab wider than the room still shows"); +} +use std::sync::Arc; + +use super::{StatusReadoutWidths, status_readouts}; +use crate::theme::Tokens; +use serde_json::json; +use vectorcraft_engine::Session; + +#[test] +fn hints_name_keys_as_the_menus_do() { + let text = |tool: &str| super::hint_segments(tool).into_iter().map(|(s, _)| s).collect::(); + for tool in vectorcraft_tools::catalog::all_tools().map(|t| t.id) { + let hint = text(tool); + assert!(!hint.contains("Option"), "{tool}: {hint}"); + assert!(cfg!(target_os = "macos") || !hint.contains("Cmd"), "{tool}: {hint}"); + } + let pretty = crate::menus::pretty_shortcut; + assert!(text("selection").contains(&pretty("Alt+Drag"))); + assert!(text("rectangle").contains(&pretty("Alt+Drag"))); + assert!(text("zoom").contains(&pretty("Alt+Click"))); + assert!(text("rotate").contains(&pretty("Alt+Click"))); + assert!(text("eyedropper").contains(&pretty("Alt+Click"))); + assert!(text("perspectiveSelection").contains(&pretty("Alt+Drag")) && text("perspectiveSelection").contains("perpendicular")); + assert!(text("paintbrush").contains(&pretty("Cmd+Shift+/")), "the Search Commands shortcut"); + if !cfg!(target_os = "macos") { + assert!(text("zoom").contains("Alt+Click") && text("paintbrush").contains("Ctrl+Shift+/")); + } +} + +#[derive(Debug)] +struct StatusText { + text: String, + at: egui::Pos2, + clip: egui::Rect, + bounds: egui::Rect, + elided: bool, +} + +/// Collect status-bar text layout from a headless frame without pointer events. +fn status_readout_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32) -> Vec { + status_readout_frame_events(app, ctx, width, vec![]) +} + +/// Collect status-bar text, positions, clips and elision state with explicit pointer events. +fn status_readout_frame_events(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> Vec { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(StatusText { + text: t.galley.job.text.clone(), + at: t.pos, + clip: s.clip_rect, + bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), + elided: t.galley.elided, + }), + _ => None, + }) + .collect() +} + +/// Find a required text record by prefix, failing the test when it is absent. +fn status_text<'a>(texts: &'a [StatusText], prefix: &str) -> &'a StatusText { + texts.iter().find(|t| t.text.starts_with(prefix)).unwrap_or_else(|| panic!("missing {prefix:?}: {texts:?}")) +} + +/// Prepare an English test document with the app fonts and a status message. +fn status_readout_app() -> (crate::VectorcraftApp, egui::Context) { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.ui.status = "Saved recovery data for Untitled-1".to_owned(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Brightness::Dark); + (app, ctx) +} + +/// Draw the production readouts and observe the cached measurement object across frames. +fn status_width_cache_frame(app: &crate::VectorcraftApp, ctx: &egui::Context, width: f32, ppp: f32) -> Arc { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); + let mut input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); + let mut cache = None; + let mut output = ctx.run_ui(input, |ui| { + let t = Tokens::get(ui.ctx()); + status_readouts(app, ui, &t); + cache = ui.data(|data| data.get_temp::>(ui.scope_id().with("status-readout-widths"))); + }); + output.textures_delta.clear(); + cache.expect("production status readouts populated their cache") +} + +/// Live values, hover state and window width reuse the same measurement object. +#[test] +fn status_width_measurements_are_reused_across_frames() { + let (mut app, ctx) = status_readout_app(); + let first = status_width_cache_frame(&app, &ctx, 1400.0, 1.0); + for width in [1400.0_f32, 500.0, 2200.0] { + app.perf = crate::Perf { render_ms: f64::from(width), frame_ms: f64::from(width) / 3.0, fps: 120.0 }; + app.hover_doc = Some(vectorcraft_geom::Point::new(-width as f64, width as f64)); + let cached = status_width_cache_frame(&app, &ctx, width, 1.0); + assert!(Arc::ptr_eq(&first, &cached), "live state or window size remeasured the samples"); + } + app.hover_doc = None; + assert!(Arc::ptr_eq(&first, &status_width_cache_frame(&app, &ctx, 1400.0, 1.0))); +} + +/// Each independent cache-key component refreshes widths, then subsequent frames reuse them. +#[test] +fn status_width_measurements_refresh_for_language_units_and_dpi() { + let (mut app, ctx) = status_readout_app(); + let mut previous = status_width_cache_frame(&app, &ctx, 4000.0, 1.0); + let french = crate::i18n::Lang::from_code("fr").unwrap(); + for (lang, unit, ppp) in [ + (french, vectorcraft_doc::Unit::Points, 1.0), + (french, vectorcraft_doc::Unit::Millimeters, 1.0), + (french, vectorcraft_doc::Unit::Millimeters, 1.5), + (crate::i18n::Lang::EN, vectorcraft_doc::Unit::Millimeters, 1.5), + (crate::i18n::Lang::EN, vectorcraft_doc::Unit::Points, 1.5), + (crate::i18n::Lang::EN, vectorcraft_doc::Unit::Points, 1.0), + ] { + crate::i18n::set_current(lang); + app.session.execute("document.setUnits", &json!({"units": unit.key()})).unwrap(); + let fresh = status_width_cache_frame(&app, &ctx, 4000.0, ppp); + assert_eq!(fresh.key, (lang, unit, ppp)); + assert!(!Arc::ptr_eq(&previous, &fresh), "changed language/units/DPI reused stale widths"); + assert!(fresh.values.iter().all(|w| w.is_finite() && *w > 0.0)); + assert!(Arc::ptr_eq(&fresh, &status_width_cache_frame(&app, &ctx, 4000.0, ppp))); + previous = fresh; + } +} + +/// These assertions require every target to be present, and cover the old padding limits. +#[test] +fn the_status_bar_stands_still_while_its_readouts_change() { + let (mut app, ctx) = status_readout_app(); + let mut baseline = None; + let mut message_baseline = None; + for (render, frame, fps, x, y) in [ + (233.4, 4.9, 80.0, -662.25, 1647.9), + (9.8, 1.0, 120.0, 7.0, 5.0), + (100000.0, 10000.0, 1000.0, -1000.0, 100000.0), + (99999.9, 9999.9, 999.0, -999.99, 9999.99), + ] { + app.perf = crate::Perf { render_ms: render, frame_ms: frame, fps }; + app.hover_doc = Some(vectorcraft_geom::Point::new(x, y)); + let texts = status_readout_frame(&mut app, &ctx, 1400.0); + let message = status_text(&texts, "Saved recovery data"); + let extent = (message.at, message.clip, message.bounds, message.elided); + if let Some(first) = message_baseline { + assert_eq!(extent, first, "message extent changed with readout values"); + } else { + message_baseline = Some(extent); + } + for (prefix, value) in [ + ("render", format!("{render:.1} ms")), + ("ui ", format!("{frame:.1} ms")), + ("X:", app.session.general_unit().readout(x)), + ("Y:", app.session.general_unit().readout(y)), + ] { + let text = status_text(&texts, prefix); + assert!(text.text.contains(&value), "{text:?} must contain {value:?}"); + assert!(text.bounds.width() <= text.clip.width() + 0.01, "field overflow: {text:?}"); + } + let fps_text = format!("{fps:.0} fps"); + let fps_label = status_text(&texts, &fps_text); + assert!(fps_label.bounds.width() <= fps_label.clip.width() + 0.01); + let positions = ["Saved recovery data", "render", "ui ", "X:", "Y:", fps_text.as_str()].map(|p| status_text(&texts, p).at); + if let Some(first) = baseline { + assert_eq!(positions, first, "readout origins changed"); + } else { + baseline = Some(positions); + } + } + app.perf.render_ms = 1000000.0; + let tree = crate::widgets::accessibility_tests::tree(|ui| super::status_bar(&mut app, ui)); + assert!( + tree.iter().any(|n| n.role() == egui::accesskit::Role::Label && n.value() == Some("render 1000000.0 ms")), + "screen readers receive the complete value" + ); +} + +/// Canvas hover entry and exit keep the status message at the same origin. +#[test] +fn status_messages_keep_their_origin_when_the_pointer_enters_and_leaves() { + let (mut app, ctx) = status_readout_app(); + let mut first = None; + for hover in [None, Some(vectorcraft_geom::Point::new(7.0, 5.0)), None] { + app.hover_doc = hover; + let texts = status_readout_frame(&mut app, &ctx, 1400.0); + let message = status_text(&texts, "Saved recovery data"); + assert!(!message.elided); + let position = (message.at, message.clip); + if let Some(first) = first { + assert_eq!(position, first); + } else { + first = Some(position); + } + assert_eq!(texts.iter().any(|t| t.text.starts_with("X:")), hover.is_some()); + } +} + +/// Hovering a coordinate preserves its document-scoped value for inspection. +#[test] +fn hovering_a_coordinate_retains_its_full_value_for_inspection() { + let (mut app, ctx) = status_readout_app(); + app.hover_doc = Some(vectorcraft_geom::Point::new(1e12, -1e12)); + let first = status_readout_frame(&mut app, &ctx, 1400.0); + let x = status_text(&first, "X:"); + assert!(x.elided); + let expected = x.text.clone(); + app.hover_doc = None; + let inspect = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(x.clip.center())]); + assert_eq!(status_text(&inspect, "X:").text, expected); + let gone = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerGone]); + assert!(!gone.iter().any(|t| t.text.starts_with("X:"))); + // A different document must not inherit the previous document's coordinate. + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let other = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(x.clip.center())]); + assert!(!other.iter().any(|t| t.text.starts_with("X:"))); +} + +/// Narrow windows hide optional readouts while keeping the status message visible. +#[test] +fn narrow_status_bars_preserve_the_message_and_hide_optional_readouts() { + let (mut app, ctx) = status_readout_app(); + app.hover_doc = Some(vectorcraft_geom::Point::new(7.0, 5.0)); + let mut origin = None; + for (width, coords, perf) in [(1400.0, true, true), (1000.0, true, false), (800.0, false, false)] { + let texts = status_readout_frame(&mut app, &ctx, width); + let message = status_text(&texts, "Saved recovery data"); + assert!(!message.elided, "{width}: message was truncated: {message:?}"); + if let Some(origin) = origin { + assert_eq!(message.at, origin); + } else { + origin = Some(message.at); + } + assert_eq!(texts.iter().any(|t| t.text.starts_with("X:")), coords, "{width}"); + assert_eq!(texts.iter().any(|t| t.text.starts_with("render")), perf, "{width}"); + let tool = status_text(&texts, "Selection"); + assert!(message.clip.left() >= tool.bounds.right(), "readouts must not overlap navigation: {texts:?}"); + for field in texts.iter().filter(|t| t.text.starts_with("X:") || t.text.starts_with("Y:") || t.text.starts_with("render")) { + assert!(field.clip.left() >= tool.bounds.right()); + assert!(!field.clip.intersects(message.clip)); + } + } + // Still narrower bars may truncate a long message but must not draw over navigation. + let texts = status_readout_frame(&mut app, &ctx, 600.0); + let message = status_text(&texts, "Saved recovery data"); + assert!(message.clip.left() >= status_text(&texts, "Selection").bounds.right()); +} + +/// Busy-job transitions preserve message placement, labels and accessibility. +#[test] +fn background_activity_keeps_the_status_message_origin() { + let (mut app, ctx) = status_readout_app(); + let before = status_readout_frame(&mut app, &ctx, 1400.0); + let before = status_text(&before, "Saved recovery data"); + let (tx, rx) = std::sync::mpsc::channel(); + app.services.write_shared = Some(std::sync::Arc::new(|_, _| Ok(()))); + crate::background::run( + &mut app, + true, + "Saving test.vectorcraft".to_owned(), + None, + move |_| { + rx.recv().unwrap(); + Ok(json!({})) + }, + |app, result| { + app.status("Saved recovery data for Untitled-1"); + result + }, + ) + .unwrap(); + let busy = status_readout_frame(&mut app, &ctx, 1400.0); + let message = status_text(&busy, "Saving test.vectorcraft"); + assert_eq!((before.at, before.clip), (message.at, message.clip)); + crate::background::run(&mut app, true, "Exporting test.svg".to_owned(), None, |_| Ok(json!({})), |_, result| result).unwrap(); + let several = status_readout_frame(&mut app, &ctx, 1400.0); + let message = status_text(&several, "Saving test.vectorcraft"); + assert!(message.text.ends_with("(+1)")); + assert_eq!((before.at, before.clip), (message.at, message.clip)); + let tree = crate::widgets::accessibility_tests::tree(|ui| super::status_bar(&mut app, ui)); + assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::ProgressIndicator), "the busy spinner remains accessible"); + tx.send(()).unwrap(); + crate::background::wait_all(&mut app); + let after = status_readout_frame(&mut app, &ctx, 1400.0); + let after = status_text(&after, "Saved recovery data"); + assert_eq!((before.at, before.clip), (after.at, after.clip)); +} + +/// Ordinary signed coordinates fit without elision in every supported unit. +#[test] +fn ordinary_coordinates_are_readable_in_every_unit() { + let (mut app, ctx) = status_readout_app(); + for unit in vectorcraft_doc::Unit::ALL { + app.session.execute("document.setUnits", &json!({"units": unit.key()})).unwrap(); + for (x, y) in [(-1234.56, 1234.56), (-9999.99, 9999.99), (9999.99, -9999.99)] { + let (x, y) = (unit.to_pt(x), unit.to_pt(y)); + app.hover_doc = Some(vectorcraft_geom::Point::new(x, y)); + let texts = status_readout_frame(&mut app, &ctx, 1400.0); + for (axis, value) in [("X:", x), ("Y:", y)] { + let field = status_text(&texts, axis); + assert_eq!(field.text, format!("{axis} {}", unit.readout(value))); + assert!(!field.elided, "{unit:?}: {field:?}"); + assert!(field.clip.contains_rect(field.bounds), "{unit:?}: {field:?}"); + } + } + } +} + +/// Localized diagnostics remain readable without moving neighboring fields. +#[test] +fn localized_diagnostics_are_readable_and_keep_their_field_bounds() { + let (mut app, ctx) = status_readout_app(); + for lang in crate::i18n::Lang::all() { + crate::i18n::set_current(lang); + let render = crate::i18n::tr_ctx(lang, "diagnostics", "render"); + let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui"); + let mut baseline = None; + for (render_ms, frame_ms, fps, readable) in [(233.4, 4.9, 80.0, true), (9999.9, 9999.9, 9999.0, true), (1e12, 1e12, 1e12, false)] { + app.perf = crate::Perf { render_ms, frame_ms, fps }; + let texts = status_readout_frame(&mut app, &ctx, 4000.0); + let fields = [format!("{render} {render_ms:.1} ms"), format!("{ui_name} {frame_ms:.1} ms"), format!("{fps:.0} fps")]; + let bounds = fields.map(|value| { + let field = status_text(&texts, &value); + assert_eq!(field.text, value); + assert_eq!(!field.elided, readable, "{}: {field:?}", lang.code()); + assert!(field.bounds.width() <= field.clip.width() + 0.01, "{}: {field:?}", lang.code()); + (field.at, field.clip) + }); + if let Some(first) = baseline { + assert_eq!(bounds, first, "{}: field allocation changed", lang.code()); + } else { + baseline = Some(bounds); + } + } + // Even long translations must yield their whole diagnostics group in a narrow + // window rather than overlapping the message or navigation controls. + let texts = status_readout_frame(&mut app, &ctx, 800.0); + assert!(!texts.iter().any(|t| t.text == format!("{render} {:.1} ms", app.perf.render_ms)), "{}", lang.code()); + } + crate::i18n::set_current(crate::i18n::Lang::EN); +} + +/// A document tab's × sits where the platform puts it (#673): after the title on Windows, +/// Linux and the web, before it on macOS; clicking it closes that document. +#[test] +fn a_tabs_close_box_sits_where_the_platform_puts_it() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 200.0)); + let frame = |app: &mut crate::VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::doc_tabs(app, ui)); + out.textures_delta.clear(); + let mut titles = vec![]; + for c in &out.shapes { + // The titles, not the × icons. + if let egui::Shape::Text(t) = &c.shape + && t.visual_bounding_rect().width() > 30.0 + { + titles.push(t.visual_bounding_rect()); + } + } + titles.sort_by(|a, b| a.left().total_cmp(&b.left())); + titles + }; + let titles = frame(&mut app, vec![]); + assert_eq!(titles.len(), 2, "{titles:?}"); + // The first tab's ×: 20 pt past its title's end, or 16 pt before its start. + let first = titles[0]; + let x = if super::CLOSE_BEFORE_TITLE { first.left() - 16.0 } else { first.right() + 20.0 }; + let at = egui::pos2(x, first.center().y); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(at), press(true)]); + frame(&mut app, vec![press(false)]); + assert_eq!(app.session.documents().len(), 1, "the × closed the first document"); + assert!(super::CLOSE_BEFORE_TITLE == cfg!(target_os = "macos")); +} + +/// One headless frame of the status bar; returns the artboard navigator's buttons, left to +/// right: first, previous, next, last. +fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); + out.textures_delta.clear(); + let size = egui::vec2(18.0, 18.0); + let mut r: Vec = + ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()); + r.sort_by(|a, b| a.left().total_cmp(&b.left())); + r +} + +/// Artboard navigation updates the active artboard and fitted view together. +#[test] +fn the_artboard_navigator_goes_to_artboards_and_fitting_shows_the_current_one() { + use vectorcraft_geom::Point; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + // Three 200 × 100 artboards in a row, centred at x = 100, 320 and 540. + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("artboard.new", json!({})).unwrap(); + app.run("artboard.new", json!({})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let buttons = navigator(&mut app, &ctx, vec![]); + assert_eq!(buttons.len(), 4); + let click = |app: &mut crate::VectorcraftApp, r: egui::Rect| { + let b = |pressed| egui::Event::PointerButton { + pos: r.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + navigator(app, &ctx, vec![egui::Event::PointerMoved(r.center())]); + navigator(app, &ctx, vec![b(true)]); + navigator(app, &ctx, vec![b(false)]); + }; + let at = |app: &crate::VectorcraftApp| (app.view().unwrap().artboard, app.view().unwrap().center); + click(&mut app, buttons[2]); + assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "next"); + click(&mut app, buttons[3]); + assert_eq!(at(&app), (2, Point::new(540.0, 50.0)), "last"); + click(&mut app, buttons[1]); + assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "previous"); + // Fit Artboard in Window and Actual Size show the navigator's artboard. + app.run("view.setZoom", json!({"zoom": 300, "center": [0, 0]})).unwrap(); + app.run("view.fitArtboard", json!({})).unwrap(); + assert_eq!(at(&app), (1, Point::new(320.0, 50.0))); + app.run("view.actualSize", json!({})).unwrap(); + assert_eq!((at(&app).1, app.view().unwrap().zoom), (Point::new(320.0, 50.0), 1.0)); + click(&mut app, buttons[0]); + assert_eq!(at(&app), (0, Point::new(100.0, 50.0)), "first"); + // The command also takes an index, clamped to the last artboard. + assert_eq!(app.run("view.goToArtboard", json!({"index": 9})), Ok(json!({"index": 2}))); + assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err()); +} + +/// storytold/vectorcraft#2719: status-bar 100% matches Actual Size when Display Print Size is on. +#[test] +fn set_zoom_100_matches_actual_size_with_display_print_size() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap(); + app.run("view.setZoom", json!({"zoom": 100})).unwrap(); + assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12); +} + +/// Screen readers name the artboard navigator's four buttons, the ones that lead nowhere from +/// the first artboard disabled. +#[test] +fn screen_readers_name_the_artboard_navigators_buttons() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let tree = tree(|ui| super::status_bar(&mut app, ui)); + for (label, enabled) in [("First Artboard", false), ("Previous Artboard", false), ("Next Artboard", true), ("Last Artboard", true)] { + let n = named(&tree, label); + assert_eq!((n.role(), n.is_disabled()), (egui::accesskit::Role::Button, !enabled), "{label}"); + } +} + +/// One headless frame of the status bar, with room above it for the artboard list; returns the +/// texts painted and where they are. +fn status_texts(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 600.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect() +} + +/// Read the painted coordinates while checking that drawing never changes the source +/// position, document, selection or either branch of its edit history. +fn assert_status_coordinates(app: &mut crate::VectorcraftApp, expected: Option<&str>) { + let hover = app.hover_doc; + let before = + app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = status_texts(app, &ctx, vec![]); + let coordinates: Vec<&str> = + texts.iter().filter_map(|(text, _)| (text.starts_with("X: ") || text.starts_with("Y: ")).then_some(text.as_str())).collect(); + let expected: Vec<&str> = expected.into_iter().flat_map(|text| text.split(" ")).collect(); + assert_eq!(coordinates, expected, "{texts:?}"); + assert_eq!(app.hover_doc, hover, "tools still receive the absolute document position"); + let after = + app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); + assert_eq!(after, before, "painting is read-only"); +} + +#[test] +fn status_coordinates_follow_global_ruler_origins_and_general_units() { + use vectorcraft_geom::Point; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.globalRulers", json!({"global": true})).unwrap(); + app.session.active_mut().unwrap().mark_saved(); + app.hover_doc = Some(Point::new(72.0, 144.0)); + assert_status_coordinates(&mut app, Some("X: 72.00 pt Y: 144.00 pt")); + app.run("document.setRulerOrigin", json!({"x": 36, "y": 72})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 36.00 pt Y: 72.00 pt")); + app.run("document.setRulerOrigin", json!({"x": -72, "y": 216})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 144.00 pt Y: -72.00 pt")); + app.run("document.setUnits", json!({"units": "Inches"})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 2.00 in Y: -1.00 in")); + app.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 50.80 mm Y: -25.40 mm")); + app.run("document.resetRulerOrigin", json!({})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 25.40 mm Y: 50.80 mm")); + // Keep a nonempty Redo branch as well as Undo while painting. + app.run("edit.undo", json!({})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 50.80 mm Y: -25.40 mm")); +} + +#[test] +fn status_coordinates_follow_the_active_artboard_and_ruler_mode() { + use vectorcraft_geom::Point; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap(); + app.hover_doc = Some(Point::new(760.0, -210.0)); + app.run("document.setRulerOrigin", json!({"artboard": 0, "x": 25, "y": 30})).unwrap(); + app.run("view.goToArtboard", json!({"index": 0})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 735.00 pt Y: -240.00 pt")); + app.run("view.goToArtboard", json!({"index": 1})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 60.00 pt Y: 40.00 pt")); + app.run("document.setRulerOrigin", json!({"artboard": 1, "x": 800, "y": -190})).unwrap(); + assert_status_coordinates(&mut app, Some("X: -40.00 pt Y: -20.00 pt")); + app.run("view.globalRulers", json!({"global": true})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 760.00 pt Y: -210.00 pt")); + app.run("document.setRulerOrigin", json!({"x": -20, "y": -10})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 780.00 pt Y: -200.00 pt")); + app.run("view.globalRulers", json!({"global": false})).unwrap(); + assert_status_coordinates(&mut app, Some("X: -40.00 pt Y: -20.00 pt")); + app.run("document.resetRulerOrigin", json!({"artboard": 1})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 60.00 pt Y: 40.00 pt")); + app.run("view.goToArtboard", json!({"index": 0})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 735.00 pt Y: -240.00 pt")); +} + +#[test] +fn status_coordinates_handle_missing_pointer_or_document() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + assert_status_coordinates(&mut app, None); + app.hover_doc = Some(vectorcraft_geom::Point::new(42.0, -21.0)); + assert_status_coordinates(&mut app, Some("X: 42.00 pt Y: -21.00 pt")); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("document.setRulerOrigin", json!({"x": 10, "y": 20})).unwrap(); + app.hover_doc = None; + assert_status_coordinates(&mut app, None); +} + +#[test] +fn status_coordinates_keep_the_inspected_point_relative_after_an_origin_change() { + let (mut app, ctx) = status_readout_app(); + app.run("view.globalRulers", json!({"global": true})).unwrap(); + app.run("document.setRulerOrigin", json!({"x": 36, "y": 72})).unwrap(); + app.hover_doc = Some(vectorcraft_geom::Point::new(72.0, 144.0)); + let first = status_readout_frame(&mut app, &ctx, 1400.0); + let at = status_text(&first, "X:").bounds.center(); + app.hover_doc = None; + let inspecting = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(at)]); + assert_eq!(status_text(&inspecting, "X:").text, "X: 36.00 pt"); + assert_eq!(status_text(&inspecting, "Y:").text, "Y: 72.00 pt"); + app.run("document.setRulerOrigin", json!({"x": -72, "y": 216})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let inspecting = status_readout_frame(&mut app, &ctx, 1400.0); + assert_eq!(status_text(&inspecting, "X:").text, "X: 144.00 pt"); + assert_eq!(status_text(&inspecting, "Y:").text, "Y: -72.00 pt"); + assert_eq!(app.hover_doc, None, "inspection never changes the absolute canvas hover state"); + assert_eq!(app.session.active().unwrap().doc, before, "painting is read-only"); + let gone = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerGone]); + assert!(!gone.iter().any(|field| field.text.starts_with("X:") || field.text.starts_with("Y:"))); +} + +/// A click at `at` in the status bar; returns the texts of the frame after it. +fn status_click(app: &mut crate::VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) -> Vec<(String, egui::Rect)> { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + status_texts(app, ctx, vec![event]); + } + status_texts(app, ctx, vec![]) +} + +/// The navigator shows the active artboard's number and name, and its list shows every artboard +/// by number and name. Choosing one goes to it as the navigator's buttons do +/// (`view.goToArtboard`): it becomes the active artboard and the Artboard tool's, the view +/// centers on it, and the list closes. +#[test] +fn the_artboard_navigator_lists_the_artboards_by_number_and_name() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + app.run("artboard.setProps", json!({"index": 0, "name": "Front"})).unwrap(); + app.run("artboard.setProps", json!({"index": 1, "name": "Back"})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); + app.select_tool("artboard"); + app.run("view.goToArtboard", json!({"index": 1})).unwrap(); + let first = app.session.active().unwrap().doc.artboards[0].rect.center(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = status_texts(&mut app, &ctx, vec![]); + let field = texts.iter().find(|(s, _)| s == "2 Back").map(|(_, r)| *r).expect("the field shows artboard 2's number and name"); + assert!(!texts.iter().any(|(s, _)| s == "Front"), "the list is closed: {texts:?}"); + let texts = status_click(&mut app, &ctx, field.center()); + let at = |s: &str| texts.iter().find(|(t, _)| t == s).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {s:?} in {texts:?}")); + let rows: Vec = [("1", "Front"), ("2", "Back"), ("3", "Artboard 3")] + .into_iter() + .map(|(number, name)| { + let (n, r) = (at(number), at(name)); + assert!(r.y_range().contains(n.center().y) && n.right() < r.left(), "{number} before {name} on one row: {texts:?}"); + r + }) + .collect(); + assert!(rows.windows(2).all(|w| w[0].bottom() <= w[1].top()), "the rows in the artboards' order: {rows:?}"); + let texts = status_click(&mut app, &ctx, at("Front").center()); + assert_eq!((app.view().unwrap().artboard, app.view().unwrap().center), (0, first), "Front, fitted in the window"); + assert_eq!(app.session.tool_options()["active"], json!(0)); + assert!(texts.iter().any(|(s, _)| s == "1 Front"), "{texts:?}"); + assert!(!texts.iter().any(|(s, _)| s == "Back"), "the list closed: {texts:?}"); +} + +/// With 1,000 artboards the list lays out at most 12 rows. It opens on the active +/// artboard's row, a swipe scrolls it to the last artboard, and choosing that row goes there. +#[test] +fn the_artboard_list_lays_out_only_the_rows_near_the_view() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100, "artboards": 1000})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); + app.run("view.goToArtboard", json!({"index": 699})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let rows = |texts: &[(String, egui::Rect)]| texts.iter().filter(|(s, _)| s.starts_with("Artboard ")).cloned().collect::>(); + let texts = status_texts(&mut app, &ctx, vec![]); + let field = texts.iter().find(|(s, _)| s == "700 Artboard 700").map(|(_, r)| *r).expect("the field"); + let shown = rows(&status_click(&mut app, &ctx, field.center())); + assert!((1..=12).contains(&shown.len()), "{} rows laid out", shown.len()); + let active = shown.iter().find(|(s, _)| s == "Artboard 700").map(|(_, r)| *r).expect("the active artboard's row in view"); + let swipe = |phase, dy| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + phase, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + status_texts( + &mut app, + &ctx, + vec![egui::Event::PointerMoved(active.center()), swipe(egui::TouchPhase::Start, 0.0), swipe(egui::TouchPhase::Move, -100_000.0)], + ); + let shown = rows(&status_texts(&mut app, &ctx, vec![swipe(egui::TouchPhase::End, 0.0)])); + assert!((1..=12).contains(&shown.len()), "{} rows laid out", shown.len()); + let last = shown.iter().find(|(s, _)| s == "Artboard 1000").map(|(_, r)| *r).expect("the last row after the swipe"); + status_click(&mut app, &ctx, last.center()); + assert_eq!(app.view().unwrap().artboard, 999); +} + +fn artboard_navigation_app(tool: &str) -> (crate::VectorcraftApp, vectorcraft_doc::NodeId) { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); + app.select_tool(tool); + crate::i18n::set_current(crate::i18n::Lang::EN); + (app, vectorcraft_doc::NodeId(id)) +} + +/// Click the actual status-bar Next control, without sending a canvas pointer event. +fn artboard_navigation_next(app: &mut crate::VectorcraftApp, ctx: &egui::Context) { + let buttons = navigator(app, ctx, vec![]); + assert_eq!(buttons.len(), 4); + let at = buttons[2].center(); + assert!(!app.canvas_rect.unwrap().contains(at)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + navigator(app, ctx, vec![event]); + } +} + +fn artboard_navigation_properties_frame( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + events: Vec, +) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { + egui::Panel::right("navigation-properties").exact_size(300.0).show(ui, |ui| crate::panels::properties::show(app, ui)); + }); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), + _ => None, + }) + .collect() +} + +/// None clicks Properties' Delete Artboard; Some routes the real key through shortcuts. +fn artboard_navigation_delete(key: Option) { + for multiple in [false, true] { + let (mut app, id) = artboard_navigation_app("artboard"); + let selected = if multiple { json!([0, 2]) } else { json!([0]) }; + app.session.set_tool_option("selected", &selected); + assert_eq!(app.view().unwrap().artboard, 0); + assert_eq!(app.session.tool_options()["active"], json!(0)); + assert_eq!(app.session.tool_options()["selected"], selected); + app.session.active_mut().unwrap().mark_saved(); + let st = app.session.active().unwrap(); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let undo = st.history.undo.len(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + + artboard_navigation_next(&mut app, &ctx); + + assert_eq!(app.view().unwrap().artboard, 1, "Next shows board 2"); + assert_eq!(app.session.tool_options()["active"], json!(1), "Next must change the destructive target before deletion"); + assert_eq!(app.session.tool_options()["selected"], json!([1]), "navigation replaces the old artboard multiselection"); + assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc, before, "navigation only changes view and tool state"); + assert_eq!(st.selection, selection, "ordinary artwork stays selected"); + assert_eq!(st.history.undo.len(), undo); + assert!(st.history.redo.is_empty()); + assert!(!st.is_dirty()); + + if let Some(key) = key { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![ev], ..Default::default() }, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + super::status_bar(&mut app, ui); + }); + out.textures_delta.clear(); + } else { + artboard_navigation_properties_frame(&mut app, &ctx, vec![]); + let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name), "Properties shows the navigated board"); + let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, rect)| rect.center()).expect("Properties Delete button"); + assert!(!app.canvas_rect.unwrap().contains(at)); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); + } + } + + let st = app.session.active().unwrap(); + assert_eq!( + st.doc.artboards, + vec![before.artboards[0].clone(), before.artboards[2].clone()], + "first and third original board records survive" + ); + assert_eq!(st.doc.node(id), before.node(id), "selected artwork survives unchanged"); + assert_eq!(st.selection, selection); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); + assert!(st.history.redo.is_empty()); + assert!(st.is_dirty()); + let deleted = st.doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc, before, "one Undo restores every board and the artwork"); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); + assert!(!st.is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, deleted); + } +} + +#[test] +fn artboard_navigation_next_then_delete_uses_shown_board() { + artboard_navigation_delete(Some(egui::Key::Delete)); +} + +#[test] +fn artboard_navigation_next_then_backspace_uses_shown_board() { + artboard_navigation_delete(Some(egui::Key::Backspace)); +} + +#[test] +fn artboard_navigation_next_then_properties_delete_uses_shown_board() { + artboard_navigation_delete(None); +} + +/// Navigating artboards preserves other tools, their selections and undo history. +#[test] +fn artboard_navigation_preserves_other_tools_selection_and_history() { + for tool in ["selection", "rectangle"] { + let (mut app, _) = artboard_navigation_app(tool); + app.run("object.move", json!({"dx": 7, "dy": 9})).unwrap(); + let redo = app.session.active().unwrap().doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + app.session.active_mut().unwrap().mark_saved(); + let st = app.session.active().unwrap(); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let history = (st.history.undo.len(), st.history.redo.len()); + assert_eq!(history.1, 1); + let options = app.session.tool_options(); + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + artboard_navigation_next(&mut app, &ctx); + + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center()); + assert_eq!(app.session.tool_id(), tool); + assert_eq!(app.session.tool_options(), options); + let st = app.session.active().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), history); + assert!(!st.is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, redo, "navigation preserves the original Redo branch"); + } +} + +#[test] +fn the_tab_title_names_the_opacity_mask_while_it_is_edited() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false, false); + assert!(title(&s).ends_with("(RGB/Preview)"), "{}", title(&s)); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + assert!(title(&s).ends_with("(/Opacity Mask)"), "{}", title(&s)); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + assert!(title(&s).ends_with("(RGB/Preview)")); +} + +/// One headless frame of the Control bar; returns the isolation button's rectangle, if it shows. +fn isolation_button(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::control_bar(app, ui)); + out.textures_delta.clear(); + ctx.viewport(|vp| vp.prev_pass.widgets.get(egui::Id::unique(super::ISOLATION_BUTTON)).map(|w| w.rect)) +} + +/// Entering isolation mode leaves the canvas where it was: the breadcrumb bar is drawn over the +/// canvas and takes no layout height from it (#3126). +#[test] +fn the_isolation_bar_does_not_take_layout_height_from_the_canvas() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); + let top = |app: &mut crate::VectorcraftApp| { + let mut below = 0.0; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + super::doc_tabs(app, ui); + below = ui.available_rect_before_wrap().top(); + let area = ui.available_rect_before_wrap(); + super::isolation_bar(app, ui, area); + assert_eq!(ui.available_rect_before_wrap().top(), below, "the bar takes no layout space"); + }); + out.textures_delta.clear(); + below + }; + let before = top(&mut app); + app.run("object.isolate", json!({"id": id})).unwrap(); + assert!(app.session.active().unwrap().isolation.is_some()); + assert_eq!(top(&mut app), before, "isolation mode leaves the canvas where it was"); +} + +/// The Control bar's Isolate Selected Object isolates a path inside a group that Direct +/// Selection selected, and Exit Isolation Mode leaves with the path selected. Type, symbol +/// instances, blends, two objects and an envelope's contents get no isolate button, and pattern +/// editing gets no exit button. +#[test] +fn the_control_bar_isolates_the_selected_object_and_exits_isolation_mode() { + use vectorcraft_doc::NodeId; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let rect = |app: &mut crate::VectorcraftApp, x: f64| { + app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 50.0)); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.select_tool("directSelection"); + app.run("select.anchors", json!({"id": a, "anchors": [[0, 0]]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let click = |app: &mut crate::VectorcraftApp| { + let r = isolation_button(app, &ctx, vec![]).expect("the bar shows the isolation button"); + let press = |pressed| egui::Event::PointerButton { + pos: r.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + for e in [egui::Event::PointerMoved(r.center()), press(true), press(false)] { + isolation_button(app, &ctx, vec![e]); + } + }; + click(&mut app); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a)), "Isolate Selected Object isolates the path"); + click(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![NodeId(a)]), "Exit Isolation Mode selects the path"); + app.select_tool("selection"); + // Type, a symbol instance, a blend and two objects. + let text = app.run("text.create", json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + let c = rect(&mut app, 100.0); + app.run("select.set", json!({"ids": [c]})).unwrap(); + let symbol = app.run("symbol.new", json!({"name": "Box"})).unwrap()["id"].as_u64().unwrap(); + let (d, e) = (rect(&mut app, 150.0), rect(&mut app, 250.0)); + let blend = app.run("object.blend.make", json!({"ids": [d, e], "steps": 1})).unwrap()["id"].as_u64().unwrap(); + for ids in [json!([text]), json!([symbol]), json!([blend]), json!([text, symbol])] { + app.run("select.set", json!({ "ids": ids })).unwrap(); + assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "{ids}"); + } + // An envelope's contents, while Edit Contents is on. + let g = rect(&mut app, 350.0); + app.run("select.set", json!({"ids": [g]})).unwrap(); + app.run("object.envelope.makeWithWarp", json!({})).unwrap(); + app.run("object.envelope.editContents", json!({"editing": true})).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects, vec![NodeId(g)]); + assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "the envelope's controls show instead"); + let f = rect(&mut app, 300.0); + app.run("select.set", json!({"ids": [f]})).unwrap(); + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + assert!(app.session.active().unwrap().isolation.is_some()); + assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "the isolation bar ends pattern editing"); +} + +/// The Zoom tool's hint follows Performance › Animated Zoom (#394): a drag zooms as it goes, +/// or (off) zooms into the area dragged across. +#[test] +fn the_zoom_hint_follows_animated_zoom() { + fn texts(s: &egui::Shape, out: &mut String) { + match s { + egui::Shape::Text(t) => out.push_str(t.galley.text()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let hint = |app: &mut crate::VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::hint_bar(app, ui)); + out.textures_delta.clear(); + let mut shown = String::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + shown + }; + let on = hint(&mut app); + assert!(on.contains(" right or left to zoom in or out") && on.contains("Hold"), "{on}"); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + let off = hint(&mut app); + assert!(off.contains(" to zoom into an area") && !off.contains("Hold"), "{off}"); +} + +fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.custom_titlebar = custom_titlebar; + // The preference that makes the shell launch without its own title bar. + app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap(); + app +} + +#[test] +fn without_the_preference_the_mark_stays() { + // The default (e.g. the web build): no custom bar, no preference. + let app = crate::VectorcraftApp::new(Session::new(), Default::default()); + assert!(!super::system_title_bar(&app)); +} + +#[test] +fn window_title_with_no_document_is_the_app_name() { + assert_eq!(super::window_title(&title_test_app(true)), "VectorCraft"); +} + +#[test] +fn window_title_follows_the_active_file() { + let mut app = title_test_app(true); + app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap(); + assert_eq!(super::window_title(&app), "foo.svg \u{2014} VectorCraft"); + // Unsaved changes gain a `*` prefix, like the `*` in the document tabs. + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert_eq!(super::window_title(&app), "*foo.svg \u{2014} VectorCraft"); +} + +#[test] +fn system_mode_hides_the_mark() { + for custom in [true, false] { + let mut app = title_test_app(custom); + app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(&mut app, ui)); + let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count(); + out.textures_delta.clear(); + let marks: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx) => Some(m.calc_bounds()), + _ => None, + }) + .collect(); + let system = super::system_title_bar(&app); + assert_eq!(system, !custom && !cfg!(target_os = "macos")); + assert_eq!(marks.is_empty(), system, "mark in system mode (custom={custom})"); + assert_eq!(uploads, if system { 0 } else { 1 }, "mark texture in system mode (custom={custom})"); + } +} + +#[test] +fn sync_window_title_sends_only_on_change() { + let mut app = title_test_app(true); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + super::sync_window_title(&mut app, &ctx); + assert_eq!(app.last_window_title, "VectorCraft"); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(); + assert!(cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(t) if t == "VectorCraft")), "{cmds:?}"); + // Same document state again: the cached title stops a repeat command. + super::sync_window_title(&mut app, &ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(); + assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(_))), "repeat Title: {cmds:?}"); + app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap(); + super::sync_window_title(&mut app, &ctx); + assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft"); +} +/// Draw actual tabs, including the same per-frame view synchronization as the app. +fn artboard_document_tabs_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { + app.sync_views(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::doc_tabs(app, ui)); + out.textures_delta.clear(); + let mut titles: Vec<_> = out + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(t) if t.galley.text().contains(" @ ") => Some(t.visual_bounding_rect()), + _ => None, + }) + .collect(); + titles.sort_by(|a, b| a.left().total_cmp(&b.left())); + titles +} + +fn artboard_document_click_tab(app: &mut crate::VectorcraftApp, ctx: &egui::Context, index: usize) { + let titles = artboard_document_tabs_frame(app, ctx, vec![]); + assert_eq!(titles.len(), 2, "both real document tabs must be visible"); + let at = titles[index].center(); + assert!(!app.canvas_rect.unwrap().contains(at)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + artboard_document_tabs_frame(app, ctx, vec![event]); + } + assert_eq!(app.session.active_index(), Some(index), "the actual tab click must activate its document"); +} + +fn artboard_document_app() -> (crate::VectorcraftApp, egui::Context) { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + app.run("artboard.setProps", json!({"index": 0, "name": "Keep"})).unwrap(); + app.run("artboard.setProps", json!({"index": 1, "name": "Shown"})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("symbol.new", json!({"name": "Tab symbol"})).unwrap(); + app.run("shape.rectangle", json!({"x": 230, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("guide.add", json!({"artboard": 0, "pos": 20})).unwrap(); + app.run("guide.add", json!({"artboard": 1, "pos": 250, "vertical": true})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + // Neither tab nor Properties clicks enter the canvas; a canvas move could hide the bug. + app.canvas_rect = Some(egui::Rect::from_min_max(egui::pos2(0.0, 40.0), egui::pos2(800.0, 260.0))); + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + artboard_document_click_tab(&mut app, &ctx, 0); + (app, ctx) +} + +#[test] +fn artboard_document_tab_roundtrip_properties_delete_uses_shown_board() { + let (mut app, ctx) = artboard_document_app(); + app.select_tool("artboard"); + app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); + app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); + artboard_navigation_next(&mut app, &ctx); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(app.session.tool_options()["selected"], json!([1]), "start with the tool and navigator aligned"); + app.session.active_mut().unwrap().mark_saved(); + let before = app.session.active().unwrap().doc.clone(); + let selection = app.session.active().unwrap().selection.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + let other = app.session.documents()[1].doc.clone(); + let other_history = (app.session.documents()[1].history.undo.len(), app.session.documents()[1].history.redo.len()); + + // No direct set_active, tool reselection, or selected-option repair after this round trip. + artboard_document_click_tab(&mut app, &ctx, 1); + artboard_document_click_tab(&mut app, &ctx, 0); + assert_eq!(app.view().unwrap().artboard, 1, "the document keeps its displayed artboard"); + assert_eq!(app.session.active().unwrap().doc, before, "switching tabs is not a document edit"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + assert!(!app.session.active().unwrap().is_dirty()); + let selected_before_delete = app.session.tool_options()["selected"].clone(); + let active_before_delete = app.session.tool_options()["active"].clone(); + assert_eq!(app.session.tool_options()["moveArt"], json!(false)); + assert_eq!(app.session.tool_options()["scaleArt"], json!(true)); + + artboard_navigation_properties_frame(&mut app, &ctx, vec![]); + let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(label, _)| label == "Shown"), "Properties must display the second board before deletion"); + let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, r)| r.center()).expect("actual Properties Delete button"); + assert!(!app.canvas_rect.unwrap().contains(at)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let commands = app.run_count; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); + } + let st = app.session.active().unwrap(); + assert_eq!( + st.doc.artboards.iter().map(|a| a.name.as_str()).collect::>(), + vec!["Keep"], + "Properties Delete after a document tab round trip must remove the shown board" + ); + assert_eq!(selected_before_delete, json!([1])); + assert_eq!(active_before_delete, json!(1)); + assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "the original first board survives"); + assert_eq!(st.doc.guides, vec![before.guides[0].clone()], "only the deleted board's guide is removed"); + assert_eq!(st.doc.layers, before.layers, "artwork and symbol instances stay unchanged"); + assert_eq!(st.doc.symbols, before.symbols, "symbol definitions stay unchanged"); + assert_eq!(st.selection, selection); + assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual Properties control"); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); + assert!(st.history.redo.is_empty()); + assert!(st.is_dirty()); + let deleted = st.doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before, "one Undo restores the correct board, guides and art"); + assert!(!app.session.active().unwrap().is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, deleted); + assert_eq!(app.session.documents()[1].doc, other); + assert_eq!((app.session.documents()[1].history.undo.len(), app.session.documents()[1].history.redo.len()), other_history); +} + +#[test] +fn artboard_document_tab_same_document_preserves_multiselection_and_options() { + let (mut app, ctx) = artboard_document_app(); + app.select_tool("artboard"); + artboard_navigation_next(&mut app, &ctx); + app.run("tool.setOption", json!({"key": "selected", "value": [1, 0]})).unwrap(); + app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); + app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); + let options = app.session.tool_options(); + let before = app.session.active().unwrap().doc.clone(); + let history = (app.session.active().unwrap().history.undo.len(), app.session.active().unwrap().history.redo.len()); + artboard_document_click_tab(&mut app, &ctx, 0); + for _ in 0..3 { + artboard_document_tabs_frame(&mut app, &ctx, vec![]); + } + assert_eq!(app.session.tool_options(), options, "an already-active tab must not collapse valid multiselection or options"); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!((app.session.active().unwrap().history.undo.len(), app.session.active().unwrap().history.redo.len()), history); +} + +#[test] +fn artboard_document_tab_roundtrip_preserves_other_tools() { + for tool in ["selection", "rectangle"] { + let (mut app, ctx) = artboard_document_app(); + app.select_tool(tool); + artboard_navigation_next(&mut app, &ctx); + let before = app.session.active().unwrap().doc.clone(); + let selection = app.session.active().unwrap().selection.clone(); + let options = app.session.tool_options(); + let history = (app.session.active().unwrap().history.undo.len(), app.session.active().unwrap().history.redo.len()); + artboard_document_click_tab(&mut app, &ctx, 1); + artboard_document_click_tab(&mut app, &ctx, 0); + assert_eq!(app.session.tool_id(), tool); + assert_eq!(app.session.tool_options(), options); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(app.session.active().unwrap().selection, selection); + assert_eq!((app.session.active().unwrap().history.undo.len(), app.session.active().unwrap().history.redo.len()), history); + } +} + +#[test] +fn artboard_close_active_tab_properties_delete_uses_shown_board() { + let (mut app, ctx) = artboard_document_app(); + let history = |st: &vectorcraft_engine::DocState| { + let entries = |items: &[vectorcraft_engine::HistoryEntry]| { + items.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone())).collect::>() + }; + (entries(&st.history.undo), entries(&st.history.redo)) + }; + let outside_canvas = |app: &crate::VectorcraftApp| { + let p = ctx.input(|i| i.pointer.hover_pos()).expect("a real chrome or panel pointer position"); + assert!(!app.canvas_rect.unwrap().contains(p), "the pointer must stay outside the canvas: {p:?}"); + }; + app.select_tool("artboard"); + app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); + app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); + artboard_navigation_next(&mut app, &ctx); + outside_canvas(&app); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(app.session.tool_options()["selected"], json!([1]), "the initial navigator action targets Shown"); + app.session.active_mut().unwrap().mark_saved(); + let st = app.session.active().unwrap(); + let (uid, revision) = (st.uid, st.revision); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let history_before = history(st); + let history_limits = (st.history.limit, st.history.byte_budget); + assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); + assert_eq!(before.guides.len(), 2); + assert!(!selection.objects.is_empty()); + assert!(history_before.1.is_empty()); + assert!(!st.is_dirty()); + let mut expected = before.as_ref().clone(); + expected.artboards = vec![before.artboards[0].clone()]; + expected.guides = vec![before.guides[0].clone()]; + + // Route from the status bar along the panel edge to the tab strip, never through canvas. + for at in [egui::pos2(1000.0, 280.0), egui::pos2(1000.0, 20.0)] { + artboard_document_tabs_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + outside_canvas(&app); + } + artboard_document_click_tab(&mut app, &ctx, 1); + outside_canvas(&app); + app.session.active_mut().unwrap().mark_saved(); + let other_uid = app.session.active().unwrap().uid; + assert_ne!(other_uid, uid); + assert!(app.session.documents().iter().all(|d| !d.is_dirty()), "both ordinary documents are in saved state"); + assert!(app.ui.dialog.is_none()); + eprintln!("ARTBOARD_CLOSE_TARGET_SETUP_READY"); + + // The actual B tab x, using the existing close-box test's title-relative hit position. + let titles = artboard_document_tabs_frame(&mut app, &ctx, vec![]); + outside_canvas(&app); + assert_eq!(titles.len(), 2); + let title = titles[1]; + let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 }; + let at = egui::pos2(x, title.center().y); + assert!(!app.canvas_rect.unwrap().contains(at)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + artboard_document_tabs_frame(&mut app, &ctx, vec![event]); + outside_canvas(&app); + } + assert_eq!(app.session.documents().len(), 1, "B tab x must really close B"); + assert_eq!(app.session.active_index(), Some(0)); + assert_eq!(app.session.active().unwrap().uid, uid, "the survivor is the original A, not a replacement document"); + assert!(app.session.documents().iter().all(|d| d.uid != other_uid)); + assert!(app.ui.dialog.is_none(), "a clean close must not leave a save/discard modal"); + for _ in 0..2 { + artboard_document_tabs_frame(&mut app, &ctx, vec![]); + outside_canvas(&app); + } + let st = app.session.active().unwrap(); + assert_eq!(st.doc, before, "closing B must not edit A"); + assert_eq!(st.selection, selection); + assert_eq!(history(st), history_before, "closing B preserves all A Undo/Redo entries"); + assert_eq!(st.revision, revision); + assert_eq!((st.history.limit, st.history.byte_budget), history_limits); + assert!(!st.is_dirty()); + assert_eq!(app.view().unwrap().artboard, 1); + eprintln!("ARTBOARD_CLOSE_TARGET_CLOSED_B"); + + // Cross the tab strip to the Properties panel before moving down to its Delete control. + app.sync_views(); + artboard_navigation_properties_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(1000.0, 20.0))]); + outside_canvas(&app); + app.sync_views(); + let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); + outside_canvas(&app); + assert!(texts.iter().any(|(label, _)| label == "Shown"), "actual Properties must display Shown before deletion"); + assert_eq!(app.view().unwrap().artboard, 1); + let tool_before_delete = app.session.tool_options(); + eprintln!("ARTBOARD_CLOSE_TARGET_SHOWN_DISPLAYED tool={tool_before_delete}"); + let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, r)| r.center()).expect("actual Properties Delete button"); + assert!(at.x > app.canvas_rect.unwrap().right(), "Delete is in the right panel"); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let commands = app.run_count; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + app.sync_views(); + artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); + outside_canvas(&app); + } + let st = app.session.active().unwrap(); + assert_eq!(app.run_count, commands.wrapping_add(1), "the actual Properties click executes one command"); + assert_eq!(st.doc.artboards.len(), 1, "the real Delete action removed one board"); + assert_eq!(st.history.undo.len(), history_before.0.len() + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); + eprintln!("ARTBOARD_CLOSE_TARGET_DELETE_CLICKED tool_before={tool_before_delete}"); + // Do not assert tool alignment before this destructive-action oracle: it must expose the wrong deletion. + assert_eq!( + st.doc.artboards.iter().map(|a| a.name.as_str()).collect::>(), + vec!["Keep"], + "ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B" + ); + assert_eq!(st.doc.as_ref(), &expected, "only Shown and its attached guide may be removed from the full document"); + assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged"); + assert_eq!(st.doc.symbols, before.symbols); + assert_eq!(st.doc.guides, vec![before.guides[0].clone()]); + assert_eq!(st.selection, selection); + assert_eq!(st.uid, uid); + assert_eq!(st.revision, revision + 1); + let history_after = history(st); + assert_eq!(&history_after.0[..history_before.0.len()], history_before.0.as_slice()); + let entry = history_after.0.last().unwrap(); + assert_eq!(entry.1, before); + assert_eq!(entry.2.as_ref(), &selection); + assert!(history_after.1.is_empty()); + assert!(st.is_dirty()); + let deleted = st.doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.uid, uid); + assert_eq!(st.doc, before, "one Undo restores the full saved A including both boards, guides and artwork"); + assert_eq!(st.selection, selection); + assert_eq!(history(st).0, history_before.0); + assert_eq!(st.history.redo.len(), 1); + assert_eq!(st.history.redo[0].label, "Delete Artboard"); + assert_eq!(st.history.redo[0].doc, deleted); + assert_eq!(st.history.redo[0].selection.as_ref(), &selection); + assert!(!st.is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.uid, uid); + assert_eq!(st.doc.as_ref(), &expected, "one Redo repeats only the correct deletion"); + assert_eq!(st.selection, selection); + assert_eq!(history(st), history_after); + assert_eq!((st.history.limit, st.history.byte_budget), history_limits); + assert!(st.is_dirty()); + assert_eq!(app.session.documents().len(), 1, "Undo/Redo do not reopen B"); + eprintln!("ARTBOARD_CLOSE_TARGET_MODEL_AND_HISTORY_PRESERVED"); +} From 06bb496447b0430d43b7d7202c644cfcce33498a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:27 +0000 Subject: [PATCH 020/199] refactor(tools): move direct.rs tests to direct/tests.rs --- crates/tools/src/direct.rs | 1270 +----------------------------- crates/tools/src/direct/tests.rs | 1267 +++++++++++++++++++++++++++++ 2 files changed, 1268 insertions(+), 1269 deletions(-) create mode 100644 crates/tools/src/direct/tests.rs diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 737f19983..77a96e8b2 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1076,1272 +1076,4 @@ impl DirectSelectionTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - /// #414: a ruler guide is picked over the art, but an anchor on it is picked first. - #[test] - fn direct_and_group_selection_pick_ruler_guides() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 100.0)); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y)); - let pick = vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]; - for group in [false, true] { - let mut t = DirectSelectionTool::new(group); - assert_eq!(down(&mut t, 150.0), pick, "on the rect's edge (group: {group})"); - assert!(t.busy()); - t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 150.0)); - assert_eq!(t.cursor(&c, Point::new(101.0, 300.0), Mods::default()), Cursor::ResizeH); - } - let mut t = DirectSelectionTool::new(false); - assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); - } - - /// A Cmd/Ctrl+Shift double-click with Direct Selection or Group Selection releases the ruler - /// guide or the guide path under the pointer; a plain double-click on a ruler guide, or one while - /// guides are hidden or locked, releases nothing. - #[test] - fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - let Some(NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; - *guide = true; - let (s, p) = (Selection::default(), paint()); - let both = Mods { cmd: true, shift: true, ..Mods::default() }; - let index = vec![Action::Exec("view.guides.release".into(), json!({"index": 0}))]; - let ids = vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]; - let locked = ToolContext { guides: false, ..cx(&d, &s, &p) }; - for group in [false, true] { - let dbl = |c: &ToolContext, x: f64, y: f64, m: Mods| { - DirectSelectionTool::new(group).pointer(c, &PointerEvent::new(PointerKind::DoubleClick, x, y).with_mods(m)) - }; - let c = cx(&d, &s, &p); - assert_eq!(dbl(&c, 350.0, 301.0, both), index, "on the ruler guide (group: {group})"); - assert_eq!(dbl(&c, 200.0, 150.0, both), ids, "on the guide path (group: {group})"); - assert!(dbl(&c, 350.0, 301.0, Mods::default()).is_empty(), "a plain double-click (group: {group})"); - assert!(dbl(&c, 350.0, 301.0, Mods { cmd: true, ..Mods::default() }).is_empty(), "Cmd/Ctrl alone (group: {group})"); - assert!(dbl(&locked, 350.0, 301.0, both).is_empty(), "guides locked (group: {group})"); - assert!(dbl(&locked, 200.0, 150.0, both).is_empty(), "guides locked (group: {group})"); - } - } - - /// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and - /// further with a larger anchor Size or Tolerance (#593); away from it the segment is. - #[test] - fn anchors_are_picked_before_the_segments_through_them() { - let (d, id) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; - let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y)); - let c = cx(&d, &s, &p); - // 4.3 px from the corner, 0.5 px from the top edge. - assert_eq!(press(&c, 104.2, 100.5), corner); - assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor"); - assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)"); - let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) }; - assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide"); - let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) }; - assert_eq!(press(&loose, 107.5, 100.5), corner); - // On screen: at 200% the reach is half as far in the document. - let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) }; - assert_eq!(press(&zoomed, 102.1, 100.2), corner); - assert_ne!(press(&zoomed, 102.6, 100.2), corner); - } - - /// A press on a clip group's clipping path, where the art it clips lies over its edge, takes - /// the clipping path's segment, so the mask can be reshaped on the canvas; inside the mask the - /// art is taken, as before. - #[test] - fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() { - let (d, _, clip, art) = doc_with_masked_square(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let press = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); - let names = |actions: &[Action], id: NodeId| { - actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(id.0) || v["ids"] == json!([id.0]))) - }; - // The middle of the right edge, on it and just inside it. - for x in [500.0, 498.5] { - let a = press(x, 450.0); - assert!(names(&a, clip), "at {x}: {a:?}"); - } - let a = press(450.0, 450.0); - assert!(names(&a, art) && !names(&a, clip), "{a:?}"); - } - - /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. - fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let key = |id: NodeId, x: f64| { - let r = vectorcraft_geom::shapes::rectangle(Rect::new(x, 300.0, x + 20.0, 320.0)); - std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) - }; - let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let b = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }); - d.insert(Some(l), 1, b).unwrap(); - (d, g, k1) - } - - /// Highlight anchors on mouse over (#394): the anchor under the pointer, within the selection - /// tolerance, shows enlarged; none with the preference off or away from anchors. - #[test] - fn the_anchor_under_the_pointer_is_highlighted() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let big = |t: &DirectSelectionTool, cx: &ToolContext| { - t.overlays(cx).into_iter().find_map(|o| match o { - Overlay::Anchor { p, size, .. } if size > 5.0 => Some(p), - _ => None, - }) - }; - let mut t = DirectSelectionTool::new(false); - let on = cx(&d, &s, &p); - t.pointer(&on, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); - assert_eq!(big(&t, &on), Some(Point::new(100.0, 100.0)), "the corner of {id:?}"); - t.pointer(&on, &PointerEvent::new(PointerKind::Move, 150.0, 100.0)); - assert_eq!(big(&t, &on), None, "mid-segment: no anchor"); - let off = ToolContext { highlight_anchors: false, ..cx(&d, &s, &p) }; - t.pointer(&off, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); - assert_eq!(big(&t, &off), None, "off"); - } - - #[test] - fn dragging_a_spine_point_moves_it() { - let (d, g, _) = blend_doc(); - let mut s = Selection::default(); - s.set([g]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 211.0, 310.0)), vec![]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 221.0, 350.0)); - assert_eq!(a[0], Action::Begin("Reshape Spine".into())); - assert_eq!(a[1], Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 1, "x": 220.0, "y": 350.0}))); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 221.0, 350.0)), vec![Action::Commit]); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows"); - } - - /// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection - /// reshapes a curved spine segment and leaves a straight one alone, as it does a path's. - #[test] - fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() { - let (mut d, g, _) = blend_doc(); - let mut s = Selection::default(); - s.set([g]); - let p = paint(); - let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| { - t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); - let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0)); - t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0)); - a - }; - let straight = cx(&d, &s, &p); - let a = drag(crate::create("anchorPoint").as_mut(), &straight); - assert!( - matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0), - "{a:?}" - ); - let a = drag(&mut DirectSelectionTool::new(false), &straight); - assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}"); - // The spine bent upward: Direct Selection takes it from where it passes. - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false); - sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0)); - let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") }; - (spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]); - let mut t = DirectSelectionTool::new(false); - let curved = cx(&d, &s, &p); - t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0)); - let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0)); - assert_eq!(a[0], Action::Begin("Reshape Spine".into())); - assert!( - matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0), - "{a:?}" - ); - assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]); - } - - /// Shift held while a curved segment is dragged sends `perpendicular`. The test drags a path's - /// segment with Direct Selection, and a blend spine's segment with Direct Selection and with the - /// Anchor Point tool. - #[test] - fn shift_reshapes_a_segment_perpendicular() { - let shift = Mods { shift: true, ..Mods::default() }; - let p = paint(); - let drag = |t: &mut dyn crate::Tool, cx: &ToolContext, from: (f64, f64), mods: Mods| { - t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1)); - let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, from.0 + 10.0, from.1 - 20.0).with_mods(mods)); - t.pointer(cx, &PointerEvent::new(PointerKind::Up, from.0 + 10.0, from.1 - 20.0).with_mods(mods)); - a - }; - let sent = |a: &[Action], cmd: &str| match a.last() { - Some(Action::Preview(c, v)) if c == cmd => v["perpendicular"].as_bool(), - _ => None, - }; - let (d, _) = arch_doc(); - let s = Selection::default(); - let arch = cx(&d, &s, &p); - let (mut b, g, _) = blend_doc(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false); - sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0)); - let Some(NodeKind::Blend { spec, .. }) = b.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") }; - (spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]); - let mut bs = Selection::default(); - bs.set([g]); - let spine = cx(&b, &bs, &p); - for (mods, perpendicular) in [(Mods::default(), false), (shift, true)] { - let a = drag(&mut DirectSelectionTool::new(false), &arch, (150.0, 262.5), mods); - assert_eq!(sent(&a, "path.reshapeSegment"), Some(perpendicular), "{a:?}"); - let a = drag(&mut DirectSelectionTool::new(false), &spine, (160.0, 330.0), mods); - assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}"); - let a = drag(crate::create("anchorPoint").as_mut(), &spine, (160.0, 330.0), mods); - assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}"); - } - } - - #[test] - fn direct_and_group_selection_pick_blend_keys() { - let (d, g, k1) = blend_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let a = DirectSelectionTool::new(false).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); - let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); - // A click on a step (between the keys) still selects the blend. - let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 304.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); - } - - #[test] - fn dragging_a_frame_corner_or_edge_reshapes_area_type() { - let (d, text) = doc_with_area_type(); - let p = paint(); - let s = Selection::default(); - let cx1 = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - // The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text... - assert_eq!( - t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)), - vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] - ); - // ...and drags that corner. - let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0)); - assert_eq!(a[0], Action::Begin("Reshape Type Area".into())); - assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0}))); - assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]); - // The top edge drags both its ends; a click alone changes nothing. - let mut s = Selection::default(); - s.add(text); - let cx2 = cx(&d, &s, &p); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 350.0, 301.0)).is_empty()); - let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0)); - assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0}))); - t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0)); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty()); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty()); - // The selected frame's corners show. - assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4); - } - - /// #3980: type on a path shows its path's anchors and a drag of one reshapes the path, the last - /// one too although the end bracket stands on it; the bracket's stem above it drags the bracket. - #[test] - fn dragging_an_anchor_of_type_on_a_path_reshapes_its_path() { - let (d, text) = doc_with_path_type(); - let p = paint(); - let s = Selection::default(); - let cx1 = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert_eq!( - t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 101.0, 301.0)), - vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] - ); - let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 121.0, 261.0)); - assert_eq!(a[0], Action::Begin("Reshape Type Path".into())); - assert_eq!(a[1], Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 0]], "dx": 20.0, "dy": -40.0}))); - assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 121.0, 261.0)), vec![Action::Commit]); - let mut s = Selection::default(); - s.add(text); - let cx2 = cx(&d, &s, &p); - let anchors: Vec = - t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Anchor { p, filled: false, .. } = o { Some(*p) } else { None }).collect(); - assert_eq!(anchors, vec![Point::new(100.0, 300.0), Point::new(400.0, 300.0)]); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 300.0)).is_empty()); - let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 400.0, 260.0)); - assert_eq!(a.last(), Some(&Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 1]], "dx": 0.0, "dy": -40.0})))); - t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 400.0, 260.0)); - t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 290.0)); - let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 340.0, 290.0)); - assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "type.pathOptions"), "{a:?}"); - } - - #[test] - fn click_anchor_selects_it() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); - } - - /// Pressing an anchor of a path whose anchors are all selected (stored as the whole path) - /// keeps them all selected, so the drag moves them all; Shift still deselects that anchor. - #[test] - fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); - assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)); - let shift = Mods { shift: true, ..Default::default() }; - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); - } - - /// A click (no drag) on an anchor that is selected with others, whether the whole path is or - /// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the - /// selection so a drag moves it all, the release without a drag narrows it. Shift still - /// toggles, and a click on a path's only selected anchor changes nothing visible. - #[test] - fn a_click_on_a_selected_anchor_selects_it_alone() { - let (d, id) = doc_with_rect(); - let p = paint(); - let alone = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; - let mut whole = Selection::default(); - whole.set([id]); - for s in [whole, anchors_of(id, &[(0, 0), (0, 1)])] { - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty(), "{s:?}"); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)), alone, "{s:?}"); - // Dragged instead: moved, selection kept. - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)), vec![Action::Commit], "{s:?}"); - } - // An anchor not yet selected is selected on the press, and the release adds nothing. - let none = Selection::default(); - let cx = cx(&d, &none, &p); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), alone); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)).is_empty()); - } - - /// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies - /// it as the Selection tool's Alt-drag does. - #[test] - fn alt_dragging_an_anchor_copies_the_whole_path() { - let (d, id) = doc_with_rect(); - let alt = Mods { alt: true, ..Default::default() }; - let mut whole = Selection::default(); - whole.set([id]); - let p = paint(); - for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] { - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt)); - let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; - assert_eq!(a, if pick { set } else { vec![] }, "{s:?}"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); - assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); - } - } - - #[test] - fn marquee_selects_anchors() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]); - } - - #[test] - fn marquee_selects_ruler_guides() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert_eq!( - a, - vec![ - Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"})), - Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), - ] - ); - } - - /// #2441: Group Selection picks one path of a compound path (the outer shape or a hole); - /// a second click on it adds the compound path, like a group around a member. - #[test] - fn group_selection_picks_a_path_of_a_compound_path() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let rect = |id, a: f64, b: f64| { - std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) - }; - let mut c = vectorcraft_doc::Node::new( - compound, - vectorcraft_doc::NodeKind::Compound { - children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], - rule: vectorcraft_geom::FillRule::EvenOdd, - }, - ); - c.appearance = vectorcraft_doc::Appearance::default_art().into(); - d.insert(Some(l), 0, c).unwrap(); - let p = paint(); - let none = Selection::default(); - let click = |s: &Selection, x, y| DirectSelectionTool::new(true).pointer(&cx(&d, s, &p), &PointerEvent::new(PointerKind::Down, x, y)); - let set = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; - assert_eq!(click(&none, 25.0, 100.0), set(outer), "the fill between the outline and the hole"); - assert_eq!(click(&none, 50.0, 100.0), set(hole), "the hole's outline"); - assert!(click(&none, 100.0, 100.0).is_empty(), "inside the hole nothing is hit"); - let mut s = Selection::default(); - s.set([outer]); - assert_eq!(click(&s, 25.0, 100.0), vec![Action::Exec("select.add".into(), json!({"ids": [compound.0]}))]); - } - - /// Group Selection Shift-click on a selected compound-path member deselects it on release. - #[test] - fn group_selection_shift_click_deselects_a_compound_path_member() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let rect = |id, a: f64, b: f64| { - std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) - }; - let mut c = vectorcraft_doc::Node::new( - compound, - vectorcraft_doc::NodeKind::Compound { - children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], - rule: vectorcraft_geom::FillRule::EvenOdd, - }, - ); - c.appearance = vectorcraft_doc::Appearance::default_art().into(); - d.insert(Some(l), 0, c).unwrap(); - let p = paint(); - let mut s = Selection::default(); - s.set([outer]); - let shift = Mods { shift: true, ..Mods::default() }; - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(true); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0).with_mods(shift)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 25.0, 100.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": outer.0}))]); - } - - /// A path inside an inner group, a sibling path outside it, and the groups' ids. - /// The inside path is the rectangle at (100, 100); the outside one at (300, 300). - fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (inside, outside, inner, outer) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); - let path = |id, x| { - std::sync::Arc::new(vectorcraft_doc::Node::path( - id, - vectorcraft_geom::shapes::rectangle(Rect::new(x, x, x + 100.0, x + 100.0)), - vectorcraft_doc::Appearance::default_art(), - )) - }; - let inner_g = vectorcraft_doc::Node::group(inner, vec![path(inside, 100.0)]); - let outer_g = vectorcraft_doc::Node::group(outer, vec![std::sync::Arc::new(inner_g), path(outside, 300.0)]); - d.insert(Some(l), 0, outer_g).unwrap(); - (d, inside, outside, inner, outer) - } - - /// Isolation mode: a marquee and an anchor click take the path inside the isolated group and - /// leave the path beside it. Group Selection stops at the isolated group instead of adding - /// the group around it. - #[test] - fn isolation_mode_leaves_art_outside_the_isolated_group() { - let (d, inside, outside, inner, outer) = nested_group_doc(); - let p = paint(); - let none = Selection::default(); - let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &none, &p) }; - let mut t = DirectSelectionTool::new(false); - t.pointer(&isolated, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); - let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Up, 450.0, 450.0)); - let items = a.first().and_then(|act| match act { - Action::Exec(c, v) if c == "select.anchorsMany" => v.get("items").cloned(), - _ => None, - }); - let items = items.unwrap(); - assert!(items.as_array().is_some_and(|a| a.iter().all(|item| item["id"] == inside.0)), "marquee: {a:?}"); - assert!(items.as_array().is_some_and(|a| !a.is_empty()), "the path inside is still selected: {a:?}"); - - let a = DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); - assert!(a.is_empty(), "a click on {outside:?}, outside isolation, starts a marquee: {a:?}"); - let a = DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": inside.0, "anchors": [[0, 0]], "mode": "set"}))]); - - let mut s = Selection::default(); - s.set([inside, inner]); - let climbed = ToolContext { isolation: Some(inner), ..cx(&d, &s, &p) }; - let a = DirectSelectionTool::new(true).pointer(&climbed, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert!(a.is_empty(), "does not add the group outside isolation ({outer:?}): {a:?}"); - } - - /// #927: a marquee across type selects the type object whole, beside the anchors it holds. - #[test] - fn marquee_selects_type_it_touches() { - // The rectangle at (100, 100)–(200, 200) and area type at (300, 300). - let (d, text) = doc_with_area_type(); - let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - // From empty canvas across the type to inside the rectangle (its bottom-right corner). - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); - let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]); - // Away from the type, only anchors. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": rect.0, "anchors": [[0, 1]]}], "mode": "set"}))]); - } - - /// A marquee across a member of an Intertwine group selects the member's anchors, as in a - /// plain group, and not the Intertwine group whole. - #[test] - fn marquee_selects_the_anchors_of_intertwine_members() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (a, b, g) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let path = |id, x| { - std::sync::Arc::new(vectorcraft_doc::Node::path( - id, - vectorcraft_geom::shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), - vectorcraft_doc::Appearance::default_art(), - )) - }; - let intertwine = vectorcraft_doc::Node::new(g, vectorcraft_doc::NodeKind::Intertwine { children: vec![path(a, 100.0), path(b, 300.0)] }); - d.insert(Some(l), 0, intertwine).unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - for group in [false, true] { - let mut t = DirectSelectionTool::new(group); - // Around the first member's top-right corner. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); - let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - let items = json!([{"id": a.0, "anchors": [[0, 1]]}]); - assert_eq!(actions, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))], "group: {group}"); - } - } - - /// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too. - #[test] - fn shift_marquee_toggles_anchors() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - for group in [false, true] { - let mut t = DirectSelectionTool::new(group); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0).with_mods(shift)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0).with_mods(shift)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "toggle"}))]); - } - } - - /// Group Selection: E pressed during a marquee drag makes it select the objects entirely inside - /// it whole, a group's member without its group. Direct Selection does not take E. - #[test] - fn e_during_a_group_selection_marquee_selects_the_objects_inside() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); - let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 1, Node::group(g, vec![])).unwrap(); - d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap(); - d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let e = ToolKey::Letter('e'); - let drag = |group: bool, mods: Mods| { - let mut t = DirectSelectionTool::new(group); - assert!(!t.claims_key(&cx, e), "no marquee yet"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0).with_mods(mods)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 250.0).with_mods(mods)); - assert_eq!(t.claims_key(&cx, e), group); - t.key(&cx, e, Mods::default()); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 250.0).with_mods(mods)) - }; - assert_eq!(drag(true, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0, a.0]}))]); - let shift = Mods { shift: true, ..Default::default() }; - assert_eq!(drag(true, shift), vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0, a.0]}))]); - // Direct Selection's marquee still selects the anchors inside it. - let anchors = |id: NodeId, list: Value| json!({"id": id.0, "anchors": list}); - let all = json!([[0, 0], [0, 1], [0, 2], [0, 3]]); - assert_eq!( - drag(false, Mods::default()), - vec![Action::Exec("select.anchorsMany".into(), json!({"items": [anchors(id, all.clone()), anchors(a, all)], "mode": "set"}))] - ); - } - - /// An open path from (100, 300) to (200, 300) arching up through (150, 262.5). - fn arch_doc() -> (vectorcraft_doc::Document, NodeId) { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); - sp.anchors[0].h_out = Point::new(100.0, 250.0); - sp.anchors[1].h_in = Point::new(200.0, 250.0); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); - (d, id) - } - - /// #1743: Escape mid-drag cancels it; the release after it does nothing more. - #[test] - fn escape_cancels_a_drag() { - let (d, _) = arch_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 240.0)); - assert!(t.busy()); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); - assert!(!t.busy()); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 220.0)), vec![]); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 220.0)), vec![]); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![], "nothing to cancel"); - } - - #[test] - fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() { - let (d, arch) = arch_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - // The arch: its segment gets selected, and the drag bends it. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); - assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": arch.0, "segments": [[0, 0]]}))]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5)); - assert_eq!(a[0], Action::Begin("Reshape".into())); - let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") }; - assert_eq!( - (cmd.as_str(), v["id"].clone(), v["segment"].clone(), v["dx"].clone(), v["dy"].clone()), - ("path.reshapeSegment", json!(arch.0), json!(0), json!(0.0), json!(-20.0)) - ); - assert!((v["t"].as_f64().unwrap() - 0.5).abs() < 0.01, "{v}"); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]); - // A straight edge of the rectangle: its two anchors get selected and move. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); - assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.segments" && v["segments"] == json!([[0, 0]])), "{a:?}"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); - assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0}))); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0)); - // Alt on the stroke picks the whole path, and Alt-drag copies it. - let alt = Mods { alt: true, ..Default::default() }; - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0).with_mods(alt)); - assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0).with_mods(alt)); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0).with_mods(alt)); - // The fill still selects the whole path, and a path selected as a whole still moves whole. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); - let rect = d.layers[0].children().unwrap()[0].id; - let mut whole = Selection::default(); - whole.set([rect]); - let cx = crate::testutil::cx(&d, &whole, &p); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)), vec![]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); - } - - /// #494: the handles shown are the handles dragged. A path selected as a whole (the one the - /// Pen just drew) shows all its handles, and they drag; the direct-selected anchors' handles - /// only otherwise; none with Show handles when multiple anchors are selected off and several - /// anchors selected. A handle shorter than the tolerance leaves its anchor to be picked, and - /// the tolerance is the preference's, in screen pixels at any zoom. - #[test] - fn the_handles_shown_are_the_handles_dragged() { - let (d, arch) = arch_doc(); - let p = paint(); - // Does a press at (x, y) take a handle? - let handle = |c: &ToolContext, x: f64, y: f64| { - DirectSelectionTool::new(false).pointer(c, &PointerEvent::new(PointerKind::Down, x, y)) == vec![Action::Begin("Reshape".into())] - }; - let mut whole = Selection::default(); - whole.set([arch]); - let c = cx(&d, &whole, &p); - assert!(handle(&c, 101.0, 251.0), "the first anchor's handle, the path selected whole"); - let mut t = DirectSelectionTool::new(false); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 251.0)); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 240.0)); - assert!(matches!(&a[..], [Action::Preview(c, v)] if c == "path.setHandle" && v["anchor"] == 0 && v["which"] == "out"), "{a:?}"); - // Only the second anchor direct-selected: the first one's handle is hidden. - let second = anchors_of(arch, &[(0, 1)]); - assert!(!handle(&cx(&d, &second, &p), 101.0, 251.0)); - assert!(handle(&cx(&d, &second, &p), 201.0, 251.0)); - // Show handles when multiple anchors are selected off: the whole path's are hidden. - assert!(!handle(&ToolContext { handles_multiple: false, ..cx(&d, &whole, &p) }, 101.0, 251.0)); - assert!(handle(&ToolContext { handles_multiple: false, ..cx(&d, &second, &p) }, 201.0, 251.0)); - // The tolerance is the preference's, in screen pixels: 3 px is 0.75 pt at 400%. - let zoomed = ToolContext { zoom: 4.0, ..cx(&d, &whole, &p) }; - assert!(!handle(&zoomed, 101.0, 251.0)); - assert!(handle(&zoomed, 100.5, 250.5)); - assert!(handle(&ToolContext { selection_tolerance: 8.0, ..cx(&d, &whole, &p) }, 106.0, 254.0)); - } - - /// #494: a handle 2 pt long, inside the tolerance round its anchor, doesn't hide the anchor. - #[test] - fn a_short_handle_leaves_its_anchor_to_be_picked() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); - sp.anchors[0].h_out = Point::new(102.0, 300.0); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); - let s = anchors_of(id, &[(0, 0)]); - let p = paint(); - let c = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), vec![], "the anchor, already selected"); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 290.0)); - assert_eq!(a[0], Action::Begin("Move".into())); - t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 290.0)); - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle"); - } - - /// Direct Selection's cursors from Illustrator's manual: the white arrow at rest; over an anchor - /// a square, hollow on an unselected path and filled on a selected one; the Reshape Segment - /// cursor over a curved segment a drag reshapes, not over a straight one (which moves) or a - /// path selected whole (which moves whole); Group Selection's plus with Alt and as its tool. - #[test] - fn hovering_shows_the_anchor_square_reshape_and_group_cursors() { - let (d, arch) = arch_doc(); - let p = paint(); - let m = Mods::default(); - let pt = Point::new; - let none = Selection::default(); - let c = cx(&d, &none, &p); - let t = DirectSelectionTool::new(false); - assert_eq!(t.cursor(&c, pt(20.0, 20.0), m), Cursor::ArrowHollow); - assert_eq!(t.cursor(&c, pt(100.0, 300.0), m), Cursor::DirectAnchor); - assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ReshapeSegment); - assert_eq!(t.cursor(&c, pt(150.0, 100.0), m), Cursor::ArrowHollow, "a straight segment moves"); - assert_eq!(t.cursor(&c, pt(150.0, 150.0), m), Cursor::ArrowHollow, "the fill"); - let alt = Mods { alt: true, ..m }; - assert_eq!(t.cursor(&c, pt(100.0, 300.0), alt), Cursor::GroupSelection); - assert_eq!(DirectSelectionTool::new(true).cursor(&c, pt(100.0, 300.0), m), Cursor::GroupSelection); - let mut whole = Selection::default(); - whole.set([arch]); - let c = cx(&d, &whole, &p); - assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); - assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ArrowHollow, "a path selected whole moves whole"); - // One anchor selected: its handle end shows and keeps the plain arrow, Alt or not (a press - // takes the handle). - let s = anchors_of(arch, &[(0, 0)]); - let c = cx(&d, &s, &p); - assert_eq!(t.cursor(&c, pt(100.0, 250.0), m), Cursor::ArrowHollow, "a handle end"); - assert_eq!(t.cursor(&c, pt(100.0, 250.0), alt), Cursor::ArrowHollow, "a handle end with Alt"); - assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); - // Bending the curve keeps the Reshape Segment cursor. - let mut t = DirectSelectionTool::new(false); - let c = cx(&d, &none, &p); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); - t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0)); - assert_eq!(t.cursor(&c, pt(150.0, 230.0), m), Cursor::ReshapeSegment); - } - - /// A drag shows the filled arrow, hollow while it snaps to a point; an Alt-drag copy shows the - /// double arrowhead. - #[test] - fn drags_show_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() { - let (d, _, b) = two_squares(); - let p = paint(); - let none = Mods::default(); - let at = |kind, x, y, m: Mods| PointerEvent::new(kind, x, y).with_mods(m); - let one = anchors_of(b, &[(0, 0)]); - let c = cx(&d, &one, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, none)); - t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, none)); - assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), none), Cursor::Arrow); - // Onto A's bottom-right corner. - t.pointer(&c, &at(PointerKind::Drag, 203.0, 199.0, none)); - assert_eq!(t.cursor(&c, Point::new(203.0, 199.0), none), Cursor::ArrowHollow); - t.pointer(&c, &at(PointerKind::Up, 203.0, 199.0, none)); - // Shift with a Constrain Angle of 10°: the constraint alone isn't a snap. - let shift = Mods { shift: true, ..none }; - let tilted = ToolContext { constrain_angle: 10.0, ..cx(&d, &one, &p) }; - t.pointer(&tilted, &at(PointerKind::Down, 300.0, 300.0, shift)); - t.pointer(&tilted, &at(PointerKind::Drag, 380.0, 262.0, shift)); - assert_eq!(t.cursor(&tilted, Point::new(380.0, 262.0), shift), Cursor::Arrow); - t.pointer(&tilted, &at(PointerKind::Up, 380.0, 262.0, shift)); - let mut whole = Selection::default(); - whole.set([b]); - let c = cx(&d, &whole, &p); - let alt = Mods { alt: true, ..none }; - t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, alt)); - t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, alt)); - assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), alt), Cursor::SelectionCopy); - } - - /// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B). - fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) { - let (mut d, a) = doc_with_rect(); - let l = d.layers[0].id; - let b = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - (d, a, b) - } - - /// An anchor of a selected path is picked before the one of an unselected path on a layer - /// above that covers it, as Highlight anchors on mouse over already marks it (#1508); with - /// nothing selected, the topmost path's anchor is picked. - #[test] - fn a_selected_path_s_anchor_is_picked_before_one_above_it() { - let (mut d, a) = doc_with_rect(); - let top = d.add_layer(None); - let b = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(101.0, 100.0, 201.0, 200.0)); - d.insert(Some(top), 0, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - let p = paint(); - let pick = |id: NodeId| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; - let s = anchors_of(a, &[(0, 2)]); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(a)); - let mut whole = Selection::default(); - whole.set([a]); - let mut t = DirectSelectionTool::new(false); - let cx2 = cx(&d, &whole, &p); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 101.0, 100.0)).is_empty()); - assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 101.0, 100.0)), pick(a)); - let none = Selection::default(); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(b)); - } - - /// The anchors `anchors` of path `id` direct-selected. - fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection { - let mut s = Selection::default(); - s.set([id]); - s.anchors.insert(id, anchors.iter().copied().collect()); - s - } - - /// Press at `from`, drag to `to`: the move the drag previews, and the smart guide labels shown. - fn drag(t: &mut DirectSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Value, Vec) { - t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(mods)); - let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods)); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - let labels = t - .overlays(cx) - .into_iter() - .filter_map(|o| match o { - Overlay::Label { text, .. } => Some(text), - _ => None, - }) - .collect(); - assert_eq!(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![Action::Commit]); - assert!(t.overlays(cx).iter().all(|o| !matches!(o, Overlay::Label { .. })), "the guides go with the drag"); - (v.clone(), labels) - } - - /// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does: - /// onto another path's anchor, or onto an anchor of the original the copy leaves behind. - #[test] - fn alt_dragged_copies_snap_by_the_anchor_pressed() { - let (d, _, b) = two_squares(); - let p = paint(); - let alt = Mods { alt: true, ..Default::default() }; - let mut whole = Selection::default(); - whole.set([b]); - let c = cx(&d, &whole, &p); - let mut t = DirectSelectionTool::new(false); - let copy = - |dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true}); - // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. - let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt); - assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()])); - // B's top-left corner onto the original B's top-right one: the copy sits beside it. - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt); - assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()])); - // The history says what the drag did. - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt)); - let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt)); - assert_eq!(out.first(), Some(&Action::Begin("Copy".into()))); - } - - /// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released - /// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed - /// snaps all along, onto the original only while it is left behind. - #[test] - fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { - let (d, _, b) = two_squares(); - let p = paint(); - let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() }); - let mut whole = Selection::default(); - whole.set([b]); - let c = cx(&d, &whole, &p); - let mut t = DirectSelectionTool::new(false); - let preview = |dx: f64, dy: f64, copy: bool| { - Action::Preview( - "object.transform".into(), - json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}), - ) - }; - assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty()); - assert_eq!( - t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), - vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)] - ); - // The tool sees the document as previewed: B moved along, not where the copy leaves it. - let (mut moved, _, b2) = two_squares(); - let l = moved.layers[0].id; - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0)); - moved.remove(b2).unwrap(); - moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - let c = cx(&moved, &whole, &p); - // Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one. - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]); - // Alt up: a move again, which leaves nothing behind to snap to. - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]); - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]); - // With only some anchors selected, the drag still moves just those. - let one = anchors_of(b, &[(0, 0)]); - let c = cx(&d, &one, &p); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt)); - assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}"); - } - - /// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors - /// of its own path, and with several selected the one pressed on snaps, the others following. - #[test] - fn dragged_anchors_snap_to_points_segments_and_smart_guides() { - let (d, _, b) = two_squares(); - let p = paint(); - let none = Mods::default(); - let one = anchors_of(b, &[(0, 0)]); - let c = cx(&d, &one, &p); - let mut t = DirectSelectionTool::new(false); - // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. - let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), none); - assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -100.0}), vec!["anchor".to_string()])); - // Onto A's right side. - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 150.0), none); - assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -150.0}), vec!["path".to_string()])); - // In line with B's own bottom-left corner, which stays put. - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none); - assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()])); - // Shift keeps the move at 45° steps and still snaps along that line (#886): on the - // diagonal from B's corner, onto A's bottom-right corner… - let shift = Mods { shift: true, ..none }; - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift); - let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9; - assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}"); - // …and with nothing in reach, at the pointer's distance along its angle. - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift); - let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true, 0.0); - assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}"); - // Two anchors: the one pressed on (B's top-right) lands on A's corner. - let two = anchors_of(b, &[(0, 0), (0, 1)]); - let c = cx(&d, &two, &p); - let (v, _) = drag(&mut t, &c, (400.0, 300.0), (202.0, 198.0), none); - assert_eq!(v, json!({"dx": -200.0, "dy": -100.0})); - // Smart Guides off: Snap to Point pulls within its distance (2 px) only. - let c = ToolContext { smart_guides: false, ..cx(&d, &one, &p) }; - assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -100.0, "dy": -100.0})); - assert_eq!(drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), none).0, json!({"dx": -97.0, "dy": -101.0})); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &one, &p) }; - assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0})); - } - - /// #2120: the end of a slanted straight line, dragged roughly along it, stays on its direction - /// (Line Extension) and the guide runs along it from the other end. - #[test] - fn dragged_end_of_a_slanted_line_stays_on_its_direction() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let p = paint(); - let end = anchors_of(id, &[(0, 1)]); - let c = cx(&d, &end, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 380.0, 340.0)); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0)); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - let (dx, dy) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap()); - assert!((dx - 40.0).abs() < 1e-9 && (dy + 30.0).abs() < 1e-9, "{v}"); - let guides: Vec<(Point, Point)> = - t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect(); - assert!(matches!(guides[..], [(a, b)] if a == Point::new(300.0, 400.0) && (b - Point::new(420.0, 310.0)).hypot() < 1e-9), "{guides:?}"); - } - - /// Group Selection (and Direct Selection on a whole path) moves objects snapping as the - /// Selection tool does: B's left side lines up with A's right one. - #[test] - fn moved_objects_snap_with_smart_guides() { - let (d, _, b) = two_squares(); - let p = paint(); - let mut s = Selection::default(); - s.set([b]); - let c = cx(&d, &s, &p); - for group in [true, false] { - let mut t = DirectSelectionTool::new(group); - let (v, _) = drag(&mut t, &c, (350.0, 350.0), (253.0, 351.0), Mods::default()); - assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, -100.0, 1.0]), "group: {group}"); - } - } - - /// #1816: Group Selection and Direct Selection move a whole path with Snap to Grid as the - /// Selection tool does, landing an edge on the grid; the center does not snap. A 45 pt square at - /// 90–135 on the 9 pt grid, dragged right by 22 to 112–157, lands its left edge on the gridline - /// 108 and moves by 18. Its center, at 134.5 after the drag, is nearer the gridline 135 and - /// does not snap. - #[test] - fn a_move_of_objects_lands_an_edge_on_the_grid() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 90.0, 135.0, 135.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - let p = paint(); - let mut s = Selection::default(); - s.set([id]); - let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - for group in [true, false] { - let mut t = DirectSelectionTool::new(group); - let (v, _) = drag(&mut t, &c, (110.0, 110.0), (132.0, 110.0), Mods::default()); - assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, 18.0, 0.0]), "group: {group}"); - } - } - - /// Group Selection and Direct Selection on a whole path keep a Shift-constrained move on its - /// line when it snaps, as the Selection tool does: level by 13.04 with Snap to Grid on, B's - /// right side (413.04) lands on the gridline 414, and its top stays at 300, between 297 and - /// 306. - #[test] - fn a_shift_move_of_objects_snaps_along_its_line() { - let (d, _, b) = two_squares(); - let p = paint(); - let mut s = Selection::default(); - s.set([b]); - let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - let shift = Mods { shift: true, ..Mods::default() }; - for group in [true, false] { - let mut t = DirectSelectionTool::new(group); - let (v, _) = drag(&mut t, &c, (350.0, 350.0), (363.0, 351.0), shift); - let (x, y) = (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()); - assert!((x - 14.0).abs() < 1e-9 && y.abs() < 1e-9, "group: {group}: {v}"); - } - } - - /// Direct Selection's double-click on type where a layer's clipping mask edge runs through it - /// edits the type, though a press there takes the mask's segment. - #[test] - fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() { - let (d, _, at) = doc_with_type_on_a_layer_masks_edge(); - let (s, p) = (Selection::default(), paint()); - let acts = DirectSelectionTool::new(false).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::DoubleClick, at.x, at.y)); - assert_eq!(acts, vec![Action::SwitchTool("type".into())]); - } - - #[test] - fn double_clicking_type_switches_to_type_tool() { - use std::sync::Arc; - let (mut d, id) = doc_with_area_type(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let b = d.node(id).unwrap().geometric_bounds().unwrap(); - let mid = b.center(); - // Direct Selection (shortcut A, group == false) switches to the Type tool on double click. - let mut t = DirectSelectionTool::new(false); - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, mid.x, mid.y)); - assert_eq!(acts, vec![Action::SwitchTool("type".into())]); - - // Group Selection (group == true) does not switch to Type tool. - let mut gt = DirectSelectionTool::new(true); - let acts_group = gt.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, mid.x, mid.y)); - assert!(acts_group.is_empty(), "group selection shouldn't switch: {acts_group:?}"); - - // Text inside a group also switches to Type tool with Direct Selection. - let gid = d.alloc_id(); - let text_id = d.alloc_id(); - let text_node = Node::new( - text_id, - vectorcraft_doc::NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(Point::new(50.0, 50.0), "In group", Default::default()))), - ); - d.insert(Some(d.layers[0].id), 2, Node::group(gid, vec![Arc::new(text_node)])).unwrap(); - let in_group_b = d.node(gid).unwrap().geometric_bounds().unwrap(); - let c2 = cx(&d, &s, &p); - let acts_group_text = t.pointer(&c2, &PointerEvent::new(PointerKind::DoubleClick, in_group_b.center().x, in_group_b.center().y)); - assert_eq!(acts_group_text, vec![Action::SwitchTool("type".into())]); - } - - #[test] - fn direct_selection_click_and_drag_member_of_compound_path() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let rect = |id, a: f64, b: f64| { - std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) - }; - let mut c = vectorcraft_doc::Node::new( - compound, - vectorcraft_doc::NodeKind::Compound { - children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], - rule: vectorcraft_geom::FillRule::EvenOdd, - }, - ); - c.appearance = vectorcraft_doc::Appearance::default_art().into(); - d.insert(Some(l), 0, c).unwrap(); - let p = paint(); - let none = Selection::default(); - let mut t = DirectSelectionTool::new(false); - let ctx = cx(&d, &none, &p); - let down = t.pointer(&ctx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0)); - assert_eq!( - down, - vec![Action::Exec("select.set".into(), json!({"ids": [outer.0]}))], - "Direct Selection selects child member, not compound parent" - ); - let mut s = Selection::default(); - s.set([outer]); - let ctx_sel = cx(&d, &s, &p); - let drag = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Drag, 35.0, 110.0)); - assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "object.transform")), "drags the selected child member: {drag:?}"); - let up = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Up, 35.0, 110.0)); - assert_eq!(up, vec![Action::Commit]); - } - - #[test] - fn shift_clicking_anchor_after_segment_pick_toggles_or_adds() { - let (d, id) = doc_with_rect(); - let p = paint(); - let mut s = Selection::default(); - s.set([id]); - s.segments.insert(id, [(0, 0)].into()); - s.anchors.insert(id, [(0, 0), (0, 1)].into()); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - let shift = Mods { shift: true, ..Default::default() }; - // Shift-click on anchor (0, 0) (top-left at 100, 100) toggles it off - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); - // Shift-click on anchor (0, 2) (bottom-right at 200, 200) toggles it on (adds it) - let a2 = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 200.0).with_mods(shift)); - assert_eq!(a2, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"}))]); - } - - #[test] - fn shift_clicking_segment_toggles_segment() { - let (d, id) = doc_with_rect(); - let p = paint(); - let s = Selection::default(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - let shift = Mods { shift: true, ..Default::default() }; - // The rectangle is at 100..200. Segment 0 is the top edge (y=100, x between 100 and 200). - // Shift-click at (150, 100) selects segment 0 with mode "toggle". - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 100.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[0, 0]], "mode": "toggle"}))]); - } - - /// In normal viewmode, clicking the fill of an open path in front selects the open path's shape - /// and anchors, and NEVER clicks through to select or grab a path behind it (even if the path - /// behind has an anchor directly under the click). - #[test] - fn clicking_fill_of_open_path_in_front_never_selects_path_behind() { - use vectorcraft_geom::{Anchor, SubPath}; - let (mut d, _behind) = doc_with_rect(); - // `behind` has an anchor at (100.0, 100.0). - // Create an open path in front (id `front`), with a fill, covering (100.0, 100.0). - let front = d.alloc_id(); - let l = d.layers[0].id; - // Triangle from (50, 50) to (150, 50) to (150, 150), open (implicit closing edge from (150, 150) to (50, 50)). - // (100.0, 100.0) is inside this filled triangle, but `front` has NO anchor at (100.0, 100.0). - let anchors = vec![Anchor::corner(Point::new(50.0, 50.0)), Anchor::corner(Point::new(150.0, 50.0)), Anchor::corner(Point::new(150.0, 150.0))]; - let open_path = PathData::single(SubPath::new(anchors, false)); - let ap = vectorcraft_doc::Appearance::default_art(); // has solid white fill - d.insert(Some(l), 1, vectorcraft_doc::Node::path(front, open_path, ap)).unwrap(); - - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - - // Hovering over (100.0, 100.0) does NOT hover the hidden anchor of `behind`. - assert_eq!(hovered_anchor(&c, Point::new(100.0, 100.0)), None); - - // Clicking at (100.0, 100.0) selects `front` (the open path in front), NOT `behind`. - let mut t = DirectSelectionTool::new(false); - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [front.0]}))]); - } - - /// A selected path's Bézier direction handle positioned over a background filled rectangle - /// can be hit and dragged without being occluded by the background fill. - #[test] - fn selected_path_handle_over_background_rect_is_draggable() { - use vectorcraft_geom::{Anchor, SubPath}; - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - // Background rectangle covering 0..300 with default white fill - let bg = d.alloc_id(); - let bg_rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 300.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(bg, bg_rect, vectorcraft_doc::Appearance::default_art())).unwrap(); - - // Selected path with a Bézier curve whose out-handle is at (150, 150) - let fg = d.alloc_id(); - let mut a0 = Anchor::corner(Point::new(100.0, 150.0)); - a0.h_out = Point::new(150.0, 150.0); // handle over the background rect - let a1 = Anchor::corner(Point::new(200.0, 150.0)); - let curve = PathData::single(SubPath::new(vec![a0, a1], false)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(fg, curve, vectorcraft_doc::Appearance::default_art())).unwrap(); - - let mut s = Selection::default(); - s.set([fg]); - s.anchors.insert(fg, [(0, 0)].into()); - let p = paint(); - let c = cx(&d, &s, &p); - - // hit_handle on the handle at (150, 150) must find the handle of `fg` - assert_eq!(hit_handle(&c, Point::new(150.0, 150.0), c.point_tol()), Some((fg, 0, 0, true))); - - // Direct Selection tool clicking on the handle initiates Reshape - let mut t = DirectSelectionTool::new(false); - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert_eq!(acts, vec![Action::Begin("Reshape".into())]); - } -} +mod tests; diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs new file mode 100644 index 000000000..06a940ac6 --- /dev/null +++ b/crates/tools/src/direct/tests.rs @@ -0,0 +1,1267 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +/// #414: a ruler guide is picked over the art, but an anchor on it is picked first. +#[test] +fn direct_and_group_selection_pick_ruler_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(true, 100.0)); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y)); + let pick = vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]; + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + assert_eq!(down(&mut t, 150.0), pick, "on the rect's edge (group: {group})"); + assert!(t.busy()); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 150.0)); + assert_eq!(t.cursor(&c, Point::new(101.0, 300.0), Mods::default()), Cursor::ResizeH); + } + let mut t = DirectSelectionTool::new(false); + assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); +} + +/// A Cmd/Ctrl+Shift double-click with Direct Selection or Group Selection releases the ruler +/// guide or the guide path under the pointer; a plain double-click on a ruler guide, or one while +/// guides are hidden or locked, releases nothing. +#[test] +fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + let Some(NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; + *guide = true; + let (s, p) = (Selection::default(), paint()); + let both = Mods { cmd: true, shift: true, ..Mods::default() }; + let index = vec![Action::Exec("view.guides.release".into(), json!({"index": 0}))]; + let ids = vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]; + let locked = ToolContext { guides: false, ..cx(&d, &s, &p) }; + for group in [false, true] { + let dbl = |c: &ToolContext, x: f64, y: f64, m: Mods| { + DirectSelectionTool::new(group).pointer(c, &PointerEvent::new(PointerKind::DoubleClick, x, y).with_mods(m)) + }; + let c = cx(&d, &s, &p); + assert_eq!(dbl(&c, 350.0, 301.0, both), index, "on the ruler guide (group: {group})"); + assert_eq!(dbl(&c, 200.0, 150.0, both), ids, "on the guide path (group: {group})"); + assert!(dbl(&c, 350.0, 301.0, Mods::default()).is_empty(), "a plain double-click (group: {group})"); + assert!(dbl(&c, 350.0, 301.0, Mods { cmd: true, ..Mods::default() }).is_empty(), "Cmd/Ctrl alone (group: {group})"); + assert!(dbl(&locked, 350.0, 301.0, both).is_empty(), "guides locked (group: {group})"); + assert!(dbl(&locked, 200.0, 150.0, both).is_empty(), "guides locked (group: {group})"); + } +} + +/// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and +/// further with a larger anchor Size or Tolerance (#593); away from it the segment is. +#[test] +fn anchors_are_picked_before_the_segments_through_them() { + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; + let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y)); + let c = cx(&d, &s, &p); + // 4.3 px from the corner, 0.5 px from the top edge. + assert_eq!(press(&c, 104.2, 100.5), corner); + assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor"); + assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)"); + let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) }; + assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide"); + let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) }; + assert_eq!(press(&loose, 107.5, 100.5), corner); + // On screen: at 200% the reach is half as far in the document. + let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) }; + assert_eq!(press(&zoomed, 102.1, 100.2), corner); + assert_ne!(press(&zoomed, 102.6, 100.2), corner); +} + +/// A press on a clip group's clipping path, where the art it clips lies over its edge, takes +/// the clipping path's segment, so the mask can be reshaped on the canvas; inside the mask the +/// art is taken, as before. +#[test] +fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() { + let (d, _, clip, art) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let press = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + let names = |actions: &[Action], id: NodeId| { + actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(id.0) || v["ids"] == json!([id.0]))) + }; + // The middle of the right edge, on it and just inside it. + for x in [500.0, 498.5] { + let a = press(x, 450.0); + assert!(names(&a, clip), "at {x}: {a:?}"); + } + let a = press(450.0, 450.0); + assert!(names(&a, art) && !names(&a, clip), "{a:?}"); +} + +/// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. +fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let b = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }); + d.insert(Some(l), 1, b).unwrap(); + (d, g, k1) +} + +/// Highlight anchors on mouse over (#394): the anchor under the pointer, within the selection +/// tolerance, shows enlarged; none with the preference off or away from anchors. +#[test] +fn the_anchor_under_the_pointer_is_highlighted() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let big = |t: &DirectSelectionTool, cx: &ToolContext| { + t.overlays(cx).into_iter().find_map(|o| match o { + Overlay::Anchor { p, size, .. } if size > 5.0 => Some(p), + _ => None, + }) + }; + let mut t = DirectSelectionTool::new(false); + let on = cx(&d, &s, &p); + t.pointer(&on, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); + assert_eq!(big(&t, &on), Some(Point::new(100.0, 100.0)), "the corner of {id:?}"); + t.pointer(&on, &PointerEvent::new(PointerKind::Move, 150.0, 100.0)); + assert_eq!(big(&t, &on), None, "mid-segment: no anchor"); + let off = ToolContext { highlight_anchors: false, ..cx(&d, &s, &p) }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); + assert_eq!(big(&t, &off), None, "off"); +} + +#[test] +fn dragging_a_spine_point_moves_it() { + let (d, g, _) = blend_doc(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 211.0, 310.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 221.0, 350.0)); + assert_eq!(a[0], Action::Begin("Reshape Spine".into())); + assert_eq!(a[1], Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 1, "x": 220.0, "y": 350.0}))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 221.0, 350.0)), vec![Action::Commit]); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows"); +} + +/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection +/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's. +#[test] +fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() { + let (mut d, g, _) = blend_doc(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| { + t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); + let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0)); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0)); + a + }; + let straight = cx(&d, &s, &p); + let a = drag(crate::create("anchorPoint").as_mut(), &straight); + assert!( + matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0), + "{a:?}" + ); + let a = drag(&mut DirectSelectionTool::new(false), &straight); + assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}"); + // The spine bent upward: Direct Selection takes it from where it passes. + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false); + sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0)); + let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") }; + (spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]); + let mut t = DirectSelectionTool::new(false); + let curved = cx(&d, &s, &p); + t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0)); + let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0)); + assert_eq!(a[0], Action::Begin("Reshape Spine".into())); + assert!( + matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0), + "{a:?}" + ); + assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]); +} + +/// Shift held while a curved segment is dragged sends `perpendicular`. The test drags a path's +/// segment with Direct Selection, and a blend spine's segment with Direct Selection and with the +/// Anchor Point tool. +#[test] +fn shift_reshapes_a_segment_perpendicular() { + let shift = Mods { shift: true, ..Mods::default() }; + let p = paint(); + let drag = |t: &mut dyn crate::Tool, cx: &ToolContext, from: (f64, f64), mods: Mods| { + t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1)); + let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, from.0 + 10.0, from.1 - 20.0).with_mods(mods)); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, from.0 + 10.0, from.1 - 20.0).with_mods(mods)); + a + }; + let sent = |a: &[Action], cmd: &str| match a.last() { + Some(Action::Preview(c, v)) if c == cmd => v["perpendicular"].as_bool(), + _ => None, + }; + let (d, _) = arch_doc(); + let s = Selection::default(); + let arch = cx(&d, &s, &p); + let (mut b, g, _) = blend_doc(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false); + sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0)); + let Some(NodeKind::Blend { spec, .. }) = b.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") }; + (spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]); + let mut bs = Selection::default(); + bs.set([g]); + let spine = cx(&b, &bs, &p); + for (mods, perpendicular) in [(Mods::default(), false), (shift, true)] { + let a = drag(&mut DirectSelectionTool::new(false), &arch, (150.0, 262.5), mods); + assert_eq!(sent(&a, "path.reshapeSegment"), Some(perpendicular), "{a:?}"); + let a = drag(&mut DirectSelectionTool::new(false), &spine, (160.0, 330.0), mods); + assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}"); + let a = drag(crate::create("anchorPoint").as_mut(), &spine, (160.0, 330.0), mods); + assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}"); + } +} + +#[test] +fn direct_and_group_selection_pick_blend_keys() { + let (d, g, k1) = blend_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let a = DirectSelectionTool::new(false).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); + let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); + // A click on a step (between the keys) still selects the blend. + let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); +} + +#[test] +fn dragging_a_frame_corner_or_edge_reshapes_area_type() { + let (d, text) = doc_with_area_type(); + let p = paint(); + let s = Selection::default(); + let cx1 = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + // The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text... + assert_eq!( + t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)), + vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] + ); + // ...and drags that corner. + let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0)); + assert_eq!(a[0], Action::Begin("Reshape Type Area".into())); + assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0}))); + assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]); + // The top edge drags both its ends; a click alone changes nothing. + let mut s = Selection::default(); + s.add(text); + let cx2 = cx(&d, &s, &p); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 350.0, 301.0)).is_empty()); + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0)); + assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0}))); + t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0)); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty()); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty()); + // The selected frame's corners show. + assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4); +} + +/// #3980: type on a path shows its path's anchors and a drag of one reshapes the path, the last +/// one too although the end bracket stands on it; the bracket's stem above it drags the bracket. +#[test] +fn dragging_an_anchor_of_type_on_a_path_reshapes_its_path() { + let (d, text) = doc_with_path_type(); + let p = paint(); + let s = Selection::default(); + let cx1 = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!( + t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 101.0, 301.0)), + vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] + ); + let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 121.0, 261.0)); + assert_eq!(a[0], Action::Begin("Reshape Type Path".into())); + assert_eq!(a[1], Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 0]], "dx": 20.0, "dy": -40.0}))); + assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 121.0, 261.0)), vec![Action::Commit]); + let mut s = Selection::default(); + s.add(text); + let cx2 = cx(&d, &s, &p); + let anchors: Vec = + t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Anchor { p, filled: false, .. } = o { Some(*p) } else { None }).collect(); + assert_eq!(anchors, vec![Point::new(100.0, 300.0), Point::new(400.0, 300.0)]); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 300.0)).is_empty()); + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 400.0, 260.0)); + assert_eq!(a.last(), Some(&Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 1]], "dx": 0.0, "dy": -40.0})))); + t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 400.0, 260.0)); + t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 290.0)); + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 340.0, 290.0)); + assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "type.pathOptions"), "{a:?}"); +} + +#[test] +fn click_anchor_selects_it() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); +} + +/// Pressing an anchor of a path whose anchors are all selected (stored as the whole path) +/// keeps them all selected, so the drag moves them all; Shift still deselects that anchor. +#[test] +fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)); + let shift = Mods { shift: true, ..Default::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); +} + +/// A click (no drag) on an anchor that is selected with others, whether the whole path is or +/// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the +/// selection so a drag moves it all, the release without a drag narrows it. Shift still +/// toggles, and a click on a path's only selected anchor changes nothing visible. +#[test] +fn a_click_on_a_selected_anchor_selects_it_alone() { + let (d, id) = doc_with_rect(); + let p = paint(); + let alone = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; + let mut whole = Selection::default(); + whole.set([id]); + for s in [whole, anchors_of(id, &[(0, 0), (0, 1)])] { + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty(), "{s:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)), alone, "{s:?}"); + // Dragged instead: moved, selection kept. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)), vec![Action::Commit], "{s:?}"); + } + // An anchor not yet selected is selected on the press, and the release adds nothing. + let none = Selection::default(); + let cx = cx(&d, &none, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), alone); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)).is_empty()); +} + +/// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies +/// it as the Selection tool's Alt-drag does. +#[test] +fn alt_dragging_an_anchor_copies_the_whole_path() { + let (d, id) = doc_with_rect(); + let alt = Mods { alt: true, ..Default::default() }; + let mut whole = Selection::default(); + whole.set([id]); + let p = paint(); + for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] { + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt)); + let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(a, if pick { set } else { vec![] }, "{s:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); + } +} + +#[test] +fn marquee_selects_anchors() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]); +} + +#[test] +fn marquee_selects_ruler_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert_eq!( + a, + vec![ + Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"})), + Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), + ] + ); +} + +/// #2441: Group Selection picks one path of a compound path (the outer shape or a hole); +/// a second click on it adds the compound path, like a group around a member. +#[test] +fn group_selection_picks_a_path_of_a_compound_path() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let rect = |id, a: f64, b: f64| { + std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) + }; + let mut c = vectorcraft_doc::Node::new( + compound, + vectorcraft_doc::NodeKind::Compound { + children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], + rule: vectorcraft_geom::FillRule::EvenOdd, + }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 0, c).unwrap(); + let p = paint(); + let none = Selection::default(); + let click = |s: &Selection, x, y| DirectSelectionTool::new(true).pointer(&cx(&d, s, &p), &PointerEvent::new(PointerKind::Down, x, y)); + let set = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(click(&none, 25.0, 100.0), set(outer), "the fill between the outline and the hole"); + assert_eq!(click(&none, 50.0, 100.0), set(hole), "the hole's outline"); + assert!(click(&none, 100.0, 100.0).is_empty(), "inside the hole nothing is hit"); + let mut s = Selection::default(); + s.set([outer]); + assert_eq!(click(&s, 25.0, 100.0), vec![Action::Exec("select.add".into(), json!({"ids": [compound.0]}))]); +} + +/// Group Selection Shift-click on a selected compound-path member deselects it on release. +#[test] +fn group_selection_shift_click_deselects_a_compound_path_member() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let rect = |id, a: f64, b: f64| { + std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) + }; + let mut c = vectorcraft_doc::Node::new( + compound, + vectorcraft_doc::NodeKind::Compound { + children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], + rule: vectorcraft_geom::FillRule::EvenOdd, + }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 0, c).unwrap(); + let p = paint(); + let mut s = Selection::default(); + s.set([outer]); + let shift = Mods { shift: true, ..Mods::default() }; + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(true); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 25.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": outer.0}))]); +} + +/// A path inside an inner group, a sibling path outside it, and the groups' ids. +/// The inside path is the rectangle at (100, 100); the outside one at (300, 300). +fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (inside, outside, inner, outer) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id, x| { + std::sync::Arc::new(vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::rectangle(Rect::new(x, x, x + 100.0, x + 100.0)), + vectorcraft_doc::Appearance::default_art(), + )) + }; + let inner_g = vectorcraft_doc::Node::group(inner, vec![path(inside, 100.0)]); + let outer_g = vectorcraft_doc::Node::group(outer, vec![std::sync::Arc::new(inner_g), path(outside, 300.0)]); + d.insert(Some(l), 0, outer_g).unwrap(); + (d, inside, outside, inner, outer) +} + +/// Isolation mode: a marquee and an anchor click take the path inside the isolated group and +/// leave the path beside it. Group Selection stops at the isolated group instead of adding +/// the group around it. +#[test] +fn isolation_mode_leaves_art_outside_the_isolated_group() { + let (d, inside, outside, inner, outer) = nested_group_doc(); + let p = paint(); + let none = Selection::default(); + let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &none, &p) }; + let mut t = DirectSelectionTool::new(false); + t.pointer(&isolated, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Up, 450.0, 450.0)); + let items = a.first().and_then(|act| match act { + Action::Exec(c, v) if c == "select.anchorsMany" => v.get("items").cloned(), + _ => None, + }); + let items = items.unwrap(); + assert!(items.as_array().is_some_and(|a| a.iter().all(|item| item["id"] == inside.0)), "marquee: {a:?}"); + assert!(items.as_array().is_some_and(|a| !a.is_empty()), "the path inside is still selected: {a:?}"); + + let a = DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + assert!(a.is_empty(), "a click on {outside:?}, outside isolation, starts a marquee: {a:?}"); + let a = DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": inside.0, "anchors": [[0, 0]], "mode": "set"}))]); + + let mut s = Selection::default(); + s.set([inside, inner]); + let climbed = ToolContext { isolation: Some(inner), ..cx(&d, &s, &p) }; + let a = DirectSelectionTool::new(true).pointer(&climbed, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert!(a.is_empty(), "does not add the group outside isolation ({outer:?}): {a:?}"); +} + +/// #927: a marquee across type selects the type object whole, beside the anchors it holds. +#[test] +fn marquee_selects_type_it_touches() { + // The rectangle at (100, 100)–(200, 200) and area type at (300, 300). + let (d, text) = doc_with_area_type(); + let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + // From empty canvas across the type to inside the rectangle (its bottom-right corner). + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); + let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]); + // Away from the type, only anchors. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": rect.0, "anchors": [[0, 1]]}], "mode": "set"}))]); +} + +/// A marquee across a member of an Intertwine group selects the member's anchors, as in a +/// plain group, and not the Intertwine group whole. +#[test] +fn marquee_selects_the_anchors_of_intertwine_members() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (a, b, g) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id, x| { + std::sync::Arc::new(vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), + vectorcraft_doc::Appearance::default_art(), + )) + }; + let intertwine = vectorcraft_doc::Node::new(g, vectorcraft_doc::NodeKind::Intertwine { children: vec![path(a, 100.0), path(b, 300.0)] }); + d.insert(Some(l), 0, intertwine).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + // Around the first member's top-right corner. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + let items = json!([{"id": a.0, "anchors": [[0, 1]]}]); + assert_eq!(actions, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))], "group: {group}"); + } +} + +/// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too. +#[test] +fn shift_marquee_toggles_anchors() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "toggle"}))]); + } +} + +/// Group Selection: E pressed during a marquee drag makes it select the objects entirely inside +/// it whole, a group's member without its group. Direct Selection does not take E. +#[test] +fn e_during_a_group_selection_marquee_selects_the_objects_inside() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); + let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 1, Node::group(g, vec![])).unwrap(); + d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap(); + d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let e = ToolKey::Letter('e'); + let drag = |group: bool, mods: Mods| { + let mut t = DirectSelectionTool::new(group); + assert!(!t.claims_key(&cx, e), "no marquee yet"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0).with_mods(mods)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 250.0).with_mods(mods)); + assert_eq!(t.claims_key(&cx, e), group); + t.key(&cx, e, Mods::default()); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 250.0).with_mods(mods)) + }; + assert_eq!(drag(true, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0, a.0]}))]); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(drag(true, shift), vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0, a.0]}))]); + // Direct Selection's marquee still selects the anchors inside it. + let anchors = |id: NodeId, list: Value| json!({"id": id.0, "anchors": list}); + let all = json!([[0, 0], [0, 1], [0, 2], [0, 3]]); + assert_eq!( + drag(false, Mods::default()), + vec![Action::Exec("select.anchorsMany".into(), json!({"items": [anchors(id, all.clone()), anchors(a, all)], "mode": "set"}))] + ); +} + +/// An open path from (100, 300) to (200, 300) arching up through (150, 262.5). +fn arch_doc() -> (vectorcraft_doc::Document, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); + sp.anchors[0].h_out = Point::new(100.0, 250.0); + sp.anchors[1].h_in = Point::new(200.0, 250.0); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, id) +} + +/// #1743: Escape mid-drag cancels it; the release after it does nothing more. +#[test] +fn escape_cancels_a_drag() { + let (d, _) = arch_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 240.0)); + assert!(t.busy()); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy()); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 220.0)), vec![]); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 220.0)), vec![]); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![], "nothing to cancel"); +} + +#[test] +fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() { + let (d, arch) = arch_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + // The arch: its segment gets selected, and the drag bends it. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); + assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": arch.0, "segments": [[0, 0]]}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5)); + assert_eq!(a[0], Action::Begin("Reshape".into())); + let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") }; + assert_eq!( + (cmd.as_str(), v["id"].clone(), v["segment"].clone(), v["dx"].clone(), v["dy"].clone()), + ("path.reshapeSegment", json!(arch.0), json!(0), json!(0.0), json!(-20.0)) + ); + assert!((v["t"].as_f64().unwrap() - 0.5).abs() < 0.01, "{v}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]); + // A straight edge of the rectangle: its two anchors get selected and move. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); + assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.segments" && v["segments"] == json!([[0, 0]])), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); + assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0}))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0)); + // Alt on the stroke picks the whole path, and Alt-drag copies it. + let alt = Mods { alt: true, ..Default::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0).with_mods(alt)); + assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0).with_mods(alt)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0).with_mods(alt)); + // The fill still selects the whole path, and a path selected as a whole still moves whole. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); + let rect = d.layers[0].children().unwrap()[0].id; + let mut whole = Selection::default(); + whole.set([rect]); + let cx = crate::testutil::cx(&d, &whole, &p); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); +} + +/// #494: the handles shown are the handles dragged. A path selected as a whole (the one the +/// Pen just drew) shows all its handles, and they drag; the direct-selected anchors' handles +/// only otherwise; none with Show handles when multiple anchors are selected off and several +/// anchors selected. A handle shorter than the tolerance leaves its anchor to be picked, and +/// the tolerance is the preference's, in screen pixels at any zoom. +#[test] +fn the_handles_shown_are_the_handles_dragged() { + let (d, arch) = arch_doc(); + let p = paint(); + // Does a press at (x, y) take a handle? + let handle = |c: &ToolContext, x: f64, y: f64| { + DirectSelectionTool::new(false).pointer(c, &PointerEvent::new(PointerKind::Down, x, y)) == vec![Action::Begin("Reshape".into())] + }; + let mut whole = Selection::default(); + whole.set([arch]); + let c = cx(&d, &whole, &p); + assert!(handle(&c, 101.0, 251.0), "the first anchor's handle, the path selected whole"); + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 251.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 240.0)); + assert!(matches!(&a[..], [Action::Preview(c, v)] if c == "path.setHandle" && v["anchor"] == 0 && v["which"] == "out"), "{a:?}"); + // Only the second anchor direct-selected: the first one's handle is hidden. + let second = anchors_of(arch, &[(0, 1)]); + assert!(!handle(&cx(&d, &second, &p), 101.0, 251.0)); + assert!(handle(&cx(&d, &second, &p), 201.0, 251.0)); + // Show handles when multiple anchors are selected off: the whole path's are hidden. + assert!(!handle(&ToolContext { handles_multiple: false, ..cx(&d, &whole, &p) }, 101.0, 251.0)); + assert!(handle(&ToolContext { handles_multiple: false, ..cx(&d, &second, &p) }, 201.0, 251.0)); + // The tolerance is the preference's, in screen pixels: 3 px is 0.75 pt at 400%. + let zoomed = ToolContext { zoom: 4.0, ..cx(&d, &whole, &p) }; + assert!(!handle(&zoomed, 101.0, 251.0)); + assert!(handle(&zoomed, 100.5, 250.5)); + assert!(handle(&ToolContext { selection_tolerance: 8.0, ..cx(&d, &whole, &p) }, 106.0, 254.0)); +} + +/// #494: a handle 2 pt long, inside the tolerance round its anchor, doesn't hide the anchor. +#[test] +fn a_short_handle_leaves_its_anchor_to_be_picked() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); + sp.anchors[0].h_out = Point::new(102.0, 300.0); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); + let s = anchors_of(id, &[(0, 0)]); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), vec![], "the anchor, already selected"); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 290.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 290.0)); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle"); +} + +/// Direct Selection's cursors from Illustrator's manual: the white arrow at rest; over an anchor +/// a square, hollow on an unselected path and filled on a selected one; the Reshape Segment +/// cursor over a curved segment a drag reshapes, not over a straight one (which moves) or a +/// path selected whole (which moves whole); Group Selection's plus with Alt and as its tool. +#[test] +fn hovering_shows_the_anchor_square_reshape_and_group_cursors() { + let (d, arch) = arch_doc(); + let p = paint(); + let m = Mods::default(); + let pt = Point::new; + let none = Selection::default(); + let c = cx(&d, &none, &p); + let t = DirectSelectionTool::new(false); + assert_eq!(t.cursor(&c, pt(20.0, 20.0), m), Cursor::ArrowHollow); + assert_eq!(t.cursor(&c, pt(100.0, 300.0), m), Cursor::DirectAnchor); + assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ReshapeSegment); + assert_eq!(t.cursor(&c, pt(150.0, 100.0), m), Cursor::ArrowHollow, "a straight segment moves"); + assert_eq!(t.cursor(&c, pt(150.0, 150.0), m), Cursor::ArrowHollow, "the fill"); + let alt = Mods { alt: true, ..m }; + assert_eq!(t.cursor(&c, pt(100.0, 300.0), alt), Cursor::GroupSelection); + assert_eq!(DirectSelectionTool::new(true).cursor(&c, pt(100.0, 300.0), m), Cursor::GroupSelection); + let mut whole = Selection::default(); + whole.set([arch]); + let c = cx(&d, &whole, &p); + assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); + assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ArrowHollow, "a path selected whole moves whole"); + // One anchor selected: its handle end shows and keeps the plain arrow, Alt or not (a press + // takes the handle). + let s = anchors_of(arch, &[(0, 0)]); + let c = cx(&d, &s, &p); + assert_eq!(t.cursor(&c, pt(100.0, 250.0), m), Cursor::ArrowHollow, "a handle end"); + assert_eq!(t.cursor(&c, pt(100.0, 250.0), alt), Cursor::ArrowHollow, "a handle end with Alt"); + assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); + // Bending the curve keeps the Reshape Segment cursor. + let mut t = DirectSelectionTool::new(false); + let c = cx(&d, &none, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0)); + assert_eq!(t.cursor(&c, pt(150.0, 230.0), m), Cursor::ReshapeSegment); +} + +/// A drag shows the filled arrow, hollow while it snaps to a point; an Alt-drag copy shows the +/// double arrowhead. +#[test] +fn drags_show_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() { + let (d, _, b) = two_squares(); + let p = paint(); + let none = Mods::default(); + let at = |kind, x, y, m: Mods| PointerEvent::new(kind, x, y).with_mods(m); + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, none)); + t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, none)); + assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), none), Cursor::Arrow); + // Onto A's bottom-right corner. + t.pointer(&c, &at(PointerKind::Drag, 203.0, 199.0, none)); + assert_eq!(t.cursor(&c, Point::new(203.0, 199.0), none), Cursor::ArrowHollow); + t.pointer(&c, &at(PointerKind::Up, 203.0, 199.0, none)); + // Shift with a Constrain Angle of 10°: the constraint alone isn't a snap. + let shift = Mods { shift: true, ..none }; + let tilted = ToolContext { constrain_angle: 10.0, ..cx(&d, &one, &p) }; + t.pointer(&tilted, &at(PointerKind::Down, 300.0, 300.0, shift)); + t.pointer(&tilted, &at(PointerKind::Drag, 380.0, 262.0, shift)); + assert_eq!(t.cursor(&tilted, Point::new(380.0, 262.0), shift), Cursor::Arrow); + t.pointer(&tilted, &at(PointerKind::Up, 380.0, 262.0, shift)); + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let alt = Mods { alt: true, ..none }; + t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, alt)); + t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, alt)); + assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), alt), Cursor::SelectionCopy); +} + +/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B). +fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let b = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, a, b) +} + +/// An anchor of a selected path is picked before the one of an unselected path on a layer +/// above that covers it, as Highlight anchors on mouse over already marks it (#1508); with +/// nothing selected, the topmost path's anchor is picked. +#[test] +fn a_selected_path_s_anchor_is_picked_before_one_above_it() { + let (mut d, a) = doc_with_rect(); + let top = d.add_layer(None); + let b = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(101.0, 100.0, 201.0, 200.0)); + d.insert(Some(top), 0, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let pick = |id: NodeId| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; + let s = anchors_of(a, &[(0, 2)]); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(a)); + let mut whole = Selection::default(); + whole.set([a]); + let mut t = DirectSelectionTool::new(false); + let cx2 = cx(&d, &whole, &p); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 101.0, 100.0)).is_empty()); + assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 101.0, 100.0)), pick(a)); + let none = Selection::default(); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(b)); +} + +/// The anchors `anchors` of path `id` direct-selected. +fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection { + let mut s = Selection::default(); + s.set([id]); + s.anchors.insert(id, anchors.iter().copied().collect()); + s +} + +/// Press at `from`, drag to `to`: the move the drag previews, and the smart guide labels shown. +fn drag(t: &mut DirectSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Value, Vec) { + t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(mods)); + let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let labels = t + .overlays(cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Label { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![Action::Commit]); + assert!(t.overlays(cx).iter().all(|o| !matches!(o, Overlay::Label { .. })), "the guides go with the drag"); + (v.clone(), labels) +} + +/// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does: +/// onto another path's anchor, or onto an anchor of the original the copy leaves behind. +#[test] +fn alt_dragged_copies_snap_by_the_anchor_pressed() { + let (d, _, b) = two_squares(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let mut t = DirectSelectionTool::new(false); + let copy = + |dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true}); + // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. + let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt); + assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()])); + // B's top-left corner onto the original B's top-right one: the copy sits beside it. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt); + assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()])); + // The history says what the drag did. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt)); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt)); + assert_eq!(out.first(), Some(&Action::Begin("Copy".into()))); +} + +/// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released +/// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed +/// snaps all along, onto the original only while it is left behind. +#[test] +fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { + let (d, _, b) = two_squares(); + let p = paint(); + let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() }); + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let mut t = DirectSelectionTool::new(false); + let preview = |dx: f64, dy: f64, copy: bool| { + Action::Preview( + "object.transform".into(), + json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}), + ) + }; + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty()); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), + vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)] + ); + // The tool sees the document as previewed: B moved along, not where the copy leaves it. + let (mut moved, _, b2) = two_squares(); + let l = moved.layers[0].id; + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0)); + moved.remove(b2).unwrap(); + moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let c = cx(&moved, &whole, &p); + // Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one. + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]); + // Alt up: a move again, which leaves nothing behind to snap to. + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]); + // With only some anchors selected, the drag still moves just those. + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt)); + assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}"); +} + +/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors +/// of its own path, and with several selected the one pressed on snaps, the others following. +#[test] +fn dragged_anchors_snap_to_points_segments_and_smart_guides() { + let (d, _, b) = two_squares(); + let p = paint(); + let none = Mods::default(); + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + let mut t = DirectSelectionTool::new(false); + // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. + let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), none); + assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -100.0}), vec!["anchor".to_string()])); + // Onto A's right side. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 150.0), none); + assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -150.0}), vec!["path".to_string()])); + // In line with B's own bottom-left corner, which stays put. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none); + assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()])); + // Shift keeps the move at 45° steps and still snaps along that line (#886): on the + // diagonal from B's corner, onto A's bottom-right corner… + let shift = Mods { shift: true, ..none }; + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift); + let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9; + assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}"); + // …and with nothing in reach, at the pointer's distance along its angle. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift); + let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true, 0.0); + assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}"); + // Two anchors: the one pressed on (B's top-right) lands on A's corner. + let two = anchors_of(b, &[(0, 0), (0, 1)]); + let c = cx(&d, &two, &p); + let (v, _) = drag(&mut t, &c, (400.0, 300.0), (202.0, 198.0), none); + assert_eq!(v, json!({"dx": -200.0, "dy": -100.0})); + // Smart Guides off: Snap to Point pulls within its distance (2 px) only. + let c = ToolContext { smart_guides: false, ..cx(&d, &one, &p) }; + assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -100.0, "dy": -100.0})); + assert_eq!(drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), none).0, json!({"dx": -97.0, "dy": -101.0})); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &one, &p) }; + assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0})); +} + +/// #2120: the end of a slanted straight line, dragged roughly along it, stays on its direction +/// (Line Extension) and the guide runs along it from the other end. +#[test] +fn dragged_end_of_a_slanted_line_stays_on_its_direction() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let end = anchors_of(id, &[(0, 1)]); + let c = cx(&d, &end, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 380.0, 340.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let (dx, dy) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap()); + assert!((dx - 40.0).abs() < 1e-9 && (dy + 30.0).abs() < 1e-9, "{v}"); + let guides: Vec<(Point, Point)> = + t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect(); + assert!(matches!(guides[..], [(a, b)] if a == Point::new(300.0, 400.0) && (b - Point::new(420.0, 310.0)).hypot() < 1e-9), "{guides:?}"); +} + +/// Group Selection (and Direct Selection on a whole path) moves objects snapping as the +/// Selection tool does: B's left side lines up with A's right one. +#[test] +fn moved_objects_snap_with_smart_guides() { + let (d, _, b) = two_squares(); + let p = paint(); + let mut s = Selection::default(); + s.set([b]); + let c = cx(&d, &s, &p); + for group in [true, false] { + let mut t = DirectSelectionTool::new(group); + let (v, _) = drag(&mut t, &c, (350.0, 350.0), (253.0, 351.0), Mods::default()); + assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, -100.0, 1.0]), "group: {group}"); + } +} + +/// #1816: Group Selection and Direct Selection move a whole path with Snap to Grid as the +/// Selection tool does, landing an edge on the grid; the center does not snap. A 45 pt square at +/// 90–135 on the 9 pt grid, dragged right by 22 to 112–157, lands its left edge on the gridline +/// 108 and moves by 18. Its center, at 134.5 after the drag, is nearer the gridline 135 and +/// does not snap. +#[test] +fn a_move_of_objects_lands_an_edge_on_the_grid() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 90.0, 135.0, 135.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let mut s = Selection::default(); + s.set([id]); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + for group in [true, false] { + let mut t = DirectSelectionTool::new(group); + let (v, _) = drag(&mut t, &c, (110.0, 110.0), (132.0, 110.0), Mods::default()); + assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, 18.0, 0.0]), "group: {group}"); + } +} + +/// Group Selection and Direct Selection on a whole path keep a Shift-constrained move on its +/// line when it snaps, as the Selection tool does: level by 13.04 with Snap to Grid on, B's +/// right side (413.04) lands on the gridline 414, and its top stays at 300, between 297 and +/// 306. +#[test] +fn a_shift_move_of_objects_snaps_along_its_line() { + let (d, _, b) = two_squares(); + let p = paint(); + let mut s = Selection::default(); + s.set([b]); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let shift = Mods { shift: true, ..Mods::default() }; + for group in [true, false] { + let mut t = DirectSelectionTool::new(group); + let (v, _) = drag(&mut t, &c, (350.0, 350.0), (363.0, 351.0), shift); + let (x, y) = (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()); + assert!((x - 14.0).abs() < 1e-9 && y.abs() < 1e-9, "group: {group}: {v}"); + } +} + +/// Direct Selection's double-click on type where a layer's clipping mask edge runs through it +/// edits the type, though a press there takes the mask's segment. +#[test] +fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() { + let (d, _, at) = doc_with_type_on_a_layer_masks_edge(); + let (s, p) = (Selection::default(), paint()); + let acts = DirectSelectionTool::new(false).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::DoubleClick, at.x, at.y)); + assert_eq!(acts, vec![Action::SwitchTool("type".into())]); +} + +#[test] +fn double_clicking_type_switches_to_type_tool() { + use std::sync::Arc; + let (mut d, id) = doc_with_area_type(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let b = d.node(id).unwrap().geometric_bounds().unwrap(); + let mid = b.center(); + // Direct Selection (shortcut A, group == false) switches to the Type tool on double click. + let mut t = DirectSelectionTool::new(false); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, mid.x, mid.y)); + assert_eq!(acts, vec![Action::SwitchTool("type".into())]); + + // Group Selection (group == true) does not switch to Type tool. + let mut gt = DirectSelectionTool::new(true); + let acts_group = gt.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, mid.x, mid.y)); + assert!(acts_group.is_empty(), "group selection shouldn't switch: {acts_group:?}"); + + // Text inside a group also switches to Type tool with Direct Selection. + let gid = d.alloc_id(); + let text_id = d.alloc_id(); + let text_node = Node::new( + text_id, + vectorcraft_doc::NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(Point::new(50.0, 50.0), "In group", Default::default()))), + ); + d.insert(Some(d.layers[0].id), 2, Node::group(gid, vec![Arc::new(text_node)])).unwrap(); + let in_group_b = d.node(gid).unwrap().geometric_bounds().unwrap(); + let c2 = cx(&d, &s, &p); + let acts_group_text = t.pointer(&c2, &PointerEvent::new(PointerKind::DoubleClick, in_group_b.center().x, in_group_b.center().y)); + assert_eq!(acts_group_text, vec![Action::SwitchTool("type".into())]); +} + +#[test] +fn direct_selection_click_and_drag_member_of_compound_path() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let rect = |id, a: f64, b: f64| { + std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) + }; + let mut c = vectorcraft_doc::Node::new( + compound, + vectorcraft_doc::NodeKind::Compound { + children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], + rule: vectorcraft_geom::FillRule::EvenOdd, + }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 0, c).unwrap(); + let p = paint(); + let none = Selection::default(); + let mut t = DirectSelectionTool::new(false); + let ctx = cx(&d, &none, &p); + let down = t.pointer(&ctx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0)); + assert_eq!( + down, + vec![Action::Exec("select.set".into(), json!({"ids": [outer.0]}))], + "Direct Selection selects child member, not compound parent" + ); + let mut s = Selection::default(); + s.set([outer]); + let ctx_sel = cx(&d, &s, &p); + let drag = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Drag, 35.0, 110.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "object.transform")), "drags the selected child member: {drag:?}"); + let up = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Up, 35.0, 110.0)); + assert_eq!(up, vec![Action::Commit]); +} + +#[test] +fn shift_clicking_anchor_after_segment_pick_toggles_or_adds() { + let (d, id) = doc_with_rect(); + let p = paint(); + let mut s = Selection::default(); + s.set([id]); + s.segments.insert(id, [(0, 0)].into()); + s.anchors.insert(id, [(0, 0), (0, 1)].into()); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let shift = Mods { shift: true, ..Default::default() }; + // Shift-click on anchor (0, 0) (top-left at 100, 100) toggles it off + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); + // Shift-click on anchor (0, 2) (bottom-right at 200, 200) toggles it on (adds it) + let a2 = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 200.0).with_mods(shift)); + assert_eq!(a2, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"}))]); +} + +#[test] +fn shift_clicking_segment_toggles_segment() { + let (d, id) = doc_with_rect(); + let p = paint(); + let s = Selection::default(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let shift = Mods { shift: true, ..Default::default() }; + // The rectangle is at 100..200. Segment 0 is the top edge (y=100, x between 100 and 200). + // Shift-click at (150, 100) selects segment 0 with mode "toggle". + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[0, 0]], "mode": "toggle"}))]); +} + +/// In normal viewmode, clicking the fill of an open path in front selects the open path's shape +/// and anchors, and NEVER clicks through to select or grab a path behind it (even if the path +/// behind has an anchor directly under the click). +#[test] +fn clicking_fill_of_open_path_in_front_never_selects_path_behind() { + use vectorcraft_geom::{Anchor, SubPath}; + let (mut d, _behind) = doc_with_rect(); + // `behind` has an anchor at (100.0, 100.0). + // Create an open path in front (id `front`), with a fill, covering (100.0, 100.0). + let front = d.alloc_id(); + let l = d.layers[0].id; + // Triangle from (50, 50) to (150, 50) to (150, 150), open (implicit closing edge from (150, 150) to (50, 50)). + // (100.0, 100.0) is inside this filled triangle, but `front` has NO anchor at (100.0, 100.0). + let anchors = vec![Anchor::corner(Point::new(50.0, 50.0)), Anchor::corner(Point::new(150.0, 50.0)), Anchor::corner(Point::new(150.0, 150.0))]; + let open_path = PathData::single(SubPath::new(anchors, false)); + let ap = vectorcraft_doc::Appearance::default_art(); // has solid white fill + d.insert(Some(l), 1, vectorcraft_doc::Node::path(front, open_path, ap)).unwrap(); + + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + + // Hovering over (100.0, 100.0) does NOT hover the hidden anchor of `behind`. + assert_eq!(hovered_anchor(&c, Point::new(100.0, 100.0)), None); + + // Clicking at (100.0, 100.0) selects `front` (the open path in front), NOT `behind`. + let mut t = DirectSelectionTool::new(false); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [front.0]}))]); +} + +/// A selected path's Bézier direction handle positioned over a background filled rectangle +/// can be hit and dragged without being occluded by the background fill. +#[test] +fn selected_path_handle_over_background_rect_is_draggable() { + use vectorcraft_geom::{Anchor, SubPath}; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + // Background rectangle covering 0..300 with default white fill + let bg = d.alloc_id(); + let bg_rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 300.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(bg, bg_rect, vectorcraft_doc::Appearance::default_art())).unwrap(); + + // Selected path with a Bézier curve whose out-handle is at (150, 150) + let fg = d.alloc_id(); + let mut a0 = Anchor::corner(Point::new(100.0, 150.0)); + a0.h_out = Point::new(150.0, 150.0); // handle over the background rect + let a1 = Anchor::corner(Point::new(200.0, 150.0)); + let curve = PathData::single(SubPath::new(vec![a0, a1], false)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(fg, curve, vectorcraft_doc::Appearance::default_art())).unwrap(); + + let mut s = Selection::default(); + s.set([fg]); + s.anchors.insert(fg, [(0, 0)].into()); + let p = paint(); + let c = cx(&d, &s, &p); + + // hit_handle on the handle at (150, 150) must find the handle of `fg` + assert_eq!(hit_handle(&c, Point::new(150.0, 150.0), c.point_tol()), Some((fg, 0, 0, true))); + + // Direct Selection tool clicking on the handle initiates Reshape + let mut t = DirectSelectionTool::new(false); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(acts, vec![Action::Begin("Reshape".into())]); +} From e8a069ebc2c6364d438a9a1c78baaf7e42acdf15 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:29 +0000 Subject: [PATCH 021/199] refactor(engine): move cmd/threads.rs tests to cmd/threads/tests.rs --- crates/engine/src/cmd/threads.rs | 1270 +----------------------- crates/engine/src/cmd/threads/tests.rs | 1267 +++++++++++++++++++++++ 2 files changed, 1268 insertions(+), 1269 deletions(-) create mode 100644 crates/engine/src/cmd/threads/tests.rs diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 420856962..4382ae0b2 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -708,1272 +708,4 @@ fn break_thread(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - const TEXT: &str = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. "; - - fn setup() -> (Session, u64, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": {"width": 150, "height": 60}})).unwrap()["id"] - .as_u64() - .unwrap(); - let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - (s, a, b) - } - - fn text(s: &Session, id: u64) -> String { - match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("not text"), - } - } - - #[test] - fn thread_flows_reflows_on_edit_and_survives_delete() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - let (ta, tb) = (text(&s, a), text(&s, b)); - assert!(!ta.is_empty() && !tb.is_empty()); - assert_eq!(format!("{ta}{tb}"), TEXT.repeat(4)); - // Editing the first frame re-flows into the second. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); - assert_ne!(text(&s, b), tb, "the overflow moved on"); - // Deleting the first frame moves all its text into the second. - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("edit.clear", &json!({})).unwrap(); - assert_eq!(text(&s, b), format!("NEW {}", TEXT.repeat(4))); - assert!(s.doc().unwrap().doc.text_threads.is_empty(), "a one-frame thread is no thread"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.text_threads.len(), 1); - } - - #[test] - fn edits_copy_only_the_stories_of_threads_whose_frames_changed() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - let c = s.execute("text.create", &json!({"x": 10, "y": 200, "text": TEXT.repeat(2), "area": {"width": 150, "height": 40}})).unwrap()["id"] - .as_u64() - .unwrap(); - let d = s.execute("shape.rectangle", &json!({"x": 200, "y": 220, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [c, d]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - let texts = |s: &Session| [a, b, c, d].map(|id| text(s, id)); - let copied = || COPIED.with(std::cell::Cell::get); - let (shown, start) = (texts(&s), copied()); - // A rectangle drawn, moved and dragged beside the threads changes none of their frames. - let r = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); - s.begin_interaction("Move").unwrap(); - for dx in 1..=3 { - s.preview("object.move", &json!({"dx": dx, "dy": 0})).unwrap(); - } - s.commit_interaction().unwrap(); - assert_eq!(copied(), start, "edits beside the threads copied their stories"); - assert_eq!(texts(&s), shown); - // Typing in the first thread copies and re-flows that thread's story only. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); - assert_eq!(copied(), start + 1); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); - assert_eq!(texts(&s)[2..], shown[2..]); - let ids = |t: [u64; 2]| t.map(NodeId).to_vec(); - assert_eq!(s.doc().unwrap().doc.text_threads, [ids([a, b]), ids([c, d])]); - } - - /// Time edits of a story of 50,000 characters threaded through 8 frames, each frame wrapping - /// around an ellipse, with one style, a style change every 10 words and a style change at every - /// word: 21 characters typed into the first frame, then 200 moves of a rectangle below the - /// frames and 200 frames of a drag of it. - /// Run with `cargo test --release -p vectorcraft-engine --lib threaded_story_edit_timing -- --ignored --nocapture`. - #[test] - #[ignore = "manual timing probe; wall-clock performance depends on machine load"] - fn threaded_story_edit_timing() { - fn median(s: &mut Session, n: u32, f: &dyn Fn(&mut Session, f64)) -> f64 { - let mut ms: Vec = (1..=n) - .map(|i| { - let start = std::time::Instant::now(); - f(s, f64::from(i)); - start.elapsed().as_secs_f64() * 1000.0 - }) - .collect(); - ms.sort_by(f64::total_cmp); - ms[ms.len() / 2] - } - let story = format!("{}\n", TEXT.repeat(6).trim_end()).repeat(81)[..50_000].to_string(); - for words in [0, 10, 1] { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 4400, "height": 1200})).unwrap(); - let area = json!({"width": 500, "height": 900}); - let first = s.execute("text.create", &json!({"x": 20, "y": 20, "text": story, "size": 9, "area": area})).unwrap()["id"].as_u64().unwrap(); - if words > 0 { - s.edit("Restyle", |d, _| { - let Some(NodeKind::Text(t)) = d.node_mut(NodeId(first)).map(|n| &mut n.kind) else { panic!("not text") }; - let style = t.runs[0].style.clone(); - let pieces: Vec<&str> = story.split_inclusive([' ', '\n']).collect(); - t.runs = pieces - .chunks(words) - .enumerate() - .map(|(i, c)| { - let mut run = TextRun::new(c.concat(), style.clone()); - run.style.underline = i % 2 == 1; - run - }) - .collect(); - refresh_bounds(t); - Ok(()) - }) - .unwrap(); - } - let mut ids = vec![first]; - for k in 1..8 { - let frame = json!({"x": 20 + k * 540, "y": 20, "width": 500, "height": 900}); - ids.push(s.execute("shape.rectangle", &frame).unwrap()["id"].as_u64().unwrap()); - } - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - for k in 0..8 { - let e = s.execute("shape.ellipse", &json!({"x": 195 + k * 540, "y": 395, "width": 150, "height": 150})).unwrap()["id"].clone(); - s.execute("object.textWrap.make", &json!({"ids": [e]})).unwrap(); - } - let r = s.execute("shape.rectangle", &json!({"x": 20, "y": 1100, "width": 30, "height": 30})).unwrap()["id"].clone(); - s.execute("select.set", &json!({"ids": [r]})).unwrap(); - let typing = median(&mut s, 21, &|s, _| { - s.execute("text.editRange", &json!({"id": first, "start": 0, "end": 0, "insert": "x"})).unwrap(); - }); - let edit = median(&mut s, 200, &|s, _| { - s.execute("object.move", &json!({"dx": 0.5, "dy": 0})).unwrap(); - }); - s.begin_interaction("Move").unwrap(); - let frame = median(&mut s, 200, &|s, i| { - s.preview("object.move", &json!({"dx": i * 0.25, "dy": 0})).unwrap(); - }); - s.commit_interaction().unwrap(); - let d = &s.doc().unwrap().doc; - let runs: usize = ids.iter().filter_map(|id| text_of(d, NodeId(*id))).map(|t| t.runs.len()).sum(); - println!("{runs} runs in 8 frames: typing {typing:.1} ms, a move below them {edit:.3} ms, a drag frame {frame:.3} ms (medians)"); - } - } - - fn new_id(v: Value) -> u64 { - v["id"].as_u64().unwrap() - } - - fn threads(s: &Session) -> Vec> { - s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect() - } - - /// Bounds of area type `id`'s frame on the page. - fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; - let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; - t.xf.transform_rect_bbox(frame.bounds().unwrap()) - } - - /// #2572: the text loaded from an out port threads into a closed path, into a new frame of the - /// first one's shape and size at a point, and into a new rectangle, each in one undo step. - #[test] - fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() { - let (mut s, a, b) = setup(); - assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b); - assert_eq!(threads(&s), [[a, b]]); - assert!(!text(&s, b).is_empty(), "the overflow flowed into the path"); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); - assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back"); - - let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap()); - assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click"); - let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap()); - assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0)); - assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from"); - assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4)); - assert!(!text(&s, d).is_empty()); - // A frame threaded from the middle of a thread goes in after it. - let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap()); - assert_eq!(threads(&s), [[a, e, c, d]]); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none()); - assert_eq!(threads(&s), [[a, c, d]]); - } - - /// #2572: the text loaded from an in port threads back: the new frame or the path clicked comes - /// before the frame it was loaded from, so the story starts there and flows on into it; in the - /// middle of a thread the new frame goes in between, each in one undo step. - #[test] - fn add_before_threads_the_story_from_the_new_frame_into_the_clicked_one() { - let (mut s, a, b) = setup(); - let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200], "before": true})).unwrap()); - assert_eq!(threads(&s), [[c, a]]); - assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the clicked frame's size at the click"); - assert!(TEXT.repeat(4).starts_with(&text(&s, c)) && !text(&s, c).is_empty(), "the story starts in the new frame: {:?}", text(&s, c)); - assert_eq!(format!("{}{}", text(&s, c), text(&s, a)), TEXT.repeat(4)); - assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(c)]); - // In between the frame and the one before it. - let d = new_id(s.execute("text.thread.add", &json!({"from": a, "rect": [10, 100, 300, 150], "before": true})).unwrap()); - assert_eq!(threads(&s), [[c, d, a]]); - assert_eq!(format!("{}{}{}", text(&s, c), text(&s, d), text(&s, a)), TEXT.repeat(4)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [[c, a]]); - // A closed path clicked with the in port's text comes first, with the start of the story. - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b, "before": true})).unwrap()), b); - assert_eq!(threads(&s), [[b, a]]); - assert!(TEXT.repeat(4).starts_with(&text(&s, b)) && !text(&s, b).is_empty()); - assert_eq!(format!("{}{}", text(&s, b), text(&s, a)), TEXT.repeat(4)); - // `before: false` is the out port's. - let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0], "before": false})).unwrap()); - assert_eq!(threads(&s), [[b, a, e]]); - } - - /// Type on a path along a line beside the area type of [`setup`]. - fn type_on_a_line(s: &mut Session) -> u64 { - let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap()); - new_id(s.execute("text.createInPath", &json!({"path": line, "mode": "onPath", "text": ""})).unwrap()) - } - - /// Type on a path threads as area type does, in any mix: a story flows from area type along a - /// path and on into area type, and a new frame threaded from type on a path at a point is type - /// on a path of the same shape. - #[test] - fn type_on_a_path_threads_with_area_type() { - let (mut s, a, b) = setup(); - let on_path = type_on_a_line(&mut s); - s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); - s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap(); - assert_eq!(threads(&s), [[a, on_path, b]]); - let shares = [a, on_path, b].map(|id| text(&s, id)); - assert!(shares.iter().all(|t| !t.is_empty()), "each frame takes a share: {shares:?}"); - assert_eq!(shares.concat(), TEXT.repeat(4)); - - let c = new_id(s.execute("text.thread.add", &json!({"from": on_path, "at": [300, 250]})).unwrap()); - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind else { panic!("not text") }; - assert!(matches!(t.kind, TextKind::OnPath { .. }), "type on a path, as the frame it was threaded from"); - assert_eq!(threads(&s), [[a, on_path, c, b]]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [[a, on_path, b]]); - } - - /// Type on a path released from its thread gives its text back to the frame before it. - #[test] - fn type_on_a_path_is_released_from_its_thread() { - let (mut s, a, _) = setup(); - let on_path = type_on_a_line(&mut s); - s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); - assert!(!text(&s, on_path).is_empty()); - s.execute("select.set", &json!({"ids": [on_path]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert!(text(&s, on_path).is_empty()); - assert_eq!(text(&s, a), TEXT.repeat(4)); - } - - /// Area type threaded into from an out port takes the overflow at once, before its own text. - #[test] - fn add_into_area_type_flows_the_story_into_it() { - let (mut s, a, _) = setup(); - let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap()); - s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); - assert_eq!(threads(&s), [[a, c]]); - assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4))); - assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c)); - } - - #[test] - fn add_refuses_bad_params_and_leaves_the_document_alone() { - let (mut s, a, b) = setup(); - let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap()); - let before = s.doc().unwrap().doc.clone(); - for p in [ - json!({"to": b}), - json!({"from": b, "at": [0, 0]}), - json!({"from": 9999, "at": [0, 0]}), - json!({"from": -1, "at": [0, 0]}), - json!({"from": a}), - json!({"from": a, "to": b, "at": [0, 0]}), - json!({"from": a, "to": a}), - json!({"from": a, "to": line}), - json!({"from": a, "to": 9999}), - json!({"from": a, "at": [0]}), - json!({"from": a, "at": ["1", 2]}), - json!({"from": a, "rect": [0, 0, -10, 10]}), - json!({"from": a, "rect": [0, 0, 10, 0]}), - json!({"from": a, "rect": [0, 0, 10]}), - json!({"from": a, "rect": "big"}), - json!({"from": a, "at": [0, 0], "before": "yes"}), - json!({"from": a, "at": [0, 0], "before": 1}), - ] { - assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted"); - assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document"); - } - s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); - let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap()); - assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded"); - } - - /// A frame released from a thread that keeps two or more frames gives its text back to them: - /// the story flows through them once. - #[test] - fn release_from_a_longer_thread_keeps_the_story_once() { - let (mut s, a, b) = setup(); - let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); - s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); - s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); - assert_eq!(threads(&s), [[a, c, b]]); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert_eq!(threads(&s), [[a, b]]); - assert!(text(&s, c).is_empty()); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); - } - - /// A thread of `n` frames (2 or 3), each showing part of the story: area type `a` of [`setup`], - /// for 3 an area type frame that is empty until it is threaded, then the rectangle `b`. - fn thread_of(n: usize) -> (Session, Vec) { - let (mut s, a, b) = setup(); - let frames = if n == 3 { - let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); - s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); - s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); - vec![a, c, b] - } else { - s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); - vec![a, b] - }; - assert_eq!(threads(&s), std::slice::from_ref(&frames)); - assert!(frames.iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); - (s, frames) - } - - /// Convert To Point Type on a frame of threaded text takes it out of its thread with the text - /// it holds, and the other frames keep the text they hold: the story holds each character once. - /// A thread left with one frame is no thread. Undo threads the frame again. - #[test] - fn a_frame_converted_to_point_type_leaves_its_thread_with_its_text() { - // Soft wraps of the point type become line breaks, so its characters are compared without - // white space. - let squeeze = |t: &str| t.split_whitespace().collect::(); - for n in [2, 3] { - for k in 0..n { - let (mut s, frames) = thread_of(n); - let shown: Vec = frames.iter().map(|id| text(&s, *id)).collect(); - let id = frames[k]; - s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("not text") }; - assert!(matches!(t.kind, TextKind::Point), "{n} frames, frame {k}: point type"); - assert_eq!(squeeze(&text(&s, id)), squeeze(&shown[k]), "{n} frames, frame {k}: the point type keeps its text"); - for (f, before) in frames.iter().zip(&shown).filter(|(f, _)| **f != id) { - assert_eq!(text(&s, *f), *before, "{n} frames, frame {k}: frame {f} keeps its text"); - } - let rest: Vec = frames.iter().copied().filter(|f| *f != id).collect(); - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{n} frames, frame {k}"); - let all: String = frames.iter().map(|f| text(&s, *f)).collect(); - assert_eq!(squeeze(&all), squeeze(&TEXT.repeat(4)), "{n} frames, frame {k}: each character once"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), std::slice::from_ref(&frames)); - assert_eq!(frames.iter().map(|f| text(&s, *f)).collect::>(), shown, "{n} frames, frame {k}: undo"); - } - } - } - - #[test] - fn release_and_remove() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - let tb = text(&s, b); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("text.thread.remove", &json!({})).unwrap(); - assert_eq!(text(&s, b), tb, "remove keeps what each frame shows"); - assert!(s.doc().unwrap().doc.text_threads.is_empty()); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert_eq!(text(&s, b), ""); - assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame"); - } - - /// `story` threaded through three area type frames, of 150 by 60, 40 and `last` points, each - /// showing part of it. - fn three_frames(story: &str, last: f64) -> (Session, [u64; 3]) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let frame = |s: &mut Session, x: f64, y: f64, h: f64, text: &str| { - new_id(s.execute("text.create", &json!({"x": x, "y": y, "text": text, "area": {"width": 150, "height": h}})).unwrap()) - }; - let a = frame(&mut s, 10.0, 10.0, 60.0, story); - let b = frame(&mut s, 10.0, 100.0, 40.0, ""); - let c = frame(&mut s, 200.0, 10.0, last, ""); - s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); - s.execute("text.thread.add", &json!({"from": b, "to": c})).unwrap(); - assert_eq!(threads(&s), [[a, b, c]]); - assert!([a, b, c].iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); - (s, [a, b, c]) - } - - fn continues(s: &Session, id: u64) -> bool { - text_of(&s.doc().unwrap().doc, NodeId(id)).is_some_and(|t| t.first_para_continues) - } - - /// A double-click on a threaded port breaks the thread there: the story flows into the frames - /// before the break and the frames after it are left empty, still threaded with each other - /// when two or more. One undo step restores the thread and its text. - #[test] - fn break_splits_the_thread_and_the_story_flows_into_the_first_part() { - let story = TEXT.repeat(12); - let (mut s, [a, b, c]) = three_frames(&story, 40.0); - let d = new_id(s.execute("text.create", &json!({"x": 200, "y": 100, "text": "", "area": {"width": 150, "height": 100}})).unwrap()); - s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap(); - let all = [a, b, c, d]; - let shown: Vec = all.iter().map(|id| text(&s, *id)).collect(); - assert!(shown.iter().all(|t| !t.is_empty()), "each frame shows part of the story"); - let joined = |s: &Session, ids: &[u64]| ids.iter().map(|id| text(s, *id)).collect::(); - // (frame, port, threads after, the frames that hold the story, the frames left empty) - let cases = [ - (a, "out", vec![vec![b, c, d]], vec![a], vec![b, c, d]), - (b, "in", vec![vec![b, c, d]], vec![a], vec![b, c, d]), - (b, "out", vec![vec![a, b], vec![c, d]], vec![a, b], vec![c, d]), - (d, "in", vec![vec![a, b, c]], vec![a, b, c], vec![d]), - (c, "out", vec![vec![a, b, c]], vec![a, b, c], vec![d]), - ]; - for (id, port, after, full, empty) in cases { - let case = format!("{port} port of {id}"); - s.execute("text.thread.break", &json!({"id": id, "port": port})).unwrap(); - assert_eq!(threads(&s), after, "{case}"); - assert_eq!(joined(&s, &full), story, "{case}: the story flows into the first part"); - assert!(empty.iter().all(|id| text(&s, *id).is_empty()), "{case}: the frames after the break are empty"); - assert!(!continues(&s, empty[0]), "{case}: the first frame after the break starts a paragraph"); - assert!(!continues(&s, a), "{case}: the first frame starts the story"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [all.to_vec()], "{case}: undo"); - assert_eq!(all.iter().map(|id| text(&s, *id)).collect::>(), shown, "{case}: undo"); - } - } - - #[test] - fn break_refuses_a_port_with_no_thread_and_bad_params() { - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); - let lone = new_id(s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Alone.", "area": {"width": 100, "height": 100}})).unwrap()); - let ids = [a, b, c, lone]; - let shown: Vec = ids.iter().map(|id| text(&s, *id)).collect(); - let before = threads(&s); - for params in [ - json!({"id": a, "port": "in"}), - json!({"id": c, "port": "out"}), - json!({"id": lone, "port": "out"}), - json!({"id": lone, "port": "in"}), - json!({"id": a, "port": "up"}), - json!({"id": a}), - json!({"port": "out"}), - json!({"id": 9999, "port": "out"}), - ] { - assert!(s.execute("text.thread.break", ¶ms).is_err(), "{params}"); - } - assert_eq!(threads(&s), before); - assert_eq!(ids.iter().map(|id| text(&s, *id)).collect::>(), shown); - } - - /// Whether the document holds area type or type on a path `id`. - fn is_frame(s: &Session, id: u64) -> bool { - text_of(&s.doc().unwrap().doc, NodeId(id)).is_some() - } - - /// Create Outlines and Object › Expand on frames of threaded text take them out of their - /// thread with the text they show, which their outlines draw, and the other frames keep the - /// text they hold: each character is in the document once. A thread left with one frame is no - /// thread. Undo threads the frames again. - #[test] - fn outlined_frames_leave_their_thread_with_their_text() { - for command in ["type.createOutlines", "object.expand"] { - for out in [&[0][..], &[1], &[2], &[0, 2]] { - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4)); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute(command, &json!({})).unwrap(); - let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); - for id in &ids { - assert!(!is_frame(&s, *id), "{command} {out:?}: frame {id} is outlined"); - } - let st = s.doc().unwrap(); - let groups = st.selection.objects.iter().filter(|g| matches!(st.doc.node(**g).map(|n| &n.kind), Some(NodeKind::Group { .. }))); - assert_eq!(groups.count(), ids.len(), "{command} {out:?}: the outlines are selected"); - for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { - assert_eq!(text(&s, *id), *before, "{command} {out:?}: frame {id} keeps its text"); - } - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{command} {out:?}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{command} {out:?}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{command} {out:?}: undo"); - } - } - } - - /// Expand Appearance outlines a frame with an effect, and the frame leaves its thread with the - /// text it shows; Object › Expand of such a frame expands its appearance first. - #[test] - fn a_frame_whose_appearance_expands_leaves_its_thread_with_its_text() { - for command in ["effect.expandAppearance", "object.expand"] { - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); - s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [b]})).unwrap(); - let shown = [a, b, c].map(|id| text(&s, id)); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute(command, &json!({})).unwrap(); - assert!(!is_frame(&s, b), "{command}: the frame is outlined"); - assert_eq!((text(&s, a), text(&s, c)), (shown[0].clone(), shown[2].clone()), "{command}: the other frames keep their text"); - assert_eq!(threads(&s), [[a, c]], "{command}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [[a, b, c]], "{command}: undo"); - assert_eq!([a, b, c].map(|id| text(&s, id)), shown, "{command}: undo"); - } - } - - /// Object › Flatten Transparency outlines frames of threaded text with Convert All Text to - /// Outlines, and turns transparent frames into flat regions or an image: each such frame leaves - /// its thread with the text it shows, and the other frames keep the text they hold. Undo threads - /// the frames again. When the last frame is flattened, the frame before it takes the text it has - /// no room for. - #[test] - fn flattened_frames_leave_their_thread_with_their_text() { - let cases = [(json!({"textToOutlines": true}), false), (json!({}), true), (json!({"balance": 0}), true)]; - for (options, transparent) in cases { - for out in [&[0][..], &[1], &[2], &[0, 2]] { - let case = format!("{options} {out:?}"); - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - if transparent { - s.execute("transparency.set", &json!({"ids": ids, "opacity": 50})).unwrap(); - } - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4)); - let mut p = options.clone(); - p["ids"] = json!(ids); - s.execute("object.flattenTransparency", &p).unwrap(); - let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); - for id in &ids { - assert!(!is_frame(&s, *id), "{case}: frame {id} is flattened"); - } - for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { - assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); - } - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{case}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); - } - } - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - s.execute("object.flattenTransparency", &json!({"ids": [c], "textToOutlines": true})).unwrap(); - assert_eq!(text(&s, a), shown[0]); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); - assert_eq!(threads(&s), [[a, b]]); - } - - /// Expand Appearance keeps type whose only effect is a shadow or Crop Marks as type under a new - /// id, in a group under the frame's id: the type takes the frame's place in its thread, and - /// each frame keeps its text, the last frame the text it has no room for too. Type in a group - /// with Crop Marks keeps its id and stays in its thread. - #[test] - fn type_that_expand_appearance_keeps_takes_its_frames_place() { - for (effect, grouped) in [("stylize.dropShadow", false), ("cropMarks", false), ("cropMarks", true)] { - for k in [1, 2] { - let case = format!("{effect} grouped {grouped} frame {k}"); - let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); - let id = frames[k]; - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let target = if grouped { new_id(s.execute("object.group", &json!({})).unwrap()) } else { id }; - s.execute("effect.apply", &json!({"effect": effect, "item": null, "ids": [target]})).unwrap(); - let shown = frames.map(|id| text(&s, id)); - s.execute("select.set", &json!({"ids": [target]})).unwrap(); - s.execute("effect.expandAppearance", &json!({})).unwrap(); - let mut types = vec![]; - if let Some(n) = s.doc().unwrap().doc.node(NodeId(target)) { - n.walk(&mut |m| { - if matches!(m.kind, NodeKind::Text(_)) { - types.push(m.id.0); - } - }); - } - let [kept] = types[..] else { panic!("{case}: the type is kept: {types:?}") }; - assert_eq!(kept == id, grouped, "{case}: the type has a new id unless it was in a group"); - let mut now = frames; - now[k] = kept; - assert_eq!(threads(&s), [now], "{case}: the type takes the frame's place"); - assert_eq!(now.map(|id| text(&s, id)), shown, "{case}: each frame keeps its text"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{case}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); - } - } - } - - /// The text that does not fit a thread's last frame, which its outlines do not draw, stays in - /// the story: when the last frame is outlined, the frame before it takes that text after its - /// own. - #[test] - fn outlining_the_last_frame_keeps_the_text_it_has_no_room_for() { - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("type.createOutlines", &json!({})).unwrap(); - assert_eq!(text(&s, a), shown[0]); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); - assert_eq!(threads(&s), [[a, b]]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!([a, b, c].map(|id| text(&s, id)), shown); - } - - /// Object › Rasterize on frames of threaded text takes them out of their thread with the text - /// they show, which the image draws, and the other frames keep the text they hold. Undo threads - /// the frames again. When the last frame is rasterized, the frame before it takes the text it - /// has no room for. - #[test] - fn rasterized_frames_leave_their_thread_with_their_text() { - for clip in [false, true] { - for out in [&[0][..], &[1], &[2], &[0, 2]] { - let case = format!("clippingMask {clip} {out:?}"); - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4)); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap(); - let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); - for id in &ids { - assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized"); - } - for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { - assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); - } - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{case}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); - } - } - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("object.rasterize", &json!({"ppi": 36})).unwrap(); - assert_eq!(text(&s, a), shown[0]); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); - assert_eq!(threads(&s), [[a, b]]); - } - - /// Frame `id`'s first line: whether its first paragraph continues from the frame before, where - /// its text starts from the frame's left edge, and how many list markers it starts with. - fn first_line(s: &Session, id: u64) -> (bool, f64, usize) { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; - let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let l = &lay.lines[0]; - let x = lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x); - let markers = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count(); - (t.first_para_continues, (x - frame.bounds().unwrap().x0 - 1e-9).round(), markers) - } - - /// A paragraph's first-line indent and bullet belong to its first line, in the frame it starts - /// in: the frame it continues into starts at its left indent, without the bullet. Re-flowing, - /// saving and opening keep it. In a frame that is no longer threaded and in a copy of a frame, - /// the first paragraph has its first-line indent and bullet. - #[test] - fn a_paragraph_continued_into_the_next_frame_starts_it_without_its_first_line_indent_or_bullet() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "bullet"})).unwrap(); - assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 18.0, 1), (true, 18.0, 0))); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "none", "leftIndent": 0, "firstLineIndent": 24})).unwrap(); - assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0))); - assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); - // Typing re-flows the story and keeps it. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); - assert_eq!(first_line(&s, b), (true, 0.0, 0)); - // Saved and opened: a native file re-flows its threads, a restored document keeps its text. - for name in ["thread.vectorcraft", "thread.eps"] { - let bytes = if name.ends_with(".eps") { - let r = s.execute("document.exportEps", &json!({})).unwrap(); - vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() - } else { - vectorcraft_format::save(&s.doc().unwrap().doc, false) - }; - let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); - assert_eq!(threads(&s), [[a, b]], "{name}"); - assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0)), "{name}"); - } - // A copy of the second frame is in no thread, and starts with the indent. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); - assert_eq!((first_line(&s, b), first_line(&s, copy)), ((true, 0.0, 0), (false, 24.0, 0))); - // …and so is a pasted one, and each copy's cached bounds and baselines are its own layout's. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let pasted = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); - let dragged = s.execute("layer.move", &json!({"ids": [b], "target": a, "place": "above", "copy": true})).unwrap()["ids"][0].as_u64().unwrap(); - assert_eq!((first_line(&s, pasted), first_line(&s, dragged)), ((false, 24.0, 0), (false, 24.0, 0))); - // Every text object in the document has the cached bounds and baselines of its own layout. - let own_caches = |s: &Session, what: &str| { - let mut texts = vec![]; - s.doc().unwrap().doc.walk(|n| { - if let NodeKind::Text(t) = &n.kind { - texts.push((n.id, t)); - } - }); - for (id, t) in texts { - let mut fresh = (**t).clone(); - refresh_bounds(&mut fresh); - assert!(t.cached_bounds.is_some() && t.cached_bounds == fresh.cached_bounds, "{what}: {id:?}"); - assert_eq!(t.cached_baselines, fresh.cached_baselines, "{what}: {id:?}"); - } - }; - own_caches(&s, "copies"); - // Release to Layers (Build), Release Opacity Mask, Edit Opacity Mask and Draw Inside copy the - // frame too. - let layer = s.doc().unwrap().doc.layer_containing(NodeId(b)).unwrap().0; - s.execute("layer.releaseToLayers", &json!({"id": layer, "build": true})).unwrap(); - own_caches(&s, "Release to Layers (Build)"); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("transparency.makeOpacityMask", &json!({"ids": [a, b]})).unwrap(); - s.execute("transparency.releaseOpacityMask", &json!({})).unwrap(); - own_caches(&s, "Release Opacity Mask"); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("transparency.editOpacityMask", &json!({"id": a})).unwrap(); - own_caches(&s, "Edit Opacity Mask"); - s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); - for _ in 0..3 { - s.execute("edit.undo", &json!({})).unwrap(); - } - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); - s.execute("shape.rectangle", &json!({"x": 210, "y": 20, "width": 20, "height": 20})).unwrap(); - own_caches(&s, "Draw Inside"); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap(); - assert_eq!((threads(&s), first_line(&s, b)), (vec![vec![a, b]], (true, 0.0, 0))); - // Remove Threading: each frame keeps its text, and its first paragraph starts in it. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("text.thread.remove", &json!({})).unwrap(); - assert_eq!(first_line(&s, b), (false, 24.0, 0)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(first_line(&s, b), (true, 0.0, 0)); - // Released, the frame is empty and starts the next text typed into it as a paragraph. - let continues = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.first_para_continues, - _ => panic!("not text"), - }; - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(text(&s, b).is_empty() && !continues(&s, b)); - s.execute("edit.undo", &json!({})).unwrap(); - // With the first frame released, the second one holds the whole story and starts it. - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(threads(&s).is_empty() && text(&s, a).is_empty()); - assert_eq!(first_line(&s, b), (false, 24.0, 0)); - s.execute("edit.undo", &json!({})).unwrap(); - // Converted to point type, it is in no thread, and its first line starts at the indent where - // the frame's left edge was. - let left = frame_box(&s, b).x0; - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("type.convertToPointType", &json!({})).unwrap(); - assert!(!continues(&s, b)); - { - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind else { panic!("not text") }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let g = lay.glyphs.iter().find(|g| g.len > 0).unwrap(); - let x = (t.xf * g.origin).x - left; - assert!((x - 24.0).abs() < 1e-6, "{x}"); - } - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(first_line(&s, b), (true, 0.0, 0)); - // Text that ends the first frame with a paragraph break: the second frame starts a - // paragraph, with its indent. - let all = text(&s, a).len(); - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": all, "insert": "Short line.\n".repeat(12)})).unwrap(); - assert!(text(&s, a).ends_with('\n'), "{:?}", text(&s, a)); - assert_eq!(first_line(&s, b), (false, 24.0, 0)); - } - - /// When Create Outlines takes frames out of a thread, a frame that now starts its thread or is - /// in no thread starts its paragraph with the first-line indent; a frame after one that ends - /// inside the paragraph still continues it. The frames that stay hold each character they held - /// once, and keep their text when no mark changes. Undo marks the frames again. - #[test] - fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() { - for (out, want) in [ - (&[0][..], [None, Some(false), Some(true)]), - (&[0, 2], [None, Some(false), None]), - (&[1], [Some(false), None, Some(true)]), - (&[2], [Some(false), Some(true), None]), - ] { - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - s.execute("text.setFormat", &json!({"ids": frames, "leftIndent": 0, "firstLineIndent": 24})).unwrap(); - let before = frames.map(|id| first_line(&s, id)); - assert_eq!(before, [(false, 24.0, 0), (true, 0.0, 0), (true, 0.0, 0)], "{out:?}"); - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4), "{out:?}: the last frame has room for the rest"); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute("type.createOutlines", &json!({})).unwrap(); - let stay: Vec = (0..3).filter(|k| want[*k].is_some()).collect(); - for k in &stay { - let on = want[*k] == Some(true); - let indent = if on { 0.0 } else { 24.0 }; - assert_eq!(first_line(&s, frames[*k]), (on, indent, 0), "{out:?}: frame {k}"); - } - let held: String = stay.iter().map(|k| shown[*k].as_str()).collect(); - assert_eq!(stay.iter().map(|k| text(&s, frames[*k])).collect::(), held, "{out:?}: each character once"); - if stay.iter().all(|k| want[*k] == Some(before[*k].0)) { - for k in &stay { - assert_eq!(text(&s, frames[*k]), shown[*k], "{out:?}: frame {k} keeps its text"); - } - } - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(frames.map(|id| first_line(&s, id)), before, "{out:?}: undo"); - } - } - - /// The text that a thread's last frame has no room for is measured as the frame lays it out, - /// without the first-line indent of the paragraph it continues: when the frame is outlined, the - /// frame before it takes only that text. - #[test] - fn outlining_a_continued_last_frame_keeps_only_the_text_it_has_no_room_for() { - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - s.execute("text.setFormat", &json!({"ids": [a, b, c], "leftIndent": 0, "firstLineIndent": 72})).unwrap(); - assert_eq!(first_line(&s, c), (true, 0.0, 0)); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("type.createOutlines", &json!({})).unwrap(); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1])); - } - - /// The text that a thread's last frame has no room for is measured with the frame's numbered - /// paragraphs counted on from the frames before it: when the frame is outlined, the frame before - /// it takes only that text. - #[test] - fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); - let story = format!("{items}{}", TEXT.repeat(4)); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); - let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); - s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); - // A hanging indent that "I." fits and "VIII." does not: the text after "VIII." starts at - // the next tab stop. - s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "leftIndent": 14, "firstLineIndent": -14})).unwrap(); - let shown = [a, c].map(|id| text(&s, id)); - assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}"); - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; - assert_eq!(t.list_count, 7); - let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII.")); - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_count: 0, ..t }); - assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text"); - let hidden = shown[1][end..].trim_start_matches(' '); - s.execute("select.set", &json!({"ids": [c]})).unwrap(); - s.execute("type.createOutlines", &json!({})).unwrap(); - assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); - } - - /// A story of one-line paragraphs that each start with a quotation mark, threaded from area - /// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. - fn quoted_thread() -> (Session, u64, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let story = "“Quoted.”\n".repeat(20); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - assert!(text(&s, b).starts_with('“')); - (s, a, b) - } - - /// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame - /// (its lines' opening quotation marks hang). - fn optical(s: &Session, id: u64) -> (bool, bool) { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; - let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; - (t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0) - } - - /// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment - /// in any frame sets it in every frame of the thread, in one undo step, and the lines in every - /// frame hang. Typing keeps it, and frames taken out of the thread keep it. - #[test] - fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() { - let (mut s, a, b) = quoted_thread(); - assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap(); - assert_eq!(r, json!({"on": true, "ids": [a, b]})); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too"); - // Turned off from the second frame, it is off in both, and one undo turns it back on in both. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); - assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); - // Typing in the first frame re-flows the story and keeps the setting. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap(); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); - // Any edit that sets one frame sets the thread. - s.edit("Set", |d, _| { - let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") }; - t.optical_margin = false; - refresh_bounds(t); - Ok(()) - }) - .unwrap(); - assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); - s.execute("edit.undo", &json!({})).unwrap(); - // Released or unthreaded, each frame keeps it. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("text.thread.remove", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); - } - - /// Threading type into a thread gives the joined thread the setting of its first frame. - #[test] - fn a_thread_takes_its_first_frames_optical_margin_alignment() { - let (mut s, a, b) = quoted_thread(); - s.execute("text.thread.remove", &json!({})).unwrap(); - s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap(); - assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false)); - // Threaded again in stacking order: the second frame takes the first one's setting. - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false)); - // From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting. - s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap(); - assert_eq!(threads(&s), [[b, a]]); - assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false)); - // A new frame threaded from a frame takes the thread's setting. - s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap(); - let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap()); - assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]); - } - - /// A file saved with the frames of one thread set differently opens with the first frame's - /// setting in every frame: a native file, and an EPS file restored from the document it carries. - #[test] - fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() { - let (mut s, a, b) = quoted_thread(); - for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] { - for (id, on) in [(a, first), (b, !first)] { - let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); - let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; - t.optical_margin = on; - } - let bytes = if name.ends_with(".eps") { - let r = s.execute("document.exportEps", &json!({})).unwrap(); - vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() - } else { - vectorcraft_format::save(&s.doc().unwrap().doc, false) - }; - let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); - assert_eq!(threads(&s), [[a, b]], "{name}"); - assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}"); - } - } - - /// One count runs through a thread: the second frame's numbered paragraphs go on from the - /// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a - /// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks - /// and the frame a Release Selection leaves alone count from 1. - #[test] - fn numbered_list_paragraphs_count_on_through_a_thread() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - let numbers = |s: &Session, id: u64| { - let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() - }; - let all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::>(); - let first = numbers(&s, a); - assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}"); - assert_eq!([first.clone(), numbers(&s, b)].concat(), all(1)); - // Typing a paragraph into the first frame numbers it and moves the rest on. - s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "Item 0\n"})).unwrap(); - assert_eq!([numbers(&s, a), numbers(&s, b)].concat(), [vec!["1.".to_string()], all(2), vec!["11.".to_string()]].concat()); - s.execute("edit.undo", &json!({})).unwrap(); - let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; - let after: Vec = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).markers.iter().map(|m| m.text.clone()).collect(); - assert_eq!(after, all(first.len() + 1), "reopened"); - // A pasted copy of the second frame is in no thread. - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - s.execute("edit.paste", &json!({})).unwrap(); - let copy = s.doc().unwrap().selection.objects.first().copied().unwrap(); - assert_ne!(copy, NodeId(b)); - assert_eq!(numbers(&s, copy.0).first().map(String::as_str), Some("1.")); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - let duplicate = s.execute("edit.duplicate", &json!({"dx": 10})).unwrap()["ids"][0].as_u64().unwrap(); - assert_eq!(numbers(&s, duplicate).first().map(String::as_str), Some("1."), "a duplicate"); - let layer = s.doc().unwrap().doc.layers[0].id.0; - let moved = s.execute("layer.move", &json!({"ids": [b], "target": layer, "copy": true})).unwrap()["ids"][0].as_u64().unwrap(); - assert_eq!(numbers(&s, moved).first().map(String::as_str), Some("1."), "a copy dragged in the Layers panel"); - assert_eq!(numbers(&s, b), all(first.len() + 1), "the frame itself"); - // Convert To Point Type takes the second frame out of the thread with its text. - s.execute("type.convertToPointType", &json!({"ids": [b]})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."), "the frame converted to point type"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(numbers(&s, b), all(first.len() + 1), "undo"); - // Remove Threading: each frame is a story of its own. - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("text.thread.remove", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1.")); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(numbers(&s, b), all(first.len() + 1), "undo"); - // Release Selection of the first frame: the second one holds the whole story, from 1. - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!(numbers(&s, b), all(1), "the frame left holds the story"); - } - - /// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art - /// looks as it did; Break Link puts the frames in no thread, and each counts from 1. - #[test] - fn a_symbol_made_of_a_numbered_thread_keeps_its_numbers() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - let count = |t: &TextObject| t.list_count; - let carried = text_of(&s.doc().unwrap().doc, NodeId(b)).map(count).unwrap(); - assert!(carried > 0); - let instance = new_id(s.execute("symbol.new", &json!({"name": "Numbered"})).unwrap()); - let counts = |n: &Node| { - let mut counts = vec![]; - n.walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind { - counts.push(t.list_count); - } - }); - counts.sort_unstable(); - counts - }; - let doc = &s.doc().unwrap().doc; - assert_eq!(counts(&doc.symbols.iter().find(|x| x.name == "Numbered").unwrap().art), [0, carried]); - // Break Link puts the frames on the artboard in no thread: each counts from 1. - s.execute("select.set", &json!({"ids": [instance]})).unwrap(); - s.execute("symbol.breakLink", &json!({})).unwrap(); - let doc = &s.doc().unwrap().doc; - assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::>(), [0, 0]); - } - - /// The text of each frame in symbol `name`'s art, sorted. - fn symbol_texts(s: &Session, name: &str) -> Vec { - let doc = &s.doc().unwrap().doc; - let Some(sym) = doc.symbols.iter().find(|x| x.name == name) else { panic!("no symbol {name}") }; - let mut texts = vec![]; - sym.art.walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind { - texts.push(t.plain_text()); - } - }); - texts.sort(); - texts - } - - /// New Symbol and Redefine Symbol on frames of threaded text replace them with an instance - /// whose art holds the text they show, and the frames leave their thread: the other frames keep - /// the text they hold, so each character is drawn once. Undo threads the frames again. When the - /// last frame goes into the symbol, the frame before it takes the text it has no room for, and - /// the symbol's art holds only the text the frame shows. - #[test] - fn frames_made_into_a_symbol_leave_their_thread_with_their_text() { - let symbol = |s: &mut Session, command: &str, ids: &[u64]| { - let name = if command == "symbol.update" { - let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); - s.execute("select.set", &json!({"ids": [mark]})).unwrap(); - s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); - "Mark" - } else { - "Story" - }; - s.execute("select.set", &json!({"ids": ids})).unwrap(); - s.execute(command, &json!({"name": name})).unwrap(); - name - }; - for command in ["symbol.new", "symbol.update"] { - for out in [&[0][..], &[1], &[2], &[0, 2]] { - let case = format!("{command} {out:?}"); - let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); - let shown = frames.map(|id| text(&s, id)); - assert_eq!(shown.concat(), TEXT.repeat(4)); - let ids: Vec = out.iter().map(|k| frames[*k]).collect(); - let name = symbol(&mut s, command, &ids); - let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); - for id in &ids { - assert!(!is_frame(&s, *id), "{case}: frame {id} is in the symbol"); - } - let mut held: Vec = out.iter().map(|k| shown[*k].clone()).collect(); - held.sort(); - assert_eq!(symbol_texts(&s, name), held, "{case}: the symbol's art holds the text the frames show"); - for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { - assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); - } - assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(threads(&s), [frames], "{case}: undo"); - assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); - } - let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); - let shown = [a, b, c].map(|id| text(&s, id)); - let laid = { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) - }; - assert!(laid.overflow, "the last frame has more text than it shows"); - let end = laid.lines.last().map_or(0, |l| l.end); - let hidden = shown[2][end..].trim_start_matches(' '); - let name = symbol(&mut s, command, &[c]); - assert_eq!(text(&s, a), shown[0], "{command}"); - assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "{command}: the frame before it keeps its text and takes the rest"); - assert_eq!(threads(&s), [[a, b]], "{command}"); - let held = symbol_texts(&s, name); - let held: Vec<&str> = held.iter().map(|t| t.trim_end()).collect(); - assert_eq!(held, [shown[2][..end].trim_end()], "{command}: the symbol's art holds what the frame shows"); - // With every frame of the thread in the symbol, the last frame keeps all its text. - let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); - let mut shown = frames.map(|id| text(&s, id)).to_vec(); - shown.sort(); - let name = symbol(&mut s, command, &frames); - assert_eq!(symbol_texts(&s, name), shown, "{command}: the whole thread"); - assert!(threads(&s).is_empty(), "{command}: the whole thread"); - } - } - - /// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the - /// mask art too. - #[test] - fn a_frame_pasted_into_an_edited_opacity_mask_numbers_from_1() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); - assert!(text_of(&s.doc().unwrap().doc, NodeId(b)).is_some_and(|t| t.list_count > 0)); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let obj = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 280})).unwrap()); - let m = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()); - s.execute("select.set", &json!({"ids": [obj, m]})).unwrap(); - s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); - s.execute("transparency.editOpacityMask", &json!({})).unwrap(); - s.execute("edit.paste", &json!({})).unwrap(); - let doc = &s.doc().unwrap().doc; - let mut counts = vec![]; - doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind { - counts.push(t.list_count); - } - }); - assert_eq!(counts, [0], "the pasted frame in the mask art"); - } - - /// A duplicate of a frame that carried a count of 99 or more numbers from 1 and has the layout - /// bounds of what it draws: "01." leaves the text at the left indent, where a three-digit - /// number pushed it on to the next tab stop and the paragraph took more lines. - #[test] - fn a_duplicated_frame_numbers_from_1_with_its_own_bounds() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); - let story = format!("{}Itemized lists paragraph text", "x\n".repeat(110)); - let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 1500}})).unwrap()); - let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 300})).unwrap()); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [a, b], "number": "twoDigits"})).unwrap(); - s.execute("select.set", &json!({"ids": [b]})).unwrap(); - let copy = s.execute("edit.duplicate", &json!({"dx": 200})).unwrap()["ids"][0].as_u64().unwrap(); - let laid = |s: &Session, id: u64| { - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("area type") }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - (lay.markers[0].text.clone(), lay.lines.len(), lay.bounds, t.cached_bounds.unwrap()) - }; - let (number, lines, _, _) = laid(&s, b); - let (copy_number, copy_lines, bounds, cached) = laid(&s, copy); - assert!(number.len() == 4 && number.starts_with('1'), "a three-digit number: {number}"); - assert_eq!(copy_number, "01."); - assert!(copy_lines < lines, "{copy_lines} {lines}"); - assert!((cached.y1 - bounds.y1).abs() < 1e-6, "the copy's bounds: {cached:?} {bounds:?}"); - } - - /// Release Selection that leaves one frame of a thread gives that frame the whole story and - /// the layout bounds of its new text. - #[test] - fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() { - let (mut s, a, b) = setup(); - s.execute("text.thread.create", &json!({})).unwrap(); - s.execute("select.set", &json!({"ids": [a]})).unwrap(); - s.execute("text.thread.releaseSelection", &json!({})).unwrap(); - assert!(threads(&s).is_empty()); - assert_eq!(text(&s, b), TEXT.repeat(4)); - let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(b)) else { panic!("area type") }; - let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; - let cached = t.cached_bounds.unwrap(); - assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}"); - } -} +mod tests; diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs new file mode 100644 index 000000000..07531d4ed --- /dev/null +++ b/crates/engine/src/cmd/threads/tests.rs @@ -0,0 +1,1267 @@ +use super::*; + +const TEXT: &str = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. "; + +fn setup() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + (s, a, b) +} + +fn text(s: &Session, id: u64) -> String { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } +} + +#[test] +fn thread_flows_reflows_on_edit_and_survives_delete() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let (ta, tb) = (text(&s, a), text(&s, b)); + assert!(!ta.is_empty() && !tb.is_empty()); + assert_eq!(format!("{ta}{tb}"), TEXT.repeat(4)); + // Editing the first frame re-flows into the second. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); + assert_ne!(text(&s, b), tb, "the overflow moved on"); + // Deleting the first frame moves all its text into the second. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!(text(&s, b), format!("NEW {}", TEXT.repeat(4))); + assert!(s.doc().unwrap().doc.text_threads.is_empty(), "a one-frame thread is no thread"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.text_threads.len(), 1); +} + +#[test] +fn edits_copy_only_the_stories_of_threads_whose_frames_changed() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let c = s.execute("text.create", &json!({"x": 10, "y": 200, "text": TEXT.repeat(2), "area": {"width": 150, "height": 40}})).unwrap()["id"] + .as_u64() + .unwrap(); + let d = s.execute("shape.rectangle", &json!({"x": 200, "y": 220, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [c, d]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + let texts = |s: &Session| [a, b, c, d].map(|id| text(s, id)); + let copied = || COPIED.with(std::cell::Cell::get); + let (shown, start) = (texts(&s), copied()); + // A rectangle drawn, moved and dragged beside the threads changes none of their frames. + let r = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.begin_interaction("Move").unwrap(); + for dx in 1..=3 { + s.preview("object.move", &json!({"dx": dx, "dy": 0})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(copied(), start, "edits beside the threads copied their stories"); + assert_eq!(texts(&s), shown); + // Typing in the first thread copies and re-flows that thread's story only. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(copied(), start + 1); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); + assert_eq!(texts(&s)[2..], shown[2..]); + let ids = |t: [u64; 2]| t.map(NodeId).to_vec(); + assert_eq!(s.doc().unwrap().doc.text_threads, [ids([a, b]), ids([c, d])]); +} + +/// Time edits of a story of 50,000 characters threaded through 8 frames, each frame wrapping +/// around an ellipse, with one style, a style change every 10 words and a style change at every +/// word: 21 characters typed into the first frame, then 200 moves of a rectangle below the +/// frames and 200 frames of a drag of it. +/// Run with `cargo test --release -p vectorcraft-engine --lib threaded_story_edit_timing -- --ignored --nocapture`. +#[test] +#[ignore = "manual timing probe; wall-clock performance depends on machine load"] +fn threaded_story_edit_timing() { + fn median(s: &mut Session, n: u32, f: &dyn Fn(&mut Session, f64)) -> f64 { + let mut ms: Vec = (1..=n) + .map(|i| { + let start = std::time::Instant::now(); + f(s, f64::from(i)); + start.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + ms.sort_by(f64::total_cmp); + ms[ms.len() / 2] + } + let story = format!("{}\n", TEXT.repeat(6).trim_end()).repeat(81)[..50_000].to_string(); + for words in [0, 10, 1] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 4400, "height": 1200})).unwrap(); + let area = json!({"width": 500, "height": 900}); + let first = s.execute("text.create", &json!({"x": 20, "y": 20, "text": story, "size": 9, "area": area})).unwrap()["id"].as_u64().unwrap(); + if words > 0 { + s.edit("Restyle", |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(NodeId(first)).map(|n| &mut n.kind) else { panic!("not text") }; + let style = t.runs[0].style.clone(); + let pieces: Vec<&str> = story.split_inclusive([' ', '\n']).collect(); + t.runs = pieces + .chunks(words) + .enumerate() + .map(|(i, c)| { + let mut run = TextRun::new(c.concat(), style.clone()); + run.style.underline = i % 2 == 1; + run + }) + .collect(); + refresh_bounds(t); + Ok(()) + }) + .unwrap(); + } + let mut ids = vec![first]; + for k in 1..8 { + let frame = json!({"x": 20 + k * 540, "y": 20, "width": 500, "height": 900}); + ids.push(s.execute("shape.rectangle", &frame).unwrap()["id"].as_u64().unwrap()); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + for k in 0..8 { + let e = s.execute("shape.ellipse", &json!({"x": 195 + k * 540, "y": 395, "width": 150, "height": 150})).unwrap()["id"].clone(); + s.execute("object.textWrap.make", &json!({"ids": [e]})).unwrap(); + } + let r = s.execute("shape.rectangle", &json!({"x": 20, "y": 1100, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let typing = median(&mut s, 21, &|s, _| { + s.execute("text.editRange", &json!({"id": first, "start": 0, "end": 0, "insert": "x"})).unwrap(); + }); + let edit = median(&mut s, 200, &|s, _| { + s.execute("object.move", &json!({"dx": 0.5, "dy": 0})).unwrap(); + }); + s.begin_interaction("Move").unwrap(); + let frame = median(&mut s, 200, &|s, i| { + s.preview("object.move", &json!({"dx": i * 0.25, "dy": 0})).unwrap(); + }); + s.commit_interaction().unwrap(); + let d = &s.doc().unwrap().doc; + let runs: usize = ids.iter().filter_map(|id| text_of(d, NodeId(*id))).map(|t| t.runs.len()).sum(); + println!("{runs} runs in 8 frames: typing {typing:.1} ms, a move below them {edit:.3} ms, a drag frame {frame:.3} ms (medians)"); + } +} + +fn new_id(v: Value) -> u64 { + v["id"].as_u64().unwrap() +} + +fn threads(s: &Session) -> Vec> { + s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect() +} + +/// Bounds of area type `id`'s frame on the page. +fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; + t.xf.transform_rect_bbox(frame.bounds().unwrap()) +} + +/// #2572: the text loaded from an out port threads into a closed path, into a new frame of the +/// first one's shape and size at a point, and into a new rectangle, each in one undo step. +#[test] +fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() { + let (mut s, a, b) = setup(); + assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b); + assert_eq!(threads(&s), [[a, b]]); + assert!(!text(&s, b).is_empty(), "the overflow flowed into the path"); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back"); + + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap()); + assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click"); + let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap()); + assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0)); + assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from"); + assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4)); + assert!(!text(&s, d).is_empty()); + // A frame threaded from the middle of a thread goes in after it. + let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap()); + assert_eq!(threads(&s), [[a, e, c, d]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none()); + assert_eq!(threads(&s), [[a, c, d]]); +} + +/// #2572: the text loaded from an in port threads back: the new frame or the path clicked comes +/// before the frame it was loaded from, so the story starts there and flows on into it; in the +/// middle of a thread the new frame goes in between, each in one undo step. +#[test] +fn add_before_threads_the_story_from_the_new_frame_into_the_clicked_one() { + let (mut s, a, b) = setup(); + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200], "before": true})).unwrap()); + assert_eq!(threads(&s), [[c, a]]); + assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the clicked frame's size at the click"); + assert!(TEXT.repeat(4).starts_with(&text(&s, c)) && !text(&s, c).is_empty(), "the story starts in the new frame: {:?}", text(&s, c)); + assert_eq!(format!("{}{}", text(&s, c), text(&s, a)), TEXT.repeat(4)); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(c)]); + // In between the frame and the one before it. + let d = new_id(s.execute("text.thread.add", &json!({"from": a, "rect": [10, 100, 300, 150], "before": true})).unwrap()); + assert_eq!(threads(&s), [[c, d, a]]); + assert_eq!(format!("{}{}{}", text(&s, c), text(&s, d), text(&s, a)), TEXT.repeat(4)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[c, a]]); + // A closed path clicked with the in port's text comes first, with the start of the story. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b, "before": true})).unwrap()), b); + assert_eq!(threads(&s), [[b, a]]); + assert!(TEXT.repeat(4).starts_with(&text(&s, b)) && !text(&s, b).is_empty()); + assert_eq!(format!("{}{}", text(&s, b), text(&s, a)), TEXT.repeat(4)); + // `before: false` is the out port's. + let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0], "before": false})).unwrap()); + assert_eq!(threads(&s), [[b, a, e]]); +} + +/// Type on a path along a line beside the area type of [`setup`]. +fn type_on_a_line(s: &mut Session) -> u64 { + let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap()); + new_id(s.execute("text.createInPath", &json!({"path": line, "mode": "onPath", "text": ""})).unwrap()) +} + +/// Type on a path threads as area type does, in any mix: a story flows from area type along a +/// path and on into area type, and a new frame threaded from type on a path at a point is type +/// on a path of the same shape. +#[test] +fn type_on_a_path_threads_with_area_type() { + let (mut s, a, b) = setup(); + let on_path = type_on_a_line(&mut s); + s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); + s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap(); + assert_eq!(threads(&s), [[a, on_path, b]]); + let shares = [a, on_path, b].map(|id| text(&s, id)); + assert!(shares.iter().all(|t| !t.is_empty()), "each frame takes a share: {shares:?}"); + assert_eq!(shares.concat(), TEXT.repeat(4)); + + let c = new_id(s.execute("text.thread.add", &json!({"from": on_path, "at": [300, 250]})).unwrap()); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind else { panic!("not text") }; + assert!(matches!(t.kind, TextKind::OnPath { .. }), "type on a path, as the frame it was threaded from"); + assert_eq!(threads(&s), [[a, on_path, c, b]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, on_path, b]]); +} + +/// Type on a path released from its thread gives its text back to the frame before it. +#[test] +fn type_on_a_path_is_released_from_its_thread() { + let (mut s, a, _) = setup(); + let on_path = type_on_a_line(&mut s); + s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); + assert!(!text(&s, on_path).is_empty()); + s.execute("select.set", &json!({"ids": [on_path]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert!(text(&s, on_path).is_empty()); + assert_eq!(text(&s, a), TEXT.repeat(4)); +} + +/// Area type threaded into from an out port takes the overflow at once, before its own text. +#[test] +fn add_into_area_type_flows_the_story_into_it() { + let (mut s, a, _) = setup(); + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + assert_eq!(threads(&s), [[a, c]]); + assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4))); + assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c)); +} + +#[test] +fn add_refuses_bad_params_and_leaves_the_document_alone() { + let (mut s, a, b) = setup(); + let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap()); + let before = s.doc().unwrap().doc.clone(); + for p in [ + json!({"to": b}), + json!({"from": b, "at": [0, 0]}), + json!({"from": 9999, "at": [0, 0]}), + json!({"from": -1, "at": [0, 0]}), + json!({"from": a}), + json!({"from": a, "to": b, "at": [0, 0]}), + json!({"from": a, "to": a}), + json!({"from": a, "to": line}), + json!({"from": a, "to": 9999}), + json!({"from": a, "at": [0]}), + json!({"from": a, "at": ["1", 2]}), + json!({"from": a, "rect": [0, 0, -10, 10]}), + json!({"from": a, "rect": [0, 0, 10, 0]}), + json!({"from": a, "rect": [0, 0, 10]}), + json!({"from": a, "rect": "big"}), + json!({"from": a, "at": [0, 0], "before": "yes"}), + json!({"from": a, "at": [0, 0], "before": 1}), + ] { + assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted"); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document"); + } + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap()); + assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded"); +} + +/// A frame released from a thread that keeps two or more frames gives its text back to them: +/// the story flows through them once. +#[test] +fn release_from_a_longer_thread_keeps_the_story_once() { + let (mut s, a, b) = setup(); + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); + assert_eq!(threads(&s), [[a, c, b]]); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, b]]); + assert!(text(&s, c).is_empty()); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); +} + +/// A thread of `n` frames (2 or 3), each showing part of the story: area type `a` of [`setup`], +/// for 3 an area type frame that is empty until it is threaded, then the rectangle `b`. +fn thread_of(n: usize) -> (Session, Vec) { + let (mut s, a, b) = setup(); + let frames = if n == 3 { + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); + vec![a, c, b] + } else { + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + vec![a, b] + }; + assert_eq!(threads(&s), std::slice::from_ref(&frames)); + assert!(frames.iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); + (s, frames) +} + +/// Convert To Point Type on a frame of threaded text takes it out of its thread with the text +/// it holds, and the other frames keep the text they hold: the story holds each character once. +/// A thread left with one frame is no thread. Undo threads the frame again. +#[test] +fn a_frame_converted_to_point_type_leaves_its_thread_with_its_text() { + // Soft wraps of the point type become line breaks, so its characters are compared without + // white space. + let squeeze = |t: &str| t.split_whitespace().collect::(); + for n in [2, 3] { + for k in 0..n { + let (mut s, frames) = thread_of(n); + let shown: Vec = frames.iter().map(|id| text(&s, *id)).collect(); + let id = frames[k]; + s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("not text") }; + assert!(matches!(t.kind, TextKind::Point), "{n} frames, frame {k}: point type"); + assert_eq!(squeeze(&text(&s, id)), squeeze(&shown[k]), "{n} frames, frame {k}: the point type keeps its text"); + for (f, before) in frames.iter().zip(&shown).filter(|(f, _)| **f != id) { + assert_eq!(text(&s, *f), *before, "{n} frames, frame {k}: frame {f} keeps its text"); + } + let rest: Vec = frames.iter().copied().filter(|f| *f != id).collect(); + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{n} frames, frame {k}"); + let all: String = frames.iter().map(|f| text(&s, *f)).collect(); + assert_eq!(squeeze(&all), squeeze(&TEXT.repeat(4)), "{n} frames, frame {k}: each character once"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), std::slice::from_ref(&frames)); + assert_eq!(frames.iter().map(|f| text(&s, *f)).collect::>(), shown, "{n} frames, frame {k}: undo"); + } + } +} + +#[test] +fn release_and_remove() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let tb = text(&s, b); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!(text(&s, b), tb, "remove keeps what each frame shows"); + assert!(s.doc().unwrap().doc.text_threads.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(text(&s, b), ""); + assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame"); +} + +/// `story` threaded through three area type frames, of 150 by 60, 40 and `last` points, each +/// showing part of it. +fn three_frames(story: &str, last: f64) -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let frame = |s: &mut Session, x: f64, y: f64, h: f64, text: &str| { + new_id(s.execute("text.create", &json!({"x": x, "y": y, "text": text, "area": {"width": 150, "height": h}})).unwrap()) + }; + let a = frame(&mut s, 10.0, 10.0, 60.0, story); + let b = frame(&mut s, 10.0, 100.0, 40.0, ""); + let c = frame(&mut s, 200.0, 10.0, last, ""); + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread.add", &json!({"from": b, "to": c})).unwrap(); + assert_eq!(threads(&s), [[a, b, c]]); + assert!([a, b, c].iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); + (s, [a, b, c]) +} + +fn continues(s: &Session, id: u64) -> bool { + text_of(&s.doc().unwrap().doc, NodeId(id)).is_some_and(|t| t.first_para_continues) +} + +/// A double-click on a threaded port breaks the thread there: the story flows into the frames +/// before the break and the frames after it are left empty, still threaded with each other +/// when two or more. One undo step restores the thread and its text. +#[test] +fn break_splits_the_thread_and_the_story_flows_into_the_first_part() { + let story = TEXT.repeat(12); + let (mut s, [a, b, c]) = three_frames(&story, 40.0); + let d = new_id(s.execute("text.create", &json!({"x": 200, "y": 100, "text": "", "area": {"width": 150, "height": 100}})).unwrap()); + s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap(); + let all = [a, b, c, d]; + let shown: Vec = all.iter().map(|id| text(&s, *id)).collect(); + assert!(shown.iter().all(|t| !t.is_empty()), "each frame shows part of the story"); + let joined = |s: &Session, ids: &[u64]| ids.iter().map(|id| text(s, *id)).collect::(); + // (frame, port, threads after, the frames that hold the story, the frames left empty) + let cases = [ + (a, "out", vec![vec![b, c, d]], vec![a], vec![b, c, d]), + (b, "in", vec![vec![b, c, d]], vec![a], vec![b, c, d]), + (b, "out", vec![vec![a, b], vec![c, d]], vec![a, b], vec![c, d]), + (d, "in", vec![vec![a, b, c]], vec![a, b, c], vec![d]), + (c, "out", vec![vec![a, b, c]], vec![a, b, c], vec![d]), + ]; + for (id, port, after, full, empty) in cases { + let case = format!("{port} port of {id}"); + s.execute("text.thread.break", &json!({"id": id, "port": port})).unwrap(); + assert_eq!(threads(&s), after, "{case}"); + assert_eq!(joined(&s, &full), story, "{case}: the story flows into the first part"); + assert!(empty.iter().all(|id| text(&s, *id).is_empty()), "{case}: the frames after the break are empty"); + assert!(!continues(&s, empty[0]), "{case}: the first frame after the break starts a paragraph"); + assert!(!continues(&s, a), "{case}: the first frame starts the story"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [all.to_vec()], "{case}: undo"); + assert_eq!(all.iter().map(|id| text(&s, *id)).collect::>(), shown, "{case}: undo"); + } +} + +#[test] +fn break_refuses_a_port_with_no_thread_and_bad_params() { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); + let lone = new_id(s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Alone.", "area": {"width": 100, "height": 100}})).unwrap()); + let ids = [a, b, c, lone]; + let shown: Vec = ids.iter().map(|id| text(&s, *id)).collect(); + let before = threads(&s); + for params in [ + json!({"id": a, "port": "in"}), + json!({"id": c, "port": "out"}), + json!({"id": lone, "port": "out"}), + json!({"id": lone, "port": "in"}), + json!({"id": a, "port": "up"}), + json!({"id": a}), + json!({"port": "out"}), + json!({"id": 9999, "port": "out"}), + ] { + assert!(s.execute("text.thread.break", ¶ms).is_err(), "{params}"); + } + assert_eq!(threads(&s), before); + assert_eq!(ids.iter().map(|id| text(&s, *id)).collect::>(), shown); +} + +/// Whether the document holds area type or type on a path `id`. +fn is_frame(s: &Session, id: u64) -> bool { + text_of(&s.doc().unwrap().doc, NodeId(id)).is_some() +} + +/// Create Outlines and Object › Expand on frames of threaded text take them out of their +/// thread with the text they show, which their outlines draw, and the other frames keep the +/// text they hold: each character is in the document once. A thread left with one frame is no +/// thread. Undo threads the frames again. +#[test] +fn outlined_frames_leave_their_thread_with_their_text() { + for command in ["type.createOutlines", "object.expand"] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute(command, &json!({})).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{command} {out:?}: frame {id} is outlined"); + } + let st = s.doc().unwrap(); + let groups = st.selection.objects.iter().filter(|g| matches!(st.doc.node(**g).map(|n| &n.kind), Some(NodeKind::Group { .. }))); + assert_eq!(groups.count(), ids.len(), "{command} {out:?}: the outlines are selected"); + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{command} {out:?}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{command} {out:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{command} {out:?}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{command} {out:?}: undo"); + } + } +} + +/// Expand Appearance outlines a frame with an effect, and the frame leaves its thread with the +/// text it shows; Object › Expand of such a frame expands its appearance first. +#[test] +fn a_frame_whose_appearance_expands_leaves_its_thread_with_its_text() { + for command in ["effect.expandAppearance", "object.expand"] { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [b]})).unwrap(); + let shown = [a, b, c].map(|id| text(&s, id)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute(command, &json!({})).unwrap(); + assert!(!is_frame(&s, b), "{command}: the frame is outlined"); + assert_eq!((text(&s, a), text(&s, c)), (shown[0].clone(), shown[2].clone()), "{command}: the other frames keep their text"); + assert_eq!(threads(&s), [[a, c]], "{command}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, b, c]], "{command}: undo"); + assert_eq!([a, b, c].map(|id| text(&s, id)), shown, "{command}: undo"); + } +} + +/// Object › Flatten Transparency outlines frames of threaded text with Convert All Text to +/// Outlines, and turns transparent frames into flat regions or an image: each such frame leaves +/// its thread with the text it shows, and the other frames keep the text they hold. Undo threads +/// the frames again. When the last frame is flattened, the frame before it takes the text it has +/// no room for. +#[test] +fn flattened_frames_leave_their_thread_with_their_text() { + let cases = [(json!({"textToOutlines": true}), false), (json!({}), true), (json!({"balance": 0}), true)]; + for (options, transparent) in cases { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let case = format!("{options} {out:?}"); + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + if transparent { + s.execute("transparency.set", &json!({"ids": ids, "opacity": 50})).unwrap(); + } + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let mut p = options.clone(); + p["ids"] = json!(ids); + s.execute("object.flattenTransparency", &p).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{case}: frame {id} is flattened"); + } + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("object.flattenTransparency", &json!({"ids": [c], "textToOutlines": true})).unwrap(); + assert_eq!(text(&s, a), shown[0]); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]]); +} + +/// Expand Appearance keeps type whose only effect is a shadow or Crop Marks as type under a new +/// id, in a group under the frame's id: the type takes the frame's place in its thread, and +/// each frame keeps its text, the last frame the text it has no room for too. Type in a group +/// with Crop Marks keeps its id and stays in its thread. +#[test] +fn type_that_expand_appearance_keeps_takes_its_frames_place() { + for (effect, grouped) in [("stylize.dropShadow", false), ("cropMarks", false), ("cropMarks", true)] { + for k in [1, 2] { + let case = format!("{effect} grouped {grouped} frame {k}"); + let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); + let id = frames[k]; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let target = if grouped { new_id(s.execute("object.group", &json!({})).unwrap()) } else { id }; + s.execute("effect.apply", &json!({"effect": effect, "item": null, "ids": [target]})).unwrap(); + let shown = frames.map(|id| text(&s, id)); + s.execute("select.set", &json!({"ids": [target]})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let mut types = vec![]; + if let Some(n) = s.doc().unwrap().doc.node(NodeId(target)) { + n.walk(&mut |m| { + if matches!(m.kind, NodeKind::Text(_)) { + types.push(m.id.0); + } + }); + } + let [kept] = types[..] else { panic!("{case}: the type is kept: {types:?}") }; + assert_eq!(kept == id, grouped, "{case}: the type has a new id unless it was in a group"); + let mut now = frames; + now[k] = kept; + assert_eq!(threads(&s), [now], "{case}: the type takes the frame's place"); + assert_eq!(now.map(|id| text(&s, id)), shown, "{case}: each frame keeps its text"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } +} + +/// The text that does not fit a thread's last frame, which its outlines do not draw, stays in +/// the story: when the last frame is outlined, the frame before it takes that text after its +/// own. +#[test] +fn outlining_the_last_frame_keeps_the_text_it_has_no_room_for() { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), shown[0]); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([a, b, c].map(|id| text(&s, id)), shown); +} + +/// Object › Rasterize on frames of threaded text takes them out of their thread with the text +/// they show, which the image draws, and the other frames keep the text they hold. Undo threads +/// the frames again. When the last frame is rasterized, the frame before it takes the text it +/// has no room for. +#[test] +fn rasterized_frames_leave_their_thread_with_their_text() { + for clip in [false, true] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let case = format!("clippingMask {clip} {out:?}"); + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized"); + } + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 36})).unwrap(); + assert_eq!(text(&s, a), shown[0]); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]]); +} + +/// Frame `id`'s first line: whether its first paragraph continues from the frame before, where +/// its text starts from the frame's left edge, and how many list markers it starts with. +fn first_line(s: &Session, id: u64) -> (bool, f64, usize) { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = &lay.lines[0]; + let x = lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x); + let markers = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count(); + (t.first_para_continues, (x - frame.bounds().unwrap().x0 - 1e-9).round(), markers) +} + +/// A paragraph's first-line indent and bullet belong to its first line, in the frame it starts +/// in: the frame it continues into starts at its left indent, without the bullet. Re-flowing, +/// saving and opening keep it. In a frame that is no longer threaded and in a copy of a frame, +/// the first paragraph has its first-line indent and bullet. +#[test] +fn a_paragraph_continued_into_the_next_frame_starts_it_without_its_first_line_indent_or_bullet() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "bullet"})).unwrap(); + assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 18.0, 1), (true, 18.0, 0))); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "none", "leftIndent": 0, "firstLineIndent": 24})).unwrap(); + assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0))); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + // Typing re-flows the story and keeps it. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(first_line(&s, b), (true, 0.0, 0)); + // Saved and opened: a native file re-flows its threads, a restored document keeps its text. + for name in ["thread.vectorcraft", "thread.eps"] { + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0)), "{name}"); + } + // A copy of the second frame is in no thread, and starts with the indent. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!((first_line(&s, b), first_line(&s, copy)), ((true, 0.0, 0), (false, 24.0, 0))); + // …and so is a pasted one, and each copy's cached bounds and baselines are its own layout's. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let pasted = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + let dragged = s.execute("layer.move", &json!({"ids": [b], "target": a, "place": "above", "copy": true})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!((first_line(&s, pasted), first_line(&s, dragged)), ((false, 24.0, 0), (false, 24.0, 0))); + // Every text object in the document has the cached bounds and baselines of its own layout. + let own_caches = |s: &Session, what: &str| { + let mut texts = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push((n.id, t)); + } + }); + for (id, t) in texts { + let mut fresh = (**t).clone(); + refresh_bounds(&mut fresh); + assert!(t.cached_bounds.is_some() && t.cached_bounds == fresh.cached_bounds, "{what}: {id:?}"); + assert_eq!(t.cached_baselines, fresh.cached_baselines, "{what}: {id:?}"); + } + }; + own_caches(&s, "copies"); + // Release to Layers (Build), Release Opacity Mask, Edit Opacity Mask and Draw Inside copy the + // frame too. + let layer = s.doc().unwrap().doc.layer_containing(NodeId(b)).unwrap().0; + s.execute("layer.releaseToLayers", &json!({"id": layer, "build": true})).unwrap(); + own_caches(&s, "Release to Layers (Build)"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({"ids": [a, b]})).unwrap(); + s.execute("transparency.releaseOpacityMask", &json!({})).unwrap(); + own_caches(&s, "Release Opacity Mask"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({"id": a})).unwrap(); + own_caches(&s, "Edit Opacity Mask"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 210, "y": 20, "width": 20, "height": 20})).unwrap(); + own_caches(&s, "Draw Inside"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap(); + assert_eq!((threads(&s), first_line(&s, b)), (vec![vec![a, b]], (true, 0.0, 0))); + // Remove Threading: each frame keeps its text, and its first paragraph starts in it. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!(first_line(&s, b), (false, 24.0, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(first_line(&s, b), (true, 0.0, 0)); + // Released, the frame is empty and starts the next text typed into it as a paragraph. + let continues = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.first_para_continues, + _ => panic!("not text"), + }; + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(text(&s, b).is_empty() && !continues(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + // With the first frame released, the second one holds the whole story and starts it. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty() && text(&s, a).is_empty()); + assert_eq!(first_line(&s, b), (false, 24.0, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + // Converted to point type, it is in no thread, and its first line starts at the indent where + // the frame's left edge was. + let left = frame_box(&s, b).x0; + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("type.convertToPointType", &json!({})).unwrap(); + assert!(!continues(&s, b)); + { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind else { panic!("not text") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let g = lay.glyphs.iter().find(|g| g.len > 0).unwrap(); + let x = (t.xf * g.origin).x - left; + assert!((x - 24.0).abs() < 1e-6, "{x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(first_line(&s, b), (true, 0.0, 0)); + // Text that ends the first frame with a paragraph break: the second frame starts a + // paragraph, with its indent. + let all = text(&s, a).len(); + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": all, "insert": "Short line.\n".repeat(12)})).unwrap(); + assert!(text(&s, a).ends_with('\n'), "{:?}", text(&s, a)); + assert_eq!(first_line(&s, b), (false, 24.0, 0)); +} + +/// When Create Outlines takes frames out of a thread, a frame that now starts its thread or is +/// in no thread starts its paragraph with the first-line indent; a frame after one that ends +/// inside the paragraph still continues it. The frames that stay hold each character they held +/// once, and keep their text when no mark changes. Undo marks the frames again. +#[test] +fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() { + for (out, want) in [ + (&[0][..], [None, Some(false), Some(true)]), + (&[0, 2], [None, Some(false), None]), + (&[1], [Some(false), None, Some(true)]), + (&[2], [Some(false), Some(true), None]), + ] { + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + s.execute("text.setFormat", &json!({"ids": frames, "leftIndent": 0, "firstLineIndent": 24})).unwrap(); + let before = frames.map(|id| first_line(&s, id)); + assert_eq!(before, [(false, 24.0, 0), (true, 0.0, 0), (true, 0.0, 0)], "{out:?}"); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4), "{out:?}: the last frame has room for the rest"); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + let stay: Vec = (0..3).filter(|k| want[*k].is_some()).collect(); + for k in &stay { + let on = want[*k] == Some(true); + let indent = if on { 0.0 } else { 24.0 }; + assert_eq!(first_line(&s, frames[*k]), (on, indent, 0), "{out:?}: frame {k}"); + } + let held: String = stay.iter().map(|k| shown[*k].as_str()).collect(); + assert_eq!(stay.iter().map(|k| text(&s, frames[*k])).collect::(), held, "{out:?}: each character once"); + if stay.iter().all(|k| want[*k] == Some(before[*k].0)) { + for k in &stay { + assert_eq!(text(&s, frames[*k]), shown[*k], "{out:?}: frame {k} keeps its text"); + } + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames.map(|id| first_line(&s, id)), before, "{out:?}: undo"); + } +} + +/// The text that a thread's last frame has no room for is measured as the frame lays it out, +/// without the first-line indent of the paragraph it continues: when the frame is outlined, the +/// frame before it takes only that text. +#[test] +fn outlining_a_continued_last_frame_keeps_only_the_text_it_has_no_room_for() { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + s.execute("text.setFormat", &json!({"ids": [a, b, c], "leftIndent": 0, "firstLineIndent": 72})).unwrap(); + assert_eq!(first_line(&s, c), (true, 0.0, 0)); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1])); +} + +/// The text that a thread's last frame has no room for is measured with the frame's numbered +/// paragraphs counted on from the frames before it: when the frame is outlined, the frame before +/// it takes only that text. +#[test] +fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + // A hanging indent that "I." fits and "VIII." does not: the text after "VIII." starts at + // the next tab stop. + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "leftIndent": 14, "firstLineIndent": -14})).unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + assert_eq!(t.list_count, 7); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII.")); + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_count: 0, ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); +} + +/// A story of one-line paragraphs that each start with a quotation mark, threaded from area +/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. +fn quoted_thread() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let story = "“Quoted.”\n".repeat(20); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert!(text(&s, b).starts_with('“')); + (s, a, b) +} + +/// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame +/// (its lines' opening quotation marks hang). +fn optical(s: &Session, id: u64) -> (bool, bool) { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; + (t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0) +} + +/// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment +/// in any frame sets it in every frame of the thread, in one undo step, and the lines in every +/// frame hang. Typing keeps it, and frames taken out of the thread keep it. +#[test] +fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() { + let (mut s, a, b) = quoted_thread(); + assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap(); + assert_eq!(r, json!({"on": true, "ids": [a, b]})); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too"); + // Turned off from the second frame, it is off in both, and one undo turns it back on in both. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); + assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); + // Typing in the first frame re-flows the story and keeps the setting. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap(); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); + // Any edit that sets one frame sets the thread. + s.edit("Set", |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") }; + t.optical_margin = false; + refresh_bounds(t); + Ok(()) + }) + .unwrap(); + assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false))); + s.execute("edit.undo", &json!({})).unwrap(); + // Released or unthreaded, each frame keeps it. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); +} + +/// Threading type into a thread gives the joined thread the setting of its first frame. +#[test] +fn a_thread_takes_its_first_frames_optical_margin_alignment() { + let (mut s, a, b) = quoted_thread(); + s.execute("text.thread.remove", &json!({})).unwrap(); + s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap(); + assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false)); + // Threaded again in stacking order: the second frame takes the first one's setting. + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false)); + // From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting. + s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap(); + assert_eq!(threads(&s), [[b, a]]); + assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false)); + // A new frame threaded from a frame takes the thread's setting. + s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap(); + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap()); + assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]); +} + +/// A file saved with the frames of one thread set differently opens with the first frame's +/// setting in every frame: a native file, and an EPS file restored from the document it carries. +#[test] +fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() { + let (mut s, a, b) = quoted_thread(); + for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] { + for (id, on) in [(a, first), (b, !first)] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.optical_margin = on; + } + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}"); + } +} + +/// One count runs through a thread: the second frame's numbered paragraphs go on from the +/// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a +/// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks +/// and the frame a Release Selection leaves alone count from 1. +#[test] +fn numbered_list_paragraphs_count_on_through_a_thread() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + let numbers = |s: &Session, id: u64| { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() + }; + let all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::>(); + let first = numbers(&s, a); + assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}"); + assert_eq!([first.clone(), numbers(&s, b)].concat(), all(1)); + // Typing a paragraph into the first frame numbers it and moves the rest on. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "Item 0\n"})).unwrap(); + assert_eq!([numbers(&s, a), numbers(&s, b)].concat(), [vec!["1.".to_string()], all(2), vec!["11.".to_string()]].concat()); + s.execute("edit.undo", &json!({})).unwrap(); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + let after: Vec = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).markers.iter().map(|m| m.text.clone()).collect(); + assert_eq!(after, all(first.len() + 1), "reopened"); + // A pasted copy of the second frame is in no thread. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.objects.first().copied().unwrap(); + assert_ne!(copy, NodeId(b)); + assert_eq!(numbers(&s, copy.0).first().map(String::as_str), Some("1.")); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let duplicate = s.execute("edit.duplicate", &json!({"dx": 10})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(numbers(&s, duplicate).first().map(String::as_str), Some("1."), "a duplicate"); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let moved = s.execute("layer.move", &json!({"ids": [b], "target": layer, "copy": true})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(numbers(&s, moved).first().map(String::as_str), Some("1."), "a copy dragged in the Layers panel"); + assert_eq!(numbers(&s, b), all(first.len() + 1), "the frame itself"); + // Convert To Point Type takes the second frame out of the thread with its text. + s.execute("type.convertToPointType", &json!({"ids": [b]})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."), "the frame converted to point type"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(numbers(&s, b), all(first.len() + 1), "undo"); + // Remove Threading: each frame is a story of its own. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1.")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(numbers(&s, b), all(first.len() + 1), "undo"); + // Release Selection of the first frame: the second one holds the whole story, from 1. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(numbers(&s, b), all(1), "the frame left holds the story"); +} + +/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art +/// looks as it did; Break Link puts the frames in no thread, and each counts from 1. +#[test] +fn a_symbol_made_of_a_numbered_thread_keeps_its_numbers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + let count = |t: &TextObject| t.list_count; + let carried = text_of(&s.doc().unwrap().doc, NodeId(b)).map(count).unwrap(); + assert!(carried > 0); + let instance = new_id(s.execute("symbol.new", &json!({"name": "Numbered"})).unwrap()); + let counts = |n: &Node| { + let mut counts = vec![]; + n.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + counts.push(t.list_count); + } + }); + counts.sort_unstable(); + counts + }; + let doc = &s.doc().unwrap().doc; + assert_eq!(counts(&doc.symbols.iter().find(|x| x.name == "Numbered").unwrap().art), [0, carried]); + // Break Link puts the frames on the artboard in no thread: each counts from 1. + s.execute("select.set", &json!({"ids": [instance]})).unwrap(); + s.execute("symbol.breakLink", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::>(), [0, 0]); +} + +/// The text of each frame in symbol `name`'s art, sorted. +fn symbol_texts(s: &Session, name: &str) -> Vec { + let doc = &s.doc().unwrap().doc; + let Some(sym) = doc.symbols.iter().find(|x| x.name == name) else { panic!("no symbol {name}") }; + let mut texts = vec![]; + sym.art.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + texts.push(t.plain_text()); + } + }); + texts.sort(); + texts +} + +/// New Symbol and Redefine Symbol on frames of threaded text replace them with an instance +/// whose art holds the text they show, and the frames leave their thread: the other frames keep +/// the text they hold, so each character is drawn once. Undo threads the frames again. When the +/// last frame goes into the symbol, the frame before it takes the text it has no room for, and +/// the symbol's art holds only the text the frame shows. +#[test] +fn frames_made_into_a_symbol_leave_their_thread_with_their_text() { + let symbol = |s: &mut Session, command: &str, ids: &[u64]| { + let name = if command == "symbol.update" { + let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [mark]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + "Mark" + } else { + "Story" + }; + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute(command, &json!({"name": name})).unwrap(); + name + }; + for command in ["symbol.new", "symbol.update"] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let case = format!("{command} {out:?}"); + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + let name = symbol(&mut s, command, &ids); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{case}: frame {id} is in the symbol"); + } + let mut held: Vec = out.iter().map(|k| shown[*k].clone()).collect(); + held.sort(); + assert_eq!(symbol_texts(&s, name), held, "{case}: the symbol's art holds the text the frames show"); + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + let name = symbol(&mut s, command, &[c]); + assert_eq!(text(&s, a), shown[0], "{command}"); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "{command}: the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]], "{command}"); + let held = symbol_texts(&s, name); + let held: Vec<&str> = held.iter().map(|t| t.trim_end()).collect(); + assert_eq!(held, [shown[2][..end].trim_end()], "{command}: the symbol's art holds what the frame shows"); + // With every frame of the thread in the symbol, the last frame keeps all its text. + let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); + let mut shown = frames.map(|id| text(&s, id)).to_vec(); + shown.sort(); + let name = symbol(&mut s, command, &frames); + assert_eq!(symbol_texts(&s, name), shown, "{command}: the whole thread"); + assert!(threads(&s).is_empty(), "{command}: the whole thread"); + } +} + +/// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the +/// mask art too. +#[test] +fn a_frame_pasted_into_an_edited_opacity_mask_numbers_from_1() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + assert!(text_of(&s.doc().unwrap().doc, NodeId(b)).is_some_and(|t| t.list_count > 0)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let obj = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 280})).unwrap()); + let m = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [obj, m]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + let mut counts = vec![]; + doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + counts.push(t.list_count); + } + }); + assert_eq!(counts, [0], "the pasted frame in the mask art"); +} + +/// A duplicate of a frame that carried a count of 99 or more numbers from 1 and has the layout +/// bounds of what it draws: "01." leaves the text at the left indent, where a three-digit +/// number pushed it on to the next tab stop and the paragraph took more lines. +#[test] +fn a_duplicated_frame_numbers_from_1_with_its_own_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let story = format!("{}Itemized lists paragraph text", "x\n".repeat(110)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 1500}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 300})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "number": "twoDigits"})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let copy = s.execute("edit.duplicate", &json!({"dx": 200})).unwrap()["ids"][0].as_u64().unwrap(); + let laid = |s: &Session, id: u64| { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("area type") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + (lay.markers[0].text.clone(), lay.lines.len(), lay.bounds, t.cached_bounds.unwrap()) + }; + let (number, lines, _, _) = laid(&s, b); + let (copy_number, copy_lines, bounds, cached) = laid(&s, copy); + assert!(number.len() == 4 && number.starts_with('1'), "a three-digit number: {number}"); + assert_eq!(copy_number, "01."); + assert!(copy_lines < lines, "{copy_lines} {lines}"); + assert!((cached.y1 - bounds.y1).abs() < 1e-6, "the copy's bounds: {cached:?} {bounds:?}"); +} + +/// Release Selection that leaves one frame of a thread gives that frame the whole story and +/// the layout bounds of its new text. +#[test] +fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(text(&s, b), TEXT.repeat(4)); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(b)) else { panic!("area type") }; + let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; + let cached = t.cached_bounds.unwrap(); + assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}"); +} From 79c5624f7ae7b5045feb4bbecea7c4586eef63b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:30 +0000 Subject: [PATCH 022/199] refactor(ui-egui): move shortcuts.rs tests to shortcuts/tests.rs --- crates/ui-egui/src/shortcuts.rs | 1243 +------------------------ crates/ui-egui/src/shortcuts/tests.rs | 1240 ++++++++++++++++++++++++ 2 files changed, 1241 insertions(+), 1242 deletions(-) create mode 100644 crates/ui-egui/src/shortcuts/tests.rs diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1eab0da7a..378590131 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -1164,1245 +1164,4 @@ fn letter_of(k: Key) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - /// One headless frame delivering `events` to the shortcut handler. - fn frame(app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { - // The production path: `logic` takes this host iteration's input once through - // `handle_input_once`, which runs the shortcut handler when the fonts are ready. A - // direct call covers the passes before those fonts are installed; the frame key makes - // it a no-op on the passes that already took this frame. - app.logic(ui.ctx()); - handle(app, ui.ctx()); - }); - out.textures_delta.clear(); - out - } - - #[test] - fn option_and_altgr_still_type_but_cmd_and_ctrl_chords_do_not() { - use egui::Modifiers as M; - assert!(is_shortcut_chord(M::COMMAND | M::SHIFT)); - assert!(is_shortcut_chord(M::CTRL)); - assert!(!is_shortcut_chord(M::NONE)); - assert!(!is_shortcut_chord(M::SHIFT)); - assert!(!is_shortcut_chord(M::ALT), "Option+key types é, ø on macOS"); - assert!(!is_shortcut_chord(M::CTRL | M::ALT), "AltGr types @ on a German keyboard"); - } - - /// A press of `key` with Ctrl and Alt held (as Firefox on Windows reports AltGr), followed by - /// the text it typed when `typed` is given. - fn ctrl_alt_press(key: Key, typed: Option<&str>) -> Vec { - let m = Modifiers::CTRL | Modifiers::ALT | Modifiers::COMMAND; - let mut v = - vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }]; - v.extend(typed.map(|t| egui::Event::Text(t.into()))); - v - } - - /// The text of the type object `id`, and the document's layer count. - fn text_and_layers(app: &mut VectorcraftApp, id: u64) -> (String, usize) { - let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); - let text = r["runs"].as_array().unwrap().iter().filter_map(|run| run["text"].as_str()).collect(); - (text, app.session.active().unwrap().doc.layers.len()) - } - - /// While the Type tool edits text, a character typed with AltGr (Ctrl+Alt in Firefox on - /// Windows) is typed and runs no command: not New Layer Options… (Cmd+Alt+L), Reset Tracking - /// (Cmd+Alt+Q), Export for Screens (Cmd+Alt+E), Select All on Active Artboard (Cmd+Alt+A) or - /// Undo (Cmd+Z). - #[test] - fn altgr_characters_typed_in_text_run_no_command() { - let mut wrong = vec![]; - for (key, typed) in [(Key::L, "ł"), (Key::Q, "@"), (Key::E, "€"), (Key::A, "ą"), (Key::Z, "ż")] { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("text.setRangeStyle", &json!({"id": id, "tracking": 200, "kerning": -40})).unwrap(); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); - assert!(app.session.tool_wants_text()); - let layers = text_and_layers(&mut app, id).1; - let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.len(); - let before = selected(&app); - frame(&mut app, ctrl_alt_press(key, Some(typed))); - let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); - let spaced = - r["runs"].as_array().unwrap().iter().all(|run| run["style"]["tracking"] == json!(200.0) && run["style"]["kerning"] == json!(-40.0)); - let got = (text_and_layers(&mut app, id), spaced, selected(&app) == before, app.ui.dialog.is_none(), app.session.tool_wants_text()); - if got != ((format!("Hi{typed}"), layers), true, true, true, true) { - wrong.push(format!("{key:?}: (text, layers), tracking and kerning kept, selection kept, no dialog, still editing = {got:?}")); - } - } - assert!(wrong.is_empty(), "{wrong:#?}"); - } - - /// What egui reports for the command key: Cmd on a Mac, Ctrl elsewhere. The keymap reads a - /// Mac's Ctrl as a key of its own, so Ctrl+L there is not Cmd+L. - fn command_key() -> Modifiers { - if crate::keymap::platform().is_mac() { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND } - } - - /// Ctrl and Ctrl+Alt chords (Cmd and Cmd+Alt on a Mac) that type no text still run their - /// commands while the Type tool edits, and with the Selection tool a Ctrl+Alt press runs its - /// chord as before. Ctrl+L is New Layer, and Ctrl+Alt+L is New Layer Options…, which opens - /// Layer Options for a new layer. - #[test] - fn ctrl_alt_chords_without_typed_text_still_run() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert!(app.session.tool_wants_text()); - let layers = text_and_layers(&mut app, id).1; - let new_layer_options = - |app: &VectorcraftApp| app.ui.dialog.as_ref().is_some_and(|d| d.kind == "layerOptions" && d.fields.get("mode") == Some(&json!("new"))); - let m = command_key(); - frame(&mut app, press(Key::L, m)); - assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "Ctrl+L while editing: New Layer"); - assert!(app.ui.dialog.is_none(), "Ctrl+L opens no dialog"); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert!(app.session.tool_wants_text()); - frame(&mut app, press(Key::L, m | Modifiers::ALT)); - assert!(new_layer_options(&app), "Ctrl+Alt+L with no text typed while editing: New Layer Options…"); - assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK"); - crate::dialogs::cancel(&mut app); - app.select_tool("selection"); - let mut typed = press(Key::L, m | Modifiers::ALT); - typed.push(egui::Event::Text("ł".into())); - frame(&mut app, typed); - assert!(new_layer_options(&app), "Selection tool: Ctrl+Alt+L runs New Layer Options… as before"); - assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK"); - } - - /// Cmd as egui reports it on this platform: the Command key on macOS, Ctrl elsewhere. - fn cmd() -> Modifiers { - if crate::keymap::platform().is_mac() { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND } - } - - /// The web build keeps from the browser the presses the keymap binds: the View menu's zoom - /// chords, Undo and Redo, whatever the browser reports as the key typed and its position. - #[test] - fn presses_the_keymap_binds_run_a_shortcut() { - crate::shortcut_editor::sync(&crate::state::UiState::default()); - let (cmd, shift, alt) = (cmd(), Modifiers::SHIFT, Modifiers::ALT); - for (key, code, mods) in [ - ("=", "Equal", cmd), - ("+", "Equal", cmd | shift), - ("+", "NumpadAdd", cmd), - ("-", "Minus", cmd), - ("0", "Digit0", cmd), - ("º", "Digit0", cmd | alt), - ("z", "KeyZ", cmd), - ("Z", "KeyZ", cmd | shift), - ("[", "BracketLeft", cmd), - // German's `+` key, where US has `]`: the character decides. - ("+", "BracketRight", cmd), - ] { - assert!(runs_a_shortcut(key, code, mods), "{key} ({code}) {mods:?}"); - } - // Keys the browser has no name egui knows for are matched by their position. - assert!(runs_a_shortcut("Dead", "KeyZ", cmd), "a dead key at Z"); - for (key, code, mods) in [("Insert", "Numpad0", cmd), ("F13", "F13", cmd), ("Unidentified", "", cmd)] { - assert!(!runs_a_shortcut(key, code, mods), "{key} ({code}) runs nothing"); - } - } - - /// The user's shortcuts decide: a chord given to another command runs that one, and a chord - /// taken from its command runs nothing. - #[test] - fn presses_follow_the_users_shortcuts() { - let mut ui = crate::state::UiState::default(); - crate::keymap::set_override(&mut ui.shortcut_overrides, "view.zoomIn", ""); - crate::keymap::set_override(&mut ui.shortcut_overrides, "view.grid", "Cmd+Alt+Shift+F9"); - crate::shortcut_editor::sync(&ui); - let zoom_in = runs_a_shortcut("=", "Equal", cmd()); - let grid = runs_a_shortcut("F9", "F9", cmd() | Modifiers::ALT | Modifiers::SHIFT); - crate::shortcut_editor::sync(&crate::state::UiState::default()); - assert!(!zoom_in, "Zoom In has no shortcut"); - assert!(grid, "Show Grid's own chord"); - assert!(runs_a_shortcut("=", "Equal", cmd()), "the defaults again"); - } - - /// #2094: Cmd+Shift+O is Type › Create Outlines (not File › Open's Cmd+O, not the Artboard - /// tool's Shift+O), with the Selection tool or while the Type tool edits the text. - #[test] - fn cmd_shift_o_creates_outlines() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - assert_eq!(crate::menus::shortcut_of("type.createOutlines"), Some("Cmd+Shift+O")); - let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; - let chord = egui::Event::Key { key: Key::O, physical_key: None, pressed: true, repeat: false, modifiers: cmd_shift }; - let is_text = |app: &VectorcraftApp, id: u64| { - matches!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_))) - }; - let group_kids = |app: &VectorcraftApp| -> usize { - let id = app.session.active().unwrap().selection.objects.first().copied().unwrap(); - match &app.session.doc().unwrap().doc.node(id).unwrap().kind { - vectorcraft_doc::NodeKind::Group { children, .. } => children.len(), - _ => 0, - } - }; - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - assert!(is_text(&app, id)); - frame(&mut app, vec![chord.clone()]); - assert!(!is_text(&app, id), "Selection tool: the chord outlines the text"); - assert_eq!(group_kids(&app), 2, "H and i"); - assert_ne!(app.session.tool_id(), "artboard", "not the Artboard tool's Shift+O"); - assert!(app.ui.dialog.is_none(), "not File › Open"); - // While the Type tool edits, the same chord outlines; a Text event with Cmd held must not type. - let id = app.session.execute("text.create", &json!({"x": 20, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert!(app.session.tool_wants_text()); - frame(&mut app, vec![egui::Event::ModifiersChanged(cmd_shift), chord, egui::Event::Text("o".into())]); - assert!(!is_text(&app, id), "Type tool: the chord outlines the text being edited"); - assert_eq!(group_kids(&app), 2, "Yo only: the stray Text with Cmd was not typed"); - assert!(app.ui.dialog.is_none(), "not File › Open while typing"); - } - - /// Cmd+Shift+B while the Type tool edits text is Type › Bold, not Hide Bounding Box (#724). - #[test] - fn cmd_shift_b_while_typing_is_bold_not_the_bounding_box() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - app.select_tool("type"); - let view = app.view_info(); - for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { - app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 50.0, 50.0), view).unwrap(); - } - frame(&mut app, vec![egui::Event::Text("Bold".into())]); - assert!(app.session.tool_wants_text()); - let chord = - egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }; - frame(&mut app, vec![chord.clone()]); - assert!(app.ui.view.bounding_box, "the bounding box stays"); - // The chord reached Bold: the text is in a bold face, or its family has none and says so. - let face = crate::panels::character::text_style(&app).map(|(s, _)| s.font_style).unwrap_or_default(); - assert!(face.contains("Bold") || app.ui.status.contains("has no Bold style"), "{face:?} {:?}", app.ui.status); - // Out of the text, the chord hides the bounding box as before. - app.select_tool("selection"); - frame(&mut app, vec![chord]); - assert!(!app.ui.view.bounding_box); - } - - /// Cmd+Shift+L, C, R, J and F align paragraphs (#1111): every paragraph of the selected type, - /// or while the Type tool edits text, those its selection touches. - #[test] - fn cmd_shift_letters_align_paragraphs() { - use vectorcraft_doc::Justify; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); - let chord = - |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }; - let paras = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { - vectorcraft_doc::NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justify).collect::>(), - _ => panic!("text"), - }; - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - for (key, want) in [ - (Key::C, Justify::Center), - (Key::R, Justify::Right), - (Key::J, Justify::JustifyLeft), - (Key::F, Justify::JustifyAll), - (Key::L, Justify::Left), - ] { - frame(&mut app, vec![chord(key)]); - assert_eq!(paras(&app), [want, want], "{key:?}"); - } - // With the Type tool, only the paragraph its selection touches. - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 5, "end": 6})); - assert!(app.session.tool_wants_text()); - frame(&mut app, vec![chord(Key::R)]); - assert_eq!(paras(&app), [Justify::Left, Justify::Right]); - // Nothing selected: nothing to align. - app.select_tool("selection"); - app.session.execute("select.none", &json!({})).unwrap(); - assert!(!crate::menus::enabled(&app, "type.alignCenter")); - } - - /// Cmd+Alt+Q resets the tracking and kerning of the selected type (tracking 0, kerning Auto), - /// and of the Type tool's selected text while it edits. - #[test] - fn cmd_alt_q_resets_tracking_and_kerning() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two"})).unwrap()["id"].as_u64().unwrap(); - let spaced = - |app: &mut VectorcraftApp| app.session.execute("text.setRangeStyle", &json!({"id": id, "tracking": 200, "kerning": -40})).unwrap(); - let spacing = |app: &mut VectorcraftApp| { - let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); - r["runs"] - .as_array() - .unwrap() - .iter() - .map(|run| (run["text"].clone(), run["style"]["tracking"].clone(), run["style"]["kerning"].clone())) - .collect::>() - }; - let chord = - egui::Event::Key { key: Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT }; - spaced(&mut app); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - frame(&mut app, vec![chord.clone()]); - assert_eq!(spacing(&mut app), [(json!("One Two"), json!(0.0), json!(null))]); - // While the Type tool edits: only its selection. - spaced(&mut app); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 4, "end": 7})); - assert!(app.session.tool_wants_text()); - frame(&mut app, vec![chord]); - assert_eq!(spacing(&mut app), [(json!("One "), json!(200.0), json!(-40.0)), (json!("Two"), json!(0.0), json!(null))]); - } - - /// Cmd+Shift+X resets the horizontal scale of the selected type to 100%, and of the Type - /// tool's selected text while it edits. egui reports the chord as a Cut with Shift held, and - /// the type object is not cut. - #[test] - fn cmd_shift_x_resets_horizontal_scale() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two"})).unwrap()["id"].as_u64().unwrap(); - let stretched = |app: &mut VectorcraftApp| app.session.execute("text.setRangeStyle", &json!({"id": id, "hScale": 150})).unwrap(); - let scales = |app: &mut VectorcraftApp| { - let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); - r["runs"].as_array().unwrap().iter().map(|run| (run["text"].clone(), run["style"]["h_scale"].clone())).collect::>() - }; - let chord = vec![egui::Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT), egui::Event::Cut]; - stretched(&mut app); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - frame(&mut app, chord.clone()); - assert_eq!(scales(&mut app), [(json!("One Two"), json!(100.0))]); - // While the Type tool edits: only its selection, and the text stays. - stretched(&mut app); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 4, "end": 7})); - assert!(app.session.tool_wants_text()); - frame(&mut app, chord); - assert_eq!(scales(&mut app), [(json!("One "), json!(150.0)), (json!("Two"), json!(100.0))]); - } - - /// Tab shows and hides the panels, and in the Type tool it types a tab, without moving the - /// keyboard focus on to a field: the keys after it (a tool letter, the next letter typed) still - /// work. - #[test] - fn tab_leaves_the_keyboard_to_the_shortcuts_and_the_type_tool() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0)); - let run = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - }; - let press = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; - for _ in 0..3 { - run(&mut app, vec![]); - } - assert!(app.ui.dock && app.ui.toolbar); - run(&mut app, vec![press(Key::Tab)]); - run(&mut app, vec![]); - assert!(!app.ui.dock && !app.ui.toolbar, "Tab hid the panels"); - run(&mut app, vec![press(Key::Tab)]); - run(&mut app, vec![]); - assert!(app.ui.dock && app.ui.toolbar, "a second Tab shows them again"); - run(&mut app, vec![press(Key::P), egui::Event::Text("p".into())]); - assert_eq!(app.session.tool_id(), "pen", "and a tool letter after it works"); - // The Type tool: a, Tab, b is one text. - app.select_tool("type"); - let view = app.view_info(); - for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { - app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 50.0, 50.0), view).unwrap(); - } - run(&mut app, vec![egui::Event::Text("a".into())]); - run(&mut app, vec![press(Key::Tab)]); - run(&mut app, vec![egui::Event::Text("b".into())]); - let doc = app.session.execute("document.inspect", &json!({})).unwrap(); - assert_eq!(doc["layers"][0]["children"][0]["name"], "a\tb"); - } - - /// egui counts frames per viewport: two viewports of one context can both be on frame 0. Each - /// one must take its shortcut, not lose the second to a dedupe key that omits the viewport. - #[test] - fn two_viewports_on_the_same_frame_number_each_take_their_shortcut() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); - let aux = egui::ViewportId::from_hash_of("vectorcraft-shortcuts-aux"); - let tab = egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; - let mut frames = vec![]; - // The auxiliary viewport goes first so the root one has not advanced past frame 0. - for viewport in [aux, egui::ViewportId::ROOT] { - let mut raw = egui::RawInput { events: vec![tab.clone()], screen_rect: Some(screen), viewport_id: viewport, ..Default::default() }; - raw.viewports.entry(viewport).or_default(); - let mut out = ctx.run_ui(raw, |ui| { - frames.push(ui.ctx().cumulative_frame_nr_for(viewport)); - handle(&mut app, ui.ctx()); - }); - out.textures_delta.clear(); - } - assert_eq!(frames, [0, 0], "both viewports are on frame 0"); - assert!(app.ui.dock, "two Tabs in two viewports toggled the panels twice (the second was not a duplicate)"); - } - - /// Shift+Tab shows and hides the panels and leaves the toolbar and the Control bar as they are, - /// while Tab hides all three. Hiding the panels closes a panel popped out of the icon column. - /// In a focused field neither key shows or hides anything. - #[test] - fn shift_tab_hides_the_panels_but_not_the_toolbar_or_the_control_bar() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0)); - let run = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - }; - let press = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; - let shown = |app: &VectorcraftApp| (app.ui.dock, app.ui.toolbar, app.ui.control_bar); - app.ui.control_bar = true; - for _ in 0..3 { - run(&mut app, vec![]); - } - assert_eq!(shown(&app), (true, true, true)); - app.run("window.panel", json!({"panel": "color"})).unwrap(); - run(&mut app, vec![press(Modifiers::SHIFT)]); - run(&mut app, vec![]); - assert_eq!(shown(&app), (false, true, true), "Shift+Tab hid the panels only"); - assert_eq!(app.ui.open_panel, None, "and the panel popped out of the icon column"); - run(&mut app, vec![press(Modifiers::SHIFT)]); - run(&mut app, vec![]); - assert_eq!(shown(&app), (true, true, true), "a second Shift+Tab shows them again"); - app.run("window.panel", json!({"panel": "color"})).unwrap(); - run(&mut app, vec![press(Modifiers::NONE)]); - run(&mut app, vec![]); - assert_eq!(shown(&app), (false, false, false), "Tab hides the toolbar and the Control bar too"); - assert_eq!(app.ui.open_panel, None); - run(&mut app, vec![press(Modifiers::NONE)]); - run(&mut app, vec![]); - assert_eq!(shown(&app), (true, true, true)); - // A field of the Color panel has the keyboard focus: Shift+Tab and Tab do not show or hide anything. - app.run("window.panel", json!({"panel": "color"})).unwrap(); - for _ in 0..3 { - run(&mut app, vec![]); - } - let fields: Vec<(egui::Id, egui::Rect)> = ctx.viewport(|vp| { - vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.sense.senses_click()).map(|w| (w.id, w.rect)).collect() - }); - let (id, rect) = - fields.into_iter().find(|&(id, _)| egui::TextEdit::load_state(&ctx, id).is_some()).unwrap_or_else(|| panic!("no field on screen")); - let click = - |pressed| egui::Event::PointerButton { pos: rect.center(), button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - run(&mut app, vec![egui::Event::PointerMoved(rect.center()), click(true)]); - run(&mut app, vec![click(false)]); - assert!(ctx.memory(|m| m.has_focus(id)), "the field has the keyboard"); - for modifiers in [Modifiers::SHIFT, Modifiers::NONE] { - run(&mut app, vec![press(modifiers)]); - run(&mut app, vec![]); - assert_eq!(shown(&app), (true, true, true), "{modifiers:?}"); - assert_eq!(app.ui.open_panel.as_deref(), Some("color"), "{modifiers:?}"); - } - } - - /// The second Tab shows again only the bars the first Tab hid: a Control bar that was off stays - /// off, and a toolbar hidden with Window › Tools stays hidden, after a restart too. Shift+Tab - /// between two Tabs, and Tab between two Shift+Tabs, leave a state the same key goes back from. - /// After a switch of workspace, Tab hides and shows that workspace's bars. - #[test] - fn tab_shows_again_only_the_bars_it_hid() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0)); - let run = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - }; - let press = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; - let tab = |app: &mut VectorcraftApp, modifiers| { - run(app, vec![press(modifiers)]); - run(app, vec![]); - (app.ui.dock, app.ui.toolbar, app.ui.control_bar) - }; - for _ in 0..3 { - run(&mut app, vec![]); - } - assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (true, true, false), "the Control bar starts off"); - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "the second Tab leaves the Control bar off"); - app.run("window.toolbar", json!({})).unwrap(); - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (true, false, false), "a toolbar hidden with Window › Tools stays hidden"); - app.run("window.toolbar", json!({})).unwrap(); - // Hidden by Tab when the app quits: the next Tab after a restart shows the same bars. - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - app.ui = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); - assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "after a restart"); - // Shift+Tab after Tab shows the panels, and a second Shift+Tab hides them again; Tab then - // shows what the first Tab hid. - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false)); - assert_eq!(tab(&mut app, Modifiers::SHIFT), (false, false, false)); - assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "Tab after Shift+Tab"); - // Tab after Shift+Tab hides the toolbar, and a second Tab goes back to Shift+Tab's state. - assert_eq!(tab(&mut app, Modifiers::SHIFT), (false, true, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (false, true, false), "Shift+Tab's state"); - // Panels shown with Shift+Tab between two Tabs stay shown. - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "the panels Shift+Tab showed"); - // A bar shown another way after Tab: the next Tab hides it, and the Tab after that shows it again. - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - app.run("window.control", json!({})).unwrap(); - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, true), "only the Control bar"); - // Nothing shows and Tab hid nothing: Tab shows all three. - app.run("window.control", json!({})).unwrap(); - assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (false, false, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, true)); - // After a switch of workspace, Tab hides that workspace's bars and shows them again. - app.run("window.toolbar", json!({})).unwrap(); - app.run("window.control", json!({})).unwrap(); - app.run("window.workspace.new", json!({"name": "Panels Only"})).unwrap(); - app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); - app.run("window.workspace", json!({"name": "Panels Only"})).unwrap(); - assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (true, false, false)); - assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false), "Tab after a switch of workspace"); - assert_eq!(tab(&mut app, Modifiers::NONE), (true, false, false)); - } - - #[test] - fn copy_and_paste_events_use_the_system_clipboard() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - // Copy publishes SVG to the system clipboard (egui's CopyText output command). - let out = frame(&mut app, vec![egui::Event::Copy]); - let copied = out.platform_output.commands.iter().find_map(|c| match c { - egui::OutputCommand::CopyText(t) => Some(t.clone()), - _ => None, - }); - let svg = copied.expect("copy publishes SVG"); - assert!(svg.contains(""##; - frame(&mut app, vec![egui::Event::Paste(foreign.into())]); - assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 5); - // Plain text is not art: nothing is pasted from it (the internal clipboard is reused). - frame(&mut app, vec![egui::Event::Paste("hello".into())]); - assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 8); - } - - /// While a grid is dragged out, ↑/↓ change its rows and ←/→ its columns. - #[test] - fn arrows_while_dragging_a_grid_change_its_rows_and_columns() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("rectangularGrid"); - let view = app.view_info(); - // Drag a grid out, pressing `keys` on the way: its (horizontal, vertical) dividers. - let grid = |app: &mut VectorcraftApp, keys: &[Key]| { - app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 80.0), view).unwrap(); - app.session.pointer(&PointerEvent::new(PointerKind::Drag, 300.0, 220.0), view).unwrap(); - for &key in keys { - let press = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; - frame(app, vec![press]); - } - app.session.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 220.0), view).unwrap(); - let d = &app.session.doc().unwrap().doc; - let g = d.layers[0].children().unwrap().last().unwrap().clone(); - let vectorcraft_doc::NodeKind::Group { children, .. } = &g.kind else { panic!("not a grid: {:?}", g.kind) }; - let bounds: Vec<_> = children.iter().filter_map(|c| c.geometric_bounds()).collect(); - (bounds.iter().filter(|b| b.height() < 0.5).count(), bounds.iter().filter(|b| b.width() < 0.5).count()) - }; - let (rows, columns) = grid(&mut app, &[]); - assert_eq!(grid(&mut app, &[Key::ArrowRight, Key::ArrowRight, Key::ArrowUp]), (rows + 1, columns + 2)); - assert_eq!(grid(&mut app, &[Key::ArrowLeft, Key::ArrowDown]), (rows, columns + 1)); - } - - /// A press of `key` with `m` held. - fn press(key: Key, m: Modifiers) -> Vec { - vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }] - } - - /// Binds Show Grid to `chord`, or with `None` leaves every command its default shortcut. - fn bind_grid(app: &mut VectorcraftApp, chord: Option<&str>) { - app.ui.shortcut_overrides.clear(); - if let Some(c) = chord { - app.ui.shortcut_overrides.insert("view.grid", vec![c.to_string()]); - } - crate::shortcut_editor::sync(&app.ui); - } - - /// Clicks the active tool at (`x`, 200) and returns how many objects the first layer holds. - fn click_at(app: &mut VectorcraftApp, x: f64) -> usize { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let view = app.view_info(); - for kind in [PointerKind::Down, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, x, 200.0), view).unwrap(); - } - app.session.doc().unwrap().doc.layers[0].children().unwrap().len() - } - - /// A command bound to Cmd+Enter runs without the active tool's Enter: the Selection tool opens - /// no Move dialog, the Type tool types no line break, and the Pen tool's path stays open. With - /// nothing bound to it, Cmd+Enter goes to the tool as Enter does. - #[test] - fn a_command_on_cmd_enter_runs_without_the_tools_enter() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap()["id"].clone(); - let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); - let grid = app.ui.view.grid; - app.select_tool("selection"); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); - assert_eq!((kind(&app).as_deref(), app.ui.view.grid), (Some("move"), grid), "nothing bound: Cmd+Enter opens Move as Enter does"); - app.ui.dialog = None; - bind_grid(&mut app, Some("Cmd+Enter")); - frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); - assert_eq!((kind(&app), app.ui.view.grid), (None, !grid), "Cmd+Enter runs Show Grid only"); - frame(&mut app, press(Key::Enter, Modifiers::NONE)); - assert_eq!((kind(&app).as_deref(), app.ui.view.grid), (Some("move"), !grid), "Enter still opens Move"); - app.ui.dialog = None; - let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert!(app.session.tool_wants_text()); - frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); - let typed = text_and_layers(&mut app, text).0; - assert_eq!((typed.as_str(), app.ui.view.grid), ("Hi", grid), "Cmd+Enter runs Show Grid and types no line break"); - app.session.execute("select.none", &json!({})).unwrap(); - app.select_tool("pen"); - click_at(&mut app, 100.0); - let objects = click_at(&mut app, 150.0); - frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); - assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects, !grid), "Cmd+Enter runs Show Grid and the path stays open"); - bind_grid(&mut app, None); - frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); - assert_eq!(click_at(&mut app, 250.0), objects + 1, "nothing bound: Cmd+Enter ends the path as Enter does"); - } - - /// A command bound to Cmd+Escape runs without the active tool's Escape: the Pen tool's path - /// stays open, isolation mode stays on, and the Type tool goes on editing its text. With nothing - /// bound to it, Cmd+Escape goes to the tool as Escape does. Windows keeps Cmd+Escape (Ctrl+Esc) - /// for the Start menu, so no command can be bound to it there and it goes to the tool. - #[test] - fn a_command_on_cmd_escape_runs_without_the_tools_escape() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap()["id"].clone(); - let grid = app.ui.view.grid; - let cmd_escape = || press(Key::Escape, Modifiers::COMMAND); - let bindable = crate::keymap::platform() != craft_keymap::Platform::Windows; - bind_grid(&mut app, Some("Cmd+Escape")); - app.select_tool("pen"); - click_at(&mut app, 100.0); - let objects = click_at(&mut app, 150.0); - frame(&mut app, cmd_escape()); - if bindable { - assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects, !grid), "Cmd+Escape runs Show Grid and the path stays open"); - } else { - assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects + 1, grid), "Windows: Cmd+Escape stays unbound and ends the path"); - } - let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].clone(); - let edit = |app: &mut VectorcraftApp| { - app.session.execute("select.set", &json!({"ids": [text]})).unwrap(); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert!(app.session.tool_wants_text()); - }; - if bindable { - edit(&mut app); - frame(&mut app, cmd_escape()); - assert_eq!( - (app.session.tool_wants_text(), app.ui.view.grid), - (true, grid), - "Cmd+Escape runs Show Grid and the Type tool goes on editing" - ); - } - app.select_tool("selection"); - app.session.execute("object.isolate", &json!({"id": id})).unwrap(); - let isolated = |app: &VectorcraftApp| app.session.active().unwrap().isolation.is_some(); - if bindable { - frame(&mut app, cmd_escape()); - assert_eq!((isolated(&app), app.ui.view.grid), (true, !grid), "Cmd+Escape runs Show Grid and isolation mode stays on"); - } - bind_grid(&mut app, None); - frame(&mut app, cmd_escape()); - assert!(!isolated(&app), "nothing bound: Cmd+Escape leaves isolation mode as Escape does"); - edit(&mut app); - frame(&mut app, cmd_escape()); - assert!(!app.session.tool_wants_text(), "nothing bound: Cmd+Escape ends the editing as Escape does"); - app.session.execute("select.none", &json!({})).unwrap(); - app.select_tool("pen"); - let objects = click_at(&mut app, 300.0); - frame(&mut app, cmd_escape()); - assert_eq!(click_at(&mut app, 350.0), objects + 1, "nothing bound: Cmd+Escape ends the path as Escape does"); - } - - /// Enter opens the tool's dialog: the Move dialog for the selection tools, the tool's own for - /// Rotate, Scale, Reflect and Shear. The Enter that opens it isn't also its OK. - #[test] - fn enter_opens_the_tools_dialog_and_leaves_it_open() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; - // A whole app frame: the shortcuts first, then the UI, where a dialog takes its Enter. - let frame = |app: &mut VectorcraftApp, events: Vec| { - let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - }; - let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); - // The undo steps and the journal entries (the dialog's OK runs its command, which the journal records). - let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); - app.select_tool("selection"); - frame(&mut app, vec![enter()]); - assert_eq!(kind(&app), None, "nothing is selected"); - app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap(); - for (tool, dialog) in [ - ("selection", "move"), - ("directSelection", "move"), - ("groupSelection", "move"), - ("rotate", "rotate"), - ("scale", "scale"), - ("reflect", "reflect"), - ("shear", "shear"), - ] { - app.select_tool(tool); - let before = steps(&app); - frame(&mut app, vec![enter()]); - assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: Enter opens it"); - frame(&mut app, vec![]); - assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open"); - assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it"); - // The next Enter is its OK. With its default values only Reflect changes the art, so only - // Reflect records an undo step. - frame(&mut app, vec![enter()]); - let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1); - assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms"); - } - app.select_tool("rectangle"); - frame(&mut app, vec![enter()]); - assert_eq!(kind(&app), None, "another tool has its own Enter"); - } - - /// A drag with the Selection tool, then Enter: the Move dialog opens on the drag's offset, and - /// Enter is its OK. With Copy ticked once, each Enter, Enter after that makes another copy at - /// the same spacing. - #[test] - fn enter_after_a_drag_repeats_its_offset() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); - let ctx = egui::Context::default(); - let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; - let frame = |app: &mut VectorcraftApp, events: Vec| { - let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - }; - let lefts = |app: &VectorcraftApp| { - let kids = app.session.doc().unwrap().doc.layers[0].children().unwrap(); - kids.iter().map(|n| n.geometric_bounds().unwrap().x0.round()).collect::>() - }; - app.select_tool("selection"); - let view = app.view_info(); - for (kind, x, y) in - [(PointerKind::Down, 20.0, 20.0), (PointerKind::Drag, 40.0, 20.0), (PointerKind::Drag, 60.0, 20.0), (PointerKind::Up, 60.0, 20.0)] - { - app.session.pointer(&PointerEvent::new(kind, x, y), view).unwrap(); - } - let [moved] = lefts(&app)[..] else { panic!("one rectangle") }; - let step = moved - 10.0; - assert!(step > 0.0); - // Keys reach the canvas from the second frame on. - frame(&mut app, vec![]); - frame(&mut app, vec![enter()]); - let d = app.ui.dialog.as_mut().expect("Enter opens Move"); - assert_eq!((d.kind.as_str(), d.f64("dx", f64::NAN).round(), d.f64("dy", f64::NAN)), ("move", step, 0.0)); - d.fields.insert("copy".into(), json!(true)); - frame(&mut app, vec![enter()]); - for _ in 0..2 { - frame(&mut app, vec![enter()]); - frame(&mut app, vec![enter()]); - } - assert!(app.ui.dialog.is_none()); - assert_eq!(lefts(&app), (0..4).map(|i| moved + step * f64::from(i)).collect::>()); - // With Preview on too, each Enter, Enter still makes one copy, and one undo step. - frame(&mut app, vec![enter()]); - app.ui.dialog.as_mut().expect("Move").fields.insert("preview".into(), json!(true)); - frame(&mut app, vec![enter()]); - let steps = app.session.active().unwrap().history.undo.len(); - for _ in 0..2 { - frame(&mut app, vec![enter()]); - assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("preview") && d.bool("copy")) && app.session.in_interaction()); - frame(&mut app, vec![enter()]); - assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); - } - assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 2); - assert_eq!(lefts(&app), (0..7).map(|i| moved + step * f64::from(i)).collect::>()); - } - - /// #1661: typed `[` and `]` reach the tool that takes them: the Blob Brush's and the Eraser's - /// size (Option or AltGr brackets too), the stroke weight left as it is; other tools leave them - /// alone. - #[test] - fn brackets_size_the_blob_brush_and_the_eraser() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - app.session.execute("stroke.set", &json!({"weight": 3})).unwrap(); - let size = |app: &mut VectorcraftApp| app.session.tool_options()["size"].as_f64().unwrap(); - let text = |t: &str| egui::Event::Text(t.into()); - for tool in ["blobBrush", "eraser"] { - app.select_tool(tool); - let before = size(&mut app); - frame(&mut app, vec![text("]")]); - frame(&mut app, vec![text("]")]); - assert_eq!(size(&mut app), before + 2.0, "{tool}"); - // Option-8 types `[` on a Turkish Mac keyboard. - let option_8 = egui::Event::Key { key: Key::Num8, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }; - frame(&mut app, vec![option_8, text("[")]); - assert_eq!(size(&mut app), before + 1.0, "{tool}: a bracket typed with Option"); - } - assert_eq!(app.session.paint.stroke_width, 3.0, "the stroke weight stays"); - app.select_tool("rectangle"); - frame(&mut app, vec![text("[")]); - assert_eq!(app.session.paint.stroke_width, 3.0); - } - - #[test] - fn comma_and_period_reapply_the_last_colour_and_gradient() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - app.session.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); - app.session.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); - let fill = |app: &VectorcraftApp| crate::panels::current_paints(app).0; - frame(&mut app, vec![egui::Event::Text(".".into())]); - assert!(matches!(fill(&app), vectorcraft_color::Paint::Gradient(_)), "`.` applies the last gradient"); - frame(&mut app, vec![egui::Event::Text(",".into())]); - assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour"); - } - - #[test] - fn shifted_punctuation_shortcuts_work_as_typed() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let a = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); - let b = app.session.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); - let order = - |app: &VectorcraftApp| -> Vec { app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect() }; - let (a, b) = (a.as_u64().unwrap(), b.as_u64().unwrap()); - assert_eq!(order(&app), [a, b]); - let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; - // Recorded in the Keyboard Shortcuts editor, they read as the keys (and so conflict). - assert_eq!(crate::shortcut_editor::chord_from_event(Key::CloseCurlyBracket, cmd_shift).as_deref(), Some("Cmd+Shift+]")); - assert_eq!(crate::shortcut_editor::chord_from_event(Key::Questionmark, cmd_shift).as_deref(), Some("Cmd+Shift+/")); - // Cmd+Shift+] arrives as Cmd+Shift+`}`, Cmd+Shift+[ as Cmd+Shift+`{`. - frame(&mut app, vec![press(Key::CloseCurlyBracket, cmd_shift)]); - assert_eq!(order(&app), [b, a], "Bring to Front"); - frame(&mut app, vec![press(Key::OpenCurlyBracket, cmd_shift)]); - assert_eq!(order(&app), [a, b], "Send to Back"); - // Cmd+Shift+/ arrives as Cmd+Shift+`?`: Search Commands. - frame(&mut app, vec![press(Key::Questionmark, cmd_shift)]); - assert!(app.ui.palette_open, "Search Commands"); - app.ui.palette_open = false; - // The Curvature tool's Shift+~ arrives as the text `~` with Shift. - frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), egui::Event::Text("~".into())]); - assert_eq!(app.session.tool_id(), "curvature"); - } - - /// Cmd/Ctrl+Alt+Shift+. and , step the font size by five Size/Leading increments, and - /// Cmd/Ctrl+Shift+. still steps it by one: the chord with Alt does not reach the one-step - /// command. - #[test] - fn cmd_alt_shift_period_and_comma_step_the_font_size_by_five() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two", "size": 12})).unwrap()["id"].as_u64().unwrap(); - let sizes = |app: &mut VectorcraftApp| { - let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); - r["runs"].as_array().unwrap().iter().map(|run| (run["text"].clone(), run["style"]["size"].clone())).collect::>() - }; - let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - let five = Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT; - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - frame(&mut app, vec![press(Key::Period, five)]); - assert_eq!(sizes(&mut app), [(json!("One Two"), json!(22.0))]); - frame(&mut app, vec![press(Key::Period, Modifiers::COMMAND | Modifiers::SHIFT)]); - assert_eq!(sizes(&mut app), [(json!("One Two"), json!(24.0))], "one step"); - frame(&mut app, vec![press(Key::Comma, five)]); - assert_eq!(sizes(&mut app), [(json!("One Two"), json!(14.0))]); - // While the Type tool edits: only its selection. - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 4, "end": 7})); - assert!(app.session.tool_wants_text()); - frame(&mut app, vec![press(Key::Period, five)]); - assert_eq!(sizes(&mut app), [(json!("One "), json!(14.0)), (json!("Two"), json!(24.0))]); - } - - /// #3786: Alt+arrows step a selected text object's spacing instead of copying and nudging it. - #[test] - fn alt_arrows_step_selected_type_object() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two", "size": 20})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("selection"); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - - let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT)]); - let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); - assert_eq!(r["runs"][0]["style"]["tracking"], json!(20.0)); - assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]); - - frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowDown, Modifiers::ALT)]); - let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); - assert_eq!(r["runs"][0]["style"]["leading"], json!(26.0)); - - let alt_shift = Modifiers::ALT | Modifiers::SHIFT; - frame(&mut app, vec![egui::Event::ModifiersChanged(alt_shift), press(Key::ArrowUp, alt_shift)]); - let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); - assert_eq!(r["runs"][0]["style"]["baseline_shift"], json!(2.0)); - - // Shift+Alt+Left/Right have no text-spacing action; they must still be consumed instead of - // falling through to Alt+arrow's copy-and-nudge behaviour. - for key in [Key::ArrowLeft, Key::ArrowRight] { - frame(&mut app, vec![egui::Event::ModifiersChanged(alt_shift), press(key, alt_shift)]); - assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]); - } - - // Other objects keep Alt+arrow's copy-and-nudge shortcut. - let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.set", &json!({"ids": [rect]})).unwrap(); - frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT)]); - let selected = &app.session.active().unwrap().selection.objects; - assert_eq!(selected.len(), 1); - assert_ne!(selected[0], vectorcraft_doc::NodeId(rect), "a non-type object is still copied by Alt+arrow"); - } - - /// #793: with a layout without Latin letters the one-key shortcuts go by the key's position: - /// the P key typing Persian `ح` chooses the Pen, the V key typing Russian `м` the Selection - /// tool. A Latin layout keeps the letter typed: AZERTY's A (the Q key's place) is A. - #[test] - fn one_key_shortcuts_follow_the_key_on_layouts_without_latin_letters() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let typed = |key, physical, text: &str| { - vec![ - egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE }, - egui::Event::Text(text.into()), - ] - }; - frame(&mut app, typed(Key::P, Key::P, "ح")); - assert_eq!(app.session.tool_id(), "pen"); - frame(&mut app, typed(Key::V, Key::V, "м")); - assert_eq!(app.session.tool_id(), "selection"); - frame(&mut app, typed(Key::A, Key::Q, "a")); - assert_eq!(app.session.tool_id(), "directSelection", "AZERTY: the letter typed"); - } - - #[test] - fn plus_typed_any_way_zooms_in() { - assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key"); - assert_eq!(crate::shortcut_editor::chord_from_event(Key::Plus, Modifiers::COMMAND).as_deref(), Some("Cmd+="), "a recorded `+` too"); - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - let zoom = |app: &mut VectorcraftApp| app.view_mut().unwrap().zoom; - let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; - // `=`, Shift+`=` (reported as `=` or as `+`), and the numpad `+` / a layout's own `+` key. - for (key, m) in [(Key::Equals, Modifiers::COMMAND), (Key::Equals, cmd_shift), (Key::Plus, cmd_shift), (Key::Plus, Modifiers::COMMAND)] { - let before = zoom(&mut app); - frame(&mut app, vec![press(key, m)]); - assert!(zoom(&mut app) > before, "{key:?} with {m:?} zooms in"); - } - let before = zoom(&mut app); - frame(&mut app, vec![press(Key::Minus, Modifiers::COMMAND)]); - assert!(zoom(&mut app) < before, "Cmd+- zooms out"); - } - - /// `+` selects the Add Anchor Point tool, as `-` selects Delete Anchor Point (#2142): typed as - /// Shift+`=` on a US layout, or by a layout's own `+` key or the numpad's. - #[test] - fn plus_selects_the_add_anchor_point_tool() { - assert_eq!(parse("+"), parse("="), "a bare `+` is the `=` key"); - assert_eq!(crate::shortcut_editor::tool_for_key("+"), Some("addAnchor")); - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - for m in [Modifiers::SHIFT, Modifiers::NONE] { - app.select_tool("selection"); - frame(&mut app, vec![egui::Event::ModifiersChanged(m), egui::Event::Text("+".into())]); - assert_eq!(app.session.tool_id(), "addAnchor", "`+` with {m:?}"); - } - frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::NONE), egui::Event::Text("-".into())]); - assert_eq!(app.session.tool_id(), "deleteAnchor"); - } - - #[test] - fn digit_keys_reach_a_dragging_tool_once_per_press() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - app.session.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); - let id = app.session.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone(); - app.session.execute("perspective.attach", &json!({"ids": [id], "plane": "right"})).unwrap(); - app.select_tool("perspectiveSelection"); - let v = app.view_info(); - let c = - app.session.doc().unwrap().doc.node(vectorcraft_engine::doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().center(); - app.session.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); - app.session.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 30.0, c.y), v).unwrap(); - let key = |repeat| egui::Event::Key { key: Key::Num5, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }; - // A held key repeats: only its first press toggles. - frame(&mut app, vec![key(false), key(true)]); - let preview = app.session.doc().unwrap().interaction.as_ref().unwrap().preview.clone().unwrap(); - assert_eq!((preview.0.as_str(), &preview.1["perpendicular"]), ("perspective.move", &json!(true))); - assert_eq!(digit_of(Key::Num0), Some(0)); - assert_eq!(digit_of(Key::A), None); - } - - /// E pressed while the Selection tool drags a marquee makes it select only the objects inside - /// it: once per press, without Cmd, and from the key at E's position on a layout without Latin - /// letters. - #[test] - fn e_reaches_a_dragged_marquee_once_per_press() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); - let inside = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); - let touched = app.session.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); - app.select_tool("selection"); - let e = |key, repeat, modifiers| egui::Event::Key { key, physical_key: Some(Key::E), pressed: true, repeat, modifiers }; - let mut marquee = |events: Vec| { - let v = app.view_info(); - app.session.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); - app.session.pointer(&PointerEvent::new(PointerKind::Drag, 350.0, 250.0), v).unwrap(); - frame(&mut app, events); - app.session.pointer(&PointerEvent::new(PointerKind::Up, 350.0, 250.0), v).unwrap(); - let ids = app.session.doc().unwrap().selection.objects.iter().map(|i| json!(i.0)).collect::>(); - assert_eq!(app.session.tool_id(), "selection"); - ids - }; - assert_eq!(marquee(vec![]), vec![inside.clone(), touched.clone()]); - // A held key repeats: only its first press switches. - assert_eq!(marquee(vec![e(Key::E, false, Modifiers::NONE), e(Key::E, true, Modifiers::NONE)]), vec![inside.clone()]); - let up = egui::Event::Key { key: Key::E, physical_key: Some(Key::E), pressed: false, repeat: false, modifiers: Modifiers::NONE }; - let twice = vec![e(Key::E, false, Modifiers::NONE), up, e(Key::E, false, Modifiers::NONE)]; - assert_eq!(marquee(twice), vec![inside.clone(), touched.clone()], "pressed twice: what it touches"); - // A Russian layout: egui-winit reports the key at E's position, and the text it types. - assert_eq!(marquee(vec![e(Key::E, false, Modifiers::NONE), egui::Event::Text("у".into())]), vec![inside.clone()]); - assert_eq!(marquee(vec![e(Key::E, false, Modifiers::COMMAND)]), vec![inside, touched], "Cmd+E is not E"); - assert_eq!(letter_of(Key::E), Some('e')); - assert_eq!(letter_of(Key::Num1), None); - assert_eq!(letter_of(Key::Escape), None); - } - - /// Shortcuts reach tools, panels (`window.panel {panel}`) and commands through the keymap, - /// and a held Cmd+= keeps zooming in. - #[test] - fn shortcuts_run_tools_panels_and_commands() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); - app.session.execute("select.set", &json!({"ids": []})).unwrap(); - let press = |key, modifiers, repeat| egui::Event::Key { key, physical_key: None, pressed: true, repeat, modifiers }; - crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:pen", "F2"); - crate::shortcut_editor::sync(&app.ui); - frame(&mut app, vec![press(Key::F2, Modifiers::NONE, false)]); - assert_eq!(app.session.tool_id(), "pen", "a tool on a key that types nothing"); - app.select_tool("selection"); - frame(&mut app, vec![press(Key::F7, Modifiers::SHIFT, false)]); - assert_eq!(app.ui.open_panel.as_deref(), Some("align")); - frame(&mut app, vec![press(Key::A, Modifiers::COMMAND, false)]); - assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "Select All"); - let zoom = |app: &mut VectorcraftApp| app.view_mut().unwrap().zoom; - frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND, false)]); - let once = zoom(&mut app); - frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND, true)]); - assert!(zoom(&mut app) > once, "a key repeat zooms again"); - } - - /// Cmd+, opens Settings on a Mac only, beside the frame's other shortcuts, and never takes a - /// chord a shortcut already has. - #[test] - fn cmd_comma_opens_settings_on_a_mac() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - let on = |app: &mut VectorcraftApp, os, events| { - app.ui.dialog = None; - app.session.execute("select.set", &json!({"ids": []})).unwrap(); - let ctx = egui::Context::default(); - ctx.set_os(os); - ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| handle(app, ui.ctx())).textures_delta.clear(); - let settings = app.ui.dialog.as_ref().is_some_and(|d| d.kind == "preferences"); - (settings, app.session.active().unwrap().selection.objects.len()) - }; - let (mac, linux) = (egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Nix); - let comma = press(Key::Comma, Modifiers::COMMAND); - let select_all = press(Key::A, Modifiers::COMMAND); - assert_eq!(on(&mut app, mac, vec![comma.clone()]), (true, 0)); - assert_eq!(on(&mut app, mac, vec![select_all.clone(), comma.clone()]), (true, 1), "with another shortcut in the frame"); - assert_eq!(on(&mut app, linux, vec![select_all, comma.clone()]), (false, 1), "not elsewhere"); - crate::keymap::set_override(&mut app.ui.shortcut_overrides, "select.all", "Cmd+,"); - crate::shortcut_editor::sync(&app.ui); - assert_eq!(on(&mut app, mac, vec![comma]), (false, 1), "a shortcut that has it keeps it"); - } - - #[test] - fn parses() { - let s = parse("Cmd+Shift+]").unwrap(); - assert_eq!(s.logical_key, Key::CloseBracket); - assert!(s.modifiers.shift && s.modifiers.command); - assert_eq!(parse("Cmd+=").unwrap().logical_key, Key::Equals); - assert_eq!(parse("Cmd+Alt+2").unwrap().logical_key, Key::Num2); - assert_eq!(parse("F12").unwrap().logical_key, Key::F12); - assert!(parse("").is_none()); - } - - /// An IME replacement on the Type tool arrives as `DeleteSurroundingBytes` + `Commit` events - /// and is taken once, in order: the marked text is dropped, the committed text replaces it, and - /// the events never reach `route_type_input` as typed characters. - #[test] - fn ime_delete_surrounding_bytes_edits_the_type_tool_once() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let v = vectorcraft_engine::ViewInfo::default(); - app.session.select_tool("type", v).unwrap(); - app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); - app.session.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 100.0), v).unwrap(); - app.session.tool_text("Héllo", v).unwrap(); - let ctx = egui::Context::default(); - // Fonts install on the first logic pass and are ready on the second. - for _ in 0..2 { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); - out.textures_delta.clear(); - } - let events = vec![ - egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 3, after_bytes: 0 }), - egui::Event::Ime(egui::ImeEvent::Commit("y".into())), - ]; - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.logic(ui.ctx())); - out.textures_delta.clear(); - let mut text = String::new(); - if let Some(st) = app.session.active() { - st.doc.walk(|node| { - if let vectorcraft_doc::NodeKind::Text(t) = &node.kind { - text = t.plain_text(); - } - }); - } - assert_eq!(text, "Héy"); - assert!(app.session.tool_wants_text(), "editing continues after the replacement"); - } - - /// X11/Wayland PRIMARY middle-click after a press in a panel field: `handle` runs before the - /// fields are drawn, and must leave the event for egui's text field (it pastes even without - /// focus). Deleting it here made PRIMARY paste dead outside dialogs. - #[test] - fn middle_click_paste_reaches_an_unfocused_text_field() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 200.0)); - let id = egui::Id::unique("middle-click-paste-test"); - let mut buf = String::new(); - let mut at = egui::pos2(40.0, 40.0); - for step in 0..2 { - let events = - if step == 1 { vec![egui::Event::PointerMoved(at), egui::Event::MiddleClickPaste { pos: at, text: "x".into() }] } else { vec![] }; - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { - handle(&mut app, ui.ctx()); - let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id)); - at = resp.rect.center(); - }); - out.textures_delta.clear(); - } - assert_eq!(buf, "x"); - } - - /// A shortcut is taken once per frame, but only within the context that frame belongs to: a - /// new context starts again at frame 0 (a restarted host, or a test's fresh context) and its - /// input is new, not a rerun of the old context's frame 0. - #[test] - fn a_new_context_takes_its_own_frame_zero() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - let press = |app: &mut VectorcraftApp| { - let ctx = egui::Context::default(); - let ev = - egui::Event::Key { key: Key::N, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT }; - let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| handle(app, ui.ctx())); - out.textures_delta.clear(); - }; - press(&mut app); - assert_eq!(app.session.documents().len(), 1, "New Without Dialog makes a document"); - press(&mut app); - assert_eq!(app.session.documents().len(), 2, "a second context's frame 0 is new input"); - } - - #[test] - fn all_registered_shortcuts_parse() { - for c in vectorcraft_engine::command_specs() { - if let Some(s) = c.shortcut - && s != "D" - && s != "X" - && s != "Shift+X" - && s != "/" - { - assert!(parse(s).is_some(), "{} has unparsable shortcut {s}", c.id); - } - } - for c in crate::menus::UI_COMMANDS { - if !c.2.is_empty() && c.2 != "F" && c.2 != "Shift+F" { - assert!(parse(c.2).is_some(), "{} has unparsable shortcut {}", c.0, c.2); - } - } - } -} +mod tests; diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs new file mode 100644 index 000000000..8edeeafd1 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -0,0 +1,1240 @@ +use super::*; + +/// One headless frame delivering `events` to the shortcut handler. +fn frame(app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + // The production path: `logic` takes this host iteration's input once through + // `handle_input_once`, which runs the shortcut handler when the fonts are ready. A + // direct call covers the passes before those fonts are installed; the frame key makes + // it a no-op on the passes that already took this frame. + app.logic(ui.ctx()); + handle(app, ui.ctx()); + }); + out.textures_delta.clear(); + out +} + +#[test] +fn option_and_altgr_still_type_but_cmd_and_ctrl_chords_do_not() { + use egui::Modifiers as M; + assert!(is_shortcut_chord(M::COMMAND | M::SHIFT)); + assert!(is_shortcut_chord(M::CTRL)); + assert!(!is_shortcut_chord(M::NONE)); + assert!(!is_shortcut_chord(M::SHIFT)); + assert!(!is_shortcut_chord(M::ALT), "Option+key types é, ø on macOS"); + assert!(!is_shortcut_chord(M::CTRL | M::ALT), "AltGr types @ on a German keyboard"); +} + +/// A press of `key` with Ctrl and Alt held (as Firefox on Windows reports AltGr), followed by +/// the text it typed when `typed` is given. +fn ctrl_alt_press(key: Key, typed: Option<&str>) -> Vec { + let m = Modifiers::CTRL | Modifiers::ALT | Modifiers::COMMAND; + let mut v = + vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }]; + v.extend(typed.map(|t| egui::Event::Text(t.into()))); + v +} + +/// The text of the type object `id`, and the document's layer count. +fn text_and_layers(app: &mut VectorcraftApp, id: u64) -> (String, usize) { + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + let text = r["runs"].as_array().unwrap().iter().filter_map(|run| run["text"].as_str()).collect(); + (text, app.session.active().unwrap().doc.layers.len()) +} + +/// While the Type tool edits text, a character typed with AltGr (Ctrl+Alt in Firefox on +/// Windows) is typed and runs no command: not New Layer Options… (Cmd+Alt+L), Reset Tracking +/// (Cmd+Alt+Q), Export for Screens (Cmd+Alt+E), Select All on Active Artboard (Cmd+Alt+A) or +/// Undo (Cmd+Z). +#[test] +fn altgr_characters_typed_in_text_run_no_command() { + let mut wrong = vec![]; + for (key, typed) in [(Key::L, "ł"), (Key::Q, "@"), (Key::E, "€"), (Key::A, "ą"), (Key::Z, "ż")] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("text.setRangeStyle", &json!({"id": id, "tracking": 200, "kerning": -40})).unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); + assert!(app.session.tool_wants_text()); + let layers = text_and_layers(&mut app, id).1; + let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.len(); + let before = selected(&app); + frame(&mut app, ctrl_alt_press(key, Some(typed))); + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + let spaced = + r["runs"].as_array().unwrap().iter().all(|run| run["style"]["tracking"] == json!(200.0) && run["style"]["kerning"] == json!(-40.0)); + let got = (text_and_layers(&mut app, id), spaced, selected(&app) == before, app.ui.dialog.is_none(), app.session.tool_wants_text()); + if got != ((format!("Hi{typed}"), layers), true, true, true, true) { + wrong.push(format!("{key:?}: (text, layers), tracking and kerning kept, selection kept, no dialog, still editing = {got:?}")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// What egui reports for the command key: Cmd on a Mac, Ctrl elsewhere. The keymap reads a +/// Mac's Ctrl as a key of its own, so Ctrl+L there is not Cmd+L. +fn command_key() -> Modifiers { + if crate::keymap::platform().is_mac() { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND } +} + +/// Ctrl and Ctrl+Alt chords (Cmd and Cmd+Alt on a Mac) that type no text still run their +/// commands while the Type tool edits, and with the Selection tool a Ctrl+Alt press runs its +/// chord as before. Ctrl+L is New Layer, and Ctrl+Alt+L is New Layer Options…, which opens +/// Layer Options for a new layer. +#[test] +fn ctrl_alt_chords_without_typed_text_still_run() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + let layers = text_and_layers(&mut app, id).1; + let new_layer_options = + |app: &VectorcraftApp| app.ui.dialog.as_ref().is_some_and(|d| d.kind == "layerOptions" && d.fields.get("mode") == Some(&json!("new"))); + let m = command_key(); + frame(&mut app, press(Key::L, m)); + assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "Ctrl+L while editing: New Layer"); + assert!(app.ui.dialog.is_none(), "Ctrl+L opens no dialog"); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + frame(&mut app, press(Key::L, m | Modifiers::ALT)); + assert!(new_layer_options(&app), "Ctrl+Alt+L with no text typed while editing: New Layer Options…"); + assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK"); + crate::dialogs::cancel(&mut app); + app.select_tool("selection"); + let mut typed = press(Key::L, m | Modifiers::ALT); + typed.push(egui::Event::Text("ł".into())); + frame(&mut app, typed); + assert!(new_layer_options(&app), "Selection tool: Ctrl+Alt+L runs New Layer Options… as before"); + assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK"); +} + +/// Cmd as egui reports it on this platform: the Command key on macOS, Ctrl elsewhere. +fn cmd() -> Modifiers { + if crate::keymap::platform().is_mac() { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND } +} + +/// The web build keeps from the browser the presses the keymap binds: the View menu's zoom +/// chords, Undo and Redo, whatever the browser reports as the key typed and its position. +#[test] +fn presses_the_keymap_binds_run_a_shortcut() { + crate::shortcut_editor::sync(&crate::state::UiState::default()); + let (cmd, shift, alt) = (cmd(), Modifiers::SHIFT, Modifiers::ALT); + for (key, code, mods) in [ + ("=", "Equal", cmd), + ("+", "Equal", cmd | shift), + ("+", "NumpadAdd", cmd), + ("-", "Minus", cmd), + ("0", "Digit0", cmd), + ("º", "Digit0", cmd | alt), + ("z", "KeyZ", cmd), + ("Z", "KeyZ", cmd | shift), + ("[", "BracketLeft", cmd), + // German's `+` key, where US has `]`: the character decides. + ("+", "BracketRight", cmd), + ] { + assert!(runs_a_shortcut(key, code, mods), "{key} ({code}) {mods:?}"); + } + // Keys the browser has no name egui knows for are matched by their position. + assert!(runs_a_shortcut("Dead", "KeyZ", cmd), "a dead key at Z"); + for (key, code, mods) in [("Insert", "Numpad0", cmd), ("F13", "F13", cmd), ("Unidentified", "", cmd)] { + assert!(!runs_a_shortcut(key, code, mods), "{key} ({code}) runs nothing"); + } +} + +/// The user's shortcuts decide: a chord given to another command runs that one, and a chord +/// taken from its command runs nothing. +#[test] +fn presses_follow_the_users_shortcuts() { + let mut ui = crate::state::UiState::default(); + crate::keymap::set_override(&mut ui.shortcut_overrides, "view.zoomIn", ""); + crate::keymap::set_override(&mut ui.shortcut_overrides, "view.grid", "Cmd+Alt+Shift+F9"); + crate::shortcut_editor::sync(&ui); + let zoom_in = runs_a_shortcut("=", "Equal", cmd()); + let grid = runs_a_shortcut("F9", "F9", cmd() | Modifiers::ALT | Modifiers::SHIFT); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert!(!zoom_in, "Zoom In has no shortcut"); + assert!(grid, "Show Grid's own chord"); + assert!(runs_a_shortcut("=", "Equal", cmd()), "the defaults again"); +} + +/// #2094: Cmd+Shift+O is Type › Create Outlines (not File › Open's Cmd+O, not the Artboard +/// tool's Shift+O), with the Selection tool or while the Type tool edits the text. +#[test] +fn cmd_shift_o_creates_outlines() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + assert_eq!(crate::menus::shortcut_of("type.createOutlines"), Some("Cmd+Shift+O")); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + let chord = egui::Event::Key { key: Key::O, physical_key: None, pressed: true, repeat: false, modifiers: cmd_shift }; + let is_text = |app: &VectorcraftApp, id: u64| { + matches!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_))) + }; + let group_kids = |app: &VectorcraftApp| -> usize { + let id = app.session.active().unwrap().selection.objects.first().copied().unwrap(); + match &app.session.doc().unwrap().doc.node(id).unwrap().kind { + vectorcraft_doc::NodeKind::Group { children, .. } => children.len(), + _ => 0, + } + }; + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + assert!(is_text(&app, id)); + frame(&mut app, vec![chord.clone()]); + assert!(!is_text(&app, id), "Selection tool: the chord outlines the text"); + assert_eq!(group_kids(&app), 2, "H and i"); + assert_ne!(app.session.tool_id(), "artboard", "not the Artboard tool's Shift+O"); + assert!(app.ui.dialog.is_none(), "not File › Open"); + // While the Type tool edits, the same chord outlines; a Text event with Cmd held must not type. + let id = app.session.execute("text.create", &json!({"x": 20, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + frame(&mut app, vec![egui::Event::ModifiersChanged(cmd_shift), chord, egui::Event::Text("o".into())]); + assert!(!is_text(&app, id), "Type tool: the chord outlines the text being edited"); + assert_eq!(group_kids(&app), 2, "Yo only: the stray Text with Cmd was not typed"); + assert!(app.ui.dialog.is_none(), "not File › Open while typing"); +} + +/// Cmd+Shift+B while the Type tool edits text is Type › Bold, not Hide Bounding Box (#724). +#[test] +fn cmd_shift_b_while_typing_is_bold_not_the_bounding_box() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.select_tool("type"); + let view = app.view_info(); + for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { + app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 50.0, 50.0), view).unwrap(); + } + frame(&mut app, vec![egui::Event::Text("Bold".into())]); + assert!(app.session.tool_wants_text()); + let chord = + egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }; + frame(&mut app, vec![chord.clone()]); + assert!(app.ui.view.bounding_box, "the bounding box stays"); + // The chord reached Bold: the text is in a bold face, or its family has none and says so. + let face = crate::panels::character::text_style(&app).map(|(s, _)| s.font_style).unwrap_or_default(); + assert!(face.contains("Bold") || app.ui.status.contains("has no Bold style"), "{face:?} {:?}", app.ui.status); + // Out of the text, the chord hides the bounding box as before. + app.select_tool("selection"); + frame(&mut app, vec![chord]); + assert!(!app.ui.view.bounding_box); +} + +/// Cmd+Shift+L, C, R, J and F align paragraphs (#1111): every paragraph of the selected type, +/// or while the Type tool edits text, those its selection touches. +#[test] +fn cmd_shift_letters_align_paragraphs() { + use vectorcraft_doc::Justify; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + let chord = + |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }; + let paras = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justify).collect::>(), + _ => panic!("text"), + }; + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [ + (Key::C, Justify::Center), + (Key::R, Justify::Right), + (Key::J, Justify::JustifyLeft), + (Key::F, Justify::JustifyAll), + (Key::L, Justify::Left), + ] { + frame(&mut app, vec![chord(key)]); + assert_eq!(paras(&app), [want, want], "{key:?}"); + } + // With the Type tool, only the paragraph its selection touches. + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 5, "end": 6})); + assert!(app.session.tool_wants_text()); + frame(&mut app, vec![chord(Key::R)]); + assert_eq!(paras(&app), [Justify::Left, Justify::Right]); + // Nothing selected: nothing to align. + app.select_tool("selection"); + app.session.execute("select.none", &json!({})).unwrap(); + assert!(!crate::menus::enabled(&app, "type.alignCenter")); +} + +/// Cmd+Alt+Q resets the tracking and kerning of the selected type (tracking 0, kerning Auto), +/// and of the Type tool's selected text while it edits. +#[test] +fn cmd_alt_q_resets_tracking_and_kerning() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two"})).unwrap()["id"].as_u64().unwrap(); + let spaced = + |app: &mut VectorcraftApp| app.session.execute("text.setRangeStyle", &json!({"id": id, "tracking": 200, "kerning": -40})).unwrap(); + let spacing = |app: &mut VectorcraftApp| { + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + r["runs"] + .as_array() + .unwrap() + .iter() + .map(|run| (run["text"].clone(), run["style"]["tracking"].clone(), run["style"]["kerning"].clone())) + .collect::>() + }; + let chord = + egui::Event::Key { key: Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT }; + spaced(&mut app); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + frame(&mut app, vec![chord.clone()]); + assert_eq!(spacing(&mut app), [(json!("One Two"), json!(0.0), json!(null))]); + // While the Type tool edits: only its selection. + spaced(&mut app); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 4, "end": 7})); + assert!(app.session.tool_wants_text()); + frame(&mut app, vec![chord]); + assert_eq!(spacing(&mut app), [(json!("One "), json!(200.0), json!(-40.0)), (json!("Two"), json!(0.0), json!(null))]); +} + +/// Cmd+Shift+X resets the horizontal scale of the selected type to 100%, and of the Type +/// tool's selected text while it edits. egui reports the chord as a Cut with Shift held, and +/// the type object is not cut. +#[test] +fn cmd_shift_x_resets_horizontal_scale() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two"})).unwrap()["id"].as_u64().unwrap(); + let stretched = |app: &mut VectorcraftApp| app.session.execute("text.setRangeStyle", &json!({"id": id, "hScale": 150})).unwrap(); + let scales = |app: &mut VectorcraftApp| { + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + r["runs"].as_array().unwrap().iter().map(|run| (run["text"].clone(), run["style"]["h_scale"].clone())).collect::>() + }; + let chord = vec![egui::Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT), egui::Event::Cut]; + stretched(&mut app); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + frame(&mut app, chord.clone()); + assert_eq!(scales(&mut app), [(json!("One Two"), json!(100.0))]); + // While the Type tool edits: only its selection, and the text stays. + stretched(&mut app); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 4, "end": 7})); + assert!(app.session.tool_wants_text()); + frame(&mut app, chord); + assert_eq!(scales(&mut app), [(json!("One "), json!(150.0)), (json!("Two"), json!(100.0))]); +} + +/// Tab shows and hides the panels, and in the Type tool it types a tab, without moving the +/// keyboard focus on to a field: the keys after it (a tool letter, the next letter typed) still +/// work. +#[test] +fn tab_leaves_the_keyboard_to_the_shortcuts_and_the_type_tool() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0)); + let run = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let press = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + for _ in 0..3 { + run(&mut app, vec![]); + } + assert!(app.ui.dock && app.ui.toolbar); + run(&mut app, vec![press(Key::Tab)]); + run(&mut app, vec![]); + assert!(!app.ui.dock && !app.ui.toolbar, "Tab hid the panels"); + run(&mut app, vec![press(Key::Tab)]); + run(&mut app, vec![]); + assert!(app.ui.dock && app.ui.toolbar, "a second Tab shows them again"); + run(&mut app, vec![press(Key::P), egui::Event::Text("p".into())]); + assert_eq!(app.session.tool_id(), "pen", "and a tool letter after it works"); + // The Type tool: a, Tab, b is one text. + app.select_tool("type"); + let view = app.view_info(); + for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { + app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 50.0, 50.0), view).unwrap(); + } + run(&mut app, vec![egui::Event::Text("a".into())]); + run(&mut app, vec![press(Key::Tab)]); + run(&mut app, vec![egui::Event::Text("b".into())]); + let doc = app.session.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(doc["layers"][0]["children"][0]["name"], "a\tb"); +} + +/// egui counts frames per viewport: two viewports of one context can both be on frame 0. Each +/// one must take its shortcut, not lose the second to a dedupe key that omits the viewport. +#[test] +fn two_viewports_on_the_same_frame_number_each_take_their_shortcut() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let aux = egui::ViewportId::from_hash_of("vectorcraft-shortcuts-aux"); + let tab = egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let mut frames = vec![]; + // The auxiliary viewport goes first so the root one has not advanced past frame 0. + for viewport in [aux, egui::ViewportId::ROOT] { + let mut raw = egui::RawInput { events: vec![tab.clone()], screen_rect: Some(screen), viewport_id: viewport, ..Default::default() }; + raw.viewports.entry(viewport).or_default(); + let mut out = ctx.run_ui(raw, |ui| { + frames.push(ui.ctx().cumulative_frame_nr_for(viewport)); + handle(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + } + assert_eq!(frames, [0, 0], "both viewports are on frame 0"); + assert!(app.ui.dock, "two Tabs in two viewports toggled the panels twice (the second was not a duplicate)"); +} + +/// Shift+Tab shows and hides the panels and leaves the toolbar and the Control bar as they are, +/// while Tab hides all three. Hiding the panels closes a panel popped out of the icon column. +/// In a focused field neither key shows or hides anything. +#[test] +fn shift_tab_hides_the_panels_but_not_the_toolbar_or_the_control_bar() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0)); + let run = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let press = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; + let shown = |app: &VectorcraftApp| (app.ui.dock, app.ui.toolbar, app.ui.control_bar); + app.ui.control_bar = true; + for _ in 0..3 { + run(&mut app, vec![]); + } + assert_eq!(shown(&app), (true, true, true)); + app.run("window.panel", json!({"panel": "color"})).unwrap(); + run(&mut app, vec![press(Modifiers::SHIFT)]); + run(&mut app, vec![]); + assert_eq!(shown(&app), (false, true, true), "Shift+Tab hid the panels only"); + assert_eq!(app.ui.open_panel, None, "and the panel popped out of the icon column"); + run(&mut app, vec![press(Modifiers::SHIFT)]); + run(&mut app, vec![]); + assert_eq!(shown(&app), (true, true, true), "a second Shift+Tab shows them again"); + app.run("window.panel", json!({"panel": "color"})).unwrap(); + run(&mut app, vec![press(Modifiers::NONE)]); + run(&mut app, vec![]); + assert_eq!(shown(&app), (false, false, false), "Tab hides the toolbar and the Control bar too"); + assert_eq!(app.ui.open_panel, None); + run(&mut app, vec![press(Modifiers::NONE)]); + run(&mut app, vec![]); + assert_eq!(shown(&app), (true, true, true)); + // A field of the Color panel has the keyboard focus: Shift+Tab and Tab do not show or hide anything. + app.run("window.panel", json!({"panel": "color"})).unwrap(); + for _ in 0..3 { + run(&mut app, vec![]); + } + let fields: Vec<(egui::Id, egui::Rect)> = ctx.viewport(|vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.sense.senses_click()).map(|w| (w.id, w.rect)).collect() + }); + let (id, rect) = + fields.into_iter().find(|&(id, _)| egui::TextEdit::load_state(&ctx, id).is_some()).unwrap_or_else(|| panic!("no field on screen")); + let click = + |pressed| egui::Event::PointerButton { pos: rect.center(), button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + run(&mut app, vec![egui::Event::PointerMoved(rect.center()), click(true)]); + run(&mut app, vec![click(false)]); + assert!(ctx.memory(|m| m.has_focus(id)), "the field has the keyboard"); + for modifiers in [Modifiers::SHIFT, Modifiers::NONE] { + run(&mut app, vec![press(modifiers)]); + run(&mut app, vec![]); + assert_eq!(shown(&app), (true, true, true), "{modifiers:?}"); + assert_eq!(app.ui.open_panel.as_deref(), Some("color"), "{modifiers:?}"); + } +} + +/// The second Tab shows again only the bars the first Tab hid: a Control bar that was off stays +/// off, and a toolbar hidden with Window › Tools stays hidden, after a restart too. Shift+Tab +/// between two Tabs, and Tab between two Shift+Tabs, leave a state the same key goes back from. +/// After a switch of workspace, Tab hides and shows that workspace's bars. +#[test] +fn tab_shows_again_only_the_bars_it_hid() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0)); + let run = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let press = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; + let tab = |app: &mut VectorcraftApp, modifiers| { + run(app, vec![press(modifiers)]); + run(app, vec![]); + (app.ui.dock, app.ui.toolbar, app.ui.control_bar) + }; + for _ in 0..3 { + run(&mut app, vec![]); + } + assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (true, true, false), "the Control bar starts off"); + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "the second Tab leaves the Control bar off"); + app.run("window.toolbar", json!({})).unwrap(); + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (true, false, false), "a toolbar hidden with Window › Tools stays hidden"); + app.run("window.toolbar", json!({})).unwrap(); + // Hidden by Tab when the app quits: the next Tab after a restart shows the same bars. + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + app.ui = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "after a restart"); + // Shift+Tab after Tab shows the panels, and a second Shift+Tab hides them again; Tab then + // shows what the first Tab hid. + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false)); + assert_eq!(tab(&mut app, Modifiers::SHIFT), (false, false, false)); + assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "Tab after Shift+Tab"); + // Tab after Shift+Tab hides the toolbar, and a second Tab goes back to Shift+Tab's state. + assert_eq!(tab(&mut app, Modifiers::SHIFT), (false, true, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (false, true, false), "Shift+Tab's state"); + // Panels shown with Shift+Tab between two Tabs stay shown. + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + assert_eq!(tab(&mut app, Modifiers::SHIFT), (true, false, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, false), "the panels Shift+Tab showed"); + // A bar shown another way after Tab: the next Tab hides it, and the Tab after that shows it again. + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + app.run("window.control", json!({})).unwrap(); + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, true), "only the Control bar"); + // Nothing shows and Tab hid nothing: Tab shows all three. + app.run("window.control", json!({})).unwrap(); + assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (false, false, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (true, true, true)); + // After a switch of workspace, Tab hides that workspace's bars and shows them again. + app.run("window.toolbar", json!({})).unwrap(); + app.run("window.control", json!({})).unwrap(); + app.run("window.workspace.new", json!({"name": "Panels Only"})).unwrap(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false)); + app.run("window.workspace", json!({"name": "Panels Only"})).unwrap(); + assert_eq!((app.ui.dock, app.ui.toolbar, app.ui.control_bar), (true, false, false)); + assert_eq!(tab(&mut app, Modifiers::NONE), (false, false, false), "Tab after a switch of workspace"); + assert_eq!(tab(&mut app, Modifiers::NONE), (true, false, false)); +} + +#[test] +fn copy_and_paste_events_use_the_system_clipboard() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Copy publishes SVG to the system clipboard (egui's CopyText output command). + let out = frame(&mut app, vec![egui::Event::Copy]); + let copied = out.platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }); + let svg = copied.expect("copy publishes SVG"); + assert!(svg.contains(""##; + frame(&mut app, vec![egui::Event::Paste(foreign.into())]); + assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 5); + // Plain text is not art: nothing is pasted from it (the internal clipboard is reused). + frame(&mut app, vec![egui::Event::Paste("hello".into())]); + assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 8); +} + +/// While a grid is dragged out, ↑/↓ change its rows and ←/→ its columns. +#[test] +fn arrows_while_dragging_a_grid_change_its_rows_and_columns() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangularGrid"); + let view = app.view_info(); + // Drag a grid out, pressing `keys` on the way: its (horizontal, vertical) dividers. + let grid = |app: &mut VectorcraftApp, keys: &[Key]| { + app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 80.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 300.0, 220.0), view).unwrap(); + for &key in keys { + let press = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + frame(app, vec![press]); + } + app.session.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 220.0), view).unwrap(); + let d = &app.session.doc().unwrap().doc; + let g = d.layers[0].children().unwrap().last().unwrap().clone(); + let vectorcraft_doc::NodeKind::Group { children, .. } = &g.kind else { panic!("not a grid: {:?}", g.kind) }; + let bounds: Vec<_> = children.iter().filter_map(|c| c.geometric_bounds()).collect(); + (bounds.iter().filter(|b| b.height() < 0.5).count(), bounds.iter().filter(|b| b.width() < 0.5).count()) + }; + let (rows, columns) = grid(&mut app, &[]); + assert_eq!(grid(&mut app, &[Key::ArrowRight, Key::ArrowRight, Key::ArrowUp]), (rows + 1, columns + 2)); + assert_eq!(grid(&mut app, &[Key::ArrowLeft, Key::ArrowDown]), (rows, columns + 1)); +} + +/// A press of `key` with `m` held. +fn press(key: Key, m: Modifiers) -> Vec { + vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }] +} + +/// Binds Show Grid to `chord`, or with `None` leaves every command its default shortcut. +fn bind_grid(app: &mut VectorcraftApp, chord: Option<&str>) { + app.ui.shortcut_overrides.clear(); + if let Some(c) = chord { + app.ui.shortcut_overrides.insert("view.grid", vec![c.to_string()]); + } + crate::shortcut_editor::sync(&app.ui); +} + +/// Clicks the active tool at (`x`, 200) and returns how many objects the first layer holds. +fn click_at(app: &mut VectorcraftApp, x: f64) -> usize { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, x, 200.0), view).unwrap(); + } + app.session.doc().unwrap().doc.layers[0].children().unwrap().len() +} + +/// A command bound to Cmd+Enter runs without the active tool's Enter: the Selection tool opens +/// no Move dialog, the Type tool types no line break, and the Pen tool's path stays open. With +/// nothing bound to it, Cmd+Enter goes to the tool as Enter does. +#[test] +fn a_command_on_cmd_enter_runs_without_the_tools_enter() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap()["id"].clone(); + let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); + let grid = app.ui.view.grid; + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); + assert_eq!((kind(&app).as_deref(), app.ui.view.grid), (Some("move"), grid), "nothing bound: Cmd+Enter opens Move as Enter does"); + app.ui.dialog = None; + bind_grid(&mut app, Some("Cmd+Enter")); + frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); + assert_eq!((kind(&app), app.ui.view.grid), (None, !grid), "Cmd+Enter runs Show Grid only"); + frame(&mut app, press(Key::Enter, Modifiers::NONE)); + assert_eq!((kind(&app).as_deref(), app.ui.view.grid), (Some("move"), !grid), "Enter still opens Move"); + app.ui.dialog = None; + let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); + let typed = text_and_layers(&mut app, text).0; + assert_eq!((typed.as_str(), app.ui.view.grid), ("Hi", grid), "Cmd+Enter runs Show Grid and types no line break"); + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("pen"); + click_at(&mut app, 100.0); + let objects = click_at(&mut app, 150.0); + frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); + assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects, !grid), "Cmd+Enter runs Show Grid and the path stays open"); + bind_grid(&mut app, None); + frame(&mut app, press(Key::Enter, Modifiers::COMMAND)); + assert_eq!(click_at(&mut app, 250.0), objects + 1, "nothing bound: Cmd+Enter ends the path as Enter does"); +} + +/// A command bound to Cmd+Escape runs without the active tool's Escape: the Pen tool's path +/// stays open, isolation mode stays on, and the Type tool goes on editing its text. With nothing +/// bound to it, Cmd+Escape goes to the tool as Escape does. Windows keeps Cmd+Escape (Ctrl+Esc) +/// for the Start menu, so no command can be bound to it there and it goes to the tool. +#[test] +fn a_command_on_cmd_escape_runs_without_the_tools_escape() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap()["id"].clone(); + let grid = app.ui.view.grid; + let cmd_escape = || press(Key::Escape, Modifiers::COMMAND); + let bindable = crate::keymap::platform() != craft_keymap::Platform::Windows; + bind_grid(&mut app, Some("Cmd+Escape")); + app.select_tool("pen"); + click_at(&mut app, 100.0); + let objects = click_at(&mut app, 150.0); + frame(&mut app, cmd_escape()); + if bindable { + assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects, !grid), "Cmd+Escape runs Show Grid and the path stays open"); + } else { + assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects + 1, grid), "Windows: Cmd+Escape stays unbound and ends the path"); + } + let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].clone(); + let edit = |app: &mut VectorcraftApp| { + app.session.execute("select.set", &json!({"ids": [text]})).unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + }; + if bindable { + edit(&mut app); + frame(&mut app, cmd_escape()); + assert_eq!( + (app.session.tool_wants_text(), app.ui.view.grid), + (true, grid), + "Cmd+Escape runs Show Grid and the Type tool goes on editing" + ); + } + app.select_tool("selection"); + app.session.execute("object.isolate", &json!({"id": id})).unwrap(); + let isolated = |app: &VectorcraftApp| app.session.active().unwrap().isolation.is_some(); + if bindable { + frame(&mut app, cmd_escape()); + assert_eq!((isolated(&app), app.ui.view.grid), (true, !grid), "Cmd+Escape runs Show Grid and isolation mode stays on"); + } + bind_grid(&mut app, None); + frame(&mut app, cmd_escape()); + assert!(!isolated(&app), "nothing bound: Cmd+Escape leaves isolation mode as Escape does"); + edit(&mut app); + frame(&mut app, cmd_escape()); + assert!(!app.session.tool_wants_text(), "nothing bound: Cmd+Escape ends the editing as Escape does"); + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("pen"); + let objects = click_at(&mut app, 300.0); + frame(&mut app, cmd_escape()); + assert_eq!(click_at(&mut app, 350.0), objects + 1, "nothing bound: Cmd+Escape ends the path as Escape does"); +} + +/// Enter opens the tool's dialog: the Move dialog for the selection tools, the tool's own for +/// Rotate, Scale, Reflect and Shear. The Enter that opens it isn't also its OK. +#[test] +fn enter_opens_the_tools_dialog_and_leaves_it_open() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + // A whole app frame: the shortcuts first, then the UI, where a dialog takes its Enter. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); + // The undo steps and the journal entries (the dialog's OK runs its command, which the journal records). + let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); + app.select_tool("selection"); + frame(&mut app, vec![enter()]); + assert_eq!(kind(&app), None, "nothing is selected"); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap(); + for (tool, dialog) in [ + ("selection", "move"), + ("directSelection", "move"), + ("groupSelection", "move"), + ("rotate", "rotate"), + ("scale", "scale"), + ("reflect", "reflect"), + ("shear", "shear"), + ] { + app.select_tool(tool); + let before = steps(&app); + frame(&mut app, vec![enter()]); + assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: Enter opens it"); + frame(&mut app, vec![]); + assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open"); + assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it"); + // The next Enter is its OK. With its default values only Reflect changes the art, so only + // Reflect records an undo step. + frame(&mut app, vec![enter()]); + let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1); + assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms"); + } + app.select_tool("rectangle"); + frame(&mut app, vec![enter()]); + assert_eq!(kind(&app), None, "another tool has its own Enter"); +} + +/// A drag with the Selection tool, then Enter: the Move dialog opens on the drag's offset, and +/// Enter is its OK. With Copy ticked once, each Enter, Enter after that makes another copy at +/// the same spacing. +#[test] +fn enter_after_a_drag_repeats_its_offset() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let ctx = egui::Context::default(); + let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let frame = |app: &mut VectorcraftApp, events: Vec| { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let lefts = |app: &VectorcraftApp| { + let kids = app.session.doc().unwrap().doc.layers[0].children().unwrap(); + kids.iter().map(|n| n.geometric_bounds().unwrap().x0.round()).collect::>() + }; + app.select_tool("selection"); + let view = app.view_info(); + for (kind, x, y) in + [(PointerKind::Down, 20.0, 20.0), (PointerKind::Drag, 40.0, 20.0), (PointerKind::Drag, 60.0, 20.0), (PointerKind::Up, 60.0, 20.0)] + { + app.session.pointer(&PointerEvent::new(kind, x, y), view).unwrap(); + } + let [moved] = lefts(&app)[..] else { panic!("one rectangle") }; + let step = moved - 10.0; + assert!(step > 0.0); + // Keys reach the canvas from the second frame on. + frame(&mut app, vec![]); + frame(&mut app, vec![enter()]); + let d = app.ui.dialog.as_mut().expect("Enter opens Move"); + assert_eq!((d.kind.as_str(), d.f64("dx", f64::NAN).round(), d.f64("dy", f64::NAN)), ("move", step, 0.0)); + d.fields.insert("copy".into(), json!(true)); + frame(&mut app, vec![enter()]); + for _ in 0..2 { + frame(&mut app, vec![enter()]); + frame(&mut app, vec![enter()]); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(lefts(&app), (0..4).map(|i| moved + step * f64::from(i)).collect::>()); + // With Preview on too, each Enter, Enter still makes one copy, and one undo step. + frame(&mut app, vec![enter()]); + app.ui.dialog.as_mut().expect("Move").fields.insert("preview".into(), json!(true)); + frame(&mut app, vec![enter()]); + let steps = app.session.active().unwrap().history.undo.len(); + for _ in 0..2 { + frame(&mut app, vec![enter()]); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("preview") && d.bool("copy")) && app.session.in_interaction()); + frame(&mut app, vec![enter()]); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + } + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 2); + assert_eq!(lefts(&app), (0..7).map(|i| moved + step * f64::from(i)).collect::>()); +} + +/// #1661: typed `[` and `]` reach the tool that takes them: the Blob Brush's and the Eraser's +/// size (Option or AltGr brackets too), the stroke weight left as it is; other tools leave them +/// alone. +#[test] +fn brackets_size_the_blob_brush_and_the_eraser() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("stroke.set", &json!({"weight": 3})).unwrap(); + let size = |app: &mut VectorcraftApp| app.session.tool_options()["size"].as_f64().unwrap(); + let text = |t: &str| egui::Event::Text(t.into()); + for tool in ["blobBrush", "eraser"] { + app.select_tool(tool); + let before = size(&mut app); + frame(&mut app, vec![text("]")]); + frame(&mut app, vec![text("]")]); + assert_eq!(size(&mut app), before + 2.0, "{tool}"); + // Option-8 types `[` on a Turkish Mac keyboard. + let option_8 = egui::Event::Key { key: Key::Num8, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }; + frame(&mut app, vec![option_8, text("[")]); + assert_eq!(size(&mut app), before + 1.0, "{tool}: a bracket typed with Option"); + } + assert_eq!(app.session.paint.stroke_width, 3.0, "the stroke weight stays"); + app.select_tool("rectangle"); + frame(&mut app, vec![text("[")]); + assert_eq!(app.session.paint.stroke_width, 3.0); +} + +#[test] +fn comma_and_period_reapply_the_last_colour_and_gradient() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.session.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + app.session.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let fill = |app: &VectorcraftApp| crate::panels::current_paints(app).0; + frame(&mut app, vec![egui::Event::Text(".".into())]); + assert!(matches!(fill(&app), vectorcraft_color::Paint::Gradient(_)), "`.` applies the last gradient"); + frame(&mut app, vec![egui::Event::Text(",".into())]); + assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour"); +} + +#[test] +fn shifted_punctuation_shortcuts_work_as_typed() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let a = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + let b = app.session.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + let order = + |app: &VectorcraftApp| -> Vec { app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect() }; + let (a, b) = (a.as_u64().unwrap(), b.as_u64().unwrap()); + assert_eq!(order(&app), [a, b]); + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + // Recorded in the Keyboard Shortcuts editor, they read as the keys (and so conflict). + assert_eq!(crate::shortcut_editor::chord_from_event(Key::CloseCurlyBracket, cmd_shift).as_deref(), Some("Cmd+Shift+]")); + assert_eq!(crate::shortcut_editor::chord_from_event(Key::Questionmark, cmd_shift).as_deref(), Some("Cmd+Shift+/")); + // Cmd+Shift+] arrives as Cmd+Shift+`}`, Cmd+Shift+[ as Cmd+Shift+`{`. + frame(&mut app, vec![press(Key::CloseCurlyBracket, cmd_shift)]); + assert_eq!(order(&app), [b, a], "Bring to Front"); + frame(&mut app, vec![press(Key::OpenCurlyBracket, cmd_shift)]); + assert_eq!(order(&app), [a, b], "Send to Back"); + // Cmd+Shift+/ arrives as Cmd+Shift+`?`: Search Commands. + frame(&mut app, vec![press(Key::Questionmark, cmd_shift)]); + assert!(app.ui.palette_open, "Search Commands"); + app.ui.palette_open = false; + // The Curvature tool's Shift+~ arrives as the text `~` with Shift. + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), egui::Event::Text("~".into())]); + assert_eq!(app.session.tool_id(), "curvature"); +} + +/// Cmd/Ctrl+Alt+Shift+. and , step the font size by five Size/Leading increments, and +/// Cmd/Ctrl+Shift+. still steps it by one: the chord with Alt does not reach the one-step +/// command. +#[test] +fn cmd_alt_shift_period_and_comma_step_the_font_size_by_five() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two", "size": 12})).unwrap()["id"].as_u64().unwrap(); + let sizes = |app: &mut VectorcraftApp| { + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + r["runs"].as_array().unwrap().iter().map(|run| (run["text"].clone(), run["style"]["size"].clone())).collect::>() + }; + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let five = Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT; + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + frame(&mut app, vec![press(Key::Period, five)]); + assert_eq!(sizes(&mut app), [(json!("One Two"), json!(22.0))]); + frame(&mut app, vec![press(Key::Period, Modifiers::COMMAND | Modifiers::SHIFT)]); + assert_eq!(sizes(&mut app), [(json!("One Two"), json!(24.0))], "one step"); + frame(&mut app, vec![press(Key::Comma, five)]); + assert_eq!(sizes(&mut app), [(json!("One Two"), json!(14.0))]); + // While the Type tool edits: only its selection. + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 4, "end": 7})); + assert!(app.session.tool_wants_text()); + frame(&mut app, vec![press(Key::Period, five)]); + assert_eq!(sizes(&mut app), [(json!("One "), json!(14.0)), (json!("Two"), json!(24.0))]); +} + +/// #3786: Alt+arrows step a selected text object's spacing instead of copying and nudging it. +#[test] +fn alt_arrows_step_selected_type_object() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two", "size": 20})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT)]); + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(r["runs"][0]["style"]["tracking"], json!(20.0)); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]); + + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowDown, Modifiers::ALT)]); + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(r["runs"][0]["style"]["leading"], json!(26.0)); + + let alt_shift = Modifiers::ALT | Modifiers::SHIFT; + frame(&mut app, vec![egui::Event::ModifiersChanged(alt_shift), press(Key::ArrowUp, alt_shift)]); + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(r["runs"][0]["style"]["baseline_shift"], json!(2.0)); + + // Shift+Alt+Left/Right have no text-spacing action; they must still be consumed instead of + // falling through to Alt+arrow's copy-and-nudge behaviour. + for key in [Key::ArrowLeft, Key::ArrowRight] { + frame(&mut app, vec![egui::Event::ModifiersChanged(alt_shift), press(key, alt_shift)]); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]); + } + + // Other objects keep Alt+arrow's copy-and-nudge shortcut. + let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [rect]})).unwrap(); + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT)]); + let selected = &app.session.active().unwrap().selection.objects; + assert_eq!(selected.len(), 1); + assert_ne!(selected[0], vectorcraft_doc::NodeId(rect), "a non-type object is still copied by Alt+arrow"); +} + +/// #793: with a layout without Latin letters the one-key shortcuts go by the key's position: +/// the P key typing Persian `ح` chooses the Pen, the V key typing Russian `м` the Selection +/// tool. A Latin layout keeps the letter typed: AZERTY's A (the Q key's place) is A. +#[test] +fn one_key_shortcuts_follow_the_key_on_layouts_without_latin_letters() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let typed = |key, physical, text: &str| { + vec![ + egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + egui::Event::Text(text.into()), + ] + }; + frame(&mut app, typed(Key::P, Key::P, "ح")); + assert_eq!(app.session.tool_id(), "pen"); + frame(&mut app, typed(Key::V, Key::V, "м")); + assert_eq!(app.session.tool_id(), "selection"); + frame(&mut app, typed(Key::A, Key::Q, "a")); + assert_eq!(app.session.tool_id(), "directSelection", "AZERTY: the letter typed"); +} + +#[test] +fn plus_typed_any_way_zooms_in() { + assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key"); + assert_eq!(crate::shortcut_editor::chord_from_event(Key::Plus, Modifiers::COMMAND).as_deref(), Some("Cmd+="), "a recorded `+` too"); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let zoom = |app: &mut VectorcraftApp| app.view_mut().unwrap().zoom; + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + // `=`, Shift+`=` (reported as `=` or as `+`), and the numpad `+` / a layout's own `+` key. + for (key, m) in [(Key::Equals, Modifiers::COMMAND), (Key::Equals, cmd_shift), (Key::Plus, cmd_shift), (Key::Plus, Modifiers::COMMAND)] { + let before = zoom(&mut app); + frame(&mut app, vec![press(key, m)]); + assert!(zoom(&mut app) > before, "{key:?} with {m:?} zooms in"); + } + let before = zoom(&mut app); + frame(&mut app, vec![press(Key::Minus, Modifiers::COMMAND)]); + assert!(zoom(&mut app) < before, "Cmd+- zooms out"); +} + +/// `+` selects the Add Anchor Point tool, as `-` selects Delete Anchor Point (#2142): typed as +/// Shift+`=` on a US layout, or by a layout's own `+` key or the numpad's. +#[test] +fn plus_selects_the_add_anchor_point_tool() { + assert_eq!(parse("+"), parse("="), "a bare `+` is the `=` key"); + assert_eq!(crate::shortcut_editor::tool_for_key("+"), Some("addAnchor")); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + for m in [Modifiers::SHIFT, Modifiers::NONE] { + app.select_tool("selection"); + frame(&mut app, vec![egui::Event::ModifiersChanged(m), egui::Event::Text("+".into())]); + assert_eq!(app.session.tool_id(), "addAnchor", "`+` with {m:?}"); + } + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::NONE), egui::Event::Text("-".into())]); + assert_eq!(app.session.tool_id(), "deleteAnchor"); +} + +#[test] +fn digit_keys_reach_a_dragging_tool_once_per_press() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + app.session.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.session.execute("perspective.attach", &json!({"ids": [id], "plane": "right"})).unwrap(); + app.select_tool("perspectiveSelection"); + let v = app.view_info(); + let c = + app.session.doc().unwrap().doc.node(vectorcraft_engine::doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().center(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 30.0, c.y), v).unwrap(); + let key = |repeat| egui::Event::Key { key: Key::Num5, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }; + // A held key repeats: only its first press toggles. + frame(&mut app, vec![key(false), key(true)]); + let preview = app.session.doc().unwrap().interaction.as_ref().unwrap().preview.clone().unwrap(); + assert_eq!((preview.0.as_str(), &preview.1["perpendicular"]), ("perspective.move", &json!(true))); + assert_eq!(digit_of(Key::Num0), Some(0)); + assert_eq!(digit_of(Key::A), None); +} + +/// E pressed while the Selection tool drags a marquee makes it select only the objects inside +/// it: once per press, without Cmd, and from the key at E's position on a layout without Latin +/// letters. +#[test] +fn e_reaches_a_dragged_marquee_once_per_press() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let inside = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + let touched = app.session.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + app.select_tool("selection"); + let e = |key, repeat, modifiers| egui::Event::Key { key, physical_key: Some(Key::E), pressed: true, repeat, modifiers }; + let mut marquee = |events: Vec| { + let v = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 350.0, 250.0), v).unwrap(); + frame(&mut app, events); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 350.0, 250.0), v).unwrap(); + let ids = app.session.doc().unwrap().selection.objects.iter().map(|i| json!(i.0)).collect::>(); + assert_eq!(app.session.tool_id(), "selection"); + ids + }; + assert_eq!(marquee(vec![]), vec![inside.clone(), touched.clone()]); + // A held key repeats: only its first press switches. + assert_eq!(marquee(vec![e(Key::E, false, Modifiers::NONE), e(Key::E, true, Modifiers::NONE)]), vec![inside.clone()]); + let up = egui::Event::Key { key: Key::E, physical_key: Some(Key::E), pressed: false, repeat: false, modifiers: Modifiers::NONE }; + let twice = vec![e(Key::E, false, Modifiers::NONE), up, e(Key::E, false, Modifiers::NONE)]; + assert_eq!(marquee(twice), vec![inside.clone(), touched.clone()], "pressed twice: what it touches"); + // A Russian layout: egui-winit reports the key at E's position, and the text it types. + assert_eq!(marquee(vec![e(Key::E, false, Modifiers::NONE), egui::Event::Text("у".into())]), vec![inside.clone()]); + assert_eq!(marquee(vec![e(Key::E, false, Modifiers::COMMAND)]), vec![inside, touched], "Cmd+E is not E"); + assert_eq!(letter_of(Key::E), Some('e')); + assert_eq!(letter_of(Key::Num1), None); + assert_eq!(letter_of(Key::Escape), None); +} + +/// Shortcuts reach tools, panels (`window.panel {panel}`) and commands through the keymap, +/// and a held Cmd+= keeps zooming in. +#[test] +fn shortcuts_run_tools_panels_and_commands() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + app.session.execute("select.set", &json!({"ids": []})).unwrap(); + let press = |key, modifiers, repeat| egui::Event::Key { key, physical_key: None, pressed: true, repeat, modifiers }; + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:pen", "F2"); + crate::shortcut_editor::sync(&app.ui); + frame(&mut app, vec![press(Key::F2, Modifiers::NONE, false)]); + assert_eq!(app.session.tool_id(), "pen", "a tool on a key that types nothing"); + app.select_tool("selection"); + frame(&mut app, vec![press(Key::F7, Modifiers::SHIFT, false)]); + assert_eq!(app.ui.open_panel.as_deref(), Some("align")); + frame(&mut app, vec![press(Key::A, Modifiers::COMMAND, false)]); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "Select All"); + let zoom = |app: &mut VectorcraftApp| app.view_mut().unwrap().zoom; + frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND, false)]); + let once = zoom(&mut app); + frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND, true)]); + assert!(zoom(&mut app) > once, "a key repeat zooms again"); +} + +/// Cmd+, opens Settings on a Mac only, beside the frame's other shortcuts, and never takes a +/// chord a shortcut already has. +#[test] +fn cmd_comma_opens_settings_on_a_mac() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let on = |app: &mut VectorcraftApp, os, events| { + app.ui.dialog = None; + app.session.execute("select.set", &json!({"ids": []})).unwrap(); + let ctx = egui::Context::default(); + ctx.set_os(os); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| handle(app, ui.ctx())).textures_delta.clear(); + let settings = app.ui.dialog.as_ref().is_some_and(|d| d.kind == "preferences"); + (settings, app.session.active().unwrap().selection.objects.len()) + }; + let (mac, linux) = (egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Nix); + let comma = press(Key::Comma, Modifiers::COMMAND); + let select_all = press(Key::A, Modifiers::COMMAND); + assert_eq!(on(&mut app, mac, vec![comma.clone()]), (true, 0)); + assert_eq!(on(&mut app, mac, vec![select_all.clone(), comma.clone()]), (true, 1), "with another shortcut in the frame"); + assert_eq!(on(&mut app, linux, vec![select_all, comma.clone()]), (false, 1), "not elsewhere"); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "select.all", "Cmd+,"); + crate::shortcut_editor::sync(&app.ui); + assert_eq!(on(&mut app, mac, vec![comma]), (false, 1), "a shortcut that has it keeps it"); +} + +#[test] +fn parses() { + let s = parse("Cmd+Shift+]").unwrap(); + assert_eq!(s.logical_key, Key::CloseBracket); + assert!(s.modifiers.shift && s.modifiers.command); + assert_eq!(parse("Cmd+=").unwrap().logical_key, Key::Equals); + assert_eq!(parse("Cmd+Alt+2").unwrap().logical_key, Key::Num2); + assert_eq!(parse("F12").unwrap().logical_key, Key::F12); + assert!(parse("").is_none()); +} + +/// An IME replacement on the Type tool arrives as `DeleteSurroundingBytes` + `Commit` events +/// and is taken once, in order: the marked text is dropped, the committed text replaces it, and +/// the events never reach `route_type_input` as typed characters. +#[test] +fn ime_delete_surrounding_bytes_edits_the_type_tool_once() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let v = vectorcraft_engine::ViewInfo::default(); + app.session.select_tool("type", v).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 100.0), v).unwrap(); + app.session.tool_text("Héllo", v).unwrap(); + let ctx = egui::Context::default(); + // Fonts install on the first logic pass and are ready on the second. + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + } + let events = vec![ + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 3, after_bytes: 0 }), + egui::Event::Ime(egui::ImeEvent::Commit("y".into())), + ]; + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + let mut text = String::new(); + if let Some(st) = app.session.active() { + st.doc.walk(|node| { + if let vectorcraft_doc::NodeKind::Text(t) = &node.kind { + text = t.plain_text(); + } + }); + } + assert_eq!(text, "Héy"); + assert!(app.session.tool_wants_text(), "editing continues after the replacement"); +} + +/// X11/Wayland PRIMARY middle-click after a press in a panel field: `handle` runs before the +/// fields are drawn, and must leave the event for egui's text field (it pastes even without +/// focus). Deleting it here made PRIMARY paste dead outside dialogs. +#[test] +fn middle_click_paste_reaches_an_unfocused_text_field() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 200.0)); + let id = egui::Id::unique("middle-click-paste-test"); + let mut buf = String::new(); + let mut at = egui::pos2(40.0, 40.0); + for step in 0..2 { + let events = + if step == 1 { vec![egui::Event::PointerMoved(at), egui::Event::MiddleClickPaste { pos: at, text: "x".into() }] } else { vec![] }; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { + handle(&mut app, ui.ctx()); + let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id)); + at = resp.rect.center(); + }); + out.textures_delta.clear(); + } + assert_eq!(buf, "x"); +} + +/// A shortcut is taken once per frame, but only within the context that frame belongs to: a +/// new context starts again at frame 0 (a restarted host, or a test's fresh context) and its +/// input is new, not a rerun of the old context's frame 0. +#[test] +fn a_new_context_takes_its_own_frame_zero() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let press = |app: &mut VectorcraftApp| { + let ctx = egui::Context::default(); + let ev = + egui::Event::Key { key: Key::N, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| handle(app, ui.ctx())); + out.textures_delta.clear(); + }; + press(&mut app); + assert_eq!(app.session.documents().len(), 1, "New Without Dialog makes a document"); + press(&mut app); + assert_eq!(app.session.documents().len(), 2, "a second context's frame 0 is new input"); +} + +#[test] +fn all_registered_shortcuts_parse() { + for c in vectorcraft_engine::command_specs() { + if let Some(s) = c.shortcut + && s != "D" + && s != "X" + && s != "Shift+X" + && s != "/" + { + assert!(parse(s).is_some(), "{} has unparsable shortcut {s}", c.id); + } + } + for c in crate::menus::UI_COMMANDS { + if !c.2.is_empty() && c.2 != "F" && c.2 != "Shift+F" { + assert!(parse(c.2).is_some(), "{} has unparsable shortcut {}", c.0, c.2); + } + } +} From 32034021a4526e274d87c6ac8a0d857fadcc6fdf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:32 +0000 Subject: [PATCH 023/199] refactor(ui-egui): move panels/artboards.rs tests to panels/artboards/tests.rs --- crates/ui-egui/src/panels/artboards.rs | 1237 +----------------- crates/ui-egui/src/panels/artboards/tests.rs | 1234 +++++++++++++++++ 2 files changed, 1235 insertions(+), 1236 deletions(-) create mode 100644 crates/ui-egui/src/panels/artboards/tests.rs diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index ab69ce4e1..f929bc3a4 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -595,1239 +595,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use super::*; - use crate::widgets::accessibility_tests::{named, tree}; - use vectorcraft_engine::Session; - - /// Clicking a row makes its artboard the active one (the navigator's, Fit Artboard in Window's). - #[test] - fn clicking_a_row_makes_its_artboard_active() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 200, "height": 150})).unwrap(); - app.view_mut().unwrap().artboard = 0; - let ctx = egui::Context::default(); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - // Where the second row's name is painted. - let at = frame(&mut app, vec![]) - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text() == "Artboard 2" => Some(t.pos + vec2(4.0, 4.0)), - _ => None, - }) - .unwrap(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for e in [egui::Event::PointerMoved(at), button(true), button(false)] { - frame(&mut app, vec![e]); - } - assert_eq!(app.view().unwrap().artboard, 1, "Artboard 2 is active"); - // The navigator moves it on; the panel follows. - crate::menus::invoke(&mut app, "view.goToArtboard", json!({"index": "previous"})); - assert_eq!(selected(&app, 2), 0, "and the panel follows the navigator"); - } - - /// #1006: Select › All on Active Artboard selects the art of the artboard made active here, - /// and an explicit artboard still wins. - #[test] - fn all_on_active_artboard_takes_the_active_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap(); - let mut rect = |x| app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); - let (a, b) = (rect(10), rect(130)); - let picked = |app: &mut VectorcraftApp, params| { - crate::menus::invoke(app, "select.allOnArtboard", params); - let ids: Vec = app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect(); - json!(ids) - }; - select(&mut app, 1); - assert_eq!(picked(&mut app, json!({})), json!([b])); - select(&mut app, 0); - assert_eq!(picked(&mut app, json!({})), json!([a])); - assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b])); - } - - /// Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art resize the artboard made - /// active here, as the panel menu's Fit to Artwork Bounds does, and leave artboard 1 as it was; - /// an explicit index still wins. - #[test] - fn fit_to_artwork_bounds_and_selected_art_resize_the_active_artboard() { - for id in ["artboard.fitToArt", "artboard.fitToSelection"] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap(); - // Partly on artboard 1 and partly on artboard 2. - app.session.execute("shape.rectangle", &json!({"x": 90, "y": 10, "width": 60, "height": 30})).unwrap(); - app.session.execute("paint.setStroke", &json!({"none": true})).unwrap(); - let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); - let (first, art) = (boards(&app)[0], vectorcraft_geom::Rect::new(90.0, 10.0, 150.0, 40.0)); - select(&mut app, 1); - crate::menus::invoke(&mut app, id, json!({})); - assert_eq!(boards(&app), [first, art], "{id} resizes Artboard 2, the active one"); - crate::menus::invoke(&mut app, id, json!({"index": 0})); - assert_eq!(boards(&app), [art, art], "{id} with an index resizes that artboard"); - } - } - - /// With artboards 1 and 3 selected with the Artboard tool, Object › Artboards › Fit to Artwork - /// Bounds fits each of them to the art on it and leaves artboard 2 as it was. - #[test] - fn fit_to_artwork_bounds_fits_each_selected_artboard_to_its_own_art() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 3})).unwrap(); - for (x, h) in [(10, 30), (130, 20), (250, 40)] { - app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap(); - } - app.session.execute("select.all", &json!({})).unwrap(); - app.session.execute("paint.setStroke", &json!({"none": true})).unwrap(); - let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); - let was = boards(&app); - app.select_tool("artboard"); - select(&mut app, 0); - app.session.set_tool_option("selected", &json!([0, 2])); - crate::menus::invoke(&mut app, "artboard.fitToArt", json!({})); - let rect = vectorcraft_geom::Rect::new; - assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]); - } - - /// With the Artboard tool, Shift-click on a row selects the artboards from the last row clicked - /// without Shift to it and makes it the active one, Ctrl/Cmd-click adds its artboard to the - /// tool's selection or takes it out (#1139), a plain click selects it alone, and every selected - /// row is highlighted. - #[test] - fn shift_clicking_rows_selects_several_artboards() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - for x in [400, 800, 1200] { - app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); - } - app.select_tool("artboard"); - select(&mut app, 1); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let raw = egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - let shapes = frame(&mut app, 0.0, vec![]); - // `row(k)` returns the point where row `k`'s name is painted. - let row = |k: usize| { - shapes - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text() == format!("Artboard {k}") => Some(t.pos + vec2(4.0, 4.0)), - _ => None, - }) - .unwrap() - }; - let click = |k: usize, modifiers: egui::Modifiers| { - let at = row(k); - let b = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; - vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(at), b(true), b(false)] - }; - let highlighted = |shapes: Vec| { - shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected)).count() - }; - // With no row clicked yet, Shift-click ranges from the active artboard. - frame(&mut app, 1.0, click(4, egui::Modifiers::SHIFT)); - assert_eq!((picked(&app, 4), selected(&app, 4)), (vec![1, 2, 3], 3)); - frame(&mut app, 1.5, click(3, egui::Modifiers::SHIFT)); - assert_eq!(picked(&app, 4), [1, 2], "the next Shift-click ranges from the same artboard"); - frame(&mut app, 2.0, click(1, egui::Modifiers::NONE)); - assert_eq!(picked(&app, 4), [0]); - frame(&mut app, 3.0, click(3, egui::Modifiers::SHIFT)); - assert_eq!((picked(&app, 4), selected(&app, 4)), (vec![0, 1, 2], 2), "Artboards 1 to 3, Artboard 3 active"); - assert_eq!(highlighted(frame(&mut app, 4.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)])), 3, "three rows highlighted"); - // A second Shift-click ranges from the same row. - frame(&mut app, 5.0, click(2, egui::Modifiers::SHIFT)); - assert_eq!(picked(&app, 4), [0, 1]); - frame(&mut app, 6.0, click(3, egui::Modifiers::SHIFT)); - frame(&mut app, 7.0, click(2, egui::Modifiers::COMMAND)); - assert_eq!(picked(&app, 4), [0, 2], "Ctrl/Cmd-click takes Artboard 2 out"); - frame(&mut app, 8.0, click(2, egui::Modifiers::COMMAND)); - assert_eq!(picked(&app, 4), [0, 1, 2], "and puts it back"); - frame(&mut app, 9.0, click(4, egui::Modifiers::NONE)); - assert_eq!(picked(&app, 4), [3]); - assert_eq!(highlighted(frame(&mut app, 10.0, vec![])), 1); - // With another tool, a Shift-click makes the row's artboard active, as a plain click does. - app.select_tool("selection"); - frame(&mut app, 11.0, click(2, egui::Modifiers::SHIFT)); - assert_eq!((picked(&app, 4), selected(&app, 4)), (vec![1], 1)); - } - - /// Double-clicking a row's number goes to its artboard and its name renames it; a click on - /// another row's options icon makes that artboard the active one, and the Artboard tool's - /// artboard is the active one. - #[test] - fn double_clicks_go_to_or_rename_and_options_edits_the_rows_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 200, "height": 150})).unwrap(); - app.view_mut().unwrap().artboard = 0; - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - let shapes = frame(&mut app, 0.0, vec![]); - let name = shapes - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text() == "Artboard 2" => Some(t.pos + vec2(4.0, 4.0)), - _ => None, - }) - .unwrap(); - // The highlighted row (Artboard 1's), for its Options button at the right end. - let row = shapes - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected => Some(r.rect), - _ => None, - }) - .unwrap(); - let events = |at: egui::Pos2, clicks: usize| { - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - std::iter::once(egui::Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect::>() - }; - // Artboard 2's number: it is active, no rename starts. - frame(&mut app, 1.0, events(name - vec2(16.0, 0.0), 2)); - assert_eq!(app.view().unwrap().artboard, 1); - assert_eq!(pstate::>(&ctx, "ab-edit"), None); - // Its name: a rename. - frame(&mut app, 2.0, events(name, 2)); - assert_eq!(pstate::>(&ctx, "ab-edit"), Some(1)); - set_pstate::>(&ctx, "ab-edit", None); - frame(&mut app, 3.0, vec![]); - // A click on Artboard 1's options icon makes Artboard 1 active, and the Artboard tool, - // chosen next, starts on it. - frame(&mut app, 4.0, events(egui::pos2(row.right() - 14.0, row.center().y), 1)); - app.select_tool("artboard"); - assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["active"].clone()), (0, json!(0))); - // While the Artboard tool is chosen, clicking a row moves the tool to that artboard too. - frame(&mut app, 5.0, events(name, 1)); - assert_eq!(app.session.tool_options()["active"], json!(1)); - // And clicking an artboard with the tool makes it the active one, highlighted in the panel. - let view = app.view_info(); - for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { - crate::canvas::dispatch(&mut app, &vectorcraft_tools::PointerEvent::new(kind, 100.0, 100.0), view); - } - assert_eq!((app.session.tool_options()["active"].clone(), selected(&app, 2)), (json!(0), 0)); - } - - /// Shift+Enter in a row's name field renames the artboard and keeps the field open, the new - /// name selected; Enter then renames it again and closes the field. - #[test] - fn shift_enter_renames_an_artboard_and_keeps_the_field_open() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); - let ctx = egui::Context::default(); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - }; - let name = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards[1].name.clone(); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - // What a double-click on the second row's name does: its name field opens. - set_pstate::>(&ctx, "ab-edit", Some(1)); - frame(&mut app, vec![]); - frame(&mut app, vec![]); - frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Cover".into())]); - frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::SHIFT)]); - assert_eq!(name(&app), "Cover", "Shift+Enter applies the name"); - let field = ctx.memory(|m| m.focused()).expect("the field keeps the focus"); - assert_eq!(pstate::>(&ctx, "ab-edit"), Some(1), "and stays open"); - frame(&mut app, vec![]); - let selected = egui::TextEdit::load_state(&ctx, field).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); - assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 5)), "the name shown is selected"); - frame(&mut app, vec![egui::Event::Text("Back".into())]); - frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); - frame(&mut app, vec![]); - assert_eq!(name(&app), "Back", "typing replaced it, and Enter applies that"); - assert_eq!(pstate::>(&ctx, "ab-edit"), None, "and closes the field"); - } - - /// With artboards 1, 3 and 4 selected with the Artboard tool, the panel menu's Rename opens an - /// empty name field on the active artboard's row, and the name typed there goes to all three, - /// numbered in panel order, in one undo step. With one artboard selected, Rename gives it the - /// name as typed. - #[test] - fn the_panel_menus_rename_numbers_several_selected_artboards() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - for x in [400, 800, 1200] { - app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); - } - app.select_tool("artboard"); - select(&mut app, 2); - app.session.set_tool_option("selected", &json!([2, 0, 3])); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let raw = |events| egui::RawInput { events, screen_rect: screen, ..Default::default() }; - let rename = |app: &mut VectorcraftApp| { - let mut out = ctx.run_ui(raw(vec![]), |ui| menu(app, ui)); - out.textures_delta.clear(); - let item = out - .shapes - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text().trim_start() == "Rename" => Some(t.visual_bounding_rect().center()), - _ => None, - }) - .unwrap(); - let button = - |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - ctx.run_ui(raw(vec![egui::Event::PointerMoved(item), button(true), button(false)]), |ui| menu(app, ui)).textures_delta.clear(); - }; - let frame = |app: &mut VectorcraftApp, events| ctx.run_ui(raw(events), |ui| show(app, ui)).textures_delta.clear(); - let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - let names = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::>(); - rename(&mut app); - assert_eq!(pstate::>(&ctx, "ab-edit"), Some(2), "the field opens on the active artboard's row"); - frame(&mut app, vec![]); - frame(&mut app, vec![]); - frame(&mut app, vec![egui::Event::Text("abc".into())]); - frame(&mut app, vec![enter.clone()]); - frame(&mut app, vec![]); - assert_eq!(names(&app), ["abc-1", "Artboard 2", "abc-2", "abc-3"]); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(names(&app), ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4"], "one undo step"); - // One artboard selected: the field shows its name, and the name typed replaces it. - select(&mut app, 1); - rename(&mut app); - frame(&mut app, vec![]); - frame(&mut app, vec![]); - let all = egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; - frame(&mut app, vec![all, egui::Event::Text("Solo".into())]); - frame(&mut app, vec![enter]); - frame(&mut app, vec![]); - assert_eq!(names(&app), ["Artboard 1", "Solo", "Artboard 3", "Artboard 4"]); - } - - /// Paste run on the session alone (`session.execute`, which leaves the view as it is) with the - /// Artboard tool selects the pasted artboard and leaves the active artboard where it was. The - /// panel menu's Rename then opens the name field on the selected artboard's row and renames - /// that artboard, not the active one. - #[test] - fn the_panel_menus_rename_opens_on_a_selected_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); - app.select_tool("artboard"); - select(&mut app, 1); - app.session.execute("edit.copy", &json!({})).unwrap(); - app.session.execute("edit.paste", &json!({})).unwrap(); - assert_eq!((selected(&app, 3), picked(&app, 3)), (1, vec![2]), "Artboard 2 is active, and the pasted Artboard 3 is selected"); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let raw = |events| egui::RawInput { events, screen_rect: screen, ..Default::default() }; - let mut out = ctx.run_ui(raw(vec![]), |ui| menu(&mut app, ui)); - out.textures_delta.clear(); - let item = out - .shapes - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text().trim_start() == "Rename" => Some(t.visual_bounding_rect().center()), - _ => None, - }) - .unwrap(); - let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - ctx.run_ui(raw(vec![egui::Event::PointerMoved(item), button(true), button(false)]), |ui| menu(&mut app, ui)).textures_delta.clear(); - assert_eq!(pstate::>(&ctx, "ab-edit"), Some(2), "the field opens on Artboard 3's row"); - let mut frame = |events| ctx.run_ui(raw(events), |ui| show(&mut app, ui)).textures_delta.clear(); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - frame(vec![]); - frame(vec![]); - frame(vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Pasted".into())]); - frame(vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); - frame(vec![]); - let names: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); - assert_eq!(names, ["Artboard 1", "Artboard 2", "Pasted"]); - } - - /// With the Artboard tool, Paste and Paste in Place make the pasted artboard the active one - /// (the navigator's and the panel's), and Cut makes the artboard before the cut one active, the - /// one the tool then has active. - #[test] - fn paste_and_cut_with_the_artboard_tool_make_its_artboard_active() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); - app.select_tool("artboard"); - select(&mut app, 1); - let active = |app: &VectorcraftApp| (app.view().unwrap().artboard, app.session.tool_options()["active"].clone()); - app.run("edit.copy", json!({})).unwrap(); - app.run("edit.paste", json!({})).unwrap(); - assert_eq!(active(&app), (3, json!(3)), "Paste: the pasted Artboard 4"); - app.run("edit.pasteInPlace", json!({})).unwrap(); - assert_eq!(active(&app), (4, json!(4)), "Paste in Place: the pasted Artboard 5"); - select(&mut app, 2); - app.run("edit.cut", json!({})).unwrap(); - assert_eq!(active(&app), (1, json!(1)), "Cut Artboard 3: Artboard 2"); - } - - /// `tool.setOption` with the Artboard tool's `selected` or `active` makes the artboard the tool - /// then has active the active one. - #[test] - fn the_artboard_tools_options_make_its_artboard_active() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); - app.select_tool("artboard"); - app.run("tool.setOption", json!({"key": "selected", "value": [2, 0]})).unwrap(); - assert_eq!((app.view().unwrap().artboard, picked(&app, 3)), (2, vec![0, 2])); - app.run("tool.setOption", json!({"tool": "artboard", "values": {"active": 1}})).unwrap(); - assert_eq!((app.view().unwrap().artboard, picked(&app, 3)), (1, vec![1])); - } - - /// With the Artboard tool, a `command.batch` that stays in the document makes the artboard the - /// tool then has active the active one, as its steps do on their own: a batch that leaves the - /// tool's artboards keeps the active artboard, and Copy and Paste make the pasted one active. - #[test] - fn a_batch_in_one_document_makes_the_artboard_tools_artboard_active() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); - app.select_tool("artboard"); - select(&mut app, 1); - let active = |app: &VectorcraftApp| (app.view().unwrap().artboard, app.session.tool_options()["active"].clone()); - app.run("command.batch", json!({"commands": [{"command": "object.unlockAll", "params": {}}]})).unwrap(); - assert_eq!(active(&app), (1, json!(1))); - let steps = [json!({"command": "edit.copy", "params": {}}), json!({"command": "edit.paste", "params": {}})]; - app.run("command.batch", json!({"commands": steps})).unwrap(); - assert_eq!(active(&app), (3, json!(3)), "the pasted Artboard 4"); - } - - /// With the Artboard tool, `document.activate` makes the active artboard of the document it - /// switches to the tool's, as choosing the document's tab does. - #[test] - fn switching_documents_with_the_artboard_tool_keeps_the_active_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); - app.select_tool("artboard"); - select(&mut app, 2); - app.run("file.new", json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); - app.run("document.activate", json!({"index": 0})).unwrap(); - assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (2, json!([2]))); - } - - /// With the Artboard tool, a `command.batch` that switches to another document and back keeps - /// the active artboard, and the tool starts on it. When a later step of the batch pastes an - /// artboard, the pasted one becomes active, as Paste on its own makes it. A later Cut of the - /// tool's artboard 1 makes the new first artboard active, as Cut on its own makes it. - #[test] - fn a_batch_that_switches_documents_and_back_keeps_the_active_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); - app.run("file.new", json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); - app.run("document.activate", json!({"index": 0})).unwrap(); - app.select_tool("artboard"); - select(&mut app, 2); - let step = |command: &str, params: Value| json!({"command": command, "params": params}); - let back = [step("document.activate", json!({"index": 1})), step("document.activate", json!({"index": 0}))]; - app.run("command.batch", json!({"commands": back})).unwrap(); - assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (2, json!([2]))); - app.run("edit.copy", json!({})).unwrap(); - let mut steps = back.to_vec(); - steps.push(step("edit.paste", json!({}))); - app.run("command.batch", json!({"commands": steps})).unwrap(); - assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (3, json!([3])), "the pasted Artboard 4"); - let mut steps = back.to_vec(); - steps.push(step("edit.cut", json!({}))); - app.run("command.batch", json!({"commands": steps})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3, "Artboard 1 cut"); - assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (0, json!([0])), "the new first artboard"); - } - - /// With the Artboard tool, a double-click on a name with Shift or Ctrl/Cmd held that leaves - /// several artboards selected opens the name field for all of them. A Ctrl/Cmd double-click on - /// an unselected row's name adds its artboard to the selection, and the second click leaves it - /// selected. A plain double-click selects the row's artboard alone and opens the field for it. - #[test] - fn a_shift_or_ctrl_double_click_on_a_name_renames_every_selected_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - for x in [400, 800, 1200] { - app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); - } - app.select_tool("artboard"); - select(&mut app, 0); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let raw = egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - let shapes = frame(&mut app, 0.0, vec![]); - // `row(k)` returns the point where row `k`'s name is painted. - let row = |k: usize| { - shapes - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text() == format!("Artboard {k}") => Some(t.pos + vec2(4.0, 4.0)), - _ => None, - }) - .unwrap() - }; - let click = |k: usize, modifiers: egui::Modifiers| { - let at = row(k); - let b = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; - vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(at), b(true), b(false)] - }; - // Two clicks on row `k`'s name in two frames 0.1 s apart, then the row and the artboards - // the name field opened for, and the field closed again. - let double_click = |app: &mut VectorcraftApp, time: f64, k: usize, modifiers: egui::Modifiers| { - frame(app, time, click(k, modifiers)); - frame(app, time + 0.1, click(k, modifiers)); - let opened = (pstate::>(&ctx, "ab-edit"), pstate::>(&ctx, "ab-edit-all")); - set_pstate::>(&ctx, "ab-edit", None); - set_pstate::>(&ctx, "ab-edit-all", vec![]); - frame(app, time + 0.5, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); - opened - }; - frame(&mut app, 1.0, click(1, egui::Modifiers::NONE)); - assert_eq!(double_click(&mut app, 2.0, 3, egui::Modifiers::SHIFT), (Some(2), vec![0, 1, 2]), "Shift: Artboards 1 to 3"); - assert_eq!(double_click(&mut app, 4.0, 2, egui::Modifiers::COMMAND), (Some(1), vec![0, 1, 2]), "Ctrl/Cmd on a selected one"); - assert_eq!(double_click(&mut app, 5.0, 4, egui::Modifiers::COMMAND), (Some(3), vec![0, 1, 2, 3]), "Ctrl/Cmd on another one"); - assert_eq!(picked(&app, 4), [0, 1, 2, 3], "which stays selected"); - assert_eq!(double_click(&mut app, 6.0, 4, egui::Modifiers::NONE), (Some(3), vec![3]), "a plain double-click"); - } - - /// A row's options icon opens Artboard Options for that row's artboard and leaves the tool as - /// it is (#1876): a click on the active artboard's icon, or a double-click on another row's - /// icon, whose first click only makes that artboard active. With the Artboard tool in use, the - /// tool moves to that artboard too. - #[test] - fn the_options_icon_opens_artboard_options_for_its_rows_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); - app.view_mut().unwrap().artboard = 0; - let tool = app.session.tool_id().to_string(); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - // The highlighted row (Artboard 1's). The rows are 24 points apart, and each icon's - // center is 14 points in from the row's right end. - let row = frame(&mut app, 0.0, vec![]) - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected => Some(r.rect), - _ => None, - }) - .unwrap(); - let icon = |k: f32| egui::pos2(row.right() - 14.0, row.center().y + 24.0 * k); - let events = |at: egui::Pos2, clicks: usize| { - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - std::iter::once(egui::Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect::>() - }; - let opened = |app: &mut VectorcraftApp| app.ui.dialog.take().map(|d| (d.kind.clone(), d.f64("index", 9.0), d.f64("width", 0.0))); - // Artboard 2's icon: the first click makes it active, the second (a double-click) opens its - // options; no rename starts. - frame(&mut app, 1.0, events(icon(1.0), 1)); - assert_eq!((app.view().unwrap().artboard, opened(&mut app)), (1, None)); - frame(&mut app, 1.1, events(icon(1.0), 1)); - assert_eq!(opened(&mut app), Some(("artboardOptions".to_string(), 1.0, 100.0))); - assert_eq!((app.session.tool_id(), pstate::>(&ctx, "ab-edit")), (tool.as_str(), None)); - // The active artboard's icon: one click opens its options. - frame(&mut app, 3.0, events(icon(1.0), 1)); - assert_eq!(opened(&mut app), Some(("artboardOptions".to_string(), 1.0, 100.0))); - // With the Artboard tool, a double-click on Artboard 1's icon (both clicks in one frame) - // opens Artboard 1's options, and the tool moves to it. - app.select_tool("artboard"); - frame(&mut app, 5.0, events(icon(0.0), 2)); - assert_eq!(opened(&mut app), Some(("artboardOptions".to_string(), 0.0, 200.0))); - assert_eq!((app.session.tool_id(), app.session.tool_options()["active"].clone()), ("artboard", json!(0))); - } - - /// The panel menu's Artboard Options… opens Artboard Options for the active artboard and leaves - /// the tool as it is. - #[test] - fn the_panel_menus_artboard_options_opens_the_active_artboards_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); - select(&mut app, 1); - let tool = app.session.tool_id().to_string(); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| menu(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - let item = frame(&mut app, vec![]) - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text().trim_start() == "Artboard Options…" => Some(t.visual_bounding_rect().center()), - _ => None, - }) - .unwrap(); - let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, vec![egui::Event::PointerMoved(item), button(true), button(false)]); - let d = app.ui.dialog.take().unwrap(); - assert_eq!((d.kind.as_str(), d.f64("index", 9.0), d.f64("x", 0.0)), ("artboardOptions", 1.0, 400.0)); - assert_eq!(app.session.tool_id(), tool); - } - - /// With two artboards selected with the Artboard tool, the panel menu's Duplicate Artboards - /// copies both, with their art, in one undo step, and the tool then selects the copies, the - /// active artboard's copy active. - #[test] - fn the_panel_menus_duplicate_artboards_copies_every_selected_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - for x in [400, 800] { - app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); - } - for x in [10, 410] { - app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap(); - } - app.select_tool("artboard"); - select(&mut app, 1); - app.session.set_tool_option("selected", &json!([1, 0])); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| menu(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - let item = frame(&mut app, vec![]) - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text().trim_start() == "Duplicate Artboards" => Some(t.visual_bounding_rect().center()), - _ => None, - }) - .unwrap(); - let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, vec![egui::Event::PointerMoved(item), button(true), button(false)]); - let d = &app.session.active().unwrap().doc; - let names: Vec<&str> = d.artboards.iter().map(|a| a.name.as_str()).collect(); - assert_eq!(names, ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 1 copy", "Artboard 2 copy"]); - assert_eq!(d.layers[0].children().unwrap().len(), 4, "the squares and their copies"); - assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (4, json!([4, 3])), "Artboard 2's copy is active"); - app.run("edit.undo", json!({})).unwrap(); - let d = &app.session.active().unwrap().doc; - assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (3, 2), "one undo step"); - } - - /// The panel menu's Convert to Artboards turns the geometric bounds of each selected rectangle - /// into a new artboard and removes the rectangles, in one undo step, as Object › Artboards › - /// Convert to Artboards does; with nothing selected it is off. - #[test] - fn the_panel_menus_convert_to_artboards_converts_the_selected_objects() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - for x in [10, 60] { - app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 30})).unwrap(); - } - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| menu(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - let item = frame(&mut app, vec![]) - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text().trim_start() == "Convert to Artboards" => Some(t.visual_bounding_rect().center()), - _ => None, - }) - .unwrap(); - let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let click = |app: &mut VectorcraftApp| frame(app, vec![egui::Event::PointerMoved(item), button(true), button(false)]); - let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); - let first = boards(&app)[0]; - app.session.execute("select.none", &json!({})).unwrap(); - frame(&mut app, vec![]); - click(&mut app); - // An enabled item would run the command, and its refusal would show in the status bar. - assert_eq!((boards(&app), app.ui.status.as_str()), (vec![first], ""), "off with nothing selected"); - app.session.execute("select.all", &json!({})).unwrap(); - // Draw one frame with the item enabled, as the app redraws the panel after the selection changes. - frame(&mut app, vec![]); - click(&mut app); - let rect = vectorcraft_geom::Rect::new; - assert_eq!(boards(&app), [first, rect(10.0, 10.0, 30.0, 40.0), rect(60.0, 10.0, 80.0, 40.0)]); - let d = app.session.active().unwrap(); - assert_eq!((d.doc.layers[0].children().unwrap().len(), d.selection.objects.len()), (0, 0), "the rectangles are removed"); - app.run("edit.undo", json!({})).unwrap(); - let d = &app.session.active().unwrap().doc; - assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 2), "one undo step"); - } - - /// One headless frame of the Control bar, 2,000 points wide, with `events` → the texts painted, - /// with their rects. - fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { - bar_frame_in(app, ctx, events, 2000.0) - } - - /// [`bar_frame`] in a window `width` points wide. - fn bar_frame_in(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, width: f32) -> Vec<(String, egui::Rect)> { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 300.0)); - let mut out = - ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| crate::chrome::control_bar(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), - _ => None, - }) - .collect() - } - - /// A context with the app's fonts, which the Control bar's label uses. - fn bar_context() -> egui::Context { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - ctx - } - - /// Two frames of the Control bar → the texts the second one painted. - fn bar_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> { - bar_frame(app, ctx, vec![]); - bar_frame(app, ctx, vec![]) - } - - /// Where `label` is painted in `texts`. - fn text_rect(texts: &[(String, egui::Rect)], label: &str) -> egui::Rect { - texts.iter().find(|(t, _)| t == label).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) - } - - /// A press and a release at `at` on the Control bar, a frame each. - fn bar_click(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) { - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - bar_frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]); - bar_frame(app, ctx, vec![button(false)]); - } - - /// Click the Control bar's field at `at`, select all of it, type `typed` and press Enter. - fn bar_type(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, typed: &str) { - let key = |key, modifiers| vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]; - bar_click(app, ctx, at); - bar_frame(app, ctx, key(egui::Key::A, egui::Modifiers::COMMAND)); - bar_frame(app, ctx, vec![egui::Event::Text(typed.into())]); - bar_frame(app, ctx, key(egui::Key::Enter, egui::Modifiers::NONE)); - bar_frame(app, ctx, vec![]); - } - - /// The Control bar's clickable widgets of the last frame that lie between x `from` and `to`, - /// left to right. - fn bar_buttons(ctx: &egui::Context, from: f32, to: f32) -> Vec { - let mut all: Vec = ctx.viewport(|vp| { - vp.prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .filter(|w| w.sense.senses_click() && w.rect.left() >= from && w.rect.right() <= to) - .map(|w| w.rect) - .collect() - }); - all.sort_by(|a, b| a.left().total_cmp(&b.left())); - all - } - - /// The artboards' rectangles, the undo steps and the artboards' names. - fn boards(app: &VectorcraftApp) -> (Vec, usize, Vec) { - let st = app.session.active().unwrap(); - (st.doc.artboards.iter().map(|a| a.rect).collect(), st.history.undo.len(), st.doc.artboards.iter().map(|a| a.name.clone()).collect()) - } - - /// With the Artboard tool in use, the Control bar shows the active artboard's controls in place - /// of the object controls: its preset, name, the Transform link, Move and Scale Artwork with - /// Artboard, X, Y, W and H, left to right, and in a narrow window the link without the fields. - /// Another tool shows the object controls. - #[test] - fn the_control_bar_shows_the_artboard_tools_controls() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "units": "Points"})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); - let ctx = bar_context(); - let has = |texts: &[(String, egui::Rect)], s: &str| texts.iter().any(|(t, _)| t == s); - let texts = bar_texts(&mut app, &ctx); - assert!(has(&texts, "Opacity:") && !has(&texts, "Name:") && !has(&texts, "Preset:"), "{texts:?}"); - app.select_tool("artboard"); - let texts = bar_texts(&mut app, &ctx); - let order = [ - "Artboard", - "Preset:", - "Name:", - "Artboard 1", - "Transform", - "Move Artwork with Artboard", - "Scale Artwork with Artboard", - "X:", - "Y:", - "W:", - "200 pt", - "H:", - ]; - let lefts: Vec = order.iter().map(|s| text_rect(&texts, s).left()).collect(); - assert!(lefts.windows(2).all(|w| w[0] < w[1]), "{order:?} at {lefts:?}"); - for s in ["Opacity:", "Stroke:"] { - assert!(!has(&texts, s), "{s}: {texts:?}"); - } - // In a window that leaves 300 points right of Scale Artwork with Artboard, the inline fields - // go (X: would start inside the window). - let width = text_rect(&texts, "Scale Artwork with Artboard").right() + 300.0; - bar_frame_in(&mut app, &ctx, vec![], width); - let texts = bar_frame_in(&mut app, &ctx, vec![], width); - assert!(has(&texts, "Scale Artwork with Artboard") && !has(&texts, "X:") && !has(&texts, "W:"), "{texts:?}"); - // The Snapping menu sits first on a wide bar and leaves the narrow bar to the artboard controls. - assert!(has(&bar_texts(&mut app, &ctx), "Snapping")); - // In the app's narrowest window, 800 points wide, the name and the Transform link still fit, - // and a click on the link shows the artboard's X, Y, W and H in the Transform panel's popover. - let narrow = |app: &mut VectorcraftApp, events| bar_frame_in(app, &ctx, events, 800.0); - narrow(&mut app, vec![]); - let texts = narrow(&mut app, vec![]); - assert!(!has(&texts, "Snapping"), "{texts:?}"); - let link = text_rect(&texts, "Transform"); - assert!(link.right() < 790.0 && text_rect(&texts, "Artboard 1").right() < link.left(), "{texts:?}"); - // Move and Scale Artwork with Artboard show as their boxes alone, inside the window, and the - // second one turns Scale Artwork with Artboard on. - assert!(!has(&texts, "Move Artwork with Artboard"), "{texts:?}"); - let boxes = bar_buttons(&ctx, link.right(), 790.0); - assert_eq!(boxes.len(), 2, "{boxes:?}"); - let scale = |app: &VectorcraftApp| app.session.tool_options()["scaleArt"].as_bool(); - assert_eq!(scale(&app), Some(false)); - let press = |pressed| egui::Event::PointerButton { - pos: boxes[1].center(), - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; - narrow(&mut app, vec![egui::Event::PointerMoved(boxes[1].center()), press(true)]); - narrow(&mut app, vec![press(false)]); - assert_eq!(scale(&app), Some(true)); - narrow(&mut app, vec![]); - let button = - |pressed| egui::Event::PointerButton { pos: link.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - narrow(&mut app, vec![egui::Event::PointerMoved(link.center()), button(true)]); - narrow(&mut app, vec![button(false)]); - let texts = narrow(&mut app, vec![]); - assert!(has(&texts, "W:") && has(&texts, "200 pt"), "the popover: {texts:?}"); - } - - /// The Control bar's W resizes the active artboard about the reference point in one undo step: - /// about its center at first, and from its top-left corner once the locator's top-left point is - /// chosen. - #[test] - fn the_control_bar_resizes_the_active_artboard_about_the_reference_point() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "units": "Points", "artboards": 2})).unwrap(); - app.select_tool("artboard"); - select(&mut app, 1); - let ctx = bar_context(); - let (before, undo, _) = boards(&app); - let w = text_rect(&bar_texts(&mut app, &ctx), "W:"); - bar_type(&mut app, &ctx, egui::pos2(w.right() + 40.0, w.center().y), "300"); - let b = before[1]; - let mut want = before.clone(); - want[1] = vectorcraft_geom::Rect::new(b.x0 - 50.0, b.y0, b.x1 + 50.0, b.y1); - assert_eq!(boards(&app).0, want, "Artboard 2 widens about its center"); - assert_eq!(boards(&app).1, undo + 1, "one undo step"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(boards(&app).0, before); - // The locator's points are the 9-point-square buttons; the top-left one is drawn first. - let texts = bar_texts(&mut app, &ctx); - let points: Vec = bar_buttons(&ctx, 0.0, text_rect(&texts, "X:").left()) - .into_iter() - .filter(|r| (r.width() - 9.0).abs() < 0.5 && (r.height() - 9.0).abs() < 0.5) - .collect(); - let top_left = points.iter().min_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()).unwrap(); - bar_click(&mut app, &ctx, top_left.center()); - assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), Some(0), "the shared reference point is the top-left one"); - let w = text_rect(&bar_texts(&mut app, &ctx), "W:"); - bar_type(&mut app, &ctx, egui::pos2(w.right() + 40.0, w.center().y), "300"); - want[1] = vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + 300.0, b.y1); - assert_eq!(boards(&app).0, want, "Artboard 2 widens from its left edge"); - } - - /// The Control bar's Portrait and Landscape buttons swap the active artboard's width and height, - /// its top-left corner staying put, in one undo step each. - #[test] - fn the_control_bar_orientation_swaps_width_and_height() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "units": "Points"})).unwrap(); - app.select_tool("artboard"); - let ctx = bar_context(); - let (_, undo, _) = boards(&app); - // Between "Preset:" and "Name:", the buttons that take a click: the preset menu, Portrait, - // Landscape and New Artboard (Delete Artboard is disabled with one artboard). - let buttons = |app: &mut VectorcraftApp| { - let texts = bar_texts(app, &ctx); - bar_buttons(&ctx, text_rect(&texts, "Preset:").right(), text_rect(&texts, "Name:").left()) - }; - let b = buttons(&mut app); - bar_click(&mut app, &ctx, b[b.len() - 3].center()); - assert_eq!((boards(&app).0, boards(&app).1), (vec![vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0)], undo + 1), "Portrait"); - let b = buttons(&mut app); - bar_click(&mut app, &ctx, b[b.len() - 2].center()); - assert_eq!((boards(&app).0, boards(&app).1), (vec![vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0)], undo + 2), "Landscape"); - } - - /// Typing a name into the Control bar's Name field and pressing Enter renames the active - /// artboard alone, in one undo step. - #[test] - fn the_control_bar_name_field_renames_the_active_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); - app.select_tool("artboard"); - select(&mut app, 1); - let ctx = bar_context(); - let (_, undo, _) = boards(&app); - let name = text_rect(&bar_texts(&mut app, &ctx), "Artboard 2"); - bar_type(&mut app, &ctx, name.center(), "Cover"); - assert_eq!((boards(&app).2, boards(&app).1), (vec!["Artboard 1".to_string(), "Cover".to_string()], undo + 1)); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(boards(&app).2, ["Artboard 1", "Artboard 2"]); - } - - /// The Control bar's New Artboard adds an artboard and makes it the active one, Artboard Options - /// opens the active artboard's options, Rearrange All Artboards opens its dialog, and Delete - /// Artboard deletes the selected artboard. - #[test] - fn the_control_bar_adds_deletes_and_opens_artboard_dialogs() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.select_tool("artboard"); - let ctx = bar_context(); - let texts = bar_texts(&mut app, &ctx); - // With one artboard Delete Artboard is disabled, so New Artboard is the last button before - // "Name:"; with two, Delete Artboard is. - let (preset, name) = (text_rect(&texts, "Preset:").right(), text_rect(&texts, "Name:").left()); - let b = bar_buttons(&ctx, preset, name); - bar_click(&mut app, &ctx, b[b.len() - 1].center()); - assert_eq!((boards(&app).2.len(), selected(&app, 2)), (2, 1), "New Artboard adds Artboard 2 and makes it active"); - // Artboard Options and Rearrange All Artboards: the two buttons between the name and the - // Transform link. - let texts = bar_texts(&mut app, &ctx); - let between = - |texts: &[(String, egui::Rect)]| bar_buttons(&ctx, text_rect(texts, "Artboard 2").right(), text_rect(texts, "Transform").left()); - let options = between(&texts)[0]; - bar_click(&mut app, &ctx, options.center()); - let d = app.ui.dialog.take().unwrap(); - assert_eq!((d.kind.as_str(), d.f64("index", 9.0)), ("artboardOptions", 1.0)); - let texts = bar_texts(&mut app, &ctx); - let rearrange = between(&texts)[1]; - bar_click(&mut app, &ctx, rearrange.center()); - assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some(crate::dialogs::rearrange_artboards::KIND.to_string())); - bar_texts(&mut app, &ctx); - let b = bar_buttons(&ctx, preset, name); - bar_click(&mut app, &ctx, b[b.len() - 1].center()); - assert_eq!(boards(&app).2, ["Artboard 1"], "Delete Artboard deletes Artboard 2"); - } - - /// Choosing the Artboard tool starts it on the active artboard, the one the panel highlights: - /// Artboard Options shows that artboard, the pointer moving over another artboard leaves it - /// active, and Delete removes it. Shift+O while the tool is in use keeps the artboards it has - /// selected. - #[test] - fn choosing_the_artboard_tool_starts_it_on_the_active_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - for x in [400, 800] { - app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); - } - select(&mut app, 1); - app.run("tool.select", json!({"tool": "artboard"})).unwrap(); - assert_eq!(app.session.tool_options()["active"], json!(1)); - app.run("tool.options", json!({"tool": "artboard"})).unwrap(); - assert_eq!(app.ui.dialog.take().unwrap().str("name"), "Artboard 2"); - let view = app.view_info(); - crate::canvas::dispatch(&mut app, &vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Move, 100.0, 75.0), view); - assert_eq!(selected(&app, 3), 1, "the pointer over Artboard 1 leaves Artboard 2 active"); - let r = app.session.tool_key(vectorcraft_tools::ToolKey::Delete, Default::default(), view); - crate::canvas::apply_requests(&mut app, r); - let names: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); - assert_eq!(names, ["Artboard 1", "Artboard 3"], "Delete removes Artboard 2"); - // Shift+O while the tool is in use keeps the artboards it has selected. - app.session.set_tool_option("selected", &json!([0, 1])); - let ctx = egui::Context::default(); - let shift_o = vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::Text("O".into())]; - let mut out = ctx.run_ui(egui::RawInput { events: shift_o, ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); - out.textures_delta.clear(); - assert_eq!((app.session.tool_id(), picked(&app, 2)), ("artboard", vec![0, 1])); - } - - /// A row dragged onto New Artboard duplicates its artboard with its art (#446), outlining the - /// button while it is held over it, and the copy becomes the active artboard. - #[test] - fn dragging_a_row_onto_new_artboard_duplicates_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - let shapes = frame(&mut app, vec![]); - let t = Tokens::get(&ctx); - let rect_of = |fill| { - shapes.iter().find_map(|c| match &c.shape { - egui::Shape::Rect(r) if r.fill == fill => Some(r.rect), - _ => None, - }) - }; - let row = rect_of(t.row_selected).unwrap().center(); - // The bottom bar's buttons sit under its divider, New Artboard second from the right. - let bar = rect_of(t.divider).unwrap(); - let new = pos2(bar.right() - 24.0 - 4.0 - 12.0, bar.bottom() + 2.0 + 12.0); - let button = - |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, vec![egui::Event::PointerMoved(row), button(row, true)]); - frame(&mut app, vec![egui::Event::PointerMoved(row + vec2(0.0, 12.0))]); - let held = frame(&mut app, vec![egui::Event::PointerMoved(new)]); - assert!(held.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.stroke.color == t.accent)), "New Artboard is outlined"); - frame(&mut app, vec![button(new, false)]); - let d = &app.session.active().unwrap().doc; - assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (2, 2), "the artboard and its art"); - assert_eq!(app.view().unwrap().artboard, 1); - } - - /// A click in a row's lock column locks its artboard and shows the lock next to the name, a - /// second click unlocks it, and each click is one undo step. Delete Artboard leaves a locked - /// artboard. - #[test] - fn the_lock_column_locks_and_unlocks_an_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.session.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 200, "height": 150})).unwrap(); - select(&mut app, 1); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - // The highlighted row (Artboard 2's): its lock column is 36 points in from its right end. - let shapes = frame(&mut app, 0.0, vec![]); - let row = shapes - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected => Some(r.rect), - _ => None, - }) - .unwrap(); - let lock = egui::pos2(row.right() - 36.0, row.center().y); - let click = |at: egui::Pos2| { - let b = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - vec![egui::Event::PointerMoved(at), b(true), b(false)] - }; - let away = vec![egui::Event::PointerMoved(egui::pos2(-50.0, -50.0))]; - let locked = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.locked).collect::>(); - // Whether something is drawn in the lock column of Artboard 2's row. - let cell = egui::Rect::from_center_size(lock, vec2(14.0, 14.0)); - let lock_drawn = |shapes: Vec| { - shapes.iter().any(|c| { - let b = c.shape.visual_bounding_rect(); - b.is_positive() && cell.contains_rect(b) - }) - }; - assert!(!lock_drawn(frame(&mut app, 1.0, away.clone()))); - frame(&mut app, 2.0, click(lock)); - assert_eq!(locked(&app), [false, true]); - assert!(lock_drawn(frame(&mut app, 3.0, away.clone())), "the lock shows"); - assert!(deletable(&app, 2).is_empty(), "Delete Artboard leaves it"); - delete_selected(&mut app); - assert_eq!(app.session.active().unwrap().doc.artboards.len(), 2); - frame(&mut app, 5.0, click(lock)); - assert_eq!(locked(&app), [false, false]); - assert!(!lock_drawn(frame(&mut app, 6.0, away))); - app.session.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(locked(&app), [false, true], "one undo step"); - } - - /// A new size for the selected artboards leaves a locked one among them as it is. The - /// artboard the field belongs to stays listed when it is locked, so `artboard.setProps` - /// refuses the change. A name typed for them goes to the locked one too. - #[test] - fn a_new_size_for_the_selected_artboards_leaves_a_locked_one() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - for x in [400, 800] { - app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); - } - app.select_tool("artboard"); - app.session.set_tool_option("selected", &json!([0, 1, 2])); - app.session.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); - assert_eq!(resized_with(&app, 0), [0, 2]); - assert_eq!(resized_with(&app, 1), [0, 1, 2], "the field's own artboard stays listed"); - crate::panels::transform::set_artboard(&mut app, 0, 0, [None, None, Some(300.0), None], false); - let widths = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect::>(); - assert_eq!(widths, [300.0, 200.0, 300.0]); - let all = renamed_with(&app, 0); - assert_eq!(all, [0, 1, 2]); - rename(&mut app, &all, "abc").unwrap(); - let names = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::>(); - assert_eq!(names, ["abc-1", "abc-2", "abc-3"]); - } - - /// Alt-click on New Artboard opens Artboard Options for the new artboard and adds nothing yet: - /// the fields are those a plain click's artboard gets. Cancel adds nothing; OK adds the artboard - /// with the fields in one undo step and makes it the active one. - #[test] - fn alt_clicking_new_artboard_opens_artboard_options_for_the_new_artboard() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); - select(&mut app, 0); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - }; - // The bottom bar's buttons sit under its divider, New Artboard second from the right. - let bar = frame(&mut app, 0.0, vec![]) - .iter() - .find_map(|c| match &c.shape { - egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).divider => Some(r.rect), - _ => None, - }) - .unwrap(); - let new = pos2(bar.right() - 24.0 - 4.0 - 12.0, bar.bottom() + 2.0 + 12.0); - let click = |modifiers: egui::Modifiers| { - let b = |pressed| egui::Event::PointerButton { pos: new, button: egui::PointerButton::Primary, pressed, modifiers }; - vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(new), b(true), b(false)] - }; - let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len(); - let fields = |app: &VectorcraftApp| { - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.kind, crate::dialogs::artboard_options::KIND); - (d.str("name"), ["x", "y", "width", "height"].map(|k| d.f64(k, -1.0))) - }; - // Alt-click: the dialog for Artboard 3, 20 pt right of Artboard 2 and as large; no artboard yet. - frame(&mut app, 1.0, click(egui::Modifiers::ALT)); - assert_eq!(fields(&app), ("Artboard 3".to_string(), [520.0, 300.0, 100.0, 50.0])); - assert_eq!(count(&app), 2, "no artboard is added before OK"); - // A plain click adds the artboard the dialog showed, with no dialog. - crate::dialogs::cancel(&mut app); - frame(&mut app, 2.0, click(egui::Modifiers::NONE)); - let ab = app.session.active().unwrap().doc.artboards[2].clone(); - assert_eq!((ab.name.as_str(), ab.rect), ("Artboard 3", vectorcraft_geom::Rect::new(520.0, 300.0, 620.0, 350.0))); - assert!(app.ui.dialog.is_none()); - app.run("edit.undo", json!({})).unwrap(); - select(&mut app, 0); - // Cancel adds nothing. - frame(&mut app, 3.0, click(egui::Modifiers::ALT)); - crate::dialogs::cancel(&mut app); - assert_eq!((count(&app), app.view().unwrap().artboard), (2, 0)); - // OK adds it with the fields, in one undo step, and makes it the active artboard. - frame(&mut app, 4.0, click(egui::Modifiers::ALT)); - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("name".into(), json!("Cover")); - d.fields.insert("width".into(), json!(300)); - let undo = app.session.doc().unwrap().history.undo.len(); - crate::dialogs::confirm(&mut app).unwrap(); - let ab = app.session.active().unwrap().doc.artboards[2].clone(); - assert_eq!((ab.name.as_str(), ab.rect), ("Cover", vectorcraft_geom::Rect::new(520.0, 300.0, 820.0, 350.0))); - assert_eq!((app.view().unwrap().artboard, app.session.doc().unwrap().history.undo.len()), (2, undo + 1)); - assert!(app.ui.dialog.is_none()); - } - - /// `ui.newArtboard` answers with the dialog's kind. A name left empty gives the new artboard - /// its default name, and with the Artboard tool in use the tool moves to the new artboard. - #[test] - fn new_artboard_options_with_an_empty_name_add_the_default_name() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - assert!(app.run("ui.newArtboard", json!({})).is_err(), "no document"); - app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); - app.select_tool("artboard"); - assert_eq!(app.run("ui.newArtboard", json!({})).unwrap(), json!({"dialog": "artboardOptions"})); - app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(" ")); - crate::dialogs::confirm(&mut app).unwrap(); - let names: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); - assert_eq!(names, ["Artboard 1", "Artboard 2"]); - assert_eq!((selected(&app, 2), app.session.tool_options()["active"].clone()), (1, json!(1))); - } - - /// The panel menu's Delete Empty Artboards is disabled while every artboard has art, and - /// enabled otherwise; a click deletes the empty ones in one undo step and makes the artboard - /// left active. - #[test] - fn delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); - for x in [400, 800] { - app.run("artboard.new", json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); - } - let rect = - |app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); - let ids: Vec = [10.0, 450.0, 850.0].into_iter().map(|x| rect(&mut app, x)).collect(); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); - let raw = |events| egui::RawInput { events, screen_rect: screen, ..Default::default() }; - let item_of = |app: &mut VectorcraftApp| { - let mut out = ctx.run_ui(raw(vec![]), |ui| menu(app, ui)); - out.textures_delta.clear(); - out.shapes.iter().find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text().trim_start() == "Delete Empty Artboards" => Some(t.visual_bounding_rect().center()), - _ => None, - }) - }; - // Menu rows pad their label where a check mark would go. - let label = " Delete Empty Artboards"; - assert!(named(&tree(|ui| menu(&mut app, ui)), label).is_disabled(), "every artboard has art"); - app.run("select.set", json!({"ids": [ids[0], ids[2]]})).unwrap(); - app.run("edit.clear", json!({})).unwrap(); - select(&mut app, 2); - assert!(!named(&tree(|ui| menu(&mut app, ui)), label).is_disabled(), "two artboards are empty"); - let item = item_of(&mut app).unwrap(); - let steps = app.session.active().unwrap().history.undo.len(); - let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - ctx.run_ui(raw(vec![egui::Event::PointerMoved(item), button(true), button(false)]), |ui| menu(&mut app, ui)).textures_delta.clear(); - let st = app.session.active().unwrap(); - let names: Vec = st.doc.artboards.iter().map(|a| a.name.clone()).collect(); - assert_eq!(names, ["Artboard 2"]); - assert_eq!(st.history.undo.len(), steps + 1, "one undo step"); - assert_eq!(selected(&app, 1), 0, "the artboard left is active"); - } - - /// With the Artboard tool, the canvas context menu locks and unlocks artboards: Lock Selected - /// locks the selected one (the others stay free), Unlock All frees every artboard. - #[test] - fn the_artboard_tools_context_menu_locks_and_unlocks_artboards() { - use crate::menus::Item; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 3})).unwrap(); - let locked = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.locked).collect::>(); - let run = |app: &mut VectorcraftApp, menu: &str, label: &str| { - let items = crate::menus::context_items(app); - let Some(Item::Sub(_, subs)) = items.iter().find(|i| matches!(i, Item::Sub(t, _) if *t == menu)) else { panic!("no {menu}") }; - let Some(Item::Cmd(_, id, p)) = subs.iter().find(|i| matches!(i, Item::Cmd(l, ..) if *l == label)) else { panic!("no {label}") }; - crate::menus::invoke(app, id, p.clone()); - }; - // Without the Artboard tool the menu has no such submenu. - assert!(!crate::menus::context_items(&app).iter().any(|i| matches!(i, Item::Sub(t, _) if *t == "Lock Artboards"))); - app.select_tool("artboard"); - select(&mut app, 1); - run(&mut app, "Lock Artboards", "Lock Selected"); - assert_eq!(locked(&app), [false, true, false]); - run(&mut app, "Lock Artboards", "Lock All Except Selected"); - assert_eq!(locked(&app), [true, true, true], "the others lock, the locked one stays"); - run(&mut app, "Unlock Artboards", "Unlock Selected"); - assert_eq!(locked(&app), [true, false, true]); - run(&mut app, "Unlock Artboards", "Unlock All"); - assert_eq!(locked(&app), [false, false, false]); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs new file mode 100644 index 000000000..e0edc9a17 --- /dev/null +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -0,0 +1,1234 @@ +use super::*; +use crate::widgets::accessibility_tests::{named, tree}; +use vectorcraft_engine::Session; + +/// Clicking a row makes its artboard the active one (the navigator's, Fit Artboard in Window's). +#[test] +fn clicking_a_row_makes_its_artboard_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 200, "height": 150})).unwrap(); + app.view_mut().unwrap().artboard = 0; + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + // Where the second row's name is painted. + let at = frame(&mut app, vec![]) + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "Artboard 2" => Some(t.pos + vec2(4.0, 4.0)), + _ => None, + }) + .unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [egui::Event::PointerMoved(at), button(true), button(false)] { + frame(&mut app, vec![e]); + } + assert_eq!(app.view().unwrap().artboard, 1, "Artboard 2 is active"); + // The navigator moves it on; the panel follows. + crate::menus::invoke(&mut app, "view.goToArtboard", json!({"index": "previous"})); + assert_eq!(selected(&app, 2), 0, "and the panel follows the navigator"); +} + +/// #1006: Select › All on Active Artboard selects the art of the artboard made active here, +/// and an explicit artboard still wins. +#[test] +fn all_on_active_artboard_takes_the_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap(); + let mut rect = |x| app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + let (a, b) = (rect(10), rect(130)); + let picked = |app: &mut VectorcraftApp, params| { + crate::menus::invoke(app, "select.allOnArtboard", params); + let ids: Vec = app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect(); + json!(ids) + }; + select(&mut app, 1); + assert_eq!(picked(&mut app, json!({})), json!([b])); + select(&mut app, 0); + assert_eq!(picked(&mut app, json!({})), json!([a])); + assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b])); +} + +/// Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art resize the artboard made +/// active here, as the panel menu's Fit to Artwork Bounds does, and leave artboard 1 as it was; +/// an explicit index still wins. +#[test] +fn fit_to_artwork_bounds_and_selected_art_resize_the_active_artboard() { + for id in ["artboard.fitToArt", "artboard.fitToSelection"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap(); + // Partly on artboard 1 and partly on artboard 2. + app.session.execute("shape.rectangle", &json!({"x": 90, "y": 10, "width": 60, "height": 30})).unwrap(); + app.session.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let (first, art) = (boards(&app)[0], vectorcraft_geom::Rect::new(90.0, 10.0, 150.0, 40.0)); + select(&mut app, 1); + crate::menus::invoke(&mut app, id, json!({})); + assert_eq!(boards(&app), [first, art], "{id} resizes Artboard 2, the active one"); + crate::menus::invoke(&mut app, id, json!({"index": 0})); + assert_eq!(boards(&app), [art, art], "{id} with an index resizes that artboard"); + } +} + +/// With artboards 1 and 3 selected with the Artboard tool, Object › Artboards › Fit to Artwork +/// Bounds fits each of them to the art on it and leaves artboard 2 as it was. +#[test] +fn fit_to_artwork_bounds_fits_each_selected_artboard_to_its_own_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 3})).unwrap(); + for (x, h) in [(10, 30), (130, 20), (250, 40)] { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap(); + } + app.session.execute("select.all", &json!({})).unwrap(); + app.session.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let was = boards(&app); + app.select_tool("artboard"); + select(&mut app, 0); + app.session.set_tool_option("selected", &json!([0, 2])); + crate::menus::invoke(&mut app, "artboard.fitToArt", json!({})); + let rect = vectorcraft_geom::Rect::new; + assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]); +} + +/// With the Artboard tool, Shift-click on a row selects the artboards from the last row clicked +/// without Shift to it and makes it the active one, Ctrl/Cmd-click adds its artboard to the +/// tool's selection or takes it out (#1139), a plain click selects it alone, and every selected +/// row is highlighted. +#[test] +fn shift_clicking_rows_selects_several_artboards() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + for x in [400, 800, 1200] { + app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); + } + app.select_tool("artboard"); + select(&mut app, 1); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let raw = egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let shapes = frame(&mut app, 0.0, vec![]); + // `row(k)` returns the point where row `k`'s name is painted. + let row = |k: usize| { + shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == format!("Artboard {k}") => Some(t.pos + vec2(4.0, 4.0)), + _ => None, + }) + .unwrap() + }; + let click = |k: usize, modifiers: egui::Modifiers| { + let at = row(k); + let b = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; + vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(at), b(true), b(false)] + }; + let highlighted = |shapes: Vec| { + shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected)).count() + }; + // With no row clicked yet, Shift-click ranges from the active artboard. + frame(&mut app, 1.0, click(4, egui::Modifiers::SHIFT)); + assert_eq!((picked(&app, 4), selected(&app, 4)), (vec![1, 2, 3], 3)); + frame(&mut app, 1.5, click(3, egui::Modifiers::SHIFT)); + assert_eq!(picked(&app, 4), [1, 2], "the next Shift-click ranges from the same artboard"); + frame(&mut app, 2.0, click(1, egui::Modifiers::NONE)); + assert_eq!(picked(&app, 4), [0]); + frame(&mut app, 3.0, click(3, egui::Modifiers::SHIFT)); + assert_eq!((picked(&app, 4), selected(&app, 4)), (vec![0, 1, 2], 2), "Artboards 1 to 3, Artboard 3 active"); + assert_eq!(highlighted(frame(&mut app, 4.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)])), 3, "three rows highlighted"); + // A second Shift-click ranges from the same row. + frame(&mut app, 5.0, click(2, egui::Modifiers::SHIFT)); + assert_eq!(picked(&app, 4), [0, 1]); + frame(&mut app, 6.0, click(3, egui::Modifiers::SHIFT)); + frame(&mut app, 7.0, click(2, egui::Modifiers::COMMAND)); + assert_eq!(picked(&app, 4), [0, 2], "Ctrl/Cmd-click takes Artboard 2 out"); + frame(&mut app, 8.0, click(2, egui::Modifiers::COMMAND)); + assert_eq!(picked(&app, 4), [0, 1, 2], "and puts it back"); + frame(&mut app, 9.0, click(4, egui::Modifiers::NONE)); + assert_eq!(picked(&app, 4), [3]); + assert_eq!(highlighted(frame(&mut app, 10.0, vec![])), 1); + // With another tool, a Shift-click makes the row's artboard active, as a plain click does. + app.select_tool("selection"); + frame(&mut app, 11.0, click(2, egui::Modifiers::SHIFT)); + assert_eq!((picked(&app, 4), selected(&app, 4)), (vec![1], 1)); +} + +/// Double-clicking a row's number goes to its artboard and its name renames it; a click on +/// another row's options icon makes that artboard the active one, and the Artboard tool's +/// artboard is the active one. +#[test] +fn double_clicks_go_to_or_rename_and_options_edits_the_rows_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 200, "height": 150})).unwrap(); + app.view_mut().unwrap().artboard = 0; + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let shapes = frame(&mut app, 0.0, vec![]); + let name = shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "Artboard 2" => Some(t.pos + vec2(4.0, 4.0)), + _ => None, + }) + .unwrap(); + // The highlighted row (Artboard 1's), for its Options button at the right end. + let row = shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected => Some(r.rect), + _ => None, + }) + .unwrap(); + let events = |at: egui::Pos2, clicks: usize| { + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + std::iter::once(egui::Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect::>() + }; + // Artboard 2's number: it is active, no rename starts. + frame(&mut app, 1.0, events(name - vec2(16.0, 0.0), 2)); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(pstate::>(&ctx, "ab-edit"), None); + // Its name: a rename. + frame(&mut app, 2.0, events(name, 2)); + assert_eq!(pstate::>(&ctx, "ab-edit"), Some(1)); + set_pstate::>(&ctx, "ab-edit", None); + frame(&mut app, 3.0, vec![]); + // A click on Artboard 1's options icon makes Artboard 1 active, and the Artboard tool, + // chosen next, starts on it. + frame(&mut app, 4.0, events(egui::pos2(row.right() - 14.0, row.center().y), 1)); + app.select_tool("artboard"); + assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["active"].clone()), (0, json!(0))); + // While the Artboard tool is chosen, clicking a row moves the tool to that artboard too. + frame(&mut app, 5.0, events(name, 1)); + assert_eq!(app.session.tool_options()["active"], json!(1)); + // And clicking an artboard with the tool makes it the active one, highlighted in the panel. + let view = app.view_info(); + for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { + crate::canvas::dispatch(&mut app, &vectorcraft_tools::PointerEvent::new(kind, 100.0, 100.0), view); + } + assert_eq!((app.session.tool_options()["active"].clone(), selected(&app, 2)), (json!(0), 0)); +} + +/// Shift+Enter in a row's name field renames the artboard and keeps the field open, the new +/// name selected; Enter then renames it again and closes the field. +#[test] +fn shift_enter_renames_an_artboard_and_keeps_the_field_open() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let name = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards[1].name.clone(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + // What a double-click on the second row's name does: its name field opens. + set_pstate::>(&ctx, "ab-edit", Some(1)); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Cover".into())]); + frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::SHIFT)]); + assert_eq!(name(&app), "Cover", "Shift+Enter applies the name"); + let field = ctx.memory(|m| m.focused()).expect("the field keeps the focus"); + assert_eq!(pstate::>(&ctx, "ab-edit"), Some(1), "and stays open"); + frame(&mut app, vec![]); + let selected = egui::TextEdit::load_state(&ctx, field).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 5)), "the name shown is selected"); + frame(&mut app, vec![egui::Event::Text("Back".into())]); + frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + frame(&mut app, vec![]); + assert_eq!(name(&app), "Back", "typing replaced it, and Enter applies that"); + assert_eq!(pstate::>(&ctx, "ab-edit"), None, "and closes the field"); +} + +/// With artboards 1, 3 and 4 selected with the Artboard tool, the panel menu's Rename opens an +/// empty name field on the active artboard's row, and the name typed there goes to all three, +/// numbered in panel order, in one undo step. With one artboard selected, Rename gives it the +/// name as typed. +#[test] +fn the_panel_menus_rename_numbers_several_selected_artboards() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + for x in [400, 800, 1200] { + app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); + } + app.select_tool("artboard"); + select(&mut app, 2); + app.session.set_tool_option("selected", &json!([2, 0, 3])); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let raw = |events| egui::RawInput { events, screen_rect: screen, ..Default::default() }; + let rename = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(raw(vec![]), |ui| menu(app, ui)); + out.textures_delta.clear(); + let item = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text().trim_start() == "Rename" => Some(t.visual_bounding_rect().center()), + _ => None, + }) + .unwrap(); + let button = + |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + ctx.run_ui(raw(vec![egui::Event::PointerMoved(item), button(true), button(false)]), |ui| menu(app, ui)).textures_delta.clear(); + }; + let frame = |app: &mut VectorcraftApp, events| ctx.run_ui(raw(events), |ui| show(app, ui)).textures_delta.clear(); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let names = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::>(); + rename(&mut app); + assert_eq!(pstate::>(&ctx, "ab-edit"), Some(2), "the field opens on the active artboard's row"); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("abc".into())]); + frame(&mut app, vec![enter.clone()]); + frame(&mut app, vec![]); + assert_eq!(names(&app), ["abc-1", "Artboard 2", "abc-2", "abc-3"]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(names(&app), ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4"], "one undo step"); + // One artboard selected: the field shows its name, and the name typed replaces it. + select(&mut app, 1); + rename(&mut app); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let all = egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + frame(&mut app, vec![all, egui::Event::Text("Solo".into())]); + frame(&mut app, vec![enter]); + frame(&mut app, vec![]); + assert_eq!(names(&app), ["Artboard 1", "Solo", "Artboard 3", "Artboard 4"]); +} + +/// Paste run on the session alone (`session.execute`, which leaves the view as it is) with the +/// Artboard tool selects the pasted artboard and leaves the active artboard where it was. The +/// panel menu's Rename then opens the name field on the selected artboard's row and renames +/// that artboard, not the active one. +#[test] +fn the_panel_menus_rename_opens_on_a_selected_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); + app.select_tool("artboard"); + select(&mut app, 1); + app.session.execute("edit.copy", &json!({})).unwrap(); + app.session.execute("edit.paste", &json!({})).unwrap(); + assert_eq!((selected(&app, 3), picked(&app, 3)), (1, vec![2]), "Artboard 2 is active, and the pasted Artboard 3 is selected"); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let raw = |events| egui::RawInput { events, screen_rect: screen, ..Default::default() }; + let mut out = ctx.run_ui(raw(vec![]), |ui| menu(&mut app, ui)); + out.textures_delta.clear(); + let item = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text().trim_start() == "Rename" => Some(t.visual_bounding_rect().center()), + _ => None, + }) + .unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + ctx.run_ui(raw(vec![egui::Event::PointerMoved(item), button(true), button(false)]), |ui| menu(&mut app, ui)).textures_delta.clear(); + assert_eq!(pstate::>(&ctx, "ab-edit"), Some(2), "the field opens on Artboard 3's row"); + let mut frame = |events| ctx.run_ui(raw(events), |ui| show(&mut app, ui)).textures_delta.clear(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame(vec![]); + frame(vec![]); + frame(vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Pasted".into())]); + frame(vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + frame(vec![]); + let names: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); + assert_eq!(names, ["Artboard 1", "Artboard 2", "Pasted"]); +} + +/// With the Artboard tool, Paste and Paste in Place make the pasted artboard the active one +/// (the navigator's and the panel's), and Cut makes the artboard before the cut one active, the +/// one the tool then has active. +#[test] +fn paste_and_cut_with_the_artboard_tool_make_its_artboard_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + select(&mut app, 1); + let active = |app: &VectorcraftApp| (app.view().unwrap().artboard, app.session.tool_options()["active"].clone()); + app.run("edit.copy", json!({})).unwrap(); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(active(&app), (3, json!(3)), "Paste: the pasted Artboard 4"); + app.run("edit.pasteInPlace", json!({})).unwrap(); + assert_eq!(active(&app), (4, json!(4)), "Paste in Place: the pasted Artboard 5"); + select(&mut app, 2); + app.run("edit.cut", json!({})).unwrap(); + assert_eq!(active(&app), (1, json!(1)), "Cut Artboard 3: Artboard 2"); +} + +/// `tool.setOption` with the Artboard tool's `selected` or `active` makes the artboard the tool +/// then has active the active one. +#[test] +fn the_artboard_tools_options_make_its_artboard_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + app.run("tool.setOption", json!({"key": "selected", "value": [2, 0]})).unwrap(); + assert_eq!((app.view().unwrap().artboard, picked(&app, 3)), (2, vec![0, 2])); + app.run("tool.setOption", json!({"tool": "artboard", "values": {"active": 1}})).unwrap(); + assert_eq!((app.view().unwrap().artboard, picked(&app, 3)), (1, vec![1])); +} + +/// With the Artboard tool, a `command.batch` that stays in the document makes the artboard the +/// tool then has active the active one, as its steps do on their own: a batch that leaves the +/// tool's artboards keeps the active artboard, and Copy and Paste make the pasted one active. +#[test] +fn a_batch_in_one_document_makes_the_artboard_tools_artboard_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + select(&mut app, 1); + let active = |app: &VectorcraftApp| (app.view().unwrap().artboard, app.session.tool_options()["active"].clone()); + app.run("command.batch", json!({"commands": [{"command": "object.unlockAll", "params": {}}]})).unwrap(); + assert_eq!(active(&app), (1, json!(1))); + let steps = [json!({"command": "edit.copy", "params": {}}), json!({"command": "edit.paste", "params": {}})]; + app.run("command.batch", json!({"commands": steps})).unwrap(); + assert_eq!(active(&app), (3, json!(3)), "the pasted Artboard 4"); +} + +/// With the Artboard tool, `document.activate` makes the active artboard of the document it +/// switches to the tool's, as choosing the document's tab does. +#[test] +fn switching_documents_with_the_artboard_tool_keeps_the_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + select(&mut app, 2); + app.run("file.new", json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (2, json!([2]))); +} + +/// With the Artboard tool, a `command.batch` that switches to another document and back keeps +/// the active artboard, and the tool starts on it. When a later step of the batch pastes an +/// artboard, the pasted one becomes active, as Paste on its own makes it. A later Cut of the +/// tool's artboard 1 makes the new first artboard active, as Cut on its own makes it. +#[test] +fn a_batch_that_switches_documents_and_back_keeps_the_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap(); + app.run("file.new", json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.select_tool("artboard"); + select(&mut app, 2); + let step = |command: &str, params: Value| json!({"command": command, "params": params}); + let back = [step("document.activate", json!({"index": 1})), step("document.activate", json!({"index": 0}))]; + app.run("command.batch", json!({"commands": back})).unwrap(); + assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (2, json!([2]))); + app.run("edit.copy", json!({})).unwrap(); + let mut steps = back.to_vec(); + steps.push(step("edit.paste", json!({}))); + app.run("command.batch", json!({"commands": steps})).unwrap(); + assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (3, json!([3])), "the pasted Artboard 4"); + let mut steps = back.to_vec(); + steps.push(step("edit.cut", json!({}))); + app.run("command.batch", json!({"commands": steps})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3, "Artboard 1 cut"); + assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (0, json!([0])), "the new first artboard"); +} + +/// With the Artboard tool, a double-click on a name with Shift or Ctrl/Cmd held that leaves +/// several artboards selected opens the name field for all of them. A Ctrl/Cmd double-click on +/// an unselected row's name adds its artboard to the selection, and the second click leaves it +/// selected. A plain double-click selects the row's artboard alone and opens the field for it. +#[test] +fn a_shift_or_ctrl_double_click_on_a_name_renames_every_selected_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + for x in [400, 800, 1200] { + app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); + } + app.select_tool("artboard"); + select(&mut app, 0); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let raw = egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let shapes = frame(&mut app, 0.0, vec![]); + // `row(k)` returns the point where row `k`'s name is painted. + let row = |k: usize| { + shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == format!("Artboard {k}") => Some(t.pos + vec2(4.0, 4.0)), + _ => None, + }) + .unwrap() + }; + let click = |k: usize, modifiers: egui::Modifiers| { + let at = row(k); + let b = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; + vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(at), b(true), b(false)] + }; + // Two clicks on row `k`'s name in two frames 0.1 s apart, then the row and the artboards + // the name field opened for, and the field closed again. + let double_click = |app: &mut VectorcraftApp, time: f64, k: usize, modifiers: egui::Modifiers| { + frame(app, time, click(k, modifiers)); + frame(app, time + 0.1, click(k, modifiers)); + let opened = (pstate::>(&ctx, "ab-edit"), pstate::>(&ctx, "ab-edit-all")); + set_pstate::>(&ctx, "ab-edit", None); + set_pstate::>(&ctx, "ab-edit-all", vec![]); + frame(app, time + 0.5, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + opened + }; + frame(&mut app, 1.0, click(1, egui::Modifiers::NONE)); + assert_eq!(double_click(&mut app, 2.0, 3, egui::Modifiers::SHIFT), (Some(2), vec![0, 1, 2]), "Shift: Artboards 1 to 3"); + assert_eq!(double_click(&mut app, 4.0, 2, egui::Modifiers::COMMAND), (Some(1), vec![0, 1, 2]), "Ctrl/Cmd on a selected one"); + assert_eq!(double_click(&mut app, 5.0, 4, egui::Modifiers::COMMAND), (Some(3), vec![0, 1, 2, 3]), "Ctrl/Cmd on another one"); + assert_eq!(picked(&app, 4), [0, 1, 2, 3], "which stays selected"); + assert_eq!(double_click(&mut app, 6.0, 4, egui::Modifiers::NONE), (Some(3), vec![3]), "a plain double-click"); +} + +/// A row's options icon opens Artboard Options for that row's artboard and leaves the tool as +/// it is (#1876): a click on the active artboard's icon, or a double-click on another row's +/// icon, whose first click only makes that artboard active. With the Artboard tool in use, the +/// tool moves to that artboard too. +#[test] +fn the_options_icon_opens_artboard_options_for_its_rows_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); + app.view_mut().unwrap().artboard = 0; + let tool = app.session.tool_id().to_string(); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + // The highlighted row (Artboard 1's). The rows are 24 points apart, and each icon's + // center is 14 points in from the row's right end. + let row = frame(&mut app, 0.0, vec![]) + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected => Some(r.rect), + _ => None, + }) + .unwrap(); + let icon = |k: f32| egui::pos2(row.right() - 14.0, row.center().y + 24.0 * k); + let events = |at: egui::Pos2, clicks: usize| { + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + std::iter::once(egui::Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect::>() + }; + let opened = |app: &mut VectorcraftApp| app.ui.dialog.take().map(|d| (d.kind.clone(), d.f64("index", 9.0), d.f64("width", 0.0))); + // Artboard 2's icon: the first click makes it active, the second (a double-click) opens its + // options; no rename starts. + frame(&mut app, 1.0, events(icon(1.0), 1)); + assert_eq!((app.view().unwrap().artboard, opened(&mut app)), (1, None)); + frame(&mut app, 1.1, events(icon(1.0), 1)); + assert_eq!(opened(&mut app), Some(("artboardOptions".to_string(), 1.0, 100.0))); + assert_eq!((app.session.tool_id(), pstate::>(&ctx, "ab-edit")), (tool.as_str(), None)); + // The active artboard's icon: one click opens its options. + frame(&mut app, 3.0, events(icon(1.0), 1)); + assert_eq!(opened(&mut app), Some(("artboardOptions".to_string(), 1.0, 100.0))); + // With the Artboard tool, a double-click on Artboard 1's icon (both clicks in one frame) + // opens Artboard 1's options, and the tool moves to it. + app.select_tool("artboard"); + frame(&mut app, 5.0, events(icon(0.0), 2)); + assert_eq!(opened(&mut app), Some(("artboardOptions".to_string(), 0.0, 200.0))); + assert_eq!((app.session.tool_id(), app.session.tool_options()["active"].clone()), ("artboard", json!(0))); +} + +/// The panel menu's Artboard Options… opens Artboard Options for the active artboard and leaves +/// the tool as it is. +#[test] +fn the_panel_menus_artboard_options_opens_the_active_artboards_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); + select(&mut app, 1); + let tool = app.session.tool_id().to_string(); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| menu(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let item = frame(&mut app, vec![]) + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text().trim_start() == "Artboard Options…" => Some(t.visual_bounding_rect().center()), + _ => None, + }) + .unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(item), button(true), button(false)]); + let d = app.ui.dialog.take().unwrap(); + assert_eq!((d.kind.as_str(), d.f64("index", 9.0), d.f64("x", 0.0)), ("artboardOptions", 1.0, 400.0)); + assert_eq!(app.session.tool_id(), tool); +} + +/// With two artboards selected with the Artboard tool, the panel menu's Duplicate Artboards +/// copies both, with their art, in one undo step, and the tool then selects the copies, the +/// active artboard's copy active. +#[test] +fn the_panel_menus_duplicate_artboards_copies_every_selected_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + for x in [400, 800] { + app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); + } + for x in [10, 410] { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap(); + } + app.select_tool("artboard"); + select(&mut app, 1); + app.session.set_tool_option("selected", &json!([1, 0])); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| menu(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let item = frame(&mut app, vec![]) + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text().trim_start() == "Duplicate Artboards" => Some(t.visual_bounding_rect().center()), + _ => None, + }) + .unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(item), button(true), button(false)]); + let d = &app.session.active().unwrap().doc; + let names: Vec<&str> = d.artboards.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 1 copy", "Artboard 2 copy"]); + assert_eq!(d.layers[0].children().unwrap().len(), 4, "the squares and their copies"); + assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (4, json!([4, 3])), "Artboard 2's copy is active"); + app.run("edit.undo", json!({})).unwrap(); + let d = &app.session.active().unwrap().doc; + assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (3, 2), "one undo step"); +} + +/// The panel menu's Convert to Artboards turns the geometric bounds of each selected rectangle +/// into a new artboard and removes the rectangles, in one undo step, as Object › Artboards › +/// Convert to Artboards does; with nothing selected it is off. +#[test] +fn the_panel_menus_convert_to_artboards_converts_the_selected_objects() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + for x in [10, 60] { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 30})).unwrap(); + } + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| menu(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let item = frame(&mut app, vec![]) + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text().trim_start() == "Convert to Artboards" => Some(t.visual_bounding_rect().center()), + _ => None, + }) + .unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let click = |app: &mut VectorcraftApp| frame(app, vec![egui::Event::PointerMoved(item), button(true), button(false)]); + let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let first = boards(&app)[0]; + app.session.execute("select.none", &json!({})).unwrap(); + frame(&mut app, vec![]); + click(&mut app); + // An enabled item would run the command, and its refusal would show in the status bar. + assert_eq!((boards(&app), app.ui.status.as_str()), (vec![first], ""), "off with nothing selected"); + app.session.execute("select.all", &json!({})).unwrap(); + // Draw one frame with the item enabled, as the app redraws the panel after the selection changes. + frame(&mut app, vec![]); + click(&mut app); + let rect = vectorcraft_geom::Rect::new; + assert_eq!(boards(&app), [first, rect(10.0, 10.0, 30.0, 40.0), rect(60.0, 10.0, 80.0, 40.0)]); + let d = app.session.active().unwrap(); + assert_eq!((d.doc.layers[0].children().unwrap().len(), d.selection.objects.len()), (0, 0), "the rectangles are removed"); + app.run("edit.undo", json!({})).unwrap(); + let d = &app.session.active().unwrap().doc; + assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 2), "one undo step"); +} + +/// One headless frame of the Control bar, 2,000 points wide, with `events` → the texts painted, +/// with their rects. +fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + bar_frame_in(app, ctx, events, 2000.0) +} + +/// [`bar_frame`] in a window `width` points wide. +fn bar_frame_in(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, width: f32) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 300.0)); + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| crate::chrome::control_bar(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), + _ => None, + }) + .collect() +} + +/// A context with the app's fonts, which the Control bar's label uses. +fn bar_context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx +} + +/// Two frames of the Control bar → the texts the second one painted. +fn bar_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> { + bar_frame(app, ctx, vec![]); + bar_frame(app, ctx, vec![]) +} + +/// Where `label` is painted in `texts`. +fn text_rect(texts: &[(String, egui::Rect)], label: &str) -> egui::Rect { + texts.iter().find(|(t, _)| t == label).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) +} + +/// A press and a release at `at` on the Control bar, a frame each. +fn bar_click(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + bar_frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]); + bar_frame(app, ctx, vec![button(false)]); +} + +/// Click the Control bar's field at `at`, select all of it, type `typed` and press Enter. +fn bar_type(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, typed: &str) { + let key = |key, modifiers| vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]; + bar_click(app, ctx, at); + bar_frame(app, ctx, key(egui::Key::A, egui::Modifiers::COMMAND)); + bar_frame(app, ctx, vec![egui::Event::Text(typed.into())]); + bar_frame(app, ctx, key(egui::Key::Enter, egui::Modifiers::NONE)); + bar_frame(app, ctx, vec![]); +} + +/// The Control bar's clickable widgets of the last frame that lie between x `from` and `to`, +/// left to right. +fn bar_buttons(ctx: &egui::Context, from: f32, to: f32) -> Vec { + let mut all: Vec = ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.left() >= from && w.rect.right() <= to) + .map(|w| w.rect) + .collect() + }); + all.sort_by(|a, b| a.left().total_cmp(&b.left())); + all +} + +/// The artboards' rectangles, the undo steps and the artboards' names. +fn boards(app: &VectorcraftApp) -> (Vec, usize, Vec) { + let st = app.session.active().unwrap(); + (st.doc.artboards.iter().map(|a| a.rect).collect(), st.history.undo.len(), st.doc.artboards.iter().map(|a| a.name.clone()).collect()) +} + +/// With the Artboard tool in use, the Control bar shows the active artboard's controls in place +/// of the object controls: its preset, name, the Transform link, Move and Scale Artwork with +/// Artboard, X, Y, W and H, left to right, and in a narrow window the link without the fields. +/// Another tool shows the object controls. +#[test] +fn the_control_bar_shows_the_artboard_tools_controls() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "units": "Points"})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let ctx = bar_context(); + let has = |texts: &[(String, egui::Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + let texts = bar_texts(&mut app, &ctx); + assert!(has(&texts, "Opacity:") && !has(&texts, "Name:") && !has(&texts, "Preset:"), "{texts:?}"); + app.select_tool("artboard"); + let texts = bar_texts(&mut app, &ctx); + let order = [ + "Artboard", + "Preset:", + "Name:", + "Artboard 1", + "Transform", + "Move Artwork with Artboard", + "Scale Artwork with Artboard", + "X:", + "Y:", + "W:", + "200 pt", + "H:", + ]; + let lefts: Vec = order.iter().map(|s| text_rect(&texts, s).left()).collect(); + assert!(lefts.windows(2).all(|w| w[0] < w[1]), "{order:?} at {lefts:?}"); + for s in ["Opacity:", "Stroke:"] { + assert!(!has(&texts, s), "{s}: {texts:?}"); + } + // In a window that leaves 300 points right of Scale Artwork with Artboard, the inline fields + // go (X: would start inside the window). + let width = text_rect(&texts, "Scale Artwork with Artboard").right() + 300.0; + bar_frame_in(&mut app, &ctx, vec![], width); + let texts = bar_frame_in(&mut app, &ctx, vec![], width); + assert!(has(&texts, "Scale Artwork with Artboard") && !has(&texts, "X:") && !has(&texts, "W:"), "{texts:?}"); + // The Snapping menu sits first on a wide bar and leaves the narrow bar to the artboard controls. + assert!(has(&bar_texts(&mut app, &ctx), "Snapping")); + // In the app's narrowest window, 800 points wide, the name and the Transform link still fit, + // and a click on the link shows the artboard's X, Y, W and H in the Transform panel's popover. + let narrow = |app: &mut VectorcraftApp, events| bar_frame_in(app, &ctx, events, 800.0); + narrow(&mut app, vec![]); + let texts = narrow(&mut app, vec![]); + assert!(!has(&texts, "Snapping"), "{texts:?}"); + let link = text_rect(&texts, "Transform"); + assert!(link.right() < 790.0 && text_rect(&texts, "Artboard 1").right() < link.left(), "{texts:?}"); + // Move and Scale Artwork with Artboard show as their boxes alone, inside the window, and the + // second one turns Scale Artwork with Artboard on. + assert!(!has(&texts, "Move Artwork with Artboard"), "{texts:?}"); + let boxes = bar_buttons(&ctx, link.right(), 790.0); + assert_eq!(boxes.len(), 2, "{boxes:?}"); + let scale = |app: &VectorcraftApp| app.session.tool_options()["scaleArt"].as_bool(); + assert_eq!(scale(&app), Some(false)); + let press = |pressed| egui::Event::PointerButton { + pos: boxes[1].center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + narrow(&mut app, vec![egui::Event::PointerMoved(boxes[1].center()), press(true)]); + narrow(&mut app, vec![press(false)]); + assert_eq!(scale(&app), Some(true)); + narrow(&mut app, vec![]); + let button = + |pressed| egui::Event::PointerButton { pos: link.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + narrow(&mut app, vec![egui::Event::PointerMoved(link.center()), button(true)]); + narrow(&mut app, vec![button(false)]); + let texts = narrow(&mut app, vec![]); + assert!(has(&texts, "W:") && has(&texts, "200 pt"), "the popover: {texts:?}"); +} + +/// The Control bar's W resizes the active artboard about the reference point in one undo step: +/// about its center at first, and from its top-left corner once the locator's top-left point is +/// chosen. +#[test] +fn the_control_bar_resizes_the_active_artboard_about_the_reference_point() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "units": "Points", "artboards": 2})).unwrap(); + app.select_tool("artboard"); + select(&mut app, 1); + let ctx = bar_context(); + let (before, undo, _) = boards(&app); + let w = text_rect(&bar_texts(&mut app, &ctx), "W:"); + bar_type(&mut app, &ctx, egui::pos2(w.right() + 40.0, w.center().y), "300"); + let b = before[1]; + let mut want = before.clone(); + want[1] = vectorcraft_geom::Rect::new(b.x0 - 50.0, b.y0, b.x1 + 50.0, b.y1); + assert_eq!(boards(&app).0, want, "Artboard 2 widens about its center"); + assert_eq!(boards(&app).1, undo + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(boards(&app).0, before); + // The locator's points are the 9-point-square buttons; the top-left one is drawn first. + let texts = bar_texts(&mut app, &ctx); + let points: Vec = bar_buttons(&ctx, 0.0, text_rect(&texts, "X:").left()) + .into_iter() + .filter(|r| (r.width() - 9.0).abs() < 0.5 && (r.height() - 9.0).abs() < 0.5) + .collect(); + let top_left = points.iter().min_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()).unwrap(); + bar_click(&mut app, &ctx, top_left.center()); + assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), Some(0), "the shared reference point is the top-left one"); + let w = text_rect(&bar_texts(&mut app, &ctx), "W:"); + bar_type(&mut app, &ctx, egui::pos2(w.right() + 40.0, w.center().y), "300"); + want[1] = vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + 300.0, b.y1); + assert_eq!(boards(&app).0, want, "Artboard 2 widens from its left edge"); +} + +/// The Control bar's Portrait and Landscape buttons swap the active artboard's width and height, +/// its top-left corner staying put, in one undo step each. +#[test] +fn the_control_bar_orientation_swaps_width_and_height() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "units": "Points"})).unwrap(); + app.select_tool("artboard"); + let ctx = bar_context(); + let (_, undo, _) = boards(&app); + // Between "Preset:" and "Name:", the buttons that take a click: the preset menu, Portrait, + // Landscape and New Artboard (Delete Artboard is disabled with one artboard). + let buttons = |app: &mut VectorcraftApp| { + let texts = bar_texts(app, &ctx); + bar_buttons(&ctx, text_rect(&texts, "Preset:").right(), text_rect(&texts, "Name:").left()) + }; + let b = buttons(&mut app); + bar_click(&mut app, &ctx, b[b.len() - 3].center()); + assert_eq!((boards(&app).0, boards(&app).1), (vec![vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0)], undo + 1), "Portrait"); + let b = buttons(&mut app); + bar_click(&mut app, &ctx, b[b.len() - 2].center()); + assert_eq!((boards(&app).0, boards(&app).1), (vec![vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0)], undo + 2), "Landscape"); +} + +/// Typing a name into the Control bar's Name field and pressing Enter renames the active +/// artboard alone, in one undo step. +#[test] +fn the_control_bar_name_field_renames_the_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + app.select_tool("artboard"); + select(&mut app, 1); + let ctx = bar_context(); + let (_, undo, _) = boards(&app); + let name = text_rect(&bar_texts(&mut app, &ctx), "Artboard 2"); + bar_type(&mut app, &ctx, name.center(), "Cover"); + assert_eq!((boards(&app).2, boards(&app).1), (vec!["Artboard 1".to_string(), "Cover".to_string()], undo + 1)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(boards(&app).2, ["Artboard 1", "Artboard 2"]); +} + +/// The Control bar's New Artboard adds an artboard and makes it the active one, Artboard Options +/// opens the active artboard's options, Rearrange All Artboards opens its dialog, and Delete +/// Artboard deletes the selected artboard. +#[test] +fn the_control_bar_adds_deletes_and_opens_artboard_dialogs() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.select_tool("artboard"); + let ctx = bar_context(); + let texts = bar_texts(&mut app, &ctx); + // With one artboard Delete Artboard is disabled, so New Artboard is the last button before + // "Name:"; with two, Delete Artboard is. + let (preset, name) = (text_rect(&texts, "Preset:").right(), text_rect(&texts, "Name:").left()); + let b = bar_buttons(&ctx, preset, name); + bar_click(&mut app, &ctx, b[b.len() - 1].center()); + assert_eq!((boards(&app).2.len(), selected(&app, 2)), (2, 1), "New Artboard adds Artboard 2 and makes it active"); + // Artboard Options and Rearrange All Artboards: the two buttons between the name and the + // Transform link. + let texts = bar_texts(&mut app, &ctx); + let between = + |texts: &[(String, egui::Rect)]| bar_buttons(&ctx, text_rect(texts, "Artboard 2").right(), text_rect(texts, "Transform").left()); + let options = between(&texts)[0]; + bar_click(&mut app, &ctx, options.center()); + let d = app.ui.dialog.take().unwrap(); + assert_eq!((d.kind.as_str(), d.f64("index", 9.0)), ("artboardOptions", 1.0)); + let texts = bar_texts(&mut app, &ctx); + let rearrange = between(&texts)[1]; + bar_click(&mut app, &ctx, rearrange.center()); + assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some(crate::dialogs::rearrange_artboards::KIND.to_string())); + bar_texts(&mut app, &ctx); + let b = bar_buttons(&ctx, preset, name); + bar_click(&mut app, &ctx, b[b.len() - 1].center()); + assert_eq!(boards(&app).2, ["Artboard 1"], "Delete Artboard deletes Artboard 2"); +} + +/// Choosing the Artboard tool starts it on the active artboard, the one the panel highlights: +/// Artboard Options shows that artboard, the pointer moving over another artboard leaves it +/// active, and Delete removes it. Shift+O while the tool is in use keeps the artboards it has +/// selected. +#[test] +fn choosing_the_artboard_tool_starts_it_on_the_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + for x in [400, 800] { + app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); + } + select(&mut app, 1); + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + assert_eq!(app.session.tool_options()["active"], json!(1)); + app.run("tool.options", json!({"tool": "artboard"})).unwrap(); + assert_eq!(app.ui.dialog.take().unwrap().str("name"), "Artboard 2"); + let view = app.view_info(); + crate::canvas::dispatch(&mut app, &vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Move, 100.0, 75.0), view); + assert_eq!(selected(&app, 3), 1, "the pointer over Artboard 1 leaves Artboard 2 active"); + let r = app.session.tool_key(vectorcraft_tools::ToolKey::Delete, Default::default(), view); + crate::canvas::apply_requests(&mut app, r); + let names: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); + assert_eq!(names, ["Artboard 1", "Artboard 3"], "Delete removes Artboard 2"); + // Shift+O while the tool is in use keeps the artboards it has selected. + app.session.set_tool_option("selected", &json!([0, 1])); + let ctx = egui::Context::default(); + let shift_o = vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::Text("O".into())]; + let mut out = ctx.run_ui(egui::RawInput { events: shift_o, ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + out.textures_delta.clear(); + assert_eq!((app.session.tool_id(), picked(&app, 2)), ("artboard", vec![0, 1])); +} + +/// A row dragged onto New Artboard duplicates its artboard with its art (#446), outlining the +/// button while it is held over it, and the copy becomes the active artboard. +#[test] +fn dragging_a_row_onto_new_artboard_duplicates_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let shapes = frame(&mut app, vec![]); + let t = Tokens::get(&ctx); + let rect_of = |fill| { + shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == fill => Some(r.rect), + _ => None, + }) + }; + let row = rect_of(t.row_selected).unwrap().center(); + // The bottom bar's buttons sit under its divider, New Artboard second from the right. + let bar = rect_of(t.divider).unwrap(); + let new = pos2(bar.right() - 24.0 - 4.0 - 12.0, bar.bottom() + 2.0 + 12.0); + let button = + |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(row), button(row, true)]); + frame(&mut app, vec![egui::Event::PointerMoved(row + vec2(0.0, 12.0))]); + let held = frame(&mut app, vec![egui::Event::PointerMoved(new)]); + assert!(held.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.stroke.color == t.accent)), "New Artboard is outlined"); + frame(&mut app, vec![button(new, false)]); + let d = &app.session.active().unwrap().doc; + assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (2, 2), "the artboard and its art"); + assert_eq!(app.view().unwrap().artboard, 1); +} + +/// A click in a row's lock column locks its artboard and shows the lock next to the name, a +/// second click unlocks it, and each click is one undo step. Delete Artboard leaves a locked +/// artboard. +#[test] +fn the_lock_column_locks_and_unlocks_an_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 200, "height": 150})).unwrap(); + select(&mut app, 1); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + // The highlighted row (Artboard 2's): its lock column is 36 points in from its right end. + let shapes = frame(&mut app, 0.0, vec![]); + let row = shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected => Some(r.rect), + _ => None, + }) + .unwrap(); + let lock = egui::pos2(row.right() - 36.0, row.center().y); + let click = |at: egui::Pos2| { + let b = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + vec![egui::Event::PointerMoved(at), b(true), b(false)] + }; + let away = vec![egui::Event::PointerMoved(egui::pos2(-50.0, -50.0))]; + let locked = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.locked).collect::>(); + // Whether something is drawn in the lock column of Artboard 2's row. + let cell = egui::Rect::from_center_size(lock, vec2(14.0, 14.0)); + let lock_drawn = |shapes: Vec| { + shapes.iter().any(|c| { + let b = c.shape.visual_bounding_rect(); + b.is_positive() && cell.contains_rect(b) + }) + }; + assert!(!lock_drawn(frame(&mut app, 1.0, away.clone()))); + frame(&mut app, 2.0, click(lock)); + assert_eq!(locked(&app), [false, true]); + assert!(lock_drawn(frame(&mut app, 3.0, away.clone())), "the lock shows"); + assert!(deletable(&app, 2).is_empty(), "Delete Artboard leaves it"); + delete_selected(&mut app); + assert_eq!(app.session.active().unwrap().doc.artboards.len(), 2); + frame(&mut app, 5.0, click(lock)); + assert_eq!(locked(&app), [false, false]); + assert!(!lock_drawn(frame(&mut app, 6.0, away))); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(locked(&app), [false, true], "one undo step"); +} + +/// A new size for the selected artboards leaves a locked one among them as it is. The +/// artboard the field belongs to stays listed when it is locked, so `artboard.setProps` +/// refuses the change. A name typed for them goes to the locked one too. +#[test] +fn a_new_size_for_the_selected_artboards_leaves_a_locked_one() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + for x in [400, 800] { + app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); + } + app.select_tool("artboard"); + app.session.set_tool_option("selected", &json!([0, 1, 2])); + app.session.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + assert_eq!(resized_with(&app, 0), [0, 2]); + assert_eq!(resized_with(&app, 1), [0, 1, 2], "the field's own artboard stays listed"); + crate::panels::transform::set_artboard(&mut app, 0, 0, [None, None, Some(300.0), None], false); + let widths = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect::>(); + assert_eq!(widths, [300.0, 200.0, 300.0]); + let all = renamed_with(&app, 0); + assert_eq!(all, [0, 1, 2]); + rename(&mut app, &all, "abc").unwrap(); + let names = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::>(); + assert_eq!(names, ["abc-1", "abc-2", "abc-3"]); +} + +/// Alt-click on New Artboard opens Artboard Options for the new artboard and adds nothing yet: +/// the fields are those a plain click's artboard gets. Cancel adds nothing; OK adds the artboard +/// with the fields in one undo step and makes it the active one. +#[test] +fn alt_clicking_new_artboard_opens_artboard_options_for_the_new_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); + select(&mut app, 0); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + // The bottom bar's buttons sit under its divider, New Artboard second from the right. + let bar = frame(&mut app, 0.0, vec![]) + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).divider => Some(r.rect), + _ => None, + }) + .unwrap(); + let new = pos2(bar.right() - 24.0 - 4.0 - 12.0, bar.bottom() + 2.0 + 12.0); + let click = |modifiers: egui::Modifiers| { + let b = |pressed| egui::Event::PointerButton { pos: new, button: egui::PointerButton::Primary, pressed, modifiers }; + vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(new), b(true), b(false)] + }; + let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len(); + let fields = |app: &VectorcraftApp| { + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.kind, crate::dialogs::artboard_options::KIND); + (d.str("name"), ["x", "y", "width", "height"].map(|k| d.f64(k, -1.0))) + }; + // Alt-click: the dialog for Artboard 3, 20 pt right of Artboard 2 and as large; no artboard yet. + frame(&mut app, 1.0, click(egui::Modifiers::ALT)); + assert_eq!(fields(&app), ("Artboard 3".to_string(), [520.0, 300.0, 100.0, 50.0])); + assert_eq!(count(&app), 2, "no artboard is added before OK"); + // A plain click adds the artboard the dialog showed, with no dialog. + crate::dialogs::cancel(&mut app); + frame(&mut app, 2.0, click(egui::Modifiers::NONE)); + let ab = app.session.active().unwrap().doc.artboards[2].clone(); + assert_eq!((ab.name.as_str(), ab.rect), ("Artboard 3", vectorcraft_geom::Rect::new(520.0, 300.0, 620.0, 350.0))); + assert!(app.ui.dialog.is_none()); + app.run("edit.undo", json!({})).unwrap(); + select(&mut app, 0); + // Cancel adds nothing. + frame(&mut app, 3.0, click(egui::Modifiers::ALT)); + crate::dialogs::cancel(&mut app); + assert_eq!((count(&app), app.view().unwrap().artboard), (2, 0)); + // OK adds it with the fields, in one undo step, and makes it the active artboard. + frame(&mut app, 4.0, click(egui::Modifiers::ALT)); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("name".into(), json!("Cover")); + d.fields.insert("width".into(), json!(300)); + let undo = app.session.doc().unwrap().history.undo.len(); + crate::dialogs::confirm(&mut app).unwrap(); + let ab = app.session.active().unwrap().doc.artboards[2].clone(); + assert_eq!((ab.name.as_str(), ab.rect), ("Cover", vectorcraft_geom::Rect::new(520.0, 300.0, 820.0, 350.0))); + assert_eq!((app.view().unwrap().artboard, app.session.doc().unwrap().history.undo.len()), (2, undo + 1)); + assert!(app.ui.dialog.is_none()); +} + +/// `ui.newArtboard` answers with the dialog's kind. A name left empty gives the new artboard +/// its default name, and with the Artboard tool in use the tool moves to the new artboard. +#[test] +fn new_artboard_options_with_an_empty_name_add_the_default_name() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert!(app.run("ui.newArtboard", json!({})).is_err(), "no document"); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.select_tool("artboard"); + assert_eq!(app.run("ui.newArtboard", json!({})).unwrap(), json!({"dialog": "artboardOptions"})); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(" ")); + crate::dialogs::confirm(&mut app).unwrap(); + let names: Vec = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); + assert_eq!(names, ["Artboard 1", "Artboard 2"]); + assert_eq!((selected(&app, 2), app.session.tool_options()["active"].clone()), (1, json!(1))); +} + +/// The panel menu's Delete Empty Artboards is disabled while every artboard has art, and +/// enabled otherwise; a click deletes the empty ones in one undo step and makes the artboard +/// left active. +#[test] +fn delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + for x in [400, 800] { + app.run("artboard.new", json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); + } + let rect = + |app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + let ids: Vec = [10.0, 450.0, 850.0].into_iter().map(|x| rect(&mut app, x)).collect(); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let raw = |events| egui::RawInput { events, screen_rect: screen, ..Default::default() }; + let item_of = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(raw(vec![]), |ui| menu(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text().trim_start() == "Delete Empty Artboards" => Some(t.visual_bounding_rect().center()), + _ => None, + }) + }; + // Menu rows pad their label where a check mark would go. + let label = " Delete Empty Artboards"; + assert!(named(&tree(|ui| menu(&mut app, ui)), label).is_disabled(), "every artboard has art"); + app.run("select.set", json!({"ids": [ids[0], ids[2]]})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + select(&mut app, 2); + assert!(!named(&tree(|ui| menu(&mut app, ui)), label).is_disabled(), "two artboards are empty"); + let item = item_of(&mut app).unwrap(); + let steps = app.session.active().unwrap().history.undo.len(); + let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + ctx.run_ui(raw(vec![egui::Event::PointerMoved(item), button(true), button(false)]), |ui| menu(&mut app, ui)).textures_delta.clear(); + let st = app.session.active().unwrap(); + let names: Vec = st.doc.artboards.iter().map(|a| a.name.clone()).collect(); + assert_eq!(names, ["Artboard 2"]); + assert_eq!(st.history.undo.len(), steps + 1, "one undo step"); + assert_eq!(selected(&app, 1), 0, "the artboard left is active"); +} + +/// With the Artboard tool, the canvas context menu locks and unlocks artboards: Lock Selected +/// locks the selected one (the others stay free), Unlock All frees every artboard. +#[test] +fn the_artboard_tools_context_menu_locks_and_unlocks_artboards() { + use crate::menus::Item; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 3})).unwrap(); + let locked = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.locked).collect::>(); + let run = |app: &mut VectorcraftApp, menu: &str, label: &str| { + let items = crate::menus::context_items(app); + let Some(Item::Sub(_, subs)) = items.iter().find(|i| matches!(i, Item::Sub(t, _) if *t == menu)) else { panic!("no {menu}") }; + let Some(Item::Cmd(_, id, p)) = subs.iter().find(|i| matches!(i, Item::Cmd(l, ..) if *l == label)) else { panic!("no {label}") }; + crate::menus::invoke(app, id, p.clone()); + }; + // Without the Artboard tool the menu has no such submenu. + assert!(!crate::menus::context_items(&app).iter().any(|i| matches!(i, Item::Sub(t, _) if *t == "Lock Artboards"))); + app.select_tool("artboard"); + select(&mut app, 1); + run(&mut app, "Lock Artboards", "Lock Selected"); + assert_eq!(locked(&app), [false, true, false]); + run(&mut app, "Lock Artboards", "Lock All Except Selected"); + assert_eq!(locked(&app), [true, true, true], "the others lock, the locked one stays"); + run(&mut app, "Unlock Artboards", "Unlock Selected"); + assert_eq!(locked(&app), [true, false, true]); + run(&mut app, "Unlock Artboards", "Unlock All"); + assert_eq!(locked(&app), [false, false, false]); +} From 34006a8edec7570b9260674c3c3b11e995108751 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:33 +0000 Subject: [PATCH 024/199] refactor(tools): move builder.rs tests to builder/tests.rs --- crates/tools/src/builder.rs | 1070 +---------------------------- crates/tools/src/builder/tests.rs | 1067 ++++++++++++++++++++++++++++ 2 files changed, 1068 insertions(+), 1069 deletions(-) create mode 100644 crates/tools/src/builder/tests.rs diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 44899b3d7..d5d665e75 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -1680,1072 +1680,4 @@ pub fn create(id: &str) -> Option> { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::{Appearance, Selection}; - use vectorcraft_geom::shapes; - - fn two_rects() -> (Document, NodeId, NodeId) { - two_rects_at(0.0) - } - - /// Squares A (`x`..`x`+100) and B (`x`+50..`x`+150), 100 pt tall, overlapping by half. - fn two_rects_at(x: f64) -> (Document, NodeId, NodeId) { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default_art())).unwrap(); - let b = d.alloc_id(); - d.insert(Some(l), 1, Node::path(b, shapes::rectangle(Rect::new(x + 50.0, 0.0, x + 150.0, 100.0)), Appearance::default_art())).unwrap(); - (d, a, b) - } - - fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { - PointerEvent::new(kind, x, y) - } - - #[test] - fn shape_builder_hover_highlights_region() { - let (d, a, b) = two_rects(); - let mut s = Selection::default(); - s.set([a, b]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - assert!(t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)).is_empty()); - let ov = t.overlays(&cx); - assert_eq!(ov.len(), 2); - let Overlay::Path { path, .. } = &ov[1] else { panic!() }; - let bb = path.bounding_box(); - assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "{bb:?}"); - t.pointer(&cx, &ev(PointerKind::Move, 400.0, 400.0)); - assert!(t.overlays(&cx).is_empty()); - } - - #[test] - fn shape_builder_drag_emits_merge() { - let (d, a, b) = two_rects(); - let mut s = Selection::default(); - s.set([b, a]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 50.0)); - assert_eq!(t.touched.regions.len(), 2); - assert!(t.busy()); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0)); - let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; - assert_eq!(id, "shapeBuilder.merge"); - assert_eq!(v["erase"], false); - assert_eq!(v["ids"], json!([a.0, b.0]), "sorted back to front"); - assert!(v["points"].as_array().unwrap().len() >= 2); - } - - #[test] - fn shape_builder_rebuilds_its_map_for_a_new_revision_at_the_same_address() { - let (mut d, a, b) = two_rects(); - let mut s = Selection::default(); - s.set([a, b]); - let p = paint(); - let mut t = ShapeBuilderTool::default(); - let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; - t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0)); - // A later revision of the document, with the squares 300 pt to the right, at the same address. - d = two_rects_at(300.0).0; - let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - t.pointer(&cx2, &ev(PointerKind::Down, 325.0, 50.0)); - t.pointer(&cx2, &ev(PointerKind::Drag, 375.0, 50.0)); - let acts = t.pointer(&cx2, &ev(PointerKind::Up, 375.0, 50.0)); - assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); - } - - #[test] - fn shape_builder_builds_its_map_once_for_hover_alt_hover_and_a_merge() { - let (d, s) = corner_rects(); - let p = paint(); - let mut t = ShapeBuilderTool::default(); - let alt = Mods { alt: true, ..Default::default() }; - let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; - t.pointer(&cx1, &ev(PointerKind::Move, 101.0, 75.0)); - assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); - // Alt hovering over an edge uses the same map. - t.pointer(&cx1, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt)); - assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover); - assert_eq!(t.cache.builds, 1, "a hover then an Alt hover"); - t.pointer(&cx1, &ev(PointerKind::Down, 25.0, 25.0)); - t.pointer(&cx1, &ev(PointerKind::Drag, 75.0, 75.0)); - let acts = t.pointer(&cx1, &ev(PointerKind::Up, 75.0, 75.0)); - assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); - // The merge left the document as it was (it failed, say): the map still fits it. - t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 75.0)); - assert_eq!(t.cache.builds, 1, "a hover after a merge that changed nothing"); - let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - t.pointer(&cx2, &ev(PointerKind::Move, 75.0, 75.0)); - assert_eq!(t.cache.builds, 2, "a hover on a new revision"); - } - - /// A row of `n` selected squares 20 pt wide, each overlapping the next by half: 4 segments each. - fn square_row(n: usize) -> (Document, Selection) { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let mut s = Selection::default(); - for k in 0..n { - let x = k as f64 * 10.0; - let id = d.alloc_id(); - d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())).unwrap(); - s.add(id); - } - (d, s) - } - - /// Polls `t`, as the desktop app does each frame, until it stops waiting. - fn poll_until_ready(t: &mut dyn Tool, cx: &ToolContext) { - let start = std::time::Instant::now(); - while t.waiting() { - assert!(start.elapsed() < std::time::Duration::from_secs(60), "the tool is still waiting"); - std::thread::sleep(std::time::Duration::from_millis(2)); - t.poll(cx, false); - } - } - - #[test] - fn shape_builder_hover_builds_a_large_map_on_another_thread() { - let (d, s) = square_row(40); - let p = paint(); - let mut t = ShapeBuilderTool::default(); - let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; - let hover = ev(PointerKind::Move, 15.0, 10.0); - t.pointer(&cx1, &hover); - assert_eq!(t.cache.builds, 0, "the first hover returns before the map is built"); - assert!(t.waiting() && t.hover.is_none() && t.overlays(&cx1).is_empty(), "{:?}", t.hover); - poll_until_ready(&mut t, &cx1); - assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); - assert_eq!((t.cache.builds, t.cache.background), (1, 1)); - assert_eq!(t.overlays(&cx1).len(), 2, "the region's hatch and outline"); - // A new revision while the map of the one before it is being built: only the newer one's - // map is used. - let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - let cx3 = ToolContext { revision: (1, 3), ..cx(&d, &s, &p) }; - t.pointer(&cx2, &hover); - t.pointer(&cx3, &hover); - poll_until_ready(&mut t, &cx3); - assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); - assert_eq!((t.cache.builds, t.cache.background), (3, 3), "the earlier revision's map, then the newer one's"); - assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 3))); - // A host without frames waits for the map. - let cx4 = ToolContext { revision: (1, 4), ..cx(&d, &s, &p) }; - t.pointer(&cx4, &hover); - assert!(t.waiting() && t.hover.is_none()); - t.poll(&cx4, true); - assert!(!t.waiting()); - assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); - assert_eq!((t.cache.builds, t.cache.background), (4, 4)); - // A press while the map is being built waits for it, and the drag merges. - let cx5 = ToolContext { revision: (1, 5), ..cx(&d, &s, &p) }; - t.pointer(&cx5, &hover); - t.pointer(&cx5, &ev(PointerKind::Down, 15.0, 10.0)); - t.pointer(&cx5, &ev(PointerKind::Drag, 35.0, 10.0)); - let acts = t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)); - assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); - assert_eq!((t.cache.builds, t.cache.background), (5, 5)); - assert!(!t.waiting()); - // Changing an option forgets a map being built with the old one. - let cx6 = ToolContext { revision: (1, 6), ..cx(&d, &s, &p) }; - t.pointer(&cx6, &hover); - t.set_option("gapDetection", &json!(true)); - assert!(!t.waiting()); - } - - #[test] - fn shape_builder_hover_builds_a_small_map_at_once() { - let (d, s) = square_row(3); - let p = paint(); - let mut t = ShapeBuilderTool::default(); - let cx = cx(&d, &s, &p); - t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0)); - assert!(!t.waiting() && matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); - assert_eq!((t.cache.builds, t.cache.background), (1, 0)); - } - - #[test] - fn shape_builder_alt_drag_erases_and_empty_drag_does_nothing() { - let (d, a, b) = two_rects(); - let mut s = Selection::default(); - s.set([a, b]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let alt = Mods { alt: true, ..Default::default() }; - t.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0).with_mods(alt)); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 125.0, 50.0).with_mods(alt)); - let Action::Exec(_, v) = &acts[0] else { panic!() }; - assert_eq!(v["erase"], true); - t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)); - assert!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)).is_empty()); - } - - /// Squares A (0..100) and B (50..150 both ways), overlapping at a corner, selected. - fn corner_rects() -> (Document, Selection) { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let mut s = Selection::default(); - for (k, r) in [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(50.0, 50.0, 150.0, 150.0)].into_iter().enumerate() { - let id = d.alloc_id(); - d.insert(Some(l), k, Node::path(id, shapes::rectangle(r), Appearance::default_art())).unwrap(); - s.add(id); - } - (d, s) - } - - fn colors(ov: &[Overlay]) -> Vec<[u8; 3]> { - ov.iter().map(|o| if let Overlay::Path { color, .. } = o { *color } else { panic!("{o:?}") }).collect() - } - - #[test] - fn shape_builder_alt_switches_to_erase_mode_while_held() { - let (d, s) = corner_rects(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let alt = Mods { alt: true, ..Default::default() }; - let at = Point::new(75.0, 75.0); - // Merge mode: a grey mesh and the plus pointer. - t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y)); - assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder); - // Alt held: what would go highlights red, and the pointer shows a minus. - t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y).with_mods(alt)); - assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 2]); - assert_eq!(t.cursor(&cx, at, alt), Cursor::ShapeBuilderErase); - // Released: merge mode again. - t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y)); - assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder); - // A drag keeps the mode it started in. - t.pointer(&cx, &ev(PointerKind::Down, at.x, at.y).with_mods(alt)); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilderErase); - let acts = t.pointer(&cx, &ev(PointerKind::Up, at.x, at.y)); - let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; - assert_eq!(v["erase"], true); - } - - #[test] - fn shape_builder_alt_on_an_edge_highlights_and_deletes_it() { - let (d, s) = corner_rects(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let alt = Mods { alt: true, ..Default::default() }; - // A's side inside B, between the points where B's outline crosses it. - t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt)); - assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover); - let ov = t.overlays(&cx); - let [Overlay::Path { path, color: HIGHLIGHT_RED, width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") }; - assert_eq!(path.bounding_box(), Rect::new(50.0, 50.0, 100.0, 100.0)); - // Without Alt it's the region under the pointer. - t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0)); - assert!(matches!(t.hover, Some(Hit::Region(_)))); - t.pointer(&cx, &ev(PointerKind::Down, 101.0, 75.0).with_mods(alt)); - assert_eq!(t.touched.edges.len(), 1); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 101.0, 75.0).with_mods(alt)); - let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; - assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0))); - } - - #[test] - fn shape_builder_erase_drag_into_regions_shows_the_regions_not_the_edges_it_crosses() { - let (d, s) = corner_rects(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let alt = Mods { alt: true, ..Default::default() }; - t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0).with_mods(alt)); - t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(alt)); - assert_eq!(t.touched.regions.len(), 2); - assert!(!t.touched.edges.is_empty(), "the drag crossed B's outline"); - assert!(t.cache.map.erased_edges(&t.touched).is_empty(), "which goes with the regions"); - // Two regions (mesh + outline each) and the drag's trail, all red. - assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]); - } - - /// Rect A (0..100) and rect B (98..198), both 100 tall, selected: they overlap in a 2 pt strip. - fn sliver_rects() -> (Document, Selection) { - rects(&[Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(98.0, 0.0, 198.0, 100.0)]) - } - - /// The rectangles `rs`, back to front, selected. - fn rects(rs: &[Rect]) -> (Document, Selection) { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let mut s = Selection::default(); - for (k, r) in rs.iter().enumerate() { - let id = d.alloc_id(); - d.insert(Some(l), k, Node::path(id, shapes::rectangle(*r), Appearance::default_art())).unwrap(); - s.add(id); - } - (d, s) - } - - fn map_of(d: &Document, s: &Selection) -> BuilderMap { - BuilderMap::new(d, &sorted_roots(d, &s.objects), true, true).0 - } - - #[test] - fn shape_builder_erase_drag_across_an_overlap_narrower_than_twice_the_tolerance_touches_it() { - let (d, s) = sliver_rects(); - let map = map_of(&d, &s); - let strip = map.region_at(Point::new(99.0, 50.0)).unwrap(); - // Every point of the strip is within 4 pt of its edges. - let t = map.builder_hits_along(&[Point::new(50.0, 50.0), Point::new(150.0, 50.0)], Some(4.0)); - assert_eq!(t.regions.len(), 3, "{t:?}"); - assert!(t.regions.contains(&strip), "{t:?}"); - assert!(map.erased_edges(&t).is_empty(), "the strip's edges go with it"); - // A drag at a shallow angle goes about 10 pt through the strip and touches it too. - let t = map.builder_hits_along(&[Point::new(90.0, 10.0), Point::new(106.0, 90.0)], Some(4.0)); - assert_eq!(t.regions.len(), 3, "{t:?}"); - // So does a drag that starts in the strip, and one that goes into it and back out. - let t = map.builder_hits_along(&[Point::new(99.0, 50.0), Point::new(150.0, 50.0)], Some(4.0)); - assert!(t.regions.contains(&strip), "{t:?}"); - let t = map.builder_hits_along(&[Point::new(50.0, 50.0), Point::new(99.0, 50.0), Point::new(50.0, 51.0)], Some(4.0)); - assert!(t.regions.contains(&strip), "{t:?}"); - // Merging is unchanged, and so is the plain hit test other tools use. - assert_eq!(map.builder_hit(Point::new(99.0, 50.0), Some(4.0)), Some(Hit::Region(strip))); - assert!(matches!(map.hit(Point::new(99.0, 50.0), Some(4.0)), Some(Hit::Edge(_)))); - assert_eq!(map.builder_hit(Point::new(99.0, 50.0), None), Some(Hit::Region(strip))); - } - - #[test] - fn shape_builder_erase_drag_across_a_thin_bar_touches_it_from_either_side() { - // A 1.5 pt bar inside a square, and the same bar on its own. - let bar = Rect::new(49.0, 10.0, 50.5, 90.0); - for rs in [vec![Rect::new(0.0, 0.0, 100.0, 100.0), bar], vec![bar]] { - let (d, s) = rects(&rs); - let map = map_of(&d, &s); - let inner = map.region_at(Point::new(49.75, 50.0)).unwrap(); - let t = map.builder_hits_along(&[Point::new(20.0, 50.0), Point::new(80.0, 50.0)], Some(4.0)); - assert!(t.regions.contains(&inner), "{rs:?}: {t:?}"); - assert_eq!(t.regions.len(), rs.len(), "{rs:?}: {t:?}"); - // 0.1 pt from an end, the nearest outline is the end, and the bar is still 1.5 pt - // across from its sides: a click there and a drag across the bar there touch it. - assert_eq!(map.builder_hit(Point::new(49.75, 10.1), Some(4.0)), Some(Hit::Region(inner)), "{rs:?}"); - assert_eq!(map.builder_hit(Point::new(49.75, 89.9), Some(4.0)), Some(Hit::Region(inner)), "{rs:?}"); - let t = map.builder_hits_along(&[Point::new(20.0, 10.1), Point::new(80.0, 10.1)], Some(4.0)); - assert!(t.regions.contains(&inner), "{rs:?}: {t:?}"); - } - // Near a corner of the square, both of its edges there are close, and the square is wide - // across from each: the edge is touched. - let (d, s) = rects(&[Rect::new(0.0, 0.0, 100.0, 100.0), bar]); - let map = map_of(&d, &s); - assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_)))); - // So it is when the square's top edge has a point 2 pt from the corner: the end of the - // segment past that point is not where the square is narrow. - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let pts = [(0.0, 0.0), (2.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)); - let path = PathData::new(vec![SubPath::polyline(&pts, true)]); - d.insert(Some(l), 0, Node::path(id, path, Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.add(id); - let map = map_of(&d, &s); - let square = map.region_at(Point::new(50.0, 50.0)).unwrap(); - assert_eq!(map.regions[square].1.segments().count(), 5, "the point on the top edge stays"); - assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_)))); - // A bar whose sides each have a point level with a click 0.1 pt from its end: the click - // and a drag across the bar there still touch the bar. - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let pts = [(49.0, 10.0), (50.5, 10.0), (50.5, 10.1), (50.5, 90.0), (49.0, 90.0), (49.0, 10.1)].map(|(x, y)| Point::new(x, y)); - let path = PathData::new(vec![SubPath::polyline(&pts, true)]); - d.insert(Some(l), 0, Node::path(id, path, Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.add(id); - let map = map_of(&d, &s); - let split = map.region_at(Point::new(49.75, 50.0)).unwrap(); - assert_eq!(map.regions[split].1.segments().count(), 6, "the points on the sides stay"); - assert_eq!(map.builder_hit(Point::new(49.75, 10.1), Some(4.0)), Some(Hit::Region(split))); - let t = map.builder_hits_along(&[Point::new(20.0, 10.1), Point::new(80.0, 10.1)], Some(4.0)); - assert!(t.regions.contains(&split), "{t:?}"); - } - - #[test] - fn shape_builder_erase_drag_along_an_edge_does_not_touch_the_regions_beside_it() { - let (d, s) = grid(); - let map = map_of(&d, &s); - // 1 pt above the edges between the top row and the middle one, through all three squares. - let t = map.builder_hits_along(&[Point::new(50.0, 99.0), Point::new(250.0, 99.0)], Some(4.0)); - assert!(t.regions.is_empty(), "{t:?}"); - assert!(!t.edges.is_empty()); - // Crossing the edge 2 pt before a junction clips the square below without touching it. - let pts = [Point::new(50.0, 99.0), Point::new(97.0, 99.0), Point::new(99.0, 101.0), Point::new(150.0, 101.0)]; - let t = map.builder_hits_along(&pts, Some(4.0)); - let clipped = map.region_at(Point::new(99.0, 101.0)).unwrap(); - assert!(!t.regions.contains(&clipped), "{t:?}"); - } - - #[test] - fn shape_builder_alt_hover_over_a_narrow_overlap_highlights_it() { - let (d, s) = sliver_rects(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let alt = Mods { alt: true, ..Default::default() }; - t.pointer(&cx, &ev(PointerKind::Move, 99.0, 50.0).with_mods(alt)); - let strip = t.cache.map.region_at(Point::new(99.0, 50.0)); - assert_eq!(t.hover, strip.map(Hit::Region)); - // Beside a wide region, the edge. - t.pointer(&cx, &ev(PointerKind::Move, 50.0, 99.0).with_mods(alt)); - assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover); - } - - #[test] - fn shape_builder_alt_drag_highlights_an_overlap_it_crosses_over_two_moves() { - let (d, s) = sliver_rects(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let alt = Mods { alt: true, ..Default::default() }; - t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(alt)); - // One move ends inside the strip, and the next one leaves it. - t.pointer(&cx, &ev(PointerKind::Drag, 99.0, 50.0).with_mods(alt)); - t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 50.0).with_mods(alt)); - assert_eq!(t.touched.regions.len(), 3, "{:?}", t.touched); - assert!(t.cache.map.erased_edges(&t.touched).is_empty()); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 50.0).with_mods(alt)); - let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; - assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0))); - } - - /// A 3×3 grid of touching 100 pt squares from (0, 0) to (300, 300), selected. - fn grid() -> (Document, Selection) { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let mut s = Selection::default(); - for k in 0..9 { - let (x, y) = ((k % 3) as f64 * 100.0, (k / 3) as f64 * 100.0); - let id = d.alloc_id(); - d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 100.0, y + 100.0)), Appearance::default_art())).unwrap(); - s.add(id); - } - (d, s) - } - - /// The paths of an overlay list (a shaded region draws two: its mesh and its outline), and its - /// marquees. - fn paths_and_marquees(ov: &[Overlay]) -> (usize, Vec) { - let paths = ov.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(); - (paths, ov.iter().filter_map(|o| if let Overlay::Marquee(r) = o { Some(*r) } else { None }).collect()) - } - - #[test] - fn shape_builder_shift_drag_highlights_and_merges_every_region_in_its_marquee() { - let (d, s) = grid(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let shift = Mods { shift: true, ..Default::default() }; - t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0).with_mods(shift)); - // Shift may be let go once the drag is on. - t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); - assert_eq!(t.touched.regions.len(), 9, "{:?}", t.touched); - assert_eq!(t.touched.regions[0], t.cache.map.region_at(Point::new(250.0, 250.0)).unwrap(), "where the drag started comes first"); - assert_eq!(paths_and_marquees(&t.overlays(&cx)), (18, vec![Rect::new(50.0, 50.0, 250.0, 250.0)]), "nine regions, no trail"); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)); - let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; - assert_eq!((id.as_str(), &v["rect"], &v["erase"]), ("shapeBuilder.merge", &json!([250.0, 250.0, -200.0, -200.0]), &json!(false))); - assert!(v.get("points").is_none(), "{v}"); - assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee goes with the drag"); - } - - #[test] - fn shape_builder_alt_shift_drag_marks_the_regions_in_its_marquee_for_deleting() { - let (d, s) = grid(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let alt_shift = Mods { alt: true, shift: true, ..Default::default() }; - // A narrow marquee down the middle column. - t.pointer(&cx, &ev(PointerKind::Down, 140.0, 50.0).with_mods(alt_shift)); - t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 250.0).with_mods(alt_shift)); - assert_eq!(t.touched.regions.len(), 3); - let ov = t.overlays(&cx); - assert!(ov.iter().all(|o| matches!(o, Overlay::Marquee(_) | Overlay::Path { color: HIGHLIGHT_RED, .. })), "{ov:?}"); - assert_eq!(paths_and_marquees(&ov).0, 6, "three regions"); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 250.0).with_mods(alt_shift)); - let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; - assert_eq!((&v["rect"], &v["erase"]), (&json!([140.0, 50.0, 20.0, 200.0]), &json!(true))); - } - - /// Without Shift a drag still touches what the pointer passes over; a Shift-click that does not - /// move is a click. - #[test] - fn shape_builder_drag_without_shift_and_shift_click_send_points() { - let (d, s) = grid(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); - assert_eq!(t.touched.regions.len(), 3, "the diagonal"); - let (paths, marquees) = paths_and_marquees(&t.overlays(&cx)); - assert_eq!((paths, marquees.len()), (7, 0), "three regions and the trail"); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)); - let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; - assert_eq!(v["points"], json!([[250.0, 250.0], [150.0, 150.0], [50.0, 50.0]])); - assert!(v.get("rect").is_none(), "{v}"); - let shift = Mods { shift: true, ..Default::default() }; - t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(shift)); - assert_eq!(t.touched.regions.len(), 1); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0).with_mods(shift)); - let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; - assert_eq!((&v["points"], v.get("rect")), (&json!([[150.0, 150.0]]), None)); - } - - #[test] - fn marquee_regions_touch_crossed_outlines_held_anchors_and_the_region_around_it() { - let (d, s) = grid(); - let (map, _) = BuilderMap::new(&d, &sorted_roots(&d, &s.objects), false, true); - let at = |x: f64, y: f64| map.region_at(Point::new(x, y)).unwrap(); - // Wholly inside the middle square, then crossing into the one to its right. - assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(180.0, 180.0)), [at(150.0, 150.0)]); - assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(220.0, 180.0)), [at(150.0, 150.0), at(250.0, 150.0)]); - // Ending on a shared side does not reach the square past it. - assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(200.0, 180.0)), [at(150.0, 150.0)]); - // Around the whole grid, from outside it: every square, with no region under either corner. - assert_eq!(map.regions_in_marquee(Point::new(-10.0, -10.0), Point::new(310.0, 310.0)).len(), 9); - assert!(map.regions_in_marquee(Point::new(400.0, 400.0), Point::new(450.0, 450.0)).is_empty()); - // Where the drag started first, then where it ends, whichever way it runs. - for (a, b) in [((50.0, 50.0), (250.0, 250.0)), ((250.0, 250.0), (50.0, 50.0)), ((250.0, 50.0), (50.0, 250.0))] { - let got = map.regions_in_marquee(a.into(), b.into()); - assert_eq!((got.len(), got[0], got[1]), (9, at(a.0, a.1), at(b.0, b.1)), "{a:?} to {b:?}"); - } - } - - /// With In Merge Mode, Clicking Stroke Splits the Path on, a Shift-drag that starts on a stroke - /// sends its marquee and no split, however little it moves; a Shift-click there splits the - /// stroke as a click does. - #[test] - fn shape_builder_shift_drag_on_a_stroke_merges_and_a_shift_click_splits() { - let (d, s) = corner_rects(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - t.set_option("clickStrokeSplitsPath", &json!(true)); - let shift = Mods { shift: true, ..Default::default() }; - // 1 pt outside A's left side, a move of less than 3 pixels, and one across the art. - for (to, rect) in [((0.5, 26.0), json!([-1.0, 25.0, 1.5, 1.0])), ((75.0, 75.0), json!([-1.0, 25.0, 76.0, 50.0]))] { - t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); - t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); - t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift)); - let acts = t.pointer(&cx, &ev(PointerKind::Up, to.0, to.1).with_mods(shift)); - let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; - assert_eq!((&v["rect"], v.get("points"), v.get("splitStroke")), (&rect, None, None), "{v}"); - } - t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); - t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); - let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0).with_mods(shift)); - let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; - assert_eq!((&v["splitStroke"], &v["points"], v.get("rect")), (&json!(true), &json!([[-1.0, 25.0]]), None), "{v}"); - } - - /// While a Shift-drag that started over a stroke a click would split draws its marquee, the - /// pointer shows the Shape Builder, not the scissors, and the stroke is not highlighted. - #[test] - fn shape_builder_shift_drag_shows_no_scissors_or_stroke_highlight() { - let (d, s) = corner_rects(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - t.set_option("clickStrokeSplitsPath", &json!(true)); - let shift = Mods { shift: true, ..Default::default() }; - let highlighted = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Path { color: STROKE_HIGHLIGHT, width: 3.0, .. })); - t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); - assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), shift), Cursor::Scissors); - assert!(highlighted(&t.overlays(&cx))); - t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); - t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(shift)); - let ov = t.overlays(&cx); - assert!(ov.iter().any(|o| matches!(o, Overlay::Marquee(_))) && !highlighted(&ov), "{ov:?}"); - assert_eq!(t.cursor(&cx, Point::new(75.0, 75.0), shift), Cursor::ShapeBuilder); - } - - #[test] - fn shape_builder_stroke_options_round_trip_and_pick_the_side_under_the_pointer() { - let (d, s) = corner_rects(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeBuilderTool::default(); - let o = t.options(); - assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(false), &json!(true), &json!("#ff4f4f"))); - // With the option off, the pointer 1 pt outside A's left side highlights nothing. - t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); - assert!(t.overlays(&cx).is_empty()); - assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::ShapeBuilder); - t.set_option("clickStrokeSplitsPath", &json!(true)); - t.set_option("highlightColor", &json!("#00ff00")); - // A value that is not a color or not a switch is ignored. - t.set_option("highlightColor", &json!("nope")); - t.set_option("highlightStroke", &json!("off")); - let o = t.options(); - assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(true), &json!("#00ff00"))); - // On, the tool draws A's left side from corner to corner in the highlight color and shows the scissors. - t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); - let ov = t.overlays(&cx); - let [Overlay::Path { path, color: [0, 0xff, 0], width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") }; - assert_eq!(path.bounding_box(), Rect::new(0.0, 0.0, 0.0, 100.0)); - assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::Scissors); - // With Alt the tool is in erase mode, which deletes edges and does not split them. - let alt = Mods { alt: true, ..Default::default() }; - t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), alt), Cursor::ShapeBuilderErase); - assert!(t.stroke.is_none()); - // A click splits; the command gets the point, the switch and the tolerance. - t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); - let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0)); - let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; - assert_eq!((id.as_str(), &v["splitStroke"], &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(false), &json!(4.0))); - // A release up to 3 pixels from the press is still a click, which splits where the press was. - t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); - t.pointer(&cx, &ev(PointerKind::Drag, -1.5, 26.0)); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 0.5, 27.0)); - let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; - assert_eq!((&v["splitStroke"], &v["points"]), (&json!(true), &json!([[-1.0, 25.0]]))); - // A release farther away is a drag, which splits nothing. - t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); - assert!(t.pointer(&cx, &ev(PointerKind::Up, -1.0, 30.0)).is_empty()); - // So is a Straight Line drag that goes away and ends back near the press. - t.set_option("selection", &json!("straightLine")); - t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 25.0)); - let acts = t.pointer(&cx, &ev(PointerKind::Up, -0.5, 25.5)); - assert!(!acts.iter().any(|a| matches!(a, Action::Exec(_, v) if v.get("splitStroke").is_some())), "{acts:?}"); - t.set_option("selection", &json!("freeform")); - // A drag merges as before and sends no switch. - t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0)); - let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 75.0)); - let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; - assert!(v.get("splitStroke").is_none(), "{v}"); - } - - #[test] - fn hatch_stays_inside_region() { - let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath(); - let h = hatch(&bp, 4.0); - let bb = h.bounding_box(); - assert!(bb.x0 >= -1e-6 && bb.y0 >= -1e-6 && bb.x1 <= 40.0 + 1e-6 && bb.y1 <= 20.0 + 1e-6); - assert!(h.elements().len() > 10); - } - - /// An empty hidden sources group for a Live Paint group made in a test. - fn empty_sources(d: &mut Document) -> Arc { - let mut g = Node::group(d.alloc_id(), vec![]); - g.name = Some(SOURCES_NAME.into()); - g.visible = false; - Arc::new(g) - } - - fn live_group_doc() -> (Document, NodeId, NodeId, NodeId) { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let f1 = d.alloc_id(); - let f2 = d.alloc_id(); - let e = d.alloc_id(); - let mut face1 = Node::path(f1, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); - face1.name = Some(FACE_NAME.into()); - let mut face2 = Node::path(f2, shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 50.0)), Appearance::default()); - face2.name = Some(FACE_NAME.into()); - let mut edge = Node::path(e, shapes::line(Point::new(50.0, 0.0), Point::new(50.0, 50.0)), Appearance::default()); - edge.name = Some(EDGE_NAME.into()); - let gid = d.alloc_id(); - let mut g = Node::group(gid, vec![empty_sources(&mut d), Arc::new(face1), Arc::new(face2), Arc::new(edge)]); - g.name = Some(LIVE_PAINT_NAME.into()); - d.insert(Some(l), 0, g).unwrap(); - (d, gid, f2, e) - } - - /// Adds a Live Paint group of one face `r` with its four sides as edges (left, top, right, - /// bottom) in front of the layer's art; returns the group and the edges. - fn live_square(d: &mut Document, r: Rect) -> (NodeId, [NodeId; 4]) { - let l = d.layers[0].id; - let mut face = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); - face.name = Some(FACE_NAME.into()); - let (a, b, c, e) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); - let mut kids = vec![empty_sources(d), Arc::new(face)]; - let mut ids = [NodeId(0); 4]; - for (k, (from, to)) in [(e, a), (a, b), (b, c), (c, e)].into_iter().enumerate() { - ids[k] = d.alloc_id(); - let mut edge = Node::path(ids[k], shapes::line(from, to), Appearance::default()); - edge.name = Some(EDGE_NAME.into()); - kids.push(Arc::new(edge)); - } - let gid = d.alloc_id(); - let mut g = Node::group(gid, kids); - g.name = Some(LIVE_PAINT_NAME.into()); - let at = d.layers[0].children().map_or(0, |c| c.len()); - d.insert(Some(l), at, g).unwrap(); - (gid, ids) - } - - #[test] - fn bucket_click_fills_face_and_shift_click_strokes_edge() { - let (d, gid, f2, _e) = live_group_doc(); - assert_eq!(live_paint_at(&d, Point::new(75.0, 25.0)), Some(gid)); - assert_eq!(face_at(d.node(gid).unwrap(), Point::new(75.0, 25.0)).unwrap().id, f2); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = LivePaintBucketTool::default(); - t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); - let ov = t.overlays(&cx); - assert!(matches!(&ov[0], Overlay::Path { color: HIGHLIGHT_RED, .. })); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); - let Action::Exec(id, v) = &acts[0] else { panic!() }; - assert_eq!(id, "livePaint.fill"); - assert_eq!(v["group"], gid.0); - let shift = Mods { shift: true, ..Default::default() }; - let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift)); - let Action::Exec(id, _) = &acts[0] else { panic!() }; - assert_eq!(id, "livePaint.strokeEdge"); - } - - #[test] - fn bucket_on_plain_selection_makes_live_paint() { - let (d, a, b) = two_rects(); - let mut s = Selection::default(); - s.set([a, b]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = LivePaintBucketTool::default(); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)); - let Action::Exec(id, v) = &acts[0] else { panic!() }; - assert_eq!(id, "livePaint.fill"); - assert_eq!(v["ids"], json!([a.0, b.0])); - } - - #[test] - fn bucket_rebuilds_its_map_for_a_new_revision_at_the_same_address() { - let (mut d, a, b) = two_rects(); - let mut s = Selection::default(); - s.set([a, b]); - let p = paint(); - let mut t = LivePaintBucketTool::default(); - let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; - t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0)); - // A later revision of the document, with the squares 300 pt to the right, at the same address. - d = two_rects_at(300.0).0; - let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - let acts = t.pointer(&cx2, &ev(PointerKind::Down, 375.0, 50.0)); - assert_eq!(acts, vec![Action::Exec("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] }))]); - } - - #[test] - fn bucket_keeps_its_map_while_the_document_is_unchanged() { - let (d, a, b) = two_rects(); - let mut s = Selection::default(); - s.set([a, b]); - let p = paint(); - let mut t = LivePaintBucketTool::default(); - let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; - t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0)); - let acts = t.pointer(&cx1, &ev(PointerKind::Down, 75.0, 50.0)); - assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "livePaint.fill"), "{acts:?}"); - // The fill left the document as it was: the map still fits it. - t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0)); - assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing"); - let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - t.pointer(&cx2, &ev(PointerKind::Move, 25.0, 50.0)); - assert_eq!(t.cache.builds, 2, "a hover on a new revision"); - } - - #[test] - fn bucket_hover_builds_a_large_map_on_another_thread() { - let (d, s) = square_row(40); - let p = paint(); - let mut t = LivePaintBucketTool::default(); - let cx = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; - let hover = ev(PointerKind::Move, 15.0, 10.0); - t.pointer(&cx, &hover); - assert_eq!(t.cache.builds, 0, "the first hover returns before the map is built"); - assert!(t.waiting() && t.hover == LpHover::None, "{:?}", t.hover); - poll_until_ready(&mut t, &cx); - assert!(matches!(t.hover, LpHover::Region(_)), "{:?}", t.hover); - assert_eq!((t.cache.builds, t.cache.background), (1, 1)); - assert_eq!(t.overlays(&cx).len(), 1); - } - - #[test] - fn bucket_takes_the_map_it_waits_for_while_hovering_a_live_paint_group() { - let (mut d, s) = square_row(40); - live_square(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); - let p = paint(); - let mut t = LivePaintBucketTool::default(); - let cx = cx(&d, &s, &p); - t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0)); - assert!(t.waiting()); - t.pointer(&cx, &ev(PointerKind::Move, 150.0, 150.0)); - assert!(matches!(t.hover, LpHover::Part(_)), "{:?}", t.hover); - poll_until_ready(&mut t, &cx); - assert!(matches!(t.hover, LpHover::Part(_)), "{:?}", t.hover); - assert_eq!((t.cache.builds, t.cache.background), (1, 1)); - } - - #[test] - fn live_paint_selection_hover_builds_no_map_of_the_selection() { - let (d, s) = square_row(3); - let p = paint(); - let mut t = LivePaintSelectionTool::default(); - let cx = cx(&d, &s, &p); - t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0)); - assert_eq!(t.hover, LpHover::None); - assert_eq!(t.cache.builds, 0); - assert!(!t.waiting()); - } - - #[test] - fn live_paint_selection_selects_faces() { - let (d, _gid, f2, e) = live_group_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = LivePaintSelectionTool::default(); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); - assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [f2.0] }))]); - let alt = Mods { alt: true, ..Default::default() }; - let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)); - assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [e.0] }))]); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)); - assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]); - } - - #[test] - fn live_paint_selection_options_choose_what_a_click_selects() { - let (d, _gid, f2, e) = live_group_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Default::default() }; - let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; - let none = vec![Action::Exec("select.none".into(), json!({}))]; - let mut t = LivePaintSelectionTool::default(); - assert_eq!(t.options(), json!({"selectFills": true, "selectStrokes": true, "color": "#ff2a2a", "width": 4.0})); - // Select Fills off: a click inside a face selects nothing, and a click by an edge selects it. - t.set_option("selectFills", &json!(false)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)), none); - t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); - assert!(t.overlays(&cx).is_empty()); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0)), set(&[e])); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e])); - // Select Strokes off: Alt-click by an edge selects the face under the pointer. - t.set_option("selectFills", &json!(true)); - t.set_option("selectStrokes", &json!(false)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2])); - t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(alt)); - let ov = t.overlays(&cx); - let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; - assert_eq!(path.bounding_box(), Rect::new(50.0, 0.0, 100.0, 50.0), "the face, not the edge"); - // Both off: nothing is selected or highlighted. - t.set_option("selectFills", &json!(false)); - for m in [Mods::default(), alt] { - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none); - t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(m)); - assert!(t.overlays(&cx).is_empty()); - } - let shift = Mods { shift: true, ..Default::default() }; - assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty()); - } - - #[test] - fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() { - let mut d = Document::new(500.0, 500.0); - let (_, [_, _, right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Default::default() }; - let mut t = LivePaintSelectionTool::default(); - // Alt held, and Select Fills off without Alt: 2 pt right of the square, the pointer - // highlights the square's right side, and a click selects that side (Shift-click adds it). - for (fills, m) in [(true, alt), (false, Mods::default())] { - t.set_option("selectFills", &json!(fills)); - t.pointer(&cx, &ev(PointerKind::Move, 102.0, 50.0).with_mods(m)); - let ov = t.overlays(&cx); - let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; - assert_eq!(path.bounding_box(), Rect::new(100.0, 0.0, 100.0, 100.0)); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(m)); - assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [right.0] }))]); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m })); - assert_eq!(acts, vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]); - } - // Farther than 4 pixels from every edge: no highlight, and a click selects nothing. - t.pointer(&cx, &ev(PointerKind::Move, 106.0, 50.0)); - assert!(t.overlays(&cx).is_empty()); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); - } - - #[test] - fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() { - let mut d = Document::new(500.0, 500.0); - let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); - let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0)); - let s = Selection::default(); - let p = paint(); - let alt = Mods { alt: true, ..Default::default() }; - let shift = Mods { shift: true, ..Default::default() }; - // 1.5 pt from the back group's right side and 2.5 pt from the front group's left side. - let (x, y) = (101.5, 50.0); - let edge_x = |ov: &[Overlay]| match ov { - [] => None, - [Overlay::Path { path, .. }] => Some(path.bounding_box().x0), - _ => panic!("{ov:?}"), - }; - // Live Paint Selection (Alt) and Live Paint Bucket (Shift) highlight and pick the same edge. - let check = |d: &Document, want: Option<(NodeId, NodeId, f64)>| { - let cx = cx(d, &s, &p); - let mut sel = LivePaintSelectionTool::default(); - sel.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(alt)); - assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2)); - let picked = match want { - Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), - None => Action::Exec("select.none".into(), json!({})), - }; - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); - let mut bucket = LivePaintBucketTool::default(); - bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift)); - assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2)); - let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, y], "tolerance": 4.0 }))); - assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(shift)), stroked.into_iter().collect::>()); - }; - check(&d, Some((back, back_right, 100.0))); - // A hidden group's edges are not highlighted or picked. - d.node_mut(back).unwrap().visible = false; - check(&d, Some((front, front_left, 104.0))); - d.node_mut(front).unwrap().visible = false; - check(&d, None); - } - - #[test] - fn live_paint_tools_skip_hidden_edges() { - let mut d = Document::new(500.0, 500.0); - let (_, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); - let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0)); - // The back group's right side is hidden on its own; both groups stay visible. - d.node_mut(back_right).unwrap().visible = false; - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Default::default() }; - let shift = Mods { shift: true, ..Default::default() }; - let edge_x = |ov: &[Overlay]| match ov { - [] => None, - [Overlay::Path { path, .. }] => Some(path.bounding_box().x0), - _ => panic!("{ov:?}"), - }; - // Outside the faces, 1.5 pt from the hidden side and 2.5 pt from the front group's left - // side, both tools highlight and pick the visible side. Inside the back face, 1.5 pt from - // the hidden side, no edge is in reach, so neither tool highlights or picks one. - for (x, want) in [(101.5, Some((front, front_left, 104.0))), (98.5, None)] { - let mut sel = LivePaintSelectionTool::default(); - sel.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(alt)); - assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2), "x {x}"); - let picked = match want { - Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), - None => Action::Exec("select.none".into(), json!({})), - }; - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); - let mut bucket = LivePaintBucketTool::default(); - bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift)); - assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}"); - let stroked = - want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, 50.0], "tolerance": 4.0 }))); - assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(shift)), stroked.into_iter().collect::>(), "x {x}"); - } - } - - #[test] - fn live_paint_tools_skip_hidden_faces() { - let mut d = Document::new(500.0, 500.0); - let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); - let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0)); - let face = |g: NodeId| faces(d.node(g).unwrap())[0].id; - let (back_face, hidden) = (face(back), face(front)); - // The front group's face is hidden on its own; both groups stay visible. - d.node_mut(hidden).unwrap().visible = false; - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Default::default() }; - let part = |ov: &[Overlay]| match ov { - [] => None, - [Overlay::Path { path, .. }] => Some(path.bounding_box()), - _ => panic!("{ov:?}"), - }; - // Where the faces overlap, both tools take the back group's face under the hidden one. - let mut bucket = LivePaintBucketTool::default(); - bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); - assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); - let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); - assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]); - let mut sel = LivePaintSelectionTool::default(); - sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); - assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); - // Over the hidden face alone, neither tool highlights or picks a face. - bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); - assert_eq!(part(&bucket.overlays(&cx)), None); - assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty()); - sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); - assert_eq!(part(&sel.overlays(&cx)), None); - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); - // Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side - // as it does outside every face. - sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt)); - assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0))); - let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); - assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]); - } - - #[test] - fn live_paint_selection_options_set_the_highlight() { - let (d, ..) = live_group_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = LivePaintSelectionTool::default(); - let highlight = |t: &mut LivePaintSelectionTool| { - t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); - match t.overlays(&cx).as_slice() { - [Overlay::Path { color, width, .. }] => (*color, *width), - ov => panic!("{ov:?}"), - } - }; - assert_eq!(highlight(&mut t), (HIGHLIGHT_RED, 4.0)); - t.set_option("color", &json!("#00FF00")); - t.set_option("width", &json!(9)); - assert_eq!(highlight(&mut t), ([0, 0xff, 0], 9.0)); - // A layer color's name; a value that is not a color, or a width that is not a number, is ignored. - t.set_option("color", &json!("light blue")); - t.set_option("width", &json!("wide")); - assert_eq!(highlight(&mut t), ([0x4f, 0x80, 0xff], 9.0)); - t.set_option("color", &json!("nope")); - assert_eq!(t.options()["color"], "#4f80ff"); - // The width stays within its range. - t.set_option("width", &json!(100)); - assert_eq!(t.options()["width"], json!(LP_SELECT_WIDTH_RANGE.1)); - t.set_option("width", &json!(0)); - assert_eq!(t.options()["width"], json!(LP_SELECT_WIDTH_RANGE.0)); - } - - #[test] - fn create_builds_the_three_tools() { - for id in ["shapeBuilder", "livePaintBucket", "livePaintSelection"] { - assert_eq!(create(id).unwrap().id(), id); - } - assert!(create("pen").is_none()); - } -} +mod tests; diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs new file mode 100644 index 000000000..2a0f5b1a7 --- /dev/null +++ b/crates/tools/src/builder/tests.rs @@ -0,0 +1,1067 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::{Appearance, Selection}; +use vectorcraft_geom::shapes; + +fn two_rects() -> (Document, NodeId, NodeId) { + two_rects_at(0.0) +} + +/// Squares A (`x`..`x`+100) and B (`x`+50..`x`+150), 100 pt tall, overlapping by half. +fn two_rects_at(x: f64) -> (Document, NodeId, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default_art())).unwrap(); + let b = d.alloc_id(); + d.insert(Some(l), 1, Node::path(b, shapes::rectangle(Rect::new(x + 50.0, 0.0, x + 150.0, 100.0)), Appearance::default_art())).unwrap(); + (d, a, b) +} + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) +} + +#[test] +fn shape_builder_hover_highlights_region() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)).is_empty()); + let ov = t.overlays(&cx); + assert_eq!(ov.len(), 2); + let Overlay::Path { path, .. } = &ov[1] else { panic!() }; + let bb = path.bounding_box(); + assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "{bb:?}"); + t.pointer(&cx, &ev(PointerKind::Move, 400.0, 400.0)); + assert!(t.overlays(&cx).is_empty()); +} + +#[test] +fn shape_builder_drag_emits_merge() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([b, a]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 50.0)); + assert_eq!(t.touched.regions.len(), 2); + assert!(t.busy()); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0)); + let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(id, "shapeBuilder.merge"); + assert_eq!(v["erase"], false); + assert_eq!(v["ids"], json!([a.0, b.0]), "sorted back to front"); + assert!(v["points"].as_array().unwrap().len() >= 2); +} + +#[test] +fn shape_builder_rebuilds_its_map_for_a_new_revision_at_the_same_address() { + let (mut d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let mut t = ShapeBuilderTool::default(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0)); + // A later revision of the document, with the squares 300 pt to the right, at the same address. + d = two_rects_at(300.0).0; + let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + t.pointer(&cx2, &ev(PointerKind::Down, 325.0, 50.0)); + t.pointer(&cx2, &ev(PointerKind::Drag, 375.0, 50.0)); + let acts = t.pointer(&cx2, &ev(PointerKind::Up, 375.0, 50.0)); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); +} + +#[test] +fn shape_builder_builds_its_map_once_for_hover_alt_hover_and_a_merge() { + let (d, s) = corner_rects(); + let p = paint(); + let mut t = ShapeBuilderTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + t.pointer(&cx1, &ev(PointerKind::Move, 101.0, 75.0)); + assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + // Alt hovering over an edge uses the same map. + t.pointer(&cx1, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt)); + assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover); + assert_eq!(t.cache.builds, 1, "a hover then an Alt hover"); + t.pointer(&cx1, &ev(PointerKind::Down, 25.0, 25.0)); + t.pointer(&cx1, &ev(PointerKind::Drag, 75.0, 75.0)); + let acts = t.pointer(&cx1, &ev(PointerKind::Up, 75.0, 75.0)); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); + // The merge left the document as it was (it failed, say): the map still fits it. + t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 75.0)); + assert_eq!(t.cache.builds, 1, "a hover after a merge that changed nothing"); + let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + t.pointer(&cx2, &ev(PointerKind::Move, 75.0, 75.0)); + assert_eq!(t.cache.builds, 2, "a hover on a new revision"); +} + +/// A row of `n` selected squares 20 pt wide, each overlapping the next by half: 4 segments each. +fn square_row(n: usize) -> (Document, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut s = Selection::default(); + for k in 0..n { + let x = k as f64 * 10.0; + let id = d.alloc_id(); + d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())).unwrap(); + s.add(id); + } + (d, s) +} + +/// Polls `t`, as the desktop app does each frame, until it stops waiting. +fn poll_until_ready(t: &mut dyn Tool, cx: &ToolContext) { + let start = std::time::Instant::now(); + while t.waiting() { + assert!(start.elapsed() < std::time::Duration::from_secs(60), "the tool is still waiting"); + std::thread::sleep(std::time::Duration::from_millis(2)); + t.poll(cx, false); + } +} + +#[test] +fn shape_builder_hover_builds_a_large_map_on_another_thread() { + let (d, s) = square_row(40); + let p = paint(); + let mut t = ShapeBuilderTool::default(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let hover = ev(PointerKind::Move, 15.0, 10.0); + t.pointer(&cx1, &hover); + assert_eq!(t.cache.builds, 0, "the first hover returns before the map is built"); + assert!(t.waiting() && t.hover.is_none() && t.overlays(&cx1).is_empty(), "{:?}", t.hover); + poll_until_ready(&mut t, &cx1); + assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + assert_eq!((t.cache.builds, t.cache.background), (1, 1)); + assert_eq!(t.overlays(&cx1).len(), 2, "the region's hatch and outline"); + // A new revision while the map of the one before it is being built: only the newer one's + // map is used. + let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + let cx3 = ToolContext { revision: (1, 3), ..cx(&d, &s, &p) }; + t.pointer(&cx2, &hover); + t.pointer(&cx3, &hover); + poll_until_ready(&mut t, &cx3); + assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + assert_eq!((t.cache.builds, t.cache.background), (3, 3), "the earlier revision's map, then the newer one's"); + assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 3))); + // A host without frames waits for the map. + let cx4 = ToolContext { revision: (1, 4), ..cx(&d, &s, &p) }; + t.pointer(&cx4, &hover); + assert!(t.waiting() && t.hover.is_none()); + t.poll(&cx4, true); + assert!(!t.waiting()); + assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + assert_eq!((t.cache.builds, t.cache.background), (4, 4)); + // A press while the map is being built waits for it, and the drag merges. + let cx5 = ToolContext { revision: (1, 5), ..cx(&d, &s, &p) }; + t.pointer(&cx5, &hover); + t.pointer(&cx5, &ev(PointerKind::Down, 15.0, 10.0)); + t.pointer(&cx5, &ev(PointerKind::Drag, 35.0, 10.0)); + let acts = t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); + assert_eq!((t.cache.builds, t.cache.background), (5, 5)); + assert!(!t.waiting()); + // Changing an option forgets a map being built with the old one. + let cx6 = ToolContext { revision: (1, 6), ..cx(&d, &s, &p) }; + t.pointer(&cx6, &hover); + t.set_option("gapDetection", &json!(true)); + assert!(!t.waiting()); +} + +#[test] +fn shape_builder_hover_builds_a_small_map_at_once() { + let (d, s) = square_row(3); + let p = paint(); + let mut t = ShapeBuilderTool::default(); + let cx = cx(&d, &s, &p); + t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0)); + assert!(!t.waiting() && matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + assert_eq!((t.cache.builds, t.cache.background), (1, 0)); +} + +#[test] +fn shape_builder_alt_drag_erases_and_empty_drag_does_nothing() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0).with_mods(alt)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 125.0, 50.0).with_mods(alt)); + let Action::Exec(_, v) = &acts[0] else { panic!() }; + assert_eq!(v["erase"], true); + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)).is_empty()); +} + +/// Squares A (0..100) and B (50..150 both ways), overlapping at a corner, selected. +fn corner_rects() -> (Document, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut s = Selection::default(); + for (k, r) in [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(50.0, 50.0, 150.0, 150.0)].into_iter().enumerate() { + let id = d.alloc_id(); + d.insert(Some(l), k, Node::path(id, shapes::rectangle(r), Appearance::default_art())).unwrap(); + s.add(id); + } + (d, s) +} + +fn colors(ov: &[Overlay]) -> Vec<[u8; 3]> { + ov.iter().map(|o| if let Overlay::Path { color, .. } = o { *color } else { panic!("{o:?}") }).collect() +} + +#[test] +fn shape_builder_alt_switches_to_erase_mode_while_held() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + let at = Point::new(75.0, 75.0); + // Merge mode: a grey mesh and the plus pointer. + t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y)); + assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder); + // Alt held: what would go highlights red, and the pointer shows a minus. + t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y).with_mods(alt)); + assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 2]); + assert_eq!(t.cursor(&cx, at, alt), Cursor::ShapeBuilderErase); + // Released: merge mode again. + t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y)); + assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder); + // A drag keeps the mode it started in. + t.pointer(&cx, &ev(PointerKind::Down, at.x, at.y).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilderErase); + let acts = t.pointer(&cx, &ev(PointerKind::Up, at.x, at.y)); + let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(v["erase"], true); +} + +#[test] +fn shape_builder_alt_on_an_edge_highlights_and_deletes_it() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + // A's side inside B, between the points where B's outline crosses it. + t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt)); + assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover); + let ov = t.overlays(&cx); + let [Overlay::Path { path, color: HIGHLIGHT_RED, width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") }; + assert_eq!(path.bounding_box(), Rect::new(50.0, 50.0, 100.0, 100.0)); + // Without Alt it's the region under the pointer. + t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0)); + assert!(matches!(t.hover, Some(Hit::Region(_)))); + t.pointer(&cx, &ev(PointerKind::Down, 101.0, 75.0).with_mods(alt)); + assert_eq!(t.touched.edges.len(), 1); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 101.0, 75.0).with_mods(alt)); + let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0))); +} + +#[test] +fn shape_builder_erase_drag_into_regions_shows_the_regions_not_the_edges_it_crosses() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0).with_mods(alt)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(alt)); + assert_eq!(t.touched.regions.len(), 2); + assert!(!t.touched.edges.is_empty(), "the drag crossed B's outline"); + assert!(t.cache.map.erased_edges(&t.touched).is_empty(), "which goes with the regions"); + // Two regions (mesh + outline each) and the drag's trail, all red. + assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]); +} + +/// Rect A (0..100) and rect B (98..198), both 100 tall, selected: they overlap in a 2 pt strip. +fn sliver_rects() -> (Document, Selection) { + rects(&[Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(98.0, 0.0, 198.0, 100.0)]) +} + +/// The rectangles `rs`, back to front, selected. +fn rects(rs: &[Rect]) -> (Document, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut s = Selection::default(); + for (k, r) in rs.iter().enumerate() { + let id = d.alloc_id(); + d.insert(Some(l), k, Node::path(id, shapes::rectangle(*r), Appearance::default_art())).unwrap(); + s.add(id); + } + (d, s) +} + +fn map_of(d: &Document, s: &Selection) -> BuilderMap { + BuilderMap::new(d, &sorted_roots(d, &s.objects), true, true).0 +} + +#[test] +fn shape_builder_erase_drag_across_an_overlap_narrower_than_twice_the_tolerance_touches_it() { + let (d, s) = sliver_rects(); + let map = map_of(&d, &s); + let strip = map.region_at(Point::new(99.0, 50.0)).unwrap(); + // Every point of the strip is within 4 pt of its edges. + let t = map.builder_hits_along(&[Point::new(50.0, 50.0), Point::new(150.0, 50.0)], Some(4.0)); + assert_eq!(t.regions.len(), 3, "{t:?}"); + assert!(t.regions.contains(&strip), "{t:?}"); + assert!(map.erased_edges(&t).is_empty(), "the strip's edges go with it"); + // A drag at a shallow angle goes about 10 pt through the strip and touches it too. + let t = map.builder_hits_along(&[Point::new(90.0, 10.0), Point::new(106.0, 90.0)], Some(4.0)); + assert_eq!(t.regions.len(), 3, "{t:?}"); + // So does a drag that starts in the strip, and one that goes into it and back out. + let t = map.builder_hits_along(&[Point::new(99.0, 50.0), Point::new(150.0, 50.0)], Some(4.0)); + assert!(t.regions.contains(&strip), "{t:?}"); + let t = map.builder_hits_along(&[Point::new(50.0, 50.0), Point::new(99.0, 50.0), Point::new(50.0, 51.0)], Some(4.0)); + assert!(t.regions.contains(&strip), "{t:?}"); + // Merging is unchanged, and so is the plain hit test other tools use. + assert_eq!(map.builder_hit(Point::new(99.0, 50.0), Some(4.0)), Some(Hit::Region(strip))); + assert!(matches!(map.hit(Point::new(99.0, 50.0), Some(4.0)), Some(Hit::Edge(_)))); + assert_eq!(map.builder_hit(Point::new(99.0, 50.0), None), Some(Hit::Region(strip))); +} + +#[test] +fn shape_builder_erase_drag_across_a_thin_bar_touches_it_from_either_side() { + // A 1.5 pt bar inside a square, and the same bar on its own. + let bar = Rect::new(49.0, 10.0, 50.5, 90.0); + for rs in [vec![Rect::new(0.0, 0.0, 100.0, 100.0), bar], vec![bar]] { + let (d, s) = rects(&rs); + let map = map_of(&d, &s); + let inner = map.region_at(Point::new(49.75, 50.0)).unwrap(); + let t = map.builder_hits_along(&[Point::new(20.0, 50.0), Point::new(80.0, 50.0)], Some(4.0)); + assert!(t.regions.contains(&inner), "{rs:?}: {t:?}"); + assert_eq!(t.regions.len(), rs.len(), "{rs:?}: {t:?}"); + // 0.1 pt from an end, the nearest outline is the end, and the bar is still 1.5 pt + // across from its sides: a click there and a drag across the bar there touch it. + assert_eq!(map.builder_hit(Point::new(49.75, 10.1), Some(4.0)), Some(Hit::Region(inner)), "{rs:?}"); + assert_eq!(map.builder_hit(Point::new(49.75, 89.9), Some(4.0)), Some(Hit::Region(inner)), "{rs:?}"); + let t = map.builder_hits_along(&[Point::new(20.0, 10.1), Point::new(80.0, 10.1)], Some(4.0)); + assert!(t.regions.contains(&inner), "{rs:?}: {t:?}"); + } + // Near a corner of the square, both of its edges there are close, and the square is wide + // across from each: the edge is touched. + let (d, s) = rects(&[Rect::new(0.0, 0.0, 100.0, 100.0), bar]); + let map = map_of(&d, &s); + assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_)))); + // So it is when the square's top edge has a point 2 pt from the corner: the end of the + // segment past that point is not where the square is narrow. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let pts = [(0.0, 0.0), (2.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)); + let path = PathData::new(vec![SubPath::polyline(&pts, true)]); + d.insert(Some(l), 0, Node::path(id, path, Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let map = map_of(&d, &s); + let square = map.region_at(Point::new(50.0, 50.0)).unwrap(); + assert_eq!(map.regions[square].1.segments().count(), 5, "the point on the top edge stays"); + assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_)))); + // A bar whose sides each have a point level with a click 0.1 pt from its end: the click + // and a drag across the bar there still touch the bar. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let pts = [(49.0, 10.0), (50.5, 10.0), (50.5, 10.1), (50.5, 90.0), (49.0, 90.0), (49.0, 10.1)].map(|(x, y)| Point::new(x, y)); + let path = PathData::new(vec![SubPath::polyline(&pts, true)]); + d.insert(Some(l), 0, Node::path(id, path, Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let map = map_of(&d, &s); + let split = map.region_at(Point::new(49.75, 50.0)).unwrap(); + assert_eq!(map.regions[split].1.segments().count(), 6, "the points on the sides stay"); + assert_eq!(map.builder_hit(Point::new(49.75, 10.1), Some(4.0)), Some(Hit::Region(split))); + let t = map.builder_hits_along(&[Point::new(20.0, 10.1), Point::new(80.0, 10.1)], Some(4.0)); + assert!(t.regions.contains(&split), "{t:?}"); +} + +#[test] +fn shape_builder_erase_drag_along_an_edge_does_not_touch_the_regions_beside_it() { + let (d, s) = grid(); + let map = map_of(&d, &s); + // 1 pt above the edges between the top row and the middle one, through all three squares. + let t = map.builder_hits_along(&[Point::new(50.0, 99.0), Point::new(250.0, 99.0)], Some(4.0)); + assert!(t.regions.is_empty(), "{t:?}"); + assert!(!t.edges.is_empty()); + // Crossing the edge 2 pt before a junction clips the square below without touching it. + let pts = [Point::new(50.0, 99.0), Point::new(97.0, 99.0), Point::new(99.0, 101.0), Point::new(150.0, 101.0)]; + let t = map.builder_hits_along(&pts, Some(4.0)); + let clipped = map.region_at(Point::new(99.0, 101.0)).unwrap(); + assert!(!t.regions.contains(&clipped), "{t:?}"); +} + +#[test] +fn shape_builder_alt_hover_over_a_narrow_overlap_highlights_it() { + let (d, s) = sliver_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Move, 99.0, 50.0).with_mods(alt)); + let strip = t.cache.map.region_at(Point::new(99.0, 50.0)); + assert_eq!(t.hover, strip.map(Hit::Region)); + // Beside a wide region, the edge. + t.pointer(&cx, &ev(PointerKind::Move, 50.0, 99.0).with_mods(alt)); + assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover); +} + +#[test] +fn shape_builder_alt_drag_highlights_an_overlap_it_crosses_over_two_moves() { + let (d, s) = sliver_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(alt)); + // One move ends inside the strip, and the next one leaves it. + t.pointer(&cx, &ev(PointerKind::Drag, 99.0, 50.0).with_mods(alt)); + t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 50.0).with_mods(alt)); + assert_eq!(t.touched.regions.len(), 3, "{:?}", t.touched); + assert!(t.cache.map.erased_edges(&t.touched).is_empty()); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 50.0).with_mods(alt)); + let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0))); +} + +/// A 3×3 grid of touching 100 pt squares from (0, 0) to (300, 300), selected. +fn grid() -> (Document, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut s = Selection::default(); + for k in 0..9 { + let (x, y) = ((k % 3) as f64 * 100.0, (k / 3) as f64 * 100.0); + let id = d.alloc_id(); + d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 100.0, y + 100.0)), Appearance::default_art())).unwrap(); + s.add(id); + } + (d, s) +} + +/// The paths of an overlay list (a shaded region draws two: its mesh and its outline), and its +/// marquees. +fn paths_and_marquees(ov: &[Overlay]) -> (usize, Vec) { + let paths = ov.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(); + (paths, ov.iter().filter_map(|o| if let Overlay::Marquee(r) = o { Some(*r) } else { None }).collect()) +} + +#[test] +fn shape_builder_shift_drag_highlights_and_merges_every_region_in_its_marquee() { + let (d, s) = grid(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let shift = Mods { shift: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0).with_mods(shift)); + // Shift may be let go once the drag is on. + t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); + assert_eq!(t.touched.regions.len(), 9, "{:?}", t.touched); + assert_eq!(t.touched.regions[0], t.cache.map.region_at(Point::new(250.0, 250.0)).unwrap(), "where the drag started comes first"); + assert_eq!(paths_and_marquees(&t.overlays(&cx)), (18, vec![Rect::new(50.0, 50.0, 250.0, 250.0)]), "nine regions, no trail"); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)); + let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((id.as_str(), &v["rect"], &v["erase"]), ("shapeBuilder.merge", &json!([250.0, 250.0, -200.0, -200.0]), &json!(false))); + assert!(v.get("points").is_none(), "{v}"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee goes with the drag"); +} + +#[test] +fn shape_builder_alt_shift_drag_marks_the_regions_in_its_marquee_for_deleting() { + let (d, s) = grid(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt_shift = Mods { alt: true, shift: true, ..Default::default() }; + // A narrow marquee down the middle column. + t.pointer(&cx, &ev(PointerKind::Down, 140.0, 50.0).with_mods(alt_shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 250.0).with_mods(alt_shift)); + assert_eq!(t.touched.regions.len(), 3); + let ov = t.overlays(&cx); + assert!(ov.iter().all(|o| matches!(o, Overlay::Marquee(_) | Overlay::Path { color: HIGHLIGHT_RED, .. })), "{ov:?}"); + assert_eq!(paths_and_marquees(&ov).0, 6, "three regions"); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 250.0).with_mods(alt_shift)); + let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((&v["rect"], &v["erase"]), (&json!([140.0, 50.0, 20.0, 200.0]), &json!(true))); +} + +/// Without Shift a drag still touches what the pointer passes over; a Shift-click that does not +/// move is a click. +#[test] +fn shape_builder_drag_without_shift_and_shift_click_send_points() { + let (d, s) = grid(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); + assert_eq!(t.touched.regions.len(), 3, "the diagonal"); + let (paths, marquees) = paths_and_marquees(&t.overlays(&cx)); + assert_eq!((paths, marquees.len()), (7, 0), "three regions and the trail"); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)); + let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[250.0, 250.0], [150.0, 150.0], [50.0, 50.0]])); + assert!(v.get("rect").is_none(), "{v}"); + let shift = Mods { shift: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(shift)); + assert_eq!(t.touched.regions.len(), 1); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0).with_mods(shift)); + let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((&v["points"], v.get("rect")), (&json!([[150.0, 150.0]]), None)); +} + +#[test] +fn marquee_regions_touch_crossed_outlines_held_anchors_and_the_region_around_it() { + let (d, s) = grid(); + let (map, _) = BuilderMap::new(&d, &sorted_roots(&d, &s.objects), false, true); + let at = |x: f64, y: f64| map.region_at(Point::new(x, y)).unwrap(); + // Wholly inside the middle square, then crossing into the one to its right. + assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(180.0, 180.0)), [at(150.0, 150.0)]); + assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(220.0, 180.0)), [at(150.0, 150.0), at(250.0, 150.0)]); + // Ending on a shared side does not reach the square past it. + assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(200.0, 180.0)), [at(150.0, 150.0)]); + // Around the whole grid, from outside it: every square, with no region under either corner. + assert_eq!(map.regions_in_marquee(Point::new(-10.0, -10.0), Point::new(310.0, 310.0)).len(), 9); + assert!(map.regions_in_marquee(Point::new(400.0, 400.0), Point::new(450.0, 450.0)).is_empty()); + // Where the drag started first, then where it ends, whichever way it runs. + for (a, b) in [((50.0, 50.0), (250.0, 250.0)), ((250.0, 250.0), (50.0, 50.0)), ((250.0, 50.0), (50.0, 250.0))] { + let got = map.regions_in_marquee(a.into(), b.into()); + assert_eq!((got.len(), got[0], got[1]), (9, at(a.0, a.1), at(b.0, b.1)), "{a:?} to {b:?}"); + } +} + +/// With In Merge Mode, Clicking Stroke Splits the Path on, a Shift-drag that starts on a stroke +/// sends its marquee and no split, however little it moves; a Shift-click there splits the +/// stroke as a click does. +#[test] +fn shape_builder_shift_drag_on_a_stroke_merges_and_a_shift_click_splits() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + t.set_option("clickStrokeSplitsPath", &json!(true)); + let shift = Mods { shift: true, ..Default::default() }; + // 1 pt outside A's left side, a move of less than 3 pixels, and one across the art. + for (to, rect) in [((0.5, 26.0), json!([-1.0, 25.0, 1.5, 1.0])), ((75.0, 75.0), json!([-1.0, 25.0, 76.0, 50.0]))] { + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); + t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, to.0, to.1).with_mods(shift)); + let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((&v["rect"], v.get("points"), v.get("splitStroke")), (&rect, None, None), "{v}"); + } + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0).with_mods(shift)); + let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((&v["splitStroke"], &v["points"], v.get("rect")), (&json!(true), &json!([[-1.0, 25.0]]), None), "{v}"); +} + +/// While a Shift-drag that started over a stroke a click would split draws its marquee, the +/// pointer shows the Shape Builder, not the scissors, and the stroke is not highlighted. +#[test] +fn shape_builder_shift_drag_shows_no_scissors_or_stroke_highlight() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + t.set_option("clickStrokeSplitsPath", &json!(true)); + let shift = Mods { shift: true, ..Default::default() }; + let highlighted = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Path { color: STROKE_HIGHLIGHT, width: 3.0, .. })); + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), shift), Cursor::Scissors); + assert!(highlighted(&t.overlays(&cx))); + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(shift)); + let ov = t.overlays(&cx); + assert!(ov.iter().any(|o| matches!(o, Overlay::Marquee(_))) && !highlighted(&ov), "{ov:?}"); + assert_eq!(t.cursor(&cx, Point::new(75.0, 75.0), shift), Cursor::ShapeBuilder); +} + +#[test] +fn shape_builder_stroke_options_round_trip_and_pick_the_side_under_the_pointer() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let o = t.options(); + assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(false), &json!(true), &json!("#ff4f4f"))); + // With the option off, the pointer 1 pt outside A's left side highlights nothing. + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + assert!(t.overlays(&cx).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::ShapeBuilder); + t.set_option("clickStrokeSplitsPath", &json!(true)); + t.set_option("highlightColor", &json!("#00ff00")); + // A value that is not a color or not a switch is ignored. + t.set_option("highlightColor", &json!("nope")); + t.set_option("highlightStroke", &json!("off")); + let o = t.options(); + assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(true), &json!("#00ff00"))); + // On, the tool draws A's left side from corner to corner in the highlight color and shows the scissors. + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + let ov = t.overlays(&cx); + let [Overlay::Path { path, color: [0, 0xff, 0], width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") }; + assert_eq!(path.bounding_box(), Rect::new(0.0, 0.0, 0.0, 100.0)); + assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::Scissors); + // With Alt the tool is in erase mode, which deletes edges and does not split them. + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), alt), Cursor::ShapeBuilderErase); + assert!(t.stroke.is_none()); + // A click splits; the command gets the point, the switch and the tolerance. + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0)); + let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((id.as_str(), &v["splitStroke"], &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(false), &json!(4.0))); + // A release up to 3 pixels from the press is still a click, which splits where the press was. + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Drag, -1.5, 26.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 0.5, 27.0)); + let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((&v["splitStroke"], &v["points"]), (&json!(true), &json!([[-1.0, 25.0]]))); + // A release farther away is a drag, which splits nothing. + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); + assert!(t.pointer(&cx, &ev(PointerKind::Up, -1.0, 30.0)).is_empty()); + // So is a Straight Line drag that goes away and ends back near the press. + t.set_option("selection", &json!("straightLine")); + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 25.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, -0.5, 25.5)); + assert!(!acts.iter().any(|a| matches!(a, Action::Exec(_, v) if v.get("splitStroke").is_some())), "{acts:?}"); + t.set_option("selection", &json!("freeform")); + // A drag merges as before and sends no switch. + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 75.0)); + let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert!(v.get("splitStroke").is_none(), "{v}"); +} + +#[test] +fn hatch_stays_inside_region() { + let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath(); + let h = hatch(&bp, 4.0); + let bb = h.bounding_box(); + assert!(bb.x0 >= -1e-6 && bb.y0 >= -1e-6 && bb.x1 <= 40.0 + 1e-6 && bb.y1 <= 20.0 + 1e-6); + assert!(h.elements().len() > 10); +} + +/// An empty hidden sources group for a Live Paint group made in a test. +fn empty_sources(d: &mut Document) -> Arc { + let mut g = Node::group(d.alloc_id(), vec![]); + g.name = Some(SOURCES_NAME.into()); + g.visible = false; + Arc::new(g) +} + +fn live_group_doc() -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let f1 = d.alloc_id(); + let f2 = d.alloc_id(); + let e = d.alloc_id(); + let mut face1 = Node::path(f1, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); + face1.name = Some(FACE_NAME.into()); + let mut face2 = Node::path(f2, shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 50.0)), Appearance::default()); + face2.name = Some(FACE_NAME.into()); + let mut edge = Node::path(e, shapes::line(Point::new(50.0, 0.0), Point::new(50.0, 50.0)), Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + let gid = d.alloc_id(); + let mut g = Node::group(gid, vec![empty_sources(&mut d), Arc::new(face1), Arc::new(face2), Arc::new(edge)]); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + (d, gid, f2, e) +} + +/// Adds a Live Paint group of one face `r` with its four sides as edges (left, top, right, +/// bottom) in front of the layer's art; returns the group and the edges. +fn live_square(d: &mut Document, r: Rect) -> (NodeId, [NodeId; 4]) { + let l = d.layers[0].id; + let mut face = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + let (a, b, c, e) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); + let mut kids = vec![empty_sources(d), Arc::new(face)]; + let mut ids = [NodeId(0); 4]; + for (k, (from, to)) in [(e, a), (a, b), (b, c), (c, e)].into_iter().enumerate() { + ids[k] = d.alloc_id(); + let mut edge = Node::path(ids[k], shapes::line(from, to), Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + let at = d.layers[0].children().map_or(0, |c| c.len()); + d.insert(Some(l), at, g).unwrap(); + (gid, ids) +} + +#[test] +fn bucket_click_fills_face_and_shift_click_strokes_edge() { + let (d, gid, f2, _e) = live_group_doc(); + assert_eq!(live_paint_at(&d, Point::new(75.0, 25.0)), Some(gid)); + assert_eq!(face_at(d.node(gid).unwrap(), Point::new(75.0, 25.0)).unwrap().id, f2); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + let ov = t.overlays(&cx); + assert!(matches!(&ov[0], Overlay::Path { color: HIGHLIGHT_RED, .. })); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + let Action::Exec(id, v) = &acts[0] else { panic!() }; + assert_eq!(id, "livePaint.fill"); + assert_eq!(v["group"], gid.0); + let shift = Mods { shift: true, ..Default::default() }; + let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift)); + let Action::Exec(id, _) = &acts[0] else { panic!() }; + assert_eq!(id, "livePaint.strokeEdge"); +} + +#[test] +fn bucket_on_plain_selection_makes_live_paint() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)); + let Action::Exec(id, v) = &acts[0] else { panic!() }; + assert_eq!(id, "livePaint.fill"); + assert_eq!(v["ids"], json!([a.0, b.0])); +} + +#[test] +fn bucket_rebuilds_its_map_for_a_new_revision_at_the_same_address() { + let (mut d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let mut t = LivePaintBucketTool::default(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0)); + // A later revision of the document, with the squares 300 pt to the right, at the same address. + d = two_rects_at(300.0).0; + let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + let acts = t.pointer(&cx2, &ev(PointerKind::Down, 375.0, 50.0)); + assert_eq!(acts, vec![Action::Exec("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] }))]); +} + +#[test] +fn bucket_keeps_its_map_while_the_document_is_unchanged() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let mut t = LivePaintBucketTool::default(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0)); + let acts = t.pointer(&cx1, &ev(PointerKind::Down, 75.0, 50.0)); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "livePaint.fill"), "{acts:?}"); + // The fill left the document as it was: the map still fits it. + t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0)); + assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing"); + let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + t.pointer(&cx2, &ev(PointerKind::Move, 25.0, 50.0)); + assert_eq!(t.cache.builds, 2, "a hover on a new revision"); +} + +#[test] +fn bucket_hover_builds_a_large_map_on_another_thread() { + let (d, s) = square_row(40); + let p = paint(); + let mut t = LivePaintBucketTool::default(); + let cx = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let hover = ev(PointerKind::Move, 15.0, 10.0); + t.pointer(&cx, &hover); + assert_eq!(t.cache.builds, 0, "the first hover returns before the map is built"); + assert!(t.waiting() && t.hover == LpHover::None, "{:?}", t.hover); + poll_until_ready(&mut t, &cx); + assert!(matches!(t.hover, LpHover::Region(_)), "{:?}", t.hover); + assert_eq!((t.cache.builds, t.cache.background), (1, 1)); + assert_eq!(t.overlays(&cx).len(), 1); +} + +#[test] +fn bucket_takes_the_map_it_waits_for_while_hovering_a_live_paint_group() { + let (mut d, s) = square_row(40); + live_square(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let p = paint(); + let mut t = LivePaintBucketTool::default(); + let cx = cx(&d, &s, &p); + t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0)); + assert!(t.waiting()); + t.pointer(&cx, &ev(PointerKind::Move, 150.0, 150.0)); + assert!(matches!(t.hover, LpHover::Part(_)), "{:?}", t.hover); + poll_until_ready(&mut t, &cx); + assert!(matches!(t.hover, LpHover::Part(_)), "{:?}", t.hover); + assert_eq!((t.cache.builds, t.cache.background), (1, 1)); +} + +#[test] +fn live_paint_selection_hover_builds_no_map_of_the_selection() { + let (d, s) = square_row(3); + let p = paint(); + let mut t = LivePaintSelectionTool::default(); + let cx = cx(&d, &s, &p); + t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0)); + assert_eq!(t.hover, LpHover::None); + assert_eq!(t.cache.builds, 0); + assert!(!t.waiting()); +} + +#[test] +fn live_paint_selection_selects_faces() { + let (d, _gid, f2, e) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintSelectionTool::default(); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [f2.0] }))]); + let alt = Mods { alt: true, ..Default::default() }; + let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [e.0] }))]); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)); + assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]); +} + +#[test] +fn live_paint_selection_options_choose_what_a_click_selects() { + let (d, _gid, f2, e) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let none = vec![Action::Exec("select.none".into(), json!({}))]; + let mut t = LivePaintSelectionTool::default(); + assert_eq!(t.options(), json!({"selectFills": true, "selectStrokes": true, "color": "#ff2a2a", "width": 4.0})); + // Select Fills off: a click inside a face selects nothing, and a click by an edge selects it. + t.set_option("selectFills", &json!(false)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)), none); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + assert!(t.overlays(&cx).is_empty()); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0)), set(&[e])); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e])); + // Select Strokes off: Alt-click by an edge selects the face under the pointer. + t.set_option("selectFills", &json!(true)); + t.set_option("selectStrokes", &json!(false)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2])); + t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(alt)); + let ov = t.overlays(&cx); + let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; + assert_eq!(path.bounding_box(), Rect::new(50.0, 0.0, 100.0, 50.0), "the face, not the edge"); + // Both off: nothing is selected or highlighted. + t.set_option("selectFills", &json!(false)); + for m in [Mods::default(), alt] { + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none); + t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(m)); + assert!(t.overlays(&cx).is_empty()); + } + let shift = Mods { shift: true, ..Default::default() }; + assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty()); +} + +#[test] +fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() { + let mut d = Document::new(500.0, 500.0); + let (_, [_, _, right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = LivePaintSelectionTool::default(); + // Alt held, and Select Fills off without Alt: 2 pt right of the square, the pointer + // highlights the square's right side, and a click selects that side (Shift-click adds it). + for (fills, m) in [(true, alt), (false, Mods::default())] { + t.set_option("selectFills", &json!(fills)); + t.pointer(&cx, &ev(PointerKind::Move, 102.0, 50.0).with_mods(m)); + let ov = t.overlays(&cx); + let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; + assert_eq!(path.bounding_box(), Rect::new(100.0, 0.0, 100.0, 100.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(m)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [right.0] }))]); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m })); + assert_eq!(acts, vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]); + } + // Farther than 4 pixels from every edge: no highlight, and a click selects nothing. + t.pointer(&cx, &ev(PointerKind::Move, 106.0, 50.0)); + assert!(t.overlays(&cx).is_empty()); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); +} + +#[test] +fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() { + let mut d = Document::new(500.0, 500.0); + let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + // 1.5 pt from the back group's right side and 2.5 pt from the front group's left side. + let (x, y) = (101.5, 50.0); + let edge_x = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box().x0), + _ => panic!("{ov:?}"), + }; + // Live Paint Selection (Alt) and Live Paint Bucket (Shift) highlight and pick the same edge. + let check = |d: &Document, want: Option<(NodeId, NodeId, f64)>| { + let cx = cx(d, &s, &p); + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(alt)); + assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2)); + let picked = match want { + Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), + None => Action::Exec("select.none".into(), json!({})), + }; + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift)); + assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2)); + let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, y], "tolerance": 4.0 }))); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(shift)), stroked.into_iter().collect::>()); + }; + check(&d, Some((back, back_right, 100.0))); + // A hidden group's edges are not highlighted or picked. + d.node_mut(back).unwrap().visible = false; + check(&d, Some((front, front_left, 104.0))); + d.node_mut(front).unwrap().visible = false; + check(&d, None); +} + +#[test] +fn live_paint_tools_skip_hidden_edges() { + let mut d = Document::new(500.0, 500.0); + let (_, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0)); + // The back group's right side is hidden on its own; both groups stay visible. + d.node_mut(back_right).unwrap().visible = false; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let edge_x = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box().x0), + _ => panic!("{ov:?}"), + }; + // Outside the faces, 1.5 pt from the hidden side and 2.5 pt from the front group's left + // side, both tools highlight and pick the visible side. Inside the back face, 1.5 pt from + // the hidden side, no edge is in reach, so neither tool highlights or picks one. + for (x, want) in [(101.5, Some((front, front_left, 104.0))), (98.5, None)] { + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(alt)); + assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2), "x {x}"); + let picked = match want { + Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), + None => Action::Exec("select.none".into(), json!({})), + }; + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift)); + assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}"); + let stroked = + want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, 50.0], "tolerance": 4.0 }))); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(shift)), stroked.into_iter().collect::>(), "x {x}"); + } +} + +#[test] +fn live_paint_tools_skip_hidden_faces() { + let mut d = Document::new(500.0, 500.0); + let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0)); + let face = |g: NodeId| faces(d.node(g).unwrap())[0].id; + let (back_face, hidden) = (face(back), face(front)); + // The front group's face is hidden on its own; both groups stay visible. + d.node_mut(hidden).unwrap().visible = false; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let part = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box()), + _ => panic!("{ov:?}"), + }; + // Where the faces overlap, both tools take the back group's face under the hidden one. + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]); + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); + // Over the hidden face alone, neither tool highlights or picks a face. + bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&bucket.overlays(&cx)), None); + assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty()); + sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&sel.overlays(&cx)), None); + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + // Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side + // as it does outside every face. + sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt)); + assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0))); + let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); + assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]); +} + +#[test] +fn live_paint_selection_options_set_the_highlight() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintSelectionTool::default(); + let highlight = |t: &mut LivePaintSelectionTool| { + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + match t.overlays(&cx).as_slice() { + [Overlay::Path { color, width, .. }] => (*color, *width), + ov => panic!("{ov:?}"), + } + }; + assert_eq!(highlight(&mut t), (HIGHLIGHT_RED, 4.0)); + t.set_option("color", &json!("#00FF00")); + t.set_option("width", &json!(9)); + assert_eq!(highlight(&mut t), ([0, 0xff, 0], 9.0)); + // A layer color's name; a value that is not a color, or a width that is not a number, is ignored. + t.set_option("color", &json!("light blue")); + t.set_option("width", &json!("wide")); + assert_eq!(highlight(&mut t), ([0x4f, 0x80, 0xff], 9.0)); + t.set_option("color", &json!("nope")); + assert_eq!(t.options()["color"], "#4f80ff"); + // The width stays within its range. + t.set_option("width", &json!(100)); + assert_eq!(t.options()["width"], json!(LP_SELECT_WIDTH_RANGE.1)); + t.set_option("width", &json!(0)); + assert_eq!(t.options()["width"], json!(LP_SELECT_WIDTH_RANGE.0)); +} + +#[test] +fn create_builds_the_three_tools() { + for id in ["shapeBuilder", "livePaintBucket", "livePaintSelection"] { + assert_eq!(create(id).unwrap().id(), id); + } + assert!(create("pen").is_none()); +} From 1bbb3e0b054344b6231d82fc8462c6238418e5d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:35 +0000 Subject: [PATCH 025/199] refactor(render): move fx.rs tests to fx/tests.rs --- crates/render/src/fx.rs | 1044 +-------------------------------- crates/render/src/fx/tests.rs | 1041 ++++++++++++++++++++++++++++++++ 2 files changed, 1042 insertions(+), 1043 deletions(-) create mode 100644 crates/render/src/fx/tests.rs diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 115639e37..9de83c3d2 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -2461,1046 +2461,4 @@ impl Renderer { } #[cfg(test)] -mod tests { - use serde_json::json; - use vectorcraft_doc::color::{Color, Paint}; - use vectorcraft_doc::{Appearance, Document, Effect, Node}; - use vectorcraft_geom::{Affine, Rect, shapes}; - - use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin}; - use crate::tests_fontchange::fonts_steady; - use crate::{RenderOptions, Rendered, Renderer}; - - /// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however - /// far the blur spreads at the zoom (it was up to 65535 pixels a side: 16 GB). - #[test] - fn a_wide_blur_at_a_high_zoom_is_filtered_within_the_pixel_budget() { - // A 100 pt square at 3200 % and at 6400 %, blurred by 1000 pt, in a 1600 × 1000 view. - for zoom in [32.0, 64.0] { - let reach = Rect::new(0.0, 0.0, 100.0 * zoom, 100.0 * zoom); - let (x0, y0, w, h, k) = filter_region(reach, (1600, 1000), 1000.0 * zoom).unwrap(); - assert!(f64::from(w) * f64::from(h) <= PIXEL_BUDGET, "{w} × {h}"); - assert!(k < 1.0 && x0 < 0.0 && y0 < 0.0); - // It still covers the view and the blur's spread around it, at that scale. - assert!(f64::from(w) / k >= 1600.0 + 1500.0 * zoom && f64::from(h) / k >= 1000.0 + 1500.0 * zoom); - } - // A small blur is filtered at full size, cropped to what can show. - assert_eq!(filter_region(Rect::new(10.0, 20.0, 110.0, 70.0), (1600, 1000), 4.0), Some((2.0, 12.0, 116, 66, 1.0))); - assert_eq!(filter_region(Rect::new(5000.0, 0.0, 5100.0, 100.0), (1600, 1000), 4.0), None, "off the view"); - assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0)); - } - - /// #2558: while dragging, content and a shadow's region over the drag's budget at their scale - /// render at a lower scale, within the budget; otherwise their scale stays as it is. - #[test] - fn a_drag_lowers_the_scale_of_rasters_over_its_budget() { - let d = drag_scale(true, (2560.0, 1440.0), 1.0).unwrap(); - assert!(d < 1.0 && (2560.0 * d) * (1440.0 * d) <= DRAG_PIXEL_BUDGET * (1.0 + 1e-9), "{d}"); - assert_eq!(drag_scale(false, (2560.0, 1440.0), 1.0), None, "not dragging"); - assert_eq!(drag_scale(true, (1000.0, 800.0), 1.0), None, "within the budget"); - // At a scale already lowered to 0.5, 1280 × 720 raster pixels: within the budget. - assert_eq!(drag_scale(true, (2560.0, 1440.0), 0.5), None); - // A 2000 × 1500 pixel square blurred by 20 pixels in a 1600 × 1000 view. - let reach = Rect::new(-200.0, -200.0, 1800.0, 1300.0); - let (x0, y0, w, h, k) = filter_region_within(reach, (1600, 1000), 20.0, DRAG_PIXEL_BUDGET).unwrap(); - assert!(f64::from(w) * f64::from(h) <= DRAG_PIXEL_BUDGET && k < 1.0, "{w} × {h} at {k}"); - // It covers the same pixels as the full-size region, at that scale. - let full = filter_region(reach, (1600, 1000), 20.0).unwrap(); - assert_eq!((x0, y0, full.4), (full.0, full.1, 1.0)); - assert!((f64::from(w) / k - f64::from(full.2)).abs() <= 1.0 / k && (f64::from(h) / k - f64::from(full.3)).abs() <= 1.0 / k); - let small = Rect::new(10.0, 20.0, 110.0, 70.0); - assert_eq!(filter_region_within(small, (1600, 1000), 4.0, DRAG_PIXEL_BUDGET), filter_region(small, (1600, 1000), 4.0)); - } - - /// The margin a raster cropped to the view is made again with keeps it within the pixel budget, - /// a pixel of rounding on each edge included, and within the side limit; it is at most half the - /// view's longer side, and 0 when the view and its spread alone fill the budget. - #[test] - fn the_margin_for_pans_stays_within_the_pixel_budget() { - for ((w, h), m, want) in [((2560.0, 1440.0), 32.0, 441.0), ((640.0, 360.0), 32.0, 320.0), ((3840.0, 2160.0), 32.0, 0.0)] { - let e = pan_margin((w, h), m, PIXEL_BUDGET, 8192.0); - assert_eq!(e, want, "{w} × {h}"); - let (rw, rh) = (w + 2.0 * (m + e) + 2.0, h + 2.0 * (m + e) + 2.0); - assert!(e == 0.0 || rw * rh <= PIXEL_BUDGET, "{w} × {h}: {rw} × {rh}"); - } - assert_eq!(pan_margin((2000.0, 10.0), 0.0, 1e9, 3000.0), 498.0, "within the side limit"); - assert_eq!(pan_margin((2000.0, 10.0), f64::NAN, PIXEL_BUDGET, 8192.0), 0.0); - } - - fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document { - let mut d = Document::new(100.0, 100.0); - let id = d.alloc_id(); - let mut n = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)); - n.appearance.effects = effects.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); - let l = d.layers[0].id; - d.insert(Some(l), 0, n).unwrap(); - d - } - - fn render(d: &Document) -> Rendered { - Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) - } - - fn lum(p: [u8; 4]) -> u32 { - p[0] as u32 + p[1] as u32 + p[2] as u32 - } - - const WHITE: u32 = 765; - - /// A page of objects with object-level and item-level geometry effects (Offset Path, Roughen, - /// Warp, Outline Stroke, Transform) under drop shadows and in groups. - fn effects_page() -> Document { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true }; - for i in 0..24 { - let (x, y) = (10.0 + (i % 6) as f64 * 30.0, 10.0 + (i / 6) as f64 * 45.0); - let id = d.alloc_id(); - let r = Rect::new(x, y, x + 20.0, y + 30.0); - let shape = if i % 2 == 0 { shapes::ellipse(r) } else { shapes::star(r.center(), 12.0, 5.0, 5, 0.0) }; - let mut n = Node::path(id, shape, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::solid(Color::BLACK), 1.5)); - n.appearance.effects = match i % 4 { - 0 => vec![fx("path.offsetPath", json!({"offset": 3}))], - 1 => vec![fx("distort.roughen", json!({"size": 4, "relative": true})), fx("stylize.dropShadow", json!({}))], - 2 => vec![fx("warp.arc", json!({"bend": 30})), fx("path.outlineStroke", json!({}))], - _ => vec![fx("path.offsetPath", json!({"offset": -2})), fx("distort.transform", json!({"moveH": 4, "rotate": 10}))], - }; - if let Some(f) = n.appearance.fill_mut() { - f.effects.push(fx("path.offsetPath", json!({"offset": 2}))); - } - d.insert(Some(l), usize::MAX, n).unwrap(); - } - d - } - - /// Cached live-effect geometry draws exactly what computing it afresh draws: a second (warm) - /// frame, a frame after panning, and a fresh renderer all give the same pixels, and an edited - /// object (a new node) draws its new geometry, not the cached one. - #[test] - fn cached_effect_geometry_draws_the_same_pixels() { - let d = effects_page(); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let view = Affine::scale(2.0); - let mut r = Renderer::new(); - let cold = r.render(&d, 400, 400, view, &opts); - let warm = r.render(&d, 400, 400, view, &opts); - assert!(cold.pixels == warm.pixels, "a warm frame differs from the cold one"); - let _ = r.render(&d, 400, 400, Affine::translate((15.0, 0.0)) * view, &opts); - assert!(r.render(&d, 400, 400, view, &opts).pixels == cold.pixels, "after a pan"); - assert!(Renderer::new().render(&d, 400, 400, view, &opts).pixels == cold.pixels, "a fresh renderer"); - // Edit one object's effect: the warm renderer must draw the new result. - let mut e = d.clone(); - let id = e.layers[0].children().unwrap()[0].id; - e.node_mut(id).unwrap().appearance.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 9}), visible: true }]; - let edited = r.render(&e, 400, 400, view, &opts); - assert!(edited.pixels != cold.pixels, "the edit shows"); - assert!(edited.pixels == Renderer::new().render(&e, 400, 400, view, &opts).pixels, "and matches a fresh render"); - } - - /// Shadow rasters of objects in the tree are matched by the node's `Arc` (kept alive), not by - /// comparing nodes; over the byte budget the least recently drawn go first. - #[test] - fn shadow_rasters_keep_their_node_and_a_byte_budget() { - let d = effects_page(); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let mut r = Renderer::new(); - let first = r.render(&d, 400, 400, Affine::scale(2.0), &opts); - assert!(!r.shadows.is_empty() && r.shadows.values().all(|e| matches!(e.node, Drawn::Shared(_)))); - // A frame of other objects makes rasters of its own; the older ones go first. - let _ = r.render(&effects_page(), 400, 400, Affine::scale(2.0), &opts); - let newest = r.stamp; - let bytes = |r: &Renderer| r.shadows.values().map(|e| e.bytes()).sum::(); - let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| e.bytes()).sum::(); - assert!(bytes(&r) > latest); - r.trim_fx_rasters(latest); - assert_eq!(bytes(&r), latest); - assert!(r.shadows.values().all(|e| e.stamp == newest)); - assert!(r.render(&d, 400, 400, Affine::scale(2.0), &opts).pixels == first.pixels, "made again alike"); - } - - /// An Outer Glow whose whole reach is over the budget is made coarser over that whole reach and - /// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole - /// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes, - /// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel - /// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and - /// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow - /// round the page at 800% (scale 3, near the narrowest blur made coarser). - #[test] - fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { - let size = (640, 360); - for (rect, blur, zoom, center, scale) in [ - (Rect::new(200.0, 300.0, 400.0, 500.0), 100.0, 5.0, (440.0, 400.0), 2.0), - (Rect::new(0.0, 0.0, 595.0, 842.0), 2.0, 8.0, (595.0, 421.0), 3.0), - ] { - let mut d = Document::new(595.0, 842.0); - let layer = d.layers[0].id; - let mut n = - Node::path(d.alloc_id(), shapes::rectangle(rect), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0)); - n.appearance.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"blur": blur}), visible: true }]; - d.insert(Some(layer), 0, n).unwrap(); - let base = Affine::translate((320.0, 180.0)) * Affine::scale(zoom) * Affine::translate((-center.0, -center.1)); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let mut r = Renderer::new(); - r.threads = 2; - r.render(&d, size.0, size.1, base, &opts); - let image = |r: &Renderer| r.shadows.values().map(|e| std::sync::Arc::as_ptr(&e.image)).collect::>(); - let kept = image(&r); - assert!(r.shadows.values().all(|e| e.scale == scale && e.view.is_none()), "blur {blur}: coarser, over the whole reach"); - for pan in [(37.0, -21.0), (1.0, 0.0), (-3.0, 5.0), (0.5, 0.0), (37.3, -21.6)] { - let xf = Affine::translate(pan) * base; - let panned = r.render(&d, size.0, size.1, xf, &opts); - assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn"); - let mut fresh = Renderer::new(); - fresh.threads = 2; - assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); - } - } - } - - /// The multithreaded rasterizer draws the same pixels with any number of workers, so the - /// automatic thread count can follow the machine. - #[test] - fn pixels_do_not_depend_on_the_thread_count() { - let mut d = effects_page(); - // Overlapping translucent and blended objects on top of the effects. - let l = d.layers[0].id; - for i in 0..400 { - let id = d.alloc_id(); - let (x, y) = ((i * 37 % 190) as f64, (i * 53 % 190) as f64); - let mut n = Node::path( - id, - shapes::ellipse(Rect::new(x, y, x + 14.0, y + 9.0)), - Appearance::basic(Paint::solid(Color::rgb(0.9, 0.3, 0.1)), Paint::solid(Color::BLACK), 0.7), - ); - n.opacity = 0.6; - d.insert(Some(l), usize::MAX, n).unwrap(); - } - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let mut frames = vec![]; - // The test sets each renderer's count. `set_default_threads` would also change the count of the - // renderers that other tests create while this one runs. - for threads in [0, 1, 4, 8] { - let mut r = Renderer::new(); - r.threads = threads; - frames.push(r.render(&d, 1200, 900, Affine::scale(6.0), &opts).pixels); - } - assert!(frames.windows(2).all(|w| w[0] == w[1]), "the thread count changed pixels"); - } - - #[test] - fn thumbnails_of_glowing_objects_render_without_panicking() { - // Regression: thumbnails used vello's default (multithreaded) context, which panics on filters. - let d = doc_with( - Rect::new(20.0, 20.0, 60.0, 60.0), - Color::BLACK, - vec![("stylize.outerGlow", json!({"color": "#ff0000", "opacity": 100, "blur": 6}))], - ); - let id = d.layers[0].children().unwrap()[0].id; - let t = Renderer::new().render_thumbnail(&d, id, 48).unwrap(); - assert_eq!((t.width, t.height), (48, 48)); - } - - #[test] - fn per_stroke_raster_effects_use_the_single_threaded_pipeline() { - let mut d = doc_with(Rect::new(20.0, 20.0, 60.0, 60.0), Color::BLACK, vec![]); - let l = d.layers[0].id; - let id = d.layers[0].children().unwrap()[0].id; - let mut n = (*d.node(id).unwrap()).clone(); - n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0).into(); - if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = - n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))) - { - s.effects = - vec![Effect { id: "stylize.outerGlow".into(), params: json!({"color": "#ff0000", "opacity": 100, "blur": 6}), visible: true }]; - } - d.remove(id).unwrap(); - d.insert(Some(l), 0, n).unwrap(); - let mut r = Renderer::new(); - r.threads = 4; - let _ = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); - } - - #[test] - fn drop_shadow_darkens_outside_the_shape() { - let r = Rect::new(20.0, 20.0, 60.0, 60.0); - let plain = render(&doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![])); - assert_eq!(lum(plain.pixel(66, 66)), WHITE); - let d = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 2, "opacity": 100}))]); - let img = render(&d); - assert!(lum(img.pixel(66, 66)) < 200, "shadow below-right: {:?}", img.pixel(66, 66)); - // The object stays on top and unshadowed; the far side has no shadow. - assert_eq!(img.pixel(40, 40), [255, 0, 0, 255]); - assert_eq!(lum(img.pixel(15, 15)), WHITE); - } - - /// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and - /// right; `shade` "color" or "darkness". - fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document { - let mut d = doc_with( - Rect::new(20.0, 20.0, 60.0, 60.0), - fill, - vec![( - "stylize.dropShadow", - json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal", "shade": shade, "darkness": darkness}), - )], - ); - d.color_mode = mode; - d - } - - /// Darkness: the shadow is the object's own color, each channel times 1 − darkness in an RGB - /// document (the reference app's guide gives the object's color at 0%, - /// page 428), in the cached - /// raster and, at 6000 %, in the filter layer that a raster too large to cache is drawn with. - #[test] - fn drop_shadow_darkness_darkens_the_objects_own_color() { - let rgb = vectorcraft_doc::ColorMode::Rgb; - let red = Color::rgb(1.0, 0.0, 0.0); - // At 6000 %, the view's center is (65, 65), where only the shadow lies. - let zoomed = Affine::translate((50.0, 50.0)) * Affine::scale(60.0) * Affine::translate((-65.0, -65.0)); - for (darkness, want) in [(0.0, 255), (50.0, 128), (100.0, 0)] { - let d = shaded_doc(rgb, red, "darkness", darkness); - let px = render(&d).pixel(66, 66); - assert!(px[0].abs_diff(want) <= 1 && px[1] == 0 && px[2] == 0, "{darkness}%: {px:?}"); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let px = Renderer::new().render(&d, 100, 100, zoomed, &opts).pixel(50, 50); - assert!(px[0].abs_diff(want) <= 1 && px[1] == 0 && px[2] == 0, "{darkness}% at 6000 %: {px:?}"); - } - } - - /// In a CMYK document Darkness adds black ink and keeps the object's cyan, magenta and yellow - /// (same page of the guide: at 100% an object with colors other than black gets a shadow of those colors under - /// full black, and a black object's shadow is 100% black). - #[test] - fn drop_shadow_darkness_adds_black_in_a_cmyk_document() { - let cmyk = vectorcraft_doc::ColorMode::Cmyk; - let inks = |d: &Document| { - let i = Renderer::new().render_region_inks(d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, &RenderOptions::default()); - let at = (66 * 100 + 66) * 4; - [i[at], i[at + 1], i[at + 2], i[at + 3]] - }; - let red = Color::cmyk(0.0, 1.0, 1.0, 0.0); - for (darkness, k) in [(0.0, 0), (50.0, 128), (100.0, 255)] { - let got = inks(&shaded_doc(cmyk, red, "darkness", darkness)); - assert!(got[0] == 0 && got[1] == 255 && got[2] == 255 && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}"); - } - // Half black ink, darkened by half: K 50 + 50 × 50% = K 75. - let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 0.5), "darkness", 50.0)); - assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(191) <= 1, "{got:?}"); - let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 1.0), "darkness", 100.0)); - assert_eq!(got, [0, 0, 0, 255]); - } - - /// Color stays the default: a shadow without the new keys, and one with Color chosen and a - /// Darkness value, draw the same pixels, which a shadow with Darkness does not. - #[test] - fn a_drop_shadow_with_color_draws_as_before() { - let rgb = vectorcraft_doc::ColorMode::Rgb; - let red = Color::rgb(1.0, 0.0, 0.0); - let old = doc_with( - Rect::new(20.0, 20.0, 60.0, 60.0), - red, - vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))], - ); - let before = render(&old).pixels; - assert_eq!(render(&shaded_doc(rgb, red, "color", 40.0)).pixels, before); - assert_ne!(render(&shaded_doc(rgb, red, "darkness", 40.0)).pixels, before); - } - - #[test] - fn shadow_of_offscreen_object_is_not_culled() { - let d = doc_with( - Rect::new(-60.0, 20.0, -5.0, 60.0), - Color::WHITE, - vec![("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 100}))], - ); - let img = render(&d); - let row: Vec = (0..10).map(|i| lum(img.pixel(i * 10, 40))).collect(); - assert!(lum(img.pixel(10, 40)) < 100, "{row:?}"); - } - - #[test] - fn geometry_effect_is_rendered() { - let r = Rect::new(30.0, 30.0, 70.0, 70.0); - let d = doc_with(r, Color::BLACK, vec![("path.offsetPath", json!({"offset": 10}))]); - let img = render(&d); - assert_eq!(lum(img.pixel(25, 50)), 0, "offset grows the fill"); - let d = doc_with(r, Color::BLACK, vec![("distort.transform", json!({"moveH": -25}))]); - let img = render(&d); - assert_eq!(lum(img.pixel(10, 50)), 0); - assert_eq!(lum(img.pixel(60, 50)), WHITE); - } - - #[test] - fn per_fill_effects_apply_to_that_fill_only() { - let mut d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![]); - let id = d.layers[0].children().unwrap()[0].id; - let n = d.node_mut(id).unwrap(); - n.appearance.fill_mut().unwrap().effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 20}), visible: true }); - let img = render(&d); - assert_eq!(lum(img.pixel(85, 50)), 0); - assert_eq!(lum(img.pixel(40, 50)), WHITE); - } - - #[test] - fn blur_glow_and_feather_soften() { - let r = Rect::new(30.0, 30.0, 70.0, 70.0); - let blur = render(&doc_with(r, Color::BLACK, vec![("blur.gaussian", json!({"radius": 6}))])); - let edge = lum(blur.pixel(29, 50)); - assert!(edge > 0 && edge < WHITE, "blur spreads over the edge: {edge}"); - let glow = - render(&doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))])); - let g = glow.pixel(27, 50); - assert!(g[0] > g[1] + 20, "outer glow is red: {g:?}"); - let inner = - render(&doc_with(r, Color::BLACK, vec![("stylize.innerGlow", json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4}))])); - assert!(lum(inner.pixel(31, 50)) > 60, "inner glow lightens the edge: {:?}", inner.pixel(31, 50)); - assert!(lum(inner.pixel(50, 50)) < 30, "centre stays dark"); - let feather = render(&doc_with(r, Color::BLACK, vec![("stylize.feather", json!({"radius": 8}))])); - assert!(lum(feather.pixel(31, 50)) > 20, "feather fades the edge inward"); - assert_eq!(lum(feather.pixel(26, 50)), WHITE, "feather stays inside"); - } - - #[test] - fn an_outer_glow_blurs_a_solid_silhouette_of_faint_art() { - // GUARDS: the glow's strength comes from where the art is, not how opaque it is (a glow round a glow relies on it). - let r = Rect::new(30.0, 30.0, 70.0, 70.0); - let glow = |fill_opacity: f32| { - let mut d = - doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); - let id = d.layers[0].children().unwrap()[0].id; - d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity; - render(&d).pixel(29, 50) - }; - let (faint, opaque) = (glow(0.2), glow(1.0)); - assert_eq!(faint[1], opaque[1], "faint {faint:?}, opaque {opaque:?}"); - assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}"); - } - - #[test] - fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() { - // GUARDS: the glows AI files export measure as the solid silhouette grown by a quarter of a pixel - // of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720. - let r = Rect::new(30.0, 30.0, 70.0, 70.0); - let green_outside = |ppi: f64| { - let mut d = - doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); - d.raster_effects_ppi = ppi; - render(&d).pixel(27, 50)[1] - }; - let (coarse, fine) = (green_outside(72.0), green_outside(720.0)); - assert!(coarse + 3 < fine, "the red glow is stronger at 72 ppi: 72 ppi {coarse}, 720 ppi {fine}"); - } - - #[test] - fn a_glow_glows_round_the_glows_before_it_in_the_stack() { - // GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it. - let r = Rect::new(30.0, 30.0, 70.0, 70.0); - let d = doc_with( - r, - Color::BLACK, - vec![ - ("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 8})), - ("stylize.outerGlow", json!({"color": "#0000ff", "mode": "normal", "opacity": 100, "blur": 2})), - ], - ); - let p = render(&d).pixel(22, 50); - assert!(p[2] > p[0] + 100, "8 pt out, the tight blue glow round the red one shows: {p:?}"); - } - - #[test] - fn a_glow_too_large_for_a_full_size_raster_keeps_its_solid_silhouette() { - // GUARDS: at 40× the glow's raster passes the 4 Mpx budget and is made coarser instead of falling back to the - // filter layer, which blurs the faint alpha. - let r = Rect::new(30.0, 30.0, 70.0, 70.0); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let view = Affine::translate((-1100.0, -1900.0)) * Affine::scale(40.0); - let glow = |fill_opacity: f32| { - let mut d = - doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); - let id = d.layers[0].children().unwrap()[0].id; - d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity; - Renderer::new().render(&d, 200, 200, view, &opts).pixel(60, 100) - }; - let (faint, opaque) = (glow(0.2), glow(1.0)); - assert!(faint[1].abs_diff(opaque[1]) <= 2, "faint {faint:?}, opaque {opaque:?}"); - assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}"); - } - - #[test] - fn thirty_stacked_glows_draw_quickly_cached_or_not() { - // GUARDS: each glow is of the content with the glows before it; drawing those again for every glow costs 2^n. - // A shape draws through the shadow cache, a symbol instance without it (the filter layers). - let glows: Vec<(&str, serde_json::Value)> = - (0..30).map(|k| ("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 20, "blur": 1 + k % 4}))).collect(); - let shape = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, glows.clone()); - let mut instanced = Document::new(100.0, 100.0); - let art = Node::path( - instanced.alloc_id(), - shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), - Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), - ); - instanced.symbols.push(vectorcraft_doc::Symbol { name: "Square".into(), art: std::sync::Arc::new(art) }); - let mut instance = Node::new( - instanced.alloc_id(), - vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((30.0, 30.0)) }, - ); - instance.appearance.effects = glows.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); - let l = instanced.layers[0].id; - instanced.insert(Some(l), 0, instance).unwrap(); - for d in [&shape, &instanced] { - let start = std::time::Instant::now(); - let p = render(d).pixel(25, 50); - assert!(start.elapsed() < std::time::Duration::from_secs(20), "{:?}", start.elapsed()); - assert!(p[1] < 250, "the glows show: {p:?}"); - } - } - - #[test] - fn hidden_effects_are_ignored_and_outline_mode_uses_effected_geometry() { - let r = Rect::new(30.0, 30.0, 70.0, 70.0); - let mut d = doc_with(r, Color::BLACK, vec![("distort.transform", json!({"moveH": -25}))]); - let id = d.layers[0].children().unwrap()[0].id; - let opts = RenderOptions { background: Some([255, 255, 255, 255]), outline: true, ..Default::default() }; - let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); - assert!(lum(img.pixel(5, 50)) < 400, "outline follows the moved path"); - d.node_mut(id).unwrap().appearance.effects[0].visible = false; - let img = render(&d); - assert_eq!(lum(img.pixel(50, 50)), 0); - assert_eq!(lum(img.pixel(10, 50)), WHITE); - } - - #[test] - fn visual_bounds_include_shadow() { - let d = doc_with(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK, vec![("stylize.dropShadow", json!({"x": 7, "y": 7, "blur": 5}))]); - let n = &d.layers[0].children().unwrap()[0]; - let b = super::visual_bounds(&d.symbol_boxes(), n).unwrap(); - assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9); - } - - /// Multithreaded rendering filters each effect offscreen; it must match the single-threaded - /// reference (blend modes against the real backdrop, objects partly off-screen, all effects). - #[test] - fn multithreaded_filters_match_single_threaded() { - let cases: Vec<(Rect, Vec<(&str, serde_json::Value)>)> = vec![ - (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.dropShadow", json!({"x": 6, "y": 4, "blur": 5, "opacity": 75}))]), - (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.outerGlow", json!({"color": "#ff00aa", "blur": 8, "opacity": 90}))]), - ( - Rect::new(20.0, 20.0, 60.0, 60.0), - vec![ - ("stylize.innerGlow", json!({"blur": 6})), - ("stylize.innerGlow", json!({"blur": 4, "source": "center", "mode": "multiply", "color": "#0044ff"})), - ], - ), - (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.feather", json!({"radius": 6}))]), - (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("blur.gaussian", json!({"radius": 4})), ("stylize.dropShadow", json!({"blur": 3}))]), - // Partly outside the frame: the blur still spreads in from off-screen content. - ( - Rect::new(-30.0, 70.0, 30.0, 130.0), - vec![("stylize.outerGlow", json!({"blur": 12, "mode": "normal"})), ("blur.gaussian", json!({"radius": 3}))], - ), - ]; - for (r, fx) in cases { - let name = format!("{fx:?}"); - let mut d = doc_with(r, Color::rgb(0.2, 0.6, 0.3), fx); - // A coloured backdrop so blend modes have something to blend with. - let l = d.layers[0].id; - let bid = d.alloc_id(); - let bg = Node::path( - bid, - shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), - Appearance::basic(Paint::solid(Color::rgb(0.9, 0.5, 0.1)), Paint::None, 0.0), - ); - d.insert(Some(l), 0, bg).unwrap(); - let opts = RenderOptions::default(); - let mut st = Renderer::new(); - st.threads = 0; - let mut mt = Renderer::new(); - mt.threads = 3; - let a = st.render(&d, 100, 100, Affine::IDENTITY, &opts); - let b = mt.render(&d, 100, 100, Affine::IDENTITY, &opts); - let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap(); - // The offscreen pass adds one 8-bit premultiplied round trip: a few levels of rounding. - assert!(worst <= 4, "{name}: max channel difference {worst}"); - } - } - - #[test] - fn photoshop_filters_draw_and_follow_a_pan_from_the_cache() { - let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![("blur.radial", json!({"amount": 40, "quality": "draft"}))]); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let mut r = Renderer::new(); - let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts); - assert!(lum(a.pixel(31, 31)) > 60, "the spin softens the corners: {:?}", a.pixel(31, 31)); - assert_eq!(lum(a.pixel(50, 50)), 0, "the centre stays"); - assert!(lum(a.pixel(50, 28)) < WHITE, "turned corners reach past the sides"); - // Panned: the cached raster moves with the view. - let b = r.render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts); - for (x, y) in [(31, 31), (50, 28), (69, 50), (40, 66)] { - assert_eq!(a.pixel(x, y), b.pixel(x + 10, y + 3), "({x}, {y})"); - } - // A fresh renderer draws the panned view the same. - let c = Renderer::new().render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts); - assert_eq!(b.pixels, c.pixels); - } - - /// A blurred image that can't be decoded: every frame that draws it counts the failure - /// ([`crate::paint::decode_failures`]), from a new raster or from the raster kept. - #[test] - fn cached_effect_rasters_count_the_images_they_could_not_decode() { - use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; - let mut d = Document::new(100.0, 100.0); - // A PNG signature with nothing after it. - d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = ImageObject { - key: "cut".into(), - width: 40, - height: 40, - xf: Affine::translate((30.0, 30.0)), - link: None, - placement: Default::default(), - pixels: None, - }; - let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); - n.appearance.effects = vec![Effect { id: "blur.gaussian".into(), params: json!({"radius": 4.0}), visible: true }]; - d.insert(Some(d.layers[0].id), 0, n).unwrap(); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let mut r = Renderer::new(); - for frame in 0..3 { - let before = crate::paint::decode_failures(); - r.render(&d, 100, 100, Affine::IDENTITY, &opts); - assert!(crate::paint::decode_failures() > before, "frame {frame}"); - } - assert_eq!(r.pixel_fx.len(), 1, "the later frames draw from the raster kept"); - } - - /// An image that can't be decoded, with Rasterize, a drop shadow and a blur: the shadow draws - /// the Rasterize image first, and the blurred content draws the image kept from that draw - /// ([`super::Kept`]). Every frame counts the failure, also when the shadow and the blurred - /// content are drawn from the rasters kept. - #[test] - fn rasters_drawn_from_a_kept_rasterize_image_count_the_images_it_could_not_decode() { - use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; - let mut d = Document::new(100.0, 100.0); - // A PNG signature with nothing after it. - d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = ImageObject { - key: "cut".into(), - width: 40, - height: 40, - xf: Affine::translate((30.0, 30.0)), - link: None, - placement: Default::default(), - pixels: None, - }; - let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); - n.appearance.effects = [ - ("rasterize", json!({"background": "white"})), - ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})), - ("blur.gaussian", json!({"radius": 4.0})), - ] - .into_iter() - .map(|(id, params)| Effect { id: id.into(), params, visible: true }) - .collect(); - d.insert(Some(d.layers[0].id), 0, n).unwrap(); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let mut r = Renderer::new(); - for frame in 0..3 { - let before = crate::paint::decode_failures(); - r.render(&d, 100, 100, Affine::IDENTITY, &opts); - assert!(crate::paint::decode_failures() > before, "frame {frame}"); - } - assert_eq!((r.shadows.len(), r.pixel_fx.len()), (1, 1), "the later frames draw from the rasters kept"); - } - - /// While interacting, an object that only moved draws its drop shadow and filtered content - /// from the rasters of an earlier frame, at the nearest whole pixel, without running the - /// effects again (#1257). The frame after the interaction runs them again: it is what a fresh - /// renderer draws. - #[test] - fn moved_objects_draw_their_effect_rasters_while_interacting() { - let effects = - vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "opacity": 80})), ("texture.craquelure", json!({"crackSpacing": 6}))]; - let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), effects); - let id = base.layers[0].children().unwrap()[0].id; - // The object moved from where it started, as each frame of a drag moves it. - let moved = |dx: f64, dy: f64| { - let mut d = base.clone(); - d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false); - d - }; - let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); - let live = RenderOptions { interacting: true, ..still.clone() }; - let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still).pixels; - let mut r = Renderer::new(); - assert!(r.render(&base, 200, 200, view, &still).pixels == fresh(&base)); - assert_eq!(r.stats.moved, 0); - let (mut d, mut last) = (base.clone(), vec![]); - for (dx, dy) in [(3.0, 0.0), (3.3, 1.2), (5.15, 2.5), (6.3, 2.2)] { - d = moved(dx, dy); - last = r.render(&d, 200, 200, view, &live).pixels; - assert_eq!(r.stats.moved, 2, "the shadow and the filtered content moved by ({dx}, {dy})"); - // At the nearest whole pixel (2 a point): what a fresh renderer draws there. - let whole = moved((dx * 2.0).round() / 2.0, (dy * 2.0).round() / 2.0); - assert!(last == fresh(&whole), "moved by ({dx}, {dy})"); - } - // The frame after the drag, of the same document: the effects run again where the object is. - let after = r.render(&d, 200, 200, view, &still).pixels; - assert_eq!(r.stats.moved, 0); - assert!(after == fresh(&d), "the frame after the drag"); - assert!(after != last, "6.3 is not on a whole pixel"); - } - - /// While dragging (#2558), filtered content and a drop shadow that the frame does not draw from a - /// raster of an earlier frame render within the drag's budget and keep no raster. At 6400% a - /// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so the still - /// frame keeps rasters cropped to its view, which the moved rectangle can't take along, and a - /// 1600 × 1000 view near its corner needs more than the drag's budget. The - /// frame after the drag, of the same document, is what a fresh renderer draws, as is a frame - /// rendered while interacting without a drag (a dialog's preview). - #[test] - fn a_drag_renders_raster_effects_within_the_drag_budget() { - let effects = vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 7, "opacity": 80})), ("texture.texturizer", json!({}))]; - let base = doc_with(Rect::new(20.0, 20.0, 80.0, 76.0), Color::rgb(0.9, 0.4, 0.1), effects); - let id = base.layers[0].children().unwrap()[0].id; - let mut d = base.clone(); - d.node_mut(id).unwrap().transform(Affine::translate((0.3, 0.2)), false); - // The view is centered 10 pt inside the rectangle's right and bottom edges, and shows the - // shadow beyond the right edge. - let view = Affine::translate((800.0, 500.0)) * Affine::scale(64.0) * Affine::translate((-70.0, -66.0)); - let still = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let live = RenderOptions { interacting: true, ..still.clone() }; - let drag = RenderOptions { dragging: true, ..live.clone() }; - // Every renderer here has 3 threads on any machine. A drag renders drop shadows within its - // budget only with `threads` above 0. - let threaded = || { - let mut r = Renderer::new(); - r.threads = 3; - r - }; - let fresh = threaded().render(&d, 1600, 1000, view, &still).pixels; - let mut r = threaded(); - let _ = r.render(&base, 1600, 1000, view, &still); - assert_eq!(r.stats.previewed, 0); - let dragged = r.render(&d, 1600, 1000, view, &drag).pixels; - assert_eq!(r.stats.previewed, 2, "the shadow and the filtered content"); - assert_eq!(r.stats.moved, 2, "counted as moved: the frame after the drag renders them again"); - assert!(r.pixel_fx.is_empty() && r.shadows.values().all(|e| e.stamp < r.stamp), "no raster kept"); - assert!(dragged != fresh, "at the drag's lower resolution"); - let after = r.render(&d, 1600, 1000, view, &still).pixels; - assert_eq!((r.stats.previewed, r.stats.moved), (0, 0)); - assert!(after == fresh, "the frame after the drag"); - assert!(r.render(&d, 1600, 1000, view, &live).pixels == fresh, "interacting without a drag"); - assert_eq!(r.stats.previewed, 0); - // A drop shadow of about 2 million pixels at 4000%, within the shadow cache's limit: scaled - // by a drag, it can't take its raster along, and renders within the drag's budget. - let shadowed = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"blur": 2}))]); - let mut scaled = shadowed.clone(); - let id = scaled.layers[0].children().unwrap()[0].id; - scaled.node_mut(id).unwrap().transform(Affine::scale_about(1.05, vectorcraft_geom::Point::new(35.0, 33.0)), false); - let view = Affine::translate((800.0, 500.0)) * Affine::scale(40.0) * Affine::translate((-35.0, -33.0)); - let mut r = threaded(); - let _ = r.render(&shadowed, 1600, 1000, view, &still); - let kept = r.shadows.len(); - assert_eq!((kept, r.stats.previewed), (1, 0), "a still frame keeps the shadow's raster"); - let _ = r.render(&scaled, 1600, 1000, view, &drag); - assert_eq!((r.shadows.len(), r.stats.previewed), (kept, 1), "the scaled shadow"); - let after = r.render(&scaled, 1600, 1000, view, &still).pixels; - assert!(after == threaded().render(&scaled, 1600, 1000, view, &still).pixels, "the frame after the drag"); - } - - /// While interacting, a group holding a symbol instance whose art lies outside the 20 × 20 - /// square around the origin draws its drop shadow from the raster of an earlier frame. The - /// shadow is made from the instance's art, which the renderer measures the group's content by. - #[test] - fn a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting() { - let _fonts = fonts_steady(); - let mut base = Document::new(100.0, 100.0); - let orange = Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0); - let art = Node::path(base.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 130.0, 126.0)), orange); - base.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: std::sync::Arc::new(art) }); - // The instance draws the art from (20, 20) to (50, 46), and the 20 × 20 square around the - // origin from (-90, -90) to (-70, -70). - let xf = Affine::translate((-80.0, -80.0)); - let instance = Node::new(base.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Far".into(), xf }); - let mut group = Node::group(base.alloc_id(), vec![std::sync::Arc::new(instance)]); - let shadow = json!({"x": 4, "y": 3, "blur": 2, "opacity": 80}); - group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }); - let (id, l) = (group.id, base.layers[0].id); - base.insert(Some(l), 0, group).unwrap(); - // The group moved from where it started, as each frame of a drag moves it. - let moved = |dx: f64, dy: f64| { - let mut d = base.clone(); - d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false); - d - }; - let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); - let live = RenderOptions { interacting: true, ..still.clone() }; - let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still); - let mut r = Renderer::new(); - let first = r.render(&base, 200, 200, view, &still); - assert!(first.pixels == fresh(&base).pixels); - // The shadow, 4 pt right of the art's right edge at x 50, at (104, 70) in pixels. - assert!(lum(first.pixel(104, 70)) < WHITE, "{:?}", first.pixel(104, 70)); - // Moves on whole pixels (2 a point): the moved shadow lies where a fresh renderer draws it. - for (dx, dy) in [(3.0, 0.0), (5.5, 2.0)] { - let d = moved(dx, dy); - let frame = r.render(&d, 200, 200, view, &live).pixels; - assert_eq!(r.stats.moved, 1, "the shadow moved by ({dx}, {dy})"); - assert!(frame == fresh(&d).pixels, "moved by ({dx}, {dy})"); - } - } - - /// While interacting, a raster moves with its object only when the content's pixels (for a - /// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under - /// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object, - /// art added beyond the old raster, a changed Feather outline and De-Interlace run the effects - /// again. A shadow keeps its raster under a new fill color. - #[test] - fn moved_rasters_need_the_same_content_and_effects() { - let _fonts = fonts_steady(); - let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); - let live = RenderOptions { interacting: true, ..still.clone() }; - // `before` drawn, then `after` while interacting: the rasters drawn moved, and whether the - // frame is what a fresh renderer draws. - let drag = |before: &Document, after: &Document| { - let mut r = Renderer::new(); - let _ = r.render(before, 200, 200, view, &still); - let frame = r.render(after, 200, 200, view, &live).pixels; - (r.stats.moved, frame == Renderer::new().render(after, 200, 200, view, &still).pixels) - }; - // `d`'s object moved 5, 3 (10, 6 pixels), then changed by `edit`. - let moved = |d: &Document, patterns: bool, edit: &dyn Fn(&mut Node)| { - let mut d = d.clone(); - let id = d.layers[0].children().unwrap()[0].id; - let n = d.node_mut(id).unwrap(); - n.transform(Affine::translate((5.0, 3.0)), vectorcraft_doc::Scaling { patterns, ..Default::default() }); - edit(n); - d - }; - let cracks = || vec![("texture.craquelure", json!({"crackSpacing": 6}))]; - let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), cracks()); - assert_eq!(drag(&base, &moved(&base, false, &|_| {})), (1, true), "moved"); - let recolor = |n: &mut Node| n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.1, 0.4, 0.9)); - assert_eq!(drag(&base, &moved(&base, false, &recolor)), (0, true), "recolored"); - let spacing = |n: &mut Node| n.appearance.effects[0].params = json!({"crackSpacing": 9}); - assert_eq!(drag(&base, &moved(&base, false, &spacing)), (0, true), "other effect settings"); - let resize = |n: &mut Node| n.transform(Affine::scale_about(1.5, vectorcraft_geom::Point::new(25.0, 23.0)), false); - assert_eq!(drag(&base, &moved(&base, false, &resize)), (0, true), "resized"); - // A square added beyond the old raster leaves the content inside it as it was. - let beyond = |n: &mut Node| { - if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind { - path.subpaths.extend(shapes::rectangle(Rect::new(75.0, 30.0, 80.0, 35.0)).subpaths); - } - }; - assert_eq!(drag(&base, &moved(&base, false, &beyond)), (0, true), "art added beyond the old raster"); - // At 72 ppi, De-Interlace works on lines 1 point tall. The object moves 3 points down, so - // De-Interlace makes its other field again. - let fields = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("video.deinterlace", json!({}))]); - assert_eq!(drag(&fields, &moved(&fields, false, &|_| {})), (0, true), "De-Interlace"); - // A pattern fill: its tiles stay on the page unless they move with the object. - let mut tiled = base.clone(); - let red = Node::path( - vectorcraft_doc::NodeId(900), - shapes::rectangle(Rect::new(0.0, 0.0, 3.0, 3.0)), - Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), - ); - let mut def = vectorcraft_doc::pattern::PatternDef::new("Dots", vec![std::sync::Arc::new(red)]); - def.tile = Rect::new(0.0, 0.0, 7.0, 7.0); - tiled.patterns.push(def); - let id = tiled.layers[0].children().unwrap()[0].id; - tiled.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = vectorcraft_doc::pattern::pattern_paint("Dots"); - assert_eq!(drag(&tiled, &moved(&tiled, false, &|_| {})), (0, true), "tiles that stay on the page"); - assert_eq!(drag(&tiled, &moved(&tiled, true, &|_| {})), (1, true), "tiles that move with the object"); - // Feather clips to the outline: a notch in the top edge where the tiles paint nothing - // (x 29 to 33 and y 20 to 24 once moved) leaves the content's pixels as they were. - let mut feathered = tiled.clone(); - feathered.node_mut(id).unwrap().appearance.effects = - vec![Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }]; - assert_eq!(drag(&feathered, &moved(&feathered, true, &|_| {})), (1, true), "feathered"); - let notch = |n: &mut Node| { - let mut bp = vectorcraft_geom::BezPath::new(); - bp.move_to((25.0, 23.0)); - for p in [(30.0, 23.0), (30.5, 23.6), (31.5, 23.6), (32.0, 23.0), (55.0, 23.0), (55.0, 49.0), (25.0, 49.0)] { - bp.line_to(p); - } - bp.close_path(); - if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind { - *path = vectorcraft_geom::PathData::from_bezpath(&bp); - } - }; - assert_eq!(drag(&feathered, &moved(&feathered, true, ¬ch)), (0, true), "a notch in the feathered outline"); - // A shadow is made from the silhouette's alpha: recolored, the object takes its shadow - // along; notched, the object makes its shadow again. - let shadowed = - doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2}))]); - assert_eq!(drag(&shadowed, &moved(&shadowed, false, &recolor)), (1, true), "a shadow under a new color"); - assert_eq!(drag(&shadowed, &moved(&shadowed, false, ¬ch)), (0, true), "a shadow under a notch"); - // A shadow with Darkness is made from the object's colors: recolored, it is made again. - let shaded = doc_with( - Rect::new(20.0, 20.0, 50.0, 46.0), - Color::rgb(0.9, 0.4, 0.1), - vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "shade": "darkness", "darkness": 50}))], - ); - assert_eq!(drag(&shaded, &moved(&shaded, false, &|_| {})), (1, true), "a shadow with Darkness, moved"); - assert_eq!(drag(&shaded, &moved(&shaded, false, &recolor)), (0, true), "a shadow with Darkness under a new color"); - } - - /// A raster made over moved rasters counts as moved itself (#1257): a group with a drop shadow - /// and Gaussian Blur of its own, whose filtered member moves by parts of a pixel while the - /// other member stays, draws after the drag what a fresh renderer draws. - #[test] - fn rasters_made_over_moved_rasters_count_as_moved() { - let mut d = Document::new(100.0, 100.0); - let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); - let (sid, mid, gid) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let still = Node::path(sid, shapes::rectangle(Rect::new(60.0, 60.0, 80.0, 80.0)), fill(Color::rgb(0.1, 0.5, 0.2))); - let mut member = Node::path(mid, shapes::rectangle(Rect::new(20.0, 20.0, 50.0, 46.0)), fill(Color::rgb(0.9, 0.4, 0.1))); - member.appearance.effects = vec![Effect { id: "texture.craquelure".into(), params: json!({"crackSpacing": 6}), visible: true }]; - let mut group = Node::group(gid, vec![std::sync::Arc::new(still), std::sync::Arc::new(member)]); - group.appearance.effects = vec![ - Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 3, "blur": 2}), visible: true }, - Effect { id: "blur.gaussian".into(), params: json!({"radius": 1}), visible: true }, - ]; - let l = d.layers[0].id; - d.insert(Some(l), 0, group).unwrap(); - let (view, opts) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); - let mut r = Renderer::new(); - let _ = r.render(&d, 200, 200, view, &opts); - let mut moved = d.clone(); - moved.node_mut(mid).unwrap().transform(Affine::translate((3.3, 1.2)), false); - let _ = r.render(&moved, 200, 200, view, &RenderOptions { interacting: true, ..opts.clone() }); - assert!(r.stats.moved > 0, "the member's filtered content moved"); - let after = r.render(&moved, 200, 200, view, &opts).pixels; - assert!(after == Renderer::new().render(&moved, 200, 200, view, &opts).pixels, "the frame after the drag"); - } - - /// Reproduce #1257's moving, filtered rectangle: the first frame renders as before a drag, - /// and the next eight render as the canvas renders a drag's frames (`interacting`). Then the - /// eighth position renders as the canvas renders the frame after the drag, and that frame - /// renders once more with every raster cached. - /// Run with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`. - #[test] - #[ignore = "manual timing probe; wall-clock performance depends on machine load"] - fn texture_drag_perf() { - for effect in ["texture.craquelure", "texture.mosaicTiles", "texture.stainedGlass"] { - let mut base = Document::new(600.0, 500.0); - base.color_mode = vectorcraft_doc::ColorMode::Cmyk; - let (layer, id) = (base.layers[0].id, base.alloc_id()); - let mut n = Node::path( - id, - shapes::rectangle(Rect::new(100.0, 100.0, 309.298, 370.938)), - Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::solid(Color::BLACK), 1.0), - ); - n.appearance.effects = vec![ - Effect { id: "stylize.roundCorners".into(), params: json!({"radius": 25.175}), visible: true }, - Effect { id: effect.into(), params: json!({}), visible: true }, - ]; - base.insert(Some(layer), 0, n).unwrap(); - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let live = RenderOptions { interacting: true, ..opts.clone() }; - let view = Affine::scale(1.9007 * 1.5); - let mut r = Renderer::new(); - r.threads = crate::default_threads(); - let (mut ms, mut doc) = (vec![], base.clone()); - for frame in 0..9 { - doc = base.clone(); - doc.node_mut(id).unwrap().transform(Affine::translate((3.0 + f64::from(frame) * 0.75, f64::from(frame) * 0.5)), false); - let start = std::time::Instant::now(); - let image = r.render(&doc, 1800, 1500, view, if frame > 0 { &live } else { &opts }); - let time = start.elapsed().as_secs_f64() * 1000.0; - if frame > 0 { - ms.push(time); - } - std::hint::black_box(image); - } - let mut time = |o: &RenderOptions| { - let start = std::time::Instant::now(); - std::hint::black_box(r.render(&doc, 1800, 1500, view, o)); - start.elapsed().as_secs_f64() * 1000.0 - }; - let (after, again) = (time(&opts), time(&opts)); - ms.sort_by(f64::total_cmp); - println!( - "{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}; the frame after the drag {after:.2} ms, that frame again {again:.2} ms", - (ms[3] + ms[4]) * 0.5 - ); - } - } - - /// Reproduce #2558's drag of a textured or blurred rectangle when zoomed in, in a 2560 × 1440 - /// view centered on the rectangle: the first frame renders as before a drag, and the next eight - /// render as a drag's frames, once with `interacting` alone and once with `dragging` as well (as - /// the canvas renders them). Then the eighth position renders as the canvas renders the frame - /// after the drag. The hashes of that frame and of a fresh renderer's frame compare builds. - /// Run with `cargo test -p vectorcraft-render zoomed_drag_perf -- --ignored --nocapture`. - #[test] - #[ignore = "manual timing probe; wall-clock performance depends on machine load"] - fn zoomed_drag_perf() { - let sets: [(&str, Vec<(&str, serde_json::Value)>); 3] = [ - ("Texturizer", vec![("texture.texturizer", json!({}))]), - ("Gaussian Blur 8 pt", vec![("blur.gaussian", json!({"radius": 8}))]), - ("Drop Shadow and Craquelure", vec![("stylize.dropShadow", json!({"blur": 7})), ("texture.craquelure", json!({}))]), - ]; - let hash = |px: &[u8]| { - use std::hash::{Hash, Hasher}; - let mut h = std::hash::DefaultHasher::new(); - px.hash(&mut h); - h.finish() - }; - for (name, effects) in sets { - for zoom in [1.0, 4.0, 16.0, 64.0] { - let base = doc_with(Rect::new(100.0, 100.0, 300.0, 250.0), Color::rgb(0.9, 0.4, 0.1), effects.clone()); - let id = base.layers[0].children().unwrap()[0].id; - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let view = Affine::translate((1280.0, 720.0)) * Affine::scale(zoom) * Affine::translate((-200.0, -175.0)); - for dragging in [false, true] { - let live = RenderOptions { interacting: true, dragging, ..opts.clone() }; - let mut r = Renderer::new(); - r.threads = crate::default_threads(); - let (mut ms, mut doc) = (vec![], base.clone()); - for frame in 0..9 { - doc = base.clone(); - let by = (f64::from(frame) * 0.75 / zoom, f64::from(frame) * 0.5 / zoom); - doc.node_mut(id).unwrap().transform(Affine::translate(by), false); - let start = std::time::Instant::now(); - std::hint::black_box(r.render(&doc, 2560, 1440, view, if frame > 0 { &live } else { &opts })); - if frame > 0 { - ms.push(start.elapsed().as_secs_f64() * 1000.0); - } - } - let previewed = r.stats.previewed; - let start = std::time::Instant::now(); - let still = r.render(&doc, 2560, 1440, view, &opts); - let after = start.elapsed().as_secs_f64() * 1000.0; - let fresh = Renderer::new().render(&doc, 2560, 1440, view, &opts); - ms.sort_by(f64::total_cmp); - println!( - "{name} at {:.0}%, dragging {dragging}: drag frames median {:.1} ms (min {:.1}, max {:.1}), {previewed} previewed; the frame after the drag {after:.1} ms; hashes {:016x} {:016x}", - zoom * 100.0, - (ms[3] + ms[4]) * 0.5, - ms[0], - ms[7], - hash(&still.pixels), - hash(&fresh.pixels) - ); - } - } - } - } - - #[test] - fn photoshop_filters_on_content_larger_than_the_budget() { - // Zoomed in 200×: the 40 pt square covers 8000 × 8000 px around a 200 × 200 view. - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let view = Affine::translate((100.0, 100.0)) * Affine::scale(200.0) * Affine::translate((-70.0, -50.0)); - for (id, p) in [("sharpen.unsharpMask", json!({"amount": 200})), ("blur.smart", json!({})), ("blur.radial", json!({"quality": "draft"}))] { - let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::rgb(0.2, 0.4, 0.8), vec![(id, p)]); - let img = Renderer::new().render(&d, 200, 200, view, &opts); - // The view shows the square's right side half way down: inside left, outside right. - assert!(lum(img.pixel(40, 100)) < 600, "{id}: inside {:?}", img.pixel(40, 100)); - // (Smart Blur's wide samples read a blurred copy: a faint fringe under its threshold.) - assert!(lum(img.pixel(190, 100)) >= WHITE - 3 * 25, "{id}: outside {:?}", img.pixel(190, 100)); - } - } -} +mod tests; diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs new file mode 100644 index 000000000..fd941dc2c --- /dev/null +++ b/crates/render/src/fx/tests.rs @@ -0,0 +1,1041 @@ +use serde_json::json; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node}; +use vectorcraft_geom::{Affine, Rect, shapes}; + +use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin}; +use crate::tests_fontchange::fonts_steady; +use crate::{RenderOptions, Rendered, Renderer}; + +/// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however +/// far the blur spreads at the zoom (it was up to 65535 pixels a side: 16 GB). +#[test] +fn a_wide_blur_at_a_high_zoom_is_filtered_within_the_pixel_budget() { + // A 100 pt square at 3200 % and at 6400 %, blurred by 1000 pt, in a 1600 × 1000 view. + for zoom in [32.0, 64.0] { + let reach = Rect::new(0.0, 0.0, 100.0 * zoom, 100.0 * zoom); + let (x0, y0, w, h, k) = filter_region(reach, (1600, 1000), 1000.0 * zoom).unwrap(); + assert!(f64::from(w) * f64::from(h) <= PIXEL_BUDGET, "{w} × {h}"); + assert!(k < 1.0 && x0 < 0.0 && y0 < 0.0); + // It still covers the view and the blur's spread around it, at that scale. + assert!(f64::from(w) / k >= 1600.0 + 1500.0 * zoom && f64::from(h) / k >= 1000.0 + 1500.0 * zoom); + } + // A small blur is filtered at full size, cropped to what can show. + assert_eq!(filter_region(Rect::new(10.0, 20.0, 110.0, 70.0), (1600, 1000), 4.0), Some((2.0, 12.0, 116, 66, 1.0))); + assert_eq!(filter_region(Rect::new(5000.0, 0.0, 5100.0, 100.0), (1600, 1000), 4.0), None, "off the view"); + assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0)); +} + +/// #2558: while dragging, content and a shadow's region over the drag's budget at their scale +/// render at a lower scale, within the budget; otherwise their scale stays as it is. +#[test] +fn a_drag_lowers_the_scale_of_rasters_over_its_budget() { + let d = drag_scale(true, (2560.0, 1440.0), 1.0).unwrap(); + assert!(d < 1.0 && (2560.0 * d) * (1440.0 * d) <= DRAG_PIXEL_BUDGET * (1.0 + 1e-9), "{d}"); + assert_eq!(drag_scale(false, (2560.0, 1440.0), 1.0), None, "not dragging"); + assert_eq!(drag_scale(true, (1000.0, 800.0), 1.0), None, "within the budget"); + // At a scale already lowered to 0.5, 1280 × 720 raster pixels: within the budget. + assert_eq!(drag_scale(true, (2560.0, 1440.0), 0.5), None); + // A 2000 × 1500 pixel square blurred by 20 pixels in a 1600 × 1000 view. + let reach = Rect::new(-200.0, -200.0, 1800.0, 1300.0); + let (x0, y0, w, h, k) = filter_region_within(reach, (1600, 1000), 20.0, DRAG_PIXEL_BUDGET).unwrap(); + assert!(f64::from(w) * f64::from(h) <= DRAG_PIXEL_BUDGET && k < 1.0, "{w} × {h} at {k}"); + // It covers the same pixels as the full-size region, at that scale. + let full = filter_region(reach, (1600, 1000), 20.0).unwrap(); + assert_eq!((x0, y0, full.4), (full.0, full.1, 1.0)); + assert!((f64::from(w) / k - f64::from(full.2)).abs() <= 1.0 / k && (f64::from(h) / k - f64::from(full.3)).abs() <= 1.0 / k); + let small = Rect::new(10.0, 20.0, 110.0, 70.0); + assert_eq!(filter_region_within(small, (1600, 1000), 4.0, DRAG_PIXEL_BUDGET), filter_region(small, (1600, 1000), 4.0)); +} + +/// The margin a raster cropped to the view is made again with keeps it within the pixel budget, +/// a pixel of rounding on each edge included, and within the side limit; it is at most half the +/// view's longer side, and 0 when the view and its spread alone fill the budget. +#[test] +fn the_margin_for_pans_stays_within_the_pixel_budget() { + for ((w, h), m, want) in [((2560.0, 1440.0), 32.0, 441.0), ((640.0, 360.0), 32.0, 320.0), ((3840.0, 2160.0), 32.0, 0.0)] { + let e = pan_margin((w, h), m, PIXEL_BUDGET, 8192.0); + assert_eq!(e, want, "{w} × {h}"); + let (rw, rh) = (w + 2.0 * (m + e) + 2.0, h + 2.0 * (m + e) + 2.0); + assert!(e == 0.0 || rw * rh <= PIXEL_BUDGET, "{w} × {h}: {rw} × {rh}"); + } + assert_eq!(pan_margin((2000.0, 10.0), 0.0, 1e9, 3000.0), 498.0, "within the side limit"); + assert_eq!(pan_margin((2000.0, 10.0), f64::NAN, PIXEL_BUDGET, 8192.0), 0.0); +} + +fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let mut n = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)); + n.appearance.effects = effects.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn lum(p: [u8; 4]) -> u32 { + p[0] as u32 + p[1] as u32 + p[2] as u32 +} + +const WHITE: u32 = 765; + +/// A page of objects with object-level and item-level geometry effects (Offset Path, Roughen, +/// Warp, Outline Stroke, Transform) under drop shadows and in groups. +fn effects_page() -> Document { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true }; + for i in 0..24 { + let (x, y) = (10.0 + (i % 6) as f64 * 30.0, 10.0 + (i / 6) as f64 * 45.0); + let id = d.alloc_id(); + let r = Rect::new(x, y, x + 20.0, y + 30.0); + let shape = if i % 2 == 0 { shapes::ellipse(r) } else { shapes::star(r.center(), 12.0, 5.0, 5, 0.0) }; + let mut n = Node::path(id, shape, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::solid(Color::BLACK), 1.5)); + n.appearance.effects = match i % 4 { + 0 => vec![fx("path.offsetPath", json!({"offset": 3}))], + 1 => vec![fx("distort.roughen", json!({"size": 4, "relative": true})), fx("stylize.dropShadow", json!({}))], + 2 => vec![fx("warp.arc", json!({"bend": 30})), fx("path.outlineStroke", json!({}))], + _ => vec![fx("path.offsetPath", json!({"offset": -2})), fx("distort.transform", json!({"moveH": 4, "rotate": 10}))], + }; + if let Some(f) = n.appearance.fill_mut() { + f.effects.push(fx("path.offsetPath", json!({"offset": 2}))); + } + d.insert(Some(l), usize::MAX, n).unwrap(); + } + d +} + +/// Cached live-effect geometry draws exactly what computing it afresh draws: a second (warm) +/// frame, a frame after panning, and a fresh renderer all give the same pixels, and an edited +/// object (a new node) draws its new geometry, not the cached one. +#[test] +fn cached_effect_geometry_draws_the_same_pixels() { + let d = effects_page(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::scale(2.0); + let mut r = Renderer::new(); + let cold = r.render(&d, 400, 400, view, &opts); + let warm = r.render(&d, 400, 400, view, &opts); + assert!(cold.pixels == warm.pixels, "a warm frame differs from the cold one"); + let _ = r.render(&d, 400, 400, Affine::translate((15.0, 0.0)) * view, &opts); + assert!(r.render(&d, 400, 400, view, &opts).pixels == cold.pixels, "after a pan"); + assert!(Renderer::new().render(&d, 400, 400, view, &opts).pixels == cold.pixels, "a fresh renderer"); + // Edit one object's effect: the warm renderer must draw the new result. + let mut e = d.clone(); + let id = e.layers[0].children().unwrap()[0].id; + e.node_mut(id).unwrap().appearance.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 9}), visible: true }]; + let edited = r.render(&e, 400, 400, view, &opts); + assert!(edited.pixels != cold.pixels, "the edit shows"); + assert!(edited.pixels == Renderer::new().render(&e, 400, 400, view, &opts).pixels, "and matches a fresh render"); +} + +/// Shadow rasters of objects in the tree are matched by the node's `Arc` (kept alive), not by +/// comparing nodes; over the byte budget the least recently drawn go first. +#[test] +fn shadow_rasters_keep_their_node_and_a_byte_budget() { + let d = effects_page(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 400, 400, Affine::scale(2.0), &opts); + assert!(!r.shadows.is_empty() && r.shadows.values().all(|e| matches!(e.node, Drawn::Shared(_)))); + // A frame of other objects makes rasters of its own; the older ones go first. + let _ = r.render(&effects_page(), 400, 400, Affine::scale(2.0), &opts); + let newest = r.stamp; + let bytes = |r: &Renderer| r.shadows.values().map(|e| e.bytes()).sum::(); + let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| e.bytes()).sum::(); + assert!(bytes(&r) > latest); + r.trim_fx_rasters(latest); + assert_eq!(bytes(&r), latest); + assert!(r.shadows.values().all(|e| e.stamp == newest)); + assert!(r.render(&d, 400, 400, Affine::scale(2.0), &opts).pixels == first.pixels, "made again alike"); +} + +/// An Outer Glow whose whole reach is over the budget is made coarser over that whole reach and +/// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole +/// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes, +/// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel +/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and +/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow +/// round the page at 800% (scale 3, near the narrowest blur made coarser). +#[test] +fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { + let size = (640, 360); + for (rect, blur, zoom, center, scale) in [ + (Rect::new(200.0, 300.0, 400.0, 500.0), 100.0, 5.0, (440.0, 400.0), 2.0), + (Rect::new(0.0, 0.0, 595.0, 842.0), 2.0, 8.0, (595.0, 421.0), 3.0), + ] { + let mut d = Document::new(595.0, 842.0); + let layer = d.layers[0].id; + let mut n = + Node::path(d.alloc_id(), shapes::rectangle(rect), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0)); + n.appearance.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"blur": blur}), visible: true }]; + d.insert(Some(layer), 0, n).unwrap(); + let base = Affine::translate((320.0, 180.0)) * Affine::scale(zoom) * Affine::translate((-center.0, -center.1)); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + r.threads = 2; + r.render(&d, size.0, size.1, base, &opts); + let image = |r: &Renderer| r.shadows.values().map(|e| std::sync::Arc::as_ptr(&e.image)).collect::>(); + let kept = image(&r); + assert!(r.shadows.values().all(|e| e.scale == scale && e.view.is_none()), "blur {blur}: coarser, over the whole reach"); + for pan in [(37.0, -21.0), (1.0, 0.0), (-3.0, 5.0), (0.5, 0.0), (37.3, -21.6)] { + let xf = Affine::translate(pan) * base; + let panned = r.render(&d, size.0, size.1, xf, &opts); + assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn"); + let mut fresh = Renderer::new(); + fresh.threads = 2; + assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); + } + } +} + +/// The multithreaded rasterizer draws the same pixels with any number of workers, so the +/// automatic thread count can follow the machine. +#[test] +fn pixels_do_not_depend_on_the_thread_count() { + let mut d = effects_page(); + // Overlapping translucent and blended objects on top of the effects. + let l = d.layers[0].id; + for i in 0..400 { + let id = d.alloc_id(); + let (x, y) = ((i * 37 % 190) as f64, (i * 53 % 190) as f64); + let mut n = Node::path( + id, + shapes::ellipse(Rect::new(x, y, x + 14.0, y + 9.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.3, 0.1)), Paint::solid(Color::BLACK), 0.7), + ); + n.opacity = 0.6; + d.insert(Some(l), usize::MAX, n).unwrap(); + } + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut frames = vec![]; + // The test sets each renderer's count. `set_default_threads` would also change the count of the + // renderers that other tests create while this one runs. + for threads in [0, 1, 4, 8] { + let mut r = Renderer::new(); + r.threads = threads; + frames.push(r.render(&d, 1200, 900, Affine::scale(6.0), &opts).pixels); + } + assert!(frames.windows(2).all(|w| w[0] == w[1]), "the thread count changed pixels"); +} + +#[test] +fn thumbnails_of_glowing_objects_render_without_panicking() { + // Regression: thumbnails used vello's default (multithreaded) context, which panics on filters. + let d = doc_with( + Rect::new(20.0, 20.0, 60.0, 60.0), + Color::BLACK, + vec![("stylize.outerGlow", json!({"color": "#ff0000", "opacity": 100, "blur": 6}))], + ); + let id = d.layers[0].children().unwrap()[0].id; + let t = Renderer::new().render_thumbnail(&d, id, 48).unwrap(); + assert_eq!((t.width, t.height), (48, 48)); +} + +#[test] +fn per_stroke_raster_effects_use_the_single_threaded_pipeline() { + let mut d = doc_with(Rect::new(20.0, 20.0, 60.0, 60.0), Color::BLACK, vec![]); + let l = d.layers[0].id; + let id = d.layers[0].children().unwrap()[0].id; + let mut n = (*d.node(id).unwrap()).clone(); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0).into(); + if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = + n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))) + { + s.effects = + vec![Effect { id: "stylize.outerGlow".into(), params: json!({"color": "#ff0000", "opacity": 100, "blur": 6}), visible: true }]; + } + d.remove(id).unwrap(); + d.insert(Some(l), 0, n).unwrap(); + let mut r = Renderer::new(); + r.threads = 4; + let _ = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); +} + +#[test] +fn drop_shadow_darkens_outside_the_shape() { + let r = Rect::new(20.0, 20.0, 60.0, 60.0); + let plain = render(&doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![])); + assert_eq!(lum(plain.pixel(66, 66)), WHITE); + let d = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 2, "opacity": 100}))]); + let img = render(&d); + assert!(lum(img.pixel(66, 66)) < 200, "shadow below-right: {:?}", img.pixel(66, 66)); + // The object stays on top and unshadowed; the far side has no shadow. + assert_eq!(img.pixel(40, 40), [255, 0, 0, 255]); + assert_eq!(lum(img.pixel(15, 15)), WHITE); +} + +/// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and +/// right; `shade` "color" or "darkness". +fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document { + let mut d = doc_with( + Rect::new(20.0, 20.0, 60.0, 60.0), + fill, + vec![( + "stylize.dropShadow", + json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal", "shade": shade, "darkness": darkness}), + )], + ); + d.color_mode = mode; + d +} + +/// Darkness: the shadow is the object's own color, each channel times 1 − darkness in an RGB +/// document (the reference app's guide gives the object's color at 0%, +/// page 428), in the cached +/// raster and, at 6000 %, in the filter layer that a raster too large to cache is drawn with. +#[test] +fn drop_shadow_darkness_darkens_the_objects_own_color() { + let rgb = vectorcraft_doc::ColorMode::Rgb; + let red = Color::rgb(1.0, 0.0, 0.0); + // At 6000 %, the view's center is (65, 65), where only the shadow lies. + let zoomed = Affine::translate((50.0, 50.0)) * Affine::scale(60.0) * Affine::translate((-65.0, -65.0)); + for (darkness, want) in [(0.0, 255), (50.0, 128), (100.0, 0)] { + let d = shaded_doc(rgb, red, "darkness", darkness); + let px = render(&d).pixel(66, 66); + assert!(px[0].abs_diff(want) <= 1 && px[1] == 0 && px[2] == 0, "{darkness}%: {px:?}"); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let px = Renderer::new().render(&d, 100, 100, zoomed, &opts).pixel(50, 50); + assert!(px[0].abs_diff(want) <= 1 && px[1] == 0 && px[2] == 0, "{darkness}% at 6000 %: {px:?}"); + } +} + +/// In a CMYK document Darkness adds black ink and keeps the object's cyan, magenta and yellow +/// (same page of the guide: at 100% an object with colors other than black gets a shadow of those colors under +/// full black, and a black object's shadow is 100% black). +#[test] +fn drop_shadow_darkness_adds_black_in_a_cmyk_document() { + let cmyk = vectorcraft_doc::ColorMode::Cmyk; + let inks = |d: &Document| { + let i = Renderer::new().render_region_inks(d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, &RenderOptions::default()); + let at = (66 * 100 + 66) * 4; + [i[at], i[at + 1], i[at + 2], i[at + 3]] + }; + let red = Color::cmyk(0.0, 1.0, 1.0, 0.0); + for (darkness, k) in [(0.0, 0), (50.0, 128), (100.0, 255)] { + let got = inks(&shaded_doc(cmyk, red, "darkness", darkness)); + assert!(got[0] == 0 && got[1] == 255 && got[2] == 255 && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}"); + } + // Half black ink, darkened by half: K 50 + 50 × 50% = K 75. + let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 0.5), "darkness", 50.0)); + assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(191) <= 1, "{got:?}"); + let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 1.0), "darkness", 100.0)); + assert_eq!(got, [0, 0, 0, 255]); +} + +/// Color stays the default: a shadow without the new keys, and one with Color chosen and a +/// Darkness value, draw the same pixels, which a shadow with Darkness does not. +#[test] +fn a_drop_shadow_with_color_draws_as_before() { + let rgb = vectorcraft_doc::ColorMode::Rgb; + let red = Color::rgb(1.0, 0.0, 0.0); + let old = doc_with( + Rect::new(20.0, 20.0, 60.0, 60.0), + red, + vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))], + ); + let before = render(&old).pixels; + assert_eq!(render(&shaded_doc(rgb, red, "color", 40.0)).pixels, before); + assert_ne!(render(&shaded_doc(rgb, red, "darkness", 40.0)).pixels, before); +} + +#[test] +fn shadow_of_offscreen_object_is_not_culled() { + let d = doc_with( + Rect::new(-60.0, 20.0, -5.0, 60.0), + Color::WHITE, + vec![("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 100}))], + ); + let img = render(&d); + let row: Vec = (0..10).map(|i| lum(img.pixel(i * 10, 40))).collect(); + assert!(lum(img.pixel(10, 40)) < 100, "{row:?}"); +} + +#[test] +fn geometry_effect_is_rendered() { + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let d = doc_with(r, Color::BLACK, vec![("path.offsetPath", json!({"offset": 10}))]); + let img = render(&d); + assert_eq!(lum(img.pixel(25, 50)), 0, "offset grows the fill"); + let d = doc_with(r, Color::BLACK, vec![("distort.transform", json!({"moveH": -25}))]); + let img = render(&d); + assert_eq!(lum(img.pixel(10, 50)), 0); + assert_eq!(lum(img.pixel(60, 50)), WHITE); +} + +#[test] +fn per_fill_effects_apply_to_that_fill_only() { + let mut d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![]); + let id = d.layers[0].children().unwrap()[0].id; + let n = d.node_mut(id).unwrap(); + n.appearance.fill_mut().unwrap().effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 20}), visible: true }); + let img = render(&d); + assert_eq!(lum(img.pixel(85, 50)), 0); + assert_eq!(lum(img.pixel(40, 50)), WHITE); +} + +#[test] +fn blur_glow_and_feather_soften() { + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let blur = render(&doc_with(r, Color::BLACK, vec![("blur.gaussian", json!({"radius": 6}))])); + let edge = lum(blur.pixel(29, 50)); + assert!(edge > 0 && edge < WHITE, "blur spreads over the edge: {edge}"); + let glow = + render(&doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))])); + let g = glow.pixel(27, 50); + assert!(g[0] > g[1] + 20, "outer glow is red: {g:?}"); + let inner = + render(&doc_with(r, Color::BLACK, vec![("stylize.innerGlow", json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4}))])); + assert!(lum(inner.pixel(31, 50)) > 60, "inner glow lightens the edge: {:?}", inner.pixel(31, 50)); + assert!(lum(inner.pixel(50, 50)) < 30, "centre stays dark"); + let feather = render(&doc_with(r, Color::BLACK, vec![("stylize.feather", json!({"radius": 8}))])); + assert!(lum(feather.pixel(31, 50)) > 20, "feather fades the edge inward"); + assert_eq!(lum(feather.pixel(26, 50)), WHITE, "feather stays inside"); +} + +#[test] +fn an_outer_glow_blurs_a_solid_silhouette_of_faint_art() { + // GUARDS: the glow's strength comes from where the art is, not how opaque it is (a glow round a glow relies on it). + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let glow = |fill_opacity: f32| { + let mut d = + doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); + let id = d.layers[0].children().unwrap()[0].id; + d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity; + render(&d).pixel(29, 50) + }; + let (faint, opaque) = (glow(0.2), glow(1.0)); + assert_eq!(faint[1], opaque[1], "faint {faint:?}, opaque {opaque:?}"); + assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}"); +} + +#[test] +fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() { + // GUARDS: the glows AI files export measure as the solid silhouette grown by a quarter of a pixel + // of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720. + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let green_outside = |ppi: f64| { + let mut d = + doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); + d.raster_effects_ppi = ppi; + render(&d).pixel(27, 50)[1] + }; + let (coarse, fine) = (green_outside(72.0), green_outside(720.0)); + assert!(coarse + 3 < fine, "the red glow is stronger at 72 ppi: 72 ppi {coarse}, 720 ppi {fine}"); +} + +#[test] +fn a_glow_glows_round_the_glows_before_it_in_the_stack() { + // GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it. + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let d = doc_with( + r, + Color::BLACK, + vec![ + ("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 8})), + ("stylize.outerGlow", json!({"color": "#0000ff", "mode": "normal", "opacity": 100, "blur": 2})), + ], + ); + let p = render(&d).pixel(22, 50); + assert!(p[2] > p[0] + 100, "8 pt out, the tight blue glow round the red one shows: {p:?}"); +} + +#[test] +fn a_glow_too_large_for_a_full_size_raster_keeps_its_solid_silhouette() { + // GUARDS: at 40× the glow's raster passes the 4 Mpx budget and is made coarser instead of falling back to the + // filter layer, which blurs the faint alpha. + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::translate((-1100.0, -1900.0)) * Affine::scale(40.0); + let glow = |fill_opacity: f32| { + let mut d = + doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); + let id = d.layers[0].children().unwrap()[0].id; + d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity; + Renderer::new().render(&d, 200, 200, view, &opts).pixel(60, 100) + }; + let (faint, opaque) = (glow(0.2), glow(1.0)); + assert!(faint[1].abs_diff(opaque[1]) <= 2, "faint {faint:?}, opaque {opaque:?}"); + assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}"); +} + +#[test] +fn thirty_stacked_glows_draw_quickly_cached_or_not() { + // GUARDS: each glow is of the content with the glows before it; drawing those again for every glow costs 2^n. + // A shape draws through the shadow cache, a symbol instance without it (the filter layers). + let glows: Vec<(&str, serde_json::Value)> = + (0..30).map(|k| ("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 20, "blur": 1 + k % 4}))).collect(); + let shape = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, glows.clone()); + let mut instanced = Document::new(100.0, 100.0); + let art = Node::path( + instanced.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + instanced.symbols.push(vectorcraft_doc::Symbol { name: "Square".into(), art: std::sync::Arc::new(art) }); + let mut instance = Node::new( + instanced.alloc_id(), + vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((30.0, 30.0)) }, + ); + instance.appearance.effects = glows.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + let l = instanced.layers[0].id; + instanced.insert(Some(l), 0, instance).unwrap(); + for d in [&shape, &instanced] { + let start = std::time::Instant::now(); + let p = render(d).pixel(25, 50); + assert!(start.elapsed() < std::time::Duration::from_secs(20), "{:?}", start.elapsed()); + assert!(p[1] < 250, "the glows show: {p:?}"); + } +} + +#[test] +fn hidden_effects_are_ignored_and_outline_mode_uses_effected_geometry() { + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let mut d = doc_with(r, Color::BLACK, vec![("distort.transform", json!({"moveH": -25}))]); + let id = d.layers[0].children().unwrap()[0].id; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), outline: true, ..Default::default() }; + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); + assert!(lum(img.pixel(5, 50)) < 400, "outline follows the moved path"); + d.node_mut(id).unwrap().appearance.effects[0].visible = false; + let img = render(&d); + assert_eq!(lum(img.pixel(50, 50)), 0); + assert_eq!(lum(img.pixel(10, 50)), WHITE); +} + +#[test] +fn visual_bounds_include_shadow() { + let d = doc_with(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK, vec![("stylize.dropShadow", json!({"x": 7, "y": 7, "blur": 5}))]); + let n = &d.layers[0].children().unwrap()[0]; + let b = super::visual_bounds(&d.symbol_boxes(), n).unwrap(); + assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9); +} + +/// Multithreaded rendering filters each effect offscreen; it must match the single-threaded +/// reference (blend modes against the real backdrop, objects partly off-screen, all effects). +#[test] +fn multithreaded_filters_match_single_threaded() { + let cases: Vec<(Rect, Vec<(&str, serde_json::Value)>)> = vec![ + (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.dropShadow", json!({"x": 6, "y": 4, "blur": 5, "opacity": 75}))]), + (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.outerGlow", json!({"color": "#ff00aa", "blur": 8, "opacity": 90}))]), + ( + Rect::new(20.0, 20.0, 60.0, 60.0), + vec![ + ("stylize.innerGlow", json!({"blur": 6})), + ("stylize.innerGlow", json!({"blur": 4, "source": "center", "mode": "multiply", "color": "#0044ff"})), + ], + ), + (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.feather", json!({"radius": 6}))]), + (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("blur.gaussian", json!({"radius": 4})), ("stylize.dropShadow", json!({"blur": 3}))]), + // Partly outside the frame: the blur still spreads in from off-screen content. + ( + Rect::new(-30.0, 70.0, 30.0, 130.0), + vec![("stylize.outerGlow", json!({"blur": 12, "mode": "normal"})), ("blur.gaussian", json!({"radius": 3}))], + ), + ]; + for (r, fx) in cases { + let name = format!("{fx:?}"); + let mut d = doc_with(r, Color::rgb(0.2, 0.6, 0.3), fx); + // A coloured backdrop so blend modes have something to blend with. + let l = d.layers[0].id; + let bid = d.alloc_id(); + let bg = Node::path( + bid, + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.5, 0.1)), Paint::None, 0.0), + ); + d.insert(Some(l), 0, bg).unwrap(); + let opts = RenderOptions::default(); + let mut st = Renderer::new(); + st.threads = 0; + let mut mt = Renderer::new(); + mt.threads = 3; + let a = st.render(&d, 100, 100, Affine::IDENTITY, &opts); + let b = mt.render(&d, 100, 100, Affine::IDENTITY, &opts); + let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap(); + // The offscreen pass adds one 8-bit premultiplied round trip: a few levels of rounding. + assert!(worst <= 4, "{name}: max channel difference {worst}"); + } +} + +#[test] +fn photoshop_filters_draw_and_follow_a_pan_from_the_cache() { + let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![("blur.radial", json!({"amount": 40, "quality": "draft"}))]); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert!(lum(a.pixel(31, 31)) > 60, "the spin softens the corners: {:?}", a.pixel(31, 31)); + assert_eq!(lum(a.pixel(50, 50)), 0, "the centre stays"); + assert!(lum(a.pixel(50, 28)) < WHITE, "turned corners reach past the sides"); + // Panned: the cached raster moves with the view. + let b = r.render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts); + for (x, y) in [(31, 31), (50, 28), (69, 50), (40, 66)] { + assert_eq!(a.pixel(x, y), b.pixel(x + 10, y + 3), "({x}, {y})"); + } + // A fresh renderer draws the panned view the same. + let c = Renderer::new().render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts); + assert_eq!(b.pixels, c.pixels); +} + +/// A blurred image that can't be decoded: every frame that draws it counts the failure +/// ([`crate::paint::decode_failures`]), from a new raster or from the raster kept. +#[test] +fn cached_effect_rasters_count_the_images_they_could_not_decode() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + let mut d = Document::new(100.0, 100.0); + // A PNG signature with nothing after it. + d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = ImageObject { + key: "cut".into(), + width: 40, + height: 40, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + n.appearance.effects = vec![Effect { id: "blur.gaussian".into(), params: json!({"radius": 4.0}), visible: true }]; + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + for frame in 0..3 { + let before = crate::paint::decode_failures(); + r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert!(crate::paint::decode_failures() > before, "frame {frame}"); + } + assert_eq!(r.pixel_fx.len(), 1, "the later frames draw from the raster kept"); +} + +/// An image that can't be decoded, with Rasterize, a drop shadow and a blur: the shadow draws +/// the Rasterize image first, and the blurred content draws the image kept from that draw +/// ([`super::Kept`]). Every frame counts the failure, also when the shadow and the blurred +/// content are drawn from the rasters kept. +#[test] +fn rasters_drawn_from_a_kept_rasterize_image_count_the_images_it_could_not_decode() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + let mut d = Document::new(100.0, 100.0); + // A PNG signature with nothing after it. + d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = ImageObject { + key: "cut".into(), + width: 40, + height: 40, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + n.appearance.effects = [ + ("rasterize", json!({"background": "white"})), + ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})), + ("blur.gaussian", json!({"radius": 4.0})), + ] + .into_iter() + .map(|(id, params)| Effect { id: id.into(), params, visible: true }) + .collect(); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + for frame in 0..3 { + let before = crate::paint::decode_failures(); + r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert!(crate::paint::decode_failures() > before, "frame {frame}"); + } + assert_eq!((r.shadows.len(), r.pixel_fx.len()), (1, 1), "the later frames draw from the rasters kept"); +} + +/// While interacting, an object that only moved draws its drop shadow and filtered content +/// from the rasters of an earlier frame, at the nearest whole pixel, without running the +/// effects again (#1257). The frame after the interaction runs them again: it is what a fresh +/// renderer draws. +#[test] +fn moved_objects_draw_their_effect_rasters_while_interacting() { + let effects = + vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "opacity": 80})), ("texture.craquelure", json!({"crackSpacing": 6}))]; + let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), effects); + let id = base.layers[0].children().unwrap()[0].id; + // The object moved from where it started, as each frame of a drag moves it. + let moved = |dx: f64, dy: f64| { + let mut d = base.clone(); + d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false); + d + }; + let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let live = RenderOptions { interacting: true, ..still.clone() }; + let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still).pixels; + let mut r = Renderer::new(); + assert!(r.render(&base, 200, 200, view, &still).pixels == fresh(&base)); + assert_eq!(r.stats.moved, 0); + let (mut d, mut last) = (base.clone(), vec![]); + for (dx, dy) in [(3.0, 0.0), (3.3, 1.2), (5.15, 2.5), (6.3, 2.2)] { + d = moved(dx, dy); + last = r.render(&d, 200, 200, view, &live).pixels; + assert_eq!(r.stats.moved, 2, "the shadow and the filtered content moved by ({dx}, {dy})"); + // At the nearest whole pixel (2 a point): what a fresh renderer draws there. + let whole = moved((dx * 2.0).round() / 2.0, (dy * 2.0).round() / 2.0); + assert!(last == fresh(&whole), "moved by ({dx}, {dy})"); + } + // The frame after the drag, of the same document: the effects run again where the object is. + let after = r.render(&d, 200, 200, view, &still).pixels; + assert_eq!(r.stats.moved, 0); + assert!(after == fresh(&d), "the frame after the drag"); + assert!(after != last, "6.3 is not on a whole pixel"); +} + +/// While dragging (#2558), filtered content and a drop shadow that the frame does not draw from a +/// raster of an earlier frame render within the drag's budget and keep no raster. At 6400% a +/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so the still +/// frame keeps rasters cropped to its view, which the moved rectangle can't take along, and a +/// 1600 × 1000 view near its corner needs more than the drag's budget. The +/// frame after the drag, of the same document, is what a fresh renderer draws, as is a frame +/// rendered while interacting without a drag (a dialog's preview). +#[test] +fn a_drag_renders_raster_effects_within_the_drag_budget() { + let effects = vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 7, "opacity": 80})), ("texture.texturizer", json!({}))]; + let base = doc_with(Rect::new(20.0, 20.0, 80.0, 76.0), Color::rgb(0.9, 0.4, 0.1), effects); + let id = base.layers[0].children().unwrap()[0].id; + let mut d = base.clone(); + d.node_mut(id).unwrap().transform(Affine::translate((0.3, 0.2)), false); + // The view is centered 10 pt inside the rectangle's right and bottom edges, and shows the + // shadow beyond the right edge. + let view = Affine::translate((800.0, 500.0)) * Affine::scale(64.0) * Affine::translate((-70.0, -66.0)); + let still = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let live = RenderOptions { interacting: true, ..still.clone() }; + let drag = RenderOptions { dragging: true, ..live.clone() }; + // Every renderer here has 3 threads on any machine. A drag renders drop shadows within its + // budget only with `threads` above 0. + let threaded = || { + let mut r = Renderer::new(); + r.threads = 3; + r + }; + let fresh = threaded().render(&d, 1600, 1000, view, &still).pixels; + let mut r = threaded(); + let _ = r.render(&base, 1600, 1000, view, &still); + assert_eq!(r.stats.previewed, 0); + let dragged = r.render(&d, 1600, 1000, view, &drag).pixels; + assert_eq!(r.stats.previewed, 2, "the shadow and the filtered content"); + assert_eq!(r.stats.moved, 2, "counted as moved: the frame after the drag renders them again"); + assert!(r.pixel_fx.is_empty() && r.shadows.values().all(|e| e.stamp < r.stamp), "no raster kept"); + assert!(dragged != fresh, "at the drag's lower resolution"); + let after = r.render(&d, 1600, 1000, view, &still).pixels; + assert_eq!((r.stats.previewed, r.stats.moved), (0, 0)); + assert!(after == fresh, "the frame after the drag"); + assert!(r.render(&d, 1600, 1000, view, &live).pixels == fresh, "interacting without a drag"); + assert_eq!(r.stats.previewed, 0); + // A drop shadow of about 2 million pixels at 4000%, within the shadow cache's limit: scaled + // by a drag, it can't take its raster along, and renders within the drag's budget. + let shadowed = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"blur": 2}))]); + let mut scaled = shadowed.clone(); + let id = scaled.layers[0].children().unwrap()[0].id; + scaled.node_mut(id).unwrap().transform(Affine::scale_about(1.05, vectorcraft_geom::Point::new(35.0, 33.0)), false); + let view = Affine::translate((800.0, 500.0)) * Affine::scale(40.0) * Affine::translate((-35.0, -33.0)); + let mut r = threaded(); + let _ = r.render(&shadowed, 1600, 1000, view, &still); + let kept = r.shadows.len(); + assert_eq!((kept, r.stats.previewed), (1, 0), "a still frame keeps the shadow's raster"); + let _ = r.render(&scaled, 1600, 1000, view, &drag); + assert_eq!((r.shadows.len(), r.stats.previewed), (kept, 1), "the scaled shadow"); + let after = r.render(&scaled, 1600, 1000, view, &still).pixels; + assert!(after == threaded().render(&scaled, 1600, 1000, view, &still).pixels, "the frame after the drag"); +} + +/// While interacting, a group holding a symbol instance whose art lies outside the 20 × 20 +/// square around the origin draws its drop shadow from the raster of an earlier frame. The +/// shadow is made from the instance's art, which the renderer measures the group's content by. +#[test] +fn a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting() { + let _fonts = fonts_steady(); + let mut base = Document::new(100.0, 100.0); + let orange = Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0); + let art = Node::path(base.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 130.0, 126.0)), orange); + base.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: std::sync::Arc::new(art) }); + // The instance draws the art from (20, 20) to (50, 46), and the 20 × 20 square around the + // origin from (-90, -90) to (-70, -70). + let xf = Affine::translate((-80.0, -80.0)); + let instance = Node::new(base.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Far".into(), xf }); + let mut group = Node::group(base.alloc_id(), vec![std::sync::Arc::new(instance)]); + let shadow = json!({"x": 4, "y": 3, "blur": 2, "opacity": 80}); + group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }); + let (id, l) = (group.id, base.layers[0].id); + base.insert(Some(l), 0, group).unwrap(); + // The group moved from where it started, as each frame of a drag moves it. + let moved = |dx: f64, dy: f64| { + let mut d = base.clone(); + d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false); + d + }; + let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let live = RenderOptions { interacting: true, ..still.clone() }; + let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still); + let mut r = Renderer::new(); + let first = r.render(&base, 200, 200, view, &still); + assert!(first.pixels == fresh(&base).pixels); + // The shadow, 4 pt right of the art's right edge at x 50, at (104, 70) in pixels. + assert!(lum(first.pixel(104, 70)) < WHITE, "{:?}", first.pixel(104, 70)); + // Moves on whole pixels (2 a point): the moved shadow lies where a fresh renderer draws it. + for (dx, dy) in [(3.0, 0.0), (5.5, 2.0)] { + let d = moved(dx, dy); + let frame = r.render(&d, 200, 200, view, &live).pixels; + assert_eq!(r.stats.moved, 1, "the shadow moved by ({dx}, {dy})"); + assert!(frame == fresh(&d).pixels, "moved by ({dx}, {dy})"); + } +} + +/// While interacting, a raster moves with its object only when the content's pixels (for a +/// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under +/// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object, +/// art added beyond the old raster, a changed Feather outline and De-Interlace run the effects +/// again. A shadow keeps its raster under a new fill color. +#[test] +fn moved_rasters_need_the_same_content_and_effects() { + let _fonts = fonts_steady(); + let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let live = RenderOptions { interacting: true, ..still.clone() }; + // `before` drawn, then `after` while interacting: the rasters drawn moved, and whether the + // frame is what a fresh renderer draws. + let drag = |before: &Document, after: &Document| { + let mut r = Renderer::new(); + let _ = r.render(before, 200, 200, view, &still); + let frame = r.render(after, 200, 200, view, &live).pixels; + (r.stats.moved, frame == Renderer::new().render(after, 200, 200, view, &still).pixels) + }; + // `d`'s object moved 5, 3 (10, 6 pixels), then changed by `edit`. + let moved = |d: &Document, patterns: bool, edit: &dyn Fn(&mut Node)| { + let mut d = d.clone(); + let id = d.layers[0].children().unwrap()[0].id; + let n = d.node_mut(id).unwrap(); + n.transform(Affine::translate((5.0, 3.0)), vectorcraft_doc::Scaling { patterns, ..Default::default() }); + edit(n); + d + }; + let cracks = || vec![("texture.craquelure", json!({"crackSpacing": 6}))]; + let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), cracks()); + assert_eq!(drag(&base, &moved(&base, false, &|_| {})), (1, true), "moved"); + let recolor = |n: &mut Node| n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.1, 0.4, 0.9)); + assert_eq!(drag(&base, &moved(&base, false, &recolor)), (0, true), "recolored"); + let spacing = |n: &mut Node| n.appearance.effects[0].params = json!({"crackSpacing": 9}); + assert_eq!(drag(&base, &moved(&base, false, &spacing)), (0, true), "other effect settings"); + let resize = |n: &mut Node| n.transform(Affine::scale_about(1.5, vectorcraft_geom::Point::new(25.0, 23.0)), false); + assert_eq!(drag(&base, &moved(&base, false, &resize)), (0, true), "resized"); + // A square added beyond the old raster leaves the content inside it as it was. + let beyond = |n: &mut Node| { + if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind { + path.subpaths.extend(shapes::rectangle(Rect::new(75.0, 30.0, 80.0, 35.0)).subpaths); + } + }; + assert_eq!(drag(&base, &moved(&base, false, &beyond)), (0, true), "art added beyond the old raster"); + // At 72 ppi, De-Interlace works on lines 1 point tall. The object moves 3 points down, so + // De-Interlace makes its other field again. + let fields = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("video.deinterlace", json!({}))]); + assert_eq!(drag(&fields, &moved(&fields, false, &|_| {})), (0, true), "De-Interlace"); + // A pattern fill: its tiles stay on the page unless they move with the object. + let mut tiled = base.clone(); + let red = Node::path( + vectorcraft_doc::NodeId(900), + shapes::rectangle(Rect::new(0.0, 0.0, 3.0, 3.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + let mut def = vectorcraft_doc::pattern::PatternDef::new("Dots", vec![std::sync::Arc::new(red)]); + def.tile = Rect::new(0.0, 0.0, 7.0, 7.0); + tiled.patterns.push(def); + let id = tiled.layers[0].children().unwrap()[0].id; + tiled.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = vectorcraft_doc::pattern::pattern_paint("Dots"); + assert_eq!(drag(&tiled, &moved(&tiled, false, &|_| {})), (0, true), "tiles that stay on the page"); + assert_eq!(drag(&tiled, &moved(&tiled, true, &|_| {})), (1, true), "tiles that move with the object"); + // Feather clips to the outline: a notch in the top edge where the tiles paint nothing + // (x 29 to 33 and y 20 to 24 once moved) leaves the content's pixels as they were. + let mut feathered = tiled.clone(); + feathered.node_mut(id).unwrap().appearance.effects = + vec![Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }]; + assert_eq!(drag(&feathered, &moved(&feathered, true, &|_| {})), (1, true), "feathered"); + let notch = |n: &mut Node| { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((25.0, 23.0)); + for p in [(30.0, 23.0), (30.5, 23.6), (31.5, 23.6), (32.0, 23.0), (55.0, 23.0), (55.0, 49.0), (25.0, 49.0)] { + bp.line_to(p); + } + bp.close_path(); + if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind { + *path = vectorcraft_geom::PathData::from_bezpath(&bp); + } + }; + assert_eq!(drag(&feathered, &moved(&feathered, true, ¬ch)), (0, true), "a notch in the feathered outline"); + // A shadow is made from the silhouette's alpha: recolored, the object takes its shadow + // along; notched, the object makes its shadow again. + let shadowed = + doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2}))]); + assert_eq!(drag(&shadowed, &moved(&shadowed, false, &recolor)), (1, true), "a shadow under a new color"); + assert_eq!(drag(&shadowed, &moved(&shadowed, false, ¬ch)), (0, true), "a shadow under a notch"); + // A shadow with Darkness is made from the object's colors: recolored, it is made again. + let shaded = doc_with( + Rect::new(20.0, 20.0, 50.0, 46.0), + Color::rgb(0.9, 0.4, 0.1), + vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "shade": "darkness", "darkness": 50}))], + ); + assert_eq!(drag(&shaded, &moved(&shaded, false, &|_| {})), (1, true), "a shadow with Darkness, moved"); + assert_eq!(drag(&shaded, &moved(&shaded, false, &recolor)), (0, true), "a shadow with Darkness under a new color"); +} + +/// A raster made over moved rasters counts as moved itself (#1257): a group with a drop shadow +/// and Gaussian Blur of its own, whose filtered member moves by parts of a pixel while the +/// other member stays, draws after the drag what a fresh renderer draws. +#[test] +fn rasters_made_over_moved_rasters_count_as_moved() { + let mut d = Document::new(100.0, 100.0); + let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let (sid, mid, gid) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let still = Node::path(sid, shapes::rectangle(Rect::new(60.0, 60.0, 80.0, 80.0)), fill(Color::rgb(0.1, 0.5, 0.2))); + let mut member = Node::path(mid, shapes::rectangle(Rect::new(20.0, 20.0, 50.0, 46.0)), fill(Color::rgb(0.9, 0.4, 0.1))); + member.appearance.effects = vec![Effect { id: "texture.craquelure".into(), params: json!({"crackSpacing": 6}), visible: true }]; + let mut group = Node::group(gid, vec![std::sync::Arc::new(still), std::sync::Arc::new(member)]); + group.appearance.effects = vec![ + Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 3, "blur": 2}), visible: true }, + Effect { id: "blur.gaussian".into(), params: json!({"radius": 1}), visible: true }, + ]; + let l = d.layers[0].id; + d.insert(Some(l), 0, group).unwrap(); + let (view, opts) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let mut r = Renderer::new(); + let _ = r.render(&d, 200, 200, view, &opts); + let mut moved = d.clone(); + moved.node_mut(mid).unwrap().transform(Affine::translate((3.3, 1.2)), false); + let _ = r.render(&moved, 200, 200, view, &RenderOptions { interacting: true, ..opts.clone() }); + assert!(r.stats.moved > 0, "the member's filtered content moved"); + let after = r.render(&moved, 200, 200, view, &opts).pixels; + assert!(after == Renderer::new().render(&moved, 200, 200, view, &opts).pixels, "the frame after the drag"); +} + +/// Reproduce #1257's moving, filtered rectangle: the first frame renders as before a drag, +/// and the next eight render as the canvas renders a drag's frames (`interacting`). Then the +/// eighth position renders as the canvas renders the frame after the drag, and that frame +/// renders once more with every raster cached. +/// Run with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`. +#[test] +#[ignore = "manual timing probe; wall-clock performance depends on machine load"] +fn texture_drag_perf() { + for effect in ["texture.craquelure", "texture.mosaicTiles", "texture.stainedGlass"] { + let mut base = Document::new(600.0, 500.0); + base.color_mode = vectorcraft_doc::ColorMode::Cmyk; + let (layer, id) = (base.layers[0].id, base.alloc_id()); + let mut n = Node::path( + id, + shapes::rectangle(Rect::new(100.0, 100.0, 309.298, 370.938)), + Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::solid(Color::BLACK), 1.0), + ); + n.appearance.effects = vec![ + Effect { id: "stylize.roundCorners".into(), params: json!({"radius": 25.175}), visible: true }, + Effect { id: effect.into(), params: json!({}), visible: true }, + ]; + base.insert(Some(layer), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let live = RenderOptions { interacting: true, ..opts.clone() }; + let view = Affine::scale(1.9007 * 1.5); + let mut r = Renderer::new(); + r.threads = crate::default_threads(); + let (mut ms, mut doc) = (vec![], base.clone()); + for frame in 0..9 { + doc = base.clone(); + doc.node_mut(id).unwrap().transform(Affine::translate((3.0 + f64::from(frame) * 0.75, f64::from(frame) * 0.5)), false); + let start = std::time::Instant::now(); + let image = r.render(&doc, 1800, 1500, view, if frame > 0 { &live } else { &opts }); + let time = start.elapsed().as_secs_f64() * 1000.0; + if frame > 0 { + ms.push(time); + } + std::hint::black_box(image); + } + let mut time = |o: &RenderOptions| { + let start = std::time::Instant::now(); + std::hint::black_box(r.render(&doc, 1800, 1500, view, o)); + start.elapsed().as_secs_f64() * 1000.0 + }; + let (after, again) = (time(&opts), time(&opts)); + ms.sort_by(f64::total_cmp); + println!( + "{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}; the frame after the drag {after:.2} ms, that frame again {again:.2} ms", + (ms[3] + ms[4]) * 0.5 + ); + } +} + +/// Reproduce #2558's drag of a textured or blurred rectangle when zoomed in, in a 2560 × 1440 +/// view centered on the rectangle: the first frame renders as before a drag, and the next eight +/// render as a drag's frames, once with `interacting` alone and once with `dragging` as well (as +/// the canvas renders them). Then the eighth position renders as the canvas renders the frame +/// after the drag. The hashes of that frame and of a fresh renderer's frame compare builds. +/// Run with `cargo test -p vectorcraft-render zoomed_drag_perf -- --ignored --nocapture`. +#[test] +#[ignore = "manual timing probe; wall-clock performance depends on machine load"] +fn zoomed_drag_perf() { + let sets: [(&str, Vec<(&str, serde_json::Value)>); 3] = [ + ("Texturizer", vec![("texture.texturizer", json!({}))]), + ("Gaussian Blur 8 pt", vec![("blur.gaussian", json!({"radius": 8}))]), + ("Drop Shadow and Craquelure", vec![("stylize.dropShadow", json!({"blur": 7})), ("texture.craquelure", json!({}))]), + ]; + let hash = |px: &[u8]| { + use std::hash::{Hash, Hasher}; + let mut h = std::hash::DefaultHasher::new(); + px.hash(&mut h); + h.finish() + }; + for (name, effects) in sets { + for zoom in [1.0, 4.0, 16.0, 64.0] { + let base = doc_with(Rect::new(100.0, 100.0, 300.0, 250.0), Color::rgb(0.9, 0.4, 0.1), effects.clone()); + let id = base.layers[0].children().unwrap()[0].id; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::translate((1280.0, 720.0)) * Affine::scale(zoom) * Affine::translate((-200.0, -175.0)); + for dragging in [false, true] { + let live = RenderOptions { interacting: true, dragging, ..opts.clone() }; + let mut r = Renderer::new(); + r.threads = crate::default_threads(); + let (mut ms, mut doc) = (vec![], base.clone()); + for frame in 0..9 { + doc = base.clone(); + let by = (f64::from(frame) * 0.75 / zoom, f64::from(frame) * 0.5 / zoom); + doc.node_mut(id).unwrap().transform(Affine::translate(by), false); + let start = std::time::Instant::now(); + std::hint::black_box(r.render(&doc, 2560, 1440, view, if frame > 0 { &live } else { &opts })); + if frame > 0 { + ms.push(start.elapsed().as_secs_f64() * 1000.0); + } + } + let previewed = r.stats.previewed; + let start = std::time::Instant::now(); + let still = r.render(&doc, 2560, 1440, view, &opts); + let after = start.elapsed().as_secs_f64() * 1000.0; + let fresh = Renderer::new().render(&doc, 2560, 1440, view, &opts); + ms.sort_by(f64::total_cmp); + println!( + "{name} at {:.0}%, dragging {dragging}: drag frames median {:.1} ms (min {:.1}, max {:.1}), {previewed} previewed; the frame after the drag {after:.1} ms; hashes {:016x} {:016x}", + zoom * 100.0, + (ms[3] + ms[4]) * 0.5, + ms[0], + ms[7], + hash(&still.pixels), + hash(&fresh.pixels) + ); + } + } + } +} + +#[test] +fn photoshop_filters_on_content_larger_than_the_budget() { + // Zoomed in 200×: the 40 pt square covers 8000 × 8000 px around a 200 × 200 view. + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::translate((100.0, 100.0)) * Affine::scale(200.0) * Affine::translate((-70.0, -50.0)); + for (id, p) in [("sharpen.unsharpMask", json!({"amount": 200})), ("blur.smart", json!({})), ("blur.radial", json!({"quality": "draft"}))] { + let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::rgb(0.2, 0.4, 0.8), vec![(id, p)]); + let img = Renderer::new().render(&d, 200, 200, view, &opts); + // The view shows the square's right side half way down: inside left, outside right. + assert!(lum(img.pixel(40, 100)) < 600, "{id}: inside {:?}", img.pixel(40, 100)); + // (Smart Blur's wide samples read a blurred copy: a faint fringe under its threshold.) + assert!(lum(img.pixel(190, 100)) >= WHITE - 3 * 25, "{id}: outside {:?}", img.pixel(190, 100)); + } +} From 8fd986ae13d80ed85553da48e559d7c57def589f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:36 +0000 Subject: [PATCH 026/199] refactor(tools): move draw2/gesture.rs tests to draw2/gesture/tests.rs --- crates/tools/src/draw2/gesture.rs | 1039 +---------------------- crates/tools/src/draw2/gesture/tests.rs | 1036 ++++++++++++++++++++++ 2 files changed, 1037 insertions(+), 1038 deletions(-) create mode 100644 crates/tools/src/draw2/gesture/tests.rs diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 82b512143..bdb8e4d84 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -692,1041 +692,4 @@ impl Tool for GestureTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - fn drag(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { - let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1))]; - for &(x, y) in &pts[1..] { - v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y))); - } - let l = pts[pts.len() - 1]; - v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); - v - } - - fn drag_held(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { - let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m))]; - for &(x, y) in &pts[1..] { - v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m))); - } - let l = pts[pts.len() - 1]; - v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); - v - } - - /// The Pencil's new path starts and ends on Smart Guides (#506), the points between where - /// the hand went. - #[test] - fn pencil_ends_snap_to_smart_guides() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("pencil"); - let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]])); - // The Paintbrush follows the hand all the way. - let mut t = GestureTool::new("paintbrush"); - let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["points"][0], json!([102.0, 98.0])); - } - - /// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and - /// the Pencil send plain points. - #[test] - fn paintbrush_sends_pen_pressure() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let stroke = |id: &str, pressures: [f32; 3]| { - let mut t = GestureTool::new(id); - let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up]; - let mut last = vec![]; - for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) { - last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) }); - } - let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") }; - v["points"].clone() - }; - assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]])); - assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); - assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); - } - - #[test] - fn pencil_previews_and_commits() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("pencil"); - let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); - assert!(acts[0].is_empty()); - assert_eq!(acts[1][0], Action::Begin("Pencil".into())); - assert!(matches!(&acts[1][1], Action::Preview(c, v) if c == "path.freehand" && v["points"].as_array().unwrap().len() == 2)); - let last = acts.last().unwrap(); - assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false && v["style"] == "pencil")); - assert_eq!(last[1], Action::Commit); - assert!(!t.busy()); - } - - /// Live curve fitting is the Pencil's, on by default. Off, the previews while a stroke is drawn - /// ask for the points unfitted (`fit: false`), and the release's preview is fitted as before. - #[test] - fn pencil_with_live_curve_fitting_off_previews_the_stroke_unfitted() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let stroke = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 6.0), (40.0, 0.0)]; - // The `fit` each preview sends: those while drawn, then the release's. - let fits = |t: &mut GestureTool| { - let acts = drag(t, &cx, &stroke, Mods::default()); - let fit = |a: &Action| if let Action::Preview(_, v) = a { Some(v.get("fit").cloned()) } else { None }; - let (drawn, release) = acts.split_at(acts.len() - 1); - (drawn.iter().flatten().filter_map(fit).collect::>(), release[0].iter().filter_map(fit).collect::>()) - }; - let mut t = GestureTool::new("pencil"); - assert_eq!(t.options()["liveFit"], json!(true)); - let (drawn, release) = fits(&mut t); - assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); - assert_eq!(release, [None]); - t.set_option("liveFit", &json!(false)); - assert_eq!(t.options()["liveFit"], json!(false)); - let (drawn, release) = fits(&mut t); - assert!(!drawn.is_empty() && drawn.iter().all(|f| *f == Some(json!(false))), "{drawn:?}"); - assert_eq!(release, [None], "the release fits the stroke"); - // Choosing another tool while the stroke is drawn keeps it fitted, too. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - for &(x, y) in &stroke[1..] { - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y)); - } - let acts = t.deactivate(&cx); - assert!(matches!(acts.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("fit").is_none()), "{acts:?}"); - // The Paintbrush has no such option, and its previews are fitted. - let mut t = GestureTool::new("paintbrush"); - t.set_option("liveFit", &json!(false)); - assert!(t.options().get("liveFit").is_none()); - let (drawn, _) = fits(&mut t); - assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); - } - - /// Keep selected is on by default, and every Pencil and Paintbrush preview sends it. - #[test] - fn pencil_and_paintbrush_send_keep_selected() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - for id in ["pencil", "paintbrush"] { - let mut t = GestureTool::new(id); - assert_eq!(t.options()["keepSelected"], json!(true), "{id}"); - for keep in [true, false] { - t.set_option("keepSelected", &json!(keep)); - assert_eq!(t.options()["keepSelected"], json!(keep), "{id}"); - let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); - assert!(acts[1..].iter().all(|a| a.iter().all(|a| !matches!(a, Action::Preview(_, v) if v["keepSelected"] != keep))), "{id}"); - assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["keepSelected"] == keep), "{id}"); - } - } - } - - /// With the Pencil's Live preview off, a stroke previews nothing while it is drawn: a thin line - /// follows the pen (an overlay), and the release draws the path with its fill and stroke. On (the - /// default), every step previews the path and no line is shown. The Paintbrush has no such option. - #[test] - fn pencil_with_live_preview_off_shows_a_thin_line_until_the_release() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let previews = |acts: &[Action]| acts.iter().filter(|a| matches!(a, Action::Preview(..))).count(); - let line = |t: &GestureTool| t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == 1.0)); - let pts = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 5.0)]; - for live in [true, false] { - let mut t = GestureTool::new("pencil"); - assert_eq!(t.options()["livePreview"], json!(true), "on by default"); - t.set_option("livePreview", &json!(live)); - assert_eq!(t.options()["livePreview"], json!(live)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1)); - let mut during = vec![]; - for &(x, y) in &pts[1..] { - during.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y))); - } - assert!(matches!(during.first(), Some(Action::Begin(_))), "live {live}: {during:?}"); - assert_eq!(previews(&during), if live { 3 } else { 0 }, "live {live}: {during:?}"); - assert_eq!(line(&t), !live, "live {live}: the thin line shows only with Live preview off"); - let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 30.0, 5.0)); - assert!(matches!(up.as_slice(), [Action::Preview(c, _), Action::Commit] if c == "path.freehand"), "live {live}: {up:?}"); - assert!(!line(&t), "live {live}: no line after the release"); - } - // Escape mid-stroke with Live preview off cancels the stroke it began. - let mut t = GestureTool::new("pencil"); - t.set_option("livePreview", &json!(false)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); - // The Paintbrush keeps previewing: Live preview isn't one of its options. - let mut b = GestureTool::new("paintbrush"); - assert!(b.options().get("livePreview").is_none()); - b.set_option("livePreview", &json!(false)); - b.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - let acts = b.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); - assert_eq!(previews(&acts), 1, "{acts:?}"); - } - - /// Holding Alt shows the Paintbrush's closing cursor before the press, and the Pencil's and the - /// Paintbrush's while drawing (before a press, Alt gives the Pencil its straight-line cursor); a release - /// with Alt still held closes the path, and Alt let go before the release leaves it open. - /// The freehand tools take every position the pointer passed (`wants_samples`); the Join tool - /// doesn't need them. - #[test] - fn the_freehand_tools_want_every_sample() { - for id in ["pencil", "paintbrush", "blobBrush", "eraser", "knife", "smooth", "pathEraser"] { - assert!(GestureTool::new(id).wants_samples(), "{id}"); - } - assert!(!GestureTool::new("join").wants_samples()); - } - - #[test] - fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Default::default() }; - let at = Point::new(10.0, 5.0); - for (id, own, closing, style) in - [("pencil", Cursor::Pencil, Cursor::PencilClose, "pencil"), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose, "brush")] - { - let mut t = GestureTool::new(id); - assert_eq!(t.cursor(&cx, at, Mods::default()), if id == "pencil" { Cursor::PencilNew } else { own }, "{id}"); - assert_eq!(t.cursor(&cx, at, alt), if id == "pencil" { Cursor::PencilStraight } else { closing }, "{id}"); - for alt_at_release in [true, false] { - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, at, alt), closing, "{id} while drawing"); - assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id} while drawing"); - let up = Mods { alt: alt_at_release, ..Default::default() }; - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(up)); - assert!( - matches!(&acts[0], Action::Preview(_, v) if v["closed"] == alt_at_release && v["style"] == style), - "{id}, Alt at the release {alt_at_release}: {acts:?}" - ); - } - } - // With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until - // a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's. - let mut t = GestureTool::new("pencil"); - t.set_option("altSmooth", &json!(true)); - assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); - assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun"); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); - assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}"); - // A smoothing drag keeps the Smooth cursor when Alt is let go. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag"); - } - - #[test] - fn blob_brush_drag_shows_scrub_length_measurement_labels() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let on = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) }; - let off = ToolContext { measurement_labels: false, ..cx(&d, &s, &p) }; - let mut t = GestureTool::new("blobBrush"); - t.pointer(&on, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - t.pointer(&on, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - let want = format!("L: {}", on.len(100.0)); - assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); - let mut quiet = GestureTool::new("blobBrush"); - quiet.pointer(&off, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - quiet.pointer(&off, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - assert!(!quiet.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); - } - - /// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its - /// stroke sends both. - #[test] - fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("blobBrush"); - assert_eq!((t.options()["keepSelected"].clone(), t.options()["mergeOnlySelection"].clone()), (json!(false), json!(false))); - for (keep, only) in [(false, false), (true, false), (false, true)] { - t.set_option("keepSelected", &json!(keep)); - t.set_option("mergeOnlySelection", &json!(only)); - let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); - assert!( - matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["keepSelected"] == keep && v["mergeOnlySelection"] == only), - "{acts:?}" - ); - } - // The Eraser keeps Size, Angle and Roundness: a round tip by default. - assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0})); - } - - /// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, - /// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points - /// dragged with Shift held move to the other line together, and each point follows the Shift - /// state of its own event. - #[test] - fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let blob = |acts: &[Vec]| -> Vec<(f64, f64)> { - let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(c, "path.blob"); - v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() - }; - let mut t = GestureTool::new("blobBrush"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift)); - assert_eq!(pts.len(), 3); - assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift)); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}"); - // A pointer that crosses to the vertical line takes the points before it along, each on - // its own side of the press point and at its own distance. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift)); - assert_eq!(pts.len(), 4, "{pts:?}"); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}"); - assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}"); - // Without Shift the stroke follows the pointer. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default())); - assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]); - // Shift pressed and let go mid-stroke constrains only the points dragged while it is down, - // and a later press of Shift leaves the points of the earlier one where they are. - let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); - t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift)); - t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default())); - let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]); - assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}"); - assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}"); - assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}"); - // The axes turn with the Constrain Angle. - cx.constrain_angle = 30.0; - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!((a - 30.0).abs() < 1e-9, "{pts:?}"); - // A pointer back at the press point leaves the stroke on its line. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}"); - } - - /// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45° - /// steps from the Constrain Angle, previewed as it is dragged; the Paintbrush keeps following - /// the hand. - #[test] - fn pencil_shift_draws_a_straight_line_at_45_degree_steps() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let pts = |acts: &[Vec]| -> Vec> { - acts.iter() - .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) - .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) - .collect() - }; - let angle = |q: (f64, f64), from: (f64, f64)| (-(q.1 - from.1)).atan2(q.0 - from.0).to_degrees(); - let mut t = GestureTool::new("pencil"); - // A wavy drag heading up and right at about 40°: a line at 45°, as long as the drag. - let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)], shift); - let all = pts(&acts); - assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); - let end = *all.last().unwrap().last().unwrap(); - assert!((angle(end, (0.0, 0.0)) - 45.0).abs() < 1e-9, "{end:?}"); - assert!((end.0.hypot(end.1) - 100f64.hypot(84.0)).abs() < 1e-9, "{end:?}"); - let last = acts.last().unwrap(); - assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); - // The steps turn with the Constrain Angle: at 30°, a drag at 40° gives 30°, one at 60° gives 75°. - cx.constrain_angle = 30.0; - for (to, want) in [((100.0, -84.0), 30.0), ((50.0, -86.6), 75.0)] { - let all = pts(&drag_held(&mut t, &cx, &[(10.0, 10.0), (to.0 + 10.0, to.1 + 10.0)], shift)); - let end = *all.last().unwrap().last().unwrap(); - assert!((angle(end, (10.0, 10.0)) - want).abs() < 1e-9, "{to:?}: {all:?}"); - } - // Without Shift, and with the Paintbrush, the stroke follows the hand. - for (id, m) in [("pencil", Mods::default()), ("paintbrush", shift)] { - let mut t = GestureTool::new(id); - let all = pts(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); - assert_eq!(all.last().unwrap().len(), 3, "{id}: {all:?}"); - } - } - - /// #3799: a Shift line leaves no Smart Guides of its press on screen while it is dragged - /// (its end doesn't snap), and Alt at its release leaves it open. - #[test] - fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let mut t = GestureTool::new("pencil"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 102.0, 98.0).with_mods(shift)); - assert!(!t.overlays(&cx).is_empty(), "the press snaps to the corner and shows it"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 140.0).with_mods(shift)); - assert!(t.overlays(&cx).is_empty(), "{:?}", t.overlays(&cx)); - let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 140.0).with_mods(shift_alt)); - assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false && v["points"].as_array().unwrap().len() == 2), "{acts:?}"); - } - - /// Before a press, the Pencil shows its straight-line cursor exactly when the press would draw a - /// straight line; once the press is down, and with the Paintbrush, Shift leaves the tool's own - /// cursor. - #[test] - fn pencil_shows_the_straight_line_cursor_when_a_press_draws_a_line() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let at = Point::new(0.0, 0.0); - let shift = Mods { shift: true, ..Default::default() }; - let alt = Mods { alt: true, ..Default::default() }; - let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - // The last drag's preview: two points for a line, all three for a freehand stroke, none - // for a smoothing drag. - let straight = |acts: &[Vec]| { - acts[acts.len() - 2].iter().any(|a| matches!(a, Action::Preview(_, v) if v["points"].as_array().is_some_and(|q| q.len() == 2))) - }; - for alt_smooth in [false, true] { - for m in [Mods::default(), shift, alt, shift_alt] { - let mut t = GestureTool::new("pencil"); - t.set_option("altSmooth", &json!(alt_smooth)); - let shown = t.cursor(&cx, at, m) == Cursor::PencilStraight; - let drawn = straight(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); - assert_eq!(shown, drawn, "altSmooth {alt_smooth}, {m:?}"); - } - } - let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::PencilNew); - assert_eq!(t.cursor(&cx, at, shift), Cursor::PencilStraight); - assert_eq!(t.cursor(&cx, at, alt), Cursor::PencilStraight, "Alt alone draws a line at any angle"); - assert_eq!(t.cursor(&cx, at, shift_alt), Cursor::PencilStraight, "Alt at the release leaves a straight line open"); - // While the line is drawn, the Pencil's own cursor; after the release, Shift shows it again. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "while the line is drawn"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -10.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, at, shift), Cursor::PencilStraight, "after the release"); - // Shift pressed during a freehand stroke doesn't make it a line. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "a freehand stroke"); - assert_eq!(GestureTool::new("paintbrush").cursor(&cx, at, shift), Cursor::Paintbrush); - assert_eq!(Cursor::PencilStraight.precise(), Cursor::Crosshair, "Use Precise Cursors"); - } - - /// Before a press, the Pencil shows its new-path cursor (the star) exactly when the press draws - /// a new path: over an end of a selected open path, which the stroke continues, and over a - /// selected path, which it redraws, it does not. With nothing selected, with Edit Selected Paths - /// off and away from the paths, it does; while a stroke is drawn, it does not. - #[test] - fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - // The stroke edits the selected path when its previews continue or redraw it. - let edits = |acts: &[Vec]| { - acts.iter().flatten().any(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() || v.get("redraw").is_some())) - }; - let (end, middle, away) = (Point::new(405.0, 303.0), Point::new(350.0, 304.0), Point::new(350.0, 360.0)); - for edit_within in [12.0, 0.0] { - for at in [end, Point::new(296.0, 302.0), middle, away, Point::new(200.0, 200.0)] { - let mut t = GestureTool::new("pencil"); - t.set_option("editWithin", &json!(edit_within)); - let shown = t.cursor(&cx, at, Mods::default()) == Cursor::PencilNew; - let acts = drag(&mut t, &cx, &[(at.x, at.y), (360.0, 330.0), (420.0, 350.0)], Mods::default()); - assert_eq!(shown, !edits(&acts), "editWithin {edit_within} at {at:?}"); - } - } - let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues"); - assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws"); - assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path"); - // While a stroke is drawn, the Pencil's own cursor. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, away.x, away.y)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 380.0, 380.0)); - assert_eq!(t.cursor(&cx, Point::new(380.0, 380.0), Mods::default()), Cursor::Pencil, "while a stroke is drawn"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 380.0, 380.0)); - assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "after the release"); - // With nothing selected, every press draws a new path. - let none = Selection::default(); - let cx_none = crate::testutil::cx(&d, &none, &p); - assert_eq!(GestureTool::new("pencil").cursor(&cx_none, middle, Mods::default()), Cursor::PencilNew, "nothing selected"); - // Shift held: a straight line, with its own cursor; the Paintbrush keeps its cursor. - let shift = Mods { shift: true, ..Default::default() }; - assert_eq!(GestureTool::new("pencil").cursor(&cx, away, shift), Cursor::PencilStraight); - assert_eq!(GestureTool::new("paintbrush").cursor(&cx, away, Mods::default()), Cursor::Paintbrush); - } - - /// #3799: a Shift line started on an end of a selected open path continues the path from that - /// end, constrained from the end itself. - #[test] - fn pencil_shift_line_continues_a_selected_path_from_its_end() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let mut t = GestureTool::new("pencil"); - // Pressed 5 pt off the end, released 100 pt right and 4 pt above it. - let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 290.0), (500.0, 296.0)], Mods { shift: true, ..Default::default() }); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); - assert_eq!(v["points"], json!([[400.0, 300.0], [400.0 + 100f64.hypot(4.0), 300.0]])); - } - - /// Before a press over an end of a selected open path, the Pencil shows its continuation cursor - /// exactly when the press would draw a straight line that continues the path; away from the - /// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with - /// the Paintbrush, it does not. - #[test] - fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let alt = Mods { alt: true, ..Default::default() }; - let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - // The release's stroke: a straight line that continues the path. - let continues = |acts: &[Vec]| { - acts.last().and_then(|a| a.first()).is_some_and( - |a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2)), - ) - }; - for alt_smooth in [false, true] { - for m in [Mods::default(), shift, alt, shift_alt] { - for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] { - let mut t = GestureTool::new("pencil"); - t.set_option("altSmooth", &json!(alt_smooth)); - let shown = t.cursor(&cx, at, m) == Cursor::PencilContinue; - let acts = drag_held(&mut t, &cx, &[(at.x, at.y), (450.0, 250.0), other_end], m); - assert_eq!(shown, continues(&acts), "altSmooth {alt_smooth}, {m:?} at {at:?}"); - } - } - } - let mut t = GestureTool::new("pencil"); - let end = Point::new(405.0, 303.0); - assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue); - assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand"); - assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends"); - // While the line is drawn, the Pencil's own cursor. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 303.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, end, shift), Cursor::Pencil, "while the line is drawn"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 450.0, 303.0).with_mods(shift)); - // Edit Selected Paths off: the press draws a new line. - t.set_option("editWithin", &json!(0.0)); - assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilStraight, "Edit Selected Paths off"); - let none = Selection::default(); - let cx_none = crate::testutil::cx(&d, &none, &p); - assert_eq!(GestureTool::new("pencil").cursor(&cx_none, end, shift), Cursor::PencilStraight, "nothing selected"); - assert_eq!(GestureTool::new("paintbrush").cursor(&cx, end, shift), Cursor::Paintbrush); - assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); - } - - /// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt - /// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. - #[test] - fn pencil_shift_line_ignores_alt_for_cursor_and_smoothing() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - let mut t = GestureTool::new("pencil"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), shift_alt), Cursor::Pencil); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0).with_mods(shift)); - - let mut t = GestureTool::new("pencil"); - t.alt_smooth = true; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift_alt)); - assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(..)]), "{acts:?}"); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 0.0).with_mods(shift_alt)); - assert!(matches!(&acts[0], Action::Preview(_, v) if v["points"].as_array().unwrap().len() == 2), "{acts:?}"); - } - - /// #3846: a Shift line dragged back to its press point has no line: the preview is cancelled - /// and the release commits nothing (not the previous end of the line). - #[test] - fn pencil_shift_line_back_at_the_press_point_draws_nothing() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - // Released back at the press point, and dragged back there first. - for back_in_drag in [false, true] { - let mut t = GestureTool::new("pencil"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift)); - let mut acts = vec![]; - if back_in_drag { - // The shown line goes as soon as the pointer is back, not only at the release. - let back = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.2, 0.0).with_mods(shift)); - assert_eq!(back, vec![Action::Cancel]); - acts.extend(back); - } - acts.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.2, 0.0).with_mods(shift))); - assert!(acts.contains(&Action::Cancel), "{back_in_drag}: {acts:?}"); - assert!(!acts.contains(&Action::Commit) && !acts.iter().any(|a| matches!(a, Action::Preview(..))), "{back_in_drag}: {acts:?}"); - } - } - - /// #3799: Alt held at the press draws a straight Pencil line at any angle from the press point - /// to the pointer, previewed as it is dragged, and left open by a release with Alt still held; - /// with Shift too it keeps Shift's 45° steps. Alt pressed after the press keeps the stroke - /// freehand and closes it at the release, and the Paintbrush keeps following the hand. - #[test] - fn pencil_alt_at_the_press_draws_a_straight_line_at_any_angle() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let alt = Mods { alt: true, ..Default::default() }; - let pts = |acts: &[Vec]| -> Vec> { - acts.iter() - .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) - .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) - .collect() - }; - let wavy = [(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)]; - let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), alt), Cursor::PencilStraight, "before the press"); - let acts = drag_held(&mut t, &cx, &wavy, alt); - let all = pts(&acts); - assert_eq!(all.len(), 4, "{all:?}"); - assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); - assert_eq!(all[1][1], (50.0, -60.0), "the preview follows the pointer at its own angle"); - assert_eq!(all[3][1], (100.0, -84.0), "{all:?}"); - let last = acts.last().unwrap(); - assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); - // While the line is drawn, Alt shows no closing ring. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, 10.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, Point::new(40.0, 10.0), alt), Cursor::Pencil); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, 10.0).with_mods(alt)); - // Shift with Alt: 45° steps. - let all = pts(&drag_held(&mut t, &cx, &wavy, Mods { shift: true, alt: true, ..Default::default() })); - let end = *all.last().unwrap().last().unwrap(); - assert!(((-end.1).atan2(end.0).to_degrees() - 45.0).abs() < 1e-9, "{end:?}"); - // Alt pressed after the press: freehand, closed at the release. - let acts = drag(&mut t, &cx, &wavy, alt); - assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}"); - assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}"); - // The Paintbrush with Alt held follows the hand and closes. - let mut b = GestureTool::new("paintbrush"); - let acts = drag_held(&mut b, &cx, &wavy, alt); - assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}"); - assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}"); - } - - /// #3799: with Alt key toggles to Smooth Tool on, Alt held at the press smooths as before and - /// draws no line. - #[test] - fn pencil_alt_with_alt_to_smooth_on_smooths_instead_of_drawing_a_line() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let alt = Mods { alt: true, ..Default::default() }; - let mut t = GestureTool::new("pencil"); - t.set_option("altSmooth", &json!(true)); - let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt); - assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}"); - assert!(matches!(&acts.last().unwrap()[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3)); - } - - /// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of - /// a selected open path it continues the path from that end to the pointer. - #[test] - fn pencil_alt_line_snaps_its_ends_and_continues_a_selected_path() { - let (mut d, _) = doc_with_rect(); - let alt = Mods { alt: true, ..Default::default() }; - let p = paint(); - { - let s = Selection::default(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("pencil"); - let acts = drag_held(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (198.0, 203.0)], alt); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["points"], json!([[100.0, 100.0], [200.0, 200.0]])); - } - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let mut t = GestureTool::new("pencil"); - let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 340.0), (470.0, 330.0)], alt); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); - assert_eq!(v["points"], json!([[400.0, 300.0], [470.0, 330.0]])); - } - - #[test] - fn pencil_alt_closes_and_continues_selected_path() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("paintbrush"); - let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods { alt: true, ..Default::default() }); - assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "brush")); - // A selected closed rect is not continued; an open path would be (see engine tests). - let mut s2 = Selection::default(); - s2.set([id]); - let cx2 = crate::testutil::cx(&d, &s2, &p); - let acts = drag(&mut t, &cx2, &[(100.0, 100.0), (90.0, 90.0)], Mods::default()); - assert!(matches!(&acts[1][1], Action::Preview(_, v) if v.get("extend").is_none())); - } - - /// Edit selected paths within 0 turns editing off: a stroke that starts exactly on an end of a - /// selected open path draws a new path; at 12 px it continues the path from that end. - #[test] - fn edit_selected_paths_within_0_draws_a_new_path_from_an_end() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - for tool in ["pencil", "paintbrush"] { - let mut t = GestureTool::new(tool); - for (within, extends) in [(12.0, true), (0.0, false)] { - t.set_option("editWithin", &json!(within)); - for (x, end) in [(400.0, "end"), (300.0, "start")] { - let acts = drag(&mut t, &cx, &[(x, 300.0), (x + 20.0, 350.0), (x, 380.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; - if extends { - assert_eq!(v["extend"], json!({"id": id.0, "end": end}), "{tool} from the {end}"); - } else { - assert!(v.get("extend").is_none() && v.get("redraw").is_none(), "{tool} from the {end}: {v}"); - } - } - } - } - } - - /// A Pencil stroke from an end of a selected open path to an end of another selected open path - /// connects the two; it does not when the other path is not selected, and the Paintbrush does - /// not connect. - #[test] - fn pencil_connects_a_stroke_from_one_selected_path_to_an_end_of_another() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| { - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), - vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - id - }; - let a = add(&mut d, (300.0, 300.0), (400.0, 300.0)); - let b = add(&mut d, (500.0, 400.0), (600.0, 400.0)); - let p = paint(); - let stroke = [(400.0, 300.0), (450.0, 350.0), (499.0, 401.0)]; - let mut both = Selection::default(); - both.set([a, b]); - let mut one = Selection::default(); - one.set([a]); - // The previews select the path continued: after the press only it is selected. - let last = |tool: &str, at_press: &Selection| { - let mut t = GestureTool::new(tool); - t.pointer(&cx(&d, at_press, &p), &PointerEvent::new(PointerKind::Down, stroke[0].0, stroke[0].1)); - let after = cx(&d, &one, &p); - t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[1].0, stroke[1].1)); - t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[2].0, stroke[2].1)); - let acts = t.pointer(&after, &PointerEvent::new(PointerKind::Up, stroke[2].0, stroke[2].1)); - let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; - v.clone() - }; - let v = last("pencil", &both); - assert_eq!((v["extend"].clone(), v["connect"].clone()), (json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}))); - assert!(last("paintbrush", &both).get("connect").is_none(), "the Paintbrush"); - let v = last("pencil", &one); - assert!(v.get("extend").is_some() && v.get("connect").is_none(), "{v}"); - } - - /// Close paths when ends are within 0 turns closing off: a stroke that ends exactly where it - /// started stays open; at 15 px it is closed. - #[test] - fn close_paths_within_0_leaves_a_stroke_that_ends_at_its_start_open() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - for tool in ["pencil", "paintbrush"] { - let mut t = GestureTool::new(tool); - for (within, closes) in [(15.0, true), (0.0, false)] { - t.set_option("closeWithin", &json!(within)); - let acts = drag(&mut t, &cx, &[(310.0, 330.0), (360.0, 350.0), (330.0, 390.0), (310.0, 330.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; - assert_eq!(v["points"][0], v["points"][3], "{tool}"); - assert_eq!(v["closed"], json!(closes), "{tool} at {within} px"); - } - } - } - - /// While a stroke is drawn back near its start, the cursor shows the closing ring when a - /// release there closes the path (Close Paths When Ends Are Within), and the tool's own cursor - /// away from the start, at 0 pixels and after the release. - #[test] - fn a_stroke_near_its_start_shows_the_closing_cursor() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let none = Mods::default(); - let tools = [("pencil", Cursor::Pencil, Cursor::PencilClose), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose)]; - for (tool, own, closing) in tools { - for within in [15.0, 0.0] { - let near = if within > 0.0 { closing } else { own }; - let mut t = GestureTool::new(tool); - t.set_option("closeWithin", &json!(within)); - let mut at = |k, x, y| { - let acts = t.pointer(&cx, &PointerEvent::new(k, x, y)); - (acts, t.cursor(&cx, Point::new(x, y), none)) - }; - assert_eq!(at(PointerKind::Down, 310.0, 330.0).1, own, "{tool} at the press"); - assert_eq!(at(PointerKind::Drag, 360.0, 350.0).1, own, "{tool} away from the start"); - assert_eq!(at(PointerKind::Drag, 330.0, 390.0).1, own, "{tool} away from the start"); - assert_eq!(at(PointerKind::Drag, 318.0, 335.0).1, near, "{tool} near the start at {within} px"); - assert_eq!(at(PointerKind::Drag, 345.0, 340.0).1, own, "{tool} away from the start again"); - assert_eq!(at(PointerKind::Drag, 318.0, 335.0).1, near, "{tool} back near the start at {within} px"); - let (acts, after) = at(PointerKind::Up, 318.0, 335.0); - let Some(Action::Preview(_, v)) = acts.first() else { panic!("{tool}: {acts:?}") }; - assert_eq!(v["closed"], json!(within > 0.0), "{tool}: the release does what the cursor showed"); - assert_eq!(after, if tool == "pencil" { Cursor::PencilNew } else { own }, "{tool} after the release"); - } - } - // A straight Pencil line (Shift or Alt held at the press) stays open, so it shows no ring - // near its start. - for held in [Mods { shift: true, ..Mods::default() }, Mods { alt: true, ..Mods::default() }] { - let mut t = GestureTool::new("pencil"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 310.0, 330.0).with_mods(held)); - for (x, y) in [(360.0, 330.0), (330.0, 390.0), (318.0, 335.0)] { - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(held)); - } - for m in [held, none] { - assert_eq!(t.cursor(&cx, Point::new(318.0, 335.0), m), Cursor::Pencil, "a straight line, {held:?}, {m:?}"); - } - } - } - - /// A Paintbrush sample under a pixel from the last point is dropped, and a release there keeps - /// the last point: the closing cursor follows that point, not the pointer. - #[test] - fn the_paintbrush_closing_cursor_follows_the_point_a_release_keeps() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let none = Mods::default(); - let mut t = GestureTool::new("paintbrush"); - t.set_option("closeWithin", &json!(15.0)); - for (k, x, y) in [(PointerKind::Down, 310.0, 330.0), (PointerKind::Drag, 360.0, 350.0), (PointerKind::Drag, 330.0, 390.0)] { - t.pointer(&cx, &PointerEvent::new(k, x, y)); - } - // The last point kept is 15.5 from the start, outside Close Paths When Ends Are Within. - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 325.5, 330.0)); - assert_eq!(t.cursor(&cx, Point::new(325.5, 330.0), none), Cursor::Paintbrush, "outside the closing distance"); - // The pointer moves 0.7 to 14.8 from the start; the sample is dropped. - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 324.8, 330.0)); - assert_eq!(t.cursor(&cx, Point::new(324.8, 330.0), none), Cursor::Paintbrush, "the dropped sample shows no ring"); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 324.8, 330.0)); - let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; - assert_eq!(v["closed"], json!(false), "the release keeps the last point and leaves the path open"); - } - - #[test] - fn eraser_and_knife_exec_on_release() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("eraser"); - let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); - assert!(acts[1].is_empty()); - assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 10.0)); - let mut k = GestureTool::new("knife"); - k.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - k.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - assert_eq!(k.overlays(&cx).len(), 1); - let a = k.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3)); - } - - /// The Eraser's Angle and Roundness (Eraser Tool Options) go with its stroke, kept within - /// −180–180° and 1–100%; while it drags, a tip that is not round shows the outline of the area - /// it covers, and a round one the drag as wide as the tip. - #[test] - fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("eraser"); - t.set_option("size", &json!(20)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - let round = t.overlays(&cx); - assert!(matches!(&round[..], [Overlay::Path { width, .. }] if *width == 20.0), "{round:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - for (angle, roundness, sent) in [(90.0, 25.0, (90.0, 25.0)), (400.0, 0.0, (180.0, 1.0)), (-400.0, 250.0, (-180.0, 100.0))] { - t.set_option("angle", &json!(angle)); - t.set_option("roundness", &json!(roundness)); - assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(sent.0), Some(sent.1))); - let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); - assert!( - matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 20.0 && v["angle"] == sent.0 && v["roundness"] == sent.1), - "{acts:?}" - ); - } - // A tip 20 pt long and 5 pt wide, standing upright: along a horizontal drag from x 50 to - // x 150 the outline reaches 2.5 pt either side of the drag and 10 pt above and below - // its ends. - t.set_option("angle", &json!(90)); - t.set_option("roundness", &json!(25)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - let o = t.overlays(&cx); - let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") }; - assert_eq!(*width, 1.0); - let b = vectorcraft_geom::Shape::bounding_box(path); - assert!((b.x0 - 47.5).abs() < 0.1 && (b.x1 - 152.5).abs() < 0.1, "{b:?}"); - assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}"); - } - - #[test] - fn smooth_needs_selection_and_brackets_resize() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("smooth"); - let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); - assert!(acts.iter().all(|a| a.is_empty())); - let mut b = GestureTool::new("blobBrush"); - b.key(&cx, ToolKey::BracketRight, Mods::default()); - assert_eq!(b.options()["size"], 11.0); - } - - #[test] - fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - - // Off by default, as Pencil Tool Options ships it; on, Alt lends the Smooth tool. - let mut t = GestureTool::new("pencil"); - assert_eq!(t.options()["altSmooth"], json!(false)); - t.set_option("altSmooth", &json!(true)); - - // When alt is pressed, cursor shows smooth tool. - let alt_mod = Mods { alt: true, ..Default::default() }; - assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::Smooth); - assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), Mods::default()), Cursor::PencilNew); - - // Alt-drag over selected path smooths it. - let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], alt_mod); - assert!(acts[1].is_empty(), "drag does not preview freehand path while smoothing"); - let last = acts.last().unwrap(); - assert!( - matches!(&last[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3), - "expected path.smoothRegion, got: {last:?}" - ); - - // When altSmooth is disabled: - t.set_option("altSmooth", &json!(false)); - assert_eq!(t.options()["altSmooth"], json!(false)); - // Before a press, Alt gives a straight line rather than a closed path. - assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::PencilStraight); - - // A drag released with Alt held draws a closed pencil path. - let empty_sel = Selection::default(); - let cx_empty = crate::testutil::cx(&d, &empty_sel, &p); - let acts = drag(&mut t, &cx_empty, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], alt_mod); - assert_eq!(acts[1][0], Action::Begin("Pencil".into())); - let last = acts.last().unwrap(); - assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "pencil")); - assert_eq!(last[1], Action::Commit); - } -} +mod tests; diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs new file mode 100644 index 000000000..5cc7684ea --- /dev/null +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -0,0 +1,1036 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +fn drag(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { + let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1))]; + for &(x, y) in &pts[1..] { + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y))); + } + let l = pts[pts.len() - 1]; + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); + v +} + +fn drag_held(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { + let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m))]; + for &(x, y) in &pts[1..] { + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m))); + } + let l = pts[pts.len() - 1]; + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); + v +} + +/// The Pencil's new path starts and ends on Smart Guides (#506), the points between where +/// the hand went. +#[test] +fn pencil_ends_snap_to_smart_guides() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("pencil"); + let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]])); + // The Paintbrush follows the hand all the way. + let mut t = GestureTool::new("paintbrush"); + let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["points"][0], json!([102.0, 98.0])); +} + +/// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and +/// the Pencil send plain points. +#[test] +fn paintbrush_sends_pen_pressure() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let stroke = |id: &str, pressures: [f32; 3]| { + let mut t = GestureTool::new(id); + let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up]; + let mut last = vec![]; + for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) { + last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) }); + } + let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") }; + v["points"].clone() + }; + assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]])); + assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); + assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); +} + +#[test] +fn pencil_previews_and_commits() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("pencil"); + let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); + assert!(acts[0].is_empty()); + assert_eq!(acts[1][0], Action::Begin("Pencil".into())); + assert!(matches!(&acts[1][1], Action::Preview(c, v) if c == "path.freehand" && v["points"].as_array().unwrap().len() == 2)); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false && v["style"] == "pencil")); + assert_eq!(last[1], Action::Commit); + assert!(!t.busy()); +} + +/// Live curve fitting is the Pencil's, on by default. Off, the previews while a stroke is drawn +/// ask for the points unfitted (`fit: false`), and the release's preview is fitted as before. +#[test] +fn pencil_with_live_curve_fitting_off_previews_the_stroke_unfitted() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let stroke = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 6.0), (40.0, 0.0)]; + // The `fit` each preview sends: those while drawn, then the release's. + let fits = |t: &mut GestureTool| { + let acts = drag(t, &cx, &stroke, Mods::default()); + let fit = |a: &Action| if let Action::Preview(_, v) = a { Some(v.get("fit").cloned()) } else { None }; + let (drawn, release) = acts.split_at(acts.len() - 1); + (drawn.iter().flatten().filter_map(fit).collect::>(), release[0].iter().filter_map(fit).collect::>()) + }; + let mut t = GestureTool::new("pencil"); + assert_eq!(t.options()["liveFit"], json!(true)); + let (drawn, release) = fits(&mut t); + assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); + assert_eq!(release, [None]); + t.set_option("liveFit", &json!(false)); + assert_eq!(t.options()["liveFit"], json!(false)); + let (drawn, release) = fits(&mut t); + assert!(!drawn.is_empty() && drawn.iter().all(|f| *f == Some(json!(false))), "{drawn:?}"); + assert_eq!(release, [None], "the release fits the stroke"); + // Choosing another tool while the stroke is drawn keeps it fitted, too. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + for &(x, y) in &stroke[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y)); + } + let acts = t.deactivate(&cx); + assert!(matches!(acts.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("fit").is_none()), "{acts:?}"); + // The Paintbrush has no such option, and its previews are fitted. + let mut t = GestureTool::new("paintbrush"); + t.set_option("liveFit", &json!(false)); + assert!(t.options().get("liveFit").is_none()); + let (drawn, _) = fits(&mut t); + assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); +} + +/// Keep selected is on by default, and every Pencil and Paintbrush preview sends it. +#[test] +fn pencil_and_paintbrush_send_keep_selected() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + for id in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(id); + assert_eq!(t.options()["keepSelected"], json!(true), "{id}"); + for keep in [true, false] { + t.set_option("keepSelected", &json!(keep)); + assert_eq!(t.options()["keepSelected"], json!(keep), "{id}"); + let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); + assert!(acts[1..].iter().all(|a| a.iter().all(|a| !matches!(a, Action::Preview(_, v) if v["keepSelected"] != keep))), "{id}"); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["keepSelected"] == keep), "{id}"); + } + } +} + +/// With the Pencil's Live preview off, a stroke previews nothing while it is drawn: a thin line +/// follows the pen (an overlay), and the release draws the path with its fill and stroke. On (the +/// default), every step previews the path and no line is shown. The Paintbrush has no such option. +#[test] +fn pencil_with_live_preview_off_shows_a_thin_line_until_the_release() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let previews = |acts: &[Action]| acts.iter().filter(|a| matches!(a, Action::Preview(..))).count(); + let line = |t: &GestureTool| t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == 1.0)); + let pts = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 5.0)]; + for live in [true, false] { + let mut t = GestureTool::new("pencil"); + assert_eq!(t.options()["livePreview"], json!(true), "on by default"); + t.set_option("livePreview", &json!(live)); + assert_eq!(t.options()["livePreview"], json!(live)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1)); + let mut during = vec![]; + for &(x, y) in &pts[1..] { + during.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y))); + } + assert!(matches!(during.first(), Some(Action::Begin(_))), "live {live}: {during:?}"); + assert_eq!(previews(&during), if live { 3 } else { 0 }, "live {live}: {during:?}"); + assert_eq!(line(&t), !live, "live {live}: the thin line shows only with Live preview off"); + let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 30.0, 5.0)); + assert!(matches!(up.as_slice(), [Action::Preview(c, _), Action::Commit] if c == "path.freehand"), "live {live}: {up:?}"); + assert!(!line(&t), "live {live}: no line after the release"); + } + // Escape mid-stroke with Live preview off cancels the stroke it began. + let mut t = GestureTool::new("pencil"); + t.set_option("livePreview", &json!(false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + // The Paintbrush keeps previewing: Live preview isn't one of its options. + let mut b = GestureTool::new("paintbrush"); + assert!(b.options().get("livePreview").is_none()); + b.set_option("livePreview", &json!(false)); + b.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + let acts = b.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); + assert_eq!(previews(&acts), 1, "{acts:?}"); +} + +/// Holding Alt shows the Paintbrush's closing cursor before the press, and the Pencil's and the +/// Paintbrush's while drawing (before a press, Alt gives the Pencil its straight-line cursor); a release +/// with Alt still held closes the path, and Alt let go before the release leaves it open. +/// The freehand tools take every position the pointer passed (`wants_samples`); the Join tool +/// doesn't need them. +#[test] +fn the_freehand_tools_want_every_sample() { + for id in ["pencil", "paintbrush", "blobBrush", "eraser", "knife", "smooth", "pathEraser"] { + assert!(GestureTool::new(id).wants_samples(), "{id}"); + } + assert!(!GestureTool::new("join").wants_samples()); +} + +#[test] +fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let at = Point::new(10.0, 5.0); + for (id, own, closing, style) in + [("pencil", Cursor::Pencil, Cursor::PencilClose, "pencil"), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose, "brush")] + { + let mut t = GestureTool::new(id); + assert_eq!(t.cursor(&cx, at, Mods::default()), if id == "pencil" { Cursor::PencilNew } else { own }, "{id}"); + assert_eq!(t.cursor(&cx, at, alt), if id == "pencil" { Cursor::PencilStraight } else { closing }, "{id}"); + for alt_at_release in [true, false] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, alt), closing, "{id} while drawing"); + assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id} while drawing"); + let up = Mods { alt: alt_at_release, ..Default::default() }; + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(up)); + assert!( + matches!(&acts[0], Action::Preview(_, v) if v["closed"] == alt_at_release && v["style"] == style), + "{id}, Alt at the release {alt_at_release}: {acts:?}" + ); + } + } + // With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until + // a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's. + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(true)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}"); + // A smoothing drag keeps the Smooth cursor when Alt is let go. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag"); +} + +#[test] +fn blob_brush_drag_shows_scrub_length_measurement_labels() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let on = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) }; + let off = ToolContext { measurement_labels: false, ..cx(&d, &s, &p) }; + let mut t = GestureTool::new("blobBrush"); + t.pointer(&on, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&on, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let want = format!("L: {}", on.len(100.0)); + assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); + let mut quiet = GestureTool::new("blobBrush"); + quiet.pointer(&off, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + quiet.pointer(&off, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + assert!(!quiet.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); +} + +/// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its +/// stroke sends both. +#[test] +fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("blobBrush"); + assert_eq!((t.options()["keepSelected"].clone(), t.options()["mergeOnlySelection"].clone()), (json!(false), json!(false))); + for (keep, only) in [(false, false), (true, false), (false, true)] { + t.set_option("keepSelected", &json!(keep)); + t.set_option("mergeOnlySelection", &json!(only)); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!( + matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["keepSelected"] == keep && v["mergeOnlySelection"] == only), + "{acts:?}" + ); + } + // The Eraser keeps Size, Angle and Roundness: a round tip by default. + assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0})); +} + +/// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, +/// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points +/// dragged with Shift held move to the other line together, and each point follows the Shift +/// state of its own event. +#[test] +fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let blob = |acts: &[Vec]| -> Vec<(f64, f64)> { + let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(c, "path.blob"); + v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() + }; + let mut t = GestureTool::new("blobBrush"); + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift)); + assert_eq!(pts.len(), 3); + assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}"); + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift)); + assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}"); + // A pointer that crosses to the vertical line takes the points before it along, each on + // its own side of the press point and at its own distance. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift)); + assert_eq!(pts.len(), 4, "{pts:?}"); + assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}"); + assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}"); + // Without Shift the stroke follows the pointer. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default())); + assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]); + // Shift pressed and let go mid-stroke constrains only the points dragged while it is down, + // and a later press of Shift leaves the points of the earlier one where they are. + let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default())); + let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]); + assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}"); + assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}"); + assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}"); + // The axes turn with the Constrain Angle. + cx.constrain_angle = 30.0; + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift)); + let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); + assert!((a - 30.0).abs() < 1e-9, "{pts:?}"); + // A pointer back at the press point leaves the stroke on its line. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift)); + let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); + assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}"); +} + +/// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45° +/// steps from the Constrain Angle, previewed as it is dragged; the Paintbrush keeps following +/// the hand. +#[test] +fn pencil_shift_draws_a_straight_line_at_45_degree_steps() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let pts = |acts: &[Vec]| -> Vec> { + acts.iter() + .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) + .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) + .collect() + }; + let angle = |q: (f64, f64), from: (f64, f64)| (-(q.1 - from.1)).atan2(q.0 - from.0).to_degrees(); + let mut t = GestureTool::new("pencil"); + // A wavy drag heading up and right at about 40°: a line at 45°, as long as the drag. + let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)], shift); + let all = pts(&acts); + assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); + let end = *all.last().unwrap().last().unwrap(); + assert!((angle(end, (0.0, 0.0)) - 45.0).abs() < 1e-9, "{end:?}"); + assert!((end.0.hypot(end.1) - 100f64.hypot(84.0)).abs() < 1e-9, "{end:?}"); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); + // The steps turn with the Constrain Angle: at 30°, a drag at 40° gives 30°, one at 60° gives 75°. + cx.constrain_angle = 30.0; + for (to, want) in [((100.0, -84.0), 30.0), ((50.0, -86.6), 75.0)] { + let all = pts(&drag_held(&mut t, &cx, &[(10.0, 10.0), (to.0 + 10.0, to.1 + 10.0)], shift)); + let end = *all.last().unwrap().last().unwrap(); + assert!((angle(end, (10.0, 10.0)) - want).abs() < 1e-9, "{to:?}: {all:?}"); + } + // Without Shift, and with the Paintbrush, the stroke follows the hand. + for (id, m) in [("pencil", Mods::default()), ("paintbrush", shift)] { + let mut t = GestureTool::new(id); + let all = pts(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); + assert_eq!(all.last().unwrap().len(), 3, "{id}: {all:?}"); + } +} + +/// #3799: a Shift line leaves no Smart Guides of its press on screen while it is dragged +/// (its end doesn't snap), and Alt at its release leaves it open. +#[test] +fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 102.0, 98.0).with_mods(shift)); + assert!(!t.overlays(&cx).is_empty(), "the press snaps to the corner and shows it"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 140.0).with_mods(shift)); + assert!(t.overlays(&cx).is_empty(), "{:?}", t.overlays(&cx)); + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 140.0).with_mods(shift_alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false && v["points"].as_array().unwrap().len() == 2), "{acts:?}"); +} + +/// Before a press, the Pencil shows its straight-line cursor exactly when the press would draw a +/// straight line; once the press is down, and with the Paintbrush, Shift leaves the tool's own +/// cursor. +#[test] +fn pencil_shows_the_straight_line_cursor_when_a_press_draws_a_line() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let at = Point::new(0.0, 0.0); + let shift = Mods { shift: true, ..Default::default() }; + let alt = Mods { alt: true, ..Default::default() }; + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + // The last drag's preview: two points for a line, all three for a freehand stroke, none + // for a smoothing drag. + let straight = |acts: &[Vec]| { + acts[acts.len() - 2].iter().any(|a| matches!(a, Action::Preview(_, v) if v["points"].as_array().is_some_and(|q| q.len() == 2))) + }; + for alt_smooth in [false, true] { + for m in [Mods::default(), shift, alt, shift_alt] { + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(alt_smooth)); + let shown = t.cursor(&cx, at, m) == Cursor::PencilStraight; + let drawn = straight(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); + assert_eq!(shown, drawn, "altSmooth {alt_smooth}, {m:?}"); + } + } + let mut t = GestureTool::new("pencil"); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::PencilNew); + assert_eq!(t.cursor(&cx, at, shift), Cursor::PencilStraight); + assert_eq!(t.cursor(&cx, at, alt), Cursor::PencilStraight, "Alt alone draws a line at any angle"); + assert_eq!(t.cursor(&cx, at, shift_alt), Cursor::PencilStraight, "Alt at the release leaves a straight line open"); + // While the line is drawn, the Pencil's own cursor; after the release, Shift shows it again. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "while the line is drawn"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -10.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, at, shift), Cursor::PencilStraight, "after the release"); + // Shift pressed during a freehand stroke doesn't make it a line. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "a freehand stroke"); + assert_eq!(GestureTool::new("paintbrush").cursor(&cx, at, shift), Cursor::Paintbrush); + assert_eq!(Cursor::PencilStraight.precise(), Cursor::Crosshair, "Use Precise Cursors"); +} + +/// Before a press, the Pencil shows its new-path cursor (the star) exactly when the press draws +/// a new path: over an end of a selected open path, which the stroke continues, and over a +/// selected path, which it redraws, it does not. With nothing selected, with Edit Selected Paths +/// off and away from the paths, it does; while a stroke is drawn, it does not. +#[test] +fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + // The stroke edits the selected path when its previews continue or redraw it. + let edits = |acts: &[Vec]| { + acts.iter().flatten().any(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() || v.get("redraw").is_some())) + }; + let (end, middle, away) = (Point::new(405.0, 303.0), Point::new(350.0, 304.0), Point::new(350.0, 360.0)); + for edit_within in [12.0, 0.0] { + for at in [end, Point::new(296.0, 302.0), middle, away, Point::new(200.0, 200.0)] { + let mut t = GestureTool::new("pencil"); + t.set_option("editWithin", &json!(edit_within)); + let shown = t.cursor(&cx, at, Mods::default()) == Cursor::PencilNew; + let acts = drag(&mut t, &cx, &[(at.x, at.y), (360.0, 330.0), (420.0, 350.0)], Mods::default()); + assert_eq!(shown, !edits(&acts), "editWithin {edit_within} at {at:?}"); + } + } + let mut t = GestureTool::new("pencil"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues"); + assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws"); + assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path"); + // While a stroke is drawn, the Pencil's own cursor. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, away.x, away.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 380.0, 380.0)); + assert_eq!(t.cursor(&cx, Point::new(380.0, 380.0), Mods::default()), Cursor::Pencil, "while a stroke is drawn"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 380.0, 380.0)); + assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "after the release"); + // With nothing selected, every press draws a new path. + let none = Selection::default(); + let cx_none = crate::testutil::cx(&d, &none, &p); + assert_eq!(GestureTool::new("pencil").cursor(&cx_none, middle, Mods::default()), Cursor::PencilNew, "nothing selected"); + // Shift held: a straight line, with its own cursor; the Paintbrush keeps its cursor. + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(GestureTool::new("pencil").cursor(&cx, away, shift), Cursor::PencilStraight); + assert_eq!(GestureTool::new("paintbrush").cursor(&cx, away, Mods::default()), Cursor::Paintbrush); +} + +/// #3799: a Shift line started on an end of a selected open path continues the path from that +/// end, constrained from the end itself. +#[test] +fn pencil_shift_line_continues_a_selected_path_from_its_end() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = GestureTool::new("pencil"); + // Pressed 5 pt off the end, released 100 pt right and 4 pt above it. + let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 290.0), (500.0, 296.0)], Mods { shift: true, ..Default::default() }); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); + assert_eq!(v["points"], json!([[400.0, 300.0], [400.0 + 100f64.hypot(4.0), 300.0]])); +} + +/// Before a press over an end of a selected open path, the Pencil shows its continuation cursor +/// exactly when the press would draw a straight line that continues the path; away from the +/// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with +/// the Paintbrush, it does not. +#[test] +fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let alt = Mods { alt: true, ..Default::default() }; + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + // The release's stroke: a straight line that continues the path. + let continues = |acts: &[Vec]| { + acts.last().and_then(|a| a.first()).is_some_and( + |a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2)), + ) + }; + for alt_smooth in [false, true] { + for m in [Mods::default(), shift, alt, shift_alt] { + for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] { + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(alt_smooth)); + let shown = t.cursor(&cx, at, m) == Cursor::PencilContinue; + let acts = drag_held(&mut t, &cx, &[(at.x, at.y), (450.0, 250.0), other_end], m); + assert_eq!(shown, continues(&acts), "altSmooth {alt_smooth}, {m:?} at {at:?}"); + } + } + } + let mut t = GestureTool::new("pencil"); + let end = Point::new(405.0, 303.0); + assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue); + assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand"); + assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends"); + // While the line is drawn, the Pencil's own cursor. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 303.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, end, shift), Cursor::Pencil, "while the line is drawn"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 450.0, 303.0).with_mods(shift)); + // Edit Selected Paths off: the press draws a new line. + t.set_option("editWithin", &json!(0.0)); + assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilStraight, "Edit Selected Paths off"); + let none = Selection::default(); + let cx_none = crate::testutil::cx(&d, &none, &p); + assert_eq!(GestureTool::new("pencil").cursor(&cx_none, end, shift), Cursor::PencilStraight, "nothing selected"); + assert_eq!(GestureTool::new("paintbrush").cursor(&cx, end, shift), Cursor::Paintbrush); + assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); +} + +/// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt +/// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. +#[test] +fn pencil_shift_line_ignores_alt_for_cursor_and_smoothing() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), shift_alt), Cursor::Pencil); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0).with_mods(shift)); + + let mut t = GestureTool::new("pencil"); + t.alt_smooth = true; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift_alt)); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(..)]), "{acts:?}"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 0.0).with_mods(shift_alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["points"].as_array().unwrap().len() == 2), "{acts:?}"); +} + +/// #3846: a Shift line dragged back to its press point has no line: the preview is cancelled +/// and the release commits nothing (not the previous end of the line). +#[test] +fn pencil_shift_line_back_at_the_press_point_draws_nothing() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + // Released back at the press point, and dragged back there first. + for back_in_drag in [false, true] { + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift)); + let mut acts = vec![]; + if back_in_drag { + // The shown line goes as soon as the pointer is back, not only at the release. + let back = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.2, 0.0).with_mods(shift)); + assert_eq!(back, vec![Action::Cancel]); + acts.extend(back); + } + acts.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.2, 0.0).with_mods(shift))); + assert!(acts.contains(&Action::Cancel), "{back_in_drag}: {acts:?}"); + assert!(!acts.contains(&Action::Commit) && !acts.iter().any(|a| matches!(a, Action::Preview(..))), "{back_in_drag}: {acts:?}"); + } +} + +/// #3799: Alt held at the press draws a straight Pencil line at any angle from the press point +/// to the pointer, previewed as it is dragged, and left open by a release with Alt still held; +/// with Shift too it keeps Shift's 45° steps. Alt pressed after the press keeps the stroke +/// freehand and closes it at the release, and the Paintbrush keeps following the hand. +#[test] +fn pencil_alt_at_the_press_draws_a_straight_line_at_any_angle() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Default::default() }; + let pts = |acts: &[Vec]| -> Vec> { + acts.iter() + .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) + .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) + .collect() + }; + let wavy = [(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)]; + let mut t = GestureTool::new("pencil"); + assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), alt), Cursor::PencilStraight, "before the press"); + let acts = drag_held(&mut t, &cx, &wavy, alt); + let all = pts(&acts); + assert_eq!(all.len(), 4, "{all:?}"); + assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); + assert_eq!(all[1][1], (50.0, -60.0), "the preview follows the pointer at its own angle"); + assert_eq!(all[3][1], (100.0, -84.0), "{all:?}"); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); + // While the line is drawn, Alt shows no closing ring. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, 10.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, Point::new(40.0, 10.0), alt), Cursor::Pencil); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, 10.0).with_mods(alt)); + // Shift with Alt: 45° steps. + let all = pts(&drag_held(&mut t, &cx, &wavy, Mods { shift: true, alt: true, ..Default::default() })); + let end = *all.last().unwrap().last().unwrap(); + assert!(((-end.1).atan2(end.0).to_degrees() - 45.0).abs() < 1e-9, "{end:?}"); + // Alt pressed after the press: freehand, closed at the release. + let acts = drag(&mut t, &cx, &wavy, alt); + assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}"); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}"); + // The Paintbrush with Alt held follows the hand and closes. + let mut b = GestureTool::new("paintbrush"); + let acts = drag_held(&mut b, &cx, &wavy, alt); + assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}"); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}"); +} + +/// #3799: with Alt key toggles to Smooth Tool on, Alt held at the press smooths as before and +/// draws no line. +#[test] +fn pencil_alt_with_alt_to_smooth_on_smooths_instead_of_drawing_a_line() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Default::default() }; + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(true)); + let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt); + assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}"); + assert!(matches!(&acts.last().unwrap()[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3)); +} + +/// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of +/// a selected open path it continues the path from that end to the pointer. +#[test] +fn pencil_alt_line_snaps_its_ends_and_continues_a_selected_path() { + let (mut d, _) = doc_with_rect(); + let alt = Mods { alt: true, ..Default::default() }; + let p = paint(); + { + let s = Selection::default(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("pencil"); + let acts = drag_held(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (198.0, 203.0)], alt); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 100.0], [200.0, 200.0]])); + } + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = GestureTool::new("pencil"); + let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 340.0), (470.0, 330.0)], alt); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); + assert_eq!(v["points"], json!([[400.0, 300.0], [470.0, 330.0]])); +} + +#[test] +fn pencil_alt_closes_and_continues_selected_path() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("paintbrush"); + let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods { alt: true, ..Default::default() }); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "brush")); + // A selected closed rect is not continued; an open path would be (see engine tests). + let mut s2 = Selection::default(); + s2.set([id]); + let cx2 = crate::testutil::cx(&d, &s2, &p); + let acts = drag(&mut t, &cx2, &[(100.0, 100.0), (90.0, 90.0)], Mods::default()); + assert!(matches!(&acts[1][1], Action::Preview(_, v) if v.get("extend").is_none())); +} + +/// Edit selected paths within 0 turns editing off: a stroke that starts exactly on an end of a +/// selected open path draws a new path; at 12 px it continues the path from that end. +#[test] +fn edit_selected_paths_within_0_draws_a_new_path_from_an_end() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + for tool in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(tool); + for (within, extends) in [(12.0, true), (0.0, false)] { + t.set_option("editWithin", &json!(within)); + for (x, end) in [(400.0, "end"), (300.0, "start")] { + let acts = drag(&mut t, &cx, &[(x, 300.0), (x + 20.0, 350.0), (x, 380.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; + if extends { + assert_eq!(v["extend"], json!({"id": id.0, "end": end}), "{tool} from the {end}"); + } else { + assert!(v.get("extend").is_none() && v.get("redraw").is_none(), "{tool} from the {end}: {v}"); + } + } + } + } +} + +/// A Pencil stroke from an end of a selected open path to an end of another selected open path +/// connects the two; it does not when the other path is not selected, and the Paintbrush does +/// not connect. +#[test] +fn pencil_connects_a_stroke_from_one_selected_path_to_an_end_of_another() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| { + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), + vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + let a = add(&mut d, (300.0, 300.0), (400.0, 300.0)); + let b = add(&mut d, (500.0, 400.0), (600.0, 400.0)); + let p = paint(); + let stroke = [(400.0, 300.0), (450.0, 350.0), (499.0, 401.0)]; + let mut both = Selection::default(); + both.set([a, b]); + let mut one = Selection::default(); + one.set([a]); + // The previews select the path continued: after the press only it is selected. + let last = |tool: &str, at_press: &Selection| { + let mut t = GestureTool::new(tool); + t.pointer(&cx(&d, at_press, &p), &PointerEvent::new(PointerKind::Down, stroke[0].0, stroke[0].1)); + let after = cx(&d, &one, &p); + t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[1].0, stroke[1].1)); + t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[2].0, stroke[2].1)); + let acts = t.pointer(&after, &PointerEvent::new(PointerKind::Up, stroke[2].0, stroke[2].1)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + v.clone() + }; + let v = last("pencil", &both); + assert_eq!((v["extend"].clone(), v["connect"].clone()), (json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}))); + assert!(last("paintbrush", &both).get("connect").is_none(), "the Paintbrush"); + let v = last("pencil", &one); + assert!(v.get("extend").is_some() && v.get("connect").is_none(), "{v}"); +} + +/// Close paths when ends are within 0 turns closing off: a stroke that ends exactly where it +/// started stays open; at 15 px it is closed. +#[test] +fn close_paths_within_0_leaves_a_stroke_that_ends_at_its_start_open() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + for tool in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(tool); + for (within, closes) in [(15.0, true), (0.0, false)] { + t.set_option("closeWithin", &json!(within)); + let acts = drag(&mut t, &cx, &[(310.0, 330.0), (360.0, 350.0), (330.0, 390.0), (310.0, 330.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; + assert_eq!(v["points"][0], v["points"][3], "{tool}"); + assert_eq!(v["closed"], json!(closes), "{tool} at {within} px"); + } + } +} + +/// While a stroke is drawn back near its start, the cursor shows the closing ring when a +/// release there closes the path (Close Paths When Ends Are Within), and the tool's own cursor +/// away from the start, at 0 pixels and after the release. +#[test] +fn a_stroke_near_its_start_shows_the_closing_cursor() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let none = Mods::default(); + let tools = [("pencil", Cursor::Pencil, Cursor::PencilClose), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose)]; + for (tool, own, closing) in tools { + for within in [15.0, 0.0] { + let near = if within > 0.0 { closing } else { own }; + let mut t = GestureTool::new(tool); + t.set_option("closeWithin", &json!(within)); + let mut at = |k, x, y| { + let acts = t.pointer(&cx, &PointerEvent::new(k, x, y)); + (acts, t.cursor(&cx, Point::new(x, y), none)) + }; + assert_eq!(at(PointerKind::Down, 310.0, 330.0).1, own, "{tool} at the press"); + assert_eq!(at(PointerKind::Drag, 360.0, 350.0).1, own, "{tool} away from the start"); + assert_eq!(at(PointerKind::Drag, 330.0, 390.0).1, own, "{tool} away from the start"); + assert_eq!(at(PointerKind::Drag, 318.0, 335.0).1, near, "{tool} near the start at {within} px"); + assert_eq!(at(PointerKind::Drag, 345.0, 340.0).1, own, "{tool} away from the start again"); + assert_eq!(at(PointerKind::Drag, 318.0, 335.0).1, near, "{tool} back near the start at {within} px"); + let (acts, after) = at(PointerKind::Up, 318.0, 335.0); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{tool}: {acts:?}") }; + assert_eq!(v["closed"], json!(within > 0.0), "{tool}: the release does what the cursor showed"); + assert_eq!(after, if tool == "pencil" { Cursor::PencilNew } else { own }, "{tool} after the release"); + } + } + // A straight Pencil line (Shift or Alt held at the press) stays open, so it shows no ring + // near its start. + for held in [Mods { shift: true, ..Mods::default() }, Mods { alt: true, ..Mods::default() }] { + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 310.0, 330.0).with_mods(held)); + for (x, y) in [(360.0, 330.0), (330.0, 390.0), (318.0, 335.0)] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(held)); + } + for m in [held, none] { + assert_eq!(t.cursor(&cx, Point::new(318.0, 335.0), m), Cursor::Pencil, "a straight line, {held:?}, {m:?}"); + } + } +} + +/// A Paintbrush sample under a pixel from the last point is dropped, and a release there keeps +/// the last point: the closing cursor follows that point, not the pointer. +#[test] +fn the_paintbrush_closing_cursor_follows_the_point_a_release_keeps() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let none = Mods::default(); + let mut t = GestureTool::new("paintbrush"); + t.set_option("closeWithin", &json!(15.0)); + for (k, x, y) in [(PointerKind::Down, 310.0, 330.0), (PointerKind::Drag, 360.0, 350.0), (PointerKind::Drag, 330.0, 390.0)] { + t.pointer(&cx, &PointerEvent::new(k, x, y)); + } + // The last point kept is 15.5 from the start, outside Close Paths When Ends Are Within. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 325.5, 330.0)); + assert_eq!(t.cursor(&cx, Point::new(325.5, 330.0), none), Cursor::Paintbrush, "outside the closing distance"); + // The pointer moves 0.7 to 14.8 from the start; the sample is dropped. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 324.8, 330.0)); + assert_eq!(t.cursor(&cx, Point::new(324.8, 330.0), none), Cursor::Paintbrush, "the dropped sample shows no ring"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 324.8, 330.0)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["closed"], json!(false), "the release keeps the last point and leaves the path open"); +} + +#[test] +fn eraser_and_knife_exec_on_release() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("eraser"); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(acts[1].is_empty()); + assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 10.0)); + let mut k = GestureTool::new("knife"); + k.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + k.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + assert_eq!(k.overlays(&cx).len(), 1); + let a = k.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3)); +} + +/// The Eraser's Angle and Roundness (Eraser Tool Options) go with its stroke, kept within +/// −180–180° and 1–100%; while it drags, a tip that is not round shows the outline of the area +/// it covers, and a round one the drag as wide as the tip. +#[test] +fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("eraser"); + t.set_option("size", &json!(20)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let round = t.overlays(&cx); + assert!(matches!(&round[..], [Overlay::Path { width, .. }] if *width == 20.0), "{round:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + for (angle, roundness, sent) in [(90.0, 25.0, (90.0, 25.0)), (400.0, 0.0, (180.0, 1.0)), (-400.0, 250.0, (-180.0, 100.0))] { + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(sent.0), Some(sent.1))); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!( + matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 20.0 && v["angle"] == sent.0 && v["roundness"] == sent.1), + "{acts:?}" + ); + } + // A tip 20 pt long and 5 pt wide, standing upright: along a horizontal drag from x 50 to + // x 150 the outline reaches 2.5 pt either side of the drag and 10 pt above and below + // its ends. + t.set_option("angle", &json!(90)); + t.set_option("roundness", &json!(25)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let o = t.overlays(&cx); + let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") }; + assert_eq!(*width, 1.0); + let b = vectorcraft_geom::Shape::bounding_box(path); + assert!((b.x0 - 47.5).abs() < 0.1 && (b.x1 - 152.5).abs() < 0.1, "{b:?}"); + assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}"); +} + +#[test] +fn smooth_needs_selection_and_brackets_resize() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("smooth"); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(acts.iter().all(|a| a.is_empty())); + let mut b = GestureTool::new("blobBrush"); + b.key(&cx, ToolKey::BracketRight, Mods::default()); + assert_eq!(b.options()["size"], 11.0); +} + +#[test] +fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + + // Off by default, as Pencil Tool Options ships it; on, Alt lends the Smooth tool. + let mut t = GestureTool::new("pencil"); + assert_eq!(t.options()["altSmooth"], json!(false)); + t.set_option("altSmooth", &json!(true)); + + // When alt is pressed, cursor shows smooth tool. + let alt_mod = Mods { alt: true, ..Default::default() }; + assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::Smooth); + assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), Mods::default()), Cursor::PencilNew); + + // Alt-drag over selected path smooths it. + let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], alt_mod); + assert!(acts[1].is_empty(), "drag does not preview freehand path while smoothing"); + let last = acts.last().unwrap(); + assert!( + matches!(&last[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3), + "expected path.smoothRegion, got: {last:?}" + ); + + // When altSmooth is disabled: + t.set_option("altSmooth", &json!(false)); + assert_eq!(t.options()["altSmooth"], json!(false)); + // Before a press, Alt gives a straight line rather than a closed path. + assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::PencilStraight); + + // A drag released with Alt held draws a closed pencil path. + let empty_sel = Selection::default(); + let cx_empty = crate::testutil::cx(&d, &empty_sel, &p); + let acts = drag(&mut t, &cx_empty, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], alt_mod); + assert_eq!(acts[1][0], Action::Begin("Pencil".into())); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "pencil")); + assert_eq!(last[1], Action::Commit); +} From 327cb2656c63e2ca3f1dd0e435fbf3bd831214ac Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:37 +0000 Subject: [PATCH 027/199] refactor(pathops): move planar.rs tests to planar/tests.rs --- crates/pathops/src/planar.rs | 1024 +--------------------------- crates/pathops/src/planar/tests.rs | 1021 +++++++++++++++++++++++++++ 2 files changed, 1022 insertions(+), 1023 deletions(-) create mode 100644 crates/pathops/src/planar/tests.rs diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index b0492e4bf..c53d3951d 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -1597,1026 +1597,4 @@ fn interior_point_within(path: &PathData, tolerance: f64) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - use vectorcraft_geom::{Rect, shapes}; - - /// Sparse crossings add, compare and read like the dense counts they stand for. - #[test] - fn crossings_are_sparse_counts() { - let mut c = Crossings::single(7, true); - c += Crossings::single(2, false); - c += Crossings::single(7, true); - assert_eq!((c.get(14), c.get(5), c.get(15), c.get(0)), (2, 1, 0, 0)); - assert_eq!(c.0.len(), 2, "only the slots that aren't 0"); - assert_eq!(c.of_tag(7), Crossings(vec![(14, 2)])); - assert_eq!(Crossings::single(2, false) + Crossings::single(7, true) + Crossings::single(7, true), c); - assert_eq!(Crossings::default() + Crossings::default(), Crossings::default()); - } - - fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape { - Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key) - } - - fn rect(x0: f64, y0: f64, x1: f64, y1: f64, key: u64) -> Shape { - Shape::new(shapes::rectangle(Rect::new(x0, y0, x1, y1)), FillRule::NonZero, key) - } - - fn area(r: &Region) -> f64 { - r.path.to_bezpath().area() - } - - fn at(faces: &[Region], x: f64, y: f64) -> Vec<&Region> { - faces.iter().filter(|f| f.contains(Point::new(x, y))).collect() - } - - #[test] - fn crossing_lines_enclose_a_face() { - // Three lines crossing pairwise, their ends sticking out: one triangle. - let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 1), line((-10.0, 80.0), (110.0, 80.0), 2)]; - let (faces, edges) = live_paint(&s); - assert_eq!(faces.len(), 1, "{faces:?}"); - let f = &faces[0]; - assert!((area(f) - 900.0).abs() < 1e-6, "triangle (20,80) (50,50) (80,80): {}", area(f)); - assert!(f.sources.is_empty(), "nothing fills it yet"); - assert_eq!(f.path.subpaths.len(), 1); - assert_eq!(f.path.subpaths[0].anchors.len(), 3, "the loose ends are not part of it"); - // Each line is cut where the others cross it: 3 + 3 + 3 pieces. - assert_eq!(edges.len(), 9); - assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3); - } - - fn open(points: &[(f64, f64)], key: u64) -> Shape { - let pts: Vec = points.iter().map(|&(x, y)| Point::new(x, y)).collect(); - Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, key) - } - - #[test] - fn gap_detection_closes_a_square_drawn_with_a_gap() { - // One open path round a square, its end 2 pt short of its start. - let s = [open(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 2.0)], 0)]; - assert!(live_paint(&s).0.is_empty(), "paint leaks through the gap"); - let (faces, edges, gaps) = live_paint_with_gaps(&s, 3.0); - assert_eq!(gaps.len(), 1, "{gaps:?}"); - let g = gaps[0]; - assert!(g[0].distance(Point::new(0.0, 0.0)) < 1e-9 && g[1].distance(Point::new(0.0, 2.0)) < 1e-6, "{g:?}"); - assert_eq!(faces.len(), 1, "{faces:?}"); - assert!((area(&faces[0]) - 10000.0).abs() < 1e-6, "{}", area(&faces[0])); - assert_eq!(edges, live_paint(&s).1, "the gap adds no edge"); - // A gap wider than the size stays open. - let (faces, _, gaps) = live_paint_with_gaps(&s, 1.5); - assert!(faces.is_empty() && gaps.is_empty()); - } - - #[test] - fn gaps_close_to_curves_and_between_paths() { - // A line across a circle, each end 2 pt short of it: the circle splits in two. - let circle = Shape::new(shapes::ellipse(Rect::new(50.0, 0.0, 150.0, 100.0)), FillRule::NonZero, 0); - let s = [circle, line((52.0, 50.0), (148.0, 50.0), 1)]; - assert_eq!(live_paint(&s).0.len(), 1); - let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); - assert_eq!(gaps.len(), 2, "{gaps:?}"); - for g in &gaps { - assert!((g[0].distance(g[1]) - 2.0).abs() < 1e-6, "{g:?}"); - } - assert_eq!(faces.len(), 2, "{faces:?}"); - assert_eq!(at(&faces, 100.0, 25.0).len(), 1); - assert_eq!(at(&faces, 100.0, 75.0).len(), 1); - // Four lines round a square whose corners miss by 1 pt: the gaps are listed once each. - let s = [ - line((0.0, 0.0), (99.0, 0.0), 0), - line((100.0, 1.0), (100.0, 99.0), 1), - line((99.0, 100.0), (1.0, 100.0), 2), - line((0.0, 99.0), (0.0, 1.0), 3), - ]; - let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); - assert_eq!(gaps.len(), 4, "{gaps:?}"); - assert_eq!(faces.len(), 1, "{faces:?}"); - // One curve that comes back round to 2 pt from its start. - let mut bp = BezPath::new(); - bp.move_to((0.0, 0.0)); - bp.curve_to((100.0, 0.0), (100.0, 100.0), (0.0, 2.0)); - let s = [Shape::new(PathData::from_bezpath(&bp), FillRule::NonZero, 0)]; - let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); - assert_eq!(gaps.len(), 1, "{gaps:?}"); - assert!((gaps[0][0].distance(gaps[0][1]) - 2.0).abs() < 1e-9, "{gaps:?}"); - assert_eq!(faces.len(), 1, "{faces:?}"); - // A line far longer than the gap, and many lines far from each other. - let mut s = vec![line((0.0, 0.0), (100000.0, 0.0), 0), line((50000.0, 2.0), (50000.0, 100.0), 1)]; - s.extend((0..2000).map(|k| { - let (x, y) = (f64::from(k % 50) * 40.0, 1000.0 + f64::from(k / 50) * 40.0); - line((x, y), (x + 10.0, y), 2) - })); - let gaps = find_gaps(&s, 3.0); - assert_eq!(gaps.len(), 1, "{gaps:?}"); - assert!(gaps[0][0].distance(Point::new(50000.0, 2.0)) < 1e-9 && gaps[0][1].distance(Point::new(50000.0, 0.0)) < 1e-9, "{gaps:?}"); - // Paths far from the origin: one gap between two loose ends, listed once. - let s = [line((1e20, 0.0), (1e20, 100.0), 0), line((1e20, 102.0), (1e20, 200.0), 1)]; - assert_eq!(find_gaps(&s, 3.0).len(), 1, "{:?}", find_gaps(&s, 3.0)); - } - - #[test] - fn overshoots_and_joined_ends_are_no_gaps() { - // Ends that cross a line just before they stop, square to it and at 30 degrees. - let t = 30f64.to_radians(); - let s = [ - line((-10.0, 0.0), (110.0, 0.0), 0), - line((20.0, 100.0), (20.0, -1.0), 1), - line((80.0 + 100.0 * t.cos(), 100.0 * t.sin()), (80.0 - t.cos(), -t.sin()), 2), - ]; - assert!(find_gaps(&s, 3.0).is_empty(), "{:?}", find_gaps(&s, 3.0)); - // An end on another path, or on its own, is joined there. - let s = [line((0.0, 0.0), (100.0, 0.0), 0), line((50.0, 0.0), (50.0, 100.0), 1)]; - assert!(find_gaps(&s, 3.0).is_empty(), "{:?}", find_gaps(&s, 3.0)); - // Another path 2 pt away does not make a gap from the joined end. - let s = [line((0.0, 0.0), (100.0, 0.0), 0), line((50.0, 0.0), (50.0, 100.0), 1), line((40.0, -2.0), (60.0, -2.0), 2)]; - let gaps = find_gaps(&s, 3.0); - assert!(!gaps.is_empty() && gaps.iter().all(|g| g[0].distance(Point::new(50.0, 0.0)) > 1e-9), "{gaps:?}"); - let six = [open(&[(0.0, 0.0), (0.0, 100.0), (50.0, 100.0), (50.0, 50.0), (0.0, 50.0)], 0)]; - assert!(find_gaps(&six, 3.0).is_empty(), "{:?}", find_gaps(&six, 3.0)); - // Sizes that are not a length find nothing. - let square = [open(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 2.0)], 0)]; - for size in [0.0, -3.0, f64::NAN, f64::INFINITY] { - assert!(find_gaps(&square, size).is_empty(), "{size}"); - } - } - - #[test] - fn a_grid_of_lines_makes_its_cells() { - let mut s = vec![]; - for k in 0..4 { - let v = f64::from(k) * 10.0; - s.push(line((v, -5.0), (v, 35.0), 0)); - s.push(line((-5.0, v), (35.0, v), 1)); - } - let (faces, _) = live_paint(&s); - assert_eq!(faces.len(), 9); - assert!(faces.iter().all(|f| (area(f) - 100.0).abs() < 1e-6)); - assert_eq!(at(&faces, 15.0, 15.0).len(), 1); - } - - #[test] - fn a_line_across_a_shape_splits_it() { - let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)]; - let (faces, edges) = live_paint(&s); - assert_eq!(faces.len(), 2); - assert!(faces.iter().all(|f| f.sources == [0]), "both halves keep the rectangle's fill"); - assert!((faces.iter().map(area).sum::() - 5000.0).abs() < 1e-6); - assert_eq!(at(&faces, 10.0, 25.0).len(), 1); - assert_eq!(at(&faces, 90.0, 25.0).len(), 1); - // The rectangle's outline in two pieces, the line in three. - assert_eq!(edges.iter().filter(|e| e.key == 7).count(), 2); - assert_eq!(edges.iter().filter(|e| e.key == 8).count(), 3); - } - - #[test] - fn overlapping_shapes_keep_their_coverage() { - let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; - let (faces, _) = live_paint(&s); - assert_eq!(faces.len(), 3); - assert_eq!(at(&faces, 25.0, 50.0)[0].sources, [0]); - assert_eq!(at(&faces, 75.0, 50.0)[0].sources, [0, 1]); - assert_eq!(at(&faces, 125.0, 50.0)[0].sources, [1]); - // Same faces as the Pathfinder regions. - let regions = crate::regions(&s); - assert_eq!(regions.len(), 3); - for f in &faces { - assert!(regions.iter().any(|r| r.sources == f.sources && (area(r).abs() - area(f)).abs() < 1e-6)); - } - } - - #[test] - fn a_closed_loop_of_lines_inside_a_shape_is_a_hole_and_a_face() { - let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)]; - let sq = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0), (40.0, 40.0)]; - for w in sq.windows(2) { - s.push(line(w[0], w[1], 1)); - } - // A loose line inside: no face of its own, no hole. - s.push(line((10.0, 10.0), (20.0, 20.0), 2)); - let (faces, _) = live_paint(&s); - assert_eq!(faces.len(), 2, "{faces:?}"); - let outer = at(&faces, 5.0, 5.0); - assert_eq!(outer.len(), 1); - assert_eq!(outer[0].path.subpaths.len(), 2, "the inner square is a hole in it"); - assert!((area(outer[0]) - 9600.0).abs() < 1e-6); - let inner = at(&faces, 50.0, 50.0); - assert_eq!(inner.len(), 1, "the hole doesn't count as the outer face"); - assert_eq!(inner[0].sources, [0], "the rectangle fills it"); - assert!((area(inner[0]) - 400.0).abs() < 1e-6); - } - - #[test] - fn an_area_enclosed_by_shapes_is_an_unfilled_face() { - // A frame of four bars around a 50 × 50 gap. - let s = [rect(0.0, 0.0, 70.0, 10.0, 0), rect(0.0, 60.0, 70.0, 70.0, 0), rect(0.0, 0.0, 10.0, 70.0, 0), rect(60.0, 0.0, 70.0, 70.0, 0)]; - let (faces, _) = live_paint(&s); - let gap = at(&faces, 35.0, 35.0); - assert_eq!(gap.len(), 1); - assert!(gap[0].sources.is_empty()); - assert!((area(gap[0]) - 2500.0).abs() < 1e-6); - } - - #[test] - fn curves_and_a_circle_crossed_by_a_line() { - let circle = Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), FillRule::NonZero, 0); - let (faces, edges) = live_paint(&[circle, line((-10.0, 50.0), (110.0, 50.0), 1)]); - assert_eq!(faces.len(), 2); - for f in &faces { - assert!((area(f) - std::f64::consts::PI * 2500.0 / 2.0).abs() < 2.0, "{}", area(f)); - assert!(f.path.subpaths[0].anchors.len() <= 4, "no extra anchors from the sweep's splits"); - } - assert_eq!(edges.iter().filter(|e| e.key == 0).count(), 2, "two arcs"); - } - - #[test] - fn a_line_ending_on_an_edge_splits_the_shape() { - let s = [line((90.0, 20.0), (70.0, 10.0), 0), rect(80.0, 10.0, 90.0, 50.0, 1)]; - let (faces, _) = live_paint(&s); - assert_eq!(faces.len(), 2); - assert!((area(&faces[0]) + area(&faces[1]) - 400.0).abs() < 1e-6); - for f in &faces { - let p = crate::interior_point(&f.path).unwrap(); - assert_eq!(at(&faces, p.x, p.y).len(), 1, "{p:?}"); - assert_eq!(f.sources, [1]); - } - } - - #[test] - fn nothing_or_garbage_gives_an_empty_map() { - assert_eq!(live_paint(&[]), (vec![], vec![])); - assert!(live_paint(&[line((0.0, 0.0), (f64::NAN, 1.0), 0)]).0.is_empty()); - let (faces, edges) = live_paint(&[line((0.0, 0.0), (10.0, 10.0), 0)]); - assert!(faces.is_empty()); - assert_eq!(edges.len(), 1); - } - - /// A strip merged across an axonometric grid of lines (#893, the reporter's file): faces meeting - /// at a vertex share its exact coordinates, so the merge is one outline with no edge left inside. - #[test] - fn faces_share_their_corners_exactly_so_merging_them_leaves_no_seam() { - const LINES: &[[f64; 4]] = &[ - [416.57797027866155, 268.45362144397683, 416.5779702786616, 811.5463785560231], - [243.12292300414873, 511.1134238473054, 713.4550472745127, 782.6598024033286], - [836.8770769958512, 511.1134238473054, 366.5449527254871, 782.6598024033286], - [457.718646852441, 268.45362144397683, 457.718646852441, 811.5463785560231], - [263.6932612910385, 475.4845528055331, 734.0253855614023, 747.0309313615563], - [816.3067387089613, 475.4845528055331, 345.9746144385975, 747.0309313615563], - [498.85932342622044, 268.45362144397683, 498.85932342622044, 811.5463785560231], - [284.26359957792823, 439.8556817637607, 754.5957238482922, 711.4020603197839], - [795.7364004220717, 439.8556817637607, 325.40427615170773, 711.4020603197839], - [539.9999999999998, 268.45362144397683, 539.9999999999998, 811.5463785560231], - [304.83393786481787, 404.22681072198844, 775.1660621351818, 675.7731892780117], - [775.1660621351821, 404.22681072198844, 304.833937864818, 675.7731892780117], - [581.1406765737793, 268.45362144397683, 581.1406765737793, 811.5463785560231], - [325.4042761517076, 368.59793968021614, 795.7364004220715, 640.1443182362394], - [754.5957238482922, 368.59793968021614, 284.26359957792835, 640.1443182362394], - [622.2813531475588, 268.45362144397683, 622.2813531475588, 811.5463785560231], - [345.97461443859737, 332.9690686384438, 816.3067387089613, 604.515447194467], - [734.0253855614026, 332.9690686384438, 263.6932612910385, 604.515447194467], - [663.422029721338, 268.45362144397683, 663.422029721338, 811.5463785560231], - [366.54495272548706, 297.3401975966715, 836.877076995851, 568.8865761526947], - [713.4550472745128, 297.3401975966715, 243.12292300414884, 568.8865761526947], - ]; - let s: Vec = LINES.iter().enumerate().map(|(i, &[x0, y0, x1, y1])| line((x0, y0), (x1, y1), i as u64)).collect(); - let (faces, _) = live_paint(&s); - let corners: Vec = faces.iter().flat_map(|f| f.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect(); - for p in &corners { - for q in &corners { - assert!(p == q || p.distance(*q) > 1e-6, "one corner, two positions: {p:?} {q:?}"); - } - } - // Down the strip between the lines x ≈ 498.9 and x = 540: 11 triangles, one outline. - let mut strip: Vec<&Region> = vec![]; - for k in 0..=240 { - if let Some(f) = faces.iter().find(|f| f.contains(Point::new(519.43, 420.0 + f64::from(k)))) - && !strip.iter().any(|g| std::ptr::eq(*g, f)) - { - strip.push(f); - } - } - assert_eq!(strip.len(), 11); - // As the Shape Builder merges them: in the map, so no sweep can round the corners apart. - let merged = crate::FaceMerger::new(&s).merge(&strip); - assert_eq!(merged.subpaths.len(), 1, "{merged:?}"); - let want: f64 = strip.iter().map(|f| area(f)).sum(); - assert!((crate::area(&merged, FillRule::NonZero) - want).abs() < 1e-6 * want); - } - - /// Merging faces of a grid of lines in the map: a ring keeps its hole, cells that only touch at - /// a corner stay two outlines, a line's loose end inside the merge leaves no spur. - #[test] - fn faces_merge_in_the_map_with_holes_corners_and_loose_ends() { - let mut s = vec![]; - for k in 0..4 { - let v = f64::from(k) * 10.0; - s.push(line((v, -5.0), (v, 35.0), 0)); - s.push(line((-5.0, v), (35.0, v), 1)); - } - // A loose end poking into the middle cell. - s.push(line((15.0, 10.0), (15.0, 15.0), 2)); - let (faces, _) = live_paint(&s); - let cell = |x: f64, y: f64| at(&faces, x, y)[0]; - let merger = crate::FaceMerger::new(&s); - let m = map(&s, true).unwrap(); - let check = |picked: &[&Region], subpaths: usize, area_want: f64| { - // Merged in the map itself, not by the fallback union. - let inside: Vec = picked.iter().map(|f| interior_point(&f.path).unwrap()).collect(); - assert_eq!(m.merge(&inside, &crate::pathfinder::cover(picked, s.len()), None).as_ref(), Some(&merger.merge(picked))); - let m = merger.merge(picked); - assert_eq!(m.subpaths.len(), subpaths, "{m:?}"); - assert!((crate::area(&m, FillRule::NonZero) - area_want).abs() < 1e-6, "{}", crate::area(&m, FillRule::NonZero)); - m - }; - let all: Vec<&Region> = faces.iter().collect(); - let whole = check(&all, 1, 900.0); - assert_eq!(whole.subpaths[0].anchors.len(), 4, "one square, no anchors left from the inner lines or the loose end"); - let ring: Vec<&Region> = - [(5., 5.), (15., 5.), (25., 5.), (5., 15.), (25., 15.), (5., 25.), (15., 25.), (25., 25.)].iter().map(|&(x, y)| cell(x, y)).collect(); - check(&ring, 2, 800.0); - check(&[cell(5.0, 5.0), cell(15.0, 15.0)], 2, 200.0); - } - - /// Merging in the map takes in a sliver next to the merged faces that only their shapes fill, - /// such as the overlap of two squares that overlap by 0.001, and leaves a thin cutout that - /// nothing fills as it is. - #[test] - fn faces_merge_in_the_map_with_the_slivers_their_shapes_fill() { - let far = line((400.0, 400.0), (450.0, 450.0), 2); - let s = [rect(100.0, 100.0, 200.0, 200.0, 0), rect(199.999, 100.0, 299.999, 200.0, 1), far.clone()]; - let regions = shape_builder(&s, false).regions; - assert_eq!(regions.len(), 2, "the overlap is a sliver: {regions:?}"); - let both: Vec<&Region> = regions.iter().collect(); - let merged = crate::FaceMerger::new(&s).merge(&both); - assert_eq!(merged.subpaths.len(), 1, "{merged:?}"); - assert!((crate::area(&merged, FillRule::NonZero) - 19999.9).abs() < 1e-6, "{}", crate::area(&merged, FillRule::NonZero)); - // Live Paint's map does not test which inputs cover the sliver. - let covered = |cover: bool| map(&s, cover).unwrap().slivers.into_iter().map(|(_, sources)| sources).collect::>(); - assert_eq!(covered(true), [vec![0, 1]]); - assert_eq!(covered(false), [Vec::::new()]); - // A square with a cutout 0.005 wide. - let mut frame = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); - frame.subpaths.extend(shapes::rectangle(Rect::new(50.0, 10.0, 50.005, 90.0)).subpaths); - let s = [Shape::new(frame, FillRule::EvenOdd, 0), far]; - let regions = shape_builder(&s, false).regions; - assert_eq!(regions.len(), 1, "{regions:?}"); - let merged = crate::FaceMerger::new(&s).merge(&[®ions[0]]); - assert_eq!(merged.subpaths.len(), 2, "the cutout stays: {merged:?}"); - assert!((crate::area(&merged, FillRule::EvenOdd) - 9999.6).abs() < 1e-6, "{}", crate::area(&merged, FillRule::EvenOdd)); - } - - #[test] - fn shape_builder_lines_cut_regions() { - // A line across a rectangle: two regions, the line in three pieces keyed by its index. - let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)]; - let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); - assert_eq!(regions.len(), 2); - assert!(regions.iter().all(|r| r.sources == [0])); - assert_eq!(lines.len(), 3); - assert!(lines.iter().all(|l| l.key == 1 && !l.path.is_closed())); - // Lines alone enclose a region nothing fills. - let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 0), line((-10.0, 80.0), (110.0, 80.0), 0)]; - let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); - assert_eq!(regions.len(), 1); - assert!(regions[0].sources.is_empty() && regions[0].top().is_none()); - assert_eq!(lines.len(), 9); - } - - #[test] - fn shape_builder_merges_adjacent_gap_closed_faces_without_keeping_bridges() { - let outline = Shape::new( - PathData::single(SubPath::polyline( - &[Point::new(0.0, 4.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0), Point::new(0.0, 0.0)], - false, - )), - FillRule::NonZero, - 0, - ); - let shapes = [outline, line((50.0, 0.0), (50.0, 100.0), 1)]; - let arr = shape_builder_with_gap(&shapes, true, 6.0); - assert_eq!(arr.regions.len(), 2); - let faces: Vec<_> = arr.regions.iter().collect(); - let merged = crate::FaceMerger::with_gap(&shapes, 6.0).merge(&faces); - assert!((crate::area(&merged, FillRule::NonZero).abs() - 10000.0).abs() < 1e-6); - assert_eq!(merged.subpaths.len(), 1, "the shared edge disappears"); - assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key < 2), "bridges are not art"); - // A request over 72 pt cannot close a gap of 80 pt. - let large = [Shape::new( - PathData::single(SubPath::polyline( - &[Point::new(0.0, 80.0), Point::new(0.0, 200.0), Point::new(200.0, 200.0), Point::new(200.0, 0.0), Point::new(0.0, 0.0)], - false, - )), - FillRule::NonZero, - 0, - )]; - assert!(shape_builder_with_gap(&large, false, 1000.0).regions.is_empty()); - } - - #[test] - fn shape_builder_gap_detection_closes_small_gaps() { - // Three sides of a 100 x 100 square, the fourth open by 4: no region without Gap Detection. - let open = |gap: f64| { - let pts = [(0.0, gap), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)]; - let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); - vec![Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0)] - }; - let s = open(4.0); - assert!(shape_builder(&s, false).regions.is_empty()); - assert!(shape_builder_with_gap(&s, false, 3.0).regions.is_empty(), "a gap longer than the length stays open"); - let arr = shape_builder_with_gap(&s, true, 6.0); - assert_eq!(arr.regions.len(), 1); - assert!((area(&arr.regions[0]) - 10000.0).abs() < 1e-6); - // The bridge is no art: the path comes back as it was, in its own pieces only. - assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key == 0)); - let len: f64 = arr.lines.iter().map(|p| p.path.to_bezpath().perimeter(1e-6)).sum(); - assert!((len - 396.0).abs() < 1e-6, "{len}"); - // Two paths whose ends miss each other: a U and a lid 3 above it. - let u = Shape::new( - PathData::single(SubPath::polyline( - &[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)], - false, - )), - FillRule::NonZero, - 0, - ); - let s = [u, line((0.0, -3.0), (100.0, -3.0), 1)]; - assert!(shape_builder_with_gap(&s, false, 2.0).regions.is_empty()); - let arr = shape_builder_with_gap(&s, false, 3.5); - assert_eq!(arr.regions.len(), 1); - assert!((area(&arr.regions[0]) - 10300.0).abs() < 1e-6); - // An end close to the side of another path bridges to the nearest point on it. - let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((50.0, 140.0), (50.0, 104.0), 1)]; - assert_eq!(shape_builder_with_gap(&s, false, 5.0).regions.len(), 1, "nothing is enclosed, nothing breaks"); - // Bad gaps are no gap. - let s = open(4.0); - for gap in [f64::NAN, f64::INFINITY, -3.0] { - assert!(shape_builder_with_gap(&s, false, gap).regions.is_empty()); - } - } - - #[test] - fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() { - let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; - assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() }); - // A broken line can't be swept and fills nothing: the filled areas still give their regions. - let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((0.0, 0.0), (f64::NAN, 1.0), 1)]; - assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s[..1]), ..Default::default() }); - // Zero-length and doubled-back lines don't break it either. - let s = [ - rect(0.0, 0.0, 100.0, 100.0, 0), - line((50.0, 50.0), (50.0, 50.0), 1), - line((0.0, 0.0), (100.0, 0.0), 2), - line((50.0, 0.0), (50.0, 100.0), 3), - ]; - let regions = shape_builder(&s, false).regions; - assert_eq!(regions.len(), 2); - assert!((regions.iter().map(area).sum::() - 10000.0).abs() < 1e-6); - } - - fn circle(cx: f64, cy: f64, r: f64, key: u64) -> Shape { - Shape::new(shapes::ellipse(Rect::new(cx - r, cy - r, cx + r, cy + r)), FillRule::NonZero, key) - } - - #[test] - fn shape_builder_offers_the_gaps_closed_shapes_enclose() { - // Four circles in a 2×2 grid, each touching its neighbours (#1691): the four circles and - // the diamond between them, which nothing fills. - let s = [circle(50.0, 50.0, 50.0, 0), circle(150.0, 50.0, 50.0, 1), circle(50.0, 150.0, 50.0, 2), circle(150.0, 150.0, 50.0, 3)]; - let regions = shape_builder(&s, false).regions; - assert_eq!(regions.len(), 5, "{regions:?}"); - let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect(); - assert_eq!(gaps.len(), 1); - assert!(gaps[0].contains(Point::new(100.0, 100.0)) && !gaps[0].contains(Point::new(50.0, 50.0))); - let diamond = 10000.0 - std::f64::consts::PI * 2500.0; - assert!((area(gaps[0]).abs() - diamond).abs() < 0.01 * diamond, "{}", area(gaps[0])); - assert_eq!(shape_builder(&s, true).regions, regions, "the outline edges don't change them"); - // A ring of four bars that overlap at the corners: the eight pieces of the bars and the - // square they enclose. - let s = - [rect(0.0, 0.0, 300.0, 100.0, 0), rect(200.0, 0.0, 300.0, 300.0, 1), rect(0.0, 200.0, 300.0, 300.0, 2), rect(0.0, 0.0, 100.0, 300.0, 3)]; - let regions = shape_builder(&s, false).regions; - assert_eq!(regions.len(), 9, "{regions:?}"); - let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect(); - assert_eq!(gaps.len(), 1); - assert!((area(gaps[0]).abs() - 10000.0).abs() < 1e-6 && gaps[0].contains(Point::new(150.0, 150.0))); - // The gap merges with a bar in the planar map: the bar's piece and the square, no seam. - let merger = crate::FaceMerger::new(&s); - let bar = regions.iter().find(|r| r.sources == [0] && r.contains(Point::new(150.0, 50.0))).unwrap(); - let merged = merger.merge(&[bar, gaps[0]]); - assert_eq!(merged.subpaths.len(), 1, "{merged:?}"); - assert!((crate::area(&merged, FillRule::NonZero) - 20000.0).abs() < 1e-6); - // Shapes apart or overlapping without a hole enclose no gap. - let s = [circle(50.0, 50.0, 50.0, 0), circle(250.0, 50.0, 50.0, 1), rect(80.0, 0.0, 220.0, 60.0, 2)]; - assert!(shape_builder(&s, false).regions.iter().all(|r| !r.sources.is_empty())); - } - - #[test] - fn shape_builder_stays_quick_up_to_its_cap() { - // A wavy ring of nearly the most segments the map takes, crossed by a few lines. - let n = SHAPE_BUILDER_MAX_SEGMENTS - 8; - let ring: Vec = (0..n) - .map(|k| { - let t = k as f64 / n as f64 * std::f64::consts::TAU; - let r = 100.0 + 5.0 * (t * 200.0).sin(); - Point::new(r * t.cos(), r * t.sin()) - }) - .collect(); - let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)]; - // Closed paths alone are swept for the gaps they enclose. - let started = std::time::Instant::now(); - assert_eq!(shape_builder(&s, false).regions.len(), 1, "the ring encloses no gap"); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); - for k in 0..4 { - let y = -60.0 + 40.0 * f64::from(k); - s.push(line((-150.0, y), (150.0, y + 7.0), 1)); - } - let started = std::time::Instant::now(); - let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); - assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)"); - assert!(lines.len() >= 12, "{}", lines.len()); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); - // Past the cap, only the filled areas count. - s.push(line((0.0, 0.0), (1.0, 1.0), 2)); - s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k))))); - assert!(shape_builder(&s, false).lines.is_empty()); - } - - #[test] - fn a_traced_outline_of_many_segments_is_cut_by_lines() { - // #937: art traced to thousands of segments was past the old cap (4096), so lines stopped - // cutting it and the Shape Builder offered the whole shapes. A blob of 20 000 segments - // crossed by two long lines (far wider than any of its segments: the index of the - // outlines keeps those apart) splits into four regions, each line in four pieces. - let n = 20_000; - let blob: Vec = (0..n) - .map(|k| { - let t = k as f64 / n as f64 * std::f64::consts::TAU; - let r = 100.0 + 5.0 * (t * 37.0).sin(); - Point::new(r * t.cos(), r * t.sin()) - }) - .collect(); - let s = [ - Shape::new(PathData::single(SubPath::polyline(&blob, true)), FillRule::NonZero, 0), - line((-150.0, 0.0), (150.0, 3.0), 1), - line((0.0, -150.0), (3.0, 150.0), 2), - ]; - let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); - assert_eq!(regions.len(), 4, "{}", regions.len()); - assert!(regions.iter().all(|r| r.sources == [0])); - let whole = crate::area(&s[0].path, FillRule::NonZero); - assert!((regions.iter().map(area).sum::() - whole).abs() < 1e-6 * whole); - assert_eq!(lines.iter().filter(|l| l.key == 1).count(), 4, "cut twice by the blob and once by the other line"); - assert_eq!(lines.iter().filter(|l| l.key == 2).count(), 4); - } - - #[test] - fn a_hatch_of_parallel_lines_is_cut_line_by_line() { - // Hundreds of long horizontal lines over a square, all alike in x (the weak axis of an index - // by x alone), and one vertical line: every strip is cut in two. - let n: u32 = 300; - let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)]; - for k in 1..n { - let y = 100.0 * f64::from(k) / f64::from(n); - s.push(line((-10.0, y), (110.0, y), u64::from(k))); - } - s.push(line((50.0, -10.0), (50.0, 110.0), u64::from(n))); - let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); - assert_eq!(regions.len(), 2 * n as usize, "{}", regions.len()); - assert!(regions.iter().all(|r| r.sources == [0])); - assert!((regions.iter().map(area).sum::() - 10000.0).abs() < 1e-6); - // Each horizontal line: a loose end outside, two pieces inside, a loose end outside. - assert_eq!(lines.iter().filter(|l| l.key == 1).count(), 4); - // The vertical line: cut by the square's sides and every hatch line. - assert_eq!(lines.iter().filter(|l| l.key == u64::from(n)).count(), n as usize + 2); - } - - #[test] - fn rect_index_finds_every_rectangle_holding_a_point() { - // Narrow in x, narrow in y only, and wide both ways: each lookup matches a scan of them all. - let mut items = vec![(kurbo::Rect::new(0.0, 0.0, 100.0, 100.0), 0usize)]; - for k in 1..50 { - let a = f64::from(k as u32) * 2.0; - items.push((kurbo::Rect::new(a, 0.0, a + 1.0, 100.0), k)); - items.push((kurbo::Rect::new(0.0, a, 100.0, a + 1.0), 100 + k)); - } - items.push((kurbo::Rect::new(f64::NAN, 0.0, 1.0, 1.0), 999)); - let index = RectIndex::new(items.clone()); - for p in [Point::new(4.5, 6.5), Point::new(50.0, 50.0), Point::new(-1.0, 3.0), Point::new(99.5, 0.5)] { - let mut got: Vec = index.at(p, 0.1).copied().collect(); - got.sort_unstable(); - let mut want: Vec = items.iter().filter(|(r, _)| r.inflate(0.1, 0.1).contains(p)).map(|(_, k)| *k).collect(); - want.sort_unstable(); - assert_eq!(got, want, "{p:?}"); - } - assert_eq!(RectIndex::::new(Vec::new()).at(Point::ZERO, 1.0).count(), 0); - } - - #[test] - fn rect_index_reads_a_diagonal_hatch_only_near_the_point() { - // 2,000 parallel diagonals a tenth of a point apart: the bounds of every line left of a - // point hold it, so an index of the bounds alone handed half the hatch to each lookup - // (quadratic in the hatch, #937). Filed by pieces, a lookup gets the lines of its - // neighbourhood (a few cells' worth), among them every line through the point. - let n = 2000; - let items: Vec<_> = (0..n) - .map(|k| { - let d = f64::from(k) * 0.1; - let s = PathSeg::Line(kurbo::Line::new((d, 0.0), (d + 200.0, 200.0))); - (s.bounding_box(), (s, k)) - }) - .collect(); - let index = RectIndex::with_footprint(items.clone(), |r, (s, _), cell| segment_footprint(r, s, cell)); - for p in [Point::new(100.0, 100.0), Point::new(130.25, 60.0), Point::new(250.0, 150.0), Point::new(399.9, 200.0)] { - let got: Vec = index.at(p, 0.1).map(|(_, k)| *k).collect(); - assert!(got.len() <= n as usize / 4, "{p:?}: {} candidates", got.len()); - let through: Vec = - items.iter().filter(|(_, (s, _))| kurbo::ParamCurveNearest::nearest(s, p, 1e-9).distance_sq <= 0.01).map(|(_, (_, k))| *k).collect(); - assert!(!through.is_empty() && through.iter().all(|k| got.contains(k)), "{p:?}: {through:?} vs {got:?}"); - } - // Faces a hatch cuts a square into: thin stripes across the whole grid, each filed under - // its own column of cells, so a lookup reads a few of them, not the hatch. - let stripes: Vec<_> = (0..n).map(|k| (kurbo::Rect::new(f64::from(k) * 0.1, 0.0, f64::from(k) * 0.1 + 0.1, 200.0), k)).collect(); - let index = RectIndex::new(stripes); - let got: Vec = index.at(Point::new(100.05, 50.0), 0.0).copied().collect(); - assert_eq!(got, [1000]); - assert!(index.rest.is_empty()); - // A footprint still finds a point on the item but off the grid's cells' edges, and a - // degenerate grid (collinear items) is divided along its length. - let line = PathSeg::Line(kurbo::Line::new((0.0, 0.0), (1000.0, 0.0))); - let items: Vec<_> = (0..10).map(|k| (line.bounding_box(), (line, k))).collect(); - let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell)); - assert_eq!(index.at(Point::new(333.3, 0.0), 0.1).count(), 10); - assert_eq!(index.at(Point::new(1000.0, 0.0), 0.1).count(), 10); - assert_eq!(index.at(Point::new(-5.0, 0.0), 0.1).count(), 0); - } - - /// The grid's memory stays linear in the item count: pieces of one segment falling in the same - /// cell file it once, and coincident segments (each along every cell of a one-column grid) go - /// to the scanned list once the grid holds its budget of references, and are still all found. - #[test] - fn rect_index_holds_a_bounded_number_of_references() { - // Ten collinear horizontal lines: a grid of 10 cells a hundred points wide and a hair tall, - // so each line's 4096 pieces fall about 400 per cell; filed once per cell. - let line = PathSeg::Line(kurbo::Line::new((0.0, 0.0), (1000.0, 0.0))); - let items: Vec<_> = (0..10).map(|k| (line.bounding_box(), (line, k))).collect(); - let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell)); - assert!(index.rest.is_empty(), "every line is in the grid, {} scanned", index.rest.len()); - assert!(index.cells.iter().all(|c| c.len() <= 10), "{:?}", index.cells.iter().map(Vec::len).collect::>()); - assert!(index.cells.iter().map(Vec::len).sum::() >= 10, "the lines are filed"); - assert_eq!(index.at(Point::new(500.0, 0.0), 0.1).count(), 10); - // 4,096 coincident vertical segments: one column, a row per segment, every segment along - // every row would be 16 million references; the budget keeps it to a few hundred per item. - let n = 4096u32; - let seg = PathSeg::Line(kurbo::Line::new((50.0, 0.0), (50.0, 200.0))); - let items: Vec<_> = (0..n).map(|k| (seg.bounding_box(), (seg, k))).collect(); - let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell)); - let references: usize = index.cells.iter().map(Vec::len).sum(); - assert!(references <= reference_budget(n as usize, index.cells.len()), "{references} references"); - assert!(references < 2_000_000, "{references} references"); - assert!(!index.rest.is_empty() && index.rest.len() < n as usize, "{} scanned", index.rest.len()); - let mut got: Vec = index.at(Point::new(50.0, 100.0), 0.1).map(|(_, k)| *k).collect(); - got.sort_unstable(); - assert_eq!(got.len(), n as usize); - assert_eq!(got, (0..n).collect::>()); - assert_eq!(index.at(Point::new(60.0, 100.0), 0.1).count(), 0); - } - - fn poly(pts: &[(f64, f64)], closed: bool) -> SubPath { - SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), closed) - } - - fn ends(sp: &SubPath) -> (Point, Point) { - (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p) - } - - /// Each piece cuts out of the input it is keyed by, losing just its own length (what erasing - /// it does), and none is a speck. - fn assert_pieces_cut_out(shapes: &[Shape], arr: &BuilderArrangement) { - for e in arr.edges.iter().chain(&arr.lines) { - let len = e.path.length(); - assert!(len > 1e-3, "a speck of a piece, keyed {}: {:?}", e.key, e.path); - let s = &shapes[e.key as usize]; - let cut = cut_out(&s.path, &e.path.subpaths[0], false, 0.05).unwrap_or_else(|| panic!("{:?} not on input {}", e.path, e.key)); - let lost = s.path.length() - cut.length(); - assert!((lost - len).abs() <= 1e-6 * s.path.length(), "input {} lost {lost} for a piece {len} long", e.key); - } - } - - #[test] - fn a_line_crossed_where_its_two_passes_part_is_cut_once() { - // The shrunk case of `pieces_cut_out_of_their_inputs`: the sweep cut the first line's way - // there and its way back by the third line a rounding error (1.2e-6) apart, joined by a - // stub. The line came back as a speck keyed by the third line and a piece running from - // the crossing out to (10, 70) and back to the other cut, which erasing took twice over. - let s = [line((10.0, 70.0), (80.0, 20.0), 0), line((10.0, 40.0), (70.0, 90.0), 0), line((20.0, 80.0), (20.0, 50.0), 0)]; - let arr = shape_builder(&s, true); - assert_pieces_cut_out(&s, &arr); - // The first line cut by both others, the other two once each. - let count = |k: u64| arr.lines.iter().filter(|l| l.key == k).count(); - assert_eq!((count(0), count(1), count(2)), (3, 2, 2), "{:?}", arr.lines); - let at = Point::new(20.0, 70.0 - 50.0 / 7.0); - for k in [0, 2] { - assert!(arr.lines.iter().filter(|l| l.key == k).all(|l| { - let (a, b) = ends(&l.path.subpaths[0]); - a.distance(b) > 1.0 && l.path.subpaths[0].anchors.len() == 2 - })); - assert_eq!( - arr.lines - .iter() - .filter(|l| l.key == k && [ends(&l.path.subpaths[0]).0, ends(&l.path.subpaths[0]).1].iter().any(|p| p.distance(at) < 1e-9)) - .count(), - 2 - ); - } - } - - #[test] - fn a_run_of_tiny_level_segments_is_not_contracted_as_stubs() { - // 2000 segments of 1e-4 (0.2 pt in all), nearly level: each one alone is as short as a - // stub the sweep leaves between two cuts, but chained they are real geometry, and the - // stub contraction must not pull them into one vertex. - let pts: Vec = (0..=2000).map(|k| Point::new(f64::from(k) * 1e-4, f64::from(k) * 1e-8)).collect(); - let run = Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0); - let whole = run.path.length(); - let s = [run, line((0.1003, -1.0), (0.1003, 1.0), 0)]; - let arr = shape_builder(&s, false); - let pieces: Vec<&Shape> = arr.lines.iter().filter(|l| l.key == 0).collect(); - assert_eq!(pieces.len(), 2, "cut once by the line: {}", pieces.len()); - let total: f64 = pieces.iter().map(|l| l.path.length()).sum(); - // Where the line crosses, one segment may be taken for a stub (it is as short as one), - // moving the cut by less than a stub's reach; the rest of the run stays. - assert!((total - whole).abs() <= DEFAULT_PRECISION / 16.0, "{total} of {whole}"); - for l in &pieces { - let (a, b) = ends(&l.path.subpaths[0]); - assert!(a.distance(b) > 0.09, "a piece collapsed: {a:?} {b:?}"); - } - assert_eq!(arr.lines.iter().filter(|l| l.key == 1).count(), 2); - } - - #[test] - fn a_dense_hatch_at_exact_binary_steps_cuts_each_line_once_per_crossing() { - // Lines every 0.25 or 0.5 pt (exact in binary) cut a crossing line or a circle where two - // passes of each line met it a rounding error apart, adding a speck and a piece there. - let hatch = |n: i32, step: f64, f: &dyn Fn(f64) -> Shape| -> Vec { (1..=n).map(|k| f(step * f64::from(k))).collect() }; - // Horizontal lines across a diagonal: each in two pieces, the diagonal in n + 1. - let mut s = vec![line((-3.0, -2.0), (33.0, 31.0), 0)]; - s.extend(hatch(119, 0.25, &|y| line((-5.0, y), (35.0, y), 0))); - let arr = shape_builder(&s, false); - assert_pieces_cut_out(&s, &arr); - assert_eq!(arr.lines.iter().filter(|l| l.key == 0).count(), 120); - assert!((1..=119).all(|k| arr.lines.iter().filter(|l| l.key == k).count() == 2)); - assert_eq!(arr.lines.len(), 120 + 2 * 119); - // Sloping lines across a 45° one. - let mut s = vec![line((-3.0, -3.0), (33.0, 33.0), 0)]; - s.extend(hatch(59, 0.5, &|v| line((v - 40.0, -5.0), (v + 40.0, 35.0), 0))); - let arr = shape_builder(&s, false); - assert_pieces_cut_out(&s, &arr); - assert_eq!(arr.lines.len(), 60 + 2 * 59, "{}", arr.lines.len()); - // Vertical lines across a circle: n + 1 strips, each line in three pieces, the circle's - // outline in two per line. - let mut s = vec![Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 30.0, 30.0)), FillRule::NonZero, 0)]; - s.extend(hatch(119, 0.25, &|x| line((x, -5.0), (x, 35.0), 0))); - let arr = shape_builder(&s, true); - assert_pieces_cut_out(&s, &arr); - assert_eq!(arr.regions.len(), 120); - assert!(arr.regions.iter().all(|r| r.sources == [0])); - assert!((1..=119).all(|k| arr.lines.iter().filter(|l| l.key == k).count() == 3)); - assert_eq!(arr.lines.len(), 3 * 119); - assert_eq!(arr.edges.len(), 2 * 119); - } - - #[test] - fn shape_builder_edges_are_the_outlines_cut_where_paths_meet() { - // Two overlapping squares: each outline in two pieces, cut where the other crosses it. - let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 50.0, 150.0, 150.0, 1)]; - let with = shape_builder(&s, true); - assert_eq!(with.regions, shape_builder(&s, false).regions, "the regions don't change"); - assert!(with.lines.is_empty() && shape_builder(&s, false).edges.is_empty()); - assert_eq!(with.edges.len(), 4); - for key in [0, 1] { - assert_eq!(with.edges.iter().filter(|e| e.key == key && !e.path.is_closed()).count(), 2); - } - let inner = with.edges.iter().find(|e| e.key == 0 && e.path.bounds() == Some(Rect::new(50.0, 50.0, 100.0, 100.0))).expect("A inside B"); - // Erasing it opens A there. - let cut = cut_out(&s[0].path, &inner.path.subpaths[0], false, 1e-3).expect("on A"); - assert_eq!(cut.subpaths.len(), 1); - let sp = &cut.subpaths[0]; - assert!(!sp.closed && sp.anchors.len() == 5, "{sp:?}"); - let (a, b) = ends(sp); - assert!([a, b].contains(&Point::new(100.0, 50.0)) && [a, b].contains(&Point::new(50.0, 100.0)), "{a:?} {b:?}"); - assert!((cut.length() - 300.0).abs() < 1e-6, "{}", cut.length()); - // Not on B. - assert_eq!(cut_out(&s[1].path, &inner.path.subpaths[0], false, 1e-3), None); - // A lone square's edge is its whole outline: erasing it leaves nothing. - let lone = shape_builder(&s[..1], true); - assert_eq!(lone.edges.len(), 1); - assert!(cut_out(&s[0].path, &lone.edges[0].path.subpaths[0], false, 1e-3).unwrap().is_empty()); - } - - #[test] - fn cut_out_splits_open_paths_and_keeps_closing_edges_invisible() { - let open = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0)], false)); - let cut = cut_out(&open, &poly(&[(60.0, 0.0), (30.0, 0.0)], false), false, 1e-3).unwrap(); - let mut parts: Vec<(Point, Point)> = cut.subpaths.iter().map(ends).collect(); - parts.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); - assert_eq!(parts, vec![(Point::new(0.0, 0.0), Point::new(30.0, 0.0)), (Point::new(60.0, 0.0), Point::new(100.0, 0.0))]); - // An end piece only shortens it. - let cut = cut_out(&open, &poly(&[(70.0, 0.0), (100.0, 0.0)], false), false, 1e-3).unwrap(); - assert_eq!(cut.subpaths.iter().map(ends).collect::>(), vec![(Point::new(0.0, 0.0), Point::new(70.0, 0.0))]); - - // A filled open path runs on round its invisible closing edge (100,100) → (0,0). - let corner = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false)); - let piece = poly(&[(100.0, 50.0), (100.0, 100.0), (50.0, 50.0)], false); - assert_eq!(cut_out(&corner, &piece, false, 1e-3), None, "without the fill it isn't on the path"); - let cut = cut_out(&corner, &piece, true, 1e-3).unwrap(); - assert_eq!(cut.subpaths.len(), 1); - assert_eq!(cut.subpaths[0].anchors.iter().map(|a| a.p).collect::>(), [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)].map(Point::from)); - // Cut elsewhere, the closing edge stays invisible: two open runs, not one through it. - let cut = cut_out(&corner, &poly(&[(20.0, 0.0), (60.0, 0.0)], false), true, 1e-3).unwrap(); - assert_eq!(cut.subpaths.len(), 2); - assert!(cut.subpaths.iter().all(|sp| !sp.closed)); - assert!((cut.length() - 160.0).abs() < 1e-6, "{}", cut.length()); - } - - #[test] - fn run_around_stops_at_the_stops_either_side() { - // A lone square's edge is its whole outline; with its corners as stops, a point by any side - // gives that side. - let lone = shape_builder(&[rect(0.0, 0.0, 100.0, 100.0, 0)], true); - let edge = &lone.edges[0].path.subpaths[0]; - let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(Point::from); - let side = |at: (f64, f64)| { - run_around(edge, &corners, Point::from(at), 1e-3) - .map(|sp| (sp.closed, PathData::single(sp.clone()).length(), PathData::single(sp).bounds())) - }; - assert_eq!(side((50.0, 1.0)), Some((false, 100.0, Some(Rect::new(0.0, 0.0, 100.0, 0.0))))); - for at in [(0.5, 50.0), (99.0, 50.0), (50.0, 100.0)] { - let (closed, length, _) = side(at).expect("a side"); - assert!(!closed && (length - 100.0).abs() < 1e-6, "{at:?}: {length}"); - } - // Without two stops on it, the run would be all of the outline. - assert_eq!(run_around(edge, &corners[..1], Point::new(50.0, 0.0), 1e-3), None); - // An open piece's run ends at the piece's own ends where no stop comes first; (40, 5) lies - // off the line and does not stop the run. - let line = poly(&[(0.0, 0.0), (100.0, 0.0)], false); - let run = run_around(&line, &[Point::new(40.0, 0.0), Point::new(40.0, 5.0)], Point::new(70.0, 0.0), 1e-3).unwrap(); - assert_eq!(ends(&run), (Point::new(40.0, 0.0), Point::new(100.0, 0.0))); - let run = run_around(&line, &[Point::new(40.0, 0.0)], Point::new(10.0, 0.0), 1e-3).unwrap(); - assert_eq!(ends(&run), (Point::new(0.0, 0.0), Point::new(40.0, 0.0))); - // A stop counts only where it is nearest the piece: the folded line's last anchor lies - // 0.02 from its first side and does not stop the run there. - let folded = poly(&[(0.0, 0.0), (100.0, 0.0), (50.0, 0.02)], false); - let stops = folded.anchors.iter().map(|a| a.p).collect::>(); - let run = run_around(&folded, &stops, Point::new(25.0, 0.0), 0.05).unwrap(); - assert_eq!(ends(&run), (Point::new(0.0, 0.0), Point::new(100.0, 0.0))); - } - - #[test] - fn run_around_a_ring_of_4000_sides_measures_only_the_stops_it_reaches() { - // Every anchor of the ring is a stop, so each run is one side. Measuring every stop along - // the whole ring would take 16 million nearest-point searches a call. - let n = 4000; - let pts: Vec<(f64, f64)> = (0..n) - .map(|k| { - let a = k as f64 * std::f64::consts::TAU / n as f64; - (500.0 * a.cos(), 500.0 * a.sin()) - }) - .collect(); - let ring = poly(&pts, true); - let stops: Vec = ring.anchors.iter().map(|a| a.p).collect(); - let start = std::time::Instant::now(); - for k in (0..n).step_by(200) { - let run = run_around(&ring, &stops, ring.segment(k).eval(0.5), 1e-3).expect("a side"); - assert_eq!(ends(&run), (stops[k], stops[(k + 1) % n]), "side {k}"); - } - assert!(start.elapsed().as_secs_f64() < 5.0, "20 runs took {:?}", start.elapsed()); - } - - /// Proptest shrank a failure of `pieces_cut_out_of_their_inputs` to this input with linesweeper - /// 0.5. The sweep cuts lines 0 and 1 at points of their crossing up to 1.4 `eps` apart, and - /// `vertex_id` merges a point only into a vertex within `eps` of it. Line 0's loose end in the - /// square is still one piece, and erasing it shortens line 0 by its length only. No face or - /// merged outline runs out along it and back. - #[test] - fn a_loose_end_is_one_piece_when_the_sweep_cuts_its_passes_apart() { - let s = [line((30.0, 20.0), (70.0, 0.0), 0), line((40.0, 0.0), (0.0, 90.0), 1), rect(10.0, 0.0, 40.0, 30.0, 2)]; - let arr = shape_builder(&s, true); - for key in 0..3 { - assert_eq!(arr.edges.iter().chain(&arr.lines).filter(|e| e.key == key).count(), 3, "pieces of {key}"); - } - for e in arr.edges.iter().chain(&arr.lines) { - let input = &s[e.key as usize].path; - let cut = cut_out(input, &e.path.subpaths[0], false, 0.05).expect("on its input"); - assert!((input.length() - cut.length() - e.path.length()).abs() < 1e-6, "{:?}", e.path); - } - // From (30,20) to the crossing at (220/7, 135/7). - let loose = arr.lines.iter().find(|l| l.path.subpaths[0].anchors.iter().any(|a| a.p == Point::new(30.0, 20.0))).expect("the loose end"); - assert_eq!(loose.path.subpaths[0].anchors.len(), 2, "{:?}", loose.path); - assert!((loose.path.length() - 2000f64.sqrt() / 28.0).abs() < 1e-6, "{}", loose.path.length()); - let (faces, _) = live_paint(&s); - let left = at(&faces, 15.0, 15.0); - assert_eq!(left.len(), 1); - assert_eq!(left[0].path.subpaths[0].anchors.len(), 4, "(10,0) (40,0) (26.67,30) (10,30): {:?}", left[0].path); - let merged = crate::FaceMerger::new(&s).merge(&faces.iter().collect::>()); - assert_eq!(merged.subpaths.len(), 1, "the square: {merged:?}"); - assert_eq!(merged.subpaths[0].anchors.len(), 4, "{merged:?}"); - } - - fn arb_segment() -> impl Strategy { - let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0); - prop_oneof![ - (c(), c(), c(), c()).prop_map(|(a, b, x, y)| line((a, b), (x, y), 0)), - (c(), c(), 1..5i32, 1..5i32).prop_map(|(x, y, w, h)| rect(x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0, 0)), - (c(), c(), 1..5i32).prop_map(|(x, y, r)| { - let r = f64::from(r) * 10.0; - Shape::new(shapes::ellipse(Rect::new(x, y, x + r, y + r)), FillRule::EvenOdd, 0) - }), - ] - } - - proptest! { - #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] - - /// Faces never overlap, and a face's sources are exactly the inputs whose fill holds a - /// point inside it. - #[test] - fn faces_are_disjoint_and_know_what_covers_them(shapes in prop::collection::vec(arb_segment(), 1..7)) { - let (faces, _) = live_paint(&shapes); - for f in &faces { - prop_assert!(area(f) > 0.0); - let Some(p) = crate::interior_point(&f.path) else { continue }; - prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1, "{:?} in more than one face", p); - let covering: Vec = shapes - .iter() - .enumerate() - .filter(|(_, s)| s.path.is_closed() && vectorcraft_geom::hit::fill_contains(&s.path.to_bezpath(), s.rule, p)) - .map(|(i, _)| i) - .collect(); - prop_assert_eq!(&f.sources, &covering, "at {:?}", p); - } - } - - /// Closed shapes alone: the filled faces cover exactly the Pathfinder regions. - #[test] - fn filled_faces_are_the_pathfinder_regions(shapes in prop::collection::vec(arb_segment(), 1..6)) { - let closed: Vec = shapes.into_iter().filter(|s| s.path.is_closed()).collect(); - let (faces, _) = live_paint(&closed); - let filled: f64 = faces.iter().filter(|f| !f.sources.is_empty()).map(area).sum(); - let regions: f64 = crate::regions(&closed).iter().map(|r| area(r).abs()).sum(); - // Both refit split curves to [`DEFAULT_PRECISION`], so curved areas may differ by up - // to that much along every outline. - let outlines: f64 = closed.iter().map(|s| s.path.to_bezpath().perimeter(1e-6)).sum(); - prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions); - } - - /// Every edge and line piece cuts out of the input it is keyed by, which loses its length - /// and nothing else. - #[test] - fn pieces_cut_out_of_their_inputs(shapes in prop::collection::vec(arb_segment(), 1..6)) { - let arr = shape_builder(&shapes, true); - for e in arr.edges.iter().chain(&arr.lines) { - let (Some(s), Some(piece)) = (shapes.get(e.key as usize), e.path.subpaths.first()) else { continue }; - let len = e.path.length(); - let cut = cut_out(&s.path, piece, false, 0.05); - if len <= 0.05 { - // A speck where two paths touch. - prop_assert!(cut.is_none()); - continue; - } - prop_assert!(cut.is_some(), "{:?} not on {:?}", piece, s.path); - let lost = s.path.length() - cut.map_or(0.0, |c| c.length()); - prop_assert!((lost - len).abs() <= 0.05 + 1e-3 * len, "lost {} for a piece {} long", lost, len); - } - } - - /// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics. - #[test] - fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) { - let lines: Vec = pts.into_iter().map(|(a, b, x, y)| line((a, b), (x, y), 0)).collect(); - let (faces, edges) = live_paint(&lines); - prop_assert!(edges.len() >= lines.len().min(1)); - for f in &faces { - prop_assert!(f.sources.is_empty()); - if let Some(p) = crate::interior_point(&f.path) { - prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1); - } - } - } - } - - #[test] - fn many_lines_stay_quick() { - // 60 lines through a 100 pt square: about 900 crossings. - let lines: Vec = (0..60) - .map(|k| { - let t = f64::from(k) * 0.37; - line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0) - }) - .collect(); - let started = std::time::Instant::now(); - let (faces, _) = live_paint(&lines); - assert!(faces.len() > 500, "{}", faces.len()); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); - } -} +mod tests; diff --git a/crates/pathops/src/planar/tests.rs b/crates/pathops/src/planar/tests.rs new file mode 100644 index 000000000..0d819c899 --- /dev/null +++ b/crates/pathops/src/planar/tests.rs @@ -0,0 +1,1021 @@ +use super::*; +use proptest::prelude::*; +use vectorcraft_geom::{Rect, shapes}; + +/// Sparse crossings add, compare and read like the dense counts they stand for. +#[test] +fn crossings_are_sparse_counts() { + let mut c = Crossings::single(7, true); + c += Crossings::single(2, false); + c += Crossings::single(7, true); + assert_eq!((c.get(14), c.get(5), c.get(15), c.get(0)), (2, 1, 0, 0)); + assert_eq!(c.0.len(), 2, "only the slots that aren't 0"); + assert_eq!(c.of_tag(7), Crossings(vec![(14, 2)])); + assert_eq!(Crossings::single(2, false) + Crossings::single(7, true) + Crossings::single(7, true), c); + assert_eq!(Crossings::default() + Crossings::default(), Crossings::default()); +} + +fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape { + Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key) +} + +fn rect(x0: f64, y0: f64, x1: f64, y1: f64, key: u64) -> Shape { + Shape::new(shapes::rectangle(Rect::new(x0, y0, x1, y1)), FillRule::NonZero, key) +} + +fn area(r: &Region) -> f64 { + r.path.to_bezpath().area() +} + +fn at(faces: &[Region], x: f64, y: f64) -> Vec<&Region> { + faces.iter().filter(|f| f.contains(Point::new(x, y))).collect() +} + +#[test] +fn crossing_lines_enclose_a_face() { + // Three lines crossing pairwise, their ends sticking out: one triangle. + let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 1), line((-10.0, 80.0), (110.0, 80.0), 2)]; + let (faces, edges) = live_paint(&s); + assert_eq!(faces.len(), 1, "{faces:?}"); + let f = &faces[0]; + assert!((area(f) - 900.0).abs() < 1e-6, "triangle (20,80) (50,50) (80,80): {}", area(f)); + assert!(f.sources.is_empty(), "nothing fills it yet"); + assert_eq!(f.path.subpaths.len(), 1); + assert_eq!(f.path.subpaths[0].anchors.len(), 3, "the loose ends are not part of it"); + // Each line is cut where the others cross it: 3 + 3 + 3 pieces. + assert_eq!(edges.len(), 9); + assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3); +} + +fn open(points: &[(f64, f64)], key: u64) -> Shape { + let pts: Vec = points.iter().map(|&(x, y)| Point::new(x, y)).collect(); + Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, key) +} + +#[test] +fn gap_detection_closes_a_square_drawn_with_a_gap() { + // One open path round a square, its end 2 pt short of its start. + let s = [open(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 2.0)], 0)]; + assert!(live_paint(&s).0.is_empty(), "paint leaks through the gap"); + let (faces, edges, gaps) = live_paint_with_gaps(&s, 3.0); + assert_eq!(gaps.len(), 1, "{gaps:?}"); + let g = gaps[0]; + assert!(g[0].distance(Point::new(0.0, 0.0)) < 1e-9 && g[1].distance(Point::new(0.0, 2.0)) < 1e-6, "{g:?}"); + assert_eq!(faces.len(), 1, "{faces:?}"); + assert!((area(&faces[0]) - 10000.0).abs() < 1e-6, "{}", area(&faces[0])); + assert_eq!(edges, live_paint(&s).1, "the gap adds no edge"); + // A gap wider than the size stays open. + let (faces, _, gaps) = live_paint_with_gaps(&s, 1.5); + assert!(faces.is_empty() && gaps.is_empty()); +} + +#[test] +fn gaps_close_to_curves_and_between_paths() { + // A line across a circle, each end 2 pt short of it: the circle splits in two. + let circle = Shape::new(shapes::ellipse(Rect::new(50.0, 0.0, 150.0, 100.0)), FillRule::NonZero, 0); + let s = [circle, line((52.0, 50.0), (148.0, 50.0), 1)]; + assert_eq!(live_paint(&s).0.len(), 1); + let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); + assert_eq!(gaps.len(), 2, "{gaps:?}"); + for g in &gaps { + assert!((g[0].distance(g[1]) - 2.0).abs() < 1e-6, "{g:?}"); + } + assert_eq!(faces.len(), 2, "{faces:?}"); + assert_eq!(at(&faces, 100.0, 25.0).len(), 1); + assert_eq!(at(&faces, 100.0, 75.0).len(), 1); + // Four lines round a square whose corners miss by 1 pt: the gaps are listed once each. + let s = [ + line((0.0, 0.0), (99.0, 0.0), 0), + line((100.0, 1.0), (100.0, 99.0), 1), + line((99.0, 100.0), (1.0, 100.0), 2), + line((0.0, 99.0), (0.0, 1.0), 3), + ]; + let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); + assert_eq!(gaps.len(), 4, "{gaps:?}"); + assert_eq!(faces.len(), 1, "{faces:?}"); + // One curve that comes back round to 2 pt from its start. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.curve_to((100.0, 0.0), (100.0, 100.0), (0.0, 2.0)); + let s = [Shape::new(PathData::from_bezpath(&bp), FillRule::NonZero, 0)]; + let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); + assert_eq!(gaps.len(), 1, "{gaps:?}"); + assert!((gaps[0][0].distance(gaps[0][1]) - 2.0).abs() < 1e-9, "{gaps:?}"); + assert_eq!(faces.len(), 1, "{faces:?}"); + // A line far longer than the gap, and many lines far from each other. + let mut s = vec![line((0.0, 0.0), (100000.0, 0.0), 0), line((50000.0, 2.0), (50000.0, 100.0), 1)]; + s.extend((0..2000).map(|k| { + let (x, y) = (f64::from(k % 50) * 40.0, 1000.0 + f64::from(k / 50) * 40.0); + line((x, y), (x + 10.0, y), 2) + })); + let gaps = find_gaps(&s, 3.0); + assert_eq!(gaps.len(), 1, "{gaps:?}"); + assert!(gaps[0][0].distance(Point::new(50000.0, 2.0)) < 1e-9 && gaps[0][1].distance(Point::new(50000.0, 0.0)) < 1e-9, "{gaps:?}"); + // Paths far from the origin: one gap between two loose ends, listed once. + let s = [line((1e20, 0.0), (1e20, 100.0), 0), line((1e20, 102.0), (1e20, 200.0), 1)]; + assert_eq!(find_gaps(&s, 3.0).len(), 1, "{:?}", find_gaps(&s, 3.0)); +} + +#[test] +fn overshoots_and_joined_ends_are_no_gaps() { + // Ends that cross a line just before they stop, square to it and at 30 degrees. + let t = 30f64.to_radians(); + let s = [ + line((-10.0, 0.0), (110.0, 0.0), 0), + line((20.0, 100.0), (20.0, -1.0), 1), + line((80.0 + 100.0 * t.cos(), 100.0 * t.sin()), (80.0 - t.cos(), -t.sin()), 2), + ]; + assert!(find_gaps(&s, 3.0).is_empty(), "{:?}", find_gaps(&s, 3.0)); + // An end on another path, or on its own, is joined there. + let s = [line((0.0, 0.0), (100.0, 0.0), 0), line((50.0, 0.0), (50.0, 100.0), 1)]; + assert!(find_gaps(&s, 3.0).is_empty(), "{:?}", find_gaps(&s, 3.0)); + // Another path 2 pt away does not make a gap from the joined end. + let s = [line((0.0, 0.0), (100.0, 0.0), 0), line((50.0, 0.0), (50.0, 100.0), 1), line((40.0, -2.0), (60.0, -2.0), 2)]; + let gaps = find_gaps(&s, 3.0); + assert!(!gaps.is_empty() && gaps.iter().all(|g| g[0].distance(Point::new(50.0, 0.0)) > 1e-9), "{gaps:?}"); + let six = [open(&[(0.0, 0.0), (0.0, 100.0), (50.0, 100.0), (50.0, 50.0), (0.0, 50.0)], 0)]; + assert!(find_gaps(&six, 3.0).is_empty(), "{:?}", find_gaps(&six, 3.0)); + // Sizes that are not a length find nothing. + let square = [open(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 2.0)], 0)]; + for size in [0.0, -3.0, f64::NAN, f64::INFINITY] { + assert!(find_gaps(&square, size).is_empty(), "{size}"); + } +} + +#[test] +fn a_grid_of_lines_makes_its_cells() { + let mut s = vec![]; + for k in 0..4 { + let v = f64::from(k) * 10.0; + s.push(line((v, -5.0), (v, 35.0), 0)); + s.push(line((-5.0, v), (35.0, v), 1)); + } + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 9); + assert!(faces.iter().all(|f| (area(f) - 100.0).abs() < 1e-6)); + assert_eq!(at(&faces, 15.0, 15.0).len(), 1); +} + +#[test] +fn a_line_across_a_shape_splits_it() { + let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)]; + let (faces, edges) = live_paint(&s); + assert_eq!(faces.len(), 2); + assert!(faces.iter().all(|f| f.sources == [0]), "both halves keep the rectangle's fill"); + assert!((faces.iter().map(area).sum::() - 5000.0).abs() < 1e-6); + assert_eq!(at(&faces, 10.0, 25.0).len(), 1); + assert_eq!(at(&faces, 90.0, 25.0).len(), 1); + // The rectangle's outline in two pieces, the line in three. + assert_eq!(edges.iter().filter(|e| e.key == 7).count(), 2); + assert_eq!(edges.iter().filter(|e| e.key == 8).count(), 3); +} + +#[test] +fn overlapping_shapes_keep_their_coverage() { + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 3); + assert_eq!(at(&faces, 25.0, 50.0)[0].sources, [0]); + assert_eq!(at(&faces, 75.0, 50.0)[0].sources, [0, 1]); + assert_eq!(at(&faces, 125.0, 50.0)[0].sources, [1]); + // Same faces as the Pathfinder regions. + let regions = crate::regions(&s); + assert_eq!(regions.len(), 3); + for f in &faces { + assert!(regions.iter().any(|r| r.sources == f.sources && (area(r).abs() - area(f)).abs() < 1e-6)); + } +} + +#[test] +fn a_closed_loop_of_lines_inside_a_shape_is_a_hole_and_a_face() { + let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)]; + let sq = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0), (40.0, 40.0)]; + for w in sq.windows(2) { + s.push(line(w[0], w[1], 1)); + } + // A loose line inside: no face of its own, no hole. + s.push(line((10.0, 10.0), (20.0, 20.0), 2)); + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 2, "{faces:?}"); + let outer = at(&faces, 5.0, 5.0); + assert_eq!(outer.len(), 1); + assert_eq!(outer[0].path.subpaths.len(), 2, "the inner square is a hole in it"); + assert!((area(outer[0]) - 9600.0).abs() < 1e-6); + let inner = at(&faces, 50.0, 50.0); + assert_eq!(inner.len(), 1, "the hole doesn't count as the outer face"); + assert_eq!(inner[0].sources, [0], "the rectangle fills it"); + assert!((area(inner[0]) - 400.0).abs() < 1e-6); +} + +#[test] +fn an_area_enclosed_by_shapes_is_an_unfilled_face() { + // A frame of four bars around a 50 × 50 gap. + let s = [rect(0.0, 0.0, 70.0, 10.0, 0), rect(0.0, 60.0, 70.0, 70.0, 0), rect(0.0, 0.0, 10.0, 70.0, 0), rect(60.0, 0.0, 70.0, 70.0, 0)]; + let (faces, _) = live_paint(&s); + let gap = at(&faces, 35.0, 35.0); + assert_eq!(gap.len(), 1); + assert!(gap[0].sources.is_empty()); + assert!((area(gap[0]) - 2500.0).abs() < 1e-6); +} + +#[test] +fn curves_and_a_circle_crossed_by_a_line() { + let circle = Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), FillRule::NonZero, 0); + let (faces, edges) = live_paint(&[circle, line((-10.0, 50.0), (110.0, 50.0), 1)]); + assert_eq!(faces.len(), 2); + for f in &faces { + assert!((area(f) - std::f64::consts::PI * 2500.0 / 2.0).abs() < 2.0, "{}", area(f)); + assert!(f.path.subpaths[0].anchors.len() <= 4, "no extra anchors from the sweep's splits"); + } + assert_eq!(edges.iter().filter(|e| e.key == 0).count(), 2, "two arcs"); +} + +#[test] +fn a_line_ending_on_an_edge_splits_the_shape() { + let s = [line((90.0, 20.0), (70.0, 10.0), 0), rect(80.0, 10.0, 90.0, 50.0, 1)]; + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 2); + assert!((area(&faces[0]) + area(&faces[1]) - 400.0).abs() < 1e-6); + for f in &faces { + let p = crate::interior_point(&f.path).unwrap(); + assert_eq!(at(&faces, p.x, p.y).len(), 1, "{p:?}"); + assert_eq!(f.sources, [1]); + } +} + +#[test] +fn nothing_or_garbage_gives_an_empty_map() { + assert_eq!(live_paint(&[]), (vec![], vec![])); + assert!(live_paint(&[line((0.0, 0.0), (f64::NAN, 1.0), 0)]).0.is_empty()); + let (faces, edges) = live_paint(&[line((0.0, 0.0), (10.0, 10.0), 0)]); + assert!(faces.is_empty()); + assert_eq!(edges.len(), 1); +} + +/// A strip merged across an axonometric grid of lines (#893, the reporter's file): faces meeting +/// at a vertex share its exact coordinates, so the merge is one outline with no edge left inside. +#[test] +fn faces_share_their_corners_exactly_so_merging_them_leaves_no_seam() { + const LINES: &[[f64; 4]] = &[ + [416.57797027866155, 268.45362144397683, 416.5779702786616, 811.5463785560231], + [243.12292300414873, 511.1134238473054, 713.4550472745127, 782.6598024033286], + [836.8770769958512, 511.1134238473054, 366.5449527254871, 782.6598024033286], + [457.718646852441, 268.45362144397683, 457.718646852441, 811.5463785560231], + [263.6932612910385, 475.4845528055331, 734.0253855614023, 747.0309313615563], + [816.3067387089613, 475.4845528055331, 345.9746144385975, 747.0309313615563], + [498.85932342622044, 268.45362144397683, 498.85932342622044, 811.5463785560231], + [284.26359957792823, 439.8556817637607, 754.5957238482922, 711.4020603197839], + [795.7364004220717, 439.8556817637607, 325.40427615170773, 711.4020603197839], + [539.9999999999998, 268.45362144397683, 539.9999999999998, 811.5463785560231], + [304.83393786481787, 404.22681072198844, 775.1660621351818, 675.7731892780117], + [775.1660621351821, 404.22681072198844, 304.833937864818, 675.7731892780117], + [581.1406765737793, 268.45362144397683, 581.1406765737793, 811.5463785560231], + [325.4042761517076, 368.59793968021614, 795.7364004220715, 640.1443182362394], + [754.5957238482922, 368.59793968021614, 284.26359957792835, 640.1443182362394], + [622.2813531475588, 268.45362144397683, 622.2813531475588, 811.5463785560231], + [345.97461443859737, 332.9690686384438, 816.3067387089613, 604.515447194467], + [734.0253855614026, 332.9690686384438, 263.6932612910385, 604.515447194467], + [663.422029721338, 268.45362144397683, 663.422029721338, 811.5463785560231], + [366.54495272548706, 297.3401975966715, 836.877076995851, 568.8865761526947], + [713.4550472745128, 297.3401975966715, 243.12292300414884, 568.8865761526947], + ]; + let s: Vec = LINES.iter().enumerate().map(|(i, &[x0, y0, x1, y1])| line((x0, y0), (x1, y1), i as u64)).collect(); + let (faces, _) = live_paint(&s); + let corners: Vec = faces.iter().flat_map(|f| f.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect(); + for p in &corners { + for q in &corners { + assert!(p == q || p.distance(*q) > 1e-6, "one corner, two positions: {p:?} {q:?}"); + } + } + // Down the strip between the lines x ≈ 498.9 and x = 540: 11 triangles, one outline. + let mut strip: Vec<&Region> = vec![]; + for k in 0..=240 { + if let Some(f) = faces.iter().find(|f| f.contains(Point::new(519.43, 420.0 + f64::from(k)))) + && !strip.iter().any(|g| std::ptr::eq(*g, f)) + { + strip.push(f); + } + } + assert_eq!(strip.len(), 11); + // As the Shape Builder merges them: in the map, so no sweep can round the corners apart. + let merged = crate::FaceMerger::new(&s).merge(&strip); + assert_eq!(merged.subpaths.len(), 1, "{merged:?}"); + let want: f64 = strip.iter().map(|f| area(f)).sum(); + assert!((crate::area(&merged, FillRule::NonZero) - want).abs() < 1e-6 * want); +} + +/// Merging faces of a grid of lines in the map: a ring keeps its hole, cells that only touch at +/// a corner stay two outlines, a line's loose end inside the merge leaves no spur. +#[test] +fn faces_merge_in_the_map_with_holes_corners_and_loose_ends() { + let mut s = vec![]; + for k in 0..4 { + let v = f64::from(k) * 10.0; + s.push(line((v, -5.0), (v, 35.0), 0)); + s.push(line((-5.0, v), (35.0, v), 1)); + } + // A loose end poking into the middle cell. + s.push(line((15.0, 10.0), (15.0, 15.0), 2)); + let (faces, _) = live_paint(&s); + let cell = |x: f64, y: f64| at(&faces, x, y)[0]; + let merger = crate::FaceMerger::new(&s); + let m = map(&s, true).unwrap(); + let check = |picked: &[&Region], subpaths: usize, area_want: f64| { + // Merged in the map itself, not by the fallback union. + let inside: Vec = picked.iter().map(|f| interior_point(&f.path).unwrap()).collect(); + assert_eq!(m.merge(&inside, &crate::pathfinder::cover(picked, s.len()), None).as_ref(), Some(&merger.merge(picked))); + let m = merger.merge(picked); + assert_eq!(m.subpaths.len(), subpaths, "{m:?}"); + assert!((crate::area(&m, FillRule::NonZero) - area_want).abs() < 1e-6, "{}", crate::area(&m, FillRule::NonZero)); + m + }; + let all: Vec<&Region> = faces.iter().collect(); + let whole = check(&all, 1, 900.0); + assert_eq!(whole.subpaths[0].anchors.len(), 4, "one square, no anchors left from the inner lines or the loose end"); + let ring: Vec<&Region> = + [(5., 5.), (15., 5.), (25., 5.), (5., 15.), (25., 15.), (5., 25.), (15., 25.), (25., 25.)].iter().map(|&(x, y)| cell(x, y)).collect(); + check(&ring, 2, 800.0); + check(&[cell(5.0, 5.0), cell(15.0, 15.0)], 2, 200.0); +} + +/// Merging in the map takes in a sliver next to the merged faces that only their shapes fill, +/// such as the overlap of two squares that overlap by 0.001, and leaves a thin cutout that +/// nothing fills as it is. +#[test] +fn faces_merge_in_the_map_with_the_slivers_their_shapes_fill() { + let far = line((400.0, 400.0), (450.0, 450.0), 2); + let s = [rect(100.0, 100.0, 200.0, 200.0, 0), rect(199.999, 100.0, 299.999, 200.0, 1), far.clone()]; + let regions = shape_builder(&s, false).regions; + assert_eq!(regions.len(), 2, "the overlap is a sliver: {regions:?}"); + let both: Vec<&Region> = regions.iter().collect(); + let merged = crate::FaceMerger::new(&s).merge(&both); + assert_eq!(merged.subpaths.len(), 1, "{merged:?}"); + assert!((crate::area(&merged, FillRule::NonZero) - 19999.9).abs() < 1e-6, "{}", crate::area(&merged, FillRule::NonZero)); + // Live Paint's map does not test which inputs cover the sliver. + let covered = |cover: bool| map(&s, cover).unwrap().slivers.into_iter().map(|(_, sources)| sources).collect::>(); + assert_eq!(covered(true), [vec![0, 1]]); + assert_eq!(covered(false), [Vec::::new()]); + // A square with a cutout 0.005 wide. + let mut frame = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + frame.subpaths.extend(shapes::rectangle(Rect::new(50.0, 10.0, 50.005, 90.0)).subpaths); + let s = [Shape::new(frame, FillRule::EvenOdd, 0), far]; + let regions = shape_builder(&s, false).regions; + assert_eq!(regions.len(), 1, "{regions:?}"); + let merged = crate::FaceMerger::new(&s).merge(&[®ions[0]]); + assert_eq!(merged.subpaths.len(), 2, "the cutout stays: {merged:?}"); + assert!((crate::area(&merged, FillRule::EvenOdd) - 9999.6).abs() < 1e-6, "{}", crate::area(&merged, FillRule::EvenOdd)); +} + +#[test] +fn shape_builder_lines_cut_regions() { + // A line across a rectangle: two regions, the line in three pieces keyed by its index. + let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)]; + let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); + assert_eq!(regions.len(), 2); + assert!(regions.iter().all(|r| r.sources == [0])); + assert_eq!(lines.len(), 3); + assert!(lines.iter().all(|l| l.key == 1 && !l.path.is_closed())); + // Lines alone enclose a region nothing fills. + let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 0), line((-10.0, 80.0), (110.0, 80.0), 0)]; + let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); + assert_eq!(regions.len(), 1); + assert!(regions[0].sources.is_empty() && regions[0].top().is_none()); + assert_eq!(lines.len(), 9); +} + +#[test] +fn shape_builder_merges_adjacent_gap_closed_faces_without_keeping_bridges() { + let outline = Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 4.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0), Point::new(0.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + ); + let shapes = [outline, line((50.0, 0.0), (50.0, 100.0), 1)]; + let arr = shape_builder_with_gap(&shapes, true, 6.0); + assert_eq!(arr.regions.len(), 2); + let faces: Vec<_> = arr.regions.iter().collect(); + let merged = crate::FaceMerger::with_gap(&shapes, 6.0).merge(&faces); + assert!((crate::area(&merged, FillRule::NonZero).abs() - 10000.0).abs() < 1e-6); + assert_eq!(merged.subpaths.len(), 1, "the shared edge disappears"); + assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key < 2), "bridges are not art"); + // A request over 72 pt cannot close a gap of 80 pt. + let large = [Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 80.0), Point::new(0.0, 200.0), Point::new(200.0, 200.0), Point::new(200.0, 0.0), Point::new(0.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + )]; + assert!(shape_builder_with_gap(&large, false, 1000.0).regions.is_empty()); +} + +#[test] +fn shape_builder_gap_detection_closes_small_gaps() { + // Three sides of a 100 x 100 square, the fourth open by 4: no region without Gap Detection. + let open = |gap: f64| { + let pts = [(0.0, gap), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)]; + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + vec![Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0)] + }; + let s = open(4.0); + assert!(shape_builder(&s, false).regions.is_empty()); + assert!(shape_builder_with_gap(&s, false, 3.0).regions.is_empty(), "a gap longer than the length stays open"); + let arr = shape_builder_with_gap(&s, true, 6.0); + assert_eq!(arr.regions.len(), 1); + assert!((area(&arr.regions[0]) - 10000.0).abs() < 1e-6); + // The bridge is no art: the path comes back as it was, in its own pieces only. + assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key == 0)); + let len: f64 = arr.lines.iter().map(|p| p.path.to_bezpath().perimeter(1e-6)).sum(); + assert!((len - 396.0).abs() < 1e-6, "{len}"); + // Two paths whose ends miss each other: a U and a lid 3 above it. + let u = Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + ); + let s = [u, line((0.0, -3.0), (100.0, -3.0), 1)]; + assert!(shape_builder_with_gap(&s, false, 2.0).regions.is_empty()); + let arr = shape_builder_with_gap(&s, false, 3.5); + assert_eq!(arr.regions.len(), 1); + assert!((area(&arr.regions[0]) - 10300.0).abs() < 1e-6); + // An end close to the side of another path bridges to the nearest point on it. + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((50.0, 140.0), (50.0, 104.0), 1)]; + assert_eq!(shape_builder_with_gap(&s, false, 5.0).regions.len(), 1, "nothing is enclosed, nothing breaks"); + // Bad gaps are no gap. + let s = open(4.0); + for gap in [f64::NAN, f64::INFINITY, -3.0] { + assert!(shape_builder_with_gap(&s, false, gap).regions.is_empty()); + } +} + +#[test] +fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() { + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; + assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() }); + // A broken line can't be swept and fills nothing: the filled areas still give their regions. + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((0.0, 0.0), (f64::NAN, 1.0), 1)]; + assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s[..1]), ..Default::default() }); + // Zero-length and doubled-back lines don't break it either. + let s = [ + rect(0.0, 0.0, 100.0, 100.0, 0), + line((50.0, 50.0), (50.0, 50.0), 1), + line((0.0, 0.0), (100.0, 0.0), 2), + line((50.0, 0.0), (50.0, 100.0), 3), + ]; + let regions = shape_builder(&s, false).regions; + assert_eq!(regions.len(), 2); + assert!((regions.iter().map(area).sum::() - 10000.0).abs() < 1e-6); +} + +fn circle(cx: f64, cy: f64, r: f64, key: u64) -> Shape { + Shape::new(shapes::ellipse(Rect::new(cx - r, cy - r, cx + r, cy + r)), FillRule::NonZero, key) +} + +#[test] +fn shape_builder_offers_the_gaps_closed_shapes_enclose() { + // Four circles in a 2×2 grid, each touching its neighbours (#1691): the four circles and + // the diamond between them, which nothing fills. + let s = [circle(50.0, 50.0, 50.0, 0), circle(150.0, 50.0, 50.0, 1), circle(50.0, 150.0, 50.0, 2), circle(150.0, 150.0, 50.0, 3)]; + let regions = shape_builder(&s, false).regions; + assert_eq!(regions.len(), 5, "{regions:?}"); + let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect(); + assert_eq!(gaps.len(), 1); + assert!(gaps[0].contains(Point::new(100.0, 100.0)) && !gaps[0].contains(Point::new(50.0, 50.0))); + let diamond = 10000.0 - std::f64::consts::PI * 2500.0; + assert!((area(gaps[0]).abs() - diamond).abs() < 0.01 * diamond, "{}", area(gaps[0])); + assert_eq!(shape_builder(&s, true).regions, regions, "the outline edges don't change them"); + // A ring of four bars that overlap at the corners: the eight pieces of the bars and the + // square they enclose. + let s = + [rect(0.0, 0.0, 300.0, 100.0, 0), rect(200.0, 0.0, 300.0, 300.0, 1), rect(0.0, 200.0, 300.0, 300.0, 2), rect(0.0, 0.0, 100.0, 300.0, 3)]; + let regions = shape_builder(&s, false).regions; + assert_eq!(regions.len(), 9, "{regions:?}"); + let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect(); + assert_eq!(gaps.len(), 1); + assert!((area(gaps[0]).abs() - 10000.0).abs() < 1e-6 && gaps[0].contains(Point::new(150.0, 150.0))); + // The gap merges with a bar in the planar map: the bar's piece and the square, no seam. + let merger = crate::FaceMerger::new(&s); + let bar = regions.iter().find(|r| r.sources == [0] && r.contains(Point::new(150.0, 50.0))).unwrap(); + let merged = merger.merge(&[bar, gaps[0]]); + assert_eq!(merged.subpaths.len(), 1, "{merged:?}"); + assert!((crate::area(&merged, FillRule::NonZero) - 20000.0).abs() < 1e-6); + // Shapes apart or overlapping without a hole enclose no gap. + let s = [circle(50.0, 50.0, 50.0, 0), circle(250.0, 50.0, 50.0, 1), rect(80.0, 0.0, 220.0, 60.0, 2)]; + assert!(shape_builder(&s, false).regions.iter().all(|r| !r.sources.is_empty())); +} + +#[test] +fn shape_builder_stays_quick_up_to_its_cap() { + // A wavy ring of nearly the most segments the map takes, crossed by a few lines. + let n = SHAPE_BUILDER_MAX_SEGMENTS - 8; + let ring: Vec = (0..n) + .map(|k| { + let t = k as f64 / n as f64 * std::f64::consts::TAU; + let r = 100.0 + 5.0 * (t * 200.0).sin(); + Point::new(r * t.cos(), r * t.sin()) + }) + .collect(); + let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)]; + // Closed paths alone are swept for the gaps they enclose. + let started = std::time::Instant::now(); + assert_eq!(shape_builder(&s, false).regions.len(), 1, "the ring encloses no gap"); + assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + for k in 0..4 { + let y = -60.0 + 40.0 * f64::from(k); + s.push(line((-150.0, y), (150.0, y + 7.0), 1)); + } + let started = std::time::Instant::now(); + let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); + assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)"); + assert!(lines.len() >= 12, "{}", lines.len()); + assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + // Past the cap, only the filled areas count. + s.push(line((0.0, 0.0), (1.0, 1.0), 2)); + s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k))))); + assert!(shape_builder(&s, false).lines.is_empty()); +} + +#[test] +fn a_traced_outline_of_many_segments_is_cut_by_lines() { + // #937: art traced to thousands of segments was past the old cap (4096), so lines stopped + // cutting it and the Shape Builder offered the whole shapes. A blob of 20 000 segments + // crossed by two long lines (far wider than any of its segments: the index of the + // outlines keeps those apart) splits into four regions, each line in four pieces. + let n = 20_000; + let blob: Vec = (0..n) + .map(|k| { + let t = k as f64 / n as f64 * std::f64::consts::TAU; + let r = 100.0 + 5.0 * (t * 37.0).sin(); + Point::new(r * t.cos(), r * t.sin()) + }) + .collect(); + let s = [ + Shape::new(PathData::single(SubPath::polyline(&blob, true)), FillRule::NonZero, 0), + line((-150.0, 0.0), (150.0, 3.0), 1), + line((0.0, -150.0), (3.0, 150.0), 2), + ]; + let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); + assert_eq!(regions.len(), 4, "{}", regions.len()); + assert!(regions.iter().all(|r| r.sources == [0])); + let whole = crate::area(&s[0].path, FillRule::NonZero); + assert!((regions.iter().map(area).sum::() - whole).abs() < 1e-6 * whole); + assert_eq!(lines.iter().filter(|l| l.key == 1).count(), 4, "cut twice by the blob and once by the other line"); + assert_eq!(lines.iter().filter(|l| l.key == 2).count(), 4); +} + +#[test] +fn a_hatch_of_parallel_lines_is_cut_line_by_line() { + // Hundreds of long horizontal lines over a square, all alike in x (the weak axis of an index + // by x alone), and one vertical line: every strip is cut in two. + let n: u32 = 300; + let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)]; + for k in 1..n { + let y = 100.0 * f64::from(k) / f64::from(n); + s.push(line((-10.0, y), (110.0, y), u64::from(k))); + } + s.push(line((50.0, -10.0), (50.0, 110.0), u64::from(n))); + let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); + assert_eq!(regions.len(), 2 * n as usize, "{}", regions.len()); + assert!(regions.iter().all(|r| r.sources == [0])); + assert!((regions.iter().map(area).sum::() - 10000.0).abs() < 1e-6); + // Each horizontal line: a loose end outside, two pieces inside, a loose end outside. + assert_eq!(lines.iter().filter(|l| l.key == 1).count(), 4); + // The vertical line: cut by the square's sides and every hatch line. + assert_eq!(lines.iter().filter(|l| l.key == u64::from(n)).count(), n as usize + 2); +} + +#[test] +fn rect_index_finds_every_rectangle_holding_a_point() { + // Narrow in x, narrow in y only, and wide both ways: each lookup matches a scan of them all. + let mut items = vec![(kurbo::Rect::new(0.0, 0.0, 100.0, 100.0), 0usize)]; + for k in 1..50 { + let a = f64::from(k as u32) * 2.0; + items.push((kurbo::Rect::new(a, 0.0, a + 1.0, 100.0), k)); + items.push((kurbo::Rect::new(0.0, a, 100.0, a + 1.0), 100 + k)); + } + items.push((kurbo::Rect::new(f64::NAN, 0.0, 1.0, 1.0), 999)); + let index = RectIndex::new(items.clone()); + for p in [Point::new(4.5, 6.5), Point::new(50.0, 50.0), Point::new(-1.0, 3.0), Point::new(99.5, 0.5)] { + let mut got: Vec = index.at(p, 0.1).copied().collect(); + got.sort_unstable(); + let mut want: Vec = items.iter().filter(|(r, _)| r.inflate(0.1, 0.1).contains(p)).map(|(_, k)| *k).collect(); + want.sort_unstable(); + assert_eq!(got, want, "{p:?}"); + } + assert_eq!(RectIndex::::new(Vec::new()).at(Point::ZERO, 1.0).count(), 0); +} + +#[test] +fn rect_index_reads_a_diagonal_hatch_only_near_the_point() { + // 2,000 parallel diagonals a tenth of a point apart: the bounds of every line left of a + // point hold it, so an index of the bounds alone handed half the hatch to each lookup + // (quadratic in the hatch, #937). Filed by pieces, a lookup gets the lines of its + // neighbourhood (a few cells' worth), among them every line through the point. + let n = 2000; + let items: Vec<_> = (0..n) + .map(|k| { + let d = f64::from(k) * 0.1; + let s = PathSeg::Line(kurbo::Line::new((d, 0.0), (d + 200.0, 200.0))); + (s.bounding_box(), (s, k)) + }) + .collect(); + let index = RectIndex::with_footprint(items.clone(), |r, (s, _), cell| segment_footprint(r, s, cell)); + for p in [Point::new(100.0, 100.0), Point::new(130.25, 60.0), Point::new(250.0, 150.0), Point::new(399.9, 200.0)] { + let got: Vec = index.at(p, 0.1).map(|(_, k)| *k).collect(); + assert!(got.len() <= n as usize / 4, "{p:?}: {} candidates", got.len()); + let through: Vec = + items.iter().filter(|(_, (s, _))| kurbo::ParamCurveNearest::nearest(s, p, 1e-9).distance_sq <= 0.01).map(|(_, (_, k))| *k).collect(); + assert!(!through.is_empty() && through.iter().all(|k| got.contains(k)), "{p:?}: {through:?} vs {got:?}"); + } + // Faces a hatch cuts a square into: thin stripes across the whole grid, each filed under + // its own column of cells, so a lookup reads a few of them, not the hatch. + let stripes: Vec<_> = (0..n).map(|k| (kurbo::Rect::new(f64::from(k) * 0.1, 0.0, f64::from(k) * 0.1 + 0.1, 200.0), k)).collect(); + let index = RectIndex::new(stripes); + let got: Vec = index.at(Point::new(100.05, 50.0), 0.0).copied().collect(); + assert_eq!(got, [1000]); + assert!(index.rest.is_empty()); + // A footprint still finds a point on the item but off the grid's cells' edges, and a + // degenerate grid (collinear items) is divided along its length. + let line = PathSeg::Line(kurbo::Line::new((0.0, 0.0), (1000.0, 0.0))); + let items: Vec<_> = (0..10).map(|k| (line.bounding_box(), (line, k))).collect(); + let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell)); + assert_eq!(index.at(Point::new(333.3, 0.0), 0.1).count(), 10); + assert_eq!(index.at(Point::new(1000.0, 0.0), 0.1).count(), 10); + assert_eq!(index.at(Point::new(-5.0, 0.0), 0.1).count(), 0); +} + +/// The grid's memory stays linear in the item count: pieces of one segment falling in the same +/// cell file it once, and coincident segments (each along every cell of a one-column grid) go +/// to the scanned list once the grid holds its budget of references, and are still all found. +#[test] +fn rect_index_holds_a_bounded_number_of_references() { + // Ten collinear horizontal lines: a grid of 10 cells a hundred points wide and a hair tall, + // so each line's 4096 pieces fall about 400 per cell; filed once per cell. + let line = PathSeg::Line(kurbo::Line::new((0.0, 0.0), (1000.0, 0.0))); + let items: Vec<_> = (0..10).map(|k| (line.bounding_box(), (line, k))).collect(); + let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell)); + assert!(index.rest.is_empty(), "every line is in the grid, {} scanned", index.rest.len()); + assert!(index.cells.iter().all(|c| c.len() <= 10), "{:?}", index.cells.iter().map(Vec::len).collect::>()); + assert!(index.cells.iter().map(Vec::len).sum::() >= 10, "the lines are filed"); + assert_eq!(index.at(Point::new(500.0, 0.0), 0.1).count(), 10); + // 4,096 coincident vertical segments: one column, a row per segment, every segment along + // every row would be 16 million references; the budget keeps it to a few hundred per item. + let n = 4096u32; + let seg = PathSeg::Line(kurbo::Line::new((50.0, 0.0), (50.0, 200.0))); + let items: Vec<_> = (0..n).map(|k| (seg.bounding_box(), (seg, k))).collect(); + let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell)); + let references: usize = index.cells.iter().map(Vec::len).sum(); + assert!(references <= reference_budget(n as usize, index.cells.len()), "{references} references"); + assert!(references < 2_000_000, "{references} references"); + assert!(!index.rest.is_empty() && index.rest.len() < n as usize, "{} scanned", index.rest.len()); + let mut got: Vec = index.at(Point::new(50.0, 100.0), 0.1).map(|(_, k)| *k).collect(); + got.sort_unstable(); + assert_eq!(got.len(), n as usize); + assert_eq!(got, (0..n).collect::>()); + assert_eq!(index.at(Point::new(60.0, 100.0), 0.1).count(), 0); +} + +fn poly(pts: &[(f64, f64)], closed: bool) -> SubPath { + SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), closed) +} + +fn ends(sp: &SubPath) -> (Point, Point) { + (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p) +} + +/// Each piece cuts out of the input it is keyed by, losing just its own length (what erasing +/// it does), and none is a speck. +fn assert_pieces_cut_out(shapes: &[Shape], arr: &BuilderArrangement) { + for e in arr.edges.iter().chain(&arr.lines) { + let len = e.path.length(); + assert!(len > 1e-3, "a speck of a piece, keyed {}: {:?}", e.key, e.path); + let s = &shapes[e.key as usize]; + let cut = cut_out(&s.path, &e.path.subpaths[0], false, 0.05).unwrap_or_else(|| panic!("{:?} not on input {}", e.path, e.key)); + let lost = s.path.length() - cut.length(); + assert!((lost - len).abs() <= 1e-6 * s.path.length(), "input {} lost {lost} for a piece {len} long", e.key); + } +} + +#[test] +fn a_line_crossed_where_its_two_passes_part_is_cut_once() { + // The shrunk case of `pieces_cut_out_of_their_inputs`: the sweep cut the first line's way + // there and its way back by the third line a rounding error (1.2e-6) apart, joined by a + // stub. The line came back as a speck keyed by the third line and a piece running from + // the crossing out to (10, 70) and back to the other cut, which erasing took twice over. + let s = [line((10.0, 70.0), (80.0, 20.0), 0), line((10.0, 40.0), (70.0, 90.0), 0), line((20.0, 80.0), (20.0, 50.0), 0)]; + let arr = shape_builder(&s, true); + assert_pieces_cut_out(&s, &arr); + // The first line cut by both others, the other two once each. + let count = |k: u64| arr.lines.iter().filter(|l| l.key == k).count(); + assert_eq!((count(0), count(1), count(2)), (3, 2, 2), "{:?}", arr.lines); + let at = Point::new(20.0, 70.0 - 50.0 / 7.0); + for k in [0, 2] { + assert!(arr.lines.iter().filter(|l| l.key == k).all(|l| { + let (a, b) = ends(&l.path.subpaths[0]); + a.distance(b) > 1.0 && l.path.subpaths[0].anchors.len() == 2 + })); + assert_eq!( + arr.lines + .iter() + .filter(|l| l.key == k && [ends(&l.path.subpaths[0]).0, ends(&l.path.subpaths[0]).1].iter().any(|p| p.distance(at) < 1e-9)) + .count(), + 2 + ); + } +} + +#[test] +fn a_run_of_tiny_level_segments_is_not_contracted_as_stubs() { + // 2000 segments of 1e-4 (0.2 pt in all), nearly level: each one alone is as short as a + // stub the sweep leaves between two cuts, but chained they are real geometry, and the + // stub contraction must not pull them into one vertex. + let pts: Vec = (0..=2000).map(|k| Point::new(f64::from(k) * 1e-4, f64::from(k) * 1e-8)).collect(); + let run = Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0); + let whole = run.path.length(); + let s = [run, line((0.1003, -1.0), (0.1003, 1.0), 0)]; + let arr = shape_builder(&s, false); + let pieces: Vec<&Shape> = arr.lines.iter().filter(|l| l.key == 0).collect(); + assert_eq!(pieces.len(), 2, "cut once by the line: {}", pieces.len()); + let total: f64 = pieces.iter().map(|l| l.path.length()).sum(); + // Where the line crosses, one segment may be taken for a stub (it is as short as one), + // moving the cut by less than a stub's reach; the rest of the run stays. + assert!((total - whole).abs() <= DEFAULT_PRECISION / 16.0, "{total} of {whole}"); + for l in &pieces { + let (a, b) = ends(&l.path.subpaths[0]); + assert!(a.distance(b) > 0.09, "a piece collapsed: {a:?} {b:?}"); + } + assert_eq!(arr.lines.iter().filter(|l| l.key == 1).count(), 2); +} + +#[test] +fn a_dense_hatch_at_exact_binary_steps_cuts_each_line_once_per_crossing() { + // Lines every 0.25 or 0.5 pt (exact in binary) cut a crossing line or a circle where two + // passes of each line met it a rounding error apart, adding a speck and a piece there. + let hatch = |n: i32, step: f64, f: &dyn Fn(f64) -> Shape| -> Vec { (1..=n).map(|k| f(step * f64::from(k))).collect() }; + // Horizontal lines across a diagonal: each in two pieces, the diagonal in n + 1. + let mut s = vec![line((-3.0, -2.0), (33.0, 31.0), 0)]; + s.extend(hatch(119, 0.25, &|y| line((-5.0, y), (35.0, y), 0))); + let arr = shape_builder(&s, false); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.lines.iter().filter(|l| l.key == 0).count(), 120); + assert!((1..=119).all(|k| arr.lines.iter().filter(|l| l.key == k).count() == 2)); + assert_eq!(arr.lines.len(), 120 + 2 * 119); + // Sloping lines across a 45° one. + let mut s = vec![line((-3.0, -3.0), (33.0, 33.0), 0)]; + s.extend(hatch(59, 0.5, &|v| line((v - 40.0, -5.0), (v + 40.0, 35.0), 0))); + let arr = shape_builder(&s, false); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.lines.len(), 60 + 2 * 59, "{}", arr.lines.len()); + // Vertical lines across a circle: n + 1 strips, each line in three pieces, the circle's + // outline in two per line. + let mut s = vec![Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 30.0, 30.0)), FillRule::NonZero, 0)]; + s.extend(hatch(119, 0.25, &|x| line((x, -5.0), (x, 35.0), 0))); + let arr = shape_builder(&s, true); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.regions.len(), 120); + assert!(arr.regions.iter().all(|r| r.sources == [0])); + assert!((1..=119).all(|k| arr.lines.iter().filter(|l| l.key == k).count() == 3)); + assert_eq!(arr.lines.len(), 3 * 119); + assert_eq!(arr.edges.len(), 2 * 119); +} + +#[test] +fn shape_builder_edges_are_the_outlines_cut_where_paths_meet() { + // Two overlapping squares: each outline in two pieces, cut where the other crosses it. + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 50.0, 150.0, 150.0, 1)]; + let with = shape_builder(&s, true); + assert_eq!(with.regions, shape_builder(&s, false).regions, "the regions don't change"); + assert!(with.lines.is_empty() && shape_builder(&s, false).edges.is_empty()); + assert_eq!(with.edges.len(), 4); + for key in [0, 1] { + assert_eq!(with.edges.iter().filter(|e| e.key == key && !e.path.is_closed()).count(), 2); + } + let inner = with.edges.iter().find(|e| e.key == 0 && e.path.bounds() == Some(Rect::new(50.0, 50.0, 100.0, 100.0))).expect("A inside B"); + // Erasing it opens A there. + let cut = cut_out(&s[0].path, &inner.path.subpaths[0], false, 1e-3).expect("on A"); + assert_eq!(cut.subpaths.len(), 1); + let sp = &cut.subpaths[0]; + assert!(!sp.closed && sp.anchors.len() == 5, "{sp:?}"); + let (a, b) = ends(sp); + assert!([a, b].contains(&Point::new(100.0, 50.0)) && [a, b].contains(&Point::new(50.0, 100.0)), "{a:?} {b:?}"); + assert!((cut.length() - 300.0).abs() < 1e-6, "{}", cut.length()); + // Not on B. + assert_eq!(cut_out(&s[1].path, &inner.path.subpaths[0], false, 1e-3), None); + // A lone square's edge is its whole outline: erasing it leaves nothing. + let lone = shape_builder(&s[..1], true); + assert_eq!(lone.edges.len(), 1); + assert!(cut_out(&s[0].path, &lone.edges[0].path.subpaths[0], false, 1e-3).unwrap().is_empty()); +} + +#[test] +fn cut_out_splits_open_paths_and_keeps_closing_edges_invisible() { + let open = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0)], false)); + let cut = cut_out(&open, &poly(&[(60.0, 0.0), (30.0, 0.0)], false), false, 1e-3).unwrap(); + let mut parts: Vec<(Point, Point)> = cut.subpaths.iter().map(ends).collect(); + parts.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); + assert_eq!(parts, vec![(Point::new(0.0, 0.0), Point::new(30.0, 0.0)), (Point::new(60.0, 0.0), Point::new(100.0, 0.0))]); + // An end piece only shortens it. + let cut = cut_out(&open, &poly(&[(70.0, 0.0), (100.0, 0.0)], false), false, 1e-3).unwrap(); + assert_eq!(cut.subpaths.iter().map(ends).collect::>(), vec![(Point::new(0.0, 0.0), Point::new(70.0, 0.0))]); + + // A filled open path runs on round its invisible closing edge (100,100) → (0,0). + let corner = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false)); + let piece = poly(&[(100.0, 50.0), (100.0, 100.0), (50.0, 50.0)], false); + assert_eq!(cut_out(&corner, &piece, false, 1e-3), None, "without the fill it isn't on the path"); + let cut = cut_out(&corner, &piece, true, 1e-3).unwrap(); + assert_eq!(cut.subpaths.len(), 1); + assert_eq!(cut.subpaths[0].anchors.iter().map(|a| a.p).collect::>(), [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)].map(Point::from)); + // Cut elsewhere, the closing edge stays invisible: two open runs, not one through it. + let cut = cut_out(&corner, &poly(&[(20.0, 0.0), (60.0, 0.0)], false), true, 1e-3).unwrap(); + assert_eq!(cut.subpaths.len(), 2); + assert!(cut.subpaths.iter().all(|sp| !sp.closed)); + assert!((cut.length() - 160.0).abs() < 1e-6, "{}", cut.length()); +} + +#[test] +fn run_around_stops_at_the_stops_either_side() { + // A lone square's edge is its whole outline; with its corners as stops, a point by any side + // gives that side. + let lone = shape_builder(&[rect(0.0, 0.0, 100.0, 100.0, 0)], true); + let edge = &lone.edges[0].path.subpaths[0]; + let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(Point::from); + let side = |at: (f64, f64)| { + run_around(edge, &corners, Point::from(at), 1e-3) + .map(|sp| (sp.closed, PathData::single(sp.clone()).length(), PathData::single(sp).bounds())) + }; + assert_eq!(side((50.0, 1.0)), Some((false, 100.0, Some(Rect::new(0.0, 0.0, 100.0, 0.0))))); + for at in [(0.5, 50.0), (99.0, 50.0), (50.0, 100.0)] { + let (closed, length, _) = side(at).expect("a side"); + assert!(!closed && (length - 100.0).abs() < 1e-6, "{at:?}: {length}"); + } + // Without two stops on it, the run would be all of the outline. + assert_eq!(run_around(edge, &corners[..1], Point::new(50.0, 0.0), 1e-3), None); + // An open piece's run ends at the piece's own ends where no stop comes first; (40, 5) lies + // off the line and does not stop the run. + let line = poly(&[(0.0, 0.0), (100.0, 0.0)], false); + let run = run_around(&line, &[Point::new(40.0, 0.0), Point::new(40.0, 5.0)], Point::new(70.0, 0.0), 1e-3).unwrap(); + assert_eq!(ends(&run), (Point::new(40.0, 0.0), Point::new(100.0, 0.0))); + let run = run_around(&line, &[Point::new(40.0, 0.0)], Point::new(10.0, 0.0), 1e-3).unwrap(); + assert_eq!(ends(&run), (Point::new(0.0, 0.0), Point::new(40.0, 0.0))); + // A stop counts only where it is nearest the piece: the folded line's last anchor lies + // 0.02 from its first side and does not stop the run there. + let folded = poly(&[(0.0, 0.0), (100.0, 0.0), (50.0, 0.02)], false); + let stops = folded.anchors.iter().map(|a| a.p).collect::>(); + let run = run_around(&folded, &stops, Point::new(25.0, 0.0), 0.05).unwrap(); + assert_eq!(ends(&run), (Point::new(0.0, 0.0), Point::new(100.0, 0.0))); +} + +#[test] +fn run_around_a_ring_of_4000_sides_measures_only_the_stops_it_reaches() { + // Every anchor of the ring is a stop, so each run is one side. Measuring every stop along + // the whole ring would take 16 million nearest-point searches a call. + let n = 4000; + let pts: Vec<(f64, f64)> = (0..n) + .map(|k| { + let a = k as f64 * std::f64::consts::TAU / n as f64; + (500.0 * a.cos(), 500.0 * a.sin()) + }) + .collect(); + let ring = poly(&pts, true); + let stops: Vec = ring.anchors.iter().map(|a| a.p).collect(); + let start = std::time::Instant::now(); + for k in (0..n).step_by(200) { + let run = run_around(&ring, &stops, ring.segment(k).eval(0.5), 1e-3).expect("a side"); + assert_eq!(ends(&run), (stops[k], stops[(k + 1) % n]), "side {k}"); + } + assert!(start.elapsed().as_secs_f64() < 5.0, "20 runs took {:?}", start.elapsed()); +} + +/// Proptest shrank a failure of `pieces_cut_out_of_their_inputs` to this input with linesweeper +/// 0.5. The sweep cuts lines 0 and 1 at points of their crossing up to 1.4 `eps` apart, and +/// `vertex_id` merges a point only into a vertex within `eps` of it. Line 0's loose end in the +/// square is still one piece, and erasing it shortens line 0 by its length only. No face or +/// merged outline runs out along it and back. +#[test] +fn a_loose_end_is_one_piece_when_the_sweep_cuts_its_passes_apart() { + let s = [line((30.0, 20.0), (70.0, 0.0), 0), line((40.0, 0.0), (0.0, 90.0), 1), rect(10.0, 0.0, 40.0, 30.0, 2)]; + let arr = shape_builder(&s, true); + for key in 0..3 { + assert_eq!(arr.edges.iter().chain(&arr.lines).filter(|e| e.key == key).count(), 3, "pieces of {key}"); + } + for e in arr.edges.iter().chain(&arr.lines) { + let input = &s[e.key as usize].path; + let cut = cut_out(input, &e.path.subpaths[0], false, 0.05).expect("on its input"); + assert!((input.length() - cut.length() - e.path.length()).abs() < 1e-6, "{:?}", e.path); + } + // From (30,20) to the crossing at (220/7, 135/7). + let loose = arr.lines.iter().find(|l| l.path.subpaths[0].anchors.iter().any(|a| a.p == Point::new(30.0, 20.0))).expect("the loose end"); + assert_eq!(loose.path.subpaths[0].anchors.len(), 2, "{:?}", loose.path); + assert!((loose.path.length() - 2000f64.sqrt() / 28.0).abs() < 1e-6, "{}", loose.path.length()); + let (faces, _) = live_paint(&s); + let left = at(&faces, 15.0, 15.0); + assert_eq!(left.len(), 1); + assert_eq!(left[0].path.subpaths[0].anchors.len(), 4, "(10,0) (40,0) (26.67,30) (10,30): {:?}", left[0].path); + let merged = crate::FaceMerger::new(&s).merge(&faces.iter().collect::>()); + assert_eq!(merged.subpaths.len(), 1, "the square: {merged:?}"); + assert_eq!(merged.subpaths[0].anchors.len(), 4, "{merged:?}"); +} + +fn arb_segment() -> impl Strategy { + let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0); + prop_oneof![ + (c(), c(), c(), c()).prop_map(|(a, b, x, y)| line((a, b), (x, y), 0)), + (c(), c(), 1..5i32, 1..5i32).prop_map(|(x, y, w, h)| rect(x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0, 0)), + (c(), c(), 1..5i32).prop_map(|(x, y, r)| { + let r = f64::from(r) * 10.0; + Shape::new(shapes::ellipse(Rect::new(x, y, x + r, y + r)), FillRule::EvenOdd, 0) + }), + ] +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + /// Faces never overlap, and a face's sources are exactly the inputs whose fill holds a + /// point inside it. + #[test] + fn faces_are_disjoint_and_know_what_covers_them(shapes in prop::collection::vec(arb_segment(), 1..7)) { + let (faces, _) = live_paint(&shapes); + for f in &faces { + prop_assert!(area(f) > 0.0); + let Some(p) = crate::interior_point(&f.path) else { continue }; + prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1, "{:?} in more than one face", p); + let covering: Vec = shapes + .iter() + .enumerate() + .filter(|(_, s)| s.path.is_closed() && vectorcraft_geom::hit::fill_contains(&s.path.to_bezpath(), s.rule, p)) + .map(|(i, _)| i) + .collect(); + prop_assert_eq!(&f.sources, &covering, "at {:?}", p); + } + } + + /// Closed shapes alone: the filled faces cover exactly the Pathfinder regions. + #[test] + fn filled_faces_are_the_pathfinder_regions(shapes in prop::collection::vec(arb_segment(), 1..6)) { + let closed: Vec = shapes.into_iter().filter(|s| s.path.is_closed()).collect(); + let (faces, _) = live_paint(&closed); + let filled: f64 = faces.iter().filter(|f| !f.sources.is_empty()).map(area).sum(); + let regions: f64 = crate::regions(&closed).iter().map(|r| area(r).abs()).sum(); + // Both refit split curves to [`DEFAULT_PRECISION`], so curved areas may differ by up + // to that much along every outline. + let outlines: f64 = closed.iter().map(|s| s.path.to_bezpath().perimeter(1e-6)).sum(); + prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions); + } + + /// Every edge and line piece cuts out of the input it is keyed by, which loses its length + /// and nothing else. + #[test] + fn pieces_cut_out_of_their_inputs(shapes in prop::collection::vec(arb_segment(), 1..6)) { + let arr = shape_builder(&shapes, true); + for e in arr.edges.iter().chain(&arr.lines) { + let (Some(s), Some(piece)) = (shapes.get(e.key as usize), e.path.subpaths.first()) else { continue }; + let len = e.path.length(); + let cut = cut_out(&s.path, piece, false, 0.05); + if len <= 0.05 { + // A speck where two paths touch. + prop_assert!(cut.is_none()); + continue; + } + prop_assert!(cut.is_some(), "{:?} not on {:?}", piece, s.path); + let lost = s.path.length() - cut.map_or(0.0, |c| c.length()); + prop_assert!((lost - len).abs() <= 0.05 + 1e-3 * len, "lost {} for a piece {} long", lost, len); + } + } + + /// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics. + #[test] + fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) { + let lines: Vec = pts.into_iter().map(|(a, b, x, y)| line((a, b), (x, y), 0)).collect(); + let (faces, edges) = live_paint(&lines); + prop_assert!(edges.len() >= lines.len().min(1)); + for f in &faces { + prop_assert!(f.sources.is_empty()); + if let Some(p) = crate::interior_point(&f.path) { + prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1); + } + } + } +} + +#[test] +fn many_lines_stay_quick() { + // 60 lines through a 100 pt square: about 900 crossings. + let lines: Vec = (0..60) + .map(|k| { + let t = f64::from(k) * 0.37; + line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0) + }) + .collect(); + let started = std::time::Instant::now(); + let (faces, _) = live_paint(&lines); + assert!(faces.len() > 500, "{}", faces.len()); + assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); +} From 16e84598279aa63d35f5cfc5499ad1c816c8ab31 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:39 +0000 Subject: [PATCH 028/199] refactor(ui-egui): move toolbar.rs tests to toolbar/tests.rs --- crates/ui-egui/src/toolbar.rs | 947 +--------------------------- crates/ui-egui/src/toolbar/tests.rs | 944 +++++++++++++++++++++++++++ 2 files changed, 945 insertions(+), 946 deletions(-) create mode 100644 crates/ui-egui/src/toolbar/tests.rs diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index dd9ddd03b..3bf0ef5c4 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -861,949 +861,4 @@ fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -pub(crate) mod tests { - use egui::accesskit::{Role, Toggled}; - use egui::{Event, PointerButton, Pos2}; - use vectorcraft_engine::Session; - - use super::*; - use crate::widgets::accessibility_tests::{role_and_state, tree}; - - /// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { - frame_in(app, ctx, time, events, 1200.0) - } - - /// [`frame`] in a window `height` points tall. - fn frame_in(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec, height: f32) -> Vec { - let input = egui::RawInput { - time: Some(time), - events, - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, height))), - ..Default::default() - }; - let mut out = ctx.run_ui(input, |ui| show(app, ui)); - out.textures_delta.clear(); - widget_rects(ctx, vec2(36.0, PITCH - 1.0)) - } - - /// The rects of the last frame's widgets of `size`, top to bottom. - pub(crate) fn widget_rects(ctx: &egui::Context, size: egui::Vec2) -> Vec { - let mut r: Vec = - ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()); - r.sort_by(|a, b| a.top().total_cmp(&b.top())); - r - } - - /// A mouse wheel turn over `at`, scrolling the content up by `dy` points. - pub(crate) fn wheel(at: Pos2, dy: f32) -> Vec { - let unit = egui::MouseWheelUnit::Point; - vec![ - Event::PointerMoved(at), - Event::MouseWheel { - unit, - delta: vec2(0.0, -dy), - phase: egui::TouchPhase::Move, - modifiers: Default::default(), - source: egui::MouseWheelSource::Unknown, - }, - ] - } - - #[test] - fn the_toolbar_scrolls_in_a_short_window() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - // 500 pt: even two columns don't fit, so Edit Toolbar (the last button) starts below the window. - let tools = frame_in(&mut app, &ctx, 0.0, vec![], 500.0); - let edit_toolbar = || widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap(); - assert!(edit_toolbar().bottom() > 500.0); - frame_in(&mut app, &ctx, 0.1, wheel(tools[0].center(), 2000.0), 500.0); - for k in 2..40 { - frame_in(&mut app, &ctx, f64::from(k) * 0.1, vec![], 500.0); - } - assert!(edit_toolbar().bottom() <= 500.0, "scrolled into view: {:?}", edit_toolbar()); - // Back up to the top. - frame_in(&mut app, &ctx, 4.0, wheel(tools[0].center(), -2000.0), 500.0); - for k in 41..80 { - frame_in(&mut app, &ctx, f64::from(k) * 0.1, vec![], 500.0); - } - assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]); - } - - /// The tool buttons sit in the middle of the panel, between its border and the separator - /// line, docked and floating, in one or two columns (docked, they sat 2½ pt right of centre). - #[test] - fn the_tool_buttons_are_centred_in_the_panel() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - for (double, floating) in [(false, None), (true, None), (false, Some([100.0, 100.0])), (true, Some([100.0, 100.0]))] { - app.run("window.toolbarColumns", json!({ "double": double })).unwrap(); - app.ui.toolbar_pos = floating; - frame(&mut app, &ctx, 0.0, vec![]); - let tools = frame(&mut app, &ctx, 0.1, vec![]); - // The title bar spans the panel's inside. - let hdr = ctx.viewport(|vp| { - vp.prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .filter(|w| w.rect.height() == 14.0 && w.rect.width() > 40.0) - .map(|w| w.rect) - .next() - }); - let hdr = hdr.unwrap(); - let first = tools[0]; - let row_right = tools.iter().filter(|r| r.top() == first.top()).map(|r| r.right()).fold(first.right(), f32::max); - assert_eq!(row_right - hdr.center().x, hdr.center().x - first.left(), "double {double}, floating {floating:?}: {first:?} in {hdr:?}"); - assert!(first.left() >= hdr.left() && row_right <= hdr.right(), "double {double}, floating {floating:?}: {first:?} in {hdr:?}"); - } - } - - /// Screen readers name each tool button by its tool tip and say which tool is active, and name - /// the title bar's column toggle and grip and the paint buttons under the tools. - #[test] - fn screen_readers_name_the_tool_buttons_and_the_active_tool() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.select_tool("pen"); - let tree = tree(|ui| show(&mut app, ui)); - let name = |id| tool_info(id).map(tip).unwrap_or_default(); - assert_eq!(role_and_state(&tree, &name("pen")), (Role::Button, Some(Toggled::True))); - assert_eq!(role_and_state(&tree, &name("selection")), (Role::Button, Some(Toggled::False))); - for label in ["Toggle single/double column", "Drag to float the toolbar", "Color (,)", "Gradient (.)", "None (/)"] { - assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); - } - } - - /// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a - /// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel. - #[test] - fn more_tools_open_their_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - // Without the Artboard tool, the window's artboard. - assert_eq!(open_options(&mut app, "artboard").unwrap()["dialog"], "artboardOptions"); - let d = app.ui.dialog.take().unwrap(); - assert_eq!((d.f64("index", 9.0), d.f64("width", 0.0), d.str("name")), (0.0, 300.0, "Artboard 1".to_string())); - // With it, its active artboard. - app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 50})).unwrap(); - app.run("tool.select", json!({"tool": "artboard"})).unwrap(); - app.run("tool.setOption", json!({"key": "active", "value": 1})).unwrap(); - open_options(&mut app, "artboard").unwrap(); - let d = app.ui.dialog.take().unwrap(); - assert_eq!((d.f64("index", 9.0), d.f64("x", 0.0), d.f64("width", 0.0)), (1.0, 400.0, 100.0)); - // Graph Type needs a selected graph. - app.run("select.set", json!({"ids": []})).unwrap(); - assert!(open_options(&mut app, "pieGraph").is_err()); - assert!(app.ui.dialog.is_none()); - app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap(); - assert_eq!(open_options(&mut app, "pieGraph").unwrap()["dialog"], "command"); - let d = app.ui.dialog.take().unwrap(); - assert_eq!((d.str("__command"), d.str("type")), ("graph.setType".to_string(), "column".to_string())); - // The Magic Wand panel opens, and stays open on a second double-click. - open_options(&mut app, "magicWand").unwrap(); - assert!(crate::panel_docking::showing(&app.ui, "magicWand")); - open_options(&mut app, "magicWand").unwrap(); - assert!(crate::panel_docking::showing(&app.ui, "magicWand")); - } - - /// A double-click at `at`, `time` seconds in (a second apart from the last). - fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) { - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)]); - frame(app, ctx, time + 0.1, vec![]); - } - - #[test] - fn double_clicking_a_tool_button_opens_its_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - let buttons = frame(&mut app, &ctx, 0.0, vec![]); - // The Color category's slots close the list: Gradient, then Eyedropper. - let (gradient, eyedropper) = (buttons[buttons.len() - 2], buttons[buttons.len() - 1]); - double_click(&mut app, &ctx, 1.0, gradient.center()); - assert_eq!((app.session.tool_id(), app.ui.open_panel.as_deref()), ("gradient", Some("gradient"))); - // Again: the panel stays open. - double_click(&mut app, &ctx, 2.0, gradient.center()); - assert_eq!(app.ui.open_panel.as_deref(), Some("gradient")); - double_click(&mut app, &ctx, 3.0, eyedropper.center()); - let d = app.ui.dialog.clone().expect("Eyedropper Options"); - assert_eq!((app.session.tool_id(), d.kind.as_str()), ("eyedropper", crate::dialogs::eyedropper::KIND)); - // OK applies the options. - app.ui.dialog.as_mut().unwrap().fields.insert("apply".into(), json!({"appearance": {"transparency": false}})); - crate::dialogs::confirm(&mut app).unwrap(); - let o = app.session.prefs.eyedropper; - assert!(o.pick_up.appearance.transparency && !o.apply.appearance.transparency && o.apply.appearance.fill.color); - assert!(app.run("tool.options", json!({"tool": "lasso"})).is_err()); - } - - /// A double-click on the Type on a Path or Vertical Type on a Path tool opens Type on a Path - /// Options for the selected type on a path, as the menu item does; with nothing selected, or - /// with no type on a path selected, nothing opens. - #[test] - fn double_clicking_a_type_on_a_path_tool_opens_type_on_a_path_options() { - for tool in ["typeOnPath", "verticalTypeOnPath"] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.ui.toolbar_advanced = true; - app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); - app.select_tool(tool); - let index = slots(&app).iter().position(|(_, tools)| tools.contains(&tool)).expect("a toolbar slot"); - let ctx = egui::Context::default(); - let at = frame_in(&mut app, &ctx, 0.0, vec![], 2000.0)[index].center(); - let double_click = |app: &mut VectorcraftApp, time: f64| { - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame_in(app, &ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)], 2000.0); - frame_in(app, &ctx, time + 0.1, vec![], 2000.0); - }; - // Nothing selected: nothing opens. - double_click(&mut app, 1.0); - assert!(app.ui.dialog.is_none(), "{tool}"); - assert_eq!(app.run("tool.options", json!({ "tool": tool })), Err("nothing selected".into())); - // A path that holds no type: nothing opens either. - let path = app.run("path.create", json!({"d": "M100 200 L500 200"})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [path]})).unwrap(); - double_click(&mut app, 2.0); - assert!(app.ui.dialog.is_none(), "{tool}"); - let e = app.run("tool.options", json!({ "tool": tool })).unwrap_err(); - assert!(e.contains("`type.pathOptions`: no text objects selected"), "{tool}: {e}"); - assert!(app.ui.dialog.is_none(), "{tool}"); - // Type on a path selected: the dialog opens on its options. - let r = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [300, 210]})).unwrap(); - app.run("select.set", json!({"ids": [r["id"]]})).unwrap(); - app.run("type.pathOptions", json!({"effect": "skew"})).unwrap(); - assert_eq!(app.session.tool_id(), tool); - double_click(&mut app, 3.0); - let d = app.ui.dialog.clone().unwrap_or_else(|| panic!("{tool}: Type on a Path Options")); - assert_eq!((d.str("__command"), d.str("__label")), ("type.pathOptions".to_string(), "Type on a Path Options".to_string())); - assert_eq!(d.fields.get("effect"), Some(&json!("skew")), "{tool}"); - assert!(!d.fields.contains_key("start") && !d.fields.contains_key("end"), "{tool}: the menu item's fields"); - // OK applies what the dialog holds. - app.ui.dialog.as_mut().unwrap().fields.insert("effect".into(), json!("gravity")); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(app.session.execute("type.pathOptions", &json!({})).unwrap()["effect"], "gravity", "{tool}"); - } - } - - #[test] - fn polygon_and_star_canvas_counts_persist_without_new_toolbar_dialogs() { - for (tool, key, count) in [("polygon", "sides", 3), ("star", "points", 7)] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width":300,"height":300})).unwrap(); - app.select_tool(tool); - assert!(open_options(&mut app, tool).is_err(), "upstream toolbar opening behavior stays unchanged"); - assert!(app.ui.dialog.is_none()); - let objects = - |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::(); - crate::dialogs::open_tool_dialog(&mut app, tool, json!({"x":120,"y":130})); - let d = app.ui.dialog.as_mut().unwrap(); - assert!(d.fields.contains_key(if tool == "polygon" { "radius" } else { "radius1" })); - d.fields.insert(key.into(), json!(count)); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(objects(&app), 1, "deliberate exact-size placement"); - assert_eq!(app.session.tool_options()[key], count); - for round in 0..3 { - if round == 2 { - app.select_tool("ellipse"); - app.select_tool(tool); - } - let view = app.view_info(); - for (kind, x, y) in [ - (vectorcraft_tools::PointerKind::Down, 50.0, 50.0), - (vectorcraft_tools::PointerKind::Drag, 90.0, 90.0), - (vectorcraft_tools::PointerKind::Up, 90.0, 90.0), - ] { - app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, x, y), view).unwrap(); - } - assert_eq!(objects(&app), round + 2); - let st = app.session.active().unwrap(); - let node = st.doc.node(*st.selection.objects.first().unwrap()).unwrap(); - assert_eq!(node.path_data().unwrap().anchor_count(), if tool == "polygon" { 3 } else { 14 }); - } - crate::dialogs::open_tool_dialog(&mut app, tool, json!({"x":140,"y":150})); - let d = app.ui.dialog.as_mut().unwrap(); - assert_eq!(d.fields[key], count, "reopening uses the chosen count"); - d.fields.insert(key.into(), json!(9)); - crate::dialogs::cancel(&mut app); - assert_eq!(objects(&app), 4); - assert_eq!(app.session.tool_options()[key], count, "Cancel keeps the count"); - } - } - - /// #2691: a double-click on the Line Segment, Arc, Rectangular Grid or Polar Grid tool opens - /// its Tool Options (the Rectangular Grid and Arc help pages, step 3). Cancel leaves the next - /// drag as it was; OK draws nothing and the next drag, and a click's dialog, use the options. - #[test] - fn double_clicking_the_line_arc_and_grid_tools_opens_their_tool_options() { - // (tool, the option changed, its new value, the drag's command, the param it shows in) - let cases = [ - ("lineSegment", "fillLine", json!(true), "shape.line", "fill"), - ("arc", "slope", json!(-30.0), "shape.arc", "slope"), - ("rectangularGrid", "rows", json!(2), "shape.rectangularGrid", "rows"), - ("polarGrid", "radial", json!(3), "shape.polarGrid", "radial"), - ]; - let objects = - |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::(); - let drag = |app: &mut VectorcraftApp| { - let view = app.view_info(); - for (kind, x, y) in [ - (vectorcraft_tools::PointerKind::Down, 50.0, 50.0), - (vectorcraft_tools::PointerKind::Drag, 150.0, 120.0), - (vectorcraft_tools::PointerKind::Up, 150.0, 120.0), - ] { - app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, x, y), view).unwrap(); - } - app.session.journal.last().cloned().expect("the drag's command") - }; - for (tool, key, value, cmd, param) in cases { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.ui.toolbar_advanced = true; - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.select_tool(tool); - let index = slots(&app).iter().position(|(_, tools)| tools.contains(&tool)).expect("a toolbar slot"); - let ctx = egui::Context::default(); - let at = frame_in(&mut app, &ctx, 0.0, vec![], 2000.0)[index].center(); - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame_in(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)], 2000.0); - let d = app.ui.dialog.clone().unwrap_or_else(|| panic!("{tool}: Tool Options")); - assert_eq!((d.kind.as_str(), d.bool("__toolOptions")), (tool, true)); - assert!(!d.fields.contains_key("x"), "{tool}: no click to draw at"); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); - let title = match tool { - "lineSegment" => "Line Segment Tool Options", - "arc" => "Arc Segment Tool Options", - "rectangularGrid" => "Rectangular Grid Tool Options", - _ => "Polar Grid Tool Options", - }; - assert!(text.contains(title) && !text.contains("Reference:"), "{tool}: {text}"); - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert(key.into(), value.clone()); - crate::dialogs::cancel(&mut app); - let (c, p) = drag(&mut app); - assert_eq!(c, cmd); - assert_ne!(p.get(param), Some(&value), "{tool}: Cancel changes nothing"); - assert_eq!(objects(&app), 1); - app.run("tool.options", json!({ "tool": tool })).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert(key.into(), value.clone()); - if tool.ends_with("Grid") { - d.fields.insert("width".into(), json!("150 pt")); - } - crate::dialogs::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - assert_eq!(objects(&app), 1, "{tool}: OK draws nothing"); - let (c, p) = drag(&mut app); - assert_eq!((c.as_str(), &p[param]), (cmd, &value), "{tool}: the next drag uses the option"); - // A click's dialog opens at them too, a grid at its Default Size. - crate::dialogs::open_tool_dialog(&mut app, tool, json!({"x": 100, "y": 100})); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.fields[key], value, "{tool}"); - if tool.ends_with("Grid") { - assert_eq!(d.f64("width", 0.0), 150.0); - } - } - } - - #[test] - fn double_clicking_the_hand_zoom_and_transform_tools() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(660.0, 460.0))); - app.run("view.setZoom", json!({"zoom": 333, "center": [10, 10]})).unwrap(); - // Hand: the artboard fits the window. Zoom: 100%. - app.run("tool.options", json!({"tool": "hand"})).unwrap(); - let v = *app.view().unwrap(); - assert_eq!((v.center.x, v.center.y, v.zoom), (150.0, 100.0, 2.0)); - app.run("tool.options", json!({"tool": "zoom"})).unwrap(); - assert_eq!(app.view().unwrap().zoom, 1.0); - // The transform tools open their dialogs, like Object › Transform: not with nothing selected. - assert_eq!(app.run("tool.options", json!({"tool": "rotate"})), Err("nothing selected".into())); - assert!(app.ui.dialog.is_none()); - let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap(); - for tool in ["rotate", "scale", "reflect", "shear"] { - app.run("tool.options", json!({"tool": tool})).unwrap(); - assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some(tool.to_string())); - } - // OK in Rotate turns the selection about its centre. - app.run("tool.options", json!({"tool": "rotate"})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(90)); - crate::dialogs::confirm(&mut app).unwrap(); - let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); - assert!((b.x0 - 20.0).abs() < 1e-9 && (b.y0 - 0.0).abs() < 1e-9 && (b.width() - 20.0).abs() < 1e-9, "{b:?}"); - } - - #[test] - fn double_clicking_a_selection_tool_opens_the_move_dialog() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - // The Selection tool's button is the first one. - let selection = frame(&mut app, &ctx, 0.0, vec![])[0]; - // Nothing selected: Move is disabled, so nothing opens. - double_click(&mut app, &ctx, 1.0, selection.center()); - assert!(app.ui.dialog.is_none()); - assert_eq!(app.run("tool.options", json!({"tool": "directSelection"})), Err("nothing selected".into())); - let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap(); - double_click(&mut app, &ctx, 2.0, selection.center()); - assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some("move".to_string())); - // The Direct Selection and Group Selection tools open it too. - for tool in ["directSelection", "groupSelection"] { - app.run("tool.options", json!({"tool": tool})).unwrap(); - assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some("move".to_string()), "{tool}"); - } - // OK moves the selection by what was typed. - app.run("tool.options", json!({"tool": "selection"})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("dx".into(), json!("25 pt")); - crate::dialogs::confirm(&mut app).unwrap(); - let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); - assert_eq!((b.x0, b.y0), (35.0, 10.0)); - } - - /// #1616: in every language a flyout is wide enough that no tool's name runs into its shortcut - /// (the Spanish Live Paint names need more than the 250 pt the English ones fit in). - #[test] - fn a_flyout_is_as_wide_as_its_longest_translated_row() { - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - for lang in crate::i18n::Lang::all() { - for group in TOOL_GROUPS { - let rows: Vec = group.iter().map(|tool| row_text(ui, lang, tool, Color32::WHITE)).collect(); - let w = rows_width(rows.iter()); - assert!(w >= 250.0, "{lang:?}"); - for (tool, (name, sc)) in group.iter().zip(&rows) { - let name_end = NAME_X + name.size().x; - let shortcut_start = w - ROW_END - sc.as_ref().map_or(0.0, |sc| sc.size().x); - assert!( - name_end <= shortcut_start + 0.01, - "{lang:?} {}: the name ends at {name_end}, the shortcut starts at {shortcut_start}", - tool.id - ); - } - } - } - let es = crate::i18n::Lang::from_code("es").unwrap(); - let live_paint = TOOL_GROUPS.iter().find(|g| g.iter().any(|t| t.id == "livePaintSelection")).expect("the Live Paint group"); - let rows: Vec = live_paint.iter().map(|tool| row_text(ui, es, tool, Color32::WHITE)).collect(); - assert!(rows_width(rows.iter()) > 250.0, "the Spanish Live Paint flyout is wider than the English one"); - }); - out.textures_delta.clear(); - } - - #[test] - fn right_clicking_a_tool_group_opens_its_flyout() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - let buttons = frame(&mut app, &ctx, 0.0, vec![]); - // Selection, Direct Selection, Lasso, then the Shapes group (Rectangle and its sub-tools). - let at = buttons[3].center(); - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(true)]); - frame(&mut app, &ctx, 1.05, vec![b(false)]); - frame(&mut app, &ctx, 1.1, vec![]); - assert!(app.ui.flyout.is_some(), "the right-click opens the flyout and the same click doesn't close it"); - let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout is shown"); - assert_eq!(app.session.tool_id(), "selection", "a right-click only opens the flyout"); - // Its second row is Rounded Rectangle (30 pt rows). - let row = egui::pos2(menu.left() + 60.0, menu.top() + 45.0); - let p = |pressed| Event::PointerButton { pos: row, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(row), p(true)]); - frame(&mut app, &ctx, 2.05, vec![p(false)]); - assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None)); - // A long press opens it too, and releasing the press (a click, being short of 0.8 s) leaves - // it open without choosing the button's tool. - let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 3.0, vec![Event::PointerMoved(at), p(true)]); - frame(&mut app, &ctx, 3.5, vec![]); - assert!(app.ui.flyout.is_some(), "a long press opens the flyout"); - frame(&mut app, &ctx, 3.6, vec![p(false)]); - frame(&mut app, &ctx, 3.7, vec![]); - assert!(app.ui.flyout.is_some(), "releasing the long press leaves the flyout open"); - // A click anywhere else closes it. - let away = egui::pos2(300.0, 1000.0); - let c = |pressed| Event::PointerButton { pos: away, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 4.0, vec![Event::PointerMoved(away), c(true)]); - frame(&mut app, &ctx, 4.05, vec![c(false)]); - assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None)); - } - - /// The Shapes group's button (Selection, Direct Selection, Lasso, then Rectangle). - fn shapes_button(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect { - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - frame(app, ctx, 0.0, vec![])[3] - } - - #[test] - fn a_held_press_asks_for_the_frame_that_opens_the_flyout() { - // egui draws only on input: a still press must schedule the frame where it becomes long. - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - let at = shapes_button(&mut app, &ctx).center(); - let p = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }; - frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), p]); - // The frames egui runs after the input settle; then nothing moves. - frame(&mut app, &ctx, 1.02, vec![]); - let input = - egui::RawInput { time: Some(1.04), screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), ..Default::default() }; - let out = ctx.run_ui(input, |ui| show(&mut app, ui)); - let delay = out.viewport_output.get(&egui::ViewportId::ROOT).unwrap().repaint_delay; - assert!(delay <= std::time::Duration::from_secs_f64(LONG_PRESS), "repaint in {delay:?}"); - assert!(app.ui.flyout.is_none(), "not open before the press is long"); - } - - #[test] - fn pressing_the_corner_triangle_opens_the_flyout_at_once() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - let button = shapes_button(&mut app, &ctx); - let at = button.right_bottom() - vec2(4.0, 4.0); - let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), p(true)]); - assert!(app.ui.flyout.is_some(), "the press on the triangle opens it"); - frame(&mut app, &ctx, 1.05, vec![p(false)]); - frame(&mut app, &ctx, 1.1, vec![]); - assert_eq!((app.session.tool_id(), app.ui.flyout.is_some()), ("selection", true), "the release neither closes it nor picks the tool"); - // A plain click in the middle of the button still just picks its tool. - let mid = button.center(); - let c = |pressed| Event::PointerButton { pos: mid, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(mid), c(true)]); - frame(&mut app, &ctx, 2.05, vec![c(false)]); - assert_eq!((app.session.tool_id(), app.ui.flyout), ("rectangle", None)); - } - - #[test] - fn the_flyout_opens_on_the_right_press_even_when_its_tool_is_active() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - let at = shapes_button(&mut app, &ctx).center(); - app.select_tool("rectangle"); - for (k, t0) in [1.0, 3.0, 5.0].into_iter().enumerate() { - let r = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, t0, vec![Event::PointerMoved(at), r(true)]); - assert!(app.ui.flyout.is_some(), "right press #{k} opens it before the release"); - frame(&mut app, &ctx, t0 + 0.05, vec![r(false)]); - assert!(app.ui.flyout.is_some(), "right press #{k} leaves it open"); - // A click away closes it; then try again. - let away = egui::pos2(300.0, 1000.0); - let c = |pressed| Event::PointerButton { pos: away, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, t0 + 1.0, vec![Event::PointerMoved(away), c(true)]); - frame(&mut app, &ctx, t0 + 1.05, vec![c(false)]); - assert!(app.ui.flyout.is_none()); - } - // A long press on the active tool's button opens it too. - let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 8.0, vec![Event::PointerMoved(at), p(true)]); - frame(&mut app, &ctx, 8.0 + LONG_PRESS + 0.01, vec![]); - assert!(app.ui.flyout.is_some(), "a long press on the active tool opens it"); - assert_eq!(app.session.tool_id(), "rectangle"); - } - - #[test] - fn type_button_long_press_selects_vertical_type_in_both_layouts() { - for advanced in [false, true] { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.ui.toolbar_advanced = advanced; - let all = slots(&app); - let index = all.iter().position(|(_, tools)| tools.contains(&"type")).unwrap(); - assert!(all[index].1.contains(&"verticalType")); - for id in ["type", "verticalType", "areaType", "verticalAreaType", "typeOnPath", "verticalTypeOnPath"] { - assert_eq!(all.iter().filter(|(_, tools)| tools.contains(&id)).count(), 1); - } - let ctx = egui::Context::default(); - let at = frame_in(&mut app, &ctx, 0.0, vec![], 2000.0)[index].center(); - let pointer = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame_in(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), pointer(at, true)], 2000.0); - frame_in(&mut app, &ctx, 1.36, vec![], 2000.0); - frame_in(&mut app, &ctx, 1.4, vec![pointer(at, false)], 2000.0); - frame_in(&mut app, &ctx, 1.45, vec![], 2000.0); - assert_eq!(app.session.tool_id(), "selection"); - let tools: Vec = ctx.data(|d| d.get_temp(egui::Id::unique("flyout-tools"))).unwrap(); - let row_index = tools.iter().position(|id| id == "verticalType").unwrap(); - let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).unwrap(); - let row = egui::pos2(menu.left() + 60.0, menu.top() + 15.0 + row_index as f32 * 30.0); - frame_in(&mut app, &ctx, 2.0, vec![Event::PointerMoved(row), pointer(row, true)], 2000.0); - frame_in(&mut app, &ctx, 2.05, vec![pointer(row, false)], 2000.0); - assert_eq!(app.session.tool_id(), "verticalType"); - assert_eq!(app.ui.flyout, None); - } - } - - /// A press at `from`, dragged through `to` (one frame each), then released. - fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: &[Pos2]) { - let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, ctx, time, vec![Event::PointerMoved(from), b(from, true)]); - for (k, p) in to.iter().enumerate() { - frame(app, ctx, time + 0.05 * (k + 1) as f64, vec![Event::PointerMoved(*p)]); - } - let end = to.last().copied().unwrap_or(from); - frame(app, ctx, time + 0.05 * (to.len() + 1) as f64, vec![b(end, false)]); - frame(app, ctx, time + 0.05 * (to.len() + 2) as f64, vec![]); - } - - /// A click at `at`. - fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2, button: PointerButton) { - click_in(app, ctx, time, at, button, 1200.0); - } - - /// [`click`] in a window `height` points tall; returns the tool buttons' rects after it. - fn click_in(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2, button: PointerButton, height: f32) -> Vec { - let b = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }; - frame_in(app, ctx, time, vec![Event::PointerMoved(at), b(true)], height); - frame_in(app, ctx, time + 0.05, vec![b(false)], height); - frame_in(app, ctx, time + 0.1, vec![], height) - } - - /// The Color, Gradient and None buttons grow with the toolbar, and in two columns the three - /// drawing modes sit side by side, each setting its mode (#1129). - #[test] - fn the_paint_and_drawing_mode_buttons_fit_the_toolbar_width() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - frame_in(&mut app, &ctx, 0.0, vec![], 900.0); - assert_eq!(widget_rects(&ctx, vec2(11.0, 11.0)).len(), 3, "one column: three 11 pt chips"); - app.ui.toolbar_double = true; - frame_in(&mut app, &ctx, 1.0, vec![], 900.0); - assert_eq!(widget_rects(&ctx, vec2(17.0, 17.0)).len(), 3, "two columns: three 17 pt chips"); - let modes = widget_rects(&ctx, vec2(22.0, 22.0)); - assert_eq!(modes.len(), 3, "the three drawing modes side by side"); - assert!(modes.iter().all(|r| (r.top() - modes[0].top()).abs() < 0.5), "{modes:?}"); - click_in(&mut app, &ctx, 2.0, modes[1].center(), PointerButton::Primary, 900.0); - assert_eq!(app.session.draw_mode, vectorcraft_engine::DrawMode::Behind); - } - - /// How many columns of tool buttons `rects` make. - fn columns(rects: &[egui::Rect]) -> usize { - let mut lefts: Vec = rects.iter().map(|r| r.left().round() as i32).collect(); - lefts.sort_unstable(); - lefts.dedup(); - lefts.len() - } - - /// Rows of tool buttons by their tops: how many buttons each row holds, top to bottom. - fn row_sizes(rects: &[egui::Rect]) -> Vec { - let mut rows: Vec<(i32, usize)> = vec![]; - for r in rects { - let top = r.top().round() as i32; - match rows.last_mut() { - Some((t, n)) if *t == top => *n += 1, - _ => rows.push((top, 1)), - } - } - rows.into_iter().map(|(_, n)| n).collect() - } - - /// #1736: without group labels (as it ships), the tools run on from group to group: one column - /// evenly spaced, with nothing between the groups; two columns with every row full but maybe - /// the last. With the labels, each group starts its own row under its name. - #[test] - fn the_tools_run_on_without_group_labels() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - let one = frame_in(&mut app, &ctx, 0.0, vec![], 1600.0); - assert_eq!(one.len(), slots(&app).len()); - let steps: Vec = one.windows(2).map(|w| w[1].top() - w[0].top()).collect(); - assert!(steps.iter().all(|d| (d - steps[0]).abs() < 0.5), "evenly spaced: {steps:?}"); - app.ui.toolbar_double = true; - let rows = row_sizes(&frame_in(&mut app, &ctx, 0.5, vec![], 1600.0)); - let slots = slots(&app).len(); - assert_eq!(rows.iter().sum::(), slots); - assert!(rows[..rows.len() - 1].iter().all(|n| *n == 2), "{rows:?}"); - assert_eq!(rows.len(), slots.div_ceil(2)); - app.run("prefs.set", json!({"key": "toolGroupLabels", "value": true})).unwrap(); - let labelled = row_sizes(&frame_in(&mut app, &ctx, 1.0, vec![], 1600.0)); - assert!(labelled.len() > rows.len() && labelled.contains(&1), "{labelled:?}"); - } - - #[test] - fn the_double_arrow_toggles_one_and_two_columns_in_a_short_window() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - // 700 pt: too short for the single column, which scrolls instead of turning into two. - assert_eq!(columns(&frame_in(&mut app, &ctx, 0.0, vec![], 700.0)), 1); - let arrow = pos2(8.0, 7.0); - assert_eq!(columns(&click_in(&mut app, &ctx, 1.0, arrow, PointerButton::Primary, 700.0)), 2); - assert!(app.ui.toolbar_double && crate::menus::checked(&app, "window.toolbarColumns", &json!({})) == Some(true)); - assert_eq!(columns(&click_in(&mut app, &ctx, 2.0, arrow, PointerButton::Primary, 700.0)), 1); - assert!(!app.ui.toolbar_double); - } - - #[test] - fn the_columns_command_toggles_or_sets_the_toolbar_columns() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - assert_eq!(app.run("window.toolbarColumns", json!({})).unwrap(), json!(true)); - assert_eq!(columns(&frame_in(&mut app, &ctx, 0.0, vec![], 700.0)), 2); - assert_eq!(app.run("window.toolbarColumns", json!({"double": true})).unwrap(), json!(true)); - assert_eq!(app.run("window.toolbarColumns", json!({})).unwrap(), json!(false)); - assert_eq!(crate::menus::checked(&app, "window.toolbarColumns", &json!({})), Some(false)); - assert_eq!(columns(&frame_in(&mut app, &ctx, 1.0, vec![], 700.0)), 1); - assert!(app.run("window.toolbarColumns", json!({"double": "yes"})).is_err()); - // Advanced (every tool group) is taller still and keeps the column the user chose too. - app.run("window.toolbarAdvanced", json!({})).unwrap(); - assert_eq!(columns(&frame_in(&mut app, &ctx, 2.0, vec![], 700.0)), 1); - } - - #[test] - fn a_flyout_dragged_by_its_bar_floats_until_its_close_box_puts_it_back() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - let at = shapes_button(&mut app, &ctx).center(); - click(&mut app, &ctx, 1.0, at, PointerButton::Secondary); - let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout is shown"); - // Its grab bar runs down its right side: dragging it tears the flyout off. - let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); - drag(&mut app, &ctx, 2.0, bar, &[bar + vec2(20.0, 0.0), bar + vec2(60.0, 40.0)]); - assert_eq!(app.ui.flyout, None, "the flyout became the floating one"); - let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one floating flyout: {:?}", app.ui.floating_flyouts) }; - assert_eq!(f.tools.first().map(String::as_str), Some("rectangle")); - let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown"); - // A strip of its nine tools' buttons, its bar under the pointer. - let drop = bar + vec2(60.0, 40.0); - assert!(float.height() < 40.0 && float.width() > 9.0 * STRIP_BUTTON.x, "a one-row strip: {float:?}"); - assert!( - (float.right() - 1.0 - BAR / 2.0 - drop.x).abs() < 1.0 && float.y_range().contains(drop.y), - "its bar is under the pointer: {float:?}" - ); - // The same bar moves it. - let bar = pos2(float.right() - 1.0 - BAR / 2.0, float.bottom() - 6.0); - drag(&mut app, &ctx, 3.0, bar, &[bar + vec2(0.0, 20.0), bar + vec2(-10.0, 100.0)]); - let moved = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap(); - assert!((moved.min - (float.min + vec2(-10.0, 100.0))).length() < 2.0, "moved to {moved:?} from {float:?}"); - // Picking a tool in it leaves it open. - click(&mut app, &ctx, 4.0, pos2(moved.left() + STRIP_BUTTON.x * 1.5, moved.center().y), PointerButton::Primary); - assert_eq!((app.session.tool_id(), app.ui.floating_flyouts.len()), ("roundedRectangle", 1)); - // While it floats, the presses that open the group's flyout raise it instead. - click(&mut app, &ctx, 5.0, at, PointerButton::Secondary); - assert_eq!(app.ui.flyout, None, "no flyout beside the floating one"); - assert!(ctx.data(|d| d.get_temp::(floating_area("rectangle"))).is_some(), "the floating flyout was raised"); - let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 6.0, vec![Event::PointerMoved(at), p(true)]); - frame(&mut app, &ctx, 6.0 + LONG_PRESS + 0.01, vec![]); - frame(&mut app, &ctx, 6.5, vec![p(false)]); - assert_eq!((app.ui.flyout, app.session.tool_id()), (None, "roundedRectangle"), "a long press neither opens the flyout nor picks the tool"); - // The × at the top of the bar puts it back: the group opens as a flyout again. - click(&mut app, &ctx, 7.0, pos2(moved.right() - BAR / 2.0, moved.top() + CLOSE / 2.0), PointerButton::Primary); - assert!(app.ui.floating_flyouts.is_empty()); - click(&mut app, &ctx, 8.0, at, PointerButton::Secondary); - assert!(app.ui.flyout.is_some()); - } - - #[test] - fn a_click_on_a_flyouts_bar_tears_it_off() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - let at = shapes_button(&mut app, &ctx).center(); - click(&mut app, &ctx, 1.0, at, PointerButton::Secondary); - let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).unwrap(); - let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); - click(&mut app, &ctx, 2.0, bar, PointerButton::Primary); - frame(&mut app, &ctx, 2.2, vec![]); - assert_eq!(app.ui.flyout, None); - assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::>(), ["rectangle"]); - let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown"); - assert!(float.contains(bar), "its bar is under the pointer: {float:?}"); - assert_eq!(app.session.tool_id(), "selection", "no tool picked"); - } - - #[test] - fn releasing_a_long_press_over_the_flyouts_bar_tears_it_off() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - let at = shapes_button(&mut app, &ctx).center(); - let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(at, true)]); - frame(&mut app, &ctx, 1.0 + LONG_PRESS + 0.01, vec![]); - frame(&mut app, &ctx, 1.45, vec![]); - let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the long press opens the flyout"); - let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); - frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(bar)]); - frame(&mut app, &ctx, 1.55, vec![b(bar, false)]); - frame(&mut app, &ctx, 1.6, vec![]); - assert_eq!((app.ui.flyout, app.ui.floating_flyouts.len(), app.session.tool_id()), (None, 1, "selection")); - } - - #[test] - fn window_float_tools_floats_and_puts_back_a_group() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let check = |app: &VectorcraftApp| crate::menus::checked(app, "window.floatTools", &json!({"tool": "star"})); - assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(true)); - let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one strip: {:?}", app.ui.floating_flyouts) }; - assert_eq!(f.tools.first().map(String::as_str), Some("rectangle"), "the Basic toolbar's Shapes group"); - assert_eq!(check(&app), Some(true)); - // Already floating: stays where it is. - app.ui.floating_flyouts[0].pos = [300.0, 400.0]; - assert_eq!(app.run("window.floatTools", json!({"tool": "rectangle", "floating": true})).unwrap(), json!(true)); - assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.pos).collect::>(), [[300.0, 400.0]]); - // Omitted toggles it back. - assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(false)); - assert!(app.ui.floating_flyouts.is_empty() && check(&app) == Some(false)); - assert_eq!(app.run("window.floatTools", json!({"tool": "pen", "floating": false})).unwrap(), json!(false)); - // A tool alone in its slot, an unknown tool, a bad flag. - assert!(app.run("window.floatTools", json!({"tool": "ellipse"})).is_err()); - assert!(app.run("window.floatTools", json!({"tool": "nope"})).is_err()); - assert!(app.run("window.floatTools", json!({})).is_err()); - assert!(app.run("window.floatTools", json!({"tool": "pen", "floating": "yes"})).is_err()); - assert!(app.ui.floating_flyouts.is_empty()); - } - - #[test] - fn workspaces_keep_floating_flyouts_and_reset_puts_them_back() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("window.floatTools", json!({"tool": "pen"})).unwrap(); - app.run("window.workspace.new", json!({"name": "Strips"})).unwrap(); - app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); - assert!(app.ui.floating_flyouts.is_empty(), "the built-in workspaces float none"); - app.run("window.workspace", json!({"name": "Strips"})).unwrap(); - assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::>(), ["pen"]); - // Workspaces saved before floating strips load without any. - let mut old = serde_json::to_value(crate::workspaces::Workspace::default()).unwrap(); - old.as_object_mut().unwrap().remove("floatingFlyouts"); - assert!(serde_json::from_value::(old).unwrap().floating_flyouts.is_empty()); - } - - #[test] - fn loaded_floating_flyouts_keep_known_tools_one_strip_per_group() { - let f = |tools: &[&str]| crate::state::FloatingFlyout { tools: tools.iter().map(|s| s.to_string()).collect(), pos: [0.0, 0.0] }; - let ui = crate::state::UiState { - floating_flyouts: vec![f(&["pen", "nope", "addAnchor"]), f(&["pen", "addAnchor"]), f(&["nope"]), f(&[])], - ..Default::default() - }; - let ui = ui.sanitized(); - assert_eq!(ui.floating_flyouts.iter().map(|f| f.tools.join(",")).collect::>(), ["pen,addAnchor"]); - } - - #[test] - fn floating_flyouts_survive_saved_preferences_and_stay_on_screen() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools: vec!["pen".into(), "addAnchor".into()], pos: [5000.0, 10.0] }); - let saved = serde_json::to_value(&app.ui).unwrap(); - let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); - assert_eq!(back.floating_flyouts.len(), 1); - // Preferences saved before floating flyouts load without any. - let mut old = saved; - old.as_object_mut().unwrap().remove("floating_flyouts"); - assert!(serde_json::from_value::(old).unwrap().floating_flyouts.is_empty()); - let ctx = egui::Context::default(); - shapes_button(&mut app, &ctx); - frame(&mut app, &ctx, 1.0, vec![]); - let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); - assert!(r.right() <= 400.5 && r.left() >= 0.0 && r.top() >= 0.0, "on screen: {r:?}"); - // A position that isn't a number puts it near the toolbar. - app.ui.floating_flyouts[0].pos = [f32::NAN, f32::INFINITY]; - frame(&mut app, &ctx, 2.0, vec![]); - let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); - assert!(r.min.x.is_finite() && r.min.y.is_finite() && r.left() >= 0.0, "on screen: {r:?}"); - } - - /// User Interface › Show Tool Group Labels (#663): off as it ships (#1736), the toolbar shows - /// none of its groups' names and nothing between the groups; on, it names them. - #[test] - fn tool_group_labels_can_be_hidden() { - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let draw = |app: &mut VectorcraftApp| { - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1600.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - let mut shown = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - shown - }; - let without = draw(&mut app); - assert!(!without.iter().any(|t| ["Select", "Shapes", "Draw", "Modify", "Type"].contains(&t.as_str())), "{without:?}"); - app.run("prefs.set", json!({"key": "toolGroupLabels", "value": true})).unwrap(); - let with = draw(&mut app); - assert!(with.iter().any(|t| t == "Shapes") && with.iter().any(|t| t == "Draw"), "{with:?}"); - } - - /// Hover `at` for two seconds, coming from elsewhere: the texts painted meanwhile. - fn hovered_texts(app: &mut VectorcraftApp, ctx: &egui::Context, t0: f64, at: Pos2) -> Vec { - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut shown = vec![]; - for i in 0..20 { - let events = match i { - 0 => vec![Event::PointerGone], - 1 => vec![Event::PointerMoved(at)], - _ => vec![], - }; - let time = t0 + f64::from(i) * 0.1; - let raw = egui::RawInput { - events, - time: Some(time), - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), - ..Default::default() - }; - let mut out = ctx.run_ui(raw, |ui| { - crate::prefs_dialog::apply_runtime(app, ui.ctx()); - show(app, ui); - }); - out.textures_delta.clear(); - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - } - shown - } - - #[test] - fn a_floating_flyouts_buttons_have_tool_tips_when_tool_tips_are_on() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let tools: Vec = ["rectangle", "roundedRectangle", "star"].map(String::from).to_vec(); - app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] }); - let ctx = egui::Context::default(); - shapes_button(&mut app, &ctx); - frame(&mut app, &ctx, 1.0, vec![]); - let strip = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap(); - let star = pos2(strip.left() + STRIP_BUTTON.x * 2.5, strip.center().y); - let tip = tl!("Star Tool").to_string(); - assert!(hovered_texts(&mut app, &ctx, 10.0, star).contains(&tip), "on by default"); - app.session.prefs.show_tool_tips = false; - assert!(!hovered_texts(&mut app, &ctx, 20.0, star).contains(&tip), "off: no tool tip"); - app.session.prefs.show_tool_tips = true; - assert!(hovered_texts(&mut app, &ctx, 30.0, star).contains(&tip), "on again"); - } - - /// Screen readers name a flyout's rows and a floating strip's buttons by their tools' tool tips, - /// with the active tool's pressed, and name the strip's close box and both tear-off bars. - #[test] - fn screen_readers_name_a_flyouts_rows_and_a_floating_strips_buttons() { - let name = |id| tool_info(id).map(tip).unwrap_or_default(); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let group: Vec = ["rectangle", "roundedRectangle", "ellipse"].map(String::from).to_vec(); - let flyout = tree(|ui| { - app.ui.flyout = Some(0); - ui.data_mut(|d| d.insert_temp(egui::Id::unique("flyout-tools"), group.clone())); - show(&mut app, ui); - }); - assert_eq!(role_and_state(&flyout, &name("roundedRectangle")), (Role::Button, Some(Toggled::False))); - assert_eq!(role_and_state(&flyout, &name("ellipse")), (Role::Button, Some(Toggled::False))); - assert_eq!(role_and_state(&flyout, "Drag to float as a toolbar"), (Role::Button, None)); - app.ui.flyout = None; - app.select_tool("star"); - let tools: Vec = ["polygon", "star"].map(String::from).to_vec(); - app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] }); - let strip = tree(|ui| show(&mut app, ui)); - assert_eq!(role_and_state(&strip, &name("polygon")), (Role::Button, Some(Toggled::False))); - assert_eq!(role_and_state(&strip, &name("star")), (Role::Button, Some(Toggled::True))); - assert_eq!(role_and_state(&strip, "Put back in the toolbar"), (Role::Button, None)); - assert_eq!(role_and_state(&strip, "Drag to move"), (Role::Button, None)); - } -} +pub(crate) mod tests; diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs new file mode 100644 index 000000000..43e4addec --- /dev/null +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -0,0 +1,944 @@ +use egui::accesskit::{Role, Toggled}; +use egui::{Event, PointerButton, Pos2}; +use vectorcraft_engine::Session; + +use super::*; +use crate::widgets::accessibility_tests::{role_and_state, tree}; + +/// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { + frame_in(app, ctx, time, events, 1200.0) +} + +/// [`frame`] in a window `height` points tall. +fn frame_in(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec, height: f32) -> Vec { + let input = egui::RawInput { + time: Some(time), + events, + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, height))), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); + widget_rects(ctx, vec2(36.0, PITCH - 1.0)) +} + +/// The rects of the last frame's widgets of `size`, top to bottom. +pub(crate) fn widget_rects(ctx: &egui::Context, size: egui::Vec2) -> Vec { + let mut r: Vec = + ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()); + r.sort_by(|a, b| a.top().total_cmp(&b.top())); + r +} + +/// A mouse wheel turn over `at`, scrolling the content up by `dy` points. +pub(crate) fn wheel(at: Pos2, dy: f32) -> Vec { + let unit = egui::MouseWheelUnit::Point; + vec![ + Event::PointerMoved(at), + Event::MouseWheel { + unit, + delta: vec2(0.0, -dy), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ] +} + +#[test] +fn the_toolbar_scrolls_in_a_short_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // 500 pt: even two columns don't fit, so Edit Toolbar (the last button) starts below the window. + let tools = frame_in(&mut app, &ctx, 0.0, vec![], 500.0); + let edit_toolbar = || widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap(); + assert!(edit_toolbar().bottom() > 500.0); + frame_in(&mut app, &ctx, 0.1, wheel(tools[0].center(), 2000.0), 500.0); + for k in 2..40 { + frame_in(&mut app, &ctx, f64::from(k) * 0.1, vec![], 500.0); + } + assert!(edit_toolbar().bottom() <= 500.0, "scrolled into view: {:?}", edit_toolbar()); + // Back up to the top. + frame_in(&mut app, &ctx, 4.0, wheel(tools[0].center(), -2000.0), 500.0); + for k in 41..80 { + frame_in(&mut app, &ctx, f64::from(k) * 0.1, vec![], 500.0); + } + assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]); +} + +/// The tool buttons sit in the middle of the panel, between its border and the separator +/// line, docked and floating, in one or two columns (docked, they sat 2½ pt right of centre). +#[test] +fn the_tool_buttons_are_centred_in_the_panel() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for (double, floating) in [(false, None), (true, None), (false, Some([100.0, 100.0])), (true, Some([100.0, 100.0]))] { + app.run("window.toolbarColumns", json!({ "double": double })).unwrap(); + app.ui.toolbar_pos = floating; + frame(&mut app, &ctx, 0.0, vec![]); + let tools = frame(&mut app, &ctx, 0.1, vec![]); + // The title bar spans the panel's inside. + let hdr = ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.height() == 14.0 && w.rect.width() > 40.0) + .map(|w| w.rect) + .next() + }); + let hdr = hdr.unwrap(); + let first = tools[0]; + let row_right = tools.iter().filter(|r| r.top() == first.top()).map(|r| r.right()).fold(first.right(), f32::max); + assert_eq!(row_right - hdr.center().x, hdr.center().x - first.left(), "double {double}, floating {floating:?}: {first:?} in {hdr:?}"); + assert!(first.left() >= hdr.left() && row_right <= hdr.right(), "double {double}, floating {floating:?}: {first:?} in {hdr:?}"); + } +} + +/// Screen readers name each tool button by its tool tip and say which tool is active, and name +/// the title bar's column toggle and grip and the paint buttons under the tools. +#[test] +fn screen_readers_name_the_tool_buttons_and_the_active_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.select_tool("pen"); + let tree = tree(|ui| show(&mut app, ui)); + let name = |id| tool_info(id).map(tip).unwrap_or_default(); + assert_eq!(role_and_state(&tree, &name("pen")), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, &name("selection")), (Role::Button, Some(Toggled::False))); + for label in ["Toggle single/double column", "Drag to float the toolbar", "Color (,)", "Gradient (.)", "None (/)"] { + assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); + } +} + +/// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a +/// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel. +#[test] +fn more_tools_open_their_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + // Without the Artboard tool, the window's artboard. + assert_eq!(open_options(&mut app, "artboard").unwrap()["dialog"], "artboardOptions"); + let d = app.ui.dialog.take().unwrap(); + assert_eq!((d.f64("index", 9.0), d.f64("width", 0.0), d.str("name")), (0.0, 300.0, "Artboard 1".to_string())); + // With it, its active artboard. + app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 50})).unwrap(); + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + app.run("tool.setOption", json!({"key": "active", "value": 1})).unwrap(); + open_options(&mut app, "artboard").unwrap(); + let d = app.ui.dialog.take().unwrap(); + assert_eq!((d.f64("index", 9.0), d.f64("x", 0.0), d.f64("width", 0.0)), (1.0, 400.0, 100.0)); + // Graph Type needs a selected graph. + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(open_options(&mut app, "pieGraph").is_err()); + assert!(app.ui.dialog.is_none()); + app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap(); + assert_eq!(open_options(&mut app, "pieGraph").unwrap()["dialog"], "command"); + let d = app.ui.dialog.take().unwrap(); + assert_eq!((d.str("__command"), d.str("type")), ("graph.setType".to_string(), "column".to_string())); + // The Magic Wand panel opens, and stays open on a second double-click. + open_options(&mut app, "magicWand").unwrap(); + assert!(crate::panel_docking::showing(&app.ui, "magicWand")); + open_options(&mut app, "magicWand").unwrap(); + assert!(crate::panel_docking::showing(&app.ui, "magicWand")); +} + +/// A double-click at `at`, `time` seconds in (a second apart from the last). +fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) { + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)]); + frame(app, ctx, time + 0.1, vec![]); +} + +#[test] +fn double_clicking_a_tool_button_opens_its_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let buttons = frame(&mut app, &ctx, 0.0, vec![]); + // The Color category's slots close the list: Gradient, then Eyedropper. + let (gradient, eyedropper) = (buttons[buttons.len() - 2], buttons[buttons.len() - 1]); + double_click(&mut app, &ctx, 1.0, gradient.center()); + assert_eq!((app.session.tool_id(), app.ui.open_panel.as_deref()), ("gradient", Some("gradient"))); + // Again: the panel stays open. + double_click(&mut app, &ctx, 2.0, gradient.center()); + assert_eq!(app.ui.open_panel.as_deref(), Some("gradient")); + double_click(&mut app, &ctx, 3.0, eyedropper.center()); + let d = app.ui.dialog.clone().expect("Eyedropper Options"); + assert_eq!((app.session.tool_id(), d.kind.as_str()), ("eyedropper", crate::dialogs::eyedropper::KIND)); + // OK applies the options. + app.ui.dialog.as_mut().unwrap().fields.insert("apply".into(), json!({"appearance": {"transparency": false}})); + crate::dialogs::confirm(&mut app).unwrap(); + let o = app.session.prefs.eyedropper; + assert!(o.pick_up.appearance.transparency && !o.apply.appearance.transparency && o.apply.appearance.fill.color); + assert!(app.run("tool.options", json!({"tool": "lasso"})).is_err()); +} + +/// A double-click on the Type on a Path or Vertical Type on a Path tool opens Type on a Path +/// Options for the selected type on a path, as the menu item does; with nothing selected, or +/// with no type on a path selected, nothing opens. +#[test] +fn double_clicking_a_type_on_a_path_tool_opens_type_on_a_path_options() { + for tool in ["typeOnPath", "verticalTypeOnPath"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.toolbar_advanced = true; + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + app.select_tool(tool); + let index = slots(&app).iter().position(|(_, tools)| tools.contains(&tool)).expect("a toolbar slot"); + let ctx = egui::Context::default(); + let at = frame_in(&mut app, &ctx, 0.0, vec![], 2000.0)[index].center(); + let double_click = |app: &mut VectorcraftApp, time: f64| { + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame_in(app, &ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)], 2000.0); + frame_in(app, &ctx, time + 0.1, vec![], 2000.0); + }; + // Nothing selected: nothing opens. + double_click(&mut app, 1.0); + assert!(app.ui.dialog.is_none(), "{tool}"); + assert_eq!(app.run("tool.options", json!({ "tool": tool })), Err("nothing selected".into())); + // A path that holds no type: nothing opens either. + let path = app.run("path.create", json!({"d": "M100 200 L500 200"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [path]})).unwrap(); + double_click(&mut app, 2.0); + assert!(app.ui.dialog.is_none(), "{tool}"); + let e = app.run("tool.options", json!({ "tool": tool })).unwrap_err(); + assert!(e.contains("`type.pathOptions`: no text objects selected"), "{tool}: {e}"); + assert!(app.ui.dialog.is_none(), "{tool}"); + // Type on a path selected: the dialog opens on its options. + let r = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [300, 210]})).unwrap(); + app.run("select.set", json!({"ids": [r["id"]]})).unwrap(); + app.run("type.pathOptions", json!({"effect": "skew"})).unwrap(); + assert_eq!(app.session.tool_id(), tool); + double_click(&mut app, 3.0); + let d = app.ui.dialog.clone().unwrap_or_else(|| panic!("{tool}: Type on a Path Options")); + assert_eq!((d.str("__command"), d.str("__label")), ("type.pathOptions".to_string(), "Type on a Path Options".to_string())); + assert_eq!(d.fields.get("effect"), Some(&json!("skew")), "{tool}"); + assert!(!d.fields.contains_key("start") && !d.fields.contains_key("end"), "{tool}: the menu item's fields"); + // OK applies what the dialog holds. + app.ui.dialog.as_mut().unwrap().fields.insert("effect".into(), json!("gravity")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.execute("type.pathOptions", &json!({})).unwrap()["effect"], "gravity", "{tool}"); + } +} + +#[test] +fn polygon_and_star_canvas_counts_persist_without_new_toolbar_dialogs() { + for (tool, key, count) in [("polygon", "sides", 3), ("star", "points", 7)] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":300,"height":300})).unwrap(); + app.select_tool(tool); + assert!(open_options(&mut app, tool).is_err(), "upstream toolbar opening behavior stays unchanged"); + assert!(app.ui.dialog.is_none()); + let objects = + |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::(); + crate::dialogs::open_tool_dialog(&mut app, tool, json!({"x":120,"y":130})); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.fields.contains_key(if tool == "polygon" { "radius" } else { "radius1" })); + d.fields.insert(key.into(), json!(count)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(objects(&app), 1, "deliberate exact-size placement"); + assert_eq!(app.session.tool_options()[key], count); + for round in 0..3 { + if round == 2 { + app.select_tool("ellipse"); + app.select_tool(tool); + } + let view = app.view_info(); + for (kind, x, y) in [ + (vectorcraft_tools::PointerKind::Down, 50.0, 50.0), + (vectorcraft_tools::PointerKind::Drag, 90.0, 90.0), + (vectorcraft_tools::PointerKind::Up, 90.0, 90.0), + ] { + app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, x, y), view).unwrap(); + } + assert_eq!(objects(&app), round + 2); + let st = app.session.active().unwrap(); + let node = st.doc.node(*st.selection.objects.first().unwrap()).unwrap(); + assert_eq!(node.path_data().unwrap().anchor_count(), if tool == "polygon" { 3 } else { 14 }); + } + crate::dialogs::open_tool_dialog(&mut app, tool, json!({"x":140,"y":150})); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields[key], count, "reopening uses the chosen count"); + d.fields.insert(key.into(), json!(9)); + crate::dialogs::cancel(&mut app); + assert_eq!(objects(&app), 4); + assert_eq!(app.session.tool_options()[key], count, "Cancel keeps the count"); + } +} + +/// #2691: a double-click on the Line Segment, Arc, Rectangular Grid or Polar Grid tool opens +/// its Tool Options (the Rectangular Grid and Arc help pages, step 3). Cancel leaves the next +/// drag as it was; OK draws nothing and the next drag, and a click's dialog, use the options. +#[test] +fn double_clicking_the_line_arc_and_grid_tools_opens_their_tool_options() { + // (tool, the option changed, its new value, the drag's command, the param it shows in) + let cases = [ + ("lineSegment", "fillLine", json!(true), "shape.line", "fill"), + ("arc", "slope", json!(-30.0), "shape.arc", "slope"), + ("rectangularGrid", "rows", json!(2), "shape.rectangularGrid", "rows"), + ("polarGrid", "radial", json!(3), "shape.polarGrid", "radial"), + ]; + let objects = + |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::(); + let drag = |app: &mut VectorcraftApp| { + let view = app.view_info(); + for (kind, x, y) in [ + (vectorcraft_tools::PointerKind::Down, 50.0, 50.0), + (vectorcraft_tools::PointerKind::Drag, 150.0, 120.0), + (vectorcraft_tools::PointerKind::Up, 150.0, 120.0), + ] { + app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, x, y), view).unwrap(); + } + app.session.journal.last().cloned().expect("the drag's command") + }; + for (tool, key, value, cmd, param) in cases { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.toolbar_advanced = true; + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.select_tool(tool); + let index = slots(&app).iter().position(|(_, tools)| tools.contains(&tool)).expect("a toolbar slot"); + let ctx = egui::Context::default(); + let at = frame_in(&mut app, &ctx, 0.0, vec![], 2000.0)[index].center(); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame_in(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)], 2000.0); + let d = app.ui.dialog.clone().unwrap_or_else(|| panic!("{tool}: Tool Options")); + assert_eq!((d.kind.as_str(), d.bool("__toolOptions")), (tool, true)); + assert!(!d.fields.contains_key("x"), "{tool}: no click to draw at"); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + let title = match tool { + "lineSegment" => "Line Segment Tool Options", + "arc" => "Arc Segment Tool Options", + "rectangularGrid" => "Rectangular Grid Tool Options", + _ => "Polar Grid Tool Options", + }; + assert!(text.contains(title) && !text.contains("Reference:"), "{tool}: {text}"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert(key.into(), value.clone()); + crate::dialogs::cancel(&mut app); + let (c, p) = drag(&mut app); + assert_eq!(c, cmd); + assert_ne!(p.get(param), Some(&value), "{tool}: Cancel changes nothing"); + assert_eq!(objects(&app), 1); + app.run("tool.options", json!({ "tool": tool })).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert(key.into(), value.clone()); + if tool.ends_with("Grid") { + d.fields.insert("width".into(), json!("150 pt")); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(objects(&app), 1, "{tool}: OK draws nothing"); + let (c, p) = drag(&mut app); + assert_eq!((c.as_str(), &p[param]), (cmd, &value), "{tool}: the next drag uses the option"); + // A click's dialog opens at them too, a grid at its Default Size. + crate::dialogs::open_tool_dialog(&mut app, tool, json!({"x": 100, "y": 100})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields[key], value, "{tool}"); + if tool.ends_with("Grid") { + assert_eq!(d.f64("width", 0.0), 150.0); + } + } +} + +#[test] +fn double_clicking_the_hand_zoom_and_transform_tools() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(660.0, 460.0))); + app.run("view.setZoom", json!({"zoom": 333, "center": [10, 10]})).unwrap(); + // Hand: the artboard fits the window. Zoom: 100%. + app.run("tool.options", json!({"tool": "hand"})).unwrap(); + let v = *app.view().unwrap(); + assert_eq!((v.center.x, v.center.y, v.zoom), (150.0, 100.0, 2.0)); + app.run("tool.options", json!({"tool": "zoom"})).unwrap(); + assert_eq!(app.view().unwrap().zoom, 1.0); + // The transform tools open their dialogs, like Object › Transform: not with nothing selected. + assert_eq!(app.run("tool.options", json!({"tool": "rotate"})), Err("nothing selected".into())); + assert!(app.ui.dialog.is_none()); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap(); + for tool in ["rotate", "scale", "reflect", "shear"] { + app.run("tool.options", json!({"tool": tool})).unwrap(); + assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some(tool.to_string())); + } + // OK in Rotate turns the selection about its centre. + app.run("tool.options", json!({"tool": "rotate"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(90)); + crate::dialogs::confirm(&mut app).unwrap(); + let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert!((b.x0 - 20.0).abs() < 1e-9 && (b.y0 - 0.0).abs() < 1e-9 && (b.width() - 20.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn double_clicking_a_selection_tool_opens_the_move_dialog() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + // The Selection tool's button is the first one. + let selection = frame(&mut app, &ctx, 0.0, vec![])[0]; + // Nothing selected: Move is disabled, so nothing opens. + double_click(&mut app, &ctx, 1.0, selection.center()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.run("tool.options", json!({"tool": "directSelection"})), Err("nothing selected".into())); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap(); + double_click(&mut app, &ctx, 2.0, selection.center()); + assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some("move".to_string())); + // The Direct Selection and Group Selection tools open it too. + for tool in ["directSelection", "groupSelection"] { + app.run("tool.options", json!({"tool": tool})).unwrap(); + assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some("move".to_string()), "{tool}"); + } + // OK moves the selection by what was typed. + app.run("tool.options", json!({"tool": "selection"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("dx".into(), json!("25 pt")); + crate::dialogs::confirm(&mut app).unwrap(); + let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0), (35.0, 10.0)); +} + +/// #1616: in every language a flyout is wide enough that no tool's name runs into its shortcut +/// (the Spanish Live Paint names need more than the 250 pt the English ones fit in). +#[test] +fn a_flyout_is_as_wide_as_its_longest_translated_row() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + for lang in crate::i18n::Lang::all() { + for group in TOOL_GROUPS { + let rows: Vec = group.iter().map(|tool| row_text(ui, lang, tool, Color32::WHITE)).collect(); + let w = rows_width(rows.iter()); + assert!(w >= 250.0, "{lang:?}"); + for (tool, (name, sc)) in group.iter().zip(&rows) { + let name_end = NAME_X + name.size().x; + let shortcut_start = w - ROW_END - sc.as_ref().map_or(0.0, |sc| sc.size().x); + assert!( + name_end <= shortcut_start + 0.01, + "{lang:?} {}: the name ends at {name_end}, the shortcut starts at {shortcut_start}", + tool.id + ); + } + } + } + let es = crate::i18n::Lang::from_code("es").unwrap(); + let live_paint = TOOL_GROUPS.iter().find(|g| g.iter().any(|t| t.id == "livePaintSelection")).expect("the Live Paint group"); + let rows: Vec = live_paint.iter().map(|tool| row_text(ui, es, tool, Color32::WHITE)).collect(); + assert!(rows_width(rows.iter()) > 250.0, "the Spanish Live Paint flyout is wider than the English one"); + }); + out.textures_delta.clear(); +} + +#[test] +fn right_clicking_a_tool_group_opens_its_flyout() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let buttons = frame(&mut app, &ctx, 0.0, vec![]); + // Selection, Direct Selection, Lasso, then the Shapes group (Rectangle and its sub-tools). + let at = buttons[3].center(); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(true)]); + frame(&mut app, &ctx, 1.05, vec![b(false)]); + frame(&mut app, &ctx, 1.1, vec![]); + assert!(app.ui.flyout.is_some(), "the right-click opens the flyout and the same click doesn't close it"); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout is shown"); + assert_eq!(app.session.tool_id(), "selection", "a right-click only opens the flyout"); + // Its second row is Rounded Rectangle (30 pt rows). + let row = egui::pos2(menu.left() + 60.0, menu.top() + 45.0); + let p = |pressed| Event::PointerButton { pos: row, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(row), p(true)]); + frame(&mut app, &ctx, 2.05, vec![p(false)]); + assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None)); + // A long press opens it too, and releasing the press (a click, being short of 0.8 s) leaves + // it open without choosing the button's tool. + let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 3.0, vec![Event::PointerMoved(at), p(true)]); + frame(&mut app, &ctx, 3.5, vec![]); + assert!(app.ui.flyout.is_some(), "a long press opens the flyout"); + frame(&mut app, &ctx, 3.6, vec![p(false)]); + frame(&mut app, &ctx, 3.7, vec![]); + assert!(app.ui.flyout.is_some(), "releasing the long press leaves the flyout open"); + // A click anywhere else closes it. + let away = egui::pos2(300.0, 1000.0); + let c = |pressed| Event::PointerButton { pos: away, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 4.0, vec![Event::PointerMoved(away), c(true)]); + frame(&mut app, &ctx, 4.05, vec![c(false)]); + assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None)); +} + +/// The Shapes group's button (Selection, Direct Selection, Lasso, then Rectangle). +fn shapes_button(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect { + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + frame(app, ctx, 0.0, vec![])[3] +} + +#[test] +fn a_held_press_asks_for_the_frame_that_opens_the_flyout() { + // egui draws only on input: a still press must schedule the frame where it becomes long. + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let at = shapes_button(&mut app, &ctx).center(); + let p = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), p]); + // The frames egui runs after the input settle; then nothing moves. + frame(&mut app, &ctx, 1.02, vec![]); + let input = + egui::RawInput { time: Some(1.04), screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), ..Default::default() }; + let out = ctx.run_ui(input, |ui| show(&mut app, ui)); + let delay = out.viewport_output.get(&egui::ViewportId::ROOT).unwrap().repaint_delay; + assert!(delay <= std::time::Duration::from_secs_f64(LONG_PRESS), "repaint in {delay:?}"); + assert!(app.ui.flyout.is_none(), "not open before the press is long"); +} + +#[test] +fn pressing_the_corner_triangle_opens_the_flyout_at_once() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let button = shapes_button(&mut app, &ctx); + let at = button.right_bottom() - vec2(4.0, 4.0); + let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), p(true)]); + assert!(app.ui.flyout.is_some(), "the press on the triangle opens it"); + frame(&mut app, &ctx, 1.05, vec![p(false)]); + frame(&mut app, &ctx, 1.1, vec![]); + assert_eq!((app.session.tool_id(), app.ui.flyout.is_some()), ("selection", true), "the release neither closes it nor picks the tool"); + // A plain click in the middle of the button still just picks its tool. + let mid = button.center(); + let c = |pressed| Event::PointerButton { pos: mid, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(mid), c(true)]); + frame(&mut app, &ctx, 2.05, vec![c(false)]); + assert_eq!((app.session.tool_id(), app.ui.flyout), ("rectangle", None)); +} + +#[test] +fn the_flyout_opens_on_the_right_press_even_when_its_tool_is_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let at = shapes_button(&mut app, &ctx).center(); + app.select_tool("rectangle"); + for (k, t0) in [1.0, 3.0, 5.0].into_iter().enumerate() { + let r = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, t0, vec![Event::PointerMoved(at), r(true)]); + assert!(app.ui.flyout.is_some(), "right press #{k} opens it before the release"); + frame(&mut app, &ctx, t0 + 0.05, vec![r(false)]); + assert!(app.ui.flyout.is_some(), "right press #{k} leaves it open"); + // A click away closes it; then try again. + let away = egui::pos2(300.0, 1000.0); + let c = |pressed| Event::PointerButton { pos: away, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, t0 + 1.0, vec![Event::PointerMoved(away), c(true)]); + frame(&mut app, &ctx, t0 + 1.05, vec![c(false)]); + assert!(app.ui.flyout.is_none()); + } + // A long press on the active tool's button opens it too. + let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 8.0, vec![Event::PointerMoved(at), p(true)]); + frame(&mut app, &ctx, 8.0 + LONG_PRESS + 0.01, vec![]); + assert!(app.ui.flyout.is_some(), "a long press on the active tool opens it"); + assert_eq!(app.session.tool_id(), "rectangle"); +} + +#[test] +fn type_button_long_press_selects_vertical_type_in_both_layouts() { + for advanced in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.toolbar_advanced = advanced; + let all = slots(&app); + let index = all.iter().position(|(_, tools)| tools.contains(&"type")).unwrap(); + assert!(all[index].1.contains(&"verticalType")); + for id in ["type", "verticalType", "areaType", "verticalAreaType", "typeOnPath", "verticalTypeOnPath"] { + assert_eq!(all.iter().filter(|(_, tools)| tools.contains(&id)).count(), 1); + } + let ctx = egui::Context::default(); + let at = frame_in(&mut app, &ctx, 0.0, vec![], 2000.0)[index].center(); + let pointer = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame_in(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), pointer(at, true)], 2000.0); + frame_in(&mut app, &ctx, 1.36, vec![], 2000.0); + frame_in(&mut app, &ctx, 1.4, vec![pointer(at, false)], 2000.0); + frame_in(&mut app, &ctx, 1.45, vec![], 2000.0); + assert_eq!(app.session.tool_id(), "selection"); + let tools: Vec = ctx.data(|d| d.get_temp(egui::Id::unique("flyout-tools"))).unwrap(); + let row_index = tools.iter().position(|id| id == "verticalType").unwrap(); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).unwrap(); + let row = egui::pos2(menu.left() + 60.0, menu.top() + 15.0 + row_index as f32 * 30.0); + frame_in(&mut app, &ctx, 2.0, vec![Event::PointerMoved(row), pointer(row, true)], 2000.0); + frame_in(&mut app, &ctx, 2.05, vec![pointer(row, false)], 2000.0); + assert_eq!(app.session.tool_id(), "verticalType"); + assert_eq!(app.ui.flyout, None); + } +} + +/// A press at `from`, dragged through `to` (one frame each), then released. +fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: &[Pos2]) { + let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, time, vec![Event::PointerMoved(from), b(from, true)]); + for (k, p) in to.iter().enumerate() { + frame(app, ctx, time + 0.05 * (k + 1) as f64, vec![Event::PointerMoved(*p)]); + } + let end = to.last().copied().unwrap_or(from); + frame(app, ctx, time + 0.05 * (to.len() + 1) as f64, vec![b(end, false)]); + frame(app, ctx, time + 0.05 * (to.len() + 2) as f64, vec![]); +} + +/// A click at `at`. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2, button: PointerButton) { + click_in(app, ctx, time, at, button, 1200.0); +} + +/// [`click`] in a window `height` points tall; returns the tool buttons' rects after it. +fn click_in(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2, button: PointerButton, height: f32) -> Vec { + let b = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }; + frame_in(app, ctx, time, vec![Event::PointerMoved(at), b(true)], height); + frame_in(app, ctx, time + 0.05, vec![b(false)], height); + frame_in(app, ctx, time + 0.1, vec![], height) +} + +/// The Color, Gradient and None buttons grow with the toolbar, and in two columns the three +/// drawing modes sit side by side, each setting its mode (#1129). +#[test] +fn the_paint_and_drawing_mode_buttons_fit_the_toolbar_width() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + frame_in(&mut app, &ctx, 0.0, vec![], 900.0); + assert_eq!(widget_rects(&ctx, vec2(11.0, 11.0)).len(), 3, "one column: three 11 pt chips"); + app.ui.toolbar_double = true; + frame_in(&mut app, &ctx, 1.0, vec![], 900.0); + assert_eq!(widget_rects(&ctx, vec2(17.0, 17.0)).len(), 3, "two columns: three 17 pt chips"); + let modes = widget_rects(&ctx, vec2(22.0, 22.0)); + assert_eq!(modes.len(), 3, "the three drawing modes side by side"); + assert!(modes.iter().all(|r| (r.top() - modes[0].top()).abs() < 0.5), "{modes:?}"); + click_in(&mut app, &ctx, 2.0, modes[1].center(), PointerButton::Primary, 900.0); + assert_eq!(app.session.draw_mode, vectorcraft_engine::DrawMode::Behind); +} + +/// How many columns of tool buttons `rects` make. +fn columns(rects: &[egui::Rect]) -> usize { + let mut lefts: Vec = rects.iter().map(|r| r.left().round() as i32).collect(); + lefts.sort_unstable(); + lefts.dedup(); + lefts.len() +} + +/// Rows of tool buttons by their tops: how many buttons each row holds, top to bottom. +fn row_sizes(rects: &[egui::Rect]) -> Vec { + let mut rows: Vec<(i32, usize)> = vec![]; + for r in rects { + let top = r.top().round() as i32; + match rows.last_mut() { + Some((t, n)) if *t == top => *n += 1, + _ => rows.push((top, 1)), + } + } + rows.into_iter().map(|(_, n)| n).collect() +} + +/// #1736: without group labels (as it ships), the tools run on from group to group: one column +/// evenly spaced, with nothing between the groups; two columns with every row full but maybe +/// the last. With the labels, each group starts its own row under its name. +#[test] +fn the_tools_run_on_without_group_labels() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + let one = frame_in(&mut app, &ctx, 0.0, vec![], 1600.0); + assert_eq!(one.len(), slots(&app).len()); + let steps: Vec = one.windows(2).map(|w| w[1].top() - w[0].top()).collect(); + assert!(steps.iter().all(|d| (d - steps[0]).abs() < 0.5), "evenly spaced: {steps:?}"); + app.ui.toolbar_double = true; + let rows = row_sizes(&frame_in(&mut app, &ctx, 0.5, vec![], 1600.0)); + let slots = slots(&app).len(); + assert_eq!(rows.iter().sum::(), slots); + assert!(rows[..rows.len() - 1].iter().all(|n| *n == 2), "{rows:?}"); + assert_eq!(rows.len(), slots.div_ceil(2)); + app.run("prefs.set", json!({"key": "toolGroupLabels", "value": true})).unwrap(); + let labelled = row_sizes(&frame_in(&mut app, &ctx, 1.0, vec![], 1600.0)); + assert!(labelled.len() > rows.len() && labelled.contains(&1), "{labelled:?}"); +} + +#[test] +fn the_double_arrow_toggles_one_and_two_columns_in_a_short_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + // 700 pt: too short for the single column, which scrolls instead of turning into two. + assert_eq!(columns(&frame_in(&mut app, &ctx, 0.0, vec![], 700.0)), 1); + let arrow = pos2(8.0, 7.0); + assert_eq!(columns(&click_in(&mut app, &ctx, 1.0, arrow, PointerButton::Primary, 700.0)), 2); + assert!(app.ui.toolbar_double && crate::menus::checked(&app, "window.toolbarColumns", &json!({})) == Some(true)); + assert_eq!(columns(&click_in(&mut app, &ctx, 2.0, arrow, PointerButton::Primary, 700.0)), 1); + assert!(!app.ui.toolbar_double); +} + +#[test] +fn the_columns_command_toggles_or_sets_the_toolbar_columns() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + assert_eq!(app.run("window.toolbarColumns", json!({})).unwrap(), json!(true)); + assert_eq!(columns(&frame_in(&mut app, &ctx, 0.0, vec![], 700.0)), 2); + assert_eq!(app.run("window.toolbarColumns", json!({"double": true})).unwrap(), json!(true)); + assert_eq!(app.run("window.toolbarColumns", json!({})).unwrap(), json!(false)); + assert_eq!(crate::menus::checked(&app, "window.toolbarColumns", &json!({})), Some(false)); + assert_eq!(columns(&frame_in(&mut app, &ctx, 1.0, vec![], 700.0)), 1); + assert!(app.run("window.toolbarColumns", json!({"double": "yes"})).is_err()); + // Advanced (every tool group) is taller still and keeps the column the user chose too. + app.run("window.toolbarAdvanced", json!({})).unwrap(); + assert_eq!(columns(&frame_in(&mut app, &ctx, 2.0, vec![], 700.0)), 1); +} + +#[test] +fn a_flyout_dragged_by_its_bar_floats_until_its_close_box_puts_it_back() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let at = shapes_button(&mut app, &ctx).center(); + click(&mut app, &ctx, 1.0, at, PointerButton::Secondary); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout is shown"); + // Its grab bar runs down its right side: dragging it tears the flyout off. + let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); + drag(&mut app, &ctx, 2.0, bar, &[bar + vec2(20.0, 0.0), bar + vec2(60.0, 40.0)]); + assert_eq!(app.ui.flyout, None, "the flyout became the floating one"); + let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one floating flyout: {:?}", app.ui.floating_flyouts) }; + assert_eq!(f.tools.first().map(String::as_str), Some("rectangle")); + let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown"); + // A strip of its nine tools' buttons, its bar under the pointer. + let drop = bar + vec2(60.0, 40.0); + assert!(float.height() < 40.0 && float.width() > 9.0 * STRIP_BUTTON.x, "a one-row strip: {float:?}"); + assert!( + (float.right() - 1.0 - BAR / 2.0 - drop.x).abs() < 1.0 && float.y_range().contains(drop.y), + "its bar is under the pointer: {float:?}" + ); + // The same bar moves it. + let bar = pos2(float.right() - 1.0 - BAR / 2.0, float.bottom() - 6.0); + drag(&mut app, &ctx, 3.0, bar, &[bar + vec2(0.0, 20.0), bar + vec2(-10.0, 100.0)]); + let moved = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap(); + assert!((moved.min - (float.min + vec2(-10.0, 100.0))).length() < 2.0, "moved to {moved:?} from {float:?}"); + // Picking a tool in it leaves it open. + click(&mut app, &ctx, 4.0, pos2(moved.left() + STRIP_BUTTON.x * 1.5, moved.center().y), PointerButton::Primary); + assert_eq!((app.session.tool_id(), app.ui.floating_flyouts.len()), ("roundedRectangle", 1)); + // While it floats, the presses that open the group's flyout raise it instead. + click(&mut app, &ctx, 5.0, at, PointerButton::Secondary); + assert_eq!(app.ui.flyout, None, "no flyout beside the floating one"); + assert!(ctx.data(|d| d.get_temp::(floating_area("rectangle"))).is_some(), "the floating flyout was raised"); + let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 6.0, vec![Event::PointerMoved(at), p(true)]); + frame(&mut app, &ctx, 6.0 + LONG_PRESS + 0.01, vec![]); + frame(&mut app, &ctx, 6.5, vec![p(false)]); + assert_eq!((app.ui.flyout, app.session.tool_id()), (None, "roundedRectangle"), "a long press neither opens the flyout nor picks the tool"); + // The × at the top of the bar puts it back: the group opens as a flyout again. + click(&mut app, &ctx, 7.0, pos2(moved.right() - BAR / 2.0, moved.top() + CLOSE / 2.0), PointerButton::Primary); + assert!(app.ui.floating_flyouts.is_empty()); + click(&mut app, &ctx, 8.0, at, PointerButton::Secondary); + assert!(app.ui.flyout.is_some()); +} + +#[test] +fn a_click_on_a_flyouts_bar_tears_it_off() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let at = shapes_button(&mut app, &ctx).center(); + click(&mut app, &ctx, 1.0, at, PointerButton::Secondary); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).unwrap(); + let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); + click(&mut app, &ctx, 2.0, bar, PointerButton::Primary); + frame(&mut app, &ctx, 2.2, vec![]); + assert_eq!(app.ui.flyout, None); + assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::>(), ["rectangle"]); + let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown"); + assert!(float.contains(bar), "its bar is under the pointer: {float:?}"); + assert_eq!(app.session.tool_id(), "selection", "no tool picked"); +} + +#[test] +fn releasing_a_long_press_over_the_flyouts_bar_tears_it_off() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let at = shapes_button(&mut app, &ctx).center(); + let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(at, true)]); + frame(&mut app, &ctx, 1.0 + LONG_PRESS + 0.01, vec![]); + frame(&mut app, &ctx, 1.45, vec![]); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the long press opens the flyout"); + let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); + frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(bar)]); + frame(&mut app, &ctx, 1.55, vec![b(bar, false)]); + frame(&mut app, &ctx, 1.6, vec![]); + assert_eq!((app.ui.flyout, app.ui.floating_flyouts.len(), app.session.tool_id()), (None, 1, "selection")); +} + +#[test] +fn window_float_tools_floats_and_puts_back_a_group() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let check = |app: &VectorcraftApp| crate::menus::checked(app, "window.floatTools", &json!({"tool": "star"})); + assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(true)); + let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one strip: {:?}", app.ui.floating_flyouts) }; + assert_eq!(f.tools.first().map(String::as_str), Some("rectangle"), "the Basic toolbar's Shapes group"); + assert_eq!(check(&app), Some(true)); + // Already floating: stays where it is. + app.ui.floating_flyouts[0].pos = [300.0, 400.0]; + assert_eq!(app.run("window.floatTools", json!({"tool": "rectangle", "floating": true})).unwrap(), json!(true)); + assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.pos).collect::>(), [[300.0, 400.0]]); + // Omitted toggles it back. + assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(false)); + assert!(app.ui.floating_flyouts.is_empty() && check(&app) == Some(false)); + assert_eq!(app.run("window.floatTools", json!({"tool": "pen", "floating": false})).unwrap(), json!(false)); + // A tool alone in its slot, an unknown tool, a bad flag. + assert!(app.run("window.floatTools", json!({"tool": "ellipse"})).is_err()); + assert!(app.run("window.floatTools", json!({"tool": "nope"})).is_err()); + assert!(app.run("window.floatTools", json!({})).is_err()); + assert!(app.run("window.floatTools", json!({"tool": "pen", "floating": "yes"})).is_err()); + assert!(app.ui.floating_flyouts.is_empty()); +} + +#[test] +fn workspaces_keep_floating_flyouts_and_reset_puts_them_back() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.floatTools", json!({"tool": "pen"})).unwrap(); + app.run("window.workspace.new", json!({"name": "Strips"})).unwrap(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + assert!(app.ui.floating_flyouts.is_empty(), "the built-in workspaces float none"); + app.run("window.workspace", json!({"name": "Strips"})).unwrap(); + assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::>(), ["pen"]); + // Workspaces saved before floating strips load without any. + let mut old = serde_json::to_value(crate::workspaces::Workspace::default()).unwrap(); + old.as_object_mut().unwrap().remove("floatingFlyouts"); + assert!(serde_json::from_value::(old).unwrap().floating_flyouts.is_empty()); +} + +#[test] +fn loaded_floating_flyouts_keep_known_tools_one_strip_per_group() { + let f = |tools: &[&str]| crate::state::FloatingFlyout { tools: tools.iter().map(|s| s.to_string()).collect(), pos: [0.0, 0.0] }; + let ui = crate::state::UiState { + floating_flyouts: vec![f(&["pen", "nope", "addAnchor"]), f(&["pen", "addAnchor"]), f(&["nope"]), f(&[])], + ..Default::default() + }; + let ui = ui.sanitized(); + assert_eq!(ui.floating_flyouts.iter().map(|f| f.tools.join(",")).collect::>(), ["pen,addAnchor"]); +} + +#[test] +fn floating_flyouts_survive_saved_preferences_and_stay_on_screen() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools: vec!["pen".into(), "addAnchor".into()], pos: [5000.0, 10.0] }); + let saved = serde_json::to_value(&app.ui).unwrap(); + let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(back.floating_flyouts.len(), 1); + // Preferences saved before floating flyouts load without any. + let mut old = saved; + old.as_object_mut().unwrap().remove("floating_flyouts"); + assert!(serde_json::from_value::(old).unwrap().floating_flyouts.is_empty()); + let ctx = egui::Context::default(); + shapes_button(&mut app, &ctx); + frame(&mut app, &ctx, 1.0, vec![]); + let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); + assert!(r.right() <= 400.5 && r.left() >= 0.0 && r.top() >= 0.0, "on screen: {r:?}"); + // A position that isn't a number puts it near the toolbar. + app.ui.floating_flyouts[0].pos = [f32::NAN, f32::INFINITY]; + frame(&mut app, &ctx, 2.0, vec![]); + let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); + assert!(r.min.x.is_finite() && r.min.y.is_finite() && r.left() >= 0.0, "on screen: {r:?}"); +} + +/// User Interface › Show Tool Group Labels (#663): off as it ships (#1736), the toolbar shows +/// none of its groups' names and nothing between the groups; on, it names them. +#[test] +fn tool_group_labels_can_be_hidden() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |app: &mut VectorcraftApp| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + shown + }; + let without = draw(&mut app); + assert!(!without.iter().any(|t| ["Select", "Shapes", "Draw", "Modify", "Type"].contains(&t.as_str())), "{without:?}"); + app.run("prefs.set", json!({"key": "toolGroupLabels", "value": true})).unwrap(); + let with = draw(&mut app); + assert!(with.iter().any(|t| t == "Shapes") && with.iter().any(|t| t == "Draw"), "{with:?}"); +} + +/// Hover `at` for two seconds, coming from elsewhere: the texts painted meanwhile. +fn hovered_texts(app: &mut VectorcraftApp, ctx: &egui::Context, t0: f64, at: Pos2) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut shown = vec![]; + for i in 0..20 { + let events = match i { + 0 => vec![Event::PointerGone], + 1 => vec![Event::PointerMoved(at)], + _ => vec![], + }; + let time = t0 + f64::from(i) * 0.1; + let raw = egui::RawInput { + events, + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + crate::prefs_dialog::apply_runtime(app, ui.ctx()); + show(app, ui); + }); + out.textures_delta.clear(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + } + shown +} + +#[test] +fn a_floating_flyouts_buttons_have_tool_tips_when_tool_tips_are_on() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let tools: Vec = ["rectangle", "roundedRectangle", "star"].map(String::from).to_vec(); + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] }); + let ctx = egui::Context::default(); + shapes_button(&mut app, &ctx); + frame(&mut app, &ctx, 1.0, vec![]); + let strip = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap(); + let star = pos2(strip.left() + STRIP_BUTTON.x * 2.5, strip.center().y); + let tip = tl!("Star Tool").to_string(); + assert!(hovered_texts(&mut app, &ctx, 10.0, star).contains(&tip), "on by default"); + app.session.prefs.show_tool_tips = false; + assert!(!hovered_texts(&mut app, &ctx, 20.0, star).contains(&tip), "off: no tool tip"); + app.session.prefs.show_tool_tips = true; + assert!(hovered_texts(&mut app, &ctx, 30.0, star).contains(&tip), "on again"); +} + +/// Screen readers name a flyout's rows and a floating strip's buttons by their tools' tool tips, +/// with the active tool's pressed, and name the strip's close box and both tear-off bars. +#[test] +fn screen_readers_name_a_flyouts_rows_and_a_floating_strips_buttons() { + let name = |id| tool_info(id).map(tip).unwrap_or_default(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let group: Vec = ["rectangle", "roundedRectangle", "ellipse"].map(String::from).to_vec(); + let flyout = tree(|ui| { + app.ui.flyout = Some(0); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("flyout-tools"), group.clone())); + show(&mut app, ui); + }); + assert_eq!(role_and_state(&flyout, &name("roundedRectangle")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&flyout, &name("ellipse")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&flyout, "Drag to float as a toolbar"), (Role::Button, None)); + app.ui.flyout = None; + app.select_tool("star"); + let tools: Vec = ["polygon", "star"].map(String::from).to_vec(); + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] }); + let strip = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&strip, &name("polygon")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&strip, &name("star")), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&strip, "Put back in the toolbar"), (Role::Button, None)); + assert_eq!(role_and_state(&strip, "Drag to move"), (Role::Button, None)); +} From 8efb850d6d32f51115ac51347cf19f12628da520 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:40 +0000 Subject: [PATCH 029/199] refactor(doc): move hit.rs tests to hit/tests.rs --- crates/doc/src/hit.rs | 841 +----------------------------------- crates/doc/src/hit/tests.rs | 838 +++++++++++++++++++++++++++++++++++ 2 files changed, 839 insertions(+), 840 deletions(-) create mode 100644 crates/doc/src/hit/tests.rs diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 6f142a584..d93f923d3 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -731,843 +731,4 @@ fn marquee_in_clip(clip: &Node, r: Rect) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use crate::{Appearance, Node}; - use std::sync::Arc; - use vectorcraft_color::{Color, Paint}; - use vectorcraft_geom::shapes; - - #[test] - fn hits_topmost() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - let b = d.alloc_id(); - d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap(); - let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap(); - assert_eq!(h.leaf, b); - assert_eq!(h.kind, HitKind::Fill); - let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); - assert_eq!(h.leaf, a); - assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none()); - // Outline mode: interior misses. - assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none()); - assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some()); - } - - #[test] - fn unfilled_interior_misses() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert( - Some(l), - 9, - Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), - ) - .unwrap(); - assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); - assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some()); - } - - /// #1103: a marquee drawn over a background picks what is on it, not the background, whose - /// outline it doesn't touch. - #[test] - fn a_marquee_inside_a_background_leaves_the_background() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let (back, small) = (d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap(); - d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); - assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]); - // Reaching the background's edge takes it too. - assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]); - } - - #[test] - fn group_top_object() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - let g = d.alloc_id(); - let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); - d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap(); - let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); - assert_eq!(h.leaf, a); - assert_eq!(h.top_object(None), g); - assert_eq!(h.top_object(Some(g)), a); - assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]); - assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]); - assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty()); - } - - /// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and - /// the art, in that order. - fn half_clipped(d: &mut Document) -> (Node, Node) { - let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); - let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art()); - (mask, art) - } - - /// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away - /// half alone it selects nothing, and around a rectangle drawn beside the group's mask it - /// takes that rectangle alone; reaching into the mask takes the group. The same holds for - /// Group Selection's leaves and in isolation mode. - #[test] - fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - let (mask, art) = half_clipped(&mut d); - let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id()); - let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); - clip.set_clips(true); - d.insert(Some(l), 0, clip).unwrap(); - d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); - // This marquee crosses only the hidden art's top and right edges. - let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); - assert!(marquee(&d, hidden, None, false).is_empty()); - assert!(marquee(&d, hidden, None, true).is_empty()); - assert!(marquee(&d, hidden, Some(g), false).is_empty()); - assert!(marquee(&d, hidden, Some(art_id), false).is_empty()); - // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. - assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); - // This marquee crosses the shown half's top edge and goes on into the hidden half. - let shown = Rect::new(50.0, -10.0, 160.0, 10.0); - assert_eq!(marquee(&d, shown, None, false), vec![g]); - assert!(marquee(&d, shown, None, true).contains(&art_id)); - assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art"); - } - - /// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the - /// clipped art's corner leaves the clip group. An open mask clips to its area closed by a - /// straight line, and a marquee across that line takes the art it reaches inside. - #[test] - fn a_marquee_must_meet_the_clipping_path_itself() { - // A clip group of a mask through `pts` (closed or open) over the art `art`. - let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let mut mask = vectorcraft_geom::BezPath::new(); - mask.move_to(pts[0]); - for &p in &pts[1..] { - mask.line_to(p); - } - if closed { - mask.close_path(); - } - let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default()); - let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art()); - let g = d.alloc_id(); - let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); - clip.set_clips(true); - d.insert(Some(l), 0, clip).unwrap(); - (d, g) - }; - let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0)); - assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty()); - assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point"); - // The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it. - let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0)); - assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]); - } - - /// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the - /// mask shows them, as in a clip group. - #[test] - fn a_marquee_leaves_art_a_layer_clips_away() { - for sublayer in [false, true] { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - let beside = d.alloc_id(); - d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); - let masked = d.alloc_id(); - let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2)); - layer.set_clips(true); - d.insert(sublayer.then_some(l), 9, layer).unwrap(); - let (mask, art) = half_clipped(&mut d); - let (mask_id, art_id) = (mask.id, art.id); - d.insert(Some(masked), 0, mask).unwrap(); - d.insert(Some(masked), 1, art).unwrap(); - assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}"); - assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}"); - assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}"); - } - } - - /// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over - /// the original shape of a face the group erased it selects nothing, with Group Selection's - /// leaves too, and over a face it shows it takes the group. With the original shapes isolated - /// (Construction mode) it takes the original shape it reaches. - #[test] - fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - // Two original squares, 0..100 and 50..150; the group shows only the first one's face. - let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); - let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); - let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); - let b_id = b.id; - let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); - sources.name = Some(crate::shaper::SOURCES.into()); - sources.opacity = 0.0; - let sources_id = sources.id; - let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); - face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); - let face_id = face.id; - let g = d.alloc_id(); - let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]); - group.extras_mut().shaper = Some(Box::default()); - d.insert(Some(l), 0, group).unwrap(); - // This marquee crosses only the right edge of the second square, whose face is erased. - let erased = Rect::new(140.0, 40.0, 160.0, 60.0); - assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses"); - assert!(marquee(&d, erased, None, false).is_empty()); - assert!(marquee(&d, erased, None, true).is_empty()); - assert!(marquee(&d, erased, Some(g), false).is_empty()); - // This marquee crosses the shown face's left edge. - let shown = Rect::new(-10.0, 40.0, 10.0, 60.0); - assert_eq!(marquee(&d, shown, None, false), vec![g]); - assert_eq!(marquee(&d, shown, None, true), vec![face_id]); - assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]); - // Construction mode: the original shapes are the objects. - assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]); - } - - /// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and - /// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing. - /// Across a member's edge it selects the group, and with the leaves that Direct and Group - /// Selection take, or in isolation mode, it selects the member. - #[test] - fn a_marquee_meets_an_intertwine_group_by_its_members() { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - // Two squares, 0..100 and 150..250, with a gap between them inside the group's box. - let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); - let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); - a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0)))); - let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art()); - let (a_id, g) = (a.id, d.alloc_id()); - d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap(); - let gap = Rect::new(110.0, 40.0, 140.0, 60.0); - assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses"); - assert!(marquee(&d, gap, None, false).is_empty()); - assert!(marquee(&d, gap, None, true).is_empty()); - assert!(marquee(&d, gap, Some(g), false).is_empty()); - assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill"); - // This marquee crosses the first square's left edge. - let edge = Rect::new(-10.0, 40.0, 10.0, 60.0); - assert_eq!(marquee(&d, edge, None, false), vec![g]); - assert_eq!(marquee(&d, edge, None, true), vec![a_id]); - assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]); - } - - /// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a - /// grid repeat it selects nothing, and across a copy's edge it takes the repeat. - #[test] - fn a_marquee_meets_a_repeat_by_its_copies() { - let mut d = Document::new(300.0, 200.0); - let l = d.layers[0].id; - let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); - // Copies at 0..20, 40..60 and 80..100. - let kind = crate::pattern::RepeatKind::Grid { - h_spacing: 20.0, - v_spacing: 20.0, - rows: 1, - cols: 3, - grid_type: crate::pattern::TileType::Grid, - flip_rows: false, - flip_cols: false, - }; - let r = d.alloc_id(); - d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap(); - let gap = Rect::new(25.0, 5.0, 35.0, 15.0); - assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses"); - assert!(marquee(&d, gap, None, false).is_empty()); - assert!(marquee(&d, gap, None, true).is_empty()); - assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge"); - assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]); - } - - /// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there - /// too: inside the box, away from every key object's and the content's outline, it takes them. - #[test] - fn a_marquee_meets_blends_and_envelopes_by_their_box() { - let mut d = Document::new(300.0, 300.0); - let l = d.layers[0].id; - let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())); - let blend = d.alloc_id(); - let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)]; - d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap(); - let envelope = d.alloc_id(); - let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))]; - let e = Node::new( - envelope, - NodeKind::Envelope { - content, - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), - fidelity: 100.0, - editing: false, - options: Default::default(), - frame: Box::new(vectorcraft_geom::Affine::IDENTITY), - }, - ); - d.insert(Some(l), 1, e).unwrap(); - for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] { - assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id)); - assert_eq!(marquee(&d, r, None, false), vec![id]); - assert_eq!(marquee(&d, r, None, true), vec![id]); - } - } - - /// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a - /// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the - /// clipping path and the art. - fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); - if let NodeKind::Path { clipping, .. } = &mut path.kind { - *clipping = true; - } - let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art()); - if layer { - d.insert(Some(l), 0, path).unwrap(); - d.insert(Some(l), 1, picture).unwrap(); - d.node_mut(l).unwrap().set_clips(true); - (d, l, clip, art) - } else { - d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap(); - (d, g, clip, art) - } - } - - const SELECTING: HitOptions = - HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None }; - - /// For the selection tools a clip group's clipping path is hit on its outline, within the - /// tolerance, before the art it clips and just outside it too; inside, the art is hit. - #[test] - fn a_clipping_path_is_hit_on_its_outline_before_its_art() { - let (d, g, clip, art) = masked_square(false); - for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] { - let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap(); - assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}"); - } - let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap(); - assert_eq!((h.leaf, h.top_object(None)), (art, g)); - // Beyond the tolerance outside it, the clipped art is not there to hit. - assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none()); - } - - /// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling - /// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints - /// nothing there. - #[test] - fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() { - let (d, _, clip, art) = masked_square(false); - let p = Point::new(138.5, 90.0); - assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); - assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); - assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art)); - assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); - // A hidden or locked clipping path is passed over. - for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] { - let mut d = d.clone(); - change(d.node_mut(clip).unwrap()); - assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); - } - } - - /// A layer's clipping mask is hit on its outline too, and Select Behind lists what the - /// repeated hit test finds: the mask on and just outside its edge, then the art inside it; - /// well inside, the art alone (the mask paints nothing there). - #[test] - fn select_behind_takes_a_layer_masks_outline_first() { - let (d, _, clip, art) = masked_square(true); - for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] { - let p = Point::new(x, 90.0); - assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}"); - assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}"); - } - // Without clip_outlines, as before. - assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); - } - - /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the - /// sublayer; hidden, locked and template sublayers block both. - #[test] - fn sublayers_are_not_objects() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let sub = d.alloc_id(); - d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap(); - let inner = d.alloc_id(); - d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap(); - let a = d.alloc_id(); - d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - let g = d.alloc_id(); - let b = d.alloc_id(); - let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art()); - d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap(); - let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); - assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3)); - let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap(); - assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b)); - let all = Rect::new(-5.0, -5.0, 200.0, 200.0); - assert_eq!(marquee(&d, all, None, false), vec![a, g]); - assert_eq!(d.layer_containing(a), Some(inner)); - assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour"); - assert_eq!(d.selectable_art(), vec![a, g]); - for f in [ - |n: &mut Node| n.visible = false, - |n: &mut Node| n.locked = true, - |n: &mut Node| { - if let NodeKind::Layer { template, .. } = &mut n.kind { - *template = true; - } - }, - ] { - let mut d2 = d.clone(); - f(d2.node_mut(inner).unwrap()); - assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none()); - assert_eq!(marquee(&d2, all, None, false), vec![g]); - assert_eq!(d2.selectable_art(), vec![g]); - } - // A sublayer whose Preview is off is clicked in outline: its fill no longer hits. - if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind { - *preview = false; - } - assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); - assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some()); - } - - /// Object Selection by Path Only: a filled path or compound path hits on its outline only. - #[test] - fn path_only_leaves_out_fills() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let (a, c) = (d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default()); - let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero }); - compound.appearance = Appearance::default_art().into(); - d.insert(Some(l), 9, compound).unwrap(); - let path_only = HitOptions { path_only: true, ..Default::default() }; - for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] { - assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits"); - assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit"); - assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does"); - } - } - - /// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so - /// a path under it can be picked through it. - #[test] - fn path_only_picks_paths_through_images() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let (path, image) = (d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); - // 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path. - let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0); - let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None }; - d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); - let path_only = HitOptions { path_only: true, ..Default::default() }; - let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf); - let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5)); - assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path"); - assert_eq!(hit(inside, HitOptions::default()), Some(image)); - assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image"); - assert_eq!(hit(inside, path_only), None, "path only: not inside the image"); - assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame"); - } - - /// Type Object Selection by Path Only: point type is picked on its baseline, area type on its - /// frame and type on a path on its path, no longer anywhere in its bounds. - #[test] - fn type_path_only_picks_type_on_its_path() { - use crate::{CharStyle, TextKind, TextObject}; - let mut d = Document::new(400.0, 400.0); - let l = d.layers[0].id; - let mut add = |t: TextObject| { - let id = d.alloc_id(); - d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); - id - }; - // Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide. - let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() }); - point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); - let point = add(point); - let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default()); - area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; - let area = add(area); - let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default()); - on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None }; - let on_path = add(on_path); - let path_only = HitOptions { type_path_only: true, ..Default::default() }; - let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind)); - // Off: anywhere in the bounds. - assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds))); - assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds))); - assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds))); - // On: the glyphs and the inside of the frame or path no longer pick the type. - assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs"); - assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame"); - assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path"); - // The baseline (within the tolerance, across the layout's width), frame and path do. - assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline"); - assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout"); - assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame"); - assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path"); - } - - /// Type Object Selection by Path Only with the layout's baselines: every line's for point type, - /// area type's besides its frame, a vertical column's centre line, and type in perspective - /// where it is drawn. - #[test] - fn type_path_only_picks_every_baseline() { - use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject}; - use vectorcraft_geom::{Affine, Homography}; - let mut d = Document::new(400.0, 400.0); - let l = d.layers[0].id; - let mut add = |t: TextObject, perspective: Option| { - let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); - n.extras_mut().perspective = perspective.map(|a| { - Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) }) - }); - let id = n.id; - d.insert(Some(l), 9, n).unwrap(); - id - }; - let style = CharStyle { size: 20.0, ..CharStyle::default() }; - // Two lines of point type at (10, 100), 24 pt apart. - let mut point = TextObject::point( - Point::new(10.0, 100.0), - "Hello -world", - style.clone(), - ); - point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0)); - point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))]; - let point = add(point, None); - // Area type: a line inside its frame. - let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone()); - area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; - area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))]; - let area = add(area, None); - // Vertical point type at (300, 200) with no baselines cached: down its first column. - let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone()); - vertical.vertical = true; - vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0)); - let vertical = add(vertical, None); - // Point type drawn 100 to the right of where it is laid out. - let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style); - moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); - let moved = add(moved, Some(Affine::translate((100.0, 0.0)))); - let path_only = HitOptions { type_path_only: true, ..Default::default() }; - let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf); - assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline"); - assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's"); - assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines"); - assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line"); - assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline"); - assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines"); - assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column"); - assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it"); - assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn"); - assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out"); - } - - /// `objects_at`: every object under the point, topmost first; a group counts once. - #[test] - fn objects_at_lists_the_stack() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let rect = - |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); - let a = rect(&mut d, 0.0); - let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0)); - let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id()); - d.insert(Some(l), 0, a).unwrap(); - d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap(); - let far = rect(&mut d, 120.0); - d.insert(Some(l), 9, far).unwrap(); - let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope); - assert_eq!(at(30.0, None), vec![g, a_id]); - assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects"); - // Isolated, the art around the group is locked: clicks pass through it (#833). - assert!(at(5.0, Some(g)).is_empty()); - assert!(at(130.0, Some(g)).is_empty()); - assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none()); - assert_eq!(at(5.0, None), vec![a_id]); - assert!(at(90.0, None).is_empty()); - } - - /// Many overlapping paths: Select Behind must list each exactly once, front to back. - /// This is quadratic with repeated full-document hit tests, but linear in tree size with - /// the per-object walk. - #[test] - fn objects_at_large_overlapping_stack() { - let mut d = Document::new(100.0, 100.0); - let layer = d.layers[0].id; - let mut ids = Vec::new(); - for _ in 0..256 { - let id = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - ids.push(id); - } - ids.reverse(); - assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids); - } - - /// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping, - /// invisibility, and path/outline-only settings. - #[test] - fn objects_at_one_pass_matches_repeated_hits() { - let mut d = Document::new(200.0, 200.0); - let layer = d.layers[0].id; - let add = |d: &mut Document, parent, bounds| { - let id = d.alloc_id(); - d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap(); - id - }; - add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0)); - let sub = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap(); - add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0)); - let group = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap(); - add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0)); - add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0)); - let clip = d.alloc_id(); - let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art()); - let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art()); - let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]); - clipping.set_clips(true); - d.insert(Some(layer), usize::MAX, clipping).unwrap(); - for (point, opt) in [ - (Point::new(25.0, 25.0), HitOptions::default()), - (Point::new(3.0, 25.0), HitOptions::default()), - (Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }), - (Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }), - (Point::new(100.0, 100.0), HitOptions::default()), - ] { - assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}"); - } - if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind { - *preview = false; - } - let p = Point::new(25.0, 25.0); - let opt = HitOptions::default(); - assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); - d.node_mut(group).unwrap().locked = true; - assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); - } - - /// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends - /// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects - /// in the same order as the hit test repeated over what is left. - #[test] - fn objects_at_matches_repeated_hits_on_random_documents() { - let mut seed = 0x9e37_79b9_u32; - let mut next = move |n: u32| { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - seed % n - }; - fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) { - for _ in 0..2 + next(4) { - let id = d.alloc_id(); - let (x, y) = (f64::from(next(60)), f64::from(next(60))); - let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50))); - let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() }; - let mut n = match next(if depth < 3 { 8 } else { 4 }) { - 4 | 5 => Node::group(id, Vec::new()), - 6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)), - 7 => { - let mut g = Node::group(id, Vec::new()); - g.set_clips(true); - g - } - _ => Node::path(id, shapes::rectangle(rect), appearance), - }; - n.visible = next(8) != 0; - n.locked = next(8) == 0; - if let NodeKind::Layer { preview, .. } = &mut n.kind { - *preview = next(3) != 0; - } - let container = n.is_container(); - // A sublayer belongs in a layer; in a group it is a group like another to the hit test. - d.insert(Some(parent), usize::MAX, n).unwrap(); - if container { - fill(d, id, depth + 1, next); - } - } - } - for round in 0..200 { - let mut d = Document::new(120.0, 120.0); - let layer = d.layers[0].id; - fill(&mut d, layer, 0, &mut next); - // A clipping mask on the layer, or on a sublayer, now and then. - let sublayers: Vec = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect(); - for id in std::iter::once(layer).chain(sublayers) { - if next(3) == 0 { - d.node_mut(id).unwrap().set_clips(true); - } - } - for _ in 0..12 { - let p = Point::new(f64::from(next(120)), f64::from(next(120))); - let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() }; - assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}"); - } - } - } - - /// Select Behind finds a symbol instance by its symbol's art, as a click does: art from - /// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by - /// one instance and 100 pt lower by an instance in a group. - #[test] - fn objects_at_finds_symbol_instances_by_their_art() { - let mut d = Document::new(200.0, 200.0); - let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art()); - d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) }); - let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id()); - let instance = - |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) }); - d.insert(Some(layer), 0, instance(top, 0.0)).unwrap(); - d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap(); - let opt = HitOptions::default(); - for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] { - let p = Point::new(x, y); - assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}"); - assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}"); - } - } - - /// Edit Contents can expose descendants of a top-level group: preserve the original - /// selection order by falling back to the general hit traversal. - #[test] - fn objects_at_edit_contents_uses_general_order() { - let mut d = Document::new(100.0, 100.0); - let layer = d.layers[0].id; - let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())); - let (a, b) = (d.alloc_id(), d.alloc_id()); - let e = Node::new( - d.alloc_id(), - NodeKind::Envelope { - content: vec![inner(a), inner(b)], - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), - fidelity: 100.0, - editing: true, - options: Default::default(), - frame: Box::new(vectorcraft_geom::Affine::IDENTITY), - }, - ); - d.insert(Some(layer), usize::MAX, e).unwrap(); - let p = Point::new(25.0, 25.0); - let opt = HitOptions::default(); - assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); - assert_eq!(objects_at(&d, p, opt, None), vec![b, a]); - } - - #[test] - fn locked_and_hidden_skip() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - d.node_mut(a).unwrap().locked = true; - assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); - d.node_mut(a).unwrap().locked = false; - d.node_mut(l).unwrap().visible = false; - assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); - } - - /// Insert `n` locked at the top of `parent`. - fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId { - let id = n.id; - n.locked = true; - d.insert(Some(parent), usize::MAX, n).unwrap(); - id - } - - /// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is - /// listed rather than its contents, and a locked object in an unlocked group is listed itself. - /// Hidden objects, locked layers and the art outside an isolated group are left out. - #[test] - fn locked_objects_at_lists_the_locked_stack() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let rect = - |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); - let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0)); - let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b)); - let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id()); - inner.locked = true; - let inner_id = inner.id; - let mut g = Node::group(locked_group, vec![Arc::new(inner)]); - g.locked = true; - d.insert(Some(l), usize::MAX, g).unwrap(); - let open_group = d.alloc_id(); - let mut child = rect(&mut d, 25.0); - child.locked = true; - let child_id = child.id; - let free = rect(&mut d, 25.0); - d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap(); - let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() }); - assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]); - assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds"); - assert_eq!(at(&d, 5.0, None), [a]); - assert!(at(&d, 150.0, None).is_empty()); - // Isolated, only the group's own objects are looked at. - assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]); - // A hidden object is left out, and nothing on a locked or hidden layer is listed. - d.node_mut(b).unwrap().visible = false; - assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]); - d.node_mut(l).unwrap().locked = true; - assert!(at(&d, 30.0, None).is_empty()); - d.node_mut(l).unwrap().locked = false; - d.node_mut(l).unwrap().visible = false; - assert!(at(&d, 30.0, None).is_empty()); - } - - /// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones. - #[test] - fn locked_objects_at_stops_at_its_limit() { - let mut d = Document::new(100.0, 100.0); - let l = d.layers[0].id; - let ids: Vec = (0..MAX_LOCKED_AT + 5) - .map(|_| { - let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); - insert_locked(&mut d, l, n) - }) - .collect(); - let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default()); - assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::>()); - } - - #[test] - fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() { - let mut d = Document::new(200.0, 200.0); - let l = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); - let p = Point::new(10.0, 10.0); - for lock in [a, l] { - d.node_mut(lock).unwrap().locked = true; - assert!(hit_test(&d, p, HitOptions::default()).is_none()); - assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a); - assert!(d.node(lock).unwrap().locked); - d.node_mut(lock).unwrap().locked = false; - } - d.node_mut(a).unwrap().visible = false; - assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); - d.node_mut(a).unwrap().visible = true; - d.node_mut(l).unwrap().visible = false; - assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); - } -} +mod tests; diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs new file mode 100644 index 000000000..743eeed7e --- /dev/null +++ b/crates/doc/src/hit/tests.rs @@ -0,0 +1,838 @@ +use super::*; +use crate::{Appearance, Node}; +use std::sync::Arc; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_geom::shapes; + +#[test] +fn hits_topmost() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let b = d.alloc_id(); + d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap(); + let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, b); + assert_eq!(h.kind, HitKind::Fill); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, a); + assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none()); + // Outline mode: interior misses. + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none()); + assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some()); +} + +#[test] +fn unfilled_interior_misses() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert( + Some(l), + 9, + Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), + ) + .unwrap(); + assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); + assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some()); +} + +/// #1103: a marquee drawn over a background picks what is on it, not the background, whose +/// outline it doesn't touch. +#[test] +fn a_marquee_inside_a_background_leaves_the_background() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (back, small) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap(); + d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); + assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]); + // Reaching the background's edge takes it too. + assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]); +} + +#[test] +fn group_top_object() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + let g = d.alloc_id(); + let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap(); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!(h.leaf, a); + assert_eq!(h.top_object(None), g); + assert_eq!(h.top_object(Some(g)), a); + assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]); + assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]); + assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty()); +} + +/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and +/// the art, in that order. +fn half_clipped(d: &mut Document) -> (Node, Node) { + let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art()); + (mask, art) +} + +/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away +/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it +/// takes that rectangle alone; reaching into the mask takes the group. The same holds for +/// Group Selection's leaves and in isolation mode. +#[test] +fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id()); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); + // This marquee crosses only the hidden art's top and right edges. + let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); + assert!(marquee(&d, hidden, None, false).is_empty()); + assert!(marquee(&d, hidden, None, true).is_empty()); + assert!(marquee(&d, hidden, Some(g), false).is_empty()); + assert!(marquee(&d, hidden, Some(art_id), false).is_empty()); + // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. + assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); + // This marquee crosses the shown half's top edge and goes on into the hidden half. + let shown = Rect::new(50.0, -10.0, 160.0, 10.0); + assert_eq!(marquee(&d, shown, None, false), vec![g]); + assert!(marquee(&d, shown, None, true).contains(&art_id)); + assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art"); +} + +/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the +/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a +/// straight line, and a marquee across that line takes the art it reaches inside. +#[test] +fn a_marquee_must_meet_the_clipping_path_itself() { + // A clip group of a mask through `pts` (closed or open) over the art `art`. + let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let mut mask = vectorcraft_geom::BezPath::new(); + mask.move_to(pts[0]); + for &p in &pts[1..] { + mask.line_to(p); + } + if closed { + mask.close_path(); + } + let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default()); + let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art()); + let g = d.alloc_id(); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + (d, g) + }; + let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty()); + assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point"); + // The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it. + let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0)); + assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]); +} + +/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the +/// mask shows them, as in a clip group. +#[test] +fn a_marquee_leaves_art_a_layer_clips_away() { + for sublayer in [false, true] { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let beside = d.alloc_id(); + d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); + let masked = d.alloc_id(); + let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2)); + layer.set_clips(true); + d.insert(sublayer.then_some(l), 9, layer).unwrap(); + let (mask, art) = half_clipped(&mut d); + let (mask_id, art_id) = (mask.id, art.id); + d.insert(Some(masked), 0, mask).unwrap(); + d.insert(Some(masked), 1, art).unwrap(); + assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}"); + assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}"); + assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}"); + } +} + +/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over +/// the original shape of a face the group erased it selects nothing, with Group Selection's +/// leaves too, and over a face it shows it takes the group. With the original shapes isolated +/// (Construction mode) it takes the original shape it reaches. +#[test] +fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // Two original squares, 0..100 and 50..150; the group shows only the first one's face. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); + let b_id = b.id; + let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + sources.name = Some(crate::shaper::SOURCES.into()); + sources.opacity = 0.0; + let sources_id = sources.id; + let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); + let face_id = face.id; + let g = d.alloc_id(); + let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]); + group.extras_mut().shaper = Some(Box::default()); + d.insert(Some(l), 0, group).unwrap(); + // This marquee crosses only the right edge of the second square, whose face is erased. + let erased = Rect::new(140.0, 40.0, 160.0, 60.0); + assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses"); + assert!(marquee(&d, erased, None, false).is_empty()); + assert!(marquee(&d, erased, None, true).is_empty()); + assert!(marquee(&d, erased, Some(g), false).is_empty()); + // This marquee crosses the shown face's left edge. + let shown = Rect::new(-10.0, 40.0, 10.0, 60.0); + assert_eq!(marquee(&d, shown, None, false), vec![g]); + assert_eq!(marquee(&d, shown, None, true), vec![face_id]); + assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]); + // Construction mode: the original shapes are the objects. + assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]); +} + +/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and +/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing. +/// Across a member's edge it selects the group, and with the leaves that Direct and Group +/// Selection take, or in isolation mode, it selects the member. +#[test] +fn a_marquee_meets_an_intertwine_group_by_its_members() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // Two squares, 0..100 and 150..250, with a gap between them inside the group's box. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0)))); + let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art()); + let (a_id, g) = (a.id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap(); + let gap = Rect::new(110.0, 40.0, 140.0, 60.0); + assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses"); + assert!(marquee(&d, gap, None, false).is_empty()); + assert!(marquee(&d, gap, None, true).is_empty()); + assert!(marquee(&d, gap, Some(g), false).is_empty()); + assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill"); + // This marquee crosses the first square's left edge. + let edge = Rect::new(-10.0, 40.0, 10.0, 60.0); + assert_eq!(marquee(&d, edge, None, false), vec![g]); + assert_eq!(marquee(&d, edge, None, true), vec![a_id]); + assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]); +} + +/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a +/// grid repeat it selects nothing, and across a copy's edge it takes the repeat. +#[test] +fn a_marquee_meets_a_repeat_by_its_copies() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + // Copies at 0..20, 40..60 and 80..100. + let kind = crate::pattern::RepeatKind::Grid { + h_spacing: 20.0, + v_spacing: 20.0, + rows: 1, + cols: 3, + grid_type: crate::pattern::TileType::Grid, + flip_rows: false, + flip_cols: false, + }; + let r = d.alloc_id(); + d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap(); + let gap = Rect::new(25.0, 5.0, 35.0, 15.0); + assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses"); + assert!(marquee(&d, gap, None, false).is_empty()); + assert!(marquee(&d, gap, None, true).is_empty()); + assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge"); + assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]); +} + +/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there +/// too: inside the box, away from every key object's and the content's outline, it takes them. +#[test] +fn a_marquee_meets_blends_and_envelopes_by_their_box() { + let mut d = Document::new(300.0, 300.0); + let l = d.layers[0].id; + let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())); + let blend = d.alloc_id(); + let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)]; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap(); + let envelope = d.alloc_id(); + let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))]; + let e = Node::new( + envelope, + NodeKind::Envelope { + content, + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + fidelity: 100.0, + editing: false, + options: Default::default(), + frame: Box::new(vectorcraft_geom::Affine::IDENTITY), + }, + ); + d.insert(Some(l), 1, e).unwrap(); + for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] { + assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id)); + assert_eq!(marquee(&d, r, None, false), vec![id]); + assert_eq!(marquee(&d, r, None, true), vec![id]); + } +} + +/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a +/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the +/// clipping path and the art. +fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art()); + if layer { + d.insert(Some(l), 0, path).unwrap(); + d.insert(Some(l), 1, picture).unwrap(); + d.node_mut(l).unwrap().set_clips(true); + (d, l, clip, art) + } else { + d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap(); + (d, g, clip, art) + } +} + +const SELECTING: HitOptions = + HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None }; + +/// For the selection tools a clip group's clipping path is hit on its outline, within the +/// tolerance, before the art it clips and just outside it too; inside, the art is hit. +#[test] +fn a_clipping_path_is_hit_on_its_outline_before_its_art() { + let (d, g, clip, art) = masked_square(false); + for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] { + let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap(); + assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}"); + } + let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap(); + assert_eq!((h.leaf, h.top_object(None)), (art, g)); + // Beyond the tolerance outside it, the clipped art is not there to hit. + assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none()); +} + +/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling +/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints +/// nothing there. +#[test] +fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() { + let (d, _, clip, art) = masked_square(false); + let p = Point::new(138.5, 90.0); + assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); + // A hidden or locked clipping path is passed over. + for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] { + let mut d = d.clone(); + change(d.node_mut(clip).unwrap()); + assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); + } +} + +/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the +/// repeated hit test finds: the mask on and just outside its edge, then the art inside it; +/// well inside, the art alone (the mask paints nothing there). +#[test] +fn select_behind_takes_a_layer_masks_outline_first() { + let (d, _, clip, art) = masked_square(true); + for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] { + let p = Point::new(x, 90.0); + assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}"); + assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}"); + } + // Without clip_outlines, as before. + assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); +} + +/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the +/// sublayer; hidden, locked and template sublayers block both. +#[test] +fn sublayers_are_not_objects() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap(); + let inner = d.alloc_id(); + d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap(); + let a = d.alloc_id(); + d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let g = d.alloc_id(); + let b = d.alloc_id(); + let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art()); + d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap(); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3)); + let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b)); + let all = Rect::new(-5.0, -5.0, 200.0, 200.0); + assert_eq!(marquee(&d, all, None, false), vec![a, g]); + assert_eq!(d.layer_containing(a), Some(inner)); + assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour"); + assert_eq!(d.selectable_art(), vec![a, g]); + for f in [ + |n: &mut Node| n.visible = false, + |n: &mut Node| n.locked = true, + |n: &mut Node| { + if let NodeKind::Layer { template, .. } = &mut n.kind { + *template = true; + } + }, + ] { + let mut d2 = d.clone(); + f(d2.node_mut(inner).unwrap()); + assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none()); + assert_eq!(marquee(&d2, all, None, false), vec![g]); + assert_eq!(d2.selectable_art(), vec![g]); + } + // A sublayer whose Preview is off is clicked in outline: its fill no longer hits. + if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind { + *preview = false; + } + assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); + assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some()); +} + +/// Object Selection by Path Only: a filled path or compound path hits on its outline only. +#[test] +fn path_only_leaves_out_fills() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (a, c) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default()); + let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance = Appearance::default_art().into(); + d.insert(Some(l), 9, compound).unwrap(); + let path_only = HitOptions { path_only: true, ..Default::default() }; + for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] { + assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits"); + assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit"); + assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does"); + } +} + +/// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so +/// a path under it can be picked through it. +#[test] +fn path_only_picks_paths_through_images() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (path, image) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); + // 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path. + let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0); + let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); + let path_only = HitOptions { path_only: true, ..Default::default() }; + let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf); + let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5)); + assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path"); + assert_eq!(hit(inside, HitOptions::default()), Some(image)); + assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image"); + assert_eq!(hit(inside, path_only), None, "path only: not inside the image"); + assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame"); +} + +/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its +/// frame and type on a path on its path, no longer anywhere in its bounds. +#[test] +fn type_path_only_picks_type_on_its_path() { + use crate::{CharStyle, TextKind, TextObject}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let mut add = |t: TextObject| { + let id = d.alloc_id(); + d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + id + }; + // Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide. + let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() }); + point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); + let point = add(point); + let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default()); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; + let area = add(area); + let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default()); + on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None }; + let on_path = add(on_path); + let path_only = HitOptions { type_path_only: true, ..Default::default() }; + let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind)); + // Off: anywhere in the bounds. + assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds))); + assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds))); + assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds))); + // On: the glyphs and the inside of the frame or path no longer pick the type. + assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs"); + assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame"); + assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path"); + // The baseline (within the tolerance, across the layout's width), frame and path do. + assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline"); + assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout"); + assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame"); + assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path"); +} + +/// Type Object Selection by Path Only with the layout's baselines: every line's for point type, +/// area type's besides its frame, a vertical column's centre line, and type in perspective +/// where it is drawn. +#[test] +fn type_path_only_picks_every_baseline() { + use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject}; + use vectorcraft_geom::{Affine, Homography}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let mut add = |t: TextObject, perspective: Option| { + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + n.extras_mut().perspective = perspective.map(|a| { + Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) }) + }); + let id = n.id; + d.insert(Some(l), 9, n).unwrap(); + id + }; + let style = CharStyle { size: 20.0, ..CharStyle::default() }; + // Two lines of point type at (10, 100), 24 pt apart. + let mut point = TextObject::point( + Point::new(10.0, 100.0), + "Hello +world", + style.clone(), + ); + point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0)); + point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))]; + let point = add(point, None); + // Area type: a line inside its frame. + let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone()); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; + area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))]; + let area = add(area, None); + // Vertical point type at (300, 200) with no baselines cached: down its first column. + let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone()); + vertical.vertical = true; + vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0)); + let vertical = add(vertical, None); + // Point type drawn 100 to the right of where it is laid out. + let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style); + moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); + let moved = add(moved, Some(Affine::translate((100.0, 0.0)))); + let path_only = HitOptions { type_path_only: true, ..Default::default() }; + let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf); + assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline"); + assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's"); + assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines"); + assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line"); + assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline"); + assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines"); + assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column"); + assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it"); + assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn"); + assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out"); +} + +/// `objects_at`: every object under the point, topmost first; a group counts once. +#[test] +fn objects_at_lists_the_stack() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let rect = + |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let a = rect(&mut d, 0.0); + let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0)); + let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id()); + d.insert(Some(l), 0, a).unwrap(); + d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap(); + let far = rect(&mut d, 120.0); + d.insert(Some(l), 9, far).unwrap(); + let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope); + assert_eq!(at(30.0, None), vec![g, a_id]); + assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects"); + // Isolated, the art around the group is locked: clicks pass through it (#833). + assert!(at(5.0, Some(g)).is_empty()); + assert!(at(130.0, Some(g)).is_empty()); + assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none()); + assert_eq!(at(5.0, None), vec![a_id]); + assert!(at(90.0, None).is_empty()); +} + +/// Many overlapping paths: Select Behind must list each exactly once, front to back. +/// This is quadratic with repeated full-document hit tests, but linear in tree size with +/// the per-object walk. +#[test] +fn objects_at_large_overlapping_stack() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let mut ids = Vec::new(); + for _ in 0..256 { + let id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + ids.push(id); + } + ids.reverse(); + assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids); +} + +/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping, +/// invisibility, and path/outline-only settings. +#[test] +fn objects_at_one_pass_matches_repeated_hits() { + let mut d = Document::new(200.0, 200.0); + let layer = d.layers[0].id; + let add = |d: &mut Document, parent, bounds| { + let id = d.alloc_id(); + d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap(); + id + }; + add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0)); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap(); + add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0)); + let group = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap(); + add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0)); + add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0)); + let clip = d.alloc_id(); + let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art()); + let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art()); + let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]); + clipping.set_clips(true); + d.insert(Some(layer), usize::MAX, clipping).unwrap(); + for (point, opt) in [ + (Point::new(25.0, 25.0), HitOptions::default()), + (Point::new(3.0, 25.0), HitOptions::default()), + (Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }), + (Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }), + (Point::new(100.0, 100.0), HitOptions::default()), + ] { + assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}"); + } + if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind { + *preview = false; + } + let p = Point::new(25.0, 25.0); + let opt = HitOptions::default(); + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); + d.node_mut(group).unwrap().locked = true; + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); +} + +/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends +/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects +/// in the same order as the hit test repeated over what is left. +#[test] +fn objects_at_matches_repeated_hits_on_random_documents() { + let mut seed = 0x9e37_79b9_u32; + let mut next = move |n: u32| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed % n + }; + fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) { + for _ in 0..2 + next(4) { + let id = d.alloc_id(); + let (x, y) = (f64::from(next(60)), f64::from(next(60))); + let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50))); + let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() }; + let mut n = match next(if depth < 3 { 8 } else { 4 }) { + 4 | 5 => Node::group(id, Vec::new()), + 6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)), + 7 => { + let mut g = Node::group(id, Vec::new()); + g.set_clips(true); + g + } + _ => Node::path(id, shapes::rectangle(rect), appearance), + }; + n.visible = next(8) != 0; + n.locked = next(8) == 0; + if let NodeKind::Layer { preview, .. } = &mut n.kind { + *preview = next(3) != 0; + } + let container = n.is_container(); + // A sublayer belongs in a layer; in a group it is a group like another to the hit test. + d.insert(Some(parent), usize::MAX, n).unwrap(); + if container { + fill(d, id, depth + 1, next); + } + } + } + for round in 0..200 { + let mut d = Document::new(120.0, 120.0); + let layer = d.layers[0].id; + fill(&mut d, layer, 0, &mut next); + // A clipping mask on the layer, or on a sublayer, now and then. + let sublayers: Vec = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect(); + for id in std::iter::once(layer).chain(sublayers) { + if next(3) == 0 { + d.node_mut(id).unwrap().set_clips(true); + } + } + for _ in 0..12 { + let p = Point::new(f64::from(next(120)), f64::from(next(120))); + let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() }; + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}"); + } + } +} + +/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from +/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by +/// one instance and 100 pt lower by an instance in a group. +#[test] +fn objects_at_finds_symbol_instances_by_their_art() { + let mut d = Document::new(200.0, 200.0); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art()); + d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) }); + let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id()); + let instance = + |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) }); + d.insert(Some(layer), 0, instance(top, 0.0)).unwrap(); + d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap(); + let opt = HitOptions::default(); + for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] { + let p = Point::new(x, y); + assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}"); + assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}"); + } +} + +/// Edit Contents can expose descendants of a top-level group: preserve the original +/// selection order by falling back to the general hit traversal. +#[test] +fn objects_at_edit_contents_uses_general_order() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())); + let (a, b) = (d.alloc_id(), d.alloc_id()); + let e = Node::new( + d.alloc_id(), + NodeKind::Envelope { + content: vec![inner(a), inner(b)], + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + fidelity: 100.0, + editing: true, + options: Default::default(), + frame: Box::new(vectorcraft_geom::Affine::IDENTITY), + }, + ); + d.insert(Some(layer), usize::MAX, e).unwrap(); + let p = Point::new(25.0, 25.0); + let opt = HitOptions::default(); + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); + assert_eq!(objects_at(&d, p, opt, None), vec![b, a]); +} + +#[test] +fn locked_and_hidden_skip() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + d.node_mut(a).unwrap().locked = true; + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); + d.node_mut(a).unwrap().locked = false; + d.node_mut(l).unwrap().visible = false; + assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); +} + +/// Insert `n` locked at the top of `parent`. +fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId { + let id = n.id; + n.locked = true; + d.insert(Some(parent), usize::MAX, n).unwrap(); + id +} + +/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is +/// listed rather than its contents, and a locked object in an unlocked group is listed itself. +/// Hidden objects, locked layers and the art outside an isolated group are left out. +#[test] +fn locked_objects_at_lists_the_locked_stack() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let rect = + |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0)); + let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b)); + let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id()); + inner.locked = true; + let inner_id = inner.id; + let mut g = Node::group(locked_group, vec![Arc::new(inner)]); + g.locked = true; + d.insert(Some(l), usize::MAX, g).unwrap(); + let open_group = d.alloc_id(); + let mut child = rect(&mut d, 25.0); + child.locked = true; + let child_id = child.id; + let free = rect(&mut d, 25.0); + d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap(); + let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() }); + assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]); + assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds"); + assert_eq!(at(&d, 5.0, None), [a]); + assert!(at(&d, 150.0, None).is_empty()); + // Isolated, only the group's own objects are looked at. + assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]); + // A hidden object is left out, and nothing on a locked or hidden layer is listed. + d.node_mut(b).unwrap().visible = false; + assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]); + d.node_mut(l).unwrap().locked = true; + assert!(at(&d, 30.0, None).is_empty()); + d.node_mut(l).unwrap().locked = false; + d.node_mut(l).unwrap().visible = false; + assert!(at(&d, 30.0, None).is_empty()); +} + +/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones. +#[test] +fn locked_objects_at_stops_at_its_limit() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let ids: Vec = (0..MAX_LOCKED_AT + 5) + .map(|_| { + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); + insert_locked(&mut d, l, n) + }) + .collect(); + let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default()); + assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::>()); +} + +#[test] +fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let p = Point::new(10.0, 10.0); + for lock in [a, l] { + d.node_mut(lock).unwrap().locked = true; + assert!(hit_test(&d, p, HitOptions::default()).is_none()); + assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a); + assert!(d.node(lock).unwrap().locked); + d.node_mut(lock).unwrap().locked = false; + } + d.node_mut(a).unwrap().visible = false; + assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); + d.node_mut(a).unwrap().visible = true; + d.node_mut(l).unwrap().visible = false; + assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); +} From 39ad1c0c6ee435bcde628ba0d42e4debc53e9ca5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:41 +0000 Subject: [PATCH 030/199] refactor(tools): move pen.rs tests to pen/tests.rs --- crates/tools/src/pen.rs | 836 +--------------------------------- crates/tools/src/pen/tests.rs | 833 +++++++++++++++++++++++++++++++++ 2 files changed, 834 insertions(+), 835 deletions(-) create mode 100644 crates/tools/src/pen/tests.rs diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 85168a4b2..b194db0b3 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -509,838 +509,4 @@ fn spine_click(cx: &ToolContext, p: Point, tol: f64) -> Option> { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - #[test] - fn press_inside_drag_threshold_places_a_corner() { - let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default()); - let paint = paint(); - let cx = - ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) }; - let mut pen = PenTool::default(); - pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.0, 101.0)).is_empty()); - assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.0, 101.0)), vec![Action::Commit]); - pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 200.0)); - assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 204.0, 200.0)).len(), 1); - } - - #[test] - fn snapped_last_endpoint_converts_active_path_after_raw_miss() { - let first = Point::new(100.0, 300.0); - let last = Point::new(200.0, 400.0); - let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); - d.grid.spacing = 100.0; - d.grid.subdivisions = 1; - let paint = paint(); - let cx = ToolContext { - zoom: 1.0, - selection_tolerance: 8.0, - smart_guides: false, - snap_to_point: false, - snap_to_pixel: false, - snap_to_grid: true, - ..cx(&d, &s, &paint) - }; - let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0]; - assert!(!sp.closed); - assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); - let mut pen = PenTool::default(); - assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); - assert!(!pen.busy()); - - let raw = Point::new(237.0, 403.0); - let tol = cx.tol(5.0).max(cx.point_tol()); - assert!(raw.distance(first) > tol && raw.distance(last) > tol); - let from = Leave::segment(&cx, last, false); - assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); - assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); - assert!(open_end_at(&cx, last, tol, Some(id)).is_none()); - - assert_eq!( - pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 2, last.x, last.y)] - ); - assert_eq!(pen.handle, Some((id, 0, 2, last))); - assert!(pen.drag.is_none() && pen.busy()); - assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); - assert!(pen.handle.is_none() && !pen.busy()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); - } - - #[test] - fn raw_other_endpoint_wins_before_snapped_active_last_endpoint() { - let first = Point::new(100.0, 300.0); - let last = Point::new(200.0, 400.0); - let raw = Point::new(237.0, 403.0); - let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); - let other = d.alloc_id(); - let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(337.0, 403.0)], false)); - d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - d.grid.spacing = 100.0; - d.grid.subdivisions = 1; - let paint = paint(); - let cx = ToolContext { - zoom: 1.0, - selection_tolerance: 8.0, - smart_guides: false, - snap_to_point: false, - snap_to_pixel: false, - snap_to_grid: true, - ..cx(&d, &s, &paint) - }; - let mut pen = PenTool::default(); - assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); - assert!(!pen.busy()); - let tol = cx.tol(5.0).max(cx.point_tol()); - assert!(raw.distance(first) > tol && raw.distance(last) > tol); - assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); - let from = Leave::segment(&cx, last, false); - assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); - - assert_eq!( - pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] - ); - assert!(!pen.busy() && pen.active(&cx).is_none()); - assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); - } - - #[test] - fn snapped_first_endpoint_closes_active_path_after_raw_miss() { - let first = Point::new(100.0, 300.0); - let last = Point::new(200.0, 400.0); - let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); - d.grid.spacing = 100.0; - d.grid.subdivisions = 1; - let paint = paint(); - let cx = ToolContext { - zoom: 1.0, - selection_tolerance: 8.0, - smart_guides: false, - snap_to_point: false, - snap_to_pixel: false, - snap_to_grid: true, - ..cx(&d, &s, &paint) - }; - let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0]; - assert!(!sp.closed); - assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); - let mut pen = PenTool::default(); - assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); - assert!(!pen.busy()); - - let raw = Point::new(63.0, 303.0); - let tol = cx.tol(5.0).max(cx.point_tol()); - assert!(raw.distance(first) > tol && raw.distance(last) > tol); - let from = Leave::segment(&cx, last, false); - assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); - assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); - assert!(open_end_at(&cx, first, tol, Some(id)).is_none()); - - assert_eq!( - pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] - ); - let place = pen.drag.unwrap(); - assert!(place.closing); - assert_eq!((place.path, place.at), (Some(id), first)); - assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); - assert!(!pen.busy() && pen.active(&cx).is_none()); - } - - #[test] - fn raw_other_endpoint_wins_before_snapped_active_first_endpoint() { - let first = Point::new(100.0, 300.0); - let last = Point::new(200.0, 400.0); - let raw = Point::new(63.0, 303.0); - let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); - let other = d.alloc_id(); - let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(63.0, 403.0)], false)); - d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - d.grid.spacing = 100.0; - d.grid.subdivisions = 1; - let paint = paint(); - let cx = ToolContext { - zoom: 1.0, - selection_tolerance: 8.0, - smart_guides: false, - snap_to_point: false, - snap_to_pixel: false, - snap_to_grid: true, - ..cx(&d, &s, &paint) - }; - let mut pen = PenTool::default(); - assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); - assert!(!pen.busy()); - let tol = cx.tol(5.0).max(cx.point_tol()); - assert!(raw.distance(first) > tol && raw.distance(last) > tol); - assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); - let from = Leave::segment(&cx, last, false); - assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); - - assert_eq!( - pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] - ); - assert!(!pen.busy() && pen.active(&cx).is_none()); - assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); - } - - #[test] - fn first_click_creates_path() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = PenTool::default(); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - assert_eq!(a[0], Action::Begin("Pen".into())); - assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create")); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]); - } - - #[test] - fn alt_pressed_mid_drag_keeps_the_incoming_handle() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - let alt = Mods { alt: true, ..Mods::default() }; - let handles = |acts: Vec| match acts.as_slice() { - [Action::Preview(c, v)] if c == "path.appendAnchor" => (v["in"].clone(), v["out"].clone()), - other => panic!("not an anchor preview: {other:?}"), - }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); - let drag = PointerEvent::new(PointerKind::Drag, 450.0, 420.0); - assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 420.0]))); - // Alt goes down: the curve shaped so far stays, and only the outgoing handle moves on. - let drag = PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt); - assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 470.0]))); - let drag = PointerEvent::new(PointerKind::Drag, 400.0, 480.0).with_mods(alt); - assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([400.0, 480.0]))); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 400.0, 480.0).with_mods(alt)), vec![Action::Commit]); - // Alt held from the start: the new anchor gets an outgoing handle only. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 440.0).with_mods(alt)); - let drag = PointerEvent::new(PointerKind::Drag, 340.0, 440.0).with_mods(alt); - assert_eq!(handles(t.pointer(&cx, &drag)), (json!([300.0, 440.0]), json!([340.0, 440.0]))); - } - - #[test] - fn space_pressed_inside_the_drag_threshold_moves_the_anchor_by_the_next_move_only() { - let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default()); - let paint = paint(); - let cx = - ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) }; - let space = Mods { space: true, ..Mods::default() }; - let mut pen = PenTool::default(); - pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - // Still inside the threshold, Space held: nothing happens yet. - assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 102.0, 100.0).with_mods(space)).is_empty()); - // The move that crosses it moves the anchor by that move (2 px to 4 px), not by the 4 px since the press. - let acts = pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 104.0, 100.0).with_mods(space)); - let Some(Action::Preview(c, v)) = acts.first() else { panic!("not a preview: {acts:?}") }; - assert_eq!(c, "path.create"); - assert_eq!(v["anchors"][0]["x"], json!(102.0), "moved by the delta"); - assert_eq!(v["anchors"][0]["y"], json!(100.0)); - } - - #[test] - fn space_moves_the_anchor_being_placed() { - let (mut d, _) = doc_with_rect(); - let space = Mods { space: true, ..Mods::default() }; - // The first anchor of a new path. - let (s, p) = (Selection::default(), paint()); - let cx0 = cx(&d, &s, &p); - let mut t = PenTool::default(); - t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); - t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0)); - let created = |acts: Vec| match acts.as_slice() { - [Action::Preview(c, v)] if c == "path.create" => v["anchors"][0].clone(), - other => panic!("not a create preview: {other:?}"), - }; - let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 380.0).with_mods(space))); - assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [450.0, 380.0], "in": [350.0, 380.0]}), "moved, handles and all"); - // Space released: the drag shapes the handles round the anchor's new place. - let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 460.0, 380.0))); - assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [460.0, 380.0], "in": [340.0, 380.0]})); - // An anchor added to an open path, its handles broken by Alt: the kept incoming handle - // moves with it. - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let cx = cx(&d, &s, &p); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - let alt = Mods { alt: true, ..Mods::default() }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt)); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 450.0).with_mods(Mods { space: true, ..alt })); - assert_eq!( - acts, - vec![Action::Preview( - "path.appendAnchor".into(), - json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]}) - )] - ); - } - - /// A document with an open path of `anchors`, selected, being drawn by a Pen. - fn drawing(anchors: vectorcraft_geom::SubPath) -> (vectorcraft_doc::Document, NodeId, Selection) { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let path = vectorcraft_geom::PathData::single(anchors); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - (d, id, s) - } - - fn set_out(id: NodeId, ai: usize, x: f64, y: f64) -> Action { - Action::Preview("path.setHandle".into(), json!({"id": id.0, "subpath": 0, "anchor": ai, "which": "out", "x": x, "y": y, "independent": true})) - } - - #[test] - fn clicking_the_last_anchor_retracts_its_handle_and_dragging_pulls_a_new_one() { - // The last anchor is smooth: handles at 40 and 80 either side of (60, 300). - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false); - sp.anchors[1].h_in = Point::new(40.0, 300.0); - sp.anchors[1].h_out = Point::new(80.0, 300.0); - let (d, id, s) = drawing(sp); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(t.cursor(&cx, Point::new(61.0, 301.0), Mods::default()), Cursor::PenConvert); - // A click retracts the outgoing handle and adds no anchor. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 61.0, 301.0)); - assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 61.0, 301.0)), vec![Action::Commit]); - // A drag pulls a new outgoing handle out. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 60.0, 300.0)); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 280.0)), vec![set_out(id, 1, 70.0, 280.0)]); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 70.0, 280.0)), vec![Action::Commit]); - // Elsewhere a click still adds an anchor, and the first anchor still closes the path. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 400.0)); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 400.0)); - assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose); - } - - /// #544: picking the terminal anchor uses the pointer, even when new points snap elsewhere. - #[test] - fn terminal_anchor_conversion_precedes_grid_snapping() { - let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(13.0, 303.0), Point::new(63.0, 303.0)], false)); - d.grid.spacing = 100.0; - d.grid.subdivisions = 1; - let p = paint(); - let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - let mut t = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenConvert); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 63.0, 303.0)), - vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 63.0, 303.0)] - ); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 63.0, 303.0)), vec![Action::Commit]); - // The same raw picking applies when resuming the path and when closing it. - let mut t = PenTool::default(); - assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenContinue); - assert!(click(&mut t, &cx, 63.0, 303.0, Mods::default()).is_empty()); - assert_eq!(t.active(&cx).map(|a| a.0), Some(id)); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] - ); - } - - #[test] - fn smart_guides_cannot_steal_a_terminal_anchor_press() { - let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); - let other = d.alloc_id(); - let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(66.0, 300.0, 76.0, 310.0)); - d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, rect, vectorcraft_doc::Appearance::default_art())).unwrap(); - let p = paint(); - let cx = cx(&d, &s, &p); - let pos = Point::new(63.0, 300.0); - assert_eq!(crate::guides::snap_draw(&cx, pos, &[id]).0, Point::new(66.0, 300.0)); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)), - vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] - ); - } - - /// Nearby endpoints share a pick radius: use the closest one for cursor and press. - #[test] - fn overlapping_endpoint_tolerances_pick_the_nearest_end() { - let paint = paint(); - let mut failures = vec![]; - for zoom in [0.5, 1.0, 2.0] { - let first = Point::new(100.0, 300.0); - let last = Point::new(100.0 + 6.0 / zoom, 300.0); - let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false)); - for grid in [false, true] { - let cx = ToolContext { - zoom, - selection_tolerance: 8.0, - smart_guides: false, - snap_to_point: false, - snap_to_grid: grid, - ..cx(&d, &selection, &paint) - }; - for shift in [false, true] { - let mods = Mods { shift, ..Mods::default() }; - // Include off-anchor clicks in both halves of the overlap and the midpoint. - for offset in [6.0, 0.0, 1.0, 3.0, 5.0] { - let at = Point::new(first.x + offset / zoom, first.y); - let first_end = offset <= 3.0; - let mut pen = PenTool { drawing: true, ..PenTool::default() }; - let cursor = if first_end { Cursor::PenClose } else { Cursor::PenConvert }; - let actual_cursor = pen.cursor(&cx, at, mods); - let expected = if first_end { - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] - } else { - vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)] - }; - let actual = pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(mods)); - if actual_cursor != cursor || actual != expected { - failures.push((zoom, grid, shift, offset, actual_cursor, actual)); - } - assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)), vec![Action::Commit]); - } - } - } - } - assert!(failures.is_empty(), "cursor and press must pick the closest eligible endpoint: {failures:?}"); - } - - #[test] - fn overlapping_endpoint_tolerances_continue_from_the_nearest_end() { - let paint = paint(); - let mut failures = vec![]; - for zoom in [0.5, 1.0, 2.0] { - let first = Point::new(100.0, 300.0); - let last = Point::new(100.0 + 6.0 / zoom, 300.0); - let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false)); - for grid in [false, true] { - let cx = ToolContext { - zoom, - selection_tolerance: 8.0, - smart_guides: false, - snap_to_point: false, - snap_to_grid: grid, - ..cx(&d, &selection, &paint) - }; - for offset in [6.0, 0.0, 1.0, 3.0, 5.0] { - let at = Point::new(first.x + offset / zoom, first.y); - let mut pen = PenTool::default(); - assert_eq!(pen.cursor(&cx, at, Mods::default()), Cursor::PenContinue); - let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({}))] } else { vec![] }; - let actual = click(&mut pen, &cx, at.x, at.y, Mods::default()); - if actual != expected { - failures.push((zoom, grid, offset, actual)); - } - assert_eq!(pen.active(&cx).map(|a| a.0), Some(id)); - } - } - } - assert!(failures.is_empty(), "resuming must reverse only for the closest first endpoint: {failures:?}"); - } - - #[test] - fn coincident_endpoints_keep_the_prior_tie_choice() { - let at = Point::new(100.0, 300.0); - let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[at, at], false)); - let paint = paint(); - for zoom in [0.5, 1.0, 2.0] { - let cx = ToolContext { zoom, smart_guides: false, snap_to_point: false, ..cx(&d, &selection, &paint) }; - let mut drawing = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(drawing.cursor(&cx, at, Mods::default()), Cursor::PenClose); - assert_eq!( - click(&mut drawing, &cx, at.x, at.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] - ); - let mut resuming = PenTool::default(); - assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue); - assert!(click(&mut resuming, &cx, at.x, at.y, Mods::default()).is_empty()); - } - } - - #[test] - fn terminal_anchor_uses_the_selection_tolerance_at_each_zoom() { - let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); - let p = paint(); - for zoom in [0.5, 2.0] { - let cx = ToolContext { zoom, selection_tolerance: 8.0, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - let pos = Point::new(60.0, 300.0 + 6.0 / zoom); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)), - vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] - ); - let narrow = ToolContext { selection_tolerance: 1.0, ..cx }; - // The upstream point picker retains a minimum screen radius at low tolerance. - let near = Point::new(60.0, 300.0 + 4.0 / zoom); - let mut narrow_pen = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(narrow_pen.cursor(&narrow, near, Mods::default()), Cursor::PenConvert); - assert_eq!( - click(&mut narrow_pen, &narrow, near.x, near.y, Mods::default()), - vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] - ); - assert_eq!(narrow_pen.cursor(&narrow, Point::new(60.0, 300.0 + 6.0 / zoom), Mods::default()), Cursor::PenDrawing); - } - } - - /// Upstream's point radius grows with the drawn anchor; cursor and press keep agreeing. - #[test] - fn enlarged_anchor_radius_is_used_for_endpoints_and_alt_conversion() { - let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); - let p = paint(); - for zoom in [0.5, 1.0, 2.0] { - let cx = ToolContext { zoom, selection_tolerance: 1.0, anchor_size: 7, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - let at = Point::new(60.0 + 6.0 / zoom, 300.0); - let mut terminal = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(terminal.cursor(&cx, at, Mods::default()), Cursor::PenConvert); - assert_eq!( - click(&mut terminal, &cx, at.x, at.y, Mods::default()), - vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] - ); - let mut continuing = PenTool::default(); - assert_eq!(continuing.cursor(&cx, at, Mods::default()), Cursor::PenContinue); - assert!(click(&mut continuing, &cx, at.x, at.y, Mods::default()).is_empty()); - assert_eq!(continuing.active(&cx).map(|a| a.0), Some(id)); - let mut closing = PenTool { drawing: true, ..PenTool::default() }; - let first = Point::new(10.0 - 6.0 / zoom, 300.0); - assert_eq!(closing.cursor(&cx, first, Mods::default()), Cursor::PenClose); - assert_eq!( - click(&mut closing, &cx, first.x, first.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] - ); - let mut alt_pen = PenTool { drawing: true, ..PenTool::default() }; - let alt = Mods { alt: true, ..Mods::default() }; - assert!(alt_pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(alt)).is_empty()); - assert!(alt_pen.busy(), "Alt must reach the enlarged terminal-anchor target"); - assert!(alt_pen.convert.is_some()); - let away_from_segment = Point::new(35.0, 300.0 + 4.0 / zoom); - assert!(!alt_converts(&cx, away_from_segment, alt), "segment picking retains the smaller selection radius"); - } - } - - /// #516: Alt on the terminal anchor borrows Anchor Point, just like the other anchors. - #[test] - fn alt_on_the_terminal_anchor_pulls_symmetric_handles() { - let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Mods::default() }; - let mut t = PenTool { drawing: true, ..PenTool::default() }; - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(alt)).is_empty()); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 330.0).with_mods(alt)), - vec![ - Action::Begin("Convert Anchor Point".into()), - Action::Preview("path.convertAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "to": "smooth", "x": 230.0, "y": 330.0})) - ] - ); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 330.0)), vec![Action::Commit]); - assert!( - matches!(click(&mut t, &cx, 300.0, 400.0, Mods::default()).as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor") - ); - } - - #[test] - fn ending_during_a_pen_gesture_commits_and_clears_it() { - let (d, _, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); - let p = paint(); - let cx = cx(&d, &s, &p); - for terminal in [false, true] { - for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] { - let mut t = PenTool { drawing: true, ..PenTool::default() }; - let x = if terminal { 200.0 } else { 300.0 }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, 300.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, 330.0)); - let acts = match key { - Some(k) => t.key(&cx, k, Mods::default()), - None => t.deactivate(&cx), - }; - assert_eq!(acts, vec![Action::Commit]); - assert!(!t.busy()); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, 330.0)).is_empty()); - assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::Pen); - } - } - } - - /// #501: a press on the first anchor closes the path, however far the drag then goes: the - /// closing preview leaves the path closed (so not one the pen extends), and each move still - /// previews the close with symmetric handles (Alt breaks them), released as a close. - #[test] - fn a_drag_from_the_first_anchor_always_closes() { - let pts = [Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(200.0, 400.0)]; - let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&pts, false)); - let p = paint(); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0)); - assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]); - // What the tool sees once the preview applied: the path closed. - d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; - let c = cx(&d, &s, &p); - let close = |x: f64, y: f64, alt: bool| { - vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))] - }; - for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] { - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})"); - } - let alt = Mods { alt: true, ..Mods::default() }; - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 250.0).with_mods(alt)), close(170.0, 250.0, true)); - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 250.0)), vec![Action::Commit]); - assert!(!t.busy() && t.active(&c).is_none(), "the path is done"); - } - - #[test] - fn a_one_anchor_path_does_not_close_on_itself() { - let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false)); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenConvert); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 300.0)); - assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 0, 10.0, 300.0)]); - } - - #[test] - fn clicking_a_selected_blend_spine_adds_a_point() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let key = |id: NodeId, x: f64| { - let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); - std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) - }; - let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert( - Some(l), - 1, - vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }), - ) - .unwrap(); - let mut s = Selection::default(); - s.set([g]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = PenTool::default(); - assert_eq!(t.cursor(&cx, Point::new(160.0, 311.0), Mods::default()), Cursor::PenAdd); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); - assert_eq!(a, vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": g.0, "x": 160.0, "y": 310.0}))]); - // A key's own point is left alone (the click snaps to the spine line). - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]); - } - - fn click(t: &mut PenTool, cx: &ToolContext, x: f64, y: f64, m: Mods) -> Vec { - let acts = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(m)); - t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m)); - acts - } - - #[test] - fn clicking_a_selected_path_adds_or_deletes_an_anchor() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let pts = [Point::new(300.0, 400.0), Point::new(350.0, 300.0), Point::new(400.0, 400.0)]; - let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let off = ToolContext { auto_add_delete: false, ..cx(&d, &s, &p) }; - let cx = cx(&d, &s, &p); - let none = Mods::default(); - let mut t = PenTool::default(); - // On a segment: add an anchor there. - assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), none), Cursor::PenAdd); - let a = click(&mut t, &cx, 325.0, 351.0, none); - assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0), "{a:?}"); - // On an anchor: delete it. - assert_eq!(t.cursor(&cx, Point::new(350.0, 302.0), none), Cursor::PenDelete); - let a = click(&mut t, &cx, 350.0, 302.0, none); - assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]); - // An end still continues the path. - assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), none), Cursor::PenContinue); - // Shift, or the preference turned off, starts a new path instead. - let shift = Mods { shift: true, ..Mods::default() }; - assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), shift), Cursor::Pen); - assert_eq!(t.cursor(&off, Point::new(350.0, 302.0), none), Cursor::Pen); - let a = click(&mut t, &off, 350.0, 302.0, none); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}"); - // While a path is being drawn, a click goes on drawing it. - let a = click(&mut t, &cx, 325.0, 351.0, none); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); - let mut t = PenTool::default(); - let a = click(&mut t, &cx, 325.0, 351.0, shift); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}"); - } - - /// Each anchor snaps to Smart Guides (#506): hovering shows where it goes (the rubber band ends - /// there), in line with another object's centre; Shift slides it along its 45° step into line. - #[test] - fn anchors_snap_to_smart_guides() { - let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); - let o = t.overlays(&cx); - assert!(o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 318.0))), "{o:?}"); - assert!(o.iter().any( - |o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0))) - )); - let from = Point::new(60.0, 300.0); - let to = Point::new(150.0, 318.0); - assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); - let a = click(&mut t, &cx, 152.0, 318.0, Mods::default()); - assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0}))); - let shift = Mods { shift: true, ..Mods::default() }; - let a = click(&mut t, &cx, 152.0, 310.0, shift); - assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0}))); - // The first anchor of a new path lands on the rect's corner. - let mut t = PenTool::default(); - let a = click(&mut t, &cx, 102.0, 99.0, Mods::default()); - assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]}))); - } - - #[test] - fn alt_over_a_shown_handle_or_an_anchor_converts() { - // The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100). - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(300.0, 200.0)], false); - sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 100.0), Point::new(250.0, 100.0)); - let (d, id, mut s) = drawing(sp); - let p = paint(); - let alt = Mods { alt: true, ..Mods::default() }; - let t = PenTool::default(); - let cx0 = cx(&d, &s, &p); - // Alt lends the Anchor Point tool, and its caret with it. - assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::AnchorPoint); - assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::AnchorPoint); - assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen); - assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen); - // A drag of the handle moves it alone, as one gesture. - let mut t = PenTool::default(); - assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 250.0, 101.0).with_mods(alt)), vec![]); - assert!(t.busy()); - let a = t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0).with_mods(alt)); - assert_eq!( - a, - vec![ - Action::Begin("Reshape".into()), - Action::Preview( - "path.setHandle".into(), - json!({"id": id.0, "subpath": 0, "anchor": 1, "which": "out", "x": 260.0, "y": 140.0, "independent": true}) - ) - ] - ); - // Alt let go mid-drag: the gesture goes on to its end. - assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]); - assert!(!t.busy()); - // With other anchors direct-selected the handles of this one are hidden, and not grabbed. - s.anchors.insert(id, std::collections::BTreeSet::from([(0, 0)])); - let cx1 = cx(&d, &s, &p); - assert_eq!(t.cursor(&cx1, Point::new(250.0, 101.0), alt), Cursor::Pen); - } - - /// #494: Alt held over a segment of a selected path reshapes it as the Anchor Point tool does, - /// while drawing too; Alt away from paths still places an anchor. - #[test] - fn alt_over_a_segment_reshapes_it() { - let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Mods::default() }; - let mut t = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::ReshapeSegment); - assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::PenDrawing); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, Point::new(150.0, 262.0), alt), Cursor::ReshapeSegment, "kept during the reshape"); - assert_eq!(a[0], Action::Begin("Reshape".into())); - assert!( - matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0 && v["perpendicular"] == false), - "{a:?}" - ); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 262.0)), vec![Action::Commit]); - // Shift held too keeps the handles at right angles to the line between the anchors. - let alt_shift = Mods { shift: true, ..alt }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.0, 262.0).with_mods(alt_shift)); - assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["perpendicular"] == true), "{a:?}"); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.0, 262.0)), vec![Action::Commit]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 250.0, 400.0).with_mods(alt)); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); - } - - /// #494: the Pen goes on drawing the path it drew while that path stays selected (after a Cmd - /// drag with Direct Selection), but never another open path selected meanwhile. - #[test] - fn the_pen_draws_on_the_path_it_began_only() { - let (mut d, a, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); - let l = d.layers[0].id; - let b = d.alloc_id(); - let line = vectorcraft_geom::shapes::line(Point::new(10.0, 400.0), Point::new(60.0, 400.0)); - d.insert(Some(l), 2, vectorcraft_doc::Node::path(b, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let p = paint(); - let mut t = PenTool { drawing: true, ..PenTool::default() }; - let cx_a = cx(&d, &s, &p); - let appended = |acts: &[Action]| match acts { - [Action::Begin(_), Action::Preview(c, v)] if c == "path.appendAnchor" => v["id"].as_u64(), - _ => None, - }; - assert_eq!(appended(&click(&mut t, &cx_a, 100.0, 320.0, Mods::default())), Some(a.0)); - // Path B selected instead: the next click starts a new path. - let mut sb = Selection::default(); - sb.set([b]); - let cx_b = cx(&d, &sb, &p); - assert_eq!(t.cursor(&cx_b, Point::new(150.0, 350.0), Mods::default()), Cursor::Pen); - // An end of B shows what a click there does: it goes on drawing B. - assert_eq!(t.cursor(&cx_b, Point::new(60.0, 400.0), Mods::default()), Cursor::PenContinue); - let acts = click(&mut t, &cx_b, 150.0, 350.0, Mods::default()); - assert!(matches!(&acts[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{acts:?}"); - } - - /// The new-path asterisk shows until a path is started; while one is being drawn the pen has no - /// badge, and the asterisk comes back once the path is finished. - #[test] - fn the_new_path_asterisk_shows_only_before_a_path_is_started() { - let (d, _, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false)); - let p = paint(); - let cx = cx(&d, &s, &p); - let empty = Point::new(250.0, 450.0); - let mut t = PenTool::default(); - assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "no path started"); - t.drawing = true; - assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::PenDrawing, "after the first anchor"); - let a = click(&mut t, &cx, empty.x, empty.y, Mods::default()); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); - assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::PenDrawing); - assert_eq!(t.cursor(&cx, empty, Mods { shift: true, ..Mods::default() }), Cursor::PenDrawing, "Shift constrains the next anchor"); - t.key(&cx, ToolKey::Enter, Mods::default()); - assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "the path is finished"); - assert_eq!(Cursor::PenDrawing.precise(), Cursor::Crosshair); - } -} +mod tests; diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs new file mode 100644 index 000000000..925b76089 --- /dev/null +++ b/crates/tools/src/pen/tests.rs @@ -0,0 +1,833 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +#[test] +fn press_inside_drag_threshold_places_a_corner() { + let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default()); + let paint = paint(); + let cx = + ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) }; + let mut pen = PenTool::default(); + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.0, 101.0)).is_empty()); + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.0, 101.0)), vec![Action::Commit]); + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 200.0)); + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 204.0, 200.0)).len(), 1); +} + +#[test] +fn snapped_last_endpoint_converts_active_path_after_raw_miss() { + let first = Point::new(100.0, 300.0); + let last = Point::new(200.0, 400.0); + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let paint = paint(); + let cx = ToolContext { + zoom: 1.0, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_pixel: false, + snap_to_grid: true, + ..cx(&d, &s, &paint) + }; + let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0]; + assert!(!sp.closed); + assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); + let mut pen = PenTool::default(); + assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert!(!pen.busy()); + + let raw = Point::new(237.0, 403.0); + let tol = cx.tol(5.0).max(cx.point_tol()); + assert!(raw.distance(first) > tol && raw.distance(last) > tol); + let from = Leave::segment(&cx, last, false); + assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); + assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, last, tol, Some(id)).is_none()); + + assert_eq!( + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 2, last.x, last.y)] + ); + assert_eq!(pen.handle, Some((id, 0, 2, last))); + assert!(pen.drag.is_none() && pen.busy()); + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); + assert!(pen.handle.is_none() && !pen.busy()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); +} + +#[test] +fn raw_other_endpoint_wins_before_snapped_active_last_endpoint() { + let first = Point::new(100.0, 300.0); + let last = Point::new(200.0, 400.0); + let raw = Point::new(237.0, 403.0); + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); + let other = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(337.0, 403.0)], false)); + d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let paint = paint(); + let cx = ToolContext { + zoom: 1.0, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_pixel: false, + snap_to_grid: true, + ..cx(&d, &s, &paint) + }; + let mut pen = PenTool::default(); + assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert!(!pen.busy()); + let tol = cx.tol(5.0).max(cx.point_tol()); + assert!(raw.distance(first) > tol && raw.distance(last) > tol); + assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + let from = Leave::segment(&cx, last, false); + assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); + + assert_eq!( + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + ); + assert!(!pen.busy() && pen.active(&cx).is_none()); + assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); +} + +#[test] +fn snapped_first_endpoint_closes_active_path_after_raw_miss() { + let first = Point::new(100.0, 300.0); + let last = Point::new(200.0, 400.0); + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let paint = paint(); + let cx = ToolContext { + zoom: 1.0, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_pixel: false, + snap_to_grid: true, + ..cx(&d, &s, &paint) + }; + let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0]; + assert!(!sp.closed); + assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); + let mut pen = PenTool::default(); + assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert!(!pen.busy()); + + let raw = Point::new(63.0, 303.0); + let tol = cx.tol(5.0).max(cx.point_tol()); + assert!(raw.distance(first) > tol && raw.distance(last) > tol); + let from = Leave::segment(&cx, last, false); + assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); + assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, first, tol, Some(id)).is_none()); + + assert_eq!( + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + ); + let place = pen.drag.unwrap(); + assert!(place.closing); + assert_eq!((place.path, place.at), (Some(id), first)); + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); + assert!(!pen.busy() && pen.active(&cx).is_none()); +} + +#[test] +fn raw_other_endpoint_wins_before_snapped_active_first_endpoint() { + let first = Point::new(100.0, 300.0); + let last = Point::new(200.0, 400.0); + let raw = Point::new(63.0, 303.0); + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); + let other = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(63.0, 403.0)], false)); + d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let paint = paint(); + let cx = ToolContext { + zoom: 1.0, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_pixel: false, + snap_to_grid: true, + ..cx(&d, &s, &paint) + }; + let mut pen = PenTool::default(); + assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert!(!pen.busy()); + let tol = cx.tol(5.0).max(cx.point_tol()); + assert!(raw.distance(first) > tol && raw.distance(last) > tol); + assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + let from = Leave::segment(&cx, last, false); + assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); + + assert_eq!( + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + ); + assert!(!pen.busy() && pen.active(&cx).is_none()); + assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); +} + +#[test] +fn first_click_creates_path() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool::default(); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert_eq!(a[0], Action::Begin("Pen".into())); + assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create")); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]); +} + +#[test] +fn alt_pressed_mid_drag_keeps_the_incoming_handle() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + let alt = Mods { alt: true, ..Mods::default() }; + let handles = |acts: Vec| match acts.as_slice() { + [Action::Preview(c, v)] if c == "path.appendAnchor" => (v["in"].clone(), v["out"].clone()), + other => panic!("not an anchor preview: {other:?}"), + }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); + let drag = PointerEvent::new(PointerKind::Drag, 450.0, 420.0); + assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 420.0]))); + // Alt goes down: the curve shaped so far stays, and only the outgoing handle moves on. + let drag = PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt); + assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 470.0]))); + let drag = PointerEvent::new(PointerKind::Drag, 400.0, 480.0).with_mods(alt); + assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([400.0, 480.0]))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 400.0, 480.0).with_mods(alt)), vec![Action::Commit]); + // Alt held from the start: the new anchor gets an outgoing handle only. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 440.0).with_mods(alt)); + let drag = PointerEvent::new(PointerKind::Drag, 340.0, 440.0).with_mods(alt); + assert_eq!(handles(t.pointer(&cx, &drag)), (json!([300.0, 440.0]), json!([340.0, 440.0]))); +} + +#[test] +fn space_pressed_inside_the_drag_threshold_moves_the_anchor_by_the_next_move_only() { + let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default()); + let paint = paint(); + let cx = + ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) }; + let space = Mods { space: true, ..Mods::default() }; + let mut pen = PenTool::default(); + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + // Still inside the threshold, Space held: nothing happens yet. + assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 102.0, 100.0).with_mods(space)).is_empty()); + // The move that crosses it moves the anchor by that move (2 px to 4 px), not by the 4 px since the press. + let acts = pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 104.0, 100.0).with_mods(space)); + let Some(Action::Preview(c, v)) = acts.first() else { panic!("not a preview: {acts:?}") }; + assert_eq!(c, "path.create"); + assert_eq!(v["anchors"][0]["x"], json!(102.0), "moved by the delta"); + assert_eq!(v["anchors"][0]["y"], json!(100.0)); +} + +#[test] +fn space_moves_the_anchor_being_placed() { + let (mut d, _) = doc_with_rect(); + let space = Mods { space: true, ..Mods::default() }; + // The first anchor of a new path. + let (s, p) = (Selection::default(), paint()); + let cx0 = cx(&d, &s, &p); + let mut t = PenTool::default(); + t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); + t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0)); + let created = |acts: Vec| match acts.as_slice() { + [Action::Preview(c, v)] if c == "path.create" => v["anchors"][0].clone(), + other => panic!("not a create preview: {other:?}"), + }; + let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 380.0).with_mods(space))); + assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [450.0, 380.0], "in": [350.0, 380.0]}), "moved, handles and all"); + // Space released: the drag shapes the handles round the anchor's new place. + let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 460.0, 380.0))); + assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [460.0, 380.0], "in": [340.0, 380.0]})); + // An anchor added to an open path, its handles broken by Alt: the kept incoming handle + // moves with it. + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let cx = cx(&d, &s, &p); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + let alt = Mods { alt: true, ..Mods::default() }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 450.0).with_mods(Mods { space: true, ..alt })); + assert_eq!( + acts, + vec![Action::Preview( + "path.appendAnchor".into(), + json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]}) + )] + ); +} + +/// A document with an open path of `anchors`, selected, being drawn by a Pen. +fn drawing(anchors: vectorcraft_geom::SubPath) -> (vectorcraft_doc::Document, NodeId, Selection) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(anchors); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + (d, id, s) +} + +fn set_out(id: NodeId, ai: usize, x: f64, y: f64) -> Action { + Action::Preview("path.setHandle".into(), json!({"id": id.0, "subpath": 0, "anchor": ai, "which": "out", "x": x, "y": y, "independent": true})) +} + +#[test] +fn clicking_the_last_anchor_retracts_its_handle_and_dragging_pulls_a_new_one() { + // The last anchor is smooth: handles at 40 and 80 either side of (60, 300). + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false); + sp.anchors[1].h_in = Point::new(40.0, 300.0); + sp.anchors[1].h_out = Point::new(80.0, 300.0); + let (d, id, s) = drawing(sp); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, Point::new(61.0, 301.0), Mods::default()), Cursor::PenConvert); + // A click retracts the outgoing handle and adds no anchor. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 61.0, 301.0)); + assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 61.0, 301.0)), vec![Action::Commit]); + // A drag pulls a new outgoing handle out. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 60.0, 300.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 280.0)), vec![set_out(id, 1, 70.0, 280.0)]); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 70.0, 280.0)), vec![Action::Commit]); + // Elsewhere a click still adds an anchor, and the first anchor still closes the path. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 400.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 400.0)); + assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose); +} + +/// #544: picking the terminal anchor uses the pointer, even when new points snap elsewhere. +#[test] +fn terminal_anchor_conversion_precedes_grid_snapping() { + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(13.0, 303.0), Point::new(63.0, 303.0)], false)); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let p = paint(); + let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenConvert); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 63.0, 303.0)), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 63.0, 303.0)] + ); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 63.0, 303.0)), vec![Action::Commit]); + // The same raw picking applies when resuming the path and when closing it. + let mut t = PenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenContinue); + assert!(click(&mut t, &cx, 63.0, 303.0, Mods::default()).is_empty()); + assert_eq!(t.active(&cx).map(|a| a.0), Some(id)); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)), + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + ); +} + +#[test] +fn smart_guides_cannot_steal_a_terminal_anchor_press() { + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let other = d.alloc_id(); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(66.0, 300.0, 76.0, 310.0)); + d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, rect, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let cx = cx(&d, &s, &p); + let pos = Point::new(63.0, 300.0); + assert_eq!(crate::guides::snap_draw(&cx, pos, &[id]).0, Point::new(66.0, 300.0)); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] + ); +} + +/// Nearby endpoints share a pick radius: use the closest one for cursor and press. +#[test] +fn overlapping_endpoint_tolerances_pick_the_nearest_end() { + let paint = paint(); + let mut failures = vec![]; + for zoom in [0.5, 1.0, 2.0] { + let first = Point::new(100.0, 300.0); + let last = Point::new(100.0 + 6.0 / zoom, 300.0); + let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false)); + for grid in [false, true] { + let cx = ToolContext { + zoom, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_grid: grid, + ..cx(&d, &selection, &paint) + }; + for shift in [false, true] { + let mods = Mods { shift, ..Mods::default() }; + // Include off-anchor clicks in both halves of the overlap and the midpoint. + for offset in [6.0, 0.0, 1.0, 3.0, 5.0] { + let at = Point::new(first.x + offset / zoom, first.y); + let first_end = offset <= 3.0; + let mut pen = PenTool { drawing: true, ..PenTool::default() }; + let cursor = if first_end { Cursor::PenClose } else { Cursor::PenConvert }; + let actual_cursor = pen.cursor(&cx, at, mods); + let expected = if first_end { + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + } else { + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)] + }; + let actual = pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(mods)); + if actual_cursor != cursor || actual != expected { + failures.push((zoom, grid, shift, offset, actual_cursor, actual)); + } + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)), vec![Action::Commit]); + } + } + } + } + assert!(failures.is_empty(), "cursor and press must pick the closest eligible endpoint: {failures:?}"); +} + +#[test] +fn overlapping_endpoint_tolerances_continue_from_the_nearest_end() { + let paint = paint(); + let mut failures = vec![]; + for zoom in [0.5, 1.0, 2.0] { + let first = Point::new(100.0, 300.0); + let last = Point::new(100.0 + 6.0 / zoom, 300.0); + let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false)); + for grid in [false, true] { + let cx = ToolContext { + zoom, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_grid: grid, + ..cx(&d, &selection, &paint) + }; + for offset in [6.0, 0.0, 1.0, 3.0, 5.0] { + let at = Point::new(first.x + offset / zoom, first.y); + let mut pen = PenTool::default(); + assert_eq!(pen.cursor(&cx, at, Mods::default()), Cursor::PenContinue); + let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({}))] } else { vec![] }; + let actual = click(&mut pen, &cx, at.x, at.y, Mods::default()); + if actual != expected { + failures.push((zoom, grid, offset, actual)); + } + assert_eq!(pen.active(&cx).map(|a| a.0), Some(id)); + } + } + } + assert!(failures.is_empty(), "resuming must reverse only for the closest first endpoint: {failures:?}"); +} + +#[test] +fn coincident_endpoints_keep_the_prior_tie_choice() { + let at = Point::new(100.0, 300.0); + let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[at, at], false)); + let paint = paint(); + for zoom in [0.5, 1.0, 2.0] { + let cx = ToolContext { zoom, smart_guides: false, snap_to_point: false, ..cx(&d, &selection, &paint) }; + let mut drawing = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(drawing.cursor(&cx, at, Mods::default()), Cursor::PenClose); + assert_eq!( + click(&mut drawing, &cx, at.x, at.y, Mods::default()), + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + ); + let mut resuming = PenTool::default(); + assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue); + assert!(click(&mut resuming, &cx, at.x, at.y, Mods::default()).is_empty()); + } +} + +#[test] +fn terminal_anchor_uses_the_selection_tolerance_at_each_zoom() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let p = paint(); + for zoom in [0.5, 2.0] { + let cx = ToolContext { zoom, selection_tolerance: 8.0, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let pos = Point::new(60.0, 300.0 + 6.0 / zoom); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] + ); + let narrow = ToolContext { selection_tolerance: 1.0, ..cx }; + // The upstream point picker retains a minimum screen radius at low tolerance. + let near = Point::new(60.0, 300.0 + 4.0 / zoom); + let mut narrow_pen = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(narrow_pen.cursor(&narrow, near, Mods::default()), Cursor::PenConvert); + assert_eq!( + click(&mut narrow_pen, &narrow, near.x, near.y, Mods::default()), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] + ); + assert_eq!(narrow_pen.cursor(&narrow, Point::new(60.0, 300.0 + 6.0 / zoom), Mods::default()), Cursor::PenDrawing); + } +} + +/// Upstream's point radius grows with the drawn anchor; cursor and press keep agreeing. +#[test] +fn enlarged_anchor_radius_is_used_for_endpoints_and_alt_conversion() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let p = paint(); + for zoom in [0.5, 1.0, 2.0] { + let cx = ToolContext { zoom, selection_tolerance: 1.0, anchor_size: 7, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let at = Point::new(60.0 + 6.0 / zoom, 300.0); + let mut terminal = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(terminal.cursor(&cx, at, Mods::default()), Cursor::PenConvert); + assert_eq!( + click(&mut terminal, &cx, at.x, at.y, Mods::default()), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] + ); + let mut continuing = PenTool::default(); + assert_eq!(continuing.cursor(&cx, at, Mods::default()), Cursor::PenContinue); + assert!(click(&mut continuing, &cx, at.x, at.y, Mods::default()).is_empty()); + assert_eq!(continuing.active(&cx).map(|a| a.0), Some(id)); + let mut closing = PenTool { drawing: true, ..PenTool::default() }; + let first = Point::new(10.0 - 6.0 / zoom, 300.0); + assert_eq!(closing.cursor(&cx, first, Mods::default()), Cursor::PenClose); + assert_eq!( + click(&mut closing, &cx, first.x, first.y, Mods::default()), + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + ); + let mut alt_pen = PenTool { drawing: true, ..PenTool::default() }; + let alt = Mods { alt: true, ..Mods::default() }; + assert!(alt_pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(alt)).is_empty()); + assert!(alt_pen.busy(), "Alt must reach the enlarged terminal-anchor target"); + assert!(alt_pen.convert.is_some()); + let away_from_segment = Point::new(35.0, 300.0 + 4.0 / zoom); + assert!(!alt_converts(&cx, away_from_segment, alt), "segment picking retains the smaller selection radius"); + } +} + +/// #516: Alt on the terminal anchor borrows Anchor Point, just like the other anchors. +#[test] +fn alt_on_the_terminal_anchor_pulls_symmetric_handles() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(alt)).is_empty()); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 330.0).with_mods(alt)), + vec![ + Action::Begin("Convert Anchor Point".into()), + Action::Preview("path.convertAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "to": "smooth", "x": 230.0, "y": 330.0})) + ] + ); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 330.0)), vec![Action::Commit]); + assert!( + matches!(click(&mut t, &cx, 300.0, 400.0, Mods::default()).as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor") + ); +} + +#[test] +fn ending_during_a_pen_gesture_commits_and_clears_it() { + let (d, _, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); + let p = paint(); + let cx = cx(&d, &s, &p); + for terminal in [false, true] { + for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] { + let mut t = PenTool { drawing: true, ..PenTool::default() }; + let x = if terminal { 200.0 } else { 300.0 }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, 330.0)); + let acts = match key { + Some(k) => t.key(&cx, k, Mods::default()), + None => t.deactivate(&cx), + }; + assert_eq!(acts, vec![Action::Commit]); + assert!(!t.busy()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, 330.0)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::Pen); + } + } +} + +/// #501: a press on the first anchor closes the path, however far the drag then goes: the +/// closing preview leaves the path closed (so not one the pen extends), and each move still +/// previews the close with symmetric handles (Alt breaks them), released as a close. +#[test] +fn a_drag_from_the_first_anchor_always_closes() { + let pts = [Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(200.0, 400.0)]; + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&pts, false)); + let p = paint(); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0)); + assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]); + // What the tool sees once the preview applied: the path closed. + d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; + let c = cx(&d, &s, &p); + let close = |x: f64, y: f64, alt: bool| { + vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))] + }; + for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] { + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})"); + } + let alt = Mods { alt: true, ..Mods::default() }; + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 250.0).with_mods(alt)), close(170.0, 250.0, true)); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 250.0)), vec![Action::Commit]); + assert!(!t.busy() && t.active(&c).is_none(), "the path is done"); +} + +#[test] +fn a_one_anchor_path_does_not_close_on_itself() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false)); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenConvert); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 300.0)); + assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 0, 10.0, 300.0)]); +} + +#[test] +fn clicking_a_selected_blend_spine_adds_a_point() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert( + Some(l), + 1, + vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }), + ) + .unwrap(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(160.0, 311.0), Mods::default()), Cursor::PenAdd); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); + assert_eq!(a, vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": g.0, "x": 160.0, "y": 310.0}))]); + // A key's own point is left alone (the click snaps to the spine line). + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]); +} + +fn click(t: &mut PenTool, cx: &ToolContext, x: f64, y: f64, m: Mods) -> Vec { + let acts = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(m)); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m)); + acts +} + +#[test] +fn clicking_a_selected_path_adds_or_deletes_an_anchor() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let pts = [Point::new(300.0, 400.0), Point::new(350.0, 300.0), Point::new(400.0, 400.0)]; + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let off = ToolContext { auto_add_delete: false, ..cx(&d, &s, &p) }; + let cx = cx(&d, &s, &p); + let none = Mods::default(); + let mut t = PenTool::default(); + // On a segment: add an anchor there. + assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), none), Cursor::PenAdd); + let a = click(&mut t, &cx, 325.0, 351.0, none); + assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0), "{a:?}"); + // On an anchor: delete it. + assert_eq!(t.cursor(&cx, Point::new(350.0, 302.0), none), Cursor::PenDelete); + let a = click(&mut t, &cx, 350.0, 302.0, none); + assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]); + // An end still continues the path. + assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), none), Cursor::PenContinue); + // Shift, or the preference turned off, starts a new path instead. + let shift = Mods { shift: true, ..Mods::default() }; + assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), shift), Cursor::Pen); + assert_eq!(t.cursor(&off, Point::new(350.0, 302.0), none), Cursor::Pen); + let a = click(&mut t, &off, 350.0, 302.0, none); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}"); + // While a path is being drawn, a click goes on drawing it. + let a = click(&mut t, &cx, 325.0, 351.0, none); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); + let mut t = PenTool::default(); + let a = click(&mut t, &cx, 325.0, 351.0, shift); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}"); +} + +/// Each anchor snaps to Smart Guides (#506): hovering shows where it goes (the rubber band ends +/// there), in line with another object's centre; Shift slides it along its 45° step into line. +#[test] +fn anchors_snap_to_smart_guides() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); + let o = t.overlays(&cx); + assert!(o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 318.0))), "{o:?}"); + assert!(o.iter().any( + |o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0))) + )); + let from = Point::new(60.0, 300.0); + let to = Point::new(150.0, 318.0); + assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); + let a = click(&mut t, &cx, 152.0, 318.0, Mods::default()); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0}))); + let shift = Mods { shift: true, ..Mods::default() }; + let a = click(&mut t, &cx, 152.0, 310.0, shift); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0}))); + // The first anchor of a new path lands on the rect's corner. + let mut t = PenTool::default(); + let a = click(&mut t, &cx, 102.0, 99.0, Mods::default()); + assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]}))); +} + +#[test] +fn alt_over_a_shown_handle_or_an_anchor_converts() { + // The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100). + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(300.0, 200.0)], false); + sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 100.0), Point::new(250.0, 100.0)); + let (d, id, mut s) = drawing(sp); + let p = paint(); + let alt = Mods { alt: true, ..Mods::default() }; + let t = PenTool::default(); + let cx0 = cx(&d, &s, &p); + // Alt lends the Anchor Point tool, and its caret with it. + assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::AnchorPoint); + assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::AnchorPoint); + assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen); + assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen); + // A drag of the handle moves it alone, as one gesture. + let mut t = PenTool::default(); + assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 250.0, 101.0).with_mods(alt)), vec![]); + assert!(t.busy()); + let a = t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0).with_mods(alt)); + assert_eq!( + a, + vec![ + Action::Begin("Reshape".into()), + Action::Preview( + "path.setHandle".into(), + json!({"id": id.0, "subpath": 0, "anchor": 1, "which": "out", "x": 260.0, "y": 140.0, "independent": true}) + ) + ] + ); + // Alt let go mid-drag: the gesture goes on to its end. + assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]); + assert!(!t.busy()); + // With other anchors direct-selected the handles of this one are hidden, and not grabbed. + s.anchors.insert(id, std::collections::BTreeSet::from([(0, 0)])); + let cx1 = cx(&d, &s, &p); + assert_eq!(t.cursor(&cx1, Point::new(250.0, 101.0), alt), Cursor::Pen); +} + +/// #494: Alt held over a segment of a selected path reshapes it as the Anchor Point tool does, +/// while drawing too; Alt away from paths still places an anchor. +#[test] +fn alt_over_a_segment_reshapes_it() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::ReshapeSegment); + assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::PenDrawing); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, Point::new(150.0, 262.0), alt), Cursor::ReshapeSegment, "kept during the reshape"); + assert_eq!(a[0], Action::Begin("Reshape".into())); + assert!( + matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0 && v["perpendicular"] == false), + "{a:?}" + ); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 262.0)), vec![Action::Commit]); + // Shift held too keeps the handles at right angles to the line between the anchors. + let alt_shift = Mods { shift: true, ..alt }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.0, 262.0).with_mods(alt_shift)); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["perpendicular"] == true), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.0, 262.0)), vec![Action::Commit]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 250.0, 400.0).with_mods(alt)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); +} + +/// #494: the Pen goes on drawing the path it drew while that path stays selected (after a Cmd +/// drag with Direct Selection), but never another open path selected meanwhile. +#[test] +fn the_pen_draws_on_the_path_it_began_only() { + let (mut d, a, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let l = d.layers[0].id; + let b = d.alloc_id(); + let line = vectorcraft_geom::shapes::line(Point::new(10.0, 400.0), Point::new(60.0, 400.0)); + d.insert(Some(l), 2, vectorcraft_doc::Node::path(b, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + let cx_a = cx(&d, &s, &p); + let appended = |acts: &[Action]| match acts { + [Action::Begin(_), Action::Preview(c, v)] if c == "path.appendAnchor" => v["id"].as_u64(), + _ => None, + }; + assert_eq!(appended(&click(&mut t, &cx_a, 100.0, 320.0, Mods::default())), Some(a.0)); + // Path B selected instead: the next click starts a new path. + let mut sb = Selection::default(); + sb.set([b]); + let cx_b = cx(&d, &sb, &p); + assert_eq!(t.cursor(&cx_b, Point::new(150.0, 350.0), Mods::default()), Cursor::Pen); + // An end of B shows what a click there does: it goes on drawing B. + assert_eq!(t.cursor(&cx_b, Point::new(60.0, 400.0), Mods::default()), Cursor::PenContinue); + let acts = click(&mut t, &cx_b, 150.0, 350.0, Mods::default()); + assert!(matches!(&acts[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{acts:?}"); +} + +/// The new-path asterisk shows until a path is started; while one is being drawn the pen has no +/// badge, and the asterisk comes back once the path is finished. +#[test] +fn the_new_path_asterisk_shows_only_before_a_path_is_started() { + let (d, _, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false)); + let p = paint(); + let cx = cx(&d, &s, &p); + let empty = Point::new(250.0, 450.0); + let mut t = PenTool::default(); + assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "no path started"); + t.drawing = true; + assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::PenDrawing, "after the first anchor"); + let a = click(&mut t, &cx, empty.x, empty.y, Mods::default()); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); + assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::PenDrawing); + assert_eq!(t.cursor(&cx, empty, Mods { shift: true, ..Mods::default() }), Cursor::PenDrawing, "Shift constrains the next anchor"); + t.key(&cx, ToolKey::Enter, Mods::default()); + assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "the path is finished"); + assert_eq!(Cursor::PenDrawing.precise(), Cursor::Crosshair); +} From 11926af98db1bec582283290afa00a42d09648db Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:43 +0000 Subject: [PATCH 031/199] refactor(ui-egui): move panels/image_trace.rs tests to panels/image_trace/tests.rs --- crates/ui-egui/src/panels/image_trace.rs | 798 +----------------- .../ui-egui/src/panels/image_trace/tests.rs | 795 +++++++++++++++++ 2 files changed, 796 insertions(+), 797 deletions(-) create mode 100644 crates/ui-egui/src/panels/image_trace/tests.rs diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 86240875b..60c1774a1 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -634,800 +634,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use super::*; - use egui::{Event, PointerButton, Pos2, Rect, vec2}; - - /// One headless frame of the panel (and its menu) with `events`: the texts drawn, with where. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { - let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) - } - - /// Click the panel's `label` (the first one drawn). - fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { - let texts = frame(app, ctx, vec![]); - let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); - let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(app, ctx, vec![Event::PointerMoved(at), press(true)]); - frame(app, ctx, vec![press(false)]); - } - - fn traced_app() -> VectorcraftApp { - traced_app_with_ink(0) - } - - /// A quick trace, then the Image Trace button. - fn trace_and_open(app: &mut VectorcraftApp, preset: &str) { - trace_with(app, preset); - open_settings(app); - } - - fn traced_app_with_ink(ink: u8) -> VectorcraftApp { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [ink, ink, ink, 255] } else { [255, 255, 255, 255] })); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let data = vectorcraft_format::base64_encode(&png); - app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap(); - app.run("imageTrace.make", json!({"preset": "Default"})).unwrap(); - app - } - - #[test] - fn a_large_image_traces_and_the_status_bar_says_it_is_slower() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let img = image::RgbaImage::from_fn(1601, 12, |x, _| image::Rgba(if x < 800 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let data = vectorcraft_format::base64_encode(&png); - app.run("file.place", json!({"name": "wide.png", "dataBase64": data, "link": false})).unwrap(); - trace_with(&mut app, "Default"); - assert!(target(&app).2.is_some(), "the image is traced"); - assert!(app.ui.status.contains("1601 × 12") && app.ui.status.contains("more slowly"), "{}", app.ui.status); - } - - #[test] - fn a_large_image_traced_as_a_flat_logo_shows_the_size_it_was_traced_at() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let img = image::RgbaImage::from_fn(1602, 1602, |x, _| image::Rgba(if x < 800 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let data = vectorcraft_format::base64_encode(&png); - app.run("file.place", json!({"name": "logo.png", "dataBase64": data, "link": false})).unwrap(); - trace_with(&mut app, "Flat Logo"); - assert!(target(&app).2.is_some(), "the image is traced: {}", app.ui.status); - assert_eq!(app.ui.status, "Flat Logo traced the image at 801 × 801 pixels, averaged down from 1602 × 1602."); - } - - #[test] - fn sketch_quick_trace_reveals_all_adjustments_and_keeps_the_source() { - let mut app = traced_app(); - app.run("imageTrace.release", json!({})).unwrap(); - app.ui.image_trace_advanced = false; - trace_and_open(&mut app, "Sketched Art"); - assert!(crate::panel_docking::showing(&app.ui, "imageTrace")); - assert!(app.ui.image_trace_advanced); - assert_eq!(selected_trace(&app).unwrap().0, "Sketched Art"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let texts = frame(&mut app, &ctx, vec![]); - for label in ["Threshold", "Paths", "Corners", "Noise", "Method:", "Fills", "Strokes", "Ignore Color"] { - assert!(texts.iter().any(|(t, _)| t == label), "missing {label}: {texts:?}"); - } - let n = first_selected(&app).unwrap(); - assert!(is_trace(&n)); - assert!(matches!(n.children().unwrap()[0].kind, NodeKind::Image(_))); - // Opening controls again neither closes the flyout nor adds a document undo step. - let steps = app.session.active().unwrap().history.undo.len(); - open_settings(&mut app); - assert!(crate::panel_docking::showing(&app.ui, "imageTrace")); - assert_eq!(app.session.active().unwrap().history.undo.len(), steps); - // Optional local visual QA; no reference images or generated captures are committed. - if let Some(path) = std::env::var_os("VECTORCRAFT_TRACE_CAPTURE") { - let mut ready = false; - let mut harness = egui_kittest::Harness::builder().with_size(vec2(340.0, 820.0)).wgpu().build_ui_state( - move |ui, app: &mut VectorcraftApp| { - if !ready { - egui_extras::install_image_loaders(ui.ctx()); - crate::theme::install_fonts(ui.ctx()); - crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark); - ready = true; - return; - } - show(app, ui); - }, - app, - ); - harness.run_steps(4); - harness.render().unwrap().save(path).unwrap(); - } - } - - #[test] - fn a_quick_trace_leaves_the_panel_as_it_was() { - let mut app = traced_app(); - app.run("imageTrace.release", json!({})).unwrap(); - app.ui.open_panel = None; - app.ui.image_trace_advanced = false; - trace_with(&mut app, "Sketched Art"); - assert!(is_trace(&first_selected(&app).unwrap())); - assert!(app.ui.open_panel.is_none()); - assert!(!app.ui.image_trace_advanced); - } - - #[test] - fn quick_trace_activates_the_existing_floating_panel() { - let mut app = traced_app(); - app.run("window.panel.float", json!({"panel": "imageTrace"})).unwrap(); - let before = app.ui.docking.clone().unwrap(); - trace_and_open(&mut app, "Sketched Art"); - let after = app.ui.docking.as_ref().unwrap(); - assert_eq!(after.floating.len(), before.floating.len()); - assert!(after.floating.iter().any(|g| crate::panel_docking::window_panels(g) == ["imageTrace"])); - assert!(app.ui.open_panel.is_none(), "the existing floating group owns the panel"); - } - - #[test] - fn adjusting_sketch_threshold_changes_the_vector_result_and_can_be_undone() { - let mut app = traced_app_with_ink(100); - trace_and_open(&mut app, "Sketched Art"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - frame(&mut app, &ctx, vec![]); - let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", "Threshold")))).unwrap(); - let at = rail.left_center() + vec2(20.0, 0.0); - assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 2, "source and ink path"); - for pressed in [true, false] { - frame( - &mut app, - &ctx, - vec![ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, - ], - ); - } - assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 1, "only the source remains below the ink's luminance"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 2); - frame(&mut app, &ctx, vec![]); - let state: TraceUi = pstate(&ctx, "image-trace"); - assert_eq!(state.params["threshold"], 150); - } - - #[test] - fn with_preview_off_a_slider_change_waits_for_trace() { - let mut app = traced_app_with_ink(100); - trace_and_open(&mut app, "Sketched Art"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - assert!(app.ui.image_trace_preview, "on at first"); - click(&mut app, &ctx, "Preview"); - assert!(!app.ui.image_trace_preview); - let steps = app.session.active().unwrap().history.undo.len(); - let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", "Threshold")))).unwrap(); - let at = rail.left_center() + vec2(20.0, 0.0); - for pressed in [true, false] { - frame( - &mut app, - &ctx, - vec![ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, - ], - ); - } - assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "no trace while Preview is off"); - assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 2, "source and ink path, as before"); - let draft: TraceUi = pstate(&ctx, "image-trace"); - assert_ne!(draft.params["threshold"], target(&app).2.unwrap()["params"]["threshold"], "the change waits in the panel"); - click(&mut app, &ctx, "Trace"); - assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "Trace applies it as one undo step"); - assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 1, "only the source remains below the ink's luminance"); - assert_eq!(target(&app).2.unwrap()["params"], pstate::(&ctx, "image-trace").params); - assert!(!app.ui.image_trace_preview, "Trace leaves Preview off"); - } - - #[test] - fn turning_preview_on_traces_the_changes_that_waited() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let strokes = |app: &VectorcraftApp| target(app).2.map(|t| t["params"]["strokes"].clone()); - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - let steps = undo(&app); - click(&mut app, &ctx, "Preview"); - click(&mut app, &ctx, "Preview"); - assert!(app.ui.image_trace_preview); - assert_eq!(undo(&app), steps, "nothing waited, so nothing is traced"); - click(&mut app, &ctx, "Preview"); - click(&mut app, &ctx, "Strokes"); - assert_eq!(strokes(&app), Some(json!(false))); - assert_eq!(undo(&app), steps); - click(&mut app, &ctx, "Preview"); - assert_eq!(strokes(&app), Some(json!(true)), "traced again with strokes"); - assert_eq!(undo(&app), steps + 1); - // A change of view keeps the settings that wait, and Preview turned on with `ui.set` traces them. - click(&mut app, &ctx, "Preview"); - click(&mut app, &ctx, "Fills"); - assert_eq!(target(&app).2.unwrap()["params"]["fills"], json!(true)); - click(&mut app, &ctx, "Tracing Result"); - click(&mut app, &ctx, "Outlines"); - assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Outlines)); - assert_eq!(undo(&app), steps + 2, "the view changes without a trace"); - frame(&mut app, &ctx, vec![]); - assert_eq!(pstate::(&ctx, "image-trace").params["fills"], json!(false), "the change still waits"); - app.ui.image_trace_preview = true; - frame(&mut app, &ctx, vec![]); - assert_eq!(target(&app).2.unwrap()["params"]["fills"], json!(false), "traced with the waiting change"); - assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Outlines), "in the view chosen"); - assert_eq!(undo(&app), steps + 3); - } - - #[test] - fn preview_turned_on_with_nothing_selected_traces_when_the_object_is_selected_again() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let a = first_selected(&app).unwrap().id; - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - click(&mut app, &ctx, "Preview"); - click(&mut app, &ctx, "Strokes"); - assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "the change waits"); - app.run("select.none", json!({})).unwrap(); - let steps = undo(&app); - click(&mut app, &ctx, "Preview"); - assert!(app.ui.image_trace_preview); - assert_eq!(undo(&app), steps, "nothing to trace with nothing selected"); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(true), "traced with the change that waited"); - assert_eq!(undo(&app), steps + 1); - } - - #[test] - fn settings_that_wait_stay_with_their_object() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let a = first_selected(&app).unwrap().id; - app.run("edit.duplicate", json!({})).unwrap(); - let b = first_selected(&app).unwrap().id; - assert_ne!(a, b); - assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "the copy has the same settings"); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - click(&mut app, &ctx, "Preview"); - click(&mut app, &ctx, "Strokes"); - assert_eq!(pstate::(&ctx, "image-trace").params["strokes"], json!(true), "the change waits for A"); - app.run("select.set", json!({"ids": [b.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - assert_eq!(pstate::(&ctx, "image-trace").params["strokes"], json!(false), "the panel shows B's own settings"); - click(&mut app, &ctx, "Trace"); - assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "B is traced with its own settings"); - } - - #[test] - fn settings_that_wait_come_back_when_their_object_is_selected_again() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let a = first_selected(&app).unwrap().id; - app.run("edit.duplicate", json!({})).unwrap(); - let b = first_selected(&app).unwrap().id; - let shown = |ctx: &egui::Context| pstate::(ctx, "image-trace").params["strokes"].clone(); - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - click(&mut app, &ctx, "Preview"); - click(&mut app, &ctx, "Strokes"); - app.run("select.set", json!({"ids": [b.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - assert_eq!(shown(&ctx), json!(false), "B's own settings"); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - assert_eq!(shown(&ctx), json!(true), "A's change still waits"); - let steps = undo(&app); - click(&mut app, &ctx, "Trace"); - assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(true), "A is traced with its change"); - assert_eq!(undo(&app), steps + 1); - // Preview turned on while B is selected: A is traced with its change when it is selected again. - let a = first_selected(&app).unwrap().id; - click(&mut app, &ctx, "Strokes"); - app.run("select.set", json!({"ids": [b.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, "Preview"); - assert!(app.ui.image_trace_preview); - let steps = undo(&app); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "traced with the change that waited"); - assert_eq!(undo(&app), steps + 1); - frame(&mut app, &ctx, vec![]); - assert_eq!(undo(&app), steps + 1, "traced once"); - } - - #[test] - fn settings_that_wait_are_kept_when_a_plain_image_is_traced_in_between() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let a = first_selected(&app).unwrap().id; - click(&mut app, &ctx, "Preview"); - click(&mut app, &ctx, "Strokes"); - let img = image::RgbaImage::from_fn(20, 20, |_, y| image::Rgba(if y < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let data = vectorcraft_format::base64_encode(&png); - app.run("file.place", json!({"name": "plain.png", "dataBase64": data, "link": false})).unwrap(); - assert!(target(&app).1, "a plain image is selected"); - frame(&mut app, &ctx, vec![]); - click(&mut app, &ctx, "Strokes"); - click(&mut app, &ctx, "Trace"); - assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "the image is traced with the panel's settings"); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - assert_eq!(pstate::(&ctx, "image-trace").params["strokes"], json!(true), "A's change still waits"); - } - - #[test] - fn settings_that_wait_are_dropped_when_their_object_changed_since() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let a = first_selected(&app).unwrap().id; - app.run("edit.duplicate", json!({})).unwrap(); - let b = first_selected(&app).unwrap().id; - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - click(&mut app, &ctx, "Preview"); - click(&mut app, &ctx, "Strokes"); - app.run("select.set", json!({"ids": [b.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - // A's settings as they were when the change was made, other than its settings now. - let mut st: TraceUi = pstate(&ctx, "image-trace"); - let key = (app.session.active().unwrap().uid, a); - let draft = st.drafts.get_mut(&key).expect("the change waits for A"); - assert_eq!(draft.1["strokes"], json!(true)); - draft.2["params"]["threshold"] = json!(1); - set_pstate(&ctx, "image-trace", st); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - frame(&mut app, &ctx, vec![]); - let shown = pstate::(&ctx, "image-trace"); - assert_eq!(shown.params, target(&app).2.unwrap()["params"], "the panel shows A's settings as they are now"); - assert!(shown.drafts.is_empty(), "the change is dropped"); - } - - #[test] - fn preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - click(&mut app, &ctx, "Preview"); - let menu_frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| menu(app, ui)); - out.textures_delta.clear(); - crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) - }; - let texts = menu_frame(&mut app, vec![]); - // Menu items draw their label after the check mark's column. - let at = texts.iter().find(|(t, _)| t.trim() == "Reset to Default").map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{texts:?}")); - for pressed in [true, false] { - menu_frame( - &mut app, - vec![ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, - ], - ); - } - assert!(pstate::(&ctx, "image-trace").params.is_null(), "the panel's settings were reset"); - assert!(!app.ui.image_trace_preview, "Reset to Default leaves Preview"); - let saved: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); - assert!(!saved.image_trace_preview); - let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); - assert!(old.image_trace_preview, "on for preferences saved before Preview existed"); - } - - #[test] - fn sketch_adjustment_sliders_retrace_on_release() { - let mut app = traced_app(); - trace_and_open(&mut app, "Sketched Art"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - for (label, key) in [("Threshold", "threshold"), ("Paths", "paths"), ("Corners", "corners"), ("Noise", "noise")] { - frame(&mut app, &ctx, vec![]); - let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", label)))).unwrap(); - let at = rail.left_center() + vec2(20.0, 0.0); - let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - let before = target(&app).2.unwrap()["params"][key].clone(); - let preset_before = pstate::(&ctx, "image-trace").preset; - let steps = app.session.active().unwrap().history.undo.len(); - frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); - assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "{label} waits for release"); - let draft: TraceUi = pstate(&ctx, "image-trace"); - assert_ne!(draft.params[key], before, "{label} updates while held"); - assert_eq!(draft.preset, preset_before, "the preset row stays in place during {label}"); - frame(&mut app, &ctx, vec![press(false)]); - let stored = target(&app).2.unwrap(); - assert_ne!( - stored["params"][key], - before, - "{label} reaches the tracer; rail {rail:?}, draft {:?}", - pstate::(&ctx, "image-trace").params - ); - assert_eq!(stored["preset"], "Custom"); - assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "one undo step for {label}"); - frame(&mut app, &ctx, vec![]); - let state: TraceUi = pstate(&ctx, "image-trace"); - assert_eq!(state.params, stored["params"], "{label} survives the next frame"); - } - } - - /// Holding the eye button next to View down lists the selected Image Trace object for the - /// canvas to draw with its source image; releasing it shows the object's View again. The - /// document, its View and the undo history stay as they were. With a plain image selected the - /// button is disabled. - #[test] - fn holding_the_eye_button_shows_the_source_image_until_released() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - frame(&mut app, &ctx, vec![]); - let id = first_selected(&app).unwrap().id; - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - let revision = |app: &VectorcraftApp| app.session.active().unwrap().revision; - let (steps, rev) = (undo(&app), revision(&app)); - let at = ctx.data(|d| d.get_temp::(egui::Id::unique("trace-hold-source"))).unwrap().center(); - let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); - assert_eq!(app.ui.trace_hold_source.as_deref(), Some(&vec![id]), "held: the object shows its source image"); - frame(&mut app, &ctx, vec![]); - assert!(app.ui.trace_hold_source.is_some(), "still held on the next frame"); - frame(&mut app, &ctx, vec![press(false)]); - assert_eq!(app.ui.trace_hold_source, None, "released: the object shows its View again"); - assert_eq!(undo(&app), steps, "no undo step"); - assert_eq!(revision(&app), rev, "the document is unchanged"); - assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Result), "its View is unchanged"); - app.run("imageTrace.release", json!({})).unwrap(); - assert!(matches!(first_selected(&app).unwrap().kind, NodeKind::Image(_))); - frame(&mut app, &ctx, vec![]); - frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); - assert_eq!(app.ui.trace_hold_source, None, "a plain image has no source image to show"); - frame(&mut app, &ctx, vec![press(false)]); - } - - /// With another object first in the selection, the eye button and the panel menu's Release - /// act on the selected Image Trace object. - #[test] - fn the_trace_controls_work_when_another_object_is_selected_first() { - let mut app = traced_app(); - let trace_id = first_selected(&app).unwrap().id; - let made = app.run("shape.rectangle", json!({"x": 80, "y": 80, "width": 10, "height": 10})).unwrap(); - let rect = made["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [rect, trace_id.0]})).unwrap(); - assert_eq!(first_selected(&app).unwrap().id.0, rect, "the rectangle comes first"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - frame(&mut app, &ctx, vec![]); - let at = ctx.data(|d| d.get_temp::(egui::Id::unique("trace-hold-source"))).unwrap().center(); - let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); - assert_eq!(app.ui.trace_hold_source.as_deref(), Some(&vec![trace_id]), "the eye button shows the source image"); - frame(&mut app, &ctx, vec![press(false)]); - assert_eq!(app.ui.trace_hold_source, None); - - let menu_frame = |app: &mut VectorcraftApp, events: Vec| { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| menu(app, ui)); - out.textures_delta.clear(); - crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) - }; - let texts = menu_frame(&mut app, vec![]); - let at = texts.iter().find(|(t, _)| t.trim() == "Release").map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no Release in {texts:?}")); - let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - menu_frame(&mut app, vec![Event::PointerMoved(at), press(true)]); - menu_frame(&mut app, vec![press(false)]); - let st = app.session.active().unwrap(); - assert!(st.doc.node(trace_id).is_none(), "Release puts the source image back"); - } - - /// With another object first in the selection, the panel's View menu and Expand act on the - /// selected Image Trace object. - #[test] - fn view_and_expand_work_when_another_object_is_selected_first() { - let mut app = traced_app(); - let trace_id = first_selected(&app).unwrap().id; - let made = app.run("shape.rectangle", json!({"x": 80, "y": 80, "width": 10, "height": 10})).unwrap(); - let rect = made["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [rect, trace_id.0]})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - click(&mut app, &ctx, "Tracing Result"); - click(&mut app, &ctx, "Outlines"); - let view = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(trace_id).unwrap().trace_view(); - assert_eq!(view(&app), TraceView::Outlines, "the View menu sets the Image Trace object's view"); - click(&mut app, &ctx, "Expand"); - let st = app.session.active().unwrap(); - assert!(!is_trace(st.doc.node(trace_id).unwrap()), "Expand keeps only the traced paths"); - } - - /// With another object first in the selection, Trace traces the selected image, the View - /// dropdown shows the selected Image Trace object's view, and Preview traces it again. - #[test] - fn trace_view_and_preview_work_when_another_object_is_selected_first() { - let mut app = traced_app(); - app.run("imageTrace.release", json!({})).unwrap(); - let image = first_selected(&app).unwrap().id; - let made = app.run("shape.rectangle", json!({"x": 80, "y": 80, "width": 10, "height": 10})).unwrap(); - let rect = made["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [rect, image.0]})).unwrap(); - assert_eq!(first_selected(&app).unwrap().id.0, rect, "the rectangle comes first"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - click(&mut app, &ctx, "Trace"); - let st = app.session.active().unwrap(); - let trace_id = st.selection.objects.iter().copied().find(|id| st.doc.node(*id).is_some_and(is_trace)); - let trace_id = trace_id.unwrap_or_else(|| panic!("Trace makes an Image Trace object: {}", app.ui.status)); - - // The panel last showed Tracing Result; the object is drawn as Outlines. - frame(&mut app, &ctx, vec![]); - app.run("imageTrace.setView", json!({"view": "outlines"})).unwrap(); - app.run("select.set", json!({"ids": [rect, trace_id.0]})).unwrap(); - let texts = frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(t, _)| t == "Outlines") && !texts.iter().any(|(t, _)| t == "Tracing Result"), "{texts:?}"); - - // Tracing again replaces the object, so look it up in the selection. - let strokes = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - let n = st.selection.objects.iter().find_map(|id| st.doc.node(*id).filter(|n| is_trace(n))); - n.and_then(|n| n.extras().trace.as_deref().cloned()).map(|t| t["params"]["strokes"].clone()) - }; - assert_eq!(strokes(&app), Some(json!(false))); - click(&mut app, &ctx, "Strokes"); - assert_eq!(strokes(&app), Some(json!(true)), "Preview traces the object again"); - } - - #[test] - fn properties_can_reopen_the_trace_controls() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut at = None; - for _ in 0..2 { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::properties::show(&mut app, ui)); - let texts = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); - // The first Image Trace text is the section heading, the last is the button. - at = texts.iter().rev().find(|(t, _)| t == "Image Trace").map(|(_, r)| r.center()); - out.textures_delta.clear(); - } - let at = at.unwrap(); - for pressed in [true, false] { - let events = vec![ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, - ]; - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::properties::show(&mut app, ui)); - out.textures_delta.clear(); - } - assert!(crate::panel_docking::showing(&app.ui, "imageTrace")); - } - - /// #1358: in Properties the View row fits the panel, so the panel doesn't widen and clip its - /// labels (the row took the panel's whole width for its menu, beside its label). - #[test] - fn the_view_row_fits_the_properties_panel() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - // Where the panel's content ends after a few frames. - let right = |app: &mut VectorcraftApp| { - let mut right = 0.0; - for _ in 0..3 { - let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { - crate::panels::properties::show(app, ui); - right = ui.min_rect().right(); - }); - out.textures_delta.clear(); - } - right - }; - let traced = right(&mut app); - app.run("select.none", json!({})).unwrap(); - assert_eq!(traced, right(&mut app)); - } - - #[test] - fn advanced_is_a_section_that_stays_as_it_was_left() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); - let texts = frame(&mut app, &ctx, vec![]); - assert!(app.ui.image_trace_advanced && has(&texts, "Advanced") && has(&texts, "Corners"), "open at first: {texts:?}"); - click(&mut app, &ctx, "Advanced"); - assert!(!app.ui.image_trace_advanced, "the header closes it"); - assert!(!has(&frame(&mut app, &ctx, vec![]), "Corners")); - // Remembered with the UI preferences, across launches. - let saved: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); - assert!(!saved.image_trace_advanced); - let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); - assert!(old.image_trace_advanced, "open for preferences saved before the section existed"); - click(&mut app, &ctx, "Advanced"); - assert!(app.ui.image_trace_advanced && has(&frame(&mut app, &ctx, vec![]), "Corners")); - } - - #[test] - fn the_view_dropdown_shows_and_sets_the_objects_view() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let texts = frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(t, _)| t == "View:") && texts.iter().any(|(t, _)| t == "Tracing Result"), "{texts:?}"); - let steps = app.session.active().unwrap().history.undo.len(); - click(&mut app, &ctx, "Tracing Result"); - click(&mut app, &ctx, "Source Image"); - assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Source)); - assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "one undo step, no new trace"); - // Another object's view is shown when it is selected. - app.run("imageTrace.setView", json!({"view": "outlines"})).unwrap(); - assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Outlines")); - } - - #[test] - fn the_palette_shows_in_color_mode_and_traces_again() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); - let palette = |app: &VectorcraftApp| target(app).2.map(|t| t["params"]["palette"].clone()); - assert!(!has(&frame(&mut app, &ctx, vec![]), "Palette:"), "Black and White has no palette"); - app.run("imageTrace.make", json!({"preset": "6 Colors"})).unwrap(); - let texts = frame(&mut app, &ctx, vec![]); - assert!(has(&texts, "Palette:") && has(&texts, "Limited") && !has(&texts, "Library:"), "{texts:?}"); - // Document Library traces again with the document's swatches, and names them. - click(&mut app, &ctx, "Limited"); - click(&mut app, &ctx, "Document Library"); - assert_eq!(palette(&app), Some(json!("documentLibrary"))); - let texts = frame(&mut app, &ctx, vec![]); - assert!(has(&texts, "Library:") && has(&texts, "Document Swatches"), "{texts:?}"); - // Full Tone's Colors is how many, Less (0%) … More (100%). - click(&mut app, &ctx, "Document Library"); - click(&mut app, &ctx, "Full Tone"); - assert_eq!(palette(&app), Some(json!("fullTone"))); - let texts = frame(&mut app, &ctx, vec![]); - assert!(has(&texts, "50") && !has(&texts, "Library:"), "{texts:?}"); - } - - #[test] - fn document_library_colors_lists_the_documents_color_groups() { - let mut app = traced_app(); - app.run("swatch.newGroup", json!({"name": "Brand", "colors": ["#000000", "#ffffff"]})).unwrap(); - app.run("imageTrace.make", json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); - let stored = |app: &VectorcraftApp, key: &str| target(app).2.map(|t| t["params"][key].clone()); - let texts = frame(&mut app, &ctx, vec![]); - assert!(has(&texts, "Colors:") && has(&texts, "All"), "{texts:?}"); - // The list holds All and the document's groups; a group traces again with its colors. - click(&mut app, &ctx, "All"); - assert!(has(&frame(&mut app, &ctx, vec![]), " Brand")); - let steps = app.session.active().unwrap().history.undo.len(); - click(&mut app, &ctx, " Brand"); - assert_eq!(stored(&app, "colorGroup"), Some(json!("Brand"))); - assert_eq!(target(&app).2.unwrap()["preset"], "Custom"); - assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1); - assert!(has(&frame(&mut app, &ctx, vec![]), "Brand")); - // All again. - click(&mut app, &ctx, "Brand"); - click(&mut app, &ctx, " All"); - assert_eq!(stored(&app, "colorGroup"), Some(Value::Null)); - // Another library clears the group, and the row goes. - click(&mut app, &ctx, "All"); - click(&mut app, &ctx, " Brand"); - let lib = swatches::colour_libraries(&app).into_iter().find(|l| l.submenu.is_none()).unwrap(); - click(&mut app, &ctx, "Document Swatches"); - click(&mut app, &ctx, &format!(" {}", library_panel::library_name(&lib.id, &lib.name))); - assert_eq!((stored(&app, "library"), stored(&app, "colorGroup")), (Some(json!(lib.id)), Some(Value::Null))); - assert!(!has(&frame(&mut app, &ctx, vec![]), "Colors:")); - // Another palette clears the group too, and traces again (the object stores the settings of - // a trace that was made). - click(&mut app, &ctx, library_panel::library_name(&lib.id, &lib.name)); - click(&mut app, &ctx, " Document Swatches"); - click(&mut app, &ctx, "All"); - click(&mut app, &ctx, " Brand"); - assert_eq!(stored(&app, "colorGroup"), Some(json!("Brand"))); - click(&mut app, &ctx, "Document Library"); - click(&mut app, &ctx, "Limited"); - assert_eq!((stored(&app, "palette"), stored(&app, "colorGroup")), (Some(json!("limited")), Some(Value::Null))); - // Another mode clears it too, and traces again. - click(&mut app, &ctx, "Limited"); - click(&mut app, &ctx, "Document Library"); - click(&mut app, &ctx, "All"); - click(&mut app, &ctx, " Brand"); - assert_eq!(stored(&app, "colorGroup"), Some(json!("Brand"))); - click(&mut app, &ctx, "Color"); - click(&mut app, &ctx, "Grayscale"); - assert_eq!((stored(&app, "mode"), stored(&app, "colorGroup")), (Some(json!("grayscale")), Some(Value::Null))); - } - - #[test] - fn a_color_group_the_active_document_lacks_is_all() { - let mut app = traced_app(); - app.run("swatch.newGroup", json!({"name": "Brand", "colors": ["#000000", "#ffffff"]})).unwrap(); - app.run("imageTrace.make", json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - click(&mut app, &ctx, "All"); - click(&mut app, &ctx, " Brand"); - assert_eq!(target(&app).2.map(|t| t["params"]["colorGroup"].clone()), Some(json!("Brand"))); - let shows_brand = |app: &mut VectorcraftApp| frame(app, &ctx, vec![]).iter().any(|(t, _)| t == "Brand"); - // Without the group the row shows All; undoing its removal shows it again. - app.run("swatch.ungroup", json!({"name": "Brand"})).unwrap(); - assert!(!shows_brand(&mut app)); - app.run("edit.undo", json!({})).unwrap(); - assert!(shows_brand(&mut app)); - // Another document without the group, with an image that is not traced yet. - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); - let mut png = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let data = vectorcraft_format::base64_encode(&png); - app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap(); - let texts = frame(&mut app, &ctx, vec![]); - let has = |s: &str| texts.iter().any(|(t, _)| t == s); - assert!(has("Colors:") && has("All") && !has("Brand"), "{texts:?}"); - // Back in the first document, its Image Trace object shows its group again. - app.session.set_active(0); - assert!(shows_brand(&mut app)); - app.session.set_active(1); - assert!(!shows_brand(&mut app)); - // Trace uses every document swatch. - click(&mut app, &ctx, "Trace"); - let stored = target(&app).2.unwrap_or_else(|| panic!("not traced: {}", app.ui.status)); - assert!(stored["params"]["colorGroup"].as_str().is_none_or(str::is_empty), "{stored}"); - assert_eq!(stored["params"]["palette"], "documentLibrary"); - } - - #[test] - fn create_strokes_traces_again_and_keeps_one_kind_on() { - let mut app = traced_app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let create = |app: &VectorcraftApp| target(app).2.map(|t| (t["params"]["fills"].clone(), t["params"]["strokes"].clone())); - let texts = frame(&mut app, &ctx, vec![]); - for s in ["Create:", "Fills", "Strokes", "Stroke", "10"] { - assert!(texts.iter().any(|(t, _)| t == s), "{s}: {texts:?}"); - } - // Fills alone can't be turned off. - click(&mut app, &ctx, "Fills"); - assert_eq!(create(&app), Some((json!(true), json!(false)))); - click(&mut app, &ctx, "Strokes"); - assert_eq!(create(&app), Some((json!(true), json!(true))), "traced again with strokes"); - click(&mut app, &ctx, "Fills"); - assert_eq!(create(&app), Some((json!(false), json!(true)))); - click(&mut app, &ctx, "Strokes"); - assert_eq!(create(&app), Some((json!(false), json!(true))), "Strokes alone stays on"); - } - - #[test] - fn panel_and_menu_draw_headless() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - for _ in 0..2 { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - show(&mut app, ui); - menu(&mut app, ui); - }); - out.textures_delta.clear(); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs new file mode 100644 index 000000000..d388c0e15 --- /dev/null +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -0,0 +1,795 @@ +use super::*; +use egui::{Event, PointerButton, Pos2, Rect, vec2}; + +/// One headless frame of the panel (and its menu) with `events`: the texts drawn, with where. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) +} + +/// Click the panel's `label` (the first one drawn). +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + let texts = frame(app, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at), press(true)]); + frame(app, ctx, vec![press(false)]); +} + +fn traced_app() -> VectorcraftApp { + traced_app_with_ink(0) +} + +/// A quick trace, then the Image Trace button. +fn trace_and_open(app: &mut VectorcraftApp, preset: &str) { + trace_with(app, preset); + open_settings(app); +} + +fn traced_app_with_ink(ink: u8) -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [ink, ink, ink, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap(); + app.run("imageTrace.make", json!({"preset": "Default"})).unwrap(); + app +} + +#[test] +fn a_large_image_traces_and_the_status_bar_says_it_is_slower() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let img = image::RgbaImage::from_fn(1601, 12, |x, _| image::Rgba(if x < 800 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "wide.png", "dataBase64": data, "link": false})).unwrap(); + trace_with(&mut app, "Default"); + assert!(target(&app).2.is_some(), "the image is traced"); + assert!(app.ui.status.contains("1601 × 12") && app.ui.status.contains("more slowly"), "{}", app.ui.status); +} + +#[test] +fn a_large_image_traced_as_a_flat_logo_shows_the_size_it_was_traced_at() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let img = image::RgbaImage::from_fn(1602, 1602, |x, _| image::Rgba(if x < 800 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "logo.png", "dataBase64": data, "link": false})).unwrap(); + trace_with(&mut app, "Flat Logo"); + assert!(target(&app).2.is_some(), "the image is traced: {}", app.ui.status); + assert_eq!(app.ui.status, "Flat Logo traced the image at 801 × 801 pixels, averaged down from 1602 × 1602."); +} + +#[test] +fn sketch_quick_trace_reveals_all_adjustments_and_keeps_the_source() { + let mut app = traced_app(); + app.run("imageTrace.release", json!({})).unwrap(); + app.ui.image_trace_advanced = false; + trace_and_open(&mut app, "Sketched Art"); + assert!(crate::panel_docking::showing(&app.ui, "imageTrace")); + assert!(app.ui.image_trace_advanced); + assert_eq!(selected_trace(&app).unwrap().0, "Sketched Art"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame(&mut app, &ctx, vec![]); + for label in ["Threshold", "Paths", "Corners", "Noise", "Method:", "Fills", "Strokes", "Ignore Color"] { + assert!(texts.iter().any(|(t, _)| t == label), "missing {label}: {texts:?}"); + } + let n = first_selected(&app).unwrap(); + assert!(is_trace(&n)); + assert!(matches!(n.children().unwrap()[0].kind, NodeKind::Image(_))); + // Opening controls again neither closes the flyout nor adds a document undo step. + let steps = app.session.active().unwrap().history.undo.len(); + open_settings(&mut app); + assert!(crate::panel_docking::showing(&app.ui, "imageTrace")); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps); + // Optional local visual QA; no reference images or generated captures are committed. + if let Some(path) = std::env::var_os("VECTORCRAFT_TRACE_CAPTURE") { + let mut ready = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(340.0, 820.0)).wgpu().build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !ready { + egui_extras::install_image_loaders(ui.ctx()); + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark); + ready = true; + return; + } + show(app, ui); + }, + app, + ); + harness.run_steps(4); + harness.render().unwrap().save(path).unwrap(); + } +} + +#[test] +fn a_quick_trace_leaves_the_panel_as_it_was() { + let mut app = traced_app(); + app.run("imageTrace.release", json!({})).unwrap(); + app.ui.open_panel = None; + app.ui.image_trace_advanced = false; + trace_with(&mut app, "Sketched Art"); + assert!(is_trace(&first_selected(&app).unwrap())); + assert!(app.ui.open_panel.is_none()); + assert!(!app.ui.image_trace_advanced); +} + +#[test] +fn quick_trace_activates_the_existing_floating_panel() { + let mut app = traced_app(); + app.run("window.panel.float", json!({"panel": "imageTrace"})).unwrap(); + let before = app.ui.docking.clone().unwrap(); + trace_and_open(&mut app, "Sketched Art"); + let after = app.ui.docking.as_ref().unwrap(); + assert_eq!(after.floating.len(), before.floating.len()); + assert!(after.floating.iter().any(|g| crate::panel_docking::window_panels(g) == ["imageTrace"])); + assert!(app.ui.open_panel.is_none(), "the existing floating group owns the panel"); +} + +#[test] +fn adjusting_sketch_threshold_changes_the_vector_result_and_can_be_undone() { + let mut app = traced_app_with_ink(100); + trace_and_open(&mut app, "Sketched Art"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", "Threshold")))).unwrap(); + let at = rail.left_center() + vec2(20.0, 0.0); + assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 2, "source and ink path"); + for pressed in [true, false] { + frame( + &mut app, + &ctx, + vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ); + } + assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 1, "only the source remains below the ink's luminance"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 2); + frame(&mut app, &ctx, vec![]); + let state: TraceUi = pstate(&ctx, "image-trace"); + assert_eq!(state.params["threshold"], 150); +} + +#[test] +fn with_preview_off_a_slider_change_waits_for_trace() { + let mut app = traced_app_with_ink(100); + trace_and_open(&mut app, "Sketched Art"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + assert!(app.ui.image_trace_preview, "on at first"); + click(&mut app, &ctx, "Preview"); + assert!(!app.ui.image_trace_preview); + let steps = app.session.active().unwrap().history.undo.len(); + let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", "Threshold")))).unwrap(); + let at = rail.left_center() + vec2(20.0, 0.0); + for pressed in [true, false] { + frame( + &mut app, + &ctx, + vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ); + } + assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "no trace while Preview is off"); + assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 2, "source and ink path, as before"); + let draft: TraceUi = pstate(&ctx, "image-trace"); + assert_ne!(draft.params["threshold"], target(&app).2.unwrap()["params"]["threshold"], "the change waits in the panel"); + click(&mut app, &ctx, "Trace"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "Trace applies it as one undo step"); + assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 1, "only the source remains below the ink's luminance"); + assert_eq!(target(&app).2.unwrap()["params"], pstate::(&ctx, "image-trace").params); + assert!(!app.ui.image_trace_preview, "Trace leaves Preview off"); +} + +#[test] +fn turning_preview_on_traces_the_changes_that_waited() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let strokes = |app: &VectorcraftApp| target(app).2.map(|t| t["params"]["strokes"].clone()); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let steps = undo(&app); + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Preview"); + assert!(app.ui.image_trace_preview); + assert_eq!(undo(&app), steps, "nothing waited, so nothing is traced"); + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Strokes"); + assert_eq!(strokes(&app), Some(json!(false))); + assert_eq!(undo(&app), steps); + click(&mut app, &ctx, "Preview"); + assert_eq!(strokes(&app), Some(json!(true)), "traced again with strokes"); + assert_eq!(undo(&app), steps + 1); + // A change of view keeps the settings that wait, and Preview turned on with `ui.set` traces them. + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Fills"); + assert_eq!(target(&app).2.unwrap()["params"]["fills"], json!(true)); + click(&mut app, &ctx, "Tracing Result"); + click(&mut app, &ctx, "Outlines"); + assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Outlines)); + assert_eq!(undo(&app), steps + 2, "the view changes without a trace"); + frame(&mut app, &ctx, vec![]); + assert_eq!(pstate::(&ctx, "image-trace").params["fills"], json!(false), "the change still waits"); + app.ui.image_trace_preview = true; + frame(&mut app, &ctx, vec![]); + assert_eq!(target(&app).2.unwrap()["params"]["fills"], json!(false), "traced with the waiting change"); + assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Outlines), "in the view chosen"); + assert_eq!(undo(&app), steps + 3); +} + +#[test] +fn preview_turned_on_with_nothing_selected_traces_when_the_object_is_selected_again() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let a = first_selected(&app).unwrap().id; + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Strokes"); + assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "the change waits"); + app.run("select.none", json!({})).unwrap(); + let steps = undo(&app); + click(&mut app, &ctx, "Preview"); + assert!(app.ui.image_trace_preview); + assert_eq!(undo(&app), steps, "nothing to trace with nothing selected"); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(true), "traced with the change that waited"); + assert_eq!(undo(&app), steps + 1); +} + +#[test] +fn settings_that_wait_stay_with_their_object() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let a = first_selected(&app).unwrap().id; + app.run("edit.duplicate", json!({})).unwrap(); + let b = first_selected(&app).unwrap().id; + assert_ne!(a, b); + assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "the copy has the same settings"); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Strokes"); + assert_eq!(pstate::(&ctx, "image-trace").params["strokes"], json!(true), "the change waits for A"); + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(pstate::(&ctx, "image-trace").params["strokes"], json!(false), "the panel shows B's own settings"); + click(&mut app, &ctx, "Trace"); + assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "B is traced with its own settings"); +} + +#[test] +fn settings_that_wait_come_back_when_their_object_is_selected_again() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let a = first_selected(&app).unwrap().id; + app.run("edit.duplicate", json!({})).unwrap(); + let b = first_selected(&app).unwrap().id; + let shown = |ctx: &egui::Context| pstate::(ctx, "image-trace").params["strokes"].clone(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Strokes"); + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(shown(&ctx), json!(false), "B's own settings"); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(shown(&ctx), json!(true), "A's change still waits"); + let steps = undo(&app); + click(&mut app, &ctx, "Trace"); + assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(true), "A is traced with its change"); + assert_eq!(undo(&app), steps + 1); + // Preview turned on while B is selected: A is traced with its change when it is selected again. + let a = first_selected(&app).unwrap().id; + click(&mut app, &ctx, "Strokes"); + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, "Preview"); + assert!(app.ui.image_trace_preview); + let steps = undo(&app); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "traced with the change that waited"); + assert_eq!(undo(&app), steps + 1); + frame(&mut app, &ctx, vec![]); + assert_eq!(undo(&app), steps + 1, "traced once"); +} + +#[test] +fn settings_that_wait_are_kept_when_a_plain_image_is_traced_in_between() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let a = first_selected(&app).unwrap().id; + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Strokes"); + let img = image::RgbaImage::from_fn(20, 20, |_, y| image::Rgba(if y < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "plain.png", "dataBase64": data, "link": false})).unwrap(); + assert!(target(&app).1, "a plain image is selected"); + frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, "Strokes"); + click(&mut app, &ctx, "Trace"); + assert_eq!(target(&app).2.unwrap()["params"]["strokes"], json!(false), "the image is traced with the panel's settings"); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(pstate::(&ctx, "image-trace").params["strokes"], json!(true), "A's change still waits"); +} + +#[test] +fn settings_that_wait_are_dropped_when_their_object_changed_since() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let a = first_selected(&app).unwrap().id; + app.run("edit.duplicate", json!({})).unwrap(); + let b = first_selected(&app).unwrap().id; + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Strokes"); + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + // A's settings as they were when the change was made, other than its settings now. + let mut st: TraceUi = pstate(&ctx, "image-trace"); + let key = (app.session.active().unwrap().uid, a); + let draft = st.drafts.get_mut(&key).expect("the change waits for A"); + assert_eq!(draft.1["strokes"], json!(true)); + draft.2["params"]["threshold"] = json!(1); + set_pstate(&ctx, "image-trace", st); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + let shown = pstate::(&ctx, "image-trace"); + assert_eq!(shown.params, target(&app).2.unwrap()["params"], "the panel shows A's settings as they are now"); + assert!(shown.drafts.is_empty(), "the change is dropped"); +} + +#[test] +fn preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click(&mut app, &ctx, "Preview"); + let menu_frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| menu(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) + }; + let texts = menu_frame(&mut app, vec![]); + // Menu items draw their label after the check mark's column. + let at = texts.iter().find(|(t, _)| t.trim() == "Reset to Default").map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{texts:?}")); + for pressed in [true, false] { + menu_frame( + &mut app, + vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ); + } + assert!(pstate::(&ctx, "image-trace").params.is_null(), "the panel's settings were reset"); + assert!(!app.ui.image_trace_preview, "Reset to Default leaves Preview"); + let saved: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert!(!saved.image_trace_preview); + let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); + assert!(old.image_trace_preview, "on for preferences saved before Preview existed"); +} + +#[test] +fn sketch_adjustment_sliders_retrace_on_release() { + let mut app = traced_app(); + trace_and_open(&mut app, "Sketched Art"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (label, key) in [("Threshold", "threshold"), ("Paths", "paths"), ("Corners", "corners"), ("Noise", "noise")] { + frame(&mut app, &ctx, vec![]); + let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", label)))).unwrap(); + let at = rail.left_center() + vec2(20.0, 0.0); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let before = target(&app).2.unwrap()["params"][key].clone(); + let preset_before = pstate::(&ctx, "image-trace").preset; + let steps = app.session.active().unwrap().history.undo.len(); + frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "{label} waits for release"); + let draft: TraceUi = pstate(&ctx, "image-trace"); + assert_ne!(draft.params[key], before, "{label} updates while held"); + assert_eq!(draft.preset, preset_before, "the preset row stays in place during {label}"); + frame(&mut app, &ctx, vec![press(false)]); + let stored = target(&app).2.unwrap(); + assert_ne!( + stored["params"][key], + before, + "{label} reaches the tracer; rail {rail:?}, draft {:?}", + pstate::(&ctx, "image-trace").params + ); + assert_eq!(stored["preset"], "Custom"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "one undo step for {label}"); + frame(&mut app, &ctx, vec![]); + let state: TraceUi = pstate(&ctx, "image-trace"); + assert_eq!(state.params, stored["params"], "{label} survives the next frame"); + } +} + +/// Holding the eye button next to View down lists the selected Image Trace object for the +/// canvas to draw with its source image; releasing it shows the object's View again. The +/// document, its View and the undo history stay as they were. With a plain image selected the +/// button is disabled. +#[test] +fn holding_the_eye_button_shows_the_source_image_until_released() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + let id = first_selected(&app).unwrap().id; + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let revision = |app: &VectorcraftApp| app.session.active().unwrap().revision; + let (steps, rev) = (undo(&app), revision(&app)); + let at = ctx.data(|d| d.get_temp::(egui::Id::unique("trace-hold-source"))).unwrap().center(); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); + assert_eq!(app.ui.trace_hold_source.as_deref(), Some(&vec![id]), "held: the object shows its source image"); + frame(&mut app, &ctx, vec![]); + assert!(app.ui.trace_hold_source.is_some(), "still held on the next frame"); + frame(&mut app, &ctx, vec![press(false)]); + assert_eq!(app.ui.trace_hold_source, None, "released: the object shows its View again"); + assert_eq!(undo(&app), steps, "no undo step"); + assert_eq!(revision(&app), rev, "the document is unchanged"); + assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Result), "its View is unchanged"); + app.run("imageTrace.release", json!({})).unwrap(); + assert!(matches!(first_selected(&app).unwrap().kind, NodeKind::Image(_))); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); + assert_eq!(app.ui.trace_hold_source, None, "a plain image has no source image to show"); + frame(&mut app, &ctx, vec![press(false)]); +} + +/// With another object first in the selection, the eye button and the panel menu's Release +/// act on the selected Image Trace object. +#[test] +fn the_trace_controls_work_when_another_object_is_selected_first() { + let mut app = traced_app(); + let trace_id = first_selected(&app).unwrap().id; + let made = app.run("shape.rectangle", json!({"x": 80, "y": 80, "width": 10, "height": 10})).unwrap(); + let rect = made["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [rect, trace_id.0]})).unwrap(); + assert_eq!(first_selected(&app).unwrap().id.0, rect, "the rectangle comes first"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + let at = ctx.data(|d| d.get_temp::(egui::Id::unique("trace-hold-source"))).unwrap().center(); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); + assert_eq!(app.ui.trace_hold_source.as_deref(), Some(&vec![trace_id]), "the eye button shows the source image"); + frame(&mut app, &ctx, vec![press(false)]); + assert_eq!(app.ui.trace_hold_source, None); + + let menu_frame = |app: &mut VectorcraftApp, events: Vec| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| menu(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) + }; + let texts = menu_frame(&mut app, vec![]); + let at = texts.iter().find(|(t, _)| t.trim() == "Release").map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no Release in {texts:?}")); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + menu_frame(&mut app, vec![Event::PointerMoved(at), press(true)]); + menu_frame(&mut app, vec![press(false)]); + let st = app.session.active().unwrap(); + assert!(st.doc.node(trace_id).is_none(), "Release puts the source image back"); +} + +/// With another object first in the selection, the panel's View menu and Expand act on the +/// selected Image Trace object. +#[test] +fn view_and_expand_work_when_another_object_is_selected_first() { + let mut app = traced_app(); + let trace_id = first_selected(&app).unwrap().id; + let made = app.run("shape.rectangle", json!({"x": 80, "y": 80, "width": 10, "height": 10})).unwrap(); + let rect = made["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [rect, trace_id.0]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click(&mut app, &ctx, "Tracing Result"); + click(&mut app, &ctx, "Outlines"); + let view = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(trace_id).unwrap().trace_view(); + assert_eq!(view(&app), TraceView::Outlines, "the View menu sets the Image Trace object's view"); + click(&mut app, &ctx, "Expand"); + let st = app.session.active().unwrap(); + assert!(!is_trace(st.doc.node(trace_id).unwrap()), "Expand keeps only the traced paths"); +} + +/// With another object first in the selection, Trace traces the selected image, the View +/// dropdown shows the selected Image Trace object's view, and Preview traces it again. +#[test] +fn trace_view_and_preview_work_when_another_object_is_selected_first() { + let mut app = traced_app(); + app.run("imageTrace.release", json!({})).unwrap(); + let image = first_selected(&app).unwrap().id; + let made = app.run("shape.rectangle", json!({"x": 80, "y": 80, "width": 10, "height": 10})).unwrap(); + let rect = made["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [rect, image.0]})).unwrap(); + assert_eq!(first_selected(&app).unwrap().id.0, rect, "the rectangle comes first"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click(&mut app, &ctx, "Trace"); + let st = app.session.active().unwrap(); + let trace_id = st.selection.objects.iter().copied().find(|id| st.doc.node(*id).is_some_and(is_trace)); + let trace_id = trace_id.unwrap_or_else(|| panic!("Trace makes an Image Trace object: {}", app.ui.status)); + + // The panel last showed Tracing Result; the object is drawn as Outlines. + frame(&mut app, &ctx, vec![]); + app.run("imageTrace.setView", json!({"view": "outlines"})).unwrap(); + app.run("select.set", json!({"ids": [rect, trace_id.0]})).unwrap(); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "Outlines") && !texts.iter().any(|(t, _)| t == "Tracing Result"), "{texts:?}"); + + // Tracing again replaces the object, so look it up in the selection. + let strokes = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + let n = st.selection.objects.iter().find_map(|id| st.doc.node(*id).filter(|n| is_trace(n))); + n.and_then(|n| n.extras().trace.as_deref().cloned()).map(|t| t["params"]["strokes"].clone()) + }; + assert_eq!(strokes(&app), Some(json!(false))); + click(&mut app, &ctx, "Strokes"); + assert_eq!(strokes(&app), Some(json!(true)), "Preview traces the object again"); +} + +#[test] +fn properties_can_reopen_the_trace_controls() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut at = None; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::properties::show(&mut app, ui)); + let texts = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + // The first Image Trace text is the section heading, the last is the button. + at = texts.iter().rev().find(|(t, _)| t == "Image Trace").map(|(_, r)| r.center()); + out.textures_delta.clear(); + } + let at = at.unwrap(); + for pressed in [true, false] { + let events = vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ]; + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::properties::show(&mut app, ui)); + out.textures_delta.clear(); + } + assert!(crate::panel_docking::showing(&app.ui, "imageTrace")); +} + +/// #1358: in Properties the View row fits the panel, so the panel doesn't widen and clip its +/// labels (the row took the panel's whole width for its menu, beside its label). +#[test] +fn the_view_row_fits_the_properties_panel() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Where the panel's content ends after a few frames. + let right = |app: &mut VectorcraftApp| { + let mut right = 0.0; + for _ in 0..3 { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + crate::panels::properties::show(app, ui); + right = ui.min_rect().right(); + }); + out.textures_delta.clear(); + } + right + }; + let traced = right(&mut app); + app.run("select.none", json!({})).unwrap(); + assert_eq!(traced, right(&mut app)); +} + +#[test] +fn advanced_is_a_section_that_stays_as_it_was_left() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + let texts = frame(&mut app, &ctx, vec![]); + assert!(app.ui.image_trace_advanced && has(&texts, "Advanced") && has(&texts, "Corners"), "open at first: {texts:?}"); + click(&mut app, &ctx, "Advanced"); + assert!(!app.ui.image_trace_advanced, "the header closes it"); + assert!(!has(&frame(&mut app, &ctx, vec![]), "Corners")); + // Remembered with the UI preferences, across launches. + let saved: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert!(!saved.image_trace_advanced); + let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); + assert!(old.image_trace_advanced, "open for preferences saved before the section existed"); + click(&mut app, &ctx, "Advanced"); + assert!(app.ui.image_trace_advanced && has(&frame(&mut app, &ctx, vec![]), "Corners")); +} + +#[test] +fn the_view_dropdown_shows_and_sets_the_objects_view() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "View:") && texts.iter().any(|(t, _)| t == "Tracing Result"), "{texts:?}"); + let steps = app.session.active().unwrap().history.undo.len(); + click(&mut app, &ctx, "Tracing Result"); + click(&mut app, &ctx, "Source Image"); + assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Source)); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "one undo step, no new trace"); + // Another object's view is shown when it is selected. + app.run("imageTrace.setView", json!({"view": "outlines"})).unwrap(); + assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Outlines")); +} + +#[test] +fn the_palette_shows_in_color_mode_and_traces_again() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + let palette = |app: &VectorcraftApp| target(app).2.map(|t| t["params"]["palette"].clone()); + assert!(!has(&frame(&mut app, &ctx, vec![]), "Palette:"), "Black and White has no palette"); + app.run("imageTrace.make", json!({"preset": "6 Colors"})).unwrap(); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Palette:") && has(&texts, "Limited") && !has(&texts, "Library:"), "{texts:?}"); + // Document Library traces again with the document's swatches, and names them. + click(&mut app, &ctx, "Limited"); + click(&mut app, &ctx, "Document Library"); + assert_eq!(palette(&app), Some(json!("documentLibrary"))); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Library:") && has(&texts, "Document Swatches"), "{texts:?}"); + // Full Tone's Colors is how many, Less (0%) … More (100%). + click(&mut app, &ctx, "Document Library"); + click(&mut app, &ctx, "Full Tone"); + assert_eq!(palette(&app), Some(json!("fullTone"))); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "50") && !has(&texts, "Library:"), "{texts:?}"); +} + +#[test] +fn document_library_colors_lists_the_documents_color_groups() { + let mut app = traced_app(); + app.run("swatch.newGroup", json!({"name": "Brand", "colors": ["#000000", "#ffffff"]})).unwrap(); + app.run("imageTrace.make", json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + let stored = |app: &VectorcraftApp, key: &str| target(app).2.map(|t| t["params"][key].clone()); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Colors:") && has(&texts, "All"), "{texts:?}"); + // The list holds All and the document's groups; a group traces again with its colors. + click(&mut app, &ctx, "All"); + assert!(has(&frame(&mut app, &ctx, vec![]), " Brand")); + let steps = app.session.active().unwrap().history.undo.len(); + click(&mut app, &ctx, " Brand"); + assert_eq!(stored(&app, "colorGroup"), Some(json!("Brand"))); + assert_eq!(target(&app).2.unwrap()["preset"], "Custom"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1); + assert!(has(&frame(&mut app, &ctx, vec![]), "Brand")); + // All again. + click(&mut app, &ctx, "Brand"); + click(&mut app, &ctx, " All"); + assert_eq!(stored(&app, "colorGroup"), Some(Value::Null)); + // Another library clears the group, and the row goes. + click(&mut app, &ctx, "All"); + click(&mut app, &ctx, " Brand"); + let lib = swatches::colour_libraries(&app).into_iter().find(|l| l.submenu.is_none()).unwrap(); + click(&mut app, &ctx, "Document Swatches"); + click(&mut app, &ctx, &format!(" {}", library_panel::library_name(&lib.id, &lib.name))); + assert_eq!((stored(&app, "library"), stored(&app, "colorGroup")), (Some(json!(lib.id)), Some(Value::Null))); + assert!(!has(&frame(&mut app, &ctx, vec![]), "Colors:")); + // Another palette clears the group too, and traces again (the object stores the settings of + // a trace that was made). + click(&mut app, &ctx, library_panel::library_name(&lib.id, &lib.name)); + click(&mut app, &ctx, " Document Swatches"); + click(&mut app, &ctx, "All"); + click(&mut app, &ctx, " Brand"); + assert_eq!(stored(&app, "colorGroup"), Some(json!("Brand"))); + click(&mut app, &ctx, "Document Library"); + click(&mut app, &ctx, "Limited"); + assert_eq!((stored(&app, "palette"), stored(&app, "colorGroup")), (Some(json!("limited")), Some(Value::Null))); + // Another mode clears it too, and traces again. + click(&mut app, &ctx, "Limited"); + click(&mut app, &ctx, "Document Library"); + click(&mut app, &ctx, "All"); + click(&mut app, &ctx, " Brand"); + assert_eq!(stored(&app, "colorGroup"), Some(json!("Brand"))); + click(&mut app, &ctx, "Color"); + click(&mut app, &ctx, "Grayscale"); + assert_eq!((stored(&app, "mode"), stored(&app, "colorGroup")), (Some(json!("grayscale")), Some(Value::Null))); +} + +#[test] +fn a_color_group_the_active_document_lacks_is_all() { + let mut app = traced_app(); + app.run("swatch.newGroup", json!({"name": "Brand", "colors": ["#000000", "#ffffff"]})).unwrap(); + app.run("imageTrace.make", json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click(&mut app, &ctx, "All"); + click(&mut app, &ctx, " Brand"); + assert_eq!(target(&app).2.map(|t| t["params"]["colorGroup"].clone()), Some(json!("Brand"))); + let shows_brand = |app: &mut VectorcraftApp| frame(app, &ctx, vec![]).iter().any(|(t, _)| t == "Brand"); + // Without the group the row shows All; undoing its removal shows it again. + app.run("swatch.ungroup", json!({"name": "Brand"})).unwrap(); + assert!(!shows_brand(&mut app)); + app.run("edit.undo", json!({})).unwrap(); + assert!(shows_brand(&mut app)); + // Another document without the group, with an image that is not traced yet. + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap(); + let texts = frame(&mut app, &ctx, vec![]); + let has = |s: &str| texts.iter().any(|(t, _)| t == s); + assert!(has("Colors:") && has("All") && !has("Brand"), "{texts:?}"); + // Back in the first document, its Image Trace object shows its group again. + app.session.set_active(0); + assert!(shows_brand(&mut app)); + app.session.set_active(1); + assert!(!shows_brand(&mut app)); + // Trace uses every document swatch. + click(&mut app, &ctx, "Trace"); + let stored = target(&app).2.unwrap_or_else(|| panic!("not traced: {}", app.ui.status)); + assert!(stored["params"]["colorGroup"].as_str().is_none_or(str::is_empty), "{stored}"); + assert_eq!(stored["params"]["palette"], "documentLibrary"); +} + +#[test] +fn create_strokes_traces_again_and_keeps_one_kind_on() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let create = |app: &VectorcraftApp| target(app).2.map(|t| (t["params"]["fills"].clone(), t["params"]["strokes"].clone())); + let texts = frame(&mut app, &ctx, vec![]); + for s in ["Create:", "Fills", "Strokes", "Stroke", "10"] { + assert!(texts.iter().any(|(t, _)| t == s), "{s}: {texts:?}"); + } + // Fills alone can't be turned off. + click(&mut app, &ctx, "Fills"); + assert_eq!(create(&app), Some((json!(true), json!(false)))); + click(&mut app, &ctx, "Strokes"); + assert_eq!(create(&app), Some((json!(true), json!(true))), "traced again with strokes"); + click(&mut app, &ctx, "Fills"); + assert_eq!(create(&app), Some((json!(false), json!(true)))); + click(&mut app, &ctx, "Strokes"); + assert_eq!(create(&app), Some((json!(false), json!(true))), "Strokes alone stays on"); +} + +#[test] +fn panel_and_menu_draw_headless() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + for _ in 0..2 { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui); + menu(&mut app, ui); + }); + out.textures_delta.clear(); + } +} From bef332f5b25f954f8d8e57f7674c7074d7d1d99a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:44 +0000 Subject: [PATCH 032/199] refactor(vectorcraft): move gpu.rs tests to gpu/tests.rs --- apps/vectorcraft/src/gpu.rs | 751 +----------------------------- apps/vectorcraft/src/gpu/tests.rs | 748 +++++++++++++++++++++++++++++ 2 files changed, 749 insertions(+), 750 deletions(-) create mode 100644 apps/vectorcraft/src/gpu/tests.rs diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 93ca92068..f683f03b4 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -878,753 +878,4 @@ fn restart(skip: &str) -> std::io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { - Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } - } - - /// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that - /// takes 16384 px textures; an adapter with less than eframe asks for changes nothing. - #[test] - fn the_surface_may_be_as_large_as_the_adapter_allows() { - assert_eq!(surface_limit(8192, 16384), 16384); - assert_eq!(surface_limit(8192, 4096), 8192); - } - - fn names(c: &[Candidate], order: &[usize]) -> Vec { - order.iter().map(|&i| c[i].name.clone()).collect() - } - - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - const WARP: (u32, u32) = (0x1414, 0x008c); - - fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { - Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } - } - - fn monitor(pci: (u32, u32)) -> DisplayGpu { - DisplayGpu { pci, primary: false } - } - - fn primary(pci: (u32, u32)) -> DisplayGpu { - DisplayGpu { pci, primary: true } - } - - /// The desktop of pdfcraft#378 as wgpu lists it on Windows: a Ryzen's integrated GPU without a - /// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and - /// OpenGL (which reports no device id). - fn ryzen_desktop() -> Vec { - vec![ - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), - pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), - ] - } - - /// pdfcraft#378: power saving picked the integrated GPU, whose driver reset when Windows had to - /// present its frames on the monitors of the NVIDIA card. The GPU that drives the displays now - /// comes first, through DX12 (#545); the integrated GPU stays the fallback a restart reaches. - #[test] - fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { - let c = ryzen_desktop(); - for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - } - // Without the display information the order is power saving's, as before. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]), [3, 1, 2, 0, 5, 4]); - // After the NVIDIA adapters failed, the integrated GPU is next. - let skip = vec![c[2].key.clone(), c[0].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &skip), [3, 1, 5, 4]); - } - - /// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the - /// discrete one. Power saving and the display agree, in either enumeration order. - #[test] - fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { - let c = ryzen_desktop(); - for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); - } - // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the - // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one - // comes after both. - let linux = vec![ - pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), - pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), - ]; - let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; - assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, None, &[]), [0, 1, 2, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, None, &[]), [2, 0, 1, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, None, &[]), [0, 1, 2, 3]); - } - - /// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with - /// `AQ_DRM_DEVICES` naming it first), as Vulkan lists it there: the integrated GPU drives the - /// built-in panel, yet the compositor shows nothing it draws, and reports no error. The GPU the - /// compositor draws with comes first; without it, the panel's, as before. - #[test] - fn the_gpu_the_compositor_draws_with_comes_before_the_panels() { - const RTX_3050: (u32, u32) = (0x10de, 0x25ac); - const INTEL_RPL: (u32, u32) = (0x8086, 0xa7a8); - let mut c = vec![ - pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU", Backend::Vulkan, DeviceType::DiscreteGpu, RTX_3050), - pci_gpu("Intel(R) Graphics (RPL-P)", Backend::Vulkan, DeviceType::IntegratedGpu, INTEL_RPL), - pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU/PCIe/SSE2", Backend::Gl, DeviceType::Other, (RTX_3050.0, 0)), - ]; - c[2].presents = false; - let panel = [primary(INTEL_RPL)]; - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[]), [0, 1]); - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, None, None, &[]), [1, 0]); - // The usual hybrid laptop, with the compositor on the integrated GPU: the panel's GPU. - assert_eq!(adapter_order(&c, PowerPreference::None, &[primary(INTEL_RPL), monitor(RTX_3050)], Some(INTEL_RPL), None, &[]), [1, 0]); - // `WGPU_ADAPTER_NAME` still comes first, and a restart after a failure still moves on. - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), Some("intel"), &[]), [1, 0]); - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[c[0].key.clone()]), [1]); - // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. - assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some((0x1af4, 0x1050)), None, &[]), [1, 0]); - } - - /// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so - /// its GPU draws, whichever kind it is. - #[test] - fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { - let c = ryzen_desktop(); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, None, &[]), [2, 0, 3, 1, 5, 4]); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, None, &[]), [3, 1, 2, 0, 5, 4]); - // The displays only matter among adapters that can show the window; a GPU driving no - // display still loses to one that does. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, None, &[]), [3, 1, 2, 0, 5, 4]); - } - - /// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, - /// Metal's zeros) change nothing: power saving's order, the old behaviour. - #[test] - fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { - let c = ryzen_desktop(); - let low = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); - for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), low, "{displays:?}"); - } - assert_eq!( - names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, None, &[]))[0], - "NVIDIA GeForce RTX 3090" - ); - // The #502 machine's adapters report no ids in these tests: the primary display never matches. - let c = issue_502(); - assert_eq!( - adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, None, &[]), - adapter_order(&c, PowerPreference::None, &[], None, None, &[]) - ); - assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &[]).is_empty()); - } - - /// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. - #[test] - fn a_named_adapter_comes_before_the_one_driving_the_display() { - let mut c = ryzen_desktop(); - let displays = [primary(NVIDIA_3090)]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); - c[2].presents = false; - c[0].presents = false; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 5, 4]); - } - - /// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic - /// looks at the displays. - #[test] - fn only_automatic_considers_which_gpu_drives_the_display() { - assert!(automatic(None, None)); - assert!(automatic(Some("automatic"), None)); - assert!(automatic(Some("powerSaving"), None)); - assert!(!automatic(Some("lowPower"), None)); - assert!(!automatic(Some("highPerformance"), None)); - assert!(!automatic(None, Some(PowerPreference::None))); - assert!(!automatic(Some("automatic"), Some(PowerPreference::LowPower))); - assert!(preferred_displays(Some("lowPower"), None).is_empty()); - assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty()); - // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). - assert_eq!(preferred_displays(None, None), display_gpus()); - assert_eq!(preferred_compositor(Some("lowPower"), None), None); - assert_eq!(preferred_compositor(None, Some(PowerPreference::HighPerformance)), None); - assert_eq!(preferred_compositor(None, None), crate::compositor_gpu::compositor_gpu()); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(pci_ids("VEN_&DEV_"), None); - assert_eq!(pci_ids(""), None); - } - - /// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary - /// display, and none is listed twice. (A headless CI runner may list none.) - #[cfg(windows)] - #[test] - fn windows_lists_each_gpu_with_a_monitor_once() { - let gpus = windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert_ne!(g.pci.0, 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!is_internal_panel(name), "{name}"); - } - } - - /// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port - /// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without - /// PCI ids. Each GPU is listed once, the one with the panel as primary. - #[test] - fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card0", AMD_IGPU)?; - card("card1", NVIDIA_3090)?; - std::fs::create_dir_all(dir.join("card2"))?; - connector("card0-eDP-1", "connected")?; - connector("card0-HDMI-A-1", "disconnected")?; - connector("card1-DP-1", "connected")?; - connector("card1-DP-2", "connected")?; - connector("card2-Virtual-1", "connected")?; - std::fs::write(dir.join("renderD128"), "")?; - let mut gpus = linux_display_gpus(&dir); - gpus.sort_by_key(|g| g.pci); - assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); - assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); - std::fs::remove_dir_all(&dir) - } - - #[test] - fn display_gpus_are_logged_as_pci_ids() { - assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); - assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); - let mut gpus = vec![]; - add_display_gpu(&mut gpus, NVIDIA_3090, false); - add_display_gpu(&mut gpus, NVIDIA_3090, true); - add_display_gpu(&mut gpus, AMD_IGPU, false); - assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); - } - - /// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's - /// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software - /// renderer, and OpenGL. - fn issue_502() -> Vec { - vec![ - gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), - gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), - gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), - ] - } - - #[test] - fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { - let c = issue_502(); - let order = adapter_order(&c, power_preference(None, None), &[], None, None, &[]); - let first = &c[order[0]].name; - if cfg!(any(windows, target_os = "macos")) { - assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); - } else { - // #502: the integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. - assert_eq!(first, "NVIDIA GeForce RTX 5070"); - } - } - - #[test] - fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { - let c = issue_502(); - let order = |power| names(&c, &adapter_order(&c, power, &[], None, None, &[])); - assert_eq!( - order(PowerPreference::None), - [ - "NVIDIA GeForce RTX 5070", - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - assert_eq!( - order(PowerPreference::LowPower), - [ - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - assert_eq!( - order(PowerPreference::HighPerformance), - [ - "NVIDIA GeForce RTX 5070", - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - } - - /// A restart leaves out the adapter the window failed on, so Power Saving on the machine of - /// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try. - /// A dual-GPU Mac reports both GPUs under Metal with no PCI ids (#651): their keys still - /// differ, so when the integrated GPU fails, the restart renders on the discrete one. - #[test] - fn gpus_without_pci_ids_are_told_apart_by_name() { - let mac = |name: &str, device_type| Candidate { - key: key(Backend::Metal, 0, 0, name), - name: name.into(), - backend: Backend::Metal, - device_type, - pci: (0, 0), - presents: true, - }; - let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; - assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); - let first = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); - assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); - let skip = parse_skip(&c[first[0]].key); - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip)), ["NVIDIA GeForce GT 750M"]); - // A comma in a name can't split the list. - assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); - } - - #[test] - fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() { - let s = Duration::from_secs; - // The second frame began: the first was presented. - assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown); - // Still drawing (or presenting) the first frame: waited for, then given up on (#964). - for frames in [0, 1] { - assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait); - assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung); - } - // A window minimized at the start may not draw again until restored. - assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown); - // A hung adapter is left out once, like one that failed. - let skip = vec!["Dx12:10de:28a0".to_string()]; - assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); - assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); - } - - #[test] - fn a_restart_tries_the_next_adapter() { - let c = issue_502(); - let mut skip = vec![]; - let mut tried = vec![]; - while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip).first() { - tried.push(c[i].name.clone()); - skip.push(c[i].key.clone()); - } - assert_eq!( - tried, - [ - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - } - - #[test] - fn adapters_that_cannot_present_to_the_window_are_never_tried() { - let mut c = issue_502(); - c[0].presents = false; - c[3].presents = false; - for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { - let order = adapter_order(&c, power, &[], None, None, &[]); - assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); - assert_eq!(order.len(), 2); - } - for p in &mut c { - p.presents = false; - } - assert!(adapter_order(&c, PowerPreference::None, &[], None, None, &[]).is_empty()); - assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, None, &[]).is_empty()); - } - - #[test] - fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { - let c = issue_502(); - let order = adapter_order(&c, PowerPreference::HighPerformance, &[], None, Some("radv"), &[]); - assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); - assert_eq!(order.len(), 4); - // Any case; every match comes before the rest, natively first. - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, Some("GeForce"), &[]); - assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); - // An empty name or one nothing matches changes nothing. - for named in ["", "no such gpu"] { - assert_eq!( - adapter_order(&c, PowerPreference::LowPower, &[], None, Some(named), &[]), - adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]) - ); - } - // A named adapter that failed is left out like any other. - let skip = vec![c[1].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, Some("radv"), &skip)[0], 0); - } - - /// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the - /// integrated GPU first, as wgpu's own choice did (#306), each GPU through DX12 first (#545). - #[test] - fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { - let c = vec![ - gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), - gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), - gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), - gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), - ]; - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); - assert_eq!(order, [3, 1, 2, 0, 4]); - assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, None, &[]), [2, 0, 3, 1, 4]); - } - - /// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black. - /// DX12 comes first whatever the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` - /// or a restart leaving DX12 out still reach it. - #[test] - fn a_windows_gpu_renders_through_dx12_before_vulkan() { - let c = vec![ - gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), - gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), - gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), - ]; - for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { - assert_eq!(adapter_order(&c, power, &[], None, None, &[]), [1, 0, 3, 2], "{power:?}"); - } - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); - } - - #[test] - fn windows_never_loads_vulkan_drivers_unless_asked() { - let all = Backends::all(); - let start = backends(all, false); - if cfg!(windows) { - assert_eq!(start, Backends::DX12 | Backends::GL, "#806"); - } else { - assert_eq!(start, all); - } - assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it"); - } - - #[test] - fn power_preference_follows_the_environment_then_the_preference() { - assert_eq!(power_preference(None, None), AUTOMATIC); - assert_eq!(power_preference(Some("automatic"), None), AUTOMATIC); - assert_eq!(power_preference(Some("lowPower"), None), PowerPreference::LowPower); - assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance); - // 0.5.0's default, and values this version doesn't know (a newer or damaged file). - for old in ["powerSaving", "turbo", ""] { - assert_eq!(power_preference(Some(old), None), AUTOMATIC, "{old}"); - } - // WGPU_POWER_PREF wins over the preference, either way. - assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance); - assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower); - assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::None)), PowerPreference::None); - assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); - } - - /// The preference the engine saves is the one the app reads back before the window opens. - #[test] - fn the_saved_engine_preference_is_found() { - let mut prefs = vectorcraft_engine::Prefs::default(); - let saved = prefs.to_json(); - assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("automatic")); - for (value, power) in [("lowPower", PowerPreference::LowPower), ("highPerformance", PowerPreference::HighPerformance)] { - prefs.gpu_preference = value.into(); - let saved = prefs.to_json(); - assert_eq!(power_preference(saved.get("gpuPreference").and_then(serde_json::Value::as_str), None), power); - } - // Every choice Preferences offer means something here. - for (value, _) in vectorcraft_engine::cmd::prefscmds::GPU_PREFERENCES { - assert!(["automatic", "lowPower", "highPerformance"].contains(value), "{value}"); - } - } - - #[test] - fn skipped_adapters_parse_from_a_comma_separated_list() { - assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); - assert!(parse_skip("").is_empty()); - assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); - assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); - } - - #[test] - fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { - for file in [ - "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", - r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", - "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", - "naga-30.0.0/src/back/spv/writer.rs", - ] { - assert!(in_graphics_stack(file), "{file}"); - } - for file in [ - "apps/vectorcraft/src/main.rs", - "/home/wgpu/vectorcraft/crates/ui-egui/src/lib.rs", - "egui-0.36.2/src/context.rs", - "winit-0.30.12/src/x.rs", - ] { - assert!(!in_graphics_stack(file), "{file}"); - } - } - - #[test] - fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { - let amd = Some("Vulkan:1002:164e"); - assert_eq!(restart_without(true, 0, amd, &[]), amd); - assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); - // After start-up the adapter has shown frames; a lost window is something else. - assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); - // A bug outside the graphics, or no adapter picked: nothing another adapter would change. - assert_eq!(restart_without(false, 0, amd, &[]), None); - assert_eq!(restart_without(true, 0, None, &[]), None); - // Never the same adapter twice, so restarts end. - assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); - } - - #[test] - fn a_normal_exit_or_another_failure_does_not_restart() { - let startup = Startup::default(); - assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); - // No adapter was picked (none can show the window): the error is returned, nothing restarts. - let none = - eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); - assert_eq!( - finish(Ok(Err(none)), &startup), - Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) - ); - let _ = startup.adapter.set("Vulkan:1002:164e".into()); - GRAPHICS_PANIC.store(false, Ordering::Relaxed); - assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); - } - - /// A log folder of its own for each test. - fn start_dir(tag: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - d - } - - /// What a start in `dir` leaves out; it then ends without crashing. - fn next_start(dir: &std::path::Path, explicit: bool) -> LeftOut { - let s = Startup::default(); - let left_out = s.track(dir, None, explicit); - s.ended(); - left_out - } - - /// The machine of #2243: one Intel UHD Graphics 605, listed through DX12 and OpenGL, and - /// Windows' software adapter. - fn issue_2243() -> Vec { - vec![ - pci_gpu("Intel(R) UHD Graphics 605", Backend::Dx12, DeviceType::IntegratedGpu, (0x8086, 0x3184)), - pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, (0x1414, 0x008c)), - pci_gpu("Intel(R) UHD Graphics 605", Backend::Gl, DeviceType::Other, (0x8086, 0)), - ] - } - - /// #2243: the driver ends the process after the adapter was picked, before the first frame. - /// The next start leaves that adapter out and draws through OpenGL, and so do the starts after - /// it, without a status-bar note. - #[test] - fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() { - let dir = start_dir("crash"); - let c = issue_2243(); - let first = Startup::default(); - assert_eq!(first.track(&dir, None, false), LeftOut::default()); - first.picked(&c[0].key, &c[0].name, true); - // The process ends here: its file is closed and unlocked, and the start is still recorded. - drop(first); - - let second = Startup::default(); - let left_out = second.track(&dir, Some("automatic"), false); - assert_eq!(left_out.adapters, ["Dx12:8086:3184"]); - assert_eq!(left_out.crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "Intel(R) UHD Graphics 605".into() })); - assert!(!left_out.opengl_only); - assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::DX12 | Backends::GL); - let order = adapter_order(&c, AUTOMATIC, &[], None, None, &left_out.adapters); - assert_eq!(order, [2, 1], "OpenGL on the same GPU before the software adapter"); - second.picked(&c[2].key, &c[2].name, true); - second.presented(); - - let third = Startup::default(); - let left_out = third.track(&dir, None, false); - assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept"); - assert_eq!(left_out.crash, None, "the note is shown once"); - third.ended(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A start whose first frame reached the screen, or that ended without crashing (a normal exit, - /// an error, a restart on another adapter), leaves nothing out of the next. - #[test] - fn a_start_that_showed_its_first_frame_or_ended_is_cleared() { - let dir = start_dir("shown"); - let c = issue_2243(); - for end in [Startup::presented as fn(&Startup), Startup::ended] { - let first = Startup::default(); - first.track(&dir, None, false); - first.picked(&c[0].key, &c[0].name, true); - end(&first); - drop(first); - assert_eq!(next_start(&dir, false), LeftOut::default()); - } - // So does a start that ended before it picked an adapter. - let first = Startup::default(); - first.track(&dir, None, false); - assert!(finish(Ok(Ok(())), &first).is_ok()); - drop(first); - assert_eq!(next_start(&dir, false), LeftOut::default()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The process ended while the adapters were being listed (wgpu's DX12 backend creates a - /// device on each GPU to list it): the next starts list OpenGL's adapters alone, except on - /// macOS, where wgpu has none. - #[test] - fn a_crash_while_listing_lists_opengl_alone() { - let dir = start_dir("listing"); - let first = Startup::default(); - first.track(&dir, None, false); - drop(first); - let second = Startup::default(); - let left_out = second.track(&dir, None, false); - assert!(left_out.adapters.is_empty()); - if cfg!(target_os = "macos") { - assert_eq!(left_out, LeftOut::default()); - } else { - assert_eq!(left_out.crash, Some(Crash::Listing)); - assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::GL); - assert_eq!(left_out.backends(Backends::PRIMARY | Backends::GL), Backends::GL); - assert_eq!(left_out.backends(Backends::METAL), Backends::METAL, "no OpenGL to fall back to"); - } - // Listing OpenGL alone found nothing that can show the window: the next start lists every - // backend again. - second.none_left(); - second.ended(); - assert_eq!(next_start(&dir, false), LeftOut::default()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that - /// start shows its first frame on the adapter that crashed, the adapter counts again. - #[test] - fn wgpu_backend_or_adapter_name_ignores_the_crashes() { - let dir = start_dir("explicit"); - let c = issue_2243(); - let first = Startup::default(); - first.track(&dir, None, false); - first.picked(&c[0].key, &c[0].name, true); - drop(first); - assert_eq!(next_start(&dir, true), LeftOut::default()); - assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"], "still left out without them"); - - let explicit = Startup::default(); - explicit.track(&dir, None, true); - explicit.picked(&c[0].key, &c[0].name, true); - explicit.presented(); - assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A damaged record, a file that can't be opened and one another starting copy of the app holds - /// are ignored: the start goes on with nothing left out. - #[test] - fn a_damaged_or_unreadable_start_file_is_ignored() { - let dir = start_dir("damaged"); - std::fs::create_dir_all(&dir).unwrap(); - let damaged: [&[u8]; 9] = [ - b"", - b"{", - b"[]", - b"null", - b"{\"version\":1}", - b"{\"starting\":{}}", - b"\xef\xbb\xbf\0", - b"\xff\xfe{", - &[b' '; START_FILE_LIMIT as usize + 1], - ]; - for text in damaged { - std::fs::write(dir.join(START_FILE), text).unwrap(); - assert_eq!(next_start(&dir, false), LeftOut::default(), "{:?}", String::from_utf8_lossy(&text[..text.len().min(20)])); - } - - // Another copy of the app is starting: this start neither reads nor writes the file. - let c = issue_2243(); - let starting = Startup::default(); - starting.track(&dir, None, false); - starting.picked(&c[0].key, &c[0].name, true); - assert_eq!(next_start(&dir, false), LeftOut::default()); - drop(starting); - assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]); - - // A folder in the file's place. - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join(START_FILE)).unwrap(); - assert_eq!(next_start(&dir, false), LeftOut::default()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn crashes_are_forgotten_in_another_version_or_under_another_preference() { - let starting = |key: &str| Starting { adapter: Some(Recorded { key: key.into(), name: "GPU".into() }), native: true }; - let (mut record, crash) = StartRecord::begin(None, "0.9.0", "automatic"); - assert_eq!(crash, None); - record.starting = Some(starting("Dx12:8086:3184")); - let (record, crash) = StartRecord::begin(Some(record), "0.9.0", "automatic"); - assert_eq!(crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "GPU".into() })); - assert_eq!(record.left_out(None, false).adapters, ["Dx12:8086:3184"]); - // Written and read back. - assert_eq!(StartRecord::parse(&record.to_json()), Some(record.clone())); - assert!(StartRecord::begin(Some(record.clone()), "0.9.1", "automatic").0.crashed.is_empty()); - assert!(StartRecord::begin(Some(record.clone()), "0.9.0", "highPerformance").0.crashed.is_empty()); - assert_eq!(preference_name(None), "automatic"); - assert_eq!(preference_name(Some("powerSaving")), "automatic"); - assert_eq!(preference_name(Some("lowPower")), "lowPower"); - // An adapter is listed once, and the list is bounded. - let mut record = record; - for n in 0..CRASHED_LIMIT + 4 { - for _ in 0..2 { - record.starting = Some(starting(&format!("Dx12:10de:{n:04x}"))); - record = StartRecord::begin(Some(record), "0.9.0", "automatic").0; - } - } - assert_eq!(record.crashed.len(), CRASHED_LIMIT); - assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013")); - } -} +mod tests; diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs new file mode 100644 index 000000000..8a473e664 --- /dev/null +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -0,0 +1,748 @@ +use super::*; + +fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { + Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } +} + +/// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that +/// takes 16384 px textures; an adapter with less than eframe asks for changes nothing. +#[test] +fn the_surface_may_be_as_large_as_the_adapter_allows() { + assert_eq!(surface_limit(8192, 16384), 16384); + assert_eq!(surface_limit(8192, 4096), 8192); +} + +fn names(c: &[Candidate], order: &[usize]) -> Vec { + order.iter().map(|&i| c[i].name.clone()).collect() +} + +const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); +const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); +const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); +const WARP: (u32, u32) = (0x1414, 0x008c); + +fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { + Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } +} + +fn monitor(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: false } +} + +fn primary(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: true } +} + +/// The desktop of pdfcraft#378 as wgpu lists it on Windows: a Ryzen's integrated GPU without a +/// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and +/// OpenGL (which reports no device id). +fn ryzen_desktop() -> Vec { + vec![ + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), + pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), + ] +} + +/// pdfcraft#378: power saving picked the integrated GPU, whose driver reset when Windows had to +/// present its frames on the monitors of the NVIDIA card. The GPU that drives the displays now +/// comes first, through DX12 (#545); the integrated GPU stays the fallback a restart reaches. +#[test] +fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { + let c = ryzen_desktop(); + for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + } + // Without the display information the order is power saving's, as before. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]), [3, 1, 2, 0, 5, 4]); + // After the NVIDIA adapters failed, the integrated GPU is next. + let skip = vec![c[2].key.clone(), c[0].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &skip), [3, 1, 5, 4]); +} + +/// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the +/// discrete one. Power saving and the display agree, in either enumeration order. +#[test] +fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { + let c = ryzen_desktop(); + for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); + } + // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the + // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one + // comes after both. + let linux = vec![ + pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), + pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), + ]; + let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; + assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, None, &[]), [0, 1, 2, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, None, &[]), [2, 0, 1, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, None, &[]), [0, 1, 2, 3]); +} + +/// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with +/// `AQ_DRM_DEVICES` naming it first), as Vulkan lists it there: the integrated GPU drives the +/// built-in panel, yet the compositor shows nothing it draws, and reports no error. The GPU the +/// compositor draws with comes first; without it, the panel's, as before. +#[test] +fn the_gpu_the_compositor_draws_with_comes_before_the_panels() { + const RTX_3050: (u32, u32) = (0x10de, 0x25ac); + const INTEL_RPL: (u32, u32) = (0x8086, 0xa7a8); + let mut c = vec![ + pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU", Backend::Vulkan, DeviceType::DiscreteGpu, RTX_3050), + pci_gpu("Intel(R) Graphics (RPL-P)", Backend::Vulkan, DeviceType::IntegratedGpu, INTEL_RPL), + pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU/PCIe/SSE2", Backend::Gl, DeviceType::Other, (RTX_3050.0, 0)), + ]; + c[2].presents = false; + let panel = [primary(INTEL_RPL)]; + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[]), [0, 1]); + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, None, None, &[]), [1, 0]); + // The usual hybrid laptop, with the compositor on the integrated GPU: the panel's GPU. + assert_eq!(adapter_order(&c, PowerPreference::None, &[primary(INTEL_RPL), monitor(RTX_3050)], Some(INTEL_RPL), None, &[]), [1, 0]); + // `WGPU_ADAPTER_NAME` still comes first, and a restart after a failure still moves on. + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), Some("intel"), &[]), [1, 0]); + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[c[0].key.clone()]), [1]); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some((0x1af4, 0x1050)), None, &[]), [1, 0]); +} + +/// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so +/// its GPU draws, whichever kind it is. +#[test] +fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { + let c = ryzen_desktop(); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, None, &[]), [2, 0, 3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, None, &[]), [3, 1, 2, 0, 5, 4]); + // The displays only matter among adapters that can show the window; a GPU driving no + // display still loses to one that does. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, None, &[]), [3, 1, 2, 0, 5, 4]); +} + +/// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, +/// Metal's zeros) change nothing: power saving's order, the old behaviour. +#[test] +fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { + let c = ryzen_desktop(); + let low = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); + for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), low, "{displays:?}"); + } + assert_eq!( + names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, None, &[]))[0], + "NVIDIA GeForce RTX 3090" + ); + // The #502 machine's adapters report no ids in these tests: the primary display never matches. + let c = issue_502(); + assert_eq!( + adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, None, &[]), + adapter_order(&c, PowerPreference::None, &[], None, None, &[]) + ); + assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &[]).is_empty()); +} + +/// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. +#[test] +fn a_named_adapter_comes_before_the_one_driving_the_display() { + let mut c = ryzen_desktop(); + let displays = [primary(NVIDIA_3090)]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); + c[2].presents = false; + c[0].presents = false; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 5, 4]); +} + +/// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic +/// looks at the displays. +#[test] +fn only_automatic_considers_which_gpu_drives_the_display() { + assert!(automatic(None, None)); + assert!(automatic(Some("automatic"), None)); + assert!(automatic(Some("powerSaving"), None)); + assert!(!automatic(Some("lowPower"), None)); + assert!(!automatic(Some("highPerformance"), None)); + assert!(!automatic(None, Some(PowerPreference::None))); + assert!(!automatic(Some("automatic"), Some(PowerPreference::LowPower))); + assert!(preferred_displays(Some("lowPower"), None).is_empty()); + assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty()); + // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). + assert_eq!(preferred_displays(None, None), display_gpus()); + assert_eq!(preferred_compositor(Some("lowPower"), None), None); + assert_eq!(preferred_compositor(None, Some(PowerPreference::HighPerformance)), None); + assert_eq!(preferred_compositor(None, None), crate::compositor_gpu::compositor_gpu()); +} + +#[test] +fn pci_ids_come_from_windows_device_ids() { + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(pci_ids("VEN_&DEV_"), None); + assert_eq!(pci_ids(""), None); +} + +/// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary +/// display, and none is listed twice. (A headless CI runner may list none.) +#[cfg(windows)] +#[test] +fn windows_lists_each_gpu_with_a_monitor_once() { + let gpus = windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert_ne!(g.pci.0, 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } +} + +#[test] +fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!is_internal_panel(name), "{name}"); + } +} + +/// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port +/// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without +/// PCI ids. Each GPU is listed once, the one with the panel as primary. +#[test] +fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card0", AMD_IGPU)?; + card("card1", NVIDIA_3090)?; + std::fs::create_dir_all(dir.join("card2"))?; + connector("card0-eDP-1", "connected")?; + connector("card0-HDMI-A-1", "disconnected")?; + connector("card1-DP-1", "connected")?; + connector("card1-DP-2", "connected")?; + connector("card2-Virtual-1", "connected")?; + std::fs::write(dir.join("renderD128"), "")?; + let mut gpus = linux_display_gpus(&dir); + gpus.sort_by_key(|g| g.pci); + assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); + assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); + std::fs::remove_dir_all(&dir) +} + +#[test] +fn display_gpus_are_logged_as_pci_ids() { + assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); + assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); + let mut gpus = vec![]; + add_display_gpu(&mut gpus, NVIDIA_3090, false); + add_display_gpu(&mut gpus, NVIDIA_3090, true); + add_display_gpu(&mut gpus, AMD_IGPU, false); + assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); +} + +/// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's +/// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software +/// renderer, and OpenGL. +fn issue_502() -> Vec { + vec![ + gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), + gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), + ] +} + +#[test] +fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { + let c = issue_502(); + let order = adapter_order(&c, power_preference(None, None), &[], None, None, &[]); + let first = &c[order[0]].name; + if cfg!(any(windows, target_os = "macos")) { + assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + } else { + // #502: the integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. + assert_eq!(first, "NVIDIA GeForce RTX 5070"); + } +} + +#[test] +fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { + let c = issue_502(); + let order = |power| names(&c, &adapter_order(&c, power, &[], None, None, &[])); + assert_eq!( + order(PowerPreference::None), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::LowPower), + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::HighPerformance), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); +} + +/// A restart leaves out the adapter the window failed on, so Power Saving on the machine of +/// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try. +/// A dual-GPU Mac reports both GPUs under Metal with no PCI ids (#651): their keys still +/// differ, so when the integrated GPU fails, the restart renders on the discrete one. +#[test] +fn gpus_without_pci_ids_are_told_apart_by_name() { + let mac = |name: &str, device_type| Candidate { + key: key(Backend::Metal, 0, 0, name), + name: name.into(), + backend: Backend::Metal, + device_type, + pci: (0, 0), + presents: true, + }; + let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; + assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); + let first = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); + assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); + let skip = parse_skip(&c[first[0]].key); + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip)), ["NVIDIA GeForce GT 750M"]); + // A comma in a name can't split the list. + assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); +} + +#[test] +fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() { + let s = Duration::from_secs; + // The second frame began: the first was presented. + assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown); + // Still drawing (or presenting) the first frame: waited for, then given up on (#964). + for frames in [0, 1] { + assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait); + assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung); + } + // A window minimized at the start may not draw again until restored. + assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown); + // A hung adapter is left out once, like one that failed. + let skip = vec!["Dx12:10de:28a0".to_string()]; + assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); + assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); +} + +#[test] +fn a_restart_tries_the_next_adapter() { + let c = issue_502(); + let mut skip = vec![]; + let mut tried = vec![]; + while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip).first() { + tried.push(c[i].name.clone()); + skip.push(c[i].key.clone()); + } + assert_eq!( + tried, + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); +} + +#[test] +fn adapters_that_cannot_present_to_the_window_are_never_tried() { + let mut c = issue_502(); + c[0].presents = false; + c[3].presents = false; + for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { + let order = adapter_order(&c, power, &[], None, None, &[]); + assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); + assert_eq!(order.len(), 2); + } + for p in &mut c { + p.presents = false; + } + assert!(adapter_order(&c, PowerPreference::None, &[], None, None, &[]).is_empty()); + assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, None, &[]).is_empty()); +} + +#[test] +fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { + let c = issue_502(); + let order = adapter_order(&c, PowerPreference::HighPerformance, &[], None, Some("radv"), &[]); + assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + assert_eq!(order.len(), 4); + // Any case; every match comes before the rest, natively first. + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, Some("GeForce"), &[]); + assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); + // An empty name or one nothing matches changes nothing. + for named in ["", "no such gpu"] { + assert_eq!( + adapter_order(&c, PowerPreference::LowPower, &[], None, Some(named), &[]), + adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]) + ); + } + // A named adapter that failed is left out like any other. + let skip = vec![c[1].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, Some("radv"), &skip)[0], 0); +} + +/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the +/// integrated GPU first, as wgpu's own choice did (#306), each GPU through DX12 first (#545). +#[test] +fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { + let c = vec![ + gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + ]; + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); + assert_eq!(order, [3, 1, 2, 0, 4]); + assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, None, &[]), [2, 0, 3, 1, 4]); +} + +/// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black. +/// DX12 comes first whatever the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` +/// or a restart leaving DX12 out still reach it. +#[test] +fn a_windows_gpu_renders_through_dx12_before_vulkan() { + let c = vec![ + gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), + ]; + for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { + assert_eq!(adapter_order(&c, power, &[], None, None, &[]), [1, 0, 3, 2], "{power:?}"); + } + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); +} + +#[test] +fn windows_never_loads_vulkan_drivers_unless_asked() { + let all = Backends::all(); + let start = backends(all, false); + if cfg!(windows) { + assert_eq!(start, Backends::DX12 | Backends::GL, "#806"); + } else { + assert_eq!(start, all); + } + assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it"); +} + +#[test] +fn power_preference_follows_the_environment_then_the_preference() { + assert_eq!(power_preference(None, None), AUTOMATIC); + assert_eq!(power_preference(Some("automatic"), None), AUTOMATIC); + assert_eq!(power_preference(Some("lowPower"), None), PowerPreference::LowPower); + assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance); + // 0.5.0's default, and values this version doesn't know (a newer or damaged file). + for old in ["powerSaving", "turbo", ""] { + assert_eq!(power_preference(Some(old), None), AUTOMATIC, "{old}"); + } + // WGPU_POWER_PREF wins over the preference, either way. + assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance); + assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower); + assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::None)), PowerPreference::None); + assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); +} + +/// The preference the engine saves is the one the app reads back before the window opens. +#[test] +fn the_saved_engine_preference_is_found() { + let mut prefs = vectorcraft_engine::Prefs::default(); + let saved = prefs.to_json(); + assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("automatic")); + for (value, power) in [("lowPower", PowerPreference::LowPower), ("highPerformance", PowerPreference::HighPerformance)] { + prefs.gpu_preference = value.into(); + let saved = prefs.to_json(); + assert_eq!(power_preference(saved.get("gpuPreference").and_then(serde_json::Value::as_str), None), power); + } + // Every choice Preferences offer means something here. + for (value, _) in vectorcraft_engine::cmd::prefscmds::GPU_PREFERENCES { + assert!(["automatic", "lowPower", "highPerformance"].contains(value), "{value}"); + } +} + +#[test] +fn skipped_adapters_parse_from_a_comma_separated_list() { + assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); + assert!(parse_skip("").is_empty()); + assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); + assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); +} + +#[test] +fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { + for file in [ + "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", + r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", + "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", + "naga-30.0.0/src/back/spv/writer.rs", + ] { + assert!(in_graphics_stack(file), "{file}"); + } + for file in [ + "apps/vectorcraft/src/main.rs", + "/home/wgpu/vectorcraft/crates/ui-egui/src/lib.rs", + "egui-0.36.2/src/context.rs", + "winit-0.30.12/src/x.rs", + ] { + assert!(!in_graphics_stack(file), "{file}"); + } +} + +#[test] +fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { + let amd = Some("Vulkan:1002:164e"); + assert_eq!(restart_without(true, 0, amd, &[]), amd); + assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); + // After start-up the adapter has shown frames; a lost window is something else. + assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); + // A bug outside the graphics, or no adapter picked: nothing another adapter would change. + assert_eq!(restart_without(false, 0, amd, &[]), None); + assert_eq!(restart_without(true, 0, None, &[]), None); + // Never the same adapter twice, so restarts end. + assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); +} + +#[test] +fn a_normal_exit_or_another_failure_does_not_restart() { + let startup = Startup::default(); + assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); + // No adapter was picked (none can show the window): the error is returned, nothing restarts. + let none = + eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); + assert_eq!( + finish(Ok(Err(none)), &startup), + Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) + ); + let _ = startup.adapter.set("Vulkan:1002:164e".into()); + GRAPHICS_PANIC.store(false, Ordering::Relaxed); + assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); +} + +/// A log folder of its own for each test. +fn start_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d +} + +/// What a start in `dir` leaves out; it then ends without crashing. +fn next_start(dir: &std::path::Path, explicit: bool) -> LeftOut { + let s = Startup::default(); + let left_out = s.track(dir, None, explicit); + s.ended(); + left_out +} + +/// The machine of #2243: one Intel UHD Graphics 605, listed through DX12 and OpenGL, and +/// Windows' software adapter. +fn issue_2243() -> Vec { + vec![ + pci_gpu("Intel(R) UHD Graphics 605", Backend::Dx12, DeviceType::IntegratedGpu, (0x8086, 0x3184)), + pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, (0x1414, 0x008c)), + pci_gpu("Intel(R) UHD Graphics 605", Backend::Gl, DeviceType::Other, (0x8086, 0)), + ] +} + +/// #2243: the driver ends the process after the adapter was picked, before the first frame. +/// The next start leaves that adapter out and draws through OpenGL, and so do the starts after +/// it, without a status-bar note. +#[test] +fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() { + let dir = start_dir("crash"); + let c = issue_2243(); + let first = Startup::default(); + assert_eq!(first.track(&dir, None, false), LeftOut::default()); + first.picked(&c[0].key, &c[0].name, true); + // The process ends here: its file is closed and unlocked, and the start is still recorded. + drop(first); + + let second = Startup::default(); + let left_out = second.track(&dir, Some("automatic"), false); + assert_eq!(left_out.adapters, ["Dx12:8086:3184"]); + assert_eq!(left_out.crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "Intel(R) UHD Graphics 605".into() })); + assert!(!left_out.opengl_only); + assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::DX12 | Backends::GL); + let order = adapter_order(&c, AUTOMATIC, &[], None, None, &left_out.adapters); + assert_eq!(order, [2, 1], "OpenGL on the same GPU before the software adapter"); + second.picked(&c[2].key, &c[2].name, true); + second.presented(); + + let third = Startup::default(); + let left_out = third.track(&dir, None, false); + assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept"); + assert_eq!(left_out.crash, None, "the note is shown once"); + third.ended(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A start whose first frame reached the screen, or that ended without crashing (a normal exit, +/// an error, a restart on another adapter), leaves nothing out of the next. +#[test] +fn a_start_that_showed_its_first_frame_or_ended_is_cleared() { + let dir = start_dir("shown"); + let c = issue_2243(); + for end in [Startup::presented as fn(&Startup), Startup::ended] { + let first = Startup::default(); + first.track(&dir, None, false); + first.picked(&c[0].key, &c[0].name, true); + end(&first); + drop(first); + assert_eq!(next_start(&dir, false), LeftOut::default()); + } + // So does a start that ended before it picked an adapter. + let first = Startup::default(); + first.track(&dir, None, false); + assert!(finish(Ok(Ok(())), &first).is_ok()); + drop(first); + assert_eq!(next_start(&dir, false), LeftOut::default()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The process ended while the adapters were being listed (wgpu's DX12 backend creates a +/// device on each GPU to list it): the next starts list OpenGL's adapters alone, except on +/// macOS, where wgpu has none. +#[test] +fn a_crash_while_listing_lists_opengl_alone() { + let dir = start_dir("listing"); + let first = Startup::default(); + first.track(&dir, None, false); + drop(first); + let second = Startup::default(); + let left_out = second.track(&dir, None, false); + assert!(left_out.adapters.is_empty()); + if cfg!(target_os = "macos") { + assert_eq!(left_out, LeftOut::default()); + } else { + assert_eq!(left_out.crash, Some(Crash::Listing)); + assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::GL); + assert_eq!(left_out.backends(Backends::PRIMARY | Backends::GL), Backends::GL); + assert_eq!(left_out.backends(Backends::METAL), Backends::METAL, "no OpenGL to fall back to"); + } + // Listing OpenGL alone found nothing that can show the window: the next start lists every + // backend again. + second.none_left(); + second.ended(); + assert_eq!(next_start(&dir, false), LeftOut::default()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that +/// start shows its first frame on the adapter that crashed, the adapter counts again. +#[test] +fn wgpu_backend_or_adapter_name_ignores_the_crashes() { + let dir = start_dir("explicit"); + let c = issue_2243(); + let first = Startup::default(); + first.track(&dir, None, false); + first.picked(&c[0].key, &c[0].name, true); + drop(first); + assert_eq!(next_start(&dir, true), LeftOut::default()); + assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"], "still left out without them"); + + let explicit = Startup::default(); + explicit.track(&dir, None, true); + explicit.picked(&c[0].key, &c[0].name, true); + explicit.presented(); + assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A damaged record, a file that can't be opened and one another starting copy of the app holds +/// are ignored: the start goes on with nothing left out. +#[test] +fn a_damaged_or_unreadable_start_file_is_ignored() { + let dir = start_dir("damaged"); + std::fs::create_dir_all(&dir).unwrap(); + let damaged: [&[u8]; 9] = [ + b"", + b"{", + b"[]", + b"null", + b"{\"version\":1}", + b"{\"starting\":{}}", + b"\xef\xbb\xbf\0", + b"\xff\xfe{", + &[b' '; START_FILE_LIMIT as usize + 1], + ]; + for text in damaged { + std::fs::write(dir.join(START_FILE), text).unwrap(); + assert_eq!(next_start(&dir, false), LeftOut::default(), "{:?}", String::from_utf8_lossy(&text[..text.len().min(20)])); + } + + // Another copy of the app is starting: this start neither reads nor writes the file. + let c = issue_2243(); + let starting = Startup::default(); + starting.track(&dir, None, false); + starting.picked(&c[0].key, &c[0].name, true); + assert_eq!(next_start(&dir, false), LeftOut::default()); + drop(starting); + assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]); + + // A folder in the file's place. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(START_FILE)).unwrap(); + assert_eq!(next_start(&dir, false), LeftOut::default()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn crashes_are_forgotten_in_another_version_or_under_another_preference() { + let starting = |key: &str| Starting { adapter: Some(Recorded { key: key.into(), name: "GPU".into() }), native: true }; + let (mut record, crash) = StartRecord::begin(None, "0.9.0", "automatic"); + assert_eq!(crash, None); + record.starting = Some(starting("Dx12:8086:3184")); + let (record, crash) = StartRecord::begin(Some(record), "0.9.0", "automatic"); + assert_eq!(crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "GPU".into() })); + assert_eq!(record.left_out(None, false).adapters, ["Dx12:8086:3184"]); + // Written and read back. + assert_eq!(StartRecord::parse(&record.to_json()), Some(record.clone())); + assert!(StartRecord::begin(Some(record.clone()), "0.9.1", "automatic").0.crashed.is_empty()); + assert!(StartRecord::begin(Some(record.clone()), "0.9.0", "highPerformance").0.crashed.is_empty()); + assert_eq!(preference_name(None), "automatic"); + assert_eq!(preference_name(Some("powerSaving")), "automatic"); + assert_eq!(preference_name(Some("lowPower")), "lowPower"); + // An adapter is listed once, and the list is bounded. + let mut record = record; + for n in 0..CRASHED_LIMIT + 4 { + for _ in 0..2 { + record.starting = Some(starting(&format!("Dx12:10de:{n:04x}"))); + record = StartRecord::begin(Some(record), "0.9.0", "automatic").0; + } + } + assert_eq!(record.crashed.len(), CRASHED_LIMIT); + assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013")); +} From dff3e525c22e07ebabe769cc7c97b0493649e796 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:45 +0000 Subject: [PATCH 033/199] refactor(ui-egui): move panels/swatches.rs tests to panels/swatches/tests.rs --- crates/ui-egui/src/panels/swatches.rs | 719 +------------------- crates/ui-egui/src/panels/swatches/tests.rs | 716 +++++++++++++++++++ 2 files changed, 717 insertions(+), 718 deletions(-) create mode 100644 crates/ui-egui/src/panels/swatches/tests.rs diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3b0df80ac..66cbce4c5 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -1291,721 +1291,4 @@ pub(crate) fn window_menu() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::dialogs::swatch_options::KIND; - use egui::{Event, Modifiers, PointerButton, Pos2}; - use vectorcraft_color::Color; - use vectorcraft_engine::Session; - - fn app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app - } - - /// One frame of `draw` on a persistent context (clicks need the previous frame's layout). - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) { - frame_with(app, ctx, events, Modifiers::NONE, time, draw); - } - - /// [`frame`] with `modifiers` held. - fn frame_with( - app: &mut VectorcraftApp, - ctx: &egui::Context, - events: Vec, - modifiers: Modifiers, - time: f64, - draw: fn(&mut VectorcraftApp, &mut Ui), - ) { - let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); - let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); - let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| draw(app, ui)); - out.textures_delta.clear(); - } - - fn context() -> egui::Context { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - ctx - } - - fn double_click(at: Pos2) -> Vec { - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - vec![Event::PointerMoved(at), button(true), button(false), button(true), button(false)] - } - - /// Double-click the thumbnail of swatch `name` in the panel. - fn double_click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) { - frame(app, ctx, vec![], 0.0, show); - let tile = ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect; - frame(app, ctx, double_click(tile.center()), 1.0, show); - } - - /// Click the thumbnail of swatch or group `name` with `modifiers` held (frames at `time`, `time + 0.5`). - fn click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { - frame(app, ctx, vec![], time, show); - let at = ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; - frame_with(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5, show); - } - - fn selected(ctx: &egui::Context) -> Vec { - pstate(ctx, "swatch-selected") - } - - fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) { - frame(app, ctx, vec![], 2.0, |app, ui| crate::dialogs::show(app, ui.ctx())); - } - - #[test] - fn double_click_opens_swatch_options_and_ok_edits_linked_art() { - let mut app = app(); - app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); - app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap(); - let ctx = context(); - double_click_tile(&mut app, &ctx, "Red"); - let d = app.ui.dialog.as_ref().expect("double-click opens Swatch Options"); - assert_eq!((d.kind.as_str(), d.str("__swatch").as_str(), d.str("mode").as_str()), (KIND, "Red", "rgb")); - // The dialog previews live on the canvas; OK keeps the edit as one undo step. - let undo = app.session.doc().unwrap().history.undo.len(); - dialog_frame(&mut app, &ctx); - assert!(app.session.in_interaction(), "Preview runs as an interaction"); - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("color".into(), json!("#00ff00")); - d.fields.insert("name".into(), json!("Signal")); - crate::dialogs::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); - let doc = &app.session.doc().unwrap().doc; - assert_eq!(doc.swatch("Signal").and_then(|w| w.paint.color()).map(|c| c.to_hex()), Some("#00ff00".into())); - let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); - assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()), tint: 1.0 }); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); - } - - #[test] - fn cancel_rolls_the_preview_back_and_other_kinds_open_their_editors() { - let mut app = app(); - let ctx = context(); - let before = app.session.doc().unwrap().doc.clone(); - app.run("ui.swatchOptions", json!({"name": "Bright Blue"})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#000000")); - dialog_frame(&mut app, &ctx); - assert_ne!(app.session.doc().unwrap().doc, before, "previewed"); - crate::dialogs::cancel(&mut app); - assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); - assert_eq!(app.session.doc().unwrap().doc, before); - // A gradient opens Swatch Options with its name (and the gradient shown): OK renames it. - double_click_tile(&mut app, &ctx, "Sunset"); - let d = app.ui.dialog.as_mut().expect("Swatch Options for a gradient"); - assert!(d.kind == KIND && d.fields.contains_key("__gradient") && !d.fields.contains_key("color")); - d.fields.insert("name".into(), json!("Evening")); - dialog_frame(&mut app, &ctx); - crate::dialogs::confirm(&mut app).unwrap(); - assert!(matches!(app.session.doc().unwrap().doc.swatch("Evening").map(|w| &w.paint), Some(Paint::Gradient(_)))); - // Patterns open pattern editing, None nothing. - assert!(app.run("ui.swatchOptions", json!({"name": "[None]"})).is_err()); - assert!(app.run("ui.swatchOptions", json!({})).is_err()); - } - - #[test] - fn modifier_clicks_select_several_swatches_and_groups() { - let mut app = app(); - let ctx = context(); - click_tile(&mut app, &ctx, "Red", Modifiers::NONE, 0.0); - assert_eq!(selected(&ctx), ["Red"]); - assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "a plain click applies"); - click_tile(&mut app, &ctx, "Amber", Modifiers::SHIFT, 2.0); - assert_eq!(selected(&ctx), ["Red", "Orange Red", "Orange", "Amber"], "Shift extends over the shown order"); - click_tile(&mut app, &ctx, "Orange", Modifiers::COMMAND, 4.0); - click_tile(&mut app, &ctx, "Brights", Modifiers::COMMAND, 6.0); - assert_eq!(selected(&ctx), ["Red", "Orange Red", "Amber", "Brights"], "Cmd toggles swatches and colour groups"); - assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "modifier clicks only select"); - click_tile(&mut app, &ctx, "Grays", Modifiers::NONE, 8.0); - assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone"); - } - - /// The hex colors of the stops of the selection's gradient fill, and its stroke's color. - fn stops_and_stroke(app: &VectorcraftApp) -> (Vec, String) { - let (fill, stroke) = crate::panels::current_paints(app); - let Paint::Gradient(g) = fill else { panic!("the fill is still a gradient: {fill:?}") }; - (g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(), stroke.color().map(|c| c.to_hex()).unwrap_or_default()) - } - - #[test] - fn alt_click_recolors_the_selected_gradient_stop() { - let mut app = app(); - let ctx = context(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); - app.run("paint.setStroke", json!({"color": "#000000"})).unwrap(); - let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); - app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); - app.run("gradient.selectStop", json!({"index": 1})).unwrap(); - let undo = app.session.doc().unwrap().history.undo.len(); - click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); - assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], "#000000".into()), "the stop, not the stroke"); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - assert_eq!(app.session.selected_stop(), Some(1), "the stop stays selected"); - assert!(app.session.fill_active, "the Fill proxy stays in front"); - // With no stop selected, Alt-click paints the inactive proxy. - app.run("gradient.selectStop", json!({"index": null})).unwrap(); - click_tile(&mut app, &ctx, "Orange", Modifiers::ALT, 2.0); - let orange = app.session.doc().unwrap().doc.swatch("Orange").and_then(|w| w.paint.color()).unwrap().to_hex(); - assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], orange)); - } - - #[test] - fn alt_click_links_the_selected_stop_to_a_global_swatch() { - let mut app = app(); - let ctx = context(); - app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); - let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); - app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); - app.run("gradient.selectStop", json!({"index": 0})).unwrap(); - click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); - let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("a gradient fill") }; - assert_eq!(g.gradient.stops[0].swatch.as_deref(), Some("Red"), "linked as a drop on the ramp links it"); - app.run("swatch.edit", json!({"name": "Red", "color": "#00ff00"})).unwrap(); - assert_eq!(stops_and_stroke(&app).0, ["#00ff00", "#000000"], "editing the swatch recolors the stop"); - } - - #[test] - fn alt_click_recolors_the_selected_freeform_point() { - let mut app = app(); - let ctx = context(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); - app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap(); - app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); - app.run("paint.freeform.selectPoint", json!({"index": 1})).unwrap(); - click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); - let (fill, stroke) = crate::panels::current_paints(&app); - let Paint::Gradient(g) = fill else { panic!("still a gradient: {fill:?}") }; - assert_eq!(g.freeform.expect("placed points").points[1].color.to_hex(), "#ed1c24"); - assert_eq!(stroke.color().map(|c| c.to_hex()).as_deref(), Some("#000000"), "the stroke keeps its color"); - assert_eq!(app.session.selected_freeform_point(), Some(1), "the point stays selected"); - } - - #[test] - fn delete_asks_then_removes_the_selection_and_unlinks_art() { - let mut app = app(); - let ctx = context(); - app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); - app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap(); - delete(&mut app, vec!["Red".into(), "Brights".into()], false); - let d = app.ui.dialog.as_ref().expect("delete asks first"); - assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::KIND, "Delete these 2 swatches and groups?")); - dialog_frame(&mut app, &ctx); - assert!(app.session.doc().unwrap().doc.swatch("Red").is_some(), "nothing is deleted before OK"); - crate::dialogs::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - let doc = &app.session.doc().unwrap().doc; - assert!(doc.swatch("Red").is_none() && doc.swatch("Bright Red").is_none() && doc.swatch_groups.iter().all(|g| g.name != "Brights")); - let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); - assert_eq!(fill, Paint::solid(Color::from_hex("#ed1c24").unwrap()), "the art keeps its colour, unlinked"); - // Cancel keeps the swatch; Alt-click (now) deletes without asking. - delete(&mut app, vec!["Orange".into()], false); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("message"), "Delete “Orange”?"); - crate::dialogs::cancel(&mut app); - assert!(app.session.doc().unwrap().doc.swatch("Orange").is_some()); - delete(&mut app, vec!["Orange".into()], true); - assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("Orange").is_none()); - assert_eq!(deletable(&["[None]".into(), REGISTRATION.into(), "Grays".into()]), ["Grays"]); - } - - #[test] - fn new_swatch_dialog_prefills_the_active_colour_and_saves_into_the_selected_group() { - let mut app = app(); - let ctx = context(); - app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap(); - new_swatch(&mut app, &["Bright Red".into()], Modifiers::COMMAND); - let d = app.ui.dialog.as_ref().expect("New Swatch opens its dialog"); - assert_eq!( - (d.kind.as_str(), d.str("name").as_str(), d.str("group").as_str()), - (crate::dialogs::new_swatch::KIND, "R=255 G=128 B=0", "Brights") - ); - assert!(d.bool("spot") && d.bool("global"), "Cmd/Ctrl-click makes a spot colour"); - dialog_frame(&mut app, &ctx); - crate::dialogs::confirm(&mut app).unwrap(); - let doc = &app.session.doc().unwrap().doc; - let w = doc.swatch("R=255 G=128 B=0").expect("created"); - assert!(w.spot && doc.swatch_group_of(&w.name).is_some_and(|g| doc.swatch_groups[g].name == "Brights")); - // Alt-click saves at once; a gradient takes only a name and stays out of colour groups. - app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap(); - new_swatch(&mut app, &["Brights".into()], Modifiers::ALT); - assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("New Gradient Swatch 1").is_some()); - new_swatch(&mut app, &[], Modifiers::NONE); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("name").as_str(), d.fields.contains_key("__paint")), ("New Gradient Swatch 2", true)); - dialog_frame(&mut app, &ctx); - crate::dialogs::confirm(&mut app).unwrap(); - assert!(app.session.doc().unwrap().doc.swatch("New Gradient Swatch 2").is_some_and(|w| matches!(w.paint, Paint::Gradient(_)))); - } - - #[test] - fn cmd_shift_click_on_new_swatch_makes_a_global_process_color() { - let mut app = app(); - let ctx = context(); - let global_process = Modifiers::COMMAND | Modifiers::SHIFT; - app.run("paint.setFill", json!({"color": "#2a6fb0"})).unwrap(); - new_swatch(&mut app, &[], global_process); - let d = app.ui.dialog.as_ref().expect("New Swatch opens its dialog"); - assert!(d.bool("global") && !d.bool("spot"), "Cmd/Ctrl+Shift-click opens a global process color"); - let name = d.str("name"); - dialog_frame(&mut app, &ctx); - crate::dialogs::confirm(&mut app).unwrap(); - let w = app.session.doc().unwrap().doc.swatch(&name).cloned().expect("created"); - assert!(w.global && !w.spot); - // With Alt also held it saves at once. - app.run("paint.setFill", json!({"color": "#00a050"})).unwrap(); - new_swatch(&mut app, &[], global_process | Modifiers::ALT); - assert!(app.ui.dialog.is_none()); - let w = app.session.doc().unwrap().doc.swatches.last().cloned().unwrap(); - assert_eq!(w.paint.color().map(|c| c.to_hex()), Some("#00a050".into())); - assert!(w.global && !w.spot); - // A gradient saves as a gradient swatch whether or not Cmd/Ctrl and Shift are held. - app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap(); - new_swatch(&mut app, &[], Modifiers::COMMAND | Modifiers::ALT); - new_swatch(&mut app, &[], global_process | Modifiers::ALT); - let doc = &app.session.doc().unwrap().doc; - for n in ["New Gradient Swatch 1", "New Gradient Swatch 2"] { - assert!(doc.swatch(n).is_some_and(|w| matches!(w.paint, Paint::Gradient(_)) && !w.global && !w.spot), "{n}"); - } - } - - /// In a CMYK document a swatch made from an RGB fill keeps RGB, as the Color Guide's copies - /// do: Alt-click on New Swatch, the New Swatch dialog, the Fill proxy dropped on the panel and - /// Alt-dropped on a color swatch, and the same for a gradient's stops. - #[test] - fn swatches_made_from_an_rgb_fill_stay_rgb_in_a_cmyk_document() { - let mut app = app(); - let ctx = context(); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - let newest = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatches.last().map(|w| w.paint.clone()).expect("a swatch"); - let stop_colors = |p: &Paint| match p { - Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect::>(), - other => panic!("a gradient: {other:?}"), - }; - let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| { - frame(app, &ctx, vec![], time, show); - let at = tile_center(&ctx, name); - egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(paint)); - let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m }; - frame_with(app, &ctx, vec![Event::PointerMoved(at), release], m, time + 0.5, show); - }; - let rgb = Color::from_hex("#2a6fb0").unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); - app.run("paint.setFill", json!({"color": "#2a6fb0", "keepModel": true})).unwrap(); - new_swatch(&mut app, &[], Modifiers::ALT); - assert_eq!(newest(&app), Paint::solid(rgb), "Alt-click on New Swatch"); - new_swatch(&mut app, &[], Modifiers::NONE); - dialog_frame(&mut app, &ctx); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(newest(&app), Paint::solid(rgb), "the New Swatch dialog"); - drop_on(&mut app, "Red", Paint::solid(rgb), Modifiers::NONE, 3.0); - assert_eq!(newest(&app), Paint::solid(rgb), "the proxy dropped on the panel: {}", app.ui.status); - drop_on(&mut app, "Red", Paint::solid(rgb), Modifiers::ALT, 5.0); - assert_eq!(app.session.doc().unwrap().doc.swatch("Red").map(|w| w.paint.color()), Some(Some(rgb)), "Alt-dropped on Red"); - // A gradient's RGB stops. - let stops = json!([{"offset": 0, "color": "#ff8000"}, {"offset": 1, "color": "#2a6fb0"}]); - app.run("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": true})).unwrap(); - let grad = crate::panels::current_paints(&app).0; - let rgb_stops = vec![Color::from_hex("#ff8000").unwrap(), rgb]; - assert_eq!(stop_colors(&grad), rgb_stops); - new_swatch(&mut app, &[], Modifiers::ALT); - assert_eq!(stop_colors(&newest(&app)), rgb_stops, "Alt-click on New Swatch"); - new_swatch(&mut app, &[], Modifiers::NONE); - frame(&mut app, &ctx, vec![], 6.0, |app, ui| crate::dialogs::show(app, ui.ctx())); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(stop_colors(&newest(&app)), rgb_stops, "the New Swatch dialog"); - drop_on(&mut app, "Red", grad.clone(), Modifiers::NONE, 7.0); - assert_eq!(stop_colors(&newest(&app)), rgb_stops, "the gradient dropped on the panel"); - drop_on(&mut app, "Ocean", grad, Modifiers::ALT, 9.0); - let ocean = app.session.doc().unwrap().doc.swatch("Ocean").map(|w| w.paint.clone()).expect("Ocean"); - assert_eq!(stop_colors(&ocean), rgb_stops, "Alt-dropped on Ocean"); - } - - #[test] - fn new_color_group_dialog_makes_a_group_from_swatches_or_artwork() { - let mut app = app(); - let ctx = context(); - app.run("ui.newColorGroup", json!({"swatches": ["Red", "Sunset"]})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.kind.as_str(), d.str("name").as_str(), d.bool("fromArtwork")), (crate::dialogs::new_color_group::KIND, "Color Group", false)); - dialog_frame(&mut app, &ctx); - crate::dialogs::confirm(&mut app).unwrap(); - let doc = &app.session.doc().unwrap().doc; - let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group").expect("group made"); - assert_eq!(g.swatches.iter().map(|w| w.name.as_str()).collect::>(), ["Red"], "gradients stay out"); - // With art selected (and no swatches) it starts from the artwork: its colours become global. - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); - app.run("paint.setFill", json!({"ids": [id], "color": "#123456"})).unwrap(); - app.run("paint.setStroke", json!({"ids": [id], "none": true})).unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.run("ui.newColorGroup", json!({})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("name").as_str(), d.bool("fromArtwork"), d.bool("toGlobal")), ("Color Group 2", true, true)); - dialog_frame(&mut app, &ctx); - crate::dialogs::confirm(&mut app).unwrap(); - let doc = &app.session.doc().unwrap().doc; - let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group 2").expect("group made"); - assert_eq!(g.swatches.iter().map(|w| (w.name.as_str(), w.global)).collect::>(), [("R=18 G=52 B=86", true)]); - let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); - assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()), tint: 1.0 }); - } - - /// The centre of the tile or row of `name` as laid out by the last frame. - fn tile_center(ctx: &egui::Context, name: &str) -> Pos2 { - ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center() - } - - /// Press at `from`, drag to `to` and release there, a frame per step from `time`. - fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, time: f64) { - drag_with(app, ctx, from, to, Modifiers::NONE, time); - } - - /// [`drag`] with `modifiers` held. - fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) { - let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; - frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show); - frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show); - frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show); - frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show); - } - - fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec { - let d = &app.session.active().unwrap().doc; - let list = match group { - Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches, - None => &*d.swatches, - }; - list.iter().map(|w| w.name.clone()).collect() - } - - #[test] - fn find_field_filters_and_shift_extends_over_what_is_shown() { - let mut app = app(); - let ctx = context(); - set_pstate(&ctx, "swatch-show-find", true); - ctx.data_mut(|d| d.insert_temp(find_id(), "BRIGHT".to_string())); - frame(&mut app, &ctx, vec![], 0.0, show); - assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "Red"))).is_none(), "Red doesn't match"); - click_tile(&mut app, &ctx, "Bright Red", Modifiers::NONE, 1.0); - click_tile(&mut app, &ctx, "Bright Blue", Modifiers::SHIFT, 2.0); - assert_eq!(selected(&ctx), ["Bright Red", "Bright Yellow", "Bright Green", "Bright Blue"]); - // A group whose name matches shows all its swatches. - ctx.data_mut(|d| d.insert_temp(find_id(), "grays".to_string())); - frame(&mut app, &ctx, vec![], 3.0, show); - frame(&mut app, &ctx, vec![], 3.5, show); - assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "K=50"))).is_some()); - assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "Bright Red"))).is_none()); - } - - #[test] - fn list_view_rows_take_double_clicks_and_folder_clicks_select_the_group() { - let mut app = app(); - let ctx = context(); - set_pstate(&ctx, "swatch-view", View::SmallList); - double_click_tile(&mut app, &ctx, "Red"); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "double-click in a list opens Swatch Options"); - let d = &app.session.active().unwrap().doc; - let sel = vec!["Brights".to_string()]; - assert!(highlighted(d, &sel).contains(&"Bright Violet"), "a selected folder highlights its swatches"); - assert_eq!(describe(&Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, false), "Global Process Color, CMYK"); - assert_eq!(describe(&Paint::solid(Color::gray(0.5)), true, true), "Spot Color, Grayscale"); - } - - #[test] - fn dragging_tiles_reorders_and_moves_into_groups_as_one_undo_step_each() { - let mut app = app(); - let ctx = context(); - frame(&mut app, &ctx, vec![], 0.0, show); - let undo = app.session.doc().unwrap().history.undo.len(); - // Onto the left half of Red: before it. - let (from, red) = (tile_center(&ctx, "Amber"), tile_center(&ctx, "Red")); - drag(&mut app, &ctx, from, red - vec2(4.0, 0.0), 1.0); - let n = names_of(&app, None); - assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Amber"); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); - // Onto a folder: into that group, at its end. (Two frames, so tile rects are current.) - frame(&mut app, &ctx, vec![], 2.0, show); - frame(&mut app, &ctx, vec![], 2.5, show); - drag(&mut app, &ctx, tile_center(&ctx, "Amber"), tile_center(&ctx, "Grays"), 3.0); - assert_eq!(names_of(&app, Some("Grays")).last().map(String::as_str), Some("Amber")); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); - // A selection drags together, in panel order; a gradient can't join a group. - set_pstate(&ctx, "swatch-selected", vec!["Lime".to_string(), "Sunset".to_string(), "Orange".to_string()]); - frame(&mut app, &ctx, vec![], 4.0, show); - frame(&mut app, &ctx, vec![], 4.5, show); - drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Bright Red") - vec2(4.0, 0.0), 5.0); - assert_eq!(names_of(&app, Some("Brights"))[..3], ["Orange", "Lime", "Bright Red"]); - assert!(names_of(&app, None).contains(&"Sunset".to_string())); - // Dropped back on itself: nothing moves. - let (before, undo) = (names_of(&app, None), app.session.doc().unwrap().history.undo.len()); - frame(&mut app, &ctx, vec![], 6.0, show); - frame(&mut app, &ctx, vec![], 6.5, show); - let white = tile_center(&ctx, "White"); - drag(&mut app, &ctx, white, white + vec2(2.0, 0.0), 7.0); - assert_eq!((names_of(&app, None), app.session.doc().unwrap().history.undo.len()), (before, undo)); - } - - #[test] - fn art_dropped_on_the_panel_becomes_a_pattern_swatch() { - let mut app = app(); - let id = app.run("shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 9, "points": 5})).unwrap()["id"].as_u64().unwrap(); - let before = app.session.doc().unwrap().doc.clone(); - let ctx = context(); - frame(&mut app, &ctx, vec![], 0.0, show); - // Released over a swatch tile: the list takes it. - let at = tile_center(&ctx, "Bright Red"); - egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])); - let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; - frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); - let patterns = app.run("pattern.list", json!({})).unwrap(); - assert_eq!(patterns["patterns"].as_array().map(Vec::len), Some(1), "{patterns}"); - assert_eq!(patterns["editing"], Value::Null, "no pattern editing mode"); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc.node(vectorcraft_doc::NodeId(id)), before.node(vectorcraft_doc::NodeId(id)), "the art stays as it was"); - assert!(egui::DragAndDrop::payload::(&ctx).is_none()); - } - - #[test] - fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { - let mut app = app(); - let ctx = context(); - frame(&mut app, &ctx, vec![], 0.0, show); - let at = tile_center(&ctx, "Bright Red"); - egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(Paint::solid(Color::from_hex("#123456").unwrap()))); - let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; - frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); - assert_eq!( - names_of(&app, Some("Brights")).last().map(String::as_str), - Some("R=18 G=52 B=86"), - "dropped on a group's colour: into that group" - ); - assert!(egui::DragAndDrop::payload::(&ctx).is_none()); - } - - #[test] - fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() { - let mut app = app(); - let ctx = context(); - frame(&mut app, &ctx, vec![], 0.0, show); - let grad = |c: &str| { - let stops = - vec![vectorcraft_color::GradientStop::new(0.0, Color::from_hex(c).unwrap()), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)]; - Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::new(GradientKind::Radial, stops)))) - }; - let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| { - let at = tile_center(&ctx, name); - egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(paint)); - let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m }; - frame_with(app, &ctx, vec![Event::PointerMoved(at), release], m, time, show); - }; - let count = app.session.doc().unwrap().doc.swatches_iter().count(); - drop_on(&mut app, "Ocean", grad("#ff0000"), Modifiers::ALT, 1.0); - let d = &app.session.doc().unwrap().doc; - let Some(Paint::Gradient(g)) = d.swatch("Ocean").map(|w| &w.paint) else { panic!("Ocean is a gradient") }; - assert_eq!((g.gradient.kind, d.swatches_iter().count()), (GradientKind::Radial, count), "replaced, not added"); - // Without Alt, or on a swatch of another kind, it becomes a new swatch. - frame(&mut app, &ctx, vec![], 2.0, show); - drop_on(&mut app, "Red", grad("#00ff00"), Modifiers::ALT, 3.0); - assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1); - assert!(app.session.doc().unwrap().doc.swatch("Red").is_some_and(|w| w.paint.color().is_some())); - // A tint swatch only takes a new name, so a color Alt-dropped on one becomes a new swatch. - app.run("swatch.edit", json!({"name": "Orange", "global": true})).unwrap(); - app.run("swatch.new", json!({"swatch": "Orange", "tint": 40})).unwrap(); - frame(&mut app, &ctx, vec![], 4.0, show); - frame(&mut app, &ctx, vec![], 4.5, show); - drop_on(&mut app, "Orange 40%", Paint::solid(Color::from_hex("#123456").unwrap()), Modifiers::ALT, 5.0); - let d = &app.session.doc().unwrap().doc; - assert_eq!(d.swatches_iter().count(), count + 3, "{}", app.ui.status); - assert!(d.swatch("Orange 40%").is_some_and(|w| w.tint_of().is_some())); - } - - #[test] - fn alt_dragging_a_swatch_onto_another_of_its_kind_replaces_its_color() { - let mut app = app(); - let ctx = context(); - app.run("swatch.edit", json!({"name": "Orange", "global": true})).unwrap(); - app.run("swatch.new", json!({"swatch": "Orange", "tint": 40})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); - app.run("paint.setFill", json!({"ids": [id], "swatch": "Orange"})).unwrap(); - frame(&mut app, &ctx, vec![], 0.0, show); - frame(&mut app, &ctx, vec![], 0.5, show); - let doc = |app: &VectorcraftApp| app.session.doc().unwrap().doc.clone(); - let (before, undo) = (names_of(&app, None), app.session.doc().unwrap().history.undo.len()); - let red = doc(&app).swatch("Red").and_then(|w| w.paint.color()).unwrap(); - drag_with(&mut app, &ctx, tile_center(&ctx, "Red"), tile_center(&ctx, "Orange"), Modifiers::ALT, 1.0); - let d = doc(&app); - let orange = d.swatch("Orange").expect("Orange keeps its name"); - assert_eq!((orange.paint.color(), orange.global), (Some(red), true), "Orange takes Red's color"); - assert_eq!(names_of(&app, None), before, "Red stays and Orange keeps its place"); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); - let fill = d.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); - assert_eq!(fill.color(), Some(red), "art linked to Orange takes the new color"); - // A gradient dropped on a color, and a swatch dropped without Alt, move as before. - frame(&mut app, &ctx, vec![], 2.0, show); - frame(&mut app, &ctx, vec![], 2.5, show); - drag_with(&mut app, &ctx, tile_center(&ctx, "Sunset"), tile_center(&ctx, "Amber") - vec2(4.0, 0.0), Modifiers::ALT, 3.0); - let n = names_of(&app, None); - assert_eq!(n[n.iter().position(|x| x == "Amber").unwrap() - 1], "Sunset"); - assert!(doc(&app).swatch("Amber").is_some_and(|w| w.paint.color().is_some())); - frame(&mut app, &ctx, vec![], 4.0, show); - frame(&mut app, &ctx, vec![], 4.5, show); - drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Red") - vec2(4.0, 0.0), 5.0); - let n = names_of(&app, None); - assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Lime"); - assert_ne!(doc(&app).swatch("Red").and_then(|w| w.paint.color()), doc(&app).swatch("Lime").and_then(|w| w.paint.color())); - // A tint swatch only takes a new name, so a swatch Alt-dropped on one moves. - frame(&mut app, &ctx, vec![], 6.0, show); - frame(&mut app, &ctx, vec![], 6.5, show); - drag_with(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Orange 40%") - vec2(4.0, 0.0), Modifiers::ALT, 7.0); - let n = names_of(&app, None); - assert_eq!(n[n.iter().position(|x| x == "Orange 40%").unwrap() - 1], "Lime", "{}", app.ui.status); - assert_eq!(doc(&app).swatch("Orange 40%").and_then(|w| w.tint_of().map(|(base, _)| base.to_string())).as_deref(), Some("Orange")); - } - - #[test] - fn menu_targets_follow_the_panel_selection() { - let app = app(); - let sel: Vec = ["Red", "Sunset", "Grays", "Orange", "Brights"].map(String::from).to_vec(); - assert_eq!(mergeable(&app, &sel), ["Red", "Orange"], "Merge Swatches takes the selected colours, first kept"); - assert_eq!(selected_group(&app, &sel).as_deref(), Some("Grays")); - assert_eq!(selected_group(&app, &sel[..2]), None); - } - - #[test] - fn kind_filter() { - let solid = Paint::solid(Color::BLACK); - let grad = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); - assert!(Kind::All.accepts(&grad, false)); - assert!(Kind::Color.accepts(&solid, false)); - assert!(!Kind::Color.accepts(&grad, false)); - assert!(Kind::Gradient.accepts(&grad, true)); - assert!(Kind::Groups.accepts(&solid, true)); - assert!(!Kind::Groups.accepts(&solid, false)); - } - - #[test] - fn the_active_tint_or_gradient_swatch_is_highlighted_and_a_tint_edits_its_base() { - let mut app = app(); - app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap(); - app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap(); - assert_eq!(active_swatch(&app).as_deref(), Some("Ink"), "no tint swatch yet: its base"); - app.run("swatch.new", json!({})).unwrap(); - assert_eq!(active_swatch(&app).as_deref(), Some("Ink 40%")); - let tint = entries(&app, Kind::Color, "Ink 40").into_iter().find(|e| e.name() == "Ink 40%").unwrap(); - assert!(matches!(tint, Entry::Swatch { global: true, spot: true, .. }), "a tint swatch shows its base's kind"); - app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap(); - assert_eq!(active_swatch(&app).as_deref(), Some("Sunset"), "the applied gradient swatch"); - app.run("ui.swatchOptions", json!({"name": "Ink 40%"})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.kind.as_str(), d.str("__swatch").as_str()), (KIND, "Ink"), "a tint swatch opens its base's options"); - } - - /// Selecting art scrolls the tiles to its swatch, once, and clears the panel's own selection so - /// that swatch is the one highlighted. - #[test] - fn selecting_art_scrolls_to_its_swatch() { - let mut app = app(); - let colors: Vec = (0..400).map(|i| format!("#{:02x}{:02x}{:02x}", i % 256, (i / 2) % 256, 128)).collect(); - app.run("swatch.new", json!({ "colors": colors })).unwrap(); - app.run("swatch.new", json!({"name": "Last Ink", "color": "#123456", "spot": true})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); - app.run("paint.setFill", json!({"ids": [id], "swatch": "Last Ink"})).unwrap(); - app.run("select.none", json!({})).unwrap(); - let ctx = context(); - let tile = |ctx: &egui::Context| ctx.read_response(egui::Id::unique(("swatch-tile", "Last Ink"))).unwrap().rect; - frame(&mut app, &ctx, vec![], 0.0, show); - frame(&mut app, &ctx, vec![], 0.1, show); - let far = tile(&ctx).top(); - set_pstate(&ctx, "swatch-selected", vec!["Red".to_string()]); - app.run("select.set", json!({"ids": [id]})).unwrap(); - for t in [0.2, 0.3, 0.4] { - frame(&mut app, &ctx, vec![], t, show); - } - let near = tile(&ctx).top(); - assert!(near < far - 100.0, "scrolled to the swatch: {far} → {near}"); - assert!(selected(&ctx).is_empty(), "the art's swatch is highlighted"); - // Once: later frames leave the scroll where the user puts it. - assert_eq!(focus_swatch(&app, &egui::Ui::new(ctx.clone(), egui::Id::unique("t"), egui::UiBuilder::new()), Some("Last Ink")), None); - } - - /// Deselecting art keeps its swatch highlighted (not the White new art gets) until the paints for - /// new art change; selecting other art highlights its swatch. - #[test] - fn deselecting_keeps_the_arts_swatch_highlighted() { - let mut app = app(); - app.run("swatch.new", json!({"name": "Ink 185", "color": "#e4002b", "spot": true})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); - app.run("paint.setFill", json!({"ids": [id], "swatch": "Ink 185"})).unwrap(); - app.run("select.none", json!({})).unwrap(); - // New art's paints back to the defaults, as for art that came in with a file. - app.run("paint.default", json!({})).unwrap(); - let ctx = context(); - let ui = egui::Ui::new(ctx.clone(), egui::Id::unique("t"), egui::UiBuilder::new()); - let shown = |app: &VectorcraftApp| kept_swatch(app, &ui, active_swatch(app)); - assert_ne!(shown(&app).as_deref(), Some("Ink 185"), "nothing selected yet: new art's paint"); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert_eq!(shown(&app).as_deref(), Some("Ink 185")); - app.run("select.none", json!({})).unwrap(); - assert_eq!(shown(&app).as_deref(), Some("Ink 185"), "kept after deselecting"); - assert_eq!(shown(&app).as_deref(), Some("Ink 185"), "and on later frames"); - // A colour picked with nothing selected is new art's: its swatch shows. - app.run("paint.setFill", json!({"swatch": "Black"})).unwrap(); - assert_eq!(shown(&app).as_deref(), Some("Black")); - // Other art selected (a white one) shows its swatch, and keeps it once deselected. - let white = app.run("shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); - app.run("paint.setFill", json!({"ids": [white.clone()], "swatch": "White"})).unwrap(); - app.run("select.set", json!({"ids": [white]})).unwrap(); - assert_eq!(shown(&app).as_deref(), Some("White")); - app.run("select.none", json!({})).unwrap(); - assert_eq!(shown(&app).as_deref(), Some("White")); - } - - /// A selected image, or text in a colour no swatch has, highlights no swatch (not None); text in - /// a swatch's colour highlights that swatch. - #[test] - fn images_and_unswatched_text_highlight_no_swatch() { - let mut app = app(); - let mut png = vec![]; - image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) - .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .unwrap(); - let data = vectorcraft_format::base64_encode(&png); - app.run("file.place", json!({"name": "a.png", "dataBase64": data, "link": false})).unwrap(); - assert_eq!(active_swatch(&app), None, "an image: no swatch"); - let text = app.run("text.create", json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [text]})).unwrap(); - app.run("paint.setFill", json!({"color": "#3a7b5c"})).unwrap(); - assert_eq!(active_swatch(&app), None, "a colour of its own: no swatch"); - app.run("swatch.new", json!({"name": "Moss", "color": "#3a7b5c"})).unwrap(); - assert_eq!(active_swatch(&app).as_deref(), Some("Moss"), "a swatch's colour"); - } - - #[test] - fn medium_tiles_match_measured_metrics() { - assert_eq!(View::MediumThumb.tile(), (15.5, 17.0)); - assert!(View::SmallList.is_list() && !View::LargeThumb.is_list()); - } - - #[test] - fn lab_spot_tiles_show_the_colour_the_spot_colors_options_pick() { - let mut app = app(); - let lab = Color::lab(60.0, 50.0, 20.0); - app.run("swatch.new", json!({"name": "Ink", "color": lab, "spot": true})).unwrap(); - let tile = |app: &VectorcraftApp| { - let ink = entries(app, Kind::All, "Ink").into_iter().find(|e| e.name() == "Ink"); - let Some(Entry::Swatch { paint, .. }) = ink else { panic!("the Ink tile") }; - paint.color().unwrap() - }; - assert_eq!(tile(&app), lab); - app.run("swatch.spotOptions", json!({"useLab": false})).unwrap(); - assert_eq!(tile(&app), app.session.active().unwrap().doc.global_color("Ink").unwrap()); - assert!(matches!(tile(&app), Color::Cmyk { .. })); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs new file mode 100644 index 000000000..416cd2d76 --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -0,0 +1,716 @@ +use super::*; +use crate::dialogs::swatch_options::KIND; +use egui::{Event, Modifiers, PointerButton, Pos2}; +use vectorcraft_color::Color; +use vectorcraft_engine::Session; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app +} + +/// One frame of `draw` on a persistent context (clicks need the previous frame's layout). +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) { + frame_with(app, ctx, events, Modifiers::NONE, time, draw); +} + +/// [`frame`] with `modifiers` held. +fn frame_with( + app: &mut VectorcraftApp, + ctx: &egui::Context, + events: Vec, + modifiers: Modifiers, + time: f64, + draw: fn(&mut VectorcraftApp, &mut Ui), +) { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx +} + +fn double_click(at: Pos2) -> Vec { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + vec![Event::PointerMoved(at), button(true), button(false), button(true), button(false)] +} + +/// Double-click the thumbnail of swatch `name` in the panel. +fn double_click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) { + frame(app, ctx, vec![], 0.0, show); + let tile = ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect; + frame(app, ctx, double_click(tile.center()), 1.0, show); +} + +/// Click the thumbnail of swatch or group `name` with `modifiers` held (frames at `time`, `time + 0.5`). +fn click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { + frame(app, ctx, vec![], time, show); + let at = ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + frame_with(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5, show); +} + +fn selected(ctx: &egui::Context) -> Vec { + pstate(ctx, "swatch-selected") +} + +fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) { + frame(app, ctx, vec![], 2.0, |app, ui| crate::dialogs::show(app, ui.ctx())); +} + +#[test] +fn double_click_opens_swatch_options_and_ok_edits_linked_art() { + let mut app = app(); + app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap(); + let ctx = context(); + double_click_tile(&mut app, &ctx, "Red"); + let d = app.ui.dialog.as_ref().expect("double-click opens Swatch Options"); + assert_eq!((d.kind.as_str(), d.str("__swatch").as_str(), d.str("mode").as_str()), (KIND, "Red", "rgb")); + // The dialog previews live on the canvas; OK keeps the edit as one undo step. + let undo = app.session.doc().unwrap().history.undo.len(); + dialog_frame(&mut app, &ctx); + assert!(app.session.in_interaction(), "Preview runs as an interaction"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("color".into(), json!("#00ff00")); + d.fields.insert("name".into(), json!("Signal")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + let doc = &app.session.doc().unwrap().doc; + assert_eq!(doc.swatch("Signal").and_then(|w| w.paint.color()).map(|c| c.to_hex()), Some("#00ff00".into())); + let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()), tint: 1.0 }); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); +} + +#[test] +fn cancel_rolls_the_preview_back_and_other_kinds_open_their_editors() { + let mut app = app(); + let ctx = context(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("ui.swatchOptions", json!({"name": "Bright Blue"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#000000")); + dialog_frame(&mut app, &ctx); + assert_ne!(app.session.doc().unwrap().doc, before, "previewed"); + crate::dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc, before); + // A gradient opens Swatch Options with its name (and the gradient shown): OK renames it. + double_click_tile(&mut app, &ctx, "Sunset"); + let d = app.ui.dialog.as_mut().expect("Swatch Options for a gradient"); + assert!(d.kind == KIND && d.fields.contains_key("__gradient") && !d.fields.contains_key("color")); + d.fields.insert("name".into(), json!("Evening")); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(matches!(app.session.doc().unwrap().doc.swatch("Evening").map(|w| &w.paint), Some(Paint::Gradient(_)))); + // Patterns open pattern editing, None nothing. + assert!(app.run("ui.swatchOptions", json!({"name": "[None]"})).is_err()); + assert!(app.run("ui.swatchOptions", json!({})).is_err()); +} + +#[test] +fn modifier_clicks_select_several_swatches_and_groups() { + let mut app = app(); + let ctx = context(); + click_tile(&mut app, &ctx, "Red", Modifiers::NONE, 0.0); + assert_eq!(selected(&ctx), ["Red"]); + assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "a plain click applies"); + click_tile(&mut app, &ctx, "Amber", Modifiers::SHIFT, 2.0); + assert_eq!(selected(&ctx), ["Red", "Orange Red", "Orange", "Amber"], "Shift extends over the shown order"); + click_tile(&mut app, &ctx, "Orange", Modifiers::COMMAND, 4.0); + click_tile(&mut app, &ctx, "Brights", Modifiers::COMMAND, 6.0); + assert_eq!(selected(&ctx), ["Red", "Orange Red", "Amber", "Brights"], "Cmd toggles swatches and colour groups"); + assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "modifier clicks only select"); + click_tile(&mut app, &ctx, "Grays", Modifiers::NONE, 8.0); + assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone"); +} + +/// The hex colors of the stops of the selection's gradient fill, and its stroke's color. +fn stops_and_stroke(app: &VectorcraftApp) -> (Vec, String) { + let (fill, stroke) = crate::panels::current_paints(app); + let Paint::Gradient(g) = fill else { panic!("the fill is still a gradient: {fill:?}") }; + (g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(), stroke.color().map(|c| c.to_hex()).unwrap_or_default()) +} + +#[test] +fn alt_click_recolors_the_selected_gradient_stop() { + let mut app = app(); + let ctx = context(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000"})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], "#000000".into()), "the stop, not the stroke"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(app.session.selected_stop(), Some(1), "the stop stays selected"); + assert!(app.session.fill_active, "the Fill proxy stays in front"); + // With no stop selected, Alt-click paints the inactive proxy. + app.run("gradient.selectStop", json!({"index": null})).unwrap(); + click_tile(&mut app, &ctx, "Orange", Modifiers::ALT, 2.0); + let orange = app.session.doc().unwrap().doc.swatch("Orange").and_then(|w| w.paint.color()).unwrap().to_hex(); + assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], orange)); +} + +#[test] +fn alt_click_links_the_selected_stop_to_a_global_swatch() { + let mut app = app(); + let ctx = context(); + app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 0})).unwrap(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("a gradient fill") }; + assert_eq!(g.gradient.stops[0].swatch.as_deref(), Some("Red"), "linked as a drop on the ramp links it"); + app.run("swatch.edit", json!({"name": "Red", "color": "#00ff00"})).unwrap(); + assert_eq!(stops_and_stroke(&app).0, ["#00ff00", "#000000"], "editing the swatch recolors the stop"); +} + +#[test] +fn alt_click_recolors_the_selected_freeform_point() { + let mut app = app(); + let ctx = context(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap(); + app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); + app.run("paint.freeform.selectPoint", json!({"index": 1})).unwrap(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + let (fill, stroke) = crate::panels::current_paints(&app); + let Paint::Gradient(g) = fill else { panic!("still a gradient: {fill:?}") }; + assert_eq!(g.freeform.expect("placed points").points[1].color.to_hex(), "#ed1c24"); + assert_eq!(stroke.color().map(|c| c.to_hex()).as_deref(), Some("#000000"), "the stroke keeps its color"); + assert_eq!(app.session.selected_freeform_point(), Some(1), "the point stays selected"); +} + +#[test] +fn delete_asks_then_removes_the_selection_and_unlinks_art() { + let mut app = app(); + let ctx = context(); + app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap(); + delete(&mut app, vec!["Red".into(), "Brights".into()], false); + let d = app.ui.dialog.as_ref().expect("delete asks first"); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::KIND, "Delete these 2 swatches and groups?")); + dialog_frame(&mut app, &ctx); + assert!(app.session.doc().unwrap().doc.swatch("Red").is_some(), "nothing is deleted before OK"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.doc().unwrap().doc; + assert!(doc.swatch("Red").is_none() && doc.swatch("Bright Red").is_none() && doc.swatch_groups.iter().all(|g| g.name != "Brights")); + let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill, Paint::solid(Color::from_hex("#ed1c24").unwrap()), "the art keeps its colour, unlinked"); + // Cancel keeps the swatch; Alt-click (now) deletes without asking. + delete(&mut app, vec!["Orange".into()], false); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("message"), "Delete “Orange”?"); + crate::dialogs::cancel(&mut app); + assert!(app.session.doc().unwrap().doc.swatch("Orange").is_some()); + delete(&mut app, vec!["Orange".into()], true); + assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("Orange").is_none()); + assert_eq!(deletable(&["[None]".into(), REGISTRATION.into(), "Grays".into()]), ["Grays"]); +} + +#[test] +fn new_swatch_dialog_prefills_the_active_colour_and_saves_into_the_selected_group() { + let mut app = app(); + let ctx = context(); + app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap(); + new_swatch(&mut app, &["Bright Red".into()], Modifiers::COMMAND); + let d = app.ui.dialog.as_ref().expect("New Swatch opens its dialog"); + assert_eq!( + (d.kind.as_str(), d.str("name").as_str(), d.str("group").as_str()), + (crate::dialogs::new_swatch::KIND, "R=255 G=128 B=0", "Brights") + ); + assert!(d.bool("spot") && d.bool("global"), "Cmd/Ctrl-click makes a spot colour"); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let w = doc.swatch("R=255 G=128 B=0").expect("created"); + assert!(w.spot && doc.swatch_group_of(&w.name).is_some_and(|g| doc.swatch_groups[g].name == "Brights")); + // Alt-click saves at once; a gradient takes only a name and stays out of colour groups. + app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap(); + new_swatch(&mut app, &["Brights".into()], Modifiers::ALT); + assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("New Gradient Swatch 1").is_some()); + new_swatch(&mut app, &[], Modifiers::NONE); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("name").as_str(), d.fields.contains_key("__paint")), ("New Gradient Swatch 2", true)); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.swatch("New Gradient Swatch 2").is_some_and(|w| matches!(w.paint, Paint::Gradient(_)))); +} + +#[test] +fn cmd_shift_click_on_new_swatch_makes_a_global_process_color() { + let mut app = app(); + let ctx = context(); + let global_process = Modifiers::COMMAND | Modifiers::SHIFT; + app.run("paint.setFill", json!({"color": "#2a6fb0"})).unwrap(); + new_swatch(&mut app, &[], global_process); + let d = app.ui.dialog.as_ref().expect("New Swatch opens its dialog"); + assert!(d.bool("global") && !d.bool("spot"), "Cmd/Ctrl+Shift-click opens a global process color"); + let name = d.str("name"); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let w = app.session.doc().unwrap().doc.swatch(&name).cloned().expect("created"); + assert!(w.global && !w.spot); + // With Alt also held it saves at once. + app.run("paint.setFill", json!({"color": "#00a050"})).unwrap(); + new_swatch(&mut app, &[], global_process | Modifiers::ALT); + assert!(app.ui.dialog.is_none()); + let w = app.session.doc().unwrap().doc.swatches.last().cloned().unwrap(); + assert_eq!(w.paint.color().map(|c| c.to_hex()), Some("#00a050".into())); + assert!(w.global && !w.spot); + // A gradient saves as a gradient swatch whether or not Cmd/Ctrl and Shift are held. + app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap(); + new_swatch(&mut app, &[], Modifiers::COMMAND | Modifiers::ALT); + new_swatch(&mut app, &[], global_process | Modifiers::ALT); + let doc = &app.session.doc().unwrap().doc; + for n in ["New Gradient Swatch 1", "New Gradient Swatch 2"] { + assert!(doc.swatch(n).is_some_and(|w| matches!(w.paint, Paint::Gradient(_)) && !w.global && !w.spot), "{n}"); + } +} + +/// In a CMYK document a swatch made from an RGB fill keeps RGB, as the Color Guide's copies +/// do: Alt-click on New Swatch, the New Swatch dialog, the Fill proxy dropped on the panel and +/// Alt-dropped on a color swatch, and the same for a gradient's stops. +#[test] +fn swatches_made_from_an_rgb_fill_stay_rgb_in_a_cmyk_document() { + let mut app = app(); + let ctx = context(); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + let newest = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatches.last().map(|w| w.paint.clone()).expect("a swatch"); + let stop_colors = |p: &Paint| match p { + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect::>(), + other => panic!("a gradient: {other:?}"), + }; + let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| { + frame(app, &ctx, vec![], time, show); + let at = tile_center(&ctx, name); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(paint)); + let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m }; + frame_with(app, &ctx, vec![Event::PointerMoved(at), release], m, time + 0.5, show); + }; + let rgb = Color::from_hex("#2a6fb0").unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("paint.setFill", json!({"color": "#2a6fb0", "keepModel": true})).unwrap(); + new_swatch(&mut app, &[], Modifiers::ALT); + assert_eq!(newest(&app), Paint::solid(rgb), "Alt-click on New Swatch"); + new_swatch(&mut app, &[], Modifiers::NONE); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(newest(&app), Paint::solid(rgb), "the New Swatch dialog"); + drop_on(&mut app, "Red", Paint::solid(rgb), Modifiers::NONE, 3.0); + assert_eq!(newest(&app), Paint::solid(rgb), "the proxy dropped on the panel: {}", app.ui.status); + drop_on(&mut app, "Red", Paint::solid(rgb), Modifiers::ALT, 5.0); + assert_eq!(app.session.doc().unwrap().doc.swatch("Red").map(|w| w.paint.color()), Some(Some(rgb)), "Alt-dropped on Red"); + // A gradient's RGB stops. + let stops = json!([{"offset": 0, "color": "#ff8000"}, {"offset": 1, "color": "#2a6fb0"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": true})).unwrap(); + let grad = crate::panels::current_paints(&app).0; + let rgb_stops = vec![Color::from_hex("#ff8000").unwrap(), rgb]; + assert_eq!(stop_colors(&grad), rgb_stops); + new_swatch(&mut app, &[], Modifiers::ALT); + assert_eq!(stop_colors(&newest(&app)), rgb_stops, "Alt-click on New Swatch"); + new_swatch(&mut app, &[], Modifiers::NONE); + frame(&mut app, &ctx, vec![], 6.0, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(stop_colors(&newest(&app)), rgb_stops, "the New Swatch dialog"); + drop_on(&mut app, "Red", grad.clone(), Modifiers::NONE, 7.0); + assert_eq!(stop_colors(&newest(&app)), rgb_stops, "the gradient dropped on the panel"); + drop_on(&mut app, "Ocean", grad, Modifiers::ALT, 9.0); + let ocean = app.session.doc().unwrap().doc.swatch("Ocean").map(|w| w.paint.clone()).expect("Ocean"); + assert_eq!(stop_colors(&ocean), rgb_stops, "Alt-dropped on Ocean"); +} + +#[test] +fn new_color_group_dialog_makes_a_group_from_swatches_or_artwork() { + let mut app = app(); + let ctx = context(); + app.run("ui.newColorGroup", json!({"swatches": ["Red", "Sunset"]})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("name").as_str(), d.bool("fromArtwork")), (crate::dialogs::new_color_group::KIND, "Color Group", false)); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group").expect("group made"); + assert_eq!(g.swatches.iter().map(|w| w.name.as_str()).collect::>(), ["Red"], "gradients stay out"); + // With art selected (and no swatches) it starts from the artwork: its colours become global. + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "color": "#123456"})).unwrap(); + app.run("paint.setStroke", json!({"ids": [id], "none": true})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("ui.newColorGroup", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("name").as_str(), d.bool("fromArtwork"), d.bool("toGlobal")), ("Color Group 2", true, true)); + dialog_frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group 2").expect("group made"); + assert_eq!(g.swatches.iter().map(|w| (w.name.as_str(), w.global)).collect::>(), [("R=18 G=52 B=86", true)]); + let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()), tint: 1.0 }); +} + +/// The centre of the tile or row of `name` as laid out by the last frame. +fn tile_center(ctx: &egui::Context, name: &str) -> Pos2 { + ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center() +} + +/// Press at `from`, drag to `to` and release there, a frame per step from `time`. +fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, time: f64) { + drag_with(app, ctx, from, to, Modifiers::NONE, time); +} + +/// [`drag`] with `modifiers` held. +fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) { + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show); + frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show); + frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show); + frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show); +} + +fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec { + let d = &app.session.active().unwrap().doc; + let list = match group { + Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches, + None => &*d.swatches, + }; + list.iter().map(|w| w.name.clone()).collect() +} + +#[test] +fn find_field_filters_and_shift_extends_over_what_is_shown() { + let mut app = app(); + let ctx = context(); + set_pstate(&ctx, "swatch-show-find", true); + ctx.data_mut(|d| d.insert_temp(find_id(), "BRIGHT".to_string())); + frame(&mut app, &ctx, vec![], 0.0, show); + assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "Red"))).is_none(), "Red doesn't match"); + click_tile(&mut app, &ctx, "Bright Red", Modifiers::NONE, 1.0); + click_tile(&mut app, &ctx, "Bright Blue", Modifiers::SHIFT, 2.0); + assert_eq!(selected(&ctx), ["Bright Red", "Bright Yellow", "Bright Green", "Bright Blue"]); + // A group whose name matches shows all its swatches. + ctx.data_mut(|d| d.insert_temp(find_id(), "grays".to_string())); + frame(&mut app, &ctx, vec![], 3.0, show); + frame(&mut app, &ctx, vec![], 3.5, show); + assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "K=50"))).is_some()); + assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "Bright Red"))).is_none()); +} + +#[test] +fn list_view_rows_take_double_clicks_and_folder_clicks_select_the_group() { + let mut app = app(); + let ctx = context(); + set_pstate(&ctx, "swatch-view", View::SmallList); + double_click_tile(&mut app, &ctx, "Red"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "double-click in a list opens Swatch Options"); + let d = &app.session.active().unwrap().doc; + let sel = vec!["Brights".to_string()]; + assert!(highlighted(d, &sel).contains(&"Bright Violet"), "a selected folder highlights its swatches"); + assert_eq!(describe(&Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, false), "Global Process Color, CMYK"); + assert_eq!(describe(&Paint::solid(Color::gray(0.5)), true, true), "Spot Color, Grayscale"); +} + +#[test] +fn dragging_tiles_reorders_and_moves_into_groups_as_one_undo_step_each() { + let mut app = app(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + let undo = app.session.doc().unwrap().history.undo.len(); + // Onto the left half of Red: before it. + let (from, red) = (tile_center(&ctx, "Amber"), tile_center(&ctx, "Red")); + drag(&mut app, &ctx, from, red - vec2(4.0, 0.0), 1.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Amber"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Onto a folder: into that group, at its end. (Two frames, so tile rects are current.) + frame(&mut app, &ctx, vec![], 2.0, show); + frame(&mut app, &ctx, vec![], 2.5, show); + drag(&mut app, &ctx, tile_center(&ctx, "Amber"), tile_center(&ctx, "Grays"), 3.0); + assert_eq!(names_of(&app, Some("Grays")).last().map(String::as_str), Some("Amber")); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + // A selection drags together, in panel order; a gradient can't join a group. + set_pstate(&ctx, "swatch-selected", vec!["Lime".to_string(), "Sunset".to_string(), "Orange".to_string()]); + frame(&mut app, &ctx, vec![], 4.0, show); + frame(&mut app, &ctx, vec![], 4.5, show); + drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Bright Red") - vec2(4.0, 0.0), 5.0); + assert_eq!(names_of(&app, Some("Brights"))[..3], ["Orange", "Lime", "Bright Red"]); + assert!(names_of(&app, None).contains(&"Sunset".to_string())); + // Dropped back on itself: nothing moves. + let (before, undo) = (names_of(&app, None), app.session.doc().unwrap().history.undo.len()); + frame(&mut app, &ctx, vec![], 6.0, show); + frame(&mut app, &ctx, vec![], 6.5, show); + let white = tile_center(&ctx, "White"); + drag(&mut app, &ctx, white, white + vec2(2.0, 0.0), 7.0); + assert_eq!((names_of(&app, None), app.session.doc().unwrap().history.undo.len()), (before, undo)); +} + +#[test] +fn art_dropped_on_the_panel_becomes_a_pattern_swatch() { + let mut app = app(); + let id = app.run("shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 9, "points": 5})).unwrap()["id"].as_u64().unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + // Released over a swatch tile: the list takes it. + let at = tile_center(&ctx, "Bright Red"); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])); + let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); + let patterns = app.run("pattern.list", json!({})).unwrap(); + assert_eq!(patterns["patterns"].as_array().map(Vec::len), Some(1), "{patterns}"); + assert_eq!(patterns["editing"], Value::Null, "no pattern editing mode"); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.node(vectorcraft_doc::NodeId(id)), before.node(vectorcraft_doc::NodeId(id)), "the art stays as it was"); + assert!(egui::DragAndDrop::payload::(&ctx).is_none()); +} + +#[test] +fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { + let mut app = app(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + let at = tile_center(&ctx, "Bright Red"); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(Paint::solid(Color::from_hex("#123456").unwrap()))); + let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); + assert_eq!( + names_of(&app, Some("Brights")).last().map(String::as_str), + Some("R=18 G=52 B=86"), + "dropped on a group's colour: into that group" + ); + assert!(egui::DragAndDrop::payload::(&ctx).is_none()); +} + +#[test] +fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() { + let mut app = app(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + let grad = |c: &str| { + let stops = + vec![vectorcraft_color::GradientStop::new(0.0, Color::from_hex(c).unwrap()), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)]; + Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::new(GradientKind::Radial, stops)))) + }; + let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| { + let at = tile_center(&ctx, name); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(paint)); + let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m }; + frame_with(app, &ctx, vec![Event::PointerMoved(at), release], m, time, show); + }; + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + drop_on(&mut app, "Ocean", grad("#ff0000"), Modifiers::ALT, 1.0); + let d = &app.session.doc().unwrap().doc; + let Some(Paint::Gradient(g)) = d.swatch("Ocean").map(|w| &w.paint) else { panic!("Ocean is a gradient") }; + assert_eq!((g.gradient.kind, d.swatches_iter().count()), (GradientKind::Radial, count), "replaced, not added"); + // Without Alt, or on a swatch of another kind, it becomes a new swatch. + frame(&mut app, &ctx, vec![], 2.0, show); + drop_on(&mut app, "Red", grad("#00ff00"), Modifiers::ALT, 3.0); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1); + assert!(app.session.doc().unwrap().doc.swatch("Red").is_some_and(|w| w.paint.color().is_some())); + // A tint swatch only takes a new name, so a color Alt-dropped on one becomes a new swatch. + app.run("swatch.edit", json!({"name": "Orange", "global": true})).unwrap(); + app.run("swatch.new", json!({"swatch": "Orange", "tint": 40})).unwrap(); + frame(&mut app, &ctx, vec![], 4.0, show); + frame(&mut app, &ctx, vec![], 4.5, show); + drop_on(&mut app, "Orange 40%", Paint::solid(Color::from_hex("#123456").unwrap()), Modifiers::ALT, 5.0); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 3, "{}", app.ui.status); + assert!(d.swatch("Orange 40%").is_some_and(|w| w.tint_of().is_some())); +} + +#[test] +fn alt_dragging_a_swatch_onto_another_of_its_kind_replaces_its_color() { + let mut app = app(); + let ctx = context(); + app.run("swatch.edit", json!({"name": "Orange", "global": true})).unwrap(); + app.run("swatch.new", json!({"swatch": "Orange", "tint": 40})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Orange"})).unwrap(); + frame(&mut app, &ctx, vec![], 0.0, show); + frame(&mut app, &ctx, vec![], 0.5, show); + let doc = |app: &VectorcraftApp| app.session.doc().unwrap().doc.clone(); + let (before, undo) = (names_of(&app, None), app.session.doc().unwrap().history.undo.len()); + let red = doc(&app).swatch("Red").and_then(|w| w.paint.color()).unwrap(); + drag_with(&mut app, &ctx, tile_center(&ctx, "Red"), tile_center(&ctx, "Orange"), Modifiers::ALT, 1.0); + let d = doc(&app); + let orange = d.swatch("Orange").expect("Orange keeps its name"); + assert_eq!((orange.paint.color(), orange.global), (Some(red), true), "Orange takes Red's color"); + assert_eq!(names_of(&app, None), before, "Red stays and Orange keeps its place"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + let fill = d.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + assert_eq!(fill.color(), Some(red), "art linked to Orange takes the new color"); + // A gradient dropped on a color, and a swatch dropped without Alt, move as before. + frame(&mut app, &ctx, vec![], 2.0, show); + frame(&mut app, &ctx, vec![], 2.5, show); + drag_with(&mut app, &ctx, tile_center(&ctx, "Sunset"), tile_center(&ctx, "Amber") - vec2(4.0, 0.0), Modifiers::ALT, 3.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Amber").unwrap() - 1], "Sunset"); + assert!(doc(&app).swatch("Amber").is_some_and(|w| w.paint.color().is_some())); + frame(&mut app, &ctx, vec![], 4.0, show); + frame(&mut app, &ctx, vec![], 4.5, show); + drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Red") - vec2(4.0, 0.0), 5.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Lime"); + assert_ne!(doc(&app).swatch("Red").and_then(|w| w.paint.color()), doc(&app).swatch("Lime").and_then(|w| w.paint.color())); + // A tint swatch only takes a new name, so a swatch Alt-dropped on one moves. + frame(&mut app, &ctx, vec![], 6.0, show); + frame(&mut app, &ctx, vec![], 6.5, show); + drag_with(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Orange 40%") - vec2(4.0, 0.0), Modifiers::ALT, 7.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Orange 40%").unwrap() - 1], "Lime", "{}", app.ui.status); + assert_eq!(doc(&app).swatch("Orange 40%").and_then(|w| w.tint_of().map(|(base, _)| base.to_string())).as_deref(), Some("Orange")); +} + +#[test] +fn menu_targets_follow_the_panel_selection() { + let app = app(); + let sel: Vec = ["Red", "Sunset", "Grays", "Orange", "Brights"].map(String::from).to_vec(); + assert_eq!(mergeable(&app, &sel), ["Red", "Orange"], "Merge Swatches takes the selected colours, first kept"); + assert_eq!(selected_group(&app, &sel).as_deref(), Some("Grays")); + assert_eq!(selected_group(&app, &sel[..2]), None); +} + +#[test] +fn kind_filter() { + let solid = Paint::solid(Color::BLACK); + let grad = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); + assert!(Kind::All.accepts(&grad, false)); + assert!(Kind::Color.accepts(&solid, false)); + assert!(!Kind::Color.accepts(&grad, false)); + assert!(Kind::Gradient.accepts(&grad, true)); + assert!(Kind::Groups.accepts(&solid, true)); + assert!(!Kind::Groups.accepts(&solid, false)); +} + +#[test] +fn the_active_tint_or_gradient_swatch_is_highlighted_and_a_tint_edits_its_base() { + let mut app = app(); + app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap(); + assert_eq!(active_swatch(&app).as_deref(), Some("Ink"), "no tint swatch yet: its base"); + app.run("swatch.new", json!({})).unwrap(); + assert_eq!(active_swatch(&app).as_deref(), Some("Ink 40%")); + let tint = entries(&app, Kind::Color, "Ink 40").into_iter().find(|e| e.name() == "Ink 40%").unwrap(); + assert!(matches!(tint, Entry::Swatch { global: true, spot: true, .. }), "a tint swatch shows its base's kind"); + app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap(); + assert_eq!(active_swatch(&app).as_deref(), Some("Sunset"), "the applied gradient swatch"); + app.run("ui.swatchOptions", json!({"name": "Ink 40%"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("__swatch").as_str()), (KIND, "Ink"), "a tint swatch opens its base's options"); +} + +/// Selecting art scrolls the tiles to its swatch, once, and clears the panel's own selection so +/// that swatch is the one highlighted. +#[test] +fn selecting_art_scrolls_to_its_swatch() { + let mut app = app(); + let colors: Vec = (0..400).map(|i| format!("#{:02x}{:02x}{:02x}", i % 256, (i / 2) % 256, 128)).collect(); + app.run("swatch.new", json!({ "colors": colors })).unwrap(); + app.run("swatch.new", json!({"name": "Last Ink", "color": "#123456", "spot": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Last Ink"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = context(); + let tile = |ctx: &egui::Context| ctx.read_response(egui::Id::unique(("swatch-tile", "Last Ink"))).unwrap().rect; + frame(&mut app, &ctx, vec![], 0.0, show); + frame(&mut app, &ctx, vec![], 0.1, show); + let far = tile(&ctx).top(); + set_pstate(&ctx, "swatch-selected", vec!["Red".to_string()]); + app.run("select.set", json!({"ids": [id]})).unwrap(); + for t in [0.2, 0.3, 0.4] { + frame(&mut app, &ctx, vec![], t, show); + } + let near = tile(&ctx).top(); + assert!(near < far - 100.0, "scrolled to the swatch: {far} → {near}"); + assert!(selected(&ctx).is_empty(), "the art's swatch is highlighted"); + // Once: later frames leave the scroll where the user puts it. + assert_eq!(focus_swatch(&app, &egui::Ui::new(ctx.clone(), egui::Id::unique("t"), egui::UiBuilder::new()), Some("Last Ink")), None); +} + +/// Deselecting art keeps its swatch highlighted (not the White new art gets) until the paints for +/// new art change; selecting other art highlights its swatch. +#[test] +fn deselecting_keeps_the_arts_swatch_highlighted() { + let mut app = app(); + app.run("swatch.new", json!({"name": "Ink 185", "color": "#e4002b", "spot": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Ink 185"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + // New art's paints back to the defaults, as for art that came in with a file. + app.run("paint.default", json!({})).unwrap(); + let ctx = context(); + let ui = egui::Ui::new(ctx.clone(), egui::Id::unique("t"), egui::UiBuilder::new()); + let shown = |app: &VectorcraftApp| kept_swatch(app, &ui, active_swatch(app)); + assert_ne!(shown(&app).as_deref(), Some("Ink 185"), "nothing selected yet: new art's paint"); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert_eq!(shown(&app).as_deref(), Some("Ink 185")); + app.run("select.none", json!({})).unwrap(); + assert_eq!(shown(&app).as_deref(), Some("Ink 185"), "kept after deselecting"); + assert_eq!(shown(&app).as_deref(), Some("Ink 185"), "and on later frames"); + // A colour picked with nothing selected is new art's: its swatch shows. + app.run("paint.setFill", json!({"swatch": "Black"})).unwrap(); + assert_eq!(shown(&app).as_deref(), Some("Black")); + // Other art selected (a white one) shows its swatch, and keeps it once deselected. + let white = app.run("shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [white.clone()], "swatch": "White"})).unwrap(); + app.run("select.set", json!({"ids": [white]})).unwrap(); + assert_eq!(shown(&app).as_deref(), Some("White")); + app.run("select.none", json!({})).unwrap(); + assert_eq!(shown(&app).as_deref(), Some("White")); +} + +/// A selected image, or text in a colour no swatch has, highlights no swatch (not None); text in +/// a swatch's colour highlights that swatch. +#[test] +fn images_and_unswatched_text_highlight_no_swatch() { + let mut app = app(); + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "a.png", "dataBase64": data, "link": false})).unwrap(); + assert_eq!(active_swatch(&app), None, "an image: no swatch"); + let text = app.run("text.create", json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [text]})).unwrap(); + app.run("paint.setFill", json!({"color": "#3a7b5c"})).unwrap(); + assert_eq!(active_swatch(&app), None, "a colour of its own: no swatch"); + app.run("swatch.new", json!({"name": "Moss", "color": "#3a7b5c"})).unwrap(); + assert_eq!(active_swatch(&app).as_deref(), Some("Moss"), "a swatch's colour"); +} + +#[test] +fn medium_tiles_match_measured_metrics() { + assert_eq!(View::MediumThumb.tile(), (15.5, 17.0)); + assert!(View::SmallList.is_list() && !View::LargeThumb.is_list()); +} + +#[test] +fn lab_spot_tiles_show_the_colour_the_spot_colors_options_pick() { + let mut app = app(); + let lab = Color::lab(60.0, 50.0, 20.0); + app.run("swatch.new", json!({"name": "Ink", "color": lab, "spot": true})).unwrap(); + let tile = |app: &VectorcraftApp| { + let ink = entries(app, Kind::All, "Ink").into_iter().find(|e| e.name() == "Ink"); + let Some(Entry::Swatch { paint, .. }) = ink else { panic!("the Ink tile") }; + paint.color().unwrap() + }; + assert_eq!(tile(&app), lab); + app.run("swatch.spotOptions", json!({"useLab": false})).unwrap(); + assert_eq!(tile(&app), app.session.active().unwrap().doc.global_color("Ink").unwrap()); + assert!(matches!(tile(&app), Color::Cmyk { .. })); +} From 029e298aeb765080213f50c437d4650a77c1fa29 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:47 +0000 Subject: [PATCH 034/199] refactor(ui-egui): move menus.rs tests to menus/tests.rs --- crates/ui-egui/src/menus.rs | 711 +----------------------------- crates/ui-egui/src/menus/tests.rs | 708 +++++++++++++++++++++++++++++ 2 files changed, 709 insertions(+), 710 deletions(-) create mode 100644 crates/ui-egui/src/menus/tests.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index afeded96e..89162c521 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -4442,713 +4442,4 @@ pub fn menu_strings() -> std::collections::BTreeSet { } #[cfg(test)] -mod tests { - use super::*; - use crate::theme; - - /// One headless frame of the in-window menu bar; returns its titles (left to right) as - /// (rect, id of the title's popup). - fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(egui::Rect, egui::Id)> { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); - let mut layer = None; - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { - layer = Some(ui.layer_id()); - menu_bar(app, ui); - }); - out.textures_delta.clear(); - let layer = layer.unwrap(); - let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| { - vp.prev_pass - .widgets - .get_layer(layer) - .filter(|w| w.sense.senses_click() && w.rect.top() < 30.0) - // `egui::Popup::default_response_id` of the title's response. - .map(|w| (w.rect, w.id.with("popup"))) - .collect() - }); - titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left())); - titles - } - - fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec { - (0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect() - } - - /// File ends with Exit, running the app's own quit flow (unsaved documents are asked about). - #[test] - fn file_ends_with_exit() { - let tree = menu_tree(); - let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap(); - let last = file.iter().rev().find(|i| !matches!(i, Item::Sep)).unwrap(); - assert!(matches!(last, Item::Cmd("Exit", "app.quit", _)), "{last:?}"); - } - - /// No application menu in the window (as in Illustrator): the bar starts at File. - #[test] - fn the_bar_starts_at_file() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - let ctx = egui::Context::default(); - theme::install_fonts(&ctx); - theme::apply(&ctx, Default::default()); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { - menu_bar(&mut app, ui); - }); - out.textures_delta.clear(); - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut shown = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - let titles = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"]; - for t in titles { - assert!(shown.iter().any(|s| s == t), "{t} missing from {shown:?}"); - } - assert!(!shown.iter().any(|s| s == "VectorCraft"), "{shown:?}"); - } - - #[test] - fn hovering_another_title_switches_the_open_menu() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - let ctx = egui::Context::default(); - theme::install_fonts(&ctx); - theme::apply(&ctx, Default::default()); - bar_frame(&mut app, &ctx, vec![]); - let titles = bar_frame(&mut app, &ctx, vec![]); - // No application menu in the window: the bar starts at File on every platform. - assert_eq!(titles.len(), menu_tree().len() - 1); - let (file, edit, object) = (titles[0].0.center(), titles[1].0.center(), titles[2].0.center()); - let frames = |app: &mut VectorcraftApp, events: Vec| { - let mut t = bar_frame(app, &ctx, events); - for _ in 0..2 { - t = bar_frame(app, &ctx, vec![]); - } - t - }; - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - - // Nothing open: hovering a title opens nothing. - let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]); - assert!(open_titles(&ctx, &t).is_empty()); - - // Click File, then move onto Edit and on to Object: each opens in turn, alone. - frames(&mut app, vec![egui::Event::PointerMoved(file)]); - frames(&mut app, vec![button(file, true)]); - let t = frames(&mut app, vec![button(file, false)]); - assert_eq!(open_titles(&ctx, &t), vec![0], "a click opens File"); - let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]); - assert_eq!(open_titles(&ctx, &t), vec![1], "hovering Edit opens it and closes File"); - let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]); - assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Object opens it and closes Edit"); - - // A pointer resting on a title doesn't switch: File opened another way (the keyboard) - // stays open while the pointer stays still over Object. - egui::Popup::open_id(&ctx, t[0].1); - let t = frames(&mut app, vec![]); - assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone"); - } - - #[test] - fn a_popup_covering_a_title_does_not_switch_menus() { - let ctx = egui::Context::default(); - let p = egui::pos2(90.0, 12.0); - let mut reaches = (true, false); - // The popup becomes hit-testable once egui has laid it out: draw a few frames. - for _ in 0..3 { - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - let ctx = ui.ctx().clone(); - let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::unique("covered"), egui::Sense::click()); - let q = p + egui::vec2(0.0, 100.0); - let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::unique("free"), egui::Sense::click()); - egui::Area::new(egui::Id::unique("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| { - ui.allocate_space(egui::vec2(20.0, 20.0)); - }); - reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q)); - }); - out.textures_delta.clear(); - } - assert_eq!(reaches, (false, true)); - } - - #[test] - fn names_in_menus_are_not_interface_labels() { - for (id, label) in [ - ("file.openRecent1", "Layers.svg"), - ("view.goto2", "Layers"), - ("type.recentFont1", "Regular"), - ("text.setStyle", "Black"), - ("window.workspace", "Layers"), - ("plugin.dialog", "Group"), - ("window.userSwatchLibrary3", "Default"), - ] { - assert!(shows_a_name(id, label), "{id} {label}"); - } - for (id, label) in [("object.group", "Group"), ("window.workspace", "Essentials"), ("effect.apply", "Drop Shadow")] { - assert!(!shows_a_name(id, label), "{id} {label}"); - } - } - - #[test] - fn last_effect_dialog_and_view_toggles() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); - assert!(!enabled(&app, "effect.last")); - app.last_effect = Some(("distort.roughen".into(), json!({"size": 9}))); - assert!(enabled(&app, "effect.last")); - assert_eq!(dynamic_label(&app, "effect.last", "Last Effect…"), "Last Effect: Roughen…"); - app.run("effect.last", json!({})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.kind.as_str(), d.str("__effect"), d.f64("size", 0.0)), ("effect", "distort.roughen".to_string(), 9.0)); - for (id, on, off) in [ - ("view.textThreads", "Hide Text Threads", "Show Text Threads"), - ("view.gradientAnnotator", "Hide Gradient Annotator", "Show Gradient Annotator"), - ("view.livePaintGaps", "Show Live Paint Gaps", "Hide Live Paint Gaps"), - ("type.hiddenCharacters", "Show Hidden Characters", "Hide Hidden Characters"), - ("view.template", "Hide Template", "Show Template"), - ] { - assert_eq!(dynamic_label(&app, id, ""), on); - app.run(id, json!({})).unwrap(); - assert_eq!(dynamic_label(&app, id, ""), off); - } - } - - /// Edit › Undo and Edit › Redo name the step in the UI language as the History panel lists it - /// (`panels::history::step_name`), including a step named after an effect whose catalog holds - /// only the effect's menu row with an ellipsis, as Japanese does for Feather. - #[test] - fn undo_and_redo_name_the_step_as_the_history_panel_does() { - let ja = crate::i18n::Lang::from_code("ja").expect("ja registered"); - crate::i18n::set_current(ja); - let mut app = app_with_a_rectangle(); - app.run("effect.apply", json!({"effect": "stylize.feather"})).unwrap(); - assert_eq!(app.session.active().unwrap().history.undo.last().map(|h| h.label.as_str()), Some("Feather")); - let shown = |app: &VectorcraftApp, id: &'static str| { - let item = c(if id == "edit.undo" { "Undo" } else { "Redo" }, id); - match entry(app, &item) { - Some(Entry::Item(row)) => row.label.to_string(), - _ => panic!("{id} is not a menu item"), - } - }; - let step = |template: &str, name: &str| { - let name = crate::panels::history::step_name(ja, name).unwrap_or_else(|| name.to_string()); - crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &name)]) - }; - assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Feather")); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(shown(&app, "edit.redo"), step("Redo {name}", "Feather")); - assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Rectangle"), "a step with a plain row"); - crate::i18n::set_current(crate::i18n::Lang::EN); - assert_eq!(shown(&app, "edit.redo"), "Redo Feather"); - } - - /// Type › Optical Margin Alignment runs `type.opticalMarginAlignment`, needs a selection, and - /// is checked while the selected type has it on. Its check, and Type Orientation's, read the - /// type inside a selected group too. - #[test] - fn optical_margin_alignment_is_a_checked_type_menu_item() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let ids: Vec<&str> = menu_tree() - .into_iter() - .filter(|(menu, _)| *menu == "Type") - .flat_map(|(_, items)| items) - .filter_map(|item| match item { - Item::Cmd("Optical Margin Alignment", id, _) => Some(id), - _ => None, - }) - .collect(); - assert_eq!(ids, ["type.opticalMarginAlignment"]); - let id = "type.opticalMarginAlignment"; - assert_eq!(checked(&app, id, &Value::Null), Some(false)); - assert!(!enabled(&app, id)); - let text = app.run("text.create", json!({"x": 10, "y": 10, "text": "“Hi”"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [text]})).unwrap(); - assert!(enabled(&app, id)); - assert_eq!(checked(&app, id, &Value::Null), Some(false)); - app.run(id, json!({})).unwrap(); - assert_eq!(checked(&app, id, &Value::Null), Some(true)); - app.run(id, json!({})).unwrap(); - assert_eq!(checked(&app, id, &Value::Null), Some(false)); - // With the type in a selected group, the checks show the type the commands change. - app.run("object.group", json!({})).unwrap(); - app.run(id, json!({})).unwrap(); - assert_eq!(checked(&app, id, &Value::Null), Some(true)); - app.run("type.orientation.vertical", json!({})).unwrap(); - assert_eq!(checked(&app, "type.orientation.vertical", &Value::Null), Some(true)); - assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); - } - - #[test] - fn panel_ids_are_case_insensitive_and_labels_work() { - assert_eq!(normalize_panel("layers"), Some("layers")); - assert_eq!(normalize_panel("Layers"), Some("layers")); - assert_eq!(normalize_panel("LAYERS"), Some("layers")); - assert_eq!(normalize_panel("Swatches"), Some("swatches")); - assert_eq!(normalize_panel("swatches"), Some("swatches")); - assert_eq!(normalize_panel("Color Guide"), Some("colorGuide")); - assert_eq!(normalize_panel("colorguide"), Some("colorGuide")); - assert_eq!(normalize_panel(" Stroke "), Some("stroke")); - assert_eq!(normalize_panel("nope"), None); - - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("window.panel", json!({"panel": "Layers"})).unwrap(); - assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers); - app.run("window.panel", json!({"panel": "Swatches"})).unwrap(); - assert_eq!(app.ui.open_panel.as_deref(), Some("swatches")); - assert!(checked(&app, "window.panel", &json!({"panel": "Swatches"})).unwrap()); - assert!(app.run("window.panel", json!({"panel": "nope"})).is_err()); - // A collapsed dock pops the panel out of its icon, by canonical id. - app.ui.dock_collapsed = true; - app.run("window.panel", json!({"panel": "Layers"})).unwrap(); - assert_eq!(app.ui.open_panel.as_deref(), Some("layers")); - assert!(checked(&app, "window.panel", &json!({"panel": "Layers"})).unwrap()); - app.ui.dock_collapsed = false; - } - - #[test] - fn saved_views_store_and_restore_the_view() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - { - let v = app.view_mut().unwrap(); - v.zoom = 3.0; - v.center = vectorcraft_geom::Point::new(120.0, 80.0); - } - app.run("view.saved.new", json!({"name": "Detail"})).unwrap(); - assert!(enabled(&app, "view.goto1") && !enabled(&app, "view.goto2")); - assert_eq!(dynamic_label(&app, "view.goto1", ""), "Detail"); - app.view_mut().unwrap().zoom = 0.5; - app.run("view.goto1", json!({})).unwrap(); - let v = app.view().unwrap(); - assert_eq!((v.zoom, v.center.x, v.center.y), (3.0, 120.0, 80.0)); - // Unused slots stay out of the menu listing agents see. - assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2"))); - } - - /// A document's 25 views are all listed at the bottom of the View menu, by name, and the last - /// one brings back its zoom, centre and rotation after the view changed. - #[test] - fn every_saved_view_is_listed_at_the_bottom_of_the_view_menu() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - for i in 1..=25 { - { - let v = app.view_mut().unwrap(); - (v.zoom, v.center, v.rotation) = (i as f64 / 4.0, vectorcraft_geom::Point::new(10.0 * i as f64, -5.0 * i as f64), i as f64); - } - invoke(&mut app, "view.saved.new", json!({})); - crate::dialogs::confirm(&mut app).unwrap(); - } - assert_eq!(app.session.active().unwrap().doc.views.len(), 25); - let entries = menu_entries(&app); - let at = |id: &str| entries.iter().position(|e| e.command.as_deref() == Some(id)); - let (edit, first, last) = (at("view.saved.edit").unwrap(), at("view.goto1").unwrap(), at("view.goto25").unwrap()); - assert!(edit < first && last == first + 24, "listed in order after Edit Views…"); - assert!(enabled(&app, "view.goto25")); - assert_eq!(display_label(&app, "view.goto25", "Saved View"), "View 25"); - // The document's 26th view is refused, so there is never one the menu leaves out. - invoke(&mut app, "view.saved.new", json!({})); - assert!(crate::dialogs::confirm(&mut app).is_err()); - app.ui.dialog = None; - { - let v = app.view_mut().unwrap(); - (v.zoom, v.center, v.rotation) = (1.0, vectorcraft_geom::Point::new(0.0, 0.0), 0.0); - } - assert_eq!(app.run("view.goto25", json!({})).unwrap()["name"], "View 25"); - let v = app.view().unwrap(); - assert_eq!((v.zoom, v.center.x, v.center.y, v.rotation), (6.25, 250.0, -125.0, 25.0)); - } - - /// Edit Views… is off without a saved view and opens its list on the document's views, the - /// way Edit Selection… does. - #[test] - fn edit_views_opens_its_dialog_on_the_saved_views() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - assert!(!enabled(&app, "view.saved.edit")); - invoke(&mut app, "view.saved.edit", json!({})); - assert!(app.ui.dialog.is_none()); - assert!(app.ui.status.contains("No saved views"), "{}", app.ui.status); - app.run("view.saved.new", json!({"name": "Detail"})).unwrap(); - app.run("view.saved.new", json!({"name": "Page"})).unwrap(); - assert!(enabled(&app, "view.saved.edit")); - invoke(&mut app, "view.saved.edit", json!({})); - let d = app.ui.dialog.clone().expect("Edit Views dialog"); - assert_eq!((d.kind.as_str(), d.str("orig1"), d.str("name1")), ("editViews", "Page".into(), "Page".into())); - // Deleting a view takes its slot out of the menu. - let mut d = d; - d.fields.insert("deleted0".into(), json!(true)); - app.ui.dialog = Some(d); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(dynamic_label(&app, "view.goto1", ""), "Page"); - assert!(shown_state(&app, "view.goto2", &Value::Null).is_none()); - } - - fn app_with_a_rectangle() -> VectorcraftApp { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - app - } - - /// Edit › Undo and Edit › Redo name a step named after a style as the History panel lists it: - /// the template's translation with the style's name in it. - #[test] - fn undo_and_redo_name_a_style_step_around_the_style_name() { - let mut app = app_with_a_rectangle(); - let text = app.run("text.create", json!({"x": 0, "y": 40, "text": "Hello"})).unwrap()["id"].clone(); - app.run("charStyle.new", json!({"name": "Brush"})).unwrap(); - app.run("charStyle.apply", json!({"name": "Brush", "id": text})).unwrap(); - let ja = crate::i18n::Lang::from_code("ja").expect("ja registered"); - crate::i18n::set_current(ja); - let step = |template: &str| { - let applied = crate::i18n::fmt(crate::i18n::tr(ja, "Apply {name}"), &[("name", "Brush")]); - crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &applied)]) - }; - assert_eq!(display_label(&app, "edit.undo", "Undo"), step("Undo {name}")); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(display_label(&app, "edit.redo", "Redo"), step("Redo {name}")); - crate::i18n::set_current(crate::i18n::Lang::EN); - assert_eq!(display_label(&app, "edit.redo", "Redo"), "Redo Apply Brush"); - } - - #[test] - fn save_selection_asks_for_a_name() { - let mut app = app_with_a_rectangle(); - invoke(&mut app, "select.save", json!({})); - let d = app.ui.dialog.clone().expect("Save Selection dialog"); - assert_eq!( - (d.kind.as_str(), d.str("__command"), d.str("__label"), d.str("name")), - ("command", "select.save".into(), "Save Selection".into(), "Selection 1".into()) - ); - // OK saves under the name typed. - let mut d = d; - d.fields.insert("name".into(), json!("Logo")); - app.ui.dialog = Some(d); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(app.run("select.savedList", json!({})).unwrap(), json!(["Logo"])); - // The next one starts from the first free name. - invoke(&mut app, "select.save", json!({})); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("name")), Some("Selection 1".into())); - } - - #[test] - fn saved_selections_are_listed_at_the_bottom_of_the_select_menu() { - let mut app = app_with_a_rectangle(); - app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); - // Nothing saved: no slot is listed or enabled. - assert!(!enabled(&app, "select.recall1")); - assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall1"))); - app.run("select.save", json!({"name": "Ellipse"})).unwrap(); - app.run("select.none", json!({})).unwrap(); - app.run("select.all", json!({})).unwrap(); - app.run("select.save", json!({"name": "Everything"})).unwrap(); - assert!(enabled(&app, "select.recall1") && enabled(&app, "select.recall2") && !enabled(&app, "select.recall3")); - assert_eq!(dynamic_label(&app, "select.recall2", "Saved Selection"), "Everything"); - // A name is shown as it is, not translated. - assert_eq!(display_label(&app, "select.recall2", "Saved Selection"), "Everything"); - assert!(menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall2"))); - assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall3"))); - // Choosing one selects its objects again. - app.run("select.none", json!({})).unwrap(); - let r = app.run("select.recall2", json!({})).unwrap(); - assert!(r["count"].as_u64().unwrap() >= 1); - assert_eq!(app.session.active().unwrap().selection.len(), 2); - // A deleted selection's slot leaves the menus. - app.run("select.editSaved", json!({"name": "Ellipse", "delete": true})).unwrap(); - assert!(!enabled(&app, "select.recall2")); - assert!(shown_state(&app, "select.recall2", &Value::Null).is_none()); - assert!(shown_state(&app, "select.recall1", &Value::Null).is_some()); - } - - #[test] - fn update_selection_follows_save_selection_and_needs_a_recall() { - let mut app = app_with_a_rectangle(); - let entries = menu_entries(&app); - let at = |id: &str| entries.iter().position(|e| e.command.as_deref() == Some(id)); - assert_eq!(at("select.update").map(|i| i - 1), at("select.save")); - app.run("select.save", json!({"name": "Box"})).unwrap(); - assert!(!enabled(&app, "select.update")); - app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); - app.run("select.recall1", json!({})).unwrap(); - assert!(enabled(&app, "select.update")); - app.run("select.all", json!({})).unwrap(); - invoke(&mut app, "select.update", json!({})); - app.run("select.none", json!({})).unwrap(); - assert_eq!(app.run("select.recall1", json!({})).unwrap()["count"], 2); - } - - #[test] - fn edit_selection_opens_its_dialog_on_the_saved_selections() { - let mut app = app_with_a_rectangle(); - // Nothing saved yet: the item is off, and invoking it (palette, shortcut) only says so. - assert!(!enabled(&app, "select.editSaved")); - invoke(&mut app, "select.editSaved", json!({})); - assert!(app.ui.dialog.is_none()); - assert!(app.ui.status.contains("No saved selections"), "{}", app.ui.status); - app.run("select.save", json!({"name": "Logo"})).unwrap(); - assert!(enabled(&app, "select.editSaved")); - invoke(&mut app, "select.editSaved", json!({})); - let d = app.ui.dialog.clone().expect("Edit Selection dialog"); - assert_eq!((d.kind.as_str(), d.str("orig0"), d.str("name0")), (crate::dialogs::edit_selection::KIND, "Logo".into(), "Logo".into())); - } - - #[test] - fn recent_fonts_and_corner_widget_toggle() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let fams = vectorcraft_text::FontDb::global().families(); - let (a, b) = (fams[0].clone(), fams[fams.len() - 1].clone()); - app.run("text.create", json!({"x": 10, "y": 10, "text": "Hi"})).unwrap(); - app.run("text.setStyle", json!({"font": a})).unwrap(); - app.run("text.setStyle", json!({"font": b})).unwrap(); - assert_eq!(app.ui.recent_fonts, vec![b.clone(), a.clone()]); - assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), a); - assert!(enabled(&app, "type.recentFont2") && !enabled(&app, "type.recentFont3")); - app.run("type.recentFont2", json!({})).unwrap(); - assert_eq!(app.ui.recent_fonts[0], a); - assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Hide Corner Widget"); - app.run("view.cornerWidget", json!({})).unwrap(); - assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Show Corner Widget"); - } - - /// View › Precise Cursors has no menu item or default key, but the command (assignable in Keyboard Shortcuts) - /// writes General › Use Precise Cursors, the value the dialog and the saved preferences read. - #[test] - fn precise_cursors_command_toggles_the_preference() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - assert!(!app.session.prefs.use_precise_cursors); - assert_eq!(app.run("view.preciseCursors", json!({})).unwrap(), json!(true)); - assert!(app.session.prefs.use_precise_cursors); - assert_eq!(app.ui.engine_prefs["usePreciseCursors"], json!(true), "saved with the UI state"); - assert!(UI_COMMANDS.iter().any(|c| c.0 == "view.preciseCursors" && c.2.is_empty()), "assignable, no default key"); - assert_eq!(app.run("view.preciseCursors", json!({})).unwrap(), json!(false)); - assert!(!app.session.prefs.use_precise_cursors); - } - - /// The command is listed in Edit › Keyboard Shortcuts without a key and takes one the user types; with the - /// key it is the command that runs. - #[test] - fn precise_cursors_command_is_listed_and_assignable_in_the_shortcut_editor() { - use crate::shortcut_editor as ed; - let e = ed::entry("view.preciseCursors").expect("listed in the editor"); - assert_eq!((e.label.as_str(), e.group.as_str(), e.is_tool), ("Precise Cursors", "View", false)); - assert_eq!(ed::default_of("view.preciseCursors"), None, "no default key"); - let mut overrides = craft_keymap::Overrides::new(); - assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None); - assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+U"), false), Ok(vec![])); - let chord = ed::effective_in(&overrides, "view.preciseCursors").expect("assigned"); - assert_eq!(ed::normalize(&chord), ed::normalize("Cmd+Alt+Shift+U")); - // A key another command holds is refused, so the new command can't take it over silently. - assert!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+P"), false).is_err()); - assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", None, false), Ok(vec![])); - assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None, "cleared again"); - } - - /// The command is a preference, not an edit: no undo step, no unsaved changes, with a document open. - #[test] - fn precise_cursors_command_is_not_an_edit() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let steps = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) - }; - let before = steps(&app); - assert!(!before.2); - app.run("view.preciseCursors", json!({})).unwrap(); - assert!(app.session.prefs.use_precise_cursors); - assert_eq!(steps(&app), before); - } - - /// Selection & Anchor Display › Zoom to Selection (#394): Zoom In and Zoom Out bring the - /// selection to the middle; off, they keep the view's centre. - #[test] - fn zoom_to_selection_centres_the_selection() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 20})).unwrap(); - let center = |app: &VectorcraftApp| app.view().unwrap().center; - let start = center(&app); - app.run("prefs.set", json!({"key": "zoomToSelection", "value": false})).unwrap(); - app.run("view.zoomIn", json!({})).unwrap(); - assert_eq!(center(&app), start, "off: the view's centre stays"); - app.run("prefs.set", json!({"key": "zoomToSelection", "value": true})).unwrap(); - app.run("view.zoomOut", json!({})).unwrap(); - assert_eq!(center(&app), vectorcraft_geom::Point::new(30.0, 30.0), "on: the selection's centre"); - } - - /// Preferences › Type › Number of Recent Fonts (#394): Type › Recent Fonts lists that many. - #[test] - fn number_of_recent_fonts_sets_how_many_are_listed() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.ui.recent_fonts = (1..=15).map(|i| format!("Font {i}")).collect(); - let listed = |app: &VectorcraftApp| RECENT_FONT_IDS.iter().filter(|id| enabled(app, id)).count(); - assert_eq!(listed(&app), 10, "10 by default"); - for n in [15, 2] { - app.run("prefs.set", json!({"key": "recentFontsCount", "value": n})).unwrap(); - assert_eq!(listed(&app), n); - } - assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), "Font 2"); - assert!(app.run("type.recentFont3", json!({})).is_err(), "past the count"); - } - - #[test] - fn recent_files_track_opens_and_saves() { - let dir = std::env::temp_dir().join(format!("dc-recent-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let mut app = VectorcraftApp::new( - vectorcraft_engine::Session::new(), - crate::Services { - read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), - write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - ..Default::default() - }, - ); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - let (a, b) = (dir.join("a.vectorcraft"), dir.join("b.vectorcraft")); - for p in [&a, &b, &a] { - app.run("file.saveAs", json!({"path": p.to_string_lossy()})).unwrap(); - } - assert_eq!(app.ui.recent_files, [a.to_string_lossy(), b.to_string_lossy()]); - assert_eq!(dynamic_label(&app, "file.openRecent2", ""), "b.vectorcraft"); - assert!(enabled(&app, "file.openRecent2") && !enabled(&app, "file.openRecent3")); - app.run("file.openRecent2", json!({})).unwrap(); - assert_eq!(app.session.documents().len(), 2); - assert_eq!(app.ui.recent_files[0], b.to_string_lossy(), "reopening moves it to the top"); - app.run("file.clearRecent", json!({})).unwrap(); - assert!(app.ui.recent_files.is_empty() && app.run("file.openRecent1", json!({})).is_err()); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn every_bound_menu_command_exists() { - fn walk(items: &[Item], bad: &mut Vec) { - for it in items { - match it { - Item::Cmd(_, id, _) - if vectorcraft_engine::find_command(id).is_none() - && !UI_COMMANDS.iter().any(|c| c.0 == *id) - && !id.starts_with("object.path.") => - { - bad.push(id.to_string()); - } - Item::Sub(_, ch) => walk(ch, bad), - _ => {} - } - } - } - let mut bad = vec![]; - for (_, items) in menu_tree() { - walk(&items, &mut bad); - } - assert!(bad.is_empty(), "menu items bound to unknown commands: {bad:?}"); - } - - #[test] - fn shortcut_pretty() { - if cfg!(target_os = "macos") { - assert_eq!(pretty_shortcut("Cmd+Shift+]"), "⇧⌘]"); - assert_eq!(pretty_shortcut("Cmd+Alt+2"), "⌥⌘2"); - } - assert_eq!(pretty_shortcut(""), ""); - } - - /// #543: Object › Image Trace › Make and Make and Expand trace with the Default preset at once, - /// with no form asking for a preset's name. - #[test] - fn image_trace_make_traces_with_the_default_preset() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - for cmd in ["imageTrace.make", "imageTrace.makeAndExpand"] { - let e = menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some(cmd)).unwrap(); - assert_eq!(e.path, ["Object", "Image Trace"]); - assert_eq!(click_target(&e.label, cmd, &e.params), (cmd.to_string(), json!({"preset": "Default"})), "{}", e.label); - } - } - - #[test] - fn expand_appearance_is_an_object_menu_item_only() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let paths: Vec> = - menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("effect.expandAppearance")).map(|e| e.path).collect(); - assert_eq!(paths, [vec!["Object".to_string()]]); - } - - #[test] - fn menus_are_as_wide_as_their_items() { - // Items size the popup to the widest label and shortcut instead of the widest a menu may be. - let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); - let tree = menu_tree(); - let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap(); - let id = egui::Id::unique("test-menu"); - for _ in 0..3 { - let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| { - egui::Area::new(id).show(ui.ctx(), |ui| { - egui::containers::menu::menu_style(ui.style_mut()); - ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, file, &mut None)); - }); - }); - out.textures_delta.clear(); - } - let w = ctx.memory(|m| m.area_rect(id)).unwrap().width(); - assert!((230.0..340.0).contains(&w), "File menu is {w} pt wide"); - } - - #[test] - fn long_menus_scroll_inside_a_short_window() { - // The Window menu is taller than a 600 pt window: it stops above the bottom edge and - // scrolls instead of running off screen. - let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let tree = menu_tree(); - let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap(); - let id = egui::Id::unique("test-long-menu"); - let rect = |height: f32| { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height)); - for _ in 0..3 { - let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| { - egui::Area::new(id).fixed_pos(egui::pos2(300.0, 30.0)).show(ui.ctx(), |ui| { - egui::containers::menu::menu_style(ui.style_mut()); - ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, window, &mut None)); - }); - }); - out.textures_delta.clear(); - } - ctx.memory(|m| m.area_rect(id)).unwrap() - }; - let short = rect(600.0); - assert!(short.bottom() <= 600.0, "Window menu ends at {} in a 600 pt window", short.bottom()); - // With room to spare it shows whole, taller than the short window allowed. - let tall = rect(3000.0); - assert!(tall.height() > short.height() + 100.0, "{} vs {}", tall.height(), short.height()); - assert!(tall.bottom() < 3000.0); - } - - /// #3724: Window › New Window is greyed out until VectorCraft can open a second window on a - /// document, instead of being enabled and answering with an internal milestone note. - #[test] - fn new_window_is_disabled_until_it_opens_a_window() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - assert!(!enabled(&app, "window.newWindow"), "New Window is enabled but opens no window"); - let e = app.run("window.newWindow", json!({})).unwrap_err(); - assert!(!e.contains("M11.5"), "a user-facing message, not a milestone: {e}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs new file mode 100644 index 000000000..b78f5efdb --- /dev/null +++ b/crates/ui-egui/src/menus/tests.rs @@ -0,0 +1,708 @@ +use super::*; +use crate::theme; + +/// One headless frame of the in-window menu bar; returns its titles (left to right) as +/// (rect, id of the title's popup). +fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(egui::Rect, egui::Id)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); + let mut layer = None; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { + layer = Some(ui.layer_id()); + menu_bar(app, ui); + }); + out.textures_delta.clear(); + let layer = layer.unwrap(); + let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| { + vp.prev_pass + .widgets + .get_layer(layer) + .filter(|w| w.sense.senses_click() && w.rect.top() < 30.0) + // `egui::Popup::default_response_id` of the title's response. + .map(|w| (w.rect, w.id.with("popup"))) + .collect() + }); + titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left())); + titles +} + +fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec { + (0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect() +} + +/// File ends with Exit, running the app's own quit flow (unsaved documents are asked about). +#[test] +fn file_ends_with_exit() { + let tree = menu_tree(); + let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap(); + let last = file.iter().rev().find(|i| !matches!(i, Item::Sep)).unwrap(); + assert!(matches!(last, Item::Cmd("Exit", "app.quit", _)), "{last:?}"); +} + +/// No application menu in the window (as in Illustrator): the bar starts at File. +#[test] +fn the_bar_starts_at_file() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + menu_bar(&mut app, ui); + }); + out.textures_delta.clear(); + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + let titles = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"]; + for t in titles { + assert!(shown.iter().any(|s| s == t), "{t} missing from {shown:?}"); + } + assert!(!shown.iter().any(|s| s == "VectorCraft"), "{shown:?}"); +} + +#[test] +fn hovering_another_title_switches_the_open_menu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + bar_frame(&mut app, &ctx, vec![]); + let titles = bar_frame(&mut app, &ctx, vec![]); + // No application menu in the window: the bar starts at File on every platform. + assert_eq!(titles.len(), menu_tree().len() - 1); + let (file, edit, object) = (titles[0].0.center(), titles[1].0.center(), titles[2].0.center()); + let frames = |app: &mut VectorcraftApp, events: Vec| { + let mut t = bar_frame(app, &ctx, events); + for _ in 0..2 { + t = bar_frame(app, &ctx, vec![]); + } + t + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + + // Nothing open: hovering a title opens nothing. + let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]); + assert!(open_titles(&ctx, &t).is_empty()); + + // Click File, then move onto Edit and on to Object: each opens in turn, alone. + frames(&mut app, vec![egui::Event::PointerMoved(file)]); + frames(&mut app, vec![button(file, true)]); + let t = frames(&mut app, vec![button(file, false)]); + assert_eq!(open_titles(&ctx, &t), vec![0], "a click opens File"); + let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]); + assert_eq!(open_titles(&ctx, &t), vec![1], "hovering Edit opens it and closes File"); + let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]); + assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Object opens it and closes Edit"); + + // A pointer resting on a title doesn't switch: File opened another way (the keyboard) + // stays open while the pointer stays still over Object. + egui::Popup::open_id(&ctx, t[0].1); + let t = frames(&mut app, vec![]); + assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone"); +} + +#[test] +fn a_popup_covering_a_title_does_not_switch_menus() { + let ctx = egui::Context::default(); + let p = egui::pos2(90.0, 12.0); + let mut reaches = (true, false); + // The popup becomes hit-testable once egui has laid it out: draw a few frames. + for _ in 0..3 { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let ctx = ui.ctx().clone(); + let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::unique("covered"), egui::Sense::click()); + let q = p + egui::vec2(0.0, 100.0); + let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::unique("free"), egui::Sense::click()); + egui::Area::new(egui::Id::unique("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| { + ui.allocate_space(egui::vec2(20.0, 20.0)); + }); + reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q)); + }); + out.textures_delta.clear(); + } + assert_eq!(reaches, (false, true)); +} + +#[test] +fn names_in_menus_are_not_interface_labels() { + for (id, label) in [ + ("file.openRecent1", "Layers.svg"), + ("view.goto2", "Layers"), + ("type.recentFont1", "Regular"), + ("text.setStyle", "Black"), + ("window.workspace", "Layers"), + ("plugin.dialog", "Group"), + ("window.userSwatchLibrary3", "Default"), + ] { + assert!(shows_a_name(id, label), "{id} {label}"); + } + for (id, label) in [("object.group", "Group"), ("window.workspace", "Essentials"), ("effect.apply", "Drop Shadow")] { + assert!(!shows_a_name(id, label), "{id} {label}"); + } +} + +#[test] +fn last_effect_dialog_and_view_toggles() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + assert!(!enabled(&app, "effect.last")); + app.last_effect = Some(("distort.roughen".into(), json!({"size": 9}))); + assert!(enabled(&app, "effect.last")); + assert_eq!(dynamic_label(&app, "effect.last", "Last Effect…"), "Last Effect: Roughen…"); + app.run("effect.last", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("__effect"), d.f64("size", 0.0)), ("effect", "distort.roughen".to_string(), 9.0)); + for (id, on, off) in [ + ("view.textThreads", "Hide Text Threads", "Show Text Threads"), + ("view.gradientAnnotator", "Hide Gradient Annotator", "Show Gradient Annotator"), + ("view.livePaintGaps", "Show Live Paint Gaps", "Hide Live Paint Gaps"), + ("type.hiddenCharacters", "Show Hidden Characters", "Hide Hidden Characters"), + ("view.template", "Hide Template", "Show Template"), + ] { + assert_eq!(dynamic_label(&app, id, ""), on); + app.run(id, json!({})).unwrap(); + assert_eq!(dynamic_label(&app, id, ""), off); + } +} + +/// Edit › Undo and Edit › Redo name the step in the UI language as the History panel lists it +/// (`panels::history::step_name`), including a step named after an effect whose catalog holds +/// only the effect's menu row with an ellipsis, as Japanese does for Feather. +#[test] +fn undo_and_redo_name_the_step_as_the_history_panel_does() { + let ja = crate::i18n::Lang::from_code("ja").expect("ja registered"); + crate::i18n::set_current(ja); + let mut app = app_with_a_rectangle(); + app.run("effect.apply", json!({"effect": "stylize.feather"})).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.last().map(|h| h.label.as_str()), Some("Feather")); + let shown = |app: &VectorcraftApp, id: &'static str| { + let item = c(if id == "edit.undo" { "Undo" } else { "Redo" }, id); + match entry(app, &item) { + Some(Entry::Item(row)) => row.label.to_string(), + _ => panic!("{id} is not a menu item"), + } + }; + let step = |template: &str, name: &str| { + let name = crate::panels::history::step_name(ja, name).unwrap_or_else(|| name.to_string()); + crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &name)]) + }; + assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Feather")); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(shown(&app, "edit.redo"), step("Redo {name}", "Feather")); + assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Rectangle"), "a step with a plain row"); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(shown(&app, "edit.redo"), "Redo Feather"); +} + +/// Type › Optical Margin Alignment runs `type.opticalMarginAlignment`, needs a selection, and +/// is checked while the selected type has it on. Its check, and Type Orientation's, read the +/// type inside a selected group too. +#[test] +fn optical_margin_alignment_is_a_checked_type_menu_item() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let ids: Vec<&str> = menu_tree() + .into_iter() + .filter(|(menu, _)| *menu == "Type") + .flat_map(|(_, items)| items) + .filter_map(|item| match item { + Item::Cmd("Optical Margin Alignment", id, _) => Some(id), + _ => None, + }) + .collect(); + assert_eq!(ids, ["type.opticalMarginAlignment"]); + let id = "type.opticalMarginAlignment"; + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + assert!(!enabled(&app, id)); + let text = app.run("text.create", json!({"x": 10, "y": 10, "text": "“Hi”"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [text]})).unwrap(); + assert!(enabled(&app, id)); + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true)); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + // With the type in a selected group, the checks show the type the commands change. + app.run("object.group", json!({})).unwrap(); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true)); + app.run("type.orientation.vertical", json!({})).unwrap(); + assert_eq!(checked(&app, "type.orientation.vertical", &Value::Null), Some(true)); + assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); +} + +#[test] +fn panel_ids_are_case_insensitive_and_labels_work() { + assert_eq!(normalize_panel("layers"), Some("layers")); + assert_eq!(normalize_panel("Layers"), Some("layers")); + assert_eq!(normalize_panel("LAYERS"), Some("layers")); + assert_eq!(normalize_panel("Swatches"), Some("swatches")); + assert_eq!(normalize_panel("swatches"), Some("swatches")); + assert_eq!(normalize_panel("Color Guide"), Some("colorGuide")); + assert_eq!(normalize_panel("colorguide"), Some("colorGuide")); + assert_eq!(normalize_panel(" Stroke "), Some("stroke")); + assert_eq!(normalize_panel("nope"), None); + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("window.panel", json!({"panel": "Layers"})).unwrap(); + assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers); + app.run("window.panel", json!({"panel": "Swatches"})).unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some("swatches")); + assert!(checked(&app, "window.panel", &json!({"panel": "Swatches"})).unwrap()); + assert!(app.run("window.panel", json!({"panel": "nope"})).is_err()); + // A collapsed dock pops the panel out of its icon, by canonical id. + app.ui.dock_collapsed = true; + app.run("window.panel", json!({"panel": "Layers"})).unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some("layers")); + assert!(checked(&app, "window.panel", &json!({"panel": "Layers"})).unwrap()); + app.ui.dock_collapsed = false; +} + +#[test] +fn saved_views_store_and_restore_the_view() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + { + let v = app.view_mut().unwrap(); + v.zoom = 3.0; + v.center = vectorcraft_geom::Point::new(120.0, 80.0); + } + app.run("view.saved.new", json!({"name": "Detail"})).unwrap(); + assert!(enabled(&app, "view.goto1") && !enabled(&app, "view.goto2")); + assert_eq!(dynamic_label(&app, "view.goto1", ""), "Detail"); + app.view_mut().unwrap().zoom = 0.5; + app.run("view.goto1", json!({})).unwrap(); + let v = app.view().unwrap(); + assert_eq!((v.zoom, v.center.x, v.center.y), (3.0, 120.0, 80.0)); + // Unused slots stay out of the menu listing agents see. + assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2"))); +} + +/// A document's 25 views are all listed at the bottom of the View menu, by name, and the last +/// one brings back its zoom, centre and rotation after the view changed. +#[test] +fn every_saved_view_is_listed_at_the_bottom_of_the_view_menu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for i in 1..=25 { + { + let v = app.view_mut().unwrap(); + (v.zoom, v.center, v.rotation) = (i as f64 / 4.0, vectorcraft_geom::Point::new(10.0 * i as f64, -5.0 * i as f64), i as f64); + } + invoke(&mut app, "view.saved.new", json!({})); + crate::dialogs::confirm(&mut app).unwrap(); + } + assert_eq!(app.session.active().unwrap().doc.views.len(), 25); + let entries = menu_entries(&app); + let at = |id: &str| entries.iter().position(|e| e.command.as_deref() == Some(id)); + let (edit, first, last) = (at("view.saved.edit").unwrap(), at("view.goto1").unwrap(), at("view.goto25").unwrap()); + assert!(edit < first && last == first + 24, "listed in order after Edit Views…"); + assert!(enabled(&app, "view.goto25")); + assert_eq!(display_label(&app, "view.goto25", "Saved View"), "View 25"); + // The document's 26th view is refused, so there is never one the menu leaves out. + invoke(&mut app, "view.saved.new", json!({})); + assert!(crate::dialogs::confirm(&mut app).is_err()); + app.ui.dialog = None; + { + let v = app.view_mut().unwrap(); + (v.zoom, v.center, v.rotation) = (1.0, vectorcraft_geom::Point::new(0.0, 0.0), 0.0); + } + assert_eq!(app.run("view.goto25", json!({})).unwrap()["name"], "View 25"); + let v = app.view().unwrap(); + assert_eq!((v.zoom, v.center.x, v.center.y, v.rotation), (6.25, 250.0, -125.0, 25.0)); +} + +/// Edit Views… is off without a saved view and opens its list on the document's views, the +/// way Edit Selection… does. +#[test] +fn edit_views_opens_its_dialog_on_the_saved_views() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + assert!(!enabled(&app, "view.saved.edit")); + invoke(&mut app, "view.saved.edit", json!({})); + assert!(app.ui.dialog.is_none()); + assert!(app.ui.status.contains("No saved views"), "{}", app.ui.status); + app.run("view.saved.new", json!({"name": "Detail"})).unwrap(); + app.run("view.saved.new", json!({"name": "Page"})).unwrap(); + assert!(enabled(&app, "view.saved.edit")); + invoke(&mut app, "view.saved.edit", json!({})); + let d = app.ui.dialog.clone().expect("Edit Views dialog"); + assert_eq!((d.kind.as_str(), d.str("orig1"), d.str("name1")), ("editViews", "Page".into(), "Page".into())); + // Deleting a view takes its slot out of the menu. + let mut d = d; + d.fields.insert("deleted0".into(), json!(true)); + app.ui.dialog = Some(d); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(dynamic_label(&app, "view.goto1", ""), "Page"); + assert!(shown_state(&app, "view.goto2", &Value::Null).is_none()); +} + +fn app_with_a_rectangle() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app +} + +/// Edit › Undo and Edit › Redo name a step named after a style as the History panel lists it: +/// the template's translation with the style's name in it. +#[test] +fn undo_and_redo_name_a_style_step_around_the_style_name() { + let mut app = app_with_a_rectangle(); + let text = app.run("text.create", json!({"x": 0, "y": 40, "text": "Hello"})).unwrap()["id"].clone(); + app.run("charStyle.new", json!({"name": "Brush"})).unwrap(); + app.run("charStyle.apply", json!({"name": "Brush", "id": text})).unwrap(); + let ja = crate::i18n::Lang::from_code("ja").expect("ja registered"); + crate::i18n::set_current(ja); + let step = |template: &str| { + let applied = crate::i18n::fmt(crate::i18n::tr(ja, "Apply {name}"), &[("name", "Brush")]); + crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &applied)]) + }; + assert_eq!(display_label(&app, "edit.undo", "Undo"), step("Undo {name}")); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(display_label(&app, "edit.redo", "Redo"), step("Redo {name}")); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(display_label(&app, "edit.redo", "Redo"), "Redo Apply Brush"); +} + +#[test] +fn save_selection_asks_for_a_name() { + let mut app = app_with_a_rectangle(); + invoke(&mut app, "select.save", json!({})); + let d = app.ui.dialog.clone().expect("Save Selection dialog"); + assert_eq!( + (d.kind.as_str(), d.str("__command"), d.str("__label"), d.str("name")), + ("command", "select.save".into(), "Save Selection".into(), "Selection 1".into()) + ); + // OK saves under the name typed. + let mut d = d; + d.fields.insert("name".into(), json!("Logo")); + app.ui.dialog = Some(d); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.run("select.savedList", json!({})).unwrap(), json!(["Logo"])); + // The next one starts from the first free name. + invoke(&mut app, "select.save", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("name")), Some("Selection 1".into())); +} + +#[test] +fn saved_selections_are_listed_at_the_bottom_of_the_select_menu() { + let mut app = app_with_a_rectangle(); + app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); + // Nothing saved: no slot is listed or enabled. + assert!(!enabled(&app, "select.recall1")); + assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall1"))); + app.run("select.save", json!({"name": "Ellipse"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("select.save", json!({"name": "Everything"})).unwrap(); + assert!(enabled(&app, "select.recall1") && enabled(&app, "select.recall2") && !enabled(&app, "select.recall3")); + assert_eq!(dynamic_label(&app, "select.recall2", "Saved Selection"), "Everything"); + // A name is shown as it is, not translated. + assert_eq!(display_label(&app, "select.recall2", "Saved Selection"), "Everything"); + assert!(menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall2"))); + assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall3"))); + // Choosing one selects its objects again. + app.run("select.none", json!({})).unwrap(); + let r = app.run("select.recall2", json!({})).unwrap(); + assert!(r["count"].as_u64().unwrap() >= 1); + assert_eq!(app.session.active().unwrap().selection.len(), 2); + // A deleted selection's slot leaves the menus. + app.run("select.editSaved", json!({"name": "Ellipse", "delete": true})).unwrap(); + assert!(!enabled(&app, "select.recall2")); + assert!(shown_state(&app, "select.recall2", &Value::Null).is_none()); + assert!(shown_state(&app, "select.recall1", &Value::Null).is_some()); +} + +#[test] +fn update_selection_follows_save_selection_and_needs_a_recall() { + let mut app = app_with_a_rectangle(); + let entries = menu_entries(&app); + let at = |id: &str| entries.iter().position(|e| e.command.as_deref() == Some(id)); + assert_eq!(at("select.update").map(|i| i - 1), at("select.save")); + app.run("select.save", json!({"name": "Box"})).unwrap(); + assert!(!enabled(&app, "select.update")); + app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("select.recall1", json!({})).unwrap(); + assert!(enabled(&app, "select.update")); + app.run("select.all", json!({})).unwrap(); + invoke(&mut app, "select.update", json!({})); + app.run("select.none", json!({})).unwrap(); + assert_eq!(app.run("select.recall1", json!({})).unwrap()["count"], 2); +} + +#[test] +fn edit_selection_opens_its_dialog_on_the_saved_selections() { + let mut app = app_with_a_rectangle(); + // Nothing saved yet: the item is off, and invoking it (palette, shortcut) only says so. + assert!(!enabled(&app, "select.editSaved")); + invoke(&mut app, "select.editSaved", json!({})); + assert!(app.ui.dialog.is_none()); + assert!(app.ui.status.contains("No saved selections"), "{}", app.ui.status); + app.run("select.save", json!({"name": "Logo"})).unwrap(); + assert!(enabled(&app, "select.editSaved")); + invoke(&mut app, "select.editSaved", json!({})); + let d = app.ui.dialog.clone().expect("Edit Selection dialog"); + assert_eq!((d.kind.as_str(), d.str("orig0"), d.str("name0")), (crate::dialogs::edit_selection::KIND, "Logo".into(), "Logo".into())); +} + +#[test] +fn recent_fonts_and_corner_widget_toggle() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let fams = vectorcraft_text::FontDb::global().families(); + let (a, b) = (fams[0].clone(), fams[fams.len() - 1].clone()); + app.run("text.create", json!({"x": 10, "y": 10, "text": "Hi"})).unwrap(); + app.run("text.setStyle", json!({"font": a})).unwrap(); + app.run("text.setStyle", json!({"font": b})).unwrap(); + assert_eq!(app.ui.recent_fonts, vec![b.clone(), a.clone()]); + assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), a); + assert!(enabled(&app, "type.recentFont2") && !enabled(&app, "type.recentFont3")); + app.run("type.recentFont2", json!({})).unwrap(); + assert_eq!(app.ui.recent_fonts[0], a); + assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Hide Corner Widget"); + app.run("view.cornerWidget", json!({})).unwrap(); + assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Show Corner Widget"); +} + +/// View › Precise Cursors has no menu item or default key, but the command (assignable in Keyboard Shortcuts) +/// writes General › Use Precise Cursors, the value the dialog and the saved preferences read. +#[test] +fn precise_cursors_command_toggles_the_preference() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + assert!(!app.session.prefs.use_precise_cursors); + assert_eq!(app.run("view.preciseCursors", json!({})).unwrap(), json!(true)); + assert!(app.session.prefs.use_precise_cursors); + assert_eq!(app.ui.engine_prefs["usePreciseCursors"], json!(true), "saved with the UI state"); + assert!(UI_COMMANDS.iter().any(|c| c.0 == "view.preciseCursors" && c.2.is_empty()), "assignable, no default key"); + assert_eq!(app.run("view.preciseCursors", json!({})).unwrap(), json!(false)); + assert!(!app.session.prefs.use_precise_cursors); +} + +/// The command is listed in Edit › Keyboard Shortcuts without a key and takes one the user types; with the +/// key it is the command that runs. +#[test] +fn precise_cursors_command_is_listed_and_assignable_in_the_shortcut_editor() { + use crate::shortcut_editor as ed; + let e = ed::entry("view.preciseCursors").expect("listed in the editor"); + assert_eq!((e.label.as_str(), e.group.as_str(), e.is_tool), ("Precise Cursors", "View", false)); + assert_eq!(ed::default_of("view.preciseCursors"), None, "no default key"); + let mut overrides = craft_keymap::Overrides::new(); + assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None); + assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+U"), false), Ok(vec![])); + let chord = ed::effective_in(&overrides, "view.preciseCursors").expect("assigned"); + assert_eq!(ed::normalize(&chord), ed::normalize("Cmd+Alt+Shift+U")); + // A key another command holds is refused, so the new command can't take it over silently. + assert!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+P"), false).is_err()); + assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", None, false), Ok(vec![])); + assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None, "cleared again"); +} + +/// The command is a preference, not an edit: no undo step, no unsaved changes, with a document open. +#[test] +fn precise_cursors_command_is_not_an_edit() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let steps = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) + }; + let before = steps(&app); + assert!(!before.2); + app.run("view.preciseCursors", json!({})).unwrap(); + assert!(app.session.prefs.use_precise_cursors); + assert_eq!(steps(&app), before); +} + +/// Selection & Anchor Display › Zoom to Selection (#394): Zoom In and Zoom Out bring the +/// selection to the middle; off, they keep the view's centre. +#[test] +fn zoom_to_selection_centres_the_selection() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 20})).unwrap(); + let center = |app: &VectorcraftApp| app.view().unwrap().center; + let start = center(&app); + app.run("prefs.set", json!({"key": "zoomToSelection", "value": false})).unwrap(); + app.run("view.zoomIn", json!({})).unwrap(); + assert_eq!(center(&app), start, "off: the view's centre stays"); + app.run("prefs.set", json!({"key": "zoomToSelection", "value": true})).unwrap(); + app.run("view.zoomOut", json!({})).unwrap(); + assert_eq!(center(&app), vectorcraft_geom::Point::new(30.0, 30.0), "on: the selection's centre"); +} + +/// Preferences › Type › Number of Recent Fonts (#394): Type › Recent Fonts lists that many. +#[test] +fn number_of_recent_fonts_sets_how_many_are_listed() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.recent_fonts = (1..=15).map(|i| format!("Font {i}")).collect(); + let listed = |app: &VectorcraftApp| RECENT_FONT_IDS.iter().filter(|id| enabled(app, id)).count(); + assert_eq!(listed(&app), 10, "10 by default"); + for n in [15, 2] { + app.run("prefs.set", json!({"key": "recentFontsCount", "value": n})).unwrap(); + assert_eq!(listed(&app), n); + } + assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), "Font 2"); + assert!(app.run("type.recentFont3", json!({})).is_err(), "past the count"); +} + +#[test] +fn recent_files_track_opens_and_saves() { + let dir = std::env::temp_dir().join(format!("dc-recent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = VectorcraftApp::new( + vectorcraft_engine::Session::new(), + crate::Services { + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }, + ); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let (a, b) = (dir.join("a.vectorcraft"), dir.join("b.vectorcraft")); + for p in [&a, &b, &a] { + app.run("file.saveAs", json!({"path": p.to_string_lossy()})).unwrap(); + } + assert_eq!(app.ui.recent_files, [a.to_string_lossy(), b.to_string_lossy()]); + assert_eq!(dynamic_label(&app, "file.openRecent2", ""), "b.vectorcraft"); + assert!(enabled(&app, "file.openRecent2") && !enabled(&app, "file.openRecent3")); + app.run("file.openRecent2", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.ui.recent_files[0], b.to_string_lossy(), "reopening moves it to the top"); + app.run("file.clearRecent", json!({})).unwrap(); + assert!(app.ui.recent_files.is_empty() && app.run("file.openRecent1", json!({})).is_err()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn every_bound_menu_command_exists() { + fn walk(items: &[Item], bad: &mut Vec) { + for it in items { + match it { + Item::Cmd(_, id, _) + if vectorcraft_engine::find_command(id).is_none() + && !UI_COMMANDS.iter().any(|c| c.0 == *id) + && !id.starts_with("object.path.") => + { + bad.push(id.to_string()); + } + Item::Sub(_, ch) => walk(ch, bad), + _ => {} + } + } + } + let mut bad = vec![]; + for (_, items) in menu_tree() { + walk(&items, &mut bad); + } + assert!(bad.is_empty(), "menu items bound to unknown commands: {bad:?}"); +} + +#[test] +fn shortcut_pretty() { + if cfg!(target_os = "macos") { + assert_eq!(pretty_shortcut("Cmd+Shift+]"), "⇧⌘]"); + assert_eq!(pretty_shortcut("Cmd+Alt+2"), "⌥⌘2"); + } + assert_eq!(pretty_shortcut(""), ""); +} + +/// #543: Object › Image Trace › Make and Make and Expand trace with the Default preset at once, +/// with no form asking for a preset's name. +#[test] +fn image_trace_make_traces_with_the_default_preset() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for cmd in ["imageTrace.make", "imageTrace.makeAndExpand"] { + let e = menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some(cmd)).unwrap(); + assert_eq!(e.path, ["Object", "Image Trace"]); + assert_eq!(click_target(&e.label, cmd, &e.params), (cmd.to_string(), json!({"preset": "Default"})), "{}", e.label); + } +} + +#[test] +fn expand_appearance_is_an_object_menu_item_only() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let paths: Vec> = + menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("effect.expandAppearance")).map(|e| e.path).collect(); + assert_eq!(paths, [vec!["Object".to_string()]]); +} + +#[test] +fn menus_are_as_wide_as_their_items() { + // Items size the popup to the widest label and shortcut instead of the widest a menu may be. + let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let tree = menu_tree(); + let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap(); + let id = egui::Id::unique("test-menu"); + for _ in 0..3 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + egui::Area::new(id).show(ui.ctx(), |ui| { + egui::containers::menu::menu_style(ui.style_mut()); + ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, file, &mut None)); + }); + }); + out.textures_delta.clear(); + } + let w = ctx.memory(|m| m.area_rect(id)).unwrap().width(); + assert!((230.0..340.0).contains(&w), "File menu is {w} pt wide"); +} + +#[test] +fn long_menus_scroll_inside_a_short_window() { + // The Window menu is taller than a 600 pt window: it stops above the bottom edge and + // scrolls instead of running off screen. + let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let tree = menu_tree(); + let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap(); + let id = egui::Id::unique("test-long-menu"); + let rect = |height: f32| { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height)); + for _ in 0..3 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + egui::Area::new(id).fixed_pos(egui::pos2(300.0, 30.0)).show(ui.ctx(), |ui| { + egui::containers::menu::menu_style(ui.style_mut()); + ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, window, &mut None)); + }); + }); + out.textures_delta.clear(); + } + ctx.memory(|m| m.area_rect(id)).unwrap() + }; + let short = rect(600.0); + assert!(short.bottom() <= 600.0, "Window menu ends at {} in a 600 pt window", short.bottom()); + // With room to spare it shows whole, taller than the short window allowed. + let tall = rect(3000.0); + assert!(tall.height() > short.height() + 100.0, "{} vs {}", tall.height(), short.height()); + assert!(tall.bottom() < 3000.0); +} + +/// #3724: Window › New Window is greyed out until VectorCraft can open a second window on a +/// document, instead of being enabled and answering with an internal milestone note. +#[test] +fn new_window_is_disabled_until_it_opens_a_window() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(!enabled(&app, "window.newWindow"), "New Window is enabled but opens no window"); + let e = app.run("window.newWindow", json!({})).unwrap_err(); + assert!(!e.contains("M11.5"), "a user-facing message, not a milestone: {e}"); +} From ad496e9a9838410af01c1a550d8b99fa60755ab4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:48 +0000 Subject: [PATCH 035/199] refactor(ui-egui): move panels/align.rs tests to panels/align/tests.rs --- crates/ui-egui/src/panels/align.rs | 697 +---------------------- crates/ui-egui/src/panels/align/tests.rs | 694 ++++++++++++++++++++++ 2 files changed, 695 insertions(+), 696 deletions(-) create mode 100644 crates/ui-egui/src/panels/align/tests.rs diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 1f79030d2..eab7bb306 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -341,699 +341,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use egui::accesskit::{Role, Toggled}; - - use super::super::tests_appearance::{click, frame_events, text_rect}; - use super::*; - use crate::toolbar::tests::widget_rects; - use crate::widgets::accessibility_tests::{role_and_state, tree}; - - #[test] - fn single_object_aligns_to_artboard() { - let p = align_params(json!({"horizontal": "left"}), AlignTo::Selection, 1); - assert_eq!(p["to"], "artboard"); - let p = align_params(json!({"vertical": "top"}), AlignTo::Selection, 3); - assert_eq!(p["to"], "selection"); - // One object and a key: the key is a guide (#2143), which it aligns to. - assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key"); - } - - /// #2143: with one object and a guide selected, Align to Key Object is on and makes the guide - /// the key; Align then lines the object up on it rather than on the artboard. - #[test] - fn align_to_key_object_makes_a_selected_guide_the_key() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); - app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap(); - app.run("select.set", json!({"ids": [a]})).unwrap(); - app.run("guide.select", json!({"indexes": [0], "toggle": true})).unwrap(); - let ctx = egui::Context::default(); - let ([_, key, _], left) = panel_buttons(&mut app, &ctx); - click(&ctx, &mut app, key.center(), show); - assert_eq!(app.session.active().unwrap().selection.key_guide, Some(0), "{}", app.ui.status); - assert_eq!(align_to(&app), AlignTo::Key); - click(&ctx, &mut app, left.center(), show); - assert_eq!(left_edge(&app, &a), 120.0, "on the guide, not the artboard: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 120.0); - } - - /// A Shift-click that leaves the key object selected on its own lets the key go, and the align - /// buttons of the Control bar and the Properties panel, and then the Align panel's, align that - /// object to the artboard. - #[test] - fn a_lone_object_aligns_to_the_artboard() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); - let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); - let ctx = egui::Context::default(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - app.run("select.key", json!({"id": b})).unwrap(); - app.run("select.toggle", json!({"id": a})).unwrap(); - let id = vectorcraft_doc::NodeId(b.as_u64().unwrap()); - let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); - // `align_buttons` (the Control bar and the Properties panel): one frame finds the first - // button, and the next clicks Horizontal Align Right, the third. - let at = std::cell::Cell::new(egui::Pos2::ZERO); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { - ui.horizontal(|ui| { - at.set(ui.cursor().min); - align_buttons(app, ui, 24.0); - }); - }); - out.textures_delta.clear(); - }; - frame(&mut app, vec![]); - let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0); - let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]); - frame(&mut app, vec![press(false)]); - assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge"); - // The Align panel's Horizontal Align Left. - let p = with_align_to(&app, json!({"horizontal": "left", "bounds": "geometric"})); - app.run("object.align", p).unwrap(); - assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge"); - } - - #[test] - fn align_both_centers_params() { - let p = align_params(json!({"horizontal": "center", "vertical": "center"}), AlignTo::Selection, 2); - assert_eq!(p["horizontal"], "center"); - assert_eq!(p["vertical"], "center"); - assert_eq!(p["to"], "selection"); - } - - /// #541: while the selection has a key object Align aligns to it, whatever Align To says; - /// choosing Align to Selection lets the key go. - #[test] - fn a_key_object_makes_align_align_to_it() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); - let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - assert_eq!(align_to(&app), AlignTo::Selection); - app.run("select.key", json!({"id": b})).unwrap(); - assert_eq!(align_to(&app), AlignTo::Key); - app.run("select.key", json!({})).unwrap(); - assert_eq!(align_to(&app), AlignTo::Selection, "no key: the choice again"); - } - - /// Two rectangles, the back one at x 10 and the front one at x 80, selected front first → - /// (the app, the back one's id, the front one's id). - fn back_and_front() -> (VectorcraftApp, Value, Value) { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let back = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); - let front = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [front, back]})).unwrap(); - (app, back, front) - } - - /// One frame of the Align panel → its Align To buttons (Selection, Key Object, Artboard) and - /// its Horizontal Align Left button. - fn panel_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> ([egui::Rect; 3], egui::Rect) { - frame_events(ctx, app, vec![], show); - // The 28-point buttons are Distribute Spacing's two and then Align To's three. - let mut small = widget_rects(ctx, egui::vec2(28.0, 28.0)); - small.sort_by(|a, b| a.left().total_cmp(&b.left())); - let [.., selection, key, artboard] = small[..] else { panic!("no Align To buttons in {small:?}") }; - let objects = widget_rects(ctx, egui::vec2(32.0, 32.0)); - let left = objects.iter().copied().filter(|r| r.top() == objects[0].top()).min_by(|a, b| a.left().total_cmp(&b.left())).unwrap(); - ([selection, key, artboard], left) - } - - /// The left edge of object `id`. - fn left_edge(app: &VectorcraftApp, id: &Value) -> f64 { - let id = vectorcraft_doc::NodeId(id.as_u64().unwrap()); - app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - } - - /// With two objects selected and no key object, Align to Key Object makes the topmost one the - /// key, whatever order they were selected in, and Align then aligns to it. A key already set - /// stays the key. - #[test] - fn align_to_key_object_makes_the_topmost_object_the_key() { - let (mut app, back, front) = back_and_front(); - let ctx = egui::Context::default(); - let ([_, key, _], left) = panel_buttons(&mut app, &ctx); - click(&ctx, &mut app, key.center(), show); - let key_object = |app: &VectorcraftApp| app.session.active().unwrap().selection.key.map(|k| k.0); - assert_eq!(key_object(&app), front.as_u64(), "the front rectangle, selected first"); - app.run("select.set", json!({"ids": [back, front]})).unwrap(); - click(&ctx, &mut app, key.center(), show); - assert_eq!(key_object(&app), front.as_u64(), "the front rectangle, selected last"); - assert_eq!(align_to(&app), AlignTo::Key); - click(&ctx, &mut app, left.center(), show); - assert_eq!((left_edge(&app, &back), left_edge(&app, &front)), (80.0, 80.0), "to the key's left edge: {}", app.ui.status); - app.run("select.key", json!({"id": back})).unwrap(); - click(&ctx, &mut app, key.center(), show); - assert_eq!(key_object(&app), back.as_u64(), "a key already set"); - } - - /// Align to Key Object stores no Align To choice: once the key object is cleared, by Cancel - /// Key Object, by a click on the key or by another selection, Align To shows the stored - /// choice again. - #[test] - fn align_to_shows_its_choice_again_once_the_key_is_cleared() { - let (mut app, back, front) = back_and_front(); - let ctx = egui::Context::default(); - let ([_, key, artboard], _) = panel_buttons(&mut app, &ctx); - click(&ctx, &mut app, artboard.center(), show); - click(&ctx, &mut app, key.center(), show); - assert_eq!(align_to(&app), AlignTo::Key); - let cancel = text_rect(&frame_events(&ctx, &mut app, vec![], menu), " Cancel Key Object"); - click(&ctx, &mut app, cancel.center(), menu); - assert!(!has_key(&app)); - assert_eq!(align_to(&app), AlignTo::Artboard, "Cancel Key Object"); - // The Selection tool's click on the key object runs `select.key {}`. - click(&ctx, &mut app, key.center(), show); - assert!(has_key(&app)); - app.run("select.key", json!({})).unwrap(); - assert_eq!(align_to(&app), AlignTo::Artboard, "a click on the key"); - click(&ctx, &mut app, key.center(), show); - assert!(has_key(&app)); - app.run("select.set", json!({"ids": [back, front]})).unwrap(); - assert_eq!(align_to(&app), AlignTo::Artboard, "another selection"); - } - - /// The Align To choice is kept with the UI state, which the app saves with its preferences and - /// `ui.inspect` reports. A saved value other than "artboard" reads as Selection, and the rest of - /// the state still loads. - #[test] - fn the_align_to_choice_is_kept_with_the_ui_state() { - let (mut app, _, _) = back_and_front(); - let ctx = egui::Context::default(); - let ([_, _, artboard], _) = panel_buttons(&mut app, &ctx); - click(&ctx, &mut app, artboard.center(), show); - assert_eq!(crate::control::inspect(&app, &ctx)["ui"]["align_to"], "artboard"); - let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); - assert_eq!(back.align_to, AlignTo::Artboard); - for v in [json!("key"), json!("Artboard"), json!(3), Value::Null] { - let ui: crate::UiState = serde_json::from_value(json!({"align_to": v, "place_link": false})).unwrap(); - assert_eq!((ui.align_to, ui.place_link), (AlignTo::Selection, false), "{v}"); - } - let old: crate::UiState = serde_json::from_value(json!({})).unwrap(); - assert_eq!(old.align_to, AlignTo::Selection, "preferences saved without it"); - } - - /// With one object selected, Align to Key Object is disabled: a click sets no key and keeps the - /// Align To choice, and Horizontal Align Left moves the object to the artboard's left edge. - #[test] - fn align_to_key_object_needs_two_selected_objects() { - let (mut app, _, front) = back_and_front(); - app.run("select.set", json!({"ids": [front]})).unwrap(); - let ctx = egui::Context::default(); - let ([_, key, _], left) = panel_buttons(&mut app, &ctx); - let clickable = ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).any(|w| w.rect == key && w.sense.senses_click())); - assert!(!clickable, "disabled"); - click(&ctx, &mut app, key.center(), show); - assert_eq!(app.session.active().unwrap().selection.key, None); - assert_eq!(align_to(&app), AlignTo::Selection); - click(&ctx, &mut app, left.center(), show); - assert_eq!(left_edge(&app, &front), 0.0, "the artboard's left edge"); - } - - /// One frame of the Align panel → the rects of the Align To buttons drawn as chosen. - fn chosen_align_to(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - let fill = crate::theme::Tokens::get(ctx).tool_active; - let chosen = |s: &egui::Shape| match s { - egui::Shape::Rect(r) if r.fill == fill && r.rect.size() == egui::vec2(28.0, 28.0) => Some(r.rect), - _ => None, - }; - out.shapes.iter().filter_map(|c| chosen(&c.shape)).collect() - } - - /// With one object selected the align buttons align it to the artboard, and Align To shows - /// Artboard; with two, it shows the stored choice. - #[test] - fn align_to_shows_artboard_while_one_object_is_selected() { - let (mut app, _, front) = back_and_front(); - let ctx = egui::Context::default(); - let ([selection, _, artboard], _) = panel_buttons(&mut app, &ctx); - assert_eq!(chosen_align_to(&mut app, &ctx), [selection], "two objects"); - app.run("select.set", json!({"ids": [front]})).unwrap(); - assert_eq!(chosen_align_to(&mut app, &ctx), [artboard], "one object"); - assert_eq!(align_to(&app), AlignTo::Selection, "the stored choice"); - } - - /// Screen readers name the align buttons, with no pressed state, and the Align To buttons, the - /// chosen one pressed. - #[test] - fn screen_readers_name_the_buttons_and_the_align_to_choice() { - let (mut app, _, _) = back_and_front(); - let tree = tree(|ui| show(&mut app, ui)); - for (_, label, _, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) { - assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); - } - assert_eq!(role_and_state(&tree, "Align to Selection"), (Role::Button, Some(Toggled::True))); - assert_eq!(role_and_state(&tree, "Align to Key Object"), (Role::Button, None)); - assert_eq!(role_and_state(&tree, "Align to Artboard"), (Role::Button, None)); - } - - /// #789: the Control bar's and the Properties panel's align buttons follow Align To too. - #[test] - fn the_other_align_buttons_align_to_the_align_to_choice() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); - let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - let ctx = egui::Context::default(); - app.ui.align_to = AlignTo::Artboard; - // One frame finds the first button (Horizontal Align Right is third); the next clicks it. - let at = std::cell::Cell::new(egui::Pos2::ZERO); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { - ui.horizontal(|ui| { - at.set(ui.cursor().min); - align_buttons(app, ui, 24.0); - }); - }); - out.textures_delta.clear(); - }; - frame(&mut app, vec![]); - let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0); - let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]); - frame(&mut app, vec![press(false)]); - let st = app.session.active().unwrap(); - let x1 = |id: &serde_json::Value| st.doc.bounds_of(&[vectorcraft_doc::NodeId(id.as_u64().unwrap())], false).unwrap().x1; - assert_eq!((x1(&a), x1(&b)), (200.0, 200.0), "both to the artboard's right edge"); - } - - /// The Align panel's buttons, in the panel's order: (command id, label). - const BUTTON_ENTRIES: [(&str, &str); 15] = [ - ("align.horizontalLeft", "Horizontal Align Left"), - ("align.horizontalCenter", "Horizontal Align Center"), - ("align.horizontalRight", "Horizontal Align Right"), - ("align.horizontalVerticalCenter", "Horizontal & Vertical Align Center"), - ("align.verticalTop", "Vertical Align Top"), - ("align.verticalCenter", "Vertical Align Center"), - ("align.verticalBottom", "Vertical Align Bottom"), - ("align.distributeVerticalTop", "Vertical Distribute Top"), - ("align.distributeVerticalCenter", "Vertical Distribute Center"), - ("align.distributeVerticalBottom", "Vertical Distribute Bottom"), - ("align.distributeHorizontalLeft", "Horizontal Distribute Left"), - ("align.distributeHorizontalCenter", "Horizontal Distribute Center"), - ("align.distributeHorizontalRight", "Horizontal Distribute Right"), - ("align.distributeVerticalSpace", "Vertical Distribute Space"), - ("align.distributeHorizontalSpace", "Horizontal Distribute Space"), - ]; - - /// The chord the tests below give an Align panel button. - const CHORD: &str = "Cmd+Alt+Shift+F2"; - - /// One frame pressing [`CHORD`] through the shortcut handler. - fn press_chord(app: &mut VectorcraftApp) { - let ctx = egui::Context::default(); - let modifiers = egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT; - let ev = egui::Event::Key { key: egui::Key::F2, physical_key: None, pressed: true, repeat: false, modifiers }; - let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); - out.textures_delta.clear(); - } - - /// Edit › Keyboard Shortcuts lists each of the Align panel's Align Objects, Distribute Objects - /// and Distribute Spacing buttons under Align, with the button's label and no default shortcut, - /// and each can be given one. - #[test] - fn keyboard_shortcuts_lists_each_align_panel_button() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - let panel = { - let (mut app, _, _) = back_and_front(); - tree(|ui| show(&mut app, ui)) - }; - let list = app.run("shortcuts.list", json!({})).unwrap(); - for (id, label) in BUTTON_ENTRIES { - assert_eq!(role_and_state(&panel, label), (Role::Button, None), "the panel's {label} button"); - let e = list.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("{id} is not listed")); - assert_eq!( - (e["label"].as_str(), e["group"].as_str(), &e["default"], &e["shortcut"]), - (Some(label), Some("Align"), &Value::Null, &Value::Null), - "{id}" - ); - let set = app.run("shortcuts.set", json!({"id": id, "shortcut": CHORD, "force": true})).unwrap(); - assert_eq!(set["shortcut"], CHORD, "{id}"); - } - } - - /// A shortcut on an Align panel button's entry runs what a click on the button runs: Horizontal - /// Align Right aligns to the selection, to the active artboard with Align to Artboard, and to - /// the key object while there is one. Horizontal Distribute Left sends the key and the - /// Distribute Spacing value, and Horizontal Distribute Space makes the gaps equal. - #[test] - fn a_shortcut_runs_its_align_panel_button() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let ids: Vec = [(10, 20), (50, 40), (120, 20)] - .into_iter() - .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) - .collect(); - app.run("select.set", json!({"ids": ids})).unwrap(); - let edges = |app: &VectorcraftApp, right: bool| { - let st = app.session.active().unwrap(); - ids.iter() - .map(|id| { - let b = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap(); - if right { b.x1 } else { b.x0 } - }) - .collect::>() - }; - let sent = |app: &VectorcraftApp| { - let (id, p) = app.session.journal.last().unwrap(); - (id.clone(), p["to"].clone(), p.get("spacing").cloned()) - }; - app.run("shortcuts.set", json!({"id": "align.horizontalRight", "shortcut": CHORD})).unwrap(); - press_chord(&mut app); - assert_eq!(edges(&app, true), [140.0; 3], "Align to Selection: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - app.ui.align_to = AlignTo::Artboard; - press_chord(&mut app); - assert_eq!(edges(&app, true), [200.0; 3], "Align to Artboard: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - app.run("select.key", json!({"id": ids[1]})).unwrap(); - press_chord(&mut app); - assert_eq!(edges(&app, true), [90.0; 3], "the key object's right edge: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - // Distribute Objects with the key object and a Distribute Spacing value of 15 pt. - app.run("shortcuts.set", json!({"id": "align.distributeHorizontalLeft", "shortcut": CHORD, "force": true})).unwrap(); - app.ui.align_spacing = 15.0; - press_chord(&mut app); - assert_eq!(sent(&app), ("object.distribute".to_string(), json!("key"), Some(json!(15.0)))); - assert_eq!(edges(&app, false), [35.0, 50.0, 65.0], "15 pt apart: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - // Distribute Spacing with Align to Selection: the outer objects stay, and the gaps are equal. - app.run("select.key", json!({})).unwrap(); - app.ui.align_to = AlignTo::Selection; - app.run("shortcuts.set", json!({"id": "align.distributeHorizontalSpace", "shortcut": CHORD, "force": true})).unwrap(); - press_chord(&mut app); - assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("selection"), Some(Value::Null))); - assert_eq!(edges(&app, false), [10.0, 55.0, 120.0], "25 pt gaps: {}", app.ui.status); - } - - /// A shortcut on an Align panel button's entry is kept in the set Export… writes, and Import… - /// of that set gives it back after a reset; it is kept in the saved preferences too. - #[test] - fn an_align_panel_button_shortcut_is_kept_in_a_saved_set() { - let (mut app, back, front) = back_and_front(); - let written: std::rc::Rc>> = Default::default(); - let w = written.clone(); - app.services.write = Some(Box::new(move |_: &str, b: &[u8]| { - *w.borrow_mut() = b.to_vec(); - Ok(()) - })); - let top = |app: &VectorcraftApp, id: &Value| { - app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().y0 - }; - app.run("shortcuts.set", json!({"id": "align.verticalTop", "shortcut": CHORD})).unwrap(); - app.run("shortcuts.export", json!({"path": "/sets/Align.json"})).unwrap(); - // The preferences save the UI state, and the next launch reads it back. - let saved = serde_json::to_value(&app.ui).unwrap(); - let loaded: crate::UiState = serde_json::from_value(saved).unwrap(); - assert_eq!(serde_json::to_value(&loaded).unwrap()["shortcut_overrides"]["align.verticalTop"], CHORD, "the saved preferences"); - app.run("shortcuts.reset", json!({})).unwrap(); - press_chord(&mut app); - assert_eq!(top(&app, &front), 50.0, "no shortcut after the reset: {}", app.ui.status); - let set: Value = serde_json::from_slice(&written.borrow()).unwrap(); - app.run("shortcuts.import", json!({"data": set})).unwrap(); - press_chord(&mut app); - assert_eq!((top(&app, &back), top(&app, &front)), (10.0, 10.0), "the imported shortcut: {}", app.ui.status); - } - - /// One frame of the Align panel → its Horizontal Distribute Left and Horizontal Distribute - /// Space buttons. - fn distribute_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> (egui::Rect, egui::Rect) { - frame_events(ctx, app, vec![], show); - // The lower row of 32-point buttons is Distribute Objects': Vertical Distribute Top, Center - // and Bottom, then Horizontal Distribute Left, Center and Right. - let objects = widget_rects(ctx, egui::vec2(32.0, 32.0)); - let bottom = objects.last().unwrap().top(); - let mut row: Vec = objects.into_iter().filter(|r| r.top() == bottom).collect(); - row.sort_by(|a, b| a.left().total_cmp(&b.left())); - // The 28-point buttons are Distribute Spacing's two (Vertical, then Horizontal) and then - // Align To's three. - let mut small = widget_rects(ctx, egui::vec2(28.0, 28.0)); - small.sort_by(|a, b| a.left().total_cmp(&b.left())); - (row[3], small[1]) - } - - /// With Align to Artboard chosen, the Align panel's Horizontal Distribute Left puts the - /// outermost of three objects (20, 40 and 20 pt wide) on the active artboard's left and right - /// edges and spaces their left edges evenly, and Horizontal Distribute Space makes the gaps - /// between them equal. With Align to Selection chosen, the outermost objects stay put. - #[test] - fn the_distribute_buttons_follow_align_to_artboard() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200, "artboards": 2})).unwrap(); - let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); - let ids: Vec = [(10, 20), (50, 40), (120, 20)] - .into_iter() - .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) - .collect(); - app.run("select.set", json!({"ids": ids})).unwrap(); - let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::>(); - let ctx = egui::Context::default(); - let (left, space) = distribute_buttons(&mut app, &ctx); - click(&ctx, &mut app, left.center(), show); - assert_eq!(lefts(&app), [10.0, 65.0, 120.0], "Align to Selection: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - // The objects are on artboard 0, and artboard 1 is the active one. - app.ui.align_to = AlignTo::Artboard; - app.view_mut().unwrap().artboard = 1; - let (x0, x1) = (boards[1].x0, boards[1].x1); - click(&ctx, &mut app, left.center(), show); - assert_eq!(lefts(&app), [x0, x0 + 90.0, x1 - 20.0], "the active artboard's edges: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - click(&ctx, &mut app, space.center(), show); - assert_eq!(lefts(&app), [x0, x0 + 80.0, x1 - 20.0], "60 pt gaps: {}", app.ui.status); - } - - /// With a key object, the Align panel's Distribute Objects buttons send the Distribute Spacing - /// value: Horizontal Distribute Left puts the others' left edges on the key's left edge with - /// the default value of 0, and puts them 15 pt apart from it with 15. Without a key object - /// they send no spacing. - #[test] - fn the_distribute_objects_buttons_send_the_spacing_with_a_key_object() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let ids: Vec = [(10, 20), (50, 40), (120, 20)] - .into_iter() - .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) - .collect(); - app.run("select.set", json!({"ids": ids})).unwrap(); - let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::>(); - // The command and its `to` and `spacing` params (the app adds the active artboard). - let sent = |app: &VectorcraftApp| { - let (id, p) = app.session.journal.last().unwrap(); - (id.clone(), p["to"].clone(), p.get("spacing").cloned()) - }; - let ctx = egui::Context::default(); - let (left, _) = distribute_buttons(&mut app, &ctx); - click(&ctx, &mut app, left.center(), show); - assert_eq!(sent(&app), ("object.distribute".to_string(), json!("selection"), None), "no key object"); - assert_eq!(lefts(&app), [10.0, 65.0, 120.0], "{}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - app.run("select.key", json!({"id": ids[1]})).unwrap(); - click(&ctx, &mut app, left.center(), show); - assert_eq!(sent(&app), ("object.distribute".to_string(), json!("key"), Some(json!(0.0)))); - assert_eq!(lefts(&app), [50.0; 3], "on the key's left edge: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - app.ui.align_spacing = 15.0; - click(&ctx, &mut app, left.center(), show); - assert_eq!(lefts(&app), [35.0, 50.0, 65.0], "15 pt apart: {}", app.ui.status); - } - - /// #2143: with a key guide, the Distribute Objects and Distribute Spacing buttons send the - /// Distribute Spacing value too, and the objects are spaced from the guide, which stays put. - #[test] - fn the_distribute_buttons_send_the_spacing_with_a_key_guide() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let ids: Vec = [(10, 20), (50, 40), (120, 20)] - .into_iter() - .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) - .collect(); - app.run("guide.add", json!({"vertical": true, "pos": 100})).unwrap(); - app.run("select.set", json!({"ids": ids})).unwrap(); - app.run("guide.select", json!({"indexes": [0], "toggle": true})).unwrap(); - app.run("select.key", json!({"guide": 0})).unwrap(); - let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::>(); - let sent = |app: &VectorcraftApp| { - let (id, p) = app.session.journal.last().unwrap(); - (id.clone(), p["to"].clone(), p.get("spacing").cloned()) - }; - let ctx = egui::Context::default(); - let (left, space) = distribute_buttons(&mut app, &ctx); - click(&ctx, &mut app, left.center(), show); - assert_eq!(sent(&app), ("object.distribute".to_string(), json!("key"), Some(json!(0.0)))); - assert_eq!(lefts(&app), [100.0; 3], "on the guide: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - app.ui.align_spacing = 15.0; - click(&ctx, &mut app, left.center(), show); - assert_eq!(lefts(&app), [70.0, 85.0, 115.0], "15 pt apart, outward from the guide: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); - // Horizontal Distribute Space with 15: the object before the guide ends 15 pt before it, - // the one after it starts 15 pt after it. - click(&ctx, &mut app, space.center(), show); - assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("key"), Some(json!(15.0)))); - assert_eq!(lefts(&app), [10.0, 45.0, 115.0], "{}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); - } - - /// Three vertical ruler guides selected without objects: Align Objects buttons are on, and - /// Horizontal Align Left puts every guide on the artboard's left edge (#3463). - #[test] - fn the_align_buttons_align_guides_selected_alone_to_artboard() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - for pos in [20, 50, 180] { - app.run("guide.add", json!({"vertical": true, "pos": pos})).unwrap(); - } - app.run("guide.select", json!({"indexes": [0, 1, 2]})).unwrap(); - app.ui.align_to = AlignTo::Artboard; - let ctx = egui::Context::default(); - frame_events(&ctx, &mut app, vec![], show); - let row = widget_rects(&ctx, egui::vec2(32.0, 32.0)); - let top = row.iter().map(|r| r.top()).fold(f32::MAX, f32::min); - let mut row: Vec = row.into_iter().filter(|r| r.top() == top).collect(); - row.sort_by(|a, b| a.left().total_cmp(&b.left())); - assert!(row[0].width() > 0.0, "Horizontal Align Left is enabled"); - click(&ctx, &mut app, row[0].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); - assert_eq!(pos(&app), [0.0, 0.0, 0.0], "{}", app.ui.status); - } - - /// Three vertical ruler guides selected without objects: the Distribute buttons are on, and - /// Horizontal Distribute Center puts the middle guide halfway between the other two, or with - /// Align to Artboard spaces them from its left edge to its right edge, as a public forum answer - /// describes for equal columns (https://community.adobe.com/questions-652/illustrator-equal-guides-822924). - #[test] - fn the_distribute_buttons_space_guides_selected_alone() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - for pos in [20, 50, 180] { - app.run("guide.add", json!({"vertical": true, "pos": pos})).unwrap(); - } - app.run("guide.select", json!({"indexes": [0, 1, 2]})).unwrap(); - let ctx = egui::Context::default(); - frame_events(&ctx, &mut app, vec![], show); - let row = widget_rects(&ctx, egui::vec2(32.0, 32.0)); - let bottom = row.iter().map(|r| r.top()).fold(f32::MIN, f32::max); - let mut row: Vec = row.into_iter().filter(|r| r.top() == bottom).collect(); - row.sort_by(|a, b| a.left().total_cmp(&b.left())); - assert_eq!(row.len(), 6, "the Distribute row"); - // Its fifth button is Horizontal Distribute Center. - click(&ctx, &mut app, row[4].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); - assert_eq!(pos(&app), [20.0, 100.0, 180.0], "{}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(pos(&app), [20.0, 50.0, 180.0], "one undo step"); - // With Align to Artboard, the outermost guides go to its edges: equal columns across it. - app.ui.align_to = AlignTo::Artboard; - click(&ctx, &mut app, row[4].center(), show); - assert_eq!(pos(&app), [0.0, 100.0, 200.0], "{}", app.ui.status); - } - - /// Runs the Object → Align item `label` as a click on it does. - fn object_align(app: &mut VectorcraftApp, label: &str) { - let entry = crate::menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Align"] && e.label == label).expect("menu item"); - let (id, p) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params); - crate::menus::invoke(app, &id, p); - } - - /// Object → Align aligns as the align buttons do: it aligns one object to the active artboard - /// whatever Align To says, and with Align to Artboard chosen it aligns two objects to the active - /// artboard too. - #[test] - fn object_align_follows_align_to() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200, "artboards": 2})).unwrap(); - let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); - let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); - let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); - let bounds = |app: &VectorcraftApp, id: &Value| { - app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap() - }; - app.run("select.set", json!({"ids": [b]})).unwrap(); - object_align(&mut app, "Horizontal Align Left"); - assert_eq!(bounds(&app, &b).x0, 0.0, "one object, Align to Selection: the artboard's left edge"); - app.ui.align_to = AlignTo::Artboard; - app.view_mut().unwrap().artboard = 1; - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - object_align(&mut app, "Horizontal Align Right"); - assert_eq!((bounds(&app, &a).x1, bounds(&app, &b).x1), (boards[1].x1, boards[1].x1), "the active artboard's right edge"); - } - - /// With anchor points selected, Object → Align sends its params unchanged: two anchor points of - /// one path align to their own bounds with Align to Selection and with Align to Artboard chosen. - #[test] - fn object_align_aligns_anchor_points_to_each_other() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap(); - let anchors = |app: &VectorcraftApp| -> Vec<(f64, f64)> { - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); - node.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() - }; - for to in [AlignTo::Selection, AlignTo::Artboard] { - app.ui.align_to = to; - app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); - object_align(&mut app, "Horizontal Align Left"); - assert_eq!(anchors(&app), [(100.0, 40.0), (100.0, 60.0), (150.0, 160.0)], "{to:?}: {}", app.ui.status); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(anchors(&app)[1], (200.0, 60.0), "undone"); - } - } - - /// #1636: with two anchor points of one path selected, the Align panel's buttons align the - /// points to each other, not to the artboard as they align a lone object, and Align To shows - /// the stored choice. With Align to Artboard chosen the points go to the artboard. - #[test] - fn anchor_points_of_one_path_align_to_each_other() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap(); - let xs = |app: &VectorcraftApp| -> Vec { - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); - node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect() - }; - app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); - let ctx = egui::Context::default(); - let ([selection, _, artboard], left) = panel_buttons(&mut app, &ctx); - click(&ctx, &mut app, left.center(), show); - assert_eq!(xs(&app), [100.0, 100.0, 150.0], "to the points' left edge: {}", app.ui.status); - assert_eq!(chosen_align_to(&mut app, &ctx), [selection], "the stored choice"); - app.run("edit.undo", json!({})).unwrap(); - click(&ctx, &mut app, artboard.center(), show); - click(&ctx, &mut app, left.center(), show); - assert_eq!(xs(&app), [0.0, 0.0, 150.0], "to the artboard's left edge: {}", app.ui.status); - } - - /// #1636: the Distribute buttons work on the anchor points of one path (one object selected). - #[test] - fn distribute_buttons_space_the_anchor_points_of_one_path() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let id = app.run("path.create", json!({"d": "M10 10 L20 50 L100 30"})).unwrap()["id"].as_u64().unwrap(); - app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap(); - let ctx = egui::Context::default(); - frame_events(&ctx, &mut app, vec![], show); - // The 32-point buttons are the Align row and, below it, the Distribute row, whose fifth - // button is Horizontal Distribute Center. - let all = widget_rects(&ctx, egui::vec2(32.0, 32.0)); - let top = all.iter().map(|r| r.top()).fold(f32::MIN, f32::max); - let mut row: Vec<_> = all.into_iter().filter(|r| r.top() == top).collect(); - row.sort_by(|a, b| a.left().total_cmp(&b.left())); - assert_eq!(row.len(), 6, "the Distribute row"); - let hit = row[4].center(); - click(&ctx, &mut app, hit, show); - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); - let xs: Vec = node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect(); - assert_eq!(xs, [10.0, 55.0, 100.0], "{}", app.ui.status); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/align/tests.rs b/crates/ui-egui/src/panels/align/tests.rs new file mode 100644 index 000000000..7a25fd0bb --- /dev/null +++ b/crates/ui-egui/src/panels/align/tests.rs @@ -0,0 +1,694 @@ +use egui::accesskit::{Role, Toggled}; + +use super::super::tests_appearance::{click, frame_events, text_rect}; +use super::*; +use crate::toolbar::tests::widget_rects; +use crate::widgets::accessibility_tests::{role_and_state, tree}; + +#[test] +fn single_object_aligns_to_artboard() { + let p = align_params(json!({"horizontal": "left"}), AlignTo::Selection, 1); + assert_eq!(p["to"], "artboard"); + let p = align_params(json!({"vertical": "top"}), AlignTo::Selection, 3); + assert_eq!(p["to"], "selection"); + // One object and a key: the key is a guide (#2143), which it aligns to. + assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key"); +} + +/// #2143: with one object and a guide selected, Align to Key Object is on and makes the guide +/// the key; Align then lines the object up on it rather than on the artboard. +#[test] +fn align_to_key_object_makes_a_selected_guide_the_key() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("guide.select", json!({"indexes": [0], "toggle": true})).unwrap(); + let ctx = egui::Context::default(); + let ([_, key, _], left) = panel_buttons(&mut app, &ctx); + click(&ctx, &mut app, key.center(), show); + assert_eq!(app.session.active().unwrap().selection.key_guide, Some(0), "{}", app.ui.status); + assert_eq!(align_to(&app), AlignTo::Key); + click(&ctx, &mut app, left.center(), show); + assert_eq!(left_edge(&app, &a), 120.0, "on the guide, not the artboard: {}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 120.0); +} + +/// A Shift-click that leaves the key object selected on its own lets the key go, and the align +/// buttons of the Control bar and the Properties panel, and then the Align panel's, align that +/// object to the artboard. +#[test] +fn a_lone_object_aligns_to_the_artboard() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("select.key", json!({"id": b})).unwrap(); + app.run("select.toggle", json!({"id": a})).unwrap(); + let id = vectorcraft_doc::NodeId(b.as_u64().unwrap()); + let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + // `align_buttons` (the Control bar and the Properties panel): one frame finds the first + // button, and the next clicks Horizontal Align Right, the third. + let at = std::cell::Cell::new(egui::Pos2::ZERO); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + ui.horizontal(|ui| { + at.set(ui.cursor().min); + align_buttons(app, ui, 24.0); + }); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0); + let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]); + frame(&mut app, vec![press(false)]); + assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge"); + // The Align panel's Horizontal Align Left. + let p = with_align_to(&app, json!({"horizontal": "left", "bounds": "geometric"})); + app.run("object.align", p).unwrap(); + assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge"); +} + +#[test] +fn align_both_centers_params() { + let p = align_params(json!({"horizontal": "center", "vertical": "center"}), AlignTo::Selection, 2); + assert_eq!(p["horizontal"], "center"); + assert_eq!(p["vertical"], "center"); + assert_eq!(p["to"], "selection"); +} + +/// #541: while the selection has a key object Align aligns to it, whatever Align To says; +/// choosing Align to Selection lets the key go. +#[test] +fn a_key_object_makes_align_align_to_it() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert_eq!(align_to(&app), AlignTo::Selection); + app.run("select.key", json!({"id": b})).unwrap(); + assert_eq!(align_to(&app), AlignTo::Key); + app.run("select.key", json!({})).unwrap(); + assert_eq!(align_to(&app), AlignTo::Selection, "no key: the choice again"); +} + +/// Two rectangles, the back one at x 10 and the front one at x 80, selected front first → +/// (the app, the back one's id, the front one's id). +fn back_and_front() -> (VectorcraftApp, Value, Value) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let back = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + let front = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [front, back]})).unwrap(); + (app, back, front) +} + +/// One frame of the Align panel → its Align To buttons (Selection, Key Object, Artboard) and +/// its Horizontal Align Left button. +fn panel_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> ([egui::Rect; 3], egui::Rect) { + frame_events(ctx, app, vec![], show); + // The 28-point buttons are Distribute Spacing's two and then Align To's three. + let mut small = widget_rects(ctx, egui::vec2(28.0, 28.0)); + small.sort_by(|a, b| a.left().total_cmp(&b.left())); + let [.., selection, key, artboard] = small[..] else { panic!("no Align To buttons in {small:?}") }; + let objects = widget_rects(ctx, egui::vec2(32.0, 32.0)); + let left = objects.iter().copied().filter(|r| r.top() == objects[0].top()).min_by(|a, b| a.left().total_cmp(&b.left())).unwrap(); + ([selection, key, artboard], left) +} + +/// The left edge of object `id`. +fn left_edge(app: &VectorcraftApp, id: &Value) -> f64 { + let id = vectorcraft_doc::NodeId(id.as_u64().unwrap()); + app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 +} + +/// With two objects selected and no key object, Align to Key Object makes the topmost one the +/// key, whatever order they were selected in, and Align then aligns to it. A key already set +/// stays the key. +#[test] +fn align_to_key_object_makes_the_topmost_object_the_key() { + let (mut app, back, front) = back_and_front(); + let ctx = egui::Context::default(); + let ([_, key, _], left) = panel_buttons(&mut app, &ctx); + click(&ctx, &mut app, key.center(), show); + let key_object = |app: &VectorcraftApp| app.session.active().unwrap().selection.key.map(|k| k.0); + assert_eq!(key_object(&app), front.as_u64(), "the front rectangle, selected first"); + app.run("select.set", json!({"ids": [back, front]})).unwrap(); + click(&ctx, &mut app, key.center(), show); + assert_eq!(key_object(&app), front.as_u64(), "the front rectangle, selected last"); + assert_eq!(align_to(&app), AlignTo::Key); + click(&ctx, &mut app, left.center(), show); + assert_eq!((left_edge(&app, &back), left_edge(&app, &front)), (80.0, 80.0), "to the key's left edge: {}", app.ui.status); + app.run("select.key", json!({"id": back})).unwrap(); + click(&ctx, &mut app, key.center(), show); + assert_eq!(key_object(&app), back.as_u64(), "a key already set"); +} + +/// Align to Key Object stores no Align To choice: once the key object is cleared, by Cancel +/// Key Object, by a click on the key or by another selection, Align To shows the stored +/// choice again. +#[test] +fn align_to_shows_its_choice_again_once_the_key_is_cleared() { + let (mut app, back, front) = back_and_front(); + let ctx = egui::Context::default(); + let ([_, key, artboard], _) = panel_buttons(&mut app, &ctx); + click(&ctx, &mut app, artboard.center(), show); + click(&ctx, &mut app, key.center(), show); + assert_eq!(align_to(&app), AlignTo::Key); + let cancel = text_rect(&frame_events(&ctx, &mut app, vec![], menu), " Cancel Key Object"); + click(&ctx, &mut app, cancel.center(), menu); + assert!(!has_key(&app)); + assert_eq!(align_to(&app), AlignTo::Artboard, "Cancel Key Object"); + // The Selection tool's click on the key object runs `select.key {}`. + click(&ctx, &mut app, key.center(), show); + assert!(has_key(&app)); + app.run("select.key", json!({})).unwrap(); + assert_eq!(align_to(&app), AlignTo::Artboard, "a click on the key"); + click(&ctx, &mut app, key.center(), show); + assert!(has_key(&app)); + app.run("select.set", json!({"ids": [back, front]})).unwrap(); + assert_eq!(align_to(&app), AlignTo::Artboard, "another selection"); +} + +/// The Align To choice is kept with the UI state, which the app saves with its preferences and +/// `ui.inspect` reports. A saved value other than "artboard" reads as Selection, and the rest of +/// the state still loads. +#[test] +fn the_align_to_choice_is_kept_with_the_ui_state() { + let (mut app, _, _) = back_and_front(); + let ctx = egui::Context::default(); + let ([_, _, artboard], _) = panel_buttons(&mut app, &ctx); + click(&ctx, &mut app, artboard.center(), show); + assert_eq!(crate::control::inspect(&app, &ctx)["ui"]["align_to"], "artboard"); + let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert_eq!(back.align_to, AlignTo::Artboard); + for v in [json!("key"), json!("Artboard"), json!(3), Value::Null] { + let ui: crate::UiState = serde_json::from_value(json!({"align_to": v, "place_link": false})).unwrap(); + assert_eq!((ui.align_to, ui.place_link), (AlignTo::Selection, false), "{v}"); + } + let old: crate::UiState = serde_json::from_value(json!({})).unwrap(); + assert_eq!(old.align_to, AlignTo::Selection, "preferences saved without it"); +} + +/// With one object selected, Align to Key Object is disabled: a click sets no key and keeps the +/// Align To choice, and Horizontal Align Left moves the object to the artboard's left edge. +#[test] +fn align_to_key_object_needs_two_selected_objects() { + let (mut app, _, front) = back_and_front(); + app.run("select.set", json!({"ids": [front]})).unwrap(); + let ctx = egui::Context::default(); + let ([_, key, _], left) = panel_buttons(&mut app, &ctx); + let clickable = ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).any(|w| w.rect == key && w.sense.senses_click())); + assert!(!clickable, "disabled"); + click(&ctx, &mut app, key.center(), show); + assert_eq!(app.session.active().unwrap().selection.key, None); + assert_eq!(align_to(&app), AlignTo::Selection); + click(&ctx, &mut app, left.center(), show); + assert_eq!(left_edge(&app, &front), 0.0, "the artboard's left edge"); +} + +/// One frame of the Align panel → the rects of the Align To buttons drawn as chosen. +fn chosen_align_to(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let fill = crate::theme::Tokens::get(ctx).tool_active; + let chosen = |s: &egui::Shape| match s { + egui::Shape::Rect(r) if r.fill == fill && r.rect.size() == egui::vec2(28.0, 28.0) => Some(r.rect), + _ => None, + }; + out.shapes.iter().filter_map(|c| chosen(&c.shape)).collect() +} + +/// With one object selected the align buttons align it to the artboard, and Align To shows +/// Artboard; with two, it shows the stored choice. +#[test] +fn align_to_shows_artboard_while_one_object_is_selected() { + let (mut app, _, front) = back_and_front(); + let ctx = egui::Context::default(); + let ([selection, _, artboard], _) = panel_buttons(&mut app, &ctx); + assert_eq!(chosen_align_to(&mut app, &ctx), [selection], "two objects"); + app.run("select.set", json!({"ids": [front]})).unwrap(); + assert_eq!(chosen_align_to(&mut app, &ctx), [artboard], "one object"); + assert_eq!(align_to(&app), AlignTo::Selection, "the stored choice"); +} + +/// Screen readers name the align buttons, with no pressed state, and the Align To buttons, the +/// chosen one pressed. +#[test] +fn screen_readers_name_the_buttons_and_the_align_to_choice() { + let (mut app, _, _) = back_and_front(); + let tree = tree(|ui| show(&mut app, ui)); + for (_, label, _, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) { + assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); + } + assert_eq!(role_and_state(&tree, "Align to Selection"), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Align to Key Object"), (Role::Button, None)); + assert_eq!(role_and_state(&tree, "Align to Artboard"), (Role::Button, None)); +} + +/// #789: the Control bar's and the Properties panel's align buttons follow Align To too. +#[test] +fn the_other_align_buttons_align_to_the_align_to_choice() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + app.ui.align_to = AlignTo::Artboard; + // One frame finds the first button (Horizontal Align Right is third); the next clicks it. + let at = std::cell::Cell::new(egui::Pos2::ZERO); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + ui.horizontal(|ui| { + at.set(ui.cursor().min); + align_buttons(app, ui, 24.0); + }); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0); + let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]); + frame(&mut app, vec![press(false)]); + let st = app.session.active().unwrap(); + let x1 = |id: &serde_json::Value| st.doc.bounds_of(&[vectorcraft_doc::NodeId(id.as_u64().unwrap())], false).unwrap().x1; + assert_eq!((x1(&a), x1(&b)), (200.0, 200.0), "both to the artboard's right edge"); +} + +/// The Align panel's buttons, in the panel's order: (command id, label). +const BUTTON_ENTRIES: [(&str, &str); 15] = [ + ("align.horizontalLeft", "Horizontal Align Left"), + ("align.horizontalCenter", "Horizontal Align Center"), + ("align.horizontalRight", "Horizontal Align Right"), + ("align.horizontalVerticalCenter", "Horizontal & Vertical Align Center"), + ("align.verticalTop", "Vertical Align Top"), + ("align.verticalCenter", "Vertical Align Center"), + ("align.verticalBottom", "Vertical Align Bottom"), + ("align.distributeVerticalTop", "Vertical Distribute Top"), + ("align.distributeVerticalCenter", "Vertical Distribute Center"), + ("align.distributeVerticalBottom", "Vertical Distribute Bottom"), + ("align.distributeHorizontalLeft", "Horizontal Distribute Left"), + ("align.distributeHorizontalCenter", "Horizontal Distribute Center"), + ("align.distributeHorizontalRight", "Horizontal Distribute Right"), + ("align.distributeVerticalSpace", "Vertical Distribute Space"), + ("align.distributeHorizontalSpace", "Horizontal Distribute Space"), +]; + +/// The chord the tests below give an Align panel button. +const CHORD: &str = "Cmd+Alt+Shift+F2"; + +/// One frame pressing [`CHORD`] through the shortcut handler. +fn press_chord(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + let modifiers = egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT; + let ev = egui::Event::Key { key: egui::Key::F2, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); +} + +/// Edit › Keyboard Shortcuts lists each of the Align panel's Align Objects, Distribute Objects +/// and Distribute Spacing buttons under Align, with the button's label and no default shortcut, +/// and each can be given one. +#[test] +fn keyboard_shortcuts_lists_each_align_panel_button() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let panel = { + let (mut app, _, _) = back_and_front(); + tree(|ui| show(&mut app, ui)) + }; + let list = app.run("shortcuts.list", json!({})).unwrap(); + for (id, label) in BUTTON_ENTRIES { + assert_eq!(role_and_state(&panel, label), (Role::Button, None), "the panel's {label} button"); + let e = list.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("{id} is not listed")); + assert_eq!( + (e["label"].as_str(), e["group"].as_str(), &e["default"], &e["shortcut"]), + (Some(label), Some("Align"), &Value::Null, &Value::Null), + "{id}" + ); + let set = app.run("shortcuts.set", json!({"id": id, "shortcut": CHORD, "force": true})).unwrap(); + assert_eq!(set["shortcut"], CHORD, "{id}"); + } +} + +/// A shortcut on an Align panel button's entry runs what a click on the button runs: Horizontal +/// Align Right aligns to the selection, to the active artboard with Align to Artboard, and to +/// the key object while there is one. Horizontal Distribute Left sends the key and the +/// Distribute Spacing value, and Horizontal Distribute Space makes the gaps equal. +#[test] +fn a_shortcut_runs_its_align_panel_button() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ids: Vec = [(10, 20), (50, 40), (120, 20)] + .into_iter() + .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) + .collect(); + app.run("select.set", json!({"ids": ids})).unwrap(); + let edges = |app: &VectorcraftApp, right: bool| { + let st = app.session.active().unwrap(); + ids.iter() + .map(|id| { + let b = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap(); + if right { b.x1 } else { b.x0 } + }) + .collect::>() + }; + let sent = |app: &VectorcraftApp| { + let (id, p) = app.session.journal.last().unwrap(); + (id.clone(), p["to"].clone(), p.get("spacing").cloned()) + }; + app.run("shortcuts.set", json!({"id": "align.horizontalRight", "shortcut": CHORD})).unwrap(); + press_chord(&mut app); + assert_eq!(edges(&app, true), [140.0; 3], "Align to Selection: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + app.ui.align_to = AlignTo::Artboard; + press_chord(&mut app); + assert_eq!(edges(&app, true), [200.0; 3], "Align to Artboard: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + app.run("select.key", json!({"id": ids[1]})).unwrap(); + press_chord(&mut app); + assert_eq!(edges(&app, true), [90.0; 3], "the key object's right edge: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + // Distribute Objects with the key object and a Distribute Spacing value of 15 pt. + app.run("shortcuts.set", json!({"id": "align.distributeHorizontalLeft", "shortcut": CHORD, "force": true})).unwrap(); + app.ui.align_spacing = 15.0; + press_chord(&mut app); + assert_eq!(sent(&app), ("object.distribute".to_string(), json!("key"), Some(json!(15.0)))); + assert_eq!(edges(&app, false), [35.0, 50.0, 65.0], "15 pt apart: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + // Distribute Spacing with Align to Selection: the outer objects stay, and the gaps are equal. + app.run("select.key", json!({})).unwrap(); + app.ui.align_to = AlignTo::Selection; + app.run("shortcuts.set", json!({"id": "align.distributeHorizontalSpace", "shortcut": CHORD, "force": true})).unwrap(); + press_chord(&mut app); + assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("selection"), Some(Value::Null))); + assert_eq!(edges(&app, false), [10.0, 55.0, 120.0], "25 pt gaps: {}", app.ui.status); +} + +/// A shortcut on an Align panel button's entry is kept in the set Export… writes, and Import… +/// of that set gives it back after a reset; it is kept in the saved preferences too. +#[test] +fn an_align_panel_button_shortcut_is_kept_in_a_saved_set() { + let (mut app, back, front) = back_and_front(); + let written: std::rc::Rc>> = Default::default(); + let w = written.clone(); + app.services.write = Some(Box::new(move |_: &str, b: &[u8]| { + *w.borrow_mut() = b.to_vec(); + Ok(()) + })); + let top = |app: &VectorcraftApp, id: &Value| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().y0 + }; + app.run("shortcuts.set", json!({"id": "align.verticalTop", "shortcut": CHORD})).unwrap(); + app.run("shortcuts.export", json!({"path": "/sets/Align.json"})).unwrap(); + // The preferences save the UI state, and the next launch reads it back. + let saved = serde_json::to_value(&app.ui).unwrap(); + let loaded: crate::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(serde_json::to_value(&loaded).unwrap()["shortcut_overrides"]["align.verticalTop"], CHORD, "the saved preferences"); + app.run("shortcuts.reset", json!({})).unwrap(); + press_chord(&mut app); + assert_eq!(top(&app, &front), 50.0, "no shortcut after the reset: {}", app.ui.status); + let set: Value = serde_json::from_slice(&written.borrow()).unwrap(); + app.run("shortcuts.import", json!({"data": set})).unwrap(); + press_chord(&mut app); + assert_eq!((top(&app, &back), top(&app, &front)), (10.0, 10.0), "the imported shortcut: {}", app.ui.status); +} + +/// One frame of the Align panel → its Horizontal Distribute Left and Horizontal Distribute +/// Space buttons. +fn distribute_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> (egui::Rect, egui::Rect) { + frame_events(ctx, app, vec![], show); + // The lower row of 32-point buttons is Distribute Objects': Vertical Distribute Top, Center + // and Bottom, then Horizontal Distribute Left, Center and Right. + let objects = widget_rects(ctx, egui::vec2(32.0, 32.0)); + let bottom = objects.last().unwrap().top(); + let mut row: Vec = objects.into_iter().filter(|r| r.top() == bottom).collect(); + row.sort_by(|a, b| a.left().total_cmp(&b.left())); + // The 28-point buttons are Distribute Spacing's two (Vertical, then Horizontal) and then + // Align To's three. + let mut small = widget_rects(ctx, egui::vec2(28.0, 28.0)); + small.sort_by(|a, b| a.left().total_cmp(&b.left())); + (row[3], small[1]) +} + +/// With Align to Artboard chosen, the Align panel's Horizontal Distribute Left puts the +/// outermost of three objects (20, 40 and 20 pt wide) on the active artboard's left and right +/// edges and spaces their left edges evenly, and Horizontal Distribute Space makes the gaps +/// between them equal. With Align to Selection chosen, the outermost objects stay put. +#[test] +fn the_distribute_buttons_follow_align_to_artboard() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200, "artboards": 2})).unwrap(); + let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + let ids: Vec = [(10, 20), (50, 40), (120, 20)] + .into_iter() + .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) + .collect(); + app.run("select.set", json!({"ids": ids})).unwrap(); + let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::>(); + let ctx = egui::Context::default(); + let (left, space) = distribute_buttons(&mut app, &ctx); + click(&ctx, &mut app, left.center(), show); + assert_eq!(lefts(&app), [10.0, 65.0, 120.0], "Align to Selection: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + // The objects are on artboard 0, and artboard 1 is the active one. + app.ui.align_to = AlignTo::Artboard; + app.view_mut().unwrap().artboard = 1; + let (x0, x1) = (boards[1].x0, boards[1].x1); + click(&ctx, &mut app, left.center(), show); + assert_eq!(lefts(&app), [x0, x0 + 90.0, x1 - 20.0], "the active artboard's edges: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + click(&ctx, &mut app, space.center(), show); + assert_eq!(lefts(&app), [x0, x0 + 80.0, x1 - 20.0], "60 pt gaps: {}", app.ui.status); +} + +/// With a key object, the Align panel's Distribute Objects buttons send the Distribute Spacing +/// value: Horizontal Distribute Left puts the others' left edges on the key's left edge with +/// the default value of 0, and puts them 15 pt apart from it with 15. Without a key object +/// they send no spacing. +#[test] +fn the_distribute_objects_buttons_send_the_spacing_with_a_key_object() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ids: Vec = [(10, 20), (50, 40), (120, 20)] + .into_iter() + .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) + .collect(); + app.run("select.set", json!({"ids": ids})).unwrap(); + let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::>(); + // The command and its `to` and `spacing` params (the app adds the active artboard). + let sent = |app: &VectorcraftApp| { + let (id, p) = app.session.journal.last().unwrap(); + (id.clone(), p["to"].clone(), p.get("spacing").cloned()) + }; + let ctx = egui::Context::default(); + let (left, _) = distribute_buttons(&mut app, &ctx); + click(&ctx, &mut app, left.center(), show); + assert_eq!(sent(&app), ("object.distribute".to_string(), json!("selection"), None), "no key object"); + assert_eq!(lefts(&app), [10.0, 65.0, 120.0], "{}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + app.run("select.key", json!({"id": ids[1]})).unwrap(); + click(&ctx, &mut app, left.center(), show); + assert_eq!(sent(&app), ("object.distribute".to_string(), json!("key"), Some(json!(0.0)))); + assert_eq!(lefts(&app), [50.0; 3], "on the key's left edge: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + app.ui.align_spacing = 15.0; + click(&ctx, &mut app, left.center(), show); + assert_eq!(lefts(&app), [35.0, 50.0, 65.0], "15 pt apart: {}", app.ui.status); +} + +/// #2143: with a key guide, the Distribute Objects and Distribute Spacing buttons send the +/// Distribute Spacing value too, and the objects are spaced from the guide, which stays put. +#[test] +fn the_distribute_buttons_send_the_spacing_with_a_key_guide() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ids: Vec = [(10, 20), (50, 40), (120, 20)] + .into_iter() + .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) + .collect(); + app.run("guide.add", json!({"vertical": true, "pos": 100})).unwrap(); + app.run("select.set", json!({"ids": ids})).unwrap(); + app.run("guide.select", json!({"indexes": [0], "toggle": true})).unwrap(); + app.run("select.key", json!({"guide": 0})).unwrap(); + let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::>(); + let sent = |app: &VectorcraftApp| { + let (id, p) = app.session.journal.last().unwrap(); + (id.clone(), p["to"].clone(), p.get("spacing").cloned()) + }; + let ctx = egui::Context::default(); + let (left, space) = distribute_buttons(&mut app, &ctx); + click(&ctx, &mut app, left.center(), show); + assert_eq!(sent(&app), ("object.distribute".to_string(), json!("key"), Some(json!(0.0)))); + assert_eq!(lefts(&app), [100.0; 3], "on the guide: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + app.ui.align_spacing = 15.0; + click(&ctx, &mut app, left.center(), show); + assert_eq!(lefts(&app), [70.0, 85.0, 115.0], "15 pt apart, outward from the guide: {}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + // Horizontal Distribute Space with 15: the object before the guide ends 15 pt before it, + // the one after it starts 15 pt after it. + click(&ctx, &mut app, space.center(), show); + assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("key"), Some(json!(15.0)))); + assert_eq!(lefts(&app), [10.0, 45.0, 115.0], "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); +} + +/// Three vertical ruler guides selected without objects: Align Objects buttons are on, and +/// Horizontal Align Left puts every guide on the artboard's left edge (#3463). +#[test] +fn the_align_buttons_align_guides_selected_alone_to_artboard() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + for pos in [20, 50, 180] { + app.run("guide.add", json!({"vertical": true, "pos": pos})).unwrap(); + } + app.run("guide.select", json!({"indexes": [0, 1, 2]})).unwrap(); + app.ui.align_to = AlignTo::Artboard; + let ctx = egui::Context::default(); + frame_events(&ctx, &mut app, vec![], show); + let row = widget_rects(&ctx, egui::vec2(32.0, 32.0)); + let top = row.iter().map(|r| r.top()).fold(f32::MAX, f32::min); + let mut row: Vec = row.into_iter().filter(|r| r.top() == top).collect(); + row.sort_by(|a, b| a.left().total_cmp(&b.left())); + assert!(row[0].width() > 0.0, "Horizontal Align Left is enabled"); + click(&ctx, &mut app, row[0].center(), show); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + assert_eq!(pos(&app), [0.0, 0.0, 0.0], "{}", app.ui.status); +} + +/// Three vertical ruler guides selected without objects: the Distribute buttons are on, and +/// Horizontal Distribute Center puts the middle guide halfway between the other two, or with +/// Align to Artboard spaces them from its left edge to its right edge, as a public forum answer +/// describes for equal columns (https://community.adobe.com/questions-652/illustrator-equal-guides-822924). +#[test] +fn the_distribute_buttons_space_guides_selected_alone() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + for pos in [20, 50, 180] { + app.run("guide.add", json!({"vertical": true, "pos": pos})).unwrap(); + } + app.run("guide.select", json!({"indexes": [0, 1, 2]})).unwrap(); + let ctx = egui::Context::default(); + frame_events(&ctx, &mut app, vec![], show); + let row = widget_rects(&ctx, egui::vec2(32.0, 32.0)); + let bottom = row.iter().map(|r| r.top()).fold(f32::MIN, f32::max); + let mut row: Vec = row.into_iter().filter(|r| r.top() == bottom).collect(); + row.sort_by(|a, b| a.left().total_cmp(&b.left())); + assert_eq!(row.len(), 6, "the Distribute row"); + // Its fifth button is Horizontal Distribute Center. + click(&ctx, &mut app, row[4].center(), show); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + assert_eq!(pos(&app), [20.0, 100.0, 180.0], "{}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(pos(&app), [20.0, 50.0, 180.0], "one undo step"); + // With Align to Artboard, the outermost guides go to its edges: equal columns across it. + app.ui.align_to = AlignTo::Artboard; + click(&ctx, &mut app, row[4].center(), show); + assert_eq!(pos(&app), [0.0, 100.0, 200.0], "{}", app.ui.status); +} + +/// Runs the Object → Align item `label` as a click on it does. +fn object_align(app: &mut VectorcraftApp, label: &str) { + let entry = crate::menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Align"] && e.label == label).expect("menu item"); + let (id, p) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params); + crate::menus::invoke(app, &id, p); +} + +/// Object → Align aligns as the align buttons do: it aligns one object to the active artboard +/// whatever Align To says, and with Align to Artboard chosen it aligns two objects to the active +/// artboard too. +#[test] +fn object_align_follows_align_to() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200, "artboards": 2})).unwrap(); + let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); + let bounds = |app: &VectorcraftApp, id: &Value| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap() + }; + app.run("select.set", json!({"ids": [b]})).unwrap(); + object_align(&mut app, "Horizontal Align Left"); + assert_eq!(bounds(&app, &b).x0, 0.0, "one object, Align to Selection: the artboard's left edge"); + app.ui.align_to = AlignTo::Artboard; + app.view_mut().unwrap().artboard = 1; + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + object_align(&mut app, "Horizontal Align Right"); + assert_eq!((bounds(&app, &a).x1, bounds(&app, &b).x1), (boards[1].x1, boards[1].x1), "the active artboard's right edge"); +} + +/// With anchor points selected, Object → Align sends its params unchanged: two anchor points of +/// one path align to their own bounds with Align to Selection and with Align to Artboard chosen. +#[test] +fn object_align_aligns_anchor_points_to_each_other() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap(); + let anchors = |app: &VectorcraftApp| -> Vec<(f64, f64)> { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + node.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() + }; + for to in [AlignTo::Selection, AlignTo::Artboard] { + app.ui.align_to = to; + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + object_align(&mut app, "Horizontal Align Left"); + assert_eq!(anchors(&app), [(100.0, 40.0), (100.0, 60.0), (150.0, 160.0)], "{to:?}: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(anchors(&app)[1], (200.0, 60.0), "undone"); + } +} + +/// #1636: with two anchor points of one path selected, the Align panel's buttons align the +/// points to each other, not to the artboard as they align a lone object, and Align To shows +/// the stored choice. With Align to Artboard chosen the points go to the artboard. +#[test] +fn anchor_points_of_one_path_align_to_each_other() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap(); + let xs = |app: &VectorcraftApp| -> Vec { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect() + }; + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + let ctx = egui::Context::default(); + let ([selection, _, artboard], left) = panel_buttons(&mut app, &ctx); + click(&ctx, &mut app, left.center(), show); + assert_eq!(xs(&app), [100.0, 100.0, 150.0], "to the points' left edge: {}", app.ui.status); + assert_eq!(chosen_align_to(&mut app, &ctx), [selection], "the stored choice"); + app.run("edit.undo", json!({})).unwrap(); + click(&ctx, &mut app, artboard.center(), show); + click(&ctx, &mut app, left.center(), show); + assert_eq!(xs(&app), [0.0, 0.0, 150.0], "to the artboard's left edge: {}", app.ui.status); +} + +/// #1636: the Distribute buttons work on the anchor points of one path (one object selected). +#[test] +fn distribute_buttons_space_the_anchor_points_of_one_path() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("path.create", json!({"d": "M10 10 L20 50 L100 30"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap(); + let ctx = egui::Context::default(); + frame_events(&ctx, &mut app, vec![], show); + // The 32-point buttons are the Align row and, below it, the Distribute row, whose fifth + // button is Horizontal Distribute Center. + let all = widget_rects(&ctx, egui::vec2(32.0, 32.0)); + let top = all.iter().map(|r| r.top()).fold(f32::MIN, f32::max); + let mut row: Vec<_> = all.into_iter().filter(|r| r.top() == top).collect(); + row.sort_by(|a, b| a.left().total_cmp(&b.left())); + assert_eq!(row.len(), 6, "the Distribute row"); + let hit = row[4].center(); + click(&ctx, &mut app, hit, show); + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + let xs: Vec = node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect(); + assert_eq!(xs, [10.0, 55.0, 100.0], "{}", app.ui.status); +} From 15ec504cff25474e85b589c4c229761bf271e77a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:49 +0000 Subject: [PATCH 036/199] refactor(ui-egui): move dialogs/color_picker.rs tests to dialogs/color_picker/tests.rs --- crates/ui-egui/src/dialogs/color_picker.rs | 675 +----------------- .../ui-egui/src/dialogs/color_picker/tests.rs | 672 +++++++++++++++++ 2 files changed, 673 insertions(+), 674 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/color_picker/tests.rs diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 7e9d8e637..eb72ae7bb 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -640,677 +640,4 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } #[cfg(test)] -mod tests { - use vectorcraft_engine::Session; - - use super::*; - use crate::panels::color::in_gamut; - - fn app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); - app - } - - fn frame(app: &mut VectorcraftApp) { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); - out.textures_delta.clear(); - } - - fn fill_hex(app: &VectorcraftApp) -> String { - crate::panels::current_paints(app).0.color().unwrap().to_hex() - } - - /// Runs one frame of the dialog with `events` in `ctx`, which keeps the keyboard focus from - /// frame to frame, and returns the texts drawn with their screen rects. - fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, Rect)> { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); - out.textures_delta.clear(); - fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { - match s { - egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), - egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), - _ => {} - } - } - let mut texts = vec![]; - out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); - texts - } - - /// Returns the label of the value field that has the keyboard, which is the letter (or `#`) - /// nearest to the field's left on its row, and the field's rect. Returns `None` when no text - /// field has the keyboard. - fn focused_field(ctx: &egui::Context, texts: &[(String, Rect)]) -> Option<(String, Rect)> { - let id = ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some())?; - let field = ctx.read_response(id)?.rect; - let labels = ["H", "S", "B", "R", "G", "#", "L", "a", "b", "C", "M", "Y", "K"]; - let (label, _) = texts - .iter() - .filter(|(t, r)| labels.contains(&t.as_str()) && r.max.x <= field.min.x && field.y_range().contains(r.center().y)) - .max_by(|a, b| a.1.max.x.total_cmp(&b.1.max.x))?; - Some((label.clone(), field)) - } - - #[test] - fn field_and_slider_map_back_per_channel() { - let p = Picked::of(Color::rgb8(51, 153, 204)); - for ch in Channel::ALL { - let (z, x, y) = field_pos(ch, &p); - assert_eq!(field_pick(ch, z, x, y).color.to_hex(), "#3399cc", "{ch:?}"); - assert_eq!(Channel::parse(ch.id()), Some(ch)); - } - // Hue: x is saturation, y brightness (white at the top left, black along the bottom). - assert_eq!(field_pick(Channel::Hue, 0.0, 0.0, 0.0).color.to_hex(), "#ffffff"); - assert_eq!(field_pick(Channel::Hue, 0.0, 1.0, 0.0).color.to_hex(), "#ff0000"); - assert_eq!(field_pick(Channel::Hue, 0.5, 1.0, 1.0).color.to_hex(), "#000000"); - // Red: the slider is red, x blue and y green (green grows upwards). - assert_eq!(field_pick(Channel::Red, 1.0, 1.0, 0.0).color.to_hex(), "#ffffff"); - assert_eq!(field_pick(Channel::Red, 0.0, 1.0, 1.0).color.to_hex(), "#0000ff"); - assert_eq!(field_pick(Channel::Green, 1.0, 0.0, 1.0).color.to_hex(), "#00ff00"); - assert_eq!(field_pick(Channel::Blue, 0.0, 1.0, 1.0).color.to_hex(), "#ff0000"); - // The hue slider shows the spectrum whatever the colour; the others vary one component. - assert_eq!(slider_color(Channel::Hue, 1.0 / 3.0, 0.0, 1.0).to_hex(), "#00ff00"); - assert_eq!(slider_color(Channel::Saturation, 0.0, 0.0, 0.0).to_hex(), "#ffffff"); - // A grey keeps the hue it was picked with. - let grey = field_pick(Channel::Hue, 0.5, 0.0, 0.5); - assert_eq!(grey.hsb[0], 180.0); - assert_eq!(field_pos(Channel::Hue, &grey).0, 0.5); - } - - #[test] - fn only_web_colors_snaps() { - let p = Picked::of(Color::rgb8(230, 120, 40)); - assert_eq!(p.snapped(false), p); - assert_eq!(p.snapped(true).color.to_hex(), "#ff6633"); - assert!(is_web_safe(&p.snapped(true).color)); - let mut d = Dialog::new(KIND, json!({"color": "#e67828", "webOnly": true})); - assert_eq!(picked(&d).color.to_hex(), "#ff6633"); - d.fields.insert("webOnly".into(), json!(false)); - assert_eq!(picked(&d).color.to_hex(), "#e67828"); - } - - #[test] - fn gamut_correction_gives_a_printable_colour() { - let mut app = app(); - let blue = Color::rgb(0.0, 0.0, 1.0); - assert!(blue.out_of_gamut()); - let fix = in_gamut(&mut app, &blue).expect("pure blue can't be printed"); - assert!(!fix.out_of_gamut(), "{fix:?}"); - assert!(matches!(fix, Color::Rgb { .. })); - assert_eq!(in_gamut(&mut app, &Color::rgb8(128, 128, 128)), None); - // The dialog and the Color panel ask through a cache. - let ctx = egui::Context::default(); - assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix)); - assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix)); - assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None); - } - - /// The selected art takes each colour as it is picked; OK keeps the last as one undo step and - /// Cancel puts the art back. - #[test] - fn the_selection_shows_the_colour_live() { - let mut app = app(); - let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); - let before = undo(&app); - app.run("ui.colorPicker", json!({})).unwrap(); - frame(&mut app); - assert!(!app.session.in_interaction(), "nothing previews before the colour changes"); - for hex in ["ff0000", "00ff00"] { - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex)); - frame(&mut app); - assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK"); - } - super::super::confirm(&mut app).unwrap(); - assert_eq!(fill_hex(&app), "#00ff00"); - assert_eq!(undo(&app), before + 1, "one step"); - // Cancel: back to the colour it had. - app.run("ui.colorPicker", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); - frame(&mut app); - assert_eq!(fill_hex(&app), "#0000ff"); - super::super::cancel(&mut app); - assert_eq!(fill_hex(&app), "#00ff00"); - assert_eq!(undo(&app), before + 1); - } - - /// Opened for an artboard (Properties › Artboards › Fill), the picker starts from the artboard's - /// background color (white without one) and shows each color on its paper live, with nothing - /// selected. OK keeps the last as one undo step, the art's fill unchanged, and Cancel puts the - /// artboard back. - #[test] - fn an_artboard_shows_its_background_color_live() { - let mut app = app(); - app.run("select.set", json!({"ids": []})).unwrap(); - app.run("artboard.new", json!({})).unwrap(); - let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); - let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); - let art = app.session.doc().unwrap().doc.layers.clone(); - let before = undo(&app); - app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "FFFFFF", "starts from white paper"); - frame(&mut app); - assert!(!app.session.in_interaction(), "nothing previews before the color changes"); - for hex in ["ff0000", "00ff00"] { - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex)); - frame(&mut app); - assert_eq!(color(&app), Some(format!("#{hex}")), "live, before OK"); - } - super::super::confirm(&mut app).unwrap(); - assert_eq!(color(&app).as_deref(), Some("#00ff00")); - assert_eq!(undo(&app), before + 1, "one step"); - assert_eq!(app.session.doc().unwrap().doc.layers, art, "the art keeps its fill"); - // Cancel: back to the color it had, which the picker starts from. - app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00"); - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); - frame(&mut app); - assert_eq!(color(&app).as_deref(), Some("#0000ff")); - super::super::cancel(&mut app); - assert_eq!(color(&app).as_deref(), Some("#00ff00")); - assert_eq!(undo(&app), before + 1); - assert!(app.run("ui.colorPicker", json!({"artboard": 9})).is_err()); - } - - /// OK on a picker opened for an artboard without a background color, with the color left at the - /// white it starts from, leaves the artboard without one and adds no undo step. A white the - /// opener passes as `color` is set. - #[test] - fn an_untouched_picker_leaves_an_artboard_without_a_color() { - let mut app = app(); - app.run("artboard.new", json!({})).unwrap(); - let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); - let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); - let before = undo(&app); - app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); - frame(&mut app); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none(), "OK closes the picker"); - assert_eq!(color(&app), None, "no color"); - assert_eq!(undo(&app), before, "no undo step"); - app.run("ui.colorPicker", json!({"artboard": 1, "color": "#ffffff"})).unwrap(); - frame(&mut app); - super::super::confirm(&mut app).unwrap(); - assert_eq!(color(&app).as_deref(), Some("#ffffff")); - assert_eq!(undo(&app), before + 1); - } - - /// OK on an untouched picker opened for an artboard with an RGB background color in a CMYK - /// document keeps the RGB color and adds no undo step. A color picked in it is set as CMYK. - #[test] - fn an_untouched_picker_keeps_an_artboards_rgb_color_in_a_cmyk_document() { - let mut app = app(); - app.run("artboard.new", json!({})).unwrap(); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap(); - let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color; - let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); - let start = color(&app); - assert!(matches!(start, Some(Color::Rgb { .. })), "set as RGB"); - let before = undo(&app); - app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); - frame(&mut app); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none(), "OK closes the picker"); - assert_eq!(color(&app), start, "the RGB color is kept"); - assert_eq!(undo(&app), before, "no undo step"); - app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); - frame(&mut app); - super::super::confirm(&mut app).unwrap(); - assert!(matches!(color(&app), Some(Color::Cmyk { .. })), "a picked color is set as CMYK"); - assert_eq!(undo(&app), before + 1); - } - - /// OK on an untouched picker opened for an artboard with a background color in an RGB - /// document keeps the color and adds no undo step. - #[test] - fn an_untouched_picker_adds_no_undo_step_for_an_artboards_color() { - let mut app = app(); - app.run("artboard.new", json!({})).unwrap(); - app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap(); - let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); - let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); - let before = undo(&app); - app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); - frame(&mut app); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none(), "OK closes the picker"); - assert_eq!(color(&app).as_deref(), Some("#ff0000")); - assert_eq!(undo(&app), before, "no undo step"); - } - - #[test] - fn open_set_hex_and_confirm_fills() { - let mut app = app(); - app.run("ui.colorPicker", json!({})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(super::super::DialogKind::of(&d.kind), Some(super::super::DialogKind::ColorPicker)); - assert_eq!(d.str("hex"), "FFFFFF", "starts from the fill"); - assert!(!d.bool("stroke")); - frame(&mut app); - // `ui.dialog.set {field: "hex"}` then `ui.dialog.confirm`. - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); - frame(&mut app); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00"); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - assert_eq!(fill_hex(&app), "#00ff00"); - // Without a frame in between, a set hex still wins; `color` works as well. - app.run("ui.colorPicker", json!({"stroke": true})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#3366CC")); - super::super::confirm(&mut app).unwrap(); - assert_eq!(crate::panels::current_paints(&app).1.color().unwrap().to_hex(), "#3366cc"); - // The stroke proxy is in front now: the opener follows it unless told otherwise. - app.run("ui.colorPicker", json!({})).unwrap(); - assert!(app.ui.dialog.as_ref().unwrap().bool("stroke")); - app.run("ui.colorPicker", json!({"stroke": false})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!({"c": 0, "m": 1, "y": 1, "k": 0})); - super::super::confirm(&mut app).unwrap(); - assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK is kept"); - assert!(app.run("ui.colorPicker", json!({"color": "nope"})).is_err()); - // Colours take every form the paint commands take (CMYK in percent too). - app.run("ui.colorPicker", json!({"stroke": false, "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap(); - super::super::confirm(&mut app).unwrap(); - assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))); - } - - /// #1692: Tab goes through one color model's fields before the next: H, S, B, then R, G, B, the - /// hex field, then L, a, b, then C, M, Y, K. Each Lab or CMYK field is level with the field on - /// its left. - #[test] - fn tab_goes_through_one_color_models_fields_before_the_next() { - let mut app = app(); - app.run("ui.colorPicker", json!({})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - // The picker opens with the keyboard in H. The loop presses Tab until K has the keyboard - // and records each stop. A stop is `None` when no text field has the keyboard, as at a - // channel's radio button. - let mut texts = vec![]; - for _ in 0..3 { - texts = frame_in(&ctx, &mut app, vec![]); - } - let mut stops = vec![focused_field(&ctx, &texts)]; - while stops.len() < 40 && stops.last().and_then(|s| s.as_ref()).is_none_or(|(label, _)| label != "K") { - texts = frame_in(&ctx, &mut app, vec![tab.clone()]); - stops.push(focused_field(&ctx, &texts)); - } - let names: Vec> = stops.iter().map(|s| s.as_ref().map(|(label, _)| label.as_str())).collect(); - let fields: Vec<&str> = names.iter().flatten().copied().collect(); - assert_eq!(fields, ["H", "S", "B", "R", "G", "B", "#", "L", "a", "b", "C", "M", "Y", "K"], "{names:?}"); - assert_eq!(names[names.len() - 2..], [Some("Y"), Some("K")], "one Tab takes Y to K"); - let found: Vec<&(String, Rect)> = stops.iter().flatten().collect(); - for (left, right) in found[..7].iter().zip(&found[7..]) { - // Two grids: their rows land on the same subpixel-rounded line, not necessarily bit-equal. - assert!((left.1.center().y - right.1.center().y).abs() <= 0.5, "{} and {} are level", left.0, right.0); - } - } - - #[test] - fn every_mode_draws_and_cmyk_documents_get_cmyk() { - let mut app = app(); - for fields in - [json!({"channel": "red"}), json!({"channel": "brightness", "webOnly": true}), json!({"swatches": true}), json!({"color": "#0000ff"})] - { - app.run("ui.colorPicker", json!({})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - for (k, v) in fields.as_object().unwrap() { - d.fields.insert(k.clone(), v.clone()); - } - frame(&mut app); - assert!(app.ui.dialog.is_some()); - } - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap(); - super::super::confirm(&mut app).unwrap(); - assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. }))); - } - /// Inspect actual painted controls, rather than a second implementation of the layout. - #[derive(Default)] - struct Drawn { - texts: Vec<(String, Rect)>, - visible_texts: Vec, - boxes: Vec, - fields: Vec, - } - - impl Drawn { - /// Collects painted text, control bounds and color meshes, recording text visibility - /// against the clip rectangle so tests can detect clipped labels. - fn collect(&mut self, shape: &egui::Shape, clip: Rect) { - match shape { - egui::Shape::Text(t) => { - self.texts.push((t.galley.text().into(), t.visual_bounding_rect())); - if clip.expand(1.0).contains_rect(t.visual_bounding_rect()) { - self.visible_texts.push(t.galley.text().into()); - } - } - egui::Shape::Rect(r) => self.boxes.push(r.rect), - egui::Shape::Mesh(m) if m.vertices.len() > 100 => self.fields.push(m.calc_bounds()), - egui::Shape::Vec(v) => v.iter().for_each(|s| self.collect(s, clip)), - _ => {} - } - } - - /// Returns the bounds of exactly one painted label, failing on missing or duplicate text. - fn text(&self, label: &str) -> Rect { - let found: Vec<_> = self.texts.iter().filter(|(s, _)| s == label).collect(); - assert_eq!(found.len(), 1, "one {label}, including no duplicate footer: {:?}", self.texts); - found[0].1 - } - - /// Finds the smallest painted control containing a unique label's center. - fn control(&self, label: &str) -> Rect { - let center = self.text(label).center(); - self.boxes.iter().filter(|r| r.contains(center)).min_by(|a, b| a.area().total_cmp(&b.area())).copied().unwrap() - } - } - - /// Runs a dialog frame with real egui input at a physical window size, accounting for UI zoom, - /// and returns the painted controls for layout and interaction assertions. - fn draw(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2, events: Vec) -> Drawn { - let input = - egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size / ctx.zoom_factor())), events, ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| { - crate::shortcuts::handle(app, ui.ctx()); - super::super::show(app, ui.ctx()); - }); - let mut drawn = Drawn::default(); - for shape in &out.shapes { - drawn.collect(&shape.shape, shape.clip_rect); - } - out.textures_delta.clear(); - drawn - } - - /// The physical window size used for pointer and keyboard interaction tests. - fn screen() -> egui::Vec2 { - vec2(1280.0, 900.0) - } - - /// Lets the auto-sized dialog settle over six frames and returns the next frame's controls. - fn settled(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2) -> Drawn { - for _ in 0..6 { - draw(ctx, app, size, vec![]); - } - draw(ctx, app, size, vec![]) - } - - /// Creates a persistent test context with the application's fonts and default theme. - fn context() -> egui::Context { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, Default::default()); - ctx - } - - /// Sends a primary pointer press and release in separate frames at the given position. - fn click_at(ctx: &egui::Context, app: &mut VectorcraftApp, at: egui::Pos2) { - for pressed in [true, false] { - draw( - ctx, - app, - screen(), - vec![ - egui::Event::PointerMoved(at), - egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, - ], - ); - } - } - - /// Creates an unmodified, non-repeating key press for dialog shortcut tests. - fn key(key: egui::Key) -> egui::Event { - egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() } - } - - /// Checks accessible action names and each channel slider's displayed value, range and units. - #[test] - fn compact_picker_preserves_screen_reader_controls_and_channel_values() { - use egui::accesskit::Role; - - for (channel, value, maximum, text) in [ - (Channel::Hue, 210.0, 360.0, "210°"), - (Channel::Saturation, 67.0, 100.0, "67%"), - (Channel::Brightness, 60.0, 100.0, "60%"), - (Channel::Red, 51.0, 255.0, "51"), - (Channel::Green, 102.0, 255.0, "102"), - (Channel::Blue, 153.0, 255.0, "153"), - ] { - let mut app = app(); - app.services.screen_color = Some(Box::new(|| Ok(std::sync::mpsc::channel().1))); - open(&mut app, &json!({"color": "#336699"})).unwrap(); - let mut d = app.ui.dialog.take().unwrap(); - d.fields.insert("channel".into(), json!(channel.id())); - let tree = widgets::accessibility_tests::tree(|ui| { - body(&mut app, ui, &mut d); - }); - let slider = tree.iter().find(|n| n.role() == Role::Slider && n.label() == Some(channel.label())).unwrap(); - assert_eq!(slider.numeric_value(), Some(value)); - assert_eq!(slider.min_numeric_value(), Some(0.0)); - assert_eq!(slider.max_numeric_value(), Some(maximum)); - assert_eq!(slider.value(), Some(text)); - for label in ["OK", "Cancel", "Color Swatches", "Sample Color from Screen"] { - assert!(tree.iter().any(|n| n.role() == Role::Button && n.label() == Some(label)), "missing button: {label}"); - } - } - } - - /// Checks aligned actions and input rows at two UI scales, and stable dimensions when - /// switching between color models and swatches. - #[test] - fn inline_actions_are_aligned_and_modes_keep_the_window_size() { - // Language selection is process-global; German is checked in the native app instead - // of racing the other parallel UI tests by changing the locale here. - for zoom in [1.0, 1.5] { - let (ctx, mut app) = (context(), app()); - ctx.set_zoom_factor(zoom); - app.run("ui.colorPicker", json!({})).unwrap(); - let drawn = settled(&ctx, &mut app, screen()); - let ok = drawn.control(tl!("OK")); - let cancel = drawn.control(tl!("Cancel")); - let toggle = drawn.control(tl!("Color Swatches")); - assert!(ok.bottom() < cancel.top() && cancel.bottom() < toggle.top()); - for r in [cancel, toggle] { - assert!((r.left() - ok.left()).abs() < 1.0 && (r.width() - ok.width()).abs() < 1.0); - } - let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap(); - assert!((field.width() - field.height()).abs() < 1.0); - assert!((field.top() - ok.top()).abs() < 1.0); - let k = drawn.text("K"); - let last_input = drawn - .boxes - .iter() - .find(|r| (r.width() - VALUE_W).abs() < 0.5 && r.left() > k.right() && r.contains(egui::pos2(r.center().x, k.center().y))) - .unwrap(); - assert!((last_input.bottom() - field.bottom()).abs() < 2.0, "{last_input:?} vs {field:?}"); - // Fonts and translated actions may leave one normal gutter, but never an unused column. - assert!((last_input.right() - ok.right()).abs() < COLUMN_GAP, "no unused column beside the inputs: {last_input:?} vs {ok:?}"); - let id = egui::Id::unique(("dialog", KIND)); - let before = ctx.memory(|m| m.area_rect(id)).unwrap(); - assert!(drawn.boxes.iter().all(|r| r.left() >= before.left() - 1.0 && r.right() <= before.right() + 1.0), "controls fit horizontally"); - let title = drawn.text(tl!("Color Picker")); - assert!((title.center().x - before.center().x).abs() < 1.0, "centred heading"); - assert!(title.bottom() < field.top(), "a separate title band above the content"); - assert!(drawn.visible_texts.iter().any(|s| s == tl!("Only Web Colors")), "the checkbox label is fully visible"); - assert!(drawn.text("L").top() - toggle.bottom() >= PREVIEW_GAP, "space below the actions"); - click_at(&ctx, &mut app, toggle.center()); - assert!(app.ui.dialog.as_ref().unwrap().bool("swatches")); - let drawn = settled(&ctx, &mut app, screen()); - assert!((drawn.control(tl!("Color Models")).width() - ok.width()).abs() < 1.0); - let after = ctx.memory(|m| m.area_rect(id)).unwrap(); - assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}"); - assert!( - drawn.boxes.iter().all(|r| r.left() >= after.left() - 1.0 && r.right() <= after.right() + 1.0), - "swatch controls fit horizontally" - ); - click_at(&ctx, &mut app, drawn.text(tl!("Color Models")).center()); - assert!(!app.ui.dialog.as_ref().unwrap().bool("swatches")); - } - } - - /// Clicks the sampler above the field, receives a color and checks Cancel restores the paint. - #[test] - fn sampler_above_the_field_previews_and_cancel_restores_the_original() { - let (ctx, mut app) = (context(), app()); - let (tx, rx) = std::sync::mpsc::channel(); - let mut receiver = Some(rx); - app.services.screen_color = Some(Box::new(move || receiver.take().ok_or("sampler already started".into()))); - app.run("ui.colorPicker", json!({})).unwrap(); - let original = fill_hex(&app); - let drawn = settled(&ctx, &mut app, screen()); - let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap(); - let hover = egui::pos2(field.right() - 12.0, field.top() - 20.0); - draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(hover)]); - let drawn = settled(&ctx, &mut app, screen()); - let sampler = drawn.boxes.iter().find(|r| r.size() == vec2(24.0, 24.0) && r.bottom() < field.top()).unwrap(); - assert!(sampler.top() > drawn.text(tl!("Color Picker")).bottom() && sampler.right() <= field.right()); - click_at(&ctx, &mut app, sampler.center()); - assert!(app.screen_sampler.is_some(), "the button above the field starts the host sampler"); - tx.send(Ok(Some([0.0, 1.0, 0.0]))).unwrap(); - crate::screen_color::poll(&mut app, &ctx); - let drawn = settled(&ctx, &mut app, screen()); - assert_eq!(fill_hex(&app), "#00ff00"); - click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center()); - assert!(app.ui.dialog.is_none()); - assert_eq!(fill_hex(&app), original); - } - - /// Checks both correction warnings fit below the preview without widening the dialog, - /// and that clicking the web warning still corrects the color. - #[test] - fn warnings_fit_below_the_preview_without_widening_the_dialog() { - let (ctx, mut app) = (context(), app()); - app.run("ui.colorPicker", json!({"color":"#ffffff"})).unwrap(); - settled(&ctx, &mut app, screen()); - let id = egui::Id::unique(("dialog", KIND)); - let before = ctx.memory(|m| m.area_rect(id)).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000FE")); - let drawn = settled(&ctx, &mut app, screen()); - let ok = drawn.control(tl!("OK")); - let toggle = drawn.control(tl!("Color Swatches")); - let h = drawn.text("H"); - let mut warnings: Vec<_> = drawn - .boxes - .iter() - .filter(|r| (r.width() - 16.0).abs() < 0.5 && (r.height() - 16.0).abs() < 0.5 && r.bottom() < h.top()) - .copied() - .collect(); - warnings.dedup(); // The tile's fill and border share one rectangle. - assert_eq!(warnings.len(), 2, "gamut and web correction chips are both present"); - for r in &warnings { - assert!(r.right() < ok.left() && r.bottom() <= toggle.bottom()); - assert!(r.top() > ok.top() + CHIP_H, "warnings below the preview"); - } - let after = ctx.memory(|m| m.area_rect(id)).unwrap(); - assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}"); - click_at(&ctx, &mut app, warnings[1].center()); - assert!(is_web_safe(&picked(app.ui.dialog.as_ref().unwrap()).color), "web correction remains clickable"); - } - - /// Drags the custom title band and checks the dialog moves by the pointer's displacement. - #[test] - fn colour_picker_moves_by_its_title_band() { - let (ctx, mut app) = (context(), app()); - app.run("ui.colorPicker", json!({})).unwrap(); - let drawn = settled(&ctx, &mut app, screen()); - let title = drawn.text(tl!("Color Picker")).center(); - let id = egui::Id::unique(("dialog", KIND)); - let before = ctx.memory(|m| m.area_rect(id)).unwrap(); - let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title), pointer(title, true)]); - for i in 1..=4 { - draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title + vec2(6.0, 4.0) * i as f32)]); - } - draw(&ctx, &mut app, screen(), vec![pointer(title + vec2(24.0, 16.0), false)]); - settled(&ctx, &mut app, screen()); - let after = ctx.memory(|m| m.area_rect(id)).unwrap(); - assert!((after.center() - before.center() - vec2(24.0, 16.0)).length() < 1.5); - assert!(app.ui.dialog.is_some()); - } - - /// Checks OK and Enter commit typed values as one undo step, while Cancel and Escape - /// restore the original paint after a live preview. - #[test] - fn inline_ok_commits_typed_input_and_cancel_restores_the_preview() { - for enter in [false, true] { - let (ctx, mut app) = (context(), app()); - app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap(); - let drawn = settled(&ctx, &mut app, screen()); - let undo = app.session.doc().unwrap().history.undo.len(); - // The first numeric field has selected text, just as in the other form dialogs. - draw(&ctx, &mut app, screen(), vec![egui::Event::Text("120".into())]); - if enter { - draw(&ctx, &mut app, screen(), vec![key(egui::Key::Enter)]); - } else { - click_at(&ctx, &mut app, drawn.text(tl!("OK")).center()); - } - assert!(app.ui.dialog.is_none()); - assert_eq!(fill_hex(&app), "#00ff00"); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); - app.run("ui.colorPicker", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); - let drawn = settled(&ctx, &mut app, screen()); - assert_eq!(fill_hex(&app), "#0000ff"); - if enter { - draw(&ctx, &mut app, screen(), vec![key(egui::Key::Escape)]); - } else { - click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center()); - } - assert!(app.ui.dialog.is_none()); - assert_eq!(fill_hex(&app), "#00ff00"); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); - } - } - - /// Checks shrinking and stacking keep the dialog inside small windows, vertical scrolling - /// reaches the actions and last input, and Enter can still confirm. - #[test] - fn compact_picker_shrinks_then_scrolls_without_enlarging_the_window() { - for size in [vec2(740.0, 600.0), vec2(540.0, 420.0), vec2(400.0, 280.0)] { - let (ctx, mut app) = (context(), app()); - app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap(); - let drawn = settled(&ctx, &mut app, size); - let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap(); - assert!(outer.width() <= size.x && outer.height() <= size.y, "{outer:?} in {size:?}"); - let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap(); - assert!((MIN_FIELD..=FIELD).contains(&field.width())); - assert!((field.width() - field.height()).abs() < 1.0); - assert!(drawn.boxes.iter().all(|r| r.left() >= outer.left() - 1.0 && r.right() <= outer.right() + 1.0), "controls fit horizontally"); - if size.x < 500.0 { - assert!(drawn.control(tl!("OK")).top() > field.bottom(), "narrow windows stack the controls below the field"); - let mut actions_visible = false; - let mut last_input_visible = false; - for _ in 0..30 { - draw( - &ctx, - &mut app, - size, - vec![ - egui::Event::PointerMoved(outer.min + vec2(30.0, 80.0)), - egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, -20.0), - phase: egui::TouchPhase::Move, - modifiers: Default::default(), - source: egui::MouseWheelSource::Unknown, - }, - ], - ); - let scrolled = settled(&ctx, &mut app, size); - actions_visible |= scrolled.visible_texts.iter().any(|s| s == tl!("OK")); - last_input_visible |= scrolled.visible_texts.iter().any(|s| s == "K"); - } - assert!(actions_visible && last_input_visible, "vertical scrolling reveals actions and the last input"); - } - // Keyboard confirmation remains available even when the actions require scrolling. - draw(&ctx, &mut app, size, vec![key(egui::Key::Enter)]); - assert!(app.ui.dialog.is_none()); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/dialogs/color_picker/tests.rs b/crates/ui-egui/src/dialogs/color_picker/tests.rs new file mode 100644 index 000000000..62187a89a --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_picker/tests.rs @@ -0,0 +1,672 @@ +use vectorcraft_engine::Session; + +use super::*; +use crate::panels::color::in_gamut; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app +} + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn fill_hex(app: &VectorcraftApp) -> String { + crate::panels::current_paints(app).0.color().unwrap().to_hex() +} + +/// Runs one frame of the dialog with `events` in `ctx`, which keeps the keyboard focus from +/// frame to frame, and returns the texts drawn with their screen rects. +fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, Rect)> { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut texts = vec![]; + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts +} + +/// Returns the label of the value field that has the keyboard, which is the letter (or `#`) +/// nearest to the field's left on its row, and the field's rect. Returns `None` when no text +/// field has the keyboard. +fn focused_field(ctx: &egui::Context, texts: &[(String, Rect)]) -> Option<(String, Rect)> { + let id = ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some())?; + let field = ctx.read_response(id)?.rect; + let labels = ["H", "S", "B", "R", "G", "#", "L", "a", "b", "C", "M", "Y", "K"]; + let (label, _) = texts + .iter() + .filter(|(t, r)| labels.contains(&t.as_str()) && r.max.x <= field.min.x && field.y_range().contains(r.center().y)) + .max_by(|a, b| a.1.max.x.total_cmp(&b.1.max.x))?; + Some((label.clone(), field)) +} + +#[test] +fn field_and_slider_map_back_per_channel() { + let p = Picked::of(Color::rgb8(51, 153, 204)); + for ch in Channel::ALL { + let (z, x, y) = field_pos(ch, &p); + assert_eq!(field_pick(ch, z, x, y).color.to_hex(), "#3399cc", "{ch:?}"); + assert_eq!(Channel::parse(ch.id()), Some(ch)); + } + // Hue: x is saturation, y brightness (white at the top left, black along the bottom). + assert_eq!(field_pick(Channel::Hue, 0.0, 0.0, 0.0).color.to_hex(), "#ffffff"); + assert_eq!(field_pick(Channel::Hue, 0.0, 1.0, 0.0).color.to_hex(), "#ff0000"); + assert_eq!(field_pick(Channel::Hue, 0.5, 1.0, 1.0).color.to_hex(), "#000000"); + // Red: the slider is red, x blue and y green (green grows upwards). + assert_eq!(field_pick(Channel::Red, 1.0, 1.0, 0.0).color.to_hex(), "#ffffff"); + assert_eq!(field_pick(Channel::Red, 0.0, 1.0, 1.0).color.to_hex(), "#0000ff"); + assert_eq!(field_pick(Channel::Green, 1.0, 0.0, 1.0).color.to_hex(), "#00ff00"); + assert_eq!(field_pick(Channel::Blue, 0.0, 1.0, 1.0).color.to_hex(), "#ff0000"); + // The hue slider shows the spectrum whatever the colour; the others vary one component. + assert_eq!(slider_color(Channel::Hue, 1.0 / 3.0, 0.0, 1.0).to_hex(), "#00ff00"); + assert_eq!(slider_color(Channel::Saturation, 0.0, 0.0, 0.0).to_hex(), "#ffffff"); + // A grey keeps the hue it was picked with. + let grey = field_pick(Channel::Hue, 0.5, 0.0, 0.5); + assert_eq!(grey.hsb[0], 180.0); + assert_eq!(field_pos(Channel::Hue, &grey).0, 0.5); +} + +#[test] +fn only_web_colors_snaps() { + let p = Picked::of(Color::rgb8(230, 120, 40)); + assert_eq!(p.snapped(false), p); + assert_eq!(p.snapped(true).color.to_hex(), "#ff6633"); + assert!(is_web_safe(&p.snapped(true).color)); + let mut d = Dialog::new(KIND, json!({"color": "#e67828", "webOnly": true})); + assert_eq!(picked(&d).color.to_hex(), "#ff6633"); + d.fields.insert("webOnly".into(), json!(false)); + assert_eq!(picked(&d).color.to_hex(), "#e67828"); +} + +#[test] +fn gamut_correction_gives_a_printable_colour() { + let mut app = app(); + let blue = Color::rgb(0.0, 0.0, 1.0); + assert!(blue.out_of_gamut()); + let fix = in_gamut(&mut app, &blue).expect("pure blue can't be printed"); + assert!(!fix.out_of_gamut(), "{fix:?}"); + assert!(matches!(fix, Color::Rgb { .. })); + assert_eq!(in_gamut(&mut app, &Color::rgb8(128, 128, 128)), None); + // The dialog and the Color panel ask through a cache. + let ctx = egui::Context::default(); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix)); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix)); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None); +} + +/// The selected art takes each colour as it is picked; OK keeps the last as one undo step and +/// Cancel puts the art back. +#[test] +fn the_selection_shows_the_colour_live() { + let mut app = app(); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + app.run("ui.colorPicker", json!({})).unwrap(); + frame(&mut app); + assert!(!app.session.in_interaction(), "nothing previews before the colour changes"); + for hex in ["ff0000", "00ff00"] { + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex)); + frame(&mut app); + assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK"); + } + super::super::confirm(&mut app).unwrap(); + assert_eq!(fill_hex(&app), "#00ff00"); + assert_eq!(undo(&app), before + 1, "one step"); + // Cancel: back to the colour it had. + app.run("ui.colorPicker", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); + frame(&mut app); + assert_eq!(fill_hex(&app), "#0000ff"); + super::super::cancel(&mut app); + assert_eq!(fill_hex(&app), "#00ff00"); + assert_eq!(undo(&app), before + 1); +} + +/// Opened for an artboard (Properties › Artboards › Fill), the picker starts from the artboard's +/// background color (white without one) and shows each color on its paper live, with nothing +/// selected. OK keeps the last as one undo step, the art's fill unchanged, and Cancel puts the +/// artboard back. +#[test] +fn an_artboard_shows_its_background_color_live() { + let mut app = app(); + app.run("select.set", json!({"ids": []})).unwrap(); + app.run("artboard.new", json!({})).unwrap(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let art = app.session.doc().unwrap().doc.layers.clone(); + let before = undo(&app); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "FFFFFF", "starts from white paper"); + frame(&mut app); + assert!(!app.session.in_interaction(), "nothing previews before the color changes"); + for hex in ["ff0000", "00ff00"] { + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex)); + frame(&mut app); + assert_eq!(color(&app), Some(format!("#{hex}")), "live, before OK"); + } + super::super::confirm(&mut app).unwrap(); + assert_eq!(color(&app).as_deref(), Some("#00ff00")); + assert_eq!(undo(&app), before + 1, "one step"); + assert_eq!(app.session.doc().unwrap().doc.layers, art, "the art keeps its fill"); + // Cancel: back to the color it had, which the picker starts from. + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00"); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); + frame(&mut app); + assert_eq!(color(&app).as_deref(), Some("#0000ff")); + super::super::cancel(&mut app); + assert_eq!(color(&app).as_deref(), Some("#00ff00")); + assert_eq!(undo(&app), before + 1); + assert!(app.run("ui.colorPicker", json!({"artboard": 9})).is_err()); +} + +/// OK on a picker opened for an artboard without a background color, with the color left at the +/// white it starts from, leaves the artboard without one and adds no undo step. A white the +/// opener passes as `color` is set. +#[test] +fn an_untouched_picker_leaves_an_artboard_without_a_color() { + let mut app = app(); + app.run("artboard.new", json!({})).unwrap(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes the picker"); + assert_eq!(color(&app), None, "no color"); + assert_eq!(undo(&app), before, "no undo step"); + app.run("ui.colorPicker", json!({"artboard": 1, "color": "#ffffff"})).unwrap(); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(color(&app).as_deref(), Some("#ffffff")); + assert_eq!(undo(&app), before + 1); +} + +/// OK on an untouched picker opened for an artboard with an RGB background color in a CMYK +/// document keeps the RGB color and adds no undo step. A color picked in it is set as CMYK. +#[test] +fn an_untouched_picker_keeps_an_artboards_rgb_color_in_a_cmyk_document() { + let mut app = app(); + app.run("artboard.new", json!({})).unwrap(); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color; + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let start = color(&app); + assert!(matches!(start, Some(Color::Rgb { .. })), "set as RGB"); + let before = undo(&app); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes the picker"); + assert_eq!(color(&app), start, "the RGB color is kept"); + assert_eq!(undo(&app), before, "no undo step"); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(color(&app), Some(Color::Cmyk { .. })), "a picked color is set as CMYK"); + assert_eq!(undo(&app), before + 1); +} + +/// OK on an untouched picker opened for an artboard with a background color in an RGB +/// document keeps the color and adds no undo step. +#[test] +fn an_untouched_picker_adds_no_undo_step_for_an_artboards_color() { + let mut app = app(); + app.run("artboard.new", json!({})).unwrap(); + app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes the picker"); + assert_eq!(color(&app).as_deref(), Some("#ff0000")); + assert_eq!(undo(&app), before, "no undo step"); +} + +#[test] +fn open_set_hex_and_confirm_fills() { + let mut app = app(); + app.run("ui.colorPicker", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(super::super::DialogKind::of(&d.kind), Some(super::super::DialogKind::ColorPicker)); + assert_eq!(d.str("hex"), "FFFFFF", "starts from the fill"); + assert!(!d.bool("stroke")); + frame(&mut app); + // `ui.dialog.set {field: "hex"}` then `ui.dialog.confirm`. + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(fill_hex(&app), "#00ff00"); + // Without a frame in between, a set hex still wins; `color` works as well. + app.run("ui.colorPicker", json!({"stroke": true})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#3366CC")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(crate::panels::current_paints(&app).1.color().unwrap().to_hex(), "#3366cc"); + // The stroke proxy is in front now: the opener follows it unless told otherwise. + app.run("ui.colorPicker", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("stroke")); + app.run("ui.colorPicker", json!({"stroke": false})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!({"c": 0, "m": 1, "y": 1, "k": 0})); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK is kept"); + assert!(app.run("ui.colorPicker", json!({"color": "nope"})).is_err()); + // Colours take every form the paint commands take (CMYK in percent too). + app.run("ui.colorPicker", json!({"stroke": false, "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))); +} + +/// #1692: Tab goes through one color model's fields before the next: H, S, B, then R, G, B, the +/// hex field, then L, a, b, then C, M, Y, K. Each Lab or CMYK field is level with the field on +/// its left. +#[test] +fn tab_goes_through_one_color_models_fields_before_the_next() { + let mut app = app(); + app.run("ui.colorPicker", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + // The picker opens with the keyboard in H. The loop presses Tab until K has the keyboard + // and records each stop. A stop is `None` when no text field has the keyboard, as at a + // channel's radio button. + let mut texts = vec![]; + for _ in 0..3 { + texts = frame_in(&ctx, &mut app, vec![]); + } + let mut stops = vec![focused_field(&ctx, &texts)]; + while stops.len() < 40 && stops.last().and_then(|s| s.as_ref()).is_none_or(|(label, _)| label != "K") { + texts = frame_in(&ctx, &mut app, vec![tab.clone()]); + stops.push(focused_field(&ctx, &texts)); + } + let names: Vec> = stops.iter().map(|s| s.as_ref().map(|(label, _)| label.as_str())).collect(); + let fields: Vec<&str> = names.iter().flatten().copied().collect(); + assert_eq!(fields, ["H", "S", "B", "R", "G", "B", "#", "L", "a", "b", "C", "M", "Y", "K"], "{names:?}"); + assert_eq!(names[names.len() - 2..], [Some("Y"), Some("K")], "one Tab takes Y to K"); + let found: Vec<&(String, Rect)> = stops.iter().flatten().collect(); + for (left, right) in found[..7].iter().zip(&found[7..]) { + // Two grids: their rows land on the same subpixel-rounded line, not necessarily bit-equal. + assert!((left.1.center().y - right.1.center().y).abs() <= 0.5, "{} and {} are level", left.0, right.0); + } +} + +#[test] +fn every_mode_draws_and_cmyk_documents_get_cmyk() { + let mut app = app(); + for fields in + [json!({"channel": "red"}), json!({"channel": "brightness", "webOnly": true}), json!({"swatches": true}), json!({"color": "#0000ff"})] + { + app.run("ui.colorPicker", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in fields.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + frame(&mut app); + assert!(app.ui.dialog.is_some()); + } + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. }))); +} +/// Inspect actual painted controls, rather than a second implementation of the layout. +#[derive(Default)] +struct Drawn { + texts: Vec<(String, Rect)>, + visible_texts: Vec, + boxes: Vec, + fields: Vec, +} + +impl Drawn { + /// Collects painted text, control bounds and color meshes, recording text visibility + /// against the clip rectangle so tests can detect clipped labels. + fn collect(&mut self, shape: &egui::Shape, clip: Rect) { + match shape { + egui::Shape::Text(t) => { + self.texts.push((t.galley.text().into(), t.visual_bounding_rect())); + if clip.expand(1.0).contains_rect(t.visual_bounding_rect()) { + self.visible_texts.push(t.galley.text().into()); + } + } + egui::Shape::Rect(r) => self.boxes.push(r.rect), + egui::Shape::Mesh(m) if m.vertices.len() > 100 => self.fields.push(m.calc_bounds()), + egui::Shape::Vec(v) => v.iter().for_each(|s| self.collect(s, clip)), + _ => {} + } + } + + /// Returns the bounds of exactly one painted label, failing on missing or duplicate text. + fn text(&self, label: &str) -> Rect { + let found: Vec<_> = self.texts.iter().filter(|(s, _)| s == label).collect(); + assert_eq!(found.len(), 1, "one {label}, including no duplicate footer: {:?}", self.texts); + found[0].1 + } + + /// Finds the smallest painted control containing a unique label's center. + fn control(&self, label: &str) -> Rect { + let center = self.text(label).center(); + self.boxes.iter().filter(|r| r.contains(center)).min_by(|a, b| a.area().total_cmp(&b.area())).copied().unwrap() + } +} + +/// Runs a dialog frame with real egui input at a physical window size, accounting for UI zoom, +/// and returns the painted controls for layout and interaction assertions. +fn draw(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2, events: Vec) -> Drawn { + let input = + egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size / ctx.zoom_factor())), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + super::super::show(app, ui.ctx()); + }); + let mut drawn = Drawn::default(); + for shape in &out.shapes { + drawn.collect(&shape.shape, shape.clip_rect); + } + out.textures_delta.clear(); + drawn +} + +/// The physical window size used for pointer and keyboard interaction tests. +fn screen() -> egui::Vec2 { + vec2(1280.0, 900.0) +} + +/// Lets the auto-sized dialog settle over six frames and returns the next frame's controls. +fn settled(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2) -> Drawn { + for _ in 0..6 { + draw(ctx, app, size, vec![]); + } + draw(ctx, app, size, vec![]) +} + +/// Creates a persistent test context with the application's fonts and default theme. +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + ctx +} + +/// Sends a primary pointer press and release in separate frames at the given position. +fn click_at(ctx: &egui::Context, app: &mut VectorcraftApp, at: egui::Pos2) { + for pressed in [true, false] { + draw( + ctx, + app, + screen(), + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ); + } +} + +/// Creates an unmodified, non-repeating key press for dialog shortcut tests. +fn key(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() } +} + +/// Checks accessible action names and each channel slider's displayed value, range and units. +#[test] +fn compact_picker_preserves_screen_reader_controls_and_channel_values() { + use egui::accesskit::Role; + + for (channel, value, maximum, text) in [ + (Channel::Hue, 210.0, 360.0, "210°"), + (Channel::Saturation, 67.0, 100.0, "67%"), + (Channel::Brightness, 60.0, 100.0, "60%"), + (Channel::Red, 51.0, 255.0, "51"), + (Channel::Green, 102.0, 255.0, "102"), + (Channel::Blue, 153.0, 255.0, "153"), + ] { + let mut app = app(); + app.services.screen_color = Some(Box::new(|| Ok(std::sync::mpsc::channel().1))); + open(&mut app, &json!({"color": "#336699"})).unwrap(); + let mut d = app.ui.dialog.take().unwrap(); + d.fields.insert("channel".into(), json!(channel.id())); + let tree = widgets::accessibility_tests::tree(|ui| { + body(&mut app, ui, &mut d); + }); + let slider = tree.iter().find(|n| n.role() == Role::Slider && n.label() == Some(channel.label())).unwrap(); + assert_eq!(slider.numeric_value(), Some(value)); + assert_eq!(slider.min_numeric_value(), Some(0.0)); + assert_eq!(slider.max_numeric_value(), Some(maximum)); + assert_eq!(slider.value(), Some(text)); + for label in ["OK", "Cancel", "Color Swatches", "Sample Color from Screen"] { + assert!(tree.iter().any(|n| n.role() == Role::Button && n.label() == Some(label)), "missing button: {label}"); + } + } +} + +/// Checks aligned actions and input rows at two UI scales, and stable dimensions when +/// switching between color models and swatches. +#[test] +fn inline_actions_are_aligned_and_modes_keep_the_window_size() { + // Language selection is process-global; German is checked in the native app instead + // of racing the other parallel UI tests by changing the locale here. + for zoom in [1.0, 1.5] { + let (ctx, mut app) = (context(), app()); + ctx.set_zoom_factor(zoom); + app.run("ui.colorPicker", json!({})).unwrap(); + let drawn = settled(&ctx, &mut app, screen()); + let ok = drawn.control(tl!("OK")); + let cancel = drawn.control(tl!("Cancel")); + let toggle = drawn.control(tl!("Color Swatches")); + assert!(ok.bottom() < cancel.top() && cancel.bottom() < toggle.top()); + for r in [cancel, toggle] { + assert!((r.left() - ok.left()).abs() < 1.0 && (r.width() - ok.width()).abs() < 1.0); + } + let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap(); + assert!((field.width() - field.height()).abs() < 1.0); + assert!((field.top() - ok.top()).abs() < 1.0); + let k = drawn.text("K"); + let last_input = drawn + .boxes + .iter() + .find(|r| (r.width() - VALUE_W).abs() < 0.5 && r.left() > k.right() && r.contains(egui::pos2(r.center().x, k.center().y))) + .unwrap(); + assert!((last_input.bottom() - field.bottom()).abs() < 2.0, "{last_input:?} vs {field:?}"); + // Fonts and translated actions may leave one normal gutter, but never an unused column. + assert!((last_input.right() - ok.right()).abs() < COLUMN_GAP, "no unused column beside the inputs: {last_input:?} vs {ok:?}"); + let id = egui::Id::unique(("dialog", KIND)); + let before = ctx.memory(|m| m.area_rect(id)).unwrap(); + assert!(drawn.boxes.iter().all(|r| r.left() >= before.left() - 1.0 && r.right() <= before.right() + 1.0), "controls fit horizontally"); + let title = drawn.text(tl!("Color Picker")); + assert!((title.center().x - before.center().x).abs() < 1.0, "centred heading"); + assert!(title.bottom() < field.top(), "a separate title band above the content"); + assert!(drawn.visible_texts.iter().any(|s| s == tl!("Only Web Colors")), "the checkbox label is fully visible"); + assert!(drawn.text("L").top() - toggle.bottom() >= PREVIEW_GAP, "space below the actions"); + click_at(&ctx, &mut app, toggle.center()); + assert!(app.ui.dialog.as_ref().unwrap().bool("swatches")); + let drawn = settled(&ctx, &mut app, screen()); + assert!((drawn.control(tl!("Color Models")).width() - ok.width()).abs() < 1.0); + let after = ctx.memory(|m| m.area_rect(id)).unwrap(); + assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}"); + assert!( + drawn.boxes.iter().all(|r| r.left() >= after.left() - 1.0 && r.right() <= after.right() + 1.0), + "swatch controls fit horizontally" + ); + click_at(&ctx, &mut app, drawn.text(tl!("Color Models")).center()); + assert!(!app.ui.dialog.as_ref().unwrap().bool("swatches")); + } +} + +/// Clicks the sampler above the field, receives a color and checks Cancel restores the paint. +#[test] +fn sampler_above_the_field_previews_and_cancel_restores_the_original() { + let (ctx, mut app) = (context(), app()); + let (tx, rx) = std::sync::mpsc::channel(); + let mut receiver = Some(rx); + app.services.screen_color = Some(Box::new(move || receiver.take().ok_or("sampler already started".into()))); + app.run("ui.colorPicker", json!({})).unwrap(); + let original = fill_hex(&app); + let drawn = settled(&ctx, &mut app, screen()); + let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap(); + let hover = egui::pos2(field.right() - 12.0, field.top() - 20.0); + draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(hover)]); + let drawn = settled(&ctx, &mut app, screen()); + let sampler = drawn.boxes.iter().find(|r| r.size() == vec2(24.0, 24.0) && r.bottom() < field.top()).unwrap(); + assert!(sampler.top() > drawn.text(tl!("Color Picker")).bottom() && sampler.right() <= field.right()); + click_at(&ctx, &mut app, sampler.center()); + assert!(app.screen_sampler.is_some(), "the button above the field starts the host sampler"); + tx.send(Ok(Some([0.0, 1.0, 0.0]))).unwrap(); + crate::screen_color::poll(&mut app, &ctx); + let drawn = settled(&ctx, &mut app, screen()); + assert_eq!(fill_hex(&app), "#00ff00"); + click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center()); + assert!(app.ui.dialog.is_none()); + assert_eq!(fill_hex(&app), original); +} + +/// Checks both correction warnings fit below the preview without widening the dialog, +/// and that clicking the web warning still corrects the color. +#[test] +fn warnings_fit_below_the_preview_without_widening_the_dialog() { + let (ctx, mut app) = (context(), app()); + app.run("ui.colorPicker", json!({"color":"#ffffff"})).unwrap(); + settled(&ctx, &mut app, screen()); + let id = egui::Id::unique(("dialog", KIND)); + let before = ctx.memory(|m| m.area_rect(id)).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000FE")); + let drawn = settled(&ctx, &mut app, screen()); + let ok = drawn.control(tl!("OK")); + let toggle = drawn.control(tl!("Color Swatches")); + let h = drawn.text("H"); + let mut warnings: Vec<_> = drawn + .boxes + .iter() + .filter(|r| (r.width() - 16.0).abs() < 0.5 && (r.height() - 16.0).abs() < 0.5 && r.bottom() < h.top()) + .copied() + .collect(); + warnings.dedup(); // The tile's fill and border share one rectangle. + assert_eq!(warnings.len(), 2, "gamut and web correction chips are both present"); + for r in &warnings { + assert!(r.right() < ok.left() && r.bottom() <= toggle.bottom()); + assert!(r.top() > ok.top() + CHIP_H, "warnings below the preview"); + } + let after = ctx.memory(|m| m.area_rect(id)).unwrap(); + assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}"); + click_at(&ctx, &mut app, warnings[1].center()); + assert!(is_web_safe(&picked(app.ui.dialog.as_ref().unwrap()).color), "web correction remains clickable"); +} + +/// Drags the custom title band and checks the dialog moves by the pointer's displacement. +#[test] +fn colour_picker_moves_by_its_title_band() { + let (ctx, mut app) = (context(), app()); + app.run("ui.colorPicker", json!({})).unwrap(); + let drawn = settled(&ctx, &mut app, screen()); + let title = drawn.text(tl!("Color Picker")).center(); + let id = egui::Id::unique(("dialog", KIND)); + let before = ctx.memory(|m| m.area_rect(id)).unwrap(); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title), pointer(title, true)]); + for i in 1..=4 { + draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title + vec2(6.0, 4.0) * i as f32)]); + } + draw(&ctx, &mut app, screen(), vec![pointer(title + vec2(24.0, 16.0), false)]); + settled(&ctx, &mut app, screen()); + let after = ctx.memory(|m| m.area_rect(id)).unwrap(); + assert!((after.center() - before.center() - vec2(24.0, 16.0)).length() < 1.5); + assert!(app.ui.dialog.is_some()); +} + +/// Checks OK and Enter commit typed values as one undo step, while Cancel and Escape +/// restore the original paint after a live preview. +#[test] +fn inline_ok_commits_typed_input_and_cancel_restores_the_preview() { + for enter in [false, true] { + let (ctx, mut app) = (context(), app()); + app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap(); + let drawn = settled(&ctx, &mut app, screen()); + let undo = app.session.doc().unwrap().history.undo.len(); + // The first numeric field has selected text, just as in the other form dialogs. + draw(&ctx, &mut app, screen(), vec![egui::Event::Text("120".into())]); + if enter { + draw(&ctx, &mut app, screen(), vec![key(egui::Key::Enter)]); + } else { + click_at(&ctx, &mut app, drawn.text(tl!("OK")).center()); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(fill_hex(&app), "#00ff00"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + app.run("ui.colorPicker", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); + let drawn = settled(&ctx, &mut app, screen()); + assert_eq!(fill_hex(&app), "#0000ff"); + if enter { + draw(&ctx, &mut app, screen(), vec![key(egui::Key::Escape)]); + } else { + click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center()); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(fill_hex(&app), "#00ff00"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + } +} + +/// Checks shrinking and stacking keep the dialog inside small windows, vertical scrolling +/// reaches the actions and last input, and Enter can still confirm. +#[test] +fn compact_picker_shrinks_then_scrolls_without_enlarging_the_window() { + for size in [vec2(740.0, 600.0), vec2(540.0, 420.0), vec2(400.0, 280.0)] { + let (ctx, mut app) = (context(), app()); + app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap(); + let drawn = settled(&ctx, &mut app, size); + let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap(); + assert!(outer.width() <= size.x && outer.height() <= size.y, "{outer:?} in {size:?}"); + let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap(); + assert!((MIN_FIELD..=FIELD).contains(&field.width())); + assert!((field.width() - field.height()).abs() < 1.0); + assert!(drawn.boxes.iter().all(|r| r.left() >= outer.left() - 1.0 && r.right() <= outer.right() + 1.0), "controls fit horizontally"); + if size.x < 500.0 { + assert!(drawn.control(tl!("OK")).top() > field.bottom(), "narrow windows stack the controls below the field"); + let mut actions_visible = false; + let mut last_input_visible = false; + for _ in 0..30 { + draw( + &ctx, + &mut app, + size, + vec![ + egui::Event::PointerMoved(outer.min + vec2(30.0, 80.0)), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -20.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ], + ); + let scrolled = settled(&ctx, &mut app, size); + actions_visible |= scrolled.visible_texts.iter().any(|s| s == tl!("OK")); + last_input_visible |= scrolled.visible_texts.iter().any(|s| s == "K"); + } + assert!(actions_visible && last_input_visible, "vertical scrolling reveals actions and the last input"); + } + // Keyboard confirmation remains available even when the actions require scrolling. + draw(&ctx, &mut app, size, vec![key(egui::Key::Enter)]); + assert!(app.ui.dialog.is_none()); + } +} From 66bddeaac71130234259c8cc6e4a193aba1118c2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:50 +0000 Subject: [PATCH 037/199] refactor(ui-egui): move panels/library_panel.rs tests to panels/library_panel/tests.rs --- crates/ui-egui/src/panels/library_panel.rs | 659 +----------------- .../ui-egui/src/panels/library_panel/tests.rs | 656 +++++++++++++++++ 2 files changed, 657 insertions(+), 658 deletions(-) create mode 100644 crates/ui-egui/src/panels/library_panel/tests.rs diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 18a4a93eb..409020e3b 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -826,661 +826,4 @@ pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], checked: &dyn Fn(& } #[cfg(test)] -mod tests { - use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; - use serde_json::Value; - use vectorcraft_color::Paint; - use vectorcraft_engine::Session; - - use super::*; - use crate::panels::swatches::SwatchLibraries; - - fn app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app - } - - fn context() -> egui::Context { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - ctx - } - - /// One frame of the library panel with `events` and `modifiers` held. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: Modifiers, time: f64) { - let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); - let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); - let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| show_window(app, ui.ctx())); - out.textures_delta.clear(); - } - - /// The panel opened last. - fn shown(app: &VectorcraftApp) -> String { - last_open(&app.ui).expect("a library panel is open").panel() - } - - /// Click the tile or row of `name` in the panel opened last with `modifiers` (frames from - /// `time` to `time + 0.5`; a new window takes a sizing frame first). - fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { - frame(app, ctx, vec![], Modifiers::NONE, time); - frame(app, ctx, vec![], Modifiers::NONE, time + 0.25); - let at = ctx.read_response(tile_id(&shown(app), name)).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; - frame(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5); - } - - fn undo_len(app: &VectorcraftApp) -> usize { - app.session.doc().unwrap().history.undo.len() - } - - #[test] - fn clicking_a_library_swatch_adds_and_applies_it_in_one_step() { - let mut app = app(); - let ctx = context(); - let r = app.run("window.swatchLibrary", json!({"library": "Pastels"})).unwrap(); - assert_eq!((r["open"].as_str(), r["count"].as_u64(), r["panel"].as_str()), (Some("pastels"), Some(24), Some("library:swatches:pastels"))); - assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("swatches", "pastels"))); - let undo = undo_len(&app); - click(&mut app, &ctx, "Pastel Teal", Modifiers::NONE, 0.0); - let d = &app.session.doc().unwrap().doc; - let added = d.swatch("Pastel Teal").expect("added to the document"); - assert_eq!(app.session.paint.fill, Paint::solid(added.paint.color().unwrap()), "applied to the fill"); - assert_eq!(undo_len(&app), undo + 1); - // Alt-click paints the stroke (the inactive proxy). - click(&mut app, &ctx, "Soft Blue", Modifiers::ALT, 2.0); - let blue = app.session.doc().unwrap().doc.swatch("Soft Blue").and_then(|w| w.paint.color()).unwrap(); - assert_eq!(app.session.paint.stroke.color(), Some(blue)); - assert!(app.session.fill_active, "Alt keeps the active proxy"); - // A library that doesn't exist is an error; null closes the panel. - assert!(app.run("window.swatchLibrary", json!({"library": "nope"})).is_err()); - app.run("window.swatchLibrary", json!({"library": null})).unwrap(); - assert!(last_open(&app.ui).is_none()); - } - - #[test] - fn modifier_clicks_select_and_add_to_swatches_adds_the_selection() { - let mut app = app(); - let ctx = context(); - app.run("window.swatchLibrary", json!({"library": "earth-tones"})).unwrap(); - click(&mut app, &ctx, "Clay 1", Modifiers::COMMAND, 0.0); - click(&mut app, &ctx, "Ochre", Modifiers::NONE, 2.0); - assert_eq!(selection(&ctx, "library:swatches:earth-tones"), ["Ochre"], "a folder click selects its group"); - click(&mut app, &ctx, "Clay 3", Modifiers::COMMAND, 4.0); - assert!(app.session.doc().unwrap().doc.swatch("Clay 3").is_none(), "modifier clicks only select"); - let sel = selection(&ctx, "library:swatches:earth-tones"); - SwatchLibraries::add(&mut app, "earth-tones", sel); - let d = &app.session.doc().unwrap().doc; - assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Ochre").map(|g| g.swatches.len()), Some(6)); - assert!(d.swatches.iter().any(|w| w.name == "Clay 3")); - // The selection belongs to its library's panel. - app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap(); - assert!(selection(&ctx, "library:swatches:brights").is_empty()); - } - - #[test] - fn a_plain_click_sets_where_a_shift_click_range_starts() { - let mut app = app(); - let ctx = context(); - app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); - let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); - let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); - click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); - click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); - assert_eq!(selection(&ctx, "library:swatches:pastels"), order[1..=4], "the range starts at the plain click"); - // A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item. - click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0); - click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0); - click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0); - assert_eq!(selection(&ctx, "library:swatches:pastels"), order[2..=3]); - let d = &app.session.doc().unwrap().doc; - assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch"); - assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects"); - } - - #[test] - fn find_filters_list_view_and_arrows_step_through_the_libraries() { - let mut app = app(); - let ctx = context(); - app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); - let panel = "library:swatches:web-safe-216"; - app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); - ctx.data_mut(|d| d.insert_temp(egui::Id::unique(key(panel, "find")), "#FF00".to_string())); - frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0); - frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5); - assert!(ctx.read_response(tile_id(panel, "#FF0033")).is_some()); - assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); - let first = SWATCH_IDS[0]; - let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "radial-glows", "gradient libraries come after the swatch libraries"); - assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); - assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); - } - - const SWATCH_IDS: [&str; 9] = [ - "web-safe-216", - "grays-neutrals", - "earth-tones", - "skin-tone-ramps", - "pastels", - "brights", - "metallic-gradients", - "perceptual-scales", - "harmony-sets", - ]; - - #[test] - fn the_window_submenu_lists_every_library() { - let app = app(); - let ids: Vec = crate::menus::menu_entries(&app) - .into_iter() - .filter(|e| e.path == ["Window", "Swatch Libraries"] && e.command.as_deref() == Some("window.swatchLibrary")) - .map(|e| e.params["library"].clone()) - .collect(); - let all = vectorcraft_engine::cmd::swatchlib::libraries(&app.session); - let listed = |category: &str| all.iter().filter(|l| l.category == category).map(|l| json!(l.id)).collect::>(); - assert_eq!(ids, listed("builtIn")); - assert_eq!(ids, SWATCH_IDS.map(|i| json!(i))); - let gradients: Vec = crate::menus::menu_entries(&app) - .into_iter() - .filter(|e| e.path == ["Window", "Swatch Libraries", "Gradients"]) - .map(|e| e.params["library"].clone()) - .collect(); - assert!(gradients.len() >= 5); - assert_eq!(gradients, listed("gradients")); - let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults")); - assert!(defaults, "Default Swatches is listed"); - } - - #[test] - fn save_dialog_writes_user_libraries_that_the_window_menu_lists() { - let dir = std::env::temp_dir().join(format!("vc-library-panel-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut app = app(); - app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); - let user_items = |app: &VectorcraftApp| -> Vec<(String, String)> { - crate::menus::menu_entries(app) - .into_iter() - .filter(|e| e.path == ["Window", "Swatch Libraries", "User Defined"]) - .map(|e| (e.label, e.command.unwrap_or_default())) - .collect() - }; - assert!(user_items(&app).is_empty(), "empty slots are hidden"); - app.run("ui.saveSwatchLibrary", json!({"names": ["Brights"]})).unwrap(); - let d = app.ui.dialog.as_mut().expect("Save Swatch Library opens"); - assert_eq!((d.kind.as_str(), d.bool("user")), (crate::dialogs::save_swatch_library::KIND, true)); - d.fields.insert("name".into(), json!("Loud")); - d.fields.insert("selectedOnly".into(), json!(true)); - let ctx = context(); - let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() }; - ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear(); - assert!(app.ui.dialog.is_some(), "the dialog draws and stays open"); - crate::dialogs::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - assert_eq!(user_items(&app), [("Loud".to_string(), "window.userSwatchLibrary1".to_string())]); - app.run("window.userSwatchLibrary1", json!({})).unwrap(); - assert_eq!(last_open(&app.ui).map(|p| p.id.as_str()), Some("user/Loud.vcswatches")); - let (_, lib) = SwatchLibraries::get(&app, "user/Loud.vcswatches").unwrap(); - assert_eq!((lib.groups.len(), lib.len()), (1, 5), "only the selected group"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn saving_to_a_file_and_opening_a_palette_file() { - let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); - let w = written.clone(); - let services = crate::Services { - pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), - write: Some(Box::new(move |p: &str, b: &[u8]| { - w.borrow_mut().push((p.to_string(), b.to_vec())); - Ok(()) - })), - ..Default::default() - }; - let mut app = VectorcraftApp::new(Session::new(), services); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("ui.saveSwatchLibrary", json!({})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - assert!(!d.bool("user") && !d.bool("__user"), "no user folder: a file"); - d.fields.insert("format".into(), json!("gpl")); - crate::dialogs::confirm(&mut app).unwrap(); - let (path, bytes) = written.borrow()[0].clone(); - assert!(path.ends_with(".gpl"), "{path}"); - let text = String::from_utf8(bytes).unwrap(); - assert!( - text.starts_with( - "GIMP Palette -" - ) && text.contains( - " Red -" - ), - "{text}" - ); - // Opening a palette file opens it in the library panel. - crate::io::open_bytes(&mut app, "Saved.gpl", text.as_bytes(), None).unwrap(); - let open = last_open(&app.ui).cloned().unwrap(); - assert!(open.id.starts_with("loaded/"), "{open:?}"); - assert_eq!(app.session.documents().len(), 1, "not opened as a document"); - let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); - assert_eq!(other.submenu, Some("Other Libraries")); - // Built-in libraries' names are interface labels; a file's is shown as it is. - for l in SwatchLibraries::list(&app) { - assert_eq!(builtin_library(&l.id), !matches!(l.submenu, Some("User Defined" | "Other Libraries")), "{}", l.id); - } - assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES)); - } - - #[test] - fn the_save_dialog_writes_swatch_exchange_files() { - let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); - let w = written.clone(); - let services = crate::Services { - pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), - write: Some(Box::new(move |p: &str, b: &[u8]| { - w.borrow_mut().push((p.to_string(), b.to_vec())); - Ok(()) - })), - ..Default::default() - }; - let mut app = VectorcraftApp::new(Session::new(), services); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("ui.saveSwatchLibrary", json!({})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("format".into(), json!("ase")); - d.fields.insert("name".into(), json!("Brand Kit")); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); - assert!(text.contains("Swatch Exchange (.ase)") && text.contains("Swatch exchange files keep solid colors only."), "{text}"); - crate::dialogs::confirm(&mut app).unwrap(); - let (path, bytes) = written.borrow()[0].clone(); - assert_eq!(path, "/tmp/Brand Kit.ase", "the save panel suggests the name"); - let lib = vectorcraft_color::palette_io::read_bytes(&bytes, "x").unwrap(); - let solid = app.session.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count(); - assert_eq!(lib.len(), solid, "every solid color of the document"); - // The web downloads the file under that name. - let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![])); - let dl = downloads.clone(); - let services = crate::Services { - download: Some(Box::new(move |name: &str, _: &[u8]| { - dl.borrow_mut().push(name.to_string()); - Ok(()) - })), - ..Default::default() - }; - let mut web = VectorcraftApp::new(Session::new(), services); - web.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - web.run("ui.saveSwatchLibrary", json!({})).unwrap(); - let d = web.ui.dialog.as_mut().unwrap(); - d.fields.insert("format".into(), json!("ase")); - d.fields.insert("name".into(), json!(" Brand: Kit. ")); - crate::dialogs::confirm(&mut web).unwrap(); - assert_eq!(*downloads.borrow(), ["Brand- Kit.ase"], "the name made safe as a file name, as for User Defined"); - } - - #[test] - fn swatch_exchange_files_without_a_path_open_in_the_library_panel() { - // A file opened on the web has no path; open_bytes passes its bytes as dataBase64. - let mut app = app(); - crate::io::open_bytes(&mut app, "Brand.ase", &vectorcraft_testkit::ase::sample(), None).unwrap(); - let open = last_open(&app.ui).cloned().unwrap(); - assert_eq!(open.id, "loaded/Brand.ase"); - let (_, lib) = SwatchLibraries::get(&app, &open.id).unwrap(); - assert_eq!((lib.len(), lib.groups.len()), (4, 1)); - assert_eq!(app.session.documents().len(), 1, "not opened as a document"); - } - - fn docking(app: &VectorcraftApp) -> &craft_ui::docking::Layout { - app.ui.docking.as_ref().expect("library panels live in the shared layout") - } - - fn panel_of(reply: Value) -> String { - reply["panel"].as_str().expect("the reply names the panel").to_string() - } - - fn groups(app: &VectorcraftApp) -> Vec> { - docking(app).floating.iter().map(crate::panel_docking::window_panels).collect() - } - - #[test] - fn each_library_opens_in_a_panel_of_its_own() { - let mut app = app(); - // Without the shared layout yet, `window.panel` floats a library panel too. - app.run("window.panel", json!({"panel": "library:swatches:earth-tones"})).unwrap(); - assert!(is_open(&app.ui, "library:swatches:earth-tones")); - app.run("window.panel.close", json!({"panel": "library:swatches:earth-tones"})).unwrap(); - let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "Brights"})).unwrap()); - let styles = panel_of(app.run("window.graphicStyleLibrary", json!({"library": "hand-drawn"})).unwrap()); - assert_eq!([&pastels, &brights, &styles], ["library:swatches:pastels", "library:swatches:brights", "library:graphicStyles:hand-drawn"]); - assert_eq!(groups(&app), [vec![pastels.clone()], vec![brights.clone()], vec![styles.clone()]], "each floats on its own"); - assert_ne!(docking(&app).floating[0].rect, docking(&app).floating[1].rect, "one beside another"); - assert_eq!(tab_label(&app, &brights).as_deref(), Some("Brights")); - // Opening an open library brings its panel forward. - app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); - assert_eq!(groups(&app).len(), 3); - assert_eq!(last_open(&app.ui).map(LibraryPanel::panel), Some(pastels.clone())); - // They group, dock and close as any panel, and `window.panel` shows one again. - app.run("window.panel.move", json!({"panel": brights, "anchor": pastels})).unwrap(); - assert!(groups(&app).contains(&vec![pastels.clone(), brights.clone()]), "{:?}", groups(&app)); - app.run("window.panel.dock", json!({"panel": styles})).unwrap(); - assert!(craft_ui::docking::Layout { root: docking(&app).root.clone(), floating: vec![] }.contains(&styles)); - app.run("window.panel.close", json!({"panel": brights})).unwrap(); - assert!(!is_open(&app.ui, &brights) && is_open(&app.ui, &pastels)); - app.run("window.panel", json!({"panel": brights})).unwrap(); - assert!(is_open(&app.ui, &brights)); - assert!(crate::panel_docking::valid(docking(&app))); - // `library: null` closes the panels of its kind. - app.run("window.swatchLibrary", json!({"library": null})).unwrap(); - assert!(!any_open::(&app.ui) && is_open(&app.ui, &styles)); - // What isn't a library panel's id. - for bad in ["library:nope:x", "library:swatches:", "library:swatches", "Library:swatches:pastels"] { - assert!(crate::menus::normalize_panel(bad).is_none(), "{bad}"); - } - assert!(app.run("ui.libraryPanel", json!({"panel": "layers"})).is_err()); - assert!(app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true})).is_err(), "closed and not persistent"); - assert!(app.run("ui.libraryPanel", json!({"panel": styles, "view": "huge"})).is_err()); - } - - /// A context whose frames report their accessibility tree. - fn accessible() -> egui::Context { - let ctx = context(); - ctx.enable_accesskit(); - ctx - } - - /// One frame of `draw` with `events`, after the frames `ctx` ran. - fn draw_frame( - app: &mut VectorcraftApp, - ctx: &egui::Context, - draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), - events: Vec, - ) -> egui::FullOutput { - let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); - let time = Some(ctx.input(|i| i.time) + 0.1); - let mut out = ctx.run_ui(egui::RawInput { events, time, screen_rect, ..Default::default() }, |ui| draw(app, ui)); - out.textures_delta.clear(); - out - } - - /// Whether the control `draw` shows that screen readers name `label` is enabled, and its center. - fn control(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), label: &str) -> (bool, Pos2) { - draw_frame(app, ctx, draw, vec![]); - let out = draw_frame(app, ctx, draw, vec![]); - let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; - let (_, node) = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no control named {label:?}")); - let b = node.bounds().expect("bounds"); - (!node.is_disabled(), Pos2::new((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5)) - } - - /// A click at `at` in a frame of `draw`. - fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), at: Pos2) { - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - draw_frame(app, ctx, draw, vec![Event::PointerMoved(at)]); - draw_frame(app, ctx, draw, vec![button(true), button(false)]); - } - - /// The app restarted with the preferences `app` saves. - fn restarted(app: &VectorcraftApp) -> VectorcraftApp { - let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); - let mut next = VectorcraftApp::new(Session::new(), Default::default()); - next.ui = saved.sanitized(); - restore(&mut next); - next - } - - #[test] - fn persistent_panels_reopen_at_startup_with_their_settings() { - let dir = std::env::temp_dir().join(format!("vc-persistent-libraries-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); - let mut app = app(); - let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap()); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - let r = app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true, "view": "largeList", "showFind": false})).unwrap(); - assert_eq!((r["persistent"].as_bool(), r["view"].as_str(), r["showFind"].as_bool()), (Some(true), Some("largeList"), Some(false))); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); - app.run("window.panel.float", json!({"panel": pastels, "x": 40, "y": 90})).unwrap(); - // A persistent panel reopens even when it was closed. - app.run("window.panel.close", json!({"panel": brand})).unwrap(); - let next = restarted(&app); - assert!(is_open(&next.ui, &pastels) && is_open(&next.ui, &brand), "{}", next.ui.status); - assert!(!is_open(&next.ui, &brights), "a panel that isn't persistent closed with the app"); - let group = docking(&next).floating.iter().find(|g| g.contains(&pastels)).unwrap(); - assert_eq!(group.rect[..2], [40.0, 90.0], "where it was"); - let s = settings(&next.ui, "swatches", "pastels").unwrap(); - assert_eq!((s.view, s.hide_find, s.persistent), (Some(View::LargeList), true, true)); - let (_, id) = parse(&brand).unwrap(); - assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some()), "read again from its file"); - assert!(next.ui.status.is_empty(), "{}", next.ui.status); - assert!(next.ui.docking_hidden.keys().all(|p| p != &brights)); - // Unchecked, a panel no longer reopens; one whose file is gone reopens as missing, with a note. - let mut next = next; - next.run("ui.libraryPanel", json!({"panel": pastels, "persistent": false})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - let last = restarted(&next); - assert!(!is_open(&last.ui, &pastels) && is_open(&last.ui, &brand)); - assert!(last.ui.status.contains("Brand.gpl"), "{}", last.ui.status); - assert_eq!(last.ui.library_panels.iter().map(LibraryPanel::panel).collect::>(), [brand]); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn a_persistent_panel_whose_file_is_gone_is_listed_as_missing_until_located() { - let dir = std::env::temp_dir().join(format!("vc-missing-library-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("moved")).unwrap(); - let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, palette).unwrap(); - let mut app = app(); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true, "view": "smallList"})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - // The panel reopens showing its library as missing, and the preferences keep it. - let mut next = restarted(&app); - tidy(&mut next); - assert!(is_open(&next.ui, &brand), "{}", next.ui.status); - assert!(next.ui.status.contains("Brand.gpl"), "{}", next.ui.status); - let (_, gone) = parse(&brand).unwrap(); - let s = settings(&next.ui, "swatches", gone).cloned().unwrap(); - assert_eq!((s.path, s.persistent, s.view), (Some(gpl.to_string_lossy().into_owned()), true, Some(View::SmallList))); - assert_eq!(tab_label(&next, &brand).as_deref(), Some("Brand")); - // Closed, it opens again by its library's id. - next.run("window.panel.close", json!({"panel": brand})).unwrap(); - let r = next.run("window.swatchLibrary", json!({"library": gone})).unwrap(); - assert_eq!((r["panel"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some(brand.as_str()), Some("Brand"), Some(0))); - assert!(is_open(&next.ui, &brand)); - let text = crate::tests_labels::painted_text(&mut next, |app, ui| show(app, ui, &brand)); - let shown = format!("Missing: {}", gpl.to_string_lossy()); - assert!(text.contains(&shown), "{text}"); - let ctx = accessible(); - let body = |app: &mut VectorcraftApp, ui: &mut Ui| show(app, ui, &brand); - assert!(!control(&mut next, &ctx, &body, "Locate Library…").0, "dimmed without an open dialog"); - let panel_menu = |app: &mut VectorcraftApp, ui: &mut Ui| menu(app, ui, &brand); - assert!(!control(&mut next, &ctx, &panel_menu, "Next Library").0, "no library to step from"); - let mut next = restarted(&next); - assert!(is_open(&next.ui, &brand), "still listed after another restart"); - // Locate Library… reads the file where it is now and shows it in the same panel. - assert!(next.run("ui.libraryPanel.locate", json!({"panel": "layers"})).is_err()); - let moved = dir.join("moved").join("Brand.gpl"); - std::fs::write(&moved, palette).unwrap(); - let picked = moved.to_string_lossy().into_owned(); - next.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); - let (enabled, at) = control(&mut next, &ctx, &body, "Locate Library…"); - assert!(enabled); - click_at(&mut next, &ctx, &body, at); - let located = panel_id("swatches", &format!("loaded/{}", moved.to_string_lossy())); - assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &located), "{}", next.ui.status); - let (_, id) = parse(&located).unwrap(); - assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some())); - let s = settings(&next.ui, "swatches", id).cloned().unwrap(); - assert_eq!((s.path, s.persistent, s.view), (Some(moved.to_string_lossy().into_owned()), true, Some(View::SmallList))); - assert!(settings(&next.ui, "swatches", gone).is_none()); - let located_menu = |app: &mut VectorcraftApp, ui: &mut Ui| menu(app, ui, &located); - assert!(control(&mut next, &ctx, &located_menu, "Next Library").0); - let last = restarted(&next); - assert!(is_open(&last.ui, &located) && last.ui.status.is_empty(), "{}", last.ui.status); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn locating_a_library_open_in_its_own_panel_brings_that_panel_forward() { - let dir = std::env::temp_dir().join(format!("vc-locate-open-library-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, palette).unwrap(); - let mut app = app(); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - let mut next = restarted(&app); - let copy = dir.join("Copy.gpl"); - std::fs::write(©, palette).unwrap(); - let other = panel_of(next.run("window.swatchLibrary.other", json!({"path": copy.to_string_lossy()})).unwrap()); - next.run("window.panel.close", json!({"panel": brand})).unwrap(); - assert!(next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": copy.to_string_lossy()})).is_err(), "a closed panel"); - next.run("window.panel", json!({"panel": brand})).unwrap(); - let r = next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": copy.to_string_lossy()})).unwrap(); - assert_eq!((r["panel"].as_str(), r["persistent"].as_bool()), (Some(other.as_str()), Some(true))); - assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &other)); - assert!(next.ui.library_panels.iter().all(|p| p.panel() != brand), "the missing panel is forgotten"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn locate_library_leaves_a_panel_whose_library_is_there_alone() { - let dir = std::env::temp_dir().join(format!("vc-locate-found-library-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); - let mut app = app(); - let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - for persistent in [false, true] { - app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": persistent})).unwrap(); - let e = app.run("ui.libraryPanel.locate", json!({"panel": pastels, "path": gpl.to_string_lossy()})).unwrap_err(); - assert!(e.contains("isn't missing its library"), "{e}"); - assert!(is_open(&app.ui, &pastels), "persistent: {persistent}"); - assert_eq!(settings(&app.ui, "swatches", "pastels").map(|s| s.persistent), Some(persistent)); - } - assert!(SwatchLibraries::get(&app, &format!("loaded/{}", gpl.to_string_lossy())).is_none(), "the file isn't loaded"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn locate_library_reads_a_missing_library_whose_panel_is_no_longer_persistent() { - let dir = std::env::temp_dir().join(format!("vc-locate-not-persistent-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("moved")).unwrap(); - let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, palette).unwrap(); - let mut app = app(); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - let mut next = restarted(&app); - // After a command turns Persistent off, the panel stays open on its missing library until the next frame's tidy. - next.run("ui.libraryPanel", json!({"panel": brand, "persistent": false})).unwrap(); - assert!(is_open(&next.ui, &brand)); - let moved = dir.join("moved").join("Brand.gpl"); - std::fs::write(&moved, palette).unwrap(); - let r = next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": moved.to_string_lossy()})).unwrap(); - let located = panel_id("swatches", &format!("loaded/{}", moved.to_string_lossy())); - assert_eq!((r["panel"].as_str(), r["persistent"].as_bool()), (Some(located.as_str()), Some(false))); - assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &located), "{}", next.ui.status); - let (_, id) = parse(&located).unwrap(); - assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some())); - tidy(&mut next); - assert!(is_open(&next.ui, &located), "its library is there now"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn an_open_panel_whose_library_is_missing_is_brought_forward_by_its_id_when_not_persistent() { - let dir = std::env::temp_dir().join(format!("vc-open-not-persistent-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let gpl = dir.join("Brand.gpl"); - std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); - let mut app = app(); - let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); - std::fs::remove_file(&gpl).unwrap(); - let mut next = restarted(&app); - // After a command turns Persistent off, the panel stays open on its missing library until the next frame's tidy. - next.run("ui.libraryPanel", json!({"panel": brand, "persistent": false})).unwrap(); - let (_, gone) = parse(&brand).unwrap(); - let r = next.run("window.swatchLibrary", json!({"library": gone})).unwrap(); - assert_eq!((r["panel"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some(brand.as_str()), Some("Brand"), Some(0))); - assert!(is_open(&next.ui, &brand)); - tidy(&mut next); - assert!(!is_open(&next.ui, &brand), "a panel that isn't persistent still closes"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn previous_and_next_show_the_neighbouring_library_in_the_same_panel() { - let mut app = app(); - let first = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - app.run("window.panel.float", json!({"panel": first, "x": 50, "y": 60})).unwrap(); - app.run("ui.libraryPanel", json!({"panel": first, "persistent": true, "view": "smallList"})).unwrap(); - step::(&mut app, "pastels", 1); - let next = panel_id("swatches", SWATCH_IDS[5]); - assert!(!is_open(&app.ui, &first) && is_open(&app.ui, &next), "{}", app.ui.status); - let group = docking(&app).floating.iter().find(|g| g.contains(&next)).unwrap(); - assert_eq!((group.panels().count(), group.rect[0], group.rect[1]), (1, 50.0, 60.0), "in its place"); - let s = settings(&app.ui, "swatches", SWATCH_IDS[5]).unwrap(); - assert_eq!((s.view, s.persistent), (Some(View::SmallList), true), "the panel keeps its settings"); - assert!(settings(&app.ui, "swatches", "pastels").is_none()); - assert!(crate::panel_docking::valid(docking(&app))); - // A library open in a panel of its own is brought forward instead. - app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); - let r = app.run("ui.libraryPanel", json!({"panel": next, "library": "Pastels"})).unwrap(); - assert_eq!(r["panel"], json!(first)); - assert!(is_open(&app.ui, &next) && is_open(&app.ui, &first)); - } - - #[test] - fn a_panel_closes_when_its_library_is_gone() { - let dir = std::env::temp_dir().join(format!("vc-gone-library-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("Gone.gpl"), "GIMP Palette\nName: Gone\n#\n0 0 0\tInk\n").unwrap(); - std::fs::write(dir.join("Kept.gpl"), "GIMP Palette\nName: Kept\n#\n0 0 0\tInk\n").unwrap(); - let mut app = app(); - app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); - let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Gone.gpl"})).unwrap()); - let kept = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Kept.gpl"})).unwrap()); - app.run("ui.libraryPanel", json!({"panel": kept, "persistent": true})).unwrap(); - tidy(&mut app); - assert!(is_open(&app.ui, &panel)); - std::fs::remove_file(dir.join("Gone.gpl")).unwrap(); - std::fs::remove_file(dir.join("Kept.gpl")).unwrap(); - app.session.swatch_libraries.rescan(); - tidy(&mut app); - assert!(!is_open(&app.ui, &panel)); - // A persistent panel stays open and shows its library as missing. - assert!(is_open(&app.ui, &kept)); - assert_eq!(tab_label(&app, &kept).as_deref(), Some("Kept")); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, &kept)); - assert!(text.contains("Missing: user/Kept.gpl"), "{text}"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn the_panel_menu_has_persistent() { - let mut app = app(); - let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| menu(app, ui, &panel)); - for item in ["Add to Swatches", "Small List View", "Show Find Field", "Previous Library", "Persistent", "Close Library"] { - assert!(text.contains(item), "{item}: {text}"); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs new file mode 100644 index 000000000..ac01d4f44 --- /dev/null +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -0,0 +1,656 @@ +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use serde_json::Value; +use vectorcraft_color::Paint; +use vectorcraft_engine::Session; + +use super::*; +use crate::panels::swatches::SwatchLibraries; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx +} + +/// One frame of the library panel with `events` and `modifiers` held. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: Modifiers, time: f64) { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show_window(app, ui.ctx())); + out.textures_delta.clear(); +} + +/// The panel opened last. +fn shown(app: &VectorcraftApp) -> String { + last_open(&app.ui).expect("a library panel is open").panel() +} + +/// Click the tile or row of `name` in the panel opened last with `modifiers` (frames from +/// `time` to `time + 0.5`; a new window takes a sizing frame first). +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { + frame(app, ctx, vec![], Modifiers::NONE, time); + frame(app, ctx, vec![], Modifiers::NONE, time + 0.25); + let at = ctx.read_response(tile_id(&shown(app), name)).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + frame(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5); +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +#[test] +fn clicking_a_library_swatch_adds_and_applies_it_in_one_step() { + let mut app = app(); + let ctx = context(); + let r = app.run("window.swatchLibrary", json!({"library": "Pastels"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64(), r["panel"].as_str()), (Some("pastels"), Some(24), Some("library:swatches:pastels"))); + assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("swatches", "pastels"))); + let undo = undo_len(&app); + click(&mut app, &ctx, "Pastel Teal", Modifiers::NONE, 0.0); + let d = &app.session.doc().unwrap().doc; + let added = d.swatch("Pastel Teal").expect("added to the document"); + assert_eq!(app.session.paint.fill, Paint::solid(added.paint.color().unwrap()), "applied to the fill"); + assert_eq!(undo_len(&app), undo + 1); + // Alt-click paints the stroke (the inactive proxy). + click(&mut app, &ctx, "Soft Blue", Modifiers::ALT, 2.0); + let blue = app.session.doc().unwrap().doc.swatch("Soft Blue").and_then(|w| w.paint.color()).unwrap(); + assert_eq!(app.session.paint.stroke.color(), Some(blue)); + assert!(app.session.fill_active, "Alt keeps the active proxy"); + // A library that doesn't exist is an error; null closes the panel. + assert!(app.run("window.swatchLibrary", json!({"library": "nope"})).is_err()); + app.run("window.swatchLibrary", json!({"library": null})).unwrap(); + assert!(last_open(&app.ui).is_none()); +} + +#[test] +fn modifier_clicks_select_and_add_to_swatches_adds_the_selection() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "earth-tones"})).unwrap(); + click(&mut app, &ctx, "Clay 1", Modifiers::COMMAND, 0.0); + click(&mut app, &ctx, "Ochre", Modifiers::NONE, 2.0); + assert_eq!(selection(&ctx, "library:swatches:earth-tones"), ["Ochre"], "a folder click selects its group"); + click(&mut app, &ctx, "Clay 3", Modifiers::COMMAND, 4.0); + assert!(app.session.doc().unwrap().doc.swatch("Clay 3").is_none(), "modifier clicks only select"); + let sel = selection(&ctx, "library:swatches:earth-tones"); + SwatchLibraries::add(&mut app, "earth-tones", sel); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Ochre").map(|g| g.swatches.len()), Some(6)); + assert!(d.swatches.iter().any(|w| w.name == "Clay 3")); + // The selection belongs to its library's panel. + app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap(); + assert!(selection(&ctx, "library:swatches:brights").is_empty()); +} + +#[test] +fn a_plain_click_sets_where_a_shift_click_range_starts() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); + let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); + click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); + click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); + assert_eq!(selection(&ctx, "library:swatches:pastels"), order[1..=4], "the range starts at the plain click"); + // A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item. + click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0); + click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0); + click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0); + assert_eq!(selection(&ctx, "library:swatches:pastels"), order[2..=3]); + let d = &app.session.doc().unwrap().doc; + assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch"); + assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects"); +} + +#[test] +fn find_filters_list_view_and_arrows_step_through_the_libraries() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); + let panel = "library:swatches:web-safe-216"; + app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(key(panel, "find")), "#FF00".to_string())); + frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0); + frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5); + assert!(ctx.read_response(tile_id(panel, "#FF0033")).is_some()); + assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); + let first = SWATCH_IDS[0]; + let last = SwatchLibraries::list(&app).pop().unwrap().id; + assert_eq!(last, "radial-glows", "gradient libraries come after the swatch libraries"); + assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); + assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); +} + +const SWATCH_IDS: [&str; 9] = [ + "web-safe-216", + "grays-neutrals", + "earth-tones", + "skin-tone-ramps", + "pastels", + "brights", + "metallic-gradients", + "perceptual-scales", + "harmony-sets", +]; + +#[test] +fn the_window_submenu_lists_every_library() { + let app = app(); + let ids: Vec = crate::menus::menu_entries(&app) + .into_iter() + .filter(|e| e.path == ["Window", "Swatch Libraries"] && e.command.as_deref() == Some("window.swatchLibrary")) + .map(|e| e.params["library"].clone()) + .collect(); + let all = vectorcraft_engine::cmd::swatchlib::libraries(&app.session); + let listed = |category: &str| all.iter().filter(|l| l.category == category).map(|l| json!(l.id)).collect::>(); + assert_eq!(ids, listed("builtIn")); + assert_eq!(ids, SWATCH_IDS.map(|i| json!(i))); + let gradients: Vec = crate::menus::menu_entries(&app) + .into_iter() + .filter(|e| e.path == ["Window", "Swatch Libraries", "Gradients"]) + .map(|e| e.params["library"].clone()) + .collect(); + assert!(gradients.len() >= 5); + assert_eq!(gradients, listed("gradients")); + let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults")); + assert!(defaults, "Default Swatches is listed"); +} + +#[test] +fn save_dialog_writes_user_libraries_that_the_window_menu_lists() { + let dir = std::env::temp_dir().join(format!("vc-library-panel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut app = app(); + app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let user_items = |app: &VectorcraftApp| -> Vec<(String, String)> { + crate::menus::menu_entries(app) + .into_iter() + .filter(|e| e.path == ["Window", "Swatch Libraries", "User Defined"]) + .map(|e| (e.label, e.command.unwrap_or_default())) + .collect() + }; + assert!(user_items(&app).is_empty(), "empty slots are hidden"); + app.run("ui.saveSwatchLibrary", json!({"names": ["Brights"]})).unwrap(); + let d = app.ui.dialog.as_mut().expect("Save Swatch Library opens"); + assert_eq!((d.kind.as_str(), d.bool("user")), (crate::dialogs::save_swatch_library::KIND, true)); + d.fields.insert("name".into(), json!("Loud")); + d.fields.insert("selectedOnly".into(), json!(true)); + let ctx = context(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() }; + ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.ui.dialog.is_some(), "the dialog draws and stays open"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(user_items(&app), [("Loud".to_string(), "window.userSwatchLibrary1".to_string())]); + app.run("window.userSwatchLibrary1", json!({})).unwrap(); + assert_eq!(last_open(&app.ui).map(|p| p.id.as_str()), Some("user/Loud.vcswatches")); + let (_, lib) = SwatchLibraries::get(&app, "user/Loud.vcswatches").unwrap(); + assert_eq!((lib.groups.len(), lib.len()), (1, 5), "only the selected group"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn saving_to_a_file_and_opening_a_palette_file() { + let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("user") && !d.bool("__user"), "no user folder: a file"); + d.fields.insert("format".into(), json!("gpl")); + crate::dialogs::confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert!(path.ends_with(".gpl"), "{path}"); + let text = String::from_utf8(bytes).unwrap(); + assert!( + text.starts_with( + "GIMP Palette +" + ) && text.contains( + " Red +" + ), + "{text}" + ); + // Opening a palette file opens it in the library panel. + crate::io::open_bytes(&mut app, "Saved.gpl", text.as_bytes(), None).unwrap(); + let open = last_open(&app.ui).cloned().unwrap(); + assert!(open.id.starts_with("loaded/"), "{open:?}"); + assert_eq!(app.session.documents().len(), 1, "not opened as a document"); + let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); + assert_eq!(other.submenu, Some("Other Libraries")); + // Built-in libraries' names are interface labels; a file's is shown as it is. + for l in SwatchLibraries::list(&app) { + assert_eq!(builtin_library(&l.id), !matches!(l.submenu, Some("User Defined" | "Other Libraries")), "{}", l.id); + } + assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES)); +} + +#[test] +fn the_save_dialog_writes_swatch_exchange_files() { + let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("format".into(), json!("ase")); + d.fields.insert("name".into(), json!("Brand Kit")); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(text.contains("Swatch Exchange (.ase)") && text.contains("Swatch exchange files keep solid colors only."), "{text}"); + crate::dialogs::confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert_eq!(path, "/tmp/Brand Kit.ase", "the save panel suggests the name"); + let lib = vectorcraft_color::palette_io::read_bytes(&bytes, "x").unwrap(); + let solid = app.session.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count(); + assert_eq!(lib.len(), solid, "every solid color of the document"); + // The web downloads the file under that name. + let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let dl = downloads.clone(); + let services = crate::Services { + download: Some(Box::new(move |name: &str, _: &[u8]| { + dl.borrow_mut().push(name.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut web = VectorcraftApp::new(Session::new(), services); + web.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + web.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = web.ui.dialog.as_mut().unwrap(); + d.fields.insert("format".into(), json!("ase")); + d.fields.insert("name".into(), json!(" Brand: Kit. ")); + crate::dialogs::confirm(&mut web).unwrap(); + assert_eq!(*downloads.borrow(), ["Brand- Kit.ase"], "the name made safe as a file name, as for User Defined"); +} + +#[test] +fn swatch_exchange_files_without_a_path_open_in_the_library_panel() { + // A file opened on the web has no path; open_bytes passes its bytes as dataBase64. + let mut app = app(); + crate::io::open_bytes(&mut app, "Brand.ase", &vectorcraft_testkit::ase::sample(), None).unwrap(); + let open = last_open(&app.ui).cloned().unwrap(); + assert_eq!(open.id, "loaded/Brand.ase"); + let (_, lib) = SwatchLibraries::get(&app, &open.id).unwrap(); + assert_eq!((lib.len(), lib.groups.len()), (4, 1)); + assert_eq!(app.session.documents().len(), 1, "not opened as a document"); +} + +fn docking(app: &VectorcraftApp) -> &craft_ui::docking::Layout { + app.ui.docking.as_ref().expect("library panels live in the shared layout") +} + +fn panel_of(reply: Value) -> String { + reply["panel"].as_str().expect("the reply names the panel").to_string() +} + +fn groups(app: &VectorcraftApp) -> Vec> { + docking(app).floating.iter().map(crate::panel_docking::window_panels).collect() +} + +#[test] +fn each_library_opens_in_a_panel_of_its_own() { + let mut app = app(); + // Without the shared layout yet, `window.panel` floats a library panel too. + app.run("window.panel", json!({"panel": "library:swatches:earth-tones"})).unwrap(); + assert!(is_open(&app.ui, "library:swatches:earth-tones")); + app.run("window.panel.close", json!({"panel": "library:swatches:earth-tones"})).unwrap(); + let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "Brights"})).unwrap()); + let styles = panel_of(app.run("window.graphicStyleLibrary", json!({"library": "hand-drawn"})).unwrap()); + assert_eq!([&pastels, &brights, &styles], ["library:swatches:pastels", "library:swatches:brights", "library:graphicStyles:hand-drawn"]); + assert_eq!(groups(&app), [vec![pastels.clone()], vec![brights.clone()], vec![styles.clone()]], "each floats on its own"); + assert_ne!(docking(&app).floating[0].rect, docking(&app).floating[1].rect, "one beside another"); + assert_eq!(tab_label(&app, &brights).as_deref(), Some("Brights")); + // Opening an open library brings its panel forward. + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + assert_eq!(groups(&app).len(), 3); + assert_eq!(last_open(&app.ui).map(LibraryPanel::panel), Some(pastels.clone())); + // They group, dock and close as any panel, and `window.panel` shows one again. + app.run("window.panel.move", json!({"panel": brights, "anchor": pastels})).unwrap(); + assert!(groups(&app).contains(&vec![pastels.clone(), brights.clone()]), "{:?}", groups(&app)); + app.run("window.panel.dock", json!({"panel": styles})).unwrap(); + assert!(craft_ui::docking::Layout { root: docking(&app).root.clone(), floating: vec![] }.contains(&styles)); + app.run("window.panel.close", json!({"panel": brights})).unwrap(); + assert!(!is_open(&app.ui, &brights) && is_open(&app.ui, &pastels)); + app.run("window.panel", json!({"panel": brights})).unwrap(); + assert!(is_open(&app.ui, &brights)); + assert!(crate::panel_docking::valid(docking(&app))); + // `library: null` closes the panels of its kind. + app.run("window.swatchLibrary", json!({"library": null})).unwrap(); + assert!(!any_open::(&app.ui) && is_open(&app.ui, &styles)); + // What isn't a library panel's id. + for bad in ["library:nope:x", "library:swatches:", "library:swatches", "Library:swatches:pastels"] { + assert!(crate::menus::normalize_panel(bad).is_none(), "{bad}"); + } + assert!(app.run("ui.libraryPanel", json!({"panel": "layers"})).is_err()); + assert!(app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true})).is_err(), "closed and not persistent"); + assert!(app.run("ui.libraryPanel", json!({"panel": styles, "view": "huge"})).is_err()); +} + +/// A context whose frames report their accessibility tree. +fn accessible() -> egui::Context { + let ctx = context(); + ctx.enable_accesskit(); + ctx +} + +/// One frame of `draw` with `events`, after the frames `ctx` ran. +fn draw_frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), + events: Vec, +) -> egui::FullOutput { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); + let time = Some(ctx.input(|i| i.time) + 0.1); + let mut out = ctx.run_ui(egui::RawInput { events, time, screen_rect, ..Default::default() }, |ui| draw(app, ui)); + out.textures_delta.clear(); + out +} + +/// Whether the control `draw` shows that screen readers name `label` is enabled, and its center. +fn control(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), label: &str) -> (bool, Pos2) { + draw_frame(app, ctx, draw, vec![]); + let out = draw_frame(app, ctx, draw, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let (_, node) = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no control named {label:?}")); + let b = node.bounds().expect("bounds"); + (!node.is_disabled(), Pos2::new((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5)) +} + +/// A click at `at` in a frame of `draw`. +fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), at: Pos2) { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + draw_frame(app, ctx, draw, vec![Event::PointerMoved(at)]); + draw_frame(app, ctx, draw, vec![button(true), button(false)]); +} + +/// The app restarted with the preferences `app` saves. +fn restarted(app: &VectorcraftApp) -> VectorcraftApp { + let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + let mut next = VectorcraftApp::new(Session::new(), Default::default()); + next.ui = saved.sanitized(); + restore(&mut next); + next +} + +#[test] +fn persistent_panels_reopen_at_startup_with_their_settings() { + let dir = std::env::temp_dir().join(format!("vc-persistent-libraries-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); + let mut app = app(); + let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap()); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + let r = app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true, "view": "largeList", "showFind": false})).unwrap(); + assert_eq!((r["persistent"].as_bool(), r["view"].as_str(), r["showFind"].as_bool()), (Some(true), Some("largeList"), Some(false))); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + app.run("window.panel.float", json!({"panel": pastels, "x": 40, "y": 90})).unwrap(); + // A persistent panel reopens even when it was closed. + app.run("window.panel.close", json!({"panel": brand})).unwrap(); + let next = restarted(&app); + assert!(is_open(&next.ui, &pastels) && is_open(&next.ui, &brand), "{}", next.ui.status); + assert!(!is_open(&next.ui, &brights), "a panel that isn't persistent closed with the app"); + let group = docking(&next).floating.iter().find(|g| g.contains(&pastels)).unwrap(); + assert_eq!(group.rect[..2], [40.0, 90.0], "where it was"); + let s = settings(&next.ui, "swatches", "pastels").unwrap(); + assert_eq!((s.view, s.hide_find, s.persistent), (Some(View::LargeList), true, true)); + let (_, id) = parse(&brand).unwrap(); + assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some()), "read again from its file"); + assert!(next.ui.status.is_empty(), "{}", next.ui.status); + assert!(next.ui.docking_hidden.keys().all(|p| p != &brights)); + // Unchecked, a panel no longer reopens; one whose file is gone reopens as missing, with a note. + let mut next = next; + next.run("ui.libraryPanel", json!({"panel": pastels, "persistent": false})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let last = restarted(&next); + assert!(!is_open(&last.ui, &pastels) && is_open(&last.ui, &brand)); + assert!(last.ui.status.contains("Brand.gpl"), "{}", last.ui.status); + assert_eq!(last.ui.library_panels.iter().map(LibraryPanel::panel).collect::>(), [brand]); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_persistent_panel_whose_file_is_gone_is_listed_as_missing_until_located() { + let dir = std::env::temp_dir().join(format!("vc-missing-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("moved")).unwrap(); + let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, palette).unwrap(); + let mut app = app(); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true, "view": "smallList"})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + // The panel reopens showing its library as missing, and the preferences keep it. + let mut next = restarted(&app); + tidy(&mut next); + assert!(is_open(&next.ui, &brand), "{}", next.ui.status); + assert!(next.ui.status.contains("Brand.gpl"), "{}", next.ui.status); + let (_, gone) = parse(&brand).unwrap(); + let s = settings(&next.ui, "swatches", gone).cloned().unwrap(); + assert_eq!((s.path, s.persistent, s.view), (Some(gpl.to_string_lossy().into_owned()), true, Some(View::SmallList))); + assert_eq!(tab_label(&next, &brand).as_deref(), Some("Brand")); + // Closed, it opens again by its library's id. + next.run("window.panel.close", json!({"panel": brand})).unwrap(); + let r = next.run("window.swatchLibrary", json!({"library": gone})).unwrap(); + assert_eq!((r["panel"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some(brand.as_str()), Some("Brand"), Some(0))); + assert!(is_open(&next.ui, &brand)); + let text = crate::tests_labels::painted_text(&mut next, |app, ui| show(app, ui, &brand)); + let shown = format!("Missing: {}", gpl.to_string_lossy()); + assert!(text.contains(&shown), "{text}"); + let ctx = accessible(); + let body = |app: &mut VectorcraftApp, ui: &mut Ui| show(app, ui, &brand); + assert!(!control(&mut next, &ctx, &body, "Locate Library…").0, "dimmed without an open dialog"); + let panel_menu = |app: &mut VectorcraftApp, ui: &mut Ui| menu(app, ui, &brand); + assert!(!control(&mut next, &ctx, &panel_menu, "Next Library").0, "no library to step from"); + let mut next = restarted(&next); + assert!(is_open(&next.ui, &brand), "still listed after another restart"); + // Locate Library… reads the file where it is now and shows it in the same panel. + assert!(next.run("ui.libraryPanel.locate", json!({"panel": "layers"})).is_err()); + let moved = dir.join("moved").join("Brand.gpl"); + std::fs::write(&moved, palette).unwrap(); + let picked = moved.to_string_lossy().into_owned(); + next.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + let (enabled, at) = control(&mut next, &ctx, &body, "Locate Library…"); + assert!(enabled); + click_at(&mut next, &ctx, &body, at); + let located = panel_id("swatches", &format!("loaded/{}", moved.to_string_lossy())); + assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &located), "{}", next.ui.status); + let (_, id) = parse(&located).unwrap(); + assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some())); + let s = settings(&next.ui, "swatches", id).cloned().unwrap(); + assert_eq!((s.path, s.persistent, s.view), (Some(moved.to_string_lossy().into_owned()), true, Some(View::SmallList))); + assert!(settings(&next.ui, "swatches", gone).is_none()); + let located_menu = |app: &mut VectorcraftApp, ui: &mut Ui| menu(app, ui, &located); + assert!(control(&mut next, &ctx, &located_menu, "Next Library").0); + let last = restarted(&next); + assert!(is_open(&last.ui, &located) && last.ui.status.is_empty(), "{}", last.ui.status); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn locating_a_library_open_in_its_own_panel_brings_that_panel_forward() { + let dir = std::env::temp_dir().join(format!("vc-locate-open-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, palette).unwrap(); + let mut app = app(); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let mut next = restarted(&app); + let copy = dir.join("Copy.gpl"); + std::fs::write(©, palette).unwrap(); + let other = panel_of(next.run("window.swatchLibrary.other", json!({"path": copy.to_string_lossy()})).unwrap()); + next.run("window.panel.close", json!({"panel": brand})).unwrap(); + assert!(next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": copy.to_string_lossy()})).is_err(), "a closed panel"); + next.run("window.panel", json!({"panel": brand})).unwrap(); + let r = next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": copy.to_string_lossy()})).unwrap(); + assert_eq!((r["panel"].as_str(), r["persistent"].as_bool()), (Some(other.as_str()), Some(true))); + assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &other)); + assert!(next.ui.library_panels.iter().all(|p| p.panel() != brand), "the missing panel is forgotten"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn locate_library_leaves_a_panel_whose_library_is_there_alone() { + let dir = std::env::temp_dir().join(format!("vc-locate-found-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); + let mut app = app(); + let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + for persistent in [false, true] { + app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": persistent})).unwrap(); + let e = app.run("ui.libraryPanel.locate", json!({"panel": pastels, "path": gpl.to_string_lossy()})).unwrap_err(); + assert!(e.contains("isn't missing its library"), "{e}"); + assert!(is_open(&app.ui, &pastels), "persistent: {persistent}"); + assert_eq!(settings(&app.ui, "swatches", "pastels").map(|s| s.persistent), Some(persistent)); + } + assert!(SwatchLibraries::get(&app, &format!("loaded/{}", gpl.to_string_lossy())).is_none(), "the file isn't loaded"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn locate_library_reads_a_missing_library_whose_panel_is_no_longer_persistent() { + let dir = std::env::temp_dir().join(format!("vc-locate-not-persistent-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("moved")).unwrap(); + let palette = "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n"; + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, palette).unwrap(); + let mut app = app(); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let mut next = restarted(&app); + // After a command turns Persistent off, the panel stays open on its missing library until the next frame's tidy. + next.run("ui.libraryPanel", json!({"panel": brand, "persistent": false})).unwrap(); + assert!(is_open(&next.ui, &brand)); + let moved = dir.join("moved").join("Brand.gpl"); + std::fs::write(&moved, palette).unwrap(); + let r = next.run("ui.libraryPanel.locate", json!({"panel": brand, "path": moved.to_string_lossy()})).unwrap(); + let located = panel_id("swatches", &format!("loaded/{}", moved.to_string_lossy())); + assert_eq!((r["panel"].as_str(), r["persistent"].as_bool()), (Some(located.as_str()), Some(false))); + assert!(!is_open(&next.ui, &brand) && is_open(&next.ui, &located), "{}", next.ui.status); + let (_, id) = parse(&located).unwrap(); + assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some())); + tidy(&mut next); + assert!(is_open(&next.ui, &located), "its library is there now"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_open_panel_whose_library_is_missing_is_brought_forward_by_its_id_when_not_persistent() { + let dir = std::env::temp_dir().join(format!("vc-open-not-persistent-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); + let mut app = app(); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let mut next = restarted(&app); + // After a command turns Persistent off, the panel stays open on its missing library until the next frame's tidy. + next.run("ui.libraryPanel", json!({"panel": brand, "persistent": false})).unwrap(); + let (_, gone) = parse(&brand).unwrap(); + let r = next.run("window.swatchLibrary", json!({"library": gone})).unwrap(); + assert_eq!((r["panel"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some(brand.as_str()), Some("Brand"), Some(0))); + assert!(is_open(&next.ui, &brand)); + tidy(&mut next); + assert!(!is_open(&next.ui, &brand), "a panel that isn't persistent still closes"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn previous_and_next_show_the_neighbouring_library_in_the_same_panel() { + let mut app = app(); + let first = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + app.run("window.panel.float", json!({"panel": first, "x": 50, "y": 60})).unwrap(); + app.run("ui.libraryPanel", json!({"panel": first, "persistent": true, "view": "smallList"})).unwrap(); + step::(&mut app, "pastels", 1); + let next = panel_id("swatches", SWATCH_IDS[5]); + assert!(!is_open(&app.ui, &first) && is_open(&app.ui, &next), "{}", app.ui.status); + let group = docking(&app).floating.iter().find(|g| g.contains(&next)).unwrap(); + assert_eq!((group.panels().count(), group.rect[0], group.rect[1]), (1, 50.0, 60.0), "in its place"); + let s = settings(&app.ui, "swatches", SWATCH_IDS[5]).unwrap(); + assert_eq!((s.view, s.persistent), (Some(View::SmallList), true), "the panel keeps its settings"); + assert!(settings(&app.ui, "swatches", "pastels").is_none()); + assert!(crate::panel_docking::valid(docking(&app))); + // A library open in a panel of its own is brought forward instead. + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let r = app.run("ui.libraryPanel", json!({"panel": next, "library": "Pastels"})).unwrap(); + assert_eq!(r["panel"], json!(first)); + assert!(is_open(&app.ui, &next) && is_open(&app.ui, &first)); +} + +#[test] +fn a_panel_closes_when_its_library_is_gone() { + let dir = std::env::temp_dir().join(format!("vc-gone-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Gone.gpl"), "GIMP Palette\nName: Gone\n#\n0 0 0\tInk\n").unwrap(); + std::fs::write(dir.join("Kept.gpl"), "GIMP Palette\nName: Kept\n#\n0 0 0\tInk\n").unwrap(); + let mut app = app(); + app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Gone.gpl"})).unwrap()); + let kept = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Kept.gpl"})).unwrap()); + app.run("ui.libraryPanel", json!({"panel": kept, "persistent": true})).unwrap(); + tidy(&mut app); + assert!(is_open(&app.ui, &panel)); + std::fs::remove_file(dir.join("Gone.gpl")).unwrap(); + std::fs::remove_file(dir.join("Kept.gpl")).unwrap(); + app.session.swatch_libraries.rescan(); + tidy(&mut app); + assert!(!is_open(&app.ui, &panel)); + // A persistent panel stays open and shows its library as missing. + assert!(is_open(&app.ui, &kept)); + assert_eq!(tab_label(&app, &kept).as_deref(), Some("Kept")); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, &kept)); + assert!(text.contains("Missing: user/Kept.gpl"), "{text}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_panel_menu_has_persistent() { + let mut app = app(); + let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| menu(app, ui, &panel)); + for item in ["Add to Swatches", "Small List View", "Show Find Field", "Previous Library", "Persistent", "Close Library"] { + assert!(text.contains(item), "{item}: {text}"); + } +} From 36b6c513f7cb3b3c0e7eded2f015a13b7b1153a6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:51 +0000 Subject: [PATCH 038/199] refactor(ui-egui): move panels/brushes.rs tests to panels/brushes/tests.rs --- crates/ui-egui/src/panels/brushes.rs | 658 +-------------------- crates/ui-egui/src/panels/brushes/tests.rs | 655 ++++++++++++++++++++ 2 files changed, 656 insertions(+), 657 deletions(-) create mode 100644 crates/ui-egui/src/panels/brushes/tests.rs diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 65c738585..cf95c5a9f 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -713,660 +713,4 @@ pub(crate) mod counters { } #[cfg(test)] -mod tests { - use egui::{Pos2, Rect, pos2}; - use vectorcraft_doc::NodeId; - use vectorcraft_engine::Session; - - thread_local! { - /// Stroke previews rendered (cache misses), and drawn, on this test thread. - pub(super) static BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; - pub(super) static DRAWN: std::cell::Cell = const { std::cell::Cell::new(0) }; - } - - /// One frame of the panel → (previews rendered, previews drawn) in it. - fn counted(ctx: &egui::Context, app: &mut VectorcraftApp) -> (usize, usize) { - BUILDS.with(|b| b.set(0)); - DRAWN.with(|d| d.set(0)); - frame(ctx, app, vec![]); - (BUILDS.with(std::cell::Cell::get), DRAWN.with(std::cell::Cell::get)) - } - - use super::*; - - /// One headless frame of the panel, 236 pt wide as in the dock. - fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| show(app, ui)); - out.textures_delta.clear(); - } - - fn button(at: Pos2, pressed: bool) -> egui::Event { - egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } - } - - /// One frame of `draw` at `time` with `modifiers` held, its output kept. - fn frame_with( - ctx: &egui::Context, - app: &mut VectorcraftApp, - events: Vec, - modifiers: egui::Modifiers, - time: f64, - draw: fn(&mut VectorcraftApp, &mut Ui), - ) -> egui::FullOutput { - let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))); - let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); - let raw = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| draw(app, ui)); - out.textures_delta.clear(); - out - } - - /// A click at `at` with `modifiers` held, in a frame of `draw` at `time`. - fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, modifiers: egui::Modifiers, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) { - let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; - frame_with(ctx, app, vec![egui::Event::PointerMoved(at), press(true), press(false)], modifiers, time, draw); - } - - /// The accessibility nodes of `out` that take a click, with their rectangles, top to bottom - /// and then left to right. - fn clickable(out: &egui::FullOutput) -> Vec<(Option, Rect)> { - let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; - let mut v: Vec<(Option, Rect)> = nodes - .iter() - .filter(|(_, n)| n.supports_action(egui::accesskit::Action::Click)) - .filter_map(|(_, n)| { - let b = n.bounds()?; - Some((n.label().map(|l| l.trim().to_string()), Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)))) - }) - .collect(); - v.sort_by(|a, b| a.1.min.y.total_cmp(&b.1.min.y).then(a.1.min.x.total_cmp(&b.1.min.x))); - v - } - - /// #852: double-clicking a brush opens its Brush Options; OK changes the brush (and strokes - /// painted with it). A single click only applies the brush. - #[test] - fn double_clicking_a_brush_opens_its_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - let clicks = |at: Pos2, n: usize| -> Vec { - std::iter::once(egui::Event::PointerMoved(at)).chain((0..n).flat_map(|_| [button(at, true), button(at, false)])).collect() - }; - let at_time = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let raw = egui::RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), - time: Some(time), - events, - ..Default::default() - }; - ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear(); - }; - at_time(&mut app, 0.0, vec![]); - let (list, _) = brushes(&mut app); - let first = list[0].0.clone(); - assert_eq!(list[0].1, "calligraphic"); - // The first tile, clicked once: it becomes current, no dialog. - let tile = pos2(36.0, 16.0); - at_time(&mut app, 1.0, clicks(tile, 1)); - assert!(app.ui.dialog.is_none()); - assert_eq!(brushes(&mut app).1.as_deref(), Some(first.as_str())); - // Double-clicked: Calligraphic Brush Options on that brush. - at_time(&mut app, 3.0, clicks(tile, 2)); - let d = app.ui.dialog.as_mut().expect("Brush Options opened"); - assert_eq!((d.kind.as_str(), d.str("name"), d.str("sizeMode")), (crate::dialogs::brush_options::KIND, first.clone(), "fixed".into())); - d.fields.insert("sizeMode".into(), json!("pressure")); - d.fields.insert("sizeVariation".into(), json!(2)); - d.fields.insert("name".into(), json!("Pressure Round")); - crate::dialogs::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - let def = app.run("brush.get", json!({"name": "Pressure Round"})).unwrap(); - assert_eq!((def["modes"].clone(), def["variation"][2].clone()), (json!(["fixed", "fixed", "pressure"]), json!(2.0))); - assert_eq!(brushes(&mut app).1.as_deref(), Some("Pressure Round"), "renamed, still current"); - } - - /// The New Brush button and the panel menu's New Brush… open New Brush, with nothing selected - /// too, and make no brush. - #[test] - fn the_new_brush_button_and_menu_item_open_new_brush() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - ctx.enable_accesskit(); - let before = names(&mut app); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); - let new = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some("New Brush")).expect("a New Brush button").1.center(); - click(&ctx, &mut app, new, egui::Modifiers::NONE, 1.0, show); - let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); - assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::new_brush::KIND)); - app.ui.dialog = None; - menu_click(&ctx, &mut app, "New Brush…", 2.0); - assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::new_brush::KIND)); - assert_eq!(names(&mut app), before); - } - - /// The rectangles `out` fills with the selected-row color. - fn lit(ctx: &egui::Context, out: &egui::FullOutput) -> Vec { - let t = Tokens::get(ctx); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Rect(r) if r.fill == t.row_selected => Some(r.rect), - _ => None, - }) - .collect() - } - - fn brush_of(app: &VectorcraftApp, id: NodeId) -> Option { - app.session.active().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|s| s.brush.clone()) - } - - fn names(app: &mut VectorcraftApp) -> Vec { - brushes(app).0.into_iter().map(|b| b.0).collect() - } - - /// Click the panel menu's item `label` (frames at `time`, `time + 0.5`). - fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) { - let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, time, menu); - let at = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some(label)).unwrap_or_else(|| panic!("no `{label}`")).1.center(); - click(ctx, app, at, egui::Modifiers::NONE, time + 0.5, menu); - } - - /// The first three brushes (the Calligraphic ones): a click on the first applies it to the - /// selected rectangle, and then a Shift-click on the third selects all three without applying - /// the third. - fn select_three(ctx: &egui::Context, app: &mut VectorcraftApp, rect: NodeId) -> (Vec, Vec) { - let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, 0.0, show); - let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect(); - let (list, _) = brushes(app); - assert!(list[..3].iter().all(|b| b.1 == "calligraphic"), "{list:?}"); - let three: Vec = list[..3].iter().map(|b| b.0.clone()).collect(); - click(ctx, app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show); - assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str())); - click(ctx, app, tiles[2].center(), egui::Modifiers::SHIFT, 2.0, show); - assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str()), "a Shift-click only selects"); - (three, tiles) - } - - /// Shift-click selects a range of brushes and Cmd/Ctrl-click adds or removes one; the panel - /// highlights them, and Duplicate Brush and Delete Brush act on all of them in one undo step. - #[test] - fn shift_and_command_clicks_select_brushes_for_delete_and_duplicate() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let rect = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); - let ctx = egui::Context::default(); - ctx.enable_accesskit(); - let (three, tiles) = select_three(&ctx, &mut app, rect); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show); - let lit_tiles = |out: &egui::FullOutput| -> Vec { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() }; - assert_eq!(lit_tiles(&out), [0, 1, 2]); - // Cmd/Ctrl-click takes the second out again. - click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::COMMAND, 3.0, show); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show); - assert_eq!(lit_tiles(&out), [0, 2]); - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - let before = (names(&mut app), undo(&app)); - menu_click(&ctx, &mut app, "Duplicate Brush", 4.0); - let copies = [format!("{} copy", three[0]), format!("{} copy", three[2])]; - assert_eq!(names(&mut app)[before.0.len()..], copies); - assert_eq!(undo(&app), before.1 + 1); - // The bottom bar's last button, Delete Brush. - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 5.0, show); - let trash = clickable(&out).last().unwrap().1.center(); - click(&ctx, &mut app, trash, egui::Modifiers::NONE, 6.0, show); - let left = names(&mut app); - assert!(!left.contains(&three[0]) && !left.contains(&three[2]) && left.contains(&three[1]), "{left:?}"); - assert!(copies.iter().all(|c| left.contains(c)), "{left:?}"); - assert_eq!(brush_of(&app, rect), None); - assert_eq!(undo(&app), before.1 + 2); - } - - /// Selecting other art ends the multi-selection: selecting the first art again does not bring - /// it back, and with a path stroked with another brush selected, the panel highlights that - /// brush and Delete Brush deletes it alone. - #[test] - fn selecting_other_art_ends_the_brush_multi_selection() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let rect = |app: &mut VectorcraftApp, x: f64| { - NodeId(app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()) - }; - let a = rect(&mut app, 10.0); - let ctx = egui::Context::default(); - ctx.enable_accesskit(); - let (three, tiles) = select_three(&ctx, &mut app, a); - let lit_tiles = |out: &egui::FullOutput| -> Vec { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() }; - // A path without a brush, then the first one again: the current brush is highlighted. - rect(&mut app, 60.0); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.0, show); - assert_eq!(lit_tiles(&out), [0]); - app.run("select.set", json!({"ids": [a.0]})).unwrap(); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show); - assert_eq!(lit_tiles(&out), [0]); - // A path stroked with the second brush. - let c = rect(&mut app, 110.0); - app.run("brush.apply", json!({"name": three[1]})).unwrap(); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 4.0, show); - assert_eq!(lit_tiles(&out), [1]); - menu_click(&ctx, &mut app, "Delete Brush", 5.0); - let left = names(&mut app); - assert!(left.contains(&three[0]) && !left.contains(&three[1]) && left.contains(&three[2]), "{left:?}"); - assert_eq!((brush_of(&app, a).as_deref(), brush_of(&app, c)), (Some(three[0].as_str()), None)); - } - - /// After other art is selected, a Shift-click selects the range from that art's brush, not from - /// the brush clicked before. - #[test] - fn a_shift_click_after_selecting_other_art_starts_from_its_brush() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); - let ctx = egui::Context::default(); - ctx.enable_accesskit(); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); - let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect(); - let (list, _) = brushes(&mut app); - click(&ctx, &mut app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show); - app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 40})).unwrap(); - app.run("brush.apply", json!({"name": list[2].0})).unwrap(); - click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::SHIFT, 2.0, show); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show); - assert_eq!(lit(&ctx, &out).iter().filter(|r| tiles.contains(r)).collect::>(), [&tiles[1], &tiles[2]]); - } - - /// Cmd/Ctrl-clicks can deselect every brush: none is highlighted, and Delete Brush and - /// Duplicate Brush are off. - #[test] - fn command_clicks_deselect_every_brush() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); - let ctx = egui::Context::default(); - ctx.enable_accesskit(); - let (_, tiles) = select_three(&ctx, &mut app, a); - for (i, tile) in tiles.iter().enumerate() { - click(&ctx, &mut app, tile.center(), egui::Modifiers::COMMAND, 3.0 + i as f64, show); - } - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 7.0, show); - assert!(!lit(&ctx, &out).iter().any(|r| tiles.contains(r))); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 8.0, menu); - let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; - for label in ["Duplicate Brush", "Delete Brush"] { - let item = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no `{label}`")); - assert!(item.1.is_disabled(), "{label}"); - } - } - - /// Select All Unused in the panel menu highlights every brush but the one the rectangle is - /// painted with, and Delete Brush then deletes all of them. - #[test] - fn select_all_unused_selects_the_brushes_no_stroke_paints_with() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let (list, _) = brushes(&mut app); - let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); - app.run("brush.apply", json!({"name": list[1].0})).unwrap(); - let ctx = egui::Context::default(); - ctx.enable_accesskit(); - menu_click(&ctx, &mut app, "Select All Unused", 1.0); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.0, show); - let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); - assert_eq!(tiles.len(), list.len()); - let on: Vec = (0..tiles.len()).filter(|i| lit(&ctx, &out).contains(&tiles[*i])).collect(); - assert_eq!(on, (0..tiles.len()).filter(|i| *i != 1).collect::>()); - menu_click(&ctx, &mut app, "Delete Brush", 3.0); - assert_eq!(names(&mut app), [list[1].0.clone()]); - assert_eq!(brush_of(&app, a), Some(list[1].0.clone())); - } - - #[test] - fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let id = app.run("shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 9, "points": 5})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - frame(&ctx, &mut app, vec![]); - let (before, _) = brushes(&mut app); - // Art dragged off the canvas and released over the list. - let over = pos2(100.0, 60.0); - egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)])); - frame(&ctx, &mut app, vec![egui::Event::PointerMoved(over), button(over, false)]); - // New Brush opens on Art for the dropped art, and OK twice makes the brush. - let d = app.ui.dialog.as_ref().expect("New Brush opened"); - assert_eq!((d.kind.as_str(), d.str("type"), d.fields.get("__ids")), (crate::dialogs::new_brush::KIND, "art".into(), Some(&json!([id])))); - assert_eq!(brushes(&mut app).0, before); - crate::dialogs::confirm(&mut app).unwrap(); - crate::dialogs::confirm(&mut app).unwrap(); - let (after, _) = brushes(&mut app); - assert_eq!(after.len(), before.len() + 1); - let new: Vec<_> = after.iter().filter(|b| !before.contains(b)).collect(); - assert!(matches!(new.as_slice(), [(_, ty)] if ty == "art"), "{new:?}"); - // Dragging the first tile out of the panel carries that brush. - frame(&ctx, &mut app, vec![]); - let tile = pos2(36.0, 16.0); - frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile), button(tile, true)]); - frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(40.0, 40.0))]); - frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(80.0, 80.0))]); - let drag = egui::DragAndDrop::payload::(&ctx); - assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}"); - } - - /// A brush dragged onto the left half of another brush of its type goes before it, with a bar - /// marking the slot while it is held there, as one undo step; dropped on a brush of another - /// type, it stays where it is and the status bar says why. - #[test] - fn a_brush_dragged_onto_another_of_its_type_moves_before_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); - let ctx = egui::Context::default(); - ctx.enable_accesskit(); - let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); - let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); - let (list, _) = brushes(&mut app); - let order: Vec = shown(&list, &[]).into_iter().map(str::to_string).collect(); - assert_eq!(order[..4], ["3 pt. Round", "6 pt. Flat", "10 pt. Oval", "Dots"]); - let accent = Tokens::get(&ctx).accent; - let bars = |out: &egui::FullOutput| -> Vec { - let lines = out.shapes.iter().filter_map(|c| match &c.shape { - egui::Shape::LineSegment { points, stroke } if stroke.color == accent => Some(points[0].x), - _ => None, - }); - lines.collect() - }; - // Press on a tile, drag it to `to`, hold it there for a frame (its output returned) and release. - let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2, time: f64| -> egui::FullOutput { - frame_with(&ctx, app, vec![egui::Event::PointerMoved(from), button(from, true)], egui::Modifiers::NONE, time, show); - frame_with(&ctx, app, vec![egui::Event::PointerMoved(from + vec2(20.0, 0.0))], egui::Modifiers::NONE, time + 0.1, show); - frame_with(&ctx, app, vec![egui::Event::PointerMoved(to)], egui::Modifiers::NONE, time + 0.2, show); - let held = frame_with(&ctx, app, vec![], egui::Modifiers::NONE, time + 0.3, show); - frame_with(&ctx, app, vec![button(to, false)], egui::Modifiers::NONE, time + 0.4, show); - held - }; - let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); - let steps = undo(&app); - let calligraphic = - |app: &mut VectorcraftApp| -> Vec { brushes(app).0.into_iter().filter(|b| b.1 == "calligraphic").map(|b| b.0).collect() }; - // "10 pt. Oval" onto the left half of "3 pt. Round". - let held = drag(&mut app, tiles[2].center(), tiles[0].center() - vec2(16.0, 0.0), 1.0); - assert_eq!(bars(&held), [tiles[0].left() - 1.5]); - assert_eq!(calligraphic(&mut app), ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]); - assert_eq!(undo(&app), steps + 1); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(calligraphic(&mut app), ["3 pt. Round", "6 pt. Flat", "10 pt. Oval"]); - // "3 pt. Round" onto "Dots", a scatter brush: no bar, and nothing moves. - let held = drag(&mut app, tiles[0].center(), tiles[3].center(), 3.0); - assert!(bars(&held).is_empty(), "{:?}", bars(&held)); - assert_eq!(brushes(&mut app).0, list); - assert_eq!(undo(&app), steps); - assert!(app.ui.status.contains("brushes move only within their type"), "{}", app.ui.status); - } - - /// Opaque pixels of brush definition `def`'s 80 × 40 pt preview with the resources of `doc`, - /// and those of them that are Pinstripes blue. - fn pixels(doc: &Document, def: &Value) -> (usize, usize) { - let preview = preview_doc(doc, def, 80.0, 40.0).expect("a preview"); - let img = vectorcraft_render::Renderer::new().render_region(&preview, vectorcraft_geom::Rect::new(0.0, 0.0, 80.0, 40.0), 2.0, false); - let painted: Vec<&[u8; 4]> = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 200).collect(); - let blue = painted.iter().filter(|p| p[2] > 170 && p[0] < 90 && (100..160).contains(&p[1])).count(); - (painted.len(), blue) - } - - /// A library brush's preview shows the pattern its art is filled with: it renders with the - /// library's resources, which a bare document doesn't have. - #[test] - fn library_brush_previews_show_the_patterns_their_art_uses() { - let s = Session::new(); - let (_, lib) = brushlib::library(&s, "art-ribbons").unwrap(); - let def = serde_json::to_value(lib.brush("Striped Ribbon").unwrap()).unwrap(); - let (painted, blue) = pixels(&lib.doc, &def); - assert!(painted > 0 && blue * 5 >= painted, "Pinstripes paints it: {painted} painted, {blue} blue"); - let (painted, blue) = pixels(&Document::new(10.0, 10.0), &def); - assert_eq!(blue, 0, "without the pattern: {painted} painted"); - // A square tile still shows a stroke. - let square = preview_doc(&lib.doc, &def, 40.0, 40.0).unwrap(); - let img = vectorcraft_render::Renderer::new().render_region(&square, vectorcraft_geom::Rect::new(0.0, 0.0, 40.0, 40.0), 2.0, false); - assert!(img.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200)); - } - - #[test] - fn a_library_brush_dropped_on_the_panel_is_added() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - frame(&ctx, &mut app, vec![]); - let at = pos2(120.0, 40.0); - egui::DragAndDrop::set_payload(&ctx, PanelDrag::LibraryBrush { library: "pattern-borders".into(), name: "Beads".into() }); - frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at)]); - frame(&ctx, &mut app, vec![button(at, false)]); - assert!(vectorcraft_brush::find(&app.session.active().unwrap().doc, "Beads").is_some()); - } - - /// A preview is cached by its brush and the patterns and symbols its art shows: adding other - /// brushes (and the patterns and symbols they bring) renders only the new one, every preview - /// is drawn in the first frame, and changing a pattern a brush shows renders that brush again. - /// Symbol thumbnails are keyed the same way. - #[test] - fn previews_render_again_only_when_what_they_show_changes() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - let count = |app: &mut VectorcraftApp| brushes(app).0.len(); - let n = count(&mut app); - assert_eq!(counted(&ctx, &mut app), (n, n), "the first frame renders and draws every preview"); - assert_eq!(counted(&ctx, &mut app), (0, n), "then they are cached"); - // Striped Ribbon brings its pattern, Sparkles its symbol: only they render. - app.run("brush.addFromLibrary", json!({"library": "art-ribbons", "name": "Striped Ribbon"})).unwrap(); - assert_eq!(counted(&ctx, &mut app), (1, n + 1)); - let symbol_key = - |app: &VectorcraftApp, name: &str| super::super::symbols::document_symbols(app.session.active().unwrap()).key(name).unwrap_or(0); - let sparkle_before = symbol_key(&app, "Sparkle"); - app.run("brush.addFromLibrary", json!({"library": "scatter-shapes", "name": "Sparkles"})).unwrap(); - assert_eq!(counted(&ctx, &mut app), (1, n + 2)); - // A symbol added beside Sparkle leaves its thumbnail key alone. - let sparkle = symbol_key(&app, "Sparkle"); - assert_ne!(sparkle, sparkle_before, "0 before Sparkle came in"); - app.run("symbol.addFromLibrary", json!({"library": "basic-shapes", "name": "Star"})).unwrap(); - assert_eq!(symbol_key(&app, "Sparkle"), sparkle); - assert_eq!(counted(&ctx, &mut app), (0, n + 2)); - // A new Pinstripes tile: Striped Ribbon renders again, the others don't. - app.session - .edit("Pattern", |d, _| { - let p = d.patterns.iter_mut().find(|p| p.name == "Pinstripes").unwrap(); - let dot = Node::path( - NodeId(0), - vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 3.0, 3.0)), - Appearance::default_art(), - ); - p.art = vec![std::sync::Arc::new(dot)]; - Ok(()) - }) - .unwrap(); - assert_eq!(counted(&ctx, &mut app), (1, n + 2)); - } - - fn keyed() -> usize { - counters::KEYED.with(|k| k.replace(0)) - } - - /// The Brushes panel serialises and hashes the brushes of a document version once: frames - /// after the first, of it or of it again after another document was active, do neither; an - /// edit, an undo and a redo each work them out once again and render no preview again. Brush - /// Options works its preview's key out again only when its definition or the document changes. - #[test] - fn frames_after_the_first_serialise_and_hash_no_brush() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let ctx = egui::Context::default(); - let n = vectorcraft_brush::library(&app.session.active().unwrap().doc).len(); - keyed(); - assert_eq!(counted(&ctx, &mut app), (n, n)); - assert_eq!(keyed(), n, "the first frame works each brush out"); - for _ in 0..3 { - assert_eq!(counted(&ctx, &mut app), (0, n)); - } - assert_eq!(keyed(), 0, "later frames serialise and hash none"); - // Another document, then this one again. - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - frame(&ctx, &mut app, vec![]); - keyed(); - app.run("document.activate", json!({"index": 0})).unwrap(); - assert_eq!(counted(&ctx, &mut app), (0, n)); - assert_eq!(keyed(), 0, "the document's brushes are still known"); - // An edit to something else: the brushes are worked out again, no preview renders. - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); - assert_eq!(counted(&ctx, &mut app), (0, n)); - assert_eq!(keyed(), n); - for cmd in ["edit.undo", "edit.redo"] { - app.run(cmd, json!({})).unwrap(); - assert_eq!(counted(&ctx, &mut app), (0, n), "{cmd}"); - frame(&ctx, &mut app, vec![]); - assert!(keyed() <= n, "{cmd}: once at most"); - } - // Brush Options' preview of a definition. - let (_, lib) = brushlib::library(&app.session, "art-ribbons").unwrap(); - let mut def = serde_json::to_value(lib.brush("Striped Ribbon").unwrap()).unwrap(); - let options = |app: &VectorcraftApp, def: &Value| { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 100.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| definition_chip(ui, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 40.0)), app.session.active(), def)); - out.textures_delta.clear(); - }; - options(&app, &def); - assert_eq!(keyed(), 1); - for _ in 0..3 { - options(&app, &def); - } - assert_eq!(keyed(), 0, "the same definition in the same document"); - def["width"] = json!(150.0); - options(&app, &def); - options(&app, &def); - assert_eq!(keyed(), 1, "a field changed"); - app.run("shape.rectangle", json!({"x": 40, "y": 10, "width": 20, "height": 20})).unwrap(); - options(&app, &def); - options(&app, &def); - assert_eq!(keyed(), 1, "the document changed"); - } - - /// One headless frame of the Symbols panel → thumbnails rendered in it. - fn symbols_rendered(ctx: &egui::Context, app: &mut VectorcraftApp) -> usize { - use super::super::symbols::{self, counters::RENDERED}; - RENDERED.with(|c| c.set(0)); - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| symbols::show(app, ui)); - out.textures_delta.clear(); - RENDERED.with(std::cell::Cell::get) - } - - /// A 4 × 4 PNG of colour `rgb`. - fn png(rgb: [u8; 3]) -> Vec { - let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([rgb[0], rgb[1], rgb[2], 255])); - let mut out = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); - out - } - - /// Brush previews and symbol thumbnails render again when a swatch, an image, or a pattern's - /// tile type, overlap or spacing their art uses changes (only those that use it), and not when - /// one they don't use does. - #[test] - fn previews_follow_the_swatches_images_and_pattern_options_their_art_uses() { - use vectorcraft_doc::pattern::{Overlap, TileType}; - use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, Symbol}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - // Striped Ribbon brings Pinstripes. - app.run("brush.addFromLibrary", json!({"library": "art-ribbons", "name": "Striped Ribbon"})).unwrap(); - let (_, lib) = brushlib::library(&app.session, "art-ribbons").unwrap(); - let wave = lib.brush("Wave").unwrap().clone(); - let striped = lib.brush("Striped Ribbon").unwrap().art()[0].clone(); - let ink = Paint::Solid { color: Color::BLACK, swatch: Some("Ink".into()), tint: 1.0 }; - let inked = Node::path( - NodeId(0), - vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 20.0, 10.0)), - Appearance::basic(ink, Paint::None, 0.0), - ); - let im = ImageObject { - key: "pic".into(), - width: 4, - height: 4, - xf: vectorcraft_geom::Affine::scale(5.0), - link: None, - placement: Default::default(), - pixels: None, - }; - let pictured = Node::new(NodeId(0), NodeKind::Image(Box::new(im))); - // Filled with the gradient swatch Fade. - let fade = |c: Color| { - use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; - GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, c)])) - }; - let mut faded_paint = fade(Color::BLACK); - faded_paint.swatch = Some("Fade".into()); - let faded = Node::path( - NodeId(0), - vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 20.0, 10.0)), - Appearance::basic(Paint::Gradient(Box::new(faded_paint)), Paint::None, 0.0), - ); - app.session - .edit("Resources", |d, _| { - for name in ["Ink", "Other"] { - d.swatches.push(vectorcraft_color::Swatch { name: name.into(), paint: Paint::solid(Color::BLACK), global: true, spot: false }); - } - let paint = Paint::Gradient(Box::new(fade(Color::BLACK))); - d.swatches.push(vectorcraft_color::Swatch { name: "Fade".into(), paint, global: false, spot: false }); - d.images.insert("pic".into(), ImageBlob::png(png([255, 0, 0]))); - let mut brushes = vectorcraft_brush::library(d); - for (name, art) in [("Inked", &inked), ("Pictured", &pictured), ("Faded", &faded)] { - let mut b = wave.clone(); - b.name = name.into(); - if let vectorcraft_brush::BrushKind::Art(a) = &mut b.kind { - a.art = art.clone(); - } - brushes.push(b); - } - vectorcraft_brush::store(d, &brushes); - for (name, art) in [("Ink Symbol", &inked), ("Picture Symbol", &pictured), ("Stripes Symbol", &striped), ("Fade Symbol", &faded)] { - d.symbols.push(Symbol { name: name.into(), art: Arc::new(art.clone()) }); - } - Ok(()) - }) - .unwrap(); - let ctx = egui::Context::default(); - let n = brushes(&mut app).0.len(); - assert_eq!((counted(&ctx, &mut app).0, symbols_rendered(&ctx, &mut app)), (n, 4)); - assert_eq!((counted(&ctx, &mut app).0, symbols_rendered(&ctx, &mut app)), (0, 0)); - let change = |app: &mut VectorcraftApp, what: &str, f: &dyn Fn(&mut Document)| { - app.session - .edit("Change", |d, _| { - f(d); - Ok(()) - }) - .unwrap(); - (counted(&ctx, app).0, symbols_rendered(&ctx, app), what.to_string()) - }; - let swatch = - |name: &'static str| move |d: &mut Document| d.swatches.iter_mut().find(|w| w.name == name).unwrap().paint = Paint::solid(Color::WHITE); - fn pinstripes(d: &mut Document) -> &mut vectorcraft_doc::pattern::PatternDef { - d.patterns.iter_mut().find(|p| p.name == "Pinstripes").unwrap() - } - type Case<'a> = (&'a str, (usize, usize), &'a dyn Fn(&mut Document)); - let cases: [Case; 8] = [ - ("Ink", (1, 1), &swatch("Ink")), - ("Other", (0, 0), &swatch("Other")), - ("Fade", (1, 1), &|d| d.swatches.iter_mut().find(|w| w.name == "Fade").unwrap().paint = Paint::Gradient(Box::new(fade(Color::WHITE)))), - ("pic", (1, 1), &|d| { - d.images.insert("pic".into(), ImageBlob::png(png([0, 0, 255]))); - }), - ("another image", (0, 0), &|d| { - d.images.insert("pic2".into(), ImageBlob::png(png([0, 255, 0]))); - }), - ("tile type", (1, 1), &|d| pinstripes(d).tile_type = TileType::BrickByRow { offset: 0.5 }), - ("overlap", (1, 1), &|d| pinstripes(d).overlap = Overlap { right_in_front: true, bottom_in_front: true }), - ("spacing", (1, 1), &|d| pinstripes(d).h_spacing = 4.0), - ]; - for (what, (brushes, symbols), f) in cases { - assert_eq!(change(&mut app, what, f), (brushes, symbols, what.to_string())); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/brushes/tests.rs b/crates/ui-egui/src/panels/brushes/tests.rs new file mode 100644 index 000000000..35fa11aaa --- /dev/null +++ b/crates/ui-egui/src/panels/brushes/tests.rs @@ -0,0 +1,655 @@ +use egui::{Pos2, Rect, pos2}; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +thread_local! { + /// Stroke previews rendered (cache misses), and drawn, on this test thread. + pub(super) static BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub(super) static DRAWN: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// One frame of the panel → (previews rendered, previews drawn) in it. +fn counted(ctx: &egui::Context, app: &mut VectorcraftApp) -> (usize, usize) { + BUILDS.with(|b| b.set(0)); + DRAWN.with(|d| d.set(0)); + frame(ctx, app, vec![]); + (BUILDS.with(std::cell::Cell::get), DRAWN.with(std::cell::Cell::get)) +} + +use super::*; + +/// One headless frame of the panel, 236 pt wide as in the dock. +fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); +} + +fn button(at: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } +} + +/// One frame of `draw` at `time` with `modifiers` held, its output kept. +fn frame_with( + ctx: &egui::Context, + app: &mut VectorcraftApp, + events: Vec, + modifiers: egui::Modifiers, + time: f64, + draw: fn(&mut VectorcraftApp, &mut Ui), +) -> egui::FullOutput { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))); + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + let raw = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| draw(app, ui)); + out.textures_delta.clear(); + out +} + +/// A click at `at` with `modifiers` held, in a frame of `draw` at `time`. +fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, modifiers: egui::Modifiers, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) { + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; + frame_with(ctx, app, vec![egui::Event::PointerMoved(at), press(true), press(false)], modifiers, time, draw); +} + +/// The accessibility nodes of `out` that take a click, with their rectangles, top to bottom +/// and then left to right. +fn clickable(out: &egui::FullOutput) -> Vec<(Option, Rect)> { + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let mut v: Vec<(Option, Rect)> = nodes + .iter() + .filter(|(_, n)| n.supports_action(egui::accesskit::Action::Click)) + .filter_map(|(_, n)| { + let b = n.bounds()?; + Some((n.label().map(|l| l.trim().to_string()), Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)))) + }) + .collect(); + v.sort_by(|a, b| a.1.min.y.total_cmp(&b.1.min.y).then(a.1.min.x.total_cmp(&b.1.min.x))); + v +} + +/// #852: double-clicking a brush opens its Brush Options; OK changes the brush (and strokes +/// painted with it). A single click only applies the brush. +#[test] +fn double_clicking_a_brush_opens_its_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + let clicks = |at: Pos2, n: usize| -> Vec { + std::iter::once(egui::Event::PointerMoved(at)).chain((0..n).flat_map(|_| [button(at, true), button(at, false)])).collect() + }; + let at_time = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear(); + }; + at_time(&mut app, 0.0, vec![]); + let (list, _) = brushes(&mut app); + let first = list[0].0.clone(); + assert_eq!(list[0].1, "calligraphic"); + // The first tile, clicked once: it becomes current, no dialog. + let tile = pos2(36.0, 16.0); + at_time(&mut app, 1.0, clicks(tile, 1)); + assert!(app.ui.dialog.is_none()); + assert_eq!(brushes(&mut app).1.as_deref(), Some(first.as_str())); + // Double-clicked: Calligraphic Brush Options on that brush. + at_time(&mut app, 3.0, clicks(tile, 2)); + let d = app.ui.dialog.as_mut().expect("Brush Options opened"); + assert_eq!((d.kind.as_str(), d.str("name"), d.str("sizeMode")), (crate::dialogs::brush_options::KIND, first.clone(), "fixed".into())); + d.fields.insert("sizeMode".into(), json!("pressure")); + d.fields.insert("sizeVariation".into(), json!(2)); + d.fields.insert("name".into(), json!("Pressure Round")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let def = app.run("brush.get", json!({"name": "Pressure Round"})).unwrap(); + assert_eq!((def["modes"].clone(), def["variation"][2].clone()), (json!(["fixed", "fixed", "pressure"]), json!(2.0))); + assert_eq!(brushes(&mut app).1.as_deref(), Some("Pressure Round"), "renamed, still current"); +} + +/// The New Brush button and the panel menu's New Brush… open New Brush, with nothing selected +/// too, and make no brush. +#[test] +fn the_new_brush_button_and_menu_item_open_new_brush() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let before = names(&mut app); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); + let new = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some("New Brush")).expect("a New Brush button").1.center(); + click(&ctx, &mut app, new, egui::Modifiers::NONE, 1.0, show); + let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); + assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::new_brush::KIND)); + app.ui.dialog = None; + menu_click(&ctx, &mut app, "New Brush…", 2.0); + assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::new_brush::KIND)); + assert_eq!(names(&mut app), before); +} + +/// The rectangles `out` fills with the selected-row color. +fn lit(ctx: &egui::Context, out: &egui::FullOutput) -> Vec { + let t = Tokens::get(ctx); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == t.row_selected => Some(r.rect), + _ => None, + }) + .collect() +} + +fn brush_of(app: &VectorcraftApp, id: NodeId) -> Option { + app.session.active().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|s| s.brush.clone()) +} + +fn names(app: &mut VectorcraftApp) -> Vec { + brushes(app).0.into_iter().map(|b| b.0).collect() +} + +/// Click the panel menu's item `label` (frames at `time`, `time + 0.5`). +fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) { + let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, time, menu); + let at = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some(label)).unwrap_or_else(|| panic!("no `{label}`")).1.center(); + click(ctx, app, at, egui::Modifiers::NONE, time + 0.5, menu); +} + +/// The first three brushes (the Calligraphic ones): a click on the first applies it to the +/// selected rectangle, and then a Shift-click on the third selects all three without applying +/// the third. +fn select_three(ctx: &egui::Context, app: &mut VectorcraftApp, rect: NodeId) -> (Vec, Vec) { + let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, 0.0, show); + let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect(); + let (list, _) = brushes(app); + assert!(list[..3].iter().all(|b| b.1 == "calligraphic"), "{list:?}"); + let three: Vec = list[..3].iter().map(|b| b.0.clone()).collect(); + click(ctx, app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show); + assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str())); + click(ctx, app, tiles[2].center(), egui::Modifiers::SHIFT, 2.0, show); + assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str()), "a Shift-click only selects"); + (three, tiles) +} + +/// Shift-click selects a range of brushes and Cmd/Ctrl-click adds or removes one; the panel +/// highlights them, and Duplicate Brush and Delete Brush act on all of them in one undo step. +#[test] +fn shift_and_command_clicks_select_brushes_for_delete_and_duplicate() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let rect = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let (three, tiles) = select_three(&ctx, &mut app, rect); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show); + let lit_tiles = |out: &egui::FullOutput| -> Vec { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() }; + assert_eq!(lit_tiles(&out), [0, 1, 2]); + // Cmd/Ctrl-click takes the second out again. + click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::COMMAND, 3.0, show); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show); + assert_eq!(lit_tiles(&out), [0, 2]); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = (names(&mut app), undo(&app)); + menu_click(&ctx, &mut app, "Duplicate Brush", 4.0); + let copies = [format!("{} copy", three[0]), format!("{} copy", three[2])]; + assert_eq!(names(&mut app)[before.0.len()..], copies); + assert_eq!(undo(&app), before.1 + 1); + // The bottom bar's last button, Delete Brush. + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 5.0, show); + let trash = clickable(&out).last().unwrap().1.center(); + click(&ctx, &mut app, trash, egui::Modifiers::NONE, 6.0, show); + let left = names(&mut app); + assert!(!left.contains(&three[0]) && !left.contains(&three[2]) && left.contains(&three[1]), "{left:?}"); + assert!(copies.iter().all(|c| left.contains(c)), "{left:?}"); + assert_eq!(brush_of(&app, rect), None); + assert_eq!(undo(&app), before.1 + 2); +} + +/// Selecting other art ends the multi-selection: selecting the first art again does not bring +/// it back, and with a path stroked with another brush selected, the panel highlights that +/// brush and Delete Brush deletes it alone. +#[test] +fn selecting_other_art_ends_the_brush_multi_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let rect = |app: &mut VectorcraftApp, x: f64| { + NodeId(app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()) + }; + let a = rect(&mut app, 10.0); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let (three, tiles) = select_three(&ctx, &mut app, a); + let lit_tiles = |out: &egui::FullOutput| -> Vec { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() }; + // A path without a brush, then the first one again: the current brush is highlighted. + rect(&mut app, 60.0); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.0, show); + assert_eq!(lit_tiles(&out), [0]); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show); + assert_eq!(lit_tiles(&out), [0]); + // A path stroked with the second brush. + let c = rect(&mut app, 110.0); + app.run("brush.apply", json!({"name": three[1]})).unwrap(); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 4.0, show); + assert_eq!(lit_tiles(&out), [1]); + menu_click(&ctx, &mut app, "Delete Brush", 5.0); + let left = names(&mut app); + assert!(left.contains(&three[0]) && !left.contains(&three[1]) && left.contains(&three[2]), "{left:?}"); + assert_eq!((brush_of(&app, a).as_deref(), brush_of(&app, c)), (Some(three[0].as_str()), None)); +} + +/// After other art is selected, a Shift-click selects the range from that art's brush, not from +/// the brush clicked before. +#[test] +fn a_shift_click_after_selecting_other_art_starts_from_its_brush() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); + let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect(); + let (list, _) = brushes(&mut app); + click(&ctx, &mut app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show); + app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 40})).unwrap(); + app.run("brush.apply", json!({"name": list[2].0})).unwrap(); + click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::SHIFT, 2.0, show); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show); + assert_eq!(lit(&ctx, &out).iter().filter(|r| tiles.contains(r)).collect::>(), [&tiles[1], &tiles[2]]); +} + +/// Cmd/Ctrl-clicks can deselect every brush: none is highlighted, and Delete Brush and +/// Duplicate Brush are off. +#[test] +fn command_clicks_deselect_every_brush() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let (_, tiles) = select_three(&ctx, &mut app, a); + for (i, tile) in tiles.iter().enumerate() { + click(&ctx, &mut app, tile.center(), egui::Modifiers::COMMAND, 3.0 + i as f64, show); + } + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 7.0, show); + assert!(!lit(&ctx, &out).iter().any(|r| tiles.contains(r))); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 8.0, menu); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + for label in ["Duplicate Brush", "Delete Brush"] { + let item = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no `{label}`")); + assert!(item.1.is_disabled(), "{label}"); + } +} + +/// Select All Unused in the panel menu highlights every brush but the one the rectangle is +/// painted with, and Delete Brush then deletes all of them. +#[test] +fn select_all_unused_selects_the_brushes_no_stroke_paints_with() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let (list, _) = brushes(&mut app); + let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); + app.run("brush.apply", json!({"name": list[1].0})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + menu_click(&ctx, &mut app, "Select All Unused", 1.0); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.0, show); + let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); + assert_eq!(tiles.len(), list.len()); + let on: Vec = (0..tiles.len()).filter(|i| lit(&ctx, &out).contains(&tiles[*i])).collect(); + assert_eq!(on, (0..tiles.len()).filter(|i| *i != 1).collect::>()); + menu_click(&ctx, &mut app, "Delete Brush", 3.0); + assert_eq!(names(&mut app), [list[1].0.clone()]); + assert_eq!(brush_of(&app, a), Some(list[1].0.clone())); +} + +#[test] +fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = app.run("shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 9, "points": 5})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + let (before, _) = brushes(&mut app); + // Art dragged off the canvas and released over the list. + let over = pos2(100.0, 60.0); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)])); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(over), button(over, false)]); + // New Brush opens on Art for the dropped art, and OK twice makes the brush. + let d = app.ui.dialog.as_ref().expect("New Brush opened"); + assert_eq!((d.kind.as_str(), d.str("type"), d.fields.get("__ids")), (crate::dialogs::new_brush::KIND, "art".into(), Some(&json!([id])))); + assert_eq!(brushes(&mut app).0, before); + crate::dialogs::confirm(&mut app).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + let (after, _) = brushes(&mut app); + assert_eq!(after.len(), before.len() + 1); + let new: Vec<_> = after.iter().filter(|b| !before.contains(b)).collect(); + assert!(matches!(new.as_slice(), [(_, ty)] if ty == "art"), "{new:?}"); + // Dragging the first tile out of the panel carries that brush. + frame(&ctx, &mut app, vec![]); + let tile = pos2(36.0, 16.0); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile), button(tile, true)]); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(40.0, 40.0))]); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(80.0, 80.0))]); + let drag = egui::DragAndDrop::payload::(&ctx); + assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}"); +} + +/// A brush dragged onto the left half of another brush of its type goes before it, with a bar +/// marking the slot while it is held there, as one undo step; dropped on a brush of another +/// type, it stays where it is and the status bar says why. +#[test] +fn a_brush_dragged_onto_another_of_its_type_moves_before_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); + let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); + let (list, _) = brushes(&mut app); + let order: Vec = shown(&list, &[]).into_iter().map(str::to_string).collect(); + assert_eq!(order[..4], ["3 pt. Round", "6 pt. Flat", "10 pt. Oval", "Dots"]); + let accent = Tokens::get(&ctx).accent; + let bars = |out: &egui::FullOutput| -> Vec { + let lines = out.shapes.iter().filter_map(|c| match &c.shape { + egui::Shape::LineSegment { points, stroke } if stroke.color == accent => Some(points[0].x), + _ => None, + }); + lines.collect() + }; + // Press on a tile, drag it to `to`, hold it there for a frame (its output returned) and release. + let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2, time: f64| -> egui::FullOutput { + frame_with(&ctx, app, vec![egui::Event::PointerMoved(from), button(from, true)], egui::Modifiers::NONE, time, show); + frame_with(&ctx, app, vec![egui::Event::PointerMoved(from + vec2(20.0, 0.0))], egui::Modifiers::NONE, time + 0.1, show); + frame_with(&ctx, app, vec![egui::Event::PointerMoved(to)], egui::Modifiers::NONE, time + 0.2, show); + let held = frame_with(&ctx, app, vec![], egui::Modifiers::NONE, time + 0.3, show); + frame_with(&ctx, app, vec![button(to, false)], egui::Modifiers::NONE, time + 0.4, show); + held + }; + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let steps = undo(&app); + let calligraphic = + |app: &mut VectorcraftApp| -> Vec { brushes(app).0.into_iter().filter(|b| b.1 == "calligraphic").map(|b| b.0).collect() }; + // "10 pt. Oval" onto the left half of "3 pt. Round". + let held = drag(&mut app, tiles[2].center(), tiles[0].center() - vec2(16.0, 0.0), 1.0); + assert_eq!(bars(&held), [tiles[0].left() - 1.5]); + assert_eq!(calligraphic(&mut app), ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]); + assert_eq!(undo(&app), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(calligraphic(&mut app), ["3 pt. Round", "6 pt. Flat", "10 pt. Oval"]); + // "3 pt. Round" onto "Dots", a scatter brush: no bar, and nothing moves. + let held = drag(&mut app, tiles[0].center(), tiles[3].center(), 3.0); + assert!(bars(&held).is_empty(), "{:?}", bars(&held)); + assert_eq!(brushes(&mut app).0, list); + assert_eq!(undo(&app), steps); + assert!(app.ui.status.contains("brushes move only within their type"), "{}", app.ui.status); +} + +/// Opaque pixels of brush definition `def`'s 80 × 40 pt preview with the resources of `doc`, +/// and those of them that are Pinstripes blue. +fn pixels(doc: &Document, def: &Value) -> (usize, usize) { + let preview = preview_doc(doc, def, 80.0, 40.0).expect("a preview"); + let img = vectorcraft_render::Renderer::new().render_region(&preview, vectorcraft_geom::Rect::new(0.0, 0.0, 80.0, 40.0), 2.0, false); + let painted: Vec<&[u8; 4]> = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 200).collect(); + let blue = painted.iter().filter(|p| p[2] > 170 && p[0] < 90 && (100..160).contains(&p[1])).count(); + (painted.len(), blue) +} + +/// A library brush's preview shows the pattern its art is filled with: it renders with the +/// library's resources, which a bare document doesn't have. +#[test] +fn library_brush_previews_show_the_patterns_their_art_uses() { + let s = Session::new(); + let (_, lib) = brushlib::library(&s, "art-ribbons").unwrap(); + let def = serde_json::to_value(lib.brush("Striped Ribbon").unwrap()).unwrap(); + let (painted, blue) = pixels(&lib.doc, &def); + assert!(painted > 0 && blue * 5 >= painted, "Pinstripes paints it: {painted} painted, {blue} blue"); + let (painted, blue) = pixels(&Document::new(10.0, 10.0), &def); + assert_eq!(blue, 0, "without the pattern: {painted} painted"); + // A square tile still shows a stroke. + let square = preview_doc(&lib.doc, &def, 40.0, 40.0).unwrap(); + let img = vectorcraft_render::Renderer::new().render_region(&square, vectorcraft_geom::Rect::new(0.0, 0.0, 40.0, 40.0), 2.0, false); + assert!(img.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200)); +} + +#[test] +fn a_library_brush_dropped_on_the_panel_is_added() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + let at = pos2(120.0, 40.0); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::LibraryBrush { library: "pattern-borders".into(), name: "Beads".into() }); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at)]); + frame(&ctx, &mut app, vec![button(at, false)]); + assert!(vectorcraft_brush::find(&app.session.active().unwrap().doc, "Beads").is_some()); +} + +/// A preview is cached by its brush and the patterns and symbols its art shows: adding other +/// brushes (and the patterns and symbols they bring) renders only the new one, every preview +/// is drawn in the first frame, and changing a pattern a brush shows renders that brush again. +/// Symbol thumbnails are keyed the same way. +#[test] +fn previews_render_again_only_when_what_they_show_changes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + let count = |app: &mut VectorcraftApp| brushes(app).0.len(); + let n = count(&mut app); + assert_eq!(counted(&ctx, &mut app), (n, n), "the first frame renders and draws every preview"); + assert_eq!(counted(&ctx, &mut app), (0, n), "then they are cached"); + // Striped Ribbon brings its pattern, Sparkles its symbol: only they render. + app.run("brush.addFromLibrary", json!({"library": "art-ribbons", "name": "Striped Ribbon"})).unwrap(); + assert_eq!(counted(&ctx, &mut app), (1, n + 1)); + let symbol_key = + |app: &VectorcraftApp, name: &str| super::super::symbols::document_symbols(app.session.active().unwrap()).key(name).unwrap_or(0); + let sparkle_before = symbol_key(&app, "Sparkle"); + app.run("brush.addFromLibrary", json!({"library": "scatter-shapes", "name": "Sparkles"})).unwrap(); + assert_eq!(counted(&ctx, &mut app), (1, n + 2)); + // A symbol added beside Sparkle leaves its thumbnail key alone. + let sparkle = symbol_key(&app, "Sparkle"); + assert_ne!(sparkle, sparkle_before, "0 before Sparkle came in"); + app.run("symbol.addFromLibrary", json!({"library": "basic-shapes", "name": "Star"})).unwrap(); + assert_eq!(symbol_key(&app, "Sparkle"), sparkle); + assert_eq!(counted(&ctx, &mut app), (0, n + 2)); + // A new Pinstripes tile: Striped Ribbon renders again, the others don't. + app.session + .edit("Pattern", |d, _| { + let p = d.patterns.iter_mut().find(|p| p.name == "Pinstripes").unwrap(); + let dot = Node::path( + NodeId(0), + vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 3.0, 3.0)), + Appearance::default_art(), + ); + p.art = vec![std::sync::Arc::new(dot)]; + Ok(()) + }) + .unwrap(); + assert_eq!(counted(&ctx, &mut app), (1, n + 2)); +} + +fn keyed() -> usize { + counters::KEYED.with(|k| k.replace(0)) +} + +/// The Brushes panel serialises and hashes the brushes of a document version once: frames +/// after the first, of it or of it again after another document was active, do neither; an +/// edit, an undo and a redo each work them out once again and render no preview again. Brush +/// Options works its preview's key out again only when its definition or the document changes. +#[test] +fn frames_after_the_first_serialise_and_hash_no_brush() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + let n = vectorcraft_brush::library(&app.session.active().unwrap().doc).len(); + keyed(); + assert_eq!(counted(&ctx, &mut app), (n, n)); + assert_eq!(keyed(), n, "the first frame works each brush out"); + for _ in 0..3 { + assert_eq!(counted(&ctx, &mut app), (0, n)); + } + assert_eq!(keyed(), 0, "later frames serialise and hash none"); + // Another document, then this one again. + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + frame(&ctx, &mut app, vec![]); + keyed(); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert_eq!(counted(&ctx, &mut app), (0, n)); + assert_eq!(keyed(), 0, "the document's brushes are still known"); + // An edit to something else: the brushes are worked out again, no preview renders. + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + assert_eq!(counted(&ctx, &mut app), (0, n)); + assert_eq!(keyed(), n); + for cmd in ["edit.undo", "edit.redo"] { + app.run(cmd, json!({})).unwrap(); + assert_eq!(counted(&ctx, &mut app), (0, n), "{cmd}"); + frame(&ctx, &mut app, vec![]); + assert!(keyed() <= n, "{cmd}: once at most"); + } + // Brush Options' preview of a definition. + let (_, lib) = brushlib::library(&app.session, "art-ribbons").unwrap(); + let mut def = serde_json::to_value(lib.brush("Striped Ribbon").unwrap()).unwrap(); + let options = |app: &VectorcraftApp, def: &Value| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 100.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| definition_chip(ui, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 40.0)), app.session.active(), def)); + out.textures_delta.clear(); + }; + options(&app, &def); + assert_eq!(keyed(), 1); + for _ in 0..3 { + options(&app, &def); + } + assert_eq!(keyed(), 0, "the same definition in the same document"); + def["width"] = json!(150.0); + options(&app, &def); + options(&app, &def); + assert_eq!(keyed(), 1, "a field changed"); + app.run("shape.rectangle", json!({"x": 40, "y": 10, "width": 20, "height": 20})).unwrap(); + options(&app, &def); + options(&app, &def); + assert_eq!(keyed(), 1, "the document changed"); +} + +/// One headless frame of the Symbols panel → thumbnails rendered in it. +fn symbols_rendered(ctx: &egui::Context, app: &mut VectorcraftApp) -> usize { + use super::super::symbols::{self, counters::RENDERED}; + RENDERED.with(|c| c.set(0)); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| symbols::show(app, ui)); + out.textures_delta.clear(); + RENDERED.with(std::cell::Cell::get) +} + +/// A 4 × 4 PNG of colour `rgb`. +fn png(rgb: [u8; 3]) -> Vec { + let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([rgb[0], rgb[1], rgb[2], 255])); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// Brush previews and symbol thumbnails render again when a swatch, an image, or a pattern's +/// tile type, overlap or spacing their art uses changes (only those that use it), and not when +/// one they don't use does. +#[test] +fn previews_follow_the_swatches_images_and_pattern_options_their_art_uses() { + use vectorcraft_doc::pattern::{Overlap, TileType}; + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, Symbol}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + // Striped Ribbon brings Pinstripes. + app.run("brush.addFromLibrary", json!({"library": "art-ribbons", "name": "Striped Ribbon"})).unwrap(); + let (_, lib) = brushlib::library(&app.session, "art-ribbons").unwrap(); + let wave = lib.brush("Wave").unwrap().clone(); + let striped = lib.brush("Striped Ribbon").unwrap().art()[0].clone(); + let ink = Paint::Solid { color: Color::BLACK, swatch: Some("Ink".into()), tint: 1.0 }; + let inked = Node::path( + NodeId(0), + vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 20.0, 10.0)), + Appearance::basic(ink, Paint::None, 0.0), + ); + let im = ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let pictured = Node::new(NodeId(0), NodeKind::Image(Box::new(im))); + // Filled with the gradient swatch Fade. + let fade = |c: Color| { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, c)])) + }; + let mut faded_paint = fade(Color::BLACK); + faded_paint.swatch = Some("Fade".into()); + let faded = Node::path( + NodeId(0), + vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 20.0, 10.0)), + Appearance::basic(Paint::Gradient(Box::new(faded_paint)), Paint::None, 0.0), + ); + app.session + .edit("Resources", |d, _| { + for name in ["Ink", "Other"] { + d.swatches.push(vectorcraft_color::Swatch { name: name.into(), paint: Paint::solid(Color::BLACK), global: true, spot: false }); + } + let paint = Paint::Gradient(Box::new(fade(Color::BLACK))); + d.swatches.push(vectorcraft_color::Swatch { name: "Fade".into(), paint, global: false, spot: false }); + d.images.insert("pic".into(), ImageBlob::png(png([255, 0, 0]))); + let mut brushes = vectorcraft_brush::library(d); + for (name, art) in [("Inked", &inked), ("Pictured", &pictured), ("Faded", &faded)] { + let mut b = wave.clone(); + b.name = name.into(); + if let vectorcraft_brush::BrushKind::Art(a) = &mut b.kind { + a.art = art.clone(); + } + brushes.push(b); + } + vectorcraft_brush::store(d, &brushes); + for (name, art) in [("Ink Symbol", &inked), ("Picture Symbol", &pictured), ("Stripes Symbol", &striped), ("Fade Symbol", &faded)] { + d.symbols.push(Symbol { name: name.into(), art: Arc::new(art.clone()) }); + } + Ok(()) + }) + .unwrap(); + let ctx = egui::Context::default(); + let n = brushes(&mut app).0.len(); + assert_eq!((counted(&ctx, &mut app).0, symbols_rendered(&ctx, &mut app)), (n, 4)); + assert_eq!((counted(&ctx, &mut app).0, symbols_rendered(&ctx, &mut app)), (0, 0)); + let change = |app: &mut VectorcraftApp, what: &str, f: &dyn Fn(&mut Document)| { + app.session + .edit("Change", |d, _| { + f(d); + Ok(()) + }) + .unwrap(); + (counted(&ctx, app).0, symbols_rendered(&ctx, app), what.to_string()) + }; + let swatch = + |name: &'static str| move |d: &mut Document| d.swatches.iter_mut().find(|w| w.name == name).unwrap().paint = Paint::solid(Color::WHITE); + fn pinstripes(d: &mut Document) -> &mut vectorcraft_doc::pattern::PatternDef { + d.patterns.iter_mut().find(|p| p.name == "Pinstripes").unwrap() + } + type Case<'a> = (&'a str, (usize, usize), &'a dyn Fn(&mut Document)); + let cases: [Case; 8] = [ + ("Ink", (1, 1), &swatch("Ink")), + ("Other", (0, 0), &swatch("Other")), + ("Fade", (1, 1), &|d| d.swatches.iter_mut().find(|w| w.name == "Fade").unwrap().paint = Paint::Gradient(Box::new(fade(Color::WHITE)))), + ("pic", (1, 1), &|d| { + d.images.insert("pic".into(), ImageBlob::png(png([0, 0, 255]))); + }), + ("another image", (0, 0), &|d| { + d.images.insert("pic2".into(), ImageBlob::png(png([0, 255, 0]))); + }), + ("tile type", (1, 1), &|d| pinstripes(d).tile_type = TileType::BrickByRow { offset: 0.5 }), + ("overlap", (1, 1), &|d| pinstripes(d).overlap = Overlap { right_in_front: true, bottom_in_front: true }), + ("spacing", (1, 1), &|d| pinstripes(d).h_spacing = 4.0), + ]; + for (what, (brushes, symbols), f) in cases { + assert_eq!(change(&mut app, what, f), (brushes, symbols, what.to_string())); + } +} From 4d174fd4a690505f02db9bb43604267b2a658b1a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:52 +0000 Subject: [PATCH 039/199] refactor(ui-egui): move panels/character.rs tests to panels/character/tests.rs --- crates/ui-egui/src/panels/character.rs | 635 +------------------ crates/ui-egui/src/panels/character/tests.rs | 632 ++++++++++++++++++ 2 files changed, 633 insertions(+), 634 deletions(-) create mode 100644 crates/ui-egui/src/panels/character/tests.rs diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 40f4de7ad..3eddf8258 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -948,637 +948,4 @@ fn reset_panel(app: &mut VectorcraftApp) { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_engine::Session; - - #[test] - fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); - app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap(); - let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap(); - app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap(); - for (actual, expected) in [ - (shared_effective(&app, |s| s.size), 7.0), - (shared_effective(&app, CharStyle::effective_leading), 9.0), - (shared_effective(&app, |s| s.h_scale), 130.0), - (shared_effective(&app, |s| s.baseline_shift), 3.0), - ] { - assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8); - } - } - - /// A final empty paragraph counts: with the object selected, its Auto Leading differing from - /// the paragraph before it shows Leading as mixed. - #[test] - fn leading_field_counts_the_final_empty_paragraph() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\n", "size": 10})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9); - app.run("text.setFormat", json!({"autoLeading": 200, "start": 4, "end": 4})).unwrap(); - assert_eq!(shared_leading(&app), None, "the empty last paragraph differs"); - } - - /// Auto leading shows the leading the paragraph's Justification › Auto Leading sets. - #[test] - fn leading_field_shows_the_paragraph_auto_leading() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\nTwo", "size": 10})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9); - app.run("text.setFormat", json!({"autoLeading": 200, "start": 5, "end": 5})).unwrap(); - assert_eq!(shared_leading(&app), None, "the paragraphs differ"); - assert_eq!(shared(&app, |s| s.leading.is_none()), Some(true), "still Auto"); - app.run("text.setFormat", json!({"autoLeading": 200})).unwrap(); - assert!((shared_leading(&app).unwrap() - 20.0).abs() < 1e-9); - app.run("text.setStyle", json!({"leading": 15})).unwrap(); - assert!((shared_leading(&app).unwrap() - 15.0).abs() < 1e-9, "a set leading is its own"); - } - - #[test] - fn scaled_collapsed_text_keeps_nondimensional_controls_available() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap(); - app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); - assert!(text_style(&app).is_some()); - assert_eq!(shared(&app, |s| s.tracking), Some(0.0)); - assert_eq!(shared_effective(&app, |s| s.size), None); - app.run("text.setStyle", json!({"tracking": 40})).unwrap(); - app.run("text.setFormat", json!({"leftIndent": 4})).unwrap(); - assert_eq!(shared(&app, |s| s.tracking), Some(40.0)); - assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0); - let ctx = egui::Context::default(); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); - assert!(!texts.iter().any(|(s, _)| s == "No text selected")); - } - - #[test] - fn scaled_character_fields_show_document_sizes_and_mixed_group_values() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); - app.run("object.scale", json!({"sx": 200})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.size), Some(24.0)); - assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes"); - let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - app.run("object.group", json!({})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size"); - app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0)); - app.run("text.setStyle", json!({"size": 10})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); - app.run("text.setFormat", json!({"hScale": 100})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0)); - } - - #[test] - fn scaled_range_fields_follow_the_type_tool_selection() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone(); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert!(text_editing(&app).is_some()); - app.run("object.scale", json!({"sx": 200})).unwrap(); - app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - assert_eq!(shared_effective(&app, |s| s.size), None); - app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); - assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); - assert_eq!(text_style(&app).unwrap().0.size, 5.0); - } - - /// The Kerning field at a caret kerns that pair, not the whole text (#2217). - #[test] - fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX", "size": 12})).unwrap()["id"].clone(); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); - set_kerning(&mut app, json!(-50)); - let kerning = |app: &VectorcraftApp, at: usize| { - let st = app.session.active().unwrap(); - let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id.as_u64().unwrap())).map(|n| &n.kind) else { panic!("text") }; - vectorcraft_text::edit::style_at(&t.runs, at + 1).kerning // the character at `at` - }; - assert_eq!(kerning(&app, 1), Some(-50.0), "the character before the caret"); - assert_eq!(kerning(&app, 3), None, "the rest is untouched"); - assert_eq!(kerning(&app, 0), None); - // A selected range still takes it whole. - app.session.set_tool_option("select", &json!({"start": 2, "end": 4})); - set_kerning(&mut app, json!(30)); - assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0))); - } - - /// A variable font gets the Variable Font button, which opens one slider per axis named as the - /// font names it; a drag along one sets that axis of the selected type in one undo step. - #[test] - fn variable_fonts_get_a_slider_per_axis() { - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; - let db = vectorcraft_text::FontDb::global(); - db.add_font(two_axis_font().unwrap()); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone(); - let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [plain]})).unwrap(); - assert!(!crate::tests_labels::painted_text(&mut app, show).contains("Variable Font"), "a static font"); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert!(crate::tests_labels::painted_text(&mut app, show).contains("Variable Font")); - let face = db.face(TWO_AXIS_FAMILY, "Regular").unwrap(); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); - assert!(text.contains("Weight\n") && text.contains("Width\n") && text.contains("400") && text.contains("100"), "{text}"); - // The labels are the font's names in every interface language, not catalog words (ja - // translates the interface's Weight as the stroke weight). - let ja = crate::i18n::Lang::from_code("ja").unwrap(); - crate::i18n::set_current(ja); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); - crate::i18n::set_current(crate::i18n::Lang::EN); - let weight = crate::i18n::tr(ja, "Weight"); - assert!(text.contains("Weight\n") && text.contains("Width\n") && (weight == "Weight" || !text.contains(weight)), "{text}"); - // Screen readers name each slider by its axis. - let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); - for name in ["Weight", "Width"] { - assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::Slider && n.label() == Some(name)), "{name}"); - } - // ...with the number its field shows, on the axis's range. - use crate::widgets::accessibility_tests::slider_value; - let wght = face.axes().into_iter().find(|a| &a.tag == b"wght").unwrap(); - let range = (Some(f64::from(wght.min)), Some(f64::from(wght.max))); - assert_eq!(slider_value(&tree, "Weight"), (Some(400.0), range.0, range.1, Some("400".into()))); - // Drag the Weight slider from its start to past its end. - let ctx = egui::Context::default(); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut slider = None; - let input = egui::RawInput { events, ..Default::default() }; - ctx.run_ui(input, |ui| { - axis_sliders(app, ui, &face); - slider = ctx.read_response(ui.scope_id().with(("ch-axis", *b"wght"))).map(|r| r.rect); - }) - .textures_delta - .clear(); - slider - }; - frame(&mut app, vec![]); - let r = frame(&mut app, vec![]).expect("the Weight slider is drawn"); - let none = egui::Modifiers::NONE; - let (from, to) = (egui::pos2(r.left() + 4.0, r.center().y), egui::pos2(r.right() + 20.0, r.center().y)); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: none }; - frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true)]); - for i in 1..=4 { - frame(&mut app, vec![egui::Event::PointerMoved(from + (to - from) * i as f32 / 4.0)]); - } - frame(&mut app, vec![button(to, false)]); - frame(&mut app, vec![]); - let axes = |app: &VectorcraftApp| shared(app, |s| s.font_axes.clone()).unwrap(); - assert_eq!(axes(&app).into_iter().collect::>(), [("wght".to_string(), 700.0)]); - app.run("edit.undo", json!({})).unwrap(); - assert!(axes(&app).is_empty(), "one undo step"); - // Regular and Bold selected: the weights differ and the field is blank; the widths agree. - let bold = app.run("text.create", json!({"x": 20, "y": 160, "text": "lIn", "font": TWO_AXIS_FAMILY, "style": "Bold"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id, bold]})).unwrap(); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); - assert!(!text.contains("400") && !text.contains("700") && text.contains("100"), "{text}"); - let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); - assert_eq!(slider_value(&tree, "Weight"), (None, None, None, None), "a blank field: no value"); - assert_eq!(slider_value(&tree, "Width").0, Some(100.0)); - // Reset Panel leaves the settings, as it leaves the font and style. - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.run("text.setFormat", json!({"fontAxes": {"wght": 650}})).unwrap(); - reset_panel(&mut app); - assert_eq!(axes(&app).into_iter().collect::>(), [("wght".to_string(), 650.0)]); - } - - /// Bold and Italic pick the family's own faces by name, and toggle back (#724). - #[test] - fn bold_and_italic_pick_the_familys_own_faces() { - let styles: Vec = - ["Light", "Narrow", "Regular", "Italic", "Narrow Italic", "SemiBold", "Bold", "Bold Condensed", "Narrow Bold", "Bold Italic", "Black"] - .iter() - .map(|s| s.to_string()) - .collect(); - assert_eq!(pick_face(&styles, true, false, "Regular"), Some("Bold"), "not Bold Condensed, Narrow Bold or SemiBold"); - assert_eq!(pick_face(&styles, true, true, "Italic"), Some("Bold Italic")); - assert_eq!(pick_face(&styles, false, true, "Regular"), Some("Italic")); - assert_eq!(pick_face(&styles, false, false, "Italic"), Some("Regular"), "upright again, not Narrow"); - assert_eq!(pick_face(&styles, true, false, "Narrow"), Some("Narrow Bold"), "a Narrow face stays Narrow"); - assert_eq!(pick_face(&styles, false, true, "Narrow"), Some("Narrow Italic")); - let oblique: Vec = ["Book", "Oblique", "Bold-Oblique"].iter().map(|s| s.to_string()).collect(); - assert_eq!(pick_face(&oblique, true, true, "Oblique"), Some("Bold-Oblique")); - assert_eq!(pick_face(&oblique, false, false, "Oblique"), None, "Book has a weight word: no regular face"); - assert_eq!(face_of("Bold Italic"), (true, false, true, vec![])); - assert_eq!(face_of("SemiBold"), (false, false, false, vec![])); - assert_eq!(face_of("Narrow Italic"), (false, true, true, vec!["narrow".to_string()])); - // No type selected: a message, nothing changed. - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - assert!(toggle_face(&mut app, Face::Bold).is_err()); - assert!(!crate::menus::enabled(&app, "type.bold")); - } - - /// From one frame of the panel: a point inside each of the Small Caps, Superscript and - /// Subscript buttons, and which of them are drawn on. The two buttons with a "1" are - /// Superscript (the "1" up) and Subscript; Small Caps is the button before them. - fn script_buttons(ctx: &egui::Context, app: &mut VectorcraftApp) -> ([egui::Pos2; 3], [bool; 3]) { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - fn walk(s: &egui::Shape, on: egui::Color32, ones: &mut Vec, lit: &mut Vec) { - match s { - egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, on, ones, lit)), - egui::Shape::Text(t) if t.galley.text() == "1" => ones.push(t.visual_bounding_rect().center()), - egui::Shape::Rect(r) if r.fill == on && r.rect.size() == vec2(30.0, 26.0) => lit.push(r.rect), - _ => {} - } - } - let (mut ones, mut lit) = (vec![], vec![]); - for c in &out.shapes { - walk(&c.shape, Tokens::get(ctx).tool_active, &mut ones, &mut lit); - } - let [sup, sub] = ones[..] else { panic!("two script buttons: {ones:?}") }; - assert!(sup.x < sub.x && sup.y < sub.y, "Superscript, then Subscript: {ones:?}"); - // Each "1" is drawn 6 points right of its button's center and 5 points above or below it. - let y = (sup.y + sub.y) / 2.0; - let (sup, sub) = (egui::pos2(sup.x - 6.0, y), egui::pos2(sub.x - 6.0, y)); - let points = [sup - vec2(sub.x - sup.x, 0.0), sup, sub]; - (points, points.map(|p| lit.iter().any(|r| r.contains(p)))) - } - - /// Each run's (small caps, position). - fn scripts(app: &VectorcraftApp, id: &Value) -> Vec<(Option, &'static str)> { - match &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().map(|r| (r.style.small_caps, r.style.position.id())).collect(), - _ => panic!("text"), - } - } - - /// The Small Caps, Superscript and Subscript buttons set the selected type's small caps and - /// position, with one undo step for each click, and show them; Superscript and Subscript turn - /// each other off. While the Type tool edits, they set and show the selected characters. - #[test] - fn small_caps_superscript_and_subscript_buttons_set_and_show_the_text() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "E=mc2", "size": 12})).unwrap()["id"].clone(); - let ctx = egui::Context::default(); - let (at, lit) = script_buttons(&ctx, &mut app); - assert_eq!(lit, [false; 3]); - let small = Some(app.session.active().unwrap().doc.setup.small_caps_size); - for (button, runs, shown) in [ - (0, (small, "normal"), [true, false, false]), - (1, (small, "superscript"), [true, true, false]), - (2, (small, "subscript"), [true, false, true]), - (2, (small, "normal"), [true, false, false]), - (1, (small, "superscript"), [true, true, false]), - (1, (small, "normal"), [true, false, false]), - (0, (None, "normal"), [false; 3]), - ] { - let undo = app.session.active().unwrap().history.undo.len(); - super::super::tests_appearance::click(&ctx, &mut app, at[button], show); - assert_eq!(scripts(&app, &id), [runs], "button {button}"); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "button {button}"); - assert_eq!(script_buttons(&ctx, &mut app).1, shown, "button {button}"); - } - // The Type tool's selection: "2" alone. - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 4, "end": 5})); - assert!(text_editing(&app).is_some()); - super::super::tests_appearance::click(&ctx, &mut app, at[1], show); - assert_eq!(scripts(&app, &id), [(None, "normal"), (None, "superscript")]); - assert_eq!(script_buttons(&ctx, &mut app).1, [false, true, false]); - app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); - assert_eq!(script_buttons(&ctx, &mut app).1, [false; 3]); - } - - /// The panel menu's Small Caps, Superscript and Subscript set the selected type's small caps - /// and position and show a check mark when on; Superscript and Subscript turn each other off. - #[test] - fn small_caps_superscript_and_subscript_menu_items_set_and_check_the_text() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "E=mc2", "size": 12})).unwrap()["id"].clone(); - let ctx = egui::Context::default(); - let checked = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); - ["Small Caps", "Superscript", "Subscript"].map(|l| texts.iter().any(|(t, _)| *t == format!("✓ {l}"))) - }; - assert_eq!(checked(&mut app), [false; 3]); - let small = Some(app.session.active().unwrap().doc.setup.small_caps_size); - for (item, runs, shown) in [ - ("Small Caps", (small, "normal"), [true, false, false]), - ("Superscript", (small, "superscript"), [true, true, false]), - ("Subscript", (small, "subscript"), [true, false, true]), - ("Subscript", (small, "normal"), [true, false, false]), - ("Small Caps", (None, "normal"), [false; 3]), - ] { - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == item).unwrap_or_else(|| panic!("{item}: {texts:?}")).1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert_eq!(scripts(&app, &id), [runs], "{item}"); - assert_eq!(checked(&mut app), shown, "{item}"); - } - } - - /// Character panel menu › System Layout is off for a new document, and a click turns whole-point - /// spacing on and back off for every text object. - #[test] - fn system_layout_menu_item_toggles_the_document() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 13})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - let on = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - let object = match &st.doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.system_layout, - _ => panic!("text"), - }; - (st.doc.setup.system_layout, object) - }; - let row = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); - texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "System Layout") - }; - assert_eq!(on(&app), (false, false)); - assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); - let at = row(&mut app).unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert_eq!(on(&app), (true, true)); - assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); - let at = row(&mut app).unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert_eq!(on(&app), (false, false)); - assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); - } - - /// Character panel menu › Fractional Widths is on for new type, and a click turns it off and - /// back on for the whole type object. - #[test] - fn fractional_widths_menu_item_toggles_the_type_object() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 13})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - let flag = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.fractional_widths, - _ => panic!("text"), - }; - let row = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); - texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Fractional Widths") - }; - assert!(flag(&app)); - assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); - let at = row(&mut app).unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(!flag(&app)); - assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); - let at = row(&mut app).unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(flag(&app)); - assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); - } - - /// Character Alignment and Proportional Metrics are in the panel menu with the East Asian - /// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). - #[test] - fn character_alignment_shows_with_the_east_asian_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| { - let text = crate::tests_labels::painted_text(app, menu); - (text.contains("Character Alignment"), text.contains("Proportional Metrics")) - }; - assert_eq!(shown(&mut app), (false, false)); - app.session.prefs.show_east_asian_options = true; - assert_eq!(shown(&mut app), (true, true)); - } - - /// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the - /// selected characters. - #[test] - fn tate_chu_yoko_menu_item_shows_with_east_asian_options_and_toggles() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "2026"})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu); - assert!(!painted(&mut app).contains("Tate-chu-yoko")); - app.session.prefs.show_east_asian_options = true; - let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().all(|r| r.style.tate_chu_yoko), - _ => panic!("text"), - }; - assert!(!on(&app)); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap_or_else(|| panic!("{texts:?}")).1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(on(&app)); - assert!(painted(&mut app).contains("✓ Tate-chu-yoko")); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(!on(&app)); - } - - /// Digits is in the panel menu with the Middle Eastern (Indic) options only, as Paragraph - /// Direction is in the Paragraph panel. - #[test] - fn digits_show_with_the_indic_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "صفحة 3"})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Digits"); - assert!(!shown(&mut app)); - app.session.prefs.show_indic_options = true; - assert!(shown(&mut app)); - } - - /// Character Direction is in the panel menu with the Middle Eastern (Indic) options, beside - /// Digits. Its submenu checks the direction the characters the panel acts on share: the - /// selected type's, or the range the Type tool has selected. It checks no item when they differ. - #[test] - fn character_direction_checks_the_direction_of_the_selection() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Direction"); - assert!(!shown(&mut app)); - app.session.prefs.show_indic_options = true; - assert!(shown(&mut app)); - let checked = |app: &mut VectorcraftApp| { - let text = crate::tests_labels::painted_text(app, char_direction_items); - text.lines().filter_map(|l| l.strip_prefix("✓ ")).map(str::to_string).collect::>() - }; - assert_eq!(checked(&mut app), ["Default"]); - app.run("text.setRangeStyle", json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); - // The object's characters differ: nothing is checked. - assert!(checked(&mut app).is_empty()); - // The Type tool's selection: the date, then the Hebrew word. - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 9, "end": 19})); - assert_eq!(checked(&mut app), ["Left to Right"]); - app.session.set_tool_option("select", &json!({"start": 0, "end": 8})); - assert_eq!(checked(&mut app), ["Default"]); - } - - /// Small Caps, Superscript and Subscript buttons and menu items stay off when the selection mixes them. - #[test] - fn character_mixed_small_caps_and_position_show_off() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); - let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "B"})).unwrap()["id"].clone(); - app.run("text.setRangeStyle", json!({"id": a, "start": 0, "end": 1, "smallCaps": true, "position": "superscript"})).unwrap(); - app.run("text.setRangeStyle", json!({"id": b, "start": 0, "end": 1, "smallCaps": false, "position": "subscript"})).unwrap(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - assert_eq!(shared(&app, |s| s.small_caps.is_some()), None); - assert_eq!(shared(&app, |s| s.position), None); - let ctx = egui::Context::default(); - assert_eq!(script_buttons(&ctx, &mut app).1, [false, false, false]); - let checked = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); - ["Small Caps", "Superscript", "Subscript"].map(|l| texts.iter().any(|(t, _)| *t == format!("✓ {l}"))) - }; - assert_eq!(checked(&mut app), [false, false, false]); - } - - /// At a caret in text the Type tool edits, with no characters selected, the Character - /// Direction items are disabled: a click on one leaves the direction of every character as it was. - #[test] - fn character_direction_does_nothing_at_an_empty_caret() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 3, "end": 3})); - let directions = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(), - _ => panic!("text"), - }; - let before = directions(&app); - let ctx = egui::Context::default(); - let click_rtl = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], char_direction_items); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Right to Left").unwrap_or_else(|| panic!("{texts:?}")).1; - super::super::tests_appearance::click(&ctx, app, at.center(), char_direction_items); - }; - click_rtl(&mut app); - assert_eq!(directions(&app), before); - // With characters selected, the same click sets their direction. - app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); - click_rtl(&mut app); - assert_ne!(directions(&app), before); - } - - /// No Break is in the panel menu, and choosing it marks the selected characters. - #[test] - fn no_break_menu_item_toggles_the_selected_characters() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu); - assert!(painted(&mut app).contains("No Break")); - assert!(!painted(&mut app).contains("✓ No Break")); - let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().all(|r| r.style.no_break), - _ => panic!("text"), - }; - assert!(!on(&app)); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap_or_else(|| panic!("{texts:?}")).1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(on(&app)); - assert!(painted(&mut app).contains("✓ No Break")); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(!on(&app)); - } - - /// Reset Panel sets every character attribute the panel shows, except the font family, style - /// and size, back to its default (Small Caps, the position, Character Alignment, Proportional - /// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected - /// type and in the text the Type tool has selected while it edits. - #[test] - fn reset_panel_resets_every_attribute_but_the_font() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One 12"})).unwrap()["id"].as_u64().unwrap(); - let runs = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.clone())).collect::>(), - _ => panic!("text"), - }; - let differs = |s: &CharStyle, d: &CharStyle| { - [ - s.tracking != d.tracking, - s.leading != d.leading, - s.kerning != d.kerning, - s.baseline_shift != d.baseline_shift, - s.h_scale != d.h_scale, - s.v_scale != d.v_scale, - s.rotation != d.rotation, - s.underline != d.underline, - s.strikethrough != d.strikethrough, - s.all_caps != d.all_caps, - s.small_caps != d.small_caps, - s.position != d.position, - s.char_align != d.char_align, - s.proportional_metrics != d.proportional_metrics, - s.tate_chu_yoko != d.tate_chu_yoko, - s.no_break != d.no_break, - s.char_direction != d.char_direction, - s.digits != d.digits, - ] - }; - let custom = |app: &mut VectorcraftApp| { - app.run( - "text.setRangeStyle", - json!({ - "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, - "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", - "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, - "charDirection": "rightToLeft", "digits": "hindi" - }), - ) - .unwrap(); - }; - let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]); - custom(&mut app); - let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]); - app.run("select.set", json!({"ids": [id]})).unwrap(); - reset_panel(&mut app); - assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); - // While the Type tool edits text, Reset Panel resets only its selection. - custom(&mut app); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 4, "end": 6})); - assert!(text_editing(&app).is_some()); - reset_panel(&mut app); - assert_eq!(runs(&app), [("One ".to_string(), styled), ("12".to_string(), plain)]); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs new file mode 100644 index 000000000..b823b87d1 --- /dev/null +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -0,0 +1,632 @@ +use super::*; +use vectorcraft_engine::Session; + +#[test] +fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); + app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap(); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap(); + app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap(); + for (actual, expected) in [ + (shared_effective(&app, |s| s.size), 7.0), + (shared_effective(&app, CharStyle::effective_leading), 9.0), + (shared_effective(&app, |s| s.h_scale), 130.0), + (shared_effective(&app, |s| s.baseline_shift), 3.0), + ] { + assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8); + } +} + +/// A final empty paragraph counts: with the object selected, its Auto Leading differing from +/// the paragraph before it shows Leading as mixed. +#[test] +fn leading_field_counts_the_final_empty_paragraph() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\n", "size": 10})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9); + app.run("text.setFormat", json!({"autoLeading": 200, "start": 4, "end": 4})).unwrap(); + assert_eq!(shared_leading(&app), None, "the empty last paragraph differs"); +} + +/// Auto leading shows the leading the paragraph's Justification › Auto Leading sets. +#[test] +fn leading_field_shows_the_paragraph_auto_leading() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\nTwo", "size": 10})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9); + app.run("text.setFormat", json!({"autoLeading": 200, "start": 5, "end": 5})).unwrap(); + assert_eq!(shared_leading(&app), None, "the paragraphs differ"); + assert_eq!(shared(&app, |s| s.leading.is_none()), Some(true), "still Auto"); + app.run("text.setFormat", json!({"autoLeading": 200})).unwrap(); + assert!((shared_leading(&app).unwrap() - 20.0).abs() < 1e-9); + app.run("text.setStyle", json!({"leading": 15})).unwrap(); + assert!((shared_leading(&app).unwrap() - 15.0).abs() < 1e-9, "a set leading is its own"); +} + +#[test] +fn scaled_collapsed_text_keeps_nondimensional_controls_available() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap(); + app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); + assert!(text_style(&app).is_some()); + assert_eq!(shared(&app, |s| s.tracking), Some(0.0)); + assert_eq!(shared_effective(&app, |s| s.size), None); + app.run("text.setStyle", json!({"tracking": 40})).unwrap(); + app.run("text.setFormat", json!({"leftIndent": 4})).unwrap(); + assert_eq!(shared(&app, |s| s.tracking), Some(40.0)); + assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0); + let ctx = egui::Context::default(); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + assert!(!texts.iter().any(|(s, _)| s == "No text selected")); +} + +#[test] +fn scaled_character_fields_show_document_sizes_and_mixed_group_values() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); + app.run("object.scale", json!({"sx": 200})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(24.0)); + assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes"); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size"); + app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0)); + app.run("text.setStyle", json!({"size": 10})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); + app.run("text.setFormat", json!({"hScale": 100})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0)); +} + +#[test] +fn scaled_range_fields_follow_the_type_tool_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(text_editing(&app).is_some()); + app.run("object.scale", json!({"sx": 200})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_effective(&app, |s| s.size), None); + app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); + assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); + assert_eq!(text_style(&app).unwrap().0.size, 5.0); +} + +/// The Kerning field at a caret kerns that pair, not the whole text (#2217). +#[test] +fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX", "size": 12})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); + set_kerning(&mut app, json!(-50)); + let kerning = |app: &VectorcraftApp, at: usize| { + let st = app.session.active().unwrap(); + let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id.as_u64().unwrap())).map(|n| &n.kind) else { panic!("text") }; + vectorcraft_text::edit::style_at(&t.runs, at + 1).kerning // the character at `at` + }; + assert_eq!(kerning(&app, 1), Some(-50.0), "the character before the caret"); + assert_eq!(kerning(&app, 3), None, "the rest is untouched"); + assert_eq!(kerning(&app, 0), None); + // A selected range still takes it whole. + app.session.set_tool_option("select", &json!({"start": 2, "end": 4})); + set_kerning(&mut app, json!(30)); + assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0))); +} + +/// A variable font gets the Variable Font button, which opens one slider per axis named as the +/// font names it; a drag along one sets that axis of the selected type in one undo step. +#[test] +fn variable_fonts_get_a_slider_per_axis() { + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(two_axis_font().unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone(); + let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [plain]})).unwrap(); + assert!(!crate::tests_labels::painted_text(&mut app, show).contains("Variable Font"), "a static font"); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert!(crate::tests_labels::painted_text(&mut app, show).contains("Variable Font")); + let face = db.face(TWO_AXIS_FAMILY, "Regular").unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); + assert!(text.contains("Weight\n") && text.contains("Width\n") && text.contains("400") && text.contains("100"), "{text}"); + // The labels are the font's names in every interface language, not catalog words (ja + // translates the interface's Weight as the stroke weight). + let ja = crate::i18n::Lang::from_code("ja").unwrap(); + crate::i18n::set_current(ja); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); + crate::i18n::set_current(crate::i18n::Lang::EN); + let weight = crate::i18n::tr(ja, "Weight"); + assert!(text.contains("Weight\n") && text.contains("Width\n") && (weight == "Weight" || !text.contains(weight)), "{text}"); + // Screen readers name each slider by its axis. + let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); + for name in ["Weight", "Width"] { + assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::Slider && n.label() == Some(name)), "{name}"); + } + // ...with the number its field shows, on the axis's range. + use crate::widgets::accessibility_tests::slider_value; + let wght = face.axes().into_iter().find(|a| &a.tag == b"wght").unwrap(); + let range = (Some(f64::from(wght.min)), Some(f64::from(wght.max))); + assert_eq!(slider_value(&tree, "Weight"), (Some(400.0), range.0, range.1, Some("400".into()))); + // Drag the Weight slider from its start to past its end. + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut slider = None; + let input = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(input, |ui| { + axis_sliders(app, ui, &face); + slider = ctx.read_response(ui.scope_id().with(("ch-axis", *b"wght"))).map(|r| r.rect); + }) + .textures_delta + .clear(); + slider + }; + frame(&mut app, vec![]); + let r = frame(&mut app, vec![]).expect("the Weight slider is drawn"); + let none = egui::Modifiers::NONE; + let (from, to) = (egui::pos2(r.left() + 4.0, r.center().y), egui::pos2(r.right() + 20.0, r.center().y)); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: none }; + frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true)]); + for i in 1..=4 { + frame(&mut app, vec![egui::Event::PointerMoved(from + (to - from) * i as f32 / 4.0)]); + } + frame(&mut app, vec![button(to, false)]); + frame(&mut app, vec![]); + let axes = |app: &VectorcraftApp| shared(app, |s| s.font_axes.clone()).unwrap(); + assert_eq!(axes(&app).into_iter().collect::>(), [("wght".to_string(), 700.0)]); + app.run("edit.undo", json!({})).unwrap(); + assert!(axes(&app).is_empty(), "one undo step"); + // Regular and Bold selected: the weights differ and the field is blank; the widths agree. + let bold = app.run("text.create", json!({"x": 20, "y": 160, "text": "lIn", "font": TWO_AXIS_FAMILY, "style": "Bold"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id, bold]})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); + assert!(!text.contains("400") && !text.contains("700") && text.contains("100"), "{text}"); + let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); + assert_eq!(slider_value(&tree, "Weight"), (None, None, None, None), "a blank field: no value"); + assert_eq!(slider_value(&tree, "Width").0, Some(100.0)); + // Reset Panel leaves the settings, as it leaves the font and style. + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("text.setFormat", json!({"fontAxes": {"wght": 650}})).unwrap(); + reset_panel(&mut app); + assert_eq!(axes(&app).into_iter().collect::>(), [("wght".to_string(), 650.0)]); +} + +/// Bold and Italic pick the family's own faces by name, and toggle back (#724). +#[test] +fn bold_and_italic_pick_the_familys_own_faces() { + let styles: Vec = + ["Light", "Narrow", "Regular", "Italic", "Narrow Italic", "SemiBold", "Bold", "Bold Condensed", "Narrow Bold", "Bold Italic", "Black"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(pick_face(&styles, true, false, "Regular"), Some("Bold"), "not Bold Condensed, Narrow Bold or SemiBold"); + assert_eq!(pick_face(&styles, true, true, "Italic"), Some("Bold Italic")); + assert_eq!(pick_face(&styles, false, true, "Regular"), Some("Italic")); + assert_eq!(pick_face(&styles, false, false, "Italic"), Some("Regular"), "upright again, not Narrow"); + assert_eq!(pick_face(&styles, true, false, "Narrow"), Some("Narrow Bold"), "a Narrow face stays Narrow"); + assert_eq!(pick_face(&styles, false, true, "Narrow"), Some("Narrow Italic")); + let oblique: Vec = ["Book", "Oblique", "Bold-Oblique"].iter().map(|s| s.to_string()).collect(); + assert_eq!(pick_face(&oblique, true, true, "Oblique"), Some("Bold-Oblique")); + assert_eq!(pick_face(&oblique, false, false, "Oblique"), None, "Book has a weight word: no regular face"); + assert_eq!(face_of("Bold Italic"), (true, false, true, vec![])); + assert_eq!(face_of("SemiBold"), (false, false, false, vec![])); + assert_eq!(face_of("Narrow Italic"), (false, true, true, vec!["narrow".to_string()])); + // No type selected: a message, nothing changed. + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert!(toggle_face(&mut app, Face::Bold).is_err()); + assert!(!crate::menus::enabled(&app, "type.bold")); +} + +/// From one frame of the panel: a point inside each of the Small Caps, Superscript and +/// Subscript buttons, and which of them are drawn on. The two buttons with a "1" are +/// Superscript (the "1" up) and Subscript; Small Caps is the button before them. +fn script_buttons(ctx: &egui::Context, app: &mut VectorcraftApp) -> ([egui::Pos2; 3], [bool; 3]) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, on: egui::Color32, ones: &mut Vec, lit: &mut Vec) { + match s { + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, on, ones, lit)), + egui::Shape::Text(t) if t.galley.text() == "1" => ones.push(t.visual_bounding_rect().center()), + egui::Shape::Rect(r) if r.fill == on && r.rect.size() == vec2(30.0, 26.0) => lit.push(r.rect), + _ => {} + } + } + let (mut ones, mut lit) = (vec![], vec![]); + for c in &out.shapes { + walk(&c.shape, Tokens::get(ctx).tool_active, &mut ones, &mut lit); + } + let [sup, sub] = ones[..] else { panic!("two script buttons: {ones:?}") }; + assert!(sup.x < sub.x && sup.y < sub.y, "Superscript, then Subscript: {ones:?}"); + // Each "1" is drawn 6 points right of its button's center and 5 points above or below it. + let y = (sup.y + sub.y) / 2.0; + let (sup, sub) = (egui::pos2(sup.x - 6.0, y), egui::pos2(sub.x - 6.0, y)); + let points = [sup - vec2(sub.x - sup.x, 0.0), sup, sub]; + (points, points.map(|p| lit.iter().any(|r| r.contains(p)))) +} + +/// Each run's (small caps, position). +fn scripts(app: &VectorcraftApp, id: &Value) -> Vec<(Option, &'static str)> { + match &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.style.small_caps, r.style.position.id())).collect(), + _ => panic!("text"), + } +} + +/// The Small Caps, Superscript and Subscript buttons set the selected type's small caps and +/// position, with one undo step for each click, and show them; Superscript and Subscript turn +/// each other off. While the Type tool edits, they set and show the selected characters. +#[test] +fn small_caps_superscript_and_subscript_buttons_set_and_show_the_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "E=mc2", "size": 12})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + let (at, lit) = script_buttons(&ctx, &mut app); + assert_eq!(lit, [false; 3]); + let small = Some(app.session.active().unwrap().doc.setup.small_caps_size); + for (button, runs, shown) in [ + (0, (small, "normal"), [true, false, false]), + (1, (small, "superscript"), [true, true, false]), + (2, (small, "subscript"), [true, false, true]), + (2, (small, "normal"), [true, false, false]), + (1, (small, "superscript"), [true, true, false]), + (1, (small, "normal"), [true, false, false]), + (0, (None, "normal"), [false; 3]), + ] { + let undo = app.session.active().unwrap().history.undo.len(); + super::super::tests_appearance::click(&ctx, &mut app, at[button], show); + assert_eq!(scripts(&app, &id), [runs], "button {button}"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "button {button}"); + assert_eq!(script_buttons(&ctx, &mut app).1, shown, "button {button}"); + } + // The Type tool's selection: "2" alone. + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 4, "end": 5})); + assert!(text_editing(&app).is_some()); + super::super::tests_appearance::click(&ctx, &mut app, at[1], show); + assert_eq!(scripts(&app, &id), [(None, "normal"), (None, "superscript")]); + assert_eq!(script_buttons(&ctx, &mut app).1, [false, true, false]); + app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); + assert_eq!(script_buttons(&ctx, &mut app).1, [false; 3]); +} + +/// The panel menu's Small Caps, Superscript and Subscript set the selected type's small caps +/// and position and show a check mark when on; Superscript and Subscript turn each other off. +#[test] +fn small_caps_superscript_and_subscript_menu_items_set_and_check_the_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "E=mc2", "size": 12})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + let checked = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + ["Small Caps", "Superscript", "Subscript"].map(|l| texts.iter().any(|(t, _)| *t == format!("✓ {l}"))) + }; + assert_eq!(checked(&mut app), [false; 3]); + let small = Some(app.session.active().unwrap().doc.setup.small_caps_size); + for (item, runs, shown) in [ + ("Small Caps", (small, "normal"), [true, false, false]), + ("Superscript", (small, "superscript"), [true, true, false]), + ("Subscript", (small, "subscript"), [true, false, true]), + ("Subscript", (small, "normal"), [true, false, false]), + ("Small Caps", (None, "normal"), [false; 3]), + ] { + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == item).unwrap_or_else(|| panic!("{item}: {texts:?}")).1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(scripts(&app, &id), [runs], "{item}"); + assert_eq!(checked(&mut app), shown, "{item}"); + } +} + +/// Character panel menu › System Layout is off for a new document, and a click turns whole-point +/// spacing on and back off for every text object. +#[test] +fn system_layout_menu_item_toggles_the_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 13})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + let on = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + let object = match &st.doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.system_layout, + _ => panic!("text"), + }; + (st.doc.setup.system_layout, object) + }; + let row = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "System Layout") + }; + assert_eq!(on(&app), (false, false)); + assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); + let at = row(&mut app).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(on(&app), (true, true)); + assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); + let at = row(&mut app).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(on(&app), (false, false)); + assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); +} + +/// Character panel menu › Fractional Widths is on for new type, and a click turns it off and +/// back on for the whole type object. +#[test] +fn fractional_widths_menu_item_toggles_the_type_object() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 13})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + let flag = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.fractional_widths, + _ => panic!("text"), + }; + let row = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Fractional Widths") + }; + assert!(flag(&app)); + assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); + let at = row(&mut app).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(!flag(&app)); + assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); + let at = row(&mut app).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(flag(&app)); + assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); +} + +/// Character Alignment and Proportional Metrics are in the panel menu with the East Asian +/// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). +#[test] +fn character_alignment_shows_with_the_east_asian_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, menu); + (text.contains("Character Alignment"), text.contains("Proportional Metrics")) + }; + assert_eq!(shown(&mut app), (false, false)); + app.session.prefs.show_east_asian_options = true; + assert_eq!(shown(&mut app), (true, true)); +} + +/// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the +/// selected characters. +#[test] +fn tate_chu_yoko_menu_item_shows_with_east_asian_options_and_toggles() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "2026"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu); + assert!(!painted(&mut app).contains("Tate-chu-yoko")); + app.session.prefs.show_east_asian_options = true; + let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().all(|r| r.style.tate_chu_yoko), + _ => panic!("text"), + }; + assert!(!on(&app)); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap_or_else(|| panic!("{texts:?}")).1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(on(&app)); + assert!(painted(&mut app).contains("✓ Tate-chu-yoko")); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(!on(&app)); +} + +/// Digits is in the panel menu with the Middle Eastern (Indic) options only, as Paragraph +/// Direction is in the Paragraph panel. +#[test] +fn digits_show_with_the_indic_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "صفحة 3"})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Digits"); + assert!(!shown(&mut app)); + app.session.prefs.show_indic_options = true; + assert!(shown(&mut app)); +} + +/// Character Direction is in the panel menu with the Middle Eastern (Indic) options, beside +/// Digits. Its submenu checks the direction the characters the panel acts on share: the +/// selected type's, or the range the Type tool has selected. It checks no item when they differ. +#[test] +fn character_direction_checks_the_direction_of_the_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Direction"); + assert!(!shown(&mut app)); + app.session.prefs.show_indic_options = true; + assert!(shown(&mut app)); + let checked = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, char_direction_items); + text.lines().filter_map(|l| l.strip_prefix("✓ ")).map(str::to_string).collect::>() + }; + assert_eq!(checked(&mut app), ["Default"]); + app.run("text.setRangeStyle", json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + // The object's characters differ: nothing is checked. + assert!(checked(&mut app).is_empty()); + // The Type tool's selection: the date, then the Hebrew word. + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 9, "end": 19})); + assert_eq!(checked(&mut app), ["Left to Right"]); + app.session.set_tool_option("select", &json!({"start": 0, "end": 8})); + assert_eq!(checked(&mut app), ["Default"]); +} + +/// Small Caps, Superscript and Subscript buttons and menu items stay off when the selection mixes them. +#[test] +fn character_mixed_small_caps_and_position_show_off() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "B"})).unwrap()["id"].clone(); + app.run("text.setRangeStyle", json!({"id": a, "start": 0, "end": 1, "smallCaps": true, "position": "superscript"})).unwrap(); + app.run("text.setRangeStyle", json!({"id": b, "start": 0, "end": 1, "smallCaps": false, "position": "subscript"})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert_eq!(shared(&app, |s| s.small_caps.is_some()), None); + assert_eq!(shared(&app, |s| s.position), None); + let ctx = egui::Context::default(); + assert_eq!(script_buttons(&ctx, &mut app).1, [false, false, false]); + let checked = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + ["Small Caps", "Superscript", "Subscript"].map(|l| texts.iter().any(|(t, _)| *t == format!("✓ {l}"))) + }; + assert_eq!(checked(&mut app), [false, false, false]); +} + +/// At a caret in text the Type tool edits, with no characters selected, the Character +/// Direction items are disabled: a click on one leaves the direction of every character as it was. +#[test] +fn character_direction_does_nothing_at_an_empty_caret() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 3, "end": 3})); + let directions = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(), + _ => panic!("text"), + }; + let before = directions(&app); + let ctx = egui::Context::default(); + let click_rtl = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], char_direction_items); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Right to Left").unwrap_or_else(|| panic!("{texts:?}")).1; + super::super::tests_appearance::click(&ctx, app, at.center(), char_direction_items); + }; + click_rtl(&mut app); + assert_eq!(directions(&app), before); + // With characters selected, the same click sets their direction. + app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); + click_rtl(&mut app); + assert_ne!(directions(&app), before); +} + +/// No Break is in the panel menu, and choosing it marks the selected characters. +#[test] +fn no_break_menu_item_toggles_the_selected_characters() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu); + assert!(painted(&mut app).contains("No Break")); + assert!(!painted(&mut app).contains("✓ No Break")); + let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().all(|r| r.style.no_break), + _ => panic!("text"), + }; + assert!(!on(&app)); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap_or_else(|| panic!("{texts:?}")).1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(on(&app)); + assert!(painted(&mut app).contains("✓ No Break")); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(!on(&app)); +} + +/// Reset Panel sets every character attribute the panel shows, except the font family, style +/// and size, back to its default (Small Caps, the position, Character Alignment, Proportional +/// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected +/// type and in the text the Type tool has selected while it edits. +#[test] +fn reset_panel_resets_every_attribute_but_the_font() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One 12"})).unwrap()["id"].as_u64().unwrap(); + let runs = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.clone())).collect::>(), + _ => panic!("text"), + }; + let differs = |s: &CharStyle, d: &CharStyle| { + [ + s.tracking != d.tracking, + s.leading != d.leading, + s.kerning != d.kerning, + s.baseline_shift != d.baseline_shift, + s.h_scale != d.h_scale, + s.v_scale != d.v_scale, + s.rotation != d.rotation, + s.underline != d.underline, + s.strikethrough != d.strikethrough, + s.all_caps != d.all_caps, + s.small_caps != d.small_caps, + s.position != d.position, + s.char_align != d.char_align, + s.proportional_metrics != d.proportional_metrics, + s.tate_chu_yoko != d.tate_chu_yoko, + s.no_break != d.no_break, + s.char_direction != d.char_direction, + s.digits != d.digits, + ] + }; + let custom = |app: &mut VectorcraftApp| { + app.run( + "text.setRangeStyle", + json!({ + "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, + "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", + "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, + "charDirection": "rightToLeft", "digits": "hindi" + }), + ) + .unwrap(); + }; + let plain = runs(&app)[0].1.clone(); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]); + custom(&mut app); + let styled = runs(&app)[0].1.clone(); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]); + app.run("select.set", json!({"ids": [id]})).unwrap(); + reset_panel(&mut app); + assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); + // While the Type tool edits text, Reset Panel resets only its selection. + custom(&mut app); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 4, "end": 6})); + assert!(text_editing(&app).is_some()); + reset_panel(&mut app); + assert_eq!(runs(&app), [("One ".to_string(), styled), ("12".to_string(), plain)]); +} From f998e19bfd3f68900a985a451de4b6fd2696acac Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:54 +0000 Subject: [PATCH 040/199] refactor(tools): move guides.rs tests to guides/tests.rs --- crates/tools/src/guides.rs | 620 +------------------------------ crates/tools/src/guides/tests.rs | 617 ++++++++++++++++++++++++++++++ 2 files changed, 618 insertions(+), 619 deletions(-) create mode 100644 crates/tools/src/guides/tests.rs diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 56066ab7e..e05f7dceb 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -1618,622 +1618,4 @@ pub fn distance_guides(cx: &ToolContext, from: Rect, to: Rect, artboard: bool) - } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - - #[test] - fn artboard_bleed_is_a_target() { - let (mut d, _) = doc_with_rect(); - let t = Targets::collect(&d, &[], None); - assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 512.0, "no bleed, nothing near"); - d.setup.bleed = [10.0; 4]; - let t = Targets::collect(&d, &[], None); - assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 510.0); - } - - #[test] - fn snaps_to_anchor_and_alignment() { - let (d, _) = doc_with_rect(); - let t = Targets::collect(&d, &[], None); - let (p, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); - assert_eq!(p, Point::new(100.0, 100.0)); - assert!(matches!(&ov[0], Overlay::Label { text, .. } if text == "anchor")); - let (p, _) = t.snap_point(Point::new(301.0, 199.0), 4.0); - assert_eq!(p.y, 200.0); - } - - #[test] - fn rect_snap_aligns_edges() { - let (d, id) = doc_with_rect(); - let t = Targets::collect(&d, &[id], None); - // Artboard is 0..500; a rect at x0=2 snaps to the artboard's left edge. - let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); - assert_eq!(dv.x, -2.0); - assert!(!ov.is_empty()); - } - - #[test] - fn scale_snap_lands_the_moved_edges_on_targets() { - use crate::bbox::scale_for_drag; - let (d, _) = doc_with_rect(); - // The document's rect is 100..200: a box beside it is resized against it. - let t = Targets::collect(&d, &[], None); - let r = Rect::new(250.0, 100.0, 300.0, 150.0); - let snap = |h: Handle, p: Point, shift: bool, alt: bool| { - let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0); - (a.transform_rect_bbox(r), ov) - }; - // The bottom edge lands on the neighbour's bottom: same height. - let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { .. })).count(); - let align = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")); - let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 197.0), false, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 200.0)); - assert_eq!(lines(&ov), 1); - assert!(align(&ov), "{ov:?}"); - // A corner snaps each axis on its own: y to the neighbour, x stays free. - let (nr, ov) = snap(Handle::BottomRight, Point::new(330.0, 202.0), false, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 330.0, 200.0)); - assert_eq!(lines(&ov), 1); - // Proportional: the snapped axis carries the other one. - let (nr, _) = snap(Handle::BottomRight, Point::new(340.0, 198.0), true, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 350.0, 200.0)); - // From the centre the dragged edge still lands on the target. - let (nr, _) = snap(Handle::Bottom, Point::new(275.0, 198.0), false, true); - assert_eq!(nr, Rect::new(250.0, 50.0, 300.0, 200.0)); - // Out of reach, and the fixed edge's own target: untouched. - let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 180.0), false, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 180.0)); - assert!(ov.is_empty()); - let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 102.0), false, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0)); - assert!(ov.is_empty()); - } - - /// A resize handle lands on another object's anchor point (#482), not only in line with its - /// edges and centre, and says so; a side handle stops where its way passes the anchor. - #[test] - fn scale_snap_lands_the_handle_on_anchors() { - use crate::bbox::scale_for_drag; - use vectorcraft_doc::{Appearance, Node}; - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - // A triangle whose apex (320, 400) lines up with none of the edges or centres on x. - let tri = vectorcraft_geom::PathData::single(SubPath::polyline( - &[Point::new(300.0, 300.0), Point::new(380.0, 300.0), Point::new(320.0, 400.0)], - true, - )); - d.insert(Some(l), 1, Node::path(id, tri, Appearance::default_art())).unwrap(); - let t = Targets::collect(&d, &[], None); - let snap = |r: Rect, h: Handle, p: Point, shift: bool| { - let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, false), shift, false, 4.0); - (a.transform_rect_bbox(r), ov) - }; - let anchor = |ov: &[Overlay]| matches!(ov, [Overlay::Label { text, p, .. }] if text == "anchor" && *p == Point::new(320.0, 400.0)); - // A free corner takes the anchor's x and y. - let (nr, ov) = snap(Rect::new(250.0, 350.0, 300.0, 390.0), Handle::BottomRight, Point::new(322.0, 398.0), false); - assert_eq!(nr, Rect::new(250.0, 350.0, 320.0, 400.0)); - assert!(anchor(&ov), "{ov:?}"); - // The right side, at the apex's height: its x lands on the anchor. - let (nr, ov) = snap(Rect::new(250.0, 380.0, 300.0, 420.0), Handle::Right, Point::new(318.0, 401.0), false); - assert_eq!(nr, Rect::new(250.0, 380.0, 320.0, 420.0)); - assert!(anchor(&ov), "{ov:?}"); - // A proportional corner slides along its diagonal onto it. - let (nr, ov) = snap(Rect::new(240.0, 320.0, 300.0, 380.0), Handle::BottomRight, Point::new(318.0, 398.0), true); - assert_eq!(nr, Rect::new(240.0, 320.0, 320.0, 400.0)); - assert!(anchor(&ov), "{ov:?}"); - } - - /// Smart Guides › Spacing Guides (#394): a moved selection lands as far from its neighbour as - /// two other objects of its row are apart, or halfway between its two neighbours, and the - /// equal gaps are marked; up and down the same in a column. Off, it stays where it is dragged. - #[test] - fn spacing_guides_space_a_moved_selection_evenly() { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - // A and B 20 apart in a row at y 300–340, C and D 30 apart in a column at x 400–440. - for r in [ - Rect::new(20.0, 300.0, 60.0, 340.0), - Rect::new(80.0, 300.0, 120.0, 340.0), - Rect::new(400.0, 20.0, 440.0, 50.0), - Rect::new(400.0, 80.0, 440.0, 110.0), - ] { - let id = d.alloc_id(); - d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Default::default())).unwrap(); - } - let (s, p) = (Selection::default(), paint()); - let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count(); - let t = Targets::for_move(&cx(&d, &s, &p), &s.objects); - // 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each). - let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); - assert_eq!(dv, Vec2::new(-1.0, 0.0)); - assert_eq!(lines(&ov), 6, "{ov:?}"); - assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (120.0, 140.0) && a.y == b.y)), "{ov:?}"); - assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (60.0, 80.0) && a.y == b.y)), "{ov:?}"); - // Halfway between B and a box at 220–260: 50 either side. - let mut wide = d.clone(); - let id = wide.alloc_id(); - wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default())) - .unwrap(); - let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); - assert_eq!(dv, Vec2::new(2.0, 0.0)); - assert!(lines(&ov) >= 6, "{ov:?}"); - // A column: 28 under D, as C and D are 30 apart. - let (dv, ov) = t.snap_rect(Rect::new(405.0, 138.0, 445.0, 168.0), 4.0); - assert_eq!(dv, Vec2::new(0.0, 2.0)); - assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.y, b.y) == (110.0, 140.0) && a.x == b.x)), "{ov:?}"); - // Above C, 32 over it: 30. - let (dv, _) = t.snap_rect(Rect::new(405.0, -42.0, 445.0, -12.0), 4.0); - assert_eq!(dv, Vec2::new(0.0, 2.0)); - // Off: nowhere to go, and no marks. - let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) }; - let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); - assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0)); - } - - /// Smart Guides › Spacing Guides (#394): resizing by a side handle snaps the moving edge to the - /// same gap as the neighbours in its row, with the gaps marked. - #[test] - fn spacing_guides_resize_snaps_the_moving_edge_to_equal_gaps() { - use crate::bbox::scale_for_drag; - use vectorcraft_doc::{Appearance, Node}; - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - for r in [Rect::new(20.0, 300.0, 60.0, 340.0), Rect::new(80.0, 300.0, 120.0, 340.0)] { - let id = d.alloc_id(); - d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); - } - let r = Rect::new(141.0, 305.0, 181.0, 345.0); - let id = d.alloc_id(); - d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); - let s = Selection { objects: vec![id], ..Default::default() }; - let t = Targets::for_scale(&cx(&d, &s, &paint())); - let (a, ov) = t.snap_scale( - &OrientedBox::aligned(r), - Handle::Left, - scale_for_drag(r, Handle::Left, Point::new(139.0, 325.0), false, false), - false, - false, - 4.0, - ); - assert_eq!(a.transform_rect_bbox(r).x0, 140.0, "20 pt from the second square"); - let magenta = |o: &Overlay| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA); - assert!(ov.iter().any(magenta), "{ov:?}"); - } - - /// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and - /// Anchor/Path Labels off, change what shows and never where a point lands. - #[test] - fn display_options_colour_and_hide_the_guides_but_keep_the_snap() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let green = [0x00, 0xff, 0x00]; - let c = ToolContext { smart_guide_color: green, ..cx(&d, &s, &p) }; - let t = Targets::collect(&d, &[], None).styled(&c); - let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); - assert_eq!(q, Point::new(100.0, 100.0)); - assert!(matches!(&ov[0], Overlay::Label { text, color, .. } if text == "anchor" && *color == green), "{ov:?}"); - let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); - assert_eq!(q.y, 200.0); - assert!(ov.iter().all(|o| matches!(o, Overlay::Line { color, .. } | Overlay::Label { color, .. } if *color == green)), "{ov:?}"); - assert!( - ov.iter().any(|o| matches!(o, Overlay::Line { .. })) && ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")) - ); - // Alignment Guides off: the point still lines up and says so, without the line. - let c = ToolContext { alignment_guides: false, ..cx(&d, &s, &p) }; - let t = Targets::collect(&d, &[], None).styled(&c); - let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); - assert_eq!(q.y, 200.0); - assert!(!ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "{ov:?}"); - assert!(ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")), "{ov:?}"); - let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); - assert_eq!((dv.x, ov.len()), (-2.0, 0)); - // A handle already in line moves nowhere, yet is still "align". - let (q, ov) = t.snap_point_with(Point::new(400.0, 200.0), &[], 4.0); - assert_eq!(q, Point::new(400.0, 200.0)); - assert!(matches!(&ov[..], [Overlay::Label { text, .. }] if text == "align"), "{ov:?}"); - // Anchor/Path Labels off: on the anchor, without saying so. - let c = ToolContext { anchor_path_labels: false, ..cx(&d, &s, &p) }; - let t = Targets::collect(&d, &[], None).styled(&c); - let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); - assert_eq!((q, ov.len()), (Point::new(100.0, 100.0), 0)); - let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); - assert_eq!(q.y, 200.0); - assert!(ov.iter().all(|o| matches!(o, Overlay::Line { .. })) && !ov.is_empty(), "{ov:?}"); - assert_eq!(t.snap_guide(false, 199.0, 4.0), (200.0, vec![])); - } - - /// Dragging an anchor of a path: its other anchors and the segments not touching it stay - /// targets, but neither it nor the path's bounds (which move) are. - #[test] - fn anchor_drag_targets_leave_out_what_moves() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.set([id]); - s.anchors.insert(id, [(0, 0)].into_iter().collect()); - let t = Targets::for_anchor_drag(&d, &s); - let anchors: Vec = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect(); - assert_eq!(anchors, [Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(200.0, 200.0)], "by x"); - assert_eq!(t.segments.len(), 2); - assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves"); - assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself"); - } - - /// A Shaper Group's original shapes, which are not shown, are no targets: not their - /// anchors, edges or segments, not their bounds as an object a moved face spaces itself evenly - /// with, and not the anchors a transform tool's reference point snaps to on the selected group. - /// Selected, or under the pointer in Construction mode, an original shape still gives its anchors. - #[test] - fn shaper_sources_are_no_targets() { - let (d, g, sources, face) = doc_with_shaper_group(); - let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id; - // The top right corner of the second source square, which no shown art has. - let corner = Point::new(150.0, 300.0); - let gives_corner = |t: &Targets| { - t.points.iter().any(|(p, _)| *p == corner) - || t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == corner) - || t.segments.iter().any(|(_, s)| s.start() == corner || s.end() == corner) - }; - assert!(!gives_corner(&Targets::collect(&d, &[], None))); - assert!(!gives_corner(&Targets::for_drawing(&d, &[], None, false))); - assert!(!gives_corner(&Targets::for_anchor_drag(&d, &Selection::default()))); - assert!(!gives_corner(&Targets::for_handle(&d, face))); - assert!(!object_boxes(&d, &[face]).contains(&Rect::new(0.0, 300.0, 150.0, 400.0)), "the source shapes' bounds"); - let near = Rect::new(146.0, 296.0, 154.0, 304.0); - assert!(!gives_corner(&Targets::points_near(&d, &[g], near))); - assert!(gives_corner(&Targets::points_near(&d, &[b], near))); - } - - /// #2120: the dragged end of a straight segment at any angle lands on the segment's own - /// extension (Line Extension), past its other end or short of it, the guide drawn from that - /// end; with two anchors moving there is none to keep to. - #[test] - fn dragged_end_of_a_slanted_line_keeps_to_its_extension() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - // From (300, 400) up to (380, 340): along (4, -3), about 37° up. - d.insert( - Some(l), - 1, - Node::path(id, vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0)), Appearance::default_art()), - ) - .unwrap(); - let (mut s, p) = (Selection::default(), paint()); - s.set([id]); - s.anchors.insert(id, [(0, 1)].into_iter().collect()); - let t = Targets::for_anchor_drag(&d, &s).styled(&cx(&d, &s, &p)); - // 150 along it (2.5 off), and 50 along it (shorter). - for (pointer, on) in [(Point::new(421.5, 312.0), Point::new(420.0, 310.0)), (Point::new(341.5, 372.0), Point::new(340.0, 370.0))] { - let (q, ov) = t.snap_point(pointer, 4.0); - assert!((q - on).hypot() < 1e-9, "{q:?}"); - assert_eq!(lines(&ov), vec![(Point::new(300.0, 400.0), q)]); - } - // Two anchors moving, the middle one staying put: no telling which one the pointer drags, - // so neither keeps to the extension of its segment. - let id = d.alloc_id(); - let bent = vectorcraft_geom::PathData::single(SubPath::polyline( - &[Point::new(250.0, 450.0), Point::new(300.0, 400.0), Point::new(380.0, 340.0)], - false, - )); - d.insert(Some(l), 1, Node::path(id, bent, Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - s.anchors.insert(id, [(0, 0), (0, 2)].into_iter().collect()); - let t = Targets::for_anchor_drag(&d, &s); - assert_eq!(t.snap_point(Point::new(421.5, 312.0), 4.0), (Point::new(421.5, 312.0), vec![])); - } - - /// A segment nearly level (0.3°) is still straightened by dragging its end into line with - /// the other one; along its own direction, farther from level, it keeps to its extension. - #[test] - fn a_nearly_level_segment_still_lines_up_with_its_other_end() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - d.insert( - Some(l), - 1, - Node::path(id, vectorcraft_geom::shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 301.0)), Appearance::default_art()), - ) - .unwrap(); - let (mut s, p) = (Selection::default(), paint()); - s.set([id]); - s.anchors.insert(id, [(0, 1)].into_iter().collect()); - let t = Targets::for_anchor_drag(&d, &s).styled(&cx(&d, &s, &p)); - let (q, ov) = t.snap_point(Point::new(300.0, 300.0), 4.0); - assert_eq!((q, labels(&ov)), (Point::new(300.0, 300.0), vec!["align"])); - let (q, ov) = t.snap_point(Point::new(400.0, 301.5), 4.0); - assert!((q - Point::new(400.0, 301.5)).hypot() < 1e-9, "{q:?}"); - assert_eq!(lines(&ov), vec![(Point::new(100.0, 300.0), q)]); - } - - fn labels(ov: &[Overlay]) -> Vec<&str> { - ov.iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text.as_str()) } else { None }).collect() - } - - fn lines(ov: &[Overlay]) -> Vec<(Point, Point)> { - ov.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((*a, *b)) } else { None }).collect() - } - - /// A drawn point (#506): on another object's anchors, in line with them too (not only with its - /// bounds), onto its paths; Shift slides it along its 45° step into line; near a construction - /// guide through the point it leaves from it lands on the guide, where a target's line crosses - /// it if one does nearby. - #[test] - fn drawn_points_land_on_anchors_paths_alignments_and_construction_guides() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let t = Targets::for_drawing(&d, &[], None, false).styled(&c); - // The rect is 100..200: its corner, a point on its side, and in line with an anchor. - let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0); - assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"])); - let (q, ov) = t.snap_from(Point::new(202.0, 160.0), None, 4.0); - assert_eq!((q, labels(&ov)), (Point::new(200.0, 160.0), vec!["path"])); - let (q, ov) = t.snap_from(Point::new(330.0, 102.0), None, 4.0); - assert_eq!((q, labels(&ov)), (Point::new(330.0, 100.0), vec!["align"])); - // Shift from (300, 300): the pointer at 2° keeps to 0°, then slides onto x = 200. - let shift = Leave::segment(&c, Point::new(300.0, 300.0), true); - let (q, ov) = t.snap_from(Point::new(203.0, 303.0), Some(&shift), 4.0); - assert!((q - Point::new(200.0, 300.0)).hypot() < 1e-9, "{q:?}"); - assert_eq!(labels(&ov), ["align"]); - assert_eq!(lines(&ov).len(), 1, "{ov:?}"); - // Nothing in reach: on its angle, as far as the pointer. - let (q, ov) = t.snap_from(Point::new(300.0, 380.0), Some(&shift), 4.0); - assert_eq!((q, ov.len()), (Point::new(300.0, 380.0), 0)); - // Construction guides through (300, 320): onto the 135° one… - let from = Leave::segment(&c, Point::new(300.0, 320.0), false); - let (q, ov) = t.snap_from(Point::new(352.0, 373.0), Some(&from), 4.0); - assert!((q - Point::new(352.5, 372.5)).hypot() < 1e-9, "{q:?}"); - assert_eq!(lines(&ov), vec![(Point::new(300.0, 320.0), q)]); - // …where x = 200 crosses it. - let (q, ov) = t.snap_from(Point::new(203.0, 222.5), Some(&from), 4.0); - assert!((q - Point::new(200.0, 220.0)).hypot() < 1e-9, "{q:?}"); - assert_eq!((lines(&ov).len(), labels(&ov)), (2, vec!["align"])); - // Construction Guides off (or Smart Guides off): in line, not on a guide. - let off = ToolContext { construction_angles: &[], ..cx(&d, &s, &p) }; - let (q, _) = t.snap_from(Point::new(352.0, 373.0), Some(&Leave::segment(&off, Point::new(300.0, 320.0), false)), 4.0); - assert_eq!(q, Point::new(352.0, 373.0)); - // A box's corner keeps to a diagonal with Shift, the farther axis giving its size. - let (q, _) = t.snap_from(Point::new(340.0, 310.0), Some(&Leave::diagonal(Point::new(300.0, 300.0), true)), 4.0); - assert_eq!(q, Point::new(340.0, 340.0)); - } - - /// #1602: a drawn point lands where two paths cross, where a path crosses a ruler guide and - /// where two ruler guides cross, labelled "intersect"; an anchor nearby still wins. - #[test] - fn drawn_points_land_on_intersections() { - use vectorcraft_doc::{Appearance, Node}; - use vectorcraft_geom::shapes; - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - // A slanted line through the rect's top side (y = 100) at x = 150, and a circle across its - // right side (x = 200). - for path in [shapes::line(Point::new(145.0, 90.0), Point::new(165.0, 130.0)), shapes::ellipse(Rect::new(175.0, 130.0, 215.0, 170.0))] { - let id = d.alloc_id(); - d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap(); - } - d.guides.push(vectorcraft_doc::Guide::new(true, 400.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); - let (s, p) = (Selection::default(), paint()); - let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; - let t = Targets::for_drawing(&d, &[], None, false).styled(&c).with_rulers(&c); - let (q, ov) = t.snap_from(Point::new(152.0, 101.5), None, 4.0); - assert!((q - Point::new(150.0, 100.0)).hypot() < 1e-9, "{q:?}"); - assert_eq!(labels(&ov), ["intersect"]); - // The circle (curves, centre (195, 150), radius 20) crosses the rect's right side. - let (q, ov) = t.snap_from(Point::new(201.5, 131.5), None, 4.0); - assert!((q - Point::new(200.0, 150.0 - 375f64.sqrt())).hypot() < 0.02, "{q:?}"); - assert_eq!(labels(&ov), ["intersect"]); - // A ruler guide across a path, and two ruler guides. - let (q, ov) = t.snap_from(Point::new(101.0, 151.0), None, 4.0); - assert_eq!((q, labels(&ov)), (Point::new(100.0, 150.0), vec!["intersect"])); - let (q, ov) = t.snap_from(Point::new(402.0, 298.0), None, 4.0); - assert_eq!((q, labels(&ov)), (Point::new(400.0, 300.0), vec!["intersect"])); - // An anchor in reach wins, and a path alone is still "path". - assert_eq!(labels(&t.snap_from(Point::new(146.0, 91.0), None, 4.0).1), ["anchor"]); - assert_eq!(labels(&t.snap_from(Point::new(170.0, 201.0), None, 4.0).1), ["path"]); - } - - #[test] - fn perpendicular_snapping_rejects_a_curved_target() { - let (mut d, _) = doc_with_rect(); - let layer = d.layers[0].id; - let id = d.alloc_id(); - d.insert( - Some(layer), - 1, - vectorcraft_doc::Node::path( - id, - vectorcraft_geom::shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), - vectorcraft_doc::Appearance::default_art(), - ), - ) - .unwrap(); - let targets = Targets { tangent: false, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) }; - assert!(targets.touching(Point::new(400.0, 500.0), Point::new(450.0, 500.0), 4.0).is_none()); - assert!(targets.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), 4.0).is_some()); - } - - /// A line drawn from an anchor lands square to a segment (the foot of its perpendicular) and - /// just grazing a curve, with a guide and a label; the options switch each off, and the - /// result is the same each time and survives non-finite input. - #[test] - fn drawn_lines_land_perpendicular_to_segments_and_tangent_to_curves() { - use vectorcraft_doc::{Appearance, Node}; - use vectorcraft_geom::shapes; - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - d.insert(Some(l), 1, Node::path(id, shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), Appearance::default_art())).unwrap(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let snap = |c: &ToolContext, from: Point, to: Point| { - let leave = Leave::segment(c, from, false); - PointSnap::new(c, || Targets::for_drawing(&d, &[], None, false)).snap_from(c, to, Some(&leave)) - }; - // From (300, 160) the rect's right side (x = 200) is square at (200, 160). - let (q, ov) = snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)); - assert!((q - Point::new(200.0, 160.0)).hypot() < 1e-6, "{q:?}"); - assert_eq!(labels(&ov), ["perpendicular"]); - assert_eq!(lines(&ov), vec![(Point::new(300.0, 160.0), q)]); - assert!(ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "the square corner mark: {ov:?}"); - assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)).0, q, "deterministic"); - // Off, the point only lands on the path, where the pointer is. - let curved = Leave::segment(&c, Point::new(300.0, 160.0), false).without_contact(); - let (_, ov) = PointSnap::new(&c, || Targets::for_drawing(&d, &[], None, false)).snap_from(&c, Point::new(202.0, 163.0), Some(&curved)); - assert!(!labels(&ov).contains(&"perpendicular")); - let off = ToolContext { snap_perpendicular: false, ..cx(&d, &s, &p) }; - let (q, ov) = snap(&off, Point::new(300.0, 160.0), Point::new(202.0, 163.0)); - assert_eq!((q, labels(&ov)), (Point::new(200.0, 163.0), vec!["path"])); - // The circle (centre (500, 500), radius 50) seen from (400, 500): the tangent points are 60° - // round from the way back, at (475, 456.7) and (475, 543.3). - let (q, ov) = snap(&c, Point::new(400.0, 500.0), Point::new(477.0, 459.0)); - assert_eq!(labels(&ov), ["tangent"]); - assert!((q - Point::new(475.0, 500.0 - 1875f64.sqrt())).hypot() < 0.1, "{q:?}"); - assert!(((q - Point::new(400.0, 500.0)).dot(q - Point::new(500.0, 500.0))).abs() < 20.0, "{q:?}"); - let none = ToolContext { snap_tangent: false, ..cx(&d, &s, &p) }; - assert!(!labels(&snap(&none, Point::new(400.0, 500.0), Point::new(477.0, 459.0)).1).contains(&"tangent")); - // Out of reach, or with nothing finite to go on: left alone, no panic. - assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(250.0, 175.0)).0, Point::new(250.0, 175.0)); - let t = Targets::for_drawing(&d, &[], None, false); - let t = Targets { tangent: true, perpendicular: true, ..t }; - for bad in [f64::NAN, f64::INFINITY] { - assert!(t.touching(Point::new(bad, 1.0), Point::new(200.0, 160.0), 4.0).is_none()); - assert!(t.touching(Point::new(300.0, 160.0), Point::new(bad, 1.0), 4.0).is_none()); - assert!(t.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), bad).is_none()); - } - } - - /// A box dragged from a corner (Rectangle, Ellipse, ...) never snaps square to a segment, and - /// a busy spot does not hide the contact: every segment in reach is tried. - #[test] - fn contact_snaps_only_for_segments_and_in_busy_spots() { - use vectorcraft_geom::kurbo::Line; - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let snap = |t: &Targets, leave: &Leave| t.snap_from(Point::new(202.0, 163.0), Some(leave), 4.0); - let t = Targets { tangent: true, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) }; - let (_, ov) = snap(&t, &Leave::segment(&c, Point::new(300.0, 160.0), false)); - assert_eq!(labels(&ov), ["perpendicular"]); - let (_, ov) = snap(&t, &Leave::diagonal(Point::new(300.0, 160.0), false)); - assert!(!labels(&ov).contains(&"perpendicular"), "{ov:?}"); - // 70 short segments near the pointer, none square to the leave point, then the rect's side. - let mut busy = t; - let mut segments: Vec<(Rect, PathSeg)> = (0..70) - .map(|i| { - let y = 160.0 + f64::from(i) * 0.01; - (Rect::new(199.0, y, 205.0, y + 0.001), PathSeg::Line(Line::new((199.0, y), (205.0, y + 0.001)))) - }) - .collect(); - segments.extend(busy.segments.iter().cloned()); - busy.segments = segments; - let (_, ov) = snap(&busy, &Leave::segment(&c, Point::new(300.0, 160.0), false)); - assert_eq!(labels(&ov), ["perpendicular"]); - } - - /// The angle sets of Preferences › Smart Guides › Construction Guides. - #[test] - fn construction_angle_sets() { - assert_eq!(construction_angles(DEFAULT_CONSTRUCTION_ANGLES), [0.0, 45.0, 90.0, 135.0]); - assert_eq!(construction_angles("90° Angles"), [0.0, 90.0]); - assert_eq!(construction_angles("30° Angles"), [30.0, 60.0, 120.0, 150.0]); - assert_eq!(construction_angles("90° & 45° & 30° Angles").len(), 8); - assert_eq!(construction_angles("nonsense"), construction_angles(DEFAULT_CONSTRUCTION_ANGLES)); - } - - /// The sorted targets find by bisection just what a scan of all of them finds, whatever - /// gathered them. - #[test] - fn bisection_finds_what_a_scan_finds() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - for i in 0..60 { - let (x, y) = ((i * 37 % 450) as f64, (i * 53 % 430) as f64); - let tri = vectorcraft_geom::PathData::single(SubPath::polyline( - &[Point::new(x, y), Point::new(x + 13.0, y + 3.0), Point::new(x + 5.0, y + 17.0)], - true, - )); - let n = d.alloc_id(); - d.insert(Some(l), 1, Node::path(n, tri, Appearance::default_art())).unwrap(); - } - let mut s = Selection::default(); - s.set([id]); - s.anchors.insert(id, [(0, 1)].into_iter().collect()); - for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None, false), Targets::for_anchor_drag(&d, &s)] { - for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) { - let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q))); - assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}"); - for (lines, v) in [(&t.xs, q.x), (&t.ys, q.y)] { - let mut near: Vec = lines_near(lines, v, 6.0).iter().map(|l| l.0).collect(); - let mut all: Vec = lines.iter().map(|l| l.0).filter(|w| (w - v).abs() <= 6.0).collect(); - near.sort_by(f64::total_cmp); - all.sort_by(f64::total_cmp); - assert_eq!(near, all); - } - } - } - } - - /// #812: with Smart Guides on, ruler guides pull a drawn point too (Snap to Point), horizontal - /// and vertical alike, also while it slides along a construction guide or Shift's angle. - #[test] - fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() { - let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 313.0)); - d.guides.push(vectorcraft_doc::Guide::new(true, 331.0)); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut snap = DrawSnap::default(); - assert_eq!(snap.press(&c, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 313.0), "the horizontal guide"); - assert_eq!(snap.press(&c, Point::new(329.0, 260.0), &[], None), Point::new(331.0, 260.0), "the vertical guide"); - // On the 45° construction guide from (210, 210), where the horizontal guide crosses it. - let from = Leave::segment(&c, Point::new(210.0, 210.0), false); - let q = snap.press(&c, Point::new(312.0, 311.0), &[], Some(&from)); - assert!((q - Point::new(313.0, 313.0)).hypot() < 1e-9, "{q:?}"); - // Snap to Point off: Smart Guides alone don't use the ruler guides. - let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }; - assert_eq!(snap.press(&off, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 315.0)); - } - - /// A drawing tool's snapper reuses its targets while hovering over the same document state, and - /// gathers them again when it changes, and on every press. - #[test] - fn draw_snap_gathers_once_per_document_state() { - let (d, _) = doc_with_rect(); - let empty = vectorcraft_doc::Document::new(500.0, 500.0); - let (s, p) = (Selection::default(), paint()); - let at = Point::new(202.0, 198.0); - let mut snap = DrawSnap::default(); - let c = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; - assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0)); - assert_eq!(labels(snap.guides()), ["anchor"]); - // The same revision: the targets are the ones gathered (the document isn't read again). - let c = ToolContext { revision: (1, 1), ..cx(&empty, &s, &p) }; - assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0)); - // A new revision, or a press, reads it. - let c = ToolContext { revision: (1, 2), ..cx(&empty, &s, &p) }; - assert_eq!(snap.hover(&c, at, &[], None), at); - assert!(snap.guides().is_empty()); - let c = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - assert_eq!(snap.press(&c, at, &[], None), Point::new(200.0, 200.0)); - // The drag keeps what the press gathered, though the document now shows its preview. - let c = ToolContext { revision: (1, 3), ..cx(&empty, &s, &p) }; - assert_eq!(snap.drag(&c, at, None), Point::new(200.0, 200.0)); - snap.clear(); - assert!(snap.guides().is_empty()); - // Snap to Grid wins over Smart Guides, Shift still keeping the angle. - let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - let from = Leave::segment(&c, Point::ZERO, true); - let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from)); - assert!((q.y - 0.0).abs() < 1e-9, "{q:?}"); - } -} +mod tests; diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs new file mode 100644 index 000000000..9ed34c4fc --- /dev/null +++ b/crates/tools/src/guides/tests.rs @@ -0,0 +1,617 @@ +use super::*; +use crate::testutil::*; + +#[test] +fn artboard_bleed_is_a_target() { + let (mut d, _) = doc_with_rect(); + let t = Targets::collect(&d, &[], None); + assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 512.0, "no bleed, nothing near"); + d.setup.bleed = [10.0; 4]; + let t = Targets::collect(&d, &[], None); + assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 510.0); +} + +#[test] +fn snaps_to_anchor_and_alignment() { + let (d, _) = doc_with_rect(); + let t = Targets::collect(&d, &[], None); + let (p, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); + assert_eq!(p, Point::new(100.0, 100.0)); + assert!(matches!(&ov[0], Overlay::Label { text, .. } if text == "anchor")); + let (p, _) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(p.y, 200.0); +} + +#[test] +fn rect_snap_aligns_edges() { + let (d, id) = doc_with_rect(); + let t = Targets::collect(&d, &[id], None); + // Artboard is 0..500; a rect at x0=2 snaps to the artboard's left edge. + let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); + assert_eq!(dv.x, -2.0); + assert!(!ov.is_empty()); +} + +#[test] +fn scale_snap_lands_the_moved_edges_on_targets() { + use crate::bbox::scale_for_drag; + let (d, _) = doc_with_rect(); + // The document's rect is 100..200: a box beside it is resized against it. + let t = Targets::collect(&d, &[], None); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + let snap = |h: Handle, p: Point, shift: bool, alt: bool| { + let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0); + (a.transform_rect_bbox(r), ov) + }; + // The bottom edge lands on the neighbour's bottom: same height. + let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { .. })).count(); + let align = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")); + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 197.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 200.0)); + assert_eq!(lines(&ov), 1); + assert!(align(&ov), "{ov:?}"); + // A corner snaps each axis on its own: y to the neighbour, x stays free. + let (nr, ov) = snap(Handle::BottomRight, Point::new(330.0, 202.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 330.0, 200.0)); + assert_eq!(lines(&ov), 1); + // Proportional: the snapped axis carries the other one. + let (nr, _) = snap(Handle::BottomRight, Point::new(340.0, 198.0), true, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 350.0, 200.0)); + // From the centre the dragged edge still lands on the target. + let (nr, _) = snap(Handle::Bottom, Point::new(275.0, 198.0), false, true); + assert_eq!(nr, Rect::new(250.0, 50.0, 300.0, 200.0)); + // Out of reach, and the fixed edge's own target: untouched. + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 180.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 180.0)); + assert!(ov.is_empty()); + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 102.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0)); + assert!(ov.is_empty()); +} + +/// A resize handle lands on another object's anchor point (#482), not only in line with its +/// edges and centre, and says so; a side handle stops where its way passes the anchor. +#[test] +fn scale_snap_lands_the_handle_on_anchors() { + use crate::bbox::scale_for_drag; + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + // A triangle whose apex (320, 400) lines up with none of the edges or centres on x. + let tri = vectorcraft_geom::PathData::single(SubPath::polyline( + &[Point::new(300.0, 300.0), Point::new(380.0, 300.0), Point::new(320.0, 400.0)], + true, + )); + d.insert(Some(l), 1, Node::path(id, tri, Appearance::default_art())).unwrap(); + let t = Targets::collect(&d, &[], None); + let snap = |r: Rect, h: Handle, p: Point, shift: bool| { + let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, false), shift, false, 4.0); + (a.transform_rect_bbox(r), ov) + }; + let anchor = |ov: &[Overlay]| matches!(ov, [Overlay::Label { text, p, .. }] if text == "anchor" && *p == Point::new(320.0, 400.0)); + // A free corner takes the anchor's x and y. + let (nr, ov) = snap(Rect::new(250.0, 350.0, 300.0, 390.0), Handle::BottomRight, Point::new(322.0, 398.0), false); + assert_eq!(nr, Rect::new(250.0, 350.0, 320.0, 400.0)); + assert!(anchor(&ov), "{ov:?}"); + // The right side, at the apex's height: its x lands on the anchor. + let (nr, ov) = snap(Rect::new(250.0, 380.0, 300.0, 420.0), Handle::Right, Point::new(318.0, 401.0), false); + assert_eq!(nr, Rect::new(250.0, 380.0, 320.0, 420.0)); + assert!(anchor(&ov), "{ov:?}"); + // A proportional corner slides along its diagonal onto it. + let (nr, ov) = snap(Rect::new(240.0, 320.0, 300.0, 380.0), Handle::BottomRight, Point::new(318.0, 398.0), true); + assert_eq!(nr, Rect::new(240.0, 320.0, 320.0, 400.0)); + assert!(anchor(&ov), "{ov:?}"); +} + +/// Smart Guides › Spacing Guides (#394): a moved selection lands as far from its neighbour as +/// two other objects of its row are apart, or halfway between its two neighbours, and the +/// equal gaps are marked; up and down the same in a column. Off, it stays where it is dragged. +#[test] +fn spacing_guides_space_a_moved_selection_evenly() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + // A and B 20 apart in a row at y 300–340, C and D 30 apart in a column at x 400–440. + for r in [ + Rect::new(20.0, 300.0, 60.0, 340.0), + Rect::new(80.0, 300.0, 120.0, 340.0), + Rect::new(400.0, 20.0, 440.0, 50.0), + Rect::new(400.0, 80.0, 440.0, 110.0), + ] { + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Default::default())).unwrap(); + } + let (s, p) = (Selection::default(), paint()); + let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count(); + let t = Targets::for_move(&cx(&d, &s, &p), &s.objects); + // 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each). + let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); + assert_eq!(dv, Vec2::new(-1.0, 0.0)); + assert_eq!(lines(&ov), 6, "{ov:?}"); + assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (120.0, 140.0) && a.y == b.y)), "{ov:?}"); + assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (60.0, 80.0) && a.y == b.y)), "{ov:?}"); + // Halfway between B and a box at 220–260: 50 either side. + let mut wide = d.clone(); + let id = wide.alloc_id(); + wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default())) + .unwrap(); + let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); + assert_eq!(dv, Vec2::new(2.0, 0.0)); + assert!(lines(&ov) >= 6, "{ov:?}"); + // A column: 28 under D, as C and D are 30 apart. + let (dv, ov) = t.snap_rect(Rect::new(405.0, 138.0, 445.0, 168.0), 4.0); + assert_eq!(dv, Vec2::new(0.0, 2.0)); + assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.y, b.y) == (110.0, 140.0) && a.x == b.x)), "{ov:?}"); + // Above C, 32 over it: 30. + let (dv, _) = t.snap_rect(Rect::new(405.0, -42.0, 445.0, -12.0), 4.0); + assert_eq!(dv, Vec2::new(0.0, 2.0)); + // Off: nowhere to go, and no marks. + let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) }; + let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); + assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0)); +} + +/// Smart Guides › Spacing Guides (#394): resizing by a side handle snaps the moving edge to the +/// same gap as the neighbours in its row, with the gaps marked. +#[test] +fn spacing_guides_resize_snaps_the_moving_edge_to_equal_gaps() { + use crate::bbox::scale_for_drag; + use vectorcraft_doc::{Appearance, Node}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + for r in [Rect::new(20.0, 300.0, 60.0, 340.0), Rect::new(80.0, 300.0, 120.0, 340.0)] { + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + } + let r = Rect::new(141.0, 305.0, 181.0, 345.0); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + let s = Selection { objects: vec![id], ..Default::default() }; + let t = Targets::for_scale(&cx(&d, &s, &paint())); + let (a, ov) = t.snap_scale( + &OrientedBox::aligned(r), + Handle::Left, + scale_for_drag(r, Handle::Left, Point::new(139.0, 325.0), false, false), + false, + false, + 4.0, + ); + assert_eq!(a.transform_rect_bbox(r).x0, 140.0, "20 pt from the second square"); + let magenta = |o: &Overlay| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA); + assert!(ov.iter().any(magenta), "{ov:?}"); +} + +/// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and +/// Anchor/Path Labels off, change what shows and never where a point lands. +#[test] +fn display_options_colour_and_hide_the_guides_but_keep_the_snap() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let green = [0x00, 0xff, 0x00]; + let c = ToolContext { smart_guide_color: green, ..cx(&d, &s, &p) }; + let t = Targets::collect(&d, &[], None).styled(&c); + let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); + assert_eq!(q, Point::new(100.0, 100.0)); + assert!(matches!(&ov[0], Overlay::Label { text, color, .. } if text == "anchor" && *color == green), "{ov:?}"); + let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(q.y, 200.0); + assert!(ov.iter().all(|o| matches!(o, Overlay::Line { color, .. } | Overlay::Label { color, .. } if *color == green)), "{ov:?}"); + assert!( + ov.iter().any(|o| matches!(o, Overlay::Line { .. })) && ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")) + ); + // Alignment Guides off: the point still lines up and says so, without the line. + let c = ToolContext { alignment_guides: false, ..cx(&d, &s, &p) }; + let t = Targets::collect(&d, &[], None).styled(&c); + let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(q.y, 200.0); + assert!(!ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "{ov:?}"); + assert!(ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")), "{ov:?}"); + let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); + assert_eq!((dv.x, ov.len()), (-2.0, 0)); + // A handle already in line moves nowhere, yet is still "align". + let (q, ov) = t.snap_point_with(Point::new(400.0, 200.0), &[], 4.0); + assert_eq!(q, Point::new(400.0, 200.0)); + assert!(matches!(&ov[..], [Overlay::Label { text, .. }] if text == "align"), "{ov:?}"); + // Anchor/Path Labels off: on the anchor, without saying so. + let c = ToolContext { anchor_path_labels: false, ..cx(&d, &s, &p) }; + let t = Targets::collect(&d, &[], None).styled(&c); + let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); + assert_eq!((q, ov.len()), (Point::new(100.0, 100.0), 0)); + let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(q.y, 200.0); + assert!(ov.iter().all(|o| matches!(o, Overlay::Line { .. })) && !ov.is_empty(), "{ov:?}"); + assert_eq!(t.snap_guide(false, 199.0, 4.0), (200.0, vec![])); +} + +/// Dragging an anchor of a path: its other anchors and the segments not touching it stay +/// targets, but neither it nor the path's bounds (which move) are. +#[test] +fn anchor_drag_targets_leave_out_what_moves() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + s.anchors.insert(id, [(0, 0)].into_iter().collect()); + let t = Targets::for_anchor_drag(&d, &s); + let anchors: Vec = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect(); + assert_eq!(anchors, [Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(200.0, 200.0)], "by x"); + assert_eq!(t.segments.len(), 2); + assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves"); + assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself"); +} + +/// A Shaper Group's original shapes, which are not shown, are no targets: not their +/// anchors, edges or segments, not their bounds as an object a moved face spaces itself evenly +/// with, and not the anchors a transform tool's reference point snaps to on the selected group. +/// Selected, or under the pointer in Construction mode, an original shape still gives its anchors. +#[test] +fn shaper_sources_are_no_targets() { + let (d, g, sources, face) = doc_with_shaper_group(); + let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id; + // The top right corner of the second source square, which no shown art has. + let corner = Point::new(150.0, 300.0); + let gives_corner = |t: &Targets| { + t.points.iter().any(|(p, _)| *p == corner) + || t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == corner) + || t.segments.iter().any(|(_, s)| s.start() == corner || s.end() == corner) + }; + assert!(!gives_corner(&Targets::collect(&d, &[], None))); + assert!(!gives_corner(&Targets::for_drawing(&d, &[], None, false))); + assert!(!gives_corner(&Targets::for_anchor_drag(&d, &Selection::default()))); + assert!(!gives_corner(&Targets::for_handle(&d, face))); + assert!(!object_boxes(&d, &[face]).contains(&Rect::new(0.0, 300.0, 150.0, 400.0)), "the source shapes' bounds"); + let near = Rect::new(146.0, 296.0, 154.0, 304.0); + assert!(!gives_corner(&Targets::points_near(&d, &[g], near))); + assert!(gives_corner(&Targets::points_near(&d, &[b], near))); +} + +/// #2120: the dragged end of a straight segment at any angle lands on the segment's own +/// extension (Line Extension), past its other end or short of it, the guide drawn from that +/// end; with two anchors moving there is none to keep to. +#[test] +fn dragged_end_of_a_slanted_line_keeps_to_its_extension() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + // From (300, 400) up to (380, 340): along (4, -3), about 37° up. + d.insert( + Some(l), + 1, + Node::path(id, vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0)), Appearance::default_art()), + ) + .unwrap(); + let (mut s, p) = (Selection::default(), paint()); + s.set([id]); + s.anchors.insert(id, [(0, 1)].into_iter().collect()); + let t = Targets::for_anchor_drag(&d, &s).styled(&cx(&d, &s, &p)); + // 150 along it (2.5 off), and 50 along it (shorter). + for (pointer, on) in [(Point::new(421.5, 312.0), Point::new(420.0, 310.0)), (Point::new(341.5, 372.0), Point::new(340.0, 370.0))] { + let (q, ov) = t.snap_point(pointer, 4.0); + assert!((q - on).hypot() < 1e-9, "{q:?}"); + assert_eq!(lines(&ov), vec![(Point::new(300.0, 400.0), q)]); + } + // Two anchors moving, the middle one staying put: no telling which one the pointer drags, + // so neither keeps to the extension of its segment. + let id = d.alloc_id(); + let bent = vectorcraft_geom::PathData::single(SubPath::polyline( + &[Point::new(250.0, 450.0), Point::new(300.0, 400.0), Point::new(380.0, 340.0)], + false, + )); + d.insert(Some(l), 1, Node::path(id, bent, Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + s.anchors.insert(id, [(0, 0), (0, 2)].into_iter().collect()); + let t = Targets::for_anchor_drag(&d, &s); + assert_eq!(t.snap_point(Point::new(421.5, 312.0), 4.0), (Point::new(421.5, 312.0), vec![])); +} + +/// A segment nearly level (0.3°) is still straightened by dragging its end into line with +/// the other one; along its own direction, farther from level, it keeps to its extension. +#[test] +fn a_nearly_level_segment_still_lines_up_with_its_other_end() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert( + Some(l), + 1, + Node::path(id, vectorcraft_geom::shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 301.0)), Appearance::default_art()), + ) + .unwrap(); + let (mut s, p) = (Selection::default(), paint()); + s.set([id]); + s.anchors.insert(id, [(0, 1)].into_iter().collect()); + let t = Targets::for_anchor_drag(&d, &s).styled(&cx(&d, &s, &p)); + let (q, ov) = t.snap_point(Point::new(300.0, 300.0), 4.0); + assert_eq!((q, labels(&ov)), (Point::new(300.0, 300.0), vec!["align"])); + let (q, ov) = t.snap_point(Point::new(400.0, 301.5), 4.0); + assert!((q - Point::new(400.0, 301.5)).hypot() < 1e-9, "{q:?}"); + assert_eq!(lines(&ov), vec![(Point::new(100.0, 300.0), q)]); +} + +fn labels(ov: &[Overlay]) -> Vec<&str> { + ov.iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text.as_str()) } else { None }).collect() +} + +fn lines(ov: &[Overlay]) -> Vec<(Point, Point)> { + ov.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((*a, *b)) } else { None }).collect() +} + +/// A drawn point (#506): on another object's anchors, in line with them too (not only with its +/// bounds), onto its paths; Shift slides it along its 45° step into line; near a construction +/// guide through the point it leaves from it lands on the guide, where a target's line crosses +/// it if one does nearby. +#[test] +fn drawn_points_land_on_anchors_paths_alignments_and_construction_guides() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let t = Targets::for_drawing(&d, &[], None, false).styled(&c); + // The rect is 100..200: its corner, a point on its side, and in line with an anchor. + let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"])); + let (q, ov) = t.snap_from(Point::new(202.0, 160.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(200.0, 160.0), vec!["path"])); + let (q, ov) = t.snap_from(Point::new(330.0, 102.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(330.0, 100.0), vec!["align"])); + // Shift from (300, 300): the pointer at 2° keeps to 0°, then slides onto x = 200. + let shift = Leave::segment(&c, Point::new(300.0, 300.0), true); + let (q, ov) = t.snap_from(Point::new(203.0, 303.0), Some(&shift), 4.0); + assert!((q - Point::new(200.0, 300.0)).hypot() < 1e-9, "{q:?}"); + assert_eq!(labels(&ov), ["align"]); + assert_eq!(lines(&ov).len(), 1, "{ov:?}"); + // Nothing in reach: on its angle, as far as the pointer. + let (q, ov) = t.snap_from(Point::new(300.0, 380.0), Some(&shift), 4.0); + assert_eq!((q, ov.len()), (Point::new(300.0, 380.0), 0)); + // Construction guides through (300, 320): onto the 135° one… + let from = Leave::segment(&c, Point::new(300.0, 320.0), false); + let (q, ov) = t.snap_from(Point::new(352.0, 373.0), Some(&from), 4.0); + assert!((q - Point::new(352.5, 372.5)).hypot() < 1e-9, "{q:?}"); + assert_eq!(lines(&ov), vec![(Point::new(300.0, 320.0), q)]); + // …where x = 200 crosses it. + let (q, ov) = t.snap_from(Point::new(203.0, 222.5), Some(&from), 4.0); + assert!((q - Point::new(200.0, 220.0)).hypot() < 1e-9, "{q:?}"); + assert_eq!((lines(&ov).len(), labels(&ov)), (2, vec!["align"])); + // Construction Guides off (or Smart Guides off): in line, not on a guide. + let off = ToolContext { construction_angles: &[], ..cx(&d, &s, &p) }; + let (q, _) = t.snap_from(Point::new(352.0, 373.0), Some(&Leave::segment(&off, Point::new(300.0, 320.0), false)), 4.0); + assert_eq!(q, Point::new(352.0, 373.0)); + // A box's corner keeps to a diagonal with Shift, the farther axis giving its size. + let (q, _) = t.snap_from(Point::new(340.0, 310.0), Some(&Leave::diagonal(Point::new(300.0, 300.0), true)), 4.0); + assert_eq!(q, Point::new(340.0, 340.0)); +} + +/// #1602: a drawn point lands where two paths cross, where a path crosses a ruler guide and +/// where two ruler guides cross, labelled "intersect"; an anchor nearby still wins. +#[test] +fn drawn_points_land_on_intersections() { + use vectorcraft_doc::{Appearance, Node}; + use vectorcraft_geom::shapes; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + // A slanted line through the rect's top side (y = 100) at x = 150, and a circle across its + // right side (x = 200). + for path in [shapes::line(Point::new(145.0, 90.0), Point::new(165.0, 130.0)), shapes::ellipse(Rect::new(175.0, 130.0, 215.0, 170.0))] { + let id = d.alloc_id(); + d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap(); + } + d.guides.push(vectorcraft_doc::Guide::new(true, 400.0)); + d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; + let t = Targets::for_drawing(&d, &[], None, false).styled(&c).with_rulers(&c); + let (q, ov) = t.snap_from(Point::new(152.0, 101.5), None, 4.0); + assert!((q - Point::new(150.0, 100.0)).hypot() < 1e-9, "{q:?}"); + assert_eq!(labels(&ov), ["intersect"]); + // The circle (curves, centre (195, 150), radius 20) crosses the rect's right side. + let (q, ov) = t.snap_from(Point::new(201.5, 131.5), None, 4.0); + assert!((q - Point::new(200.0, 150.0 - 375f64.sqrt())).hypot() < 0.02, "{q:?}"); + assert_eq!(labels(&ov), ["intersect"]); + // A ruler guide across a path, and two ruler guides. + let (q, ov) = t.snap_from(Point::new(101.0, 151.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(100.0, 150.0), vec!["intersect"])); + let (q, ov) = t.snap_from(Point::new(402.0, 298.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(400.0, 300.0), vec!["intersect"])); + // An anchor in reach wins, and a path alone is still "path". + assert_eq!(labels(&t.snap_from(Point::new(146.0, 91.0), None, 4.0).1), ["anchor"]); + assert_eq!(labels(&t.snap_from(Point::new(170.0, 201.0), None, 4.0).1), ["path"]); +} + +#[test] +fn perpendicular_snapping_rejects_a_curved_target() { + let (mut d, _) = doc_with_rect(); + let layer = d.layers[0].id; + let id = d.alloc_id(); + d.insert( + Some(layer), + 1, + vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), + vectorcraft_doc::Appearance::default_art(), + ), + ) + .unwrap(); + let targets = Targets { tangent: false, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) }; + assert!(targets.touching(Point::new(400.0, 500.0), Point::new(450.0, 500.0), 4.0).is_none()); + assert!(targets.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), 4.0).is_some()); +} + +/// A line drawn from an anchor lands square to a segment (the foot of its perpendicular) and +/// just grazing a curve, with a guide and a label; the options switch each off, and the +/// result is the same each time and survives non-finite input. +#[test] +fn drawn_lines_land_perpendicular_to_segments_and_tangent_to_curves() { + use vectorcraft_doc::{Appearance, Node}; + use vectorcraft_geom::shapes; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 1, Node::path(id, shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let snap = |c: &ToolContext, from: Point, to: Point| { + let leave = Leave::segment(c, from, false); + PointSnap::new(c, || Targets::for_drawing(&d, &[], None, false)).snap_from(c, to, Some(&leave)) + }; + // From (300, 160) the rect's right side (x = 200) is square at (200, 160). + let (q, ov) = snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)); + assert!((q - Point::new(200.0, 160.0)).hypot() < 1e-6, "{q:?}"); + assert_eq!(labels(&ov), ["perpendicular"]); + assert_eq!(lines(&ov), vec![(Point::new(300.0, 160.0), q)]); + assert!(ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "the square corner mark: {ov:?}"); + assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)).0, q, "deterministic"); + // Off, the point only lands on the path, where the pointer is. + let curved = Leave::segment(&c, Point::new(300.0, 160.0), false).without_contact(); + let (_, ov) = PointSnap::new(&c, || Targets::for_drawing(&d, &[], None, false)).snap_from(&c, Point::new(202.0, 163.0), Some(&curved)); + assert!(!labels(&ov).contains(&"perpendicular")); + let off = ToolContext { snap_perpendicular: false, ..cx(&d, &s, &p) }; + let (q, ov) = snap(&off, Point::new(300.0, 160.0), Point::new(202.0, 163.0)); + assert_eq!((q, labels(&ov)), (Point::new(200.0, 163.0), vec!["path"])); + // The circle (centre (500, 500), radius 50) seen from (400, 500): the tangent points are 60° + // round from the way back, at (475, 456.7) and (475, 543.3). + let (q, ov) = snap(&c, Point::new(400.0, 500.0), Point::new(477.0, 459.0)); + assert_eq!(labels(&ov), ["tangent"]); + assert!((q - Point::new(475.0, 500.0 - 1875f64.sqrt())).hypot() < 0.1, "{q:?}"); + assert!(((q - Point::new(400.0, 500.0)).dot(q - Point::new(500.0, 500.0))).abs() < 20.0, "{q:?}"); + let none = ToolContext { snap_tangent: false, ..cx(&d, &s, &p) }; + assert!(!labels(&snap(&none, Point::new(400.0, 500.0), Point::new(477.0, 459.0)).1).contains(&"tangent")); + // Out of reach, or with nothing finite to go on: left alone, no panic. + assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(250.0, 175.0)).0, Point::new(250.0, 175.0)); + let t = Targets::for_drawing(&d, &[], None, false); + let t = Targets { tangent: true, perpendicular: true, ..t }; + for bad in [f64::NAN, f64::INFINITY] { + assert!(t.touching(Point::new(bad, 1.0), Point::new(200.0, 160.0), 4.0).is_none()); + assert!(t.touching(Point::new(300.0, 160.0), Point::new(bad, 1.0), 4.0).is_none()); + assert!(t.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), bad).is_none()); + } +} + +/// A box dragged from a corner (Rectangle, Ellipse, ...) never snaps square to a segment, and +/// a busy spot does not hide the contact: every segment in reach is tried. +#[test] +fn contact_snaps_only_for_segments_and_in_busy_spots() { + use vectorcraft_geom::kurbo::Line; + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let snap = |t: &Targets, leave: &Leave| t.snap_from(Point::new(202.0, 163.0), Some(leave), 4.0); + let t = Targets { tangent: true, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) }; + let (_, ov) = snap(&t, &Leave::segment(&c, Point::new(300.0, 160.0), false)); + assert_eq!(labels(&ov), ["perpendicular"]); + let (_, ov) = snap(&t, &Leave::diagonal(Point::new(300.0, 160.0), false)); + assert!(!labels(&ov).contains(&"perpendicular"), "{ov:?}"); + // 70 short segments near the pointer, none square to the leave point, then the rect's side. + let mut busy = t; + let mut segments: Vec<(Rect, PathSeg)> = (0..70) + .map(|i| { + let y = 160.0 + f64::from(i) * 0.01; + (Rect::new(199.0, y, 205.0, y + 0.001), PathSeg::Line(Line::new((199.0, y), (205.0, y + 0.001)))) + }) + .collect(); + segments.extend(busy.segments.iter().cloned()); + busy.segments = segments; + let (_, ov) = snap(&busy, &Leave::segment(&c, Point::new(300.0, 160.0), false)); + assert_eq!(labels(&ov), ["perpendicular"]); +} + +/// The angle sets of Preferences › Smart Guides › Construction Guides. +#[test] +fn construction_angle_sets() { + assert_eq!(construction_angles(DEFAULT_CONSTRUCTION_ANGLES), [0.0, 45.0, 90.0, 135.0]); + assert_eq!(construction_angles("90° Angles"), [0.0, 90.0]); + assert_eq!(construction_angles("30° Angles"), [30.0, 60.0, 120.0, 150.0]); + assert_eq!(construction_angles("90° & 45° & 30° Angles").len(), 8); + assert_eq!(construction_angles("nonsense"), construction_angles(DEFAULT_CONSTRUCTION_ANGLES)); +} + +/// The sorted targets find by bisection just what a scan of all of them finds, whatever +/// gathered them. +#[test] +fn bisection_finds_what_a_scan_finds() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + for i in 0..60 { + let (x, y) = ((i * 37 % 450) as f64, (i * 53 % 430) as f64); + let tri = vectorcraft_geom::PathData::single(SubPath::polyline( + &[Point::new(x, y), Point::new(x + 13.0, y + 3.0), Point::new(x + 5.0, y + 17.0)], + true, + )); + let n = d.alloc_id(); + d.insert(Some(l), 1, Node::path(n, tri, Appearance::default_art())).unwrap(); + } + let mut s = Selection::default(); + s.set([id]); + s.anchors.insert(id, [(0, 1)].into_iter().collect()); + for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None, false), Targets::for_anchor_drag(&d, &s)] { + for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) { + let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q))); + assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}"); + for (lines, v) in [(&t.xs, q.x), (&t.ys, q.y)] { + let mut near: Vec = lines_near(lines, v, 6.0).iter().map(|l| l.0).collect(); + let mut all: Vec = lines.iter().map(|l| l.0).filter(|w| (w - v).abs() <= 6.0).collect(); + near.sort_by(f64::total_cmp); + all.sort_by(f64::total_cmp); + assert_eq!(near, all); + } + } + } +} + +/// #812: with Smart Guides on, ruler guides pull a drawn point too (Snap to Point), horizontal +/// and vertical alike, also while it slides along a construction guide or Shift's angle. +#[test] +fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() { + let (mut d, _) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 313.0)); + d.guides.push(vectorcraft_doc::Guide::new(true, 331.0)); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut snap = DrawSnap::default(); + assert_eq!(snap.press(&c, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 313.0), "the horizontal guide"); + assert_eq!(snap.press(&c, Point::new(329.0, 260.0), &[], None), Point::new(331.0, 260.0), "the vertical guide"); + // On the 45° construction guide from (210, 210), where the horizontal guide crosses it. + let from = Leave::segment(&c, Point::new(210.0, 210.0), false); + let q = snap.press(&c, Point::new(312.0, 311.0), &[], Some(&from)); + assert!((q - Point::new(313.0, 313.0)).hypot() < 1e-9, "{q:?}"); + // Snap to Point off: Smart Guides alone don't use the ruler guides. + let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }; + assert_eq!(snap.press(&off, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 315.0)); +} + +/// A drawing tool's snapper reuses its targets while hovering over the same document state, and +/// gathers them again when it changes, and on every press. +#[test] +fn draw_snap_gathers_once_per_document_state() { + let (d, _) = doc_with_rect(); + let empty = vectorcraft_doc::Document::new(500.0, 500.0); + let (s, p) = (Selection::default(), paint()); + let at = Point::new(202.0, 198.0); + let mut snap = DrawSnap::default(); + let c = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0)); + assert_eq!(labels(snap.guides()), ["anchor"]); + // The same revision: the targets are the ones gathered (the document isn't read again). + let c = ToolContext { revision: (1, 1), ..cx(&empty, &s, &p) }; + assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0)); + // A new revision, or a press, reads it. + let c = ToolContext { revision: (1, 2), ..cx(&empty, &s, &p) }; + assert_eq!(snap.hover(&c, at, &[], None), at); + assert!(snap.guides().is_empty()); + let c = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + assert_eq!(snap.press(&c, at, &[], None), Point::new(200.0, 200.0)); + // The drag keeps what the press gathered, though the document now shows its preview. + let c = ToolContext { revision: (1, 3), ..cx(&empty, &s, &p) }; + assert_eq!(snap.drag(&c, at, None), Point::new(200.0, 200.0)); + snap.clear(); + assert!(snap.guides().is_empty()); + // Snap to Grid wins over Smart Guides, Shift still keeping the angle. + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let from = Leave::segment(&c, Point::ZERO, true); + let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from)); + assert!((q.y - 0.0).abs() < 1e-9, "{q:?}"); +} From fcb227a87c470dbe8bf7e75daeac16d3e1abc333 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:55 +0000 Subject: [PATCH 041/199] refactor(ui-egui): move panels/tabs.rs tests to panels/tabs/tests.rs --- crates/ui-egui/src/panels/tabs.rs | 616 +----------------------- crates/ui-egui/src/panels/tabs/tests.rs | 613 +++++++++++++++++++++++ 2 files changed, 614 insertions(+), 615 deletions(-) create mode 100644 crates/ui-egui/src/panels/tabs/tests.rs diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 55f3500e4..455bfb583 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -346,618 +346,4 @@ fn snap_to_unit_item(ui: &mut Ui) { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_engine::Session; - - fn frame(app: &mut VectorcraftApp) { - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - show(app, ui); - menu(app, ui); - }); - out.textures_delta.clear(); - } - - #[test] - fn panel_draws_empty_with_text_and_with_stops() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - frame(&mut app); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb", "area": {"width": 300, "height": 100}})).unwrap(); - frame(&mut app); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}, {"position": 120, "align": "decimal"}]})).unwrap(); - frame(&mut app); - let (_, stops, span) = current(&app).unwrap(); - assert_eq!(stops.len(), 2); - assert_eq!(span, 300.0); - // The stops survive a JSON round trip through the panel's encoding. - let back: Vec = stops_json(&stops).as_array().unwrap().clone(); - assert_eq!(back[1]["align"], json!("decimal")); - } - - /// In vertical type the Left and Right alignment buttons are named Top-Justified Tab and - /// Bottom-Justified Tab, in their tool tips and for screen readers, and the position field is - /// labeled Y. Horizontal type keeps Left-Justified Tab, Right-Justified Tab and X. - #[test] - fn vertical_type_names_the_alignments_top_and_bottom_and_the_position_y() { - use crate::widgets::accessibility_tests::{role_and_state, tree}; - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); - let horizontal = ["Left-Justified Tab", "Center-Justified Tab", "Right-Justified Tab", "Decimal-Justified Tab"]; - let vertical = ["Top-Justified Tab", "Center-Justified Tab", "Bottom-Justified Tab", "Decimal-Justified Tab"]; - for (names, other, axis, other_axis) in [(horizontal, vertical, "X:", "Y:"), (vertical, horizontal, "Y:", "X:")] { - if axis == "Y:" { - app.session.execute("type.orientation.vertical", &json!({})).unwrap(); - } - // Screen readers find the four buttons by name, and the selected stop's alignment is pressed. - let nodes = tree(|ui| show(&mut app, ui)); - for (i, name) in names.iter().enumerate() { - let pressed = if i == 0 { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False }; - assert_eq!(role_and_state(&nodes, name), (egui::accesskit::Role::Button, Some(pressed)), "{name}"); - } - for name in other.iter().filter(|n| !names.contains(n)) { - assert!(!nodes.iter().any(|n| n.label() == Some(name)), "{name} with {axis}"); - } - // Hovering each button shows its tool tip, and the panel shows the position field's label. - let ctx = egui::Context::default(); - ctx.global_style_mut(|s| s.interaction.tooltip_delay = 0.0); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); - let mut time = 0.0; - let mut frame = |app: &mut VectorcraftApp, events: Vec| { - time += 0.5; - let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), - _ => None, - }) - .collect::>() - }; - let texts = frame(&mut app, vec![]); - assert!(texts.iter().any(|(t, _)| t == axis) && !texts.iter().any(|(t, _)| t == other_axis), "{axis} in {texts:?}"); - for ((_, glyph), name) in ALIGNS.iter().zip(names) { - let at = texts.iter().find(|(t, _)| t == glyph).unwrap_or_else(|| panic!("{glyph} in {texts:?}")).1.center(); - frame(&mut app, vec![egui::Event::PointerMoved(at)]); - let tips = (0..3).flat_map(|_| frame(&mut app, vec![])).collect::>(); - assert!(tips.iter().any(|(t, _)| t == name), "the tip {name} in {tips:?}"); - } - } - } - - /// #924: Leader and Align On keep what is typed, frame after frame, until Enter applies it - /// (spaces kept, eight characters at most). - #[test] - fn leader_and_align_on_keep_what_is_typed_until_enter() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50, "align": "decimal"}]})).unwrap(); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); - // One frame with `events` → the texts painted and where. - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), - _ => None, - }) - .collect::>() - }; - let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - let click = |at| { - [true, false].map(|pressed| egui::Event::PointerButton { - pos: at, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }) - }; - let stop = |app: &VectorcraftApp| current(app).unwrap().1[0].clone(); - for (label, typed, want) in [("Leader:", [". ", "_", "123456789"], ". _12345"), ("Align On:", [",", "", ""], ",")] { - let texts = frame(&mut app, vec![]); - let r = texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {texts:?}")).1; - let at = egui::pos2(r.right() + 30.0, r.center().y); - let [down, up] = click(at); - frame(&mut app, vec![egui::Event::PointerMoved(at), down]); - frame(&mut app, vec![up]); - // Select what is there, then type in pieces over several frames. - frame( - &mut app, - vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }], - ); - for piece in typed.iter().filter(|p| !p.is_empty()) { - frame(&mut app, vec![egui::Event::Text(piece.to_string())]); - frame(&mut app, vec![]); - } - frame(&mut app, vec![key(egui::Key::Enter)]); - frame(&mut app, vec![]); - let s = stop(&app); - let got = if label == "Leader:" { s.leader } else { s.align_on.to_string() }; - assert_eq!(got, want, "{label}"); - } - } - - /// #1458: a stop moves when the drag starts on it, even though egui only reports the drag once - /// the pointer is past its threshold (farther from the press than the 5 px hit tolerance). - #[test] - fn dragging_a_stop_moves_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); - let ctx = egui::Context::default(); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); - // One frame with `events` → where the ruler's inch labels (9 pt) are painted. - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.job.sections.first().is_some_and(|s| s.format.font_id.size == 9.0) => { - Some((t.galley.text().to_string(), t.pos)) - } - _ => None, - }) - .collect::>() - }; - let labels = frame(&mut app, vec![]); - let at = |label: &str| labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1; - // A label sits 2 px right of its tick: 0 and 100 pt give the ruler's scale. - let (zero, hundred) = (at("0"), at("100")); - let to_x = |pt: f32| zero.x - 2.0 + pt * (hundred.x - zero.x) / 100.0; - let y = zero.y + 15.0; - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - // Press 3 px right of the stop, then drag right; egui starts the drag 6+ px from the press. - let press = egui::pos2(to_x(50.0) + 3.0, y); - frame(&mut app, vec![egui::Event::PointerMoved(press), button(press, true)]); - for dx in [9.0, 20.0, 40.0] { - frame(&mut app, vec![egui::Event::PointerMoved(press + vec2(dx, 0.0))]); - } - let end = press + vec2(40.0, 0.0); - frame(&mut app, vec![button(end, false)]); - frame(&mut app, vec![]); - let stops = current(&app).unwrap().1; - assert_eq!(stops.len(), 1); - let moved_to = to_x(stops[0].position as f32); - assert!((moved_to - end.x).abs() < 1.0, "stop at {moved_to}, released at {}", end.x); - } - - /// One frame of the panel with `events`, Shift held when `shift` → the ruler's labels (9 pt) and - /// where they are painted, left to right. - fn ruler_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, shift: bool) -> Vec<(String, egui::Pos2)> { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); - let modifiers = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; - let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.job.sections.first().is_some_and(|s| s.format.font_id.size == 9.0) => { - Some((t.galley.text().to_string(), t.pos)) - } - _ => None, - }) - .collect() - } - - /// The labels the ruler shows in a frame of [`ruler_frame`]. - fn ruler_labels(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { - ruler_frame(ctx, app, vec![], false).into_iter().map(|(t, _)| t).collect() - } - - /// Press on the ruler at `from` (points), move to `to` and release there, Shift held when - /// `shift`. A label sits 2 px right of its tick, so the first two labels give the ruler's scale. - fn drag_on_ruler(ctx: &egui::Context, app: &mut VectorcraftApp, from: f64, to: f64, shift: bool) { - let labels = ruler_frame(ctx, app, vec![], shift); - let [(zero, a), (next, b), ..] = &labels[..] else { panic!("ruler labels {labels:?}") }; - assert_eq!(zero, "0"); - let per = next.parse::().unwrap() * app.session.general_unit().points(); - let x = |pt: f64| a.x - 2.0 + (pt / per) as f32 * (b.x - a.x); - let y = a.y + 15.0; - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let (press, end) = (egui::pos2(x(from), y), egui::pos2(x(to), y)); - ruler_frame(ctx, app, vec![egui::Event::PointerMoved(press), button(press, true)], shift); - if press != end { - for f in [0.25, 0.5, 1.0] { - ruler_frame(ctx, app, vec![egui::Event::PointerMoved(press.lerp(end, f))], shift); - } - } - ruler_frame(ctx, app, vec![button(end, false)], shift); - ruler_frame(ctx, app, vec![], shift); - } - - /// The ruler is numbered in the General unit (the document's units), with labels at least 50 px - /// apart as on the canvas rulers. - #[test] - fn ruler_is_numbered_in_the_general_unit() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - let ctx = egui::Context::default(); - assert_eq!(ruler_labels(&ctx, &mut app), ["0", "50", "100", "150", "200", "250", "300"]); - app.session.execute("document.setUnits", &json!({"units": "in"})).unwrap(); - assert_eq!(ruler_labels(&ctx, &mut app), ["0", "1", "2", "3", "4"]); - app.session.execute("document.setUnits", &json!({"units": "mm"})).unwrap(); - assert_eq!(ruler_labels(&ctx, &mut app), ["0", "25", "50", "75", "100"]); - } - - /// Snap to Unit, chosen from the panel menu with or without text selected, puts a stop dragged - /// or placed by a click on the ruler on its nearest tick (every 5 pt on this points ruler). - /// Chosen again, it turns off. - #[test] - fn snap_to_unit_puts_dragged_and_clicked_stops_on_the_nearest_tick() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - let ctx = egui::Context::default(); - let row = |app: &mut VectorcraftApp| { - let rows = menu_frame(&ctx, app, vec![]); - rows.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Snap to Unit").unwrap_or_else(|| panic!("Snap to Unit")) - }; - choose(&ctx, &mut app, "Snap to Unit"); - assert_eq!(row(&mut app).0, "✓ Snap to Unit"); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); - assert_eq!(ruler_labels(&ctx, &mut app)[..2], ["0", "50"]); - let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); - drag_on_ruler(&ctx, &mut app, 50.0, 13.4, false); - assert_eq!(positions(&app), [15.0]); - // A click at 33.4 pt adds a stop at 35. - drag_on_ruler(&ctx, &mut app, 33.4, 33.4, false); - assert_eq!(positions(&app), [15.0, 35.0]); - assert_eq!(row(&mut app).0, "✓ Snap to Unit"); - // Chosen again, Snap to Unit turns off and the stop lands on the whole point nearest where it - // is dropped. - let at = row(&mut app).1.center(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - menu_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at), button(true)]); - menu_frame(&ctx, &mut app, vec![button(false)]); - assert_eq!(row(&mut app).0.trim_start(), "Snap to Unit"); - drag_on_ruler(&ctx, &mut app, 35.0, 23.4, false); - assert_eq!(positions(&app), [15.0, 23.0]); - } - - /// A snapped click more than 5 px from a stop, nearest to the tick that stop is on, selects the - /// stop and records no undo step. On this millimeters ruler the ticks are 2.5 mm (7.09 pt, 12.4 - /// px) apart. - #[test] - fn a_snapped_click_onto_a_stop_selects_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("document.setUnits", &json!({"units": "mm"})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 224, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 100}]})).unwrap(); - let ctx = egui::Context::default(); - assert_eq!(ruler_labels(&ctx, &mut app), ["0", "25", "50", "75"]); - set_pstate(&ctx, "tabs-snap", true); - let tick = 2.5 * 72.0 / 25.4; - drag_on_ruler(&ctx, &mut app, 28.4, 28.4, false); - let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); - assert_eq!(positions(&app).len(), 2); - assert!((positions(&app)[0] - 4.0 * tick).abs() < 1e-9, "{:?}", positions(&app)); - set_pstate(&ctx, "tabs-sel", 1usize); - let steps = app.session.active().unwrap().history.undo.len(); - // The click is 3.3 pt (5.8 px) right of the stop, past the 5 px hit tolerance and nearest its tick. - drag_on_ruler(&ctx, &mut app, 4.0 * tick + 3.3, 4.0 * tick + 3.3, false); - assert_eq!(positions(&app).len(), 2, "{:?}", positions(&app)); - assert_eq!(pstate::(&ctx, "tabs-sel"), 0); - assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "no undo step"); - } - - /// With Snap to Unit off, Shift held during the drag puts the stop on the ruler's nearest tick. - #[test] - fn shift_drag_snaps_a_stop_to_the_nearest_tick() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); - let ctx = egui::Context::default(); - let position = |app: &VectorcraftApp| current(app).unwrap().1[0].position; - drag_on_ruler(&ctx, &mut app, 50.0, 13.4, false); - assert!((position(&app) - 13.4).abs() < 1.0, "{}", position(&app)); - let from = position(&app); - drag_on_ruler(&ctx, &mut app, from, 41.6, true); - assert_eq!(position(&app), 40.0); - // In an inches document the ticks divide the inch in half, not in ten (#2827), so the - // nearest one to 100 pt is 99 pt -- 1 3/8 in, an eighth of an inch from 1 1/4. - app.session.execute("document.setUnits", &json!({"units": "in"})).unwrap(); - drag_on_ruler(&ctx, &mut app, 40.0, 100.0, true); - assert!((position(&app) - 99.0).abs() < 1e-9, "{}", position(&app)); - assert_eq!((position(&app) / 72.0 * 8.0).round(), position(&app) / 72.0 * 8.0, "a binary fraction of an inch"); - } - - /// One frame of the panel with `events` → the texts painted and where. - fn panel_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, egui::Rect)> { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), - _ => None, - }) - .collect() - } - - /// A stop dragged past another stays selected once the stops are sorted, so the X field shows - /// where it was dropped. - #[test] - fn a_stop_dragged_past_another_stays_selected() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); - let ctx = egui::Context::default(); - drag_on_ruler(&ctx, &mut app, 50.0, 10.0, false); - let stops = current(&app).unwrap().1; - assert_eq!(stops.len(), 2); - assert!((stops[0].position - 10.0).abs() < 1.0 && stops[1].position == 30.0, "{stops:?}"); - assert_eq!(pstate::(&ctx, "tabs-sel"), 0); - let shown = app.session.general_unit().format(stops[0].position); - let texts = panel_frame(&ctx, &mut app, vec![]); - assert!(texts.iter().any(|(t, _)| *t == shown), "{shown} in {texts:?}"); - } - - /// A stop placed by a click on the ruler or dragged on it lands on the nearest whole point, and - /// the moved stop stays selected. A position typed in the X field keeps its hundredths of a point. - #[test] - fn the_ruler_places_and_moves_stops_on_whole_points() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); - let ctx = egui::Context::default(); - let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); - // A click at 33.4 pt adds a stop at 33 pt and selects it. - drag_on_ruler(&ctx, &mut app, 33.4, 33.4, false); - assert_eq!(positions(&app), [33.0, 50.0]); - assert_eq!(pstate::(&ctx, "tabs-sel"), 0); - // The stop at 50 pt dragged to 13.6 pt lands on 14 pt, left of the stop at 33 pt, and stays selected. - drag_on_ruler(&ctx, &mut app, 50.0, 13.6, false); - assert_eq!(positions(&app), [14.0, 33.0]); - assert_eq!(pstate::(&ctx, "tabs-sel"), 0); - // 13.47 typed in the X field keeps its hundredths. - let texts = panel_frame(&ctx, &mut app, vec![]); - let at = texts.iter().find(|(t, _)| t == "14 pt").unwrap_or_else(|| panic!("14 pt in {texts:?}")).1.center(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - panel_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at), button(true)]); - panel_frame(&ctx, &mut app, vec![button(false)]); - panel_frame(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); - panel_frame(&ctx, &mut app, vec![egui::Event::Text("13.47".into())]); - panel_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); - assert_eq!(positions(&app), [13.47, 33.0]); - assert_eq!(pstate::(&ctx, "tabs-sel"), 0); - } - - /// An X typed past the next stop keeps the stop selected once the stops are sorted. An X the - /// stops cannot take (past 100,000 pt) leaves the stops and the selection as they were. - #[test] - fn an_x_typed_past_the_next_stop_keeps_the_stop_selected() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); - let ctx = egui::Context::default(); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - // Click the X field showing `shown`, type `typed` over it and press Enter → the texts painted next. - let type_x = |app: &mut VectorcraftApp, shown: &str, typed: &str| { - let texts = panel_frame(&ctx, app, vec![]); - let at = texts.iter().find(|(t, _)| t == shown).unwrap_or_else(|| panic!("{shown} in {texts:?}")).1.center(); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - panel_frame(&ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); - panel_frame(&ctx, app, vec![button(false)]); - panel_frame(&ctx, app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); - panel_frame(&ctx, app, vec![egui::Event::Text(typed.into())]); - panel_frame(&ctx, app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); - panel_frame(&ctx, app, vec![]) - }; - let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); - set_pstate(&ctx, "tabs-sel", 0usize); - let texts = type_x(&mut app, "30 pt", "70"); - assert_eq!(positions(&app), [50.0, 70.0]); - assert_eq!(pstate::(&ctx, "tabs-sel"), 1); - assert!(texts.iter().any(|(t, _)| t == "70 pt"), "70 pt in {texts:?}"); - set_pstate(&ctx, "tabs-sel", 0usize); - let texts = type_x(&mut app, "50 pt", "200000"); - assert_eq!(positions(&app), [50.0, 70.0]); - assert_eq!(pstate::(&ctx, "tabs-sel"), 0); - assert!(texts.iter().any(|(t, _)| t == "50 pt"), "50 pt in {texts:?}"); - // An X less than 1e-6 pt above another stop merges the two into the stop already there, - // which is selected. - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}, {"position": 70}]})).unwrap(); - set_pstate(&ctx, "tabs-sel", 0usize); - type_x(&mut app, "30 pt", "50.0000001"); - assert_eq!(positions(&app), [50.0, 70.0]); - assert_eq!(pstate::(&ctx, "tabs-sel"), 0); - } - - /// One frame of the panel with `events` → the stops' stems on the ruler, left to right, with - /// their colors. - fn stems_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(f32, egui::Color32)> { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::LineSegment { points: [a, b], stroke } if stroke.width == 1.5 && a.x == b.x => Some((a.x, stroke.color)), - _ => None, - }) - .collect() - } - - /// In the frame where Enter commits an X the stops cannot take (past 100,000 pt), the ruler - /// shows the stops and the selected stop as they were. - #[test] - fn a_refused_x_leaves_the_ruler_as_it_was() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); - let ctx = egui::Context::default(); - set_pstate(&ctx, "tabs-sel", 1usize); - let before = stems_frame(&ctx, &mut app, vec![]); - assert_eq!(before.len(), 2, "{before:?}"); - let texts = panel_frame(&ctx, &mut app, vec![]); - let at = texts.iter().find(|(t, _)| t == "50 pt").unwrap_or_else(|| panic!("50 pt in {texts:?}")).1.center(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - panel_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at), button(true)]); - panel_frame(&ctx, &mut app, vec![button(false)]); - panel_frame(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); - panel_frame(&ctx, &mut app, vec![egui::Event::Text("200000".into())]); - let committed = stems_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); - assert_eq!(current(&app).unwrap().1.iter().map(|s| s.position).collect::>(), [30.0, 50.0]); - assert_eq!(pstate::(&ctx, "tabs-sel"), 1); - assert_eq!(committed, before); - } - - /// One frame of the panel menu with `events` → the rows' texts and where they are painted. - fn menu_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, egui::Rect)> { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| menu(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|c| match &c.shape { - egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), - _ => None, - }) - .collect() - } - - /// Click the panel menu's row `label`, which shows no check mark. - fn choose(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) { - let rows = menu_frame(ctx, app, vec![]); - let (text, r) = rows.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label).unwrap_or_else(|| panic!("{label} in {rows:?}")); - assert!(!text.contains('✓'), "{label} shows a check mark"); - let at = r.center(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - menu_frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); - menu_frame(ctx, app, vec![button(false)]); - } - - /// The panel menu's Repeat Tab, Delete Tab and Clear All Tabs can be chosen while the text has - /// tab stops and show no check mark. Repeat Tab and Delete Tab act on the stop selected on the - /// ruler, and Clear All Tabs removes every stop. - #[test] - fn panel_menu_repeats_deletes_and_clears_tab_stops() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50, "leader": "."}]})).unwrap(); - let ctx = egui::Context::default(); - let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); - // Repeat Tab with the second stop selected: 20 copies of it, 20 pt apart (its distance from - // the first stop), leader and all. - set_pstate(&ctx, "tabs-sel", 1usize); - choose(&ctx, &mut app, "Repeat Tab"); - let repeated = positions(&app); - assert_eq!((repeated.len(), &repeated[..4]), (22, &[30.0, 50.0, 70.0, 90.0][..])); - assert!(current(&app).unwrap().1[2..].iter().all(|s| s.leader == ".")); - // Delete Tab removes the selected stop. - choose(&ctx, &mut app, "Delete Tab"); - assert_eq!(positions(&app)[..3], [30.0, 70.0, 90.0]); - // Clear All Tabs removes every stop, in one undo step of its own name. - choose(&ctx, &mut app, "Clear All Tabs"); - assert!(positions(&app).is_empty()); - assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "Clear All Tabs"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(positions(&app).len(), 21); - } - - /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's - /// left indent, from the paragraph the caret is in while the Type tool edits text. - #[test] - fn repeat_tab_measures_the_first_stop_from_the_left_indent() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b\nc d", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.setFormat", &json!({"leftIndent": 20})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); - let ctx = egui::Context::default(); - let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); - // 30 pt from the indent at 20: copies at 80, 110, ... - set_pstate(&ctx, "tabs-sel", 0usize); - choose(&ctx, &mut app, "Repeat Tab"); - let repeated = positions(&app); - assert_eq!((repeated.len(), &repeated[..4]), (21, &[50.0, 80.0, 110.0, 140.0][..])); - // A stop left of the indent: 15 pt from the indent at 20. - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 5}]})).unwrap(); - choose(&ctx, &mut app, "Repeat Tab"); - assert_eq!(positions(&app)[..4], [5.0, 20.0, 35.0, 50.0]); - // The Type tool's caret in the second paragraph, indented 40 pt: its stop at 50 repeats - // every 10 pt, and the first paragraph keeps its stop. - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - let id = app.run("text.create", json!({"x": 10, "y": 20, "text": "a b\nc d"})).unwrap()["id"].clone(); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.run("text.setFormat", json!({"id": id, "leftIndent": 20})).unwrap(); - app.run("text.setFormat", json!({"id": id, "start": 4, "end": 7, "leftIndent": 40})).unwrap(); - app.run("text.tabs.set", json!({"id": id, "stops": [{"position": 50}]})).unwrap(); - app.session.set_tool_option("select", &json!({"start": 5, "end": 5})); - choose(&ctx, &mut app, "Repeat Tab"); - assert_eq!(positions(&app)[..3], [50.0, 60.0, 70.0]); - app.session.set_tool_option("select", &json!({"start": 0, "end": 0})); - assert_eq!(positions(&app), [50.0]); - } - - /// After Delete Tab removes the last stop, the ruler shows the stop before it selected, and - /// Delete Tab removes that one next. - #[test] - fn delete_tab_after_the_last_stop_deletes_the_stop_the_ruler_shows_selected() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); - let ctx = egui::Context::default(); - set_pstate(&ctx, "tabs-sel", 1usize); - choose(&ctx, &mut app, "Delete Tab"); - assert_eq!(current(&app).unwrap().1.len(), 1); - choose(&ctx, &mut app, "Delete Tab"); - assert!(current(&app).unwrap().1.is_empty()); - } - - /// Position Panel Above Text floats the panel over the text, its ruler's 0 on the frame's left - /// edge and a stop's mark over the column it sets, at any zoom (#729). - #[test] - fn above_text_puts_the_ruler_over_the_text_at_the_zoom() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); - app.session.execute("text.create", &json!({"x": 40, "y": 200, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0))); - for zoom in [1.0, 2.0] { - if let Some(v) = app.view_mut() { - v.zoom = zoom; - } - let (node, _, span) = current(&app).unwrap(); - let ctx = egui::Context::default(); - above_text(&mut app, &ctx, &node, span).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - let g = layout.floating.iter().find(|w| w.contains(&"tabs".to_string())).unwrap(); - let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let frame = xf.rect_to_screen(node.geometric_bounds().unwrap()); - assert!((g.rect[0] + RULER_LEFT - frame.left()).abs() < 0.01, "0 on the frame's left edge"); - assert!(g.rect[1] < frame.top(), "above the text"); - // The ruler (the panel less its margins) spans the frame at this zoom. - let ruler = g.rect[2] - RULER_LEFT * 2.0; - assert!((ruler - frame.width()).abs() < 0.01, "{ruler} {}", frame.width()); - assert!((ruler - 300.0 * zoom as f32).abs() < 0.01); - } - assert_eq!(app.ui.docking.unwrap().floating.len(), 1, "placed again, not floated twice"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs new file mode 100644 index 000000000..e193f0ab5 --- /dev/null +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -0,0 +1,613 @@ +use super::*; +use vectorcraft_engine::Session; + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(app, ui); + menu(app, ui); + }); + out.textures_delta.clear(); +} + +#[test] +fn panel_draws_empty_with_text_and_with_stops() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + frame(&mut app); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb", "area": {"width": 300, "height": 100}})).unwrap(); + frame(&mut app); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}, {"position": 120, "align": "decimal"}]})).unwrap(); + frame(&mut app); + let (_, stops, span) = current(&app).unwrap(); + assert_eq!(stops.len(), 2); + assert_eq!(span, 300.0); + // The stops survive a JSON round trip through the panel's encoding. + let back: Vec = stops_json(&stops).as_array().unwrap().clone(); + assert_eq!(back[1]["align"], json!("decimal")); +} + +/// In vertical type the Left and Right alignment buttons are named Top-Justified Tab and +/// Bottom-Justified Tab, in their tool tips and for screen readers, and the position field is +/// labeled Y. Horizontal type keeps Left-Justified Tab, Right-Justified Tab and X. +#[test] +fn vertical_type_names_the_alignments_top_and_bottom_and_the_position_y() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + let horizontal = ["Left-Justified Tab", "Center-Justified Tab", "Right-Justified Tab", "Decimal-Justified Tab"]; + let vertical = ["Top-Justified Tab", "Center-Justified Tab", "Bottom-Justified Tab", "Decimal-Justified Tab"]; + for (names, other, axis, other_axis) in [(horizontal, vertical, "X:", "Y:"), (vertical, horizontal, "Y:", "X:")] { + if axis == "Y:" { + app.session.execute("type.orientation.vertical", &json!({})).unwrap(); + } + // Screen readers find the four buttons by name, and the selected stop's alignment is pressed. + let nodes = tree(|ui| show(&mut app, ui)); + for (i, name) in names.iter().enumerate() { + let pressed = if i == 0 { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False }; + assert_eq!(role_and_state(&nodes, name), (egui::accesskit::Role::Button, Some(pressed)), "{name}"); + } + for name in other.iter().filter(|n| !names.contains(n)) { + assert!(!nodes.iter().any(|n| n.label() == Some(name)), "{name} with {axis}"); + } + // Hovering each button shows its tool tip, and the panel shows the position field's label. + let ctx = egui::Context::default(); + ctx.global_style_mut(|s| s.interaction.tooltip_delay = 0.0); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.5; + let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect::>() + }; + let texts = frame(&mut app, vec![]); + assert!(texts.iter().any(|(t, _)| t == axis) && !texts.iter().any(|(t, _)| t == other_axis), "{axis} in {texts:?}"); + for ((_, glyph), name) in ALIGNS.iter().zip(names) { + let at = texts.iter().find(|(t, _)| t == glyph).unwrap_or_else(|| panic!("{glyph} in {texts:?}")).1.center(); + frame(&mut app, vec![egui::Event::PointerMoved(at)]); + let tips = (0..3).flat_map(|_| frame(&mut app, vec![])).collect::>(); + assert!(tips.iter().any(|(t, _)| t == name), "the tip {name} in {tips:?}"); + } + } +} + +/// #924: Leader and Align On keep what is typed, frame after frame, until Enter applies it +/// (spaces kept, eight characters at most). +#[test] +fn leader_and_align_on_keep_what_is_typed_until_enter() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50, "align": "decimal"}]})).unwrap(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); + // One frame with `events` → the texts painted and where. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect::>() + }; + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let click = |at| { + [true, false].map(|pressed| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }) + }; + let stop = |app: &VectorcraftApp| current(app).unwrap().1[0].clone(); + for (label, typed, want) in [("Leader:", [". ", "_", "123456789"], ". _12345"), ("Align On:", [",", "", ""], ",")] { + let texts = frame(&mut app, vec![]); + let r = texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {texts:?}")).1; + let at = egui::pos2(r.right() + 30.0, r.center().y); + let [down, up] = click(at); + frame(&mut app, vec![egui::Event::PointerMoved(at), down]); + frame(&mut app, vec![up]); + // Select what is there, then type in pieces over several frames. + frame( + &mut app, + vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }], + ); + for piece in typed.iter().filter(|p| !p.is_empty()) { + frame(&mut app, vec![egui::Event::Text(piece.to_string())]); + frame(&mut app, vec![]); + } + frame(&mut app, vec![key(egui::Key::Enter)]); + frame(&mut app, vec![]); + let s = stop(&app); + let got = if label == "Leader:" { s.leader } else { s.align_on.to_string() }; + assert_eq!(got, want, "{label}"); + } +} + +/// #1458: a stop moves when the drag starts on it, even though egui only reports the drag once +/// the pointer is past its threshold (farther from the press than the 5 px hit tolerance). +#[test] +fn dragging_a_stop_moves_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); + // One frame with `events` → where the ruler's inch labels (9 pt) are painted. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.job.sections.first().is_some_and(|s| s.format.font_id.size == 9.0) => { + Some((t.galley.text().to_string(), t.pos)) + } + _ => None, + }) + .collect::>() + }; + let labels = frame(&mut app, vec![]); + let at = |label: &str| labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1; + // A label sits 2 px right of its tick: 0 and 100 pt give the ruler's scale. + let (zero, hundred) = (at("0"), at("100")); + let to_x = |pt: f32| zero.x - 2.0 + pt * (hundred.x - zero.x) / 100.0; + let y = zero.y + 15.0; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Press 3 px right of the stop, then drag right; egui starts the drag 6+ px from the press. + let press = egui::pos2(to_x(50.0) + 3.0, y); + frame(&mut app, vec![egui::Event::PointerMoved(press), button(press, true)]); + for dx in [9.0, 20.0, 40.0] { + frame(&mut app, vec![egui::Event::PointerMoved(press + vec2(dx, 0.0))]); + } + let end = press + vec2(40.0, 0.0); + frame(&mut app, vec![button(end, false)]); + frame(&mut app, vec![]); + let stops = current(&app).unwrap().1; + assert_eq!(stops.len(), 1); + let moved_to = to_x(stops[0].position as f32); + assert!((moved_to - end.x).abs() < 1.0, "stop at {moved_to}, released at {}", end.x); +} + +/// One frame of the panel with `events`, Shift held when `shift` → the ruler's labels (9 pt) and +/// where they are painted, left to right. +fn ruler_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, shift: bool) -> Vec<(String, egui::Pos2)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); + let modifiers = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.job.sections.first().is_some_and(|s| s.format.font_id.size == 9.0) => { + Some((t.galley.text().to_string(), t.pos)) + } + _ => None, + }) + .collect() +} + +/// The labels the ruler shows in a frame of [`ruler_frame`]. +fn ruler_labels(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { + ruler_frame(ctx, app, vec![], false).into_iter().map(|(t, _)| t).collect() +} + +/// Press on the ruler at `from` (points), move to `to` and release there, Shift held when +/// `shift`. A label sits 2 px right of its tick, so the first two labels give the ruler's scale. +fn drag_on_ruler(ctx: &egui::Context, app: &mut VectorcraftApp, from: f64, to: f64, shift: bool) { + let labels = ruler_frame(ctx, app, vec![], shift); + let [(zero, a), (next, b), ..] = &labels[..] else { panic!("ruler labels {labels:?}") }; + assert_eq!(zero, "0"); + let per = next.parse::().unwrap() * app.session.general_unit().points(); + let x = |pt: f64| a.x - 2.0 + (pt / per) as f32 * (b.x - a.x); + let y = a.y + 15.0; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let (press, end) = (egui::pos2(x(from), y), egui::pos2(x(to), y)); + ruler_frame(ctx, app, vec![egui::Event::PointerMoved(press), button(press, true)], shift); + if press != end { + for f in [0.25, 0.5, 1.0] { + ruler_frame(ctx, app, vec![egui::Event::PointerMoved(press.lerp(end, f))], shift); + } + } + ruler_frame(ctx, app, vec![button(end, false)], shift); + ruler_frame(ctx, app, vec![], shift); +} + +/// The ruler is numbered in the General unit (the document's units), with labels at least 50 px +/// apart as on the canvas rulers. +#[test] +fn ruler_is_numbered_in_the_general_unit() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + let ctx = egui::Context::default(); + assert_eq!(ruler_labels(&ctx, &mut app), ["0", "50", "100", "150", "200", "250", "300"]); + app.session.execute("document.setUnits", &json!({"units": "in"})).unwrap(); + assert_eq!(ruler_labels(&ctx, &mut app), ["0", "1", "2", "3", "4"]); + app.session.execute("document.setUnits", &json!({"units": "mm"})).unwrap(); + assert_eq!(ruler_labels(&ctx, &mut app), ["0", "25", "50", "75", "100"]); +} + +/// Snap to Unit, chosen from the panel menu with or without text selected, puts a stop dragged +/// or placed by a click on the ruler on its nearest tick (every 5 pt on this points ruler). +/// Chosen again, it turns off. +#[test] +fn snap_to_unit_puts_dragged_and_clicked_stops_on_the_nearest_tick() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let row = |app: &mut VectorcraftApp| { + let rows = menu_frame(&ctx, app, vec![]); + rows.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Snap to Unit").unwrap_or_else(|| panic!("Snap to Unit")) + }; + choose(&ctx, &mut app, "Snap to Unit"); + assert_eq!(row(&mut app).0, "✓ Snap to Unit"); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + assert_eq!(ruler_labels(&ctx, &mut app)[..2], ["0", "50"]); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + drag_on_ruler(&ctx, &mut app, 50.0, 13.4, false); + assert_eq!(positions(&app), [15.0]); + // A click at 33.4 pt adds a stop at 35. + drag_on_ruler(&ctx, &mut app, 33.4, 33.4, false); + assert_eq!(positions(&app), [15.0, 35.0]); + assert_eq!(row(&mut app).0, "✓ Snap to Unit"); + // Chosen again, Snap to Unit turns off and the stop lands on the whole point nearest where it + // is dropped. + let at = row(&mut app).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + menu_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at), button(true)]); + menu_frame(&ctx, &mut app, vec![button(false)]); + assert_eq!(row(&mut app).0.trim_start(), "Snap to Unit"); + drag_on_ruler(&ctx, &mut app, 35.0, 23.4, false); + assert_eq!(positions(&app), [15.0, 23.0]); +} + +/// A snapped click more than 5 px from a stop, nearest to the tick that stop is on, selects the +/// stop and records no undo step. On this millimeters ruler the ticks are 2.5 mm (7.09 pt, 12.4 +/// px) apart. +#[test] +fn a_snapped_click_onto_a_stop_selects_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("document.setUnits", &json!({"units": "mm"})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 224, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 100}]})).unwrap(); + let ctx = egui::Context::default(); + assert_eq!(ruler_labels(&ctx, &mut app), ["0", "25", "50", "75"]); + set_pstate(&ctx, "tabs-snap", true); + let tick = 2.5 * 72.0 / 25.4; + drag_on_ruler(&ctx, &mut app, 28.4, 28.4, false); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + assert_eq!(positions(&app).len(), 2); + assert!((positions(&app)[0] - 4.0 * tick).abs() < 1e-9, "{:?}", positions(&app)); + set_pstate(&ctx, "tabs-sel", 1usize); + let steps = app.session.active().unwrap().history.undo.len(); + // The click is 3.3 pt (5.8 px) right of the stop, past the 5 px hit tolerance and nearest its tick. + drag_on_ruler(&ctx, &mut app, 4.0 * tick + 3.3, 4.0 * tick + 3.3, false); + assert_eq!(positions(&app).len(), 2, "{:?}", positions(&app)); + assert_eq!(pstate::(&ctx, "tabs-sel"), 0); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "no undo step"); +} + +/// With Snap to Unit off, Shift held during the drag puts the stop on the ruler's nearest tick. +#[test] +fn shift_drag_snaps_a_stop_to_the_nearest_tick() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + let position = |app: &VectorcraftApp| current(app).unwrap().1[0].position; + drag_on_ruler(&ctx, &mut app, 50.0, 13.4, false); + assert!((position(&app) - 13.4).abs() < 1.0, "{}", position(&app)); + let from = position(&app); + drag_on_ruler(&ctx, &mut app, from, 41.6, true); + assert_eq!(position(&app), 40.0); + // In an inches document the ticks divide the inch in half, not in ten (#2827), so the + // nearest one to 100 pt is 99 pt -- 1 3/8 in, an eighth of an inch from 1 1/4. + app.session.execute("document.setUnits", &json!({"units": "in"})).unwrap(); + drag_on_ruler(&ctx, &mut app, 40.0, 100.0, true); + assert!((position(&app) - 99.0).abs() < 1e-9, "{}", position(&app)); + assert_eq!((position(&app) / 72.0 * 8.0).round(), position(&app) / 72.0 * 8.0, "a binary fraction of an inch"); +} + +/// One frame of the panel with `events` → the texts painted and where. +fn panel_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect() +} + +/// A stop dragged past another stays selected once the stops are sorted, so the X field shows +/// where it was dropped. +#[test] +fn a_stop_dragged_past_another_stays_selected() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + drag_on_ruler(&ctx, &mut app, 50.0, 10.0, false); + let stops = current(&app).unwrap().1; + assert_eq!(stops.len(), 2); + assert!((stops[0].position - 10.0).abs() < 1.0 && stops[1].position == 30.0, "{stops:?}"); + assert_eq!(pstate::(&ctx, "tabs-sel"), 0); + let shown = app.session.general_unit().format(stops[0].position); + let texts = panel_frame(&ctx, &mut app, vec![]); + assert!(texts.iter().any(|(t, _)| *t == shown), "{shown} in {texts:?}"); +} + +/// A stop placed by a click on the ruler or dragged on it lands on the nearest whole point, and +/// the moved stop stays selected. A position typed in the X field keeps its hundredths of a point. +#[test] +fn the_ruler_places_and_moves_stops_on_whole_points() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + // A click at 33.4 pt adds a stop at 33 pt and selects it. + drag_on_ruler(&ctx, &mut app, 33.4, 33.4, false); + assert_eq!(positions(&app), [33.0, 50.0]); + assert_eq!(pstate::(&ctx, "tabs-sel"), 0); + // The stop at 50 pt dragged to 13.6 pt lands on 14 pt, left of the stop at 33 pt, and stays selected. + drag_on_ruler(&ctx, &mut app, 50.0, 13.6, false); + assert_eq!(positions(&app), [14.0, 33.0]); + assert_eq!(pstate::(&ctx, "tabs-sel"), 0); + // 13.47 typed in the X field keeps its hundredths. + let texts = panel_frame(&ctx, &mut app, vec![]); + let at = texts.iter().find(|(t, _)| t == "14 pt").unwrap_or_else(|| panic!("14 pt in {texts:?}")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + panel_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at), button(true)]); + panel_frame(&ctx, &mut app, vec![button(false)]); + panel_frame(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + panel_frame(&ctx, &mut app, vec![egui::Event::Text("13.47".into())]); + panel_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + assert_eq!(positions(&app), [13.47, 33.0]); + assert_eq!(pstate::(&ctx, "tabs-sel"), 0); +} + +/// An X typed past the next stop keeps the stop selected once the stops are sorted. An X the +/// stops cannot take (past 100,000 pt) leaves the stops and the selection as they were. +#[test] +fn an_x_typed_past_the_next_stop_keeps_the_stop_selected() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + // Click the X field showing `shown`, type `typed` over it and press Enter → the texts painted next. + let type_x = |app: &mut VectorcraftApp, shown: &str, typed: &str| { + let texts = panel_frame(&ctx, app, vec![]); + let at = texts.iter().find(|(t, _)| t == shown).unwrap_or_else(|| panic!("{shown} in {texts:?}")).1.center(); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + panel_frame(&ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); + panel_frame(&ctx, app, vec![button(false)]); + panel_frame(&ctx, app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + panel_frame(&ctx, app, vec![egui::Event::Text(typed.into())]); + panel_frame(&ctx, app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + panel_frame(&ctx, app, vec![]) + }; + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + set_pstate(&ctx, "tabs-sel", 0usize); + let texts = type_x(&mut app, "30 pt", "70"); + assert_eq!(positions(&app), [50.0, 70.0]); + assert_eq!(pstate::(&ctx, "tabs-sel"), 1); + assert!(texts.iter().any(|(t, _)| t == "70 pt"), "70 pt in {texts:?}"); + set_pstate(&ctx, "tabs-sel", 0usize); + let texts = type_x(&mut app, "50 pt", "200000"); + assert_eq!(positions(&app), [50.0, 70.0]); + assert_eq!(pstate::(&ctx, "tabs-sel"), 0); + assert!(texts.iter().any(|(t, _)| t == "50 pt"), "50 pt in {texts:?}"); + // An X less than 1e-6 pt above another stop merges the two into the stop already there, + // which is selected. + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}, {"position": 70}]})).unwrap(); + set_pstate(&ctx, "tabs-sel", 0usize); + type_x(&mut app, "30 pt", "50.0000001"); + assert_eq!(positions(&app), [50.0, 70.0]); + assert_eq!(pstate::(&ctx, "tabs-sel"), 0); +} + +/// One frame of the panel with `events` → the stops' stems on the ruler, left to right, with +/// their colors. +fn stems_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(f32, egui::Color32)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::LineSegment { points: [a, b], stroke } if stroke.width == 1.5 && a.x == b.x => Some((a.x, stroke.color)), + _ => None, + }) + .collect() +} + +/// In the frame where Enter commits an X the stops cannot take (past 100,000 pt), the ruler +/// shows the stops and the selected stop as they were. +#[test] +fn a_refused_x_leaves_the_ruler_as_it_was() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "tabs-sel", 1usize); + let before = stems_frame(&ctx, &mut app, vec![]); + assert_eq!(before.len(), 2, "{before:?}"); + let texts = panel_frame(&ctx, &mut app, vec![]); + let at = texts.iter().find(|(t, _)| t == "50 pt").unwrap_or_else(|| panic!("50 pt in {texts:?}")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + panel_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at), button(true)]); + panel_frame(&ctx, &mut app, vec![button(false)]); + panel_frame(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + panel_frame(&ctx, &mut app, vec![egui::Event::Text("200000".into())]); + let committed = stems_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + assert_eq!(current(&app).unwrap().1.iter().map(|s| s.position).collect::>(), [30.0, 50.0]); + assert_eq!(pstate::(&ctx, "tabs-sel"), 1); + assert_eq!(committed, before); +} + +/// One frame of the panel menu with `events` → the rows' texts and where they are painted. +fn menu_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| menu(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect() +} + +/// Click the panel menu's row `label`, which shows no check mark. +fn choose(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) { + let rows = menu_frame(ctx, app, vec![]); + let (text, r) = rows.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label).unwrap_or_else(|| panic!("{label} in {rows:?}")); + assert!(!text.contains('✓'), "{label} shows a check mark"); + let at = r.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + menu_frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); + menu_frame(ctx, app, vec![button(false)]); +} + +/// The panel menu's Repeat Tab, Delete Tab and Clear All Tabs can be chosen while the text has +/// tab stops and show no check mark. Repeat Tab and Delete Tab act on the stop selected on the +/// ruler, and Clear All Tabs removes every stop. +#[test] +fn panel_menu_repeats_deletes_and_clears_tab_stops() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50, "leader": "."}]})).unwrap(); + let ctx = egui::Context::default(); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + // Repeat Tab with the second stop selected: 20 copies of it, 20 pt apart (its distance from + // the first stop), leader and all. + set_pstate(&ctx, "tabs-sel", 1usize); + choose(&ctx, &mut app, "Repeat Tab"); + let repeated = positions(&app); + assert_eq!((repeated.len(), &repeated[..4]), (22, &[30.0, 50.0, 70.0, 90.0][..])); + assert!(current(&app).unwrap().1[2..].iter().all(|s| s.leader == ".")); + // Delete Tab removes the selected stop. + choose(&ctx, &mut app, "Delete Tab"); + assert_eq!(positions(&app)[..3], [30.0, 70.0, 90.0]); + // Clear All Tabs removes every stop, in one undo step of its own name. + choose(&ctx, &mut app, "Clear All Tabs"); + assert!(positions(&app).is_empty()); + assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "Clear All Tabs"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(positions(&app).len(), 21); +} + +/// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's +/// left indent, from the paragraph the caret is in while the Type tool edits text. +#[test] +fn repeat_tab_measures_the_first_stop_from_the_left_indent() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b\nc d", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.setFormat", &json!({"leftIndent": 20})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + // 30 pt from the indent at 20: copies at 80, 110, ... + set_pstate(&ctx, "tabs-sel", 0usize); + choose(&ctx, &mut app, "Repeat Tab"); + let repeated = positions(&app); + assert_eq!((repeated.len(), &repeated[..4]), (21, &[50.0, 80.0, 110.0, 140.0][..])); + // A stop left of the indent: 15 pt from the indent at 20. + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 5}]})).unwrap(); + choose(&ctx, &mut app, "Repeat Tab"); + assert_eq!(positions(&app)[..4], [5.0, 20.0, 35.0, 50.0]); + // The Type tool's caret in the second paragraph, indented 40 pt: its stop at 50 repeats + // every 10 pt, and the first paragraph keeps its stop. + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 20, "text": "a b\nc d"})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.run("text.setFormat", json!({"id": id, "leftIndent": 20})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 4, "end": 7, "leftIndent": 40})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "stops": [{"position": 50}]})).unwrap(); + app.session.set_tool_option("select", &json!({"start": 5, "end": 5})); + choose(&ctx, &mut app, "Repeat Tab"); + assert_eq!(positions(&app)[..3], [50.0, 60.0, 70.0]); + app.session.set_tool_option("select", &json!({"start": 0, "end": 0})); + assert_eq!(positions(&app), [50.0]); +} + +/// After Delete Tab removes the last stop, the ruler shows the stop before it selected, and +/// Delete Tab removes that one next. +#[test] +fn delete_tab_after_the_last_stop_deletes_the_stop_the_ruler_shows_selected() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "tabs-sel", 1usize); + choose(&ctx, &mut app, "Delete Tab"); + assert_eq!(current(&app).unwrap().1.len(), 1); + choose(&ctx, &mut app, "Delete Tab"); + assert!(current(&app).unwrap().1.is_empty()); +} + +/// Position Panel Above Text floats the panel over the text, its ruler's 0 on the frame's left +/// edge and a stop's mark over the column it sets, at any zoom (#729). +#[test] +fn above_text_puts_the_ruler_over_the_text_at_the_zoom() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + app.session.execute("text.create", &json!({"x": 40, "y": 200, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0))); + for zoom in [1.0, 2.0] { + if let Some(v) = app.view_mut() { + v.zoom = zoom; + } + let (node, _, span) = current(&app).unwrap(); + let ctx = egui::Context::default(); + above_text(&mut app, &ctx, &node, span).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let g = layout.floating.iter().find(|w| w.contains(&"tabs".to_string())).unwrap(); + let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let frame = xf.rect_to_screen(node.geometric_bounds().unwrap()); + assert!((g.rect[0] + RULER_LEFT - frame.left()).abs() < 0.01, "0 on the frame's left edge"); + assert!(g.rect[1] < frame.top(), "above the text"); + // The ruler (the panel less its margins) spans the frame at this zoom. + let ruler = g.rect[2] - RULER_LEFT * 2.0; + assert!((ruler - frame.width()).abs() < 0.01, "{ruler} {}", frame.width()); + assert!((ruler - 300.0 * zoom as f32).abs() < 0.01); + } + assert_eq!(app.ui.docking.unwrap().floating.len(), 1, "placed again, not floated twice"); +} From 1160aa433641908e88faeb4ec4c398418fabae60 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:56 +0000 Subject: [PATCH 042/199] refactor(tools): move rulerguide.rs tests to rulerguide/tests.rs --- crates/tools/src/rulerguide.rs | 605 +-------------------------- crates/tools/src/rulerguide/tests.rs | 602 ++++++++++++++++++++++++++ 2 files changed, 603 insertions(+), 604 deletions(-) create mode 100644 crates/tools/src/rulerguide/tests.rs diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 5473d620f..87822f070 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -343,607 +343,4 @@ fn on_ruler(cx: &ToolContext, vertical: bool, p: Point) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use crate::{Mods, ScreenFrame}; - use vectorcraft_doc::{Guide, Selection}; - use vectorcraft_geom::Vec2; - - fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent { - PointerEvent::new(kind, x, y).with_mods(mods) - } - - fn guides_doc() -> vectorcraft_doc::Document { - let (mut d, _) = doc_with_rect(); - d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)]; - d - } - - #[test] - fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() { - let (d, s, p) = (guides_doc(), Selection::default(), paint()); - let c = cx(&d, &s, &p); - assert_eq!(guide_at(&c, Point::new(302.0, 50.0)), Some(0)); - assert_eq!(guide_at(&c, Point::new(50.0, 398.0)), Some(1)); - // Where they cross, the nearer one. - assert_eq!(guide_at(&c, Point::new(301.0, 398.0)), Some(0)); - assert_eq!(guide_at(&c, Point::new(305.0, 50.0)), None, "beyond the 3 px tolerance"); - let hidden = ToolContext { guides: false, ..cx(&d, &s, &p) }; - assert_eq!(guide_at(&hidden, Point::new(300.0, 50.0)), None); - } - - #[test] - fn a_drag_moves_the_selected_guides_in_one_step() { - let d = guides_doc(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut g = GuideEdit::default(); - let m = Mods::default(); - assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))])); - // The tool sees the guide selected from now on. - let mut s = s; - s.set_guides([0]); - let c = cx(&d, &s, &p); - let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap(); - assert_eq!(a[0], Action::Begin("Move Guide".into())); - assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 40.0, "dy": 10.0, "copy": false}))); - assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "X: 340.00 pt")); - assert_eq!(g.cursor(&c, Point::ZERO), Some(Cursor::ResizeH)); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 60.0, m)), Some(vec![Action::Commit])); - assert!(!g.busy() && g.pointer(&c, &ev(PointerKind::Drag, 0.0, 0.0, m)).is_none()); - } - - #[test] - fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() { - let d = guides_doc(); - let mut s = Selection::default(); - s.set_guides([0]); - let p = paint(); - let drag_to = |c: &ToolContext, x: f64, mods: Mods| { - let mut g = GuideEdit::default(); - g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default())); - let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, mods)).unwrap(); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - (300.0 + v["dx"].as_f64().unwrap(), g.overlays(c)) - }; - // Smart Guides: onto the rect's right edge (x = 200), labelled. - let (x, ov) = drag_to(&cx(&d, &s, &p), 203.0, Mods::default()); - assert_eq!(x, 200.0); - assert!(matches!(&ov[0], Overlay::Label { .. })); - let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; - assert_eq!(drag_to(&off, 203.0, Mods::default()).0, 203.0); - // Shift: the ruler's ticks (labels every 50 pt at 100 %, ticks every 5 pt). - assert_eq!(drag_to(&off, 223.0, Mods { shift: true, ..Default::default() }).0, 225.0); - let zoomed = ToolContext { zoom: 5.0, ..cx(&d, &s, &p) }; - assert_eq!(drag_to(&zoomed, 223.4, Mods { shift: true, ..Default::default() }).0, 223.0, "every point at 500 %"); - let grid = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - assert_eq!(drag_to(&grid, 226.0, Mods::default()).0, 225.0, "the grid's 9 pt subdivisions"); - } - - /// #451: a guide dragged out of a ruler lands on the art's corners, side midpoints (its - /// centre's line) and anchors, and on the artboard's edges and centre, labelled. - #[test] - fn a_new_guide_snaps_to_the_art_and_the_artboard() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let m = Mods::default(); - let drag = |vertical: bool, v: f64| { - let mut g = NewGuide::new(&c, vertical, None); - let at = if vertical { Point::new(v, 50.0) } else { Point::new(50.0, v) }; - let a = g.pointer(&c, &ev(PointerKind::Drag, at.x, at.y, m), true); - let Some(Action::Preview(cmd, p)) = a.last() else { panic!("{a:?}") }; - assert_eq!((a[0].clone(), cmd.as_str(), &p["vertical"]), (Action::Begin("New Guide".into()), "guide.add", &json!(vertical))); - let label = g.overlays(&c).iter().find_map(|o| match o { - Overlay::Label { text, .. } => Some(text.clone()), - _ => None, - }); - (p["pos"].as_f64().unwrap(), label) - }; - assert_eq!(drag(true, 152.0), (150.0, Some("center".into())), "the line through the top and bottom midpoints"); - assert_eq!(drag(false, 103.0), (100.0, Some("path".into())), "the top edge, through two corners"); - assert_eq!(drag(true, 497.0), (500.0, Some("artboard".into())), "the artboard's right edge"); - assert_eq!(drag(false, 248.0), (250.0, Some("center".into())), "the artboard's centre"); - assert_eq!(drag(true, 320.0), (320.0, None), "nothing within reach"); - } - - /// Preferences › Smart Guides (#394) reach a guide dragged out of a ruler: Snapping - /// Tolerance sets how far a target pulls it, Anchor/Path Labels and Measurement Labels what - /// shows by it. - #[test] - fn a_new_guide_follows_the_smart_guide_preferences() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let drag = |c: &ToolContext| { - let mut g = NewGuide::new(c, true, None); - let a = g.pointer(c, &ev(PointerKind::Drag, 497.0, 50.0, Mods::default()), true); - let Some(Action::Preview(_, p)) = a.last() else { panic!("{a:?}") }; - (p["pos"].as_f64().unwrap(), g.overlays(c)) - }; - let (at, ov) = drag(&ToolContext { snapping_tolerance: 2.0, ..cx(&d, &s, &p) }); - assert_eq!((at, ov.len()), (497.0, 1), "3 px off is beyond 2: only the readout {ov:?}"); - let (at, ov) = drag(&ToolContext { anchor_path_labels: false, measurement_labels: false, ..cx(&d, &s, &p) }); - assert_eq!((at, ov), (500.0, vec![]), "onto the artboard's edge, nothing said"); - } - - #[test] - fn a_new_guide_is_made_on_release_over_the_canvas_only() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - let m = Mods::default(); - let mut g = NewGuide::new(&c, false, Some(0)); - let preview = |pos: f64| Action::Preview("guide.add".into(), json!({"vertical": false, "pos": pos, "artboard": 0})); - // Off the canvas (still over the ruler): nothing yet. - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![]); - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 30.0, m), true), vec![Action::Begin("New Guide".into()), preview(30.0)]); - assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "Y: 30.00 pt")); - // The same place again: no new preview. - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 90.0, 30.0, m), true), vec![]); - // Back onto the ruler it goes, and comes back over the canvas. - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![Action::Cancel]); - assert!(g.overlays(&c).is_empty()); - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 60.0, m), true), vec![Action::Begin("New Guide".into()), preview(60.0)]); - // Shift: the ruler's ticks. - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 63.0, Mods { shift: true, ..m }), true), vec![preview(65.0)]); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 40.0, 65.0, m), true), vec![Action::Commit]); - // Released off the canvas: none. - let mut g = NewGuide::new(&c, true, None); - g.pointer(&c, &ev(PointerKind::Drag, 30.0, 40.0, m), true); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, -5.0, 40.0, m), false), vec![Action::Cancel]); - } - - #[test] - fn new_ruler_guide_alt_switches_axis_and_uses_the_release_position() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - for source_vertical in [false, true] { - let mut g = NewGuide::new(&c, source_vertical, Some(0)); - for (i, alt) in [false, true, false, true].into_iter().enumerate() { - let vertical = source_vertical != alt; - let at = if vertical { 80.0 } else { 120.0 }; - let mut expected = vec![]; - if i == 0 { - expected.push(Action::Begin("New Guide".into())); - } - expected.push(Action::Preview("guide.add".into(), json!({"vertical": vertical, "pos": at, "artboard": 0}))); - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 80.0, 120.0, Mods { alt, ..Default::default() }), true), expected); - let label = format!("{}: {at:.2} pt", if vertical { "X" } else { "Y" }); - assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &label)); - } - // Releasing at a new position with Alt released uses both final coordinates and modifiers. - let at = if source_vertical { 190.0 } else { 240.0 }; - assert_eq!( - g.pointer(&c, &ev(PointerKind::Up, 190.0, 240.0, Mods::default()), true), - vec![Action::Preview("guide.add".into(), json!({"vertical": source_vertical, "pos": at, "artboard": 0})), Action::Commit] - ); - assert!(g.overlays(&c).is_empty()); - } - } - - #[test] - fn new_ruler_guide_alt_switches_even_when_coordinates_are_equal() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - for source_vertical in [false, true] { - let mut g = NewGuide::new(&c, source_vertical, None); - g.pointer(&c, &ev(PointerKind::Drag, 80.0, 80.0, Mods::default()), true); - for alt in [true, false] { - assert_eq!( - g.pointer(&c, &ev(PointerKind::Drag, 80.0, 80.0, Mods { alt, ..Default::default() }), true), - vec![Action::Preview("guide.add".into(), json!({"vertical": source_vertical != alt, "pos": 80.0}))] - ); - } - } - } - - #[test] - fn new_ruler_guide_alt_snaps_on_the_effective_axis() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - let grid = ToolContext { snap_to_grid: true, ..off }; - let pixel = ToolContext { snap_to_pixel: true, ..off }; - let smart = cx(&d, &s, &p); - for (c, shift, x, y, expected_x, expected_y) in [ - (&off, true, 223.0, 237.0, 225.0, 235.0), - (&grid, false, 226.0, 237.0, 225.0, 234.0), - (&pixel, false, 226.4, 237.6, 226.0, 238.0), - (&smart, false, 203.0, 102.0, 200.0, 100.0), - ] { - for source_vertical in [false, true] { - let mut g = NewGuide::new(c, source_vertical, None); - let actions = g.pointer(c, &ev(PointerKind::Drag, x, y, Mods { alt: true, shift, ..Default::default() }), true); - let expected = if source_vertical { expected_y } else { expected_x }; - assert_eq!(actions.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": !source_vertical, "pos": expected})))); - } - } - } - - /// With Smart Guides off, Snap to Point pulls a dragged guide onto the anchors (within its - /// distance), not the edges' midpoints. - #[test] - fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() { - let d = guides_doc(); - let mut s = Selection::default(); - s.set_guides([0]); - let p = paint(); - let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; - let moved_to = |c: &ToolContext, x: f64| { - let mut g = GuideEdit::default(); - g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default())); - let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, Mods::default())).unwrap(); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - 300.0 + v["dx"].as_f64().unwrap() - }; - assert_eq!(moved_to(&off, 201.5), 200.0); - assert_eq!(moved_to(&off, 151.0), 151.0, "the centre is no anchor"); - assert_eq!(moved_to(&off, 203.0), 203.0, "beyond the 2 px Snap to Point distance"); - let neither = ToolContext { snap_to_point: false, ..off }; - assert_eq!(moved_to(&neither, 201.5), 201.5); - let mut g = NewGuide::new(&off, true, None); - let a = g.pointer(&off, &ev(PointerKind::Drag, 98.5, 20.0, Mods::default()), true); - assert_eq!(a.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": true, "pos": 100.0})))); - } - - /// #451: an artboard guide runs across its artboard only: it is picked there, and pulls a - /// point with Snap to Point there. - #[test] - fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { - let (mut d, _) = doc_with_rect(); - let id = d.next_artboard_id(); - d.artboards.push(vectorcraft_doc::Artboard { - id, - name: "B".into(), - rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }); - d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0)); - assert_eq!(guide_at(&c, Point::new(602.0 - 4.0, 151.0)), Some(0), "within the tolerance of its end"); - assert_eq!(guide_at(&c, Point::new(300.0, 151.0)), None, "not across the first artboard"); - let t = Targets::snap_to_point(&ToolContext { smart_guides: false, ..c }, &[]).unwrap(); - assert_eq!(t.snap_point(Point::new(700.0, 151.5), 2.0).0, Point::new(700.0, 150.0)); - assert_eq!(t.snap_point(Point::new(300.0, 151.5), 2.0).0, Point::new(300.0, 151.5)); - } - - #[test] - fn shift_click_toggles_and_alt_drag_copies() { - let d = guides_doc(); - let mut s = Selection::default(); - s.set_guides([0, 1]); - let p = paint(); - let c = cx(&d, &s, &p); - let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }); - let mut g = GuideEdit::default(); - assert_eq!(g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, shift)), Some(vec![])); - let deselect = Action::Exec("guide.select".into(), json!({"indexes": [1], "toggle": true})); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, 400.0, shift)), Some(vec![deselect])); - g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, alt)); - let a = g.pointer(&c, &ev(PointerKind::Drag, 50.0, 420.0, alt)).unwrap(); - assert_eq!(a[0], Action::Begin("Copy Guide".into())); - assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": true}))); - } - - /// #2143: a click on a guide selected with objects makes it the key; on the key guide again, - /// no key. Alone (no objects), with Shift, or dragged, it doesn't. - #[test] - fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { - // The same document as doc_with_rect's, with the guides. - let (d, rect) = (guides_doc(), doc_with_rect().1); - let p = paint(); - let click = |s: &Selection, m: Mods| { - let c = cx(&d, s, &p); - let mut g = GuideEdit::default(); - let mut out = g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, m)).unwrap(); - out.extend(g.pointer(&c, &ev(PointerKind::Up, 302.0, 50.0, m)).unwrap()); - out - }; - let mut s = Selection::default(); - s.set([rect]); - s.toggle_guide(0); - let make = Action::Exec("select.key".into(), json!({"guide": 0})); - assert_eq!(click(&s, Mods::default()), vec![make]); - s.key_guide = Some(0); - assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); - let shift = Mods { shift: true, ..Default::default() }; - assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); - s.set_guides([0]); - assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); - // Not selected yet: the first click selects it. - s.set([rect]); - assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); - // A drag moves it. - s.toggle_guide(0); - let c = cx(&d, &s, &p); - let mut g = GuideEdit::default(); - g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); - g.pointer(&c, &ev(PointerKind::Drag, 340.0, 50.0, Mods::default())); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 50.0, Mods::default())), Some(vec![Action::Commit])); - } - - #[test] - fn dropped_on_its_ruler_the_guide_is_deleted() { - let d = guides_doc(); - let p = paint(); - let mut s = Selection::default(); - s.set_guides([1]); - // The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler. - let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; - let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) }; - let m = Mods::default(); - let gesture = |to: Point, mods: Mods| { - let mut g = GuideEdit::default(); - g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m)); - g.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)); - g.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)).unwrap() - }; - assert_eq!(gesture(Point::new(50.0, -6.0), m), vec![Action::Cancel, Action::Exec("guide.remove".into(), json!({"index": 1}))]); - assert_eq!(gesture(Point::new(50.0, -6.0), Mods { alt: true, ..m }), vec![Action::Cancel], "a copy is just not made"); - // Off the left side: not its ruler. - assert_eq!(gesture(Point::new(-6.0, 300.0), m), vec![Action::Commit]); - // Headless (no window): no ruler to drop it on. - let mut g = GuideEdit::default(); - let c = cx(&d, &s, &p); - g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m)); - g.pointer(&c, &ev(PointerKind::Drag, 50.0, -6.0, m)); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, -6.0, m)), Some(vec![Action::Commit])); - } - - // Tick positions are explicit fixtures in each unit, independent of the snap implementation. - fn origin_tick_cases() -> Vec<(vectorcraft_doc::Unit, f64, Point, Point, Point, f64)> { - use vectorcraft_doc::Unit; - let mm = |v| Unit::Millimeters.to_pt(v); - let inch = |v| Unit::Inches.to_pt(v); - vec![ - (Unit::Points, 1.0, Point::new(17.0, 23.0), Point::new(223.0, 237.0), Point::new(222.0, 238.0), 5.0), - (Unit::Points, 1.0, Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(223.0, 237.0), 5.0), - (Unit::Points, 5.0, Point::new(17.25, 23.75), Point::new(223.4, 237.2), Point::new(223.25, 236.75), 1.0), - (Unit::Millimeters, 1.0, Point::new(mm(1.0), mm(-1.0)), Point::new(mm(78.7), mm(83.6)), Point::new(mm(78.5), mm(84.0)), mm(2.5)), - ( - Unit::Inches, - 2.0, - Point::new(inch(0.01), inch(0.03)), - Point::new(inch(3.07), inch(3.27)), - // An inch ruler's ticks are binary fractions, so at zoom 2 they are 1/16 in - // apart, not the 0.05 in a decimal ladder gave (#2827). - Point::new(inch(3.0725), inch(3.28)), - inch(0.0625), - ), - ] - } - - fn guide_preview_position(actions: &[Action], vertical: bool, from: Option) -> f64 { - let Some(Action::Preview(command, p)) = actions.iter().find(|a| matches!(a, Action::Preview(_, _))) else { - panic!("missing guide preview: {actions:?}"); - }; - if let Some(from) = from { - assert_eq!(command, "guide.move"); - from + p[if vertical { "dx" } else { "dy" }].as_f64().unwrap() - } else { - assert_eq!(command, "guide.add"); - assert_eq!(p["vertical"], json!(vertical)); - p["pos"].as_f64().unwrap() - } - } - - #[test] - fn ruler_guide_shift_new_uses_document_origin() { - for (unit, zoom, origin, pointer, expected, tick) in origin_tick_cases() { - let (mut doc, _) = doc_with_rect(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let (selection, paint) = (Selection::default(), paint()); - let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - for vertical in [false, true] { - let mut guide = NewGuide::new(&c, vertical, None); - let mods = Mods { shift: true, ..Default::default() }; - let actions = guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true); - assert_eq!(actions.first(), Some(&Action::Begin("New Guide".into()))); - let want = if vertical { expected.x } else { expected.y }; - let actual = guide_preview_position(&actions, vertical, None); - assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} actual={actual} want={want}"); - assert!(guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true).is_empty()); - // Release updates to its own tick rather than committing a stale preview. - let actions = guide.pointer(&c, &ev(PointerKind::Up, pointer.x + tick * 2.0, pointer.y + tick * 2.0, mods), true); - assert!((guide_preview_position(&actions, vertical, None) - (want + tick * 2.0)).abs() < 1e-8); - assert_eq!(actions.last(), Some(&Action::Commit)); - assert!(guide.overlays(&c).is_empty()); - } - } - } - - #[test] - fn ruler_guide_shift_move_uses_document_origin() { - for (unit, zoom, origin, pointer, expected, _) in origin_tick_cases() { - let mut doc = guides_doc(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let paint = paint(); - for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { - let mut selection = Selection::default(); - selection.set_guides([index]); - let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - for copy in [false, true] { - let mut guide = GuideEdit::default(); - let (start, to, want) = if vertical { - (Point::new(from, 50.0), Point::new(pointer.x, 50.0), expected.x) - } else { - (Point::new(50.0, from), Point::new(50.0, pointer.y), expected.y) - }; - assert!(guide.press(&c, &ev(PointerKind::Down, start.x, start.y, Mods::default())).unwrap().is_empty()); - let mods = Mods { shift: true, alt: copy, ..Default::default() }; - let actions = guide.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)).unwrap(); - assert_eq!(actions.first(), Some(&Action::Begin(if copy { "Copy Guide" } else { "Move Guide" }.into()))); - let actual = guide_preview_position(&actions, vertical, Some(from)); - assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} copy={copy} actual={actual} want={want}"); - assert!(matches!(actions.last(), Some(Action::Preview(_, p)) if p["copy"] == json!(copy))); - assert_eq!(guide.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)), Some(vec![Action::Commit])); - assert!(!guide.busy()); - } - } - } - } - - #[test] - fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { - // One bad axis invalidates the entire origin, matching the app's ruler display. - for origin in [ - Point::ZERO, - Point::new(f64::NAN, 23.0), - Point::new(17.0, f64::NAN), - Point::new(f64::INFINITY, 23.0), - Point::new(17.0, f64::NEG_INFINITY), - Point::new(4_000_001.0, 23.0), - Point::new(17.0, -4_000_001.0), - ] { - let mut doc = guides_doc(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let paint = paint(); - for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { - let mut selection = Selection::default(); - selection.set_guides([index]); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - let mods = Mods { shift: true, ..Default::default() }; - let mut new = NewGuide::new(&c, vertical, None); - let actions = new.pointer(&c, &ev(PointerKind::Drag, 223.0, 237.0, mods), true); - assert_eq!(guide_preview_position(&actions, vertical, None), want); - let mut moved = GuideEdit::default(); - let (start, to) = if vertical { ((from, 50.0), (223.0, 50.0)) } else { ((50.0, from), (50.0, 237.0)) }; - moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); - let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, mods)).unwrap(); - assert_eq!(guide_preview_position(&actions, vertical, Some(from)), want); - } - } - } - - #[test] - fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { - for origin in [Point::ZERO, Point::new(17.0, 23.0), Point::new(-17.0, -23.0)] { - let mut doc = guides_doc(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let paint = paint(); - for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { - let mut selection = Selection::default(); - selection.set_guides([index]); - let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - for (c, pointer, expected) in [ - (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), - (ToolContext { snap_to_pixel: true, ..off }, Point::new(226.4, 237.6), Point::new(226.0, 238.0)), - (ToolContext { snap_to_grid: true, ..off }, Point::new(226.4, 237.6), Point::new(225.0, 234.0)), - (ToolContext { smart_guides: true, ..off }, Point::new(203.0, 102.0), Point::new(200.0, 100.0)), - (ToolContext { snap_to_point: true, ..off }, Point::new(201.0, 102.0), Point::new(200.0, 100.0)), - ] { - let want = if vertical { expected.x } else { expected.y }; - let mut new = NewGuide::new(&c, vertical, None); - let actions = new.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, Mods::default()), true); - assert!((guide_preview_position(&actions, vertical, None) - want).abs() < 1e-8); - let mut moved = GuideEdit::default(); - let (start, to) = - if vertical { ((from, pointer.y), (pointer.x, pointer.y)) } else { ((pointer.x, from), (pointer.x, pointer.y)) }; - moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); - let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods::default())).unwrap(); - assert!((guide_preview_position(&actions, vertical, Some(from)) - want).abs() < 1e-8); - } - } - } - } - - #[test] - fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { - for (origin, expected) in [ - (Point::new(17.0, 23.0), Point::new(205.0, 215.0)), - (Point::new(-17.0, -23.0), Point::new(239.0, 261.0)), - (Point::ZERO, Point::new(222.0, 238.0)), - (Point::new(f64::NAN, 23.0), Point::new(222.0, 238.0)), - (Point::new(17.0, 4_000_001.0), Point::new(222.0, 238.0)), - ] { - let mut doc = guides_doc(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let paint = paint(); - for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { - for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { - let mut selection = Selection::default(); - selection.set_guides([index]); - let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); - let check = |overlays: Vec| { - assert!( - matches!(overlays.last(), Some(Overlay::Measure { text, .. }) if text == &want), - "{origin:?} {unit:?}: {overlays:?}, want {want}" - ); - }; - let mut new = NewGuide::new(&c, vertical, None); - let actions = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); - assert_eq!( - guide_preview_position(&actions, vertical, None), - if vertical { 222.0 } else { 238.0 }, - "commands keep absolute coordinates" - ); - check(new.overlays(&c)); - for copy in [false, true] { - let mut moved = GuideEdit::default(); - let (start, to) = if vertical { ((from, 50.0), (222.0, 50.0)) } else { ((50.0, from), (50.0, 238.0)) }; - moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); - moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods { alt: copy, ..Default::default() })).unwrap(); - check(moved.overlays(&c)); - } - let quiet = ToolContext { measurement_labels: false, ..c }; - assert!(new.overlays(&quiet).is_empty(), "measurement-label preference is preserved"); - } - } - } - } - - #[test] - fn ruler_guide_readouts_count_from_the_active_artboard_with_artboard_rulers() { - let mut doc = guides_doc(); - doc.artboards.push(vectorcraft_doc::Artboard { - id: 2, - name: "Artboard 2".into(), - rect: Rect::new(150.0, 200.0, 350.0, 400.0), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }); - doc.ruler_origin = Point::new(5.0, 5.0); - let (paint, selection) = (paint(), Selection::default()); - for (rulers, active, zero) in [ - (vectorcraft_doc::RulerMode::Artboard, 1, Point::new(150.0, 200.0)), - (vectorcraft_doc::RulerMode::Artboard, 0, Point::new(doc.artboards[0].rect.x0, doc.artboards[0].rect.y0)), - (vectorcraft_doc::RulerMode::Global, 1, Point::new(5.0, 5.0)), - ] { - doc.rulers = rulers; - let c = - ToolContext { ruler_origin: doc.ruler_origin_for(active), smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - assert_eq!(c.ruler_origin, zero, "{rulers:?} {active}"); - for vertical in [true, false] { - let mut new = NewGuide::new(&c, vertical, None); - new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); - let want = if vertical { format!("X: {}", c.len(222.0 - zero.x)) } else { format!("Y: {}", c.len(238.0 - zero.y)) }; - assert!(matches!(new.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &want), "{rulers:?} {active}: want {want}"); - // Shift puts the guide on a ruler tick counted from the same zero point. - let snapped = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods { shift: true, ..Default::default() }), true); - let at = guide_preview_position(&snapped, vertical, None); - let from = if vertical { zero.x } else { zero.y }; - assert_eq!(at, from + c.unit.snap_to_ruler_tick(if vertical { 222.0 } else { 238.0 } - from, c.zoom), "{rulers:?} {active}"); - } - } - } -} +mod tests; diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs new file mode 100644 index 000000000..67b37412b --- /dev/null +++ b/crates/tools/src/rulerguide/tests.rs @@ -0,0 +1,602 @@ +use super::*; +use crate::testutil::*; +use crate::{Mods, ScreenFrame}; +use vectorcraft_doc::{Guide, Selection}; +use vectorcraft_geom::Vec2; + +fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent { + PointerEvent::new(kind, x, y).with_mods(mods) +} + +fn guides_doc() -> vectorcraft_doc::Document { + let (mut d, _) = doc_with_rect(); + d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)]; + d +} + +#[test] +fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() { + let (d, s, p) = (guides_doc(), Selection::default(), paint()); + let c = cx(&d, &s, &p); + assert_eq!(guide_at(&c, Point::new(302.0, 50.0)), Some(0)); + assert_eq!(guide_at(&c, Point::new(50.0, 398.0)), Some(1)); + // Where they cross, the nearer one. + assert_eq!(guide_at(&c, Point::new(301.0, 398.0)), Some(0)); + assert_eq!(guide_at(&c, Point::new(305.0, 50.0)), None, "beyond the 3 px tolerance"); + let hidden = ToolContext { guides: false, ..cx(&d, &s, &p) }; + assert_eq!(guide_at(&hidden, Point::new(300.0, 50.0)), None); +} + +#[test] +fn a_drag_moves_the_selected_guides_in_one_step() { + let d = guides_doc(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut g = GuideEdit::default(); + let m = Mods::default(); + assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))])); + // The tool sees the guide selected from now on. + let mut s = s; + s.set_guides([0]); + let c = cx(&d, &s, &p); + let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap(); + assert_eq!(a[0], Action::Begin("Move Guide".into())); + assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 40.0, "dy": 10.0, "copy": false}))); + assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "X: 340.00 pt")); + assert_eq!(g.cursor(&c, Point::ZERO), Some(Cursor::ResizeH)); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 60.0, m)), Some(vec![Action::Commit])); + assert!(!g.busy() && g.pointer(&c, &ev(PointerKind::Drag, 0.0, 0.0, m)).is_none()); +} + +#[test] +fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() { + let d = guides_doc(); + let mut s = Selection::default(); + s.set_guides([0]); + let p = paint(); + let drag_to = |c: &ToolContext, x: f64, mods: Mods| { + let mut g = GuideEdit::default(); + g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default())); + let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, mods)).unwrap(); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + (300.0 + v["dx"].as_f64().unwrap(), g.overlays(c)) + }; + // Smart Guides: onto the rect's right edge (x = 200), labelled. + let (x, ov) = drag_to(&cx(&d, &s, &p), 203.0, Mods::default()); + assert_eq!(x, 200.0); + assert!(matches!(&ov[0], Overlay::Label { .. })); + let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + assert_eq!(drag_to(&off, 203.0, Mods::default()).0, 203.0); + // Shift: the ruler's ticks (labels every 50 pt at 100 %, ticks every 5 pt). + assert_eq!(drag_to(&off, 223.0, Mods { shift: true, ..Default::default() }).0, 225.0); + let zoomed = ToolContext { zoom: 5.0, ..cx(&d, &s, &p) }; + assert_eq!(drag_to(&zoomed, 223.4, Mods { shift: true, ..Default::default() }).0, 223.0, "every point at 500 %"); + let grid = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + assert_eq!(drag_to(&grid, 226.0, Mods::default()).0, 225.0, "the grid's 9 pt subdivisions"); +} + +/// #451: a guide dragged out of a ruler lands on the art's corners, side midpoints (its +/// centre's line) and anchors, and on the artboard's edges and centre, labelled. +#[test] +fn a_new_guide_snaps_to_the_art_and_the_artboard() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let m = Mods::default(); + let drag = |vertical: bool, v: f64| { + let mut g = NewGuide::new(&c, vertical, None); + let at = if vertical { Point::new(v, 50.0) } else { Point::new(50.0, v) }; + let a = g.pointer(&c, &ev(PointerKind::Drag, at.x, at.y, m), true); + let Some(Action::Preview(cmd, p)) = a.last() else { panic!("{a:?}") }; + assert_eq!((a[0].clone(), cmd.as_str(), &p["vertical"]), (Action::Begin("New Guide".into()), "guide.add", &json!(vertical))); + let label = g.overlays(&c).iter().find_map(|o| match o { + Overlay::Label { text, .. } => Some(text.clone()), + _ => None, + }); + (p["pos"].as_f64().unwrap(), label) + }; + assert_eq!(drag(true, 152.0), (150.0, Some("center".into())), "the line through the top and bottom midpoints"); + assert_eq!(drag(false, 103.0), (100.0, Some("path".into())), "the top edge, through two corners"); + assert_eq!(drag(true, 497.0), (500.0, Some("artboard".into())), "the artboard's right edge"); + assert_eq!(drag(false, 248.0), (250.0, Some("center".into())), "the artboard's centre"); + assert_eq!(drag(true, 320.0), (320.0, None), "nothing within reach"); +} + +/// Preferences › Smart Guides (#394) reach a guide dragged out of a ruler: Snapping +/// Tolerance sets how far a target pulls it, Anchor/Path Labels and Measurement Labels what +/// shows by it. +#[test] +fn a_new_guide_follows_the_smart_guide_preferences() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let drag = |c: &ToolContext| { + let mut g = NewGuide::new(c, true, None); + let a = g.pointer(c, &ev(PointerKind::Drag, 497.0, 50.0, Mods::default()), true); + let Some(Action::Preview(_, p)) = a.last() else { panic!("{a:?}") }; + (p["pos"].as_f64().unwrap(), g.overlays(c)) + }; + let (at, ov) = drag(&ToolContext { snapping_tolerance: 2.0, ..cx(&d, &s, &p) }); + assert_eq!((at, ov.len()), (497.0, 1), "3 px off is beyond 2: only the readout {ov:?}"); + let (at, ov) = drag(&ToolContext { anchor_path_labels: false, measurement_labels: false, ..cx(&d, &s, &p) }); + assert_eq!((at, ov), (500.0, vec![]), "onto the artboard's edge, nothing said"); +} + +#[test] +fn a_new_guide_is_made_on_release_over_the_canvas_only() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let m = Mods::default(); + let mut g = NewGuide::new(&c, false, Some(0)); + let preview = |pos: f64| Action::Preview("guide.add".into(), json!({"vertical": false, "pos": pos, "artboard": 0})); + // Off the canvas (still over the ruler): nothing yet. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![]); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 30.0, m), true), vec![Action::Begin("New Guide".into()), preview(30.0)]); + assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "Y: 30.00 pt")); + // The same place again: no new preview. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 90.0, 30.0, m), true), vec![]); + // Back onto the ruler it goes, and comes back over the canvas. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![Action::Cancel]); + assert!(g.overlays(&c).is_empty()); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 60.0, m), true), vec![Action::Begin("New Guide".into()), preview(60.0)]); + // Shift: the ruler's ticks. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 63.0, Mods { shift: true, ..m }), true), vec![preview(65.0)]); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 40.0, 65.0, m), true), vec![Action::Commit]); + // Released off the canvas: none. + let mut g = NewGuide::new(&c, true, None); + g.pointer(&c, &ev(PointerKind::Drag, 30.0, 40.0, m), true); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, -5.0, 40.0, m), false), vec![Action::Cancel]); +} + +#[test] +fn new_ruler_guide_alt_switches_axis_and_uses_the_release_position() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + for source_vertical in [false, true] { + let mut g = NewGuide::new(&c, source_vertical, Some(0)); + for (i, alt) in [false, true, false, true].into_iter().enumerate() { + let vertical = source_vertical != alt; + let at = if vertical { 80.0 } else { 120.0 }; + let mut expected = vec![]; + if i == 0 { + expected.push(Action::Begin("New Guide".into())); + } + expected.push(Action::Preview("guide.add".into(), json!({"vertical": vertical, "pos": at, "artboard": 0}))); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 80.0, 120.0, Mods { alt, ..Default::default() }), true), expected); + let label = format!("{}: {at:.2} pt", if vertical { "X" } else { "Y" }); + assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &label)); + } + // Releasing at a new position with Alt released uses both final coordinates and modifiers. + let at = if source_vertical { 190.0 } else { 240.0 }; + assert_eq!( + g.pointer(&c, &ev(PointerKind::Up, 190.0, 240.0, Mods::default()), true), + vec![Action::Preview("guide.add".into(), json!({"vertical": source_vertical, "pos": at, "artboard": 0})), Action::Commit] + ); + assert!(g.overlays(&c).is_empty()); + } +} + +#[test] +fn new_ruler_guide_alt_switches_even_when_coordinates_are_equal() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + for source_vertical in [false, true] { + let mut g = NewGuide::new(&c, source_vertical, None); + g.pointer(&c, &ev(PointerKind::Drag, 80.0, 80.0, Mods::default()), true); + for alt in [true, false] { + assert_eq!( + g.pointer(&c, &ev(PointerKind::Drag, 80.0, 80.0, Mods { alt, ..Default::default() }), true), + vec![Action::Preview("guide.add".into(), json!({"vertical": source_vertical != alt, "pos": 80.0}))] + ); + } + } +} + +#[test] +fn new_ruler_guide_alt_snaps_on_the_effective_axis() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let grid = ToolContext { snap_to_grid: true, ..off }; + let pixel = ToolContext { snap_to_pixel: true, ..off }; + let smart = cx(&d, &s, &p); + for (c, shift, x, y, expected_x, expected_y) in [ + (&off, true, 223.0, 237.0, 225.0, 235.0), + (&grid, false, 226.0, 237.0, 225.0, 234.0), + (&pixel, false, 226.4, 237.6, 226.0, 238.0), + (&smart, false, 203.0, 102.0, 200.0, 100.0), + ] { + for source_vertical in [false, true] { + let mut g = NewGuide::new(c, source_vertical, None); + let actions = g.pointer(c, &ev(PointerKind::Drag, x, y, Mods { alt: true, shift, ..Default::default() }), true); + let expected = if source_vertical { expected_y } else { expected_x }; + assert_eq!(actions.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": !source_vertical, "pos": expected})))); + } + } +} + +/// With Smart Guides off, Snap to Point pulls a dragged guide onto the anchors (within its +/// distance), not the edges' midpoints. +#[test] +fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() { + let d = guides_doc(); + let mut s = Selection::default(); + s.set_guides([0]); + let p = paint(); + let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + let moved_to = |c: &ToolContext, x: f64| { + let mut g = GuideEdit::default(); + g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default())); + let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, Mods::default())).unwrap(); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + 300.0 + v["dx"].as_f64().unwrap() + }; + assert_eq!(moved_to(&off, 201.5), 200.0); + assert_eq!(moved_to(&off, 151.0), 151.0, "the centre is no anchor"); + assert_eq!(moved_to(&off, 203.0), 203.0, "beyond the 2 px Snap to Point distance"); + let neither = ToolContext { snap_to_point: false, ..off }; + assert_eq!(moved_to(&neither, 201.5), 201.5); + let mut g = NewGuide::new(&off, true, None); + let a = g.pointer(&off, &ev(PointerKind::Drag, 98.5, 20.0, Mods::default()), true); + assert_eq!(a.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": true, "pos": 100.0})))); +} + +/// #451: an artboard guide runs across its artboard only: it is picked there, and pulls a +/// point with Snap to Point there. +#[test] +fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { + let (mut d, _) = doc_with_rect(); + let id = d.next_artboard_id(); + d.artboards.push(vectorcraft_doc::Artboard { + id, + name: "B".into(), + rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }); + d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0)); + assert_eq!(guide_at(&c, Point::new(602.0 - 4.0, 151.0)), Some(0), "within the tolerance of its end"); + assert_eq!(guide_at(&c, Point::new(300.0, 151.0)), None, "not across the first artboard"); + let t = Targets::snap_to_point(&ToolContext { smart_guides: false, ..c }, &[]).unwrap(); + assert_eq!(t.snap_point(Point::new(700.0, 151.5), 2.0).0, Point::new(700.0, 150.0)); + assert_eq!(t.snap_point(Point::new(300.0, 151.5), 2.0).0, Point::new(300.0, 151.5)); +} + +#[test] +fn shift_click_toggles_and_alt_drag_copies() { + let d = guides_doc(); + let mut s = Selection::default(); + s.set_guides([0, 1]); + let p = paint(); + let c = cx(&d, &s, &p); + let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }); + let mut g = GuideEdit::default(); + assert_eq!(g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, shift)), Some(vec![])); + let deselect = Action::Exec("guide.select".into(), json!({"indexes": [1], "toggle": true})); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, 400.0, shift)), Some(vec![deselect])); + g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, alt)); + let a = g.pointer(&c, &ev(PointerKind::Drag, 50.0, 420.0, alt)).unwrap(); + assert_eq!(a[0], Action::Begin("Copy Guide".into())); + assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": true}))); +} + +/// #2143: a click on a guide selected with objects makes it the key; on the key guide again, +/// no key. Alone (no objects), with Shift, or dragged, it doesn't. +#[test] +fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { + // The same document as doc_with_rect's, with the guides. + let (d, rect) = (guides_doc(), doc_with_rect().1); + let p = paint(); + let click = |s: &Selection, m: Mods| { + let c = cx(&d, s, &p); + let mut g = GuideEdit::default(); + let mut out = g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, m)).unwrap(); + out.extend(g.pointer(&c, &ev(PointerKind::Up, 302.0, 50.0, m)).unwrap()); + out + }; + let mut s = Selection::default(); + s.set([rect]); + s.toggle_guide(0); + let make = Action::Exec("select.key".into(), json!({"guide": 0})); + assert_eq!(click(&s, Mods::default()), vec![make]); + s.key_guide = Some(0); + assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); + s.set_guides([0]); + assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); + // Not selected yet: the first click selects it. + s.set([rect]); + assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); + // A drag moves it. + s.toggle_guide(0); + let c = cx(&d, &s, &p); + let mut g = GuideEdit::default(); + g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); + g.pointer(&c, &ev(PointerKind::Drag, 340.0, 50.0, Mods::default())); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 50.0, Mods::default())), Some(vec![Action::Commit])); +} + +#[test] +fn dropped_on_its_ruler_the_guide_is_deleted() { + let d = guides_doc(); + let p = paint(); + let mut s = Selection::default(); + s.set_guides([1]); + // The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler. + let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; + let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) }; + let m = Mods::default(); + let gesture = |to: Point, mods: Mods| { + let mut g = GuideEdit::default(); + g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m)); + g.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)); + g.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)).unwrap() + }; + assert_eq!(gesture(Point::new(50.0, -6.0), m), vec![Action::Cancel, Action::Exec("guide.remove".into(), json!({"index": 1}))]); + assert_eq!(gesture(Point::new(50.0, -6.0), Mods { alt: true, ..m }), vec![Action::Cancel], "a copy is just not made"); + // Off the left side: not its ruler. + assert_eq!(gesture(Point::new(-6.0, 300.0), m), vec![Action::Commit]); + // Headless (no window): no ruler to drop it on. + let mut g = GuideEdit::default(); + let c = cx(&d, &s, &p); + g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m)); + g.pointer(&c, &ev(PointerKind::Drag, 50.0, -6.0, m)); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, -6.0, m)), Some(vec![Action::Commit])); +} + +// Tick positions are explicit fixtures in each unit, independent of the snap implementation. +fn origin_tick_cases() -> Vec<(vectorcraft_doc::Unit, f64, Point, Point, Point, f64)> { + use vectorcraft_doc::Unit; + let mm = |v| Unit::Millimeters.to_pt(v); + let inch = |v| Unit::Inches.to_pt(v); + vec![ + (Unit::Points, 1.0, Point::new(17.0, 23.0), Point::new(223.0, 237.0), Point::new(222.0, 238.0), 5.0), + (Unit::Points, 1.0, Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(223.0, 237.0), 5.0), + (Unit::Points, 5.0, Point::new(17.25, 23.75), Point::new(223.4, 237.2), Point::new(223.25, 236.75), 1.0), + (Unit::Millimeters, 1.0, Point::new(mm(1.0), mm(-1.0)), Point::new(mm(78.7), mm(83.6)), Point::new(mm(78.5), mm(84.0)), mm(2.5)), + ( + Unit::Inches, + 2.0, + Point::new(inch(0.01), inch(0.03)), + Point::new(inch(3.07), inch(3.27)), + // An inch ruler's ticks are binary fractions, so at zoom 2 they are 1/16 in + // apart, not the 0.05 in a decimal ladder gave (#2827). + Point::new(inch(3.0725), inch(3.28)), + inch(0.0625), + ), + ] +} + +fn guide_preview_position(actions: &[Action], vertical: bool, from: Option) -> f64 { + let Some(Action::Preview(command, p)) = actions.iter().find(|a| matches!(a, Action::Preview(_, _))) else { + panic!("missing guide preview: {actions:?}"); + }; + if let Some(from) = from { + assert_eq!(command, "guide.move"); + from + p[if vertical { "dx" } else { "dy" }].as_f64().unwrap() + } else { + assert_eq!(command, "guide.add"); + assert_eq!(p["vertical"], json!(vertical)); + p["pos"].as_f64().unwrap() + } +} + +#[test] +fn ruler_guide_shift_new_uses_document_origin() { + for (unit, zoom, origin, pointer, expected, tick) in origin_tick_cases() { + let (mut doc, _) = doc_with_rect(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let (selection, paint) = (Selection::default(), paint()); + let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + for vertical in [false, true] { + let mut guide = NewGuide::new(&c, vertical, None); + let mods = Mods { shift: true, ..Default::default() }; + let actions = guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true); + assert_eq!(actions.first(), Some(&Action::Begin("New Guide".into()))); + let want = if vertical { expected.x } else { expected.y }; + let actual = guide_preview_position(&actions, vertical, None); + assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} actual={actual} want={want}"); + assert!(guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true).is_empty()); + // Release updates to its own tick rather than committing a stale preview. + let actions = guide.pointer(&c, &ev(PointerKind::Up, pointer.x + tick * 2.0, pointer.y + tick * 2.0, mods), true); + assert!((guide_preview_position(&actions, vertical, None) - (want + tick * 2.0)).abs() < 1e-8); + assert_eq!(actions.last(), Some(&Action::Commit)); + assert!(guide.overlays(&c).is_empty()); + } + } +} + +#[test] +fn ruler_guide_shift_move_uses_document_origin() { + for (unit, zoom, origin, pointer, expected, _) in origin_tick_cases() { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let paint = paint(); + for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + for copy in [false, true] { + let mut guide = GuideEdit::default(); + let (start, to, want) = if vertical { + (Point::new(from, 50.0), Point::new(pointer.x, 50.0), expected.x) + } else { + (Point::new(50.0, from), Point::new(50.0, pointer.y), expected.y) + }; + assert!(guide.press(&c, &ev(PointerKind::Down, start.x, start.y, Mods::default())).unwrap().is_empty()); + let mods = Mods { shift: true, alt: copy, ..Default::default() }; + let actions = guide.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)).unwrap(); + assert_eq!(actions.first(), Some(&Action::Begin(if copy { "Copy Guide" } else { "Move Guide" }.into()))); + let actual = guide_preview_position(&actions, vertical, Some(from)); + assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} copy={copy} actual={actual} want={want}"); + assert!(matches!(actions.last(), Some(Action::Preview(_, p)) if p["copy"] == json!(copy))); + assert_eq!(guide.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)), Some(vec![Action::Commit])); + assert!(!guide.busy()); + } + } + } +} + +#[test] +fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { + // One bad axis invalidates the entire origin, matching the app's ruler display. + for origin in [ + Point::ZERO, + Point::new(f64::NAN, 23.0), + Point::new(17.0, f64::NAN), + Point::new(f64::INFINITY, 23.0), + Point::new(17.0, f64::NEG_INFINITY), + Point::new(4_000_001.0, 23.0), + Point::new(17.0, -4_000_001.0), + ] { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let paint = paint(); + for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + let mods = Mods { shift: true, ..Default::default() }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, 223.0, 237.0, mods), true); + assert_eq!(guide_preview_position(&actions, vertical, None), want); + let mut moved = GuideEdit::default(); + let (start, to) = if vertical { ((from, 50.0), (223.0, 50.0)) } else { ((50.0, from), (50.0, 237.0)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, mods)).unwrap(); + assert_eq!(guide_preview_position(&actions, vertical, Some(from)), want); + } + } +} + +#[test] +fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { + for origin in [Point::ZERO, Point::new(17.0, 23.0), Point::new(-17.0, -23.0)] { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let paint = paint(); + for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + for (c, pointer, expected) in [ + (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), + (ToolContext { snap_to_pixel: true, ..off }, Point::new(226.4, 237.6), Point::new(226.0, 238.0)), + (ToolContext { snap_to_grid: true, ..off }, Point::new(226.4, 237.6), Point::new(225.0, 234.0)), + (ToolContext { smart_guides: true, ..off }, Point::new(203.0, 102.0), Point::new(200.0, 100.0)), + (ToolContext { snap_to_point: true, ..off }, Point::new(201.0, 102.0), Point::new(200.0, 100.0)), + ] { + let want = if vertical { expected.x } else { expected.y }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, Mods::default()), true); + assert!((guide_preview_position(&actions, vertical, None) - want).abs() < 1e-8); + let mut moved = GuideEdit::default(); + let (start, to) = + if vertical { ((from, pointer.y), (pointer.x, pointer.y)) } else { ((pointer.x, from), (pointer.x, pointer.y)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods::default())).unwrap(); + assert!((guide_preview_position(&actions, vertical, Some(from)) - want).abs() < 1e-8); + } + } + } +} + +#[test] +fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { + for (origin, expected) in [ + (Point::new(17.0, 23.0), Point::new(205.0, 215.0)), + (Point::new(-17.0, -23.0), Point::new(239.0, 261.0)), + (Point::ZERO, Point::new(222.0, 238.0)), + (Point::new(f64::NAN, 23.0), Point::new(222.0, 238.0)), + (Point::new(17.0, 4_000_001.0), Point::new(222.0, 238.0)), + ] { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let paint = paint(); + for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { + for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); + let check = |overlays: Vec| { + assert!( + matches!(overlays.last(), Some(Overlay::Measure { text, .. }) if text == &want), + "{origin:?} {unit:?}: {overlays:?}, want {want}" + ); + }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); + assert_eq!( + guide_preview_position(&actions, vertical, None), + if vertical { 222.0 } else { 238.0 }, + "commands keep absolute coordinates" + ); + check(new.overlays(&c)); + for copy in [false, true] { + let mut moved = GuideEdit::default(); + let (start, to) = if vertical { ((from, 50.0), (222.0, 50.0)) } else { ((50.0, from), (50.0, 238.0)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods { alt: copy, ..Default::default() })).unwrap(); + check(moved.overlays(&c)); + } + let quiet = ToolContext { measurement_labels: false, ..c }; + assert!(new.overlays(&quiet).is_empty(), "measurement-label preference is preserved"); + } + } + } +} + +#[test] +fn ruler_guide_readouts_count_from_the_active_artboard_with_artboard_rulers() { + let mut doc = guides_doc(); + doc.artboards.push(vectorcraft_doc::Artboard { + id: 2, + name: "Artboard 2".into(), + rect: Rect::new(150.0, 200.0, 350.0, 400.0), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }); + doc.ruler_origin = Point::new(5.0, 5.0); + let (paint, selection) = (paint(), Selection::default()); + for (rulers, active, zero) in [ + (vectorcraft_doc::RulerMode::Artboard, 1, Point::new(150.0, 200.0)), + (vectorcraft_doc::RulerMode::Artboard, 0, Point::new(doc.artboards[0].rect.x0, doc.artboards[0].rect.y0)), + (vectorcraft_doc::RulerMode::Global, 1, Point::new(5.0, 5.0)), + ] { + doc.rulers = rulers; + let c = + ToolContext { ruler_origin: doc.ruler_origin_for(active), smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + assert_eq!(c.ruler_origin, zero, "{rulers:?} {active}"); + for vertical in [true, false] { + let mut new = NewGuide::new(&c, vertical, None); + new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); + let want = if vertical { format!("X: {}", c.len(222.0 - zero.x)) } else { format!("Y: {}", c.len(238.0 - zero.y)) }; + assert!(matches!(new.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &want), "{rulers:?} {active}: want {want}"); + // Shift puts the guide on a ruler tick counted from the same zero point. + let snapped = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods { shift: true, ..Default::default() }), true); + let at = guide_preview_position(&snapped, vertical, None); + let from = if vertical { zero.x } else { zero.y }; + assert_eq!(at, from + c.unit.snap_to_ruler_tick(if vertical { 222.0 } else { 238.0 } - from, c.zoom), "{rulers:?} {active}"); + } + } +} From bfcd245bb6139d5845e7db0d7e6a35e5b8dfc531 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:57 +0000 Subject: [PATCH 043/199] refactor(ui-egui): move panels/color.rs tests to panels/color/tests.rs --- crates/ui-egui/src/panels/color.rs | 597 +---------------------- crates/ui-egui/src/panels/color/tests.rs | 594 ++++++++++++++++++++++ 2 files changed, 595 insertions(+), 596 deletions(-) create mode 100644 crates/ui-egui/src/panels/color/tests.rs diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 379a3a025..c1153ce46 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -749,599 +749,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_engine::Session; - - fn app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); - app - } - - /// Run the panel for one frame with `events` and `modifiers`; returns the spectrum's rect (when - /// the options are shown) and the panel's height. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: egui::Modifiers) -> (Option, f32) { - frame_with(app, ctx, events, modifiers, "color-spectrum") - } - - /// [`frame`] returning the rect of the panel's widget `id` instead of the spectrum's. - fn frame_with( - app: &mut VectorcraftApp, - ctx: &egui::Context, - events: Vec, - modifiers: egui::Modifiers, - id: impl std::hash::Hash + std::fmt::Debug, - ) -> (Option, f32) { - let mut out = (None, 0.0); - let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); - let input = egui::RawInput { events, ..Default::default() }; - let mut full = ctx.run_ui(input, |ui| { - show(app, ui); - out = (ctx.read_response(ui.scope_id().with(&id)).map(|r| r.rect), ui.min_rect().height()); - }); - full.textures_delta.clear(); - out - } - - /// Click the spectrum at (x, y) (0..1 each) with `modifiers` held. - fn click_spectrum(app: &mut VectorcraftApp, ctx: &egui::Context, (x, y): (f32, f32), modifiers: egui::Modifiers) { - let r = frame(app, ctx, vec![], modifiers).0.expect("the spectrum is drawn"); - let pos = egui::pos2(r.left() + x * r.width(), r.top() + y * r.height()); - let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; - frame(app, ctx, vec![egui::Event::PointerMoved(pos), button(true)], modifiers); - frame(app, ctx, vec![button(false)], modifiers); - frame(app, ctx, vec![], egui::Modifiers::NONE); - } - - #[test] - fn a_recent_colour_recolours_the_selected_gradient_stop() { - // #835: it replaced the whole gradient with the colour. - let mut app = app(); - app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); - let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); - app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [10, 35], "end": [60, 35]}})).unwrap(); - app.select_tool("gradient"); - app.run("gradient.selectStop", json!({"index": 1})).unwrap(); - let red = app.session.recent_colors.iter().position(|c| c.to_hex() == "#ff0000").expect("red is a recent colour"); - let ctx = egui::Context::default(); - let none = egui::Modifiers::NONE; - let chip = frame_with(&mut app, &ctx, vec![], none, ("recent", red)).0.expect("the chip is drawn").center(); - let button = |pressed| egui::Event::PointerButton { pos: chip, button: egui::PointerButton::Primary, pressed, modifiers: none }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(chip), button(true)], none); - frame(&mut app, &ctx, vec![button(false)], none); - let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") }; - let hex: Vec = g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(); - assert_eq!(hex, ["#ffffff", "#ff0000"]); - } - - /// In a CMYK document the sliders keep the model picked in the panel for a gradient stop and a - /// freeform point, as they do for a solid fill. - #[test] - fn a_gradient_stop_and_a_freeform_point_keep_the_panels_model_in_a_cmyk_document() { - let mut app = app(); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - let stops = json!([{"offset": 0, "color": {"c": 0, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 100, "y": 100, "k": 0}}]); - app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); - app.select_tool("gradient"); - app.run("gradient.selectStop", json!({"index": 1})).unwrap(); - let orange = Color::rgb8(255, 128, 0); - let tgt = target_with(&app, false); - apply(&mut app, &tgt, orange, Live::Released, false); - let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") }; - assert_eq!(g.gradient.stops[1].color, orange); - app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); - let tgt = target_with(&app, false); - assert!(matches!(tgt, Target::Point { index: 0, .. }), "a freeform point is the target"); - apply(&mut app, &tgt, orange, Live::Released, false); - let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") }; - assert_eq!(g.freeform.map(|f| f.points[0].color), Some(orange)); - } - - /// One headless frame of the panel menu; returns its painted labels. - fn menu_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(236.0, 400.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| menu(app, ui)); - out.textures_delta.clear(); - crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) - } - - /// Click the panel menu's item `label`. - fn click_menu_item(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { - let labels = menu_frame(app, ctx, vec![]); - let at = labels.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label} in the menu")); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; - menu_frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]); - menu_frame(app, ctx, vec![button(false)]); - } - - /// In a CMYK document the panel menu's Create New Swatch saves the color the panel shows in - /// that color's model: an RGB object gives an RGB swatch, and CMYK, gray and spot colors give - /// swatches of their own colors. - #[test] - fn create_new_swatch_keeps_the_shown_colors_model_in_a_cmyk_document() { - let mut app = app(); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 60, "y": 90, "k": 0}, "spot": true})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let newest = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch"); - let ink = app.session.doc().unwrap().doc.swatch("Ink").and_then(|w| w.paint.color()).unwrap(); - for (fill, model) in [ - (json!({"color": "#336699", "keepModel": true}), vectorcraft_color::cms::Model::Rgb), - (json!({"color": {"c": 10, "m": 20, "y": 30, "k": 5}}), vectorcraft_color::cms::Model::Cmyk), - (json!({"color": {"gray": 0.4}}), vectorcraft_color::cms::Model::Gray), - (json!({"swatch": "Ink"}), ink.model()), - ] { - app.run("paint.setFill", fill.clone()).unwrap(); - let shown = crate::panels::current_paints(&app).0.color().expect("a solid fill"); - assert_eq!(shown.model(), model, "{fill}"); - let count = app.session.doc().unwrap().doc.swatches.len(); - click_menu_item(&mut app, &ctx, "Create New Swatch…"); - assert_eq!(app.session.doc().unwrap().doc.swatches.len(), count + 1, "{fill}: one new swatch"); - let w = newest(&app); - assert_eq!(w.paint.color(), Some(shown), "{fill}: the swatch has the shown color, {}", w.name); - } - } - - fn paints_hex(app: &VectorcraftApp) -> (String, String) { - let (f, s) = crate::panels::current_paints(app); - (f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default()) - } - - #[test] - fn a_drag_on_the_spectrum_holds_still_near_white() { - let mut app = app(); - let ctx = egui::Context::default(); - let none = egui::Modifiers::NONE; - // White needs no warning, #fefefe the web one, a pale violet both: one panel height. - let heights: Vec = ["#ffffff", "#fefefe", "#ded3ff"] - .iter() - .map(|c| { - app.run("paint.setFill", json!({"color": c})).unwrap(); - frame(&mut app, &ctx, vec![], none); - frame(&mut app, &ctx, vec![], none).1 - }) - .collect(); - assert!(heights.windows(2).all(|w| w[0] == w[1]), "{heights:?}"); - // Drag along the spectrum's top edge (pale colours, some out of gamut), stopping at each - // point: the colour holds while the pointer does. - let r = frame(&mut app, &ctx, vec![], none).0.expect("the spectrum is drawn"); - let at = |x: f32| egui::pos2(r.left() + x * r.width(), r.top() + 2.0); - let button = |pressed| egui::Event::PointerButton { pos: at(0.02), button: egui::PointerButton::Primary, pressed, modifiers: none }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at(0.02)), button(true)], none); - for i in 1..20 { - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at(i as f32 * 0.05))], none); - let fills: Vec = (0..3) - .map(|_| { - frame(&mut app, &ctx, vec![], none); - paints_hex(&app).0 - }) - .collect(); - assert!(fills.windows(2).all(|w| w[0] == w[1]), "at {}: {fills:?}", i as f32 * 0.05); - } - frame(&mut app, &ctx, vec![button(false)], none); - } - - #[test] - fn tandem_keeps_ratios() { - let t = tandem(Mode::Rgb, &[100.0, 50.0, 25.0], 0, 200.0); - assert_eq!(t, vec![200.0, 100.0, 50.0]); - // It stops where a component reaches its maximum. - let t = tandem(Mode::Rgb, &[200.0, 100.0, 0.0], 1, 200.0); - assert_eq!(t, vec![255.0, 127.5, 0.0]); - let t = tandem(Mode::Cmyk, &[10.0, 40.0, 20.0, 5.0], 1, 20.0); - assert_eq!(t, vec![5.0, 20.0, 10.0, 2.5]); - // From 0 the others move by the same amount. - assert_eq!(tandem(Mode::Rgb, &[0.0, 50.0, 250.0], 0, 10.0), vec![10.0, 60.0, 255.0]); - assert!(Mode::Rgb.tandem() && Mode::Cmyk.tandem() && !Mode::Hsb.tandem() && !Mode::Grayscale.tandem()); - } - - #[test] - fn the_mode_follows_the_colours_model() { - let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4); - let rgb = Color::rgb(0.2, 0.4, 0.6); - assert_eq!(display_mode(Some(Mode::Rgb), Some(&cmyk)), Mode::Cmyk); - assert_eq!(display_mode(Some(Mode::Hsb), Some(&rgb)), Mode::Hsb, "HSB is remembered for RGB colours"); - assert_eq!(display_mode(Some(Mode::Cmyk), Some(&rgb)), Mode::Rgb); - assert_eq!(display_mode(Some(Mode::Rgb), Some(&Color::gray(0.5))), Mode::Grayscale); - assert_eq!(display_mode(Some(Mode::Cmyk), None), Mode::Cmyk); - assert_eq!(convert_to(Mode::Hsb, &rgb), rgb); - assert!(matches!(convert_to(Mode::Cmyk, &rgb), Color::Cmyk { .. })); - assert_eq!(convert_to(Mode::WebSafe, &rgb).to_hex(), "#336699"); - assert_eq!(Mode::Rgb.next(), Mode::Hsb); - assert_eq!(Mode::WebSafe.next(), Mode::Grayscale); - // In the panel: an RGB pick, then a CMYK object shows its 4 sliders. - let mut app = app(); - let ctx = egui::Context::default(); - app.ui.color_mode = Some(Mode::Rgb); - app.run("paint.setFill", json!({"color": {"c": 0.1, "m": 0.2, "y": 0.3, "k": 0.4}})).unwrap(); - frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - let _ = ctx.run_ui(Default::default(), |ui| { - let mode = shown_mode(&app, target(&app, ui).color().as_ref()); - assert_eq!(mode.labels(), ["C", "M", "Y", "K"]); - }); - } - - /// The slider labels one frame of the panel draws. - fn slider_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut full = ctx.run_ui(Default::default(), |ui| show(app, ui)); - full.textures_delta.clear(); - let mut out = vec![]; - full.shapes.iter().for_each(|s| texts(&s.shape, &mut out)); - out.retain(|t| ["K", "R", "G", "B", "H", "S", "C", "M", "Y"].contains(&t.as_str())); - out - } - - /// #1675: the mode picked last is kept with the UI state, which the app saves with its - /// preferences, so after a restart the panel shows the defaults for new art in it. A selected - /// colour still shows in its own model. - #[test] - fn the_picked_mode_comes_back_after_a_restart() { - let mut app = app(); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - app.run("select.none", json!({})).unwrap(); - let ctx = egui::Context::default(); - assert_eq!(slider_labels(&mut app, &ctx), ["R", "G", "B"], "the white default is RGB"); - let _ = ctx.run_ui(Default::default(), |ui| { - let tgt = target(&app, ui); - set_mode(&mut app, &tgt, Mode::Cmyk); - }); - assert_eq!(slider_labels(&mut app, &ctx), ["C", "M", "Y", "K"]); - // Quit and start again: the saved UI state comes back, the session and the panel are new. - let saved: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.ui = saved.sanitized(); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Rgb { .. })), "the defaults start as RGB white"); - let ctx = egui::Context::default(); - assert_eq!(slider_labels(&mut app, &ctx), ["C", "M", "Y", "K"]); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); - app.run("paint.setFill", json!({"color": "#336699", "keepModel": true})).unwrap(); - assert_eq!(slider_labels(&mut app, &ctx), ["R", "G", "B"], "an RGB object shows RGB"); - // A saved value that isn't a menu mode reads as none, and the rest of the state still loads. - for v in [json!("lab"), json!("CMYK"), json!(3), serde_json::Value::Null] { - let ui: crate::UiState = serde_json::from_value(json!({"color_mode": v, "place_link": false})).unwrap(); - assert_eq!((ui.color_mode, ui.place_link), (None, false), "{v}"); - } - assert_eq!(serde_json::from_value::(json!({"color_mode": "websafe"})).unwrap().color_mode, Some(Mode::WebSafe)); - } - - #[test] - fn lab_colours_show_lab_sliders_with_signed_ranges() { - let lab = Color::lab(50.0, -20.0, 30.0); - assert_eq!(Mode::of(&lab), Mode::Lab); - assert_eq!(display_mode(Some(Mode::Rgb), Some(&lab)), Mode::Lab); - assert!(!Mode::ALL.contains(&Mode::Lab), "not a panel menu mode"); - assert_eq!(components(Mode::Lab, &lab), vec![50.0, -20.0, 30.0]); - assert_eq!(from_components(Mode::Lab, &[50.0, -20.0, 30.0]), lab); - assert_eq!(from_components(Mode::Lab, &[150.0, -300.0, 300.0]), Color::lab(100.0, -128.0, 127.0), "clamped"); - assert_eq!((Mode::Lab.unit(1, -128.0), Mode::Lab.unit(2, 127.0), Mode::Lab.unit(0, 50.0)), (0.0, 1.0, 0.5)); - assert_eq!((Mode::Lab.value_at(1, 0.0), Mode::Lab.value_at(0, 1.0)), (-128.0, 100.0)); - assert_eq!((Mode::Rgb.value_at(0, 0.5), Mode::WebSafe.value_at(0, 0.45)), (128.0, 102.0), "other modes as before"); - assert_eq!(track_color(Mode::Lab, &[50.0, -20.0, 30.0], 1, 0.0), Color::lab(50.0, -128.0, 30.0)); - assert_eq!(convert_to(Mode::Lab, &Color::rgb(1.0, 0.0, 0.0)).model(), vectorcraft_color::cms::Model::Lab); - // In the panel: a Lab fill shows L, a, b. - let mut app = app(); - let ctx = egui::Context::default(); - app.run("paint.setFill", json!({"color": {"l": 50, "a": -20, "b": 30}})).unwrap(); - frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - let _ = ctx.run_ui(Default::default(), |ui| { - let mode = shown_mode(&app, target(&app, ui).color().as_ref()); - assert_eq!(mode.labels(), ["L", "a", "b"]); - }); - } - - #[test] - fn pure_blue_is_out_of_gamut_in_rgb() { - let mut app = app(); - let ctx = egui::Context::default(); - app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap(); - frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - let (of, fix) = pstate::)>>(&ctx, "color-gamut").expect("RGB mode checks the gamut"); - assert_eq!(of.to_hex(), "#0000ff"); - assert!(fix.is_some_and(|f| !f.out_of_gamut())); - app.run("paint.setFill", json!({"color": "#808080"})).unwrap(); - frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - assert_eq!(pstate::)>>(&ctx, "color-gamut").unwrap().1, None); - } - - #[test] - fn alt_click_on_the_spectrum_paints_the_stroke() { - let mut app = app(); - let ctx = egui::Context::default(); - app.run("paint.setFill", json!({"color": "#123456"})).unwrap(); - app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap(); - assert!(app.session.fill_active); - click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::ALT); - assert_eq!(paints_hex(&app), ("#123456".into(), "#ff0000".into()), "the stroke changes"); - assert!(app.session.fill_active, "the fill stays in front"); - // A plain click paints the fill. - click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::NONE); - assert_eq!(paints_hex(&app).0, "#ff0000"); - } - - #[test] - fn shift_click_on_the_spectrum_cycles_modes() { - let mut app = app(); - let ctx = egui::Context::default(); - app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); - click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::SHIFT); - assert_eq!(app.ui.color_mode, Some(Mode::Hsb)); - assert_eq!(paints_hex(&app).0, "#336699", "HSB keeps the RGB colour"); - click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::SHIFT); - assert_eq!(app.ui.color_mode, Some(Mode::Cmyk)); - assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK converts the colour"); - } - - #[test] - fn double_clicking_a_proxy_opens_the_color_picker_for_it() { - let mut app = app(); - let ctx = egui::Context::default(); - // One frame of the proxy alone; returns the stroke square's rect. - let run = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let input = egui::RawInput { time: Some(time), events, ..Default::default() }; - let mut rect = None; - let mut out = ctx.run_ui(input, |ui| { - crate::panels::proxy(app, ui, 40.0); - rect = ctx.read_response(ui.scope_id().with("stroke-proxy")).map(|r| r.rect); - }); - out.textures_delta.clear(); - rect - }; - let pos = run(&mut app, 0.0, vec![]).expect("the stroke square is drawn").right_bottom() - vec2(3.0, 3.0); - let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; - run(&mut app, 0.1, vec![egui::Event::PointerMoved(pos), button(true)]); - run(&mut app, 0.15, vec![button(false)]); - assert!(!app.session.fill_active, "a click brings the stroke to the front"); - assert!(app.ui.dialog.is_none()); - run(&mut app, 0.2, vec![button(true)]); - run(&mut app, 0.25, vec![button(false)]); - let d = app.ui.dialog.as_ref().expect("a double-click opens the Color Picker"); - assert_eq!(d.kind, "colorPicker"); - assert!(d.bool("stroke")); - } - - #[test] - fn a_global_colour_shows_tint_mode_and_the_t_slider_sets_its_tint() { - let mut app = app(); - let ctx = egui::Context::default(); - app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); - app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap(); - frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - let _ = ctx.run_ui(Default::default(), |ui| { - let tgt = target(&app, ui); - let t = tgt.tint().expect("Tint mode"); - assert_eq!((t.swatch.as_str(), t.spot, t.tint, t.base), ("Ink", true, 0.5, Color::cmyk(0.0, 1.0, 0.0, 0.0))); - }); - // The chip's name and the T row are drawn; a click on the slider's right part raises the - // tint and keeps the link. - let texts = |app: &mut VectorcraftApp, events: Vec| { - fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { - match s { - egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())), - egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), - _ => {} - } - } - let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(260.0, 600.0)); - let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - let mut v = vec![]; - out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); - v - }; - let drawn = texts(&mut app, vec![]); - let at = |label: &str| drawn.iter().find(|(t, _)| t == label).map(|(_, p)| *p); - assert!(at("Ink").is_some(), "the swatch's name: {drawn:?}"); - let (t_label, field) = (at("T").expect("the T row"), at("50%").expect("the tint field")); - let pos = egui::pos2(field.x - 40.0, t_label.y); - let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; - texts(&mut app, vec![egui::Event::PointerMoved(pos), button(true)]); - texts(&mut app, vec![button(false)]); - let Paint::Solid { swatch, tint, .. } = crate::panels::current_paints(&app).0 else { panic!() }; - assert_eq!(swatch.as_deref(), Some("Ink")); - assert!(tint > 0.6 && tint < 1.0, "{tint}"); - // Picking a mode makes it a process colour. - let _ = ctx.run_ui(Default::default(), |ui| { - let tgt = target(&app, ui); - set_mode(&mut app, &tgt, Mode::Cmyk); - }); - assert!(matches!(crate::panels::current_paints(&app).0, Paint::Solid { swatch: None, .. })); - } - - /// Screen readers get each slider's number and the text of the field beside it, with the - /// component's range: R, G and B from 0 to 255, H in degrees, S, B, the inks and T in percent. - #[test] - fn screen_readers_get_the_numbers_the_fields_show() { - use crate::widgets::accessibility_tests::{slider_value, tree}; - let mut app = app(); - let v = |n: f64, max: f64, text: &str| (Some(n), Some(0.0), Some(max), Some(text.to_string())); - app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap(); - let rgb = tree(|ui| show(&mut app, ui)); - assert_eq!(slider_value(&rgb, "R"), v(255.0, 255.0, "255")); - assert_eq!(slider_value(&rgb, "G"), v(128.0, 255.0, "128")); - assert_eq!(slider_value(&rgb, "B"), v(0.0, 255.0, "0")); - app.run("ui.colorPanel", json!({"mode": "hsb"})).unwrap(); - let hsb = tree(|ui| show(&mut app, ui)); - assert_eq!(slider_value(&hsb, "H"), v(30.0, 360.0, "30°")); - assert_eq!(slider_value(&hsb, "S"), v(100.0, 100.0, "100%")); - assert_eq!(slider_value(&hsb, "B"), v(100.0, 100.0, "100%")); - app.run("paint.setFill", json!({"color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap(); - let cmyk = tree(|ui| show(&mut app, ui)); - assert_eq!(slider_value(&cmyk, "C"), v(10.0, 100.0, "10%")); - assert_eq!(slider_value(&cmyk, "K"), v(40.0, 100.0, "40%")); - app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); - app.run("paint.setFill", json!({"swatch": "Ink", "tint": 35})).unwrap(); - let tint = tree(|ui| show(&mut app, ui)); - assert_eq!(slider_value(&tint, "T"), v(35.0, 100.0, "35%")); - } - - #[test] - fn hide_options_leaves_the_proxy_and_spectrum() { - let mut app = app(); - let ctx = egui::Context::default(); - let (spectrum, full) = frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - assert!(spectrum.is_some()); - assert_eq!(app.run("ui.colorPanel", json!({"options": false})).unwrap()["options"], false); - let (_, hidden) = frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - assert!(hidden <= 44.0 && full > 150.0, "{hidden} / {full}"); - app.run("ui.colorPanel", json!({"options": true})).unwrap(); - assert_eq!(frame(&mut app, &ctx, vec![], egui::Modifiers::NONE).1, full); - } - - /// Hide Options is kept with the UI state, which the app saves with its preferences. A saved - /// value that isn't true or false reads as Show Options, and the rest of the state still loads. - #[test] - fn hide_options_comes_back_after_a_restart() { - let mut app = app(); - app.run("ui.colorPanel", json!({"options": false})).unwrap(); - let saved: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); - assert!(saved.color_hide_options); - assert_eq!(crate::control::inspect(&app, &egui::Context::default())["ui"]["color_hide_options"], true); - for v in [json!(7), json!("hide"), json!({}), Value::Null] { - let ui: crate::UiState = serde_json::from_value(json!({"color_hide_options": v, "color_mode": "future", "place_link": false})).unwrap(); - assert_eq!((ui.color_hide_options, ui.color_mode, ui.place_link), (false, None, false), "{v}"); - } - } - - /// `ui.colorPanel {mode}` picks a mode as the panel menu does: HSB shows an RGB fill as H, S and - /// B, and CMYK converts it as one undo step. - #[test] - fn ui_color_panel_picks_a_mode_as_the_menu_does() { - let mut app = app(); - let ctx = egui::Context::default(); - app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); - let r = app.run("ui.colorPanel", json!({"mode": "hsb"})).unwrap(); - assert_eq!(r, json!({"mode": "hsb", "options": true, "shown": "hsb"})); - assert_eq!(slider_labels(&mut app, &ctx), ["H", "S", "B"]); - assert_eq!(crate::control::inspect(&app, &ctx)["ui"]["color_mode"], "hsb"); - assert_eq!(paints_hex(&app).0, "#336699", "HSB keeps the RGB color"); - assert_eq!(app.run("ui.colorPanel", json!({"mode": "cmyk"})).unwrap()["shown"], "cmyk"); - assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK converts the color"); - app.run("edit.undo", json!({})).unwrap(); - assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Rgb { .. })), "undo restores it"); - assert_eq!(paints_hex(&app).0, "#336699"); - // `{}` reads the settings: CMYK stays picked, and the RGB fill shows in its own model. - assert_eq!(app.run("ui.colorPanel", json!({})).unwrap(), json!({"mode": "cmyk", "options": true, "shown": "rgb"})); - // A tint of a global swatch shows Tint mode. - app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); - app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap(); - assert_eq!(app.run("ui.colorPanel", json!({})).unwrap()["shown"], "tint"); - } - - /// A mode the panel menu doesn't list, Lab included, and an options value that isn't true or - /// false answer an error, and the command then changes nothing. - #[test] - fn ui_color_panel_answers_an_error_for_a_mode_off_the_menu() { - let mut app = app(); - for m in [json!("lab"), json!("oklch"), json!("hsl"), json!("future"), json!("RGB"), json!(3)] { - let e = app.run("ui.colorPanel", json!({"mode": m})).unwrap_err(); - assert_eq!(e, "`mode` must be grayscale, rgb, hsb, cmyk or websafe", "{m}"); - } - assert_eq!(app.run("ui.colorPanel", json!({"mode": "cmyk", "options": "no"})).unwrap_err(), "`options` must be true or false"); - assert_eq!((app.ui.color_mode, app.ui.color_hide_options), (None, false)); - } - - /// `ui.colorPanel` answers every junk parameter, with and without a document, without a panic. - #[test] - fn ui_color_panel_answers_junk_params_without_a_panic() { - let doc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == "ui.colorPanel").expect("registered").3; - let mut app = app(); - app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); - for p in vectorcraft_testkit::strategies::junk_params(doc, "/nonexistent").into_iter().chain(vectorcraft_testkit::strategies::junk_values()) { - let _ = app.run("ui.colorPanel", p); - } - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - assert_eq!(app.run("ui.colorPanel", json!({"mode": "cmyk"})).unwrap()["mode"], "cmyk"); - } - - #[test] - fn a_mixed_selection_has_no_colour_to_edit() { - let mut app = app(); - let ctx = egui::Context::default(); - let b = app.run("shape.rectangle", json!({"x": 80, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); - app.run("select.all", json!({})).unwrap(); - app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); - app.run("paint.setFill", json!({"color": "#00ff00", "ids": [b]})).unwrap(); - frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - assert_eq!(crate::panels::mixed_paints(&app, &ctx), (true, false)); - let _ = ctx.run_ui(Default::default(), |ui| assert_eq!(target(&app, ui).color(), None)); - app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap(); - assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false), "a new revision recomputes it"); - } - - #[test] - fn rgb_roundtrip() { - let c = Color::rgb8(230, 120, 40); - let v = components(Mode::Rgb, &c); - assert_eq!(v.iter().map(|x| x.round() as i32).collect::>(), vec![230, 120, 40]); - assert_eq!(from_components(Mode::Rgb, &v).to_hex(), c.to_hex()); - } - - #[test] - fn hsb_roundtrip_and_ranges() { - let c = Color::rgb8(230, 120, 40); - let v = components(Mode::Hsb, &c); - assert!(v[0] > 0.0 && v[0] < 360.0 && v[1] <= 100.0 && v[2] <= 100.0); - assert_eq!(from_components(Mode::Hsb, &v).to_hex(), c.to_hex()); - assert_eq!(Mode::Hsb.max(0), 360.0); - assert_eq!(Mode::Hsb.suffix(0), "°"); - } - - #[test] - fn cmyk_and_gray_keep_model() { - let c = from_components(Mode::Cmyk, &[10.0, 20.0, 30.0, 40.0]); - assert!(matches!(c, Color::Cmyk { .. })); - let v = components(Mode::Cmyk, &c); - assert!((v[3] - 40.0).abs() < 1e-3); - let g = from_components(Mode::Grayscale, &[25.0]); - assert_eq!(g, Color::gray(0.25)); - assert!((components(Mode::Grayscale, &g)[0] - 25.0).abs() < 1e-4); - // Grayscale view of an RGB colour uses luminance. - assert!((components(Mode::Grayscale, &Color::WHITE)[0]).abs() < 1e-4); - } - - #[test] - fn web_safe_snaps() { - let c = Color::rgb8(230, 120, 40); - let w = web_safe(&c); - assert_eq!(w.to_hex(), "#ff6633"); - assert!(is_web_safe(&w)); - assert!(!is_web_safe(&c)); - assert_eq!(from_components(Mode::WebSafe, &[230.0, 120.0, 40.0]).to_hex(), "#ff6633"); - } - - #[test] - fn slider_tracks_vary_one_component() { - let comps = [230.0, 120.0, 40.0]; - let a = track_color(Mode::Rgb, &comps, 0, 0.0).to_rgba8(1.0); - let b = track_color(Mode::Rgb, &comps, 0, 1.0).to_rgba8(1.0); - assert_eq!((a[0], a[1], a[2]), (0, 120, 40)); - assert_eq!((b[0], b[1], b[2]), (255, 120, 40)); - let h = track_color(Mode::Hsb, &[0.0, 100.0, 100.0], 0, 1.0 / 3.0).to_hex(); - assert_eq!(h, "#00ff00"); - } - - #[test] - fn spectrum_and_hex() { - assert_eq!(spectrum_at(Mode::Rgb, 0.0, 0.0).to_hex(), "#ffffff"); - assert_eq!(spectrum_at(Mode::Rgb, 0.0, 0.5).to_hex(), "#ff0000"); - assert_eq!(spectrum_at(Mode::Rgb, 0.3, 1.0).to_hex(), "#000000"); - assert_eq!(spectrum_at(Mode::Grayscale, 1.0, 0.3), Color::gray(1.0)); - assert_eq!(parse_hex("E67828").unwrap().to_hex(), "#e67828"); - assert_eq!(parse_hex("#fff").unwrap().to_hex(), "#ffffff"); - assert!(parse_hex("zz").is_none()); - assert!(parse_hex("12345").is_none()); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs new file mode 100644 index 000000000..8d89d498b --- /dev/null +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -0,0 +1,594 @@ +use super::*; +use vectorcraft_engine::Session; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app +} + +/// Run the panel for one frame with `events` and `modifiers`; returns the spectrum's rect (when +/// the options are shown) and the panel's height. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: egui::Modifiers) -> (Option, f32) { + frame_with(app, ctx, events, modifiers, "color-spectrum") +} + +/// [`frame`] returning the rect of the panel's widget `id` instead of the spectrum's. +fn frame_with( + app: &mut VectorcraftApp, + ctx: &egui::Context, + events: Vec, + modifiers: egui::Modifiers, + id: impl std::hash::Hash + std::fmt::Debug, +) -> (Option, f32) { + let mut out = (None, 0.0); + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, ..Default::default() }; + let mut full = ctx.run_ui(input, |ui| { + show(app, ui); + out = (ctx.read_response(ui.scope_id().with(&id)).map(|r| r.rect), ui.min_rect().height()); + }); + full.textures_delta.clear(); + out +} + +/// Click the spectrum at (x, y) (0..1 each) with `modifiers` held. +fn click_spectrum(app: &mut VectorcraftApp, ctx: &egui::Context, (x, y): (f32, f32), modifiers: egui::Modifiers) { + let r = frame(app, ctx, vec![], modifiers).0.expect("the spectrum is drawn"); + let pos = egui::pos2(r.left() + x * r.width(), r.top() + y * r.height()); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + frame(app, ctx, vec![egui::Event::PointerMoved(pos), button(true)], modifiers); + frame(app, ctx, vec![button(false)], modifiers); + frame(app, ctx, vec![], egui::Modifiers::NONE); +} + +#[test] +fn a_recent_colour_recolours_the_selected_gradient_stop() { + // #835: it replaced the whole gradient with the colour. + let mut app = app(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [10, 35], "end": [60, 35]}})).unwrap(); + app.select_tool("gradient"); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let red = app.session.recent_colors.iter().position(|c| c.to_hex() == "#ff0000").expect("red is a recent colour"); + let ctx = egui::Context::default(); + let none = egui::Modifiers::NONE; + let chip = frame_with(&mut app, &ctx, vec![], none, ("recent", red)).0.expect("the chip is drawn").center(); + let button = |pressed| egui::Event::PointerButton { pos: chip, button: egui::PointerButton::Primary, pressed, modifiers: none }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(chip), button(true)], none); + frame(&mut app, &ctx, vec![button(false)], none); + let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") }; + let hex: Vec = g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(); + assert_eq!(hex, ["#ffffff", "#ff0000"]); +} + +/// In a CMYK document the sliders keep the model picked in the panel for a gradient stop and a +/// freeform point, as they do for a solid fill. +#[test] +fn a_gradient_stop_and_a_freeform_point_keep_the_panels_model_in_a_cmyk_document() { + let mut app = app(); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + let stops = json!([{"offset": 0, "color": {"c": 0, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 100, "y": 100, "k": 0}}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); + app.select_tool("gradient"); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let orange = Color::rgb8(255, 128, 0); + let tgt = target_with(&app, false); + apply(&mut app, &tgt, orange, Live::Released, false); + let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") }; + assert_eq!(g.gradient.stops[1].color, orange); + app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); + let tgt = target_with(&app, false); + assert!(matches!(tgt, Target::Point { index: 0, .. }), "a freeform point is the target"); + apply(&mut app, &tgt, orange, Live::Released, false); + let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") }; + assert_eq!(g.freeform.map(|f| f.points[0].color), Some(orange)); +} + +/// One headless frame of the panel menu; returns its painted labels. +fn menu_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(236.0, 400.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| menu(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) +} + +/// Click the panel menu's item `label`. +fn click_menu_item(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + let labels = menu_frame(app, ctx, vec![]); + let at = labels.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label} in the menu")); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + menu_frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]); + menu_frame(app, ctx, vec![button(false)]); +} + +/// In a CMYK document the panel menu's Create New Swatch saves the color the panel shows in +/// that color's model: an RGB object gives an RGB swatch, and CMYK, gray and spot colors give +/// swatches of their own colors. +#[test] +fn create_new_swatch_keeps_the_shown_colors_model_in_a_cmyk_document() { + let mut app = app(); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 60, "y": 90, "k": 0}, "spot": true})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let newest = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch"); + let ink = app.session.doc().unwrap().doc.swatch("Ink").and_then(|w| w.paint.color()).unwrap(); + for (fill, model) in [ + (json!({"color": "#336699", "keepModel": true}), vectorcraft_color::cms::Model::Rgb), + (json!({"color": {"c": 10, "m": 20, "y": 30, "k": 5}}), vectorcraft_color::cms::Model::Cmyk), + (json!({"color": {"gray": 0.4}}), vectorcraft_color::cms::Model::Gray), + (json!({"swatch": "Ink"}), ink.model()), + ] { + app.run("paint.setFill", fill.clone()).unwrap(); + let shown = crate::panels::current_paints(&app).0.color().expect("a solid fill"); + assert_eq!(shown.model(), model, "{fill}"); + let count = app.session.doc().unwrap().doc.swatches.len(); + click_menu_item(&mut app, &ctx, "Create New Swatch…"); + assert_eq!(app.session.doc().unwrap().doc.swatches.len(), count + 1, "{fill}: one new swatch"); + let w = newest(&app); + assert_eq!(w.paint.color(), Some(shown), "{fill}: the swatch has the shown color, {}", w.name); + } +} + +fn paints_hex(app: &VectorcraftApp) -> (String, String) { + let (f, s) = crate::panels::current_paints(app); + (f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default()) +} + +#[test] +fn a_drag_on_the_spectrum_holds_still_near_white() { + let mut app = app(); + let ctx = egui::Context::default(); + let none = egui::Modifiers::NONE; + // White needs no warning, #fefefe the web one, a pale violet both: one panel height. + let heights: Vec = ["#ffffff", "#fefefe", "#ded3ff"] + .iter() + .map(|c| { + app.run("paint.setFill", json!({"color": c})).unwrap(); + frame(&mut app, &ctx, vec![], none); + frame(&mut app, &ctx, vec![], none).1 + }) + .collect(); + assert!(heights.windows(2).all(|w| w[0] == w[1]), "{heights:?}"); + // Drag along the spectrum's top edge (pale colours, some out of gamut), stopping at each + // point: the colour holds while the pointer does. + let r = frame(&mut app, &ctx, vec![], none).0.expect("the spectrum is drawn"); + let at = |x: f32| egui::pos2(r.left() + x * r.width(), r.top() + 2.0); + let button = |pressed| egui::Event::PointerButton { pos: at(0.02), button: egui::PointerButton::Primary, pressed, modifiers: none }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at(0.02)), button(true)], none); + for i in 1..20 { + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at(i as f32 * 0.05))], none); + let fills: Vec = (0..3) + .map(|_| { + frame(&mut app, &ctx, vec![], none); + paints_hex(&app).0 + }) + .collect(); + assert!(fills.windows(2).all(|w| w[0] == w[1]), "at {}: {fills:?}", i as f32 * 0.05); + } + frame(&mut app, &ctx, vec![button(false)], none); +} + +#[test] +fn tandem_keeps_ratios() { + let t = tandem(Mode::Rgb, &[100.0, 50.0, 25.0], 0, 200.0); + assert_eq!(t, vec![200.0, 100.0, 50.0]); + // It stops where a component reaches its maximum. + let t = tandem(Mode::Rgb, &[200.0, 100.0, 0.0], 1, 200.0); + assert_eq!(t, vec![255.0, 127.5, 0.0]); + let t = tandem(Mode::Cmyk, &[10.0, 40.0, 20.0, 5.0], 1, 20.0); + assert_eq!(t, vec![5.0, 20.0, 10.0, 2.5]); + // From 0 the others move by the same amount. + assert_eq!(tandem(Mode::Rgb, &[0.0, 50.0, 250.0], 0, 10.0), vec![10.0, 60.0, 255.0]); + assert!(Mode::Rgb.tandem() && Mode::Cmyk.tandem() && !Mode::Hsb.tandem() && !Mode::Grayscale.tandem()); +} + +#[test] +fn the_mode_follows_the_colours_model() { + let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4); + let rgb = Color::rgb(0.2, 0.4, 0.6); + assert_eq!(display_mode(Some(Mode::Rgb), Some(&cmyk)), Mode::Cmyk); + assert_eq!(display_mode(Some(Mode::Hsb), Some(&rgb)), Mode::Hsb, "HSB is remembered for RGB colours"); + assert_eq!(display_mode(Some(Mode::Cmyk), Some(&rgb)), Mode::Rgb); + assert_eq!(display_mode(Some(Mode::Rgb), Some(&Color::gray(0.5))), Mode::Grayscale); + assert_eq!(display_mode(Some(Mode::Cmyk), None), Mode::Cmyk); + assert_eq!(convert_to(Mode::Hsb, &rgb), rgb); + assert!(matches!(convert_to(Mode::Cmyk, &rgb), Color::Cmyk { .. })); + assert_eq!(convert_to(Mode::WebSafe, &rgb).to_hex(), "#336699"); + assert_eq!(Mode::Rgb.next(), Mode::Hsb); + assert_eq!(Mode::WebSafe.next(), Mode::Grayscale); + // In the panel: an RGB pick, then a CMYK object shows its 4 sliders. + let mut app = app(); + let ctx = egui::Context::default(); + app.ui.color_mode = Some(Mode::Rgb); + app.run("paint.setFill", json!({"color": {"c": 0.1, "m": 0.2, "y": 0.3, "k": 0.4}})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + let _ = ctx.run_ui(Default::default(), |ui| { + let mode = shown_mode(&app, target(&app, ui).color().as_ref()); + assert_eq!(mode.labels(), ["C", "M", "Y", "K"]); + }); +} + +/// The slider labels one frame of the panel draws. +fn slider_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut full = ctx.run_ui(Default::default(), |ui| show(app, ui)); + full.textures_delta.clear(); + let mut out = vec![]; + full.shapes.iter().for_each(|s| texts(&s.shape, &mut out)); + out.retain(|t| ["K", "R", "G", "B", "H", "S", "C", "M", "Y"].contains(&t.as_str())); + out +} + +/// #1675: the mode picked last is kept with the UI state, which the app saves with its +/// preferences, so after a restart the panel shows the defaults for new art in it. A selected +/// colour still shows in its own model. +#[test] +fn the_picked_mode_comes_back_after_a_restart() { + let mut app = app(); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + assert_eq!(slider_labels(&mut app, &ctx), ["R", "G", "B"], "the white default is RGB"); + let _ = ctx.run_ui(Default::default(), |ui| { + let tgt = target(&app, ui); + set_mode(&mut app, &tgt, Mode::Cmyk); + }); + assert_eq!(slider_labels(&mut app, &ctx), ["C", "M", "Y", "K"]); + // Quit and start again: the saved UI state comes back, the session and the panel are new. + let saved: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui = saved.sanitized(); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Rgb { .. })), "the defaults start as RGB white"); + let ctx = egui::Context::default(); + assert_eq!(slider_labels(&mut app, &ctx), ["C", "M", "Y", "K"]); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("paint.setFill", json!({"color": "#336699", "keepModel": true})).unwrap(); + assert_eq!(slider_labels(&mut app, &ctx), ["R", "G", "B"], "an RGB object shows RGB"); + // A saved value that isn't a menu mode reads as none, and the rest of the state still loads. + for v in [json!("lab"), json!("CMYK"), json!(3), serde_json::Value::Null] { + let ui: crate::UiState = serde_json::from_value(json!({"color_mode": v, "place_link": false})).unwrap(); + assert_eq!((ui.color_mode, ui.place_link), (None, false), "{v}"); + } + assert_eq!(serde_json::from_value::(json!({"color_mode": "websafe"})).unwrap().color_mode, Some(Mode::WebSafe)); +} + +#[test] +fn lab_colours_show_lab_sliders_with_signed_ranges() { + let lab = Color::lab(50.0, -20.0, 30.0); + assert_eq!(Mode::of(&lab), Mode::Lab); + assert_eq!(display_mode(Some(Mode::Rgb), Some(&lab)), Mode::Lab); + assert!(!Mode::ALL.contains(&Mode::Lab), "not a panel menu mode"); + assert_eq!(components(Mode::Lab, &lab), vec![50.0, -20.0, 30.0]); + assert_eq!(from_components(Mode::Lab, &[50.0, -20.0, 30.0]), lab); + assert_eq!(from_components(Mode::Lab, &[150.0, -300.0, 300.0]), Color::lab(100.0, -128.0, 127.0), "clamped"); + assert_eq!((Mode::Lab.unit(1, -128.0), Mode::Lab.unit(2, 127.0), Mode::Lab.unit(0, 50.0)), (0.0, 1.0, 0.5)); + assert_eq!((Mode::Lab.value_at(1, 0.0), Mode::Lab.value_at(0, 1.0)), (-128.0, 100.0)); + assert_eq!((Mode::Rgb.value_at(0, 0.5), Mode::WebSafe.value_at(0, 0.45)), (128.0, 102.0), "other modes as before"); + assert_eq!(track_color(Mode::Lab, &[50.0, -20.0, 30.0], 1, 0.0), Color::lab(50.0, -128.0, 30.0)); + assert_eq!(convert_to(Mode::Lab, &Color::rgb(1.0, 0.0, 0.0)).model(), vectorcraft_color::cms::Model::Lab); + // In the panel: a Lab fill shows L, a, b. + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": {"l": 50, "a": -20, "b": 30}})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + let _ = ctx.run_ui(Default::default(), |ui| { + let mode = shown_mode(&app, target(&app, ui).color().as_ref()); + assert_eq!(mode.labels(), ["L", "a", "b"]); + }); +} + +#[test] +fn pure_blue_is_out_of_gamut_in_rgb() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + let (of, fix) = pstate::)>>(&ctx, "color-gamut").expect("RGB mode checks the gamut"); + assert_eq!(of.to_hex(), "#0000ff"); + assert!(fix.is_some_and(|f| !f.out_of_gamut())); + app.run("paint.setFill", json!({"color": "#808080"})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert_eq!(pstate::)>>(&ctx, "color-gamut").unwrap().1, None); +} + +#[test] +fn alt_click_on_the_spectrum_paints_the_stroke() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#123456"})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap(); + assert!(app.session.fill_active); + click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::ALT); + assert_eq!(paints_hex(&app), ("#123456".into(), "#ff0000".into()), "the stroke changes"); + assert!(app.session.fill_active, "the fill stays in front"); + // A plain click paints the fill. + click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::NONE); + assert_eq!(paints_hex(&app).0, "#ff0000"); +} + +#[test] +fn shift_click_on_the_spectrum_cycles_modes() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); + click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::SHIFT); + assert_eq!(app.ui.color_mode, Some(Mode::Hsb)); + assert_eq!(paints_hex(&app).0, "#336699", "HSB keeps the RGB colour"); + click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::SHIFT); + assert_eq!(app.ui.color_mode, Some(Mode::Cmyk)); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK converts the colour"); +} + +#[test] +fn double_clicking_a_proxy_opens_the_color_picker_for_it() { + let mut app = app(); + let ctx = egui::Context::default(); + // One frame of the proxy alone; returns the stroke square's rect. + let run = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let input = egui::RawInput { time: Some(time), events, ..Default::default() }; + let mut rect = None; + let mut out = ctx.run_ui(input, |ui| { + crate::panels::proxy(app, ui, 40.0); + rect = ctx.read_response(ui.scope_id().with("stroke-proxy")).map(|r| r.rect); + }); + out.textures_delta.clear(); + rect + }; + let pos = run(&mut app, 0.0, vec![]).expect("the stroke square is drawn").right_bottom() - vec2(3.0, 3.0); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + run(&mut app, 0.1, vec![egui::Event::PointerMoved(pos), button(true)]); + run(&mut app, 0.15, vec![button(false)]); + assert!(!app.session.fill_active, "a click brings the stroke to the front"); + assert!(app.ui.dialog.is_none()); + run(&mut app, 0.2, vec![button(true)]); + run(&mut app, 0.25, vec![button(false)]); + let d = app.ui.dialog.as_ref().expect("a double-click opens the Color Picker"); + assert_eq!(d.kind, "colorPicker"); + assert!(d.bool("stroke")); +} + +#[test] +fn a_global_colour_shows_tint_mode_and_the_t_slider_sets_its_tint() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + let _ = ctx.run_ui(Default::default(), |ui| { + let tgt = target(&app, ui); + let t = tgt.tint().expect("Tint mode"); + assert_eq!((t.swatch.as_str(), t.spot, t.tint, t.base), ("Ink", true, 0.5, Color::cmyk(0.0, 1.0, 0.0, 0.0))); + }); + // The chip's name and the T row are drawn; a click on the slider's right part raises the + // tint and keeps the link. + let texts = |app: &mut VectorcraftApp, events: Vec| { + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(260.0, 600.0)); + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + }; + let drawn = texts(&mut app, vec![]); + let at = |label: &str| drawn.iter().find(|(t, _)| t == label).map(|(_, p)| *p); + assert!(at("Ink").is_some(), "the swatch's name: {drawn:?}"); + let (t_label, field) = (at("T").expect("the T row"), at("50%").expect("the tint field")); + let pos = egui::pos2(field.x - 40.0, t_label.y); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + texts(&mut app, vec![egui::Event::PointerMoved(pos), button(true)]); + texts(&mut app, vec![button(false)]); + let Paint::Solid { swatch, tint, .. } = crate::panels::current_paints(&app).0 else { panic!() }; + assert_eq!(swatch.as_deref(), Some("Ink")); + assert!(tint > 0.6 && tint < 1.0, "{tint}"); + // Picking a mode makes it a process colour. + let _ = ctx.run_ui(Default::default(), |ui| { + let tgt = target(&app, ui); + set_mode(&mut app, &tgt, Mode::Cmyk); + }); + assert!(matches!(crate::panels::current_paints(&app).0, Paint::Solid { swatch: None, .. })); +} + +/// Screen readers get each slider's number and the text of the field beside it, with the +/// component's range: R, G and B from 0 to 255, H in degrees, S, B, the inks and T in percent. +#[test] +fn screen_readers_get_the_numbers_the_fields_show() { + use crate::widgets::accessibility_tests::{slider_value, tree}; + let mut app = app(); + let v = |n: f64, max: f64, text: &str| (Some(n), Some(0.0), Some(max), Some(text.to_string())); + app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap(); + let rgb = tree(|ui| show(&mut app, ui)); + assert_eq!(slider_value(&rgb, "R"), v(255.0, 255.0, "255")); + assert_eq!(slider_value(&rgb, "G"), v(128.0, 255.0, "128")); + assert_eq!(slider_value(&rgb, "B"), v(0.0, 255.0, "0")); + app.run("ui.colorPanel", json!({"mode": "hsb"})).unwrap(); + let hsb = tree(|ui| show(&mut app, ui)); + assert_eq!(slider_value(&hsb, "H"), v(30.0, 360.0, "30°")); + assert_eq!(slider_value(&hsb, "S"), v(100.0, 100.0, "100%")); + assert_eq!(slider_value(&hsb, "B"), v(100.0, 100.0, "100%")); + app.run("paint.setFill", json!({"color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap(); + let cmyk = tree(|ui| show(&mut app, ui)); + assert_eq!(slider_value(&cmyk, "C"), v(10.0, 100.0, "10%")); + assert_eq!(slider_value(&cmyk, "K"), v(40.0, 100.0, "40%")); + app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink", "tint": 35})).unwrap(); + let tint = tree(|ui| show(&mut app, ui)); + assert_eq!(slider_value(&tint, "T"), v(35.0, 100.0, "35%")); +} + +#[test] +fn hide_options_leaves_the_proxy_and_spectrum() { + let mut app = app(); + let ctx = egui::Context::default(); + let (spectrum, full) = frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert!(spectrum.is_some()); + assert_eq!(app.run("ui.colorPanel", json!({"options": false})).unwrap()["options"], false); + let (_, hidden) = frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert!(hidden <= 44.0 && full > 150.0, "{hidden} / {full}"); + app.run("ui.colorPanel", json!({"options": true})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![], egui::Modifiers::NONE).1, full); +} + +/// Hide Options is kept with the UI state, which the app saves with its preferences. A saved +/// value that isn't true or false reads as Show Options, and the rest of the state still loads. +#[test] +fn hide_options_comes_back_after_a_restart() { + let mut app = app(); + app.run("ui.colorPanel", json!({"options": false})).unwrap(); + let saved: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert!(saved.color_hide_options); + assert_eq!(crate::control::inspect(&app, &egui::Context::default())["ui"]["color_hide_options"], true); + for v in [json!(7), json!("hide"), json!({}), Value::Null] { + let ui: crate::UiState = serde_json::from_value(json!({"color_hide_options": v, "color_mode": "future", "place_link": false})).unwrap(); + assert_eq!((ui.color_hide_options, ui.color_mode, ui.place_link), (false, None, false), "{v}"); + } +} + +/// `ui.colorPanel {mode}` picks a mode as the panel menu does: HSB shows an RGB fill as H, S and +/// B, and CMYK converts it as one undo step. +#[test] +fn ui_color_panel_picks_a_mode_as_the_menu_does() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); + let r = app.run("ui.colorPanel", json!({"mode": "hsb"})).unwrap(); + assert_eq!(r, json!({"mode": "hsb", "options": true, "shown": "hsb"})); + assert_eq!(slider_labels(&mut app, &ctx), ["H", "S", "B"]); + assert_eq!(crate::control::inspect(&app, &ctx)["ui"]["color_mode"], "hsb"); + assert_eq!(paints_hex(&app).0, "#336699", "HSB keeps the RGB color"); + assert_eq!(app.run("ui.colorPanel", json!({"mode": "cmyk"})).unwrap()["shown"], "cmyk"); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK converts the color"); + app.run("edit.undo", json!({})).unwrap(); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Rgb { .. })), "undo restores it"); + assert_eq!(paints_hex(&app).0, "#336699"); + // `{}` reads the settings: CMYK stays picked, and the RGB fill shows in its own model. + assert_eq!(app.run("ui.colorPanel", json!({})).unwrap(), json!({"mode": "cmyk", "options": true, "shown": "rgb"})); + // A tint of a global swatch shows Tint mode. + app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap(); + assert_eq!(app.run("ui.colorPanel", json!({})).unwrap()["shown"], "tint"); +} + +/// A mode the panel menu doesn't list, Lab included, and an options value that isn't true or +/// false answer an error, and the command then changes nothing. +#[test] +fn ui_color_panel_answers_an_error_for_a_mode_off_the_menu() { + let mut app = app(); + for m in [json!("lab"), json!("oklch"), json!("hsl"), json!("future"), json!("RGB"), json!(3)] { + let e = app.run("ui.colorPanel", json!({"mode": m})).unwrap_err(); + assert_eq!(e, "`mode` must be grayscale, rgb, hsb, cmyk or websafe", "{m}"); + } + assert_eq!(app.run("ui.colorPanel", json!({"mode": "cmyk", "options": "no"})).unwrap_err(), "`options` must be true or false"); + assert_eq!((app.ui.color_mode, app.ui.color_hide_options), (None, false)); +} + +/// `ui.colorPanel` answers every junk parameter, with and without a document, without a panic. +#[test] +fn ui_color_panel_answers_junk_params_without_a_panic() { + let doc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == "ui.colorPanel").expect("registered").3; + let mut app = app(); + app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); + for p in vectorcraft_testkit::strategies::junk_params(doc, "/nonexistent").into_iter().chain(vectorcraft_testkit::strategies::junk_values()) { + let _ = app.run("ui.colorPanel", p); + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert_eq!(app.run("ui.colorPanel", json!({"mode": "cmyk"})).unwrap()["mode"], "cmyk"); +} + +#[test] +fn a_mixed_selection_has_no_colour_to_edit() { + let mut app = app(); + let ctx = egui::Context::default(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("select.all", json!({})).unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + app.run("paint.setFill", json!({"color": "#00ff00", "ids": [b]})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (true, false)); + let _ = ctx.run_ui(Default::default(), |ui| assert_eq!(target(&app, ui).color(), None)); + app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap(); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false), "a new revision recomputes it"); +} + +#[test] +fn rgb_roundtrip() { + let c = Color::rgb8(230, 120, 40); + let v = components(Mode::Rgb, &c); + assert_eq!(v.iter().map(|x| x.round() as i32).collect::>(), vec![230, 120, 40]); + assert_eq!(from_components(Mode::Rgb, &v).to_hex(), c.to_hex()); +} + +#[test] +fn hsb_roundtrip_and_ranges() { + let c = Color::rgb8(230, 120, 40); + let v = components(Mode::Hsb, &c); + assert!(v[0] > 0.0 && v[0] < 360.0 && v[1] <= 100.0 && v[2] <= 100.0); + assert_eq!(from_components(Mode::Hsb, &v).to_hex(), c.to_hex()); + assert_eq!(Mode::Hsb.max(0), 360.0); + assert_eq!(Mode::Hsb.suffix(0), "°"); +} + +#[test] +fn cmyk_and_gray_keep_model() { + let c = from_components(Mode::Cmyk, &[10.0, 20.0, 30.0, 40.0]); + assert!(matches!(c, Color::Cmyk { .. })); + let v = components(Mode::Cmyk, &c); + assert!((v[3] - 40.0).abs() < 1e-3); + let g = from_components(Mode::Grayscale, &[25.0]); + assert_eq!(g, Color::gray(0.25)); + assert!((components(Mode::Grayscale, &g)[0] - 25.0).abs() < 1e-4); + // Grayscale view of an RGB colour uses luminance. + assert!((components(Mode::Grayscale, &Color::WHITE)[0]).abs() < 1e-4); +} + +#[test] +fn web_safe_snaps() { + let c = Color::rgb8(230, 120, 40); + let w = web_safe(&c); + assert_eq!(w.to_hex(), "#ff6633"); + assert!(is_web_safe(&w)); + assert!(!is_web_safe(&c)); + assert_eq!(from_components(Mode::WebSafe, &[230.0, 120.0, 40.0]).to_hex(), "#ff6633"); +} + +#[test] +fn slider_tracks_vary_one_component() { + let comps = [230.0, 120.0, 40.0]; + let a = track_color(Mode::Rgb, &comps, 0, 0.0).to_rgba8(1.0); + let b = track_color(Mode::Rgb, &comps, 0, 1.0).to_rgba8(1.0); + assert_eq!((a[0], a[1], a[2]), (0, 120, 40)); + assert_eq!((b[0], b[1], b[2]), (255, 120, 40)); + let h = track_color(Mode::Hsb, &[0.0, 100.0, 100.0], 0, 1.0 / 3.0).to_hex(); + assert_eq!(h, "#00ff00"); +} + +#[test] +fn spectrum_and_hex() { + assert_eq!(spectrum_at(Mode::Rgb, 0.0, 0.0).to_hex(), "#ffffff"); + assert_eq!(spectrum_at(Mode::Rgb, 0.0, 0.5).to_hex(), "#ff0000"); + assert_eq!(spectrum_at(Mode::Rgb, 0.3, 1.0).to_hex(), "#000000"); + assert_eq!(spectrum_at(Mode::Grayscale, 1.0, 0.3), Color::gray(1.0)); + assert_eq!(parse_hex("E67828").unwrap().to_hex(), "#e67828"); + assert_eq!(parse_hex("#fff").unwrap().to_hex(), "#ffffff"); + assert!(parse_hex("zz").is_none()); + assert!(parse_hex("12345").is_none()); +} From 683f0cd0de08c1195a86c87f665888811b78b7e7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:58 +0000 Subject: [PATCH 044/199] refactor(tools): move xform/artboard.rs tests to xform/artboard/tests.rs --- crates/tools/src/xform/artboard.rs | 567 +---------------------- crates/tools/src/xform/artboard/tests.rs | 564 ++++++++++++++++++++++ 2 files changed, 565 insertions(+), 566 deletions(-) create mode 100644 crates/tools/src/xform/artboard/tests.rs diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index e165f2449..ecdb79d43 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -651,569 +651,4 @@ impl Tool for ArtboardTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { - PointerEvent::new(kind, x, y) - } - - /// [`doc_with_rect`] (artboard 1 at 0–500, a rectangle at 100–200) plus Artboard 2 at x 600–800, - /// y 0–200. - fn two_boards() -> vectorcraft_doc::Document { - let (mut d, _) = doc_with_rect(); - let mut ab = d.artboards[0].clone(); - ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); - ab.name = "Artboard 2".into(); - d.artboards.push(ab); - d - } - - fn preview_params(a: &[Action]) -> Value { - a.iter() - .find_map(|a| match a { - Action::Preview(_, p) => Some(p.clone()), - _ => None, - }) - .unwrap() - } - - /// Drag artboard 2 from (700, 100) by (dx, dy) and return the preview's params. - fn drag_board2(cx: &ToolContext, t: &mut ArtboardTool, dx: f64, dy: f64) -> Value { - t.pointer(cx, &ev(PointerKind::Down, 700.0, 100.0)); - preview_params(&t.pointer(cx, &ev(PointerKind::Drag, 700.0 + dx, 100.0 + dy))) - } - - #[test] - fn artboard_resize_readout_honors_transform_tools_guides() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let measures = |t: &ArtboardTool, c: &ToolContext| t.overlays(c).iter().filter(|o| matches!(o, Overlay::Measure { .. })).count(); - let mut t = ArtboardTool { active: 1, ..Default::default() }; - for on in [true, false] { - let c = ToolContext { transform_tools_guides: on, ..cx(&d, &s, &p) }; - t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); - t.pointer(&c, &ev(PointerKind::Drag, 820.0, 100.0)); - assert_eq!(measures(&t, &c), usize::from(on), "transform tools {on}"); - t.pointer(&c, &ev(PointerKind::Up, 820.0, 100.0)); - } - let mut t = ArtboardTool::default(); - let create = ToolContext { measurement_labels: true, transform_tools_guides: false, ..cx(&d, &s, &p) }; - t.pointer(&create, &ev(PointerKind::Down, 900.0, 300.0)); - t.pointer(&create, &ev(PointerKind::Drag, 1000.0, 400.0)); - assert_eq!(measures(&t, &create), 1, "new artboard drag still uses measurement labels"); - } - - #[test] - fn bounds_follow_the_artboard_while_the_document_shows_the_preview() { - let mut d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut t = ArtboardTool::default(); - assert_eq!(drag_board2(&cx(&d, &s, &p), &mut t, 10.0, 0.0)["dx"], 10.0); - // The engine applies the preview to the document, as it does during a drag. - d.artboards[1].rect = Rect::new(610.0, 0.0, 810.0, 200.0); - let cx = cx(&d, &s, &p); - assert_eq!(preview_params(&t.pointer(&cx, &ev(PointerKind::Drag, 720.0, 100.0)))["dx"], 20.0); - let corners: Vec = t - .overlays(&cx) - .iter() - .filter_map(|o| match o { - Overlay::Anchor { p, .. } => Some(*p), - _ => None, - }) - .collect(); - assert_eq!(corners[0], Point::new(620.0, 0.0), "the handles sit on the moved artboard, not 10 pt past it"); - } - - #[test] - fn moving_snaps_to_other_artboards_with_smart_guides() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut t = ArtboardTool::default(); - let mut c = cx(&d, &s, &p); - // Left edge 3 pt right of artboard 1's right edge (500) → lands on it. - assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -100.0); - assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { color, .. } if *color == crate::guides::MAGENTA))); - t.pointer(&c, &ev(PointerKind::Up, 603.0, 100.0)); - assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })), "guides go away on release"); - c.smart_guides = false; - assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -97.0); - } - - #[test] - fn moving_never_snaps_to_itself_or_the_art_it_carries() { - let mut d = two_boards(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut t = ArtboardTool::default(); - // Its own old left edge (600) isn't a target. - assert_eq!(drag_board2(&c, &mut t, 4.0, 0.0)["dx"], 4.0); - t.pointer(&c, &ev(PointerKind::Up, 704.0, 100.0)); - // The rectangle moves along: its left edge (650) isn't a target either… - assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 52.0); - t.pointer(&c, &ev(PointerKind::Up, 752.0, 100.0)); - // …unless it stays behind. - t.move_art = false; - assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); - } - - /// Art that is also on a locked artboard stays behind when its artboard moves, so the move - /// snaps to it, as to any art that stays. - #[test] - fn moving_snaps_to_the_art_a_locked_artboard_keeps() { - let mut d = two_boards(); - let mut locked = d.artboards[0].clone(); - locked.rect = Rect::new(640.0, 40.0, 700.0, 70.0); - locked.locked = true; - d.artboards.push(locked); - let l = d.layers[0].id; - let id = d.alloc_id(); - let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut t = ArtboardTool::default(); - // Artboard 2's left edge, 600 + 52, snaps to the rectangle's at 650. - assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); - } - - #[test] - fn moving_snaps_to_grid_and_pixels() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut t = ArtboardTool::default(); - let mut c = cx(&d, &s, &p); - c.snap_to_grid = true; - // Grid step 72 / 8 = 9: x 610 → 612, y 4 → 0. - let v = drag_board2(&c, &mut t, 10.0, 4.0); - assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(12.0), Some(0.0))); - t.pointer(&c, &ev(PointerKind::Up, 710.0, 104.0)); - c.snap_to_grid = false; - c.snap_to_pixel = true; - let v = drag_board2(&c, &mut t, 10.4, 3.6); - assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(10.0), Some(4.0))); - } - - #[test] - fn resizing_snaps_the_dragged_handle() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut t = ArtboardTool { active: 1, ..Default::default() }; - let c = cx(&d, &s, &p); - // Left handle to 3 pt off artboard 1's right edge. - t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 503.0, 100.0))); - assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(500.0), Some(300.0))); - t.pointer(&c, &ev(PointerKind::Up, 503.0, 100.0)); - // The handle, not the pointer, follows: grabbed 2 pt left of it, dragged 10 pt. - t.pointer(&c, &ev(PointerKind::Down, 798.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 808.0, 100.0))); - assert_eq!(v["width"].as_f64(), Some(210.0)); - } - - /// In a view turned 90° the handles' cursors turn with the view. - #[test] - fn handle_cursors_turn_with_the_view() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let t = ArtboardTool { active: 1, ..Default::default() }; - let mut c = cx(&d, &s, &p); - let (top, right) = (Point::new(700.0, 0.0), Point::new(800.0, 100.0)); - assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeV); - c.screen = turned_view(90.0); - assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeH); - assert_eq!(t.cursor(&c, right, Mods::default()), Cursor::ResizeV); - } - - /// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one - /// out again, a plain click on an unselected one selects just it, and Delete removes all the - /// selected ones (one artboard is always left). - #[test] - fn shift_click_selects_several_artboards_that_move_and_delete_together() { - let mut d = two_boards(); - let mut third = d.artboards[0].clone(); - third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); - d.artboards.push(third); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let click = |t: &mut ArtboardTool, x: f64, y: f64, m: Mods| { - t.pointer(&c, &ev(PointerKind::Down, x, y).with_mods(m)); - t.pointer(&c, &ev(PointerKind::Up, x, y).with_mods(m)); - }; - let mut t = ArtboardTool::default(); - click(&mut t, 450.0, 450.0, Mods::default()); - click(&mut t, 700.0, 100.0, shift); - assert_eq!(t.options()["selected"], json!([1, 0]), "the Shift-clicked one is active"); - // A drag from either moves both. - t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0))); - assert_eq!((v["indices"].clone(), v["dx"].as_f64()), (json!([0, 1]), Some(20.0))); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)), vec![Action::Commit]); - // Delete removes both; with all three selected the keys retain the original first board. - assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]); - for key in [ToolKey::Delete, ToolKey::Backspace] { - t.set_option("selected", &json!([2, 0, 1])); - assert!(t.claims_key(&c, key)); - assert_eq!(t.key(&c, key, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [1, 2]}))]); - assert_eq!(t.options()["selected"], json!([0])); - } - t.set_option("selected", &json!([0, 1, 2])); - // Shift-click takes one out; a plain click on an unselected one selects just it. - click(&mut t, 100.0, 700.0, shift); - assert_eq!(t.options()["selected"], json!([0, 1])); - click(&mut t, 100.0, 700.0, Mods::default()); - assert_eq!(t.options()["selected"], json!([2])); - } - - /// The arrow keys move the selected artboards by the keyboard increment, Shift ten times as far, - /// with their art when Move Artwork with Artboard is on (#2103): the tool claims them, so they - /// no longer fall through to object.nudge and move only the selected art. A locked artboard - /// stays, and during a drag the arrows do nothing. - #[test] - fn arrow_keys_nudge_the_selected_artboards() { - let mut d = two_boards(); - let mut third = d.artboards[0].clone(); - third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); - third.locked = true; - d.artboards.push(third); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.keyboard_increment = 2.0; - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool { active: 1, ..Default::default() }; - let moved = |p: Value| vec![Action::Exec("artboard.move".into(), p)]; - for key in [ToolKey::Left, ToolKey::Right, ToolKey::Up, ToolKey::Down] { - assert!(t.claims_key(&c, key), "{key:?}"); - } - assert_eq!(t.key(&c, ToolKey::Right, Mods::default()), moved(json!({"index": 1, "dx": 2.0, "dy": 0.0, "moveArt": true}))); - assert_eq!(t.key(&c, ToolKey::Up, shift), moved(json!({"index": 1, "dx": 0.0, "dy": -20.0, "moveArt": true}))); - t.move_art = false; - t.set_option("selected", &json!([0, 1, 2])); - assert_eq!( - t.key(&c, ToolKey::Left, Mods::default()), - moved(json!({"indices": [0, 1], "dx": -2.0, "dy": 0.0, "moveArt": false})), - "the locked artboard stays" - ); - t.set_option("selected", &json!([2])); - assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "only a locked artboard selected"); - t.set_option("selected", &json!([1])); - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); - assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "not during a drag"); - } - - /// A click selects a locked artboard, but a drag neither moves it nor copies it (Alt), a press - /// on its edge doesn't resize it, it shows no handles, the pointer over it is the arrow, and - /// Delete leaves it. With an unlocked artboard selected too, a drag and Delete act on that one. - /// With Shift the pointer over it is the crosshair, a Shift-click toggles it, and a Shift-drag - /// inside it draws a new artboard, as over any artboard. - #[test] - fn a_locked_artboard_is_not_moved_resized_or_deleted() { - let mut d = two_boards(); - d.artboards[1].locked = true; - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let mut t = ArtboardTool::default(); - for m in [Mods::default(), Mods { alt: true, ..Default::default() }] { - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(m)); - assert!(t.pointer(&c, &ev(PointerKind::Drag, 750.0, 100.0).with_mods(m)).is_empty()); - assert!(t.pointer(&c, &ev(PointerKind::Up, 750.0, 100.0).with_mods(m)).is_empty()); - } - assert_eq!(t.options()["selected"], json!([1]), "the click selects it"); - assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Anchor { .. })), "no handles"); - // A press on its left edge, where a handle would be. - t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); - assert!(t.pointer(&c, &ev(PointerKind::Drag, 550.0, 100.0)).is_empty()); - t.pointer(&c, &ev(PointerKind::Up, 550.0, 100.0)); - for x in [600.0, 700.0] { - assert_eq!(t.cursor(&c, Point::new(x, 100.0), Mods::default()), Cursor::Arrow); - } - assert!(t.key(&c, ToolKey::Delete, Mods::default()).is_empty()); - t.set_option("selected", &json!([0, 1])); - t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); - assert_eq!(preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)))["indices"], json!([0])); - t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)); - t.set_option("selected", &json!([0, 1])); - assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 0}))]); - let shift = Mods { shift: true, ..Default::default() }; - assert_eq!(t.cursor(&c, Point::new(700.0, 100.0), shift), Cursor::Crosshair); - t.set_option("selected", &json!([0, 1])); - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)); - assert!(t.pointer(&c, &ev(PointerKind::Up, 700.0, 100.0).with_mods(shift)).is_empty()); - assert_eq!(t.options()["selected"], json!([0]), "the Shift-click takes it out"); - t.pointer(&c, &ev(PointerKind::Down, 650.0, 50.0).with_mods(shift)); - assert!(t.pointer(&c, &ev(PointerKind::Drag, 690.0, 80.0).with_mods(shift)).is_empty(), "no move preview"); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 690.0, 80.0).with_mods(shift)), create_artboard(Rect::new(650.0, 50.0, 690.0, 80.0))); - } - - /// A Shift-click toggles its artboard when the button comes up, so the press alone leaves the - /// selection as it was. - #[test] - fn a_shift_click_toggles_its_artboard_on_release() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)), vec![]); - assert_eq!(t.options()["selected"], json!([0]), "the press alone selects nothing"); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 701.0, 100.0).with_mods(shift)), vec![]); - assert_eq!(t.options()["selected"], json!([1, 0])); - for kind in [PointerKind::Down, PointerKind::Up] { - t.pointer(&c, &ev(kind, 700.0, 100.0).with_mods(shift)); - } - assert_eq!(t.options()["selected"], json!([0]), "a second Shift-click takes it out"); - } - - /// Shift-drag inside an artboard draws a new artboard there, neither square nor moving the - /// artboard it starts in, and the cursor shows a crosshair while Shift is held. - #[test] - fn a_shift_drag_inside_an_artboard_draws_a_new_one() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), Mods::default()), Cursor::Move); - assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), shift), Cursor::Crosshair); - assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 300.0, 300.0).with_mods(shift)), vec![]); - assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, 420.0, 380.0).with_mods(shift)), vec![], "no move preview"); - assert!(matches!(t.overlays(&c)[0], Overlay::Marquee(_))); - let a = t.pointer(&c, &ev(PointerKind::Up, 420.0, 380.0).with_mods(shift)); - assert_eq!(a, create_artboard(Rect::new(300.0, 300.0, 420.0, 380.0))); - assert_eq!(t.options()["selected"], json!([0]), "the artboard drawn in is not toggled"); - } - - /// Shift-drag on the pasteboard is a marquee: the artboards it touches leave the selection - /// or join it, the last one joining becoming active, and no artboard is drawn. - #[test] - fn a_shift_drag_on_the_pasteboard_selects_the_artboards_it_touches() { - let mut d = two_boards(); - let mut third = d.artboards[0].clone(); - third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); - d.artboards.push(third); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - let marquee = |t: &mut ArtboardTool, from: (f64, f64), to: (f64, f64)| { - assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(shift)), vec![]); - assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift)), vec![]); - assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_)))); - t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(shift)) - }; - // From between the artboards across Artboard 1 (selected) and Artboard 3: 1 leaves, 3 joins. - assert_eq!(marquee(&mut t, (550.0, 150.0), (150.0, 650.0)), vec![]); - assert_eq!(t.options()["selected"], json!([2])); - // Right of Artboard 2 across its corner: it joins and becomes active. - assert_eq!(marquee(&mut t, (850.0, -50.0), (700.0, 100.0)), vec![]); - assert_eq!(t.options()["selected"], json!([1, 2])); - // A Shift-click on the pasteboard changes nothing. - assert_eq!(marquee(&mut t, (550.0, 150.0), (551.0, 150.0)), vec![]); - assert_eq!(t.options()["selected"], json!([1, 2])); - } - - /// A drag begun without Shift still moves the artboard, and on the pasteboard still draws one; - /// Shift pressed during either no longer keeps the move at 45° or the new artboard square. - #[test] - fn a_plain_drag_still_moves_and_shift_no_longer_constrains_it() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 730.0, 110.0).with_mods(shift))); - assert_eq!((v["index"].as_u64(), v["dx"].as_f64(), v["dy"].as_f64()), (Some(1), Some(30.0), Some(10.0))); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 730.0, 110.0).with_mods(shift)), vec![Action::Commit]); - t.pointer(&c, &ev(PointerKind::Down, 1000.0, 1000.0)); - t.pointer(&c, &ev(PointerKind::Drag, 1100.0, 1050.0).with_mods(shift)); - let a = t.pointer(&c, &ev(PointerKind::Up, 1100.0, 1050.0).with_mods(shift)); - assert_eq!(a, create_artboard(Rect::new(1000.0, 1000.0, 1100.0, 1050.0))); - } - - /// Escape during a marquee or while a new artboard is being drawn ends the gesture without - /// selecting or drawing anything and keeps the tool; the next Escape leaves it. - #[test] - fn escape_cancels_a_marquee_or_a_new_artboard_and_keeps_the_tool() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - // A Shift-drag marquee on the pasteboard, a Shift-drag inside Artboard 1 and a plain drag on - // the pasteboard. - let drags = - [((850.0, -50.0), (700.0, 100.0), shift), ((300.0, 300.0), (420.0, 380.0), shift), ((1000.0, 1000.0), (1100.0, 1050.0), Mods::default())]; - for (from, to, mods) in drags { - t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)); - t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods)); - assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![], "Escape from {from:?} only ends the gesture"); - assert!(!t.busy()); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![], "the release draws nothing"); - assert_eq!(t.options()["selected"], json!([0]), "the release selects nothing"); - } - assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); - } - - /// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's - /// proportions, and never snaps to the art scaling with it. - #[test] - fn scale_art_resizes_proportionally_without_snapping_to_its_art() { - let mut d = two_boards(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let art = vectorcraft_geom::shapes::rectangle(Rect::new(700.0, 50.0, 710.0, 60.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - // Off: the right handle 3 pt past the art's right edge (710) lands on it; the height stays. - let mut t = ArtboardTool { active: 1, ..Default::default() }; - t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0))); - assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v.get("scaleArt")), (Some(110.0), Some(200.0), None)); - t.pointer(&c, &ev(PointerKind::Up, 713.0, 100.0)); - // On: the art scales along, so it's no target, and the height follows the width. - t.set_option("scaleArt", &json!(true)); - assert_eq!(t.options()["scaleArt"], true); - t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0))); - assert_eq!((v["width"].as_f64(), v["scaleArt"].as_bool()), (Some(113.0), Some(true))); - assert!((v["height"].as_f64().unwrap() - 113.0).abs() < 1e-9); - } - - /// Over an artboard's right or bottom edge the cursor shows Move and a click selects that - /// artboard, as on its left or top edge. - #[test] - fn a_click_on_an_artboards_right_or_bottom_edge_selects_it() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - for at in [Point::new(800.0, 100.0), Point::new(700.0, 200.0)] { - let mut t = ArtboardTool::default(); - assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::Move, "{at:?}"); - t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)); - t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)); - assert_eq!(t.options()["selected"], json!([1]), "{at:?}"); - } - } - - /// A proportional resize never asks for a side under 1 pt, which the command would lengthen - /// alone (scaling the art unevenly): both sides grow by one factor instead. - #[test] - fn a_proportional_resize_keeps_its_proportions_at_the_smallest_size() { - let mut d = two_boards(); - d.artboards[1].rect = Rect::new(600.0, 0.0, 800.0, 20.0); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let mut t = ArtboardTool { active: 1, scale_art: true, ..Default::default() }; - // The right handle to 2.5 % of the width: 5 × 0.5, grown to 10 × 1 about the left middle. - t.pointer(&c, &ev(PointerKind::Down, 800.0, 10.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 605.0, 10.0))); - let got = ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap()); - assert!(got.iter().zip([600.0, 9.5, 10.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}"); - } - - #[test] - fn bleed_edges_snap_to_bleed_edges() { - let mut d = two_boards(); - d.setup.bleed = [10.0; 4]; - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut t = ArtboardTool::default(); - // Artboard 2's bleed (left edge 590) lands on artboard 1's bleed (right edge 510): dragged - // 77 pt left it's 3 pt off, nothing else is within reach. - assert_eq!(drag_board2(&c, &mut t, -77.0, 0.0)["dx"], -80.0); - t.pointer(&c, &ev(PointerKind::Up, 623.0, 100.0)); - // Resizing: the left handle's bleed edge snaps the same way. - t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 523.0, 100.0))); - assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(520.0), Some(280.0))); - } - - #[test] - fn move_resize_create_delete() { - let (mut d, _) = doc_with_rect(); - let mut ab = d.artboards[0].clone(); - ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); - ab.name = "Artboard 2".into(); - d.artboards.push(ab); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ArtboardTool::default(); - // Click the second artboard → active; drag moves it. - t.pointer(&cx, &ev(PointerKind::Down, 700.0, 100.0)); - assert_eq!(t.active, 1); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0)); - assert_eq!(a[0], Action::Begin("Move Artboard".into())); - assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true, "copy": false}))); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2"))); - // Handle drag resizes. - t.pointer(&cx, &ev(PointerKind::Down, 800.0, 100.0)); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 900.0, 100.0)); - assert_eq!(a[1], Action::Preview("artboard.setProps".into(), json!({"index": 1, "x": 600.0, "y": 0.0, "width": 300.0, "height": 200.0}))); - t.pointer(&cx, &ev(PointerKind::Up, 900.0, 100.0)); - // Drag on the pasteboard creates one. - t.pointer(&cx, &ev(PointerKind::Down, 1000.0, 1000.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 1100.0, 1050.0)); - assert!(matches!(t.overlays(&cx)[0], Overlay::Marquee(_))); - let a = t.pointer(&cx, &ev(PointerKind::Up, 1100.0, 1050.0)); - assert_eq!(a[0], Action::Exec("artboard.new".into(), json!({"x": 1000.0, "y": 1000.0, "width": 100.0, "height": 50.0}))); - assert_eq!(a[1], Action::Notify("created".into())); - // Delete removes the active artboard, ahead of the Clear shortcut. - assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace)); - assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); - } - - #[test] - fn an_alt_drag_copy_snaps_to_the_artboard_it_leaves_behind() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Mods::default() }; - let mut t = ArtboardTool::default(); - // A copy of artboard 2 dragged 203 pt right: its left edge 3 pt off the original's right - // edge (800), which stays put and is a target. - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(alt)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0).with_mods(alt))); - assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(200.0), Some(true))); - // Released Alt: a plain move, and the artboard's own old edge is no target. - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0))); - assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(203.0), Some(false))); - } - - #[test] - fn alt_drag_moves_a_copy_of_the_artboard() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Mods::default() }; - let moved = - |dx: f64, copy: bool| Action::Preview("artboard.move".into(), json!({"index": 0, "dx": dx, "dy": 0.0, "moveArt": true, "copy": copy})); - let mut t = ArtboardTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt)); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0).with_mods(alt)); - assert_eq!(a, vec![Action::Begin("Duplicate Artboard".into()), moved(50.0, true)]); - // Alt released mid-drag: a plain move after all, and back. - let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 100.0)); - assert_eq!(a, vec![Action::Cancel, Action::Begin("Move Artboard".into()), moved(60.0, false)]); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(alt)); - assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]); - } -} +mod tests; diff --git a/crates/tools/src/xform/artboard/tests.rs b/crates/tools/src/xform/artboard/tests.rs new file mode 100644 index 000000000..3c8900ff5 --- /dev/null +++ b/crates/tools/src/xform/artboard/tests.rs @@ -0,0 +1,564 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) +} + +/// [`doc_with_rect`] (artboard 1 at 0–500, a rectangle at 100–200) plus Artboard 2 at x 600–800, +/// y 0–200. +fn two_boards() -> vectorcraft_doc::Document { + let (mut d, _) = doc_with_rect(); + let mut ab = d.artboards[0].clone(); + ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); + ab.name = "Artboard 2".into(); + d.artboards.push(ab); + d +} + +fn preview_params(a: &[Action]) -> Value { + a.iter() + .find_map(|a| match a { + Action::Preview(_, p) => Some(p.clone()), + _ => None, + }) + .unwrap() +} + +/// Drag artboard 2 from (700, 100) by (dx, dy) and return the preview's params. +fn drag_board2(cx: &ToolContext, t: &mut ArtboardTool, dx: f64, dy: f64) -> Value { + t.pointer(cx, &ev(PointerKind::Down, 700.0, 100.0)); + preview_params(&t.pointer(cx, &ev(PointerKind::Drag, 700.0 + dx, 100.0 + dy))) +} + +#[test] +fn artboard_resize_readout_honors_transform_tools_guides() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let measures = |t: &ArtboardTool, c: &ToolContext| t.overlays(c).iter().filter(|o| matches!(o, Overlay::Measure { .. })).count(); + let mut t = ArtboardTool { active: 1, ..Default::default() }; + for on in [true, false] { + let c = ToolContext { transform_tools_guides: on, ..cx(&d, &s, &p) }; + t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); + t.pointer(&c, &ev(PointerKind::Drag, 820.0, 100.0)); + assert_eq!(measures(&t, &c), usize::from(on), "transform tools {on}"); + t.pointer(&c, &ev(PointerKind::Up, 820.0, 100.0)); + } + let mut t = ArtboardTool::default(); + let create = ToolContext { measurement_labels: true, transform_tools_guides: false, ..cx(&d, &s, &p) }; + t.pointer(&create, &ev(PointerKind::Down, 900.0, 300.0)); + t.pointer(&create, &ev(PointerKind::Drag, 1000.0, 400.0)); + assert_eq!(measures(&t, &create), 1, "new artboard drag still uses measurement labels"); +} + +#[test] +fn bounds_follow_the_artboard_while_the_document_shows_the_preview() { + let mut d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut t = ArtboardTool::default(); + assert_eq!(drag_board2(&cx(&d, &s, &p), &mut t, 10.0, 0.0)["dx"], 10.0); + // The engine applies the preview to the document, as it does during a drag. + d.artboards[1].rect = Rect::new(610.0, 0.0, 810.0, 200.0); + let cx = cx(&d, &s, &p); + assert_eq!(preview_params(&t.pointer(&cx, &ev(PointerKind::Drag, 720.0, 100.0)))["dx"], 20.0); + let corners: Vec = t + .overlays(&cx) + .iter() + .filter_map(|o| match o { + Overlay::Anchor { p, .. } => Some(*p), + _ => None, + }) + .collect(); + assert_eq!(corners[0], Point::new(620.0, 0.0), "the handles sit on the moved artboard, not 10 pt past it"); +} + +#[test] +fn moving_snaps_to_other_artboards_with_smart_guides() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut t = ArtboardTool::default(); + let mut c = cx(&d, &s, &p); + // Left edge 3 pt right of artboard 1's right edge (500) → lands on it. + assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -100.0); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { color, .. } if *color == crate::guides::MAGENTA))); + t.pointer(&c, &ev(PointerKind::Up, 603.0, 100.0)); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })), "guides go away on release"); + c.smart_guides = false; + assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -97.0); +} + +#[test] +fn moving_never_snaps_to_itself_or_the_art_it_carries() { + let mut d = two_boards(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Its own old left edge (600) isn't a target. + assert_eq!(drag_board2(&c, &mut t, 4.0, 0.0)["dx"], 4.0); + t.pointer(&c, &ev(PointerKind::Up, 704.0, 100.0)); + // The rectangle moves along: its left edge (650) isn't a target either… + assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 52.0); + t.pointer(&c, &ev(PointerKind::Up, 752.0, 100.0)); + // …unless it stays behind. + t.move_art = false; + assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); +} + +/// Art that is also on a locked artboard stays behind when its artboard moves, so the move +/// snaps to it, as to any art that stays. +#[test] +fn moving_snaps_to_the_art_a_locked_artboard_keeps() { + let mut d = two_boards(); + let mut locked = d.artboards[0].clone(); + locked.rect = Rect::new(640.0, 40.0, 700.0, 70.0); + locked.locked = true; + d.artboards.push(locked); + let l = d.layers[0].id; + let id = d.alloc_id(); + let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Artboard 2's left edge, 600 + 52, snaps to the rectangle's at 650. + assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); +} + +#[test] +fn moving_snaps_to_grid_and_pixels() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut t = ArtboardTool::default(); + let mut c = cx(&d, &s, &p); + c.snap_to_grid = true; + // Grid step 72 / 8 = 9: x 610 → 612, y 4 → 0. + let v = drag_board2(&c, &mut t, 10.0, 4.0); + assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(12.0), Some(0.0))); + t.pointer(&c, &ev(PointerKind::Up, 710.0, 104.0)); + c.snap_to_grid = false; + c.snap_to_pixel = true; + let v = drag_board2(&c, &mut t, 10.4, 3.6); + assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(10.0), Some(4.0))); +} + +#[test] +fn resizing_snaps_the_dragged_handle() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut t = ArtboardTool { active: 1, ..Default::default() }; + let c = cx(&d, &s, &p); + // Left handle to 3 pt off artboard 1's right edge. + t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 503.0, 100.0))); + assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(500.0), Some(300.0))); + t.pointer(&c, &ev(PointerKind::Up, 503.0, 100.0)); + // The handle, not the pointer, follows: grabbed 2 pt left of it, dragged 10 pt. + t.pointer(&c, &ev(PointerKind::Down, 798.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 808.0, 100.0))); + assert_eq!(v["width"].as_f64(), Some(210.0)); +} + +/// In a view turned 90° the handles' cursors turn with the view. +#[test] +fn handle_cursors_turn_with_the_view() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let t = ArtboardTool { active: 1, ..Default::default() }; + let mut c = cx(&d, &s, &p); + let (top, right) = (Point::new(700.0, 0.0), Point::new(800.0, 100.0)); + assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeV); + c.screen = turned_view(90.0); + assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeH); + assert_eq!(t.cursor(&c, right, Mods::default()), Cursor::ResizeV); +} + +/// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one +/// out again, a plain click on an unselected one selects just it, and Delete removes all the +/// selected ones (one artboard is always left). +#[test] +fn shift_click_selects_several_artboards_that_move_and_delete_together() { + let mut d = two_boards(); + let mut third = d.artboards[0].clone(); + third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); + d.artboards.push(third); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let click = |t: &mut ArtboardTool, x: f64, y: f64, m: Mods| { + t.pointer(&c, &ev(PointerKind::Down, x, y).with_mods(m)); + t.pointer(&c, &ev(PointerKind::Up, x, y).with_mods(m)); + }; + let mut t = ArtboardTool::default(); + click(&mut t, 450.0, 450.0, Mods::default()); + click(&mut t, 700.0, 100.0, shift); + assert_eq!(t.options()["selected"], json!([1, 0]), "the Shift-clicked one is active"); + // A drag from either moves both. + t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0))); + assert_eq!((v["indices"].clone(), v["dx"].as_f64()), (json!([0, 1]), Some(20.0))); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)), vec![Action::Commit]); + // Delete removes both; with all three selected the keys retain the original first board. + assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]); + for key in [ToolKey::Delete, ToolKey::Backspace] { + t.set_option("selected", &json!([2, 0, 1])); + assert!(t.claims_key(&c, key)); + assert_eq!(t.key(&c, key, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [1, 2]}))]); + assert_eq!(t.options()["selected"], json!([0])); + } + t.set_option("selected", &json!([0, 1, 2])); + // Shift-click takes one out; a plain click on an unselected one selects just it. + click(&mut t, 100.0, 700.0, shift); + assert_eq!(t.options()["selected"], json!([0, 1])); + click(&mut t, 100.0, 700.0, Mods::default()); + assert_eq!(t.options()["selected"], json!([2])); +} + +/// The arrow keys move the selected artboards by the keyboard increment, Shift ten times as far, +/// with their art when Move Artwork with Artboard is on (#2103): the tool claims them, so they +/// no longer fall through to object.nudge and move only the selected art. A locked artboard +/// stays, and during a drag the arrows do nothing. +#[test] +fn arrow_keys_nudge_the_selected_artboards() { + let mut d = two_boards(); + let mut third = d.artboards[0].clone(); + third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); + third.locked = true; + d.artboards.push(third); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.keyboard_increment = 2.0; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool { active: 1, ..Default::default() }; + let moved = |p: Value| vec![Action::Exec("artboard.move".into(), p)]; + for key in [ToolKey::Left, ToolKey::Right, ToolKey::Up, ToolKey::Down] { + assert!(t.claims_key(&c, key), "{key:?}"); + } + assert_eq!(t.key(&c, ToolKey::Right, Mods::default()), moved(json!({"index": 1, "dx": 2.0, "dy": 0.0, "moveArt": true}))); + assert_eq!(t.key(&c, ToolKey::Up, shift), moved(json!({"index": 1, "dx": 0.0, "dy": -20.0, "moveArt": true}))); + t.move_art = false; + t.set_option("selected", &json!([0, 1, 2])); + assert_eq!( + t.key(&c, ToolKey::Left, Mods::default()), + moved(json!({"indices": [0, 1], "dx": -2.0, "dy": 0.0, "moveArt": false})), + "the locked artboard stays" + ); + t.set_option("selected", &json!([2])); + assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "only a locked artboard selected"); + t.set_option("selected", &json!([1])); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "not during a drag"); +} + +/// A click selects a locked artboard, but a drag neither moves it nor copies it (Alt), a press +/// on its edge doesn't resize it, it shows no handles, the pointer over it is the arrow, and +/// Delete leaves it. With an unlocked artboard selected too, a drag and Delete act on that one. +/// With Shift the pointer over it is the crosshair, a Shift-click toggles it, and a Shift-drag +/// inside it draws a new artboard, as over any artboard. +#[test] +fn a_locked_artboard_is_not_moved_resized_or_deleted() { + let mut d = two_boards(); + d.artboards[1].locked = true; + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let mut t = ArtboardTool::default(); + for m in [Mods::default(), Mods { alt: true, ..Default::default() }] { + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(m)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 750.0, 100.0).with_mods(m)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 750.0, 100.0).with_mods(m)).is_empty()); + } + assert_eq!(t.options()["selected"], json!([1]), "the click selects it"); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Anchor { .. })), "no handles"); + // A press on its left edge, where a handle would be. + t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 550.0, 100.0)).is_empty()); + t.pointer(&c, &ev(PointerKind::Up, 550.0, 100.0)); + for x in [600.0, 700.0] { + assert_eq!(t.cursor(&c, Point::new(x, 100.0), Mods::default()), Cursor::Arrow); + } + assert!(t.key(&c, ToolKey::Delete, Mods::default()).is_empty()); + t.set_option("selected", &json!([0, 1])); + t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); + assert_eq!(preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)))["indices"], json!([0])); + t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)); + t.set_option("selected", &json!([0, 1])); + assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 0}))]); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(t.cursor(&c, Point::new(700.0, 100.0), shift), Cursor::Crosshair); + t.set_option("selected", &json!([0, 1])); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 700.0, 100.0).with_mods(shift)).is_empty()); + assert_eq!(t.options()["selected"], json!([0]), "the Shift-click takes it out"); + t.pointer(&c, &ev(PointerKind::Down, 650.0, 50.0).with_mods(shift)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 690.0, 80.0).with_mods(shift)).is_empty(), "no move preview"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 690.0, 80.0).with_mods(shift)), create_artboard(Rect::new(650.0, 50.0, 690.0, 80.0))); +} + +/// A Shift-click toggles its artboard when the button comes up, so the press alone leaves the +/// selection as it was. +#[test] +fn a_shift_click_toggles_its_artboard_on_release() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)), vec![]); + assert_eq!(t.options()["selected"], json!([0]), "the press alone selects nothing"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 701.0, 100.0).with_mods(shift)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 0])); + for kind in [PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &ev(kind, 700.0, 100.0).with_mods(shift)); + } + assert_eq!(t.options()["selected"], json!([0]), "a second Shift-click takes it out"); +} + +/// Shift-drag inside an artboard draws a new artboard there, neither square nor moving the +/// artboard it starts in, and the cursor shows a crosshair while Shift is held. +#[test] +fn a_shift_drag_inside_an_artboard_draws_a_new_one() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), Mods::default()), Cursor::Move); + assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), shift), Cursor::Crosshair); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 300.0, 300.0).with_mods(shift)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, 420.0, 380.0).with_mods(shift)), vec![], "no move preview"); + assert!(matches!(t.overlays(&c)[0], Overlay::Marquee(_))); + let a = t.pointer(&c, &ev(PointerKind::Up, 420.0, 380.0).with_mods(shift)); + assert_eq!(a, create_artboard(Rect::new(300.0, 300.0, 420.0, 380.0))); + assert_eq!(t.options()["selected"], json!([0]), "the artboard drawn in is not toggled"); +} + +/// Shift-drag on the pasteboard is a marquee: the artboards it touches leave the selection +/// or join it, the last one joining becoming active, and no artboard is drawn. +#[test] +fn a_shift_drag_on_the_pasteboard_selects_the_artboards_it_touches() { + let mut d = two_boards(); + let mut third = d.artboards[0].clone(); + third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); + d.artboards.push(third); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + let marquee = |t: &mut ArtboardTool, from: (f64, f64), to: (f64, f64)| { + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(shift)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift)), vec![]); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_)))); + t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(shift)) + }; + // From between the artboards across Artboard 1 (selected) and Artboard 3: 1 leaves, 3 joins. + assert_eq!(marquee(&mut t, (550.0, 150.0), (150.0, 650.0)), vec![]); + assert_eq!(t.options()["selected"], json!([2])); + // Right of Artboard 2 across its corner: it joins and becomes active. + assert_eq!(marquee(&mut t, (850.0, -50.0), (700.0, 100.0)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 2])); + // A Shift-click on the pasteboard changes nothing. + assert_eq!(marquee(&mut t, (550.0, 150.0), (551.0, 150.0)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 2])); +} + +/// A drag begun without Shift still moves the artboard, and on the pasteboard still draws one; +/// Shift pressed during either no longer keeps the move at 45° or the new artboard square. +#[test] +fn a_plain_drag_still_moves_and_shift_no_longer_constrains_it() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 730.0, 110.0).with_mods(shift))); + assert_eq!((v["index"].as_u64(), v["dx"].as_f64(), v["dy"].as_f64()), (Some(1), Some(30.0), Some(10.0))); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 730.0, 110.0).with_mods(shift)), vec![Action::Commit]); + t.pointer(&c, &ev(PointerKind::Down, 1000.0, 1000.0)); + t.pointer(&c, &ev(PointerKind::Drag, 1100.0, 1050.0).with_mods(shift)); + let a = t.pointer(&c, &ev(PointerKind::Up, 1100.0, 1050.0).with_mods(shift)); + assert_eq!(a, create_artboard(Rect::new(1000.0, 1000.0, 1100.0, 1050.0))); +} + +/// Escape during a marquee or while a new artboard is being drawn ends the gesture without +/// selecting or drawing anything and keeps the tool; the next Escape leaves it. +#[test] +fn escape_cancels_a_marquee_or_a_new_artboard_and_keeps_the_tool() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + // A Shift-drag marquee on the pasteboard, a Shift-drag inside Artboard 1 and a plain drag on + // the pasteboard. + let drags = + [((850.0, -50.0), (700.0, 100.0), shift), ((300.0, 300.0), (420.0, 380.0), shift), ((1000.0, 1000.0), (1100.0, 1050.0), Mods::default())]; + for (from, to, mods) in drags { + t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)); + t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods)); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![], "Escape from {from:?} only ends the gesture"); + assert!(!t.busy()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![], "the release draws nothing"); + assert_eq!(t.options()["selected"], json!([0]), "the release selects nothing"); + } + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); +} + +/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's +/// proportions, and never snaps to the art scaling with it. +#[test] +fn scale_art_resizes_proportionally_without_snapping_to_its_art() { + let mut d = two_boards(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let art = vectorcraft_geom::shapes::rectangle(Rect::new(700.0, 50.0, 710.0, 60.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + // Off: the right handle 3 pt past the art's right edge (710) lands on it; the height stays. + let mut t = ArtboardTool { active: 1, ..Default::default() }; + t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0))); + assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v.get("scaleArt")), (Some(110.0), Some(200.0), None)); + t.pointer(&c, &ev(PointerKind::Up, 713.0, 100.0)); + // On: the art scales along, so it's no target, and the height follows the width. + t.set_option("scaleArt", &json!(true)); + assert_eq!(t.options()["scaleArt"], true); + t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0))); + assert_eq!((v["width"].as_f64(), v["scaleArt"].as_bool()), (Some(113.0), Some(true))); + assert!((v["height"].as_f64().unwrap() - 113.0).abs() < 1e-9); +} + +/// Over an artboard's right or bottom edge the cursor shows Move and a click selects that +/// artboard, as on its left or top edge. +#[test] +fn a_click_on_an_artboards_right_or_bottom_edge_selects_it() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + for at in [Point::new(800.0, 100.0), Point::new(700.0, 200.0)] { + let mut t = ArtboardTool::default(); + assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::Move, "{at:?}"); + t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)); + t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)); + assert_eq!(t.options()["selected"], json!([1]), "{at:?}"); + } +} + +/// A proportional resize never asks for a side under 1 pt, which the command would lengthen +/// alone (scaling the art unevenly): both sides grow by one factor instead. +#[test] +fn a_proportional_resize_keeps_its_proportions_at_the_smallest_size() { + let mut d = two_boards(); + d.artboards[1].rect = Rect::new(600.0, 0.0, 800.0, 20.0); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let mut t = ArtboardTool { active: 1, scale_art: true, ..Default::default() }; + // The right handle to 2.5 % of the width: 5 × 0.5, grown to 10 × 1 about the left middle. + t.pointer(&c, &ev(PointerKind::Down, 800.0, 10.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 605.0, 10.0))); + let got = ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap()); + assert!(got.iter().zip([600.0, 9.5, 10.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}"); +} + +#[test] +fn bleed_edges_snap_to_bleed_edges() { + let mut d = two_boards(); + d.setup.bleed = [10.0; 4]; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Artboard 2's bleed (left edge 590) lands on artboard 1's bleed (right edge 510): dragged + // 77 pt left it's 3 pt off, nothing else is within reach. + assert_eq!(drag_board2(&c, &mut t, -77.0, 0.0)["dx"], -80.0); + t.pointer(&c, &ev(PointerKind::Up, 623.0, 100.0)); + // Resizing: the left handle's bleed edge snaps the same way. + t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 523.0, 100.0))); + assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(520.0), Some(280.0))); +} + +#[test] +fn move_resize_create_delete() { + let (mut d, _) = doc_with_rect(); + let mut ab = d.artboards[0].clone(); + ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); + ab.name = "Artboard 2".into(); + d.artboards.push(ab); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Click the second artboard → active; drag moves it. + t.pointer(&cx, &ev(PointerKind::Down, 700.0, 100.0)); + assert_eq!(t.active, 1); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0)); + assert_eq!(a[0], Action::Begin("Move Artboard".into())); + assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true, "copy": false}))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2"))); + // Handle drag resizes. + t.pointer(&cx, &ev(PointerKind::Down, 800.0, 100.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 900.0, 100.0)); + assert_eq!(a[1], Action::Preview("artboard.setProps".into(), json!({"index": 1, "x": 600.0, "y": 0.0, "width": 300.0, "height": 200.0}))); + t.pointer(&cx, &ev(PointerKind::Up, 900.0, 100.0)); + // Drag on the pasteboard creates one. + t.pointer(&cx, &ev(PointerKind::Down, 1000.0, 1000.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 1100.0, 1050.0)); + assert!(matches!(t.overlays(&cx)[0], Overlay::Marquee(_))); + let a = t.pointer(&cx, &ev(PointerKind::Up, 1100.0, 1050.0)); + assert_eq!(a[0], Action::Exec("artboard.new".into(), json!({"x": 1000.0, "y": 1000.0, "width": 100.0, "height": 50.0}))); + assert_eq!(a[1], Action::Notify("created".into())); + // Delete removes the active artboard, ahead of the Clear shortcut. + assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace)); + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); +} + +#[test] +fn an_alt_drag_copy_snaps_to_the_artboard_it_leaves_behind() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let mut t = ArtboardTool::default(); + // A copy of artboard 2 dragged 203 pt right: its left edge 3 pt off the original's right + // edge (800), which stays put and is a target. + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(alt)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0).with_mods(alt))); + assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(200.0), Some(true))); + // Released Alt: a plain move, and the artboard's own old edge is no target. + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0))); + assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(203.0), Some(false))); +} + +#[test] +fn alt_drag_moves_a_copy_of_the_artboard() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let moved = + |dx: f64, copy: bool| Action::Preview("artboard.move".into(), json!({"index": 0, "dx": dx, "dy": 0.0, "moveArt": true, "copy": copy})); + let mut t = ArtboardTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0).with_mods(alt)); + assert_eq!(a, vec![Action::Begin("Duplicate Artboard".into()), moved(50.0, true)]); + // Alt released mid-drag: a plain move after all, and back. + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 100.0)); + assert_eq!(a, vec![Action::Cancel, Action::Begin("Move Artboard".into()), moved(60.0, false)]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(alt)); + assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]); +} From 6ceee3e8e7576a0b19e3d3461a143f0862d33d14 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:59 +0000 Subject: [PATCH 045/199] refactor(ui-egui): move panels/paragraph.rs tests to panels/paragraph/tests.rs --- crates/ui-egui/src/panels/paragraph.rs | 552 +------------------ crates/ui-egui/src/panels/paragraph/tests.rs | 549 ++++++++++++++++++ 2 files changed, 550 insertions(+), 551 deletions(-) create mode 100644 crates/ui-egui/src/panels/paragraph/tests.rs diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 77d61c9d0..8662d512e 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -742,554 +742,4 @@ fn reset_panel(app: &mut VectorcraftApp) { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_doc::NodeId; - use vectorcraft_engine::Session; - - fn alignment_row(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) -> egui::Rect { - let para = text_style(app).map_or_else(ParaStyle::default, |(_, p)| p); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - alignment_buttons(app, ui, ¶, size); - }) - .response - .rect - } - - // Inspect the actual selected wells, including the shared Properties-size buttons. - fn alignment_frame(ctx: &egui::Context, app: &mut VectorcraftApp, size: f32) -> (Vec, egui::Pos2) { - let mut row = egui::Rect::NOTHING; - let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| row = alignment_row(app, ui, size)); - frame.textures_delta.clear(); - fn wells(shape: &egui::Shape, color: egui::Color32, size: f32, out: &mut Vec) { - match shape { - egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| wells(s, color, size, out)), - egui::Shape::Rect(r) if r.fill == color && r.rect.size() == vec2(size, size) => out.push(r.rect.center()), - _ => {} - } - } - let centers: [egui::Pos2; 7] = std::array::from_fn(|i| row.min + vec2(size / 2.0 + i as f32 * (size + 3.0), size / 2.0)); - let mut active = Vec::new(); - for shape in &frame.shapes { - wells(&shape.shape, Tokens::get(ctx).tool_active, size, &mut active); - } - let selected = active - .iter() - .map(|pos| { - let index = centers.iter().position(|center| center.distance(*pos) < 0.1).expect("alignment well"); - ALIGNMENTS[index].0 - }) - .collect(); - (selected, centers[1]) - } - - fn editing_text(text: &str) -> (VectorcraftApp, Value) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": text})).unwrap()["id"].clone(); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert!(super::super::character::text_editing(&app).is_some()); - (app, id) - } - - fn select_range(app: &mut VectorcraftApp, start: usize, end: usize) { - app.session.set_tool_option("select", &json!({"start": start, "end": end})); - } - - #[test] - fn paragraph_mixed_direction_buttons_show_neither() { - let (mut app, id) = editing_text("A\nB"); - app.session.prefs.show_indic_options = true; - app.run("text.setFormat", json!({"id": id, "direction": "leftToRight"})).unwrap(); - app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "direction": "rightToLeft"})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - let para = text_style(&app).unwrap().1; - assert_eq!(selected_paragraph_direction(&app, ¶), None); - app.session.set_tool_option("selectAll", &json!(true)); - app.run("text.setFormat", json!({"id": id, "direction": "leftToRight"})).unwrap(); - assert_eq!(selected_paragraph_direction(&app, ¶), Some(false)); - app.session.set_tool_option("selectAll", &json!(true)); - app.run("text.setFormat", json!({"id": id, "direction": "rightToLeft"})).unwrap(); - assert_eq!(selected_paragraph_direction(&app, ¶), Some(true)); - } - - #[test] - fn paragraph_mixed_composer_checks_neither_menu_item() { - let (mut app, id) = editing_text("A\nB\n"); - app.run("text.setFormat", json!({"id": id, "composer": "singleLine"})).unwrap(); - app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "composer": "everyLine"})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - assert_eq!(shared_composer(&app), None); - app.run("text.setFormat", json!({"id": id, "composer": "everyLine"})).unwrap(); - assert_eq!(shared_composer(&app), Some(Composer::EveryLine)); - } - - #[test] - fn paragraph_mixed_hyphenate_checks_off() { - let (mut app, id) = editing_text("A\nB\n"); - app.run("text.setFormat", json!({"id": id, "hyphenate": true})).unwrap(); - app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "hyphenate": false})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - assert_eq!(shared_hyphenate(&app), None); - app.run("text.setFormat", json!({"id": id, "hyphenate": true})).unwrap(); - assert_eq!(shared_hyphenate(&app), Some(true)); - } - - #[test] - fn paragraph_mixed_kinsoku_and_mojikumi_show_blank_dropdowns() { - let (mut app, id) = editing_text("A\nB\n"); - app.session.prefs.show_east_asian_options = true; - app.run("text.setFormat", json!({"id": id, "kinsoku": "hard", "mojikumi": "none"})).unwrap(); - app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "kinsoku": "soft", "mojikumi": "lineEndHalf"})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - assert_eq!(shared_para(&app, |p| p.kinsoku), None); - assert_eq!(shared_para(&app, |p| p.mojikumi), None); - app.run("text.setFormat", json!({"id": id, "kinsoku": "hard", "mojikumi": "lineEndHalf"})).unwrap(); - assert_eq!(shared_para(&app, |p| p.kinsoku), Some(Kinsoku::Hard)); - assert_eq!(shared_para(&app, |p| p.mojikumi), Some(Mojikumi::LineEndHalf)); - } - - #[test] - fn paragraph_mixed_indent_fields_show_blank() { - let (mut app, id) = editing_text("A\nB\n"); - app.run("text.setFormat", json!({"id": id, "leftIndent": 10})).unwrap(); - app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "leftIndent": 20})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - assert_eq!(shared_para_field(&app, |p| p.left_indent), None); - assert_eq!(shared_para_field(&app, |p| p.space_before), Some(0.0)); - app.run("text.setFormat", json!({"id": id, "leftIndent": 12})).unwrap(); - assert_eq!(shared_para_field(&app, |p| p.left_indent), Some(12.0)); - app.session.set_tool_option("stopEditing", &json!(true)); - let other = app.run("text.create", json!({"x": 40, "y": 80, "text": "C"})).unwrap()["id"].clone(); - app.run("text.setFormat", json!({"id": other, "leftIndent": 5})).unwrap(); - app.run("select.set", json!({"ids": [id, other]})).unwrap(); - assert_eq!(shared_para_field(&app, |p| p.left_indent), None); - } - - #[test] - fn paragraph_mixed_alignment_click_center_and_undo() { - let (mut app, id) = editing_text("é\nאב\n終"); - app.run("text.setStyle", json!({"id": id, "justify": "left"})).unwrap(); - app.run("text.setStyle", json!({"id": id, "start": 3, "end": 7, "justify": "right"})).unwrap(); - app.run("text.setStyle", json!({"id": id, "start": 8, "end": 11, "justify": "justifyAll"})).unwrap(); - app.run("text.setFormat", json!({"id": id, "start": 3, "end": 7, "leftIndent": 17, "spaceAfter": 9})).unwrap(); - select_range(&mut app, 0, 7); - let before = app.session.active().unwrap().doc.clone(); - let history = app.session.active().unwrap().history.undo.len(); - let ctx = egui::Context::default(); - let (selected, center) = alignment_frame(&ctx, &mut app, 28.0); - assert!(selected.is_empty(), "mixed paragraphs must not light one alignment: {selected:?}"); - super::super::tests_appearance::click(&ctx, &mut app, center, |app, ui| { - alignment_row(app, ui, 28.0); - }); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Center]); - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); - let NodeKind::Text(text) = &node.kind else { panic!("text") }; - assert_eq!( - (text.para_at(0).justify, text.para_at(1).justify, text.para_at(2).justify), - (Justify::Center, Justify::Center, Justify::JustifyAll) - ); - let NodeKind::Text(old) = &before.node(node.id).unwrap().kind else { panic!("text") }; - for i in 0..3 { - let mut expected = old.para_at(i).clone(); - if i < 2 { - expected.justify = Justify::Center; - } - assert_eq!(text.para_at(i), &expected, "other paragraph fields stay unchanged"); - } - assert_eq!(text.runs, old.runs); - assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, before); - assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty()); - } - - #[test] - fn paragraph_mixed_alignment_caret_unicode_boundaries_and_uniform_controls() { - let (mut app, id) = editing_text("é\nאב\n"); - app.run("text.setStyle", json!({"id": id, "justify": "left"})).unwrap(); - app.run("text.setStyle", json!({"id": id, "start": 3, "end": 7, "justify": "right"})).unwrap(); - app.run("text.setStyle", json!({"id": id, "start": 8, "end": 8, "justify": "justifyAll"})).unwrap(); - let ctx = egui::Context::default(); - for (start, end, expected) in [ - (0, 2, vec![Justify::Left]), - (2, 2, vec![Justify::Left]), // newline belongs to the preceding paragraph - (3, 3, vec![Justify::Right]), - (4, 6, vec![Justify::Right]), // byte offsets inside multi-byte characters - (0, 3, vec![]), // ending at the next paragraph's start touches both - (7, 0, vec![]), // reversed selection - (8, 8, vec![Justify::JustifyAll]), // empty trailing paragraph - (100, 100, vec![Justify::JustifyAll]), // existing selection clamping - ] { - select_range(&mut app, start, end); - assert_eq!(alignment_frame(&ctx, &mut app, 24.0).0, expected, "range {start}..{end}"); - } - for (expected, _, _, key) in ALIGNMENTS { - app.run("text.setStyle", json!({"id": id, "justify": key})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [expected]); - } - } - - #[test] - fn paragraph_mixed_alignment_auto_direction_and_split_runs() { - let (mut app, id) = editing_text("é\nאב"); - // Character formatting splits the RTL paragraph across runs. - app.run("text.setRangeStyle", json!({"id": id, "start": 3, "end": 5, "size": 24})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - let ctx = egui::Context::default(); - assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "Auto resolves separately per paragraph"); - select_range(&mut app, 3, 7); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Right]); - app.run("text.setFormat", json!({"id": id, "start": 3, "end": 7, "direction": "leftToRight"})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Left]); - app.run("text.setStyle", json!({"id": id, "start": 0, "end": 2, "justify": "left"})).unwrap(); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Left], "explicit Left and resolved Auto agree"); - app.run("text.setFormat", json!({"id": id, "start": 0, "end": 2, "direction": "rightToLeft"})).unwrap(); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Left], "explicit alignment ignores direction"); - } - - #[test] - fn paragraph_mixed_alignment_explicit_rtl_and_empty_controls() { - let (mut app, id) = editing_text("Latin\nאב\n\n"); - app.run("text.setStyle", json!({"id": id, "justify": "auto"})).unwrap(); - app.run("text.setFormat", json!({"id": id, "direction": "rightToLeft"})).unwrap(); - // Auto with an explicit direction still needs the real, possibly split, paragraph text. - app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 2, "size": 24})).unwrap(); - let ctx = egui::Context::default(); - for (start, end, expected) in [ - (0, 5, Justify::Right), // nonempty Latin with explicit RTL - (6, 10, Justify::Right), // nonempty Hebrew with explicit RTL - (11, 11, Justify::Left), // existing resolver's empty paragraph behavior - (12, 12, Justify::Left), // empty trailing paragraph - ] { - select_range(&mut app, start, end); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [expected], "explicit RTL range {start}..{end}"); - } - app.run("text.setStyle", json!({"id": id, "start": 0, "end": 5, "justify": "left"})).unwrap(); - select_range(&mut app, 0, 10); - assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "explicit Left and Auto/RTL differ"); - let (mut empty, id) = editing_text(""); - empty.run("text.setStyle", json!({"id": id, "justify": "auto"})).unwrap(); - empty.run("text.setFormat", json!({"id": id, "direction": "rightToLeft"})).unwrap(); - assert_eq!(alignment_frame(&egui::Context::default(), &mut empty, 28.0).0, [Justify::Left]); - } - - #[test] - fn paragraph_mixed_alignment_object_group_and_no_text_controls() { - let (mut app, id) = editing_text("A\nB"); - app.run("text.setStyle", json!({"id": id, "justify": "left"})).unwrap(); - app.run("text.setStyle", json!({"id": id, "start": 2, "end": 3, "justify": "right"})).unwrap(); - app.session.set_tool_option("stopEditing", &json!(true)); - let ctx = egui::Context::default(); - assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "whole object covers every paragraph"); - app.run("object.group", json!({})).unwrap(); - assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "group descendant has mixed paragraphs"); - app.run("text.setStyle", json!({"justify": "center"})).unwrap(); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Center]); - let group = app.session.active().unwrap().selection.objects.clone(); - let other = app.run("text.create", json!({"x": 20, "y": 100, "text": "C"})).unwrap()["id"].clone(); - let mut ids: Vec = group.iter().map(|id| json!(id.0)).collect(); - ids.push(other); - app.run("select.set", json!({"ids": ids})).unwrap(); - assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "different selected objects"); - app.run("select.set", json!({"ids": []})).unwrap(); - assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Left], "no-text default stays unchanged"); - let (mut empty, _) = editing_text(""); - assert_eq!(alignment_frame(&egui::Context::default(), &mut empty, 28.0).0, [Justify::Left]); - } - - /// New Hebrew type aligns to the start of its direction (Align Right shows), an explicit - /// alignment stays as chosen, and Paragraph Direction (with the Indic options) sets the - /// direction: Auto then follows it. - #[test] - fn alignment_buttons_show_the_resolved_alignment_and_direction_sets_it() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); - app.session.execute("text.create", &json!({"x": 200, "y": 50, "text": "שלום"})).unwrap(); - let shown = |app: &VectorcraftApp| { - let para = text_style(app).unwrap().1; - shown_alignment(para.justify, resolved_rtl(app, ¶)) - }; - assert_eq!(shown(&app), Justify::Right); - app.session.execute("object.group", &json!({})).unwrap(); - assert_eq!(shown(&app), Justify::Right, "group controls resolve the same text descendant's direction"); - app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); - assert_eq!(shown(&app), Justify::Left); - app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap(); - app.session.execute("text.setFormat", &json!({"direction": "leftToRight"})).unwrap(); - assert_eq!(shown(&app), Justify::Left); - // The direction buttons show with the Indic options only. - crate::i18n::set_current(crate::i18n::Lang::EN); - let tips = |app: &mut VectorcraftApp| { - let ctx = egui::Context::default(); - let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - frame.textures_delta.clear(); - frame.shapes.len() - }; - let without = tips(&mut app); - app.session.prefs.show_indic_options = true; - assert!(tips(&mut app) > without, "two more buttons"); - } - - /// Insert Kashidas is in the panel with the Middle Eastern (Indic) options only, shows the - /// paragraph's setting, and Reset Panel sets it back to None. - #[test] - fn insert_kashidas_shows_with_the_indic_options_and_reset_panel_turns_it_off() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "كتب محمد"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - crate::i18n::set_current(crate::i18n::Lang::EN); - let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, show); - assert!(!painted(&mut app).contains("Insert Kashidas")); - app.session.prefs.show_indic_options = true; - let text = painted(&mut app); - assert!(text.contains("Insert Kashidas") && text.contains("None"), "{text}"); - app.run("text.setFormat", json!({"kashida": "medium"})).unwrap(); - assert!(painted(&mut app).contains("Medium")); - let kashida = |app: &VectorcraftApp| { - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); - let NodeKind::Text(text) = &node.kind else { panic!("text") }; - text.para_at(0).kashida - }; - assert_eq!(kashida(&app), vectorcraft_doc::text::Kashida::Medium); - reset_panel(&mut app); - assert_eq!(kashida(&app), vectorcraft_doc::text::Kashida::None); - } - - /// The Bullets button makes the paragraphs the Type tool's selection touches a bulleted list - /// with the dot and shows pressed; the arrow's presets pick another bullet, None turns the list - /// off, Type › Bullets and Numbering › Apply Bullets applies a list, and Reset Panel turns it - /// off. - #[test] - fn the_bullets_button_and_its_presets_set_the_list_and_reset_panel_clears_it() { - use vectorcraft_doc::text::{Bullet, ParaList}; - crate::i18n::set_current(crate::i18n::Lang::EN); - let (mut app, id) = editing_text("One\nTwo"); - select_range(&mut app, 0, 0); - let lists = |app: &VectorcraftApp| { - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); - let NodeKind::Text(text) = &node.kind else { panic!("text") }; - (text.para_at(0).list, text.para_at(1).list) - }; - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let row = std::cell::Cell::new(egui::Rect::NOTHING); - let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| { - row.set(ui.horizontal(|ui| bullets_button(app, ui, 28.0)).response.rect); - }; - super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); - let min = row.get().min; - let (button, arrow) = (min + vec2(14.0, 14.0), min + vec2(28.0 + 7.0, 14.0)); - super::super::tests_appearance::click(&ctx, &mut app, button, &mut draw); - assert_eq!(lists(&app), (Some(ParaList::bulleted(Bullet::Dot)), None), "the caret's paragraph"); - assert_eq!(shared_para(&app, |p| p.list.map(|l| l.bullet)), Some(Some(Bullet::Dot)), "the button shows pressed"); - let mut pick = |app: &mut VectorcraftApp, name: &str| { - super::super::tests_appearance::click(&ctx, app, arrow, &mut draw); - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], &mut draw); - let at = super::super::tests_appearance::text_rect(&texts, name).center(); - super::super::tests_appearance::click(&ctx, app, at, &mut draw); - }; - pick(&mut app, "Filled Square"); - assert_eq!(lists(&app), (Some(ParaList::bulleted(Bullet::Square)), None)); - pick(&mut app, "None"); - assert_eq!(lists(&app), (None, None)); - app.session.set_tool_option("selectAll", &json!(true)); - app.run("type.applyBullets", json!({})).unwrap(); - assert_eq!(lists(&app), (Some(ParaList::bulleted(Bullet::Dot)), Some(ParaList::bulleted(Bullet::Dot)))); - app.run("type.applyBullets", json!({"bullet": "dash"})).unwrap(); - assert_eq!(lists(&app).0, Some(ParaList::bulleted(Bullet::Dash))); - app.session.set_tool_option("selectAll", &json!(true)); - reset_panel(&mut app); - assert_eq!(lists(&app), (None, None)); - } - - /// The Numbered List button makes the paragraphs the Type tool's selection touches a numbered - /// list 1, 2, 3 and shows pressed; the arrow's presets, each shown by its first three numbers, - /// pick another style, None turns the list off, Type › Bullets and Numbering › Apply Numbers - /// applies a numbered list, and Reset Panel turns it off. - #[test] - fn the_numbered_list_button_and_its_presets_set_the_list_and_reset_panel_clears_it() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; - crate::i18n::set_current(crate::i18n::Lang::EN); - let (mut app, id) = editing_text("One\nTwo"); - select_range(&mut app, 0, 0); - let lists = |app: &VectorcraftApp| { - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); - let NodeKind::Text(text) = &node.kind else { panic!("text") }; - (text.para_at(0).list, text.para_at(1).list) - }; - let numbered = |style| Some(ParaList::numbered(style)); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let row = std::cell::Cell::new(egui::Rect::NOTHING); - let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| { - row.set(ui.horizontal(|ui| numbers_button(app, ui, 28.0)).response.rect); - }; - super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); - let min = row.get().min; - let (button, arrow) = (min + vec2(14.0, 14.0), min + vec2(28.0 + 7.0, 14.0)); - super::super::tests_appearance::click(&ctx, &mut app, button, &mut draw); - assert_eq!(lists(&app), (numbered(NumberStyle::Arabic), None), "the caret's paragraph"); - assert_eq!(shared_para(&app, |p| p.list.and_then(|l| l.number)), Some(Some(NumberStyle::Arabic)), "the button shows pressed"); - let mut pick = |app: &mut VectorcraftApp, name: &str| { - super::super::tests_appearance::click(&ctx, app, arrow, &mut draw); - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], &mut draw); - let at = super::super::tests_appearance::text_rect(&texts, name).center(); - super::super::tests_appearance::click(&ctx, app, at, &mut draw); - }; - for (name, style) in [ - ("I, II, III", NumberStyle::UpperRoman), - ("a, b, c", NumberStyle::LowerLetters), - ("001, 002, 003", NumberStyle::ThreeDigits), - ("1, 2, 3", NumberStyle::Arabic), - ] { - pick(&mut app, name); - assert_eq!(lists(&app), (numbered(style), None), "{name}"); - } - pick(&mut app, "None"); - assert_eq!(lists(&app), (None, None)); - app.session.set_tool_option("selectAll", &json!(true)); - app.run("type.applyNumbers", json!({})).unwrap(); - assert_eq!(lists(&app), (numbered(NumberStyle::Arabic), numbered(NumberStyle::Arabic))); - // The Numbered List button shows pressed and the Bullets button doesn't, and the other way - // round for a bulleted list. - // Each button and the arrow beside it have the same name; only a button shows pressed. - let pressed = |app: &mut VectorcraftApp| { - let nodes = crate::widgets::accessibility_tests::tree(|ui| { - ui.horizontal(|ui| { - bullets_button(app, ui, 28.0); - numbers_button(app, ui, 28.0); - }); - }); - let on = |label| nodes.iter().any(|n| n.label() == Some(label) && n.toggled() == Some(egui::accesskit::Toggled::True)); - (on("Bullets"), on("Numbered List")) - }; - assert_eq!(pressed(&mut app), (false, true)); - app.session.set_tool_option("selectAll", &json!(true)); - app.run("type.applyBullets", json!({})).unwrap(); - assert_eq!(pressed(&mut app), (true, false)); - app.session.set_tool_option("selectAll", &json!(true)); - app.run("type.applyNumbers", json!({})).unwrap(); - app.run("type.applyNumbers", json!({"number": "upperLetters"})).unwrap(); - assert_eq!(lists(&app).0, numbered(NumberStyle::UpperLetters)); - app.session.set_tool_option("selectAll", &json!(true)); - reset_panel(&mut app); - assert_eq!(lists(&app), (None, None)); - } - - /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`) acts on the - /// paragraphs the Type tool's selection touches: their numbers become characters of the text - /// and their list goes, while the other paragraphs stay in the list, and the Type tool's caret - /// stays next to the characters it was next to. The menu item is there and is enabled while - /// type is selected or edited. - #[test] - fn convert_to_text_makes_the_selected_paragraphs_numbers_characters() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; - crate::i18n::set_current(crate::i18n::Lang::EN); - let (mut app, id) = editing_text("One\nTwo"); - let text = |app: &VectorcraftApp| { - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); - let NodeKind::Text(text) = &node.kind else { panic!("text") }; - (text.plain_text(), text.para_at(0).list, text.para_at(1).list) - }; - app.session.set_tool_option("selectAll", &json!(true)); - app.run("type.applyNumbers", json!({})).unwrap(); - let numbered = Some(ParaList::numbered(NumberStyle::Arabic)); - assert_eq!(text(&app), ("One\nTwo".to_string(), numbered, numbered)); - assert!(crate::menus::enabled(&app, "type.convertListToText")); - select_range(&mut app, 5, 5); - app.run("type.convertListToText", json!({})).unwrap(); - assert_eq!(text(&app), ("One\n2.\tTwo".to_string(), numbered, None), "the caret's paragraph"); - let options = app.session.tool_options(); - assert_eq!((options["anchor"].as_u64(), options["caret"].as_u64()), (Some(8), Some(8)), "the caret stays after the T of Two"); - let typemenu = crate::menus::menu_tree().into_iter().find(|(title, _)| *title == "Type").map(|(_, items)| items).unwrap_or_default(); - let found = typemenu.iter().any(|item| { - matches!(item, crate::menus::Item::Sub("Bullets and Numbering", items) - if items.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Convert to Text", "type.convertListToText", _)))) - }); - assert!(found, "Type › Bullets and Numbering › Convert to Text"); - } - - /// Convert to Text in threaded type: when the converted paragraph breaks its lines at other - /// places and its text moves to the frame before, the Type tool's caret goes with the - /// character it was next to, and that frame becomes the selection when it was not selected. - #[test] - fn convert_to_text_keeps_the_caret_by_its_character_in_threaded_type() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); - let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); - let text = format!("One\nTwo\n{long}\nFour"); - let a = - app.run("text.create", json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); - let b = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - app.run("text.thread.create", json!({})).unwrap(); - app.run("text.setFormat", json!({"ids": [a, b], "list": "number"})).unwrap(); - let plain = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.plain_text(), - _ => panic!("text"), - }; - assert!(plain(&app, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&app, b)); - app.run("select.set", json!({"ids": [b]})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - // The caret after the d of dog, at the start of the second frame. - app.session.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1})); - assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(b))); - app.run("type.convertListToText", json!({})).unwrap(); - let first = plain(&app, a); - assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}"); - let options = app.session.tool_options(); - let after_d = first.len() - "og. ".len(); - assert_eq!( - (options["editing"].as_u64(), options["anchor"].as_u64(), options["caret"].as_u64()), - (Some(a), Some(after_d as u64), Some(after_d as u64)), - "the caret is after the d of dog in the first frame" - ); - assert_eq!(app.session.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); - // The next command keeps the Type tool editing the first frame. - app.run("text.setFormat", json!({"ids": [a], "composer": "singleLine"})).unwrap(); - assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(a))); - } - - /// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation and - /// Justification options back to their defaults in every paragraph it acts on, so the paragraphs - /// save neither. - #[test] - fn reset_panel_resets_the_hyphenation_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "First\nSecond"})).unwrap()["id"].clone(); - app.run( - "text.setFormat", - json!({ - "hyphenate": true, "wordsLongerThan": 8, "afterFirst": 2, "beforeLast": 4, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, - "wordSpacing": [60, 90, 200], "letterSpacing": [-5, 0, 5], "glyphScaling": [95, 100, 105], "autoLeading": 150, "singleWordJustification": "alignLeft", "romanHangingPunctuation": true - }), - ) - .unwrap(); - let paras = |app: &VectorcraftApp| { - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); - let NodeKind::Text(text) = &node.kind else { panic!("text") }; - [text.para_at(0).clone(), text.para_at(1).clone()] - }; - for para in paras(&app) { - assert!(para.hyphenate); - assert_ne!(para.hyphenation, HyphenationOptions::default()); - assert!(para.roman_hanging); - assert_ne!(para.justification, vectorcraft_doc::JustificationOptions::default()); - } - reset_panel(&mut app); - for para in paras(&app) { - assert!(!para.hyphenate); - assert_eq!(para.hyphenation, HyphenationOptions::default()); - assert_eq!(serde_json::to_value(¶).unwrap().get("hyphenation"), None, "saves no hyphenation options"); - assert!(!para.roman_hanging, "Roman Hanging Punctuation off"); - assert_eq!(serde_json::to_value(¶).unwrap().get("justification"), None, "saves no justification options"); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs new file mode 100644 index 000000000..ea8391896 --- /dev/null +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -0,0 +1,549 @@ +use super::*; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +fn alignment_row(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) -> egui::Rect { + let para = text_style(app).map_or_else(ParaStyle::default, |(_, p)| p); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + alignment_buttons(app, ui, ¶, size); + }) + .response + .rect +} + +// Inspect the actual selected wells, including the shared Properties-size buttons. +fn alignment_frame(ctx: &egui::Context, app: &mut VectorcraftApp, size: f32) -> (Vec, egui::Pos2) { + let mut row = egui::Rect::NOTHING; + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| row = alignment_row(app, ui, size)); + frame.textures_delta.clear(); + fn wells(shape: &egui::Shape, color: egui::Color32, size: f32, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| wells(s, color, size, out)), + egui::Shape::Rect(r) if r.fill == color && r.rect.size() == vec2(size, size) => out.push(r.rect.center()), + _ => {} + } + } + let centers: [egui::Pos2; 7] = std::array::from_fn(|i| row.min + vec2(size / 2.0 + i as f32 * (size + 3.0), size / 2.0)); + let mut active = Vec::new(); + for shape in &frame.shapes { + wells(&shape.shape, Tokens::get(ctx).tool_active, size, &mut active); + } + let selected = active + .iter() + .map(|pos| { + let index = centers.iter().position(|center| center.distance(*pos) < 0.1).expect("alignment well"); + ALIGNMENTS[index].0 + }) + .collect(); + (selected, centers[1]) +} + +fn editing_text(text: &str) -> (VectorcraftApp, Value) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": text})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(super::super::character::text_editing(&app).is_some()); + (app, id) +} + +fn select_range(app: &mut VectorcraftApp, start: usize, end: usize) { + app.session.set_tool_option("select", &json!({"start": start, "end": end})); +} + +#[test] +fn paragraph_mixed_direction_buttons_show_neither() { + let (mut app, id) = editing_text("A\nB"); + app.session.prefs.show_indic_options = true; + app.run("text.setFormat", json!({"id": id, "direction": "leftToRight"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "direction": "rightToLeft"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + let para = text_style(&app).unwrap().1; + assert_eq!(selected_paragraph_direction(&app, ¶), None); + app.session.set_tool_option("selectAll", &json!(true)); + app.run("text.setFormat", json!({"id": id, "direction": "leftToRight"})).unwrap(); + assert_eq!(selected_paragraph_direction(&app, ¶), Some(false)); + app.session.set_tool_option("selectAll", &json!(true)); + app.run("text.setFormat", json!({"id": id, "direction": "rightToLeft"})).unwrap(); + assert_eq!(selected_paragraph_direction(&app, ¶), Some(true)); +} + +#[test] +fn paragraph_mixed_composer_checks_neither_menu_item() { + let (mut app, id) = editing_text("A\nB\n"); + app.run("text.setFormat", json!({"id": id, "composer": "singleLine"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "composer": "everyLine"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_composer(&app), None); + app.run("text.setFormat", json!({"id": id, "composer": "everyLine"})).unwrap(); + assert_eq!(shared_composer(&app), Some(Composer::EveryLine)); +} + +#[test] +fn paragraph_mixed_hyphenate_checks_off() { + let (mut app, id) = editing_text("A\nB\n"); + app.run("text.setFormat", json!({"id": id, "hyphenate": true})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "hyphenate": false})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_hyphenate(&app), None); + app.run("text.setFormat", json!({"id": id, "hyphenate": true})).unwrap(); + assert_eq!(shared_hyphenate(&app), Some(true)); +} + +#[test] +fn paragraph_mixed_kinsoku_and_mojikumi_show_blank_dropdowns() { + let (mut app, id) = editing_text("A\nB\n"); + app.session.prefs.show_east_asian_options = true; + app.run("text.setFormat", json!({"id": id, "kinsoku": "hard", "mojikumi": "none"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "kinsoku": "soft", "mojikumi": "lineEndHalf"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para(&app, |p| p.kinsoku), None); + assert_eq!(shared_para(&app, |p| p.mojikumi), None); + app.run("text.setFormat", json!({"id": id, "kinsoku": "hard", "mojikumi": "lineEndHalf"})).unwrap(); + assert_eq!(shared_para(&app, |p| p.kinsoku), Some(Kinsoku::Hard)); + assert_eq!(shared_para(&app, |p| p.mojikumi), Some(Mojikumi::LineEndHalf)); +} + +#[test] +fn paragraph_mixed_indent_fields_show_blank() { + let (mut app, id) = editing_text("A\nB\n"); + app.run("text.setFormat", json!({"id": id, "leftIndent": 10})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "leftIndent": 20})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para_field(&app, |p| p.left_indent), None); + assert_eq!(shared_para_field(&app, |p| p.space_before), Some(0.0)); + app.run("text.setFormat", json!({"id": id, "leftIndent": 12})).unwrap(); + assert_eq!(shared_para_field(&app, |p| p.left_indent), Some(12.0)); + app.session.set_tool_option("stopEditing", &json!(true)); + let other = app.run("text.create", json!({"x": 40, "y": 80, "text": "C"})).unwrap()["id"].clone(); + app.run("text.setFormat", json!({"id": other, "leftIndent": 5})).unwrap(); + app.run("select.set", json!({"ids": [id, other]})).unwrap(); + assert_eq!(shared_para_field(&app, |p| p.left_indent), None); +} + +#[test] +fn paragraph_mixed_alignment_click_center_and_undo() { + let (mut app, id) = editing_text("é\nאב\n終"); + app.run("text.setStyle", json!({"id": id, "justify": "left"})).unwrap(); + app.run("text.setStyle", json!({"id": id, "start": 3, "end": 7, "justify": "right"})).unwrap(); + app.run("text.setStyle", json!({"id": id, "start": 8, "end": 11, "justify": "justifyAll"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 3, "end": 7, "leftIndent": 17, "spaceAfter": 9})).unwrap(); + select_range(&mut app, 0, 7); + let before = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + let (selected, center) = alignment_frame(&ctx, &mut app, 28.0); + assert!(selected.is_empty(), "mixed paragraphs must not light one alignment: {selected:?}"); + super::super::tests_appearance::click(&ctx, &mut app, center, |app, ui| { + alignment_row(app, ui, 28.0); + }); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Center]); + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + assert_eq!( + (text.para_at(0).justify, text.para_at(1).justify, text.para_at(2).justify), + (Justify::Center, Justify::Center, Justify::JustifyAll) + ); + let NodeKind::Text(old) = &before.node(node.id).unwrap().kind else { panic!("text") }; + for i in 0..3 { + let mut expected = old.para_at(i).clone(); + if i < 2 { + expected.justify = Justify::Center; + } + assert_eq!(text.para_at(i), &expected, "other paragraph fields stay unchanged"); + } + assert_eq!(text.runs, old.runs); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before); + assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty()); +} + +#[test] +fn paragraph_mixed_alignment_caret_unicode_boundaries_and_uniform_controls() { + let (mut app, id) = editing_text("é\nאב\n"); + app.run("text.setStyle", json!({"id": id, "justify": "left"})).unwrap(); + app.run("text.setStyle", json!({"id": id, "start": 3, "end": 7, "justify": "right"})).unwrap(); + app.run("text.setStyle", json!({"id": id, "start": 8, "end": 8, "justify": "justifyAll"})).unwrap(); + let ctx = egui::Context::default(); + for (start, end, expected) in [ + (0, 2, vec![Justify::Left]), + (2, 2, vec![Justify::Left]), // newline belongs to the preceding paragraph + (3, 3, vec![Justify::Right]), + (4, 6, vec![Justify::Right]), // byte offsets inside multi-byte characters + (0, 3, vec![]), // ending at the next paragraph's start touches both + (7, 0, vec![]), // reversed selection + (8, 8, vec![Justify::JustifyAll]), // empty trailing paragraph + (100, 100, vec![Justify::JustifyAll]), // existing selection clamping + ] { + select_range(&mut app, start, end); + assert_eq!(alignment_frame(&ctx, &mut app, 24.0).0, expected, "range {start}..{end}"); + } + for (expected, _, _, key) in ALIGNMENTS { + app.run("text.setStyle", json!({"id": id, "justify": key})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [expected]); + } +} + +#[test] +fn paragraph_mixed_alignment_auto_direction_and_split_runs() { + let (mut app, id) = editing_text("é\nאב"); + // Character formatting splits the RTL paragraph across runs. + app.run("text.setRangeStyle", json!({"id": id, "start": 3, "end": 5, "size": 24})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + let ctx = egui::Context::default(); + assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "Auto resolves separately per paragraph"); + select_range(&mut app, 3, 7); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Right]); + app.run("text.setFormat", json!({"id": id, "start": 3, "end": 7, "direction": "leftToRight"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Left]); + app.run("text.setStyle", json!({"id": id, "start": 0, "end": 2, "justify": "left"})).unwrap(); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Left], "explicit Left and resolved Auto agree"); + app.run("text.setFormat", json!({"id": id, "start": 0, "end": 2, "direction": "rightToLeft"})).unwrap(); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Left], "explicit alignment ignores direction"); +} + +#[test] +fn paragraph_mixed_alignment_explicit_rtl_and_empty_controls() { + let (mut app, id) = editing_text("Latin\nאב\n\n"); + app.run("text.setStyle", json!({"id": id, "justify": "auto"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "direction": "rightToLeft"})).unwrap(); + // Auto with an explicit direction still needs the real, possibly split, paragraph text. + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 2, "size": 24})).unwrap(); + let ctx = egui::Context::default(); + for (start, end, expected) in [ + (0, 5, Justify::Right), // nonempty Latin with explicit RTL + (6, 10, Justify::Right), // nonempty Hebrew with explicit RTL + (11, 11, Justify::Left), // existing resolver's empty paragraph behavior + (12, 12, Justify::Left), // empty trailing paragraph + ] { + select_range(&mut app, start, end); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [expected], "explicit RTL range {start}..{end}"); + } + app.run("text.setStyle", json!({"id": id, "start": 0, "end": 5, "justify": "left"})).unwrap(); + select_range(&mut app, 0, 10); + assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "explicit Left and Auto/RTL differ"); + let (mut empty, id) = editing_text(""); + empty.run("text.setStyle", json!({"id": id, "justify": "auto"})).unwrap(); + empty.run("text.setFormat", json!({"id": id, "direction": "rightToLeft"})).unwrap(); + assert_eq!(alignment_frame(&egui::Context::default(), &mut empty, 28.0).0, [Justify::Left]); +} + +#[test] +fn paragraph_mixed_alignment_object_group_and_no_text_controls() { + let (mut app, id) = editing_text("A\nB"); + app.run("text.setStyle", json!({"id": id, "justify": "left"})).unwrap(); + app.run("text.setStyle", json!({"id": id, "start": 2, "end": 3, "justify": "right"})).unwrap(); + app.session.set_tool_option("stopEditing", &json!(true)); + let ctx = egui::Context::default(); + assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "whole object covers every paragraph"); + app.run("object.group", json!({})).unwrap(); + assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "group descendant has mixed paragraphs"); + app.run("text.setStyle", json!({"justify": "center"})).unwrap(); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Center]); + let group = app.session.active().unwrap().selection.objects.clone(); + let other = app.run("text.create", json!({"x": 20, "y": 100, "text": "C"})).unwrap()["id"].clone(); + let mut ids: Vec = group.iter().map(|id| json!(id.0)).collect(); + ids.push(other); + app.run("select.set", json!({"ids": ids})).unwrap(); + assert!(alignment_frame(&ctx, &mut app, 28.0).0.is_empty(), "different selected objects"); + app.run("select.set", json!({"ids": []})).unwrap(); + assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Left], "no-text default stays unchanged"); + let (mut empty, _) = editing_text(""); + assert_eq!(alignment_frame(&egui::Context::default(), &mut empty, 28.0).0, [Justify::Left]); +} + +/// New Hebrew type aligns to the start of its direction (Align Right shows), an explicit +/// alignment stays as chosen, and Paragraph Direction (with the Indic options) sets the +/// direction: Auto then follows it. +#[test] +fn alignment_buttons_show_the_resolved_alignment_and_direction_sets_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); + app.session.execute("text.create", &json!({"x": 200, "y": 50, "text": "שלום"})).unwrap(); + let shown = |app: &VectorcraftApp| { + let para = text_style(app).unwrap().1; + shown_alignment(para.justify, resolved_rtl(app, ¶)) + }; + assert_eq!(shown(&app), Justify::Right); + app.session.execute("object.group", &json!({})).unwrap(); + assert_eq!(shown(&app), Justify::Right, "group controls resolve the same text descendant's direction"); + app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); + assert_eq!(shown(&app), Justify::Left); + app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap(); + app.session.execute("text.setFormat", &json!({"direction": "leftToRight"})).unwrap(); + assert_eq!(shown(&app), Justify::Left); + // The direction buttons show with the Indic options only. + crate::i18n::set_current(crate::i18n::Lang::EN); + let tips = |app: &mut VectorcraftApp| { + let ctx = egui::Context::default(); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + frame.textures_delta.clear(); + frame.shapes.len() + }; + let without = tips(&mut app); + app.session.prefs.show_indic_options = true; + assert!(tips(&mut app) > without, "two more buttons"); +} + +/// Insert Kashidas is in the panel with the Middle Eastern (Indic) options only, shows the +/// paragraph's setting, and Reset Panel sets it back to None. +#[test] +fn insert_kashidas_shows_with_the_indic_options_and_reset_panel_turns_it_off() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "كتب محمد"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + crate::i18n::set_current(crate::i18n::Lang::EN); + let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, show); + assert!(!painted(&mut app).contains("Insert Kashidas")); + app.session.prefs.show_indic_options = true; + let text = painted(&mut app); + assert!(text.contains("Insert Kashidas") && text.contains("None"), "{text}"); + app.run("text.setFormat", json!({"kashida": "medium"})).unwrap(); + assert!(painted(&mut app).contains("Medium")); + let kashida = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + text.para_at(0).kashida + }; + assert_eq!(kashida(&app), vectorcraft_doc::text::Kashida::Medium); + reset_panel(&mut app); + assert_eq!(kashida(&app), vectorcraft_doc::text::Kashida::None); +} + +/// The Bullets button makes the paragraphs the Type tool's selection touches a bulleted list +/// with the dot and shows pressed; the arrow's presets pick another bullet, None turns the list +/// off, Type › Bullets and Numbering › Apply Bullets applies a list, and Reset Panel turns it +/// off. +#[test] +fn the_bullets_button_and_its_presets_set_the_list_and_reset_panel_clears_it() { + use vectorcraft_doc::text::{Bullet, ParaList}; + crate::i18n::set_current(crate::i18n::Lang::EN); + let (mut app, id) = editing_text("One\nTwo"); + select_range(&mut app, 0, 0); + let lists = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).list, text.para_at(1).list) + }; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let row = std::cell::Cell::new(egui::Rect::NOTHING); + let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| { + row.set(ui.horizontal(|ui| bullets_button(app, ui, 28.0)).response.rect); + }; + super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); + let min = row.get().min; + let (button, arrow) = (min + vec2(14.0, 14.0), min + vec2(28.0 + 7.0, 14.0)); + super::super::tests_appearance::click(&ctx, &mut app, button, &mut draw); + assert_eq!(lists(&app), (Some(ParaList::bulleted(Bullet::Dot)), None), "the caret's paragraph"); + assert_eq!(shared_para(&app, |p| p.list.map(|l| l.bullet)), Some(Some(Bullet::Dot)), "the button shows pressed"); + let mut pick = |app: &mut VectorcraftApp, name: &str| { + super::super::tests_appearance::click(&ctx, app, arrow, &mut draw); + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], &mut draw); + let at = super::super::tests_appearance::text_rect(&texts, name).center(); + super::super::tests_appearance::click(&ctx, app, at, &mut draw); + }; + pick(&mut app, "Filled Square"); + assert_eq!(lists(&app), (Some(ParaList::bulleted(Bullet::Square)), None)); + pick(&mut app, "None"); + assert_eq!(lists(&app), (None, None)); + app.session.set_tool_option("selectAll", &json!(true)); + app.run("type.applyBullets", json!({})).unwrap(); + assert_eq!(lists(&app), (Some(ParaList::bulleted(Bullet::Dot)), Some(ParaList::bulleted(Bullet::Dot)))); + app.run("type.applyBullets", json!({"bullet": "dash"})).unwrap(); + assert_eq!(lists(&app).0, Some(ParaList::bulleted(Bullet::Dash))); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(lists(&app), (None, None)); +} + +/// The Numbered List button makes the paragraphs the Type tool's selection touches a numbered +/// list 1, 2, 3 and shows pressed; the arrow's presets, each shown by its first three numbers, +/// pick another style, None turns the list off, Type › Bullets and Numbering › Apply Numbers +/// applies a numbered list, and Reset Panel turns it off. +#[test] +fn the_numbered_list_button_and_its_presets_set_the_list_and_reset_panel_clears_it() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + crate::i18n::set_current(crate::i18n::Lang::EN); + let (mut app, id) = editing_text("One\nTwo"); + select_range(&mut app, 0, 0); + let lists = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).list, text.para_at(1).list) + }; + let numbered = |style| Some(ParaList::numbered(style)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let row = std::cell::Cell::new(egui::Rect::NOTHING); + let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| { + row.set(ui.horizontal(|ui| numbers_button(app, ui, 28.0)).response.rect); + }; + super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); + let min = row.get().min; + let (button, arrow) = (min + vec2(14.0, 14.0), min + vec2(28.0 + 7.0, 14.0)); + super::super::tests_appearance::click(&ctx, &mut app, button, &mut draw); + assert_eq!(lists(&app), (numbered(NumberStyle::Arabic), None), "the caret's paragraph"); + assert_eq!(shared_para(&app, |p| p.list.and_then(|l| l.number)), Some(Some(NumberStyle::Arabic)), "the button shows pressed"); + let mut pick = |app: &mut VectorcraftApp, name: &str| { + super::super::tests_appearance::click(&ctx, app, arrow, &mut draw); + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], &mut draw); + let at = super::super::tests_appearance::text_rect(&texts, name).center(); + super::super::tests_appearance::click(&ctx, app, at, &mut draw); + }; + for (name, style) in [ + ("I, II, III", NumberStyle::UpperRoman), + ("a, b, c", NumberStyle::LowerLetters), + ("001, 002, 003", NumberStyle::ThreeDigits), + ("1, 2, 3", NumberStyle::Arabic), + ] { + pick(&mut app, name); + assert_eq!(lists(&app), (numbered(style), None), "{name}"); + } + pick(&mut app, "None"); + assert_eq!(lists(&app), (None, None)); + app.session.set_tool_option("selectAll", &json!(true)); + app.run("type.applyNumbers", json!({})).unwrap(); + assert_eq!(lists(&app), (numbered(NumberStyle::Arabic), numbered(NumberStyle::Arabic))); + // The Numbered List button shows pressed and the Bullets button doesn't, and the other way + // round for a bulleted list. + // Each button and the arrow beside it have the same name; only a button shows pressed. + let pressed = |app: &mut VectorcraftApp| { + let nodes = crate::widgets::accessibility_tests::tree(|ui| { + ui.horizontal(|ui| { + bullets_button(app, ui, 28.0); + numbers_button(app, ui, 28.0); + }); + }); + let on = |label| nodes.iter().any(|n| n.label() == Some(label) && n.toggled() == Some(egui::accesskit::Toggled::True)); + (on("Bullets"), on("Numbered List")) + }; + assert_eq!(pressed(&mut app), (false, true)); + app.session.set_tool_option("selectAll", &json!(true)); + app.run("type.applyBullets", json!({})).unwrap(); + assert_eq!(pressed(&mut app), (true, false)); + app.session.set_tool_option("selectAll", &json!(true)); + app.run("type.applyNumbers", json!({})).unwrap(); + app.run("type.applyNumbers", json!({"number": "upperLetters"})).unwrap(); + assert_eq!(lists(&app).0, numbered(NumberStyle::UpperLetters)); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(lists(&app), (None, None)); +} + +/// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`) acts on the +/// paragraphs the Type tool's selection touches: their numbers become characters of the text +/// and their list goes, while the other paragraphs stay in the list, and the Type tool's caret +/// stays next to the characters it was next to. The menu item is there and is enabled while +/// type is selected or edited. +#[test] +fn convert_to_text_makes_the_selected_paragraphs_numbers_characters() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + crate::i18n::set_current(crate::i18n::Lang::EN); + let (mut app, id) = editing_text("One\nTwo"); + let text = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.plain_text(), text.para_at(0).list, text.para_at(1).list) + }; + app.session.set_tool_option("selectAll", &json!(true)); + app.run("type.applyNumbers", json!({})).unwrap(); + let numbered = Some(ParaList::numbered(NumberStyle::Arabic)); + assert_eq!(text(&app), ("One\nTwo".to_string(), numbered, numbered)); + assert!(crate::menus::enabled(&app, "type.convertListToText")); + select_range(&mut app, 5, 5); + app.run("type.convertListToText", json!({})).unwrap(); + assert_eq!(text(&app), ("One\n2.\tTwo".to_string(), numbered, None), "the caret's paragraph"); + let options = app.session.tool_options(); + assert_eq!((options["anchor"].as_u64(), options["caret"].as_u64()), (Some(8), Some(8)), "the caret stays after the T of Two"); + let typemenu = crate::menus::menu_tree().into_iter().find(|(title, _)| *title == "Type").map(|(_, items)| items).unwrap_or_default(); + let found = typemenu.iter().any(|item| { + matches!(item, crate::menus::Item::Sub("Bullets and Numbering", items) + if items.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Convert to Text", "type.convertListToText", _)))) + }); + assert!(found, "Type › Bullets and Numbering › Convert to Text"); +} + +/// Convert to Text in threaded type: when the converted paragraph breaks its lines at other +/// places and its text moves to the frame before, the Type tool's caret goes with the +/// character it was next to, and that frame becomes the selection when it was not selected. +#[test] +fn convert_to_text_keeps_the_caret_by_its_character_in_threaded_type() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); + let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); + let text = format!("One\nTwo\n{long}\nFour"); + let a = + app.run("text.create", json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("text.thread.create", json!({})).unwrap(); + app.run("text.setFormat", json!({"ids": [a, b], "list": "number"})).unwrap(); + let plain = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + assert!(plain(&app, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&app, b)); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + // The caret after the d of dog, at the start of the second frame. + app.session.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1})); + assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(b))); + app.run("type.convertListToText", json!({})).unwrap(); + let first = plain(&app, a); + assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}"); + let options = app.session.tool_options(); + let after_d = first.len() - "og. ".len(); + assert_eq!( + (options["editing"].as_u64(), options["anchor"].as_u64(), options["caret"].as_u64()), + (Some(a), Some(after_d as u64), Some(after_d as u64)), + "the caret is after the d of dog in the first frame" + ); + assert_eq!(app.session.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); + // The next command keeps the Type tool editing the first frame. + app.run("text.setFormat", json!({"ids": [a], "composer": "singleLine"})).unwrap(); + assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(a))); +} + +/// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation and +/// Justification options back to their defaults in every paragraph it acts on, so the paragraphs +/// save neither. +#[test] +fn reset_panel_resets_the_hyphenation_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "First\nSecond"})).unwrap()["id"].clone(); + app.run( + "text.setFormat", + json!({ + "hyphenate": true, "wordsLongerThan": 8, "afterFirst": 2, "beforeLast": 4, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, + "wordSpacing": [60, 90, 200], "letterSpacing": [-5, 0, 5], "glyphScaling": [95, 100, 105], "autoLeading": 150, "singleWordJustification": "alignLeft", "romanHangingPunctuation": true + }), + ) + .unwrap(); + let paras = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + [text.para_at(0).clone(), text.para_at(1).clone()] + }; + for para in paras(&app) { + assert!(para.hyphenate); + assert_ne!(para.hyphenation, HyphenationOptions::default()); + assert!(para.roman_hanging); + assert_ne!(para.justification, vectorcraft_doc::JustificationOptions::default()); + } + reset_panel(&mut app); + for para in paras(&app) { + assert!(!para.hyphenate); + assert_eq!(para.hyphenation, HyphenationOptions::default()); + assert_eq!(serde_json::to_value(¶).unwrap().get("hyphenation"), None, "saves no hyphenation options"); + assert!(!para.roman_hanging, "Roman Hanging Punctuation off"); + assert_eq!(serde_json::to_value(¶).unwrap().get("justification"), None, "saves no justification options"); + } +} From dd22bf4b1d93a59e953717fcd9731376cf0a97d5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:00 +0000 Subject: [PATCH 046/199] refactor(ui-egui): move panels/appearance.rs tests to panels/appearance/tests.rs --- crates/ui-egui/src/panels/appearance.rs | 542 +----------------- crates/ui-egui/src/panels/appearance/tests.rs | 539 +++++++++++++++++ 2 files changed, 540 insertions(+), 541 deletions(-) create mode 100644 crates/ui-egui/src/panels/appearance/tests.rs diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 04ca1bae6..9d8bc5937 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1023,544 +1023,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn opacity_labels() { - assert_eq!(opacity_text(1.0, BlendMode::Normal), "Default"); - assert_eq!(opacity_text(0.5, BlendMode::Normal), "50%"); - assert_eq!(opacity_text(0.5, BlendMode::Multiply), "50% Multiply"); - } - - #[test] - fn labels() { - assert_eq!(param_label("offsetX", true), "Offset X"); - assert_eq!(param_label("blur", true), "Blur"); - assert!(!effect_label("stylize.dropShadow").contains('…')); - assert_eq!(effect_label("path.outlineObject"), "Outline Object"); - assert_eq!(effect_label("x.unknownThing"), "unknownThing"); - // Only our own effects are interface labels; a plug-in's name is its own. - assert!(built_in_effect("stylize.dropShadow") && !built_in_effect("plugin.example.glow")); - assert_eq!(shown_effect_label("plugin.example.glow"), effect_label("plugin.example.glow")); - } - - /// A Drop Shadow's inline editor has Color and Darkness as radio buttons in its Shade row; a - /// click on Darkness sets the effect's `shade`. - #[test] - fn the_drop_shadow_editor_chooses_color_or_darkness() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - set_pstate(&ctx, "ap-fx-open-None-0", true); - let mut time = 0.0; - let mut run = |app: &mut VectorcraftApp, events: Vec| { - time += 0.1; - let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out - }; - run(&mut app, vec![]); - let out = run(&mut app, vec![]); - // The Shade row's Darkness, below the Darkness value's own row. - fn find(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) if t.galley.text() == "Darkness" => out.push(t.visual_bounding_rect().center()), - egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, out)), - _ => {} - } - } - let mut found = vec![]; - out.shapes.iter().for_each(|c| find(&c.shape, &mut found)); - assert_eq!(found.len(), 2, "{found:?}"); - let at = found.into_iter().max_by(|a, b| a.y.total_cmp(&b.y)).unwrap(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { - run(&mut app, events); - } - let n = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); - assert_eq!(n.appearance.effects[0].params["shade"], json!("darkness")); - } - - /// A rectangle with `effect` applied, its inline editor open, after a first frame in `ctx`. - fn editing(effect: &str) -> (VectorcraftApp, egui::Context, NodeId) { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - app.run("effect.apply", json!({"effect": effect})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - set_pstate(&ctx, "ap-fx-open-None-0", true); - panel_frame(&mut app, &ctx, vec![]); - (app, ctx, NodeId(id)) - } - - /// One frame of the panel in `ctx` with `events`: the texts drawn and where. - fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { - fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { - match s { - egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let time = ctx.input(|i| i.time) + 0.1; - let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - let mut v = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); - v - } - - /// Clicks the text `label` drawn in the panel (the last one drawn when there are several). - fn click_text(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { - let shown = panel_frame(app, ctx, vec![]); - let at = shown.iter().rev().find(|(s, _)| s == label).unwrap_or_else(|| panic!("no {label:?} in {shown:?}")).1.center(); - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { - panel_frame(app, ctx, events); - } - } - - fn shown_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { - panel_frame(app, ctx, vec![]).into_iter().map(|(s, _)| s).collect() - } - - /// Roughen's Points and Offset Path's Joins are dropdowns with the effect dialog's labels, and - /// a pick sends the dialog's value as one undo step. - #[test] - fn the_effect_editor_picks_choices_from_the_dialogs_dropdowns() { - for (effect, key, shown, pick, value) in - [("distort.roughen", "points", "Smooth", "Corner", "corner"), ("path.offsetPath", "joins", "Miter", "Round", "round")] - { - let (mut app, ctx, id) = editing(effect); - let texts = shown_texts(&mut app, &ctx); - assert!(texts.iter().any(|s| s == shown), "{effect}: {texts:?}"); - let undo = app.session.doc().unwrap().history.undo.len(); - click_text(&mut app, &ctx, shown); - click_text(&mut app, &ctx, pick); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect}"); - assert_eq!(st.history.undo.len(), undo + 1, "{effect}"); - assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect}"); - } - } - - /// The editor labels a parameter as the effect dialog does, without the ":". - #[test] - fn the_effect_editor_labels_parameters_as_the_dialog_does() { - for (effect, labels) in [ - ("pixelate.colorHalftone", &["Max. Radius", "Channel 1", "Channel 4"][..]), - ("video.deinterlace", &["Create New Fields by", "Duplication", "Eliminate", "Odd Fields"][..]), - ] { - let (mut app, ctx, _) = editing(effect); - let texts = shown_texts(&mut app, &ctx); - for label in labels { - assert!(texts.iter().any(|s| s == label), "{effect}: no {label:?} in {texts:?}"); - } - } - } - - /// A plug-in's choice is a dropdown of the plug-in's own values, also when the parameter is - /// called `mode` and its value names a blend mode. - #[test] - fn a_plug_in_choice_lists_the_plug_ins_values() { - use vectorcraft_plugins::wat as tpl; - let params = r#"{"mode":{"type":"choice","options":["normal","screen","dots"],"default":"screen"}}"#; - let src = tpl::module(&tpl::manifest("test.ui.inlinechoice", "effect", params), tpl::ECHO, "", 1); - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap(); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinechoice"})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - set_pstate(&ctx, "ap-fx-open-None-0", true); - panel_frame(&mut app, &ctx, vec![]); - click_text(&mut app, &ctx, "screen"); - click_text(&mut app, &ctx, "dots"); - let mode = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["mode"].clone(); - assert_eq!(mode(&app), json!("dots")); - click_text(&mut app, &ctx, "dots"); - click_text(&mut app, &ctx, "screen"); - assert_eq!(mode(&app), json!("screen")); - // Once the plug-in is removed, its value shows as it is, not as a blend mode. - app.run("plugin.remove", json!({"id": "test.ui.inlinechoice"})).unwrap(); - let texts = shown_texts(&mut app, &ctx); - assert!(texts.iter().any(|s| s == "screen") && !texts.iter().any(|s| s == "Screen"), "{texts:?}"); - } - - /// A plug-in effect's editor labels its parameters, shows their units and leaves out those whose - /// `visibleWhen` isn't met, as the effect's dialog does, and reads a typed number list the same - /// way. - #[test] - fn a_plug_in_effect_s_editor_follows_its_manifest() { - use vectorcraft_plugins::wat as tpl; - let params = r#"{"outline":{"type":"bool","default":true,"label":"Outline"}, - "spacing":{"type":"number","min":0.1,"max":10,"default":1.5,"label":"Spacing","unit":"mm","help":"Distance between the lines."}, - "angles":{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":4,"unit":"°"}, - "width":{"type":"number","min":0,"max":10,"default":0.5,"label":"Width","unit":"pt","visibleWhen":{"outline":[true]}}}"#; - let src = tpl::module(&tpl::manifest("test.ui.inlinekeys", "effect", params), tpl::ECHO, "", 1); - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap(); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinekeys"})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - set_pstate(&ctx, "ap-fx-open-None-0", true); - let texts = shown_texts(&mut app, &ctx); - for shown in ["Spacing", "1.5 mm", "Angles", "45, 135", "°", "Width", "0.5 pt"] { - assert!(texts.iter().any(|s| s == shown), "no {shown:?} in {texts:?}"); - } - assert!(!texts.iter().any(|s| s == "spacing" || s == "width"), "the labels replace the names: {texts:?}"); - // Without the outline, its width isn't shown. - app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"outline": false}})).unwrap(); - let texts = shown_texts(&mut app, &ctx); - assert!(!texts.iter().any(|s| s == "Width") && texts.iter().any(|s| s == "Spacing"), "{texts:?}"); - // A list typed in the field: separated by a semicolon and a space, and clamped. - click_text(&mut app, &ctx, "45, 135"); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - panel_frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); - panel_frame(&mut app, &ctx, vec![egui::Event::Text("10; 400 20".into())]); - panel_frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); - panel_frame(&mut app, &ctx, vec![]); - let angles = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["angles"].clone(); - assert_eq!(angles, json!([10.0, 360.0, 20.0])); - app.run("plugin.remove", json!({"id": "test.ui.inlinekeys"})).unwrap(); - } - - /// Glass with a loaded texture lists Loaded Texture in its Texture menu, as its dialog does. - #[test] - fn the_effect_editor_lists_a_loaded_glass_texture() { - let mut png = vec![]; - image::RgbaImage::from_pixel(4, 4, image::Rgba([90, 120, 150, 255])) - .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .unwrap(); - let stored = vectorcraft_engine::cmd::fileio::texture_image(&png).unwrap(); - let (mut app, ctx, id) = editing("distort.glass"); - app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"texture": "loaded", "textureImage": stored}})).unwrap(); - let texts = shown_texts(&mut app, &ctx); - assert!(texts.iter().any(|s| s == "Loaded Texture") && !texts.iter().any(|s| s == "loaded"), "{texts:?}"); - click_text(&mut app, &ctx, "Loaded Texture"); - click_text(&mut app, &ctx, "Frosted"); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params["texture"], json!("frosted")); - } - - /// The choices that their dialogs list in their own bodies are dropdowns with those dialogs' - /// labels too: Revolve's edge, Extrude & Bevel's bevel, Warp's orientation, Rasterize's - /// options and the channel of Levels and Curves. A pick sends the dialog's value as one undo - /// step. - #[test] - fn the_effect_editor_picks_choices_their_own_dialogs_list() { - for (effect, key, shown, pick, value) in [ - ("threeD.revolve", "edge", "Left", "Right", "right"), - ("threeD.extrude", "bevel", "None", "Rounded", "rounded"), - ("warp.arc", "orientation", "Horizontal", "Vertical", "vertical"), - ("rasterize", "colorModel", "RGB", "Grayscale", "grayscale"), - ("rasterize", "background", "Transparent", "White", "white"), - ("rasterize", "antiAlias", "Art Optimized", "Type Optimized", "type"), - ("adjust.levels", "channel", "RGB", "Red", "red"), - ("adjust.curves", "channel", "RGB", "Blue", "blue"), - ] { - let (mut app, ctx, id) = editing(effect); - let texts = shown_texts(&mut app, &ctx); - assert!(texts.iter().any(|s| s == shown), "{effect} {key}: {texts:?}"); - let undo = app.session.doc().unwrap().history.undo.len(); - click_text(&mut app, &ctx, shown); - click_text(&mut app, &ctx, pick); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect} {key}"); - assert_eq!(st.history.undo.len(), undo + 1, "{effect} {key}"); - assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect} {key}"); - } - } - - /// Those choices read as their dialogs show them in the UI language: in French, the channel's - /// RGB as the "effect" row translates it, and in Spanish, Extrude & Bevel's Rounded as the "3d" - /// row does, not the plain row. Rasterize names the document's color mode. - #[test] - fn the_effect_editor_shows_those_choices_as_their_dialogs_do() { - let fr = crate::i18n::Lang::from_code("fr").unwrap(); - crate::i18n::set_current(fr); - let (mut app, ctx, _) = editing("adjust.levels"); - let texts = shown_texts(&mut app, &ctx); - let rgb = crate::i18n::tr_ctx(fr, "effect", "RGB"); - assert!(texts.iter().any(|s| s == rgb), "{rgb}: {texts:?}"); - let es = crate::i18n::Lang::from_code("es").unwrap(); - crate::i18n::set_current(es); - let (mut app, ctx, _) = editing("threeD.extrude"); - app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"bevel": "rounded"}})).unwrap(); - let rounded = crate::i18n::tr_ctx(es, "3d", "Rounded"); - assert_ne!(rounded, crate::i18n::tr(es, "Rounded")); - assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == rounded), "{rounded}"); - crate::i18n::set_current(crate::i18n::Lang::EN); - let (mut app, ctx, _) = editing("rasterize"); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - let texts = shown_texts(&mut app, &ctx); - assert!(texts.iter().any(|s| s == "CMYK") && !texts.iter().any(|s| s == "document"), "{texts:?}"); - } - - #[test] - fn the_effect_editor_lists_earlier_parameters_in_their_current_form() { - // A Scribble saved with one `variation` lists it as its Path Overlap and Spacing variations. - let e = Effect { id: "stylize.scribble".into(), params: json!({"variation": 2, "angle": 270}), visible: true }; - let p = editor_params(&e); - assert!(!p.contains_key("variation"), "{p:?}"); - assert_eq!((&p["overlapVariation"], &p["spacingVariation"], &p["curvinessVariation"]), (&json!(2), &json!(2), &json!(0.0))); - assert_eq!(p["angle"].as_f64(), Some(-90.0)); - // Another effect lists its values over its defaults, as before. - let e = Effect { id: "distort.twist".into(), params: json!({"angle": 270}), visible: true }; - assert_eq!(editor_params(&e).get("angle"), Some(&json!(270))); - } - - /// A text field of the inline editor keeps what is typed from one frame to the next: a Drop - /// Shadow's color typed a character a frame and confirmed with Enter is set in one undo step. - /// Escape drops what is typed. - #[test] - fn the_effect_editor_keeps_text_typed_across_frames() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - set_pstate(&ctx, "ap-fx-open-None-0", true); - let mut time = 0.0; - let mut run = |app: &mut VectorcraftApp, events: Vec| { - time += 0.1; - let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); - out.textures_delta.clear(); - out - }; - // Every text drawn, with its center. - fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { - match s { - egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let drawn = |out: &egui::FullOutput| { - let mut v = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); - v - }; - run(&mut app, vec![]); - let shown = drawn(&run(&mut app, vec![])); - let at = shown.iter().find(|(t, _)| t == "#000000").map(|(_, p)| *p); - let Some(at) = at else { panic!("no color field: {shown:?}") }; - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { - run(&mut app, events); - } - run(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); - let undo = app.session.active().unwrap().history.undo.len(); - for ch in "#ff0000".chars() { - run(&mut app, vec![egui::Event::Text(ch.to_string())]); - } - let shown = drawn(&run(&mut app, vec![])); - assert!(shown.iter().any(|(t, _)| t == "#ff0000"), "the field shows what was typed: {shown:?}"); - run(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); - run(&mut app, vec![]); - let color = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["color"].clone(); - assert_eq!(color(&app), json!("#ff0000")); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); - // Escape drops what is typed. - for events in [vec![button(true)], vec![button(false)]] { - run(&mut app, events); - } - run(&mut app, vec![egui::Event::Text("0".into())]); - let shown = drawn(&run(&mut app, vec![])); - assert!(shown.iter().any(|(t, _)| t.len() == 8 && t.starts_with('#')), "a 0 typed into the color: {shown:?}"); - run(&mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]); - run(&mut app, vec![]); - assert_eq!(color(&app), json!("#ff0000")); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); - } - - #[test] - fn drops_pick_the_command() { - use vectorcraft_doc::{Appearance, FillLayer}; - let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true }; - let mut n = Node::path(NodeId(1), vectorcraft_geom::PathData::default(), Appearance::default_art()); - n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); // [F, S, F] - n.appearance.items[1].effects_mut().extend([fx("distort.twist"), fx("distort.roughen")]); - n.appearance.effects.push(fx("stylize.dropShadow")); - let slots = [ - Slot::Object { top: true }, - Slot::Item { index: 2, sub: false }, - Slot::Item { index: 1, sub: false }, - Slot::Effect(Some(1), 0), - Slot::Effect(Some(1), 1), - Slot::Item { index: 0, sub: false }, - Slot::Effect(None, 0), - Slot::Object { top: false }, - ]; - let rows: Vec<(Slot, Rect)> = - slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect(); - // The upper or lower half of row `i`. - let at = |i: usize, lower: bool| pos2(50.0, i as f32 * ROW + if lower { ROW * 0.75 } else { ROW * 0.25 }); - let drop = |what, p, copy| drop_command(&n, &rows, what, p, copy); - // Fill/stroke rows: moves, Alt copies to where it was dropped. - assert_eq!(drop(Dragged::Item(0), at(1, false), false), Some(("appearance.moveItem", json!({"from": 0, "to": 2})))); - assert_eq!(drop(Dragged::Item(0), at(0, false), true), Some(("appearance.duplicateItem", json!({"index": 0, "to": 3})))); - assert_eq!(drop(Dragged::Item(2), at(7, false), true), Some(("appearance.duplicateItem", json!({"index": 2, "to": 0})))); - assert_eq!(drop(Dragged::Item(1), at(3, false), false), None, "onto its own effect"); - // Effects: within the list (below the next one), into a fill, out onto the object. - let mv = |from: usize, fi: Value, to: usize, ti: Value, copy: bool| { - Some(("effect.move", json!({"from": from, "fromItem": fi, "to": to, "toItem": ti, "copy": copy}))) - }; - assert_eq!(drop(Dragged::Effect(Some(1), 0), at(4, true), false), mv(0, json!(1), 1, json!(1), false)); - assert_eq!(drop(Dragged::Effect(Some(1), 0), at(3, true), false), None, "dropped on itself"); - assert_eq!(drop(Dragged::Effect(Some(1), 1), at(5, false), true), mv(1, json!(1), 0, json!(0), true)); - assert_eq!(drop(Dragged::Effect(Some(1), 1), at(7, false), false), mv(1, json!(1), 1, Value::Null, false)); - assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false)); - assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack"); - } - - /// One headless frame of the panel: the texts drawn, top to bottom. - fn frame(app: &mut VectorcraftApp) -> Vec { - fn texts(s: &egui::Shape, out: &mut Vec<(f32, String)>) { - match s { - egui::Shape::Text(t) => out.push((t.pos.y, t.galley.text().to_string())), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - let mut v = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); - v.sort_by(|a, b| a.0.total_cmp(&b.0)); - v.into_iter().map(|(_, t)| t).collect() - } - - #[test] - fn groups_show_a_contents_row_and_type_a_characters_row_in_their_slot() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 100, "height": 100})); - let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); - let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone(); - run(&mut app, "select.set", json!({"ids": [a, b]})); - run(&mut app, "object.group", json!({})); - run(&mut app, "appearance.addFill", json!({})); - run(&mut app, "appearance.addStroke", json!({})); - let rows = - |t: Vec| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::>(); - assert_eq!(rows(frame(&mut app)), ["Stroke:", "Fill:", "Contents"]); - run(&mut app, "appearance.moveItem", json!({"from": "contents", "to": 1})); - assert_eq!(rows(frame(&mut app)), ["Stroke:", "Contents", "Fill:"]); - let t = run(&mut app, "text.create", json!({"x": 0, "y": 60, "text": "Hi"}))["id"].clone(); - run(&mut app, "select.set", json!({"ids": [t]})); - assert_eq!(rows(frame(&mut app)), ["Characters"]); - run(&mut app, "appearance.addFill", json!({})); - assert_eq!(rows(frame(&mut app)), ["Fill:", "Characters"]); - } - - #[test] - fn images_show_a_contents_row_with_new_fills_and_strokes_below_it() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 100, "height": 100})); - let rows = |t: Vec| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents"].contains(&t.as_str())).collect::>(); - let mut png = vec![]; - image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) - .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .unwrap(); - run(&mut app, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png)})); - assert_eq!(rows(frame(&mut app)), ["Contents"]); - run(&mut app, "appearance.addStroke", json!({})); - assert_eq!(rows(frame(&mut app)), ["Contents", "Stroke:"]); - } - - #[test] - fn drops_move_items_across_the_contents_row_and_the_row_itself() { - use vectorcraft_doc::FillLayer; - // A group [F0 | F1 F2]: the Contents row sits above item 0. - let mut n = Node::group(NodeId(1), vec![]); - n.appearance.items = (0..3).map(|_| AppearanceItem::Fill(FillLayer::new(Paint::None))).collect(); - n.appearance.set_contents_at(1); - let slots = [ - Slot::Object { top: true }, - Slot::Item { index: 2, sub: false }, - Slot::Item { index: 1, sub: false }, - Slot::Contents, - Slot::Item { index: 0, sub: false }, - Slot::Object { top: false }, - ]; - let rows: Vec<(Slot, Rect)> = - slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect(); - let at = |i: usize| pos2(50.0, i as f32 * ROW + ROW / 2.0); - let drop = |what, i| drop_command(&n, &rows, what, at(i), false); - // Onto the Contents row: from above it lands just below, from below just above. - assert_eq!(drop(Dragged::Item(2), 3), Some(("appearance.moveItem", json!({"from": 2, "to": 1, "contents": 2})))); - assert_eq!(drop(Dragged::Item(0), 3), Some(("appearance.moveItem", json!({"from": 0, "to": 0, "contents": 0})))); - // Onto the object row: above everything; under the stack: below everything. - assert_eq!(drop(Dragged::Item(0), 0), Some(("appearance.moveItem", json!({"from": 0, "to": 2, "contents": 0})))); - assert_eq!(drop(Dragged::Item(2), 5), Some(("appearance.moveItem", json!({"from": 2, "to": 0, "contents": 2})))); - // The row itself: above item 2, below item 0; nowhere new on itself. - assert_eq!(drop(Dragged::Contents, 1), Some(("appearance.moveItem", json!({"from": "contents", "to": 3})))); - assert_eq!(drop(Dragged::Contents, 4), Some(("appearance.moveItem", json!({"from": "contents", "to": 0})))); - assert_eq!(drop(Dragged::Contents, 3), None); - assert_eq!(drop(Dragged::Contents, 2), Some(("appearance.moveItem", json!({"from": "contents", "to": 2})))); - } - - #[test] - fn with_nothing_selected_it_lists_what_new_art_gets() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); - run(&mut app, "file.new", json!({"width": 100, "height": 100})); - run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); - run(&mut app, "appearance.addFill", json!({})); - run(&mut app, "effect.apply", json!({"effect": "stylize.dropShadow"})); - run(&mut app, "transparency.set", json!({"opacity": 50})); - run(&mut app, "graphicStyle.new", json!({"name": "Fancy"})); - run(&mut app, "select.none", json!({})); - let shown = frame(&mut app); - assert!(shown.contains(&"No Selection".to_string()) && shown.contains(&"Opacity: Default".to_string()), "{shown:?}"); - // A style clicked with nothing selected: the rows show what the next object gets. - run(&mut app, "graphicStyle.apply", json!({"name": "Fancy"})); - run(&mut app, "stroke.set", json!({"dash": [3, 3]})); - let shown = frame(&mut app); - let rows: Vec<&String> = shown.iter().filter(|t| ["Fill:", "Stroke:"].contains(&t.as_str())).collect(); - assert_eq!(rows, ["Fill:", "Stroke:", "Fill:"], "the added fill is on top"); - for want in ["No Selection: Fancy".to_string(), effect_label("stylize.dropShadow"), "Opacity: 50%".into(), "Dashed".into()] { - assert!(shown.contains(&want), "{want} in {shown:?}"); - } - } - - /// Both stroke rows expose named Weight inputs, and the drop-shadow effect names its Blur input and - /// Color radio button. - #[test] - fn inline_stroke_weight_and_effect_fields_are_named() { - use egui_kittest::kittest::Queryable; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); - app.run("appearance.addStroke", json!({})).unwrap(); - app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); - let mut first = true; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(360.0, 760.0)).with_accessibility_check(false).build_ui_state( - move |ui, app: &mut VectorcraftApp| { - if std::mem::replace(&mut first, false) { - crate::theme::install_fonts(ui.ctx()); - set_pstate(ui.ctx(), "ap-fx-open-None-0", true); - } - show(app, ui); - }, - app, - ); - h.run_steps(4); - assert_eq!(h.get_all_by_role_and_label(egui::accesskit::Role::TextInput, "Weight:").count(), 2, "both stroke rows are named"); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Blur"); - let _ = h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Color"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs new file mode 100644 index 000000000..982de7443 --- /dev/null +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -0,0 +1,539 @@ +use super::*; + +#[test] +fn opacity_labels() { + assert_eq!(opacity_text(1.0, BlendMode::Normal), "Default"); + assert_eq!(opacity_text(0.5, BlendMode::Normal), "50%"); + assert_eq!(opacity_text(0.5, BlendMode::Multiply), "50% Multiply"); +} + +#[test] +fn labels() { + assert_eq!(param_label("offsetX", true), "Offset X"); + assert_eq!(param_label("blur", true), "Blur"); + assert!(!effect_label("stylize.dropShadow").contains('…')); + assert_eq!(effect_label("path.outlineObject"), "Outline Object"); + assert_eq!(effect_label("x.unknownThing"), "unknownThing"); + // Only our own effects are interface labels; a plug-in's name is its own. + assert!(built_in_effect("stylize.dropShadow") && !built_in_effect("plugin.example.glow")); + assert_eq!(shown_effect_label("plugin.example.glow"), effect_label("plugin.example.glow")); +} + +/// A Drop Shadow's inline editor has Color and Darkness as radio buttons in its Shade row; a +/// click on Darkness sets the effect's `shade`. +#[test] +fn the_drop_shadow_editor_chooses_color_or_darkness() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events: Vec| { + time += 0.1; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out + }; + run(&mut app, vec![]); + let out = run(&mut app, vec![]); + // The Shade row's Darkness, below the Darkness value's own row. + fn find(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) if t.galley.text() == "Darkness" => out.push(t.visual_bounding_rect().center()), + egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, out)), + _ => {} + } + } + let mut found = vec![]; + out.shapes.iter().for_each(|c| find(&c.shape, &mut found)); + assert_eq!(found.len(), 2, "{found:?}"); + let at = found.into_iter().max_by(|a, b| a.y.total_cmp(&b.y)).unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + run(&mut app, events); + } + let n = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + assert_eq!(n.appearance.effects[0].params["shade"], json!("darkness")); +} + +/// A rectangle with `effect` applied, its inline editor open, after a first frame in `ctx`. +fn editing(effect: &str) -> (VectorcraftApp, egui::Context, NodeId) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": effect})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + panel_frame(&mut app, &ctx, vec![]); + (app, ctx, NodeId(id)) +} + +/// One frame of the panel in `ctx` with `events`: the texts drawn and where. +fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let time = ctx.input(|i| i.time) + 0.1; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v +} + +/// Clicks the text `label` drawn in the panel (the last one drawn when there are several). +fn click_text(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + let shown = panel_frame(app, ctx, vec![]); + let at = shown.iter().rev().find(|(s, _)| s == label).unwrap_or_else(|| panic!("no {label:?} in {shown:?}")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + panel_frame(app, ctx, events); + } +} + +fn shown_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + panel_frame(app, ctx, vec![]).into_iter().map(|(s, _)| s).collect() +} + +/// Roughen's Points and Offset Path's Joins are dropdowns with the effect dialog's labels, and +/// a pick sends the dialog's value as one undo step. +#[test] +fn the_effect_editor_picks_choices_from_the_dialogs_dropdowns() { + for (effect, key, shown, pick, value) in + [("distort.roughen", "points", "Smooth", "Corner", "corner"), ("path.offsetPath", "joins", "Miter", "Round", "round")] + { + let (mut app, ctx, id) = editing(effect); + let texts = shown_texts(&mut app, &ctx); + assert!(texts.iter().any(|s| s == shown), "{effect}: {texts:?}"); + let undo = app.session.doc().unwrap().history.undo.len(); + click_text(&mut app, &ctx, shown); + click_text(&mut app, &ctx, pick); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect}"); + assert_eq!(st.history.undo.len(), undo + 1, "{effect}"); + assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect}"); + } +} + +/// The editor labels a parameter as the effect dialog does, without the ":". +#[test] +fn the_effect_editor_labels_parameters_as_the_dialog_does() { + for (effect, labels) in [ + ("pixelate.colorHalftone", &["Max. Radius", "Channel 1", "Channel 4"][..]), + ("video.deinterlace", &["Create New Fields by", "Duplication", "Eliminate", "Odd Fields"][..]), + ] { + let (mut app, ctx, _) = editing(effect); + let texts = shown_texts(&mut app, &ctx); + for label in labels { + assert!(texts.iter().any(|s| s == label), "{effect}: no {label:?} in {texts:?}"); + } + } +} + +/// A plug-in's choice is a dropdown of the plug-in's own values, also when the parameter is +/// called `mode` and its value names a blend mode. +#[test] +fn a_plug_in_choice_lists_the_plug_ins_values() { + use vectorcraft_plugins::wat as tpl; + let params = r#"{"mode":{"type":"choice","options":["normal","screen","dots"],"default":"screen"}}"#; + let src = tpl::module(&tpl::manifest("test.ui.inlinechoice", "effect", params), tpl::ECHO, "", 1); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinechoice"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + panel_frame(&mut app, &ctx, vec![]); + click_text(&mut app, &ctx, "screen"); + click_text(&mut app, &ctx, "dots"); + let mode = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["mode"].clone(); + assert_eq!(mode(&app), json!("dots")); + click_text(&mut app, &ctx, "dots"); + click_text(&mut app, &ctx, "screen"); + assert_eq!(mode(&app), json!("screen")); + // Once the plug-in is removed, its value shows as it is, not as a blend mode. + app.run("plugin.remove", json!({"id": "test.ui.inlinechoice"})).unwrap(); + let texts = shown_texts(&mut app, &ctx); + assert!(texts.iter().any(|s| s == "screen") && !texts.iter().any(|s| s == "Screen"), "{texts:?}"); +} + +/// A plug-in effect's editor labels its parameters, shows their units and leaves out those whose +/// `visibleWhen` isn't met, as the effect's dialog does, and reads a typed number list the same +/// way. +#[test] +fn a_plug_in_effect_s_editor_follows_its_manifest() { + use vectorcraft_plugins::wat as tpl; + let params = r#"{"outline":{"type":"bool","default":true,"label":"Outline"}, + "spacing":{"type":"number","min":0.1,"max":10,"default":1.5,"label":"Spacing","unit":"mm","help":"Distance between the lines."}, + "angles":{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":4,"unit":"°"}, + "width":{"type":"number","min":0,"max":10,"default":0.5,"label":"Width","unit":"pt","visibleWhen":{"outline":[true]}}}"#; + let src = tpl::module(&tpl::manifest("test.ui.inlinekeys", "effect", params), tpl::ECHO, "", 1); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinekeys"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + let texts = shown_texts(&mut app, &ctx); + for shown in ["Spacing", "1.5 mm", "Angles", "45, 135", "°", "Width", "0.5 pt"] { + assert!(texts.iter().any(|s| s == shown), "no {shown:?} in {texts:?}"); + } + assert!(!texts.iter().any(|s| s == "spacing" || s == "width"), "the labels replace the names: {texts:?}"); + // Without the outline, its width isn't shown. + app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"outline": false}})).unwrap(); + let texts = shown_texts(&mut app, &ctx); + assert!(!texts.iter().any(|s| s == "Width") && texts.iter().any(|s| s == "Spacing"), "{texts:?}"); + // A list typed in the field: separated by a semicolon and a space, and clamped. + click_text(&mut app, &ctx, "45, 135"); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + panel_frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + panel_frame(&mut app, &ctx, vec![egui::Event::Text("10; 400 20".into())]); + panel_frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + panel_frame(&mut app, &ctx, vec![]); + let angles = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["angles"].clone(); + assert_eq!(angles, json!([10.0, 360.0, 20.0])); + app.run("plugin.remove", json!({"id": "test.ui.inlinekeys"})).unwrap(); +} + +/// Glass with a loaded texture lists Loaded Texture in its Texture menu, as its dialog does. +#[test] +fn the_effect_editor_lists_a_loaded_glass_texture() { + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 4, image::Rgba([90, 120, 150, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let stored = vectorcraft_engine::cmd::fileio::texture_image(&png).unwrap(); + let (mut app, ctx, id) = editing("distort.glass"); + app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"texture": "loaded", "textureImage": stored}})).unwrap(); + let texts = shown_texts(&mut app, &ctx); + assert!(texts.iter().any(|s| s == "Loaded Texture") && !texts.iter().any(|s| s == "loaded"), "{texts:?}"); + click_text(&mut app, &ctx, "Loaded Texture"); + click_text(&mut app, &ctx, "Frosted"); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params["texture"], json!("frosted")); +} + +/// The choices that their dialogs list in their own bodies are dropdowns with those dialogs' +/// labels too: Revolve's edge, Extrude & Bevel's bevel, Warp's orientation, Rasterize's +/// options and the channel of Levels and Curves. A pick sends the dialog's value as one undo +/// step. +#[test] +fn the_effect_editor_picks_choices_their_own_dialogs_list() { + for (effect, key, shown, pick, value) in [ + ("threeD.revolve", "edge", "Left", "Right", "right"), + ("threeD.extrude", "bevel", "None", "Rounded", "rounded"), + ("warp.arc", "orientation", "Horizontal", "Vertical", "vertical"), + ("rasterize", "colorModel", "RGB", "Grayscale", "grayscale"), + ("rasterize", "background", "Transparent", "White", "white"), + ("rasterize", "antiAlias", "Art Optimized", "Type Optimized", "type"), + ("adjust.levels", "channel", "RGB", "Red", "red"), + ("adjust.curves", "channel", "RGB", "Blue", "blue"), + ] { + let (mut app, ctx, id) = editing(effect); + let texts = shown_texts(&mut app, &ctx); + assert!(texts.iter().any(|s| s == shown), "{effect} {key}: {texts:?}"); + let undo = app.session.doc().unwrap().history.undo.len(); + click_text(&mut app, &ctx, shown); + click_text(&mut app, &ctx, pick); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect} {key}"); + assert_eq!(st.history.undo.len(), undo + 1, "{effect} {key}"); + assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect} {key}"); + } +} + +/// Those choices read as their dialogs show them in the UI language: in French, the channel's +/// RGB as the "effect" row translates it, and in Spanish, Extrude & Bevel's Rounded as the "3d" +/// row does, not the plain row. Rasterize names the document's color mode. +#[test] +fn the_effect_editor_shows_those_choices_as_their_dialogs_do() { + let fr = crate::i18n::Lang::from_code("fr").unwrap(); + crate::i18n::set_current(fr); + let (mut app, ctx, _) = editing("adjust.levels"); + let texts = shown_texts(&mut app, &ctx); + let rgb = crate::i18n::tr_ctx(fr, "effect", "RGB"); + assert!(texts.iter().any(|s| s == rgb), "{rgb}: {texts:?}"); + let es = crate::i18n::Lang::from_code("es").unwrap(); + crate::i18n::set_current(es); + let (mut app, ctx, _) = editing("threeD.extrude"); + app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"bevel": "rounded"}})).unwrap(); + let rounded = crate::i18n::tr_ctx(es, "3d", "Rounded"); + assert_ne!(rounded, crate::i18n::tr(es, "Rounded")); + assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == rounded), "{rounded}"); + crate::i18n::set_current(crate::i18n::Lang::EN); + let (mut app, ctx, _) = editing("rasterize"); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + let texts = shown_texts(&mut app, &ctx); + assert!(texts.iter().any(|s| s == "CMYK") && !texts.iter().any(|s| s == "document"), "{texts:?}"); +} + +#[test] +fn the_effect_editor_lists_earlier_parameters_in_their_current_form() { + // A Scribble saved with one `variation` lists it as its Path Overlap and Spacing variations. + let e = Effect { id: "stylize.scribble".into(), params: json!({"variation": 2, "angle": 270}), visible: true }; + let p = editor_params(&e); + assert!(!p.contains_key("variation"), "{p:?}"); + assert_eq!((&p["overlapVariation"], &p["spacingVariation"], &p["curvinessVariation"]), (&json!(2), &json!(2), &json!(0.0))); + assert_eq!(p["angle"].as_f64(), Some(-90.0)); + // Another effect lists its values over its defaults, as before. + let e = Effect { id: "distort.twist".into(), params: json!({"angle": 270}), visible: true }; + assert_eq!(editor_params(&e).get("angle"), Some(&json!(270))); +} + +/// A text field of the inline editor keeps what is typed from one frame to the next: a Drop +/// Shadow's color typed a character a frame and confirmed with Enter is set in one undo step. +/// Escape drops what is typed. +#[test] +fn the_effect_editor_keeps_text_typed_across_frames() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events: Vec| { + time += 0.1; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out + }; + // Every text drawn, with its center. + fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let drawn = |out: &egui::FullOutput| { + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + }; + run(&mut app, vec![]); + let shown = drawn(&run(&mut app, vec![])); + let at = shown.iter().find(|(t, _)| t == "#000000").map(|(_, p)| *p); + let Some(at) = at else { panic!("no color field: {shown:?}") }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + run(&mut app, events); + } + run(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + let undo = app.session.active().unwrap().history.undo.len(); + for ch in "#ff0000".chars() { + run(&mut app, vec![egui::Event::Text(ch.to_string())]); + } + let shown = drawn(&run(&mut app, vec![])); + assert!(shown.iter().any(|(t, _)| t == "#ff0000"), "the field shows what was typed: {shown:?}"); + run(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + run(&mut app, vec![]); + let color = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["color"].clone(); + assert_eq!(color(&app), json!("#ff0000")); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Escape drops what is typed. + for events in [vec![button(true)], vec![button(false)]] { + run(&mut app, events); + } + run(&mut app, vec![egui::Event::Text("0".into())]); + let shown = drawn(&run(&mut app, vec![])); + assert!(shown.iter().any(|(t, _)| t.len() == 8 && t.starts_with('#')), "a 0 typed into the color: {shown:?}"); + run(&mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]); + run(&mut app, vec![]); + assert_eq!(color(&app), json!("#ff0000")); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); +} + +#[test] +fn drops_pick_the_command() { + use vectorcraft_doc::{Appearance, FillLayer}; + let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true }; + let mut n = Node::path(NodeId(1), vectorcraft_geom::PathData::default(), Appearance::default_art()); + n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); // [F, S, F] + n.appearance.items[1].effects_mut().extend([fx("distort.twist"), fx("distort.roughen")]); + n.appearance.effects.push(fx("stylize.dropShadow")); + let slots = [ + Slot::Object { top: true }, + Slot::Item { index: 2, sub: false }, + Slot::Item { index: 1, sub: false }, + Slot::Effect(Some(1), 0), + Slot::Effect(Some(1), 1), + Slot::Item { index: 0, sub: false }, + Slot::Effect(None, 0), + Slot::Object { top: false }, + ]; + let rows: Vec<(Slot, Rect)> = + slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect(); + // The upper or lower half of row `i`. + let at = |i: usize, lower: bool| pos2(50.0, i as f32 * ROW + if lower { ROW * 0.75 } else { ROW * 0.25 }); + let drop = |what, p, copy| drop_command(&n, &rows, what, p, copy); + // Fill/stroke rows: moves, Alt copies to where it was dropped. + assert_eq!(drop(Dragged::Item(0), at(1, false), false), Some(("appearance.moveItem", json!({"from": 0, "to": 2})))); + assert_eq!(drop(Dragged::Item(0), at(0, false), true), Some(("appearance.duplicateItem", json!({"index": 0, "to": 3})))); + assert_eq!(drop(Dragged::Item(2), at(7, false), true), Some(("appearance.duplicateItem", json!({"index": 2, "to": 0})))); + assert_eq!(drop(Dragged::Item(1), at(3, false), false), None, "onto its own effect"); + // Effects: within the list (below the next one), into a fill, out onto the object. + let mv = |from: usize, fi: Value, to: usize, ti: Value, copy: bool| { + Some(("effect.move", json!({"from": from, "fromItem": fi, "to": to, "toItem": ti, "copy": copy}))) + }; + assert_eq!(drop(Dragged::Effect(Some(1), 0), at(4, true), false), mv(0, json!(1), 1, json!(1), false)); + assert_eq!(drop(Dragged::Effect(Some(1), 0), at(3, true), false), None, "dropped on itself"); + assert_eq!(drop(Dragged::Effect(Some(1), 1), at(5, false), true), mv(1, json!(1), 0, json!(0), true)); + assert_eq!(drop(Dragged::Effect(Some(1), 1), at(7, false), false), mv(1, json!(1), 1, Value::Null, false)); + assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false)); + assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack"); +} + +/// One headless frame of the panel: the texts drawn, top to bottom. +fn frame(app: &mut VectorcraftApp) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec<(f32, String)>) { + match s { + egui::Shape::Text(t) => out.push((t.pos.y, t.galley.text().to_string())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + v.into_iter().map(|(_, t)| t).collect() +} + +#[test] +fn groups_show_a_contents_row_and_type_a_characters_row_in_their_slot() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); + let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [a, b]})); + run(&mut app, "object.group", json!({})); + run(&mut app, "appearance.addFill", json!({})); + run(&mut app, "appearance.addStroke", json!({})); + let rows = + |t: Vec| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::>(); + assert_eq!(rows(frame(&mut app)), ["Stroke:", "Fill:", "Contents"]); + run(&mut app, "appearance.moveItem", json!({"from": "contents", "to": 1})); + assert_eq!(rows(frame(&mut app)), ["Stroke:", "Contents", "Fill:"]); + let t = run(&mut app, "text.create", json!({"x": 0, "y": 60, "text": "Hi"}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [t]})); + assert_eq!(rows(frame(&mut app)), ["Characters"]); + run(&mut app, "appearance.addFill", json!({})); + assert_eq!(rows(frame(&mut app)), ["Fill:", "Characters"]); +} + +#[test] +fn images_show_a_contents_row_with_new_fills_and_strokes_below_it() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let rows = |t: Vec| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents"].contains(&t.as_str())).collect::>(); + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + run(&mut app, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png)})); + assert_eq!(rows(frame(&mut app)), ["Contents"]); + run(&mut app, "appearance.addStroke", json!({})); + assert_eq!(rows(frame(&mut app)), ["Contents", "Stroke:"]); +} + +#[test] +fn drops_move_items_across_the_contents_row_and_the_row_itself() { + use vectorcraft_doc::FillLayer; + // A group [F0 | F1 F2]: the Contents row sits above item 0. + let mut n = Node::group(NodeId(1), vec![]); + n.appearance.items = (0..3).map(|_| AppearanceItem::Fill(FillLayer::new(Paint::None))).collect(); + n.appearance.set_contents_at(1); + let slots = [ + Slot::Object { top: true }, + Slot::Item { index: 2, sub: false }, + Slot::Item { index: 1, sub: false }, + Slot::Contents, + Slot::Item { index: 0, sub: false }, + Slot::Object { top: false }, + ]; + let rows: Vec<(Slot, Rect)> = + slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect(); + let at = |i: usize| pos2(50.0, i as f32 * ROW + ROW / 2.0); + let drop = |what, i| drop_command(&n, &rows, what, at(i), false); + // Onto the Contents row: from above it lands just below, from below just above. + assert_eq!(drop(Dragged::Item(2), 3), Some(("appearance.moveItem", json!({"from": 2, "to": 1, "contents": 2})))); + assert_eq!(drop(Dragged::Item(0), 3), Some(("appearance.moveItem", json!({"from": 0, "to": 0, "contents": 0})))); + // Onto the object row: above everything; under the stack: below everything. + assert_eq!(drop(Dragged::Item(0), 0), Some(("appearance.moveItem", json!({"from": 0, "to": 2, "contents": 0})))); + assert_eq!(drop(Dragged::Item(2), 5), Some(("appearance.moveItem", json!({"from": 2, "to": 0, "contents": 2})))); + // The row itself: above item 2, below item 0; nowhere new on itself. + assert_eq!(drop(Dragged::Contents, 1), Some(("appearance.moveItem", json!({"from": "contents", "to": 3})))); + assert_eq!(drop(Dragged::Contents, 4), Some(("appearance.moveItem", json!({"from": "contents", "to": 0})))); + assert_eq!(drop(Dragged::Contents, 3), None); + assert_eq!(drop(Dragged::Contents, 2), Some(("appearance.moveItem", json!({"from": "contents", "to": 2})))); +} + +#[test] +fn with_nothing_selected_it_lists_what_new_art_gets() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); + run(&mut app, "appearance.addFill", json!({})); + run(&mut app, "effect.apply", json!({"effect": "stylize.dropShadow"})); + run(&mut app, "transparency.set", json!({"opacity": 50})); + run(&mut app, "graphicStyle.new", json!({"name": "Fancy"})); + run(&mut app, "select.none", json!({})); + let shown = frame(&mut app); + assert!(shown.contains(&"No Selection".to_string()) && shown.contains(&"Opacity: Default".to_string()), "{shown:?}"); + // A style clicked with nothing selected: the rows show what the next object gets. + run(&mut app, "graphicStyle.apply", json!({"name": "Fancy"})); + run(&mut app, "stroke.set", json!({"dash": [3, 3]})); + let shown = frame(&mut app); + let rows: Vec<&String> = shown.iter().filter(|t| ["Fill:", "Stroke:"].contains(&t.as_str())).collect(); + assert_eq!(rows, ["Fill:", "Stroke:", "Fill:"], "the added fill is on top"); + for want in ["No Selection: Fancy".to_string(), effect_label("stylize.dropShadow"), "Opacity: 50%".into(), "Dashed".into()] { + assert!(shown.contains(&want), "{want} in {shown:?}"); + } +} + +/// Both stroke rows expose named Weight inputs, and the drop-shadow effect names its Blur input and +/// Color radio button. +#[test] +fn inline_stroke_weight_and_effect_fields_are_named() { + use egui_kittest::kittest::Queryable; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.run("appearance.addStroke", json!({})).unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + let mut first = true; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(360.0, 760.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if std::mem::replace(&mut first, false) { + crate::theme::install_fonts(ui.ctx()); + set_pstate(ui.ctx(), "ap-fx-open-None-0", true); + } + show(app, ui); + }, + app, + ); + h.run_steps(4); + assert_eq!(h.get_all_by_role_and_label(egui::accesskit::Role::TextInput, "Weight:").count(), 2, "both stroke rows are named"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Blur"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Color"); +} From 43d205ce8abf563780ab45aa7497888dcbfbcb77 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:00 +0000 Subject: [PATCH 047/199] refactor(tools): move draw2/anchor.rs tests to draw2/anchor/tests.rs --- crates/tools/src/draw2/anchor.rs | 535 +------------------------ crates/tools/src/draw2/anchor/tests.rs | 532 ++++++++++++++++++++++++ 2 files changed, 533 insertions(+), 534 deletions(-) create mode 100644 crates/tools/src/draw2/anchor/tests.rs diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index f1950d8f3..bf3878abf 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -581,537 +581,4 @@ pub(crate) fn press_cursor(cx: &ToolContext, p: Point) -> Cursor { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - use vectorcraft_geom::{Anchor, ParamCurve}; - - /// The Anchor Point tool has its own caret cursor, not the Pen's start-a-path one, and over a - /// segment the Reshape Segment cursor, kept while the segment is dragged. - #[test] - fn anchor_point_tool_shows_its_caret_and_reshape_over_segments() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = AnchorTool::new("anchorPoint"); - let none = Mods::default(); - for at in [Point::new(20.0, 20.0), Point::new(200.0, 100.0)] { - assert_eq!(t.cursor(&cx, at, none), Cursor::AnchorPoint, "{at:?}"); - } - assert_eq!(t.cursor(&cx, Point::new(150.0, 101.0), none), Cursor::ReshapeSegment); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 140.0)); - assert_eq!(t.cursor(&cx, Point::new(150.0, 140.0), none), Cursor::ReshapeSegment); - } - - /// Selected three-anchor blend spine; the end anchors stay pinned to the - /// centres of the two key objects. - fn blend_spine_doc(smooth: bool) -> (vectorcraft_doc::Document, NodeId, Selection) { - let (mut d, _) = doc_with_rect(); - let layer = d.layers[0].id; - let (id, first, last) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let key = |id, x| { - let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); - std::sync::Arc::new(vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())) - }; - let mut path = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(160.0, 310.0), Point::new(210.0, 310.0)], false); - if smooth { - path.anchors[1] = Anchor::smooth(Point::new(160.0, 310.0), Point::new(190.0, 330.0)); - } - let spec = - vectorcraft_doc::live::BlendSpec { spine: Some(vectorcraft_geom::PathData::single(path)), key_anchors: vec![0, 2], ..Default::default() }; - let blend = vectorcraft_doc::Node::new( - id, - vectorcraft_doc::NodeKind::Blend { children: vec![key(first, 100.0), key(last, 200.0)], spec: Box::new(spec) }, - ); - d.insert(Some(layer), 1, blend).unwrap(); - let mut selected = Selection::default(); - selected.set([id]); - (d, id, selected) - } - - /// Owner priority applies to anchors, visible handles and segments alike, regardless - /// of selection insertion order; cursor and emitted command must agree. - #[test] - fn overlapping_path_and_blend_follow_selection_then_stacking() { - use vectorcraft_doc::{Appearance, Node}; - use vectorcraft_geom::{PathData, SubPath}; - for path_on_top in [false, true] { - for path_selected in [false, true] { - for blend_first in [false, true] { - let (d, blend, sel) = blend_spine_doc(true); - let paint = paint(); - let spine = crate::direct::spines(&cx(&d, &sel, &paint)).pop().unwrap().1; - let curve = spine.subpaths[0].segment(1).eval(0.5); - // Point, handle and segment overlaps, including different cursor kinds. - for (at, path_is_segment) in [ - (Point::new(160.0, 310.0), false), - (Point::new(190.0, 330.0), false), - (Point::new(160.0, 310.0), true), - (Point::new(190.0, 330.0), true), - (curve, false), - (curve, true), - ] { - let (mut d, mut sel) = (d.clone(), sel.clone()); - let path = d.alloc_id(); - let points = if path_is_segment { - vec![at - vectorcraft_geom::Vec2::new(0.0, 30.0), at + vectorcraft_geom::Vec2::new(0.0, 30.0)] - } else { - vec![at, at + vectorcraft_geom::Vec2::new(0.0, 60.0)] - }; - let node = Node::path(path, PathData::single(SubPath::polyline(&points, false)), Appearance::default_art()); - d.insert(Some(d.layers[0].id), if path_on_top { 2 } else { 1 }, node).unwrap(); - if path_selected { - sel.set(if blend_first { vec![blend, path] } else { vec![path, blend] }); - } - let c = cx(&d, &sel, &paint); - let path_wins = path_selected && path_on_top; - let expected_id = if path_wins { path } else { blend }; - let spine = crate::direct::spines(&c).into_iter().find(|(id, _)| *id == blend).unwrap().1; - let spine_segment = AnchorTool::spine_point(&spine, at, c.point_tol()).is_none() - && AnchorTool::spine_handle(&c, &spine, at, c.point_tol()).is_none(); - let reshape = if path_wins { path_is_segment } else { spine_segment }; - let mut tool = AnchorTool::new("anchorPoint"); - assert_eq!(tool.cursor(&c, at, Mods::default()), if reshape { Cursor::ReshapeSegment } else { Cursor::AnchorPoint }); - tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); - let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 10.0, at.y + 20.0)); - let Some(Action::Preview(cmd, args)) = actions.get(1) else { panic!("missing preview: {actions:?}") }; - assert_eq!( - args["id"], expected_id.0, - "top={path_on_top}, selected={path_selected}, blend_first={blend_first}, at={at:?}, segment={path_is_segment}: {cmd}" - ); - assert_eq!(cmd.starts_with("path."), path_wins); - assert_eq!(cmd.ends_with("reshapeSegment"), reshape); - } - } - } - } - } - - #[test] - fn anchor_point_converts_a_blend_spine_point_before_reshaping_its_segment() { - let (d, id, sel) = blend_spine_doc(false); - let p = paint(); - let c = cx(&d, &sel, &p); - let mut tool = AnchorTool::new("anchorPoint"); - assert!(tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 160.0, 310.0)).is_empty()); - let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 340.0)); - assert_eq!(actions.first(), Some(&Action::Begin("Convert Anchor Point".into()))); - assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args)) - if cmd == "object.blend.spine.convertAnchor" - && args["id"] == id.0 && args["anchor"] == 1 - && args["to"] == "smooth" && args["y"] == 340.0)); - assert_eq!(tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 340.0)), vec![Action::Commit]); - } - - #[test] - fn shift_reshapes_a_blend_spine_segment_perpendicular() { - let (d, id, sel) = blend_spine_doc(true); - let p = paint(); - let c = cx(&d, &sel, &p); - let spine = crate::direct::spines(&c).pop().unwrap().1; - let at = spine.subpaths[0].segment(1).eval(0.5); - for shift in [false, true] { - let mut tool = AnchorTool::new("anchorPoint"); - tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); - let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x, at.y + 20.0).with_mods(Mods { shift, ..Mods::default() })); - assert!( - matches!(actions.get(1), Some(Action::Preview(cmd, args)) - if cmd == "object.blend.spine.reshapeSegment" && args["id"] == id.0 - && args["perpendicular"] == shift), - "{actions:?}" - ); - } - } - - #[test] - fn anchor_point_keeps_blend_spine_handles_visible_and_editable_after_switch() { - let (d, id, sel) = blend_spine_doc(true); - let p = paint(); - let c = cx(&d, &sel, &p); - // A new tool instance models switching from Direct Selection. - let mut tool = AnchorTool::new("anchorPoint"); - let overlays = tool.overlays(&c); - assert!(overlays.iter().any(|o| matches!(o, Overlay::Handle { p, .. } if *p == Point::new(190.0, 330.0)))); - assert!(overlays.iter().any(|o| matches!(o, Overlay::Handle { p, .. } if *p == Point::new(130.0, 290.0)))); - tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 190.0, 330.0)); - let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 190.0, 350.0)); - assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args)) - if cmd == "object.blend.spine.moveAnchor" - && args["id"] == id.0 && args["anchor"] == 1 && args["handle"] == "out" - && args["independent"] == true)); - assert_eq!(tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 190.0, 350.0)), vec![Action::Commit]); - - tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 160.0, 310.0)); - assert_eq!( - tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 310.0)), - vec![Action::Exec("object.blend.spine.convertAnchor".into(), json!({"id": id.0, "anchor": 1, "to": "corner"}),)], - ); - } - - /// P1-1: a handle 2pt from its anchor must not intercept a click at the anchor centre; - /// dragging the anchor converts it, while a click on an eligible handle still retracts it. - #[test] - fn anchor_point_prefers_a_blend_spine_anchor_over_its_short_handle() { - let (mut d, _) = doc_with_rect(); - let layer = d.layers[0].id; - let (id, first, last) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let key = |id, x| { - let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); - std::sync::Arc::new(vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())) - }; - let mut path = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(160.0, 310.0), Point::new(210.0, 310.0)], false); - path.anchors[1] = Anchor::smooth(Point::new(160.0, 310.0), Point::new(162.0, 310.0)); - let spec = - vectorcraft_doc::live::BlendSpec { spine: Some(vectorcraft_geom::PathData::single(path)), key_anchors: vec![0, 2], ..Default::default() }; - let blend = vectorcraft_doc::Node::new( - id, - vectorcraft_doc::NodeKind::Blend { children: vec![key(first, 100.0), key(last, 200.0)], spec: Box::new(spec) }, - ); - d.insert(Some(layer), 1, blend).unwrap(); - let mut selected = Selection::default(); - selected.set([id]); - let p = paint(); - let c = cx(&d, &selected, &p); - // Clicking the anchor centre converts the smooth point to a corner. - let mut tool = AnchorTool::new("anchorPoint"); - tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 160.0, 310.0)); - assert_eq!( - tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 310.0)), - vec![Action::Exec("object.blend.spine.convertAnchor".into(), json!({"id": id.0, "anchor": 1, "to": "corner"}),)], - ); - // Clicking the eligible handle end retracts only that handle. - let mut tool = AnchorTool::new("anchorPoint"); - tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 162.0, 310.0)); - assert_eq!( - tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 162.0, 310.0)), - vec![Action::Exec( - "object.blend.spine.moveAnchor".into(), - json!({"id": id.0, "anchor": 1, "handle": "out", "x": 160.0, "y": 310.0, "independent": true}), - )], - ); - } - - /// P2-2: with Show handles when multiple anchors are selected off, spine handles hide - /// and cannot intercept pointer input, as for ordinary paths. - #[test] - fn anchor_point_hides_blend_spine_handles_when_multiple_is_off() { - let (d, _, sel) = blend_spine_doc(true); - let p = paint(); - let single = ToolContext { handles_multiple: false, ..cx(&d, &sel, &p) }; - let tool = AnchorTool::new("anchorPoint"); - assert!(tool.overlays(&single).iter().all(|o| !matches!(o, Overlay::Handle { .. }))); - // The hidden handle endpoint is neither an editable handle nor on the curve. - let mut tool = AnchorTool::new("anchorPoint"); - tool.pointer(&single, &PointerEvent::new(PointerKind::Down, 190.0, 330.0)); - assert!(matches!(tool.state, State::Idle)); - assert!(tool.pointer(&single, &PointerEvent::new(PointerKind::Up, 190.0, 330.0)).is_empty()); - // A drag on the curve away from anchors and handles reshapes the spine segment. - let (_, spine) = crate::direct::spines(&single).pop().unwrap(); - let at = spine.subpaths[0].segment(1).eval(0.5); - assert!(AnchorTool::spine_handle(&single, &spine, at, single.point_tol()).is_none()); - assert!(AnchorTool::spine_point(&spine, at, single.point_tol()).is_none()); - let mut tool = AnchorTool::new("anchorPoint"); - assert_eq!(tool.cursor(&single, at, Mods::default()), Cursor::ReshapeSegment); - tool.pointer(&single, &PointerEvent::new(PointerKind::Down, at.x, at.y)); - let actions = tool.pointer(&single, &PointerEvent::new(PointerKind::Drag, at.x, at.y + 20.0)); - assert!(matches!(actions.get(1), Some(Action::Preview(cmd, _)) if cmd == "object.blend.spine.reshapeSegment")); - } - - /// Hover and click agree: the caret shows where a press converts a spine point or - /// edits a spine handle, and ReshapeSegment only where a press reshapes a segment. - #[test] - fn anchor_point_cursor_matches_spine_press_precedence() { - let (d, _, sel) = blend_spine_doc(true); - let p = paint(); - let c = cx(&d, &sel, &p); - let tool = AnchorTool::new("anchorPoint"); - let none = Mods::default(); - assert_eq!(tool.cursor(&c, Point::new(160.0, 310.0), none), Cursor::AnchorPoint); - assert_eq!(tool.cursor(&c, Point::new(190.0, 330.0), none), Cursor::AnchorPoint); - let spine = crate::direct::spines(&c).pop().unwrap().1; - assert_eq!(tool.cursor(&c, spine.subpaths[0].segment(1).eval(0.5), none), Cursor::ReshapeSegment); - } - - /// P1-2: Shift-dragging a blend spine handle constrains it to 45-degree steps round - /// its anchor, as for ordinary Anchor Point handles. - #[test] - fn anchor_point_shift_constrains_a_blend_spine_handle() { - let (d, id, sel) = blend_spine_doc(true); - let p = paint(); - let c = cx(&d, &sel, &p); - let shift = Mods { shift: true, ..Mods::default() }; - let mut tool = AnchorTool::new("anchorPoint"); - tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 190.0, 330.0)); - let drag = PointerEvent { kind: PointerKind::Drag, pos: Point::new(200.0, 340.0), mods: shift, pressure: 1.0 }; - let actions = tool.pointer(&c, &drag); - // The pointer (40, 30) keeps its length of 50; Shift snaps it to 45 degrees. - assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args)) - if cmd == "object.blend.spine.moveAnchor" - && args["id"] == id.0 && args["anchor"] == 1 && args["handle"] == "out" - && args["independent"] == true - && (args["x"].as_f64().unwrap() - 160.0 - 50.0 * std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6 - && (args["y"].as_f64().unwrap() - 310.0 - 50.0 * std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6)); - assert_eq!(tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 340.0)), vec![Action::Commit]); - } - - #[test] - fn add_and_delete_anchor_clicks() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = AnchorTool::new("addAnchor"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); - assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0)); - assert!((t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 20.0, 20.0))).is_empty()); - let mut t = AnchorTool::new("deleteAnchor"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 101.0)); - assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]); - } - - #[test] - fn scissors_splits_at_segment() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = AnchorTool::new("scissors"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 150.0)); - assert!( - matches!(&a[0], Action::Exec(c, v) if c == "path.split" && v["id"] == id.0 && v["segment"] == 1 && (v["t"].as_f64().unwrap() - 0.5).abs() < 1e-6) - ); - } - - /// The Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchors and - /// segments only on the art a click can pick: not on a Shaper Group's original shapes unless - /// they are isolated (Construction mode), and in isolation mode only on the isolated object. - #[test] - fn anchor_tools_leave_out_shaper_sources_and_art_outside_isolation() { - let (d, _, sources, face) = doc_with_shaper_group(); - let rect = d.layers[0].children().unwrap()[0].id; - let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id; - let (s, p) = (Selection::default(), paint()); - // The path the first command of a press with `tool` at (x, y) acts on. - let picked = |isolation: Option, tool: &str, x: f64, y: f64| { - let cx = ToolContext { isolation, ..cx(&d, &s, &p) }; - let mut t = AnchorTool::new(tool); - let mut a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); - a.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y - 10.0))); - a.iter().find_map(|a| match a { - Action::Exec(_, v) | Action::Preview(_, v) => v["id"].as_u64().map(NodeId), - _ => None, - }) - }; - // The second square's top right corner and its right side, which only its original shape - // has. - for (tool, x, y) in [("deleteAnchor", 150.0, 300.0), ("anchorPoint", 150.0, 300.0), ("addAnchor", 150.0, 350.0), ("scissors", 150.0, 350.0)] { - assert_eq!(picked(None, tool, x, y), None, "{tool}"); - assert_eq!(picked(Some(sources), tool, x, y), Some(b), "{tool}"); - } - // The face's top left corner, outside the isolated rectangle. - assert_eq!(picked(None, "deleteAnchor", 0.0, 300.0), Some(face)); - assert_eq!(picked(Some(rect), "deleteAnchor", 0.0, 300.0), None); - assert_eq!(picked(Some(rect), "deleteAnchor", 100.0, 100.0), Some(rect)); - } - - #[test] - fn anchor_point_drag_pulls_handles_click_converts() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = AnchorTool::new("anchorPoint"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 90.0)); - assert_eq!(a[0], Action::Begin("Convert Anchor Point".into())); - assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.convertAnchor" && v["to"] == "smooth" && v["id"] == id.0)); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 90.0)), vec![Action::Commit]); - // Clicking a corner without handles does nothing. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)).is_empty()); - // Dragging a segment reshapes it. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 200.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0)); - assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["dy"] == 30.0)); - } - - /// Shift held while a segment is dragged keeps its handles at right angles to the line between - /// its anchors (`perpendicular`). - #[test] - fn shift_reshapes_a_segment_perpendicular() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Mods::default() }; - for (mods, perpendicular) in [(Mods::default(), false), (shift, true)] { - let mut t = AnchorTool::new("anchorPoint"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 200.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.0, 230.0).with_mods(mods)); - assert!( - matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["perpendicular"] == perpendicular), - "{a:?}" - ); - } - } - - /// #494: the Anchor Point tool picks within Selection & Anchor Display → Tolerance, and drags - /// only the handles shown. - #[test] - fn anchor_point_picks_within_the_tolerance_and_drags_shown_handles() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); - sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 300.0)); - let path = vectorcraft_geom::PathData::single(sp); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let press = |c: &ToolContext, x: f64, y: f64| { - let mut t = AnchorTool::new("anchorPoint"); - t.pointer(c, &PointerEvent::new(PointerKind::Down, x, y)); - t.pointer(c, &PointerEvent::new(PointerKind::Drag, x + 10.0, y + 30.0)) - }; - let dragged = |a: Vec| match a.as_slice() { - [Action::Begin(_), Action::Preview(c, _)] => c.clone(), - _ => String::new(), - }; - // The segment 6 px off: picked with an 8 px tolerance, not with the default 3 px. - assert_eq!(dragged(press(&cx(&d, &s, &p), 150.0, 306.0)), ""); - let wide = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) }; - assert_eq!(dragged(press(&wide, 150.0, 306.0)), "path.reshapeSegment"); - // The middle anchor's handle drags; with Show handles when multiple anchors are selected - // off it's hidden (three anchors selected) and the press finds the segment under it. - assert_eq!(dragged(press(&cx(&d, &s, &p), 240.0, 301.0)), "path.setHandle"); - let single = ToolContext { handles_multiple: false, ..cx(&d, &s, &p) }; - assert_eq!(dragged(press(&single, 240.0, 301.0)), "path.reshapeSegment"); - } - - /// #2267: clicking a handle end retracts that handle only; the anchor becomes a corner and - /// keeps the other handle, as Illustrator's Anchor Point tool does. - #[test] - fn anchor_point_click_on_a_handle_retracts_only_that_handle() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); - sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0)); - let path = vectorcraft_geom::PathData::single(sp); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let retract = |which: &str| { - vec![Action::Exec( - "path.setHandle".into(), - json!({"id": id.0, "subpath": 0, "anchor": 1, "which": which, "x": 200.0, "y": 300.0, "independent": true}), - )] - }; - let mut t = AnchorTool::new("anchorPoint"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 240.0, 320.0)); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 240.0, 320.0)), retract("out")); - // A hand that shakes by a pixel still clicks; the incoming handle sits opposite, at (160, 280). - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 280.0)); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.5, 280.5)).is_empty()); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.5, 280.5)), retract("in")); - // The command leaves the other handle where it was. - let mut a = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0)); - a.set_handle(true, a.p, true); - assert!(!a.has_out()); - assert_eq!(a.h_in, Point::new(160.0, 280.0)); - } - - /// Shift held at the press on an anchor moves it, handles and all, instead of pulling out - /// handles; Shift still held keeps the move at 45° steps, released frees it. A Shift-click - /// that doesn't move leaves the anchor as it was. - #[test] - fn anchor_point_shift_drag_moves_the_anchor() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); - sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0)); - let path = vectorcraft_geom::PathData::single(sp); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Mods::default() }; - let moved = |x: f64, y: f64| Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "x": x, "y": y})); - let mut t = AnchorTool::new("anchorPoint"); - // Pressed a pixel off the anchor: it keeps that offset to the pointer. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 201.0, 301.0).with_mods(shift)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 231.0, 341.0)); - assert_eq!(a, vec![Action::Begin("Move".into()), moved(230.0, 340.0)]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 241.0, 306.0).with_mods(shift)); - assert!( - matches!(&a[..], [Action::Preview(c, v)] if c == "path.moveAnchor" && v["y"].as_f64().is_some_and(|y| (y - 300.0).abs() < 1e-9)), - "{a:?}" - ); - assert_eq!(t.cursor(&cx, Point::new(241.0, 306.0), shift), Cursor::Arrow); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 241.0, 306.0)), vec![Action::Commit]); - // Without Shift the same press still pulls out handles. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0)); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 340.0)); - // A Shift-click that doesn't move does nothing (no conversion to a corner). - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(shift)); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 300.0).with_mods(shift)).is_empty()); - } - - /// #3694: Space held mid-drag on a convert moves the anchor, handles and all. - #[test] - fn anchor_point_space_mid_drag_moves_the_anchor() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, vectorcraft_geom::PathData::single(sp), vectorcraft_doc::Appearance::default_art())) - .unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let space = Mods { space: true, ..Mods::default() }; - let moved = |x: f64, y: f64| Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "x": x, "y": y})); - let mut t = AnchorTool::new("anchorPoint"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0)); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 240.0, 350.0).with_mods(space)); - assert_eq!(a, vec![moved(200.0, 300.0)]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 360.0).with_mods(space)); - assert_eq!(a, vec![moved(210.0, 310.0)]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 370.0)); - assert!(matches!(&a[..], [Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}"); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 370.0)), vec![Action::Commit]); - } - - #[test] - fn anchor_point_shift_drag_honors_measurement_labels() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, vectorcraft_geom::PathData::single(sp), vectorcraft_doc::Appearance::default_art())) - .unwrap(); - let s = Selection::default(); - let p = paint(); - let base = cx(&d, &s, &p); - let on = ToolContext { measurement_labels: true, ..base }; - let off = ToolContext { measurement_labels: false, ..base }; - let shift = Mods { shift: true, ..Mods::default() }; - let mut t = AnchorTool::new("anchorPoint"); - t.pointer(&on, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(shift)); - t.pointer(&on, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0)); - let want = on.offset_label(30.0, 40.0); - assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); - assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); - } -} +mod tests; diff --git a/crates/tools/src/draw2/anchor/tests.rs b/crates/tools/src/draw2/anchor/tests.rs new file mode 100644 index 000000000..31e4108aa --- /dev/null +++ b/crates/tools/src/draw2/anchor/tests.rs @@ -0,0 +1,532 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; +use vectorcraft_geom::{Anchor, ParamCurve}; + +/// The Anchor Point tool has its own caret cursor, not the Pen's start-a-path one, and over a +/// segment the Reshape Segment cursor, kept while the segment is dragged. +#[test] +fn anchor_point_tool_shows_its_caret_and_reshape_over_segments() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("anchorPoint"); + let none = Mods::default(); + for at in [Point::new(20.0, 20.0), Point::new(200.0, 100.0)] { + assert_eq!(t.cursor(&cx, at, none), Cursor::AnchorPoint, "{at:?}"); + } + assert_eq!(t.cursor(&cx, Point::new(150.0, 101.0), none), Cursor::ReshapeSegment); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 140.0)); + assert_eq!(t.cursor(&cx, Point::new(150.0, 140.0), none), Cursor::ReshapeSegment); +} + +/// Selected three-anchor blend spine; the end anchors stay pinned to the +/// centres of the two key objects. +fn blend_spine_doc(smooth: bool) -> (vectorcraft_doc::Document, NodeId, Selection) { + let (mut d, _) = doc_with_rect(); + let layer = d.layers[0].id; + let (id, first, last) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let key = |id, x| { + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())) + }; + let mut path = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(160.0, 310.0), Point::new(210.0, 310.0)], false); + if smooth { + path.anchors[1] = Anchor::smooth(Point::new(160.0, 310.0), Point::new(190.0, 330.0)); + } + let spec = + vectorcraft_doc::live::BlendSpec { spine: Some(vectorcraft_geom::PathData::single(path)), key_anchors: vec![0, 2], ..Default::default() }; + let blend = vectorcraft_doc::Node::new( + id, + vectorcraft_doc::NodeKind::Blend { children: vec![key(first, 100.0), key(last, 200.0)], spec: Box::new(spec) }, + ); + d.insert(Some(layer), 1, blend).unwrap(); + let mut selected = Selection::default(); + selected.set([id]); + (d, id, selected) +} + +/// Owner priority applies to anchors, visible handles and segments alike, regardless +/// of selection insertion order; cursor and emitted command must agree. +#[test] +fn overlapping_path_and_blend_follow_selection_then_stacking() { + use vectorcraft_doc::{Appearance, Node}; + use vectorcraft_geom::{PathData, SubPath}; + for path_on_top in [false, true] { + for path_selected in [false, true] { + for blend_first in [false, true] { + let (d, blend, sel) = blend_spine_doc(true); + let paint = paint(); + let spine = crate::direct::spines(&cx(&d, &sel, &paint)).pop().unwrap().1; + let curve = spine.subpaths[0].segment(1).eval(0.5); + // Point, handle and segment overlaps, including different cursor kinds. + for (at, path_is_segment) in [ + (Point::new(160.0, 310.0), false), + (Point::new(190.0, 330.0), false), + (Point::new(160.0, 310.0), true), + (Point::new(190.0, 330.0), true), + (curve, false), + (curve, true), + ] { + let (mut d, mut sel) = (d.clone(), sel.clone()); + let path = d.alloc_id(); + let points = if path_is_segment { + vec![at - vectorcraft_geom::Vec2::new(0.0, 30.0), at + vectorcraft_geom::Vec2::new(0.0, 30.0)] + } else { + vec![at, at + vectorcraft_geom::Vec2::new(0.0, 60.0)] + }; + let node = Node::path(path, PathData::single(SubPath::polyline(&points, false)), Appearance::default_art()); + d.insert(Some(d.layers[0].id), if path_on_top { 2 } else { 1 }, node).unwrap(); + if path_selected { + sel.set(if blend_first { vec![blend, path] } else { vec![path, blend] }); + } + let c = cx(&d, &sel, &paint); + let path_wins = path_selected && path_on_top; + let expected_id = if path_wins { path } else { blend }; + let spine = crate::direct::spines(&c).into_iter().find(|(id, _)| *id == blend).unwrap().1; + let spine_segment = AnchorTool::spine_point(&spine, at, c.point_tol()).is_none() + && AnchorTool::spine_handle(&c, &spine, at, c.point_tol()).is_none(); + let reshape = if path_wins { path_is_segment } else { spine_segment }; + let mut tool = AnchorTool::new("anchorPoint"); + assert_eq!(tool.cursor(&c, at, Mods::default()), if reshape { Cursor::ReshapeSegment } else { Cursor::AnchorPoint }); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 10.0, at.y + 20.0)); + let Some(Action::Preview(cmd, args)) = actions.get(1) else { panic!("missing preview: {actions:?}") }; + assert_eq!( + args["id"], expected_id.0, + "top={path_on_top}, selected={path_selected}, blend_first={blend_first}, at={at:?}, segment={path_is_segment}: {cmd}" + ); + assert_eq!(cmd.starts_with("path."), path_wins); + assert_eq!(cmd.ends_with("reshapeSegment"), reshape); + } + } + } + } +} + +#[test] +fn anchor_point_converts_a_blend_spine_point_before_reshaping_its_segment() { + let (d, id, sel) = blend_spine_doc(false); + let p = paint(); + let c = cx(&d, &sel, &p); + let mut tool = AnchorTool::new("anchorPoint"); + assert!(tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 160.0, 310.0)).is_empty()); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 340.0)); + assert_eq!(actions.first(), Some(&Action::Begin("Convert Anchor Point".into()))); + assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args)) + if cmd == "object.blend.spine.convertAnchor" + && args["id"] == id.0 && args["anchor"] == 1 + && args["to"] == "smooth" && args["y"] == 340.0)); + assert_eq!(tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 340.0)), vec![Action::Commit]); +} + +#[test] +fn shift_reshapes_a_blend_spine_segment_perpendicular() { + let (d, id, sel) = blend_spine_doc(true); + let p = paint(); + let c = cx(&d, &sel, &p); + let spine = crate::direct::spines(&c).pop().unwrap().1; + let at = spine.subpaths[0].segment(1).eval(0.5); + for shift in [false, true] { + let mut tool = AnchorTool::new("anchorPoint"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x, at.y + 20.0).with_mods(Mods { shift, ..Mods::default() })); + assert!( + matches!(actions.get(1), Some(Action::Preview(cmd, args)) + if cmd == "object.blend.spine.reshapeSegment" && args["id"] == id.0 + && args["perpendicular"] == shift), + "{actions:?}" + ); + } +} + +#[test] +fn anchor_point_keeps_blend_spine_handles_visible_and_editable_after_switch() { + let (d, id, sel) = blend_spine_doc(true); + let p = paint(); + let c = cx(&d, &sel, &p); + // A new tool instance models switching from Direct Selection. + let mut tool = AnchorTool::new("anchorPoint"); + let overlays = tool.overlays(&c); + assert!(overlays.iter().any(|o| matches!(o, Overlay::Handle { p, .. } if *p == Point::new(190.0, 330.0)))); + assert!(overlays.iter().any(|o| matches!(o, Overlay::Handle { p, .. } if *p == Point::new(130.0, 290.0)))); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 190.0, 330.0)); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 190.0, 350.0)); + assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args)) + if cmd == "object.blend.spine.moveAnchor" + && args["id"] == id.0 && args["anchor"] == 1 && args["handle"] == "out" + && args["independent"] == true)); + assert_eq!(tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 190.0, 350.0)), vec![Action::Commit]); + + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 160.0, 310.0)); + assert_eq!( + tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 310.0)), + vec![Action::Exec("object.blend.spine.convertAnchor".into(), json!({"id": id.0, "anchor": 1, "to": "corner"}),)], + ); +} + +/// P1-1: a handle 2pt from its anchor must not intercept a click at the anchor centre; +/// dragging the anchor converts it, while a click on an eligible handle still retracts it. +#[test] +fn anchor_point_prefers_a_blend_spine_anchor_over_its_short_handle() { + let (mut d, _) = doc_with_rect(); + let layer = d.layers[0].id; + let (id, first, last) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let key = |id, x| { + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())) + }; + let mut path = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(160.0, 310.0), Point::new(210.0, 310.0)], false); + path.anchors[1] = Anchor::smooth(Point::new(160.0, 310.0), Point::new(162.0, 310.0)); + let spec = + vectorcraft_doc::live::BlendSpec { spine: Some(vectorcraft_geom::PathData::single(path)), key_anchors: vec![0, 2], ..Default::default() }; + let blend = vectorcraft_doc::Node::new( + id, + vectorcraft_doc::NodeKind::Blend { children: vec![key(first, 100.0), key(last, 200.0)], spec: Box::new(spec) }, + ); + d.insert(Some(layer), 1, blend).unwrap(); + let mut selected = Selection::default(); + selected.set([id]); + let p = paint(); + let c = cx(&d, &selected, &p); + // Clicking the anchor centre converts the smooth point to a corner. + let mut tool = AnchorTool::new("anchorPoint"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 160.0, 310.0)); + assert_eq!( + tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 310.0)), + vec![Action::Exec("object.blend.spine.convertAnchor".into(), json!({"id": id.0, "anchor": 1, "to": "corner"}),)], + ); + // Clicking the eligible handle end retracts only that handle. + let mut tool = AnchorTool::new("anchorPoint"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 162.0, 310.0)); + assert_eq!( + tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 162.0, 310.0)), + vec![Action::Exec( + "object.blend.spine.moveAnchor".into(), + json!({"id": id.0, "anchor": 1, "handle": "out", "x": 160.0, "y": 310.0, "independent": true}), + )], + ); +} + +/// P2-2: with Show handles when multiple anchors are selected off, spine handles hide +/// and cannot intercept pointer input, as for ordinary paths. +#[test] +fn anchor_point_hides_blend_spine_handles_when_multiple_is_off() { + let (d, _, sel) = blend_spine_doc(true); + let p = paint(); + let single = ToolContext { handles_multiple: false, ..cx(&d, &sel, &p) }; + let tool = AnchorTool::new("anchorPoint"); + assert!(tool.overlays(&single).iter().all(|o| !matches!(o, Overlay::Handle { .. }))); + // The hidden handle endpoint is neither an editable handle nor on the curve. + let mut tool = AnchorTool::new("anchorPoint"); + tool.pointer(&single, &PointerEvent::new(PointerKind::Down, 190.0, 330.0)); + assert!(matches!(tool.state, State::Idle)); + assert!(tool.pointer(&single, &PointerEvent::new(PointerKind::Up, 190.0, 330.0)).is_empty()); + // A drag on the curve away from anchors and handles reshapes the spine segment. + let (_, spine) = crate::direct::spines(&single).pop().unwrap(); + let at = spine.subpaths[0].segment(1).eval(0.5); + assert!(AnchorTool::spine_handle(&single, &spine, at, single.point_tol()).is_none()); + assert!(AnchorTool::spine_point(&spine, at, single.point_tol()).is_none()); + let mut tool = AnchorTool::new("anchorPoint"); + assert_eq!(tool.cursor(&single, at, Mods::default()), Cursor::ReshapeSegment); + tool.pointer(&single, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + let actions = tool.pointer(&single, &PointerEvent::new(PointerKind::Drag, at.x, at.y + 20.0)); + assert!(matches!(actions.get(1), Some(Action::Preview(cmd, _)) if cmd == "object.blend.spine.reshapeSegment")); +} + +/// Hover and click agree: the caret shows where a press converts a spine point or +/// edits a spine handle, and ReshapeSegment only where a press reshapes a segment. +#[test] +fn anchor_point_cursor_matches_spine_press_precedence() { + let (d, _, sel) = blend_spine_doc(true); + let p = paint(); + let c = cx(&d, &sel, &p); + let tool = AnchorTool::new("anchorPoint"); + let none = Mods::default(); + assert_eq!(tool.cursor(&c, Point::new(160.0, 310.0), none), Cursor::AnchorPoint); + assert_eq!(tool.cursor(&c, Point::new(190.0, 330.0), none), Cursor::AnchorPoint); + let spine = crate::direct::spines(&c).pop().unwrap().1; + assert_eq!(tool.cursor(&c, spine.subpaths[0].segment(1).eval(0.5), none), Cursor::ReshapeSegment); +} + +/// P1-2: Shift-dragging a blend spine handle constrains it to 45-degree steps round +/// its anchor, as for ordinary Anchor Point handles. +#[test] +fn anchor_point_shift_constrains_a_blend_spine_handle() { + let (d, id, sel) = blend_spine_doc(true); + let p = paint(); + let c = cx(&d, &sel, &p); + let shift = Mods { shift: true, ..Mods::default() }; + let mut tool = AnchorTool::new("anchorPoint"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 190.0, 330.0)); + let drag = PointerEvent { kind: PointerKind::Drag, pos: Point::new(200.0, 340.0), mods: shift, pressure: 1.0 }; + let actions = tool.pointer(&c, &drag); + // The pointer (40, 30) keeps its length of 50; Shift snaps it to 45 degrees. + assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args)) + if cmd == "object.blend.spine.moveAnchor" + && args["id"] == id.0 && args["anchor"] == 1 && args["handle"] == "out" + && args["independent"] == true + && (args["x"].as_f64().unwrap() - 160.0 - 50.0 * std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6 + && (args["y"].as_f64().unwrap() - 310.0 - 50.0 * std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6)); + assert_eq!(tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 340.0)), vec![Action::Commit]); +} + +#[test] +fn add_and_delete_anchor_clicks() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("addAnchor"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); + assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0)); + assert!((t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 20.0, 20.0))).is_empty()); + let mut t = AnchorTool::new("deleteAnchor"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 101.0)); + assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]); +} + +#[test] +fn scissors_splits_at_segment() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("scissors"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 150.0)); + assert!( + matches!(&a[0], Action::Exec(c, v) if c == "path.split" && v["id"] == id.0 && v["segment"] == 1 && (v["t"].as_f64().unwrap() - 0.5).abs() < 1e-6) + ); +} + +/// The Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchors and +/// segments only on the art a click can pick: not on a Shaper Group's original shapes unless +/// they are isolated (Construction mode), and in isolation mode only on the isolated object. +#[test] +fn anchor_tools_leave_out_shaper_sources_and_art_outside_isolation() { + let (d, _, sources, face) = doc_with_shaper_group(); + let rect = d.layers[0].children().unwrap()[0].id; + let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id; + let (s, p) = (Selection::default(), paint()); + // The path the first command of a press with `tool` at (x, y) acts on. + let picked = |isolation: Option, tool: &str, x: f64, y: f64| { + let cx = ToolContext { isolation, ..cx(&d, &s, &p) }; + let mut t = AnchorTool::new(tool); + let mut a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); + a.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y - 10.0))); + a.iter().find_map(|a| match a { + Action::Exec(_, v) | Action::Preview(_, v) => v["id"].as_u64().map(NodeId), + _ => None, + }) + }; + // The second square's top right corner and its right side, which only its original shape + // has. + for (tool, x, y) in [("deleteAnchor", 150.0, 300.0), ("anchorPoint", 150.0, 300.0), ("addAnchor", 150.0, 350.0), ("scissors", 150.0, 350.0)] { + assert_eq!(picked(None, tool, x, y), None, "{tool}"); + assert_eq!(picked(Some(sources), tool, x, y), Some(b), "{tool}"); + } + // The face's top left corner, outside the isolated rectangle. + assert_eq!(picked(None, "deleteAnchor", 0.0, 300.0), Some(face)); + assert_eq!(picked(Some(rect), "deleteAnchor", 0.0, 300.0), None); + assert_eq!(picked(Some(rect), "deleteAnchor", 100.0, 100.0), Some(rect)); +} + +#[test] +fn anchor_point_drag_pulls_handles_click_converts() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 90.0)); + assert_eq!(a[0], Action::Begin("Convert Anchor Point".into())); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.convertAnchor" && v["to"] == "smooth" && v["id"] == id.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 90.0)), vec![Action::Commit]); + // Clicking a corner without handles does nothing. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)).is_empty()); + // Dragging a segment reshapes it. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 200.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0)); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["dy"] == 30.0)); +} + +/// Shift held while a segment is dragged keeps its handles at right angles to the line between +/// its anchors (`perpendicular`). +#[test] +fn shift_reshapes_a_segment_perpendicular() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Mods::default() }; + for (mods, perpendicular) in [(Mods::default(), false), (shift, true)] { + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 200.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.0, 230.0).with_mods(mods)); + assert!( + matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["perpendicular"] == perpendicular), + "{a:?}" + ); + } +} + +/// #494: the Anchor Point tool picks within Selection & Anchor Display → Tolerance, and drags +/// only the handles shown. +#[test] +fn anchor_point_picks_within_the_tolerance_and_drags_shown_handles() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); + sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 300.0)); + let path = vectorcraft_geom::PathData::single(sp); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let press = |c: &ToolContext, x: f64, y: f64| { + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(c, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(c, &PointerEvent::new(PointerKind::Drag, x + 10.0, y + 30.0)) + }; + let dragged = |a: Vec| match a.as_slice() { + [Action::Begin(_), Action::Preview(c, _)] => c.clone(), + _ => String::new(), + }; + // The segment 6 px off: picked with an 8 px tolerance, not with the default 3 px. + assert_eq!(dragged(press(&cx(&d, &s, &p), 150.0, 306.0)), ""); + let wide = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) }; + assert_eq!(dragged(press(&wide, 150.0, 306.0)), "path.reshapeSegment"); + // The middle anchor's handle drags; with Show handles when multiple anchors are selected + // off it's hidden (three anchors selected) and the press finds the segment under it. + assert_eq!(dragged(press(&cx(&d, &s, &p), 240.0, 301.0)), "path.setHandle"); + let single = ToolContext { handles_multiple: false, ..cx(&d, &s, &p) }; + assert_eq!(dragged(press(&single, 240.0, 301.0)), "path.reshapeSegment"); +} + +/// #2267: clicking a handle end retracts that handle only; the anchor becomes a corner and +/// keeps the other handle, as Illustrator's Anchor Point tool does. +#[test] +fn anchor_point_click_on_a_handle_retracts_only_that_handle() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); + sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0)); + let path = vectorcraft_geom::PathData::single(sp); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let retract = |which: &str| { + vec![Action::Exec( + "path.setHandle".into(), + json!({"id": id.0, "subpath": 0, "anchor": 1, "which": which, "x": 200.0, "y": 300.0, "independent": true}), + )] + }; + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 240.0, 320.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 240.0, 320.0)), retract("out")); + // A hand that shakes by a pixel still clicks; the incoming handle sits opposite, at (160, 280). + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 280.0)); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.5, 280.5)).is_empty()); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.5, 280.5)), retract("in")); + // The command leaves the other handle where it was. + let mut a = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0)); + a.set_handle(true, a.p, true); + assert!(!a.has_out()); + assert_eq!(a.h_in, Point::new(160.0, 280.0)); +} + +/// Shift held at the press on an anchor moves it, handles and all, instead of pulling out +/// handles; Shift still held keeps the move at 45° steps, released frees it. A Shift-click +/// that doesn't move leaves the anchor as it was. +#[test] +fn anchor_point_shift_drag_moves_the_anchor() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); + sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0)); + let path = vectorcraft_geom::PathData::single(sp); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Mods::default() }; + let moved = |x: f64, y: f64| Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "x": x, "y": y})); + let mut t = AnchorTool::new("anchorPoint"); + // Pressed a pixel off the anchor: it keeps that offset to the pointer. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 201.0, 301.0).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 231.0, 341.0)); + assert_eq!(a, vec![Action::Begin("Move".into()), moved(230.0, 340.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 241.0, 306.0).with_mods(shift)); + assert!( + matches!(&a[..], [Action::Preview(c, v)] if c == "path.moveAnchor" && v["y"].as_f64().is_some_and(|y| (y - 300.0).abs() < 1e-9)), + "{a:?}" + ); + assert_eq!(t.cursor(&cx, Point::new(241.0, 306.0), shift), Cursor::Arrow); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 241.0, 306.0)), vec![Action::Commit]); + // Without Shift the same press still pulls out handles. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 340.0)); + // A Shift-click that doesn't move does nothing (no conversion to a corner). + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(shift)); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 300.0).with_mods(shift)).is_empty()); +} + +/// #3694: Space held mid-drag on a convert moves the anchor, handles and all. +#[test] +fn anchor_point_space_mid_drag_moves_the_anchor() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, vectorcraft_geom::PathData::single(sp), vectorcraft_doc::Appearance::default_art())) + .unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let space = Mods { space: true, ..Mods::default() }; + let moved = |x: f64, y: f64| Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "x": x, "y": y})); + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 240.0, 350.0).with_mods(space)); + assert_eq!(a, vec![moved(200.0, 300.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 360.0).with_mods(space)); + assert_eq!(a, vec![moved(210.0, 310.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 370.0)); + assert!(matches!(&a[..], [Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 370.0)), vec![Action::Commit]); +} + +#[test] +fn anchor_point_shift_drag_honors_measurement_labels() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, vectorcraft_geom::PathData::single(sp), vectorcraft_doc::Appearance::default_art())) + .unwrap(); + let s = Selection::default(); + let p = paint(); + let base = cx(&d, &s, &p); + let on = ToolContext { measurement_labels: true, ..base }; + let off = ToolContext { measurement_labels: false, ..base }; + let shift = Mods { shift: true, ..Mods::default() }; + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&on, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(shift)); + t.pointer(&on, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0)); + let want = on.offset_label(30.0, 40.0); + assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); + assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); +} From a34f7e1ada4049781ad8ebfa369829ac2819147a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:01 +0000 Subject: [PATCH 048/199] refactor(color): move palette_io.rs tests to palette_io/tests.rs --- crates/color/src/palette_io.rs | 522 +-------------------------- crates/color/src/palette_io/tests.rs | 519 ++++++++++++++++++++++++++ 2 files changed, 520 insertions(+), 521 deletions(-) create mode 100644 crates/color/src/palette_io/tests.rs diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index b1f51213b..b331d43e6 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -668,524 +668,4 @@ impl UniqueNames { } #[cfg(test)] -mod tests { - use super::*; - use crate::{Gradient, GradientPaint, GradientStop}; - use proptest::prelude::*; - - #[test] - fn gpl_blank_name_keeps_fallback() { - let blank = "GIMP Palette\nName: \nColumns: 0\n#\n 1 2 3\tx\n"; - assert_eq!(read(blank, "fallback").unwrap().name, "fallback"); - let named = "GIMP Palette\nName: Warm\n 1 2 3\tx\n"; - assert_eq!(read(named, "fallback").unwrap().name, "Warm"); - } - - /// A color book's string: a `u32` count of UTF-16 code units, then the units. - fn acb_str(out: &mut Vec, s: &str) { - let units: Vec = s.encode_utf16().collect(); - out.extend((units.len() as u32).to_be_bytes()); - units.iter().for_each(|u| out.extend(u.to_be_bytes())); - } - - /// A color book in `space` (0 RGB, 2 CMYK, 7 Lab) with `colors` (name, components) and an - /// optional spot/process marker. - fn acb(space: u16, colors: &[(&str, &[u8])], marker: &[u8]) -> Vec { - let mut b = b"8BCB".to_vec(); - b.extend(1u16.to_be_bytes()); - b.extend(3000u16.to_be_bytes()); - for s in ["$$$/colorbook/Test/title=Test Book^R", "TEST ", " C", "a book"] { - acb_str(&mut b, s); - } - b.extend((colors.len() as u16).to_be_bytes()); - b.extend(7u16.to_be_bytes()); - b.extend(0u16.to_be_bytes()); - b.extend(space.to_be_bytes()); - for (name, comps) in colors { - acb_str(&mut b, name); - b.extend(b"TST001"); - b.extend(*comps); - } - b.extend(marker); - b - } - - /// #831: color books (`.acb`) are read: their colors in RGB, CMYK or Lab, named with the book's - /// prefix and suffix, spot unless the book says process; page padding is left out. - #[test] - fn color_books_are_read() { - let lib = read_bytes(&acb(0, &[("185", &[228, 0, 43]), ("", &[0, 0, 0]), ("Red 032", &[239, 51, 64])], b"spflspot"), "fallback").unwrap(); - assert_eq!(lib.name, "Test Book®"); - let names: Vec<&str> = lib.swatches.iter().map(|s| s.name.as_str()).collect(); - assert_eq!(names, ["TEST 185 C", "TEST Red 032 C"]); - assert!(lib.swatches.iter().all(|s| s.spot && s.global)); - assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(228, 0, 43))); - // CMYK ink is stored inverted; a book marked process gives process colors. - let lib = read_bytes(&acb(2, &[("Cyan", &[0, 255, 255, 255])], b"spflproc"), "x").unwrap(); - assert_eq!(lib.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0))); - assert!(!lib.swatches[0].spot && !lib.swatches[0].global); - // Lab: lightness 0..255 for 0..100, a and b offset by 128; no marker: spot. - let lib = read_bytes(&acb(7, &[("Grey", &[255, 128, 128])], b""), "x").unwrap(); - assert_eq!(lib.swatches[0].paint.color(), Some(Color::lab(100.0, 0.0, 0.0))); - assert!(lib.swatches[0].spot); - assert!(sniff_bytes(&acb(0, &[], b""))); - // Unsupported versions and color spaces, and short files, are errors. - let mut v2 = acb(0, &[], b""); - v2[5] = 2; - assert!(read_bytes(&v2, "x").is_err()); - assert!(read_bytes(&acb(1, &[("x", &[1, 2, 3])], b""), "x").is_err()); - let full = acb(0, &[("185", &[228, 0, 43])], b""); - for cut in 4..full.len() { - assert!(read_bytes(&full[..cut], "x").is_err(), "{cut} bytes"); - } - let mut huge = b"8BCB\0\x01\0\x01".to_vec(); - huge.extend(u32::MAX.to_be_bytes()); - assert!(read_bytes(&huge, "x").is_err(), "a string longer than the file"); - } - - fn sample() -> SwatchLibrary { - let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( - GradientKind::Linear, - vec![ - GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) }, - GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) }, - ], - )))); - SwatchLibrary { - name: "Brand */ Colours".into(), - swatches: vec![ - Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.2)), global: true, spot: true }, - Swatch { name: "Fade".into(), paint: grad, global: false, spot: false }, - Swatch { name: "Tiles".into(), paint: Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, global: false, spot: false }, - ], - groups: vec![SwatchGroup { - name: "Neutrals".into(), - swatches: vec![ - Swatch { name: "R=255 G=0 B=0".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: false, spot: false }, - Swatch { name: "Mist".into(), paint: Paint::solid(Color::gray(0.25)), global: true, spot: false }, - ], - }], - } - } - - /// `lib` written as text format `f`. - fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String { - String::from_utf8(write(lib, f).unwrap()).unwrap() - } - - #[test] - fn native_round_trip_keeps_cmyk_spot_and_groups() { - let lib = sample(); - let text = written(&lib, PaletteFormat::Native); - assert!(sniff(&text)); - let back = read(&text, "fallback").unwrap(); - let mut expected = lib.clone(); - expected.swatches.retain(|w| !matches!(w.paint, Paint::Pattern { .. })); - assert_eq!(back, expected, "everything but the pattern comes back exactly"); - assert!(back.swatches[0].spot && back.swatches[0].global); - assert_eq!(back.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); - assert!(read("{\"format\": \"other\", \"version\": 1, \"name\": \"x\"}", "x").is_err()); - } - - #[test] - fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() { - let text = written(&sample(), PaletteFormat::Gpl); - assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n")); - assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}"); - assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours"); - let back = read(&text, "x").unwrap(); - assert_eq!(back.name, "Brand */ Colours"); - assert_eq!(back.swatches.len(), 1); - assert_eq!(back.groups[0].swatches[1].paint.color(), Some(Color::rgb8(191, 191, 191)), "grey as 8-bit RGB"); - // Another tool's palette: comments, a Columns header, blank lines, unnamed and repeated names. - let other = "GIMP Palette\r\nName: Sunset\r\nColumns: 4\r\n# a comment\r\n\r\n 0 128 255\tSky Blue\r\n10 20 30\r\n0 0 0 Black\r\n1 1 1 Black\r\nbad line\r\n"; - let lib = read(other, "file").unwrap(); - assert_eq!(lib.name, "Sunset"); - let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); - assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]); - assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255))); - assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback"); - // Colors and groups share names; an unnamed group is "Color Group". - let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap(); - let groups: Vec<(&str, Vec<&str>)> = - lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); - assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]); - } - - #[test] - fn css_escapes_names_and_writes_gradients() { - assert_eq!(css_property("Sky Blue"), "--sky-blue"); - assert_eq!(css_property("R=255 G=0 B=0"), "--r\\=255-g\\=0-b\\=0"); - assert_eq!(css_property("#FF00CC"), "--\\#ff00cc"); - assert_eq!(css_property("Café 50%"), "--café-50\\%"); - assert_eq!(css_property(" "), "--swatch"); - let text = written(&sample(), PaletteFormat::Css); - assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}"); - assert!(text.contains(" --ink: #"), "{text}"); - assert!(text.contains("; /* spot */")); - assert!(text.contains(" --fade: linear-gradient(90deg, #"), "{text}"); - assert!(text.contains("#ff000080 100%)"), "half-transparent stop: {text}"); - assert!(text.contains(" /* Neutrals */\n --r\\=255-g\\=0-b\\=0: #ff0000;\n")); - assert!(!text.contains("tiles")); - // Names that collide once escaped are numbered. - let lib = SwatchLibrary { - name: "x".into(), - swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(), - groups: vec![], - }; - let text = written(&lib, PaletteFormat::Css); - assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;")); - assert!(read(&text, "x").is_err(), "CSS is written only"); - } - - #[test] - fn css_preserves_fractional_gradient_stop_positions() { - for kind in [GradientKind::Linear, GradientKind::Radial] { - let lib = SwatchLibrary { - name: "Positions".into(), - swatches: vec![Swatch { - name: "Ramp".into(), - paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( - kind, - vec![ - GradientStop::new(0.0, Color::BLACK), - GradientStop::new(0.125, Color::rgb8(255, 0, 0)), - GradientStop::new(0.12890625, Color::WHITE), - GradientStop::new(1.0, Color::rgb8(0, 0, 255)), - ], - )))), - global: false, - spot: false, - }], - groups: vec![], - }; - let text = written(&lib, PaletteFormat::Css); - assert!(text.contains("#000000 0%, #ff0000 12.5%, #ffffff 12.890625%, #0000ff 100%"), "{kind:?}: {text}"); - } - } - - #[test] - fn holds_counts_the_swatches_each_format_writes() { - // `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group. - let mut lib = sample(); - let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") }; - mesh.gradient.kind = GradientKind::Freeform; - lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false }); - lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }); - let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count(); - assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css"); - for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] { - assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}"); - } - let css = written(&lib, PaletteFormat::Css); - assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}"); - } - - /// A swatch exchange file of `blocks` (type, body). - fn ase(blocks: &[(u16, Vec)]) -> Vec { - let mut out = b"ASEF\0\x01\0\0".to_vec(); - out.extend((blocks.len() as u32).to_be_bytes()); - for (kind, body) in blocks { - out.extend(kind.to_be_bytes()); - out.extend((body.len() as u32).to_be_bytes()); - out.extend(body); - } - out - } - - /// A name as `.ase` stores it. - fn ase_name(name: &str) -> Vec { - let units: Vec = name.encode_utf16().chain([0]).collect(); - let mut out = (units.len() as u16).to_be_bytes().to_vec(); - out.extend(units.iter().flat_map(|u| u.to_be_bytes())); - out - } - - /// A color block: name, model, components and color type. - fn ase_color(name: &str, model: &[u8; 4], values: &[f32], kind: u16) -> (u16, Vec) { - let mut body = ase_name(name); - body.extend(model); - body.extend(values.iter().flat_map(|v| v.to_be_bytes())); - body.extend(kind.to_be_bytes()); - (ASE_COLOR, body) - } - - #[test] - fn ase_reads_color_models_kinds_and_groups() { - let bytes = ase(&[ - ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), - ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), - (ASE_GROUP_START, ase_name("Neutrals")), - ase_color("Mist", b"Gray", &[0.75], 2), - ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), - (ASE_GROUP_END, vec![]), - ase_color("Paper", b"RGB ", &[1.0, 1.0, 1.0], 2), - ]); - assert!(sniff_bytes(&bytes)); - let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; - let expected = SwatchLibrary { - name: "Brand".into(), - swatches: vec![ - sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false), - sw("Ink", Color::cmyk(1.0, 0.5, 0.0, 0.2), true, true), - sw("Paper", Color::WHITE, false, false), - ], - groups: vec![SwatchGroup { - name: "Neutrals".into(), - swatches: vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)], - }], - }; - assert_eq!(read_bytes(&bytes, "Brand").unwrap(), expected, "named after the file; 0 global, 1 spot, 2 process"); - // A lightness above 1 is L* itself. - let deep = read_bytes(&ase(&[ase_color("Deep", b"LAB ", &[53.0, 20.0, -30.0], 2)]), "x").unwrap(); - assert_eq!(deep.swatches[0].paint.color(), Some(Color::lab(53.0, 20.0, -30.0))); - // Text libraries read through the same call, also when they aren't valid UTF-8. - assert!(sniff_bytes(b"GIMP Palette\n1 2 3 x\n") && !sniff_bytes(b"ASE") && !sniff_bytes(&[0xff, 0xfe, 0])); - assert_eq!(read_bytes(b"GIMP Palette\n1 2 3 x\n", "F").unwrap().swatches.len(), 1); - let latin1 = b"GIMP Palette\n1 2 3 Caf\xe9\n"; - assert!(sniff_bytes(latin1)); - assert_eq!(read_bytes(latin1, "F").unwrap().swatches.len(), 1); - } - - #[test] - fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() { - let mut untyped = ase_color("Plain", b"RGB ", &[0.2, 0.4, 0.6], 2); - untyped.1.truncate(untyped.1.len() - 2); - let bytes = ase(&[ - ase_color("Café 🎨", b"RGB ", &[1.0, 0.0, 0.0], 2), - ase_color("", b"CMYK", &[0.1, 0.2, 0.3, 0.0], 2), - ase_color(" Red ", b"RGB ", &[2.0, -1.0, f32::NAN], 0), - ase_color("Red", b"Gray", &[0.0], 2), - untyped, - ase_color("Hue", b"HSB ", &[0.1, 0.2, 0.3], 2), - (0x0042, vec![1, 2, 3]), - (ASE_GROUP_END, vec![]), - (ASE_GROUP_START, ase_name("")), - (ASE_GROUP_START, ase_name("Red")), - ase_color("Red", b"Gray", &[1.0], 2), - ]); - let lib = read_bytes(&bytes, "x").unwrap(); - let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); - assert_eq!(names, ["Café 🎨", "C=10 M=20 Y=30 K=0", "Red", "Red 2", "Plain"], "an HSB color and an unknown block are skipped"); - assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0"); - assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process"); - // A group start ends the open group, and groups and colors share names. - let groups: Vec<(&str, Vec<&str>)> = - lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); - assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]); - assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white"); - } - - #[test] - fn damaged_ase_files_are_errors() { - let good = ase(&[(ASE_GROUP_START, ase_name("G")), ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0)]); - assert_eq!(read_bytes(&good, "x").unwrap().len(), 1); - for n in ASE_SIGNATURE.len()..good.len() { - assert!(read_bytes(&good[..n], "x").is_err(), "cut at {n}"); - } - let mut v2 = good.clone(); - v2[5] = 2; - assert!(read_bytes(&v2, "x").unwrap_err().contains("2.0"), "version 2.0"); - // Too many colors, or too many groups (empty group starts take 6 bytes each). - let colors: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| ase_color("Gray", b"Gray", &[0.5], 2)).collect(); - assert!(read_bytes(&ase(&colors), "x").unwrap_err().contains("more than")); - let groups: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| (ASE_GROUP_START, vec![])).collect(); - assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than")); - assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit"); - } - - #[test] - fn ase_writes_each_model_kind_and_group() { - let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; - let tint = Swatch { - name: "Ink 40%".into(), - paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 }, - global: false, - spot: false, - }; - // `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals. - let mut lib = sample(); - lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false)); - lib.swatches.push(tint); - lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)]; - lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] }); - let bytes = write(&lib, PaletteFormat::Ase).unwrap(); - let expected = ase(&[ - ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), - ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), - ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2), - (ASE_GROUP_START, ase_name("Neutrals")), - ase_color("Mist", b"Gray", &[0.75], 2), - ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), - (ASE_GROUP_END, vec![]), - (ASE_GROUP_START, ase_name("Empty")), - (ASE_GROUP_END, vec![]), - ]); - assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows"); - let back = read_bytes(&bytes, "Brand").unwrap(); - assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays"); - assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase)); - assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary()); - } - - #[test] - fn ase_names_lose_nul_and_stop_at_the_reader_limit() { - let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))]; - let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect(); - let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] }; - let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); - let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect(); - // At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji. - assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]); - } - - #[test] - fn ase_clamps_components_and_writes_non_finite_ones_as_zero() { - let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false }; - let swatches = vec![ - sw("Lab", Color::lab(100.5, 300.0, -300.0)), - sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)), - sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)), - sw("Gray", Color::gray(1.5)), - ]; - let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap(); - let expected = ase(&[ - ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2), - ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2), - ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2), - ase_color("Gray", b"Gray", &[0.0], 2), - ]); - assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0"); - // Lightness 100.5 is written as 1, which reads back as L* 100. - let back = read_bytes(&bytes, "x").unwrap(); - assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0))); - } - - #[test] - fn ase_write_refuses_more_entries_than_the_reader_takes() { - let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false }; - // The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it. - let mut lib = SwatchLibrary { - name: "x".into(), - swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(), - groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }], - }; - let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); - assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1)); - lib.swatches.push(gray(0)); - let e = write(&lib, PaletteFormat::Ase).unwrap_err(); - assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}"); - // A gradient is left out and is not counted. - lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone(); - assert!(write(&lib, PaletteFormat::Ase).is_ok()); - } - - /// A color in one of the four models, in the ranges `.ase` holds. - fn arb_color() -> impl Strategy { - let unit = || 0.0f32..=1.0; - prop_oneof![ - (unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)), - (unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)), - unit().prop_map(Color::gray), - (0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)), - ] - } - - /// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch - /// (both flags off) or a gradient. - fn arb_paint() -> impl Strategy { - prop_oneof![ - 4 => (arb_color(), any::(), any::()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)), - 1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)), - 1 => Just((sample().swatches[1].paint.clone(), false, false)), - ] - } - - /// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script - /// but NUL, trimmed, so swatches and groups have unique names, as in a document. - fn arb_library() -> impl Strategy { - let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12); - (swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| { - let mut n = 0; - let mut name = |prefix: &str, base: &str| { - n += 1; - format!("{prefix}{n} {base}").trim().to_string() - }; - let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() }; - for ((paint, global, spot), base) in loose { - lib.swatches.push(Swatch { name: name("", &base), paint, global, spot }); - } - for (base, list) in groups { - let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] }; - for ((paint, global, spot), base) in list { - g.swatches.push(Swatch { name: name("", &base), paint, global, spot }); - } - lib.groups.push(g); - } - lib - }) - } - - /// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and - /// gray read back within `f32` rounding. - fn close(got: Color, want: Color) -> bool { - let near = |a: f32, b: f32| (a - b).abs() < 1e-4; - match (got, want) { - (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2), - (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2), - (Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2), - (Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2), - _ => false, - } - } - - /// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot - /// colors global. - fn ase_expected(list: &[Swatch]) -> Vec { - list.iter() - .filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot })) - .collect() - } - - /// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`? - fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool { - got.len() == want.len() - && got.iter().zip(want).all(|(g, w)| { - let color = match (&g.paint, w.paint.color()) { - (Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c), - _ => false, - }; - g.name == w.name && g.global == w.global && g.spot == w.spot && color - }) - } - - proptest! { - #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] - - /// A written `.ase` file reads back as the library's solid colors in their own model, with - /// their names, kinds and color groups. - #[test] - fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) { - let bytes = write(&lib, PaletteFormat::Ase).unwrap(); - prop_assert!(sniff_bytes(&bytes)); - let back = read_bytes(&bytes, &lib.name).unwrap(); - prop_assert_eq!(&back.name, &lib.name); - prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches); - prop_assert_eq!(back.groups.len(), lib.groups.len()); - for (g, want) in back.groups.iter().zip(&lib.groups) { - prop_assert_eq!(&g.name, &want.name); - prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches); - } - } - - /// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as - /// [`PaletteFormat::holds`] counts. - #[test] - fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) { - for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] { - let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap(); - prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f); - } - } - } -} +mod tests; diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs new file mode 100644 index 000000000..7bfd7623e --- /dev/null +++ b/crates/color/src/palette_io/tests.rs @@ -0,0 +1,519 @@ +use super::*; +use crate::{Gradient, GradientPaint, GradientStop}; +use proptest::prelude::*; + +#[test] +fn gpl_blank_name_keeps_fallback() { + let blank = "GIMP Palette\nName: \nColumns: 0\n#\n 1 2 3\tx\n"; + assert_eq!(read(blank, "fallback").unwrap().name, "fallback"); + let named = "GIMP Palette\nName: Warm\n 1 2 3\tx\n"; + assert_eq!(read(named, "fallback").unwrap().name, "Warm"); +} + +/// A color book's string: a `u32` count of UTF-16 code units, then the units. +fn acb_str(out: &mut Vec, s: &str) { + let units: Vec = s.encode_utf16().collect(); + out.extend((units.len() as u32).to_be_bytes()); + units.iter().for_each(|u| out.extend(u.to_be_bytes())); +} + +/// A color book in `space` (0 RGB, 2 CMYK, 7 Lab) with `colors` (name, components) and an +/// optional spot/process marker. +fn acb(space: u16, colors: &[(&str, &[u8])], marker: &[u8]) -> Vec { + let mut b = b"8BCB".to_vec(); + b.extend(1u16.to_be_bytes()); + b.extend(3000u16.to_be_bytes()); + for s in ["$$$/colorbook/Test/title=Test Book^R", "TEST ", " C", "a book"] { + acb_str(&mut b, s); + } + b.extend((colors.len() as u16).to_be_bytes()); + b.extend(7u16.to_be_bytes()); + b.extend(0u16.to_be_bytes()); + b.extend(space.to_be_bytes()); + for (name, comps) in colors { + acb_str(&mut b, name); + b.extend(b"TST001"); + b.extend(*comps); + } + b.extend(marker); + b +} + +/// #831: color books (`.acb`) are read: their colors in RGB, CMYK or Lab, named with the book's +/// prefix and suffix, spot unless the book says process; page padding is left out. +#[test] +fn color_books_are_read() { + let lib = read_bytes(&acb(0, &[("185", &[228, 0, 43]), ("", &[0, 0, 0]), ("Red 032", &[239, 51, 64])], b"spflspot"), "fallback").unwrap(); + assert_eq!(lib.name, "Test Book®"); + let names: Vec<&str> = lib.swatches.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["TEST 185 C", "TEST Red 032 C"]); + assert!(lib.swatches.iter().all(|s| s.spot && s.global)); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(228, 0, 43))); + // CMYK ink is stored inverted; a book marked process gives process colors. + let lib = read_bytes(&acb(2, &[("Cyan", &[0, 255, 255, 255])], b"spflproc"), "x").unwrap(); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0))); + assert!(!lib.swatches[0].spot && !lib.swatches[0].global); + // Lab: lightness 0..255 for 0..100, a and b offset by 128; no marker: spot. + let lib = read_bytes(&acb(7, &[("Grey", &[255, 128, 128])], b""), "x").unwrap(); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::lab(100.0, 0.0, 0.0))); + assert!(lib.swatches[0].spot); + assert!(sniff_bytes(&acb(0, &[], b""))); + // Unsupported versions and color spaces, and short files, are errors. + let mut v2 = acb(0, &[], b""); + v2[5] = 2; + assert!(read_bytes(&v2, "x").is_err()); + assert!(read_bytes(&acb(1, &[("x", &[1, 2, 3])], b""), "x").is_err()); + let full = acb(0, &[("185", &[228, 0, 43])], b""); + for cut in 4..full.len() { + assert!(read_bytes(&full[..cut], "x").is_err(), "{cut} bytes"); + } + let mut huge = b"8BCB\0\x01\0\x01".to_vec(); + huge.extend(u32::MAX.to_be_bytes()); + assert!(read_bytes(&huge, "x").is_err(), "a string longer than the file"); +} + +fn sample() -> SwatchLibrary { + let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![ + GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) }, + GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) }, + ], + )))); + SwatchLibrary { + name: "Brand */ Colours".into(), + swatches: vec![ + Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.2)), global: true, spot: true }, + Swatch { name: "Fade".into(), paint: grad, global: false, spot: false }, + Swatch { name: "Tiles".into(), paint: Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, global: false, spot: false }, + ], + groups: vec![SwatchGroup { + name: "Neutrals".into(), + swatches: vec![ + Swatch { name: "R=255 G=0 B=0".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: false, spot: false }, + Swatch { name: "Mist".into(), paint: Paint::solid(Color::gray(0.25)), global: true, spot: false }, + ], + }], + } +} + +/// `lib` written as text format `f`. +fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String { + String::from_utf8(write(lib, f).unwrap()).unwrap() +} + +#[test] +fn native_round_trip_keeps_cmyk_spot_and_groups() { + let lib = sample(); + let text = written(&lib, PaletteFormat::Native); + assert!(sniff(&text)); + let back = read(&text, "fallback").unwrap(); + let mut expected = lib.clone(); + expected.swatches.retain(|w| !matches!(w.paint, Paint::Pattern { .. })); + assert_eq!(back, expected, "everything but the pattern comes back exactly"); + assert!(back.swatches[0].spot && back.swatches[0].global); + assert_eq!(back.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); + assert!(read("{\"format\": \"other\", \"version\": 1, \"name\": \"x\"}", "x").is_err()); +} + +#[test] +fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() { + let text = written(&sample(), PaletteFormat::Gpl); + assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n")); + assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}"); + assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours"); + let back = read(&text, "x").unwrap(); + assert_eq!(back.name, "Brand */ Colours"); + assert_eq!(back.swatches.len(), 1); + assert_eq!(back.groups[0].swatches[1].paint.color(), Some(Color::rgb8(191, 191, 191)), "grey as 8-bit RGB"); + // Another tool's palette: comments, a Columns header, blank lines, unnamed and repeated names. + let other = "GIMP Palette\r\nName: Sunset\r\nColumns: 4\r\n# a comment\r\n\r\n 0 128 255\tSky Blue\r\n10 20 30\r\n0 0 0 Black\r\n1 1 1 Black\r\nbad line\r\n"; + let lib = read(other, "file").unwrap(); + assert_eq!(lib.name, "Sunset"); + let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]); + assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255))); + assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback"); + // Colors and groups share names; an unnamed group is "Color Group". + let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap(); + let groups: Vec<(&str, Vec<&str>)> = + lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]); +} + +#[test] +fn css_escapes_names_and_writes_gradients() { + assert_eq!(css_property("Sky Blue"), "--sky-blue"); + assert_eq!(css_property("R=255 G=0 B=0"), "--r\\=255-g\\=0-b\\=0"); + assert_eq!(css_property("#FF00CC"), "--\\#ff00cc"); + assert_eq!(css_property("Café 50%"), "--café-50\\%"); + assert_eq!(css_property(" "), "--swatch"); + let text = written(&sample(), PaletteFormat::Css); + assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}"); + assert!(text.contains(" --ink: #"), "{text}"); + assert!(text.contains("; /* spot */")); + assert!(text.contains(" --fade: linear-gradient(90deg, #"), "{text}"); + assert!(text.contains("#ff000080 100%)"), "half-transparent stop: {text}"); + assert!(text.contains(" /* Neutrals */\n --r\\=255-g\\=0-b\\=0: #ff0000;\n")); + assert!(!text.contains("tiles")); + // Names that collide once escaped are numbered. + let lib = SwatchLibrary { + name: "x".into(), + swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(), + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;")); + assert!(read(&text, "x").is_err(), "CSS is written only"); +} + +#[test] +fn css_preserves_fractional_gradient_stop_positions() { + for kind in [GradientKind::Linear, GradientKind::Radial] { + let lib = SwatchLibrary { + name: "Positions".into(), + swatches: vec![Swatch { + name: "Ramp".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + kind, + vec![ + GradientStop::new(0.0, Color::BLACK), + GradientStop::new(0.125, Color::rgb8(255, 0, 0)), + GradientStop::new(0.12890625, Color::WHITE), + GradientStop::new(1.0, Color::rgb8(0, 0, 255)), + ], + )))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + assert!(text.contains("#000000 0%, #ff0000 12.5%, #ffffff 12.890625%, #0000ff 100%"), "{kind:?}: {text}"); + } +} + +#[test] +fn holds_counts_the_swatches_each_format_writes() { + // `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group. + let mut lib = sample(); + let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") }; + mesh.gradient.kind = GradientKind::Freeform; + lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false }); + lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }); + let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count(); + assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css"); + for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] { + assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}"); + } + let css = written(&lib, PaletteFormat::Css); + assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}"); +} + +/// A swatch exchange file of `blocks` (type, body). +fn ase(blocks: &[(u16, Vec)]) -> Vec { + let mut out = b"ASEF\0\x01\0\0".to_vec(); + out.extend((blocks.len() as u32).to_be_bytes()); + for (kind, body) in blocks { + out.extend(kind.to_be_bytes()); + out.extend((body.len() as u32).to_be_bytes()); + out.extend(body); + } + out +} + +/// A name as `.ase` stores it. +fn ase_name(name: &str) -> Vec { + let units: Vec = name.encode_utf16().chain([0]).collect(); + let mut out = (units.len() as u16).to_be_bytes().to_vec(); + out.extend(units.iter().flat_map(|u| u.to_be_bytes())); + out +} + +/// A color block: name, model, components and color type. +fn ase_color(name: &str, model: &[u8; 4], values: &[f32], kind: u16) -> (u16, Vec) { + let mut body = ase_name(name); + body.extend(model); + body.extend(values.iter().flat_map(|v| v.to_be_bytes())); + body.extend(kind.to_be_bytes()); + (ASE_COLOR, body) +} + +#[test] +fn ase_reads_color_models_kinds_and_groups() { + let bytes = ase(&[ + ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), + ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), + (ASE_GROUP_START, ase_name("Neutrals")), + ase_color("Mist", b"Gray", &[0.75], 2), + ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), + (ASE_GROUP_END, vec![]), + ase_color("Paper", b"RGB ", &[1.0, 1.0, 1.0], 2), + ]); + assert!(sniff_bytes(&bytes)); + let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; + let expected = SwatchLibrary { + name: "Brand".into(), + swatches: vec![ + sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false), + sw("Ink", Color::cmyk(1.0, 0.5, 0.0, 0.2), true, true), + sw("Paper", Color::WHITE, false, false), + ], + groups: vec![SwatchGroup { + name: "Neutrals".into(), + swatches: vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)], + }], + }; + assert_eq!(read_bytes(&bytes, "Brand").unwrap(), expected, "named after the file; 0 global, 1 spot, 2 process"); + // A lightness above 1 is L* itself. + let deep = read_bytes(&ase(&[ase_color("Deep", b"LAB ", &[53.0, 20.0, -30.0], 2)]), "x").unwrap(); + assert_eq!(deep.swatches[0].paint.color(), Some(Color::lab(53.0, 20.0, -30.0))); + // Text libraries read through the same call, also when they aren't valid UTF-8. + assert!(sniff_bytes(b"GIMP Palette\n1 2 3 x\n") && !sniff_bytes(b"ASE") && !sniff_bytes(&[0xff, 0xfe, 0])); + assert_eq!(read_bytes(b"GIMP Palette\n1 2 3 x\n", "F").unwrap().swatches.len(), 1); + let latin1 = b"GIMP Palette\n1 2 3 Caf\xe9\n"; + assert!(sniff_bytes(latin1)); + assert_eq!(read_bytes(latin1, "F").unwrap().swatches.len(), 1); +} + +#[test] +fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() { + let mut untyped = ase_color("Plain", b"RGB ", &[0.2, 0.4, 0.6], 2); + untyped.1.truncate(untyped.1.len() - 2); + let bytes = ase(&[ + ase_color("Café 🎨", b"RGB ", &[1.0, 0.0, 0.0], 2), + ase_color("", b"CMYK", &[0.1, 0.2, 0.3, 0.0], 2), + ase_color(" Red ", b"RGB ", &[2.0, -1.0, f32::NAN], 0), + ase_color("Red", b"Gray", &[0.0], 2), + untyped, + ase_color("Hue", b"HSB ", &[0.1, 0.2, 0.3], 2), + (0x0042, vec![1, 2, 3]), + (ASE_GROUP_END, vec![]), + (ASE_GROUP_START, ase_name("")), + (ASE_GROUP_START, ase_name("Red")), + ase_color("Red", b"Gray", &[1.0], 2), + ]); + let lib = read_bytes(&bytes, "x").unwrap(); + let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["Café 🎨", "C=10 M=20 Y=30 K=0", "Red", "Red 2", "Plain"], "an HSB color and an unknown block are skipped"); + assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0"); + assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process"); + // A group start ends the open group, and groups and colors share names. + let groups: Vec<(&str, Vec<&str>)> = + lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]); + assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white"); +} + +#[test] +fn damaged_ase_files_are_errors() { + let good = ase(&[(ASE_GROUP_START, ase_name("G")), ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0)]); + assert_eq!(read_bytes(&good, "x").unwrap().len(), 1); + for n in ASE_SIGNATURE.len()..good.len() { + assert!(read_bytes(&good[..n], "x").is_err(), "cut at {n}"); + } + let mut v2 = good.clone(); + v2[5] = 2; + assert!(read_bytes(&v2, "x").unwrap_err().contains("2.0"), "version 2.0"); + // Too many colors, or too many groups (empty group starts take 6 bytes each). + let colors: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| ase_color("Gray", b"Gray", &[0.5], 2)).collect(); + assert!(read_bytes(&ase(&colors), "x").unwrap_err().contains("more than")); + let groups: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| (ASE_GROUP_START, vec![])).collect(); + assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than")); + assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit"); +} + +#[test] +fn ase_writes_each_model_kind_and_group() { + let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; + let tint = Swatch { + name: "Ink 40%".into(), + paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 }, + global: false, + spot: false, + }; + // `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals. + let mut lib = sample(); + lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false)); + lib.swatches.push(tint); + lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)]; + lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] }); + let bytes = write(&lib, PaletteFormat::Ase).unwrap(); + let expected = ase(&[ + ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), + ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), + ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2), + (ASE_GROUP_START, ase_name("Neutrals")), + ase_color("Mist", b"Gray", &[0.75], 2), + ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), + (ASE_GROUP_END, vec![]), + (ASE_GROUP_START, ase_name("Empty")), + (ASE_GROUP_END, vec![]), + ]); + assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows"); + let back = read_bytes(&bytes, "Brand").unwrap(); + assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays"); + assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase)); + assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary()); +} + +#[test] +fn ase_names_lose_nul_and_stop_at_the_reader_limit() { + let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))]; + let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect(); + let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] }; + let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); + let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect(); + // At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji. + assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]); +} + +#[test] +fn ase_clamps_components_and_writes_non_finite_ones_as_zero() { + let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false }; + let swatches = vec![ + sw("Lab", Color::lab(100.5, 300.0, -300.0)), + sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)), + sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)), + sw("Gray", Color::gray(1.5)), + ]; + let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap(); + let expected = ase(&[ + ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2), + ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2), + ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2), + ase_color("Gray", b"Gray", &[0.0], 2), + ]); + assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0"); + // Lightness 100.5 is written as 1, which reads back as L* 100. + let back = read_bytes(&bytes, "x").unwrap(); + assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0))); +} + +#[test] +fn ase_write_refuses_more_entries_than_the_reader_takes() { + let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false }; + // The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it. + let mut lib = SwatchLibrary { + name: "x".into(), + swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(), + groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }], + }; + let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); + assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1)); + lib.swatches.push(gray(0)); + let e = write(&lib, PaletteFormat::Ase).unwrap_err(); + assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}"); + // A gradient is left out and is not counted. + lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone(); + assert!(write(&lib, PaletteFormat::Ase).is_ok()); +} + +/// A color in one of the four models, in the ranges `.ase` holds. +fn arb_color() -> impl Strategy { + let unit = || 0.0f32..=1.0; + prop_oneof![ + (unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)), + (unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)), + unit().prop_map(Color::gray), + (0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)), + ] +} + +/// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch +/// (both flags off) or a gradient. +fn arb_paint() -> impl Strategy { + prop_oneof![ + 4 => (arb_color(), any::(), any::()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)), + 1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)), + 1 => Just((sample().swatches[1].paint.clone(), false, false)), + ] +} + +/// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script +/// but NUL, trimmed, so swatches and groups have unique names, as in a document. +fn arb_library() -> impl Strategy { + let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12); + (swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| { + let mut n = 0; + let mut name = |prefix: &str, base: &str| { + n += 1; + format!("{prefix}{n} {base}").trim().to_string() + }; + let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() }; + for ((paint, global, spot), base) in loose { + lib.swatches.push(Swatch { name: name("", &base), paint, global, spot }); + } + for (base, list) in groups { + let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] }; + for ((paint, global, spot), base) in list { + g.swatches.push(Swatch { name: name("", &base), paint, global, spot }); + } + lib.groups.push(g); + } + lib + }) +} + +/// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and +/// gray read back within `f32` rounding. +fn close(got: Color, want: Color) -> bool { + let near = |a: f32, b: f32| (a - b).abs() < 1e-4; + match (got, want) { + (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2), + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2), + (Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2), + (Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2), + _ => false, + } +} + +/// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot +/// colors global. +fn ase_expected(list: &[Swatch]) -> Vec { + list.iter() + .filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot })) + .collect() +} + +/// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`? +fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool { + got.len() == want.len() + && got.iter().zip(want).all(|(g, w)| { + let color = match (&g.paint, w.paint.color()) { + (Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c), + _ => false, + }; + g.name == w.name && g.global == w.global && g.spot == w.spot && color + }) +} + +proptest! { + #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] + + /// A written `.ase` file reads back as the library's solid colors in their own model, with + /// their names, kinds and color groups. + #[test] + fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) { + let bytes = write(&lib, PaletteFormat::Ase).unwrap(); + prop_assert!(sniff_bytes(&bytes)); + let back = read_bytes(&bytes, &lib.name).unwrap(); + prop_assert_eq!(&back.name, &lib.name); + prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches); + prop_assert_eq!(back.groups.len(), lib.groups.len()); + for (g, want) in back.groups.iter().zip(&lib.groups) { + prop_assert_eq!(&g.name, &want.name); + prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches); + } + } + + /// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as + /// [`PaletteFormat::holds`] counts. + #[test] + fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) { + for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] { + let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap(); + prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f); + } + } +} From db60090f24737cb98c55618531ba724c0f5a58be Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:02 +0000 Subject: [PATCH 049/199] refactor(engine): move cmd/textstyles.rs tests to cmd/textstyles/tests.rs --- crates/engine/src/cmd/textstyles.rs | 488 +--------------------- crates/engine/src/cmd/textstyles/tests.rs | 485 +++++++++++++++++++++ 2 files changed, 486 insertions(+), 487 deletions(-) create mode 100644 crates/engine/src/cmd/textstyles/tests.rs diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 680024832..3667169a4 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -625,490 +625,4 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - fn session_with_text(text: &str) -> (Session, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": text})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - (s, id) - } - - fn runs(s: &Session, id: u64) -> Vec { - match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.clone(), - _ => panic!(), - } - } - - #[test] - fn char_style_new_apply_range_redefine_keeps_overrides() { - let (mut s, id) = session_with_text("Hello world"); - s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0, "underline": true}})).unwrap(); - // Style "world" only (a Type tool range). - s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11})).unwrap(); - let r = runs(&s, id); - assert_eq!(r.len(), 2); - assert_eq!((r[1].text.as_str(), r[1].style.size, r[1].style.underline), ("world", 24.0, true)); - assert_eq!(r[1].style.style_name.as_deref(), Some("Emphasis")); - assert_eq!(r[0].style.style_name, None); - // A local override on the styled run survives a redefinition; other attributes update. - s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 11, "size": 30.0})).unwrap(); - s.execute("charStyle.setAttrs", &json!({"name": "Emphasis", "attrs": {"size": 18.0, "underline": false, "tracking": 50.0}})).unwrap(); - let r = runs(&s, id); - assert_eq!((r[1].style.size, r[1].style.underline, r[1].style.tracking), (30.0, false, 50.0)); - // Clear Overrides: back to exactly the style's attributes. - s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11, "clearOverrides": true})).unwrap(); - assert_eq!(runs(&s, id)[1].style.size, 18.0); - let list = s.execute("charStyle.list", &json!({})).unwrap(); - assert_eq!(list["styles"][0]["name"], NORMAL_CHAR); - assert_eq!(list["styles"][1]["uses"], 1); - } - - /// Applying and redefining a style record a step named from a template with the style's name - /// in it (`HistoryEntry::named`), and undo and redo keep it. The Normal styles' names are - /// marked as names VectorCraft gives. A new style's step has a name of its own. - #[test] - fn style_steps_are_named_from_a_template() { - let (mut s, id) = session_with_text("Hello"); - let last = |s: &Session| { - let e = s.doc().unwrap().history.undo.last().unwrap(); - (e.label.clone(), e.named().map(|n| (n.template, n.name.clone(), n.built_in))) - }; - let named = |template, name: &str, built_in| Some((template, name.to_string(), built_in)); - s.execute("charStyle.new", &json!({"name": "Body"})).unwrap(); - assert_eq!(last(&s), ("New Character Style".into(), None)); - s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); - assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); - s.execute("edit.undo", &json!({})).unwrap(); - let redo = s.doc().unwrap().history.redo.last().unwrap().named().cloned(); - assert_eq!(redo.map(|n| n.label()).as_deref(), Some("Apply Body")); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); - s.execute("charStyle.apply", &json!({"name": NORMAL_CHAR, "id": id})).unwrap(); - assert_eq!(last(&s), (format!("Apply {NORMAL_CHAR}"), named("Apply {name}", NORMAL_CHAR, true))); - s.execute("paraStyle.new", &json!({"name": "Lead"})).unwrap(); - assert_eq!(last(&s), ("New Paragraph Style".into(), None)); - s.execute("paraStyle.setAttrs", &json!({"name": "Lead", "attrs": {"direction": "rightToLeft"}})).unwrap(); - assert_eq!(last(&s), ("Redefine Lead".into(), named("Redefine {name}", "Lead", false))); - // A step renamed since it was recorded is no longer named from the template. - s.doc_mut().unwrap().history.undo.last_mut().unwrap().label = "Typing".into(); - assert_eq!(last(&s), ("Typing".into(), None)); - } - - #[test] - fn new_from_selection_rename_delete_and_undo() { - let (mut s, id) = session_with_text("Title"); - s.execute("text.setStyle", &json!({"size": 40})).unwrap(); - let name = s.execute("charStyle.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); - assert_eq!(name, "Character Style 1"); - assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs["size"], 40.0); - s.execute("charStyle.apply", &json!({"name": name})).unwrap(); - s.execute("charStyle.rename", &json!({"name": name, "to": "Heading"})).unwrap(); - assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); - assert!(s.execute("charStyle.rename", &json!({"name": NORMAL_CHAR, "to": "x"})).is_err()); - s.execute("charStyle.duplicate", &json!({"name": "Heading"})).unwrap(); - s.execute("charStyle.delete", &json!({"name": "Heading"})).unwrap(); - assert_eq!(runs(&s, id)[0].style.style_name, None); - assert_eq!(runs(&s, id)[0].style.size, 40.0, "deleting keeps the look"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); - assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"nope": 1}})).is_err()); - // Attributes are stored canonically (a hand-written colour matches the text's afterwards). - s.execute( - "charStyle.new", - &json!({"name": "Pink", "attrs": {"fill": {"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.4}}}}), - ) - .unwrap(); - s.execute("charStyle.apply", &json!({"name": "Pink"})).unwrap(); - let fill = serde_json::to_value(&runs(&s, id)[0].style.fill).unwrap(); - assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); - } - - /// New Character Style and New Paragraph Style trim the name they are given, as a rename - /// trims `to`; the trimmed name is the one compared with the styles there are. - #[test] - fn new_styles_trim_their_names_as_a_rename_does() { - let (mut s, _) = session_with_text("Body text"); - for p in ["charStyle", "paraStyle"] { - assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}"); - let list = s.execute(&format!("{p}.list"), &json!({})).unwrap(); - let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect(); - assert_eq!(names[1..], ["Body"], "{p}"); - let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string(); - assert!(e.contains("a style named `Body` already exists"), "{p}: {e}"); - let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string(); - assert!(e.contains("give the style a name"), "{p}: {e}"); - assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}"); - } - // A style whose name has spaces around it, as a saved file can hold, is copied under the - // trimmed name, numbered past the copies there are. - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() }); - s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap(); - assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2"); - } - - /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no - /// mojikumi) are still attributes a style can set, and a style captured with one applies to - /// text at the default, is duplicated and comes back out of a library. - #[test] - fn styles_take_attributes_left_out_at_their_defaults() { - let (mut s, id) = session_with_text("A"); - s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap(); - s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap(); - s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap(); - s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap(); - for bad in [json!({"nope": 1}), json!({"justify_auto": true})] { - assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}"); - } - // Captured from text with explicit leading, applied to fresh Auto-leading text. - s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap(); - let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); - assert_eq!(runs(&s, fresh)[0].style.leading, None); - for clear in [false, true] { - s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap(); - assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0)); - } - let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string(); - assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0); - s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap(); - let st = runs(&s, fresh)[0].style.clone(); - assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0))); - s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap(); - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; - assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft))); - // A library keeps the captured attributes and makes the style again where it's used. - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap(); - let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap(); - s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap(); - assert_eq!(runs(&s, other)[0].style.leading, Some(30.0)); - } - - #[test] - fn auto_leading_redefine_keeps_overrides_and_history() { - for redefine in [true, false] { - let (mut s, source) = session_with_text("Source"); - let [linked, local] = - ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); - let ids = [source, linked, local]; - s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"leading": 30}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); - s.execute("text.setStyle", &json!({"ids": [local], "leading": 42})).unwrap(); - s.execute("text.setStyle", &json!({"ids": [source], "leading": "auto"})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - if redefine { - s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); - } else { - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": null}})).unwrap(); - } - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [None, None, Some(42.0)]); - assert!(ids.iter().all(|&id| runs(&s, id)[0].style.style_name.as_deref() == Some("Body"))); - let after = s.doc().unwrap().doc.clone(); - assert_eq!(after.char_styles[0].attrs.get("leading"), Some(&Value::Null), "Auto is explicit, not an unset attribute"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, after); - let back = vectorcraft_format::load(&vectorcraft_format::save(&after, false)).unwrap(); - assert_eq!(back.char_styles, after.char_styles); - for id in ids { - let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("not text") }; - assert_eq!(t.runs, runs(&s, id)); - } - // Auto users follow the next numeric definition; real numeric and Auto overrides stay. - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 26}})).unwrap(); - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(26.0), Some(26.0), Some(42.0)]); - s.execute("text.setStyle", &json!({"ids": [linked], "leading": "auto"})).unwrap(); - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 28}})).unwrap(); - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); - // Omitting leading replaces the definition without resetting the text's leading. - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"tracking": 20}})).unwrap(); - assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); - } - } - - /// The attribute names a style of type `T` can set, without the style name. - fn settable() -> std::collections::BTreeSet { - attr_names::().into_iter().filter(|&n| n != "style_name").map(String::from).collect() - } - - /// New and Redefine capture every paragraph attribute. A paragraph style redefined from a - /// paragraph at the defaults gives its paragraphs the defaults back where they had the old - /// definition's values, and `setAttrs` with a default value does too; local overrides, and - /// attributes `setAttrs` leaves out, stay. - #[test] - fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { - use vectorcraft_doc::{Composer, HyphenationOptions}; - let (mut s, source) = session_with_text("Source"); - let [linked, local] = - ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); - let ids = [source, linked, local]; - let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para_at(0).clone(), - _ => panic!("not text"), - }; - let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit); - let composers = |s: &Session| ids.map(|id| para(s, id).composer); - let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::>(); - s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap(); - s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap(); - s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); - assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3])); - assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default()); - assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body"))); - assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); - s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); - assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); - // Style options: a default value reaches the users too; an attribute left out stays as it is. - s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap(); - assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3])); - s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap(); - assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3])); - assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"})); - } - - /// The same for character styles: Auto kerning and the Roman baseline alignment, which the - /// run's JSON leaves out, are captured and reach the style's users. - #[test] - fn char_style_redefined_to_the_defaults_updates_its_text() { - use vectorcraft_doc::CharAlign; - let (mut s, source) = session_with_text("Source"); - let [linked, local] = - ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); - let ids = [source, linked, local]; - let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning); - let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align); - let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::>(); - s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap(); - s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); - assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); - assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); - s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); - assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap(); - assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3])); - s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap(); - assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3])); - assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"})); - } - - /// A style captured from type without Variable Font settings sets none explicitly (`{}`): - /// applying it clears type's settings, and redefining it from type whose settings were reset - /// resets the type that uses it. - #[test] - fn variable_font_settings_are_captured_explicitly() { - let _fonts = crate::adding_fonts(); - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; - vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); - let (mut s, source) = session_with_text("Source"); - let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap(); - let ids = [source, linked]; - s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); - assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({}))); - s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); - let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied()); - assert_eq!(wght(&s), [None, None], "applying the style clears the settings"); - for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] { - s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap(); - s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); - assert_eq!(wght(&s), [want, want], "redefined from {set}"); - } - } - - /// A style that changes the font, applied or redefined, leaves none of the old font's - /// version and Variable Font settings, unless the style sets them. - #[test] - fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() { - let _fonts = crate::adding_fonts(); - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; - vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); - let (mut s, id) = session_with_text("Source"); - let set_variable = |s: &mut Session| { - s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap(); - s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap(); - s.edit("version", |d, _| { - if let Some(t) = text_mut(d, NodeId(id)) { - t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into())); - } - Ok(()) - }) - .unwrap(); - }; - let font = |s: &Session| { - let st = runs(s, id)[0].style.clone(); - (st.font_family, st.font_version, st.font_axes.get("wght").copied()) - }; - set_variable(&mut s); - s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap(); - assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them"); - s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); - assert_eq!(font(&s), ("Inter".to_string(), None, None)); - // A style that sets the settings keeps its own. - s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap(); - assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0))); - // Redefined to another font: its users' settings and versions go too. - s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap(); - set_variable(&mut s); - assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans")); - s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap(); - assert_eq!(font(&s), ("Inter".to_string(), None, None)); - } - - #[test] - fn auto_leading_new_distinguishes_unset_attributes() { - let (mut s, id) = session_with_text("Title"); - s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); - s.execute("charStyle.new", &json!({"name": "Sparse", "attrs": {"tracking": 20}})).unwrap(); - assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); - s.execute("charStyle.apply", &json!({"name": "Sparse", "id": id})).unwrap(); - assert_eq!(runs(&s, id)[0].style.leading, Some(30.0)); - s.execute("charStyle.new", &json!({"name": "Automatic", "attrs": {"leading": null}})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Automatic", "id": id})).unwrap(); - assert_eq!(runs(&s, id)[0].style.leading, None); - assert_eq!(s.doc().unwrap().doc.char_styles[1].attrs.get("leading"), Some(&Value::Null)); - // New from Auto text captures that value too, rather than leaving leading unspecified. - s.execute("charStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); - s.execute("text.setStyle", &json!({"ids": [id], "leading": 36})).unwrap(); - s.execute("charStyle.apply", &json!({"name": "Captured", "id": id})).unwrap(); - assert_eq!(runs(&s, id)[0].style.leading, None); - assert_eq!(s.doc().unwrap().doc.char_styles[2].attrs.get("leading"), Some(&Value::Null)); - } - - #[test] - fn paragraph_styles_and_survive_save() { - let (mut s, id) = session_with_text("Body"); - s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6.0}})).unwrap(); - s.execute("paraStyle.apply", &json!({"name": "Centered"})).unwrap(); - let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.para.clone(), - _ => panic!(), - }; - assert_eq!((para(&s).justify, para(&s).space_after), (vectorcraft_doc::Justify::Center, 6.0)); - // The Normal style can be redefined too (stored once redefined). - s.execute("paraStyle.redefine", &json!({"name": NORMAL_PARA})).unwrap(); - assert_eq!(s.execute("paraStyle.list", &json!({})).unwrap()["styles"][0]["attrs"]["justify"], "Center"); - let d = s.doc().unwrap().doc.clone(); - let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); - assert_eq!(back.para_styles, d.para_styles); - } - - fn capture_boundary_command(redefine: bool, text: &str, start: usize, end: Option, expected_size: f64) { - let (mut s, id) = session_with_text(text); - s.execute("text.setStyle", &json!({"id": id, "size": 18})).unwrap(); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": text.len(), "size": 34})).unwrap(); - if redefine { - s.execute("charStyle.new", &json!({"name": "Captured", "attrs": {"size": 24}})).unwrap(); - } - s.doc_mut().unwrap().mark_saved(); - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let undo = s.doc().unwrap().history.undo.len(); - let mut params = json!({"name": "Captured", "id": id, "start": start}); - if let Some(end) = end { - params["end"] = json!(end); - } - let command = if redefine { "charStyle.redefine" } else { "charStyle.new" }; - s.execute(command, ¶ms).unwrap(); - let st = s.doc().unwrap(); - let def = st.doc.char_styles.iter().find(|d| d.name == "Captured").unwrap(); - assert_eq!(def.attrs.get("size").and_then(Value::as_f64), Some(expected_size), "{command} {text:?} {start}..{end:?}"); - assert!(!def.attrs.contains_key("style_name")); - assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id)), "capture does not apply the definition"); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); - assert!(st.is_dirty()); - let after = st.doc.clone(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - assert_eq!(s.doc().unwrap().selection, selection); - assert!(!s.doc().unwrap().is_dirty()); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, after); - assert_eq!(s.doc().unwrap().selection, selection); - } - - fn capture_boundary_ranges(redefine: bool) { - for (text, start, end, size) in [ - ("ABcdef", 2, 5, 34.0), - ("é漢字", 2, 5, 34.0), - ("ABcdef", 5, 2, 34.0), // command ranges are ordered before selected-character sampling - ("ABcdef", 1, 5, 18.0), // a mixed range samples its first selected character - ("ABcdef", 2, 2, 18.0), // collapsed range retains insertion affinity - ("ABcdef", 3, 3, 34.0), - ("ABcdef", 99, 100, 34.0), // beyond the text retains the final run - ] { - capture_boundary_command(redefine, text, start, Some(end), size); - } - } - - #[test] - fn char_style_new_range_capture_boundary() { - capture_boundary_ranges(false); - } - - #[test] - fn char_style_redefine_range_capture_boundary() { - capture_boundary_ranges(true); - } - - #[test] - fn char_style_start_only_capture_affinity_controls() { - for redefine in [false, true] { - for text in ["ABcdef", "é漢字"] { - for (start, size) in [(0, 18.0), (2, 18.0), (3, 34.0), (99, 34.0)] { - capture_boundary_command(redefine, text, start, None, size); - } - } - } - } - - #[test] - fn para_style_range_capture_samples_start_paragraph() { - for redefine in [false, true] { - for (start, end, justify) in [(0, 7, "Left"), (4, 7, "Right"), (4, 0, "Right")] { - let (mut s, id) = session_with_text("One\nTwo"); - s.execute("text.setStyle", &json!({"id": id, "justify": "left"})).unwrap(); - s.execute("text.setStyle", &json!({"id": id, "start": 4, "end": 7, "justify": "right"})).unwrap(); - if redefine { - s.execute("paraStyle.new", &json!({"name": "Captured", "attrs": {"justify": "Center"}})).unwrap(); - } - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let undo = s.doc().unwrap().history.undo.len(); - let command = if redefine { "paraStyle.redefine" } else { "paraStyle.new" }; - s.execute(command, &json!({"name": "Captured", "id": id, "start": start, "end": end})).unwrap(); - let st = s.doc().unwrap(); - let def = st.doc.para_styles.iter().find(|d| d.name == "Captured").unwrap(); - assert_eq!(def.attrs["justify"], json!(justify), "paragraph capture still samples start, not the end or a normalized range"); - assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id))); - assert_eq!(st.selection, selection); - assert_eq!(st.history.undo.len(), undo + 1); - let after = st.doc.clone(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, after); - } - } - } -} +mod tests; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs new file mode 100644 index 000000000..e322e814a --- /dev/null +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -0,0 +1,485 @@ +use super::*; + +fn session_with_text(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": text})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + (s, id) +} + +fn runs(s: &Session, id: u64) -> Vec { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!(), + } +} + +#[test] +fn char_style_new_apply_range_redefine_keeps_overrides() { + let (mut s, id) = session_with_text("Hello world"); + s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0, "underline": true}})).unwrap(); + // Style "world" only (a Type tool range). + s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11})).unwrap(); + let r = runs(&s, id); + assert_eq!(r.len(), 2); + assert_eq!((r[1].text.as_str(), r[1].style.size, r[1].style.underline), ("world", 24.0, true)); + assert_eq!(r[1].style.style_name.as_deref(), Some("Emphasis")); + assert_eq!(r[0].style.style_name, None); + // A local override on the styled run survives a redefinition; other attributes update. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 11, "size": 30.0})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Emphasis", "attrs": {"size": 18.0, "underline": false, "tracking": 50.0}})).unwrap(); + let r = runs(&s, id); + assert_eq!((r[1].style.size, r[1].style.underline, r[1].style.tracking), (30.0, false, 50.0)); + // Clear Overrides: back to exactly the style's attributes. + s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11, "clearOverrides": true})).unwrap(); + assert_eq!(runs(&s, id)[1].style.size, 18.0); + let list = s.execute("charStyle.list", &json!({})).unwrap(); + assert_eq!(list["styles"][0]["name"], NORMAL_CHAR); + assert_eq!(list["styles"][1]["uses"], 1); +} + +/// Applying and redefining a style record a step named from a template with the style's name +/// in it (`HistoryEntry::named`), and undo and redo keep it. The Normal styles' names are +/// marked as names VectorCraft gives. A new style's step has a name of its own. +#[test] +fn style_steps_are_named_from_a_template() { + let (mut s, id) = session_with_text("Hello"); + let last = |s: &Session| { + let e = s.doc().unwrap().history.undo.last().unwrap(); + (e.label.clone(), e.named().map(|n| (n.template, n.name.clone(), n.built_in))) + }; + let named = |template, name: &str, built_in| Some((template, name.to_string(), built_in)); + s.execute("charStyle.new", &json!({"name": "Body"})).unwrap(); + assert_eq!(last(&s), ("New Character Style".into(), None)); + s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); + assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); + s.execute("edit.undo", &json!({})).unwrap(); + let redo = s.doc().unwrap().history.redo.last().unwrap().named().cloned(); + assert_eq!(redo.map(|n| n.label()).as_deref(), Some("Apply Body")); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); + s.execute("charStyle.apply", &json!({"name": NORMAL_CHAR, "id": id})).unwrap(); + assert_eq!(last(&s), (format!("Apply {NORMAL_CHAR}"), named("Apply {name}", NORMAL_CHAR, true))); + s.execute("paraStyle.new", &json!({"name": "Lead"})).unwrap(); + assert_eq!(last(&s), ("New Paragraph Style".into(), None)); + s.execute("paraStyle.setAttrs", &json!({"name": "Lead", "attrs": {"direction": "rightToLeft"}})).unwrap(); + assert_eq!(last(&s), ("Redefine Lead".into(), named("Redefine {name}", "Lead", false))); + // A step renamed since it was recorded is no longer named from the template. + s.doc_mut().unwrap().history.undo.last_mut().unwrap().label = "Typing".into(); + assert_eq!(last(&s), ("Typing".into(), None)); +} + +#[test] +fn new_from_selection_rename_delete_and_undo() { + let (mut s, id) = session_with_text("Title"); + s.execute("text.setStyle", &json!({"size": 40})).unwrap(); + let name = s.execute("charStyle.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(name, "Character Style 1"); + assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs["size"], 40.0); + s.execute("charStyle.apply", &json!({"name": name})).unwrap(); + s.execute("charStyle.rename", &json!({"name": name, "to": "Heading"})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); + assert!(s.execute("charStyle.rename", &json!({"name": NORMAL_CHAR, "to": "x"})).is_err()); + s.execute("charStyle.duplicate", &json!({"name": "Heading"})).unwrap(); + s.execute("charStyle.delete", &json!({"name": "Heading"})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name, None); + assert_eq!(runs(&s, id)[0].style.size, 40.0, "deleting keeps the look"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); + assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"nope": 1}})).is_err()); + // Attributes are stored canonically (a hand-written colour matches the text's afterwards). + s.execute( + "charStyle.new", + &json!({"name": "Pink", "attrs": {"fill": {"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.4}}}}), + ) + .unwrap(); + s.execute("charStyle.apply", &json!({"name": "Pink"})).unwrap(); + let fill = serde_json::to_value(&runs(&s, id)[0].style.fill).unwrap(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); +} + +/// New Character Style and New Paragraph Style trim the name they are given, as a rename +/// trims `to`; the trimmed name is the one compared with the styles there are. +#[test] +fn new_styles_trim_their_names_as_a_rename_does() { + let (mut s, _) = session_with_text("Body text"); + for p in ["charStyle", "paraStyle"] { + assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}"); + let list = s.execute(&format!("{p}.list"), &json!({})).unwrap(); + let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect(); + assert_eq!(names[1..], ["Body"], "{p}"); + let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string(); + assert!(e.contains("a style named `Body` already exists"), "{p}: {e}"); + let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string(); + assert!(e.contains("give the style a name"), "{p}: {e}"); + assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}"); + } + // A style whose name has spaces around it, as a saved file can hold, is copied under the + // trimmed name, numbered past the copies there are. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() }); + s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap(); + assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2"); +} + +/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no +/// mojikumi) are still attributes a style can set, and a style captured with one applies to +/// text at the default, is duplicated and comes back out of a library. +#[test] +fn styles_take_attributes_left_out_at_their_defaults() { + let (mut s, id) = session_with_text("A"); + s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap(); + s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap(); + for bad in [json!({"nope": 1}), json!({"justify_auto": true})] { + assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}"); + } + // Captured from text with explicit leading, applied to fresh Auto-leading text. + s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap(); + let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, None); + for clear in [false, true] { + s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0)); + } + let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0); + s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap(); + let st = runs(&s, fresh)[0].style.clone(); + assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0))); + s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; + assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft))); + // A library keeps the captured attributes and makes the style again where it's used. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap(); + let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap(); + assert_eq!(runs(&s, other)[0].style.leading, Some(30.0)); +} + +#[test] +fn auto_leading_redefine_keeps_overrides_and_history() { + for redefine in [true, false] { + let (mut s, source) = session_with_text("Source"); + let [linked, local] = + ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); + let ids = [source, linked, local]; + s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"leading": 30}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [local], "leading": 42})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [source], "leading": "auto"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + if redefine { + s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + } else { + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": null}})).unwrap(); + } + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [None, None, Some(42.0)]); + assert!(ids.iter().all(|&id| runs(&s, id)[0].style.style_name.as_deref() == Some("Body"))); + let after = s.doc().unwrap().doc.clone(); + assert_eq!(after.char_styles[0].attrs.get("leading"), Some(&Value::Null), "Auto is explicit, not an unset attribute"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + let back = vectorcraft_format::load(&vectorcraft_format::save(&after, false)).unwrap(); + assert_eq!(back.char_styles, after.char_styles); + for id in ids { + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("not text") }; + assert_eq!(t.runs, runs(&s, id)); + } + // Auto users follow the next numeric definition; real numeric and Auto overrides stay. + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 26}})).unwrap(); + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(26.0), Some(26.0), Some(42.0)]); + s.execute("text.setStyle", &json!({"ids": [linked], "leading": "auto"})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 28}})).unwrap(); + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); + // Omitting leading replaces the definition without resetting the text's leading. + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"tracking": 20}})).unwrap(); + assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); + } +} + +/// The attribute names a style of type `T` can set, without the style name. +fn settable() -> std::collections::BTreeSet { + attr_names::().into_iter().filter(|&n| n != "style_name").map(String::from).collect() +} + +/// New and Redefine capture every paragraph attribute. A paragraph style redefined from a +/// paragraph at the defaults gives its paragraphs the defaults back where they had the old +/// definition's values, and `setAttrs` with a default value does too; local overrides, and +/// attributes `setAttrs` leaves out, stay. +#[test] +fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { + use vectorcraft_doc::{Composer, HyphenationOptions}; + let (mut s, source) = session_with_text("Source"); + let [linked, local] = + ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); + let ids = [source, linked, local]; + let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para_at(0).clone(), + _ => panic!("not text"), + }; + let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit); + let composers = |s: &Session| ids.map(|id| para(s, id).composer); + let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::>(); + s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap(); + s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3])); + assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default()); + assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body"))); + assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); + s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); + assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); + // Style options: a default value reaches the users too; an attribute left out stays as it is. + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3])); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3])); + assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"})); +} + +/// The same for character styles: Auto kerning and the Roman baseline alignment, which the +/// run's JSON leaves out, are captured and reach the style's users. +#[test] +fn char_style_redefined_to_the_defaults_updates_its_text() { + use vectorcraft_doc::CharAlign; + let (mut s, source) = session_with_text("Source"); + let [linked, local] = + ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); + let ids = [source, linked, local]; + let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning); + let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align); + let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::>(); + s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap(); + s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); + assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); + s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); + assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3])); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3])); + assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"})); +} + +/// A style captured from type without Variable Font settings sets none explicitly (`{}`): +/// applying it clears type's settings, and redefining it from type whose settings were reset +/// resets the type that uses it. +#[test] +fn variable_font_settings_are_captured_explicitly() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); + let (mut s, source) = session_with_text("Source"); + let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap(); + let ids = [source, linked]; + s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({}))); + s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied()); + assert_eq!(wght(&s), [None, None], "applying the style clears the settings"); + for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] { + s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap(); + s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!(wght(&s), [want, want], "redefined from {set}"); + } +} + +/// A style that changes the font, applied or redefined, leaves none of the old font's +/// version and Variable Font settings, unless the style sets them. +#[test] +fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); + let (mut s, id) = session_with_text("Source"); + let set_variable = |s: &mut Session| { + s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap(); + s.edit("version", |d, _| { + if let Some(t) = text_mut(d, NodeId(id)) { + t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into())); + } + Ok(()) + }) + .unwrap(); + }; + let font = |s: &Session| { + let st = runs(s, id)[0].style.clone(); + (st.font_family, st.font_version, st.font_axes.get("wght").copied()) + }; + set_variable(&mut s); + s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap(); + assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them"); + s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); + assert_eq!(font(&s), ("Inter".to_string(), None, None)); + // A style that sets the settings keeps its own. + s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap(); + assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0))); + // Redefined to another font: its users' settings and versions go too. + s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap(); + set_variable(&mut s); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans")); + s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap(); + assert_eq!(font(&s), ("Inter".to_string(), None, None)); +} + +#[test] +fn auto_leading_new_distinguishes_unset_attributes() { + let (mut s, id) = session_with_text("Title"); + s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Sparse", "attrs": {"tracking": 20}})).unwrap(); + assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); + s.execute("charStyle.apply", &json!({"name": "Sparse", "id": id})).unwrap(); + assert_eq!(runs(&s, id)[0].style.leading, Some(30.0)); + s.execute("charStyle.new", &json!({"name": "Automatic", "attrs": {"leading": null}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Automatic", "id": id})).unwrap(); + assert_eq!(runs(&s, id)[0].style.leading, None); + assert_eq!(s.doc().unwrap().doc.char_styles[1].attrs.get("leading"), Some(&Value::Null)); + // New from Auto text captures that value too, rather than leaving leading unspecified. + s.execute("charStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); + s.execute("text.setStyle", &json!({"ids": [id], "leading": 36})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Captured", "id": id})).unwrap(); + assert_eq!(runs(&s, id)[0].style.leading, None); + assert_eq!(s.doc().unwrap().doc.char_styles[2].attrs.get("leading"), Some(&Value::Null)); +} + +#[test] +fn paragraph_styles_and_survive_save() { + let (mut s, id) = session_with_text("Body"); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6.0}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Centered"})).unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!(), + }; + assert_eq!((para(&s).justify, para(&s).space_after), (vectorcraft_doc::Justify::Center, 6.0)); + // The Normal style can be redefined too (stored once redefined). + s.execute("paraStyle.redefine", &json!({"name": NORMAL_PARA})).unwrap(); + assert_eq!(s.execute("paraStyle.list", &json!({})).unwrap()["styles"][0]["attrs"]["justify"], "Center"); + let d = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert_eq!(back.para_styles, d.para_styles); +} + +fn capture_boundary_command(redefine: bool, text: &str, start: usize, end: Option, expected_size: f64) { + let (mut s, id) = session_with_text(text); + s.execute("text.setStyle", &json!({"id": id, "size": 18})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": text.len(), "size": 34})).unwrap(); + if redefine { + s.execute("charStyle.new", &json!({"name": "Captured", "attrs": {"size": 24}})).unwrap(); + } + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let mut params = json!({"name": "Captured", "id": id, "start": start}); + if let Some(end) = end { + params["end"] = json!(end); + } + let command = if redefine { "charStyle.redefine" } else { "charStyle.new" }; + s.execute(command, ¶ms).unwrap(); + let st = s.doc().unwrap(); + let def = st.doc.char_styles.iter().find(|d| d.name == "Captured").unwrap(); + assert_eq!(def.attrs.get("size").and_then(Value::as_f64), Some(expected_size), "{command} {text:?} {start}..{end:?}"); + assert!(!def.attrs.contains_key("style_name")); + assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id)), "capture does not apply the definition"); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); + assert!(st.is_dirty()); + let after = st.doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().selection, selection); + assert!(!s.doc().unwrap().is_dirty()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.doc().unwrap().selection, selection); +} + +fn capture_boundary_ranges(redefine: bool) { + for (text, start, end, size) in [ + ("ABcdef", 2, 5, 34.0), + ("é漢字", 2, 5, 34.0), + ("ABcdef", 5, 2, 34.0), // command ranges are ordered before selected-character sampling + ("ABcdef", 1, 5, 18.0), // a mixed range samples its first selected character + ("ABcdef", 2, 2, 18.0), // collapsed range retains insertion affinity + ("ABcdef", 3, 3, 34.0), + ("ABcdef", 99, 100, 34.0), // beyond the text retains the final run + ] { + capture_boundary_command(redefine, text, start, Some(end), size); + } +} + +#[test] +fn char_style_new_range_capture_boundary() { + capture_boundary_ranges(false); +} + +#[test] +fn char_style_redefine_range_capture_boundary() { + capture_boundary_ranges(true); +} + +#[test] +fn char_style_start_only_capture_affinity_controls() { + for redefine in [false, true] { + for text in ["ABcdef", "é漢字"] { + for (start, size) in [(0, 18.0), (2, 18.0), (3, 34.0), (99, 34.0)] { + capture_boundary_command(redefine, text, start, None, size); + } + } + } +} + +#[test] +fn para_style_range_capture_samples_start_paragraph() { + for redefine in [false, true] { + for (start, end, justify) in [(0, 7, "Left"), (4, 7, "Right"), (4, 0, "Right")] { + let (mut s, id) = session_with_text("One\nTwo"); + s.execute("text.setStyle", &json!({"id": id, "justify": "left"})).unwrap(); + s.execute("text.setStyle", &json!({"id": id, "start": 4, "end": 7, "justify": "right"})).unwrap(); + if redefine { + s.execute("paraStyle.new", &json!({"name": "Captured", "attrs": {"justify": "Center"}})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let command = if redefine { "paraStyle.redefine" } else { "paraStyle.new" }; + s.execute(command, &json!({"name": "Captured", "id": id, "start": start, "end": end})).unwrap(); + let st = s.doc().unwrap(); + let def = st.doc.para_styles.iter().find(|d| d.name == "Captured").unwrap(); + assert_eq!(def.attrs["justify"], json!(justify), "paragraph capture still samples start, not the end or a normalized range"); + assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id))); + assert_eq!(st.selection, selection); + assert_eq!(st.history.undo.len(), undo + 1); + let after = st.doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + } + } +} From 8708a107f715b2ebb5e78290852cd3441c8aed43 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:02 +0000 Subject: [PATCH 050/199] refactor(ui-egui): move cursors.rs tests to cursors/tests.rs --- crates/ui-egui/src/cursors.rs | 485 +--------------------------- crates/ui-egui/src/cursors/tests.rs | 482 +++++++++++++++++++++++++++ 2 files changed, 483 insertions(+), 484 deletions(-) create mode 100644 crates/ui-egui/src/cursors/tests.rs diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index bfb28ef4d..b87e68c7b 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -767,487 +767,4 @@ fn fill_triangle(px: &mut [[f32; 4]], w: usize, t: [(Vec2, Color32); 3]) { } #[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - use serde_json::json; - use vectorcraft_engine::Session; - - use crate::VectorcraftApp; - - /// The straight RGBA of pixel (x, y). - fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] { - let i = (y * img.size[0] as usize + x) * 4; - [img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]] - } - - /// The Anchor Point caret's hotspot is the middle of its hollow square: white there, with the - /// square's ink a few points above it. - #[test] - fn anchor_point_hotspot_is_inside_its_square() { - for ppp in [1.0f32, 2.0] { - let img = rasterize(Cursor::AnchorPoint, ppp).expect("anchor point has a bitmap"); - let [hx, hy] = img.hotspot.map(usize::from); - assert_eq!(pixel(&img, hx, hy), [255, 255, 255, 255], "hollow at the hotspot at {ppp}x"); - let reach = (4.0 * ppp).round() as usize; - let inked = (1..=reach.min(hy)).any(|d| { - let p = pixel(&img, hx, hy - d); - p[0] < 64 && p[3] > 200 - }); - assert!(inked, "ink on the square's top edge within 4 points above the hotspot at {ppp}x"); - } - } - - /// Direct Selection's anchor square is hollow over an unselected path and filled over a - /// selected one; the arrow stays white. The Reshape Segment and copy arrowheads are black and - /// Group Selection's arrow white, just inside their tips. - #[test] - fn selection_arrows_are_white_or_black() { - let at = |img: &CustomCursorImage, x: f32, y: f32, ppp: f32| { - let [hx, hy] = img.hotspot.map(usize::from); - pixel(img, hx + (x * ppp).round() as usize, hy + (y * ppp).round() as usize) - }; - for ppp in [1.0f32, 2.0] { - for c in [Cursor::DirectAnchor, Cursor::DirectAnchorSelected] { - let img = rasterize(c, ppp).expect("a bitmap"); - let tip = at(&img, 1.5, 6.0, ppp); - assert!(tip[0] > 192 && tip[3] > 200, "{c:?}'s arrow is white at {ppp}x: {tip:?}"); - } - // Same arrow, so the filled square shows as more ink than the hollow one. - let ink = |c| { - let img = rasterize(c, ppp).expect("a bitmap"); - img.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 64 && p[3] > 200).count() - }; - let (hollow, filled) = (ink(Cursor::DirectAnchor), ink(Cursor::DirectAnchorSelected)); - assert!(filled > hollow, "the selected path's square is filled at {ppp}x: {filled} vs {hollow}"); - for (c, white) in [(Cursor::ReshapeSegment, false), (Cursor::SelectionCopy, false), (Cursor::GroupSelection, true)] { - let img = rasterize(c, ppp).expect("a bitmap"); - let tip = at(&img, 1.5, 6.0, ppp); - assert_eq!(tip[0] > 192, white, "{c:?} at {ppp}x: {tip:?}"); - assert!(tip[3] > 200, "{c:?} at {ppp}x: {tip:?}"); - } - } - } - - #[test] - fn a_cursor_not_drawn_yet_looks_like_the_one_it_borrows() { - for c in GLYPHS { - let Some(stand_in) = c.stand_in() else { continue }; - assert_eq!(rasterize(c, 2.0), rasterize(stand_in, 2.0), "{c:?} borrows {stand_in:?}"); - } - } - - /// Loaded text after a click on the overflow out port is a text-block glyph, not the I-beam. - #[test] - fn loaded_text_cursor_is_not_the_text_i_beam() { - assert!(Cursor::LoadedText.stand_in().is_none()); - let loaded = rasterize(Cursor::LoadedText, 2.0).expect("loaded text bitmap"); - let ibeam = rasterize(Cursor::Text, 2.0).expect("text bitmap"); - assert_ne!(loaded.rgba, ibeam.rgba); - let [hx, hy] = loaded.hotspot.map(usize::from); - assert!(pixel(&loaded, hx, hy)[3] > 200, "hotspot lies on the drawn frame"); - } - - #[test] - fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() { - for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] { - for c in GLYPHS { - let img = rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}")); - let [w, h] = img.size.map(usize::from); - assert_eq!(img.rgba.len(), w * h * 4, "{c:?}"); - assert!(img.hotspot[0] < img.size[0] && img.hotspot[1] < img.size[1], "{c:?} at {ppp}: {img:?}"); - assert!(w as f32 <= 50.0 * ppp && h as f32 <= 50.0 * ppp, "{c:?} at {ppp}: {img:?}"); - assert!(img.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 255), "{c:?}: nothing opaque"); - } - // #1269: the normal Eyedropper is a pipette, with its tip at the hotspot; only - // Use Precise Cursors turns it into a crosshair. - let dropper = rasterize(Cursor::Eyedropper, ppp).unwrap(); - let precise = rasterize(Cursor::Eyedropper.precise(), ppp).unwrap(); - assert_ne!(dropper, precise, "Eyedropper must be distinguishable from its precise cursor at {ppp}"); - let [hx, hy] = dropper.hotspot.map(usize::from); - let halo = (3.0 * ppp).ceil(); // The bitmap bounds round out to whole physical pixels. - assert!(hx as f32 <= halo && f32::from(dropper.size[1]) - hy as f32 <= halo, "the tip is at the lower-left: {dropper:?}"); - assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}"); - } - assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors"); - assert!(rasterize(Cursor::Busy, 1.0).is_none()); - assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor"); - for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] { - assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}"); - } - } - - #[test] - fn the_arrow_bitmap_is_black_in_a_white_halo_with_the_tip_on_the_hotspot() { - let img = rasterize(Cursor::Arrow, 1.0).unwrap(); - let [hx, hy] = img.hotspot.map(usize::from); - // The tip covers the hotspot's pixel; the body is solid black, the outline's halo white. - assert!(pixel(&img, hx, hy)[3] > 0, "{:?}", pixel(&img, hx, hy)); - let body = pixel(&img, hx + 2, hy + 8); - assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}"); - assert!(img.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[..3].iter().all(|&c| c > 215)), "no white halo"); - // Nothing left of or above the tip but the halo. - assert!((0..img.size[1] as usize).all(|y| pixel(&img, 0, y)[..3].iter().all(|&c| c > 100) || pixel(&img, 0, y)[3] < 128)); - // At twice the pixels per point, twice the pixels. - let big = rasterize(Cursor::Arrow, 2.0).unwrap(); - for i in 0..2 { - let ratio = f32::from(big.size[i]) / f32::from(img.size[i]); - assert!((1.8..=2.2).contains(&ratio), "{:?} vs {:?}", big.size, img.size); - } - let [bx, by] = big.hotspot.map(usize::from); - let body = pixel(&big, bx + 4, by + 16); - assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}"); - } - - /// The pen nib is a fountain pen's lying along the selection arrow's axis: its point on the - /// hotspot, the slit running back along it into the round breather hole, the heel clipped - /// at the back. - #[test] - fn the_pen_nib_has_a_slit_and_a_breather_hole() { - for ppp in [1.0f32, 2.0] { - let img = rasterize(Cursor::PenDrawing, ppp).expect("a bitmap"); - let [hx, hy] = img.hotspot.map(usize::from); - let at = |x: f32, y: f32| { - let (px, py) = ((hx as f32 + x * ppp) as usize, (hy as f32 + y * ppp) as usize); - if px < img.size[0] as usize && py < img.size[1] as usize { pixel(&img, px, py) } else { [0; 4] } - }; - // The breather hole, ~12 points along the axis ((4.6, 10.9) on screen): fully white. - let hole = at(4.6, 10.9); - assert!(hole[3] > 200 && hole[0] > 192, "the breather hole at {ppp}: {hole:?}"); - // The slit on the way to the hole: white over a black field. - let slit = at(2.4, 5.5); - assert!(slit[3] > 40 && slit[0] > 128, "the slit at {ppp}: {slit:?}"); - // The body past the hole is solid ink. - let body = at(5.5, 12.9); - assert!(body[3] > 200 && body[0] < 64, "the nib's body at {ppp}: {body:?}"); - // The heel's corners carry ink out near the bitmap's edge. - for (x, y) in [(8.8f32, 13.1), (3.3, 15.4)] { - let corner = at(x, y); - assert!(corner[3] > 0, "a heel corner at {ppp} ({x}, {y}): {corner:?}"); - } - } - } - - /// The Pen drawing a path is the same nib as the new-path Pen, without the asterisk. - #[test] - fn the_drawing_pen_has_no_new_path_asterisk() { - for ppp in [1.0, 2.0] { - let (new, drawing) = (rasterize(Cursor::Pen, ppp).unwrap(), rasterize(Cursor::PenDrawing, ppp).unwrap()); - assert_eq!(new.hotspot, drawing.hotspot, "at {ppp}"); - // The asterisk's center, 16 × 15 points from the hotspot. - let at = |img: &CustomCursorImage| { - let [hx, hy] = img.hotspot.map(usize::from); - let (x, y) = (hx + (16.0 * ppp) as usize, hy + (15.0 * ppp) as usize); - if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y)[3] } else { 0 } - }; - assert_eq!(at(&new), 255, "the new-path Pen has its asterisk at {ppp}"); - assert_eq!(at(&drawing), 0, "the drawing Pen has no badge at {ppp}"); - // The nib is the same. - let [hx, hy] = drawing.hotspot.map(usize::from); - let nib = (hx + (8.0 * ppp) as usize, hy + (8.0 * ppp) as usize); - assert_eq!(pixel(&new, nib.0, nib.1), pixel(&drawing, nib.0, nib.1), "at {ppp}"); - } - } - - /// The Pencil's and the Paintbrush's closing cursors are the crosshair those tools show, with a - /// ring below right. - #[test] - fn the_closing_cursors_are_the_crosshair_with_a_ring() { - for ppp in [1.0, 2.0] { - let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); - for c in [Cursor::PencilClose, Cursor::PaintbrushClose] { - let img = rasterize(c, ppp).unwrap(); - assert_eq!(img.hotspot, cross.hotspot, "{c:?} at {ppp}"); - let [hx, hy] = img.hotspot.map(usize::from); - let at = |img: &CustomCursorImage, dx: f32, dy: f32| { - let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); - if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } - }; - // Black pixels in the square 4 to 12 points right of and below the hotspot, clear of the arms. - let ring_ink = |img: &CustomCursorImage| { - let steps = (8.0 * ppp) as usize; - (0..steps * steps) - .filter(|i| { - let p = at(img, 4.0 + (i % steps) as f32 / ppp, 4.0 + (i / steps) as f32 / ppp); - p[3] > 200 && p[0] < 64 - }) - .count() - }; - assert!(ring_ink(&img) > 0, "{c:?} has its ring at {ppp}"); - assert_eq!(ring_ink(&cross), 0, "the crosshair has no ring at {ppp}"); - assert_eq!(at(&img, 8.0, 8.0)[3], 0, "{c:?}'s ring is hollow at {ppp}"); - // The arms are the crosshair's. - assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "{c:?} at {ppp}"); - } - } - } - - /// The Pencil's new-path cursor is the crosshair with a small star below right, where the - /// closing cursor's ring is hollow and the straight-line cursor has a single line. - #[test] - fn the_pencil_new_path_cursor_is_the_crosshair_with_a_star() { - for ppp in [1.0, 2.0] { - let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); - let img = rasterize(Cursor::PencilNew, ppp).unwrap(); - let ring = rasterize(Cursor::PencilClose, ppp).unwrap(); - let straight = rasterize(Cursor::PencilStraight, ppp).unwrap(); - assert_eq!(img.hotspot, cross.hotspot, "at {ppp}"); - let at = |img: &CustomCursorImage, dx: f32, dy: f32| { - let [hx, hy] = img.hotspot.map(usize::from); - let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); - if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } - }; - let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; - assert!(inked(at(&img, 8.5, 8.5)), "the star's middle at {ppp}"); - assert_eq!(at(&ring, 8.5, 8.5)[3], 0, "the closing ring is hollow there at {ppp}"); - assert_eq!(at(&cross, 8.5, 8.5)[3], 0, "the crosshair has no badge at {ppp}"); - // Three strokes cross at the middle: the diagonal the straight-line cursor lacks, and the - // upright one. - assert!(inked(at(&img, 5.5, 5.5)) && !inked(at(&straight, 5.5, 5.5)), "the second diagonal at {ppp}"); - // At 1 point per pixel the upright stroke covers two pixel columns in gray. - let dark = |p: [u8; 4]| p[3] > 200 && p[0] < 128; - let upright = |img: &CustomCursorImage, dy: f32| [7.5, 8.0, 8.5].iter().any(|&dx| dark(at(img, dx, dy))); - assert!(upright(&img, 4.5) && upright(&img, 11.5), "the upright stroke at {ppp}"); - assert!(!upright(&straight, 4.5) && !upright(&straight, 11.5), "the straight line has none at {ppp}"); - assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); - } - } - - /// The Pencil's straight-line cursor is the crosshair with a short straight line below right, - /// through the point where the closing cursor's ring is hollow. - #[test] - fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() { - for ppp in [1.0, 2.0] { - let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); - let img = rasterize(Cursor::PencilStraight, ppp).unwrap(); - let ring = rasterize(Cursor::PencilClose, ppp).unwrap(); - assert_eq!(img.hotspot, cross.hotspot, "at {ppp}"); - let at = |img: &CustomCursorImage, dx: f32, dy: f32| { - let [hx, hy] = img.hotspot.map(usize::from); - let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); - if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } - }; - let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; - assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}"); - assert_eq!(at(&ring, 8.5, 8.5)[3], 0, "the closing ring is hollow there at {ppp}"); - assert_eq!(at(&cross, 8.5, 8.5)[3], 0, "the crosshair has no badge at {ppp}"); - // Along the line, not across it: no ink off its ends. - assert!(!inked(at(&img, 5.0, 5.0)) && !inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); - assert_ne!(img, ring, "at {ppp}"); - assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); - } - } - - /// The Pencil's continuation cursor is the straight-line cursor with a filled square on the - /// line's lower end, beside the line where the straight-line cursor has no ink. - #[test] - fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point() { - for ppp in [1.0, 2.0] { - let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); - let img = rasterize(Cursor::PencilContinue, ppp).unwrap(); - let straight = rasterize(Cursor::PencilStraight, ppp).unwrap(); - assert_eq!(img.hotspot, cross.hotspot, "at {ppp}"); - let at = |img: &CustomCursorImage, dx: f32, dy: f32| { - let [hx, hy] = img.hotspot.map(usize::from); - let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); - if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } - }; - let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; - assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}"); - assert!(inked(at(&img, 3.5, 13.5)), "the end point's square at {ppp}"); - assert!(!inked(at(&straight, 3.5, 13.5)), "the straight-line cursor has no end point at {ppp}"); - assert!(!inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); - assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); - } - } - - #[test] - fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() { - let mut images = Images::default(); - let a = images.get(Cursor::Pen, 1.5).unwrap(); - let b = images.get(Cursor::Pen, 1.5).unwrap(); - assert!(Arc::ptr_eq(&a.rgba, &b.rgba)); - assert!(!Arc::ptr_eq(&a.rgba, &images.get(Cursor::Pen, 2.0).unwrap().rgba)); - assert!(images.get(Cursor::NotAllowed, 1.5).is_none()); - assert!(images.get(Cursor::Arrow, f32::NAN).is_none()); - assert_eq!(images.0.len(), 2); - } - - /// One headless 800×600 frame of the whole window, a tenth of a second after the last. - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { - let time = Some(ctx.input(|i| i.time) + 0.1); - let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time, events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - out - } - - /// One [`frame`] with `focused` set: true for a window that has the focus, false while another app - /// or a sheet has it. - fn focus_frame(app: &mut VectorcraftApp, ctx: &egui::Context, focused: bool, events: Vec) -> egui::FullOutput { - let time = Some(ctx.input(|i| i.time) + 0.1); - let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); - let raw = egui::RawInput { screen_rect, time, focused, events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - out - } - - #[test] - fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - for _ in 0..3 { - frame(&mut app, &ctx, vec![]); - } - let canvas = app.canvas_rect.unwrap(); - let over = canvas.center(); - let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); - if OS_CURSORS { - let img = out.platform_output.cursor_image.as_ref().expect("the Selection tool's arrow as an OS cursor"); - assert_eq!(img, &app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap()); - // Where the OS can't show the bitmap, its system cursor. - assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default); - } else { - assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None); - } - // The same bitmap while the pointer moves (the OS keeps its cursor), and an idle window - // once it rests: no frame is asked for. - let next = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over + vec2(5.0, 3.0))]); - if OS_CURSORS { - assert!(Arc::ptr_eq(&next.platform_output.cursor_image.unwrap().rgba, &out.platform_output.cursor_image.unwrap().rgba)); - } - let mut idle = frame(&mut app, &ctx, vec![]); - for _ in 0..3 { - idle = frame(&mut app, &ctx, vec![]); - } - let delay = idle.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay).unwrap(); - assert!(delay > std::time::Duration::from_millis(100), "{delay:?} {:?}", ctx.repaint_causes()); - // Off the canvas, the panels' cursors. - let off = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(canvas.left() - 20.0, canvas.center().y))]); - assert!(off.platform_output.cursor_image.is_none()); - } - - /// On Wayland the glyph is painted and the pointer hidden over the canvas, on every build (#1645). - #[test] - fn a_wayland_window_paints_the_tool_cursor() { - let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, ..Default::default() }); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - for _ in 0..3 { - frame(&mut app, &ctx, vec![]); - } - let over = app.canvas_rect.unwrap().center(); - let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); - assert!(out.platform_output.cursor_image.is_none()); - assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None); - } - - /// Without a cursor theme the canvas keeps the system pointer visible (#2450). - #[test] - fn no_cursor_theme_keeps_the_system_pointer() { - assert!(!hide_system_pointer(true)); - assert!(hide_system_pointer(false)); - let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, no_cursor_theme: true, ..Default::default() }); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let ctx = egui::Context::default(); - for _ in 0..3 { - frame(&mut app, &ctx, vec![]); - } - let over = app.canvas_rect.unwrap().center(); - let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); - assert_ne!(out.platform_output.cursor_icon, egui::CursorIcon::None); - } - - /// On macOS a window without the focus shows the system pointer over the canvas, and the glyph - /// is not painted next to it (#2020). With the focus back, the canvas sets `CursorIcon::None` again - /// on the next frame, without a pointer move. - #[test] - fn a_window_without_the_focus_shows_the_system_cursor_alone_on_macos() { - // Wayland: every desktop build paints the glyphs. - let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, ..Default::default() }); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("tool.select", json!({"tool": "rectangle"})).unwrap(); - let ctx = egui::Context::default(); - for _ in 0..3 { - frame(&mut app, &ctx, vec![]); - } - let over = app.canvas_rect.unwrap().center(); - let key = focus_frame(&mut app, &ctx, true, vec![egui::Event::PointerMoved(over)]); - assert_eq!(key.platform_output.cursor_icon, egui::CursorIcon::None, "with the focus: the glyph alone"); - let away = focus_frame(&mut app, &ctx, false, vec![egui::Event::WindowFocused(false), egui::Event::PointerMoved(over + vec2(5.0, 3.0))]); - if HIDES_ONLY_WHEN_KEY { - assert_eq!(away.platform_output.cursor_icon, egui::CursorIcon::Crosshair, "macOS without the focus: the system pointer alone"); - } else { - assert_eq!(away.platform_output.cursor_icon, egui::CursorIcon::None, "other platforms: the glyph without the focus too"); - } - let back = focus_frame(&mut app, &ctx, true, vec![egui::Event::WindowFocused(true)]); - assert_eq!(back.platform_output.cursor_icon, egui::CursorIcon::None, "the focus back: the glyph alone"); - } - - /// User Interface › Scale Cursor Proportional to UI (#394): off, the tool cursors keep their - /// size whatever the UI Scaling; on, they grow with it. - #[test] - fn tool_cursors_scale_with_the_ui_only_when_asked() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("prefs.set", json!({"key": "uiScaling", "value": 2.0})).unwrap(); - let ctx = egui::Context::default(); - for _ in 0..3 { - frame(&mut app, &ctx, vec![]); - } - assert_eq!(ctx.zoom_factor(), 2.0); - let over = app.canvas_rect.unwrap().center(); - let shown = - |app: &mut VectorcraftApp, d: f32| frame(app, &ctx, vec![egui::Event::PointerMoved(over + vec2(d, 0.0))]).platform_output.cursor_image; - if OS_CURSORS { - assert_eq!(shown(&mut app, 0.0).unwrap(), app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap(), "off: its own size"); - app.run("prefs.set", json!({"key": "scaleCursorWithUi", "value": true})).unwrap(); - assert_eq!(shown(&mut app, 4.0).unwrap(), app.canvas.cursors.get(Cursor::Arrow, 2.0).unwrap(), "on: twice as big"); - } - // Painted into the window (macOS, the web), the glyph is drawn at the scale asked for, its - // hotspot where the pointer is. - let painted = |scale: f32| { - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("probe")); - assert!(paint(&ui.ctx().layer_painter(layer), Cursor::Arrow, pos2(100.0, 100.0), scale)); - }); - out.textures_delta.clear(); - out.textures_delta.clear(); - out.shapes.iter().fold(egui::Rect::NOTHING, |r, c| r.union(c.shape.visual_bounding_rect())) - }; - let (full, half) = (painted(1.0), painted(0.5)); - assert!((half.height() * 2.0 - full.height()).abs() < 1.5 && full.height() > 15.0, "{full:?} {half:?}"); - assert!(full.contains(pos2(100.0, 100.0)) && half.contains(pos2(100.0, 100.0)), "{full:?} {half:?}"); - } - - #[test] - fn wayland_windows_paint_the_glyphs() { - assert!(os_cursors(true, false), "Windows, X11"); - assert!(!os_cursors(true, true), "Wayland: the compositor would size the bitmap (#1645)"); - assert!(!os_cursors(false, false) && !os_cursors(false, true), "macOS, the web"); - } - - #[test] - fn macos_windows_paint_the_glyphs_only_with_the_focus() { - assert!(paint_glyphs(true, true), "macOS with the focus"); - assert!(!paint_glyphs(true, false), "macOS without the focus: the system pointer shows (#2020)"); - assert!(paint_glyphs(false, true) && paint_glyphs(false, false), "Wayland, the web: painted with the focus or without"); - } - - #[test] - fn arrow_hotspot_is_tip() { - let pts = arrow_points(pos2(10.0, 20.0)); - assert_eq!(pts[0], pos2(10.0, 20.0)); - assert!(pts.iter().all(|q| q.x >= 10.0 && q.y >= 20.0)); - } -} +mod tests; diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs new file mode 100644 index 000000000..8319fe7c5 --- /dev/null +++ b/crates/ui-egui/src/cursors/tests.rs @@ -0,0 +1,482 @@ +use std::sync::Arc; + +use super::*; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; + +/// The straight RGBA of pixel (x, y). +fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] { + let i = (y * img.size[0] as usize + x) * 4; + [img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]] +} + +/// The Anchor Point caret's hotspot is the middle of its hollow square: white there, with the +/// square's ink a few points above it. +#[test] +fn anchor_point_hotspot_is_inside_its_square() { + for ppp in [1.0f32, 2.0] { + let img = rasterize(Cursor::AnchorPoint, ppp).expect("anchor point has a bitmap"); + let [hx, hy] = img.hotspot.map(usize::from); + assert_eq!(pixel(&img, hx, hy), [255, 255, 255, 255], "hollow at the hotspot at {ppp}x"); + let reach = (4.0 * ppp).round() as usize; + let inked = (1..=reach.min(hy)).any(|d| { + let p = pixel(&img, hx, hy - d); + p[0] < 64 && p[3] > 200 + }); + assert!(inked, "ink on the square's top edge within 4 points above the hotspot at {ppp}x"); + } +} + +/// Direct Selection's anchor square is hollow over an unselected path and filled over a +/// selected one; the arrow stays white. The Reshape Segment and copy arrowheads are black and +/// Group Selection's arrow white, just inside their tips. +#[test] +fn selection_arrows_are_white_or_black() { + let at = |img: &CustomCursorImage, x: f32, y: f32, ppp: f32| { + let [hx, hy] = img.hotspot.map(usize::from); + pixel(img, hx + (x * ppp).round() as usize, hy + (y * ppp).round() as usize) + }; + for ppp in [1.0f32, 2.0] { + for c in [Cursor::DirectAnchor, Cursor::DirectAnchorSelected] { + let img = rasterize(c, ppp).expect("a bitmap"); + let tip = at(&img, 1.5, 6.0, ppp); + assert!(tip[0] > 192 && tip[3] > 200, "{c:?}'s arrow is white at {ppp}x: {tip:?}"); + } + // Same arrow, so the filled square shows as more ink than the hollow one. + let ink = |c| { + let img = rasterize(c, ppp).expect("a bitmap"); + img.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 64 && p[3] > 200).count() + }; + let (hollow, filled) = (ink(Cursor::DirectAnchor), ink(Cursor::DirectAnchorSelected)); + assert!(filled > hollow, "the selected path's square is filled at {ppp}x: {filled} vs {hollow}"); + for (c, white) in [(Cursor::ReshapeSegment, false), (Cursor::SelectionCopy, false), (Cursor::GroupSelection, true)] { + let img = rasterize(c, ppp).expect("a bitmap"); + let tip = at(&img, 1.5, 6.0, ppp); + assert_eq!(tip[0] > 192, white, "{c:?} at {ppp}x: {tip:?}"); + assert!(tip[3] > 200, "{c:?} at {ppp}x: {tip:?}"); + } + } +} + +#[test] +fn a_cursor_not_drawn_yet_looks_like_the_one_it_borrows() { + for c in GLYPHS { + let Some(stand_in) = c.stand_in() else { continue }; + assert_eq!(rasterize(c, 2.0), rasterize(stand_in, 2.0), "{c:?} borrows {stand_in:?}"); + } +} + +/// Loaded text after a click on the overflow out port is a text-block glyph, not the I-beam. +#[test] +fn loaded_text_cursor_is_not_the_text_i_beam() { + assert!(Cursor::LoadedText.stand_in().is_none()); + let loaded = rasterize(Cursor::LoadedText, 2.0).expect("loaded text bitmap"); + let ibeam = rasterize(Cursor::Text, 2.0).expect("text bitmap"); + assert_ne!(loaded.rgba, ibeam.rgba); + let [hx, hy] = loaded.hotspot.map(usize::from); + assert!(pixel(&loaded, hx, hy)[3] > 200, "hotspot lies on the drawn frame"); +} + +#[test] +fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() { + for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] { + for c in GLYPHS { + let img = rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}")); + let [w, h] = img.size.map(usize::from); + assert_eq!(img.rgba.len(), w * h * 4, "{c:?}"); + assert!(img.hotspot[0] < img.size[0] && img.hotspot[1] < img.size[1], "{c:?} at {ppp}: {img:?}"); + assert!(w as f32 <= 50.0 * ppp && h as f32 <= 50.0 * ppp, "{c:?} at {ppp}: {img:?}"); + assert!(img.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 255), "{c:?}: nothing opaque"); + } + // #1269: the normal Eyedropper is a pipette, with its tip at the hotspot; only + // Use Precise Cursors turns it into a crosshair. + let dropper = rasterize(Cursor::Eyedropper, ppp).unwrap(); + let precise = rasterize(Cursor::Eyedropper.precise(), ppp).unwrap(); + assert_ne!(dropper, precise, "Eyedropper must be distinguishable from its precise cursor at {ppp}"); + let [hx, hy] = dropper.hotspot.map(usize::from); + let halo = (3.0 * ppp).ceil(); // The bitmap bounds round out to whole physical pixels. + assert!(hx as f32 <= halo && f32::from(dropper.size[1]) - hy as f32 <= halo, "the tip is at the lower-left: {dropper:?}"); + assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}"); + } + assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors"); + assert!(rasterize(Cursor::Busy, 1.0).is_none()); + assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor"); + for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] { + assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}"); + } +} + +#[test] +fn the_arrow_bitmap_is_black_in_a_white_halo_with_the_tip_on_the_hotspot() { + let img = rasterize(Cursor::Arrow, 1.0).unwrap(); + let [hx, hy] = img.hotspot.map(usize::from); + // The tip covers the hotspot's pixel; the body is solid black, the outline's halo white. + assert!(pixel(&img, hx, hy)[3] > 0, "{:?}", pixel(&img, hx, hy)); + let body = pixel(&img, hx + 2, hy + 8); + assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}"); + assert!(img.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[..3].iter().all(|&c| c > 215)), "no white halo"); + // Nothing left of or above the tip but the halo. + assert!((0..img.size[1] as usize).all(|y| pixel(&img, 0, y)[..3].iter().all(|&c| c > 100) || pixel(&img, 0, y)[3] < 128)); + // At twice the pixels per point, twice the pixels. + let big = rasterize(Cursor::Arrow, 2.0).unwrap(); + for i in 0..2 { + let ratio = f32::from(big.size[i]) / f32::from(img.size[i]); + assert!((1.8..=2.2).contains(&ratio), "{:?} vs {:?}", big.size, img.size); + } + let [bx, by] = big.hotspot.map(usize::from); + let body = pixel(&big, bx + 4, by + 16); + assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}"); +} + +/// The pen nib is a fountain pen's lying along the selection arrow's axis: its point on the +/// hotspot, the slit running back along it into the round breather hole, the heel clipped +/// at the back. +#[test] +fn the_pen_nib_has_a_slit_and_a_breather_hole() { + for ppp in [1.0f32, 2.0] { + let img = rasterize(Cursor::PenDrawing, ppp).expect("a bitmap"); + let [hx, hy] = img.hotspot.map(usize::from); + let at = |x: f32, y: f32| { + let (px, py) = ((hx as f32 + x * ppp) as usize, (hy as f32 + y * ppp) as usize); + if px < img.size[0] as usize && py < img.size[1] as usize { pixel(&img, px, py) } else { [0; 4] } + }; + // The breather hole, ~12 points along the axis ((4.6, 10.9) on screen): fully white. + let hole = at(4.6, 10.9); + assert!(hole[3] > 200 && hole[0] > 192, "the breather hole at {ppp}: {hole:?}"); + // The slit on the way to the hole: white over a black field. + let slit = at(2.4, 5.5); + assert!(slit[3] > 40 && slit[0] > 128, "the slit at {ppp}: {slit:?}"); + // The body past the hole is solid ink. + let body = at(5.5, 12.9); + assert!(body[3] > 200 && body[0] < 64, "the nib's body at {ppp}: {body:?}"); + // The heel's corners carry ink out near the bitmap's edge. + for (x, y) in [(8.8f32, 13.1), (3.3, 15.4)] { + let corner = at(x, y); + assert!(corner[3] > 0, "a heel corner at {ppp} ({x}, {y}): {corner:?}"); + } + } +} + +/// The Pen drawing a path is the same nib as the new-path Pen, without the asterisk. +#[test] +fn the_drawing_pen_has_no_new_path_asterisk() { + for ppp in [1.0, 2.0] { + let (new, drawing) = (rasterize(Cursor::Pen, ppp).unwrap(), rasterize(Cursor::PenDrawing, ppp).unwrap()); + assert_eq!(new.hotspot, drawing.hotspot, "at {ppp}"); + // The asterisk's center, 16 × 15 points from the hotspot. + let at = |img: &CustomCursorImage| { + let [hx, hy] = img.hotspot.map(usize::from); + let (x, y) = (hx + (16.0 * ppp) as usize, hy + (15.0 * ppp) as usize); + if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y)[3] } else { 0 } + }; + assert_eq!(at(&new), 255, "the new-path Pen has its asterisk at {ppp}"); + assert_eq!(at(&drawing), 0, "the drawing Pen has no badge at {ppp}"); + // The nib is the same. + let [hx, hy] = drawing.hotspot.map(usize::from); + let nib = (hx + (8.0 * ppp) as usize, hy + (8.0 * ppp) as usize); + assert_eq!(pixel(&new, nib.0, nib.1), pixel(&drawing, nib.0, nib.1), "at {ppp}"); + } +} + +/// The Pencil's and the Paintbrush's closing cursors are the crosshair those tools show, with a +/// ring below right. +#[test] +fn the_closing_cursors_are_the_crosshair_with_a_ring() { + for ppp in [1.0, 2.0] { + let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); + for c in [Cursor::PencilClose, Cursor::PaintbrushClose] { + let img = rasterize(c, ppp).unwrap(); + assert_eq!(img.hotspot, cross.hotspot, "{c:?} at {ppp}"); + let [hx, hy] = img.hotspot.map(usize::from); + let at = |img: &CustomCursorImage, dx: f32, dy: f32| { + let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); + if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } + }; + // Black pixels in the square 4 to 12 points right of and below the hotspot, clear of the arms. + let ring_ink = |img: &CustomCursorImage| { + let steps = (8.0 * ppp) as usize; + (0..steps * steps) + .filter(|i| { + let p = at(img, 4.0 + (i % steps) as f32 / ppp, 4.0 + (i / steps) as f32 / ppp); + p[3] > 200 && p[0] < 64 + }) + .count() + }; + assert!(ring_ink(&img) > 0, "{c:?} has its ring at {ppp}"); + assert_eq!(ring_ink(&cross), 0, "the crosshair has no ring at {ppp}"); + assert_eq!(at(&img, 8.0, 8.0)[3], 0, "{c:?}'s ring is hollow at {ppp}"); + // The arms are the crosshair's. + assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "{c:?} at {ppp}"); + } + } +} + +/// The Pencil's new-path cursor is the crosshair with a small star below right, where the +/// closing cursor's ring is hollow and the straight-line cursor has a single line. +#[test] +fn the_pencil_new_path_cursor_is_the_crosshair_with_a_star() { + for ppp in [1.0, 2.0] { + let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); + let img = rasterize(Cursor::PencilNew, ppp).unwrap(); + let ring = rasterize(Cursor::PencilClose, ppp).unwrap(); + let straight = rasterize(Cursor::PencilStraight, ppp).unwrap(); + assert_eq!(img.hotspot, cross.hotspot, "at {ppp}"); + let at = |img: &CustomCursorImage, dx: f32, dy: f32| { + let [hx, hy] = img.hotspot.map(usize::from); + let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); + if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } + }; + let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; + assert!(inked(at(&img, 8.5, 8.5)), "the star's middle at {ppp}"); + assert_eq!(at(&ring, 8.5, 8.5)[3], 0, "the closing ring is hollow there at {ppp}"); + assert_eq!(at(&cross, 8.5, 8.5)[3], 0, "the crosshair has no badge at {ppp}"); + // Three strokes cross at the middle: the diagonal the straight-line cursor lacks, and the + // upright one. + assert!(inked(at(&img, 5.5, 5.5)) && !inked(at(&straight, 5.5, 5.5)), "the second diagonal at {ppp}"); + // At 1 point per pixel the upright stroke covers two pixel columns in gray. + let dark = |p: [u8; 4]| p[3] > 200 && p[0] < 128; + let upright = |img: &CustomCursorImage, dy: f32| [7.5, 8.0, 8.5].iter().any(|&dx| dark(at(img, dx, dy))); + assert!(upright(&img, 4.5) && upright(&img, 11.5), "the upright stroke at {ppp}"); + assert!(!upright(&straight, 4.5) && !upright(&straight, 11.5), "the straight line has none at {ppp}"); + assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); + } +} + +/// The Pencil's straight-line cursor is the crosshair with a short straight line below right, +/// through the point where the closing cursor's ring is hollow. +#[test] +fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() { + for ppp in [1.0, 2.0] { + let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); + let img = rasterize(Cursor::PencilStraight, ppp).unwrap(); + let ring = rasterize(Cursor::PencilClose, ppp).unwrap(); + assert_eq!(img.hotspot, cross.hotspot, "at {ppp}"); + let at = |img: &CustomCursorImage, dx: f32, dy: f32| { + let [hx, hy] = img.hotspot.map(usize::from); + let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); + if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } + }; + let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; + assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}"); + assert_eq!(at(&ring, 8.5, 8.5)[3], 0, "the closing ring is hollow there at {ppp}"); + assert_eq!(at(&cross, 8.5, 8.5)[3], 0, "the crosshair has no badge at {ppp}"); + // Along the line, not across it: no ink off its ends. + assert!(!inked(at(&img, 5.0, 5.0)) && !inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); + assert_ne!(img, ring, "at {ppp}"); + assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); + } +} + +/// The Pencil's continuation cursor is the straight-line cursor with a filled square on the +/// line's lower end, beside the line where the straight-line cursor has no ink. +#[test] +fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point() { + for ppp in [1.0, 2.0] { + let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); + let img = rasterize(Cursor::PencilContinue, ppp).unwrap(); + let straight = rasterize(Cursor::PencilStraight, ppp).unwrap(); + assert_eq!(img.hotspot, cross.hotspot, "at {ppp}"); + let at = |img: &CustomCursorImage, dx: f32, dy: f32| { + let [hx, hy] = img.hotspot.map(usize::from); + let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); + if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } + }; + let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; + assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}"); + assert!(inked(at(&img, 3.5, 13.5)), "the end point's square at {ppp}"); + assert!(!inked(at(&straight, 3.5, 13.5)), "the straight-line cursor has no end point at {ppp}"); + assert!(!inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); + assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); + } +} + +#[test] +fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() { + let mut images = Images::default(); + let a = images.get(Cursor::Pen, 1.5).unwrap(); + let b = images.get(Cursor::Pen, 1.5).unwrap(); + assert!(Arc::ptr_eq(&a.rgba, &b.rgba)); + assert!(!Arc::ptr_eq(&a.rgba, &images.get(Cursor::Pen, 2.0).unwrap().rgba)); + assert!(images.get(Cursor::NotAllowed, 1.5).is_none()); + assert!(images.get(Cursor::Arrow, f32::NAN).is_none()); + assert_eq!(images.0.len(), 2); +} + +/// One headless 800×600 frame of the whole window, a tenth of a second after the last. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { + let time = Some(ctx.input(|i| i.time) + 0.1); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time, events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out +} + +/// One [`frame`] with `focused` set: true for a window that has the focus, false while another app +/// or a sheet has it. +fn focus_frame(app: &mut VectorcraftApp, ctx: &egui::Context, focused: bool, events: Vec) -> egui::FullOutput { + let time = Some(ctx.input(|i| i.time) + 0.1); + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let raw = egui::RawInput { screen_rect, time, focused, events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out +} + +#[test] +fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let canvas = app.canvas_rect.unwrap(); + let over = canvas.center(); + let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); + if OS_CURSORS { + let img = out.platform_output.cursor_image.as_ref().expect("the Selection tool's arrow as an OS cursor"); + assert_eq!(img, &app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap()); + // Where the OS can't show the bitmap, its system cursor. + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default); + } else { + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None); + } + // The same bitmap while the pointer moves (the OS keeps its cursor), and an idle window + // once it rests: no frame is asked for. + let next = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over + vec2(5.0, 3.0))]); + if OS_CURSORS { + assert!(Arc::ptr_eq(&next.platform_output.cursor_image.unwrap().rgba, &out.platform_output.cursor_image.unwrap().rgba)); + } + let mut idle = frame(&mut app, &ctx, vec![]); + for _ in 0..3 { + idle = frame(&mut app, &ctx, vec![]); + } + let delay = idle.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay).unwrap(); + assert!(delay > std::time::Duration::from_millis(100), "{delay:?} {:?}", ctx.repaint_causes()); + // Off the canvas, the panels' cursors. + let off = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(canvas.left() - 20.0, canvas.center().y))]); + assert!(off.platform_output.cursor_image.is_none()); +} + +/// On Wayland the glyph is painted and the pointer hidden over the canvas, on every build (#1645). +#[test] +fn a_wayland_window_paints_the_tool_cursor() { + let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, ..Default::default() }); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let over = app.canvas_rect.unwrap().center(); + let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); + assert!(out.platform_output.cursor_image.is_none()); + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None); +} + +/// Without a cursor theme the canvas keeps the system pointer visible (#2450). +#[test] +fn no_cursor_theme_keeps_the_system_pointer() { + assert!(!hide_system_pointer(true)); + assert!(hide_system_pointer(false)); + let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, no_cursor_theme: true, ..Default::default() }); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let over = app.canvas_rect.unwrap().center(); + let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); + assert_ne!(out.platform_output.cursor_icon, egui::CursorIcon::None); +} + +/// On macOS a window without the focus shows the system pointer over the canvas, and the glyph +/// is not painted next to it (#2020). With the focus back, the canvas sets `CursorIcon::None` again +/// on the next frame, without a pointer move. +#[test] +fn a_window_without_the_focus_shows_the_system_cursor_alone_on_macos() { + // Wayland: every desktop build paints the glyphs. + let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, ..Default::default() }); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "rectangle"})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let over = app.canvas_rect.unwrap().center(); + let key = focus_frame(&mut app, &ctx, true, vec![egui::Event::PointerMoved(over)]); + assert_eq!(key.platform_output.cursor_icon, egui::CursorIcon::None, "with the focus: the glyph alone"); + let away = focus_frame(&mut app, &ctx, false, vec![egui::Event::WindowFocused(false), egui::Event::PointerMoved(over + vec2(5.0, 3.0))]); + if HIDES_ONLY_WHEN_KEY { + assert_eq!(away.platform_output.cursor_icon, egui::CursorIcon::Crosshair, "macOS without the focus: the system pointer alone"); + } else { + assert_eq!(away.platform_output.cursor_icon, egui::CursorIcon::None, "other platforms: the glyph without the focus too"); + } + let back = focus_frame(&mut app, &ctx, true, vec![egui::Event::WindowFocused(true)]); + assert_eq!(back.platform_output.cursor_icon, egui::CursorIcon::None, "the focus back: the glyph alone"); +} + +/// User Interface › Scale Cursor Proportional to UI (#394): off, the tool cursors keep their +/// size whatever the UI Scaling; on, they grow with it. +#[test] +fn tool_cursors_scale_with_the_ui_only_when_asked() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "uiScaling", "value": 2.0})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + assert_eq!(ctx.zoom_factor(), 2.0); + let over = app.canvas_rect.unwrap().center(); + let shown = + |app: &mut VectorcraftApp, d: f32| frame(app, &ctx, vec![egui::Event::PointerMoved(over + vec2(d, 0.0))]).platform_output.cursor_image; + if OS_CURSORS { + assert_eq!(shown(&mut app, 0.0).unwrap(), app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap(), "off: its own size"); + app.run("prefs.set", json!({"key": "scaleCursorWithUi", "value": true})).unwrap(); + assert_eq!(shown(&mut app, 4.0).unwrap(), app.canvas.cursors.get(Cursor::Arrow, 2.0).unwrap(), "on: twice as big"); + } + // Painted into the window (macOS, the web), the glyph is drawn at the scale asked for, its + // hotspot where the pointer is. + let painted = |scale: f32| { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("probe")); + assert!(paint(&ui.ctx().layer_painter(layer), Cursor::Arrow, pos2(100.0, 100.0), scale)); + }); + out.textures_delta.clear(); + out.textures_delta.clear(); + out.shapes.iter().fold(egui::Rect::NOTHING, |r, c| r.union(c.shape.visual_bounding_rect())) + }; + let (full, half) = (painted(1.0), painted(0.5)); + assert!((half.height() * 2.0 - full.height()).abs() < 1.5 && full.height() > 15.0, "{full:?} {half:?}"); + assert!(full.contains(pos2(100.0, 100.0)) && half.contains(pos2(100.0, 100.0)), "{full:?} {half:?}"); +} + +#[test] +fn wayland_windows_paint_the_glyphs() { + assert!(os_cursors(true, false), "Windows, X11"); + assert!(!os_cursors(true, true), "Wayland: the compositor would size the bitmap (#1645)"); + assert!(!os_cursors(false, false) && !os_cursors(false, true), "macOS, the web"); +} + +#[test] +fn macos_windows_paint_the_glyphs_only_with_the_focus() { + assert!(paint_glyphs(true, true), "macOS with the focus"); + assert!(!paint_glyphs(true, false), "macOS without the focus: the system pointer shows (#2020)"); + assert!(paint_glyphs(false, true) && paint_glyphs(false, false), "Wayland, the web: painted with the focus or without"); +} + +#[test] +fn arrow_hotspot_is_tip() { + let pts = arrow_points(pos2(10.0, 20.0)); + assert_eq!(pts[0], pos2(10.0, 20.0)); + assert!(pts.iter().all(|q| q.x >= 10.0 && q.y >= 20.0)); +} From 18464c70072d059c0c9769a5194a76afe46cb670 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:03 +0000 Subject: [PATCH 051/199] refactor(effects): move bake.rs tests to bake/tests.rs --- crates/effects/src/bake.rs | 483 +------------------------------ crates/effects/src/bake/tests.rs | 480 ++++++++++++++++++++++++++++++ 2 files changed, 481 insertions(+), 482 deletions(-) create mode 100644 crates/effects/src/bake/tests.rs diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 496946e30..4e07df30c 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -597,485 +597,4 @@ pub fn bake_document(doc: &Document) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use vectorcraft_color::{Color, Paint}; - use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; - use vectorcraft_geom::{Point, Rect, shapes}; - - /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. - fn skip_without_craft_fonts() -> bool { - let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); - if skip { - eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); - } - skip - } - - fn fx(id: &str, params: serde_json::Value) -> Effect { - Effect { id: id.into(), params, visible: true } - } - - fn doc_with(n: Node) -> Document { - let mut d = Document::new(400.0, 400.0); - let l = d.layers[0].id; - d.insert(Some(l), 0, n).unwrap(); - d - } - - #[test] - fn plain_documents_are_not_copied() { - let mut d = Document::new(100.0, 100.0); - let id = d.alloc_id(); - let d = doc_with(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())); - assert!(bake_document(&d).is_none()); - } - - #[test] - fn object_effects_become_geometry_and_raster_effects_stay() { - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10.0}))); - n.appearance.effects.push(fx("stylize.dropShadow", json!({}))); - let d = bake_document(&doc_with(n)).unwrap(); - let m = d.node(NodeId(50)).unwrap(); - let b = m.geometric_bounds().unwrap(); - assert!((b.width() - 120.0).abs() < 0.5, "{b:?}"); - assert_eq!(m.appearance.effects.len(), 1); - assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); - } - - #[test] - fn item_effects_split_into_one_path_per_item() { - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { - s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); - } - let d = bake_document(&doc_with(n)).unwrap(); - let m = d.node(NodeId(50)).unwrap(); - let ch = m.children().unwrap(); - assert_eq!(ch.len(), 2); - assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6); - assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5); - assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50)); - } - - #[test] - fn a_baked_stroke_keeps_its_raster_effects() { - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { - s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); - s.effects.push(fx("stylize.dropShadow", json!({}))); - } - let m = bake_appearance(&n).unwrap(); - let ch = m.children().unwrap(); - let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; - assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); - } - - #[test] - fn a_baked_stroke_leaves_dither_behind() { - // Strokes ignore dither, so the filled outline goes without it and looks as the stroke did. - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { - let mut g = vectorcraft_color::GradientPaint::new(Default::default()); - g.gradient.dither = true; - s.paint = Paint::Gradient(Box::new(g)); - } - let m = bake_appearance(&n).unwrap(); - let ch = m.children().unwrap(); - let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; - let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") }; - assert!(!g.gradient.dither); - } - - #[test] - fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() { - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { - s.width = 10.0; - s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); - s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; - s.opacity = 0.5; - } - let m = bake_appearance(&n).unwrap(); - let ch = m.children().unwrap(); - let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) }; - assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); - assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes"); - assert_eq!(ch[1].opacity, 0.5); - } - - /// Black 24 pt "Type" with its baseline at (20, 60). - fn type_node(id: u64) -> Node { - let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; - Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style)))) - } - - /// The origins of the type objects in `d`, in paint order. - fn type_origins(d: &Document) -> Vec<(f64, f64)> { - let mut out = vec![]; - d.walk(|n| { - if let NodeKind::Text(t) = &n.kind { - let c = t.xf.as_coeffs(); - out.push((c[4], c[5])); - } - }); - out - } - - /// A group of the type and a 10 pt square with `effects`. - fn type_group(effects: Vec) -> Node { - let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]); - g.appearance.effects = effects; - g - } - - #[test] - fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() { - if skip_without_craft_fonts() { - return; - } - for (params, origins, square) in [ - (json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)), - (json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)), - (json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)), - ] { - let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]); - let d = bake_document(&doc_with(g.clone())).unwrap(); - assert_eq!(type_origins(&d), origins, "{params}"); - let baked = d.node(NodeId(52)).unwrap(); - assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); - // The square moves with the type, as one path holding its copies. - let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap(); - assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}"); - let mut ids = std::collections::HashSet::new(); - baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id)); - // The renderer and Expand Appearance still draw it as outlines. - let mut text = 0; - evaluate_container(None, &g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); - assert_eq!(text, 0, "{params}"); - } - // Stacked effects that only move: every copy of a copy. - let d = bake_document(&doc_with(type_group(vec![ - fx("distort.transform", json!({"moveH": 100, "copies": 1})), - fx("distort.transform", json!({"moveV": 100, "copies": 1})), - ]))) - .unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]); - } - - #[test] - fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() { - if skip_without_craft_fonts() { - return; - } - let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); - let mut hidden = type_node(55); - hidden.visible = false; - g.children_mut().unwrap().push(Arc::new(hidden)); - let d = bake_document(&doc_with(g)).unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - assert!(!d.node(NodeId(55)).unwrap().visible); - } - - #[test] - fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { - if skip_without_craft_fonts() { - return; - } - let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); - let mut shadowed = type_node(55); - shadowed.opacity = 0.5; - shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({}))); - // Type that clips a group is outlined as the plain outlines it clips by. - let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true }); - g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]); - let d = bake_document(&doc_with(g)).unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - let o = d.node(NodeId(55)).unwrap(); - assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}"); - assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); - // The outlines are copied with the type. - let b = o.geometric_bounds().unwrap(); - assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}"); - let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap(); - assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}"); - } - - #[test] - fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { - if skip_without_craft_fonts() { - return; - } - let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); - // The clipping path is a group with a fill of its own, holding type with an effect of its own. - let mut moved = type_node(60); - moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300}))); - let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]); - path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)))); - let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); - let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true }); - g.children_mut().unwrap().push(Arc::new(clip)); - let d = bake_document(&doc_with(g)).unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - // The type clips by its plain outlines, as the canvas clips by them: its own effect is left out. - let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap(); - assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}"); - } - - #[test] - fn type_with_its_own_transform_effect_copies_stays_type() { - if skip_without_craft_fonts() { - return; - } - let mut n = type_node(50); - n.opacity = 0.5; - n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))]; - let d = bake_document(&doc_with(n)).unwrap(); - assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]); - // One group keeps the object's transparency and its other effects; the copies are plain. - let g = d.node(NodeId(50)).unwrap(); - assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5); - assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); - for c in g.children().unwrap() { - assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}"); - } - // Hidden type stays hidden. - let mut n = type_node(50); - n.visible = false; - n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))]; - let d = bake_document(&doc_with(n)).unwrap(); - assert_eq!(type_origins(&d).len(), 2); - assert!(!d.node(NodeId(50)).unwrap().visible); - } - - #[test] - fn type_under_other_geometry_effects_is_still_outlined() { - for effects in [ - vec![fx("distort.transform", json!({"moveH": 30, "random": true}))], - vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))], - vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))], - vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))], - vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))], - // Copies that would reach past the effect's limit, which stops them early. - vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))], - // Copies that overlap, or land on each other. - vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))], - vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))], - ] { - let mut own = type_node(50); - own.appearance.effects = effects.clone(); - for n in [type_group(effects.clone()), own] { - let d = bake_document(&doc_with(n)).unwrap(); - assert!(type_origins(&d).is_empty(), "{effects:?}"); - } - } - } - - #[test] - fn type_whose_copies_paint_over_each_other_is_still_outlined() { - if skip_without_craft_fonts() { - return; - } - let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; - assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2); - // A 20 pt stroke on the characters reaches the copy below. - let mut stroked = type_node(50); - if let NodeKind::Text(t) = &mut stroked.kind { - t.runs[0].style.stroke = Paint::solid(Color::BLACK); - t.runs[0].style.stroke_width = 20.0; - } - let mut g = type_group(moves); - g.children_mut().unwrap()[0] = Arc::new(stroked); - assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); - } - - #[test] - fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() { - if skip_without_craft_fonts() { - return; - } - let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; - let mut translucent = type_node(50); - translucent.opacity = 0.5; - let mut underlined = type_node(50); - if let NodeKind::Text(t) = &mut underlined.kind { - t.runs[0].style.underline = true; - } - let mut shadowed = type_node(50); - shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({}))); - let mut gradient = type_node(50); - if let NodeKind::Text(t) = &mut gradient.kind { - t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); - } - let mut reshaped = type_node(50); - let mut fill = FillLayer::new(Paint::solid(Color::BLACK)); - fill.effects.push(fx("path.offsetPath", json!({"offset": 2}))); - reshaped.appearance.items.push(AppearanceItem::Fill(fill)); - // A stroke of the type object's own (beyond its characters'). - let mut stroked = type_node(50); - stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0))); - // Character strokes scale with type scaled by its transform, where the outlines keep theirs. - let mut scaled = type_node(50); - if let NodeKind::Text(t) = &mut scaled.kind { - t.runs[0].style.stroke = Paint::solid(Color::BLACK); - t.runs[0].style.stroke_width = 1.0; - t.xf *= Affine::scale(2.0); - } - let mut turned = scaled.clone(); - if let NodeKind::Text(t) = &mut turned.kind { - t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5); - } - let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))]; - for (t, kept) in [(turned, 2), (scaled, 0)] { - let mut g = type_group(far.clone()); - g.children_mut().unwrap()[0] = Arc::new(t); - assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept); - } - for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] { - let mut g = type_group(moves.clone()); - g.children_mut().unwrap()[0] = Arc::new(t); - assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); - } - } - - #[test] - fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() { - let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))]; - let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); - let mut g = type_group(moves.clone()); - g.appearance.items.push(fill.clone()); - assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); - // A group with a fill of its own inside the moved group. - let mut inner = type_group(vec![]); - inner.id = NodeId(53); - inner.appearance.items.push(fill); - let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); - outer.appearance.effects = moves; - assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty()); - } - - #[test] - fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() { - // The inner copy lands back on the original. - let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]); - inner.id = NodeId(53); - let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); - outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1}))); - assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty()); - } - - #[test] - fn type_whose_copies_hold_too_much_is_still_outlined() { - if skip_without_craft_fonts() { - return; - } - let spaced = |spaces: usize| { - let mut n = type_node(50); - if let NodeKind::Text(t) = &mut n.kind { - t.runs[0].text = format!("a{}", " ".repeat(spaces)); - } - Arc::new(n) - }; - // 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000 - // bytes together, they are outlined. - let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))]; - for (spaces, kept) in [(1000, 1001), (20_000, 0)] { - let mut g = type_group(copies.clone()); - g.children_mut().unwrap()[0] = spaced(spaces); - assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces"); - } - // 1001 × 21 copies, past 10,000. - let mut g = type_group(vec![ - fx("distort.transform", json!({"moveH": 100, "copies": 1000})), - fx("distort.transform", json!({"moveV": 100, "copies": 20})), - ]); - g.children_mut().unwrap()[0] = spaced(0); - assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); - } - - #[test] - fn copies_are_apart_when_their_boxes_only_touch() { - let b = Rect::new(0.0, 0.0, 10.0, 5.0); - assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b)); - assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b)); - assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b)); - assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b)); - // Offsets too far for the cells' range count as overlapping. - assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0))); - } - - #[test] - fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() { - if skip_without_craft_fonts() { - return; - } - let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]); - inner.id = NodeId(53); - let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); - outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1}))); - let d = bake_document(&doc_with(outer)).unwrap(); - assert!(type_origins(&d).is_empty()); - // The glyphs take part in Add: the result reaches past the 10 pt square. - let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap(); - assert!(b.width() > 30.0, "{b:?}"); - } - - #[test] - fn type_is_copied_only_when_the_effect_makes_every_copy() { - // A 10 pt line with `n` anchors. - let line = |n: usize| { - let pts: Vec = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect(); - PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)]) - }; - let right = json!({"moveH": 10, "copies": 1000}); - let down = json!({"moveV": 10, "copies": 1}); - // 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors. - assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001)); - assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none()); - // A second effect copies every copy. - assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002)); - assert!(transform_offsets(&[right, down], &line(1000)).is_none()); - // Copies stop before one reaches 1,000,000,000 pt from the origin. - let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); - assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100)); - assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none()); - // An effect that also scales. - assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none()); - } - /// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies. - fn gradient_copies(id: u64) -> Node { - use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; - let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); - let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); - n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); - n - } - - /// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right. - fn gradient_pieces(n: &Node) -> Vec { - let mut out = vec![]; - let mut walk = vec![n]; - while let Some(n) = walk.pop() { - match n.children() { - Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)), - _ => { - assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}"); - out.extend(n.geometric_bounds().map(|b| b.width())); - } - } - } - out - } - - #[test] - fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { - let d = bake_document(&doc_with(gradient_copies(50))).unwrap(); - // Three squares of their own, not one path spanning all three. - assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); - } -} +mod tests; diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs new file mode 100644 index 000000000..b150f2004 --- /dev/null +++ b/crates/effects/src/bake/tests.rs @@ -0,0 +1,480 @@ +use super::*; +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; + +/// Type tests need glyph outlines, which only the optional craft-fonts build input provides. +fn skip_without_craft_fonts() -> bool { + let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); + if skip { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + skip +} + +fn fx(id: &str, params: serde_json::Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn doc_with(n: Node) -> Document { + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +#[test] +fn plain_documents_are_not_copied() { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let d = doc_with(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())); + assert!(bake_document(&d).is_none()); +} + +#[test] +fn object_effects_become_geometry_and_raster_effects_stay() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10.0}))); + n.appearance.effects.push(fx("stylize.dropShadow", json!({}))); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let b = m.geometric_bounds().unwrap(); + assert!((b.width() - 120.0).abs() < 0.5, "{b:?}"); + assert_eq!(m.appearance.effects.len(), 1); + assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); +} + +#[test] +fn item_effects_split_into_one_path_per_item() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + } + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let ch = m.children().unwrap(); + assert_eq!(ch.len(), 2); + assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6); + assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5); + assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50)); +} + +#[test] +fn a_baked_stroke_keeps_its_raster_effects() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + s.effects.push(fx("stylize.dropShadow", json!({}))); + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; + assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); +} + +#[test] +fn a_baked_stroke_leaves_dither_behind() { + // Strokes ignore dither, so the filled outline goes without it and looks as the stroke did. + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + let mut g = vectorcraft_color::GradientPaint::new(Default::default()); + g.gradient.dither = true; + s.paint = Paint::Gradient(Box::new(g)); + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; + let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") }; + assert!(!g.gradient.dither); +} + +#[test] +fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.width = 10.0; + s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); + s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; + s.opacity = 0.5; + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes"); + assert_eq!(ch[1].opacity, 0.5); +} + +/// Black 24 pt "Type" with its baseline at (20, 60). +fn type_node(id: u64) -> Node { + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style)))) +} + +/// The origins of the type objects in `d`, in paint order. +fn type_origins(d: &Document) -> Vec<(f64, f64)> { + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + let c = t.xf.as_coeffs(); + out.push((c[4], c[5])); + } + }); + out +} + +/// A group of the type and a 10 pt square with `effects`. +fn type_group(effects: Vec) -> Node { + let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]); + g.appearance.effects = effects; + g +} + +#[test] +fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() { + if skip_without_craft_fonts() { + return; + } + for (params, origins, square) in [ + (json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)), + (json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)), + (json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)), + ] { + let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]); + let d = bake_document(&doc_with(g.clone())).unwrap(); + assert_eq!(type_origins(&d), origins, "{params}"); + let baked = d.node(NodeId(52)).unwrap(); + assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); + // The square moves with the type, as one path holding its copies. + let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap(); + assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}"); + let mut ids = std::collections::HashSet::new(); + baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id)); + // The renderer and Expand Appearance still draw it as outlines. + let mut text = 0; + evaluate_container(None, &g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(text, 0, "{params}"); + } + // Stacked effects that only move: every copy of a copy. + let d = bake_document(&doc_with(type_group(vec![ + fx("distort.transform", json!({"moveH": 100, "copies": 1})), + fx("distort.transform", json!({"moveV": 100, "copies": 1})), + ]))) + .unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]); +} + +#[test] +fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() { + if skip_without_craft_fonts() { + return; + } + let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); + let mut hidden = type_node(55); + hidden.visible = false; + g.children_mut().unwrap().push(Arc::new(hidden)); + let d = bake_document(&doc_with(g)).unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); + assert!(!d.node(NodeId(55)).unwrap().visible); +} + +#[test] +fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { + if skip_without_craft_fonts() { + return; + } + let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); + let mut shadowed = type_node(55); + shadowed.opacity = 0.5; + shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({}))); + // Type that clips a group is outlined as the plain outlines it clips by. + let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true }); + g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]); + let d = bake_document(&doc_with(g)).unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); + let o = d.node(NodeId(55)).unwrap(); + assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}"); + assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); + // The outlines are copied with the type. + let b = o.geometric_bounds().unwrap(); + assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}"); + let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap(); + assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}"); +} + +#[test] +fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { + if skip_without_craft_fonts() { + return; + } + let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); + // The clipping path is a group with a fill of its own, holding type with an effect of its own. + let mut moved = type_node(60); + moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300}))); + let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]); + path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)))); + let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true }); + g.children_mut().unwrap().push(Arc::new(clip)); + let d = bake_document(&doc_with(g)).unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); + // The type clips by its plain outlines, as the canvas clips by them: its own effect is left out. + let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap(); + assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}"); +} + +#[test] +fn type_with_its_own_transform_effect_copies_stays_type() { + if skip_without_craft_fonts() { + return; + } + let mut n = type_node(50); + n.opacity = 0.5; + n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))]; + let d = bake_document(&doc_with(n)).unwrap(); + assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]); + // One group keeps the object's transparency and its other effects; the copies are plain. + let g = d.node(NodeId(50)).unwrap(); + assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5); + assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); + for c in g.children().unwrap() { + assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}"); + } + // Hidden type stays hidden. + let mut n = type_node(50); + n.visible = false; + n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))]; + let d = bake_document(&doc_with(n)).unwrap(); + assert_eq!(type_origins(&d).len(), 2); + assert!(!d.node(NodeId(50)).unwrap().visible); +} + +#[test] +fn type_under_other_geometry_effects_is_still_outlined() { + for effects in [ + vec![fx("distort.transform", json!({"moveH": 30, "random": true}))], + vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))], + vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))], + vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))], + vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))], + // Copies that would reach past the effect's limit, which stops them early. + vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))], + // Copies that overlap, or land on each other. + vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))], + vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))], + ] { + let mut own = type_node(50); + own.appearance.effects = effects.clone(); + for n in [type_group(effects.clone()), own] { + let d = bake_document(&doc_with(n)).unwrap(); + assert!(type_origins(&d).is_empty(), "{effects:?}"); + } + } +} + +#[test] +fn type_whose_copies_paint_over_each_other_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } + let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; + assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2); + // A 20 pt stroke on the characters reaches the copy below. + let mut stroked = type_node(50); + if let NodeKind::Text(t) = &mut stroked.kind { + t.runs[0].style.stroke = Paint::solid(Color::BLACK); + t.runs[0].style.stroke_width = 20.0; + } + let mut g = type_group(moves); + g.children_mut().unwrap()[0] = Arc::new(stroked); + assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); +} + +#[test] +fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } + let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; + let mut translucent = type_node(50); + translucent.opacity = 0.5; + let mut underlined = type_node(50); + if let NodeKind::Text(t) = &mut underlined.kind { + t.runs[0].style.underline = true; + } + let mut shadowed = type_node(50); + shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({}))); + let mut gradient = type_node(50); + if let NodeKind::Text(t) = &mut gradient.kind { + t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + } + let mut reshaped = type_node(50); + let mut fill = FillLayer::new(Paint::solid(Color::BLACK)); + fill.effects.push(fx("path.offsetPath", json!({"offset": 2}))); + reshaped.appearance.items.push(AppearanceItem::Fill(fill)); + // A stroke of the type object's own (beyond its characters'). + let mut stroked = type_node(50); + stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0))); + // Character strokes scale with type scaled by its transform, where the outlines keep theirs. + let mut scaled = type_node(50); + if let NodeKind::Text(t) = &mut scaled.kind { + t.runs[0].style.stroke = Paint::solid(Color::BLACK); + t.runs[0].style.stroke_width = 1.0; + t.xf *= Affine::scale(2.0); + } + let mut turned = scaled.clone(); + if let NodeKind::Text(t) = &mut turned.kind { + t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5); + } + let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))]; + for (t, kept) in [(turned, 2), (scaled, 0)] { + let mut g = type_group(far.clone()); + g.children_mut().unwrap()[0] = Arc::new(t); + assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept); + } + for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] { + let mut g = type_group(moves.clone()); + g.children_mut().unwrap()[0] = Arc::new(t); + assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); + } +} + +#[test] +fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() { + let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))]; + let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); + let mut g = type_group(moves.clone()); + g.appearance.items.push(fill.clone()); + assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); + // A group with a fill of its own inside the moved group. + let mut inner = type_group(vec![]); + inner.id = NodeId(53); + inner.appearance.items.push(fill); + let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); + outer.appearance.effects = moves; + assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty()); +} + +#[test] +fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() { + // The inner copy lands back on the original. + let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]); + inner.id = NodeId(53); + let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); + outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1}))); + assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty()); +} + +#[test] +fn type_whose_copies_hold_too_much_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } + let spaced = |spaces: usize| { + let mut n = type_node(50); + if let NodeKind::Text(t) = &mut n.kind { + t.runs[0].text = format!("a{}", " ".repeat(spaces)); + } + Arc::new(n) + }; + // 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000 + // bytes together, they are outlined. + let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))]; + for (spaces, kept) in [(1000, 1001), (20_000, 0)] { + let mut g = type_group(copies.clone()); + g.children_mut().unwrap()[0] = spaced(spaces); + assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces"); + } + // 1001 × 21 copies, past 10,000. + let mut g = type_group(vec![ + fx("distort.transform", json!({"moveH": 100, "copies": 1000})), + fx("distort.transform", json!({"moveV": 100, "copies": 20})), + ]); + g.children_mut().unwrap()[0] = spaced(0); + assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty()); +} + +#[test] +fn copies_are_apart_when_their_boxes_only_touch() { + let b = Rect::new(0.0, 0.0, 10.0, 5.0); + assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b)); + assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b)); + assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b)); + assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b)); + // Offsets too far for the cells' range count as overlapping. + assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0))); +} + +#[test] +fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } + let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]); + inner.id = NodeId(53); + let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); + outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1}))); + let d = bake_document(&doc_with(outer)).unwrap(); + assert!(type_origins(&d).is_empty()); + // The glyphs take part in Add: the result reaches past the 10 pt square. + let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap(); + assert!(b.width() > 30.0, "{b:?}"); +} + +#[test] +fn type_is_copied_only_when_the_effect_makes_every_copy() { + // A 10 pt line with `n` anchors. + let line = |n: usize| { + let pts: Vec = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect(); + PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)]) + }; + let right = json!({"moveH": 10, "copies": 1000}); + let down = json!({"moveV": 10, "copies": 1}); + // 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors. + assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001)); + assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none()); + // A second effect copies every copy. + assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002)); + assert!(transform_offsets(&[right, down], &line(1000)).is_none()); + // Copies stop before one reaches 1,000,000,000 pt from the origin. + let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100)); + assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none()); + // An effect that also scales. + assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none()); +} +/// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies. +fn gradient_copies(id: u64) -> Node { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); + n +} + +/// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right. +fn gradient_pieces(n: &Node) -> Vec { + let mut out = vec![]; + let mut walk = vec![n]; + while let Some(n) = walk.pop() { + match n.children() { + Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)), + _ => { + assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}"); + out.extend(n.geometric_bounds().map(|b| b.width())); + } + } + } + out +} + +#[test] +fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { + let d = bake_document(&doc_with(gradient_copies(50))).unwrap(); + // Three squares of their own, not one path spanning all three. + assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); +} From 24a2b891fccc10eccd4395f77ed84797a5bedbfe Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:04 +0000 Subject: [PATCH 052/199] refactor(ui-egui): move panels/text_styles.rs tests to panels/text_styles/tests.rs --- crates/ui-egui/src/panels/text_styles.rs | 482 +----------------- .../ui-egui/src/panels/text_styles/tests.rs | 479 +++++++++++++++++ 2 files changed, 480 insertions(+), 481 deletions(-) create mode 100644 crates/ui-egui/src/panels/text_styles/tests.rs diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 4c08a3469..ac03692b3 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -193,484 +193,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_doc::{NodeId, NodeKind}; - - use super::super::tests_appearance::{click, frame_events, text_rect}; - - #[test] - fn panels_draw_and_apply_headless() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - app.session.execute("charStyle.new", &json!({"name": "Big", "attrs": {"size": 30.0}})).unwrap(); - for kind in [Kind::Char, Kind::Para] { - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - show(&mut app, ui, kind); - menu(&mut app, ui, kind); - }); - out.textures_delta.clear(); - } - app.run("charStyle.apply", target(&app, Kind::Char, json!({"name": "Big"}))).unwrap(); - let (name, attrs) = current(&app, Kind::Char).unwrap(); - assert_eq!((name.as_deref(), attrs["size"].as_f64()), (Some("Big"), Some(30.0))); - } - - #[test] - fn auto_leading_style_marks_only_real_overrides() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.run("charStyle.new", json!({"name": "Automatic", "attrs": {"leading": null}})).unwrap(); - app.run("charStyle.apply", json!({"name": "Automatic"})).unwrap(); - for (leading, label) in [(json!("auto"), "Automatic"), (json!(30), "Automatic+")] { - app.run("text.setStyle", json!({"leading": leading})).unwrap(); - let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, Kind::Char)); - assert!(shown.lines().any(|line| line == label), "expected {label} in {shown}"); - } - app.run("charStyle.apply", json!({"name": "Automatic", "clearOverrides": true})).unwrap(); - let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, Kind::Char)); - assert!(shown.lines().any(|line| line == "Automatic"), "no override after clearing: {shown}"); - } - - /// Styles made from new type hold its Auto alignment and its attributes at their defaults; - /// the type they were made from shows no override until one of those attributes changes. - #[test] - fn styles_made_from_type_mark_only_real_overrides() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - for kind in [Kind::Char, Kind::Para] { - app.run(&format!("{}.new", kind.prefix()), json!({"name": "Plain"})).unwrap(); - app.run(&format!("{}.apply", kind.prefix()), json!({"name": "Plain"})).unwrap(); - } - for (change, label) in [(None, "Plain"), (Some(json!({"kerning": 40, "composer": "singleLine"})), "Plain+")] { - if let Some(p) = change { - app.run("text.setFormat", p).unwrap(); - } - for kind in [Kind::Char, Kind::Para] { - let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); - assert!(shown.lines().any(|line| line == label), "expected {label} in the {} styles: {shown}", kind.what()); - } - } - } - - fn capture_app() -> (VectorcraftApp, NodeId, egui::Context) { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.run("charStyle.new", json!({"name": "Untouched", "attrs": {"size": 22}})).unwrap(); - let other = app.run("text.create", json!({"x": 20, "y": 100, "text": "Other", "size": 22})).unwrap()["id"].clone(); - app.run("charStyle.apply", json!({"name": "Untouched", "id": other})).unwrap(); - let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "ABcdef", "size": 18})).unwrap()["id"].as_u64().unwrap()); - app.run("text.setRangeStyle", json!({"id": id.0, "start": 2, "end": 6, "size": 34})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - assert_eq!(text_editing(&app), None); - (app, id, ctx) - } - - fn select_type_range(app: &mut VectorcraftApp, id: NodeId, start: usize, end: usize) { - // The same editing handshake and selection API as character::tests::scaled_range_fields_follow_the_type_tool_selection. - app.session.select_tool("type", Default::default()).unwrap(); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": start, "end": end})); - super::super::character::end_typing(app); - assert_eq!(app.session.tool_options()["typing"], json!(false)); - assert!(!app.session.in_interaction(), "settle typing before taking the history baseline"); - assert_eq!(text_editing(app), Some((id, start, end))); - } - - /// Click production panel/menu content with the existing hover/press/release helper. - /// A live range guard and command counter distinguish a dispatch failure from a missed click or lost Type selection. - fn click_style_ui(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId, range: Option<(usize, usize)>, is_menu: bool, label: &str) { - let expected = range.map(|(start, end)| (id, start, end)); - let mut expected_target = json!({}); - if let Some((start, end)) = range { - expected_target["id"] = json!(id.0); - if end > start { - expected_target["start"] = json!(start); - expected_target["end"] = json!(end); - } - } - let commands = app.run_count; - let mut dispatched = false; - let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { - // On the release frame this is immediately before the production menu/row handles the click and builds its target. - assert_eq!(text_editing(app), expected, "Type selection before {label}"); - assert_eq!(target(app, Kind::Char, json!({})), expected_target, "actual panel target before {label}"); - let before = app.run_count; - if is_menu { - menu(app, ui, Kind::Char); - } else { - show(app, ui, Kind::Char); - } - if app.run_count != before { - assert!(!dispatched, "{label} dispatched more than once"); - assert_eq!(app.run_count, before.wrapping_add(1), "one command from {label}"); - dispatched = true; - } - }; - frame_events(ctx, app, vec![], &mut draw); - let texts = frame_events(ctx, app, vec![], &mut draw); - // widgets::menu_item prefixes an unchecked label with three spaces; the panel row does not. - let painted = if is_menu { format!(" {label}") } else { label.to_string() }; - let pos = text_rect(&texts, &painted).center(); - click(ctx, app, pos, &mut draw); - assert!(dispatched, "pointer click did not dispatch {label}; do not interpret this as parameter rejection"); - assert_eq!(app.run_count, commands.wrapping_add(1), "exactly one real panel command"); - assert_eq!(text_editing(app), expected, "Type selection after {label}"); - } - - fn text_runs(app: &VectorcraftApp, id: NodeId) -> Vec<(String, f64, Option)> { - let st = app.session.doc().unwrap(); - let NodeKind::Text(text) = &st.doc.node(id).unwrap().kind else { panic!("expected text") }; - text.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.style_name.clone())).collect() - } - - fn capture_from_menu(range: Option<(usize, usize)>, redefine: bool) { - capture_from_ui(range, redefine, false, false); - } - - fn capture_from_ui(range: Option<(usize, usize)>, redefine: bool, footer: bool, unicode: bool) { - let (mut app, id, ctx) = capture_app(); - let (first, tail) = if unicode { ("é", "漢字") } else { ("AB", "cdef") }; - let len = first.len() + tail.len(); - if unicode { - app.run("text.setText", json!({"id": id.0, "text": format!("{first}{tail}")})).unwrap(); - app.run("text.setRangeStyle", json!({"id": id.0, "start": first.len(), "end": len, "size": 34})).unwrap(); - } - let mut follower = None; - let mut local_override = None; - if redefine { - app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); - // Select the definition through its real panel row; no test-only panel selection is installed. - click_style_ui(&ctx, &mut app, id, None, false, "Body"); - let selected: Option = pstate(&ctx, Kind::Char.key()); - assert_eq!(selected.as_deref(), Some("Body")); - for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] { - let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap()); - app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap(); - app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap(); - *slot = Some(user); - } - app.run("select.set", json!({"ids": [id.0]})).unwrap(); - app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": len, "size": 18})).unwrap(); - app.run("text.setRangeStyle", json!({"id": id.0, "start": first.len(), "end": len, "size": 34})).unwrap(); - } - let link = redefine.then(|| "Body".to_string()); - assert_eq!(text_runs(&app, id), vec![(first.into(), 18.0, link.clone()), (tail.into(), 34.0, link)]); - if let Some((start, end)) = range { - select_type_range(&mut app, id, start, end); - } - assert!(!app.session.in_interaction()); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.clone(); - let selection = app.session.doc().unwrap().selection.clone(); - let undo = app.session.doc().unwrap().history.undo.len(); - assert!(!app.session.doc().unwrap().is_dirty()); - let label = if redefine { "Redefine Character Style" } else { "New Character Style…" }; - if footer { - click_new_style_footer_range(&ctx, &mut app, id, range.expect("footer fixture has a range")); - } else { - click_style_ui(&ctx, &mut app, id, range, true, label); - } - let after = app.session.doc().unwrap().doc.clone(); - let name = if redefine { "Body" } else { "Character Style 1" }; - let definition = after.char_styles.iter().find(|s| s.name == name); - let size = definition.and_then(|s| s.attrs.get("size")).and_then(Value::as_f64); - let expected_size = if range.is_some_and(|(a, b)| b > a && a >= first.len()) { 34.0 } else { 18.0 }; - let count = before.char_styles.len() + usize::from(!redefine); - let history = &app.session.doc().unwrap().history; - assert_eq!( - (after.char_styles.len(), size, history.undo.len(), history.redo.len()), - (count, Some(expected_size), undo + 1, 0), - "{label} dispatched with live range {range:?}; capture must use the selected start and make one style edit" - ); - assert!(!definition.unwrap().attrs.contains_key("style_name")); - for old in &before.char_styles { - if old.name != name { - assert_eq!(after.char_styles.iter().find(|s| s.name == old.name), Some(old)); - } - } - // New captures without applying. Redefine changes its unmodified follower, preserving overrides and unrelated text. - before.walk(|node| { - if matches!(&node.kind, NodeKind::Text(_)) && Some(node.id) != follower { - assert_eq!(after.node(node.id), Some(node)); - } - }); - if let Some(user) = follower { - assert_eq!(text_runs(&app, user), vec![("Follower".into(), expected_size, Some("Body".into()))]); - } - if let Some(user) = local_override { - assert_eq!(text_runs(&app, user), vec![("Override".into(), 48.0, Some("Body".into()))]); - } - assert_eq!(app.session.doc().unwrap().selection, selection); - assert!(app.session.doc().unwrap().is_dirty()); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, before); - assert!(!app.session.doc().unwrap().is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, after); - assert!(app.session.doc().unwrap().is_dirty()); - } - - // Start 3 is inside the second run; style_at chooses the preceding run at an exact boundary. - #[test] - fn selected_type_range_new_character_style_from_menu() { - capture_from_menu(Some((3, 5)), false); - } - - #[test] - fn selected_type_range_redefine_character_style_from_menu() { - capture_from_menu(Some((3, 5)), true); - } - - #[test] - fn new_character_style_object_and_caret_controls() { - // The current collapsed target omits start even at caret 3, so capture still uses object-start size 18. - for range in [None, Some((3, 3))] { - capture_from_menu(range, false); - } - } - - #[test] - fn redefine_character_style_object_and_caret_controls() { - for range in [None, Some((3, 3))] { - capture_from_menu(range, true); - } - } - - #[test] - fn character_style_range_apply_from_panel_control() { - let (mut app, id, ctx) = capture_app(); - app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 26}})).unwrap(); - select_type_range(&mut app, id, 3, 5); - let before = app.session.doc().unwrap().doc.clone(); - let undo = app.session.doc().unwrap().history.undo.len(); - click_style_ui(&ctx, &mut app, id, Some((3, 5)), false, "Body"); - let after = app.session.doc().unwrap().doc.clone(); - assert_eq!( - text_runs(&app, id), - vec![("AB".into(), 18.0, None), ("c".into(), 34.0, None), ("de".into(), 26.0, Some("Body".into())), ("f".into(), 34.0, None)] - ); - assert_eq!(after.char_styles, before.char_styles, "Apply does not redefine Body"); - before.walk(|node| { - if node.id != id && matches!(&node.kind, NodeKind::Text(_)) { - assert_eq!(after.node(node.id), Some(node)); - } - }); - assert_eq!((app.session.doc().unwrap().history.undo.len(), app.session.doc().unwrap().history.redo.len()), (undo + 1, 0)); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, before); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, after); - } - - /// Feed real text/shortcut events through the canvas Type routing. No focus reset or typing - /// settlement here: the capture dispatch itself must close the live typing interaction. - fn capture_input(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { - frame_events(ctx, app, events, |app, ui| { - crate::shortcuts::handle(app, ui.ctx()); - menu(app, ui, Kind::Char); - }); - } - - fn click_new_style_footer(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId) { - click_new_style_footer_range(ctx, app, id, (0, 5)); - } - - fn click_new_style_footer_range(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId, range: (usize, usize)) { - let (start, end) = range; - let pos = std::cell::Cell::new(egui::Pos2::ZERO); - let commands = app.run_count; - let mut dispatched = false; - let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { - assert_eq!(text_editing(app), Some((id, start, end))); - assert_eq!(target(app, Kind::Char, json!({})), json!({"id": id.0, "start": start, "end": end})); - ui.set_width(280.0); - let right = ui.max_rect().right(); - let before = app.run_count; - show(app, ui, Kind::Char); - // The final row is the production bottom bar: New and Delete are its two 24-point - // buttons, four points apart. Use its laid-out bottom/right, not screen coordinates. - pos.set(egui::pos2(right - 40.0, ui.min_rect().bottom() - 12.0)); - if app.run_count != before { - assert!(!dispatched, "footer New dispatched more than once"); - assert_eq!(app.run_count, before.wrapping_add(1)); - dispatched = true; - } - }; - frame_events(ctx, app, vec![], &mut draw); - frame_events(ctx, app, vec![], &mut draw); - click(ctx, app, pos.get(), &mut draw); - assert!(dispatched, "pointer click did not dispatch footer New"); - assert_eq!(app.run_count, commands.wrapping_add(1)); - let selected: Option = pstate(ctx, Kind::Char.key()); - assert_eq!(selected.as_deref(), Some("Character Style 1"), "production footer stores the new definition"); - assert_eq!(text_editing(app), Some((id, start, end)), "footer capture preserves the Type range"); - } - - fn capture_during_live_typing(redefine: bool, footer: bool) { - let (mut app, id, ctx) = capture_app(); - let mut follower = None; - let mut local_override = None; - if redefine { - app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); - click_style_ui(&ctx, &mut app, id, None, false, "Body"); - for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] { - let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap()); - app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap(); - app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap(); - *slot = Some(user); - } - app.run("select.set", json!({"ids": [id.0]})).unwrap(); - // This override is already the captured size, so Redefine does not alter the edited - // runs and accidentally make TextTool::replace restart the typing interaction. - app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 6, "size": 18})).unwrap(); - } - let link = redefine.then(|| "Body".to_string()); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.clone(); - let selection = app.session.doc().unwrap().selection.clone(); - let undo = app.session.doc().unwrap().history.undo.len(); - app.session.select_tool("type", Default::default()).unwrap(); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert_eq!(text_editing(&app), Some((id, 0, 6))); - capture_input(&ctx, &mut app, vec![egui::Event::Text("Typed".into())]); - assert_eq!(text_runs(&app, id), vec![("Typed".into(), 18.0, link.clone())]); - assert_eq!(text_editing(&app), Some((id, 5, 5))); - assert!(app.session.in_interaction()); - assert_eq!(app.session.tool_options()["typing"], json!(true)); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); - let typed = app.session.doc().unwrap().doc.clone(); - - // Ctrl+A/Cmd+A is Select All's real command route, including key release; it must leave - // the typing interaction live until the subsequent production menu dispatch. - for pressed in [true, false] { - capture_input( - &ctx, - &mut app, - vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }], - ); - } - assert_eq!(text_editing(&app), Some((id, 0, 5))); - assert!(app.session.in_interaction()); - assert_eq!(app.session.tool_options()["typing"], json!(true)); - assert_eq!(app.session.doc().unwrap().selection, selection); - let label = if redefine { "Redefine Character Style" } else { "New Character Style…" }; - if footer { - click_new_style_footer(&ctx, &mut app, id); - } else { - click_style_ui(&ctx, &mut app, id, Some((0, 5)), true, label); - } - let captured = app.session.doc().unwrap().doc.clone(); - let capture_history = (app.session.doc().unwrap().history.undo.len(), app.session.doc().unwrap().history.redo.len()); - let capture_settled = !app.session.in_interaction() && app.session.tool_options()["typing"] == json!(false); - let name = if redefine { "Body" } else { "Character Style 1" }; - let definition = captured.char_styles.iter().find(|s| s.name == name).unwrap(); - assert_eq!(definition.attrs.get("size").and_then(Value::as_f64), Some(18.0)); - assert_eq!(captured.char_styles.len(), typed.char_styles.len() + usize::from(!redefine)); - assert_eq!(text_runs(&app, id), vec![("Typed".into(), 18.0, link.clone())]); - assert_eq!(app.session.doc().unwrap().selection, selection); - typed.walk(|node| { - if matches!(&node.kind, NodeKind::Text(_)) && Some(node.id) != follower { - assert_eq!(captured.node(node.id), Some(node)); - } - }); - if let Some(user) = follower { - assert_eq!(text_runs(&app, user), vec![("Follower".into(), 18.0, Some("Body".into()))]); - } - if let Some(user) = local_override { - assert_eq!(text_runs(&app, user), vec![("Override".into(), 48.0, Some("Body".into()))]); - } - - // Check persistence before the history assertions: RED must expose the lost definition, - // not merely report that typing was still live after capture. - capture_input(&ctx, &mut app, vec![egui::Event::Text("After".into())]); - let after = app.session.doc().unwrap().doc.clone(); - assert_eq!(text_runs(&app, id), vec![("After".into(), 18.0, link)]); - assert_eq!(after.char_styles, captured.char_styles, "further typing must preserve the captured definition"); - captured.walk(|node| { - if node.id != id && matches!(&node.kind, NodeKind::Text(_)) { - assert_eq!(after.node(node.id), Some(node), "further typing preserves followers and unrelated text"); - } - }); - assert!(capture_settled, "capture ends the prior typing interaction without ending text editing"); - assert_eq!(capture_history, (undo + 2, 0), "typing and capture are separate history entries"); - assert_eq!(text_editing(&app), Some((id, 5, 5))); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert!(app.session.in_interaction(), "further text starts its own typing interaction"); - assert_eq!(app.session.tool_options()["typing"], json!(true)); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); - - // Undo itself commits the new live typing interaction. Three separate snapshots prove - // that typing, capture and further typing can each be undone/redone independently. - for (step, expected) in [&captured, &typed, &before].into_iter().enumerate() { - let result = app.run("edit.undo", json!({})).unwrap(); - if step != 1 { - assert_eq!(result["undone"], json!("Typing")); - } - let st = app.session.doc().unwrap(); - assert_eq!(&st.doc, expected); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 2 - step, step + 1)); - assert_eq!(st.is_dirty(), step != 2); - assert!(!app.session.in_interaction()); - assert_eq!(app.session.tool_options()["typing"], json!(false)); - } - for (step, expected) in [&typed, &captured, &after].into_iter().enumerate() { - let result = app.run("edit.redo", json!({})).unwrap(); - if step != 1 { - assert_eq!(result["redone"], json!("Typing")); - } - let st = app.session.doc().unwrap(); - assert_eq!(&st.doc, expected); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + step + 1, 2 - step)); - assert!(st.is_dirty()); - } - } - - #[test] - fn new_character_style_survives_live_typing_and_undo_redo() { - capture_during_live_typing(false, false); - } - - #[test] - fn redefine_character_style_survives_live_typing_and_undo_redo() { - capture_during_live_typing(true, false); - } - - #[test] - fn new_character_style_from_footer_survives_live_typing_and_undo_redo() { - capture_during_live_typing(false, true); - } - - #[test] - fn character_style_boundary_new_from_menu() { - capture_from_menu(Some((2, 5)), false); - } - - #[test] - fn character_style_boundary_redefine_from_menu() { - capture_from_menu(Some((2, 5)), true); - } - - #[test] - fn character_style_boundary_new_from_footer() { - capture_from_ui(Some((2, 5)), false, true, false); - } - - #[test] - fn character_style_unicode_boundary_capture_controls() { - // Byte 2 is the start of 漢 after é; 2..5 selects exactly that complete character. - for (redefine, footer) in [(false, false), (true, false), (false, true)] { - capture_from_ui(Some((2, 5)), redefine, footer, true); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs new file mode 100644 index 000000000..46007dcbc --- /dev/null +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -0,0 +1,479 @@ +use super::*; +use vectorcraft_doc::{NodeId, NodeKind}; + +use super::super::tests_appearance::{click, frame_events, text_rect}; + +#[test] +fn panels_draw_and_apply_headless() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + app.session.execute("charStyle.new", &json!({"name": "Big", "attrs": {"size": 30.0}})).unwrap(); + for kind in [Kind::Char, Kind::Para] { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui, kind); + menu(&mut app, ui, kind); + }); + out.textures_delta.clear(); + } + app.run("charStyle.apply", target(&app, Kind::Char, json!({"name": "Big"}))).unwrap(); + let (name, attrs) = current(&app, Kind::Char).unwrap(); + assert_eq!((name.as_deref(), attrs["size"].as_f64()), (Some("Big"), Some(30.0))); +} + +#[test] +fn auto_leading_style_marks_only_real_overrides() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("charStyle.new", json!({"name": "Automatic", "attrs": {"leading": null}})).unwrap(); + app.run("charStyle.apply", json!({"name": "Automatic"})).unwrap(); + for (leading, label) in [(json!("auto"), "Automatic"), (json!(30), "Automatic+")] { + app.run("text.setStyle", json!({"leading": leading})).unwrap(); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, Kind::Char)); + assert!(shown.lines().any(|line| line == label), "expected {label} in {shown}"); + } + app.run("charStyle.apply", json!({"name": "Automatic", "clearOverrides": true})).unwrap(); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, Kind::Char)); + assert!(shown.lines().any(|line| line == "Automatic"), "no override after clearing: {shown}"); +} + +/// Styles made from new type hold its Auto alignment and its attributes at their defaults; +/// the type they were made from shows no override until one of those attributes changes. +#[test] +fn styles_made_from_type_mark_only_real_overrides() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + for kind in [Kind::Char, Kind::Para] { + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Plain"})).unwrap(); + app.run(&format!("{}.apply", kind.prefix()), json!({"name": "Plain"})).unwrap(); + } + for (change, label) in [(None, "Plain"), (Some(json!({"kerning": 40, "composer": "singleLine"})), "Plain+")] { + if let Some(p) = change { + app.run("text.setFormat", p).unwrap(); + } + for kind in [Kind::Char, Kind::Para] { + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == label), "expected {label} in the {} styles: {shown}", kind.what()); + } + } +} + +fn capture_app() -> (VectorcraftApp, NodeId, egui::Context) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("charStyle.new", json!({"name": "Untouched", "attrs": {"size": 22}})).unwrap(); + let other = app.run("text.create", json!({"x": 20, "y": 100, "text": "Other", "size": 22})).unwrap()["id"].clone(); + app.run("charStyle.apply", json!({"name": "Untouched", "id": other})).unwrap(); + let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "ABcdef", "size": 18})).unwrap()["id"].as_u64().unwrap()); + app.run("text.setRangeStyle", json!({"id": id.0, "start": 2, "end": 6, "size": 34})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + assert_eq!(text_editing(&app), None); + (app, id, ctx) +} + +fn select_type_range(app: &mut VectorcraftApp, id: NodeId, start: usize, end: usize) { + // The same editing handshake and selection API as character::tests::scaled_range_fields_follow_the_type_tool_selection. + app.session.select_tool("type", Default::default()).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": start, "end": end})); + super::super::character::end_typing(app); + assert_eq!(app.session.tool_options()["typing"], json!(false)); + assert!(!app.session.in_interaction(), "settle typing before taking the history baseline"); + assert_eq!(text_editing(app), Some((id, start, end))); +} + +/// Click production panel/menu content with the existing hover/press/release helper. +/// A live range guard and command counter distinguish a dispatch failure from a missed click or lost Type selection. +fn click_style_ui(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId, range: Option<(usize, usize)>, is_menu: bool, label: &str) { + let expected = range.map(|(start, end)| (id, start, end)); + let mut expected_target = json!({}); + if let Some((start, end)) = range { + expected_target["id"] = json!(id.0); + if end > start { + expected_target["start"] = json!(start); + expected_target["end"] = json!(end); + } + } + let commands = app.run_count; + let mut dispatched = false; + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + // On the release frame this is immediately before the production menu/row handles the click and builds its target. + assert_eq!(text_editing(app), expected, "Type selection before {label}"); + assert_eq!(target(app, Kind::Char, json!({})), expected_target, "actual panel target before {label}"); + let before = app.run_count; + if is_menu { + menu(app, ui, Kind::Char); + } else { + show(app, ui, Kind::Char); + } + if app.run_count != before { + assert!(!dispatched, "{label} dispatched more than once"); + assert_eq!(app.run_count, before.wrapping_add(1), "one command from {label}"); + dispatched = true; + } + }; + frame_events(ctx, app, vec![], &mut draw); + let texts = frame_events(ctx, app, vec![], &mut draw); + // widgets::menu_item prefixes an unchecked label with three spaces; the panel row does not. + let painted = if is_menu { format!(" {label}") } else { label.to_string() }; + let pos = text_rect(&texts, &painted).center(); + click(ctx, app, pos, &mut draw); + assert!(dispatched, "pointer click did not dispatch {label}; do not interpret this as parameter rejection"); + assert_eq!(app.run_count, commands.wrapping_add(1), "exactly one real panel command"); + assert_eq!(text_editing(app), expected, "Type selection after {label}"); +} + +fn text_runs(app: &VectorcraftApp, id: NodeId) -> Vec<(String, f64, Option)> { + let st = app.session.doc().unwrap(); + let NodeKind::Text(text) = &st.doc.node(id).unwrap().kind else { panic!("expected text") }; + text.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.style_name.clone())).collect() +} + +fn capture_from_menu(range: Option<(usize, usize)>, redefine: bool) { + capture_from_ui(range, redefine, false, false); +} + +fn capture_from_ui(range: Option<(usize, usize)>, redefine: bool, footer: bool, unicode: bool) { + let (mut app, id, ctx) = capture_app(); + let (first, tail) = if unicode { ("é", "漢字") } else { ("AB", "cdef") }; + let len = first.len() + tail.len(); + if unicode { + app.run("text.setText", json!({"id": id.0, "text": format!("{first}{tail}")})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id.0, "start": first.len(), "end": len, "size": 34})).unwrap(); + } + let mut follower = None; + let mut local_override = None; + if redefine { + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); + // Select the definition through its real panel row; no test-only panel selection is installed. + click_style_ui(&ctx, &mut app, id, None, false, "Body"); + let selected: Option = pstate(&ctx, Kind::Char.key()); + assert_eq!(selected.as_deref(), Some("Body")); + for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] { + let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap(); + app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap(); + *slot = Some(user); + } + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": len, "size": 18})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id.0, "start": first.len(), "end": len, "size": 34})).unwrap(); + } + let link = redefine.then(|| "Body".to_string()); + assert_eq!(text_runs(&app, id), vec![(first.into(), 18.0, link.clone()), (tail.into(), 34.0, link)]); + if let Some((start, end)) = range { + select_type_range(&mut app, id, start, end); + } + assert!(!app.session.in_interaction()); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + assert!(!app.session.doc().unwrap().is_dirty()); + let label = if redefine { "Redefine Character Style" } else { "New Character Style…" }; + if footer { + click_new_style_footer_range(&ctx, &mut app, id, range.expect("footer fixture has a range")); + } else { + click_style_ui(&ctx, &mut app, id, range, true, label); + } + let after = app.session.doc().unwrap().doc.clone(); + let name = if redefine { "Body" } else { "Character Style 1" }; + let definition = after.char_styles.iter().find(|s| s.name == name); + let size = definition.and_then(|s| s.attrs.get("size")).and_then(Value::as_f64); + let expected_size = if range.is_some_and(|(a, b)| b > a && a >= first.len()) { 34.0 } else { 18.0 }; + let count = before.char_styles.len() + usize::from(!redefine); + let history = &app.session.doc().unwrap().history; + assert_eq!( + (after.char_styles.len(), size, history.undo.len(), history.redo.len()), + (count, Some(expected_size), undo + 1, 0), + "{label} dispatched with live range {range:?}; capture must use the selected start and make one style edit" + ); + assert!(!definition.unwrap().attrs.contains_key("style_name")); + for old in &before.char_styles { + if old.name != name { + assert_eq!(after.char_styles.iter().find(|s| s.name == old.name), Some(old)); + } + } + // New captures without applying. Redefine changes its unmodified follower, preserving overrides and unrelated text. + before.walk(|node| { + if matches!(&node.kind, NodeKind::Text(_)) && Some(node.id) != follower { + assert_eq!(after.node(node.id), Some(node)); + } + }); + if let Some(user) = follower { + assert_eq!(text_runs(&app, user), vec![("Follower".into(), expected_size, Some("Body".into()))]); + } + if let Some(user) = local_override { + assert_eq!(text_runs(&app, user), vec![("Override".into(), 48.0, Some("Body".into()))]); + } + assert_eq!(app.session.doc().unwrap().selection, selection); + assert!(app.session.doc().unwrap().is_dirty()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + assert!(!app.session.doc().unwrap().is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + assert!(app.session.doc().unwrap().is_dirty()); +} + +// Start 3 is inside the second run; style_at chooses the preceding run at an exact boundary. +#[test] +fn selected_type_range_new_character_style_from_menu() { + capture_from_menu(Some((3, 5)), false); +} + +#[test] +fn selected_type_range_redefine_character_style_from_menu() { + capture_from_menu(Some((3, 5)), true); +} + +#[test] +fn new_character_style_object_and_caret_controls() { + // The current collapsed target omits start even at caret 3, so capture still uses object-start size 18. + for range in [None, Some((3, 3))] { + capture_from_menu(range, false); + } +} + +#[test] +fn redefine_character_style_object_and_caret_controls() { + for range in [None, Some((3, 3))] { + capture_from_menu(range, true); + } +} + +#[test] +fn character_style_range_apply_from_panel_control() { + let (mut app, id, ctx) = capture_app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 26}})).unwrap(); + select_type_range(&mut app, id, 3, 5); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + click_style_ui(&ctx, &mut app, id, Some((3, 5)), false, "Body"); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!( + text_runs(&app, id), + vec![("AB".into(), 18.0, None), ("c".into(), 34.0, None), ("de".into(), 26.0, Some("Body".into())), ("f".into(), 34.0, None)] + ); + assert_eq!(after.char_styles, before.char_styles, "Apply does not redefine Body"); + before.walk(|node| { + if node.id != id && matches!(&node.kind, NodeKind::Text(_)) { + assert_eq!(after.node(node.id), Some(node)); + } + }); + assert_eq!((app.session.doc().unwrap().history.undo.len(), app.session.doc().unwrap().history.redo.len()), (undo + 1, 0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); +} + +/// Feed real text/shortcut events through the canvas Type routing. No focus reset or typing +/// settlement here: the capture dispatch itself must close the live typing interaction. +fn capture_input(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { + frame_events(ctx, app, events, |app, ui| { + crate::shortcuts::handle(app, ui.ctx()); + menu(app, ui, Kind::Char); + }); +} + +fn click_new_style_footer(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId) { + click_new_style_footer_range(ctx, app, id, (0, 5)); +} + +fn click_new_style_footer_range(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId, range: (usize, usize)) { + let (start, end) = range; + let pos = std::cell::Cell::new(egui::Pos2::ZERO); + let commands = app.run_count; + let mut dispatched = false; + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + assert_eq!(text_editing(app), Some((id, start, end))); + assert_eq!(target(app, Kind::Char, json!({})), json!({"id": id.0, "start": start, "end": end})); + ui.set_width(280.0); + let right = ui.max_rect().right(); + let before = app.run_count; + show(app, ui, Kind::Char); + // The final row is the production bottom bar: New and Delete are its two 24-point + // buttons, four points apart. Use its laid-out bottom/right, not screen coordinates. + pos.set(egui::pos2(right - 40.0, ui.min_rect().bottom() - 12.0)); + if app.run_count != before { + assert!(!dispatched, "footer New dispatched more than once"); + assert_eq!(app.run_count, before.wrapping_add(1)); + dispatched = true; + } + }; + frame_events(ctx, app, vec![], &mut draw); + frame_events(ctx, app, vec![], &mut draw); + click(ctx, app, pos.get(), &mut draw); + assert!(dispatched, "pointer click did not dispatch footer New"); + assert_eq!(app.run_count, commands.wrapping_add(1)); + let selected: Option = pstate(ctx, Kind::Char.key()); + assert_eq!(selected.as_deref(), Some("Character Style 1"), "production footer stores the new definition"); + assert_eq!(text_editing(app), Some((id, start, end)), "footer capture preserves the Type range"); +} + +fn capture_during_live_typing(redefine: bool, footer: bool) { + let (mut app, id, ctx) = capture_app(); + let mut follower = None; + let mut local_override = None; + if redefine { + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); + click_style_ui(&ctx, &mut app, id, None, false, "Body"); + for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] { + let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap(); + app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap(); + *slot = Some(user); + } + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + // This override is already the captured size, so Redefine does not alter the edited + // runs and accidentally make TextTool::replace restart the typing interaction. + app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 6, "size": 18})).unwrap(); + } + let link = redefine.then(|| "Body".to_string()); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert_eq!(text_editing(&app), Some((id, 0, 6))); + capture_input(&ctx, &mut app, vec![egui::Event::Text("Typed".into())]); + assert_eq!(text_runs(&app, id), vec![("Typed".into(), 18.0, link.clone())]); + assert_eq!(text_editing(&app), Some((id, 5, 5))); + assert!(app.session.in_interaction()); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + let typed = app.session.doc().unwrap().doc.clone(); + + // Ctrl+A/Cmd+A is Select All's real command route, including key release; it must leave + // the typing interaction live until the subsequent production menu dispatch. + for pressed in [true, false] { + capture_input( + &ctx, + &mut app, + vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }], + ); + } + assert_eq!(text_editing(&app), Some((id, 0, 5))); + assert!(app.session.in_interaction()); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + assert_eq!(app.session.doc().unwrap().selection, selection); + let label = if redefine { "Redefine Character Style" } else { "New Character Style…" }; + if footer { + click_new_style_footer(&ctx, &mut app, id); + } else { + click_style_ui(&ctx, &mut app, id, Some((0, 5)), true, label); + } + let captured = app.session.doc().unwrap().doc.clone(); + let capture_history = (app.session.doc().unwrap().history.undo.len(), app.session.doc().unwrap().history.redo.len()); + let capture_settled = !app.session.in_interaction() && app.session.tool_options()["typing"] == json!(false); + let name = if redefine { "Body" } else { "Character Style 1" }; + let definition = captured.char_styles.iter().find(|s| s.name == name).unwrap(); + assert_eq!(definition.attrs.get("size").and_then(Value::as_f64), Some(18.0)); + assert_eq!(captured.char_styles.len(), typed.char_styles.len() + usize::from(!redefine)); + assert_eq!(text_runs(&app, id), vec![("Typed".into(), 18.0, link.clone())]); + assert_eq!(app.session.doc().unwrap().selection, selection); + typed.walk(|node| { + if matches!(&node.kind, NodeKind::Text(_)) && Some(node.id) != follower { + assert_eq!(captured.node(node.id), Some(node)); + } + }); + if let Some(user) = follower { + assert_eq!(text_runs(&app, user), vec![("Follower".into(), 18.0, Some("Body".into()))]); + } + if let Some(user) = local_override { + assert_eq!(text_runs(&app, user), vec![("Override".into(), 48.0, Some("Body".into()))]); + } + + // Check persistence before the history assertions: RED must expose the lost definition, + // not merely report that typing was still live after capture. + capture_input(&ctx, &mut app, vec![egui::Event::Text("After".into())]); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(text_runs(&app, id), vec![("After".into(), 18.0, link)]); + assert_eq!(after.char_styles, captured.char_styles, "further typing must preserve the captured definition"); + captured.walk(|node| { + if node.id != id && matches!(&node.kind, NodeKind::Text(_)) { + assert_eq!(after.node(node.id), Some(node), "further typing preserves followers and unrelated text"); + } + }); + assert!(capture_settled, "capture ends the prior typing interaction without ending text editing"); + assert_eq!(capture_history, (undo + 2, 0), "typing and capture are separate history entries"); + assert_eq!(text_editing(&app), Some((id, 5, 5))); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert!(app.session.in_interaction(), "further text starts its own typing interaction"); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + + // Undo itself commits the new live typing interaction. Three separate snapshots prove + // that typing, capture and further typing can each be undone/redone independently. + for (step, expected) in [&captured, &typed, &before].into_iter().enumerate() { + let result = app.run("edit.undo", json!({})).unwrap(); + if step != 1 { + assert_eq!(result["undone"], json!("Typing")); + } + let st = app.session.doc().unwrap(); + assert_eq!(&st.doc, expected); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 2 - step, step + 1)); + assert_eq!(st.is_dirty(), step != 2); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.tool_options()["typing"], json!(false)); + } + for (step, expected) in [&typed, &captured, &after].into_iter().enumerate() { + let result = app.run("edit.redo", json!({})).unwrap(); + if step != 1 { + assert_eq!(result["redone"], json!("Typing")); + } + let st = app.session.doc().unwrap(); + assert_eq!(&st.doc, expected); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + step + 1, 2 - step)); + assert!(st.is_dirty()); + } +} + +#[test] +fn new_character_style_survives_live_typing_and_undo_redo() { + capture_during_live_typing(false, false); +} + +#[test] +fn redefine_character_style_survives_live_typing_and_undo_redo() { + capture_during_live_typing(true, false); +} + +#[test] +fn new_character_style_from_footer_survives_live_typing_and_undo_redo() { + capture_during_live_typing(false, true); +} + +#[test] +fn character_style_boundary_new_from_menu() { + capture_from_menu(Some((2, 5)), false); +} + +#[test] +fn character_style_boundary_redefine_from_menu() { + capture_from_menu(Some((2, 5)), true); +} + +#[test] +fn character_style_boundary_new_from_footer() { + capture_from_ui(Some((2, 5)), false, true, false); +} + +#[test] +fn character_style_unicode_boundary_capture_controls() { + // Byte 2 is the start of 漢 after é; 2..5 selects exactly that complete character. + for (redefine, footer) in [(false, false), (true, false), (false, true)] { + capture_from_ui(Some((2, 5)), redefine, footer, true); + } +} From d129469e907c9e27e58a03c6d212eefded01f16c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:04 +0000 Subject: [PATCH 053/199] refactor(ui-egui): move panels/transform.rs tests to panels/transform/tests.rs --- crates/ui-egui/src/panels/transform.rs | 472 +------------------ crates/ui-egui/src/panels/transform/tests.rs | 469 ++++++++++++++++++ 2 files changed, 470 insertions(+), 471 deletions(-) create mode 100644 crates/ui-egui/src/panels/transform/tests.rs diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index d0faa4a08..8e97b3ab1 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -523,474 +523,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use super::*; - - /// One headless frame of the panel (or the Align panel's menu): the texts drawn. - fn texts(app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec { - fn walk(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), - _ => {} - } - } - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui)); - out.textures_delta.clear(); - let mut v = vec![]; - out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); - v - } - - #[test] - fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap(); - app.run("stroke.set", json!({"weight": 10})).unwrap(); - assert!(texts(&mut app, show).iter().any(|t| t == "100 pt")); - assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == " Use Preview Bounds")); - set_pref(&mut app, "usePreviewBounds", true); - let shown = texts(&mut app, show); - assert!(shown.iter().any(|t| t == "110 pt") && shown.iter().any(|t| t == "60 pt"), "{shown:?}"); - assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds")); - } - - #[test] - fn use_preview_bounds_shows_the_size_after_geometry_effects() { - // A 100 × 50 rectangle without a stroke, with Offset Path 10: 120 × 70 as drawn. - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 50})).unwrap(); - app.run("paint.setStroke", json!({"none": true})).unwrap(); - app.run("effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": null})).unwrap(); - let shown = texts(&mut app, show); - assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}"); - set_pref(&mut app, "usePreviewBounds", true); - let shown = texts(&mut app, show); - assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "70 pt"), "{shown:?}"); - } - - #[test] - fn rotation_field_shows_the_angle_the_box_keeps() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap(); - app.run("object.rotate", json!({"angle": 45, "absolute": true})).unwrap(); - let shown = texts(&mut app, show); - // The angle stays, and W/H are the rectangle's own sides. - assert!(shown.iter().any(|t| t == "45°"), "{shown:?}"); - assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}"); - app.run("object.resetBoundingBox", json!({})).unwrap(); - let shown = texts(&mut app, show); - assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}"); - } - - /// #812: a selected live polygon shows its Polygon Properties in the Transform panel and the - /// Properties panel's Transform section, and Make Sides Equal evens it out after a stretch. - #[test] - fn a_polygon_shows_its_polygon_properties() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.polygon", json!({"cx": 150, "cy": 150, "radius": 50, "sides": 6})).unwrap(); - for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { - let shown = texts(&mut app, draw); - // A hexagon's side is as long as its radius. - for t in ["Sides:", "6", "Angle:", "0°", "Corner:", "Round", "Corner Radius:", "Radius:", "Side Length:", "50 pt", "Make Sides Equal"] { - assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}"); - } - } - assert!(texts(&mut app, show).iter().any(|s| s == "Polygon Properties:")); - app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap(); - let live = |app: &VectorcraftApp| match &first_selected(app).unwrap().kind { - NodeKind::Path { live: Some(l), .. } => l.clone(), - k => panic!("{k:?}"), - }; - assert!(!live(&app).polygon_sides_equal()); - app.run("object.setLiveShape", json!({"makeSidesEqual": true})).unwrap(); - assert!(live(&app).polygon_sides_equal()); - } - - /// #3451: a live ellipse shows pie start/end angles and Invert Pie in the Transform panel and - /// the Properties panel's Transform section; typed angles update the shape. - #[test] - fn an_ellipse_shows_its_pie_properties() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap(); - for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { - let shown = texts(&mut app, draw); - for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] { - assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}"); - } - } - app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap(); - let shown = texts(&mut app, show); - assert!(shown.iter().any(|s| s == "30°") && shown.iter().any(|s| s == "120°"), "{shown:?}"); - } - - #[test] - fn the_artboard_tool_shows_the_active_artboard() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); - assert!(texts(&mut app, show).iter().any(|t| t == "40 pt"), "the selection first"); - app.run("tool.select", json!({"tool": "artboard"})).unwrap(); - let shown = texts(&mut app, show); - // Centre reference point: X 150, Y 100, W 300, H 200; no rotate/shear for artboards. - for v in ["150 pt", "100 pt", "300 pt", "200 pt"] { - assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}"); - } - assert!(!shown.iter().any(|t| t == "40 pt" || t == "0°"), "{shown:?}"); - } - - /// #1597: with only ruler guides selected the panel shows their X or Y, and nothing of the art. - #[test] - fn selected_guides_show_their_position() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap(); - app.run("guide.add", json!({"vertical": false, "pos": 45})).unwrap(); - app.run("guide.select", json!({"indexes": [0]})).unwrap(); - assert_eq!(selected_guides(&app), Some((Some(120.0), None))); - let shown = texts(&mut app, show); - assert!(shown.iter().any(|t| t == "120 pt") && !shown.iter().any(|t| t == "W:" || t == "45 pt"), "{shown:?}"); - app.run("guide.select", json!({"indexes": [0, 1]})).unwrap(); - let shown = texts(&mut app, show); - assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "45 pt"), "{shown:?}"); - // The field's edit: the vertical guide moves, the horizontal one stays. - app.run("guide.move", json!({"dx": 30})).unwrap(); - assert_eq!(selected_guides(&app), Some((Some(150.0), Some(45.0)))); - // Art selected with them: the art's box, as before. - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); - assert_eq!(selected_guides(&app), None); - } - - /// #1824: with an anchor selected with Direct Selection the panel shows that anchor's position, - /// and a new X moves it alone. - #[test] - fn an_anchor_selection_shows_and_moves_its_own_box() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let square = json!({"anchors": [{"x": 20, "y": 30}, {"x": 120, "y": 30}, {"x": 120, "y": 130}, {"x": 20, "y": 130}], "closed": true}); - let id = app.run("path.create", square).unwrap()["id"].as_u64().unwrap(); - let shown = texts(&mut app, show); - assert!(shown.iter().any(|t| t == "70 pt") && shown.iter().any(|t| t == "100 pt"), "the whole path: {shown:?}"); - app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); - let shown = texts(&mut app, show); - for v in ["120 pt", "30 pt", "0 pt"] { - assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}"); - } - assert!(!shown.iter().any(|t| t == "70 pt" || t == "100 pt"), "{shown:?}"); - app.run("object.setBounds", json!({"x": 150, "reference": 4})).unwrap(); - let n = first_selected(&app).unwrap(); - let Some(path) = n.path_data() else { panic!("a path") }; - let xs: Vec = path.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); - assert_eq!(xs, [20.0, 150.0, 120.0, 20.0]); - } - - /// #2398: the panel menu's Flip Horizontal and Flip Vertical mirror the selection across the - /// reference point, as the panel's flip buttons do, not across its centre. - #[test] - fn menu_flips_keep_the_reference_point() { - use crate::panels::tests_appearance::{click, frame_events}; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - // Top-right reference point: (120, 30). - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("refpt"), 2usize)); - let flip = |app: &mut VectorcraftApp, item: &str| { - let texts = frame_events(&ctx, app, vec![], menu); - let r = texts.iter().find(|(t, _)| t.trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1; - click(&ctx, app, r.center(), menu); - app.selection_box().unwrap().rect - }; - assert_eq!(flip(&mut app, "Flip Horizontal"), Rect::new(120.0, 30.0, 220.0, 80.0)); - assert_eq!(flip(&mut app, "Flip Vertical"), Rect::new(120.0, -20.0, 220.0, 30.0)); - } - - /// Clicks the item of the panel menu labelled `item` (with or without its check mark). - fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, item: &str) { - use crate::panels::tests_appearance::{click, frame_events}; - let texts = frame_events(ctx, app, vec![], menu); - let r = texts.iter().find(|(t, _)| t.trim_start_matches('✓').trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1; - click(ctx, app, r.center(), menu); - } - - /// The panel menu's items as drawn, check marks included. - fn menu_texts(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { - crate::panels::tests_appearance::frame_events(ctx, app, vec![], menu).into_iter().map(|(t, _)| t).collect() - } - - /// Types `value` into the field of `draw` that shows `shown`, and presses Enter. - fn type_into(ctx: &egui::Context, app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui), shown: &str, value: &str) { - use crate::panels::tests_appearance::{click, frame_events, text_rect}; - let texts = frame_events(ctx, app, vec![], draw); - click(ctx, app, text_rect(&texts, shown).center(), draw); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - frame_events(ctx, app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw); - frame_events(ctx, app, vec![egui::Event::Text(value.into())], draw); - frame_events(ctx, app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw); - frame_events(ctx, app, vec![], draw); - } - - /// The panel menu's Transform Object Only, Transform Pattern Only and Transform Both, one - /// checked: a new X typed in the panel (or the Properties panel's Transform section) moves the - /// pattern-filled rectangle and leaves its tiles, moves its tiles alone, or moves both. Until - /// one is chosen the panels do as General › Transform Pattern Tiles says, as they did before - /// the menu acted, and the menu checks Transform Both while it is on, Transform Object Only - /// while it is off; a choice overrides it, and resetting the preferences returns to it. - #[test] - fn the_menu_chooses_whether_the_object_its_pattern_or_both_transform() { - let tiles = |app: &VectorcraftApp, id: u64| { - let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); - let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("a pattern fill") }; - xf.as_coeffs() - }; - let labels = ["Transform Object Only", "Transform Pattern Only", "Transform Both"]; - let modes = ["object", "pattern", "both"]; - let checked = |shown: &[String]| -> Vec { (0..3).filter(|i| shown.contains(&format!("✓ {}", labels[*i]))).collect() }; - for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { - for general in [false, true] { - for pick in [None, Some(0), Some(1), Some(2)] { - let case = format!("General › Transform Pattern Tiles {general}, menu choice {pick:?}"); - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("prefs.set", json!({"key": "transformPatternTiles", "value": general})).unwrap(); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); - app.run("object.pattern.done", json!({})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 100, "y": 40, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap(); - app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let following = if general { 2 } else { 0 }; - assert_eq!(checked(&menu_texts(&ctx, &mut app)), [following], "{case}: before a choice"); - if let Some(item) = pick { - menu_click(&ctx, &mut app, labels[item]); - } - let item = pick.unwrap_or(following); - assert_eq!(app.session.prefs.transform_panel_patterns, pick.map_or("default", |i| modes[i]), "{case}"); - assert_eq!(checked(&menu_texts(&ctx, &mut app)), [item], "{case}"); - // The centre reference point: X 150 becomes 170. - type_into(&ctx, &mut app, draw, "150 pt", "170"); - let x0 = app.selection_box().unwrap().rect.x0; - assert_eq!(x0, if item == 1 { 100.0 } else { 120.0 }, "{case}: the object"); - assert_eq!(tiles(&app, id), [1.0, 0.0, 0.0, 1.0, if item == 0 { 0.0 } else { 20.0 }, 0.0], "{case}: the tiles"); - // Reset (the General category): the menu follows General › Transform Pattern - // Tiles again. - app.run("prefs.reset", json!({"category": "General"})).unwrap(); - assert_eq!(app.session.prefs.transform_panel_patterns, "default", "{case}: reset"); - let following = if app.session.prefs.transform_pattern_tiles { 2 } else { 0 }; - assert_eq!(checked(&menu_texts(&ctx, &mut app)), [following], "{case}: reset"); - } - } - } - } - - /// Transform Object Only, Transform Pattern Only and Transform Both act on the rotation field - /// of both panels and on the Transform panel's shear field as they do on X: the pattern-filled - /// rectangle turns or shears with its tiles left as they are, its tiles alone, or both. - #[test] - fn the_menu_choice_also_applies_to_rotation_and_shear() { - let tiles = |app: &VectorcraftApp, id: u64| { - let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); - let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("a pattern fill") }; - xf.as_coeffs() - }; - let labels = ["Transform Object Only", "Transform Pattern Only", "Transform Both"]; - // (panel, which "0°" field: 0 the rotation, 1 the shear) - let fields = [(show as fn(&mut VectorcraftApp, &mut Ui), 0), (show, 1), (crate::panels::properties::transform_section, 0)]; - for (draw, field) in fields { - for (item, label) in labels.iter().enumerate() { - let case = format!("{label}, {}", if field == 0 { "rotation" } else { "shear" }); - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); - app.run("object.pattern.done", json!({})).unwrap(); - let id = app.run("shape.rectangle", json!({"x": 100, "y": 40, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap(); - app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - menu_click(&ctx, &mut app, label); - let before = (app.selection_box().unwrap(), tiles(&app, id)); - // Click the field showing "0°" and type 30 over it. - use crate::panels::tests_appearance::{click, frame_events}; - let texts = frame_events(&ctx, &mut app, vec![], draw); - let at = texts.iter().filter(|(t, _)| t == "0°").nth(field).unwrap_or_else(|| panic!("{case}: {texts:?}")).1.center(); - click(&ctx, &mut app, at, draw); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw); - frame_events(&ctx, &mut app, vec![egui::Event::Text("30".into())], draw); - frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw); - frame_events(&ctx, &mut app, vec![], draw); - let after = (app.selection_box().unwrap(), tiles(&app, id)); - assert_eq!(after.0 != before.0, item != 1, "{case}: the object {:?} → {:?}", before.0, after.0); - // Turned or sheared tiles change their matrix's linear part; moved ones only its offset. - assert_eq!(after.1[..4] != before.1[..4], item != 0, "{case}: the tiles {:?} → {:?}", before.1, after.1); - } - } - } - - /// Use Registration Point for Symbol: with one symbol instance selected, the panel and the - /// Properties panel's Transform section show and set X and Y at its symbol's registration point - /// (here the top-left one) instead of the reference point (the centre). - #[test] - fn use_registration_point_for_symbol_measures_an_instance_from_its_registration_point() { - for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap(); - let id = app.run("symbol.new", json!({"name": "Corner", "registration": 0})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let shown = |app: &mut VectorcraftApp| { - crate::panels::tests_appearance::frame_events(&ctx, app, vec![], draw).into_iter().map(|(t, _)| t).collect::>() - }; - let off = shown(&mut app); - assert!(off.iter().any(|t| t == "140 pt") && off.iter().any(|t| t == "70 pt"), "the centre: {off:?}"); - assert!(menu_texts(&ctx, &mut app).contains(&" Use Registration Point for Symbol".to_string())); - menu_click(&ctx, &mut app, "Use Registration Point for Symbol"); - assert!(app.session.prefs.use_registration_point_for_symbol); - assert!(menu_texts(&ctx, &mut app).contains(&"✓ Use Registration Point for Symbol".to_string())); - let on = shown(&mut app); - assert!(on.iter().any(|t| t == "100 pt") && on.iter().any(|t| t == "50 pt") && !on.iter().any(|t| t == "140 pt"), "the top-left: {on:?}"); - type_into(&ctx, &mut app, draw, "100 pt", "30"); - assert_eq!(app.selection_box().unwrap().rect, Rect::new(30.0, 50.0, 110.0, 90.0)); - // Any other selection: the reference point. - app.run("shape.rectangle", json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap(); - let rect = shown(&mut app); - assert!(rect.iter().any(|t| t == "210 pt") && !rect.iter().any(|t| t == "200 pt"), "the centre: {rect:?}"); - } - } - - /// Use Registration Point for Symbol on a reflected, a rotated and a sheared symbol instance: - /// the panel and the Properties panel's Transform section show X and Y at the registration - /// point where the instance's transform put it (not at the box's point of the same index), a - /// new X moves it there, and their rotation field and flip buttons, and the panel menu's - /// flips, turn and mirror the instance about it. - #[test] - fn use_registration_point_for_symbol_follows_a_transformed_instance() { - let panels = [ - (show as fn(&mut VectorcraftApp, &mut Ui), "Flip Horizontal", "Flip Vertical"), - (crate::panels::properties::transform_section, "Flip Along Horizontal Axis", "Flip Along Vertical Axis"), - ]; - // How each instance is transformed after it is made: (command, params). - let shapes = [ - ("object.reflect", json!({"axis": "vertical"})), - ("object.rotate", json!({"angle": 90})), - // About the registration point (the top-left corner), which then lies on the box's - // top edge but on none of its nine reference points. - ("object.shear", json!({"angle": 30, "axis": "horizontal", "origin": [100, 50]})), - ]; - // The registration point (the art's top-left) where the instance's transform puts it. - let registration = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { - NodeKind::SymbolInstance { xf, .. } => *xf * vectorcraft_geom::Point::new(-vectorcraft_doc::SYMBOL_HALF, -vectorcraft_doc::SYMBOL_HALF), - k => panic!("not an instance: {k:?}"), - }; - let pt = |v: f64| format!("{} pt", (v * 1000.0).round() / 1000.0); - let near = |a: vectorcraft_geom::Point, b: vectorcraft_geom::Point| (a - b).hypot() < 1e-6; - for (draw, flip_h, flip_v) in panels { - for (cmd, params) in &shapes { - let case = format!("{cmd} {params}"); - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); - app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap(); - let id = app.run("symbol.new", json!({"name": "Corner", "registration": 0})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.run(cmd, params.clone()).unwrap(); - app.run("prefs.set", json!({"key": "useRegistrationPointForSymbol", "value": true})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - ctx.enable_accesskit(); - let q = registration(&app, id); - let shown: Vec = - crate::panels::tests_appearance::frame_events(&ctx, &mut app, vec![], draw).into_iter().map(|(t, _)| t).collect(); - assert!(shown.contains(&pt(q.x)) && shown.contains(&pt(q.y)), "{case}: X/Y at {q:?} in {shown:?}"); - // A new X moves the registration point there. - type_into(&ctx, &mut app, draw, &pt(q.x), &format!("{}", q.x - 40.0)); - let q = vectorcraft_geom::Point::new(q.x - 40.0, q.y); - assert!(near(registration(&app, id), q), "{case}: moved to {q:?}, now {:?}", registration(&app, id)); - // The rotation field turns it about the registration point. - let angle = app.selection_box().unwrap().angle; - let texts = crate::panels::tests_appearance::frame_events(&ctx, &mut app, vec![], draw); - let degrees = texts.iter().find(|(t, _)| t.ends_with('°')).unwrap_or_else(|| panic!("{case}: no angle in {texts:?}")).0.clone(); - let before = app.session.active().unwrap().revision; - type_into(&ctx, &mut app, draw, °rees, &format!("{}", angle + 30.0)); - assert_ne!(app.session.active().unwrap().revision, before, "{case}: turned"); - assert!(near(registration(&app, id), q), "{case}: rotated about {q:?}, now {:?}", registration(&app, id)); - // The flip buttons mirror it about the registration point. - for label in [flip_h, flip_v] { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(&mut app, ui)); - out.textures_delta.clear(); - let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; - let (_, n) = nodes - .iter() - .find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button) - .unwrap_or_else(|| panic!("no button named {label:?}")); - let b = n.bounds().expect("bounds"); - let at = egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5); - let before = app.session.active().unwrap().revision; - crate::panels::tests_appearance::click(&ctx, &mut app, at, draw); - assert_ne!(app.session.active().unwrap().revision, before, "{case}: {label} flipped"); - assert!(near(registration(&app, id), q), "{case}: {label} about {q:?}, now {:?}", registration(&app, id)); - } - // So do the panel menu's flips. - for item in ["Flip Horizontal", "Flip Vertical"] { - menu_click(&ctx, &mut app, item); - assert!(near(registration(&app, id), q), "{case}: menu {item} about {q:?}, now {:?}", registration(&app, id)); - } - } - } - } - - #[test] - fn artboard_fields_keep_the_reference_point() { - let r = Rect::new(0.0, 0.0, 300.0, 200.0); - // Centre: a new X centres it there, a new W grows it both ways. - assert_eq!(artboard_rect(r, 4, Some(200.0), None, None, None, false), Rect::new(50.0, 0.0, 350.0, 200.0)); - assert_eq!(artboard_rect(r, 4, None, None, Some(400.0), None, false), Rect::new(-50.0, 0.0, 350.0, 200.0)); - // Top-left: a new W keeps the left edge; linked, H follows. - assert_eq!(artboard_rect(r, 0, None, None, Some(600.0), None, true), Rect::new(0.0, 0.0, 600.0, 400.0)); - // Bottom-right: a new Y puts the bottom edge there. - assert_eq!(artboard_rect(r, 8, None, Some(500.0), None, None, false), Rect::new(0.0, 300.0, 300.0, 500.0)); - } - - #[test] - fn constrain_proportions() { - assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0)); - assert_eq!(constrained(100.0, 50.0, None, Some(25.0)), (50.0, 25.0)); - assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0)); - } - - /// The Transform panel names its Rotate and Shear inputs. - #[test] - fn rotate_and_shear_fields_are_named() { - use egui_kittest::kittest::Queryable; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap(); - let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(280.0, 500.0)).with_accessibility_check(false).build_ui_state( - move |ui, app: &mut VectorcraftApp| { - if !std::mem::replace(&mut fonts, true) { - crate::theme::install_fonts(ui.ctx()); - return; - } - show(app, ui); - }, - app, - ); - h.run_steps(3); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Rotate"); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Shear"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs new file mode 100644 index 000000000..e066dffe8 --- /dev/null +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -0,0 +1,469 @@ +use super::*; + +/// One headless frame of the panel (or the Align panel's menu): the texts drawn. +fn texts(app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec { + fn walk(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v +} + +#[test] +fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap(); + app.run("stroke.set", json!({"weight": 10})).unwrap(); + assert!(texts(&mut app, show).iter().any(|t| t == "100 pt")); + assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == " Use Preview Bounds")); + set_pref(&mut app, "usePreviewBounds", true); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "110 pt") && shown.iter().any(|t| t == "60 pt"), "{shown:?}"); + assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds")); +} + +#[test] +fn use_preview_bounds_shows_the_size_after_geometry_effects() { + // A 100 × 50 rectangle without a stroke, with Offset Path 10: 120 × 70 as drawn. + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 50})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + app.run("effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": null})).unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}"); + set_pref(&mut app, "usePreviewBounds", true); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "70 pt"), "{shown:?}"); +} + +#[test] +fn rotation_field_shows_the_angle_the_box_keeps() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap(); + app.run("object.rotate", json!({"angle": 45, "absolute": true})).unwrap(); + let shown = texts(&mut app, show); + // The angle stays, and W/H are the rectangle's own sides. + assert!(shown.iter().any(|t| t == "45°"), "{shown:?}"); + assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}"); + app.run("object.resetBoundingBox", json!({})).unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}"); +} + +/// #812: a selected live polygon shows its Polygon Properties in the Transform panel and the +/// Properties panel's Transform section, and Make Sides Equal evens it out after a stretch. +#[test] +fn a_polygon_shows_its_polygon_properties() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.polygon", json!({"cx": 150, "cy": 150, "radius": 50, "sides": 6})).unwrap(); + for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { + let shown = texts(&mut app, draw); + // A hexagon's side is as long as its radius. + for t in ["Sides:", "6", "Angle:", "0°", "Corner:", "Round", "Corner Radius:", "Radius:", "Side Length:", "50 pt", "Make Sides Equal"] { + assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}"); + } + } + assert!(texts(&mut app, show).iter().any(|s| s == "Polygon Properties:")); + app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap(); + let live = |app: &VectorcraftApp| match &first_selected(app).unwrap().kind { + NodeKind::Path { live: Some(l), .. } => l.clone(), + k => panic!("{k:?}"), + }; + assert!(!live(&app).polygon_sides_equal()); + app.run("object.setLiveShape", json!({"makeSidesEqual": true})).unwrap(); + assert!(live(&app).polygon_sides_equal()); +} + +/// #3451: a live ellipse shows pie start/end angles and Invert Pie in the Transform panel and +/// the Properties panel's Transform section; typed angles update the shape. +#[test] +fn an_ellipse_shows_its_pie_properties() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap(); + for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { + let shown = texts(&mut app, draw); + for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] { + assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}"); + } + } + app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|s| s == "30°") && shown.iter().any(|s| s == "120°"), "{shown:?}"); +} + +#[test] +fn the_artboard_tool_shows_the_active_artboard() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); + assert!(texts(&mut app, show).iter().any(|t| t == "40 pt"), "the selection first"); + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + let shown = texts(&mut app, show); + // Centre reference point: X 150, Y 100, W 300, H 200; no rotate/shear for artboards. + for v in ["150 pt", "100 pt", "300 pt", "200 pt"] { + assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}"); + } + assert!(!shown.iter().any(|t| t == "40 pt" || t == "0°"), "{shown:?}"); +} + +/// #1597: with only ruler guides selected the panel shows their X or Y, and nothing of the art. +#[test] +fn selected_guides_show_their_position() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap(); + app.run("guide.add", json!({"vertical": false, "pos": 45})).unwrap(); + app.run("guide.select", json!({"indexes": [0]})).unwrap(); + assert_eq!(selected_guides(&app), Some((Some(120.0), None))); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "120 pt") && !shown.iter().any(|t| t == "W:" || t == "45 pt"), "{shown:?}"); + app.run("guide.select", json!({"indexes": [0, 1]})).unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "45 pt"), "{shown:?}"); + // The field's edit: the vertical guide moves, the horizontal one stays. + app.run("guide.move", json!({"dx": 30})).unwrap(); + assert_eq!(selected_guides(&app), Some((Some(150.0), Some(45.0)))); + // Art selected with them: the art's box, as before. + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); + assert_eq!(selected_guides(&app), None); +} + +/// #1824: with an anchor selected with Direct Selection the panel shows that anchor's position, +/// and a new X moves it alone. +#[test] +fn an_anchor_selection_shows_and_moves_its_own_box() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let square = json!({"anchors": [{"x": 20, "y": 30}, {"x": 120, "y": 30}, {"x": 120, "y": 130}, {"x": 20, "y": 130}], "closed": true}); + let id = app.run("path.create", square).unwrap()["id"].as_u64().unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "70 pt") && shown.iter().any(|t| t == "100 pt"), "the whole path: {shown:?}"); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); + let shown = texts(&mut app, show); + for v in ["120 pt", "30 pt", "0 pt"] { + assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}"); + } + assert!(!shown.iter().any(|t| t == "70 pt" || t == "100 pt"), "{shown:?}"); + app.run("object.setBounds", json!({"x": 150, "reference": 4})).unwrap(); + let n = first_selected(&app).unwrap(); + let Some(path) = n.path_data() else { panic!("a path") }; + let xs: Vec = path.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert_eq!(xs, [20.0, 150.0, 120.0, 20.0]); +} + +/// #2398: the panel menu's Flip Horizontal and Flip Vertical mirror the selection across the +/// reference point, as the panel's flip buttons do, not across its centre. +#[test] +fn menu_flips_keep_the_reference_point() { + use crate::panels::tests_appearance::{click, frame_events}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Top-right reference point: (120, 30). + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("refpt"), 2usize)); + let flip = |app: &mut VectorcraftApp, item: &str| { + let texts = frame_events(&ctx, app, vec![], menu); + let r = texts.iter().find(|(t, _)| t.trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1; + click(&ctx, app, r.center(), menu); + app.selection_box().unwrap().rect + }; + assert_eq!(flip(&mut app, "Flip Horizontal"), Rect::new(120.0, 30.0, 220.0, 80.0)); + assert_eq!(flip(&mut app, "Flip Vertical"), Rect::new(120.0, -20.0, 220.0, 30.0)); +} + +/// Clicks the item of the panel menu labelled `item` (with or without its check mark). +fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, item: &str) { + use crate::panels::tests_appearance::{click, frame_events}; + let texts = frame_events(ctx, app, vec![], menu); + let r = texts.iter().find(|(t, _)| t.trim_start_matches('✓').trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1; + click(ctx, app, r.center(), menu); +} + +/// The panel menu's items as drawn, check marks included. +fn menu_texts(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { + crate::panels::tests_appearance::frame_events(ctx, app, vec![], menu).into_iter().map(|(t, _)| t).collect() +} + +/// Types `value` into the field of `draw` that shows `shown`, and presses Enter. +fn type_into(ctx: &egui::Context, app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui), shown: &str, value: &str) { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let texts = frame_events(ctx, app, vec![], draw); + click(ctx, app, text_rect(&texts, shown).center(), draw); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(ctx, app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw); + frame_events(ctx, app, vec![egui::Event::Text(value.into())], draw); + frame_events(ctx, app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw); + frame_events(ctx, app, vec![], draw); +} + +/// The panel menu's Transform Object Only, Transform Pattern Only and Transform Both, one +/// checked: a new X typed in the panel (or the Properties panel's Transform section) moves the +/// pattern-filled rectangle and leaves its tiles, moves its tiles alone, or moves both. Until +/// one is chosen the panels do as General › Transform Pattern Tiles says, as they did before +/// the menu acted, and the menu checks Transform Both while it is on, Transform Object Only +/// while it is off; a choice overrides it, and resetting the preferences returns to it. +#[test] +fn the_menu_chooses_whether_the_object_its_pattern_or_both_transform() { + let tiles = |app: &VectorcraftApp, id: u64| { + let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("a pattern fill") }; + xf.as_coeffs() + }; + let labels = ["Transform Object Only", "Transform Pattern Only", "Transform Both"]; + let modes = ["object", "pattern", "both"]; + let checked = |shown: &[String]| -> Vec { (0..3).filter(|i| shown.contains(&format!("✓ {}", labels[*i]))).collect() }; + for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { + for general in [false, true] { + for pick in [None, Some(0), Some(1), Some(2)] { + let case = format!("General › Transform Pattern Tiles {general}, menu choice {pick:?}"); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("prefs.set", json!({"key": "transformPatternTiles", "value": general})).unwrap(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + app.run("object.pattern.done", json!({})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 40, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let following = if general { 2 } else { 0 }; + assert_eq!(checked(&menu_texts(&ctx, &mut app)), [following], "{case}: before a choice"); + if let Some(item) = pick { + menu_click(&ctx, &mut app, labels[item]); + } + let item = pick.unwrap_or(following); + assert_eq!(app.session.prefs.transform_panel_patterns, pick.map_or("default", |i| modes[i]), "{case}"); + assert_eq!(checked(&menu_texts(&ctx, &mut app)), [item], "{case}"); + // The centre reference point: X 150 becomes 170. + type_into(&ctx, &mut app, draw, "150 pt", "170"); + let x0 = app.selection_box().unwrap().rect.x0; + assert_eq!(x0, if item == 1 { 100.0 } else { 120.0 }, "{case}: the object"); + assert_eq!(tiles(&app, id), [1.0, 0.0, 0.0, 1.0, if item == 0 { 0.0 } else { 20.0 }, 0.0], "{case}: the tiles"); + // Reset (the General category): the menu follows General › Transform Pattern + // Tiles again. + app.run("prefs.reset", json!({"category": "General"})).unwrap(); + assert_eq!(app.session.prefs.transform_panel_patterns, "default", "{case}: reset"); + let following = if app.session.prefs.transform_pattern_tiles { 2 } else { 0 }; + assert_eq!(checked(&menu_texts(&ctx, &mut app)), [following], "{case}: reset"); + } + } + } +} + +/// Transform Object Only, Transform Pattern Only and Transform Both act on the rotation field +/// of both panels and on the Transform panel's shear field as they do on X: the pattern-filled +/// rectangle turns or shears with its tiles left as they are, its tiles alone, or both. +#[test] +fn the_menu_choice_also_applies_to_rotation_and_shear() { + let tiles = |app: &VectorcraftApp, id: u64| { + let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("a pattern fill") }; + xf.as_coeffs() + }; + let labels = ["Transform Object Only", "Transform Pattern Only", "Transform Both"]; + // (panel, which "0°" field: 0 the rotation, 1 the shear) + let fields = [(show as fn(&mut VectorcraftApp, &mut Ui), 0), (show, 1), (crate::panels::properties::transform_section, 0)]; + for (draw, field) in fields { + for (item, label) in labels.iter().enumerate() { + let case = format!("{label}, {}", if field == 0 { "rotation" } else { "shear" }); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + app.run("object.pattern.done", json!({})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 40, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + menu_click(&ctx, &mut app, label); + let before = (app.selection_box().unwrap(), tiles(&app, id)); + // Click the field showing "0°" and type 30 over it. + use crate::panels::tests_appearance::{click, frame_events}; + let texts = frame_events(&ctx, &mut app, vec![], draw); + let at = texts.iter().filter(|(t, _)| t == "0°").nth(field).unwrap_or_else(|| panic!("{case}: {texts:?}")).1.center(); + click(&ctx, &mut app, at, draw); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw); + frame_events(&ctx, &mut app, vec![egui::Event::Text("30".into())], draw); + frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw); + frame_events(&ctx, &mut app, vec![], draw); + let after = (app.selection_box().unwrap(), tiles(&app, id)); + assert_eq!(after.0 != before.0, item != 1, "{case}: the object {:?} → {:?}", before.0, after.0); + // Turned or sheared tiles change their matrix's linear part; moved ones only its offset. + assert_eq!(after.1[..4] != before.1[..4], item != 0, "{case}: the tiles {:?} → {:?}", before.1, after.1); + } + } +} + +/// Use Registration Point for Symbol: with one symbol instance selected, the panel and the +/// Properties panel's Transform section show and set X and Y at its symbol's registration point +/// (here the top-left one) instead of the reference point (the centre). +#[test] +fn use_registration_point_for_symbol_measures_an_instance_from_its_registration_point() { + for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap(); + let id = app.run("symbol.new", json!({"name": "Corner", "registration": 0})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let shown = |app: &mut VectorcraftApp| { + crate::panels::tests_appearance::frame_events(&ctx, app, vec![], draw).into_iter().map(|(t, _)| t).collect::>() + }; + let off = shown(&mut app); + assert!(off.iter().any(|t| t == "140 pt") && off.iter().any(|t| t == "70 pt"), "the centre: {off:?}"); + assert!(menu_texts(&ctx, &mut app).contains(&" Use Registration Point for Symbol".to_string())); + menu_click(&ctx, &mut app, "Use Registration Point for Symbol"); + assert!(app.session.prefs.use_registration_point_for_symbol); + assert!(menu_texts(&ctx, &mut app).contains(&"✓ Use Registration Point for Symbol".to_string())); + let on = shown(&mut app); + assert!(on.iter().any(|t| t == "100 pt") && on.iter().any(|t| t == "50 pt") && !on.iter().any(|t| t == "140 pt"), "the top-left: {on:?}"); + type_into(&ctx, &mut app, draw, "100 pt", "30"); + assert_eq!(app.selection_box().unwrap().rect, Rect::new(30.0, 50.0, 110.0, 90.0)); + // Any other selection: the reference point. + app.run("shape.rectangle", json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap(); + let rect = shown(&mut app); + assert!(rect.iter().any(|t| t == "210 pt") && !rect.iter().any(|t| t == "200 pt"), "the centre: {rect:?}"); + } +} + +/// Use Registration Point for Symbol on a reflected, a rotated and a sheared symbol instance: +/// the panel and the Properties panel's Transform section show X and Y at the registration +/// point where the instance's transform put it (not at the box's point of the same index), a +/// new X moves it there, and their rotation field and flip buttons, and the panel menu's +/// flips, turn and mirror the instance about it. +#[test] +fn use_registration_point_for_symbol_follows_a_transformed_instance() { + let panels = [ + (show as fn(&mut VectorcraftApp, &mut Ui), "Flip Horizontal", "Flip Vertical"), + (crate::panels::properties::transform_section, "Flip Along Horizontal Axis", "Flip Along Vertical Axis"), + ]; + // How each instance is transformed after it is made: (command, params). + let shapes = [ + ("object.reflect", json!({"axis": "vertical"})), + ("object.rotate", json!({"angle": 90})), + // About the registration point (the top-left corner), which then lies on the box's + // top edge but on none of its nine reference points. + ("object.shear", json!({"angle": 30, "axis": "horizontal", "origin": [100, 50]})), + ]; + // The registration point (the art's top-left) where the instance's transform puts it. + let registration = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::SymbolInstance { xf, .. } => *xf * vectorcraft_geom::Point::new(-vectorcraft_doc::SYMBOL_HALF, -vectorcraft_doc::SYMBOL_HALF), + k => panic!("not an instance: {k:?}"), + }; + let pt = |v: f64| format!("{} pt", (v * 1000.0).round() / 1000.0); + let near = |a: vectorcraft_geom::Point, b: vectorcraft_geom::Point| (a - b).hypot() < 1e-6; + for (draw, flip_h, flip_v) in panels { + for (cmd, params) in &shapes { + let case = format!("{cmd} {params}"); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap(); + let id = app.run("symbol.new", json!({"name": "Corner", "registration": 0})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run(cmd, params.clone()).unwrap(); + app.run("prefs.set", json!({"key": "useRegistrationPointForSymbol", "value": true})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let q = registration(&app, id); + let shown: Vec = + crate::panels::tests_appearance::frame_events(&ctx, &mut app, vec![], draw).into_iter().map(|(t, _)| t).collect(); + assert!(shown.contains(&pt(q.x)) && shown.contains(&pt(q.y)), "{case}: X/Y at {q:?} in {shown:?}"); + // A new X moves the registration point there. + type_into(&ctx, &mut app, draw, &pt(q.x), &format!("{}", q.x - 40.0)); + let q = vectorcraft_geom::Point::new(q.x - 40.0, q.y); + assert!(near(registration(&app, id), q), "{case}: moved to {q:?}, now {:?}", registration(&app, id)); + // The rotation field turns it about the registration point. + let angle = app.selection_box().unwrap().angle; + let texts = crate::panels::tests_appearance::frame_events(&ctx, &mut app, vec![], draw); + let degrees = texts.iter().find(|(t, _)| t.ends_with('°')).unwrap_or_else(|| panic!("{case}: no angle in {texts:?}")).0.clone(); + let before = app.session.active().unwrap().revision; + type_into(&ctx, &mut app, draw, °rees, &format!("{}", angle + 30.0)); + assert_ne!(app.session.active().unwrap().revision, before, "{case}: turned"); + assert!(near(registration(&app, id), q), "{case}: rotated about {q:?}, now {:?}", registration(&app, id)); + // The flip buttons mirror it about the registration point. + for label in [flip_h, flip_v] { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(&mut app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let (_, n) = nodes + .iter() + .find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button) + .unwrap_or_else(|| panic!("no button named {label:?}")); + let b = n.bounds().expect("bounds"); + let at = egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5); + let before = app.session.active().unwrap().revision; + crate::panels::tests_appearance::click(&ctx, &mut app, at, draw); + assert_ne!(app.session.active().unwrap().revision, before, "{case}: {label} flipped"); + assert!(near(registration(&app, id), q), "{case}: {label} about {q:?}, now {:?}", registration(&app, id)); + } + // So do the panel menu's flips. + for item in ["Flip Horizontal", "Flip Vertical"] { + menu_click(&ctx, &mut app, item); + assert!(near(registration(&app, id), q), "{case}: menu {item} about {q:?}, now {:?}", registration(&app, id)); + } + } + } +} + +#[test] +fn artboard_fields_keep_the_reference_point() { + let r = Rect::new(0.0, 0.0, 300.0, 200.0); + // Centre: a new X centres it there, a new W grows it both ways. + assert_eq!(artboard_rect(r, 4, Some(200.0), None, None, None, false), Rect::new(50.0, 0.0, 350.0, 200.0)); + assert_eq!(artboard_rect(r, 4, None, None, Some(400.0), None, false), Rect::new(-50.0, 0.0, 350.0, 200.0)); + // Top-left: a new W keeps the left edge; linked, H follows. + assert_eq!(artboard_rect(r, 0, None, None, Some(600.0), None, true), Rect::new(0.0, 0.0, 600.0, 400.0)); + // Bottom-right: a new Y puts the bottom edge there. + assert_eq!(artboard_rect(r, 8, None, Some(500.0), None, None, false), Rect::new(0.0, 300.0, 300.0, 500.0)); +} + +#[test] +fn constrain_proportions() { + assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0)); + assert_eq!(constrained(100.0, 50.0, None, Some(25.0)), (50.0, 25.0)); + assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0)); +} + +/// The Transform panel names its Rotate and Shear inputs. +#[test] +fn rotate_and_shear_fields_are_named() { + use egui_kittest::kittest::Queryable; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(280.0, 500.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Rotate"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Shear"); +} From 5ce87c626f6a8a26d2895bdaaa10c5702dd41b40 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:05 +0000 Subject: [PATCH 054/199] refactor(ui-egui): move dialogs/brush_options.rs tests to dialogs/brush_options/tests.rs --- crates/ui-egui/src/dialogs/brush_options.rs | 470 +----------------- .../src/dialogs/brush_options/tests.rs | 467 +++++++++++++++++ 2 files changed, 468 insertions(+), 469 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/brush_options/tests.rs diff --git a/crates/ui-egui/src/dialogs/brush_options.rs b/crates/ui-egui/src/dialogs/brush_options.rs index 48fb29384..3b3c16523 100644 --- a/crates/ui-egui/src/dialogs/brush_options.rs +++ b/crates/ui-egui/src/dialogs/brush_options.rs @@ -359,472 +359,4 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_engine::Session; - - fn app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app - } - - fn set(app: &mut VectorcraftApp, fields: Value) { - let d = app.ui.dialog.as_mut().unwrap(); - for (k, v) in fields.as_object().unwrap() { - d.fields.insert(k.clone(), v.clone()); - } - } - - fn shown(app: &mut VectorcraftApp) -> String { - crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) - } - - #[test] - fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them() { - let mut app = app(); - app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); - let text = shown(&mut app); - for label in ["Calligraphic Brush Options", "Name:", "Angle:", "Roundness:", "Size:", "Variation:", "Fixed", "OK", "Cancel"] { - assert!(text.contains(label), "{label} in {text}"); - } - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.f64("angle", 0.0), d.f64("roundness", 0.0), d.f64("size", 0.0)), (45.0, 15.0, 6.0)); - // Out-of-range values are kept within their ranges. - set(&mut app, json!({"angleMode": "random", "angleVariation": 500, "roundness": 140, "sizeMode": "pressure", "sizeVariation": 4})); - assert!(shown(&mut app).contains("Pressure")); - super::super::confirm(&mut app).unwrap(); - let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap(); - assert_eq!(def["modes"], json!(["random", "fixed", "pressure"])); - assert_eq!((def["variation"].clone(), def["roundness"].clone()), (json!([180.0, 0.0, 4.0]), json!(100.0))); - // Opened again, it shows what was set. - app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0)); - } - - #[test] - fn a_taken_name_keeps_the_dialog_open() { - let mut app = app(); - app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); - set(&mut app, json!({"name": "3 pt. Round"})); - assert!(super::super::confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some()); - } - - #[test] - fn bristle_options_and_an_unknown_brush() { - let mut app = app(); - app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap(); - let text = shown(&mut app); - for label in ["Bristle Brush Options", "Shape:", "Bristle Length:", "Stiffness:", "Paint Opacity:"] { - assert!(text.contains(label), "{label} in {text}"); - } - set(&mut app, json!({"shape": "flatFan", "density": 0, "length": 120})); - super::super::confirm(&mut app).unwrap(); - let def = app.run("brush.get", json!({"name": "Bristle Round"})).unwrap(); - assert_eq!((def["shape"].clone(), def["density"].clone(), def["length"].clone()), (json!("flatFan"), json!(1.0), json!(120.0))); - // No such brush: an error, nothing opens. - app.ui.dialog = None; - assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err()); - assert!(app.ui.dialog.is_none()); - } - - /// Bristle Brush Options' Name field takes at most 31 characters as they are typed; the - /// Calligraphic Brush Options' Name field takes more. - #[test] - fn bristle_brush_options_name_field_takes_at_most_31_characters() { - let mut app = app(); - let typed = "é".repeat(40); - for (brush, kept) in [("Bristle Round", 31), ("6 pt. Flat", 40)] { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); - out.textures_delta.clear(); - }; - app.run("ui.brushOptions", json!({ "name": brush })).unwrap(); - // The Name field takes the focus with its text selected; typing replaces it. - frame(&mut app, vec![]); - frame(&mut app, vec![]); - frame(&mut app, vec![egui::Event::Text(typed.clone())]); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "é".repeat(kept), "{brush}"); - super::super::confirm(&mut app).unwrap(); - assert!(app.run("brush.get", json!({ "name": "é".repeat(kept) })).is_ok(), "{brush}"); - } - // A new name of more than 31 characters in the field (a longer saved name with some of its - // characters deleted) is refused, and the dialog stays open. - app.run("ui.brushOptions", json!({ "name": "é".repeat(31) })).unwrap(); - set(&mut app, json!({ "name": "é".repeat(35) })); - assert!(super::super::confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some()); - } - - /// A path painted with brush `name`, and the height of the art its stroke draws. - fn brushed(app: &mut VectorcraftApp, name: &str) -> (vectorcraft_doc::NodeId, f64) { - let id = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 280, "y2": 100})).unwrap()["id"].as_u64().unwrap(); - let id = vectorcraft_doc::NodeId(id); - app.run("brush.apply", json!({ "name": name, "ids": [id.0] })).unwrap(); - (id, drawn_height(app, id)) - } - - fn drawn_height(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> f64 { - let st = app.session.active().unwrap(); - let pieces = vectorcraft_brush::node_pieces(&st.doc, st.doc.node(id).unwrap()).unwrap(); - vectorcraft_brush::pieces_bounds(&pieces).unwrap().height() - } - - #[test] - fn scatter_options_show_each_value_with_its_mode_and_ok_applies_them() { - let mut app = app(); - let (id, before) = brushed(&mut app, "Confetti"); - app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - let text = shown(&mut app); - for label in [ - "Scatter Brush Options", - "Name:", - "Size:", - "Spacing:", - "Scatter:", - "Rotation:", - "Random", - "Rotation relative to:", - "Page", - "Colorization", - "Method:", - "Tints", - "Key Color:", - ] { - assert!(text.contains(label), "{label} in {text}"); - } - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.f64("sizeMin", 0.0), d.f64("sizeMax", 0.0), d.str("sizeMode")), (60.0, 140.0, "random".into())); - assert_eq!((d.str("rotationRelativeTo"), d.str("colorization")), ("page".into(), "tints".into())); - // Values are kept within their ranges (rotation here); Fixed takes the first value. - set( - &mut app, - json!({"sizeMode": "fixed", "sizeMin": 5000, "spacingMode": "pressure", "rotationMin": -400, "rotationRelativeTo": "path", "colorization": "hueShift"}), - ); - assert!(shown(&mut app).contains("Hue Shift")); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - let def = app.run("brush.get", json!({"name": "Confetti"})).unwrap(); - assert_eq!( - (&def["size"], &def["rotation"][0], &def["modes"]), - (&json!([5000.0, 140.0]), &json!(-180.0), &json!(["fixed", "pressure", "random", "random"])) - ); - assert_eq!((&def["rotation_relative_to_path"], &def["colorization"]["method"]), (&json!(true), &json!("hueShift"))); - assert!(def["colorization"]["key"].is_object(), "the art's colour as the key: {def}"); - assert!(drawn_height(&app, id) > before, "the stroke redraws with the bigger confetti"); - // Opened again, it shows what was set. - app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into())); - } - - #[test] - fn brush_key_color_eyedropper_updates_the_open_dialog() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = app(); - app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); - app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap(); - app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - set(&mut app, json!({"colorization": "hueShift"})); - let back = app.session.tool_id(); - app.select_tool("eyedropper"); - app.session.set_tool_option("brushKeyColor", &json!(true)); - app.session.set_tool_option("stop", &json!(back)); - let view = app.view_info(); - crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Down, 80.0, 70.0), view); - let key = app.ui.dialog.as_ref().unwrap().fields.get("keyColor").cloned().unwrap(); - assert_eq!(vectorcraft_engine::cmd::color_value(&key).unwrap().to_hex(), "#336699"); - assert_eq!(app.session.tool_id(), back); - } - - #[test] - fn art_options_set_width_scale_direction_flips_and_colorization() { - let mut app = app(); - let (id, before) = brushed(&mut app, "Arrow"); - app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap(); - let text = shown(&mut app); - for label in [ - "Art Brush Options", - "Width:", - "Brush Scale Options", - "Scale Proportionately", - "Stretch to Fit Stroke Length", - "Stretch Between Guides", - "Start:", - "End:", - "Direction:", - "Flip Along", - "Flip Across", - "Colorization", - ] { - assert!(text.contains(label), "{label} in {text}"); - } - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!( - (d.f64("width", 0.0), d.str("scaleMode"), d.f64("guideEnd", 0.0), d.str("direction")), - (100.0, "betweenGuides".into(), 78.0, "leftToRight".into()) - ); - set( - &mut app, - json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"}), - ); - super::super::confirm(&mut app).unwrap(); - let def = app.run("brush.get", json!({"name": "Arrow"})).unwrap(); - assert_eq!( - (&def["width"], &def["scale"], &def["direction"]), - (&json!(250.0), &json!({"mode": "betweenGuides", "start": 0.1, "end": 1.0}), &json!("bottomToTop")) - ); - assert_eq!((&def["flip_across"], &def["flip_along"], &def["colorization"]), (&json!(true), &json!(false), &json!({"method": "none"}))); - assert!(drawn_height(&app, id) != before, "the stroke redraws"); - // Stretch to Fit drops the guides. - app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap(); - set(&mut app, json!({"scaleMode": "stretch"})); - super::super::confirm(&mut app).unwrap(); - assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"})); - } - - /// Art Brush Options' Width menu: Pressure takes a second width for the heaviest pen pressure, - /// and a stroke drawn without pressure is half way between the two; Fixed drops the second width. - #[test] - fn art_options_width_follows_the_pen_pressure() { - let mut app = app(); - let (id, before) = brushed(&mut app, "Tapered Stroke"); - app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("fixed".into(), 100.0)); - assert!(shown(&mut app).contains("Fixed")); - set(&mut app, json!({"widthMode": "pressure", "widthMax": 300})); - assert!(shown(&mut app).contains("Pressure")); - super::super::confirm(&mut app).unwrap(); - let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap(); - assert_eq!((&def["width"], &def["width_mode"], &def["width_max"]), (&json!(100.0), &json!("pressure"), &json!(300.0))); - let after = drawn_height(&app, id); - assert!((after - 2.0 * before).abs() < 0.01 * before, "200 % half way: {before} then {after}"); - // Opened again, it shows what was set; Fixed drops the second width. - app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("pressure".into(), 300.0)); - set(&mut app, json!({"widthMode": "fixed"})); - super::super::confirm(&mut app).unwrap(); - let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap(); - assert!(def.get("width_mode").is_none() && def.get("width_max").is_none(), "{def}"); - assert!((drawn_height(&app, id) - before).abs() < 1e-6); - } - - /// A context with the app's fonts and theme, for [`frame`]. - fn ctx() -> egui::Context { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, Default::default()); - ctx - } - - /// Runs one 1200 × 900 frame with `events` through the app's key handling and the dialogs, and - /// returns the center of the text `label` if the frame shows it. - fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, label: &str) -> Option { - use egui::{Pos2, Rect}; - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - crate::shortcuts::handle(app, ui.ctx()); - super::super::show(app, ui.ctx()); - }); - out.textures_delta.clear(); - out.shapes.iter().find_map(|s| match &s.shape { - egui::Shape::Text(t) if t.galley.text() == label => Some(egui::Rect::from_min_size(t.pos, t.galley.size()).center()), - _ => None, - }) - } - - /// Clicks the text `label` the open dialog shows. - fn click_text(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) { - use egui::{Event, PointerButton}; - frame(ctx, app, vec![], label); - let at = frame(ctx, app, vec![], label).unwrap_or_else(|| panic!("no {label:?} shown")); - let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(ctx, app, vec![Event::PointerMoved(at), button(true)], label); - frame(ctx, app, vec![button(false)], label); - frame(ctx, app, vec![], label); - } - - /// Presses Escape. - fn escape(ctx: &egui::Context, app: &mut VectorcraftApp) { - let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - frame(ctx, app, vec![key], ""); - frame(ctx, app, vec![], ""); - } - - /// Scatter, Art and Pattern Brush Options have a Tips button beside Method. A click on it opens - /// Colorization Tips, which names each method and what it does beside a sample of the brush, - /// and a second click closes it. Screen readers name the button, the popover and each sample. - #[test] - fn tips_names_each_colorization_method_beside_a_sample() { - use crate::widgets::accessibility_tests::{role_and_state, tree}; - use egui::accesskit::Role; - let mut app = app(); - for name in ["Confetti", "Arrow", "Chain"] { - app.run("ui.brushOptions", json!({ "name": name })).unwrap(); - let text = shown(&mut app); - assert!(text.lines().any(|l| l == "Tips") && !text.contains("Colorization Tips"), "{name}: {text}"); - } - app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - let ctx = ctx(); - click_text(&ctx, &mut app, "Tips"); - assert!(app.ui.dialog.as_ref().unwrap().bool("tips"), "Tips opens Colorization Tips"); - let text = shown(&mut app); - for label in [ - "Colorization Tips", - "The samples show this brush on a blue stroke.", - "None", - "The art keeps its own colors.", - "Tints", - "Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness.", - "Tints and Shades", - "Hue Shift", - "The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness.", - "Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it.", - ] { - assert!(text.contains(label), "{label} in {text}"); - } - let nodes = tree(|ui| super::super::show(&mut app, ui.ctx())); - assert_eq!(role_and_state(&nodes, "Tips").0, Role::Button); - assert_eq!(role_and_state(&nodes, "Colorization Tips").0, Role::Dialog); - for method in ["None", "Tints", "Tints and Shades", "Hue Shift"] { - assert!(nodes.iter().any(|n| n.role() == Role::Image && n.label() == Some(method)), "a sample named {method}"); - } - click_text(&ctx, &mut app, "Tips"); - assert!(!app.ui.dialog.as_ref().unwrap().bool("tips"), "a second click closes it"); - assert!(!shown(&mut app).contains("Colorization Tips")); - // `ui.dialog.set {field: "tips", value: true}` opens it too. - set(&mut app, json!({"tips": true})); - assert!(shown(&mut app).contains("Colorization Tips")); - } - - /// Escape with Colorization Tips or the Method list open closes only that: Brush Options stays - /// open with its edits, and the next Escape cancels it. - #[test] - fn escape_closes_tips_or_the_method_list_and_leaves_the_dialog_open() { - let (mut app, ctx) = (app(), ctx()); - app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - set(&mut app, json!({"colorization": "tints"})); - click_text(&ctx, &mut app, "Tips"); - assert!(app.ui.dialog.as_ref().unwrap().bool("tips")); - escape(&ctx, &mut app); - let d = app.ui.dialog.as_ref().expect("Escape closes Tips, not Brush Options"); - assert!(!d.bool("tips") && d.str("colorization") == "tints"); - assert!(frame(&ctx, &mut app, vec![], "Colorization Tips").is_none()); - click_text(&ctx, &mut app, "Tints"); - assert!(egui::Popup::is_any_open(&ctx), "the Method list is open"); - escape(&ctx, &mut app); - assert!(app.ui.dialog.as_ref().is_some_and(|d| d.str("colorization") == "tints"), "Escape closes the list, not Brush Options"); - assert!(!egui::Popup::is_any_open(&ctx)); - escape(&ctx, &mut app); - assert!(app.ui.dialog.is_none(), "the next Escape cancels Brush Options"); - // Calligraphic Brush Options has no Tips: with `tips` set, Escape still cancels it. - app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); - set(&mut app, json!({"tips": true})); - escape(&ctx, &mut app); - assert!(app.ui.dialog.is_none(), "Escape cancels Calligraphic Brush Options"); - } - - /// Each Colorization Tips sample paints the brush with its own method on the blue stroke: - /// Confetti's black and gray art keeps its colors with None, its black becomes the stroke color - /// with Tints and with Hue Shift (black is its key color), and stays black with Tints and Shades. - #[test] - fn each_tips_sample_paints_the_brush_with_its_method() { - let mut app = app(); - app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - let key: vectorcraft_color::Color = serde_json::from_value(d.fields["keyColor"].clone()).unwrap(); - let colors = |id: &str| -> Vec { - let method = vectorcraft_brush::Colorization::parse(id, key).unwrap(); - let doc = brush_art_options::tip_doc(&brush_art_options::edited_def(d), "scatter", method, 120.0, 44.0).unwrap(); - let mut hex: Vec = vectorcraft_brush::art_colors(doc.layers.iter().map(|l| l.as_ref())).iter().map(|c| c.to_hex()).collect(); - hex.sort(); - hex - }; - let (black, blue) = ("#000000".to_string(), brush_art_options::TIP_STROKE.to_hex()); - let confetti = vectorcraft_brush::defaults().iter().find(|b| b.name == "Confetti").unwrap(); - let art: Vec = vectorcraft_brush::art_colors(confetti.art()).iter().map(|c| c.to_hex()).collect(); - let none = colors("none"); - assert!(none.len() == 2 && art.iter().all(|c| none.contains(c)), "None keeps the art's colors {art:?}: {none:?}"); - let (tints, shades, hue) = (colors("tints"), colors("tintsAndShades"), colors("hueShift")); - assert!(tints.contains(&blue) && !tints.contains(&black), "{tints:?}"); - assert!(shades.contains(&black) && !shades.contains(&blue), "{shades:?}"); - assert!(hue.contains(&blue) && !hue.contains(&black), "{hue:?}"); - assert!(tints != hue && tints != shades && none != shades, "four different samples"); - } - - /// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not - /// an object, Brush Options and Colorization Tips still draw. - #[test] - fn a_brush_definition_that_is_not_an_object_still_draws() { - let (mut app, ctx) = (app(), ctx()); - app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5})); - crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true})); - let text = shown(&mut app); - assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}"); - } - - #[test] - fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() { - let mut app = app(); - app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); - let text = shown(&mut app); - for label in [ - "Pattern Brush Options", - "Scale:", - "Spacing:", - "Outer Corner Tile", - "Side Tile", - "Inner Corner Tile", - "Start Tile", - "End Tile", - "None", - "Fit", - "Stretch to fit", - "Add space to fit", - "Approximate path", - "Flip Along", - ] { - assert!(text.contains(label), "{label} in {text}"); - } - let d = app.ui.dialog.as_ref().unwrap(); - let mut tiles: Vec<&String> = d.fields["__tiles"].as_object().unwrap().keys().collect(); - tiles.sort(); - assert_eq!(tiles, ["innerCorner", "outerCorner", "side"], "Chain has side and corner tiles, no start or end"); - // Diamonds (straight-edged tiles, so sizes compare exactly). - let (id, before) = brushed(&mut app, "Diamonds"); - app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); - assert_eq!((app.ui.dialog.as_ref().unwrap().f64("spacing", 0.0), app.ui.dialog.as_ref().unwrap().str("fit")), (25.0, "addSpace".into())); - set(&mut app, json!({"scale": 200, "spacing": 30, "fit": "approximate", "flipAlong": true})); - super::super::confirm(&mut app).unwrap(); - let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); - assert_eq!( - (&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]), - (&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true)) - ); - assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size"); - } - - /// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their - /// field, which screen readers name as the row, with the field's number and text over the - /// value's whole range. - #[test] - fn art_width_and_pattern_scale_have_sliders() { - use crate::widgets::accessibility_tests::{slider_value, tree}; - let mut app = app(); - app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap(); - set(&mut app, json!({"width": 250})); - let shown = tree(|ui| super::super::show(&mut app, ui.ctx())); - assert_eq!(slider_value(&shown, "Width:"), (Some(250.0), Some(1.0), Some(1000.0), Some("250%".into()))); - app.ui.dialog = None; - app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); - let shown = tree(|ui| super::super::show(&mut app, ui.ctx())); - assert_eq!(slider_value(&shown, "Scale:"), (Some(100.0), Some(1.0), Some(10000.0), Some("100%".into()))); - } -} +mod tests; diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs new file mode 100644 index 000000000..c8f612119 --- /dev/null +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -0,0 +1,467 @@ +use super::*; +use vectorcraft_engine::Session; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app +} + +fn set(app: &mut VectorcraftApp, fields: Value) { + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in fields.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } +} + +fn shown(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) +} + +#[test] +fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); + let text = shown(&mut app); + for label in ["Calligraphic Brush Options", "Name:", "Angle:", "Roundness:", "Size:", "Variation:", "Fixed", "OK", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("angle", 0.0), d.f64("roundness", 0.0), d.f64("size", 0.0)), (45.0, 15.0, 6.0)); + // Out-of-range values are kept within their ranges. + set(&mut app, json!({"angleMode": "random", "angleVariation": 500, "roundness": 140, "sizeMode": "pressure", "sizeVariation": 4})); + assert!(shown(&mut app).contains("Pressure")); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap(); + assert_eq!(def["modes"], json!(["random", "fixed", "pressure"])); + assert_eq!((def["variation"].clone(), def["roundness"].clone()), (json!([180.0, 0.0, 4.0]), json!(100.0))); + // Opened again, it shows what was set. + app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0)); +} + +#[test] +fn a_taken_name_keeps_the_dialog_open() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); + set(&mut app, json!({"name": "3 pt. Round"})); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); +} + +#[test] +fn bristle_options_and_an_unknown_brush() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap(); + let text = shown(&mut app); + for label in ["Bristle Brush Options", "Shape:", "Bristle Length:", "Stiffness:", "Paint Opacity:"] { + assert!(text.contains(label), "{label} in {text}"); + } + set(&mut app, json!({"shape": "flatFan", "density": 0, "length": 120})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Bristle Round"})).unwrap(); + assert_eq!((def["shape"].clone(), def["density"].clone(), def["length"].clone()), (json!("flatFan"), json!(1.0), json!(120.0))); + // No such brush: an error, nothing opens. + app.ui.dialog = None; + assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err()); + assert!(app.ui.dialog.is_none()); +} + +/// Bristle Brush Options' Name field takes at most 31 characters as they are typed; the +/// Calligraphic Brush Options' Name field takes more. +#[test] +fn bristle_brush_options_name_field_takes_at_most_31_characters() { + let mut app = app(); + let typed = "é".repeat(40); + for (brush, kept) in [("Bristle Round", 31), ("6 pt. Flat", 40)] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + app.run("ui.brushOptions", json!({ "name": brush })).unwrap(); + // The Name field takes the focus with its text selected; typing replaces it. + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text(typed.clone())]); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "é".repeat(kept), "{brush}"); + super::super::confirm(&mut app).unwrap(); + assert!(app.run("brush.get", json!({ "name": "é".repeat(kept) })).is_ok(), "{brush}"); + } + // A new name of more than 31 characters in the field (a longer saved name with some of its + // characters deleted) is refused, and the dialog stays open. + app.run("ui.brushOptions", json!({ "name": "é".repeat(31) })).unwrap(); + set(&mut app, json!({ "name": "é".repeat(35) })); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); +} + +/// A path painted with brush `name`, and the height of the art its stroke draws. +fn brushed(app: &mut VectorcraftApp, name: &str) -> (vectorcraft_doc::NodeId, f64) { + let id = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 280, "y2": 100})).unwrap()["id"].as_u64().unwrap(); + let id = vectorcraft_doc::NodeId(id); + app.run("brush.apply", json!({ "name": name, "ids": [id.0] })).unwrap(); + (id, drawn_height(app, id)) +} + +fn drawn_height(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> f64 { + let st = app.session.active().unwrap(); + let pieces = vectorcraft_brush::node_pieces(&st.doc, st.doc.node(id).unwrap()).unwrap(); + vectorcraft_brush::pieces_bounds(&pieces).unwrap().height() +} + +#[test] +fn scatter_options_show_each_value_with_its_mode_and_ok_applies_them() { + let mut app = app(); + let (id, before) = brushed(&mut app, "Confetti"); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + let text = shown(&mut app); + for label in [ + "Scatter Brush Options", + "Name:", + "Size:", + "Spacing:", + "Scatter:", + "Rotation:", + "Random", + "Rotation relative to:", + "Page", + "Colorization", + "Method:", + "Tints", + "Key Color:", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("sizeMin", 0.0), d.f64("sizeMax", 0.0), d.str("sizeMode")), (60.0, 140.0, "random".into())); + assert_eq!((d.str("rotationRelativeTo"), d.str("colorization")), ("page".into(), "tints".into())); + // Values are kept within their ranges (rotation here); Fixed takes the first value. + set( + &mut app, + json!({"sizeMode": "fixed", "sizeMin": 5000, "spacingMode": "pressure", "rotationMin": -400, "rotationRelativeTo": "path", "colorization": "hueShift"}), + ); + assert!(shown(&mut app).contains("Hue Shift")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let def = app.run("brush.get", json!({"name": "Confetti"})).unwrap(); + assert_eq!( + (&def["size"], &def["rotation"][0], &def["modes"]), + (&json!([5000.0, 140.0]), &json!(-180.0), &json!(["fixed", "pressure", "random", "random"])) + ); + assert_eq!((&def["rotation_relative_to_path"], &def["colorization"]["method"]), (&json!(true), &json!("hueShift"))); + assert!(def["colorization"]["key"].is_object(), "the art's colour as the key: {def}"); + assert!(drawn_height(&app, id) > before, "the stroke redraws with the bigger confetti"); + // Opened again, it shows what was set. + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into())); +} + +#[test] +fn brush_key_color_eyedropper_updates_the_open_dialog() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app(); + app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); + app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap(); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + set(&mut app, json!({"colorization": "hueShift"})); + let back = app.session.tool_id(); + app.select_tool("eyedropper"); + app.session.set_tool_option("brushKeyColor", &json!(true)); + app.session.set_tool_option("stop", &json!(back)); + let view = app.view_info(); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Down, 80.0, 70.0), view); + let key = app.ui.dialog.as_ref().unwrap().fields.get("keyColor").cloned().unwrap(); + assert_eq!(vectorcraft_engine::cmd::color_value(&key).unwrap().to_hex(), "#336699"); + assert_eq!(app.session.tool_id(), back); +} + +#[test] +fn art_options_set_width_scale_direction_flips_and_colorization() { + let mut app = app(); + let (id, before) = brushed(&mut app, "Arrow"); + app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap(); + let text = shown(&mut app); + for label in [ + "Art Brush Options", + "Width:", + "Brush Scale Options", + "Scale Proportionately", + "Stretch to Fit Stroke Length", + "Stretch Between Guides", + "Start:", + "End:", + "Direction:", + "Flip Along", + "Flip Across", + "Colorization", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.f64("width", 0.0), d.str("scaleMode"), d.f64("guideEnd", 0.0), d.str("direction")), + (100.0, "betweenGuides".into(), 78.0, "leftToRight".into()) + ); + set( + &mut app, + json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"}), + ); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Arrow"})).unwrap(); + assert_eq!( + (&def["width"], &def["scale"], &def["direction"]), + (&json!(250.0), &json!({"mode": "betweenGuides", "start": 0.1, "end": 1.0}), &json!("bottomToTop")) + ); + assert_eq!((&def["flip_across"], &def["flip_along"], &def["colorization"]), (&json!(true), &json!(false), &json!({"method": "none"}))); + assert!(drawn_height(&app, id) != before, "the stroke redraws"); + // Stretch to Fit drops the guides. + app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap(); + set(&mut app, json!({"scaleMode": "stretch"})); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"})); +} + +/// Art Brush Options' Width menu: Pressure takes a second width for the heaviest pen pressure, +/// and a stroke drawn without pressure is half way between the two; Fixed drops the second width. +#[test] +fn art_options_width_follows_the_pen_pressure() { + let mut app = app(); + let (id, before) = brushed(&mut app, "Tapered Stroke"); + app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("fixed".into(), 100.0)); + assert!(shown(&mut app).contains("Fixed")); + set(&mut app, json!({"widthMode": "pressure", "widthMax": 300})); + assert!(shown(&mut app).contains("Pressure")); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap(); + assert_eq!((&def["width"], &def["width_mode"], &def["width_max"]), (&json!(100.0), &json!("pressure"), &json!(300.0))); + let after = drawn_height(&app, id); + assert!((after - 2.0 * before).abs() < 0.01 * before, "200 % half way: {before} then {after}"); + // Opened again, it shows what was set; Fixed drops the second width. + app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("pressure".into(), 300.0)); + set(&mut app, json!({"widthMode": "fixed"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap(); + assert!(def.get("width_mode").is_none() && def.get("width_max").is_none(), "{def}"); + assert!((drawn_height(&app, id) - before).abs() < 1e-6); +} + +/// A context with the app's fonts and theme, for [`frame`]. +fn ctx() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + ctx +} + +/// Runs one 1200 × 900 frame with `events` through the app's key handling and the dialogs, and +/// returns the center of the text `label` if the frame shows it. +fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, label: &str) -> Option { + use egui::{Pos2, Rect}; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + super::super::show(app, ui.ctx()); + }); + out.textures_delta.clear(); + out.shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == label => Some(egui::Rect::from_min_size(t.pos, t.galley.size()).center()), + _ => None, + }) +} + +/// Clicks the text `label` the open dialog shows. +fn click_text(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) { + use egui::{Event, PointerButton}; + frame(ctx, app, vec![], label); + let at = frame(ctx, app, vec![], label).unwrap_or_else(|| panic!("no {label:?} shown")); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(ctx, app, vec![Event::PointerMoved(at), button(true)], label); + frame(ctx, app, vec![button(false)], label); + frame(ctx, app, vec![], label); +} + +/// Presses Escape. +fn escape(ctx: &egui::Context, app: &mut VectorcraftApp) { + let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(ctx, app, vec![key], ""); + frame(ctx, app, vec![], ""); +} + +/// Scatter, Art and Pattern Brush Options have a Tips button beside Method. A click on it opens +/// Colorization Tips, which names each method and what it does beside a sample of the brush, +/// and a second click closes it. Screen readers name the button, the popover and each sample. +#[test] +fn tips_names_each_colorization_method_beside_a_sample() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + use egui::accesskit::Role; + let mut app = app(); + for name in ["Confetti", "Arrow", "Chain"] { + app.run("ui.brushOptions", json!({ "name": name })).unwrap(); + let text = shown(&mut app); + assert!(text.lines().any(|l| l == "Tips") && !text.contains("Colorization Tips"), "{name}: {text}"); + } + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + let ctx = ctx(); + click_text(&ctx, &mut app, "Tips"); + assert!(app.ui.dialog.as_ref().unwrap().bool("tips"), "Tips opens Colorization Tips"); + let text = shown(&mut app); + for label in [ + "Colorization Tips", + "The samples show this brush on a blue stroke.", + "None", + "The art keeps its own colors.", + "Tints", + "Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness.", + "Tints and Shades", + "Hue Shift", + "The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness.", + "Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it.", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + let nodes = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert_eq!(role_and_state(&nodes, "Tips").0, Role::Button); + assert_eq!(role_and_state(&nodes, "Colorization Tips").0, Role::Dialog); + for method in ["None", "Tints", "Tints and Shades", "Hue Shift"] { + assert!(nodes.iter().any(|n| n.role() == Role::Image && n.label() == Some(method)), "a sample named {method}"); + } + click_text(&ctx, &mut app, "Tips"); + assert!(!app.ui.dialog.as_ref().unwrap().bool("tips"), "a second click closes it"); + assert!(!shown(&mut app).contains("Colorization Tips")); + // `ui.dialog.set {field: "tips", value: true}` opens it too. + set(&mut app, json!({"tips": true})); + assert!(shown(&mut app).contains("Colorization Tips")); +} + +/// Escape with Colorization Tips or the Method list open closes only that: Brush Options stays +/// open with its edits, and the next Escape cancels it. +#[test] +fn escape_closes_tips_or_the_method_list_and_leaves_the_dialog_open() { + let (mut app, ctx) = (app(), ctx()); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + set(&mut app, json!({"colorization": "tints"})); + click_text(&ctx, &mut app, "Tips"); + assert!(app.ui.dialog.as_ref().unwrap().bool("tips")); + escape(&ctx, &mut app); + let d = app.ui.dialog.as_ref().expect("Escape closes Tips, not Brush Options"); + assert!(!d.bool("tips") && d.str("colorization") == "tints"); + assert!(frame(&ctx, &mut app, vec![], "Colorization Tips").is_none()); + click_text(&ctx, &mut app, "Tints"); + assert!(egui::Popup::is_any_open(&ctx), "the Method list is open"); + escape(&ctx, &mut app); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.str("colorization") == "tints"), "Escape closes the list, not Brush Options"); + assert!(!egui::Popup::is_any_open(&ctx)); + escape(&ctx, &mut app); + assert!(app.ui.dialog.is_none(), "the next Escape cancels Brush Options"); + // Calligraphic Brush Options has no Tips: with `tips` set, Escape still cancels it. + app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); + set(&mut app, json!({"tips": true})); + escape(&ctx, &mut app); + assert!(app.ui.dialog.is_none(), "Escape cancels Calligraphic Brush Options"); +} + +/// Each Colorization Tips sample paints the brush with its own method on the blue stroke: +/// Confetti's black and gray art keeps its colors with None, its black becomes the stroke color +/// with Tints and with Hue Shift (black is its key color), and stays black with Tints and Shades. +#[test] +fn each_tips_sample_paints_the_brush_with_its_method() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + let key: vectorcraft_color::Color = serde_json::from_value(d.fields["keyColor"].clone()).unwrap(); + let colors = |id: &str| -> Vec { + let method = vectorcraft_brush::Colorization::parse(id, key).unwrap(); + let doc = brush_art_options::tip_doc(&brush_art_options::edited_def(d), "scatter", method, 120.0, 44.0).unwrap(); + let mut hex: Vec = vectorcraft_brush::art_colors(doc.layers.iter().map(|l| l.as_ref())).iter().map(|c| c.to_hex()).collect(); + hex.sort(); + hex + }; + let (black, blue) = ("#000000".to_string(), brush_art_options::TIP_STROKE.to_hex()); + let confetti = vectorcraft_brush::defaults().iter().find(|b| b.name == "Confetti").unwrap(); + let art: Vec = vectorcraft_brush::art_colors(confetti.art()).iter().map(|c| c.to_hex()).collect(); + let none = colors("none"); + assert!(none.len() == 2 && art.iter().all(|c| none.contains(c)), "None keeps the art's colors {art:?}: {none:?}"); + let (tints, shades, hue) = (colors("tints"), colors("tintsAndShades"), colors("hueShift")); + assert!(tints.contains(&blue) && !tints.contains(&black), "{tints:?}"); + assert!(shades.contains(&black) && !shades.contains(&blue), "{shades:?}"); + assert!(hue.contains(&blue) && !hue.contains(&black), "{hue:?}"); + assert!(tints != hue && tints != shades && none != shades, "four different samples"); +} + +/// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not +/// an object, Brush Options and Colorization Tips still draw. +#[test] +fn a_brush_definition_that_is_not_an_object_still_draws() { + let (mut app, ctx) = (app(), ctx()); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5})); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true})); + let text = shown(&mut app); + assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}"); +} + +#[test] +fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + let text = shown(&mut app); + for label in [ + "Pattern Brush Options", + "Scale:", + "Spacing:", + "Outer Corner Tile", + "Side Tile", + "Inner Corner Tile", + "Start Tile", + "End Tile", + "None", + "Fit", + "Stretch to fit", + "Add space to fit", + "Approximate path", + "Flip Along", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + let d = app.ui.dialog.as_ref().unwrap(); + let mut tiles: Vec<&String> = d.fields["__tiles"].as_object().unwrap().keys().collect(); + tiles.sort(); + assert_eq!(tiles, ["innerCorner", "outerCorner", "side"], "Chain has side and corner tiles, no start or end"); + // Diamonds (straight-edged tiles, so sizes compare exactly). + let (id, before) = brushed(&mut app, "Diamonds"); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + assert_eq!((app.ui.dialog.as_ref().unwrap().f64("spacing", 0.0), app.ui.dialog.as_ref().unwrap().str("fit")), (25.0, "addSpace".into())); + set(&mut app, json!({"scale": 200, "spacing": 30, "fit": "approximate", "flipAlong": true})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert_eq!( + (&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]), + (&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true)) + ); + assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size"); +} + +/// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their +/// field, which screen readers name as the row, with the field's number and text over the +/// value's whole range. +#[test] +fn art_width_and_pattern_scale_have_sliders() { + use crate::widgets::accessibility_tests::{slider_value, tree}; + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap(); + set(&mut app, json!({"width": 250})); + let shown = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert_eq!(slider_value(&shown, "Width:"), (Some(250.0), Some(1.0), Some(1000.0), Some("250%".into()))); + app.ui.dialog = None; + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + let shown = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert_eq!(slider_value(&shown, "Scale:"), (Some(100.0), Some(1.0), Some(10000.0), Some("100%".into()))); +} From 3e2c741dfb8e6331ba6679b284258189cda8042e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:05 +0000 Subject: [PATCH 055/199] refactor(color): move gradient.rs tests to gradient/tests.rs --- crates/color/src/gradient.rs | 466 +---------------------------- crates/color/src/gradient/tests.rs | 463 ++++++++++++++++++++++++++++ 2 files changed, 464 insertions(+), 465 deletions(-) create mode 100644 crates/color/src/gradient/tests.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index f23eb18e6..528d56033 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -801,468 +801,4 @@ pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sample_endpoints_and_mid() { - let g = Gradient::default(); - assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff"); - assert_eq!(g.sample(1.0).0.to_hex(), "#000000"); - let mid = g.sample(0.5).0.to_rgb()[0]; - assert!((mid - 0.5).abs() < 1e-5); - } - - /// Sampling increasing positions in one pass gives what sampling each alone does, out-of-order - /// and repeated stop offsets included. - #[test] - fn increasing_samples_match_single_samples() { - let stop = |offset: f32, hex: &str, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; - let g = Gradient::new( - GradientKind::Linear, - vec![ - stop(0.1, "#ff0000", 0.3), - stop(0.4, "#00ff00", 0.7), - stop(0.4, "#0000ff", 0.5), - stop(0.2, "#ffff00", 0.5), - stop(0.9, "#000000", 0.5), - ], - ); - let ts: Vec = (0..=40).map(|k| k as f32 / 40.0).collect(); - let one_by_one: Vec<_> = ts.iter().map(|&t| g.sample_with(t, Color::lerp_keep_model)).collect(); - assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one); - } - - #[test] - fn midpoint_shifts() { - let mut g = Gradient::default(); - g.stops[0].midpoint = 0.25; - // At t = 0.25 we should be half way. - assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5); - assert_eq!(g.expanded_stops().len(), 3); - } - - #[test] - fn fit_linear_horizontal() { - let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); - assert_eq!(g.start, Point::new(0.0, 25.0)); - assert_eq!(g.end, Point::new(100.0, 25.0)); - assert!((g.angle_deg()).abs() < 1e-9); - let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0); - assert!((v.angle_deg() - 90.0).abs() < 1e-9); - assert!((v.length() - 50.0).abs() < 1e-9); - } - - #[test] - fn reverse_stops() { - let mut g = Gradient::default(); - g.reverse(); - assert_eq!(g.stops[0].color.to_hex(), "#000000"); - assert_eq!(g.stops[0].offset, 0.0); - } - - /// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same - /// the other way round. - #[test] - fn reverse_mirrors_midpoints() { - let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; - let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]); - let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]); - for g in [two, three] { - let mut r = g.clone(); - r.reverse(); - let mids: Vec = r.stops.iter().map(|s| s.midpoint).collect(); - let want: Vec = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect(); - assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}"); - for i in 0..=20 { - let t = i as f32 / 20.0; - let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t)); - let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1); - assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}"); - } - r.reverse(); - let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6); - assert!(back, "reversed twice, it is as it was: {:?}", r.stops); - } - } - - fn close(a: Point, b: Point) -> bool { - a.distance(b) < 1e-9 - } - - fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom { - GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None } - } - - #[test] - fn rotate_90_gives_vertical_vector() { - let mut g = linear((0.0, 0.0), (100.0, 0.0)); - g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); - assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}"); - assert!((g.angle_deg() + 90.0).abs() < 1e-9); - } - - #[test] - fn reflect_swaps_start_and_end() { - let mut g = linear((0.0, 25.0), (100.0, 25.0)); - // Reflect across the vertical axis through x = 50. - let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0)); - g.transform(flip, GradientKind::Linear); - assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}"); - } - - #[test] - fn non_uniform_scale_turns_a_circle_into_an_ellipse() { - let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None }; - g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); - assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}"); - assert!((g.aspect - 0.5).abs() < 1e-9); - // A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long. - let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None }; - v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); - assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}"); - } - - #[test] - fn samples_follow_shear_and_rotation() { - let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4); - for kind in [GradientKind::Linear, GradientKind::Radial] { - let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None }; - let mut m = g; - m.transform(shear, kind); - for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] { - let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p)); - assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}"); - } - } - } - - #[test] - fn radial_fit_uses_the_angle_and_param_honours_aspect() { - let b = Rect::new(0.0, 0.0, 100.0, 60.0); - let g = GradientGeom::fit(GradientKind::Radial, b, 90.0); - assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}"); - let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None }; - assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12); - assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12); - assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12); - } - - fn g3() -> Gradient { - let mut g = Gradient::default(); - g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0))); - g - } - - #[test] - fn insert_samples_color_and_sorts() { - let g = Gradient::default(); - let (stops, i) = insert_stop(&g, 0.25); - assert_eq!(stops.len(), 3); - assert_eq!(i, 1); - let r = stops[1].color.to_rgb()[0]; - assert!((r - 0.75).abs() < 1e-4, "sampled {r}"); - let (stops, i) = insert_stop(&g, 2.0); - assert_eq!(i, 2); - assert_eq!(stops[2].offset, 1.0); - } - - #[test] - fn remove_keeps_two() { - let g = g3(); - let v = remove_stop(&g.stops, 1).unwrap(); - assert_eq!(v.len(), 2); - assert!(remove_stop(&v, 0).is_none()); - assert!(remove_stop(&g.stops, 9).is_none()); - } - - #[test] - fn move_reorders_and_tracks_index() { - let g = g3(); - let (v, i) = move_stop(&g.stops, 0, 0.8); - assert_eq!(i, 1); - assert_eq!(v[1].color.to_hex(), "#ffffff"); - assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset)); - let (v, i) = move_stop(&g.stops, 1, -3.0); - // Clamped to 0 and placed after the existing stop at 0. - assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string())); - } - - #[test] - fn duplicate_copies_the_stop_to_a_new_offset() { - let g = g3(); - let (v, i) = duplicate_stop(&g.stops, 1, 0.9); - assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string())); - assert_eq!(v[1], g.stops[1], "the original stays"); - assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops); - } - - #[test] - fn swap_exchanges_only_the_colours() { - let mut g = g3(); - g.stops[0].opacity = 0.25; - let v = swap_stop_colors(&g.stops, 0, 1); - assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string())); - assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0)); - assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops); - } - - #[test] - fn midpoint_math() { - let g = g3(); - assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25)); - assert_eq!(midpoint_pos(&g.stops, 2), None); - let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap(); - assert!((m - 0.2).abs() < 1e-5); - assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13); - let v = set_midpoint(&g.stops, 0, 0.99); - assert_eq!(v[0].midpoint, 0.87); - } - - #[test] - fn rebase_keeps_relative_positions() { - let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None }; - g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0)); - assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}"); - // A box without height (a horizontal line) keeps the offset from its centre line. - let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None }; - h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0)); - assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}"); - // Unplaced paints have nothing to move. - let mut p = GradientPaint::new(Gradient::default()); - p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0)); - assert_eq!(p.geom, None); - } - - #[test] - fn pin_fixes_the_fit_once() { - let mut p = GradientPaint::new(Gradient::default()); - p.angle = 90.0; - let b = Rect::new(0.0, 0.0, 100.0, 50.0); - p.pin(b); - assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); - p.pin(Rect::new(0.0, 0.0, 1.0, 1.0)); - assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); - } - - /// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre. - fn off_centre() -> GradientGeom { - let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None }; - g.set_focal(Some(Point::new(160.0, 200.0))); - g - } - - #[test] - fn focal_round_trips_through_serde_and_defaults_to_none() { - let g = off_centre(); - let s = serde_json::to_string(&g).unwrap(); - assert!(s.contains("\"focal\""), "{s}"); - assert_eq!(serde_json::from_str::(&s).unwrap(), g); - // Placements saved before focal points existed load centred, and centred ones write none. - let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap(); - assert_eq!((old.focal, old.aspect), (None, 1.0)); - assert!(!serde_json::to_string(&old).unwrap().contains("focal")); - } - - #[test] - fn focal_follows_transforms_and_rebases() { - let mut g = off_centre(); - let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5); - let f = a * g.focal.unwrap(); - g.transform(a, GradientKind::Radial); - assert!(close(g.focal.unwrap(), f), "{g:?}"); - // Still at the same parameter: the first stop. - assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9); - let mut h = off_centre(); - h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0)); - assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}"); - } - - #[test] - fn param_runs_from_the_focal_point_to_the_ellipse() { - let g = off_centre(); - let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y)); - assert!(t(160.0, 200.0).abs() < 1e-9); - // The extent circle is the last stop all round. - for a in [0.0f64, 1.0, 2.5, 4.0] { - let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin()); - assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}"); - } - // The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point - // (0.4 of the radius off), with a radius of 2/7. - assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0)); - assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier"); - assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5); - } - - #[test] - fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() { - let mut g = off_centre(); - g.set_focal(Some(Point::new(500.0, 200.0))); - assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}"); - g.set_focal(Some(Point::new(200.0, 200.0))); - assert_eq!(g.focal, None, "on the centre: centred"); - // A new vector and aspect keep it in its place in the ellipse. - let before = off_centre(); - let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None }; - h.keep_focal_from(&before); - assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}"); - } - - #[test] - fn pixels_sample_the_ramp_at_each_cell_centre() { - // White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end. - let mut g = GradientPaint::new(Gradient::default()); - g.gradient.stops[0].midpoint = 0.3; - g.gradient.stops[1].opacity = 0.5; - let b = Rect::new(0.0, 0.0, 10.0, 2.0); - let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect(); - assert_eq!(px.len(), 20); - let ramp = Ramp::new(&g.gradient, &Color::to_rgb); - for (i, p) in px.iter().take(10).enumerate() { - let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8); - assert_eq!(*p, c, "pixel {i}"); - } - assert_eq!(px[3], px[13], "rows alike"); - // The midpoint stop: grey at 30%; padded past the ends. - assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3)); - assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]); - assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]); - assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]); - } - - fn blue_yellow(method: GradientMethod) -> Gradient { - let mut g = Gradient::new( - GradientKind::Linear, - vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))], - ); - g.method = method; - g - } - - /// Illustrator 30's Perceptual method mixes colours in a perceptually even way - /// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html). - /// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey - /// a display RGB mix gives, and its lightness climbs evenly. - #[test] - fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() { - let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb(); - assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}"); - let p = blue_yellow(GradientMethod::Perceptual); - let [r, g, b] = p.sample(0.5).0.to_rgb(); - assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]); - assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]); - let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0]; - let steps: Vec = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect(); - assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}"); - // Ends and opacity as before. - assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff"); - assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00"); - } - - /// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual - /// curve (the midpoint diamond among them), more where it bends, close enough that the linear - /// ramp between them stays within half a colour level of the curve; a segment that hardly - /// bends needs few. - #[test] - fn perceptual_segments_expand_into_stops_on_the_curve() { - let pairs = [ - (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)), - (Color::BLACK, Color::rgb(1.0, 0.0, 0.0)), - (Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)), - (Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE), - ]; - for (a, b) in pairs { - let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]); - p.method = GradientMethod::Perceptual; - p.stops[0].midpoint = 0.3; - p.stops[1].opacity = 0.5; - let ex: Vec<_> = p.expanded().collect(); - assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len()); - assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order"); - assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint"); - assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6)); - let ramp = Ramp::new(&p, &Color::to_rgb); - for k in 0..=2000 { - let t = k as f32 / 2000.0; - let (c, o) = p.sample(t); - let [r, g, b] = c.to_rgb(); - let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); - assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}"); - } - } - let mut flat = Gradient::new( - GradientKind::Linear, - vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))], - ); - flat.method = GradientMethod::Perceptual; - assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint"); - } - - /// Classic gradients expand as they always did: their stops, and a stop at each off-centre - /// midpoint, sampled in display RGB (so the renderer draws the same pixels as before). - #[test] - fn classic_gradients_expand_as_before() { - let mut g = Gradient::new( - GradientKind::Linear, - vec![ - GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) }, - GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) }, - GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) }, - GradientStop::new(1.0, Color::gray(0.3)), - ], - ); - // The expansion gradients had before they had a method. - let before: Vec<_> = g - .stops - .iter() - .enumerate() - .flat_map(|(i, s)| { - let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { - let t = s.offset + (n.offset - s.offset) * s.midpoint; - let (c, o) = g.sample_with(t, Color::lerp); - (t, c, o, Some(s.midpoint)) - }); - std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) - }) - .collect(); - assert_eq!(g.expanded().collect::>(), before); - assert_eq!(before.len(), 6); - assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp)); - assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model)); - g.method = GradientMethod::Perceptual; - assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix"); - } - - /// The method is written only when Perceptual, so files from before it (and Classic - /// gradients) read as Classic and new Classic files stay as they were. - #[test] - fn method_is_saved_only_when_perceptual() { - let classic = serde_json::to_value(Gradient::default()).unwrap(); - assert!(classic.get("method").is_none(), "{classic}"); - let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); - assert_eq!(old.method, GradientMethod::Classic); - let p = blue_yellow(GradientMethod::Perceptual); - let v = serde_json::to_value(&p).unwrap(); - assert_eq!(v["method"], "Perceptual"); - assert_eq!(serde_json::from_value::(v).unwrap(), p); - assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual)); - assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic)); - assert_eq!(GradientMethod::parse("smooth"), None); - } - - /// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there, - /// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke, - /// dithered or not. - #[test] - fn perceptual_gradients_draw_as_pixels() { - for fill in [true, false] { - assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill)); - assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill)); - let mut radial = blue_yellow(GradientMethod::Perceptual); - radial.kind = GradientKind::Radial; - radial.dither = true; - assert!(GradientPaint::new(radial).draws_as_pixels(fill)); - } - } -} +mod tests; diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs new file mode 100644 index 000000000..495980e05 --- /dev/null +++ b/crates/color/src/gradient/tests.rs @@ -0,0 +1,463 @@ +use super::*; + +#[test] +fn sample_endpoints_and_mid() { + let g = Gradient::default(); + assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff"); + assert_eq!(g.sample(1.0).0.to_hex(), "#000000"); + let mid = g.sample(0.5).0.to_rgb()[0]; + assert!((mid - 0.5).abs() < 1e-5); +} + +/// Sampling increasing positions in one pass gives what sampling each alone does, out-of-order +/// and repeated stop offsets included. +#[test] +fn increasing_samples_match_single_samples() { + let stop = |offset: f32, hex: &str, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let g = Gradient::new( + GradientKind::Linear, + vec![ + stop(0.1, "#ff0000", 0.3), + stop(0.4, "#00ff00", 0.7), + stop(0.4, "#0000ff", 0.5), + stop(0.2, "#ffff00", 0.5), + stop(0.9, "#000000", 0.5), + ], + ); + let ts: Vec = (0..=40).map(|k| k as f32 / 40.0).collect(); + let one_by_one: Vec<_> = ts.iter().map(|&t| g.sample_with(t, Color::lerp_keep_model)).collect(); + assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one); +} + +#[test] +fn midpoint_shifts() { + let mut g = Gradient::default(); + g.stops[0].midpoint = 0.25; + // At t = 0.25 we should be half way. + assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5); + assert_eq!(g.expanded_stops().len(), 3); +} + +#[test] +fn fit_linear_horizontal() { + let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); + assert_eq!(g.start, Point::new(0.0, 25.0)); + assert_eq!(g.end, Point::new(100.0, 25.0)); + assert!((g.angle_deg()).abs() < 1e-9); + let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0); + assert!((v.angle_deg() - 90.0).abs() < 1e-9); + assert!((v.length() - 50.0).abs() < 1e-9); +} + +#[test] +fn reverse_stops() { + let mut g = Gradient::default(); + g.reverse(); + assert_eq!(g.stops[0].color.to_hex(), "#000000"); + assert_eq!(g.stops[0].offset, 0.0); +} + +/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same +/// the other way round. +#[test] +fn reverse_mirrors_midpoints() { + let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]); + let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]); + for g in [two, three] { + let mut r = g.clone(); + r.reverse(); + let mids: Vec = r.stops.iter().map(|s| s.midpoint).collect(); + let want: Vec = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect(); + assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}"); + for i in 0..=20 { + let t = i as f32 / 20.0; + let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t)); + let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}"); + } + r.reverse(); + let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6); + assert!(back, "reversed twice, it is as it was: {:?}", r.stops); + } +} + +fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 +} + +fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom { + GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None } +} + +#[test] +fn rotate_90_gives_vertical_vector() { + let mut g = linear((0.0, 0.0), (100.0, 0.0)); + g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}"); + assert!((g.angle_deg() + 90.0).abs() < 1e-9); +} + +#[test] +fn reflect_swaps_start_and_end() { + let mut g = linear((0.0, 25.0), (100.0, 25.0)); + // Reflect across the vertical axis through x = 50. + let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0)); + g.transform(flip, GradientKind::Linear); + assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}"); +} + +#[test] +fn non_uniform_scale_turns_a_circle_into_an_ellipse() { + let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None }; + g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); + assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}"); + assert!((g.aspect - 0.5).abs() < 1e-9); + // A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long. + let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None }; + v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); + assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}"); +} + +#[test] +fn samples_follow_shear_and_rotation() { + let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4); + for kind in [GradientKind::Linear, GradientKind::Radial] { + let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None }; + let mut m = g; + m.transform(shear, kind); + for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] { + let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p)); + assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}"); + } + } +} + +#[test] +fn radial_fit_uses_the_angle_and_param_honours_aspect() { + let b = Rect::new(0.0, 0.0, 100.0, 60.0); + let g = GradientGeom::fit(GradientKind::Radial, b, 90.0); + assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}"); + let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None }; + assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12); + assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12); + assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12); +} + +fn g3() -> Gradient { + let mut g = Gradient::default(); + g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0))); + g +} + +#[test] +fn insert_samples_color_and_sorts() { + let g = Gradient::default(); + let (stops, i) = insert_stop(&g, 0.25); + assert_eq!(stops.len(), 3); + assert_eq!(i, 1); + let r = stops[1].color.to_rgb()[0]; + assert!((r - 0.75).abs() < 1e-4, "sampled {r}"); + let (stops, i) = insert_stop(&g, 2.0); + assert_eq!(i, 2); + assert_eq!(stops[2].offset, 1.0); +} + +#[test] +fn remove_keeps_two() { + let g = g3(); + let v = remove_stop(&g.stops, 1).unwrap(); + assert_eq!(v.len(), 2); + assert!(remove_stop(&v, 0).is_none()); + assert!(remove_stop(&g.stops, 9).is_none()); +} + +#[test] +fn move_reorders_and_tracks_index() { + let g = g3(); + let (v, i) = move_stop(&g.stops, 0, 0.8); + assert_eq!(i, 1); + assert_eq!(v[1].color.to_hex(), "#ffffff"); + assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset)); + let (v, i) = move_stop(&g.stops, 1, -3.0); + // Clamped to 0 and placed after the existing stop at 0. + assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string())); +} + +#[test] +fn duplicate_copies_the_stop_to_a_new_offset() { + let g = g3(); + let (v, i) = duplicate_stop(&g.stops, 1, 0.9); + assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string())); + assert_eq!(v[1], g.stops[1], "the original stays"); + assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops); +} + +#[test] +fn swap_exchanges_only_the_colours() { + let mut g = g3(); + g.stops[0].opacity = 0.25; + let v = swap_stop_colors(&g.stops, 0, 1); + assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string())); + assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0)); + assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops); +} + +#[test] +fn midpoint_math() { + let g = g3(); + assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25)); + assert_eq!(midpoint_pos(&g.stops, 2), None); + let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap(); + assert!((m - 0.2).abs() < 1e-5); + assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13); + let v = set_midpoint(&g.stops, 0, 0.99); + assert_eq!(v[0].midpoint, 0.87); +} + +#[test] +fn rebase_keeps_relative_positions() { + let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None }; + g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0)); + assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}"); + // A box without height (a horizontal line) keeps the offset from its centre line. + let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None }; + h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0)); + assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}"); + // Unplaced paints have nothing to move. + let mut p = GradientPaint::new(Gradient::default()); + p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0)); + assert_eq!(p.geom, None); +} + +#[test] +fn pin_fixes_the_fit_once() { + let mut p = GradientPaint::new(Gradient::default()); + p.angle = 90.0; + let b = Rect::new(0.0, 0.0, 100.0, 50.0); + p.pin(b); + assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); + p.pin(Rect::new(0.0, 0.0, 1.0, 1.0)); + assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); +} + +/// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre. +fn off_centre() -> GradientGeom { + let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None }; + g.set_focal(Some(Point::new(160.0, 200.0))); + g +} + +#[test] +fn focal_round_trips_through_serde_and_defaults_to_none() { + let g = off_centre(); + let s = serde_json::to_string(&g).unwrap(); + assert!(s.contains("\"focal\""), "{s}"); + assert_eq!(serde_json::from_str::(&s).unwrap(), g); + // Placements saved before focal points existed load centred, and centred ones write none. + let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap(); + assert_eq!((old.focal, old.aspect), (None, 1.0)); + assert!(!serde_json::to_string(&old).unwrap().contains("focal")); +} + +#[test] +fn focal_follows_transforms_and_rebases() { + let mut g = off_centre(); + let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5); + let f = a * g.focal.unwrap(); + g.transform(a, GradientKind::Radial); + assert!(close(g.focal.unwrap(), f), "{g:?}"); + // Still at the same parameter: the first stop. + assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9); + let mut h = off_centre(); + h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}"); +} + +#[test] +fn param_runs_from_the_focal_point_to_the_ellipse() { + let g = off_centre(); + let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y)); + assert!(t(160.0, 200.0).abs() < 1e-9); + // The extent circle is the last stop all round. + for a in [0.0f64, 1.0, 2.5, 4.0] { + let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin()); + assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}"); + } + // The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point + // (0.4 of the radius off), with a radius of 2/7. + assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0)); + assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier"); + assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5); +} + +#[test] +fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() { + let mut g = off_centre(); + g.set_focal(Some(Point::new(500.0, 200.0))); + assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}"); + g.set_focal(Some(Point::new(200.0, 200.0))); + assert_eq!(g.focal, None, "on the centre: centred"); + // A new vector and aspect keep it in its place in the ellipse. + let before = off_centre(); + let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None }; + h.keep_focal_from(&before); + assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}"); +} + +#[test] +fn pixels_sample_the_ramp_at_each_cell_centre() { + // White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end. + let mut g = GradientPaint::new(Gradient::default()); + g.gradient.stops[0].midpoint = 0.3; + g.gradient.stops[1].opacity = 0.5; + let b = Rect::new(0.0, 0.0, 10.0, 2.0); + let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect(); + assert_eq!(px.len(), 20); + let ramp = Ramp::new(&g.gradient, &Color::to_rgb); + for (i, p) in px.iter().take(10).enumerate() { + let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8); + assert_eq!(*p, c, "pixel {i}"); + } + assert_eq!(px[3], px[13], "rows alike"); + // The midpoint stop: grey at 30%; padded past the ends. + assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3)); + assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]); + assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]); + assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]); +} + +fn blue_yellow(method: GradientMethod) -> Gradient { + let mut g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))], + ); + g.method = method; + g +} + +/// Illustrator 30's Perceptual method mixes colours in a perceptually even way +/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html). +/// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey +/// a display RGB mix gives, and its lightness climbs evenly. +#[test] +fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() { + let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb(); + assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}"); + let p = blue_yellow(GradientMethod::Perceptual); + let [r, g, b] = p.sample(0.5).0.to_rgb(); + assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]); + assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]); + let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0]; + let steps: Vec = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect(); + assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}"); + // Ends and opacity as before. + assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff"); + assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00"); +} + +/// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual +/// curve (the midpoint diamond among them), more where it bends, close enough that the linear +/// ramp between them stays within half a colour level of the curve; a segment that hardly +/// bends needs few. +#[test] +fn perceptual_segments_expand_into_stops_on_the_curve() { + let pairs = [ + (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)), + (Color::BLACK, Color::rgb(1.0, 0.0, 0.0)), + (Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)), + (Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE), + ]; + for (a, b) in pairs { + let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]); + p.method = GradientMethod::Perceptual; + p.stops[0].midpoint = 0.3; + p.stops[1].opacity = 0.5; + let ex: Vec<_> = p.expanded().collect(); + assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len()); + assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order"); + assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint"); + assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6)); + let ramp = Ramp::new(&p, &Color::to_rgb); + for k in 0..=2000 { + let t = k as f32 / 2000.0; + let (c, o) = p.sample(t); + let [r, g, b] = c.to_rgb(); + let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}"); + } + } + let mut flat = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))], + ); + flat.method = GradientMethod::Perceptual; + assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint"); +} + +/// Classic gradients expand as they always did: their stops, and a stop at each off-centre +/// midpoint, sampled in display RGB (so the renderer draws the same pixels as before). +#[test] +fn classic_gradients_expand_as_before() { + let mut g = Gradient::new( + GradientKind::Linear, + vec![ + GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) }, + GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) }, + GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) }, + GradientStop::new(1.0, Color::gray(0.3)), + ], + ); + // The expansion gradients had before they had a method. + let before: Vec<_> = g + .stops + .iter() + .enumerate() + .flat_map(|(i, s)| { + let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { + let t = s.offset + (n.offset - s.offset) * s.midpoint; + let (c, o) = g.sample_with(t, Color::lerp); + (t, c, o, Some(s.midpoint)) + }); + std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) + }) + .collect(); + assert_eq!(g.expanded().collect::>(), before); + assert_eq!(before.len(), 6); + assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp)); + assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model)); + g.method = GradientMethod::Perceptual; + assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix"); +} + +/// The method is written only when Perceptual, so files from before it (and Classic +/// gradients) read as Classic and new Classic files stay as they were. +#[test] +fn method_is_saved_only_when_perceptual() { + let classic = serde_json::to_value(Gradient::default()).unwrap(); + assert!(classic.get("method").is_none(), "{classic}"); + let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); + assert_eq!(old.method, GradientMethod::Classic); + let p = blue_yellow(GradientMethod::Perceptual); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["method"], "Perceptual"); + assert_eq!(serde_json::from_value::(v).unwrap(), p); + assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual)); + assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic)); + assert_eq!(GradientMethod::parse("smooth"), None); +} + +/// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there, +/// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke, +/// dithered or not. +#[test] +fn perceptual_gradients_draw_as_pixels() { + for fill in [true, false] { + assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill)); + assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill)); + let mut radial = blue_yellow(GradientMethod::Perceptual); + radial.kind = GradientKind::Radial; + radial.dither = true; + assert!(GradientPaint::new(radial).draws_as_pixels(fill)); + } +} From 9accfee5d46d66cbb1c97368814eed860f2facaa Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:06 +0000 Subject: [PATCH 056/199] refactor(engine): move cmd/effectcmd.rs tests to cmd/effectcmd/tests.rs --- crates/engine/src/cmd/effectcmd.rs | 460 +---------------------- crates/engine/src/cmd/effectcmd/tests.rs | 457 ++++++++++++++++++++++ 2 files changed, 458 insertions(+), 459 deletions(-) create mode 100644 crates/engine/src/cmd/effectcmd/tests.rs diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 33b73b2e5..7f9664482 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -717,462 +717,4 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { mod lighting_presets_tests; #[cfg(test)] -mod tests { - use serde_json::json; - use vectorcraft_doc::NodeKind; - use vectorcraft_render::effects; - - use crate::{NodeId, Session}; - - fn session_with_rect() -> (Session, NodeId) { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); - (s, NodeId(r["id"].as_u64().unwrap())) - } - - fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { - s.doc().unwrap().doc.node(id).cloned().unwrap() - } - - #[test] - fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); - let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let source = node(&s, id).path_data().unwrap().clone(); - s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); - let before = effects_for_test(&s, id); - assert_eq!(node(&s, id).path_data(), Some(&source)); - let doc = &s.doc().unwrap().doc; - let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); - assert_eq!(restored.node(id).unwrap().path_data(), Some(&source)); - assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before); - let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); - let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); - assert!(bytes.starts_with(b"%PDF")); - assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err()); - assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err()); - s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap(); - assert_ne!(effects_for_test(&s, id), before); - assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err()); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - assert!(matches!(node(&s, id).kind, NodeKind::Group { .. })); - assert!(!effects::has_revolve(&node(&s, id))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id).path_data(), Some(&source)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(effects_for_test(&s, id), before); - } - - /// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited, - /// exported, expanded and undone; one 3D effect per object. - #[test] - fn solid_effects_apply_save_edit_export_expand_and_undo() { - for effect in effects::SOLIDS { - let (mut s, id) = session_with_rect(); - let source = node(&s, id).path_data().unwrap().clone(); - s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap(); - let art = effects::solid_art(&node(&s, id)).unwrap(); - assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable"); - let doc = &s.doc().unwrap().doc; - let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); - assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same"); - // One 3D effect per object, whichever kind. - for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] { - assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}"); - } - s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap(); - assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art"); - assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err()); - let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); - let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); - assert!(svg.matches("= 2, "{effect}: exported as vector faces"); - s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); - assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}"); - assert!(!effects::has_solid(&node(&s, id))); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle"); - } - // Type has no outline to extrude. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap(); - let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err(); - assert!(err.to_string().contains("requires a path"), "{err}"); - } - - /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is - /// stored as the angle that shows the same view, and a later angle replaces an earlier strength. - #[test] - fn an_earlier_perspective_strength_is_set_as_its_angle() { - let (mut s, id) = session_with_rect(); - s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null, "params": {"perspective": 60}})).unwrap(); - let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); - let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap(); - let angle = angle_of(60.0); - assert!((angle - 30.12).abs() < 0.01, "{angle}"); - assert!( - params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, - "{}", - params(&s) - ); - s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap(); - assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12); - // A file of then: the strength stored; setting an angle replaces it. - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = - json!({"depth": 50, "perspective": 60}); - s.execute("effect.setParams", &json!({"index": 0, "params": {"perspectiveAngle": 120}, "ids": [id.0], "item": null})).unwrap(); - assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120})); - } - - /// Scripts from before Rotation and Height give the light by `lightAzimuth` and - /// `lightElevation`: stored as the same light; a later Height keeps that light's Rotation. - #[test] - fn an_earlier_light_direction_is_set_as_rotation_and_height() { - let (mut s, id) = session_with_rect(); - s.execute( - "effect.apply", - &json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}}), - ) - .unwrap(); - let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); - // Straight from behind the object, level with it. - assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s)); - assert_eq!((params(&s)["lightHeight"].as_f64().unwrap().round(), ¶ms(&s)["lightBehind"]), (90.0, &json!(true))); - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = - json!({"lightAzimuth": 90, "lightElevation": 0}); - s.execute("effect.setParams", &json!({"index": 0, "params": {"lightHeight": 60}, "ids": [id.0], "item": null})).unwrap(); - assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false})); - } - - /// Scripts from before Scribble's separate variations give one `variation`, which is stored as the - /// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°. - #[test] - fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() { - let (mut s, id) = session_with_rect(); - let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}}); - s.execute("effect.apply", &p).unwrap(); - let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); - assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s)); - assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2))); - s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap(); - assert_eq!(params(&s)["angle"].as_f64(), Some(160.0)); - // A file of then stores its variation; setting one of the two keeps it for the other. - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3}); - s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap(); - assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1})); - } - - fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { - effects::revolve_art(&node(s, id)).unwrap() - } - - #[test] - fn smooth_revolve_commands_validate_save_expand_and_undo() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); - let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap(); - let initial = node(&s, id); - let source = initial.path_data().unwrap().clone(); - assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); - assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap()); - let mut gradients = 0; - effects_for_test(&s, id).walk(&mut |face| { - gradients += face.appearance.items.iter().filter(|item| { - matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_))) - }).count(); - }); - assert!(gradients > 0, "commands use smooth vector shading by default"); - s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap(); - let smooth = node(&s, id); - assert_eq!(smooth.path_data(), Some(&source)); - assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap()); - let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id))); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - assert!(!effects::has_revolve(&node(&s, id))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), smooth); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), initial); - } - - #[test] - fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); - let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - let source = node(&s, id).path_data().unwrap().clone(); - s.execute( - "effect.apply", - &json!({"effect":"threeD.revolve","ids":[id.0],"item":null, - "params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}), - ) - .unwrap(); - let uncapped = node(&s, id); - assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false)); - let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); - let open_count = count(&effects_for_test(&s, id)); - s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap(); - let capped = node(&s, id); - let art = effects_for_test(&s, id); - assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap"); - assert_eq!(capped.path_data(), Some(&source)); - assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err()); - assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document"); - let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true)); - assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone())); - let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); - assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF")); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - assert_eq!(count(&node(&s, id)), count(&art)); - assert!(!effects::has_revolve(&node(&s, id))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), capped); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), uncapped); - } - - #[test] - fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); - let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); - let live = node(&s, id); - let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); - let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - let visible = count(&node(&s, id)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), live); - s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap(); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - assert!(count(&node(&s, id)) > visible); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(node(&s, id), live); - // The shared command still expands ordinary multiple-fill artwork as before. - let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); - let ordinary = NodeId(r["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap(); - s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap(); - assert!(node(&s, ordinary).appearance.effects.is_empty()); - } - - #[test] - fn apply_uses_catalog_defaults_and_undoes() { - let (mut s, id) = session_with_rect(); - let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap(); - assert_eq!(r["index"], json!(0)); - let n = node(&s, id); - assert_eq!(n.appearance.effects.len(), 1); - assert_eq!(n.appearance.effects[0].params["x"], json!(3)); - assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0)); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(node(&s, id).appearance.effects.is_empty()); - assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err()); - } - - #[test] - fn warp_options_style_change_preserves_slot_and_visibility() { - let (mut s, id) = session_with_rect(); - for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] { - s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap(); - } - s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap(); - s.doc_mut().unwrap().mark_saved(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}})); - assert!(result.is_ok(), "an applied warp can change style in place: {result:?}"); - let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap(); - let n = node(&s, id); - let new = n.appearance.effects_at(Some(1)).unwrap(); - assert_eq!(new.len(), 3); - assert_eq!(new[0], old[0]); - assert_eq!(new[2], old[2]); - assert_eq!(new[1].id, "warp.flag"); - assert_eq!(new[1].params["bend"], 25.0); - assert!(!new[1].visible); - assert!(n.appearance.effects.is_empty()); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - let after = s.doc().unwrap().doc.clone(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - assert!(!s.doc().unwrap().is_dirty()); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, after); - } - - #[test] - fn warp_options_non_warp_target_rejection_is_atomic() { - let (mut s, first) = session_with_rect(); - s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap(); - let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); - s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap(); - s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap(); - s.doc_mut().unwrap().mark_saved(); - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let undo = s.doc().unwrap().history.undo.len(); - assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err()); - let state = s.doc().unwrap(); - assert_eq!(state.doc, before); - assert_eq!(state.selection, selection); - assert_eq!(state.history.undo.len(), undo); - assert!(!state.is_dirty()); - } - - #[test] - fn list_remove_and_set_params() { - let (mut s, id) = session_with_rect(); - s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap(); - s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); - let l = s.execute("effect.list", &json!({})).unwrap(); - assert!(l["catalog"].as_array().unwrap().len() >= 34); - assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2); - s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap(); - let e = &node(&s, id).appearance.effects[0]; - assert_eq!(e.params["angle"], json!(45)); - assert!(!e.visible); - s.execute("effect.remove", &json!({"index": 0})).unwrap(); - let n = node(&s, id); - assert_eq!(n.appearance.effects.len(), 1); - assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow"); - assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); - } - - #[test] - fn expand_appearance_bakes_geometry() { - let (mut s, id) = session_with_rect(); - s.execute("paint.setStroke", &json!({"none": true})).unwrap(); - s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap(); - s.execute("effect.expandAppearance", &json!({})).unwrap(); - // One fill: the path itself, reshaped. - let n = node(&s, id); - let b = n.geometric_bounds().unwrap(); - assert!((b.width() - 120.0).abs() < 0.1, "{b:?}"); - assert!(n.appearance.effects.is_empty()); - assert!(matches!(n.kind, NodeKind::Path { live: None, .. })); - // Nothing left to expand. - assert!(s.execute("effect.expandAppearance", &json!({})).is_err()); - } - - #[test] - fn expand_transform_copies_makes_compound() { - let (mut s, id) = session_with_rect(); - s.execute("paint.setStroke", &json!({"none": true})).unwrap(); - s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap(); - s.execute("effect.expandAppearance", &json!({})).unwrap(); - let n = node(&s, id); - assert!(matches!(n.kind, NodeKind::Compound { .. })); - let b = n.geometric_bounds().unwrap(); - assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}"); - } - - #[test] - fn pathfinder_effect_on_loose_objects_groups_them_first() { - let (mut s, a) = session_with_rect(); - let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); - s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); - let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); - assert_eq!(r["grouped"], true); - let g = NodeId(r["ids"][0].as_u64().unwrap()); - let gn = node(&s, g); - assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::>(), [a, b], "stacking order kept"); - assert!(vectorcraft_render::effects::has_pathfinder(&gn)); - assert_eq!(s.doc().unwrap().selection.objects, [g]); - // Live: one united shape, so expanding leaves a single path. - s.execute("effect.expandAppearance", &json!({})).unwrap(); - assert_eq!(node(&s, g).children().unwrap().len(), 1); - // Grouping and applying are one undo step. - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - let d = &s.doc().unwrap().doc; - assert!(d.node(g).is_none()); - assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b)); - // A selection holding a group applies to each object as before; so does a single object. - s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); - let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); - assert!(r.get("grouped").is_none()); - assert_eq!(node(&s, a).appearance.effects.len(), 1); - } - - #[test] - fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() { - let (mut s, a) = session_with_rect(); - let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap()); - let glyphs = effects::outline_text(&node(&s, t)).unwrap(); - let b = glyphs.geometric_bounds().unwrap(); - assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}"); - let area = |n: &vectorcraft_doc::Node| effects::member_shapes(None, n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); - let glyph_area = area(&glyphs); - s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); - let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); - // Live: the renderer leaves the glyph's place in the square empty. - let doc = s.doc().unwrap().doc.clone(); - let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); - let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count(); - assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}"); - // Expanded: the group holds the square minus the glyph. - s.execute("effect.expandAppearance", &json!({})).unwrap(); - let left = area(&node(&s, g)); - assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}"); - } - - #[test] - fn pathfinder_effect_on_a_group_renders_live_and_expands() { - let (mut s, a) = session_with_rect(); - let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); - s.execute("select.set", &json!({"ids": [a.0, b["id"]]})).unwrap(); - let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); - s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); - // Live: the renderer shows only the back square minus the front one. - let doc = s.doc().unwrap().doc.clone(); - let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); - assert!(img.pixel(125, 150)[3] > 0); - assert_eq!(img.pixel(200, 150)[3], 0); - s.execute("effect.expandAppearance", &json!({})).unwrap(); - let n = node(&s, g); - assert!(n.appearance.effects.is_empty()); - let ch = n.children().unwrap(); - assert_eq!(ch.len(), 1); - assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6); - } - #[test] - fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() { - use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; - let (mut s, id) = session_with_rect(); - let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); - s.edit("gradient", |d, _| { - let n = d.node_mut(id).unwrap(); - n.appearance.set_fill(ramp); - Ok(()) - }) - .unwrap(); - s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); - s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap(); - s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); - let mut boxes = vec![]; - node(&s, id).walk(&mut |n| { - if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) { - boxes.push(n.geometric_bounds().unwrap().width()); - } - }); - assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient"); - } -} +mod tests; diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs new file mode 100644 index 000000000..a2b8a1490 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -0,0 +1,457 @@ +use serde_json::json; +use vectorcraft_doc::NodeKind; +use vectorcraft_render::effects; + +use crate::{NodeId, Session}; + +fn session_with_rect() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +#[test] +fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let before = effects_for_test(&s, id); + assert_eq!(node(&s, id).path_data(), Some(&source)); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().path_data(), Some(&source)); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before); + let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err()); + assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err()); + s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap(); + assert_ne!(effects_for_test(&s, id), before); + assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err()); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Group { .. })); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).path_data(), Some(&source)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(effects_for_test(&s, id), before); +} + +/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited, +/// exported, expanded and undone; one 3D effect per object. +#[test] +fn solid_effects_apply_save_edit_export_expand_and_undo() { + for effect in effects::SOLIDS { + let (mut s, id) = session_with_rect(); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap(); + let art = effects::solid_art(&node(&s, id)).unwrap(); + assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable"); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same"); + // One 3D effect per object, whichever kind. + for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] { + assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}"); + } + s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap(); + assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art"); + assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err()); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + assert!(svg.matches("= 2, "{effect}: exported as vector faces"); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}"); + assert!(!effects::has_solid(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle"); + } + // Type has no outline to extrude. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err(); + assert!(err.to_string().contains("requires a path"), "{err}"); +} + +/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is +/// stored as the angle that shows the same view, and a later angle replaces an earlier strength. +#[test] +fn an_earlier_perspective_strength_is_set_as_its_angle() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null, "params": {"perspective": 60}})).unwrap(); + let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); + let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap(); + let angle = angle_of(60.0); + assert!((angle - 30.12).abs() < 0.01, "{angle}"); + assert!( + params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, + "{}", + params(&s) + ); + s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap(); + assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12); + // A file of then: the strength stored; setting an angle replaces it. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = + json!({"depth": 50, "perspective": 60}); + s.execute("effect.setParams", &json!({"index": 0, "params": {"perspectiveAngle": 120}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120})); +} + +/// Scripts from before Rotation and Height give the light by `lightAzimuth` and +/// `lightElevation`: stored as the same light; a later Height keeps that light's Rotation. +#[test] +fn an_earlier_light_direction_is_set_as_rotation_and_height() { + let (mut s, id) = session_with_rect(); + s.execute( + "effect.apply", + &json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}}), + ) + .unwrap(); + let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); + // Straight from behind the object, level with it. + assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s)); + assert_eq!((params(&s)["lightHeight"].as_f64().unwrap().round(), ¶ms(&s)["lightBehind"]), (90.0, &json!(true))); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = + json!({"lightAzimuth": 90, "lightElevation": 0}); + s.execute("effect.setParams", &json!({"index": 0, "params": {"lightHeight": 60}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false})); +} + +/// Scripts from before Scribble's separate variations give one `variation`, which is stored as the +/// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°. +#[test] +fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() { + let (mut s, id) = session_with_rect(); + let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}}); + s.execute("effect.apply", &p).unwrap(); + let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); + assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s)); + assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2))); + s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s)["angle"].as_f64(), Some(160.0)); + // A file of then stores its variation; setting one of the two keeps it for the other. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3}); + s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1})); +} + +fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + effects::revolve_art(&node(s, id)).unwrap() +} + +#[test] +fn smooth_revolve_commands_validate_save_expand_and_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap(); + let initial = node(&s, id); + let source = initial.path_data().unwrap().clone(); + assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); + assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap()); + let mut gradients = 0; + effects_for_test(&s, id).walk(&mut |face| { + gradients += face.appearance.items.iter().filter(|item| { + matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_))) + }).count(); + }); + assert!(gradients > 0, "commands use smooth vector shading by default"); + s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap(); + let smooth = node(&s, id); + assert_eq!(smooth.path_data(), Some(&source)); + assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap()); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id))); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), smooth); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), initial); +} + +#[test] +fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute( + "effect.apply", + &json!({"effect":"threeD.revolve","ids":[id.0],"item":null, + "params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}), + ) + .unwrap(); + let uncapped = node(&s, id); + assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false)); + let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); + let open_count = count(&effects_for_test(&s, id)); + s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap(); + let capped = node(&s, id); + let art = effects_for_test(&s, id); + assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap"); + assert_eq!(capped.path_data(), Some(&source)); + assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err()); + assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document"); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true)); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone())); + let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); + assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF")); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert_eq!(count(&node(&s, id)), count(&art)); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), capped); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), uncapped); +} + +#[test] +fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let live = node(&s, id); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); + let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let visible = count(&node(&s, id)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(count(&node(&s, id)) > visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + // The shared command still expands ordinary multiple-fill artwork as before. + let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); + let ordinary = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap(); + assert!(node(&s, ordinary).appearance.effects.is_empty()); +} + +#[test] +fn apply_uses_catalog_defaults_and_undoes() { + let (mut s, id) = session_with_rect(); + let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap(); + assert_eq!(r["index"], json!(0)); + let n = node(&s, id); + assert_eq!(n.appearance.effects.len(), 1); + assert_eq!(n.appearance.effects[0].params["x"], json!(3)); + assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); + assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err()); +} + +#[test] +fn warp_options_style_change_preserves_slot_and_visibility() { + let (mut s, id) = session_with_rect(); + for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] { + s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap(); + } + s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}})); + assert!(result.is_ok(), "an applied warp can change style in place: {result:?}"); + let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap(); + let n = node(&s, id); + let new = n.appearance.effects_at(Some(1)).unwrap(); + assert_eq!(new.len(), 3); + assert_eq!(new[0], old[0]); + assert_eq!(new[2], old[2]); + assert_eq!(new[1].id, "warp.flag"); + assert_eq!(new[1].params["bend"], 25.0); + assert!(!new[1].visible); + assert!(n.appearance.effects.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let after = s.doc().unwrap().doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); +} + +#[test] +fn warp_options_non_warp_target_rejection_is_atomic() { + let (mut s, first) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap(); + let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap(); + s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err()); + let state = s.doc().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.selection, selection); + assert_eq!(state.history.undo.len(), undo); + assert!(!state.is_dirty()); +} + +#[test] +fn list_remove_and_set_params() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); + let l = s.execute("effect.list", &json!({})).unwrap(); + assert!(l["catalog"].as_array().unwrap().len() >= 34); + assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2); + s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap(); + let e = &node(&s, id).appearance.effects[0]; + assert_eq!(e.params["angle"], json!(45)); + assert!(!e.visible); + s.execute("effect.remove", &json!({"index": 0})).unwrap(); + let n = node(&s, id); + assert_eq!(n.appearance.effects.len(), 1); + assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow"); + assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); +} + +#[test] +fn expand_appearance_bakes_geometry() { + let (mut s, id) = session_with_rect(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + // One fill: the path itself, reshaped. + let n = node(&s, id); + let b = n.geometric_bounds().unwrap(); + assert!((b.width() - 120.0).abs() < 0.1, "{b:?}"); + assert!(n.appearance.effects.is_empty()); + assert!(matches!(n.kind, NodeKind::Path { live: None, .. })); + // Nothing left to expand. + assert!(s.execute("effect.expandAppearance", &json!({})).is_err()); +} + +#[test] +fn expand_transform_copies_makes_compound() { + let (mut s, id) = session_with_rect(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Compound { .. })); + let b = n.geometric_bounds().unwrap(); + assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn pathfinder_effect_on_loose_objects_groups_them_first() { + let (mut s, a) = session_with_rect(); + let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); + assert_eq!(r["grouped"], true); + let g = NodeId(r["ids"][0].as_u64().unwrap()); + let gn = node(&s, g); + assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::>(), [a, b], "stacking order kept"); + assert!(vectorcraft_render::effects::has_pathfinder(&gn)); + assert_eq!(s.doc().unwrap().selection.objects, [g]); + // Live: one united shape, so expanding leaves a single path. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().len(), 1); + // Grouping and applying are one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(g).is_none()); + assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b)); + // A selection holding a group applies to each object as before; so does a single object. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); + assert!(r.get("grouped").is_none()); + assert_eq!(node(&s, a).appearance.effects.len(), 1); +} + +#[test] +fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() { + let (mut s, a) = session_with_rect(); + let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap()); + let glyphs = effects::outline_text(&node(&s, t)).unwrap(); + let b = glyphs.geometric_bounds().unwrap(); + assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}"); + let area = |n: &vectorcraft_doc::Node| effects::member_shapes(None, n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); + let glyph_area = area(&glyphs); + s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); + // Live: the renderer leaves the glyph's place in the square empty. + let doc = s.doc().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); + let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count(); + assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}"); + // Expanded: the group holds the square minus the glyph. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let left = area(&node(&s, g)); + assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}"); +} + +#[test] +fn pathfinder_effect_on_a_group_renders_live_and_expands() { + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b["id"]]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap(); + // Live: the renderer shows only the back square minus the front one. + let doc = s.doc().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()); + assert!(img.pixel(125, 150)[3] > 0); + assert_eq!(img.pixel(200, 150)[3], 0); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let n = node(&s, g); + assert!(n.appearance.effects.is_empty()); + let ch = n.children().unwrap(); + assert_eq!(ch.len(), 1); + assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6); +} +#[test] +fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + let (mut s, id) = session_with_rect(); + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("gradient", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.set_fill(ramp); + Ok(()) + }) + .unwrap(); + s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let mut boxes = vec![]; + node(&s, id).walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) { + boxes.push(n.geometric_bounds().unwrap().width()); + } + }); + assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient"); +} From dc5888c243c9c9796a5ca6a359436a37d9646cff Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:06 +0000 Subject: [PATCH 057/199] refactor(tools): move corners.rs tests to corners/tests.rs --- crates/tools/src/corners.rs | 449 +----------------------------- crates/tools/src/corners/tests.rs | 446 +++++++++++++++++++++++++++++ 2 files changed, 447 insertions(+), 448 deletions(-) create mode 100644 crates/tools/src/corners/tests.rs diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index c9281fade..ef057e7e3 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -389,451 +389,4 @@ impl CornerDrag { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use crate::{Mods, PointerKind}; - use vectorcraft_doc::{Appearance, Node}; - - /// A live 100 × 100 rectangle at (100, 100) with corner radius `r`. - fn live_rect(r: f64, xf: Affine) -> (Document, NodeId) { - live_shape(100.0, 100.0, r, xf) - } - - /// A live `w` × `h` rectangle with corner radius `r`. - fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) { - let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; - doc_with(live.to_path(), Some(live)) - } - - /// A document holding one path with its live shape. - fn doc_with(path: PathData, live: Option) -> (Document, NodeId) { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let mut n = Node::path(id, path, Appearance::default_art()); - if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = live.map(Box::new); - } - d.insert(Some(l), 0, n).unwrap(); - (d, id) - } - - /// A five-pointed star at (200, 200) of radii 60 and 30 (#511): a plain path. - fn star() -> (Document, NodeId) { - doc_with(vectorcraft_geom::shapes::star(Point::new(200.0, 200.0), 60.0, 30.0, 5, 0.0), None) - } - - fn selected(id: NodeId) -> Selection { - let mut s = Selection::default(); - s.add(id); - s - } - - fn pts(w: &CornerWidgets) -> Vec { - w.visible().collect() - } - - fn radius(a: &Action) -> f64 { - let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") }; - assert_eq!(c, "object.setLiveShape"); - v["radius"].as_f64().unwrap() - } - - #[test] - fn widgets_sit_inside_each_corner() { - let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); - let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap(); - assert_eq!(pts(&w), [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]); - // A larger radius moves them to the arc centres; zooming in keeps a 10 px minimum. - let (d, id) = live_rect(20.0, Affine::translate((100.0, 100.0))); - assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap())[0], Point::new(120.0, 120.0)); - assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 4.0, false).unwrap())[2], Point::new(180.0, 180.0)); - // Hidden when the shape is tiny on screen and without a single selection; a plain path - // shows them with Direct Selection only. - assert!(CornerWidgets::of(&d, &selected(id), 0.2, true).is_none()); - let (d, id) = doc_with_rect(); - assert!(CornerWidgets::of(&d, &selected(id), 1.0, false).is_none()); - assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, true).unwrap())[0], Point::new(110.0, 110.0)); - assert!(CornerWidgets::of(&d, &Selection::default(), 1.0, true).is_none()); - } - - #[test] - fn dragging_a_widget_inward_rounds_all_corners_in_one_step() { - let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); - let s = selected(id); - let p = paint(); - let cx = cx(&d, &s, &p); - for mut t in [crate::create("selection"), crate::create("directSelection")] { - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty()); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0)); - assert_eq!(a[0], Action::Begin("Corner Radius".into())); - assert!((radius(&a[1]) - 15.0).abs() < 1e-9); - assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0)); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "Radius: 15.00 pt"))); - let quiet = ToolContext { measurement_labels: false, ..crate::testutil::cx(&d, &s, &p) }; - assert!(!t.overlays(&quiet).iter().any(|o| matches!(o, Overlay::Measure { .. }))); - // Past the middle the corner is fully round; back outside it is square. - assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 100.0))[0]), 50.0); - assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 260.0))[0]), 0.0); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 260.0)), vec![Action::Commit]); - } - } - - /// #938: with two rectangles selected, both show their widgets, and a drag on either rounds - /// the corners of both in one step; with Direct Selection, each its shown corners. - #[test] - fn widgets_of_several_selected_paths_drag_together() { - let (mut d, a) = live_rect(0.0, Affine::translate((100.0, 100.0))); - let live = LiveShape::Rectangle { - w: 100.0, - h: 100.0, - radii: [0.0; 4], - kinds: Default::default(), - radii_relative: false, - xf: Affine::translate((300.0, 100.0)), - }; - let b = d.alloc_id(); - let mut n = Node::path(b, live.to_path(), Appearance::default_art()); - if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(Box::new(live)); - } - let l = d.layers[0].id; - d.insert(Some(l), 1, n).unwrap(); - let mut s = selected(a); - s.add(b); - assert_eq!(pts(&CornerWidgets::of(&d, &s, 1.0, false).unwrap()).len(), 8); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = crate::create("selection"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 390.0, 191.0)); - let preview = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 375.0, 176.0)); - assert!((radius(&preview[1]) - 15.0).abs() < 1e-9); - let Action::Preview(_, v) = &preview[1] else { panic!() }; - assert_eq!(v["items"], json!([{"id": a.0}, {"id": b.0}])); - // Direct Selection with one anchor of the first: its corner, and the whole second. - s.anchors.insert(a, [(0, 2)].into()); - let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap(); - assert_eq!(w.corners(), [2, 0, 1, 2, 3]); - assert_eq!(w.command("radius", json!(5.0)), json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 5.0})); - } - - /// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also - /// with a radius past half the shorter side; a drag past fully round stops every corner there - /// and outlines them in red. - #[test] - fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() { - let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0))); - let s = selected(id); - let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap(); - let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)]; - assert_eq!(pts(&w), centres); - let p = paint(); - let cx = cx(&d, &s, &p); - let red = |t: &dyn crate::Tool| { - t.overlays(&cx).into_iter().find_map(|o| match o { - Overlay::Path { path, color, .. } if color == crate::builder::HIGHLIGHT_RED => Some(path), - _ => None, - }) - }; - let mut t = crate::create("selection"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 140.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 200.0)); - assert_eq!(radius(&a[1]), 40.0); - // The four arcs, each a quarter circle of radius 40 starting on a side. - let arcs = red(t.as_ref()).expect("the limit shows"); - let starts: Vec<_> = - arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect(); - assert_eq!(starts, [Point::new(260.0, 100.0), Point::new(300.0, 140.0), Point::new(140.0, 180.0), Point::new(100.0, 140.0)]); - // Back below the limit, the outline goes. - assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[0]), 30.0); - assert!(red(t.as_ref()).is_none()); - } - - /// #442: a rectangle saved stretched (an uneven scale in its transform, before #291) shows its - /// widgets and drags its radius in document units, as `object.setLiveShape` sets it. - #[test] - fn a_stretched_rectangle_drags_its_radius_in_document_units() { - // 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20. - let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0)); - let s = selected(id); - let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap(); - assert_eq!(pts(&w)[0], Point::new(120.0, 120.0)); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = crate::create("directSelection"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0, 120.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 110.0)); - assert!((radius(&a[1]) - 10.0).abs() < 1e-9, "{a:?}"); - } - - #[test] - fn drag_follows_a_rotated_shape_and_a_click_does_nothing() { - // Rotated 90°: the shape's top-left corner is at document (200, 100). - let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2); - let (d, id) = live_rect(10.0, xf); - let s = selected(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap(); - assert!(pts(&w)[0].distance(Point::new(190.0, 110.0)) < 1e-9); - let mut t = crate::create("directSelection"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 120.0)); - assert!((radius(&a[1]) - 20.0).abs() < 1e-9); - // A click without a drag leaves the shape (and the undo history) alone. - let mut t = crate::create("selection"); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0)).is_empty()); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty()); - } - - /// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the - /// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift - /// toggles the corner. The Selection tool's click still does nothing. - #[test] - fn a_click_on_a_widget_with_direct_selection_selects_its_corner() { - let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); - let s = selected(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let w = pts(&CornerWidgets::of(&d, &s, 1.0, true).unwrap())[2]; - // The bottom-right corner of a rounded rectangle is drawn with two anchors. - let anchors: Vec<(usize, usize)> = LiveCorners::of(d.node(id).unwrap()).unwrap().anchors_of(&[2].into()).into_iter().collect(); - assert_eq!(anchors.len(), 2); - let mut t = crate::create("directSelection"); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty()); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]); - let shift = Mods { shift: true, ..Mods::default() }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]); - // With that corner selected, its widget alone shows, and a drag rounds it alone - // (`direct_selected_corners_show_their_widgets_alone_and_round_alone`). - let mut part = selected(id); - part.anchors.insert(id, anchors.iter().copied().collect()); - assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]); - let mut t = crate::create("selection"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty()); - } - - #[test] - fn direct_selected_corners_show_their_widgets_alone_and_round_alone() { - let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); - // Direct Selection picked the bottom-right anchor. - let mut s = selected(id); - s.anchors.insert(id, [(0, 2)].into()); - let p = paint(); - let cx = cx(&d, &s, &p); - let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap(); - assert_eq!(w.corners(), [2]); - assert_eq!(pts(&w), [Point::new(190.0, 190.0)]); - assert!(!over_widget(&cx, Point::new(110.0, 110.0), true), "the other corners hide theirs"); - let mut t = crate::create("directSelection"); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty()); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0)); - assert_eq!(a[0], Action::Begin("Corner Radius".into())); - assert_eq!(a[1], Action::Preview("object.setLiveShape".into(), json!({"id": id.0, "radius": 15.0, "corners": [2]}))); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 175.0, 176.0)), vec![Action::Commit]); - } - - /// #2332: one corner rounded alone goes past half the side, until its cut reaches the - /// neighbouring anchors (the whole side of a square), or a rounded neighbour's cut. - #[test] - fn a_corner_rounded_alone_rounds_up_to_its_neighbours() { - for (top_right, max) in [(0.0, 100.0), (30.0, 70.0)] { - let live = LiveShape::Rectangle { - w: 100.0, - h: 100.0, - radii: [0.0, top_right, 0.0, 0.0], - kinds: Default::default(), - radii_relative: false, - xf: Affine::translate((100.0, 100.0)), - }; - let (d, id) = doc_with(live.to_path(), Some(live)); - // Direct Selection picked the bottom-right anchor. - let mut s = selected(id); - s.anchors.insert(id, [(0, if top_right > 0.0 { 3 } else { 2 })].into()); - let p = paint(); - let cx = cx(&d, &s, &p); - assert_eq!(CornerWidgets::of(&d, &s, 1.0, true).unwrap().corners(), [2]); - let mut t = crate::create("directSelection"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 190.0)); - assert!((radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[1]) - 60.0).abs() < 1e-9); - assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "60 is below the limit"); - assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.0, 0.0))[0]), max); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the limit shows"); - } - } - - #[test] - fn alt_click_cycles_the_kind_and_double_click_opens_the_dialog() { - let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); - let p = paint(); - let alt = Mods { alt: true, ..Mods::default() }; - let whole = selected(id); - let mut part = selected(id); - part.anchors.insert(id, [(0, 0), (0, 7)].into()); - for (s, extra) in [(&whole, json!({})), (&part, json!({"corners": [0]}))] { - let cx = cx(&d, s, &p); - let mut want = json!({"id": id.0, "kind": "invertedRound"}); - want.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); - for tool in ["selection", "directSelection"] { - let mut t = crate::create(tool); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 110.0).with_mods(alt)).is_empty()); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 110.0).with_mods(alt)); - assert_eq!(a, vec![Action::Exec("object.setLiveShape".into(), want.clone())], "{tool}"); - } - } - // A double-click on a widget opens Corners for the shown corners; elsewhere it doesn't. - let cx1 = cx(&d, &part, &p); - let a = crate::create("directSelection").pointer(&cx1, &PointerEvent::new(PointerKind::DoubleClick, 110.0, 110.0)); - assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0]}))]); - let cx2 = cx(&d, &whole, &p); - let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0)); - assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]); - // Off the widgets, the Selection tool's double-click isolates the shape (Double Click To - // Isolate) instead. - let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)); - assert_eq!(a, vec![Action::Exec("object.isolate".into(), json!({"id": id.0}))]); - } - - /// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below - /// 90°; sheared, only its acute corners keep their widgets. - #[test] - fn corners_wider_than_the_preference_hide_their_widgets() { - let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); - let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap(); - assert_eq!(w.clone().within_angle(177.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3])); - assert_eq!(w.clone().within_angle(90.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3])); - assert!(w.within_angle(89.0).is_none()); - // Sheared by 30°: 60° at the top-left and bottom-right corners, 120° at the others. - let shear = Affine::translate((100.0, 100.0)) * Affine::new([1.0, 0.0, 30f64.to_radians().tan(), 1.0, 0.0, 0.0]); - let (d, id) = live_rect(0.0, shear); - let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap(); - assert_eq!(w.clone().within_angle(100.0).map(|w| w.corners()), Some(vec![0, 2])); - let s = selected(id); - let p = paint(); - let c = ToolContext { corner_widget_max_angle: 100.0, ..cx(&d, &s, &p) }; - assert!(over_widget(&c, pts(&w)[0], false)); - assert!(!over_widget(&c, pts(&w)[1], false), "the 120° corner's widget is hidden"); - } - - #[test] - fn widgets_off_or_elsewhere_keep_the_tools_behaviour() { - let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); - let s = selected(id); - let p = paint(); - let mut c = cx(&d, &s, &p); - assert!(over_widget(&c, Point::new(110.0, 110.0), false)); - assert!(!over_widget(&c, Point::new(150.0, 150.0), false)); - c.corner_widgets = false; - assert!(!over_widget(&c, Point::new(110.0, 110.0), true)); - // With the widgets hidden, a drag from the same spot moves the object. - let mut t = crate::create("selection"); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 110.0, 110.0)); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 110.0)); - assert_eq!(a[0], Action::Begin("Move".into())); - } - - /// #511: Direct Selection shows a widget in each of a star's ten corners (the Selection tool - /// none: a star isn't a live shape); dragging a tip's inward rounds them all as one step. - #[test] - fn a_star_shows_widgets_in_every_corner_with_direct_selection() { - let (d, id) = star(); - let s = selected(id); - let p = paint(); - let cx = cx(&d, &s, &p); - assert!(CornerWidgets::for_tool(&cx, false).is_none()); - let w = CornerWidgets::for_tool(&cx, true).expect("widgets on a plain path"); - assert_eq!(w.corners(), (0..10).collect::>()); - // The top tip's widget sits 10 √2 px down its bisector. - let tip = pts(&w)[0]; - assert!(tip.distance(Point::new(200.0, 140.0 + 10.0 * 2f64.sqrt())) < 1e-9, "{tip:?}"); - let mut t = crate::create("directSelection"); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)).is_empty()); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0)); - assert_eq!(a[0], Action::Begin("Corner Radius".into())); - // The widget follows the arc's centre: r / sin(angle / 2) along the bisector. - let c = vectorcraft_geom::corners::path_corners(&star_path(&d, id))[0]; - let want = 10.0 * (c.angle_deg() / 2.0).to_radians().sin(); - assert!((radius(&a[1]) - want).abs() < 1e-9, "{a:?} want {want}"); - assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0 && v.get("corners").is_none()), "every corner: {a:?}"); - // The Selection tool drags the star instead. - let mut t = crate::create("selection"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0))[0], Action::Begin("Move".into())); - } - - fn star_path(d: &Document, id: NodeId) -> PathData { - let Some(NodeKind::Path { path, .. }) = d.node(id).map(|n| &n.kind) else { panic!("not a path") }; - path.clone() - } - - /// #511: a Direct-Selected anchor of the star shows its corner's widget alone, and the - /// preference hides the star's wide inner corners (about 125°) below their angle. - #[test] - fn star_corners_follow_the_anchors_and_the_angle_preference() { - let (d, id) = star(); - let mut s = selected(id); - s.anchors.insert(id, [(0, 3)].into()); - let p = paint(); - let c = cx(&d, &s, &p); - let w = CornerWidgets::for_tool(&c, true).unwrap(); - assert_eq!(w.corners(), [3]); - let mut t = crate::create("directSelection"); - let at = pts(&w)[0]; - t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 20.0)); - assert!(matches!(&a[1], Action::Preview(_, v) if v["corners"] == json!([3])), "{a:?}"); - // Corners wider than 90° hide: the five tips keep theirs. - let whole = selected(id); - let c = ToolContext { corner_widget_max_angle: 90.0, ..cx(&d, &whole, &p) }; - let w = CornerWidgets::for_tool(&c, true).unwrap(); - assert_eq!(w.corners(), [0, 2, 4, 6, 8]); - assert_eq!(w.command("radius", json!(4.0))["corners"], json!([0, 2, 4, 6, 8])); - } - - /// A live polygon shows its widgets to the Selection tool too; past every corner's limit the - /// cuts outline in red. - #[test] - fn a_live_polygon_rounds_with_either_tool() { - let live = - LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![], radii_relative: false }; - let (d, id) = doc_with(live.to_path(), Some(live)); - let s = selected(id); - let p = paint(); - let cx = cx(&d, &s, &p); - for any_path in [false, true] { - assert_eq!(CornerWidgets::for_tool(&cx, any_path).unwrap().corners().len(), 6); - } - let tip = pts(&CornerWidgets::for_tool(&cx, false).unwrap())[0]; - let mut t = crate::create("selection"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 300.0)); - // A hexagon's 120° corners stop where their cuts meet: 40 · tan 60°. - assert!((radius(&a[1]) - 40.0 * 3f64.sqrt()).abs() < 1e-9, "{a:?}"); - let red = t - .overlays(&cx) - .into_iter() - .any(|o| matches!(o, Overlay::Path { color, path, .. } if color == crate::builder::HIGHLIGHT_RED && path.elements().len() == 12)); - assert!(red, "six arcs at the limit"); - } - - /// A pen path's corners show widgets, curved ones too (#1144); its ends don't. - #[test] - fn a_pen_path_shows_widgets_on_its_corners_only() { - let mut sp = vectorcraft_geom::SubPath::polyline( - &[Point::new(100.0, 100.0), Point::new(300.0, 100.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)], - false, - ); - sp.anchors[2].h_out = Point::new(250.0, 350.0); - let (d, id) = doc_with(PathData::single(sp), None); - let s = selected(id); - let p = paint(); - let w = CornerWidgets::for_tool(&cx(&d, &s, &p), true).unwrap(); - assert_eq!(w.corners(), [1, 2]); - assert_eq!(pts(&w)[0], Point::new(290.0, 110.0)); - } -} +mod tests; diff --git a/crates/tools/src/corners/tests.rs b/crates/tools/src/corners/tests.rs new file mode 100644 index 000000000..6c07848fc --- /dev/null +++ b/crates/tools/src/corners/tests.rs @@ -0,0 +1,446 @@ +use super::*; +use crate::testutil::*; +use crate::{Mods, PointerKind}; +use vectorcraft_doc::{Appearance, Node}; + +/// A live 100 × 100 rectangle at (100, 100) with corner radius `r`. +fn live_rect(r: f64, xf: Affine) -> (Document, NodeId) { + live_shape(100.0, 100.0, r, xf) +} + +/// A live `w` × `h` rectangle with corner radius `r`. +fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) { + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; + doc_with(live.to_path(), Some(live)) +} + +/// A document holding one path with its live shape. +fn doc_with(path: PathData, live: Option) -> (Document, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut n = Node::path(id, path, Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = live.map(Box::new); + } + d.insert(Some(l), 0, n).unwrap(); + (d, id) +} + +/// A five-pointed star at (200, 200) of radii 60 and 30 (#511): a plain path. +fn star() -> (Document, NodeId) { + doc_with(vectorcraft_geom::shapes::star(Point::new(200.0, 200.0), 60.0, 30.0, 5, 0.0), None) +} + +fn selected(id: NodeId) -> Selection { + let mut s = Selection::default(); + s.add(id); + s +} + +fn pts(w: &CornerWidgets) -> Vec { + w.visible().collect() +} + +fn radius(a: &Action) -> f64 { + let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") }; + assert_eq!(c, "object.setLiveShape"); + v["radius"].as_f64().unwrap() +} + +#[test] +fn widgets_sit_inside_each_corner() { + let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); + let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap(); + assert_eq!(pts(&w), [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]); + // A larger radius moves them to the arc centres; zooming in keeps a 10 px minimum. + let (d, id) = live_rect(20.0, Affine::translate((100.0, 100.0))); + assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap())[0], Point::new(120.0, 120.0)); + assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 4.0, false).unwrap())[2], Point::new(180.0, 180.0)); + // Hidden when the shape is tiny on screen and without a single selection; a plain path + // shows them with Direct Selection only. + assert!(CornerWidgets::of(&d, &selected(id), 0.2, true).is_none()); + let (d, id) = doc_with_rect(); + assert!(CornerWidgets::of(&d, &selected(id), 1.0, false).is_none()); + assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, true).unwrap())[0], Point::new(110.0, 110.0)); + assert!(CornerWidgets::of(&d, &Selection::default(), 1.0, true).is_none()); +} + +#[test] +fn dragging_a_widget_inward_rounds_all_corners_in_one_step() { + let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + for mut t in [crate::create("selection"), crate::create("directSelection")] { + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0)); + assert_eq!(a[0], Action::Begin("Corner Radius".into())); + assert!((radius(&a[1]) - 15.0).abs() < 1e-9); + assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0)); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "Radius: 15.00 pt"))); + let quiet = ToolContext { measurement_labels: false, ..crate::testutil::cx(&d, &s, &p) }; + assert!(!t.overlays(&quiet).iter().any(|o| matches!(o, Overlay::Measure { .. }))); + // Past the middle the corner is fully round; back outside it is square. + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 100.0))[0]), 50.0); + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 260.0))[0]), 0.0); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 260.0)), vec![Action::Commit]); + } +} + +/// #938: with two rectangles selected, both show their widgets, and a drag on either rounds +/// the corners of both in one step; with Direct Selection, each its shown corners. +#[test] +fn widgets_of_several_selected_paths_drag_together() { + let (mut d, a) = live_rect(0.0, Affine::translate((100.0, 100.0))); + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((300.0, 100.0)), + }; + let b = d.alloc_id(); + let mut n = Node::path(b, live.to_path(), Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(live)); + } + let l = d.layers[0].id; + d.insert(Some(l), 1, n).unwrap(); + let mut s = selected(a); + s.add(b); + assert_eq!(pts(&CornerWidgets::of(&d, &s, 1.0, false).unwrap()).len(), 8); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = crate::create("selection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 390.0, 191.0)); + let preview = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 375.0, 176.0)); + assert!((radius(&preview[1]) - 15.0).abs() < 1e-9); + let Action::Preview(_, v) = &preview[1] else { panic!() }; + assert_eq!(v["items"], json!([{"id": a.0}, {"id": b.0}])); + // Direct Selection with one anchor of the first: its corner, and the whole second. + s.anchors.insert(a, [(0, 2)].into()); + let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap(); + assert_eq!(w.corners(), [2, 0, 1, 2, 3]); + assert_eq!(w.command("radius", json!(5.0)), json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 5.0})); +} + +/// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also +/// with a radius past half the shorter side; a drag past fully round stops every corner there +/// and outlines them in red. +#[test] +fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() { + let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0))); + let s = selected(id); + let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap(); + let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)]; + assert_eq!(pts(&w), centres); + let p = paint(); + let cx = cx(&d, &s, &p); + let red = |t: &dyn crate::Tool| { + t.overlays(&cx).into_iter().find_map(|o| match o { + Overlay::Path { path, color, .. } if color == crate::builder::HIGHLIGHT_RED => Some(path), + _ => None, + }) + }; + let mut t = crate::create("selection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 140.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 200.0)); + assert_eq!(radius(&a[1]), 40.0); + // The four arcs, each a quarter circle of radius 40 starting on a side. + let arcs = red(t.as_ref()).expect("the limit shows"); + let starts: Vec<_> = + arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect(); + assert_eq!(starts, [Point::new(260.0, 100.0), Point::new(300.0, 140.0), Point::new(140.0, 180.0), Point::new(100.0, 140.0)]); + // Back below the limit, the outline goes. + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[0]), 30.0); + assert!(red(t.as_ref()).is_none()); +} + +/// #442: a rectangle saved stretched (an uneven scale in its transform, before #291) shows its +/// widgets and drags its radius in document units, as `object.setLiveShape` sets it. +#[test] +fn a_stretched_rectangle_drags_its_radius_in_document_units() { + // 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20. + let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0)); + let s = selected(id); + let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap(); + assert_eq!(pts(&w)[0], Point::new(120.0, 120.0)); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0, 120.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 110.0)); + assert!((radius(&a[1]) - 10.0).abs() < 1e-9, "{a:?}"); +} + +#[test] +fn drag_follows_a_rotated_shape_and_a_click_does_nothing() { + // Rotated 90°: the shape's top-left corner is at document (200, 100). + let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2); + let (d, id) = live_rect(10.0, xf); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap(); + assert!(pts(&w)[0].distance(Point::new(190.0, 110.0)) < 1e-9); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 120.0)); + assert!((radius(&a[1]) - 20.0).abs() < 1e-9); + // A click without a drag leaves the shape (and the undo history) alone. + let mut t = crate::create("selection"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty()); +} + +/// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the +/// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift +/// toggles the corner. The Selection tool's click still does nothing. +#[test] +fn a_click_on_a_widget_with_direct_selection_selects_its_corner() { + let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let w = pts(&CornerWidgets::of(&d, &s, 1.0, true).unwrap())[2]; + // The bottom-right corner of a rounded rectangle is drawn with two anchors. + let anchors: Vec<(usize, usize)> = LiveCorners::of(d.node(id).unwrap()).unwrap().anchors_of(&[2].into()).into_iter().collect(); + assert_eq!(anchors.len(), 2); + let mut t = crate::create("directSelection"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]); + let shift = Mods { shift: true, ..Mods::default() }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]); + // With that corner selected, its widget alone shows, and a drag rounds it alone + // (`direct_selected_corners_show_their_widgets_alone_and_round_alone`). + let mut part = selected(id); + part.anchors.insert(id, anchors.iter().copied().collect()); + assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]); + let mut t = crate::create("selection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty()); +} + +#[test] +fn direct_selected_corners_show_their_widgets_alone_and_round_alone() { + let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); + // Direct Selection picked the bottom-right anchor. + let mut s = selected(id); + s.anchors.insert(id, [(0, 2)].into()); + let p = paint(); + let cx = cx(&d, &s, &p); + let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap(); + assert_eq!(w.corners(), [2]); + assert_eq!(pts(&w), [Point::new(190.0, 190.0)]); + assert!(!over_widget(&cx, Point::new(110.0, 110.0), true), "the other corners hide theirs"); + let mut t = crate::create("directSelection"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0)); + assert_eq!(a[0], Action::Begin("Corner Radius".into())); + assert_eq!(a[1], Action::Preview("object.setLiveShape".into(), json!({"id": id.0, "radius": 15.0, "corners": [2]}))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 175.0, 176.0)), vec![Action::Commit]); +} + +/// #2332: one corner rounded alone goes past half the side, until its cut reaches the +/// neighbouring anchors (the whole side of a square), or a rounded neighbour's cut. +#[test] +fn a_corner_rounded_alone_rounds_up_to_its_neighbours() { + for (top_right, max) in [(0.0, 100.0), (30.0, 70.0)] { + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0, top_right, 0.0, 0.0], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((100.0, 100.0)), + }; + let (d, id) = doc_with(live.to_path(), Some(live)); + // Direct Selection picked the bottom-right anchor. + let mut s = selected(id); + s.anchors.insert(id, [(0, if top_right > 0.0 { 3 } else { 2 })].into()); + let p = paint(); + let cx = cx(&d, &s, &p); + assert_eq!(CornerWidgets::of(&d, &s, 1.0, true).unwrap().corners(), [2]); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 190.0)); + assert!((radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[1]) - 60.0).abs() < 1e-9); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "60 is below the limit"); + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.0, 0.0))[0]), max); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the limit shows"); + } +} + +#[test] +fn alt_click_cycles_the_kind_and_double_click_opens_the_dialog() { + let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); + let p = paint(); + let alt = Mods { alt: true, ..Mods::default() }; + let whole = selected(id); + let mut part = selected(id); + part.anchors.insert(id, [(0, 0), (0, 7)].into()); + for (s, extra) in [(&whole, json!({})), (&part, json!({"corners": [0]}))] { + let cx = cx(&d, s, &p); + let mut want = json!({"id": id.0, "kind": "invertedRound"}); + want.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + for tool in ["selection", "directSelection"] { + let mut t = crate::create(tool); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 110.0).with_mods(alt)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 110.0).with_mods(alt)); + assert_eq!(a, vec![Action::Exec("object.setLiveShape".into(), want.clone())], "{tool}"); + } + } + // A double-click on a widget opens Corners for the shown corners; elsewhere it doesn't. + let cx1 = cx(&d, &part, &p); + let a = crate::create("directSelection").pointer(&cx1, &PointerEvent::new(PointerKind::DoubleClick, 110.0, 110.0)); + assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0]}))]); + let cx2 = cx(&d, &whole, &p); + let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0)); + assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]); + // Off the widgets, the Selection tool's double-click isolates the shape (Double Click To + // Isolate) instead. + let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)); + assert_eq!(a, vec![Action::Exec("object.isolate".into(), json!({"id": id.0}))]); +} + +/// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below +/// 90°; sheared, only its acute corners keep their widgets. +#[test] +fn corners_wider_than_the_preference_hide_their_widgets() { + let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); + let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap(); + assert_eq!(w.clone().within_angle(177.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3])); + assert_eq!(w.clone().within_angle(90.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3])); + assert!(w.within_angle(89.0).is_none()); + // Sheared by 30°: 60° at the top-left and bottom-right corners, 120° at the others. + let shear = Affine::translate((100.0, 100.0)) * Affine::new([1.0, 0.0, 30f64.to_radians().tan(), 1.0, 0.0, 0.0]); + let (d, id) = live_rect(0.0, shear); + let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap(); + assert_eq!(w.clone().within_angle(100.0).map(|w| w.corners()), Some(vec![0, 2])); + let s = selected(id); + let p = paint(); + let c = ToolContext { corner_widget_max_angle: 100.0, ..cx(&d, &s, &p) }; + assert!(over_widget(&c, pts(&w)[0], false)); + assert!(!over_widget(&c, pts(&w)[1], false), "the 120° corner's widget is hidden"); +} + +#[test] +fn widgets_off_or_elsewhere_keep_the_tools_behaviour() { + let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); + let s = selected(id); + let p = paint(); + let mut c = cx(&d, &s, &p); + assert!(over_widget(&c, Point::new(110.0, 110.0), false)); + assert!(!over_widget(&c, Point::new(150.0, 150.0), false)); + c.corner_widgets = false; + assert!(!over_widget(&c, Point::new(110.0, 110.0), true)); + // With the widgets hidden, a drag from the same spot moves the object. + let mut t = crate::create("selection"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 110.0, 110.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 110.0)); + assert_eq!(a[0], Action::Begin("Move".into())); +} + +/// #511: Direct Selection shows a widget in each of a star's ten corners (the Selection tool +/// none: a star isn't a live shape); dragging a tip's inward rounds them all as one step. +#[test] +fn a_star_shows_widgets_in_every_corner_with_direct_selection() { + let (d, id) = star(); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + assert!(CornerWidgets::for_tool(&cx, false).is_none()); + let w = CornerWidgets::for_tool(&cx, true).expect("widgets on a plain path"); + assert_eq!(w.corners(), (0..10).collect::>()); + // The top tip's widget sits 10 √2 px down its bisector. + let tip = pts(&w)[0]; + assert!(tip.distance(Point::new(200.0, 140.0 + 10.0 * 2f64.sqrt())) < 1e-9, "{tip:?}"); + let mut t = crate::create("directSelection"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0)); + assert_eq!(a[0], Action::Begin("Corner Radius".into())); + // The widget follows the arc's centre: r / sin(angle / 2) along the bisector. + let c = vectorcraft_geom::corners::path_corners(&star_path(&d, id))[0]; + let want = 10.0 * (c.angle_deg() / 2.0).to_radians().sin(); + assert!((radius(&a[1]) - want).abs() < 1e-9, "{a:?} want {want}"); + assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0 && v.get("corners").is_none()), "every corner: {a:?}"); + // The Selection tool drags the star instead. + let mut t = crate::create("selection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0))[0], Action::Begin("Move".into())); +} + +fn star_path(d: &Document, id: NodeId) -> PathData { + let Some(NodeKind::Path { path, .. }) = d.node(id).map(|n| &n.kind) else { panic!("not a path") }; + path.clone() +} + +/// #511: a Direct-Selected anchor of the star shows its corner's widget alone, and the +/// preference hides the star's wide inner corners (about 125°) below their angle. +#[test] +fn star_corners_follow_the_anchors_and_the_angle_preference() { + let (d, id) = star(); + let mut s = selected(id); + s.anchors.insert(id, [(0, 3)].into()); + let p = paint(); + let c = cx(&d, &s, &p); + let w = CornerWidgets::for_tool(&c, true).unwrap(); + assert_eq!(w.corners(), [3]); + let mut t = crate::create("directSelection"); + let at = pts(&w)[0]; + t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 20.0)); + assert!(matches!(&a[1], Action::Preview(_, v) if v["corners"] == json!([3])), "{a:?}"); + // Corners wider than 90° hide: the five tips keep theirs. + let whole = selected(id); + let c = ToolContext { corner_widget_max_angle: 90.0, ..cx(&d, &whole, &p) }; + let w = CornerWidgets::for_tool(&c, true).unwrap(); + assert_eq!(w.corners(), [0, 2, 4, 6, 8]); + assert_eq!(w.command("radius", json!(4.0))["corners"], json!([0, 2, 4, 6, 8])); +} + +/// A live polygon shows its widgets to the Selection tool too; past every corner's limit the +/// cuts outline in red. +#[test] +fn a_live_polygon_rounds_with_either_tool() { + let live = + LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![], radii_relative: false }; + let (d, id) = doc_with(live.to_path(), Some(live)); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + for any_path in [false, true] { + assert_eq!(CornerWidgets::for_tool(&cx, any_path).unwrap().corners().len(), 6); + } + let tip = pts(&CornerWidgets::for_tool(&cx, false).unwrap())[0]; + let mut t = crate::create("selection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 300.0)); + // A hexagon's 120° corners stop where their cuts meet: 40 · tan 60°. + assert!((radius(&a[1]) - 40.0 * 3f64.sqrt()).abs() < 1e-9, "{a:?}"); + let red = t + .overlays(&cx) + .into_iter() + .any(|o| matches!(o, Overlay::Path { color, path, .. } if color == crate::builder::HIGHLIGHT_RED && path.elements().len() == 12)); + assert!(red, "six arcs at the limit"); +} + +/// A pen path's corners show widgets, curved ones too (#1144); its ends don't. +#[test] +fn a_pen_path_shows_widgets_on_its_corners_only() { + let mut sp = vectorcraft_geom::SubPath::polyline( + &[Point::new(100.0, 100.0), Point::new(300.0, 100.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)], + false, + ); + sp.anchors[2].h_out = Point::new(250.0, 350.0); + let (d, id) = doc_with(PathData::single(sp), None); + let s = selected(id); + let p = paint(); + let w = CornerWidgets::for_tool(&cx(&d, &s, &p), true).unwrap(); + assert_eq!(w.corners(), [1, 2]); + assert_eq!(pts(&w)[0], Point::new(290.0, 110.0)); +} From aafbd39745f57343d9dc35e5fcb474d846ecdf48 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:07 +0000 Subject: [PATCH 058/199] refactor(ui-egui): move panels/gradient.rs tests to panels/gradient/tests.rs --- crates/ui-egui/src/panels/gradient.rs | 448 +------------------- crates/ui-egui/src/panels/gradient/tests.rs | 445 +++++++++++++++++++ 2 files changed, 446 insertions(+), 447 deletions(-) create mode 100644 crates/ui-egui/src/panels/gradient/tests.rs diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 427cb5f5f..08336673d 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -785,450 +785,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use egui::{Event, Modifiers, PointerButton}; - use vectorcraft_engine::Session; - - use super::*; - - #[test] - fn offsets_and_json() { - assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25); - assert_eq!(x_to_offset(-5.0, 0.0, 200.0), 0.0); - assert_eq!(x_to_offset(500.0, 0.0, 200.0), 1.0); - let j = stops_json(&Gradient::default().stops); - assert_eq!(j.as_array().unwrap().len(), 2); - assert_eq!(j[1]["midpoint"], json!(0.5)); - } - - #[test] - fn dropped_colours_recolour_or_add_an_opaque_stop() { - let mut g = Gradient::default(); - g.stops[1].opacity = 0.5; - let red = Color::rgb(1.0, 0.0, 0.0); - let (v, i) = drop_color(&g, Some(1), 0.3, red, None); - assert_eq!((v.len(), i, v[1].color, v[1].opacity), (2, 1, red, 1.0)); - let (v, i) = drop_color(&g, None, 0.3, red, None); - assert_eq!((v.len(), i, v[1].color, v[1].offset, v[1].opacity), (3, 1, red, 0.3, 1.0)); - } - - /// In a CMYK document an RGB swatch's color applied to the selected stop or freeform point - /// keeps its model, as the swatch applied to a fill does. - #[test] - fn a_swatch_color_on_a_stop_or_a_point_keeps_its_model_in_a_cmyk_document() { - let stops = json!([{"offset": 0, "color": {"c": 0, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 1}}]); - let mut app = app(json!({"gradient": {"stops": stops}})); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - let orange = Color::rgb8(255, 128, 0); - app.run("swatch.new", json!({"name": "Screen", "color": "#ff8000", "keepModel": true})).unwrap(); - app.run("gradient.selectStop", json!({"index": 1})).unwrap(); - assert!(recolor_selected(&mut app, orange, &json!({"swatch": "Screen"}))); - let Paint::Gradient(g) = active_paint(&app) else { panic!("a gradient fill") }; - assert_eq!(g.gradient.stops[1].color, orange); - app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); - app.run("paint.freeform.selectPoint", json!({"index": 1})).unwrap(); - assert!(recolor_selected(&mut app, orange, &json!({"swatch": "Screen"}))); - let Paint::Gradient(g) = active_paint(&app) else { panic!("a gradient fill") }; - assert_eq!(g.freeform.map(|f| f.points[1].color), Some(orange)); - } - - /// In a CMYK document Add to Swatches and Save to Swatches keep the gradient's RGB stops RGB. - #[test] - fn a_gradient_saved_as_a_swatch_keeps_its_stops_models_in_a_cmyk_document() { - let mut app = app(json!({"color": "#000000"})); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - let stops = json!([{"offset": 0, "color": "#ff8000"}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 1}}]); - app.run("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": true})).unwrap(); - let g = current(&app).expect("a gradient fill"); - let colors = |g: &GradientPaint| g.gradient.stops.iter().map(|s| s.color).collect::>(); - assert_eq!(colors(&g), [Color::rgb8(255, 128, 0), Color::cmyk(0.0, 0.0, 0.0, 1.0)]); - save_to_swatches(&mut app, &g); - let Some(Paint::Gradient(saved)) = app.session.doc().unwrap().doc.swatches.last().map(|w| w.paint.clone()) else { - panic!("a gradient swatch") - }; - assert_eq!(colors(&saved), colors(&g)); - } - - #[test] - fn dragged_stops_move_copy_or_swap() { - let g = Gradient::default(); - let (v, i) = dragged_stops(&g.stops, 0, 0.4, false, Some(1)); - assert_eq!((v.len(), i, v[0].offset), (2, 0, 0.4)); - let (v, i) = dragged_stops(&g.stops, 0, 0.4, true, None); - assert_eq!((v.len(), i, v[1].offset, v[1].color), (3, 1, 0.4, Color::WHITE)); - let (v, i) = dragged_stops(&g.stops, 0, 1.0, true, Some(1)); - assert_eq!((v.len(), i, v[0].color, v[1].color), (2, 0, Color::BLACK, Color::WHITE)); - // Dropped on itself: a copy. - assert_eq!(dragged_stops(&g.stops, 0, 0.0, true, Some(0)).0.len(), 3); - } - - // ---------- headless frames ---------- - - /// A selected rectangle filled with `fill` (`paint.setFill` params). - fn app(fill: Value) -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); - app.run("paint.setFill", fill).unwrap(); - app - } - - /// A headless Gradient panel: frames share one context and a clock. - struct Panel { - ctx: egui::Context, - time: f64, - } - - impl Panel { - fn new() -> Self { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - Self { ctx, time: 0.0 } - } - - /// One frame with `events` (and `modifiers` held); returns the texts drawn and where. - fn frame(&mut self, app: &mut VectorcraftApp, events: Vec, modifiers: Modifiers) -> Vec<(String, Pos2)> { - fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2)>) { - match s { - egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - self.time += 0.1; - let mut events = events; - events.insert(0, Event::ModifiersChanged(modifiers)); - let input = egui::RawInput { - time: Some(self.time), - events, - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))), - ..Default::default() - }; - let mut out = self.ctx.run_ui(input, |ui| show(app, ui)); - out.textures_delta.clear(); - let mut v = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); - v - } - - /// The rect of the widget keyed `key` (last frame). egui 0.37's `WidgetRect.parent_id` is - /// the parent Ui's unstable `unique_id`, not the stable `scope_id` widgets are salted with; - /// debug builds remember the `with(key)` that made each id, so match that. - fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect { - let suffix = format!(".with({key:?})"); - self.ctx - .viewport(|vp| { - vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| format!("{:?}", w.id).ends_with(&suffix)).map(|w| w.rect) - }) - .expect("widget drawn") - } - - fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> { - let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - self.frame(app, vec![Event::PointerMoved(at), b(true)], Modifiers::NONE); - self.frame(app, vec![b(false)], Modifiers::NONE); - self.frame(app, vec![], Modifiers::NONE) - } - - /// The `size`-square widgets right of `at` on its row (last frame), left to right. - fn row_after(&self, at: Pos2, size: f32) -> Vec { - let mut v: Vec = self.ctx.viewport(|vp| { - vp.prev_pass - .widgets - .layers() - .flat_map(|(_, w)| w.iter()) - .map(|w| w.rect) - .filter(|r| { - (r.width() - size).abs() < 0.5 && (r.height() - size).abs() < 0.5 && (r.center().y - at.y).abs() < 8.0 && r.left() > at.x - }) - .collect() - }); - v.sort_by(|a, b| a.left().total_cmp(&b.left())); - v.dedup(); - v - } - - /// Press at `from`, move to `to` in steps and release there, with `m` held. - fn drag(&mut self, app: &mut VectorcraftApp, from: Pos2, to: Pos2, m: Modifiers) { - let b = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: m }; - self.frame(app, vec![Event::PointerMoved(from), b(from, true)], m); - for k in 1..=4 { - self.frame(app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))], m); - } - self.frame(app, vec![b(to, false)], m); - self.frame(app, vec![], Modifiers::NONE); - } - } - - fn fill(app: &VectorcraftApp) -> GradientPaint { - match crate::panels::current_paints(app).0 { - Paint::Gradient(g) => *g, - p => panic!("expected a gradient fill, got {p:?}"), - } - } - - fn hexes(app: &VectorcraftApp) -> Vec { - fill(app).gradient.stops.iter().map(|s| s.color.to_hex()).collect() - } - - #[test] - fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() { - let mut app = app(json!({"gradient": {}})); - let mut p = Panel::new(); - let texts = p.frame(&mut app, vec![], Modifiers::NONE); - assert!(texts.iter().any(|(t, _)| t == "Opacity:")); - let stroke = p.widget("stroke-proxy"); - p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0)); - assert!(!app.session.fill_active, "the Stroke proxy came to the front"); - set_pstate(&p.ctx, HIDE_OPTIONS, true); - let texts = p.frame(&mut app, vec![], Modifiers::NONE); - assert!(!texts.iter().any(|(t, _)| t == "Opacity:" || t == "Type:"), "{texts:?}"); - let fill = p.widget("fill-proxy"); - p.click(&mut app, fill.left_top() + vec2(2.0, 2.0)); - assert!(app.session.fill_active, "the proxy works with the options hidden"); - p.widget(("grad-stop", 1)); - } - - #[test] - fn the_stroke_buttons_lay_the_gradient_within_along_or_across_the_stroke() { - let mut app = app(json!({"gradient": {}})); - app.run("paint.setStroke", json!({"gradient": {}, "focus": false})).unwrap(); - assert!(app.session.fill_active); - let mode = |app: &VectorcraftApp| app.session.shown_stroke().unwrap().gradient_mode; - let mut p = Panel::new(); - let texts = p.frame(&mut app, vec![], Modifiers::NONE); - let label = texts.iter().find(|(t, _)| t == "Stroke:").expect("the Stroke buttons").1; - // With the Fill proxy in front they are off. - let buttons = p.row_after(label, 22.0); - assert_eq!(buttons.len(), 3); - p.click(&mut app, buttons[1].center()); - assert_eq!(mode(&app), StrokeGradientMode::Within); - let stroke = p.widget("stroke-proxy"); - p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0)); - assert!(!app.session.fill_active); - for (i, want) in [(1, StrokeGradientMode::Along), (2, StrokeGradientMode::Across), (0, StrokeGradientMode::Within)] { - p.click(&mut app, buttons[i].center()); - assert_eq!(mode(&app), want); - } - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(mode(&app), StrokeGradientMode::Across, "one undo step per click"); - } - - #[test] - fn the_dither_box_dithers_the_gradient_fill_and_is_off_for_strokes() { - let mut app = app(json!({"gradient": {}})); - app.run("paint.setStroke", json!({"gradient": {}, "focus": false})).unwrap(); - let mut p = Panel::new(); - let texts = p.frame(&mut app, vec![], Modifiers::NONE); - let at = texts.iter().find(|(t, _)| t == "Dither").expect("the Dither box").1; - p.click(&mut app, at); - assert!(fill(&app).gradient.dither, "the fill's gradient is dithered"); - p.click(&mut app, at); - assert!(!fill(&app).gradient.dither, "a second click clears it"); - app.run("edit.undo", json!({})).unwrap(); - assert!(fill(&app).gradient.dither, "one undo step per click"); - // Behind the Stroke proxy the box is dimmed and does nothing. - let stroke = p.widget("stroke-proxy"); - p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0)); - assert!(!app.session.fill_active); - let undo = app.session.doc().unwrap().history.undo.len(); - p.click(&mut app, at); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "no edit on the stroke"); - let Paint::Gradient(st) = app.session.shown_stroke().unwrap().paint.clone() else { panic!("a gradient stroke") }; - assert!(!st.gradient.dither); - } - - #[test] - fn the_method_menu_makes_the_gradient_perceptual() { - let mut app = app(json!({"gradient": {}})); - let mut p = Panel::new(); - let texts = p.frame(&mut app, vec![], Modifiers::NONE); - assert!(texts.iter().any(|(t, _)| t == "Method:"), "{texts:?}"); - let shown = texts.iter().find(|(t, _)| t == "Classic").expect("Classic shown").1; - let undo = app.session.doc().unwrap().history.undo.len(); - let texts = p.click(&mut app, shown); - let option = texts.iter().find(|(t, _)| t == "Perceptual").expect("the menu lists Perceptual").1; - p.click(&mut app, option); - assert_eq!(fill(&app).gradient.method, GradientMethod::Perceptual); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - let texts = p.frame(&mut app, vec![], Modifiers::NONE); - assert!(texts.iter().any(|(t, _)| t == "Perceptual"), "the menu shows the method: {texts:?}"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(fill(&app).gradient.method, GradientMethod::Classic); - } - - #[test] - fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { - let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]); - let mut app = app(json!({"gradient": {"stops": stops}})); - app.run("gradient.selectStop", json!({"index": 1})).unwrap(); - let mut p = Panel::new(); - let texts = p.frame(&mut app, vec![], Modifiers::NONE); - assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}"); - let diamond = p.widget(("grad-mid", 0)); - let texts = p.click(&mut app, diamond.center()); - assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection"); - assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}"); - // Typing a location moves the midpoint. - let field = texts.iter().find(|(t, _)| t == "30%").unwrap().1; - p.click(&mut app, field); - let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }; - p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("60".into())], Modifiers::NONE); - p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); - assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops); - // Clicking a stop selects it again. - let stop = p.widget(("grad-stop", 0)); - let texts = p.click(&mut app, stop.center()); - assert_eq!(app.session.selected_stop(), Some(0)); - assert!(texts.iter().any(|(t, _)| t == "0%")); - } - - #[test] - fn the_thumbnail_menu_applies_gradient_swatches_and_saves_one() { - let mut app = app(json!({"color": "#336699"})); - let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); - app.run("swatch.new", json!({"name": "Dusk", "gradient": {"stops": stops}})).unwrap(); - let mut p = Panel::new(); - p.frame(&mut app, vec![], Modifiers::NONE); - let menu = p.widget("grad-swatches").center(); - let texts = p.click(&mut app, menu); - let dusk = texts.iter().find(|(t, _)| t == "Dusk").unwrap_or_else(|| panic!("the menu lists the swatch: {texts:?}")).1; - assert!(texts.iter().any(|(t, _)| t.ends_with("Save to Swatches"))); - p.click(&mut app, dusk); - assert_eq!(hexes(&app), ["#ff0000", "#0000ff"]); - // Save to Swatches adds the current gradient. - let before = gradient_swatches(&app).len(); - let texts = p.click(&mut app, menu); - let save = texts.iter().find(|(t, _)| t.ends_with("Save to Swatches")).unwrap().1; - p.click(&mut app, save); - assert_eq!(gradient_swatches(&app).len(), before + 1); - } - - #[test] - fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() { - let mut app = app(json!({"gradient": {}})); - let mut p = Panel::new(); - p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); - let mid = pos2((a.x + b.x) / 2.0, a.y); - let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::>(); - // A plain drag moves the stop. - p.drag(&mut app, a, mid, Modifiers::NONE); - assert_eq!(offsets(&app), [0.5, 1.0]); - app.run("edit.undo", json!({})).unwrap(); - // Alt leaves the original and drags a copy, selected on release; one undo step. - p.frame(&mut app, vec![], Modifiers::NONE); - p.drag(&mut app, a, mid, Modifiers::ALT); - assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#ffffff".to_string(), "#ffffff".into(), "#000000".into()])); - assert_eq!(app.session.selected_stop(), Some(1)); - // Alt-dropping the first stop on the last swaps their colours. - p.drag(&mut app, a, b, Modifiers::ALT); - assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#000000".to_string(), "#ffffff".into(), "#ffffff".into()])); - app.run("edit.undo", json!({})).unwrap(); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(hexes(&app), ["#ffffff", "#000000"]); - } - - #[test] - fn a_dropped_swatch_adds_or_recolours_a_stop() { - let mut app = app(json!({"gradient": {}})); - let mut p = Panel::new(); - p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); - let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| { - egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); - p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE); - let up = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; - p.frame(app, vec![up], Modifiers::NONE); - }; - drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0)); - assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]); - p.frame(&mut app, vec![], Modifiers::NONE); - let last = p.widget(("grad-stop", 2)).center(); - drop(&mut p, &mut app, last); - assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]); - // A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop. - app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap(); - let ink = app.session.doc().unwrap().doc.swatch("Ink").unwrap().paint.clone(); - egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::Paint { paint: ink, params: json!({"swatch": "Ink"}), rows: None, library: None }); - p.frame(&mut app, vec![Event::PointerMoved(a)], Modifiers::NONE); - p.frame( - &mut app, - vec![Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }], - Modifiers::NONE, - ); - let st = fill(&app).gradient.stops[0].clone(); - assert_eq!((st.swatch.as_deref(), st.tint, st.color.to_hex()), (Some("Ink"), 1.0, "#cc0066".to_string())); - app.run("swatch.edit", json!({"name": "Ink", "color": "#0066cc"})).unwrap(); - assert_eq!(hexes(&app)[0], "#0066cc"); - } - - /// In a CMYK document a color dropped on the ramp of an RGB gradient keeps its model, as the - /// gradient's other stops do. - #[test] - fn a_color_dropped_on_the_ramp_keeps_its_model_in_a_cmyk_document() { - let mut app = app(json!({"gradient": {}})); - app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); - let rgb = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); - app.run("paint.setFill", json!({"gradient": {"stops": rgb}, "keepModel": true})).unwrap(); - let mut p = Panel::new(); - p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); - let at = pos2((a.x + b.x) / 2.0, a.y - 20.0); - let red = Color::rgb(1.0, 0.0, 0.0); - egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(red))); - p.frame(&mut app, vec![Event::PointerMoved(at)], Modifiers::NONE); - let up = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; - p.frame(&mut app, vec![up], Modifiers::NONE); - let stops: Vec = fill(&app).gradient.stops.iter().map(|s| s.color).collect(); - assert_eq!(stops, [Color::WHITE, red, Color::BLACK]); - } - - #[test] - fn double_clicking_a_stop_opens_the_shared_popover_beside_it() { - let mut app = app(json!({"gradient": {}})); - let mut p = Panel::new(); - p.frame(&mut app, vec![], Modifiers::NONE); - let stop = p.widget(("grad-stop", 1)); - let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE); - let d = app.ui.dialog.clone().expect("the popover opened"); - assert_eq!( - (d.kind.as_str(), &d.fields["index"], &d.fields["screen"]), - ("gradientStop", &json!(1), &json!([stop.left(), stop.bottom() + 4.0])) - ); - assert_eq!(app.session.selected_stop(), Some(1)); - } - - #[test] - fn the_stop_eyedropper_arms_the_eyedropper_for_the_stop() { - let mut app = app(json!({"gradient": {}})); - app.select_tool("gradient"); - app.run("gradient.selectStop", json!({"index": 0})).unwrap(); - let texts = Panel::new().frame(&mut app, vec![], Modifiers::NONE); - assert!(texts.iter().any(|(t, _)| t == "#FFFFFF"), "the stop's colour row: {texts:?}"); - stop_eyedropper(&mut app); - assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient", "brushKeyColor": false}))); - } - - /// The gradient panel names its Angle and Aspect Ratio inputs. - #[test] - fn angle_and_aspect_fields_are_named() { - use egui_kittest::kittest::Queryable; - let app = app(json!({"gradient": {}})); - let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(280.0, 640.0)).with_accessibility_check(false).build_ui_state( - move |ui, app: &mut VectorcraftApp| { - if !std::mem::replace(&mut fonts, true) { - crate::theme::install_fonts(ui.ctx()); - return; - } - show(app, ui); - }, - app, - ); - h.run_steps(3); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Angle"); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Aspect Ratio"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs new file mode 100644 index 000000000..a4dac7825 --- /dev/null +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -0,0 +1,445 @@ +use egui::{Event, Modifiers, PointerButton}; +use vectorcraft_engine::Session; + +use super::*; + +#[test] +fn offsets_and_json() { + assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25); + assert_eq!(x_to_offset(-5.0, 0.0, 200.0), 0.0); + assert_eq!(x_to_offset(500.0, 0.0, 200.0), 1.0); + let j = stops_json(&Gradient::default().stops); + assert_eq!(j.as_array().unwrap().len(), 2); + assert_eq!(j[1]["midpoint"], json!(0.5)); +} + +#[test] +fn dropped_colours_recolour_or_add_an_opaque_stop() { + let mut g = Gradient::default(); + g.stops[1].opacity = 0.5; + let red = Color::rgb(1.0, 0.0, 0.0); + let (v, i) = drop_color(&g, Some(1), 0.3, red, None); + assert_eq!((v.len(), i, v[1].color, v[1].opacity), (2, 1, red, 1.0)); + let (v, i) = drop_color(&g, None, 0.3, red, None); + assert_eq!((v.len(), i, v[1].color, v[1].offset, v[1].opacity), (3, 1, red, 0.3, 1.0)); +} + +/// In a CMYK document an RGB swatch's color applied to the selected stop or freeform point +/// keeps its model, as the swatch applied to a fill does. +#[test] +fn a_swatch_color_on_a_stop_or_a_point_keeps_its_model_in_a_cmyk_document() { + let stops = json!([{"offset": 0, "color": {"c": 0, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 1}}]); + let mut app = app(json!({"gradient": {"stops": stops}})); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + let orange = Color::rgb8(255, 128, 0); + app.run("swatch.new", json!({"name": "Screen", "color": "#ff8000", "keepModel": true})).unwrap(); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + assert!(recolor_selected(&mut app, orange, &json!({"swatch": "Screen"}))); + let Paint::Gradient(g) = active_paint(&app) else { panic!("a gradient fill") }; + assert_eq!(g.gradient.stops[1].color, orange); + app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); + app.run("paint.freeform.selectPoint", json!({"index": 1})).unwrap(); + assert!(recolor_selected(&mut app, orange, &json!({"swatch": "Screen"}))); + let Paint::Gradient(g) = active_paint(&app) else { panic!("a gradient fill") }; + assert_eq!(g.freeform.map(|f| f.points[1].color), Some(orange)); +} + +/// In a CMYK document Add to Swatches and Save to Swatches keep the gradient's RGB stops RGB. +#[test] +fn a_gradient_saved_as_a_swatch_keeps_its_stops_models_in_a_cmyk_document() { + let mut app = app(json!({"color": "#000000"})); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ff8000"}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 1}}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": true})).unwrap(); + let g = current(&app).expect("a gradient fill"); + let colors = |g: &GradientPaint| g.gradient.stops.iter().map(|s| s.color).collect::>(); + assert_eq!(colors(&g), [Color::rgb8(255, 128, 0), Color::cmyk(0.0, 0.0, 0.0, 1.0)]); + save_to_swatches(&mut app, &g); + let Some(Paint::Gradient(saved)) = app.session.doc().unwrap().doc.swatches.last().map(|w| w.paint.clone()) else { + panic!("a gradient swatch") + }; + assert_eq!(colors(&saved), colors(&g)); +} + +#[test] +fn dragged_stops_move_copy_or_swap() { + let g = Gradient::default(); + let (v, i) = dragged_stops(&g.stops, 0, 0.4, false, Some(1)); + assert_eq!((v.len(), i, v[0].offset), (2, 0, 0.4)); + let (v, i) = dragged_stops(&g.stops, 0, 0.4, true, None); + assert_eq!((v.len(), i, v[1].offset, v[1].color), (3, 1, 0.4, Color::WHITE)); + let (v, i) = dragged_stops(&g.stops, 0, 1.0, true, Some(1)); + assert_eq!((v.len(), i, v[0].color, v[1].color), (2, 0, Color::BLACK, Color::WHITE)); + // Dropped on itself: a copy. + assert_eq!(dragged_stops(&g.stops, 0, 0.0, true, Some(0)).0.len(), 3); +} + +// ---------- headless frames ---------- + +/// A selected rectangle filled with `fill` (`paint.setFill` params). +fn app(fill: Value) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("paint.setFill", fill).unwrap(); + app +} + +/// A headless Gradient panel: frames share one context and a clock. +struct Panel { + ctx: egui::Context, + time: f64, +} + +impl Panel { + fn new() -> Self { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + Self { ctx, time: 0.0 } + } + + /// One frame with `events` (and `modifiers` held); returns the texts drawn and where. + fn frame(&mut self, app: &mut VectorcraftApp, events: Vec, modifiers: Modifiers) -> Vec<(String, Pos2)> { + fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + self.time += 0.1; + let mut events = events; + events.insert(0, Event::ModifiersChanged(modifiers)); + let input = egui::RawInput { + time: Some(self.time), + events, + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))), + ..Default::default() + }; + let mut out = self.ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + } + + /// The rect of the widget keyed `key` (last frame). egui 0.37's `WidgetRect.parent_id` is + /// the parent Ui's unstable `unique_id`, not the stable `scope_id` widgets are salted with; + /// debug builds remember the `with(key)` that made each id, so match that. + fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect { + let suffix = format!(".with({key:?})"); + self.ctx + .viewport(|vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| format!("{:?}", w.id).ends_with(&suffix)).map(|w| w.rect) + }) + .expect("widget drawn") + } + + fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> { + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + self.frame(app, vec![Event::PointerMoved(at), b(true)], Modifiers::NONE); + self.frame(app, vec![b(false)], Modifiers::NONE); + self.frame(app, vec![], Modifiers::NONE) + } + + /// The `size`-square widgets right of `at` on its row (last frame), left to right. + fn row_after(&self, at: Pos2, size: f32) -> Vec { + let mut v: Vec = self.ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .map(|w| w.rect) + .filter(|r| { + (r.width() - size).abs() < 0.5 && (r.height() - size).abs() < 0.5 && (r.center().y - at.y).abs() < 8.0 && r.left() > at.x + }) + .collect() + }); + v.sort_by(|a, b| a.left().total_cmp(&b.left())); + v.dedup(); + v + } + + /// Press at `from`, move to `to` in steps and release there, with `m` held. + fn drag(&mut self, app: &mut VectorcraftApp, from: Pos2, to: Pos2, m: Modifiers) { + let b = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: m }; + self.frame(app, vec![Event::PointerMoved(from), b(from, true)], m); + for k in 1..=4 { + self.frame(app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))], m); + } + self.frame(app, vec![b(to, false)], m); + self.frame(app, vec![], Modifiers::NONE); + } +} + +fn fill(app: &VectorcraftApp) -> GradientPaint { + match crate::panels::current_paints(app).0 { + Paint::Gradient(g) => *g, + p => panic!("expected a gradient fill, got {p:?}"), + } +} + +fn hexes(app: &VectorcraftApp) -> Vec { + fill(app).gradient.stops.iter().map(|s| s.color.to_hex()).collect() +} + +#[test] +fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "Opacity:")); + let stroke = p.widget("stroke-proxy"); + p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0)); + assert!(!app.session.fill_active, "the Stroke proxy came to the front"); + set_pstate(&p.ctx, HIDE_OPTIONS, true); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(!texts.iter().any(|(t, _)| t == "Opacity:" || t == "Type:"), "{texts:?}"); + let fill = p.widget("fill-proxy"); + p.click(&mut app, fill.left_top() + vec2(2.0, 2.0)); + assert!(app.session.fill_active, "the proxy works with the options hidden"); + p.widget(("grad-stop", 1)); +} + +#[test] +fn the_stroke_buttons_lay_the_gradient_within_along_or_across_the_stroke() { + let mut app = app(json!({"gradient": {}})); + app.run("paint.setStroke", json!({"gradient": {}, "focus": false})).unwrap(); + assert!(app.session.fill_active); + let mode = |app: &VectorcraftApp| app.session.shown_stroke().unwrap().gradient_mode; + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let label = texts.iter().find(|(t, _)| t == "Stroke:").expect("the Stroke buttons").1; + // With the Fill proxy in front they are off. + let buttons = p.row_after(label, 22.0); + assert_eq!(buttons.len(), 3); + p.click(&mut app, buttons[1].center()); + assert_eq!(mode(&app), StrokeGradientMode::Within); + let stroke = p.widget("stroke-proxy"); + p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0)); + assert!(!app.session.fill_active); + for (i, want) in [(1, StrokeGradientMode::Along), (2, StrokeGradientMode::Across), (0, StrokeGradientMode::Within)] { + p.click(&mut app, buttons[i].center()); + assert_eq!(mode(&app), want); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(mode(&app), StrokeGradientMode::Across, "one undo step per click"); +} + +#[test] +fn the_dither_box_dithers_the_gradient_fill_and_is_off_for_strokes() { + let mut app = app(json!({"gradient": {}})); + app.run("paint.setStroke", json!({"gradient": {}, "focus": false})).unwrap(); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let at = texts.iter().find(|(t, _)| t == "Dither").expect("the Dither box").1; + p.click(&mut app, at); + assert!(fill(&app).gradient.dither, "the fill's gradient is dithered"); + p.click(&mut app, at); + assert!(!fill(&app).gradient.dither, "a second click clears it"); + app.run("edit.undo", json!({})).unwrap(); + assert!(fill(&app).gradient.dither, "one undo step per click"); + // Behind the Stroke proxy the box is dimmed and does nothing. + let stroke = p.widget("stroke-proxy"); + p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0)); + assert!(!app.session.fill_active); + let undo = app.session.doc().unwrap().history.undo.len(); + p.click(&mut app, at); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "no edit on the stroke"); + let Paint::Gradient(st) = app.session.shown_stroke().unwrap().paint.clone() else { panic!("a gradient stroke") }; + assert!(!st.gradient.dither); +} + +#[test] +fn the_method_menu_makes_the_gradient_perceptual() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "Method:"), "{texts:?}"); + let shown = texts.iter().find(|(t, _)| t == "Classic").expect("Classic shown").1; + let undo = app.session.doc().unwrap().history.undo.len(); + let texts = p.click(&mut app, shown); + let option = texts.iter().find(|(t, _)| t == "Perceptual").expect("the menu lists Perceptual").1; + p.click(&mut app, option); + assert_eq!(fill(&app).gradient.method, GradientMethod::Perceptual); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "Perceptual"), "the menu shows the method: {texts:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(fill(&app).gradient.method, GradientMethod::Classic); +} + +#[test] +fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { + let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]); + let mut app = app(json!({"gradient": {"stops": stops}})); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}"); + let diamond = p.widget(("grad-mid", 0)); + let texts = p.click(&mut app, diamond.center()); + assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection"); + assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}"); + // Typing a location moves the midpoint. + let field = texts.iter().find(|(t, _)| t == "30%").unwrap().1; + p.click(&mut app, field); + let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }; + p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("60".into())], Modifiers::NONE); + p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); + assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops); + // Clicking a stop selects it again. + let stop = p.widget(("grad-stop", 0)); + let texts = p.click(&mut app, stop.center()); + assert_eq!(app.session.selected_stop(), Some(0)); + assert!(texts.iter().any(|(t, _)| t == "0%")); +} + +#[test] +fn the_thumbnail_menu_applies_gradient_swatches_and_saves_one() { + let mut app = app(json!({"color": "#336699"})); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + app.run("swatch.new", json!({"name": "Dusk", "gradient": {"stops": stops}})).unwrap(); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let menu = p.widget("grad-swatches").center(); + let texts = p.click(&mut app, menu); + let dusk = texts.iter().find(|(t, _)| t == "Dusk").unwrap_or_else(|| panic!("the menu lists the swatch: {texts:?}")).1; + assert!(texts.iter().any(|(t, _)| t.ends_with("Save to Swatches"))); + p.click(&mut app, dusk); + assert_eq!(hexes(&app), ["#ff0000", "#0000ff"]); + // Save to Swatches adds the current gradient. + let before = gradient_swatches(&app).len(); + let texts = p.click(&mut app, menu); + let save = texts.iter().find(|(t, _)| t.ends_with("Save to Swatches")).unwrap().1; + p.click(&mut app, save); + assert_eq!(gradient_swatches(&app).len(), before + 1); +} + +#[test] +fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let mid = pos2((a.x + b.x) / 2.0, a.y); + let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::>(); + // A plain drag moves the stop. + p.drag(&mut app, a, mid, Modifiers::NONE); + assert_eq!(offsets(&app), [0.5, 1.0]); + app.run("edit.undo", json!({})).unwrap(); + // Alt leaves the original and drags a copy, selected on release; one undo step. + p.frame(&mut app, vec![], Modifiers::NONE); + p.drag(&mut app, a, mid, Modifiers::ALT); + assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#ffffff".to_string(), "#ffffff".into(), "#000000".into()])); + assert_eq!(app.session.selected_stop(), Some(1)); + // Alt-dropping the first stop on the last swaps their colours. + p.drag(&mut app, a, b, Modifiers::ALT); + assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#000000".to_string(), "#ffffff".into(), "#ffffff".into()])); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(hexes(&app), ["#ffffff", "#000000"]); +} + +#[test] +fn a_dropped_swatch_adds_or_recolours_a_stop() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| { + egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE); + let up = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + p.frame(app, vec![up], Modifiers::NONE); + }; + drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0)); + assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]); + p.frame(&mut app, vec![], Modifiers::NONE); + let last = p.widget(("grad-stop", 2)).center(); + drop(&mut p, &mut app, last); + assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]); + // A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop. + app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap(); + let ink = app.session.doc().unwrap().doc.swatch("Ink").unwrap().paint.clone(); + egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::Paint { paint: ink, params: json!({"swatch": "Ink"}), rows: None, library: None }); + p.frame(&mut app, vec![Event::PointerMoved(a)], Modifiers::NONE); + p.frame( + &mut app, + vec![Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }], + Modifiers::NONE, + ); + let st = fill(&app).gradient.stops[0].clone(); + assert_eq!((st.swatch.as_deref(), st.tint, st.color.to_hex()), (Some("Ink"), 1.0, "#cc0066".to_string())); + app.run("swatch.edit", json!({"name": "Ink", "color": "#0066cc"})).unwrap(); + assert_eq!(hexes(&app)[0], "#0066cc"); +} + +/// In a CMYK document a color dropped on the ramp of an RGB gradient keeps its model, as the +/// gradient's other stops do. +#[test] +fn a_color_dropped_on_the_ramp_keeps_its_model_in_a_cmyk_document() { + let mut app = app(json!({"gradient": {}})); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + let rgb = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": rgb}, "keepModel": true})).unwrap(); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let at = pos2((a.x + b.x) / 2.0, a.y - 20.0); + let red = Color::rgb(1.0, 0.0, 0.0); + egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(red))); + p.frame(&mut app, vec![Event::PointerMoved(at)], Modifiers::NONE); + let up = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + p.frame(&mut app, vec![up], Modifiers::NONE); + let stops: Vec = fill(&app).gradient.stops.iter().map(|s| s.color).collect(); + assert_eq!(stops, [Color::WHITE, red, Color::BLACK]); +} + +#[test] +fn double_clicking_a_stop_opens_the_shared_popover_beside_it() { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + p.frame(&mut app, vec![], Modifiers::NONE); + let stop = p.widget(("grad-stop", 1)); + let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE); + let d = app.ui.dialog.clone().expect("the popover opened"); + assert_eq!( + (d.kind.as_str(), &d.fields["index"], &d.fields["screen"]), + ("gradientStop", &json!(1), &json!([stop.left(), stop.bottom() + 4.0])) + ); + assert_eq!(app.session.selected_stop(), Some(1)); +} + +#[test] +fn the_stop_eyedropper_arms_the_eyedropper_for_the_stop() { + let mut app = app(json!({"gradient": {}})); + app.select_tool("gradient"); + app.run("gradient.selectStop", json!({"index": 0})).unwrap(); + let texts = Panel::new().frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "#FFFFFF"), "the stop's colour row: {texts:?}"); + stop_eyedropper(&mut app); + assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient", "brushKeyColor": false}))); +} + +/// The gradient panel names its Angle and Aspect Ratio inputs. +#[test] +fn angle_and_aspect_fields_are_named() { + use egui_kittest::kittest::Queryable; + let app = app(json!({"gradient": {}})); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(280.0, 640.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Angle"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Aspect Ratio"); +} From d9b733fe707c5500b48550f2ed1de61f053601cc Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:09 +0000 Subject: [PATCH 059/199] refactor(doc): move node.rs tests to node/tests.rs --- crates/doc/src/node.rs | 443 +---------------------------------- crates/doc/src/node/tests.rs | 440 ++++++++++++++++++++++++++++++++++ 2 files changed, 441 insertions(+), 442 deletions(-) create mode 100644 crates/doc/src/node/tests.rs diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9eebb57f2..8f0e1b1d5 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -2162,445 +2162,4 @@ mod kind_de { } #[cfg(test)] -mod tests { - use super::*; - - /// A node writes its rarely set parts among the others, in the file's field order, and reads - /// them back into its extras; a node without any keeps none. - #[test] - fn extras_are_written_flat_in_order() { - let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); - n.name = Some("n".into()); - n.knockout_shape = true; - n.bbox_angle = 30.0; - { - let mut e = n.extras_mut(); - e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true))); - e.trace = Some(Box::new(serde_json::json!({"preset": "p"}))); - e.series_index = Some(2); - e.type_outlines = true; - e.graphic_style = Some(3); - e.attrs = Some(Box::default()); - e.slice = Some(Box::default()); - e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0))); - e.live_paint_gaps = Some(Box::default()); - } - /// The top-level keys of a JSON object, in the order written. - struct Keys(Vec); - impl<'de> Deserialize<'de> for Keys { - fn deserialize>(d: D) -> Result { - struct V; - impl<'de> serde::de::Visitor<'de> for V { - type Value = Keys; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("an object") - } - fn visit_map>(self, mut a: A) -> Result { - let mut out = vec![]; - while let Some(k) = a.next_key::()? { - a.next_value::()?; - out.push(k); - } - Ok(Keys(out)) - } - } - d.deserialize_map(V) - } - } - let text = serde_json::to_string(&n).unwrap(); - let keys = serde_json::from_str::(&text).unwrap().0; - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - let order = [ - "id", - "name", - "knockout_shape", - "appearance", - "mask", - "trace", - "series_index", - "type_outlines", - "kind", - "graphic_style", - "attrs", - "slice", - "bbox_angle", - "perspective", - "live_paint_gaps", - ]; - assert_eq!(keys, order); - assert_eq!(serde_json::from_value::(v).unwrap(), n); - let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); - let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); - assert!(back.extras_box().is_none()); - // Clearing the last extra lets them go; clearing one a node doesn't have allocates none. - n.extras_mut().mask = None; - assert!(n.extras_box().is_some()); - *n.extras_mut() = NodeExtras::default(); - assert!(n.extras_box().is_none()); - n.extras_mut().mask = None; - assert!(n.extras_box().is_none()); - assert_eq!(n.extras(), &NodeExtras::default()); - } - - #[test] - fn rect_live_shape_roundtrip() { - let l = LiveShape::Rectangle { - w: 10.0, - h: 20.0, - radii: [0.0; 4], - kinds: Default::default(), - radii_relative: false, - xf: Affine::translate((5.0, 5.0)), - }; - assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); - assert_eq!(l.label(), "Rectangle"); - } - - #[test] - fn corner_kinds_save_only_when_some_corner_isnt_round() { - let mut l = LiveShape::Rectangle { - w: 100.0, - h: 50.0, - radii: [0.0, 10.0, 0.0, 0.0], - kinds: Default::default(), - radii_relative: false, - xf: Affine::IDENTITY, - }; - // Older files read as round. - let json = serde_json::to_value(&l).unwrap(); - assert!(json.get("kinds").is_none(), "{json}"); - assert_eq!(serde_json::from_value::(json).unwrap(), l); - if let LiveShape::Rectangle { kinds, .. } = &mut l { - kinds[1] = CornerKind::Chamfer; - } - let json = serde_json::to_value(&l).unwrap(); - assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"])); - assert_eq!(serde_json::from_value::(json).unwrap(), l); - // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. - let p: LiveShape = - serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); - assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); - let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); - let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; - let json = serde_json::to_value(&path).unwrap(); - assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None)); - assert_eq!(serde_json::from_value::(json).unwrap(), path); - assert_eq!(path.label(), "Path"); - } - - /// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one - /// with four billion made its outline and corner lists allocate tens of gigabytes. - #[test] - fn a_polygon_from_a_file_has_3_to_1000_sides() { - let read = |sides: u32| { - let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]}); - serde_json::from_value::(json).unwrap() - }; - let sides_of = |l: &LiveShape| match l { - LiveShape::Polygon { sides, .. } => *sides, - _ => panic!("not a polygon: {l:?}"), - }; - let huge = read(4_000_000_000); - assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]); - assert_eq!(sides_of(&huge), 1000); - assert_eq!(huge.polygon_outline().anchor_count(), 1000); - } - - #[test] - fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() { - let close = |a: f64, b: f64| (a - b).abs() < 1e-9; - // A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page. - let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0); - let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false }; - assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal()); - p.set_polygon_angle(45.0); - assert!(close(p.polygon_angle().unwrap(), 45.0)); - let centre = p.to_path().bounds().unwrap().center(); - assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}"); - p.set_polygon_radius(30.0); - assert!(close(p.polygon_radius().unwrap(), 30.0)); - // A hexagon's side is as long as its radius. - assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0)); - // Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius. - p.transform(Affine::scale_non_uniform(2.0, 1.0)); - assert!(!p.polygon_sides_equal()); - let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap()); - p.make_sides_equal(); - assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a)); - let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") }; - assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0)); - // Other shapes have none of it. - let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; - assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None)); - } - - /// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path. - fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node { - let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; - let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); - if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(Box::new(live)); - } - n - } - - fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { - let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; - let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; - (*w, *h, *radii, *xf) - } - - fn near(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-9 - } - - fn near_xf(a: Affine, b: Affine) -> bool { - a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) - } - - /// Size and radius of a live rectangle, within rounding. - fn assert_live(n: &Node, w: f64, h: f64, r: f64) { - let (lw, lh, radii, _) = live_of(n); - assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}"); - } - - /// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation), - /// spans `r` along x and y: the corner is a circular arc of radius `r`. - fn assert_round_corners(n: &Node, back: Affine, r: f64) { - let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") }; - let a: Vec = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect(); - assert_eq!(a.len(), 8, "a rounded rectangle"); - // Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs. - for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] { - let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs()); - assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}"); - } - } - - const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; - - #[test] - fn uneven_scales_keep_live_corners_circular() { - // A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291). - let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0))); - let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0)); - n.transform(stretch, KEEP_CORNERS); - assert_live(&n, 300.0, 100.0, 20.0); - assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size"); - let b = n.geometric_bounds().unwrap(); - assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}"); - assert_round_corners(&n, Affine::IDENTITY, 20.0); - // Scale Corners on: the radius scales by the mean scale, still circular. - let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); - n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default()); - assert_live(&n, 400.0, 100.0, 40.0); - assert_round_corners(&n, Affine::IDENTITY, 40.0); - // Squashed below the radius, the short sides are fully round; stretched back, it returns. - n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS); - assert_round_corners(&n, Affine::IDENTITY, 25.0); - n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS); - assert_round_corners(&n, Affine::IDENTITY, 40.0); - } - - #[test] - fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() { - let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians()); - let mut n = live_rect(100.0, 50.0, 10.0, place); - // Stretched along its own width, as a bounding-box drag of a rotated shape does. - n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS); - assert_live(&n, 200.0, 50.0, 10.0); - assert!(near_xf(live_of(&n).3, place)); - assert_round_corners(&n, place.inverse(), 10.0); - // A reflection stays in `xf`; the corners stay as they were. - n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS); - assert_live(&n, 200.0, 50.0, 10.0); - let xf = live_of(&n).3; - assert!(near(xf.determinant(), -1.0)); - assert_round_corners(&n, xf.inverse(), 10.0); - } - - #[test] - fn shears_and_moves_keep_the_old_transform() { - // Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before. - let rot = Affine::rotate(30f64.to_radians()); - let mut n = live_rect(100.0, 50.0, 10.0, rot); - let before = n.clone(); - let a = Affine::scale_non_uniform(2.0, 1.0); - n.transform(a, Scaling::default()); - assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot)); - let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() }; - assert_eq!(*path, old.transformed(a)); - // A file saved with an uneven scale in `xf` keeps its exact geometry when moved... - let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0); - let mut n = live_rect(100.0, 100.0, 20.0, legacy); - n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS); - assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy)); - // ...and once scaled, has document units and circular corners of its mean radius. - n.transform(Affine::scale(2.0), Scaling::default()); - assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0); - assert!(near(live_of(&n).3.determinant(), 1.0)); - assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0); - } - - /// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live - /// Corners see and edit it in document units, with circular corners (#442). - #[test] - fn a_scale_left_in_the_transform_folds_into_the_size() { - let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0); - let n = live_rect(50.0, 80.0, 20.0, legacy); - let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") }; - let folded = live.folded(); - assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone"); - let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") }; - // Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius. - assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}"); - assert!(near_xf(*xf, Affine::translate((5.0, 5.0)))); - let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap()); - assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}"); - let corners = crate::LiveCorners::of(&n).unwrap(); - assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units"); - // Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are. - let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0); - let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]); - for xf in [place, shear] { - let mut l = live_rect(100.0, 160.0, 20.0, xf); - let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") }; - assert!(!live.fold_scale()); - assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf)); - } - } - - #[test] - fn default_transparency() { - let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - assert!(p().has_default_transparency()); - let tweaks: [fn(&mut Node); 7] = [ - |n| n.opacity = 0.5, - |n| n.blend = BlendMode::Multiply, - |n| n.isolate = true, - |n| n.knockout = Knockout::On, - |n| n.knockout = Knockout::Off, - |n| n.knockout_shape = true, - |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), - ]; - for tweak in tweaks { - let mut n = p(); - tweak(&mut n); - assert!(!n.has_default_transparency(), "{n:?}"); - } - } - - #[test] - fn transform_group_recurses() { - let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let mut g = Node::group(NodeId(1), vec![Arc::new(p)]); - g.transform(Affine::scale(2.0), true); - assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0))); - let child = &g.children().unwrap()[0]; - assert_eq!(child.appearance.stroke_width(), 2.0); - } - - #[test] - fn unplaced_gradients_pin_only_when_a_refit_would_differ() { - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; - let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); - let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0)); - let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) { - (Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom), - _ => panic!("gradient fill and stroke"), - }; - let mut n = fresh(); - n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true); - assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting"); - let mut n = fresh(); - n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); - let (f, s) = geoms(&n); - // Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical. - let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); - want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); - let f = f.unwrap(); - assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}"); - assert!((s.unwrap().length() - 102.0).abs() < 1e-9); - } - - #[test] - fn display_names() { - let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - assert_eq!(p.display_name(), ""); - let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0)); - assert_eq!(l.display_name(), "Layer 1"); - } - - #[test] - fn visual_bounds_include_stroke() { - let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round; - assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); - } - - /// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the - /// weight centred, the whole weight outside, nothing inside; scaled with the type. - #[test] - fn type_visual_bounds_grow_with_the_character_stroke() { - use crate::appearance::StrokeAlign; - use crate::text::{CharStyle, TextObject}; - let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() }; - let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style); - t.xf = Affine::scale(2.0) * t.xf; - let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); - let g = node(&t).geometric_bounds().unwrap(); - for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] { - t.runs[0].style.stroke_align = align; - assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}"); - } - t.runs[0].style.stroke = vectorcraft_color::Paint::None; - assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth"); - } - - /// A group holding a path, a compound, an image and a nested group, as a file writes it. - fn nested_group() -> Node { - let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let image = - ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; - let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); - let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); - let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; - Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false }) - } - - #[test] - fn kinds_read_back_with_type_first_or_anywhere() { - let n = nested_group(); - let json = serde_json::to_string(&n).unwrap(); - assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), n); - // Through a JSON value, whose keys are sorted: `type` comes after `children`. - let v = serde_json::to_value(&n).unwrap(); - assert_eq!(serde_json::from_value::(v.clone()).unwrap(), n); - assert_eq!(serde_json::from_str::(&v.to_string()).unwrap(), n); - // `type` last, in the middle, and keys a newer version added (ignored, as before). - let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#; - assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::Group { children: vec![], clip: true }); - let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#; - assert_eq!( - serde_json::from_str::(kind).unwrap(), - NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) } - ); - let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#; - assert!(matches!(serde_json::from_str::(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3)); - // Defaults as before. - let NodeKind::Layer { printable, preview, template, .. } = - serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap() - else { - panic!("a layer") - }; - assert!(printable && preview && !template); - } - - #[test] - fn bad_kinds_are_errors() { - for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] { - assert!(serde_json::from_str::(bad).is_err(), "{bad}"); - } - } -} +mod tests; diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs new file mode 100644 index 000000000..32ff35e6a --- /dev/null +++ b/crates/doc/src/node/tests.rs @@ -0,0 +1,440 @@ +use super::*; + +/// A node writes its rarely set parts among the others, in the file's field order, and reads +/// them back into its extras; a node without any keeps none. +#[test] +fn extras_are_written_flat_in_order() { + let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); + n.name = Some("n".into()); + n.knockout_shape = true; + n.bbox_angle = 30.0; + { + let mut e = n.extras_mut(); + e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true))); + e.trace = Some(Box::new(serde_json::json!({"preset": "p"}))); + e.series_index = Some(2); + e.type_outlines = true; + e.graphic_style = Some(3); + e.attrs = Some(Box::default()); + e.slice = Some(Box::default()); + e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0))); + e.live_paint_gaps = Some(Box::default()); + } + /// The top-level keys of a JSON object, in the order written. + struct Keys(Vec); + impl<'de> Deserialize<'de> for Keys { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Keys; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an object") + } + fn visit_map>(self, mut a: A) -> Result { + let mut out = vec![]; + while let Some(k) = a.next_key::()? { + a.next_value::()?; + out.push(k); + } + Ok(Keys(out)) + } + } + d.deserialize_map(V) + } + } + let text = serde_json::to_string(&n).unwrap(); + let keys = serde_json::from_str::(&text).unwrap().0; + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + let order = [ + "id", + "name", + "knockout_shape", + "appearance", + "mask", + "trace", + "series_index", + "type_outlines", + "kind", + "graphic_style", + "attrs", + "slice", + "bbox_angle", + "perspective", + "live_paint_gaps", + ]; + assert_eq!(keys, order); + assert_eq!(serde_json::from_value::(v).unwrap(), n); + let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); + let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); + assert!(back.extras_box().is_none()); + // Clearing the last extra lets them go; clearing one a node doesn't have allocates none. + n.extras_mut().mask = None; + assert!(n.extras_box().is_some()); + *n.extras_mut() = NodeExtras::default(); + assert!(n.extras_box().is_none()); + n.extras_mut().mask = None; + assert!(n.extras_box().is_none()); + assert_eq!(n.extras(), &NodeExtras::default()); +} + +#[test] +fn rect_live_shape_roundtrip() { + let l = LiveShape::Rectangle { + w: 10.0, + h: 20.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((5.0, 5.0)), + }; + assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); + assert_eq!(l.label(), "Rectangle"); +} + +#[test] +fn corner_kinds_save_only_when_some_corner_isnt_round() { + let mut l = LiveShape::Rectangle { + w: 100.0, + h: 50.0, + radii: [0.0, 10.0, 0.0, 0.0], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; + // Older files read as round. + let json = serde_json::to_value(&l).unwrap(); + assert!(json.get("kinds").is_none(), "{json}"); + assert_eq!(serde_json::from_value::(json).unwrap(), l); + if let LiveShape::Rectangle { kinds, .. } = &mut l { + kinds[1] = CornerKind::Chamfer; + } + let json = serde_json::to_value(&l).unwrap(); + assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"])); + assert_eq!(serde_json::from_value::(json).unwrap(), l); + // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. + let p: LiveShape = + serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); + assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); + let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); + let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; + let json = serde_json::to_value(&path).unwrap(); + assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None)); + assert_eq!(serde_json::from_value::(json).unwrap(), path); + assert_eq!(path.label(), "Path"); +} + +/// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one +/// with four billion made its outline and corner lists allocate tens of gigabytes. +#[test] +fn a_polygon_from_a_file_has_3_to_1000_sides() { + let read = |sides: u32| { + let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]}); + serde_json::from_value::(json).unwrap() + }; + let sides_of = |l: &LiveShape| match l { + LiveShape::Polygon { sides, .. } => *sides, + _ => panic!("not a polygon: {l:?}"), + }; + let huge = read(4_000_000_000); + assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]); + assert_eq!(sides_of(&huge), 1000); + assert_eq!(huge.polygon_outline().anchor_count(), 1000); +} + +#[test] +fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() { + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + // A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page. + let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0); + let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false }; + assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal()); + p.set_polygon_angle(45.0); + assert!(close(p.polygon_angle().unwrap(), 45.0)); + let centre = p.to_path().bounds().unwrap().center(); + assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}"); + p.set_polygon_radius(30.0); + assert!(close(p.polygon_radius().unwrap(), 30.0)); + // A hexagon's side is as long as its radius. + assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0)); + // Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius. + p.transform(Affine::scale_non_uniform(2.0, 1.0)); + assert!(!p.polygon_sides_equal()); + let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap()); + p.make_sides_equal(); + assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a)); + let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") }; + assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0)); + // Other shapes have none of it. + let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; + assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None)); +} + +/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path. +fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node { + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; + let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(live)); + } + n +} + +fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { + let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; + (*w, *h, *radii, *xf) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +fn near_xf(a: Affine, b: Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) +} + +/// Size and radius of a live rectangle, within rounding. +fn assert_live(n: &Node, w: f64, h: f64, r: f64) { + let (lw, lh, radii, _) = live_of(n); + assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}"); +} + +/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation), +/// spans `r` along x and y: the corner is a circular arc of radius `r`. +fn assert_round_corners(n: &Node, back: Affine, r: f64) { + let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") }; + let a: Vec = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect(); + assert_eq!(a.len(), 8, "a rounded rectangle"); + // Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs. + for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] { + let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs()); + assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}"); + } +} + +const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; + +#[test] +fn uneven_scales_keep_live_corners_circular() { + // A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291). + let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0))); + let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0)); + n.transform(stretch, KEEP_CORNERS); + assert_live(&n, 300.0, 100.0, 20.0); + assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size"); + let b = n.geometric_bounds().unwrap(); + assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}"); + assert_round_corners(&n, Affine::IDENTITY, 20.0); + // Scale Corners on: the radius scales by the mean scale, still circular. + let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); + n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default()); + assert_live(&n, 400.0, 100.0, 40.0); + assert_round_corners(&n, Affine::IDENTITY, 40.0); + // Squashed below the radius, the short sides are fully round; stretched back, it returns. + n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS); + assert_round_corners(&n, Affine::IDENTITY, 25.0); + n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS); + assert_round_corners(&n, Affine::IDENTITY, 40.0); +} + +#[test] +fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() { + let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians()); + let mut n = live_rect(100.0, 50.0, 10.0, place); + // Stretched along its own width, as a bounding-box drag of a rotated shape does. + n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS); + assert_live(&n, 200.0, 50.0, 10.0); + assert!(near_xf(live_of(&n).3, place)); + assert_round_corners(&n, place.inverse(), 10.0); + // A reflection stays in `xf`; the corners stay as they were. + n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS); + assert_live(&n, 200.0, 50.0, 10.0); + let xf = live_of(&n).3; + assert!(near(xf.determinant(), -1.0)); + assert_round_corners(&n, xf.inverse(), 10.0); +} + +#[test] +fn shears_and_moves_keep_the_old_transform() { + // Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before. + let rot = Affine::rotate(30f64.to_radians()); + let mut n = live_rect(100.0, 50.0, 10.0, rot); + let before = n.clone(); + let a = Affine::scale_non_uniform(2.0, 1.0); + n.transform(a, Scaling::default()); + assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot)); + let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() }; + assert_eq!(*path, old.transformed(a)); + // A file saved with an uneven scale in `xf` keeps its exact geometry when moved... + let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0); + let mut n = live_rect(100.0, 100.0, 20.0, legacy); + n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS); + assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy)); + // ...and once scaled, has document units and circular corners of its mean radius. + n.transform(Affine::scale(2.0), Scaling::default()); + assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0); + assert!(near(live_of(&n).3.determinant(), 1.0)); + assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0); +} + +/// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live +/// Corners see and edit it in document units, with circular corners (#442). +#[test] +fn a_scale_left_in_the_transform_folds_into_the_size() { + let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0); + let n = live_rect(50.0, 80.0, 20.0, legacy); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") }; + let folded = live.folded(); + assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone"); + let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") }; + // Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius. + assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}"); + assert!(near_xf(*xf, Affine::translate((5.0, 5.0)))); + let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap()); + assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}"); + let corners = crate::LiveCorners::of(&n).unwrap(); + assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units"); + // Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are. + let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0); + let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]); + for xf in [place, shear] { + let mut l = live_rect(100.0, 160.0, 20.0, xf); + let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") }; + assert!(!live.fold_scale()); + assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf)); + } +} + +#[test] +fn default_transparency() { + let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert!(p().has_default_transparency()); + let tweaks: [fn(&mut Node); 7] = [ + |n| n.opacity = 0.5, + |n| n.blend = BlendMode::Multiply, + |n| n.isolate = true, + |n| n.knockout = Knockout::On, + |n| n.knockout = Knockout::Off, + |n| n.knockout_shape = true, + |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), + ]; + for tweak in tweaks { + let mut n = p(); + tweak(&mut n); + assert!(!n.has_default_transparency(), "{n:?}"); + } +} + +#[test] +fn transform_group_recurses() { + let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(NodeId(1), vec![Arc::new(p)]); + g.transform(Affine::scale(2.0), true); + assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0))); + let child = &g.children().unwrap()[0]; + assert_eq!(child.appearance.stroke_width(), 2.0); +} + +#[test] +fn unplaced_gradients_pin_only_when_a_refit_would_differ() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; + let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0)); + let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) { + (Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom), + _ => panic!("gradient fill and stroke"), + }; + let mut n = fresh(); + n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true); + assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting"); + let mut n = fresh(); + n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); + let (f, s) = geoms(&n); + // Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical. + let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); + want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + let f = f.unwrap(); + assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}"); + assert!((s.unwrap().length() - 102.0).abs() < 1e-9); +} + +#[test] +fn display_names() { + let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert_eq!(p.display_name(), ""); + let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0)); + assert_eq!(l.display_name(), "Layer 1"); +} + +#[test] +fn visual_bounds_include_stroke() { + let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round; + assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); +} + +/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the +/// weight centred, the whole weight outside, nothing inside; scaled with the type. +#[test] +fn type_visual_bounds_grow_with_the_character_stroke() { + use crate::appearance::StrokeAlign; + use crate::text::{CharStyle, TextObject}; + let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style); + t.xf = Affine::scale(2.0) * t.xf; + let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); + let g = node(&t).geometric_bounds().unwrap(); + for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] { + t.runs[0].style.stroke_align = align; + assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}"); + } + t.runs[0].style.stroke = vectorcraft_color::Paint::None; + assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth"); +} + +/// A group holding a path, a compound, an image and a nested group, as a file writes it. +fn nested_group() -> Node { + let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let image = + ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); + let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); + let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; + Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false }) +} + +#[test] +fn kinds_read_back_with_type_first_or_anywhere() { + let n = nested_group(); + let json = serde_json::to_string(&n).unwrap(); + assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), n); + // Through a JSON value, whose keys are sorted: `type` comes after `children`. + let v = serde_json::to_value(&n).unwrap(); + assert_eq!(serde_json::from_value::(v.clone()).unwrap(), n); + assert_eq!(serde_json::from_str::(&v.to_string()).unwrap(), n); + // `type` last, in the middle, and keys a newer version added (ignored, as before). + let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#; + assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::Group { children: vec![], clip: true }); + let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#; + assert_eq!( + serde_json::from_str::(kind).unwrap(), + NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) } + ); + let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#; + assert!(matches!(serde_json::from_str::(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3)); + // Defaults as before. + let NodeKind::Layer { printable, preview, template, .. } = + serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap() + else { + panic!("a layer") + }; + assert!(printable && preview && !template); +} + +#[test] +fn bad_kinds_are_errors() { + for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] { + assert!(serde_json::from_str::(bad).is_err(), "{bad}"); + } +} From 47abe15bf58bf26b54d3276a7b2da5bd879fef16 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:09 +0000 Subject: [PATCH 060/199] refactor(ui-egui): move placing.rs tests to placing/tests.rs --- crates/ui-egui/src/placing.rs | 426 +--------------------------- crates/ui-egui/src/placing/tests.rs | 423 +++++++++++++++++++++++++++ 2 files changed, 424 insertions(+), 425 deletions(-) create mode 100644 crates/ui-egui/src/placing/tests.rs diff --git a/crates/ui-egui/src/placing.rs b/crates/ui-egui/src/placing.rs index 7b309a6db..0c94c2735 100644 --- a/crates/ui-egui/src/placing.rs +++ b/crates/ui-egui/src/placing.rs @@ -210,428 +210,4 @@ fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result) { } #[cfg(test)] -mod tests { - use image::ImageFormat::{Bmp, Png, Tiff}; - use vectorcraft_doc::{Document, NodeId}; - use vectorcraft_engine::Session; - - use super::*; - use crate::Services; - use crate::place::{DropAt, DropTarget}; - - /// An app reading files from disk, with a 400 × 300 pt document, that reads the files the user - /// places off the UI thread when `background`. - fn app(background: bool) -> VectorcraftApp { - let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; - let mut app = VectorcraftApp::new(Session::new(), services); - app.placing.off_the_ui_thread = background; - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app - } - - /// `bytes` written to a fresh file named `name` in a folder of `test`'s own → its path. - fn saved(test: &str, name: &str, bytes: &[u8]) -> String { - let dir = std::env::temp_dir().join(format!("vc-placing-{test}-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(name); - std::fs::write(&path, bytes).unwrap(); - path.to_string_lossy().into_owned() - } - - /// A `w` × `h` picture of one color in `format`. - fn picture(format: image::ImageFormat, w: u32, h: u32) -> Vec { - let mut out = vec![]; - image::RgbImage::from_pixel(w, h, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); - out - } - - /// A flat `w` × `h` Photoshop document of one color (raw RGB, no layers). - fn psd(w: u32, h: u32) -> Vec { - let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec(); - f.extend(h.to_be_bytes()); - f.extend(w.to_be_bytes()); - f.extend([0, 8, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); - for v in [40u8, 40, 200] { - f.extend(std::iter::repeat_n(v, (w * h) as usize)); - } - f - } - - /// A one-page PDF with a rectangle on it. - fn pdf() -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); - s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); - let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); - vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() - } - - /// Poll each frame until every file read for Place has been placed (at most 60 s). - fn placed(app: &mut VectorcraftApp) { - let t0 = std::time::Instant::now(); - while !app.placing.pending.is_empty() && t0.elapsed().as_secs() < 60 { - poll(app); - std::thread::yield_now(); - } - assert!(app.placing.pending.is_empty(), "read within 60 s"); - } - - /// What a place changes: the active document (without the second it was made in), its - /// selection, its undo steps and the last journal entry. - fn state(app: &VectorcraftApp) -> (Document, Vec, usize, Option<(String, Value)>) { - let st = app.session.doc().unwrap(); - let mut doc = (*st.doc).clone(); - doc.metadata.created = None; - (doc, st.selection.objects.clone(), st.history.undo.len(), app.session.journal.last().cloned()) - } - - /// The names of the active document's top-level objects, bottom first. - fn names(app: &VectorcraftApp) -> Vec { - let st = app.session.doc().unwrap(); - st.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect() - } - - /// A TIFF, a Photoshop document, a BMP, a PDF page and a PDF-compatible `.ai` page (both placed - /// linked) read off the UI thread are placed as when they are read at once: the same object - /// with its bounds and link, the same selection, one undo step and the same journal entry. Until - /// then the document is unchanged, and the status bar and the busy cursor show the file is being - /// placed. - #[test] - fn a_file_read_off_the_ui_thread_places_as_one_read_at_once() { - let page = pdf(); - let files = [ - ("photo.tif", picture(Tiff, 300, 200)), - ("photo.psd", psd(300, 200)), - ("photo.bmp", picture(Bmp, 300, 200)), - ("page.pdf", page.clone()), - ("page.ai", page), - ]; - for (name, bytes) in files { - let p = json!({"path": saved("same", name, &bytes), "at": [200, 150]}); - let mut at_once = app(false); - at_once.run("file.place", p.clone()).unwrap(); - let mut app = app(true); - let before = state(&app); - let r = start(&mut app, &p, false).unwrap(); - assert_eq!(r, json!({"background": true, "status": format!("Placing {name}…")})); - assert_eq!(app.ui.status, format!("Placing {name}…")); - assert!(crate::opening::long_operation_busy(&app), "{name}"); - assert!(state(&app) == before, "{name}: nothing is placed until the file is read"); - placed(&mut app); - assert!(state(&app) == state(&at_once), "{name}: placed as at once"); - assert!(app.ui.status.is_empty(), "{name}: {}", app.ui.status); - assert!(!crate::opening::long_operation_busy(&app), "{name}"); - } - } - - /// The Place dialog's OK with one file, with several (the place cursor and its thumbnails), a - /// drop on the canvas and [`crate::io::place_bytes`] read their files off the UI thread; - /// `file.place` and `file.place.queue` from MCP and scripts read them at once, with the replies - /// they have always had. - #[test] - fn what_the_user_places_is_read_off_the_ui_thread_and_commands_read_at_once() { - let mut app = app(true); - let (a, b) = (saved("routes", "a.tif", &picture(Tiff, 60, 40)), saved("routes", "b.bmp", &picture(Bmp, 60, 40))); - let r = app.run("file.place", json!({"path": a})).unwrap(); - assert!(r["ids"].is_array() && app.placing.pending.is_empty(), "{r}"); - let r = app.run("file.place.queue", json!({"paths": [b]})).unwrap(); - assert!(r["count"] == 1 && app.placing.pending.is_empty(), "{r}"); - assert_eq!(app.place.thumbnails(), ["b.bmp"]); - app.select_tool("selection"); - // The Place dialog's OK with one file. - app.run("file.place", json!({"paths": [a]})).unwrap(); - assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true); - assert_eq!(app.placing.pending.len(), 1); - placed(&mut app); - assert_eq!(names(&app), ["a.tif", "a.tif"]); - // With several: the place cursor is loaded once they are read. - app.run("file.place", json!({"paths": [a, b]})).unwrap(); - assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true); - assert_eq!(app.ui.status, "Placing a.tif, b.bmp…"); - assert_eq!(app.session.tool_id(), "selection"); - let (inspect, _) = crate::control::ControlRequest::new("ui.inspect", json!({})); - let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &inspect) else { panic!("not done") }; - assert_eq!(r["result"]["placing"], json!(["a.tif, b.bmp"]), "ui.inspect lists the files being read"); - placed(&mut app); - assert_eq!((app.session.tool_id(), app.session.tool_options()["count"].as_u64()), ("place", Some(2))); - assert_eq!(app.place.thumbnails(), ["a.tif", "b.bmp"]); - app.select_tool("selection"); - // A drop and bytes placed without a path. - let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); - crate::place::drop_files(&mut app, vec![(drop, ("b.bmp".into(), Some(b.clone()), vec![]))]); - crate::io::place_bytes(&mut app, "c.png", &picture(Png, 10, 10)).unwrap(); - assert_eq!(app.placing.pending.len(), 2); - placed(&mut app); - assert_eq!(names(&app), ["a.tif", "a.tif", "b.bmp", "c.png"]); - } - - /// A click and a drag of the place cursor read their files off the UI thread, and place them as - /// a click and a drag place them when they are read at once. - #[test] - fn the_place_cursors_clicks_read_their_files_off_the_ui_thread() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let paths = [saved("cursor", "a.tif", &picture(Tiff, 60, 40)), saved("cursor", "b.bmp", &picture(Bmp, 60, 40))]; - let view = vectorcraft_engine::ViewInfo::default(); - let press = |app: &mut VectorcraftApp, from: (f64, f64), to: (f64, f64)| { - for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { - crate::canvas::dispatch(app, &PointerEvent::new(kind, x, y), view); - } - }; - let (mut at_once, mut app) = (app(false), app(true)); - for app in [&mut at_once, &mut app] { - app.run("file.place.queue", json!({"paths": paths})).unwrap(); - press(app, (10.0, 10.0), (10.0, 10.0)); - } - assert_eq!(names(&at_once), ["a.tif"]); - assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "nothing is placed until the file is read"); - assert_eq!(app.ui.status, "Placing a.tif…"); - placed(&mut app); - assert!(state(&app) == state(&at_once), "the click places as at once"); - // A drag places the last file at the dragged size, and the previous tool comes back. - for app in [&mut at_once, &mut app] { - press(app, (100.0, 100.0), (180.0, 110.0)); - } - placed(&mut app); - assert!(state(&app) == state(&at_once), "the drag places as at once"); - assert_eq!(names(&app), ["a.tif", "b.bmp"]); - assert_eq!(app.session.tool_id(), at_once.session.tool_id()); - } - - /// The Place dialog's line about each file is read off the UI thread: the lines are empty when - /// the dialog opens, and then show what the lines read at once show. - #[test] - fn the_place_dialogs_lines_are_read_off_the_ui_thread() { - let p = json!({"paths": [saved("lines", "a.tif", &picture(Tiff, 60, 40)), saved("lines", "notes.xyz", b"hello")]}); - let mut at_once = app(false); - at_once.run("file.place", p.clone()).unwrap(); - let want = at_once.ui.dialog.as_ref().unwrap().fields["__info"].clone(); - assert!(want[0].as_str().is_some_and(|l| l.starts_with("60 × 40 px")), "{want}"); - let mut app = app(true); - app.run("file.place", p).unwrap(); - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], json!(["", ""])); - let t0 = std::time::Instant::now(); - loop { - let Some(mut d) = app.ui.dialog.take() else { panic!("the dialog stays open") }; - let reading = crate::place::fill_lines(&mut app, &mut d); - app.ui.dialog = Some(d); - if !reading || t0.elapsed().as_secs() > 60 { - break; - } - std::thread::yield_now(); - } - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], want); - } - - /// Several files dropped together are placed in the order they were dropped, a large one first - /// included, as when they are read at once. - #[test] - fn files_dropped_together_are_placed_in_the_order_dropped() { - let large = saved("order", "large.tif", &picture(Tiff, 1500, 1500)); - let small = [saved("order", "small1.png", &picture(Png, 8, 8)), saved("order", "small2.png", &picture(Png, 8, 8))]; - let mut app = app(true); - let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); - let files = [&large, &small[0], &small[1]].map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))); - crate::place::drop_files(&mut app, files.into()); - assert_eq!(app.placing.pending.len(), 3); - placed(&mut app); - assert_eq!(names(&app), ["large.tif", "small1.png", "small2.png"]); - } - - /// Files dropped together are read one at a time, as when they are read at once, so a batch of - /// large pictures is not decoded all at once. - #[test] - fn files_dropped_together_are_read_one_at_a_time() { - let files: Vec = (0..4).map(|i| saved("one-at-a-time", &format!("large{i}.tif"), &picture(Tiff, 1500, 1500))).collect(); - let mut app = app(true); - let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); - let dropped = files.iter().map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))).collect(); - crate::place::drop_files(&mut app, dropped); - placed(&mut app); - assert_eq!(names(&app).len(), 4); - assert_eq!(app.placing.reading[1].load(std::sync::atomic::Ordering::SeqCst), 1, "the most reads at once"); - } - - /// A file that can't be read shows the message it shows when read at once, dropped or placed - /// from the dialog, and nothing is placed. - #[test] - fn a_file_that_cant_be_read_off_the_ui_thread_reports_as_at_once() { - let gone = saved("unread", "gone.tif", b""); - std::fs::remove_file(&gone).unwrap(); - let unknown = saved("unread", "notes.xyz", b"hello"); - for path in [gone, unknown] { - let p = json!({"path": path}); - let mut at_once = app(false); - crate::place::run_for_user(&mut at_once, &p).unwrap_err(); - let mut app = app(true); - let before = state(&app); - crate::place::run_for_user(&mut app, &p).unwrap(); - placed(&mut app); - assert_eq!(app.ui.status, at_once.ui.status); - assert!(state(&app) == before, "{path}: nothing placed"); - // Dropped on the canvas. - let name = vectorcraft_engine::cmd::fileio::file_name(&path); - for app in [&mut at_once, &mut app] { - let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); - crate::place::drop_files(app, vec![(drop, (name.clone(), Some(path.clone()), vec![]))]); - placed(app); - } - assert_eq!(app.ui.status, at_once.ui.status); - assert!(app.ui.status.starts_with(&format!("Couldn't place {name}: ")), "{}", app.ui.status); - } - } - - /// A file whose document closed before it was read is not placed, and the status bar says why. - #[test] - fn a_file_whose_document_closed_meanwhile_is_not_placed() { - let path = saved("closed", "photo.tif", &picture(Tiff, 60, 40)); - for dropped in [false, true] { - let mut app = app(true); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - start(&mut app, &json!({"path": path}), dropped).unwrap(); - app.session.execute("file.close", &json!({})).unwrap(); - let left = state(&app); - placed(&mut app); - let why = if dropped { "dropped on" } else { "placed in" }; - assert_eq!(app.ui.status, format!("Couldn't place photo.tif: the document it was {why} was closed")); - assert!(state(&app) == left, "the other document is unchanged"); - } - } - - /// Replace swaps the object selected when Place started; when that object was deleted before the - /// file was read, nothing is placed and the status bar says why. - #[test] - fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() { - let path = saved("replace", "photo.tif", &picture(Tiff, 60, 40)); - let mut app = app(true); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); - crate::place::run_for_user(&mut app, &json!({"path": path, "replace": true})).unwrap(); - app.run("edit.clear", json!({})).unwrap(); - let cleared = state(&app); - placed(&mut app); - assert!(app.ui.status.contains("the object to replace was changed or deleted"), "{}", app.ui.status); - assert!(state(&app) == cleared, "nothing placed"); - } - - /// A file read while a dialog is open waits for the dialog to close, as a document that opens - /// does (#1694): the dialog's OK acts on the active document. - #[test] - fn a_file_read_while_a_dialog_is_open_waits_for_it_to_close() { - let path = saved("dialog", "photo.tif", &picture(Tiff, 60, 40)); - let mut app = app(true); - start(&mut app, &json!({"path": path}), false).unwrap(); - app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"}))); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "the file waits for the dialog"); - app.ui.dialog = None; - placed(&mut app); - assert_eq!(names(&app), ["photo.tif"]); - } - - /// A file read while an object is being dragged, in its document or in another one, waits for - /// the drag to end: the drag is not canceled, and the placed art stays. - #[test] - fn a_file_read_during_a_drag_waits_for_it_to_end() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let path = saved("drag", "photo.tif", &picture(Tiff, 60, 40)); - for other_document in [false, true] { - let mut app = app(true); - start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); - if other_document { - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - } - let square = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - let dragged = app.session.active_index(); - app.select_tool("selection"); - let view = vectorcraft_engine::ViewInfo::default(); - for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 40.0), (PointerKind::Drag, 60.0)] { - app.session.pointer(&PointerEvent::new(kind, x, 20.0), view).unwrap(); - } - assert!(app.session.in_interaction()); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(app.placing.pending.len() == 1 && app.session.in_interaction(), "other document: {other_document}"); - // The drag moves on before it ends. - for kind in [PointerKind::Drag, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, 60.0, 20.0), view).unwrap(); - } - placed(&mut app); - let moved = app.session.documents()[dragged.unwrap()].doc.node(NodeId(square)).and_then(|n| n.geometric_bounds()); - assert_eq!(moved.map(|b| (b.x0, b.y0)), Some((50.0, 10.0)), "other document: {other_document}"); - assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}"); - } - } - - /// A file read for one document while the place cursor is dragged in another waits for the drag - /// to end, which the place cursor keeps to itself: the drag still places its file. - #[test] - fn a_file_read_during_a_place_cursor_drag_waits_for_it_to_end() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let path = saved("cursor-drag", "photo.tif", &picture(Tiff, 60, 40)); - let cursor = [saved("cursor-drag", "a.png", &picture(Png, 8, 8)), saved("cursor-drag", "b.png", &picture(Png, 8, 8))]; - let mut app = app(true); - start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("file.place.queue", json!({"paths": cursor})).unwrap(); - let view = vectorcraft_engine::ViewInfo::default(); - for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 60.0)] { - crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, 20.0), view); - } - assert!(app.session.tool_busy() && !app.session.in_interaction()); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(app.placing.pending.len() == 1 && app.session.tool_busy(), "the file waits for the drag"); - crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Up, 60.0, 20.0), view); - placed(&mut app); - let placed_in = |i: usize| -> Vec<(String, Option)> { - let st = &app.session.documents()[i]; - st.doc - .layers - .iter() - .flat_map(|l| l.children().into_iter().flatten()) - .map(|n| (n.name.clone().unwrap_or_default(), n.geometric_bounds())) - .collect() - }; - assert_eq!(placed_in(0).iter().map(|(n, _)| n.as_str()).collect::>(), ["photo.tif"]); - assert_eq!(placed_in(1), [("a.png".to_string(), Some(vectorcraft_geom::Rect::new(20.0, 20.0, 60.0, 60.0)))], "the drag placed its file"); - } - - /// A file read while a tool waits between its clicks (the Dimension tool after its first point), - /// in the file's document or in another one, is placed without waiting for the next click. In - /// the same document the tool keeps its point; in another one it starts afresh, as when the user - /// switches documents. - #[test] - fn a_file_read_between_a_tools_clicks_is_placed() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let path = saved("clicks", "photo.tif", &picture(Tiff, 60, 40)); - for other_document in [false, true] { - let mut app = app(true); - start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); - if other_document { - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - } - app.select_tool("dimension"); - let view = vectorcraft_engine::ViewInfo::default(); - for kind in [PointerKind::Down, PointerKind::Up] { - crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 20.0, 20.0), view); - } - assert!(app.session.tool_busy() && !app.session.in_interaction(), "other document: {other_document}"); - placed(&mut app); - assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}"); - assert_eq!(app.session.tool_busy(), !other_document, "other document: {other_document}"); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs new file mode 100644 index 000000000..0627b37bc --- /dev/null +++ b/crates/ui-egui/src/placing/tests.rs @@ -0,0 +1,423 @@ +use image::ImageFormat::{Bmp, Png, Tiff}; +use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_engine::Session; + +use super::*; +use crate::Services; +use crate::place::{DropAt, DropTarget}; + +/// An app reading files from disk, with a 400 × 300 pt document, that reads the files the user +/// places off the UI thread when `background`. +fn app(background: bool) -> VectorcraftApp { + let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.placing.off_the_ui_thread = background; + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app +} + +/// `bytes` written to a fresh file named `name` in a folder of `test`'s own → its path. +fn saved(test: &str, name: &str, bytes: &[u8]) -> String { + let dir = std::env::temp_dir().join(format!("vc-placing-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() +} + +/// A `w` × `h` picture of one color in `format`. +fn picture(format: image::ImageFormat, w: u32, h: u32) -> Vec { + let mut out = vec![]; + image::RgbImage::from_pixel(w, h, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out +} + +/// A flat `w` × `h` Photoshop document of one color (raw RGB, no layers). +fn psd(w: u32, h: u32) -> Vec { + let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec(); + f.extend(h.to_be_bytes()); + f.extend(w.to_be_bytes()); + f.extend([0, 8, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + for v in [40u8, 40, 200] { + f.extend(std::iter::repeat_n(v, (w * h) as usize)); + } + f +} + +/// A one-page PDF with a rectangle on it. +fn pdf() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); + let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +/// Poll each frame until every file read for Place has been placed (at most 60 s). +fn placed(app: &mut VectorcraftApp) { + let t0 = std::time::Instant::now(); + while !app.placing.pending.is_empty() && t0.elapsed().as_secs() < 60 { + poll(app); + std::thread::yield_now(); + } + assert!(app.placing.pending.is_empty(), "read within 60 s"); +} + +/// What a place changes: the active document (without the second it was made in), its +/// selection, its undo steps and the last journal entry. +fn state(app: &VectorcraftApp) -> (Document, Vec, usize, Option<(String, Value)>) { + let st = app.session.doc().unwrap(); + let mut doc = (*st.doc).clone(); + doc.metadata.created = None; + (doc, st.selection.objects.clone(), st.history.undo.len(), app.session.journal.last().cloned()) +} + +/// The names of the active document's top-level objects, bottom first. +fn names(app: &VectorcraftApp) -> Vec { + let st = app.session.doc().unwrap(); + st.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect() +} + +/// A TIFF, a Photoshop document, a BMP, a PDF page and a PDF-compatible `.ai` page (both placed +/// linked) read off the UI thread are placed as when they are read at once: the same object +/// with its bounds and link, the same selection, one undo step and the same journal entry. Until +/// then the document is unchanged, and the status bar and the busy cursor show the file is being +/// placed. +#[test] +fn a_file_read_off_the_ui_thread_places_as_one_read_at_once() { + let page = pdf(); + let files = [ + ("photo.tif", picture(Tiff, 300, 200)), + ("photo.psd", psd(300, 200)), + ("photo.bmp", picture(Bmp, 300, 200)), + ("page.pdf", page.clone()), + ("page.ai", page), + ]; + for (name, bytes) in files { + let p = json!({"path": saved("same", name, &bytes), "at": [200, 150]}); + let mut at_once = app(false); + at_once.run("file.place", p.clone()).unwrap(); + let mut app = app(true); + let before = state(&app); + let r = start(&mut app, &p, false).unwrap(); + assert_eq!(r, json!({"background": true, "status": format!("Placing {name}…")})); + assert_eq!(app.ui.status, format!("Placing {name}…")); + assert!(crate::opening::long_operation_busy(&app), "{name}"); + assert!(state(&app) == before, "{name}: nothing is placed until the file is read"); + placed(&mut app); + assert!(state(&app) == state(&at_once), "{name}: placed as at once"); + assert!(app.ui.status.is_empty(), "{name}: {}", app.ui.status); + assert!(!crate::opening::long_operation_busy(&app), "{name}"); + } +} + +/// The Place dialog's OK with one file, with several (the place cursor and its thumbnails), a +/// drop on the canvas and [`crate::io::place_bytes`] read their files off the UI thread; +/// `file.place` and `file.place.queue` from MCP and scripts read them at once, with the replies +/// they have always had. +#[test] +fn what_the_user_places_is_read_off_the_ui_thread_and_commands_read_at_once() { + let mut app = app(true); + let (a, b) = (saved("routes", "a.tif", &picture(Tiff, 60, 40)), saved("routes", "b.bmp", &picture(Bmp, 60, 40))); + let r = app.run("file.place", json!({"path": a})).unwrap(); + assert!(r["ids"].is_array() && app.placing.pending.is_empty(), "{r}"); + let r = app.run("file.place.queue", json!({"paths": [b]})).unwrap(); + assert!(r["count"] == 1 && app.placing.pending.is_empty(), "{r}"); + assert_eq!(app.place.thumbnails(), ["b.bmp"]); + app.select_tool("selection"); + // The Place dialog's OK with one file. + app.run("file.place", json!({"paths": [a]})).unwrap(); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true); + assert_eq!(app.placing.pending.len(), 1); + placed(&mut app); + assert_eq!(names(&app), ["a.tif", "a.tif"]); + // With several: the place cursor is loaded once they are read. + app.run("file.place", json!({"paths": [a, b]})).unwrap(); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true); + assert_eq!(app.ui.status, "Placing a.tif, b.bmp…"); + assert_eq!(app.session.tool_id(), "selection"); + let (inspect, _) = crate::control::ControlRequest::new("ui.inspect", json!({})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &inspect) else { panic!("not done") }; + assert_eq!(r["result"]["placing"], json!(["a.tif, b.bmp"]), "ui.inspect lists the files being read"); + placed(&mut app); + assert_eq!((app.session.tool_id(), app.session.tool_options()["count"].as_u64()), ("place", Some(2))); + assert_eq!(app.place.thumbnails(), ["a.tif", "b.bmp"]); + app.select_tool("selection"); + // A drop and bytes placed without a path. + let doc = app.session.doc().unwrap().uid; + let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); + crate::place::drop_files(&mut app, vec![(drop, ("b.bmp".into(), Some(b.clone()), vec![]))]); + crate::io::place_bytes(&mut app, "c.png", &picture(Png, 10, 10)).unwrap(); + assert_eq!(app.placing.pending.len(), 2); + placed(&mut app); + assert_eq!(names(&app), ["a.tif", "a.tif", "b.bmp", "c.png"]); +} + +/// A click and a drag of the place cursor read their files off the UI thread, and place them as +/// a click and a drag place them when they are read at once. +#[test] +fn the_place_cursors_clicks_read_their_files_off_the_ui_thread() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let paths = [saved("cursor", "a.tif", &picture(Tiff, 60, 40)), saved("cursor", "b.bmp", &picture(Bmp, 60, 40))]; + let view = vectorcraft_engine::ViewInfo::default(); + let press = |app: &mut VectorcraftApp, from: (f64, f64), to: (f64, f64)| { + for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + crate::canvas::dispatch(app, &PointerEvent::new(kind, x, y), view); + } + }; + let (mut at_once, mut app) = (app(false), app(true)); + for app in [&mut at_once, &mut app] { + app.run("file.place.queue", json!({"paths": paths})).unwrap(); + press(app, (10.0, 10.0), (10.0, 10.0)); + } + assert_eq!(names(&at_once), ["a.tif"]); + assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "nothing is placed until the file is read"); + assert_eq!(app.ui.status, "Placing a.tif…"); + placed(&mut app); + assert!(state(&app) == state(&at_once), "the click places as at once"); + // A drag places the last file at the dragged size, and the previous tool comes back. + for app in [&mut at_once, &mut app] { + press(app, (100.0, 100.0), (180.0, 110.0)); + } + placed(&mut app); + assert!(state(&app) == state(&at_once), "the drag places as at once"); + assert_eq!(names(&app), ["a.tif", "b.bmp"]); + assert_eq!(app.session.tool_id(), at_once.session.tool_id()); +} + +/// The Place dialog's line about each file is read off the UI thread: the lines are empty when +/// the dialog opens, and then show what the lines read at once show. +#[test] +fn the_place_dialogs_lines_are_read_off_the_ui_thread() { + let p = json!({"paths": [saved("lines", "a.tif", &picture(Tiff, 60, 40)), saved("lines", "notes.xyz", b"hello")]}); + let mut at_once = app(false); + at_once.run("file.place", p.clone()).unwrap(); + let want = at_once.ui.dialog.as_ref().unwrap().fields["__info"].clone(); + assert!(want[0].as_str().is_some_and(|l| l.starts_with("60 × 40 px")), "{want}"); + let mut app = app(true); + app.run("file.place", p).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], json!(["", ""])); + let t0 = std::time::Instant::now(); + loop { + let Some(mut d) = app.ui.dialog.take() else { panic!("the dialog stays open") }; + let reading = crate::place::fill_lines(&mut app, &mut d); + app.ui.dialog = Some(d); + if !reading || t0.elapsed().as_secs() > 60 { + break; + } + std::thread::yield_now(); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], want); +} + +/// Several files dropped together are placed in the order they were dropped, a large one first +/// included, as when they are read at once. +#[test] +fn files_dropped_together_are_placed_in_the_order_dropped() { + let large = saved("order", "large.tif", &picture(Tiff, 1500, 1500)); + let small = [saved("order", "small1.png", &picture(Png, 8, 8)), saved("order", "small2.png", &picture(Png, 8, 8))]; + let mut app = app(true); + let doc = app.session.doc().unwrap().uid; + let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); + let files = [&large, &small[0], &small[1]].map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))); + crate::place::drop_files(&mut app, files.into()); + assert_eq!(app.placing.pending.len(), 3); + placed(&mut app); + assert_eq!(names(&app), ["large.tif", "small1.png", "small2.png"]); +} + +/// Files dropped together are read one at a time, as when they are read at once, so a batch of +/// large pictures is not decoded all at once. +#[test] +fn files_dropped_together_are_read_one_at_a_time() { + let files: Vec = (0..4).map(|i| saved("one-at-a-time", &format!("large{i}.tif"), &picture(Tiff, 1500, 1500))).collect(); + let mut app = app(true); + let doc = app.session.doc().unwrap().uid; + let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); + let dropped = files.iter().map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))).collect(); + crate::place::drop_files(&mut app, dropped); + placed(&mut app); + assert_eq!(names(&app).len(), 4); + assert_eq!(app.placing.reading[1].load(std::sync::atomic::Ordering::SeqCst), 1, "the most reads at once"); +} + +/// A file that can't be read shows the message it shows when read at once, dropped or placed +/// from the dialog, and nothing is placed. +#[test] +fn a_file_that_cant_be_read_off_the_ui_thread_reports_as_at_once() { + let gone = saved("unread", "gone.tif", b""); + std::fs::remove_file(&gone).unwrap(); + let unknown = saved("unread", "notes.xyz", b"hello"); + for path in [gone, unknown] { + let p = json!({"path": path}); + let mut at_once = app(false); + crate::place::run_for_user(&mut at_once, &p).unwrap_err(); + let mut app = app(true); + let before = state(&app); + crate::place::run_for_user(&mut app, &p).unwrap(); + placed(&mut app); + assert_eq!(app.ui.status, at_once.ui.status); + assert!(state(&app) == before, "{path}: nothing placed"); + // Dropped on the canvas. + let name = vectorcraft_engine::cmd::fileio::file_name(&path); + for app in [&mut at_once, &mut app] { + let doc = app.session.doc().unwrap().uid; + let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); + crate::place::drop_files(app, vec![(drop, (name.clone(), Some(path.clone()), vec![]))]); + placed(app); + } + assert_eq!(app.ui.status, at_once.ui.status); + assert!(app.ui.status.starts_with(&format!("Couldn't place {name}: ")), "{}", app.ui.status); + } +} + +/// A file whose document closed before it was read is not placed, and the status bar says why. +#[test] +fn a_file_whose_document_closed_meanwhile_is_not_placed() { + let path = saved("closed", "photo.tif", &picture(Tiff, 60, 40)); + for dropped in [false, true] { + let mut app = app(true); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + start(&mut app, &json!({"path": path}), dropped).unwrap(); + app.session.execute("file.close", &json!({})).unwrap(); + let left = state(&app); + placed(&mut app); + let why = if dropped { "dropped on" } else { "placed in" }; + assert_eq!(app.ui.status, format!("Couldn't place photo.tif: the document it was {why} was closed")); + assert!(state(&app) == left, "the other document is unchanged"); + } +} + +/// Replace swaps the object selected when Place started; when that object was deleted before the +/// file was read, nothing is placed and the status bar says why. +#[test] +fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() { + let path = saved("replace", "photo.tif", &picture(Tiff, 60, 40)); + let mut app = app(true); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + crate::place::run_for_user(&mut app, &json!({"path": path, "replace": true})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + let cleared = state(&app); + placed(&mut app); + assert!(app.ui.status.contains("the object to replace was changed or deleted"), "{}", app.ui.status); + assert!(state(&app) == cleared, "nothing placed"); +} + +/// A file read while a dialog is open waits for the dialog to close, as a document that opens +/// does (#1694): the dialog's OK acts on the active document. +#[test] +fn a_file_read_while_a_dialog_is_open_waits_for_it_to_close() { + let path = saved("dialog", "photo.tif", &picture(Tiff, 60, 40)); + let mut app = app(true); + start(&mut app, &json!({"path": path}), false).unwrap(); + app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"}))); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "the file waits for the dialog"); + app.ui.dialog = None; + placed(&mut app); + assert_eq!(names(&app), ["photo.tif"]); +} + +/// A file read while an object is being dragged, in its document or in another one, waits for +/// the drag to end: the drag is not canceled, and the placed art stays. +#[test] +fn a_file_read_during_a_drag_waits_for_it_to_end() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let path = saved("drag", "photo.tif", &picture(Tiff, 60, 40)); + for other_document in [false, true] { + let mut app = app(true); + start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); + if other_document { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + } + let square = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let dragged = app.session.active_index(); + app.select_tool("selection"); + let view = vectorcraft_engine::ViewInfo::default(); + for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 40.0), (PointerKind::Drag, 60.0)] { + app.session.pointer(&PointerEvent::new(kind, x, 20.0), view).unwrap(); + } + assert!(app.session.in_interaction()); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(app.placing.pending.len() == 1 && app.session.in_interaction(), "other document: {other_document}"); + // The drag moves on before it ends. + for kind in [PointerKind::Drag, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 60.0, 20.0), view).unwrap(); + } + placed(&mut app); + let moved = app.session.documents()[dragged.unwrap()].doc.node(NodeId(square)).and_then(|n| n.geometric_bounds()); + assert_eq!(moved.map(|b| (b.x0, b.y0)), Some((50.0, 10.0)), "other document: {other_document}"); + assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}"); + } +} + +/// A file read for one document while the place cursor is dragged in another waits for the drag +/// to end, which the place cursor keeps to itself: the drag still places its file. +#[test] +fn a_file_read_during_a_place_cursor_drag_waits_for_it_to_end() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let path = saved("cursor-drag", "photo.tif", &picture(Tiff, 60, 40)); + let cursor = [saved("cursor-drag", "a.png", &picture(Png, 8, 8)), saved("cursor-drag", "b.png", &picture(Png, 8, 8))]; + let mut app = app(true); + start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("file.place.queue", json!({"paths": cursor})).unwrap(); + let view = vectorcraft_engine::ViewInfo::default(); + for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 60.0)] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, 20.0), view); + } + assert!(app.session.tool_busy() && !app.session.in_interaction()); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(app.placing.pending.len() == 1 && app.session.tool_busy(), "the file waits for the drag"); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Up, 60.0, 20.0), view); + placed(&mut app); + let placed_in = |i: usize| -> Vec<(String, Option)> { + let st = &app.session.documents()[i]; + st.doc + .layers + .iter() + .flat_map(|l| l.children().into_iter().flatten()) + .map(|n| (n.name.clone().unwrap_or_default(), n.geometric_bounds())) + .collect() + }; + assert_eq!(placed_in(0).iter().map(|(n, _)| n.as_str()).collect::>(), ["photo.tif"]); + assert_eq!(placed_in(1), [("a.png".to_string(), Some(vectorcraft_geom::Rect::new(20.0, 20.0, 60.0, 60.0)))], "the drag placed its file"); +} + +/// A file read while a tool waits between its clicks (the Dimension tool after its first point), +/// in the file's document or in another one, is placed without waiting for the next click. In +/// the same document the tool keeps its point; in another one it starts afresh, as when the user +/// switches documents. +#[test] +fn a_file_read_between_a_tools_clicks_is_placed() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let path = saved("clicks", "photo.tif", &picture(Tiff, 60, 40)); + for other_document in [false, true] { + let mut app = app(true); + start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); + if other_document { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + } + app.select_tool("dimension"); + let view = vectorcraft_engine::ViewInfo::default(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 20.0, 20.0), view); + } + assert!(app.session.tool_busy() && !app.session.in_interaction(), "other document: {other_document}"); + placed(&mut app); + assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}"); + assert_eq!(app.session.tool_busy(), !other_document, "other document: {other_document}"); + } +} From 7eb7d590491a7559028fb5c2bed1ce17d4ea93bf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:11 +0000 Subject: [PATCH 061/199] refactor(doc): move lib.rs tests to tests.rs --- crates/doc/src/lib.rs | 421 +--------------------------------------- crates/doc/src/tests.rs | 418 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 419 insertions(+), 420 deletions(-) create mode 100644 crates/doc/src/tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6349f9f4c..6cd0e3aec 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -2382,426 +2382,7 @@ impl Document { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_geom::shapes; - - /// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch - /// are binary fractions now, and the minor ticks between them stay binary too; every other - /// unit is still read in tenths. - #[test] - fn an_inch_ruler_steps_in_binary_fractions() { - let step = |u: Unit, zoom: f64| u.ruler_step(zoom); - // Every inch step at or below an inch is 1/2^k, never a tenth or a fifth. - for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] { - let s = step(Unit::Inches, zoom); - if s <= 1.0 { - let k = s.recip(); - assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k"); - assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k"); - } - // A label step always leaves at least 50 px, which is what the ladder is picked for. - assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}"); - } - // The steps the report named: tenths became quarters, eighths and sixteenths. - assert_eq!(step(Unit::Inches, 5.0), 0.25); - assert_eq!(step(Unit::Inches, 10.0), 0.125); - assert_eq!(step(Unit::Inches, 20.0), 0.0625); - // An inch and wider is unchanged. - assert_eq!(step(Unit::Inches, 1.0), 1.0); - // Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in - // further only adds ticks. A label therefore never needs more than the six characters of - // "0.0625", which the left ruler has room for when it spells one out a character per line. - for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] { - assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}"); - } - // The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and - // 128ths the report expects to appear unlabelled when zoomed in close. - assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0); - // The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere. - assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125); - assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5); - assert_eq!(Unit::Points.ruler_minor(100.0), 10.0); - // Other units keep the decimal ladder, so a tenth and a fifth still appear. - assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0)); - } - - fn doc_with_rects() -> (Document, NodeId, NodeId) { - let mut d = Document::new(612.0, 792.0); - let layer = d.layers[0].id; - let a = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); - let b = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Appearance::default_art())).unwrap(); - (d, a, b) - } - - #[test] - fn new_document_has_layer_and_artboard() { - let d = Document::new(612.0, 792.0); - assert_eq!(d.layers.len(), 1); - assert_eq!(d.layers[0].display_name(), "Layer 1"); - assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0)); - } - - #[test] - fn art_on_artboard_takes_unlocked_objects_wholly_inside() { - let (mut d, a, b) = doc_with_rects(); - let wide = Rect::new(-1.0, -1.0, 40.0, 15.0); - assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]); - assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside"); - // Flush with the artboard's edges counts as inside: the right and bottom ones too (#890). - for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] { - assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}"); - } - d.node_mut(a).unwrap().locked = true; - assert_eq!(d.art_on_artboard(wide, false), vec![b]); - // Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on. - d.node_mut(b).unwrap().visible = false; - assert!(d.art_on_artboard(wide, false).is_empty()); - assert_eq!(d.art_on_artboard(wide, true), vec![a, b]); - Arc::make_mut(&mut d.layers[0]).locked = true; - assert!(d.art_on_artboard(wide, false).is_empty()); - assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too"); - } - - #[test] - fn art_on_artboard_tolerates_roundoff_at_each_edge() { - let (d, a, _) = doc_with_rects(); - let roundoff = 1e-12; - for board in [ - Rect::new(roundoff, 0.0, 10.0, 10.0), - Rect::new(0.0, roundoff, 10.0, 10.0), - Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0), - Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff), - ] { - assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}"); - } - // A real overhang remains outside. - for board in - [Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)] - { - assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}"); - } - } - - /// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one - /// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with - /// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one. - /// Unlocking the artboard gives the art back. - #[test] - fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { - use crate::hit::{HitOptions, hit_test, marquee, objects_at}; - let (mut d, a, b) = doc_with_rects(); - let layer = d.layers[0].id; - d.artboards.push(Artboard { - id: 2, - name: "Artboard 2".into(), - rect: Rect::new(700.0, 0.0, 900.0, 200.0), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }); - let rect = |d: &mut Document, r: Rect, appearance: Appearance| { - let id = d.alloc_id(); - d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap(); - id - }; - let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art()); - // A group with one member on the artboard and one right of it. - let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let members = [(m1, 750.0), (m2, 950.0)] - .map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art()))); - d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap(); - // Unstroked and flush with the artboard's left edge, from outside. - let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default()); - let opt = HitOptions::default(); - let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None)); - let all = Rect::new(-10.0, -10.0, 1000.0, 800.0); - assert_eq!(top(&d, 720.0, 20.0), Some(c)); - d.artboards[1].locked = true; - for id in [c, g, m1, m2] { - assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard"); - } - for id in [a, b, e, layer] { - assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't"); - } - assert_eq!(d.selectable_art(), [a, b, e]); - assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c"); - assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard"); - let isolated = HitOptions { scope: Some(g), ..opt }; - assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock"); - assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty()); - assert_eq!(marquee(&d, all, None, false), [a, b, e]); - assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too"); - // Deselected in one pass, with the key object and the anchors. - let mut sel = Selection::default(); - sel.set([a, c]); - sel.key = Some(c); - sel.anchors.insert(c, [(0, 0)].into()); - sel.deselect_on_locked_artboards(&d); - assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true)); - // A targeted layer stays targeted, with its art off the locked artboard selected. - sel.set_target(&d, layer); - sel.deselect_on_locked_artboards(&d); - assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer))); - // Shift-clicking the layer's target circle removes its art off the locked artboard, and - // a second Shift-click adds it again. - sel.toggle_target(&d, layer); - sel.deselect_on_locked_artboards(&d); - assert_eq!((sel.objects.clone(), sel.target), (vec![], None)); - sel.toggle_target(&d, layer); - sel.deselect_on_locked_artboards(&d); - assert_eq!(sel.objects, [a, b, e]); - // A sublayer with art on both artboards: its art off the locked artboard can still be clicked. - let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); - for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] { - d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art())) - .unwrap(); - } - assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None)); - assert!(!d.on_locked_artboard(sub)); - let mut both = Selection::default(); - both.set([s1, s2]); - both.deselect_on_locked_artboards(&d); - assert_eq!(both.objects, [s1]); - // New art on it is editable and can be clicked while it is new art. - d.new_art.set(Arc::new([c].into())); - assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art"); - assert_eq!(top(&d, 720.0, 20.0), Some(c)); - d.new_art.set(Default::default()); - d.artboards[1].locked = false; - assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]); - assert_eq!(top(&d, 955.0, 55.0), Some(g)); - assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]); - } - - #[test] - fn art_on_artboard_measures_a_symbol_instance_by_its_art() { - let mut d = Document::new(612.0, 792.0); - let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) }); - let (layer, id) = (d.layers[0].id, d.alloc_id()); - d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap(); - // The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't. - assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]); - } - - #[test] - fn artboard_at_counts_the_right_and_bottom_edges() { - // Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it. - let mut d = Document::new(100.0, 100.0); - for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] { - let mut a = d.artboards[0].clone(); - a.rect = rect; - d.artboards.push(a); - } - let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false)); - // Each artboard's own right and bottom edges, edges with roundoff, and points beyond them. - assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge"); - assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge"); - assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner"); - assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge"); - assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge"); - assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge"); - assert_eq!(held(&d, 200.001, 50.0), (None, None)); - assert_eq!(held(&d, 50.0, 200.001), (None, None)); - // On an edge two artboards share, the artboard to the right of it or below it holds the - // point, in either list order, except at the end of the edge that lies on that artboard's - // bottom or right edge. - assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1))); - assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2))); - // The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top - // right corner: the first holds the point, or the last when `topmost`. - assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner"); - d.artboards.swap(0, 1); - assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0))); - // Where two artboards overlap, the topmost holds the point, or the first one when not - // `topmost`. - let mut inner = d.artboards[1].clone(); - inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0); - d.artboards.push(inner); - assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1))); - assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3"); - } - - #[test] - fn find_and_paths() { - let (d, a, b) = doc_with_rects(); - assert_eq!(d.index_path(b), Some(vec![0, 1])); - assert_eq!(d.parent_of(a), Some(d.layers[0].id)); - assert_eq!(d.layer_of(b), Some(d.layers[0].id)); - assert!(d.node(NodeId(999)).is_none()); - assert_eq!(d.node_count(), 3); - } - - #[test] - fn copy_on_write_shares_untouched() { - let (d, a, b) = doc_with_rects(); - let mut e = d.clone(); - e.node_mut(a).unwrap().name = Some("A".into()); - // b's Arc is shared between both versions; a's is not. - let lb = &d.layers[0].children().unwrap()[1]; - let eb = &e.layers[0].children().unwrap()[1]; - assert!(Arc::ptr_eq(lb, eb)); - assert_eq!(d.node(a).unwrap().name, None); - assert_eq!(e.node(a).unwrap().name.as_deref(), Some("A")); - let _ = b; - } - - #[test] - fn move_and_remove() { - let (mut d, a, b) = doc_with_rects(); - let layer = d.layers[0].id; - d.move_node(a, Some(layer), usize::MAX).unwrap(); - assert_eq!(d.index_path(a), Some(vec![0, 1])); - assert_eq!(d.index_path(b), Some(vec![0, 0])); - let r = d.remove(a).unwrap(); - assert_eq!(r.id, a); - assert!(d.node(a).is_none()); - assert!(d.move_node(layer, Some(layer), 0).is_err()); - } - - #[test] - fn layers_and_names() { - let mut d = Document::new(100.0, 100.0); - let l2 = d.add_layer(None); - assert_eq!(d.node(l2).unwrap().display_name(), "Layer 2"); - assert_eq!(d.default_layer(), Some(l2)); - assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1); - } - - /// #1718: a file whose ids or counters are at their type's largest value still hands out ids - /// nothing in it uses (and never 0), instead of overflowing. - #[test] - fn exhausted_ids_in_a_file_dont_run_out() { - let (d, a, b) = doc_with_rects(); - let mut v = serde_json::to_value(&d).unwrap(); - v["next_id"] = serde_json::json!(u64::MAX); - // Object `a` gets the largest id there is. - fn renumber(v: &mut serde_json::Value, from: u64) { - match v { - serde_json::Value::Object(o) => { - if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) { - o.insert("id".into(), serde_json::json!(u64::MAX)); - } - o.values_mut().for_each(|c| renumber(c, from)); - } - serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)), - _ => {} - } - } - renumber(&mut v["layers"], a.0); - let mut d: Document = serde_json::from_value(v).unwrap(); - assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept"); - d.fix_next_id(); - let fresh: Vec = (0..3).map(|_| d.alloc_id()).collect(); - assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}"); - assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]); - // A counter left at the end by other means stays there rather than wrapping to 0. - d.next_id = u64::MAX; - assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX)); - d.reserve_ids(u64::MAX); - // Artboards and graphic styles: one past the largest, else the smallest id free. - assert_eq!(free_id([1, 2, 7].into_iter()), 8); - assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3); - assert_eq!(free_id([u32::MAX].into_iter()), 1); - d.artboards[0].id = u32::MAX; - assert_eq!(d.next_artboard_id(), 1); - } - - #[test] - fn serde_roundtrip() { - let (d, _, _) = doc_with_rects(); - let s = serde_json::to_string(&d).unwrap(); - let back: Document = serde_json::from_str(&s).unwrap(); - assert_eq!(back.node_count(), d.node_count()); - assert_eq!(back.peek_next_id(), d.peek_next_id()); - } - - #[test] - fn reid_gives_fresh_ids() { - let (mut d, a, b) = doc_with_rects(); - let g = d.alloc_id(); - let na = (*d.remove(a).unwrap()).clone(); - let nb = (*d.remove(b).unwrap()).clone(); - let group = Node::group(g, vec![Arc::new(na), Arc::new(nb)]); - let copy = d.reid(&group); - assert_ne!(copy.id, g); - let ids: Vec = copy.children().unwrap().iter().map(|c| c.id).collect(); - assert!(!ids.contains(&a) && !ids.contains(&b)); - } - - #[test] - fn setup_round_trips_and_old_files_load() { - let (mut d, _, _) = doc_with_rects(); - // A document without a setup writes no `setup` key (old readers and files stay unchanged). - assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\"")); - d.setup.bleed = [9.0, 9.0, 0.0, 18.0]; - d.setup.grid_size = GridSize::Large; - d.setup.typographers_quotes = false; - d.setup.quotes = setup::language_quotes("German").unwrap(); - d.setup.flattener_preset = Some("High Resolution".into()); - d.setup.background = Background::White; - d.setup.export_text = ExportText::Appearance; - let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); - assert_eq!(back.setup, d.setup); - // Files written before Document Setup existed load with the defaults. - let mut v = serde_json::to_value(&d).unwrap(); - v.as_object_mut().unwrap().remove("setup"); - let old: Document = serde_json::from_value(v).unwrap(); - assert_eq!(old.setup, DocSetup::default()); - } - - #[test] - fn char_position_round_trips() { - let st = CharStyle { - position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }), - small_caps: Some(75.0), - ..CharStyle::default() - }; - let v = serde_json::to_value(&st).unwrap(); - assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0})); - assert_eq!(serde_json::from_value::(v).unwrap(), st); - let plain = serde_json::to_value(CharStyle::default()).unwrap(); - assert!(plain.get("position").is_none() && plain.get("small_caps").is_none()); - let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0); - assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9); - } - - #[test] - fn units() { - assert_eq!(Unit::Inches.parse("1"), Some(72.0)); - assert_eq!(Unit::Points.parse("1in"), Some(72.0)); - assert_eq!(Unit::Points.parse("10 mm").map(|v| (v * 1000.0).round()), Some(28346.0)); - assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); - assert_eq!(Unit::Points.parse("10+5"), Some(15.0)); - assert_eq!(Unit::Points.parse("100/4"), Some(25.0)); - assert_eq!(Unit::Points.parse("-5"), Some(-5.0)); - assert_eq!(Unit::Points.parse("abc"), None); - assert_eq!(Unit::Points.format(12.5), "12.5 pt"); - assert_eq!(Unit::Inches.format(144.0), "2 in"); - } - - #[test] - fn negative_picas_and_points_sign_applies_to_whole_value() { - assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0)); - assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0)); - assert_eq!(Unit::Points.parse("-2p"), Some(-24.0)); - assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); - } - - #[test] - fn pica_form_rejects_exponent_notation() { - for s in ["1e5p", "infp", "nanp", "infp3", "1p1e2"] { - assert_eq!(Unit::Points.parse(s), None, "{s}"); - } - } -} +mod tests; #[cfg(test)] mod tests_lookups; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs new file mode 100644 index 000000000..7bb94b3fa --- /dev/null +++ b/crates/doc/src/tests.rs @@ -0,0 +1,418 @@ +use super::*; +use vectorcraft_geom::shapes; + +/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch +/// are binary fractions now, and the minor ticks between them stay binary too; every other +/// unit is still read in tenths. +#[test] +fn an_inch_ruler_steps_in_binary_fractions() { + let step = |u: Unit, zoom: f64| u.ruler_step(zoom); + // Every inch step at or below an inch is 1/2^k, never a tenth or a fifth. + for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] { + let s = step(Unit::Inches, zoom); + if s <= 1.0 { + let k = s.recip(); + assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k"); + assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k"); + } + // A label step always leaves at least 50 px, which is what the ladder is picked for. + assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}"); + } + // The steps the report named: tenths became quarters, eighths and sixteenths. + assert_eq!(step(Unit::Inches, 5.0), 0.25); + assert_eq!(step(Unit::Inches, 10.0), 0.125); + assert_eq!(step(Unit::Inches, 20.0), 0.0625); + // An inch and wider is unchanged. + assert_eq!(step(Unit::Inches, 1.0), 1.0); + // Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in + // further only adds ticks. A label therefore never needs more than the six characters of + // "0.0625", which the left ruler has room for when it spells one out a character per line. + for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] { + assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}"); + } + // The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and + // 128ths the report expects to appear unlabelled when zoomed in close. + assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0); + // The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere. + assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125); + assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5); + assert_eq!(Unit::Points.ruler_minor(100.0), 10.0); + // Other units keep the decimal ladder, so a tenth and a fifth still appear. + assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0)); +} + +fn doc_with_rects() -> (Document, NodeId, NodeId) { + let mut d = Document::new(612.0, 792.0); + let layer = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + let b = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Appearance::default_art())).unwrap(); + (d, a, b) +} + +#[test] +fn new_document_has_layer_and_artboard() { + let d = Document::new(612.0, 792.0); + assert_eq!(d.layers.len(), 1); + assert_eq!(d.layers[0].display_name(), "Layer 1"); + assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0)); +} + +#[test] +fn art_on_artboard_takes_unlocked_objects_wholly_inside() { + let (mut d, a, b) = doc_with_rects(); + let wide = Rect::new(-1.0, -1.0, 40.0, 15.0); + assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]); + assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside"); + // Flush with the artboard's edges counts as inside: the right and bottom ones too (#890). + for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] { + assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}"); + } + d.node_mut(a).unwrap().locked = true; + assert_eq!(d.art_on_artboard(wide, false), vec![b]); + // Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on. + d.node_mut(b).unwrap().visible = false; + assert!(d.art_on_artboard(wide, false).is_empty()); + assert_eq!(d.art_on_artboard(wide, true), vec![a, b]); + Arc::make_mut(&mut d.layers[0]).locked = true; + assert!(d.art_on_artboard(wide, false).is_empty()); + assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too"); +} + +#[test] +fn art_on_artboard_tolerates_roundoff_at_each_edge() { + let (d, a, _) = doc_with_rects(); + let roundoff = 1e-12; + for board in [ + Rect::new(roundoff, 0.0, 10.0, 10.0), + Rect::new(0.0, roundoff, 10.0, 10.0), + Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0), + Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff), + ] { + assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}"); + } + // A real overhang remains outside. + for board in + [Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)] + { + assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}"); + } +} + +/// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one +/// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with +/// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one. +/// Unlocking the artboard gives the art back. +#[test] +fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { + use crate::hit::{HitOptions, hit_test, marquee, objects_at}; + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + d.artboards.push(Artboard { + id: 2, + name: "Artboard 2".into(), + rect: Rect::new(700.0, 0.0, 900.0, 200.0), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }); + let rect = |d: &mut Document, r: Rect, appearance: Appearance| { + let id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap(); + id + }; + let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art()); + // A group with one member on the artboard and one right of it. + let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let members = [(m1, 750.0), (m2, 950.0)] + .map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art()))); + d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap(); + // Unstroked and flush with the artboard's left edge, from outside. + let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default()); + let opt = HitOptions::default(); + let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None)); + let all = Rect::new(-10.0, -10.0, 1000.0, 800.0); + assert_eq!(top(&d, 720.0, 20.0), Some(c)); + d.artboards[1].locked = true; + for id in [c, g, m1, m2] { + assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard"); + } + for id in [a, b, e, layer] { + assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't"); + } + assert_eq!(d.selectable_art(), [a, b, e]); + assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c"); + assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard"); + let isolated = HitOptions { scope: Some(g), ..opt }; + assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock"); + assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty()); + assert_eq!(marquee(&d, all, None, false), [a, b, e]); + assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too"); + // Deselected in one pass, with the key object and the anchors. + let mut sel = Selection::default(); + sel.set([a, c]); + sel.key = Some(c); + sel.anchors.insert(c, [(0, 0)].into()); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true)); + // A targeted layer stays targeted, with its art off the locked artboard selected. + sel.set_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer))); + // Shift-clicking the layer's target circle removes its art off the locked artboard, and + // a second Shift-click adds it again. + sel.toggle_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.target), (vec![], None)); + sel.toggle_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!(sel.objects, [a, b, e]); + // A sublayer with art on both artboards: its art off the locked artboard can still be clicked. + let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); + for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] { + d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art())) + .unwrap(); + } + assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None)); + assert!(!d.on_locked_artboard(sub)); + let mut both = Selection::default(); + both.set([s1, s2]); + both.deselect_on_locked_artboards(&d); + assert_eq!(both.objects, [s1]); + // New art on it is editable and can be clicked while it is new art. + d.new_art.set(Arc::new([c].into())); + assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art"); + assert_eq!(top(&d, 720.0, 20.0), Some(c)); + d.new_art.set(Default::default()); + d.artboards[1].locked = false; + assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]); + assert_eq!(top(&d, 955.0, 55.0), Some(g)); + assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]); +} + +#[test] +fn art_on_artboard_measures_a_symbol_instance_by_its_art() { + let mut d = Document::new(612.0, 792.0); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) }); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap(); + // The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't. + assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]); +} + +#[test] +fn artboard_at_counts_the_right_and_bottom_edges() { + // Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it. + let mut d = Document::new(100.0, 100.0); + for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] { + let mut a = d.artboards[0].clone(); + a.rect = rect; + d.artboards.push(a); + } + let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false)); + // Each artboard's own right and bottom edges, edges with roundoff, and points beyond them. + assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge"); + assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge"); + assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner"); + assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge"); + assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge"); + assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge"); + assert_eq!(held(&d, 200.001, 50.0), (None, None)); + assert_eq!(held(&d, 50.0, 200.001), (None, None)); + // On an edge two artboards share, the artboard to the right of it or below it holds the + // point, in either list order, except at the end of the edge that lies on that artboard's + // bottom or right edge. + assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1))); + assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2))); + // The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top + // right corner: the first holds the point, or the last when `topmost`. + assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner"); + d.artboards.swap(0, 1); + assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0))); + // Where two artboards overlap, the topmost holds the point, or the first one when not + // `topmost`. + let mut inner = d.artboards[1].clone(); + inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0); + d.artboards.push(inner); + assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1))); + assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3"); +} + +#[test] +fn find_and_paths() { + let (d, a, b) = doc_with_rects(); + assert_eq!(d.index_path(b), Some(vec![0, 1])); + assert_eq!(d.parent_of(a), Some(d.layers[0].id)); + assert_eq!(d.layer_of(b), Some(d.layers[0].id)); + assert!(d.node(NodeId(999)).is_none()); + assert_eq!(d.node_count(), 3); +} + +#[test] +fn copy_on_write_shares_untouched() { + let (d, a, b) = doc_with_rects(); + let mut e = d.clone(); + e.node_mut(a).unwrap().name = Some("A".into()); + // b's Arc is shared between both versions; a's is not. + let lb = &d.layers[0].children().unwrap()[1]; + let eb = &e.layers[0].children().unwrap()[1]; + assert!(Arc::ptr_eq(lb, eb)); + assert_eq!(d.node(a).unwrap().name, None); + assert_eq!(e.node(a).unwrap().name.as_deref(), Some("A")); + let _ = b; +} + +#[test] +fn move_and_remove() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + d.move_node(a, Some(layer), usize::MAX).unwrap(); + assert_eq!(d.index_path(a), Some(vec![0, 1])); + assert_eq!(d.index_path(b), Some(vec![0, 0])); + let r = d.remove(a).unwrap(); + assert_eq!(r.id, a); + assert!(d.node(a).is_none()); + assert!(d.move_node(layer, Some(layer), 0).is_err()); +} + +#[test] +fn layers_and_names() { + let mut d = Document::new(100.0, 100.0); + let l2 = d.add_layer(None); + assert_eq!(d.node(l2).unwrap().display_name(), "Layer 2"); + assert_eq!(d.default_layer(), Some(l2)); + assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1); +} + +/// #1718: a file whose ids or counters are at their type's largest value still hands out ids +/// nothing in it uses (and never 0), instead of overflowing. +#[test] +fn exhausted_ids_in_a_file_dont_run_out() { + let (d, a, b) = doc_with_rects(); + let mut v = serde_json::to_value(&d).unwrap(); + v["next_id"] = serde_json::json!(u64::MAX); + // Object `a` gets the largest id there is. + fn renumber(v: &mut serde_json::Value, from: u64) { + match v { + serde_json::Value::Object(o) => { + if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) { + o.insert("id".into(), serde_json::json!(u64::MAX)); + } + o.values_mut().for_each(|c| renumber(c, from)); + } + serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)), + _ => {} + } + } + renumber(&mut v["layers"], a.0); + let mut d: Document = serde_json::from_value(v).unwrap(); + assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept"); + d.fix_next_id(); + let fresh: Vec = (0..3).map(|_| d.alloc_id()).collect(); + assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}"); + assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]); + // A counter left at the end by other means stays there rather than wrapping to 0. + d.next_id = u64::MAX; + assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX)); + d.reserve_ids(u64::MAX); + // Artboards and graphic styles: one past the largest, else the smallest id free. + assert_eq!(free_id([1, 2, 7].into_iter()), 8); + assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3); + assert_eq!(free_id([u32::MAX].into_iter()), 1); + d.artboards[0].id = u32::MAX; + assert_eq!(d.next_artboard_id(), 1); +} + +#[test] +fn serde_roundtrip() { + let (d, _, _) = doc_with_rects(); + let s = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&s).unwrap(); + assert_eq!(back.node_count(), d.node_count()); + assert_eq!(back.peek_next_id(), d.peek_next_id()); +} + +#[test] +fn reid_gives_fresh_ids() { + let (mut d, a, b) = doc_with_rects(); + let g = d.alloc_id(); + let na = (*d.remove(a).unwrap()).clone(); + let nb = (*d.remove(b).unwrap()).clone(); + let group = Node::group(g, vec![Arc::new(na), Arc::new(nb)]); + let copy = d.reid(&group); + assert_ne!(copy.id, g); + let ids: Vec = copy.children().unwrap().iter().map(|c| c.id).collect(); + assert!(!ids.contains(&a) && !ids.contains(&b)); +} + +#[test] +fn setup_round_trips_and_old_files_load() { + let (mut d, _, _) = doc_with_rects(); + // A document without a setup writes no `setup` key (old readers and files stay unchanged). + assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\"")); + d.setup.bleed = [9.0, 9.0, 0.0, 18.0]; + d.setup.grid_size = GridSize::Large; + d.setup.typographers_quotes = false; + d.setup.quotes = setup::language_quotes("German").unwrap(); + d.setup.flattener_preset = Some("High Resolution".into()); + d.setup.background = Background::White; + d.setup.export_text = ExportText::Appearance; + let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back.setup, d.setup); + // Files written before Document Setup existed load with the defaults. + let mut v = serde_json::to_value(&d).unwrap(); + v.as_object_mut().unwrap().remove("setup"); + let old: Document = serde_json::from_value(v).unwrap(); + assert_eq!(old.setup, DocSetup::default()); +} + +#[test] +fn char_position_round_trips() { + let st = CharStyle { + position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }), + small_caps: Some(75.0), + ..CharStyle::default() + }; + let v = serde_json::to_value(&st).unwrap(); + assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0})); + assert_eq!(serde_json::from_value::(v).unwrap(), st); + let plain = serde_json::to_value(CharStyle::default()).unwrap(); + assert!(plain.get("position").is_none() && plain.get("small_caps").is_none()); + let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0); + assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9); +} + +#[test] +fn units() { + assert_eq!(Unit::Inches.parse("1"), Some(72.0)); + assert_eq!(Unit::Points.parse("1in"), Some(72.0)); + assert_eq!(Unit::Points.parse("10 mm").map(|v| (v * 1000.0).round()), Some(28346.0)); + assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); + assert_eq!(Unit::Points.parse("10+5"), Some(15.0)); + assert_eq!(Unit::Points.parse("100/4"), Some(25.0)); + assert_eq!(Unit::Points.parse("-5"), Some(-5.0)); + assert_eq!(Unit::Points.parse("abc"), None); + assert_eq!(Unit::Points.format(12.5), "12.5 pt"); + assert_eq!(Unit::Inches.format(144.0), "2 in"); +} + +#[test] +fn negative_picas_and_points_sign_applies_to_whole_value() { + assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0)); + assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0)); + assert_eq!(Unit::Points.parse("-2p"), Some(-24.0)); + assert_eq!(Unit::Points.parse("2p6"), Some(30.0)); +} + +#[test] +fn pica_form_rejects_exponent_notation() { + for s in ["1e5p", "infp", "nanp", "infp3", "1p1e2"] { + assert_eq!(Unit::Points.parse(s), None, "{s}"); + } +} From 6dd257522b31c8e6e6b6ad033427c7b6350d4ac9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:11 +0000 Subject: [PATCH 062/199] refactor(tools): move draw2/curvature.rs tests to draw2/curvature/tests.rs --- crates/tools/src/draw2/curvature.rs | 408 +--------------------- crates/tools/src/draw2/curvature/tests.rs | 405 +++++++++++++++++++++ 2 files changed, 406 insertions(+), 407 deletions(-) create mode 100644 crates/tools/src/draw2/curvature/tests.rs diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index d81dc03e5..63d4d7e35 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -656,410 +656,4 @@ impl Tool for CurvatureTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::{Appearance, Document, Node, Selection}; - use vectorcraft_geom::{ParamCurve, PathData}; - - /// The Curvature tool has its own cursor, not the Pen's start-a-path one. - #[test] - fn curvature_tool_shows_its_cursor() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - assert_eq!(CurvatureTool::default().cursor(&cx, Point::new(10.0, 10.0), Mods::default()), Cursor::Curvature); - } - - #[test] - fn first_click_creates_then_notify_tracks_path() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = CurvatureTool::default(); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.curvature" && v.get("id").is_none() && v["points"].as_array().unwrap().len() == 1)); - assert_eq!(a[1], Action::Notify("created".into())); - // Pretend the engine created `id` with one anchor: it is a rect (4 anchors) so it doesn't match. - let mut s2 = Selection::default(); - s2.set([id]); - let cx2 = crate::testutil::cx(&d, &s2, &p); - t.notify(&cx2, "created"); - assert_eq!(t.id, Some(id)); - assert!(t.active(&cx2).is_none()); - // With four points it matches, so a click appends a fifth. - t.pts = vec![(Point::new(100.0, 100.0), false); 4]; - let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)); - assert!(matches!(&a[0], Action::Exec(_, v) if v["id"] == id.0 && v["points"].as_array().unwrap().len() == 5)); - } - - /// Points snap to Smart Guides (#506): the first lands on another object's anchor and says so. - #[test] - fn points_snap_to_smart_guides() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = CurvatureTool::default(); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 202.0, 99.0)); - assert!(matches!(&a[0], Action::Exec(_, v) if v["points"][0]["x"] == 200.0 && v["points"][0]["y"] == 100.0), "{a:?}"); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor"))); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 202.0, 99.0)); - assert!(t.overlays(&cx).is_empty()); - } - - fn pt(x: f64, y: f64) -> Point { - Point::new(x, y) - } - - /// An open pen-style path: a corner start, two smooth anchors with uneven handles, a cusp and - /// an end with only an incoming handle. - fn wave() -> SubPath { - SubPath::new( - vec![ - Anchor::corner(pt(0.0, 100.0)), - Anchor::with_handles(pt(100.0, 0.0), pt(60.0, 10.0), pt(160.0, -15.0)), - Anchor::with_handles(pt(250.0, 80.0), pt(220.0, 30.0), pt(280.0, 130.0)), - Anchor::with_handles(pt(350.0, 150.0), pt(320.0, 160.0), pt(380.0, 100.0)), - Anchor::with_handles(pt(450.0, 60.0), pt(420.0, 90.0), pt(450.0, 60.0)), - ], - false, - ) - } - - /// Every point of `a` lies on `b` (within 1e-6), sampled. - fn lies_on(a: &SubPath, b: &SubPath) -> bool { - let b = PathData::single(b.clone()); - (0..a.segment_count()).all(|i| (0..=20).all(|k| b.nearest(a.segment(i).eval(k as f64 / 20.0)).is_some_and(|n| n.4 < 1e-6))) - } - - fn collinear(a: Vec2, b: Vec2) -> bool { - a.cross(b).abs() < 1e-6 * a.hypot().max(1.0) * b.hypot().max(1.0) - } - - #[test] - fn moving_a_point_recurves_only_its_two_segments() { - let before = wave(); - let mut sp = before.clone(); - assert_eq!(sp.anchors[2].kind, AnchorKind::Smooth); - assert!(curvature_move(&mut sp, 2, pt(260.0, 140.0))); - assert_eq!(sp.anchors[2].p, pt(260.0, 140.0)); - // The segments beyond its neighbours are bit-identical. - assert_eq!(sp.segment(0), before.segment(0)); - assert_eq!(sp.segment(3), before.segment(3)); - // The neighbours keep their handle directions on its segments, and their other handles. - assert!(collinear(sp.anchors[1].h_out - sp.anchors[1].p, before.anchors[1].h_out - before.anchors[1].p)); - assert!(collinear(sp.anchors[3].h_in - sp.anchors[3].p, before.anchors[3].h_in - before.anchors[3].p)); - assert_eq!((sp.anchors[1].h_in, sp.anchors[3].h_out), (before.anchors[1].h_in, before.anchors[3].h_out)); - // The moved smooth point takes the Curvature tool's handles, along the circle through it - // and its neighbours, whatever the spacing: between a third and two thirds of its chord. - let a = sp.anchors[2]; - let (prev, next) = (sp.anchors[1].p, sp.anchors[3].p); - assert_eq!(a.kind, AnchorKind::Smooth); - assert_eq!(Some((a.h_in, a.h_out)), smooth_handles(prev, a.p, next)); - assert!(collinear(a.h_in - a.p, a.h_out - a.p) && (a.h_in - a.p).dot(a.h_out - a.p) < 0.0); - for (h, to) in [(a.h_in, prev), (a.h_out, next)] { - let r = a.p.distance(h) / a.p.distance(to); - assert!((1.0 / 3.0 - 1e-9..=2.0 / 3.0 + 1e-9).contains(&r), "{r}"); - } - // A cusp keeps its handle directions; a moved end changes its one segment only. - assert_eq!(sp.anchors[3].kind, AnchorKind::Corner); - assert!(curvature_move(&mut sp, 3, pt(360.0, 170.0))); - assert!(collinear(sp.anchors[3].h_out - sp.anchors[3].p, before.anchors[3].h_out - before.anchors[3].p)); - let mut end = before.clone(); - assert!(curvature_move(&mut end, 4, pt(470.0, 20.0))); - assert_eq!(end.segment(2), before.segment(2)); - assert!(!curvature_move(&mut end, 5, pt(0.0, 0.0))); - } - - /// A smooth end of an open path has one segment: its one handle into the curve, mirroring the - /// neighbour's tangent in their chord, and nothing on the outer side. - #[test] - fn moving_a_smooth_end_gives_it_the_end_handle() { - let mut sp = curvature_spline(&[(pt(0.0, 0.0), false), (pt(100.0, 60.0), false), (pt(200.0, 0.0), false), (pt(300.0, 50.0), false)], false); - sp.anchors[0].kind = AnchorKind::Smooth; - let before = sp.clone(); - assert!(curvature_move(&mut sp, 0, pt(-20.0, 30.0))); - let (e, b) = (sp.anchors[0], sp.anchors[1]); - assert!(!e.has_in() && e.has_out()); - assert_eq!(Some(e.h_out), end_handle(e.p, &b, true)); - assert_eq!(sp.segment(2), before.segment(2)); - assert_eq!(sp.anchors[2], before.anchors[2]); - } - - #[test] - fn moving_a_corner_without_handles_keeps_its_sides_straight() { - let (d, id) = doc_with_rect(); - let mut sp = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); - assert!(curvature_move(&mut sp, 0, pt(80.0, 70.0))); - assert!((0..sp.segment_count()).all(|i| sp.segment_is_line(i))); - assert_eq!(sp.anchors[0], Anchor::corner(pt(80.0, 70.0))); - } - - #[test] - fn going_on_from_an_end_keeps_the_old_segments() { - let before = wave(); - let mut sp = before.clone(); - curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep, false); - assert_eq!(sp.anchors.len(), 6); - assert!((0..4).all(|i| sp.segment(i) == before.segment(i))); - // The old end turns smooth, going on along its incoming handle, a third of the chord out. - let e = sp.anchors[4]; - assert_eq!(e.kind, AnchorKind::Smooth); - assert!(collinear(e.h_out - e.p, e.p - e.h_in) && (e.h_out - e.p).dot(e.p - e.h_in) > 0.0); - assert!((e.h_out.distance(e.p) - e.p.distance(pt(550.0, 120.0)) / 3.0).abs() < 1e-9); - // The new end has no outer handle and one into the curve, mirroring the old end's tangent. - let n = sp.anchors[5]; - assert_eq!((n.p, n.has_out(), n.kind), (pt(550.0, 120.0), false, AnchorKind::Corner)); - assert_eq!(Some(n.h_in), end_handle(n.p, &e, false)); - // The point placed then curves through its neighbours as a drawn path's points do. - curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth, false); - let drawn = curvature_spline(&[(pt(450.0, 60.0), false), (pt(550.0, 120.0), false), (pt(650.0, 60.0), false)], false); - assert_eq!((sp.anchors[5].h_in, sp.anchors[5].h_out), (drawn.anchors[1].h_in, drawn.anchors[1].h_out)); - assert!((0..4).all(|i| sp.segment(i) == before.segment(i))); - // A corner stays one. - curvature_extend(&mut sp, false, pt(700.0, 100.0), EndCurve::Corner, false); - assert!(!sp.anchors[6].has_in() && !sp.anchors[6].has_out()); - // From the start: the old segments come one later, and the new end is the first anchor. - let mut sp = before.clone(); - curvature_extend(&mut sp, true, pt(-100.0, 50.0), EndCurve::Keep, false); - assert!(sp.anchors[0].p == pt(-100.0, 50.0) && !sp.anchors[0].has_in() && sp.anchors[0].has_out()); - assert!((0..4).all(|i| sp.segment(i + 1) == before.segment(i))); - // A corner end without handles goes on along its segment's tangent. - let old = sp.anchors[1]; - assert!(old.has_in() && !old.has_out()); - assert!(collinear(old.h_in - old.p, old.p - before.anchors[1].h_in) && (old.h_in - old.p).dot(old.p - before.anchors[1].h_in) > 0.0); - } - - #[test] - fn closing_keeps_the_old_segments() { - let before = wave(); - let mut sp = before.clone(); - curvature_close(&mut sp, false, EndCurve::Keep); - assert!(sp.closed && sp.anchors.len() == 5); - assert!((0..4).all(|i| sp.segment(i) == before.segment(i))); - // The first anchor gains an incoming handle along its tangent: no kink there. - let f = sp.anchors[0]; - assert!(f.has_in() && collinear(f.p - f.h_in, before.anchors[1].h_in - f.p)); - } - - #[test] - fn inserting_a_point_keeps_the_shape() { - let before = wave(); - let mut sp = before.clone(); - assert_eq!(curvature_insert(&mut sp, 1, 0.3), Some(2)); - assert_eq!(sp.anchors.len(), 6); - assert_eq!(sp.anchors[2].kind, AnchorKind::Smooth); - assert!(lies_on(&sp, &before) && lies_on(&before, &sp)); - // On a straight side the point is smooth too, its handles along the side. - let (d, id) = doc_with_rect(); - let rect = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); - let mut sp = rect.clone(); - let i = curvature_insert(&mut sp, 0, 0.5).unwrap(); - assert_eq!(sp.anchors[i].kind, AnchorKind::Smooth); - assert!(sp.anchors[i].has_in() && sp.anchors[i].has_out()); - assert!(lies_on(&sp, &rect) && lies_on(&rect, &sp)); - assert_eq!(curvature_insert(&mut sp, 99, 0.5), None); - } - - /// On a straight side the inserted point's handles lie along it, each a third of its own part - /// of the side; a drag then moves it as a smooth Curvature point. - #[test] - fn a_point_inserted_on_a_line_takes_the_chord_rule_and_drags_smooth() { - let (d, id) = doc_with_rect(); - let rect = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); - let mut sp = rect.clone(); - let i = curvature_insert(&mut sp, 0, 0.25).unwrap(); - let (a, prev, next) = (sp.anchors[i], sp.anchors[i - 1].p, sp.anchors[i + 1].p); - assert!((a.p.distance(a.h_in) - a.p.distance(prev) / 3.0).abs() < 1e-9); - assert!((a.p.distance(a.h_out) - a.p.distance(next) / 3.0).abs() < 1e-9); - assert!(collinear(a.h_out - a.p, next - prev) && (a.h_out - a.p).dot(next - prev) > 0.0); - assert!(sp.segment_is_line(0) || lies_on(&sp, &rect)); - // A drag bends it as a smooth point; the sides beyond stay straight. - let q = pt(a.p.x, a.p.y - 40.0); - assert!(curvature_move(&mut sp, i, q)); - let m = sp.anchors[i]; - assert_eq!(Some((m.h_in, m.h_out)), smooth_handles(prev, q, next)); - assert!((2..sp.segment_count()).all(|s| sp.segment_is_line(s))); - } - - #[test] - fn curvature_smooth_uses_the_tool_rule() { - let mut sp = curvature_spline(&[(pt(0.0, 0.0), true), (pt(100.0, 60.0), true), (pt(200.0, 0.0), false), (pt(300.0, 50.0), false)], false); - // A corner in the middle takes the bisector handles. - assert!(curvature_smooth(&mut sp, 1)); - let a = sp.anchors[1]; - assert_eq!((a.kind, Some((a.h_in, a.h_out))), (AnchorKind::Smooth, smooth_handles(pt(0.0, 0.0), a.p, pt(200.0, 0.0)))); - // An end curves into its segment, mirroring its neighbour's tangent. - assert!(curvature_smooth(&mut sp, 0)); - let e = sp.anchors[0]; - assert!(!e.has_in() && Some(e.h_out) == end_handle(e.p, &sp.anchors[1], true)); - // No direction: a fold, or an end whose neighbour has no handle there, stays as it is. - let mut fold = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), Anchor::corner(pt(100.0, 0.0)), Anchor::corner(pt(0.0, 0.0))], false); - let before = fold.clone(); - assert!(!curvature_smooth(&mut fold, 1) && !curvature_smooth(&mut fold, 0) && !curvature_smooth(&mut fold, 9)); - assert_eq!(fold, before); - } - - /// A curved end counts as smooth for the toggle: Alt-click turns it into a corner. - #[test] - fn alt_click_on_a_curved_end_makes_it_a_corner() { - let sp = curvature_spline(&[(pt(100.0, 300.0), false), (pt(200.0, 200.0), false), (pt(300.0, 300.0), false)], false); - let (d, id) = doc_with(sp); - let (s, p) = (selected(id, None), paint()); - let cx = quiet(&d, &s, &p); - let a = press(&mut CurvatureTool::default(), &cx, 100.0, 300.0, true); - assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "path.convertAnchor" && v["anchor"] == 0 && v["to"] == "corner")), "{a:?}"); - } - - /// A document with one path, `sp`. - fn doc_with(sp: SubPath) -> (Document, NodeId) { - let mut d = Document::new(800.0, 600.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - d.insert(Some(l), 0, Node::path(id, PathData::single(sp), Appearance::default_art())).unwrap(); - (d, id) - } - - /// Path `id` selected, anchor `current` of its first subpath direct-selected (or none). - fn selected(id: NodeId, current: Option) -> Selection { - let mut s = Selection::default(); - s.set([id]); - if let Some(ai) = current { - s.anchors.insert(id, [(0, ai)].into()); - } - s - } - - /// A context without snapping. - fn quiet<'a>(d: &'a Document, s: &'a Selection, p: &'a crate::PaintDefaults) -> ToolContext<'a> { - ToolContext { smart_guides: false, snap_to_point: false, ..cx(d, s, p) } - } - - fn press(t: &mut CurvatureTool, cx: &ToolContext, x: f64, y: f64, alt: bool) -> Vec { - let ev = PointerEvent::new(PointerKind::Down, x, y).with_mods(Mods { alt, ..Mods::default() }); - let mut a = t.pointer(cx, &ev); - a.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y))); - a - } - - /// The op and params of a `path.curvatureEdit` action. - fn edit_op(a: &Action) -> Option<(&str, &Value)> { - match a { - Action::Exec(c, v) | Action::Preview(c, v) if c == EDIT => Some((v["op"].as_str()?, v)), - _ => None, - } - } - - #[test] - fn a_press_on_a_point_selects_it_and_a_drag_moves_it() { - let (d, id) = doc_with(wave()); - let (s, p) = (selected(id, None), paint()); - let cx = quiet(&d, &s, &p); - let mut t = CurvatureTool::default(); - assert_eq!(t.cursor(&cx, pt(250.0, 81.0), Mods::default()), Cursor::Move); - assert_eq!(t.cursor(&cx, pt(450.0, 61.0), Mods::default()), Cursor::PenContinue); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 251.0, 81.0)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"}))]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0)); - assert_eq!(a[0], Action::Begin("Curvature".into())); - let moved = edit_op(&a[1]).map(|(op, v)| (op, v["anchor"].clone(), v["x"].clone(), v["y"].clone())); - assert_eq!(moved, Some(("move", json!(2), json!(260.0), json!(140.0)))); - assert!(t.busy()); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]); - // A press without a drag only selects. - assert_eq!(press(&mut t, &cx, 100.0, 0.0, false).len(), 1); - } - - /// #2120: the dragged end of a slanted straight path keeps to its extension (Smart Guides). - #[test] - fn a_dragged_end_of_a_straight_path_keeps_to_its_extension() { - let (d, id) = doc_with(SubPath::polyline(&[pt(300.0, 400.0), pt(380.0, 340.0)], false)); - let (s, p) = (selected(id, None), paint()); - let cx = cx(&d, &s, &p); - let mut t = CurvatureTool::default(); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 380.0, 340.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0)); - let moved = a.get(1).and_then(edit_op).map(|(op, v)| (op, v["x"].as_f64().unwrap(), v["y"].as_f64().unwrap())); - assert!(matches!(moved, Some(("move", x, y)) if (x - 420.0).abs() < 1e-9 && (y - 310.0).abs() < 1e-9), "{a:?}"); - } - - #[test] - fn alt_click_or_double_click_toggles_and_delete_removes() { - let (d, id) = doc_with(wave()); - let p = paint(); - let s = selected(id, None); - let cx = quiet(&d, &s, &p); - let mut t = CurvatureTool::default(); - let to = |a: &[Action]| a.iter().find(|a| matches!(a, Action::Exec(c, _) if c == "path.convertAnchor")).cloned(); - let a = press(&mut t, &cx, 100.0, 0.0, true); - assert!(matches!(to(&a), Some(Action::Exec(_, v)) if v["anchor"] == 1 && v["to"] == "corner"), "{a:?}"); - assert_eq!(t.cursor(&cx, pt(100.0, 0.0), Mods { alt: true, ..Mods::default() }), Cursor::PenConvert); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, 0.0, 100.0)); - assert!(matches!(to(&a), Some(Action::Exec(_, v)) if v["anchor"] == 0 && v["to"] == "smooth"), "{a:?}"); - // Delete is the tool's while a point is current, and removes it. - assert!(!t.claims_key(&cx, ToolKey::Delete)); - let s = selected(id, Some(2)); - let cx = quiet(&d, &s, &p); - assert!(t.claims_key(&cx, ToolKey::Backspace)); - assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![remove_anchor((id, 0, 2))]); - // Esc lets go of the point. - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); - } - - #[test] - fn a_press_on_a_segment_adds_a_point_and_a_drag_moves_it_in_one_step() { - let (d, id) = doc_with(wave()); - let (s, p) = (selected(id, None), paint()); - let cx = quiet(&d, &s, &p); - let mut t = CurvatureTool::default(); - let on = wave().segment(1).eval(0.5); - assert_eq!(t.cursor(&cx, on, Mods::default()), Cursor::PenAdd); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on.x, on.y)); - assert_eq!(a[0], Action::Begin("Curvature".into())); - assert!( - matches!(edit_op(&a[1]), Some(("insert", v)) if v["segment"] == 1 && (v["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && v.get("x").is_none()) - ); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, on.x + 1.0, on.y + 40.0)); - assert!(matches!(edit_op(&a[0]), Some(("insert", v)) if v["segment"] == 1 && v["y"] == on.y + 40.0), "{a:?}"); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, on.x, on.y + 40.0)), vec![Action::Commit]); - } - - #[test] - fn clicks_go_on_from_a_current_end_and_the_other_end_closes() { - let p = paint(); - let mut sp = wave(); - let mut t = CurvatureTool::default(); - let (d, id) = doc_with(sp.clone()); - let s = selected(id, Some(4)); - let a = press(&mut t, &quiet(&d, &s, &p), 550.0, 120.0, false); - assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "end" && v["from"] == "keep" && v["x"] == 550.0), "{a:?}"); - // As the engine does it: the new end is current, and the next click curves through it. - curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep, false); - let (d, id) = doc_with(sp.clone()); - let s = selected(id, Some(5)); - let cx = quiet(&d, &s, &p); - t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 650.0, 60.0)); - let ovs = t.overlays(&cx); - assert!(ovs.iter().any(|o| matches!(o, Overlay::Path { .. })), "the rubber band shows"); - let from = pt(550.0, 120.0); - let to = pt(650.0, 60.0); - assert!(ovs.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); - let a = press(&mut t, &cx, 650.0, 60.0, true); - assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["from"] == "smooth"), "{a:?}"); - curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth, false); - let (d, id) = doc_with(sp.clone()); - let s = selected(id, Some(6)); - let cx = quiet(&d, &s, &p); - // The Alt-clicked point stays a corner; a click on the first point closes. - assert_eq!(t.cursor(&cx, pt(0.0, 100.0), Mods::default()), Cursor::PenClose); - let a = press(&mut t, &cx, 0.0, 100.0, false); - assert!(matches!(edit_op(&a[0]), Some(("close", v)) if v["from"] == "corner" && v["end"] == "end"), "{a:?}"); - // Any other end keeps its curve; the start goes on from the start. - let s = selected(id, Some(0)); - let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false); - assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}"); - } -} +mod tests; diff --git a/crates/tools/src/draw2/curvature/tests.rs b/crates/tools/src/draw2/curvature/tests.rs new file mode 100644 index 000000000..10dc9d031 --- /dev/null +++ b/crates/tools/src/draw2/curvature/tests.rs @@ -0,0 +1,405 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::{Appearance, Document, Node, Selection}; +use vectorcraft_geom::{ParamCurve, PathData}; + +/// The Curvature tool has its own cursor, not the Pen's start-a-path one. +#[test] +fn curvature_tool_shows_its_cursor() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + assert_eq!(CurvatureTool::default().cursor(&cx, Point::new(10.0, 10.0), Mods::default()), Cursor::Curvature); +} + +#[test] +fn first_click_creates_then_notify_tracks_path() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CurvatureTool::default(); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.curvature" && v.get("id").is_none() && v["points"].as_array().unwrap().len() == 1)); + assert_eq!(a[1], Action::Notify("created".into())); + // Pretend the engine created `id` with one anchor: it is a rect (4 anchors) so it doesn't match. + let mut s2 = Selection::default(); + s2.set([id]); + let cx2 = crate::testutil::cx(&d, &s2, &p); + t.notify(&cx2, "created"); + assert_eq!(t.id, Some(id)); + assert!(t.active(&cx2).is_none()); + // With four points it matches, so a click appends a fifth. + t.pts = vec![(Point::new(100.0, 100.0), false); 4]; + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)); + assert!(matches!(&a[0], Action::Exec(_, v) if v["id"] == id.0 && v["points"].as_array().unwrap().len() == 5)); +} + +/// Points snap to Smart Guides (#506): the first lands on another object's anchor and says so. +#[test] +fn points_snap_to_smart_guides() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CurvatureTool::default(); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 202.0, 99.0)); + assert!(matches!(&a[0], Action::Exec(_, v) if v["points"][0]["x"] == 200.0 && v["points"][0]["y"] == 100.0), "{a:?}"); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor"))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 202.0, 99.0)); + assert!(t.overlays(&cx).is_empty()); +} + +fn pt(x: f64, y: f64) -> Point { + Point::new(x, y) +} + +/// An open pen-style path: a corner start, two smooth anchors with uneven handles, a cusp and +/// an end with only an incoming handle. +fn wave() -> SubPath { + SubPath::new( + vec![ + Anchor::corner(pt(0.0, 100.0)), + Anchor::with_handles(pt(100.0, 0.0), pt(60.0, 10.0), pt(160.0, -15.0)), + Anchor::with_handles(pt(250.0, 80.0), pt(220.0, 30.0), pt(280.0, 130.0)), + Anchor::with_handles(pt(350.0, 150.0), pt(320.0, 160.0), pt(380.0, 100.0)), + Anchor::with_handles(pt(450.0, 60.0), pt(420.0, 90.0), pt(450.0, 60.0)), + ], + false, + ) +} + +/// Every point of `a` lies on `b` (within 1e-6), sampled. +fn lies_on(a: &SubPath, b: &SubPath) -> bool { + let b = PathData::single(b.clone()); + (0..a.segment_count()).all(|i| (0..=20).all(|k| b.nearest(a.segment(i).eval(k as f64 / 20.0)).is_some_and(|n| n.4 < 1e-6))) +} + +fn collinear(a: Vec2, b: Vec2) -> bool { + a.cross(b).abs() < 1e-6 * a.hypot().max(1.0) * b.hypot().max(1.0) +} + +#[test] +fn moving_a_point_recurves_only_its_two_segments() { + let before = wave(); + let mut sp = before.clone(); + assert_eq!(sp.anchors[2].kind, AnchorKind::Smooth); + assert!(curvature_move(&mut sp, 2, pt(260.0, 140.0))); + assert_eq!(sp.anchors[2].p, pt(260.0, 140.0)); + // The segments beyond its neighbours are bit-identical. + assert_eq!(sp.segment(0), before.segment(0)); + assert_eq!(sp.segment(3), before.segment(3)); + // The neighbours keep their handle directions on its segments, and their other handles. + assert!(collinear(sp.anchors[1].h_out - sp.anchors[1].p, before.anchors[1].h_out - before.anchors[1].p)); + assert!(collinear(sp.anchors[3].h_in - sp.anchors[3].p, before.anchors[3].h_in - before.anchors[3].p)); + assert_eq!((sp.anchors[1].h_in, sp.anchors[3].h_out), (before.anchors[1].h_in, before.anchors[3].h_out)); + // The moved smooth point takes the Curvature tool's handles, along the circle through it + // and its neighbours, whatever the spacing: between a third and two thirds of its chord. + let a = sp.anchors[2]; + let (prev, next) = (sp.anchors[1].p, sp.anchors[3].p); + assert_eq!(a.kind, AnchorKind::Smooth); + assert_eq!(Some((a.h_in, a.h_out)), smooth_handles(prev, a.p, next)); + assert!(collinear(a.h_in - a.p, a.h_out - a.p) && (a.h_in - a.p).dot(a.h_out - a.p) < 0.0); + for (h, to) in [(a.h_in, prev), (a.h_out, next)] { + let r = a.p.distance(h) / a.p.distance(to); + assert!((1.0 / 3.0 - 1e-9..=2.0 / 3.0 + 1e-9).contains(&r), "{r}"); + } + // A cusp keeps its handle directions; a moved end changes its one segment only. + assert_eq!(sp.anchors[3].kind, AnchorKind::Corner); + assert!(curvature_move(&mut sp, 3, pt(360.0, 170.0))); + assert!(collinear(sp.anchors[3].h_out - sp.anchors[3].p, before.anchors[3].h_out - before.anchors[3].p)); + let mut end = before.clone(); + assert!(curvature_move(&mut end, 4, pt(470.0, 20.0))); + assert_eq!(end.segment(2), before.segment(2)); + assert!(!curvature_move(&mut end, 5, pt(0.0, 0.0))); +} + +/// A smooth end of an open path has one segment: its one handle into the curve, mirroring the +/// neighbour's tangent in their chord, and nothing on the outer side. +#[test] +fn moving_a_smooth_end_gives_it_the_end_handle() { + let mut sp = curvature_spline(&[(pt(0.0, 0.0), false), (pt(100.0, 60.0), false), (pt(200.0, 0.0), false), (pt(300.0, 50.0), false)], false); + sp.anchors[0].kind = AnchorKind::Smooth; + let before = sp.clone(); + assert!(curvature_move(&mut sp, 0, pt(-20.0, 30.0))); + let (e, b) = (sp.anchors[0], sp.anchors[1]); + assert!(!e.has_in() && e.has_out()); + assert_eq!(Some(e.h_out), end_handle(e.p, &b, true)); + assert_eq!(sp.segment(2), before.segment(2)); + assert_eq!(sp.anchors[2], before.anchors[2]); +} + +#[test] +fn moving_a_corner_without_handles_keeps_its_sides_straight() { + let (d, id) = doc_with_rect(); + let mut sp = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); + assert!(curvature_move(&mut sp, 0, pt(80.0, 70.0))); + assert!((0..sp.segment_count()).all(|i| sp.segment_is_line(i))); + assert_eq!(sp.anchors[0], Anchor::corner(pt(80.0, 70.0))); +} + +#[test] +fn going_on_from_an_end_keeps_the_old_segments() { + let before = wave(); + let mut sp = before.clone(); + curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep, false); + assert_eq!(sp.anchors.len(), 6); + assert!((0..4).all(|i| sp.segment(i) == before.segment(i))); + // The old end turns smooth, going on along its incoming handle, a third of the chord out. + let e = sp.anchors[4]; + assert_eq!(e.kind, AnchorKind::Smooth); + assert!(collinear(e.h_out - e.p, e.p - e.h_in) && (e.h_out - e.p).dot(e.p - e.h_in) > 0.0); + assert!((e.h_out.distance(e.p) - e.p.distance(pt(550.0, 120.0)) / 3.0).abs() < 1e-9); + // The new end has no outer handle and one into the curve, mirroring the old end's tangent. + let n = sp.anchors[5]; + assert_eq!((n.p, n.has_out(), n.kind), (pt(550.0, 120.0), false, AnchorKind::Corner)); + assert_eq!(Some(n.h_in), end_handle(n.p, &e, false)); + // The point placed then curves through its neighbours as a drawn path's points do. + curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth, false); + let drawn = curvature_spline(&[(pt(450.0, 60.0), false), (pt(550.0, 120.0), false), (pt(650.0, 60.0), false)], false); + assert_eq!((sp.anchors[5].h_in, sp.anchors[5].h_out), (drawn.anchors[1].h_in, drawn.anchors[1].h_out)); + assert!((0..4).all(|i| sp.segment(i) == before.segment(i))); + // A corner stays one. + curvature_extend(&mut sp, false, pt(700.0, 100.0), EndCurve::Corner, false); + assert!(!sp.anchors[6].has_in() && !sp.anchors[6].has_out()); + // From the start: the old segments come one later, and the new end is the first anchor. + let mut sp = before.clone(); + curvature_extend(&mut sp, true, pt(-100.0, 50.0), EndCurve::Keep, false); + assert!(sp.anchors[0].p == pt(-100.0, 50.0) && !sp.anchors[0].has_in() && sp.anchors[0].has_out()); + assert!((0..4).all(|i| sp.segment(i + 1) == before.segment(i))); + // A corner end without handles goes on along its segment's tangent. + let old = sp.anchors[1]; + assert!(old.has_in() && !old.has_out()); + assert!(collinear(old.h_in - old.p, old.p - before.anchors[1].h_in) && (old.h_in - old.p).dot(old.p - before.anchors[1].h_in) > 0.0); +} + +#[test] +fn closing_keeps_the_old_segments() { + let before = wave(); + let mut sp = before.clone(); + curvature_close(&mut sp, false, EndCurve::Keep); + assert!(sp.closed && sp.anchors.len() == 5); + assert!((0..4).all(|i| sp.segment(i) == before.segment(i))); + // The first anchor gains an incoming handle along its tangent: no kink there. + let f = sp.anchors[0]; + assert!(f.has_in() && collinear(f.p - f.h_in, before.anchors[1].h_in - f.p)); +} + +#[test] +fn inserting_a_point_keeps_the_shape() { + let before = wave(); + let mut sp = before.clone(); + assert_eq!(curvature_insert(&mut sp, 1, 0.3), Some(2)); + assert_eq!(sp.anchors.len(), 6); + assert_eq!(sp.anchors[2].kind, AnchorKind::Smooth); + assert!(lies_on(&sp, &before) && lies_on(&before, &sp)); + // On a straight side the point is smooth too, its handles along the side. + let (d, id) = doc_with_rect(); + let rect = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); + let mut sp = rect.clone(); + let i = curvature_insert(&mut sp, 0, 0.5).unwrap(); + assert_eq!(sp.anchors[i].kind, AnchorKind::Smooth); + assert!(sp.anchors[i].has_in() && sp.anchors[i].has_out()); + assert!(lies_on(&sp, &rect) && lies_on(&rect, &sp)); + assert_eq!(curvature_insert(&mut sp, 99, 0.5), None); +} + +/// On a straight side the inserted point's handles lie along it, each a third of its own part +/// of the side; a drag then moves it as a smooth Curvature point. +#[test] +fn a_point_inserted_on_a_line_takes_the_chord_rule_and_drags_smooth() { + let (d, id) = doc_with_rect(); + let rect = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); + let mut sp = rect.clone(); + let i = curvature_insert(&mut sp, 0, 0.25).unwrap(); + let (a, prev, next) = (sp.anchors[i], sp.anchors[i - 1].p, sp.anchors[i + 1].p); + assert!((a.p.distance(a.h_in) - a.p.distance(prev) / 3.0).abs() < 1e-9); + assert!((a.p.distance(a.h_out) - a.p.distance(next) / 3.0).abs() < 1e-9); + assert!(collinear(a.h_out - a.p, next - prev) && (a.h_out - a.p).dot(next - prev) > 0.0); + assert!(sp.segment_is_line(0) || lies_on(&sp, &rect)); + // A drag bends it as a smooth point; the sides beyond stay straight. + let q = pt(a.p.x, a.p.y - 40.0); + assert!(curvature_move(&mut sp, i, q)); + let m = sp.anchors[i]; + assert_eq!(Some((m.h_in, m.h_out)), smooth_handles(prev, q, next)); + assert!((2..sp.segment_count()).all(|s| sp.segment_is_line(s))); +} + +#[test] +fn curvature_smooth_uses_the_tool_rule() { + let mut sp = curvature_spline(&[(pt(0.0, 0.0), true), (pt(100.0, 60.0), true), (pt(200.0, 0.0), false), (pt(300.0, 50.0), false)], false); + // A corner in the middle takes the bisector handles. + assert!(curvature_smooth(&mut sp, 1)); + let a = sp.anchors[1]; + assert_eq!((a.kind, Some((a.h_in, a.h_out))), (AnchorKind::Smooth, smooth_handles(pt(0.0, 0.0), a.p, pt(200.0, 0.0)))); + // An end curves into its segment, mirroring its neighbour's tangent. + assert!(curvature_smooth(&mut sp, 0)); + let e = sp.anchors[0]; + assert!(!e.has_in() && Some(e.h_out) == end_handle(e.p, &sp.anchors[1], true)); + // No direction: a fold, or an end whose neighbour has no handle there, stays as it is. + let mut fold = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), Anchor::corner(pt(100.0, 0.0)), Anchor::corner(pt(0.0, 0.0))], false); + let before = fold.clone(); + assert!(!curvature_smooth(&mut fold, 1) && !curvature_smooth(&mut fold, 0) && !curvature_smooth(&mut fold, 9)); + assert_eq!(fold, before); +} + +/// A curved end counts as smooth for the toggle: Alt-click turns it into a corner. +#[test] +fn alt_click_on_a_curved_end_makes_it_a_corner() { + let sp = curvature_spline(&[(pt(100.0, 300.0), false), (pt(200.0, 200.0), false), (pt(300.0, 300.0), false)], false); + let (d, id) = doc_with(sp); + let (s, p) = (selected(id, None), paint()); + let cx = quiet(&d, &s, &p); + let a = press(&mut CurvatureTool::default(), &cx, 100.0, 300.0, true); + assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "path.convertAnchor" && v["anchor"] == 0 && v["to"] == "corner")), "{a:?}"); +} + +/// A document with one path, `sp`. +fn doc_with(sp: SubPath) -> (Document, NodeId) { + let mut d = Document::new(800.0, 600.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, PathData::single(sp), Appearance::default_art())).unwrap(); + (d, id) +} + +/// Path `id` selected, anchor `current` of its first subpath direct-selected (or none). +fn selected(id: NodeId, current: Option) -> Selection { + let mut s = Selection::default(); + s.set([id]); + if let Some(ai) = current { + s.anchors.insert(id, [(0, ai)].into()); + } + s +} + +/// A context without snapping. +fn quiet<'a>(d: &'a Document, s: &'a Selection, p: &'a crate::PaintDefaults) -> ToolContext<'a> { + ToolContext { smart_guides: false, snap_to_point: false, ..cx(d, s, p) } +} + +fn press(t: &mut CurvatureTool, cx: &ToolContext, x: f64, y: f64, alt: bool) -> Vec { + let ev = PointerEvent::new(PointerKind::Down, x, y).with_mods(Mods { alt, ..Mods::default() }); + let mut a = t.pointer(cx, &ev); + a.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y))); + a +} + +/// The op and params of a `path.curvatureEdit` action. +fn edit_op(a: &Action) -> Option<(&str, &Value)> { + match a { + Action::Exec(c, v) | Action::Preview(c, v) if c == EDIT => Some((v["op"].as_str()?, v)), + _ => None, + } +} + +#[test] +fn a_press_on_a_point_selects_it_and_a_drag_moves_it() { + let (d, id) = doc_with(wave()); + let (s, p) = (selected(id, None), paint()); + let cx = quiet(&d, &s, &p); + let mut t = CurvatureTool::default(); + assert_eq!(t.cursor(&cx, pt(250.0, 81.0), Mods::default()), Cursor::Move); + assert_eq!(t.cursor(&cx, pt(450.0, 61.0), Mods::default()), Cursor::PenContinue); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 251.0, 81.0)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0)); + assert_eq!(a[0], Action::Begin("Curvature".into())); + let moved = edit_op(&a[1]).map(|(op, v)| (op, v["anchor"].clone(), v["x"].clone(), v["y"].clone())); + assert_eq!(moved, Some(("move", json!(2), json!(260.0), json!(140.0)))); + assert!(t.busy()); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]); + // A press without a drag only selects. + assert_eq!(press(&mut t, &cx, 100.0, 0.0, false).len(), 1); +} + +/// #2120: the dragged end of a slanted straight path keeps to its extension (Smart Guides). +#[test] +fn a_dragged_end_of_a_straight_path_keeps_to_its_extension() { + let (d, id) = doc_with(SubPath::polyline(&[pt(300.0, 400.0), pt(380.0, 340.0)], false)); + let (s, p) = (selected(id, None), paint()); + let cx = cx(&d, &s, &p); + let mut t = CurvatureTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 380.0, 340.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0)); + let moved = a.get(1).and_then(edit_op).map(|(op, v)| (op, v["x"].as_f64().unwrap(), v["y"].as_f64().unwrap())); + assert!(matches!(moved, Some(("move", x, y)) if (x - 420.0).abs() < 1e-9 && (y - 310.0).abs() < 1e-9), "{a:?}"); +} + +#[test] +fn alt_click_or_double_click_toggles_and_delete_removes() { + let (d, id) = doc_with(wave()); + let p = paint(); + let s = selected(id, None); + let cx = quiet(&d, &s, &p); + let mut t = CurvatureTool::default(); + let to = |a: &[Action]| a.iter().find(|a| matches!(a, Action::Exec(c, _) if c == "path.convertAnchor")).cloned(); + let a = press(&mut t, &cx, 100.0, 0.0, true); + assert!(matches!(to(&a), Some(Action::Exec(_, v)) if v["anchor"] == 1 && v["to"] == "corner"), "{a:?}"); + assert_eq!(t.cursor(&cx, pt(100.0, 0.0), Mods { alt: true, ..Mods::default() }), Cursor::PenConvert); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, 0.0, 100.0)); + assert!(matches!(to(&a), Some(Action::Exec(_, v)) if v["anchor"] == 0 && v["to"] == "smooth"), "{a:?}"); + // Delete is the tool's while a point is current, and removes it. + assert!(!t.claims_key(&cx, ToolKey::Delete)); + let s = selected(id, Some(2)); + let cx = quiet(&d, &s, &p); + assert!(t.claims_key(&cx, ToolKey::Backspace)); + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![remove_anchor((id, 0, 2))]); + // Esc lets go of the point. + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); +} + +#[test] +fn a_press_on_a_segment_adds_a_point_and_a_drag_moves_it_in_one_step() { + let (d, id) = doc_with(wave()); + let (s, p) = (selected(id, None), paint()); + let cx = quiet(&d, &s, &p); + let mut t = CurvatureTool::default(); + let on = wave().segment(1).eval(0.5); + assert_eq!(t.cursor(&cx, on, Mods::default()), Cursor::PenAdd); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on.x, on.y)); + assert_eq!(a[0], Action::Begin("Curvature".into())); + assert!( + matches!(edit_op(&a[1]), Some(("insert", v)) if v["segment"] == 1 && (v["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && v.get("x").is_none()) + ); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, on.x + 1.0, on.y + 40.0)); + assert!(matches!(edit_op(&a[0]), Some(("insert", v)) if v["segment"] == 1 && v["y"] == on.y + 40.0), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, on.x, on.y + 40.0)), vec![Action::Commit]); +} + +#[test] +fn clicks_go_on_from_a_current_end_and_the_other_end_closes() { + let p = paint(); + let mut sp = wave(); + let mut t = CurvatureTool::default(); + let (d, id) = doc_with(sp.clone()); + let s = selected(id, Some(4)); + let a = press(&mut t, &quiet(&d, &s, &p), 550.0, 120.0, false); + assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "end" && v["from"] == "keep" && v["x"] == 550.0), "{a:?}"); + // As the engine does it: the new end is current, and the next click curves through it. + curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep, false); + let (d, id) = doc_with(sp.clone()); + let s = selected(id, Some(5)); + let cx = quiet(&d, &s, &p); + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 650.0, 60.0)); + let ovs = t.overlays(&cx); + assert!(ovs.iter().any(|o| matches!(o, Overlay::Path { .. })), "the rubber band shows"); + let from = pt(550.0, 120.0); + let to = pt(650.0, 60.0); + assert!(ovs.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); + let a = press(&mut t, &cx, 650.0, 60.0, true); + assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["from"] == "smooth"), "{a:?}"); + curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth, false); + let (d, id) = doc_with(sp.clone()); + let s = selected(id, Some(6)); + let cx = quiet(&d, &s, &p); + // The Alt-clicked point stays a corner; a click on the first point closes. + assert_eq!(t.cursor(&cx, pt(0.0, 100.0), Mods::default()), Cursor::PenClose); + let a = press(&mut t, &cx, 0.0, 100.0, false); + assert!(matches!(edit_op(&a[0]), Some(("close", v)) if v["from"] == "corner" && v["end"] == "end"), "{a:?}"); + // Any other end keeps its curve; the start goes on from the start. + let s = selected(id, Some(0)); + let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false); + assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}"); +} From 11c85f47f1e22fefe94b722b27b3bc032c7d44ec Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:12 +0000 Subject: [PATCH 063/199] refactor(ui-egui): move dialogs/width_point.rs tests to dialogs/width_point/tests.rs --- crates/ui-egui/src/dialogs/width_point.rs | 404 +----------------- .../ui-egui/src/dialogs/width_point/tests.rs | 401 +++++++++++++++++ 2 files changed, 402 insertions(+), 403 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/width_point/tests.rs diff --git a/crates/ui-egui/src/dialogs/width_point.rs b/crates/ui-egui/src/dialogs/width_point.rs index 66e47d76a..afa27c2ab 100644 --- a/crates/ui-egui/src/dialogs/width_point.rs +++ b/crates/ui-egui/src/dialogs/width_point.rs @@ -124,406 +124,4 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_engine::Session; - use vectorcraft_tools::{PointerEvent, PointerKind}; - - /// A 10 pt line from (100, 150) to (300, 150) with a 4 + 4 pt width point in the middle. - fn app() -> (VectorcraftApp, u64) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let id = app.run("shape.line", json!({"x1": 100, "y1": 150, "x2": 300, "y2": 150})).unwrap()["id"].as_u64().unwrap(); - app.run("stroke.set", json!({"ids": [id], "weight": 10})).unwrap(); - app.run("stroke.widthPoint.set", json!({"id": id, "t": 0.5, "left": 4, "right": 4})).unwrap(); - (app, id) - } - - fn points(app: &VectorcraftApp, id: u64) -> Vec<(f64, f64, f64)> { - let st = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke().unwrap().clone(); - let round = |v: f64| (v * 1e9).round() / 1e9; - st.profile.map(|p| p.points.into_iter().map(|(t, l, r)| (round(t), round(l), round(r))).collect()).unwrap_or_default() - } - - fn frame(app: &mut VectorcraftApp) -> Vec { - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - // A new window lays itself out in its first frame and paints in the next. - ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear(); - let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); - out.textures_delta.clear(); - let mut v = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); - v - } - - #[test] - fn double_click_opens_it_and_ok_sets_the_widths() { - for shift in [false, true] { - let (mut app, id) = app(); - let before = app.session.doc().unwrap().doc.clone(); - let undo = app.session.doc().unwrap().history.undo.len(); - let view = app.view_info(); - app.select_tool("width"); - let mut ev = PointerEvent::new(PointerKind::DoubleClick, 200.0, 150.0); - ev.mods.shift = shift; - crate::canvas::dispatch(&mut app, &ev, view); - let d = app.ui.dialog.clone().expect("Width Point Edit opened"); - assert_eq!((d.kind.as_str(), d.f64("side1", 0.0), d.f64("side2", 0.0), d.bool("linked")), (KIND, 4.0, 4.0, true)); - assert_eq!(d.bool("adjustAdjoining"), shift); - let shown = frame(&mut app); - for s in ["Width Point Edit", "Side 1:", "Side 2:", "Total Width:", "Adjust Adjoining Width Points", "Delete"] { - assert!(shown.iter().any(|t| t == s), "{s} in {shown:?}"); - } - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("side1".into(), json!(6)); - d.fields.insert("side2".into(), json!(2)); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - let ends = if shift { (1.5, 0.5) } else { (1.0, 1.0) }; - assert_eq!(points(&app, id), vec![(0.0, ends.0, ends.1), (0.5, 1.2, 0.4), (1.0, ends.0, ends.1)]); - assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); - if shift { - let edited = app.session.doc().unwrap().doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, before); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, edited); - crate::canvas::dispatch(&mut app, &ev, view); - app.ui.dialog.as_mut().unwrap().fields.insert("side1".into(), json!(20)); - super::super::cancel(&mut app); - assert!(app.ui.dialog.is_none()); - let st = app.session.doc().unwrap(); - assert_eq!(st.doc, edited); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); - } - } - } - - #[test] - fn delete_removes_the_point() { - let (mut app, id) = app(); - app.run("ui.widthPointEdit", json!({"id": id, "index": 1})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); - super::super::confirm(&mut app).unwrap(); - assert_eq!(points(&app, id).len(), 2); - assert!(app.run("ui.widthPointEdit", json!({"id": id, "index": 9})).is_err()); - } - - #[test] - fn it_does_not_stretch_to_the_screen() { - let height = |screen_h: f32| { - let (mut app, id) = app(); - app.run("ui.widthPointEdit", json!({"id": id, "index": 1})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, screen_h)); - for _ in 0..4 { - let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; - ctx.run_ui(input, |ui| super::super::show(&mut app, ui.ctx())).textures_delta.clear(); - } - ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap().height() - }; - let (short, tall) = (height(1000.0), height(2000.0)); - assert!((short - tall).abs() < 1.0 && short < 400.0, "{short} vs {tall}"); - } - - fn creation_app() -> (VectorcraftApp, u64) { - let (mut app, id) = app(); - app.run("stroke.set", json!({"ids": [id], "profile": "uniform"})).unwrap(); - app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 30, "height": 40})).unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.select_tool("width"); - (app, id) - } - - fn creation_history(app: &VectorcraftApp) -> (usize, usize, bool) { - let st = app.session.doc().unwrap(); - (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) - } - - /// The actual tool-event sequence from two stationary canvas clicks. No profile or dialog - /// is installed by the fixture after either click; DoubleClick must open the pending point. - fn creation_double_click(app: &mut VectorcraftApp, x: f64, y: f64) { - let before = app.session.doc().unwrap().doc.clone(); - let history = creation_history(app); - let view = app.view_info(); - for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up] { - crate::canvas::dispatch(app, &PointerEvent::new(kind, x, y), view); - assert!(app.ui.dialog.is_none()); - assert_eq!(app.session.doc().unwrap().doc, before, "click alone must not add a width point"); - assert_eq!(creation_history(app), history); - } - assert!(!app.session.in_interaction()); - crate::canvas::dispatch(app, &PointerEvent::new(PointerKind::DoubleClick, x, y), view); - assert!(app.ui.dialog.is_some(), "double-clicking a stroked segment must open Width Point Edit"); - assert_eq!(app.session.doc().unwrap().doc, before, "opening creates no point yet"); - assert_eq!(creation_history(app), history); - assert!(!app.session.in_interaction()); - } - - #[test] - fn new_width_point_double_click_creates_with_history() { - let (mut app, id) = creation_app(); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.clone(); - let selection = app.session.doc().unwrap().selection.clone(); - let history = creation_history(&app); - creation_double_click(&mut app, 200.0, 150.0); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.kind, KIND); - assert_eq!(d.fields["id"], json!(id)); - assert!(d.fields.get("index").is_none_or(Value::is_null)); - assert!((d.f64("t", -1.0) - 0.5).abs() < 1e-6); - assert_eq!((d.f64("side1", 0.0), d.f64("side2", 0.0), d.bool("linked")), (5.0, 5.0, true)); - assert!(!d.bool("adjustAdjoining")); - let shown = frame(&mut app); - for label in ["Width Point Edit", "Side 1:", "Side 2:", "Total Width:", "Cancel"] { - assert!(shown.iter().any(|text| text == label), "{label} in {shown:?}"); - } - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("side1".into(), json!(6)); - d.fields.insert("side2".into(), json!(2)); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - assert_eq!(points(&app, id), vec![(0.0, 1.0, 1.0), (0.5, 1.2, 0.4), (1.0, 1.0, 1.0)]); - let after = app.session.doc().unwrap().doc.clone(); - assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind, "path geometry remains unchanged"); - before.walk(|node| { - if node.id != NodeId(id) && matches!(node.kind, vectorcraft_engine::doc::NodeKind::Path { .. }) { - assert_eq!(after.node(node.id), Some(node), "other artwork remains unchanged"); - } - }); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); - assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Width Point"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, before); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(creation_history(&app), (history.0, 1, false)); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, after); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); - } - - #[test] - fn new_width_point_cancel_and_discard_keep_redo() { - for discard in [false, true] { - let (mut app, _) = creation_app(); - app.run("shape.rectangle", json!({"x": 50, "y": 20, "width": 20, "height": 20})).unwrap(); - let redo = app.session.doc().unwrap().doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.clone(); - let selection = app.session.doc().unwrap().selection.clone(); - let history = creation_history(&app); - assert_eq!(history.1, 1); - creation_double_click(&mut app, 200.0, 150.0); - app.ui.dialog.as_mut().unwrap().fields.insert("side1".into(), json!(20)); - if discard { - app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); - super::super::confirm(&mut app).unwrap(); - } else { - super::super::cancel(&mut app); - } - assert!(app.ui.dialog.is_none()); - assert_eq!(app.session.doc().unwrap().doc, before); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(creation_history(&app), history); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, redo); - } - } - - #[test] - fn new_width_point_profile_and_compound_target() { - for compound in [false, true] { - let (mut app, line) = creation_app(); - let (id, member) = if compound { - let second = app.run("shape.line", json!({"x1": 320, "y1": 50, "x2": 320, "y2": 250})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [line, second]})).unwrap(); - (app.run("object.compoundPath.make", json!({})).unwrap()["id"].as_u64().unwrap(), second) - } else { - (line, line) - }; - app.run("stroke.widthProfile.set", json!({"ids": [id], "points": [[0, 1, 0.5], [1, 2, 1.5]]})).unwrap(); - app.select_tool("width"); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.clone(); - let selection = app.session.doc().unwrap().selection.clone(); - let history = creation_history(&app); - // Make Compound Path can reverse the member. Choose the quarter point from its - // stored direction; on a straight segment this is exactly one quarter of its length. - let path = before.node(NodeId(member)).unwrap().path_data().unwrap(); - assert_eq!(path.subpaths.len(), 1); - let subpath = &path.subpaths[0]; - assert_eq!(subpath.anchors.len(), 2); - assert!(subpath.segment_is_line(0)); - let quarter = subpath.anchors[0].p.lerp(subpath.anchors[1].p, 0.25); - creation_double_click(&mut app, quarter.x, quarter.y); - let d = app.ui.dialog.as_mut().unwrap(); - assert_eq!(d.fields["id"], json!(id), "the compound owns its members' stroke"); - assert!(d.fields.get("index").is_none_or(Value::is_null)); - let actual_t = d.f64("t", -1.0); - assert!((actual_t - 0.25).abs() < 1e-6, "compound={compound} pointer={quarter:?} actual_t={actual_t} want=0.25"); - assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6); - assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6); - assert!(!d.bool("linked")); - d.fields.insert("side1".into(), json!(8)); - d.fields.insert("side2".into(), json!(3)); - super::super::confirm(&mut app).unwrap(); - assert_eq!(points(&app, id), vec![(0.0, 1.0, 0.5), (0.25, 1.6, 0.6), (1.0, 2.0, 1.5)]); - let after = app.session.doc().unwrap().doc.clone(); - assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind, "member paths retain their geometry"); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, before); - assert_eq!(creation_history(&app), (history.0, 1, false)); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, after); - } - } - - #[test] - fn new_width_point_command_opens_pending_point() { - let (mut app, id) = creation_app(); - let before = app.session.doc().unwrap().doc.clone(); - let history = creation_history(&app); - app.run("ui.widthPointEdit", json!({"id": id, "t": 0.25})).expect("the UI command accepts a prospective point"); - let d = app.ui.dialog.as_ref().unwrap(); - assert!(d.fields.get("index").is_none_or(Value::is_null)); - assert_eq!((d.f64("t", -1.0), d.f64("side1", 0.0), d.f64("side2", 0.0)), (0.25, 5.0, 5.0)); - super::super::cancel(&mut app); - assert_eq!(app.session.doc().unwrap().doc, before); - assert_eq!(creation_history(&app), history); - } - - #[test] - fn new_width_point_invalid_command_keeps_document() { - let (mut app, id) = creation_app(); - let before = app.session.doc().unwrap().doc.clone(); - let history = creation_history(&app); - for p in [ - json!({"id": id, "t": -0.01}), - json!({"id": id, "t": 1.01}), - json!({"id": id, "t": "invalid"}), - json!({"id": id, "index": "invalid", "t": 0.5}), - json!({"id": id, "index": 999, "t": 0.5}), - ] { - assert!(app.run("ui.widthPointEdit", p).is_err()); - assert!(app.ui.dialog.is_none()); - assert_eq!(app.session.doc().unwrap().doc, before); - assert_eq!(creation_history(&app), history); - } - } - - fn creation_dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { - fn collect(shape: &egui::Shape, clip: egui::Rect, texts: &mut Vec<(String, egui::Rect)>) { - match shape { - egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => { - texts.push((t.galley.text().to_string(), t.visual_bounding_rect())); - } - egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, clip, texts)), - _ => {} - } - } - let input = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0))), events, ..Default::default() }; - let mut output = ctx.run_ui(input, |ui| super::super::show(app, ui.ctx())); - output.textures_delta.clear(); - let mut texts = vec![]; - output.shapes.iter().for_each(|shape| collect(&shape.shape, shape.clip_rect, &mut texts)); - texts - } - - fn creation_dialog_click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { - for _ in 0..4 { - creation_dialog_frame(app, ctx, vec![]); - } - let texts = creation_dialog_frame(app, ctx, vec![]); - let pos = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("missing visible control {label}: {texts:?}")).1.center(); - for pressed in [true, false] { - creation_dialog_frame( - app, - ctx, - vec![ - egui::Event::PointerMoved(pos), - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, - ], - ); - } - creation_dialog_frame(app, ctx, vec![]); - } - - fn creation_adjoining_checkbox(adjust: bool) { - let (mut app, id) = creation_app(); - app.run("stroke.widthProfile.set", json!({"ids": [id], "points": [[0, 1, 1], [1, 1, 1]]})).unwrap(); - app.session.doc_mut().unwrap().mark_saved(); - let before = app.session.doc().unwrap().doc.clone(); - let selection = app.session.doc().unwrap().selection.clone(); - let history = creation_history(&app); - creation_double_click(&mut app, 200.0, 150.0); - assert!(app.ui.dialog.as_ref().unwrap().fields["index"].is_null()); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, Default::default()); - for _ in 0..5 { - creation_dialog_frame(&mut app, &ctx, vec![]); - } - // The dialog naturally focuses and selects Side 1. Its linked partner changes - // when the checkbox click commits the typed value; no fields are seeded. - assert!(ctx.memory(|m| m.focused()).is_some()); - creation_dialog_frame(&mut app, &ctx, vec![egui::Event::Text("10 pt".into())]); - creation_dialog_click(&mut app, &ctx, "Adjust Adjoining Width Points"); - assert!(app.ui.dialog.as_ref().unwrap().bool("adjustAdjoining"), "real checkbox click enables adjustment"); - if !adjust { - creation_dialog_click(&mut app, &ctx, "Adjust Adjoining Width Points"); - } - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.bool("adjustAdjoining"), adjust); - assert_eq!((d.f64("side1", 0.0), d.f64("side2", 0.0)), (10.0, 10.0)); - assert_eq!(app.session.doc().unwrap().doc, before); - assert_eq!(creation_history(&app), history); - creation_dialog_click(&mut app, &ctx, "OK"); - assert!(app.ui.dialog.is_none(), "real OK click confirms the pending point"); - let end = if adjust { 2.0 } else { 1.0 }; - assert_eq!(points(&app, id), vec![(0.0, end, end), (0.5, 2.0, 2.0), (1.0, end, end)], "checkbox choice controls adjoining widths"); - let after = app.session.doc().unwrap().doc.clone(); - assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind); - before.walk(|node| { - if node.id != NodeId(id) && matches!(node.kind, vectorcraft_engine::doc::NodeKind::Path { .. }) { - assert_eq!(after.node(node.id), Some(node), "other artwork remains unchanged"); - } - }); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); - assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Width Point"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, before); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(creation_history(&app), (history.0, 1, false)); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, after); - assert_eq!(app.session.doc().unwrap().selection, selection); - assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); - } - - #[test] - fn new_width_point_checkbox_adjusts_adjoining_in_one_step() { - creation_adjoining_checkbox(true); - } - - #[test] - fn new_width_point_checkbox_off_preserves_adjoining() { - creation_adjoining_checkbox(false); - } -} +mod tests; diff --git a/crates/ui-egui/src/dialogs/width_point/tests.rs b/crates/ui-egui/src/dialogs/width_point/tests.rs new file mode 100644 index 000000000..536c558cf --- /dev/null +++ b/crates/ui-egui/src/dialogs/width_point/tests.rs @@ -0,0 +1,401 @@ +use super::*; +use vectorcraft_engine::Session; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +/// A 10 pt line from (100, 150) to (300, 150) with a 4 + 4 pt width point in the middle. +fn app() -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.line", json!({"x1": 100, "y1": 150, "x2": 300, "y2": 150})).unwrap()["id"].as_u64().unwrap(); + app.run("stroke.set", json!({"ids": [id], "weight": 10})).unwrap(); + app.run("stroke.widthPoint.set", json!({"id": id, "t": 0.5, "left": 4, "right": 4})).unwrap(); + (app, id) +} + +fn points(app: &VectorcraftApp, id: u64) -> Vec<(f64, f64, f64)> { + let st = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke().unwrap().clone(); + let round = |v: f64| (v * 1e9).round() / 1e9; + st.profile.map(|p| p.points.into_iter().map(|(t, l, r)| (round(t), round(l), round(r))).collect()).unwrap_or_default() +} + +fn frame(app: &mut VectorcraftApp) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // A new window lays itself out in its first frame and paints in the next. + ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear(); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v +} + +#[test] +fn double_click_opens_it_and_ok_sets_the_widths() { + for shift in [false, true] { + let (mut app, id) = app(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let view = app.view_info(); + app.select_tool("width"); + let mut ev = PointerEvent::new(PointerKind::DoubleClick, 200.0, 150.0); + ev.mods.shift = shift; + crate::canvas::dispatch(&mut app, &ev, view); + let d = app.ui.dialog.clone().expect("Width Point Edit opened"); + assert_eq!((d.kind.as_str(), d.f64("side1", 0.0), d.f64("side2", 0.0), d.bool("linked")), (KIND, 4.0, 4.0, true)); + assert_eq!(d.bool("adjustAdjoining"), shift); + let shown = frame(&mut app); + for s in ["Width Point Edit", "Side 1:", "Side 2:", "Total Width:", "Adjust Adjoining Width Points", "Delete"] { + assert!(shown.iter().any(|t| t == s), "{s} in {shown:?}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("side1".into(), json!(6)); + d.fields.insert("side2".into(), json!(2)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let ends = if shift { (1.5, 0.5) } else { (1.0, 1.0) }; + assert_eq!(points(&app, id), vec![(0.0, ends.0, ends.1), (0.5, 1.2, 0.4), (1.0, ends.0, ends.1)]); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + if shift { + let edited = app.session.doc().unwrap().doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, edited); + crate::canvas::dispatch(&mut app, &ev, view); + app.ui.dialog.as_mut().unwrap().fields.insert("side1".into(), json!(20)); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc, edited); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); + } + } +} + +#[test] +fn delete_removes_the_point() { + let (mut app, id) = app(); + app.run("ui.widthPointEdit", json!({"id": id, "index": 1})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(points(&app, id).len(), 2); + assert!(app.run("ui.widthPointEdit", json!({"id": id, "index": 9})).is_err()); +} + +#[test] +fn it_does_not_stretch_to_the_screen() { + let height = |screen_h: f32| { + let (mut app, id) = app(); + app.run("ui.widthPointEdit", json!({"id": id, "index": 1})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, screen_h)); + for _ in 0..4 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| super::super::show(&mut app, ui.ctx())).textures_delta.clear(); + } + ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap().height() + }; + let (short, tall) = (height(1000.0), height(2000.0)); + assert!((short - tall).abs() < 1.0 && short < 400.0, "{short} vs {tall}"); +} + +fn creation_app() -> (VectorcraftApp, u64) { + let (mut app, id) = app(); + app.run("stroke.set", json!({"ids": [id], "profile": "uniform"})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 30, "height": 40})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("width"); + (app, id) +} + +fn creation_history(app: &VectorcraftApp) -> (usize, usize, bool) { + let st = app.session.doc().unwrap(); + (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) +} + +/// The actual tool-event sequence from two stationary canvas clicks. No profile or dialog +/// is installed by the fixture after either click; DoubleClick must open the pending point. +fn creation_double_click(app: &mut VectorcraftApp, x: f64, y: f64) { + let before = app.session.doc().unwrap().doc.clone(); + let history = creation_history(app); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(app, &PointerEvent::new(kind, x, y), view); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc, before, "click alone must not add a width point"); + assert_eq!(creation_history(app), history); + } + assert!(!app.session.in_interaction()); + crate::canvas::dispatch(app, &PointerEvent::new(PointerKind::DoubleClick, x, y), view); + assert!(app.ui.dialog.is_some(), "double-clicking a stroked segment must open Width Point Edit"); + assert_eq!(app.session.doc().unwrap().doc, before, "opening creates no point yet"); + assert_eq!(creation_history(app), history); + assert!(!app.session.in_interaction()); +} + +#[test] +fn new_width_point_double_click_creates_with_history() { + let (mut app, id) = creation_app(); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = creation_history(&app); + creation_double_click(&mut app, 200.0, 150.0); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.kind, KIND); + assert_eq!(d.fields["id"], json!(id)); + assert!(d.fields.get("index").is_none_or(Value::is_null)); + assert!((d.f64("t", -1.0) - 0.5).abs() < 1e-6); + assert_eq!((d.f64("side1", 0.0), d.f64("side2", 0.0), d.bool("linked")), (5.0, 5.0, true)); + assert!(!d.bool("adjustAdjoining")); + let shown = frame(&mut app); + for label in ["Width Point Edit", "Side 1:", "Side 2:", "Total Width:", "Cancel"] { + assert!(shown.iter().any(|text| text == label), "{label} in {shown:?}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("side1".into(), json!(6)); + d.fields.insert("side2".into(), json!(2)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(points(&app, id), vec![(0.0, 1.0, 1.0), (0.5, 1.2, 0.4), (1.0, 1.0, 1.0)]); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind, "path geometry remains unchanged"); + before.walk(|node| { + if node.id != NodeId(id) && matches!(node.kind, vectorcraft_engine::doc::NodeKind::Path { .. }) { + assert_eq!(after.node(node.id), Some(node), "other artwork remains unchanged"); + } + }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); + assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Width Point"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0, 1, false)); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); +} + +#[test] +fn new_width_point_cancel_and_discard_keep_redo() { + for discard in [false, true] { + let (mut app, _) = creation_app(); + app.run("shape.rectangle", json!({"x": 50, "y": 20, "width": 20, "height": 20})).unwrap(); + let redo = app.session.doc().unwrap().doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = creation_history(&app); + assert_eq!(history.1, 1); + creation_double_click(&mut app, 200.0, 150.0); + app.ui.dialog.as_mut().unwrap().fields.insert("side1".into(), json!(20)); + if discard { + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + } else { + super::super::cancel(&mut app); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), history); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, redo); + } +} + +#[test] +fn new_width_point_profile_and_compound_target() { + for compound in [false, true] { + let (mut app, line) = creation_app(); + let (id, member) = if compound { + let second = app.run("shape.line", json!({"x1": 320, "y1": 50, "x2": 320, "y2": 250})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [line, second]})).unwrap(); + (app.run("object.compoundPath.make", json!({})).unwrap()["id"].as_u64().unwrap(), second) + } else { + (line, line) + }; + app.run("stroke.widthProfile.set", json!({"ids": [id], "points": [[0, 1, 0.5], [1, 2, 1.5]]})).unwrap(); + app.select_tool("width"); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = creation_history(&app); + // Make Compound Path can reverse the member. Choose the quarter point from its + // stored direction; on a straight segment this is exactly one quarter of its length. + let path = before.node(NodeId(member)).unwrap().path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1); + let subpath = &path.subpaths[0]; + assert_eq!(subpath.anchors.len(), 2); + assert!(subpath.segment_is_line(0)); + let quarter = subpath.anchors[0].p.lerp(subpath.anchors[1].p, 0.25); + creation_double_click(&mut app, quarter.x, quarter.y); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["id"], json!(id), "the compound owns its members' stroke"); + assert!(d.fields.get("index").is_none_or(Value::is_null)); + let actual_t = d.f64("t", -1.0); + assert!((actual_t - 0.25).abs() < 1e-6, "compound={compound} pointer={quarter:?} actual_t={actual_t} want=0.25"); + assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6); + assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6); + assert!(!d.bool("linked")); + d.fields.insert("side1".into(), json!(8)); + d.fields.insert("side2".into(), json!(3)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(points(&app, id), vec![(0.0, 1.0, 0.5), (0.25, 1.6, 0.6), (1.0, 2.0, 1.5)]); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind, "member paths retain their geometry"); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(creation_history(&app), (history.0, 1, false)); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + } +} + +#[test] +fn new_width_point_command_opens_pending_point() { + let (mut app, id) = creation_app(); + let before = app.session.doc().unwrap().doc.clone(); + let history = creation_history(&app); + app.run("ui.widthPointEdit", json!({"id": id, "t": 0.25})).expect("the UI command accepts a prospective point"); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.fields.get("index").is_none_or(Value::is_null)); + assert_eq!((d.f64("t", -1.0), d.f64("side1", 0.0), d.f64("side2", 0.0)), (0.25, 5.0, 5.0)); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(creation_history(&app), history); +} + +#[test] +fn new_width_point_invalid_command_keeps_document() { + let (mut app, id) = creation_app(); + let before = app.session.doc().unwrap().doc.clone(); + let history = creation_history(&app); + for p in [ + json!({"id": id, "t": -0.01}), + json!({"id": id, "t": 1.01}), + json!({"id": id, "t": "invalid"}), + json!({"id": id, "index": "invalid", "t": 0.5}), + json!({"id": id, "index": 999, "t": 0.5}), + ] { + assert!(app.run("ui.widthPointEdit", p).is_err()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(creation_history(&app), history); + } +} + +fn creation_dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + fn collect(shape: &egui::Shape, clip: egui::Rect, texts: &mut Vec<(String, egui::Rect)>) { + match shape { + egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => { + texts.push((t.galley.text().to_string(), t.visual_bounding_rect())); + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, clip, texts)), + _ => {} + } + } + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| super::super::show(app, ui.ctx())); + output.textures_delta.clear(); + let mut texts = vec![]; + output.shapes.iter().for_each(|shape| collect(&shape.shape, shape.clip_rect, &mut texts)); + texts +} + +fn creation_dialog_click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + for _ in 0..4 { + creation_dialog_frame(app, ctx, vec![]); + } + let texts = creation_dialog_frame(app, ctx, vec![]); + let pos = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("missing visible control {label}: {texts:?}")).1.center(); + for pressed in [true, false] { + creation_dialog_frame( + app, + ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ); + } + creation_dialog_frame(app, ctx, vec![]); +} + +fn creation_adjoining_checkbox(adjust: bool) { + let (mut app, id) = creation_app(); + app.run("stroke.widthProfile.set", json!({"ids": [id], "points": [[0, 1, 1], [1, 1, 1]]})).unwrap(); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = creation_history(&app); + creation_double_click(&mut app, 200.0, 150.0); + assert!(app.ui.dialog.as_ref().unwrap().fields["index"].is_null()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + for _ in 0..5 { + creation_dialog_frame(&mut app, &ctx, vec![]); + } + // The dialog naturally focuses and selects Side 1. Its linked partner changes + // when the checkbox click commits the typed value; no fields are seeded. + assert!(ctx.memory(|m| m.focused()).is_some()); + creation_dialog_frame(&mut app, &ctx, vec![egui::Event::Text("10 pt".into())]); + creation_dialog_click(&mut app, &ctx, "Adjust Adjoining Width Points"); + assert!(app.ui.dialog.as_ref().unwrap().bool("adjustAdjoining"), "real checkbox click enables adjustment"); + if !adjust { + creation_dialog_click(&mut app, &ctx, "Adjust Adjoining Width Points"); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.bool("adjustAdjoining"), adjust); + assert_eq!((d.f64("side1", 0.0), d.f64("side2", 0.0)), (10.0, 10.0)); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(creation_history(&app), history); + creation_dialog_click(&mut app, &ctx, "OK"); + assert!(app.ui.dialog.is_none(), "real OK click confirms the pending point"); + let end = if adjust { 2.0 } else { 1.0 }; + assert_eq!(points(&app, id), vec![(0.0, end, end), (0.5, 2.0, 2.0), (1.0, end, end)], "checkbox choice controls adjoining widths"); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind); + before.walk(|node| { + if node.id != NodeId(id) && matches!(node.kind, vectorcraft_engine::doc::NodeKind::Path { .. }) { + assert_eq!(after.node(node.id), Some(node), "other artwork remains unchanged"); + } + }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); + assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Width Point"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0, 1, false)); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); +} + +#[test] +fn new_width_point_checkbox_adjusts_adjoining_in_one_step() { + creation_adjoining_checkbox(true); +} + +#[test] +fn new_width_point_checkbox_off_preserves_adjoining() { + creation_adjoining_checkbox(false); +} From 212cef578747a6883b6456ffebb1b729b7c08d43 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:13 +0000 Subject: [PATCH 064/199] refactor(vectorcraft): move control_server.rs tests to control_server/tests.rs --- apps/vectorcraft/src/control_server.rs | 397 +------------------ apps/vectorcraft/src/control_server/tests.rs | 394 ++++++++++++++++++ 2 files changed, 395 insertions(+), 396 deletions(-) create mode 100644 apps/vectorcraft/src/control_server/tests.rs diff --git a/apps/vectorcraft/src/control_server.rs b/apps/vectorcraft/src/control_server.rs index c7886df3e..508ab0daf 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -289,399 +289,4 @@ fn serve_lines_with( } #[cfg(test)] -mod tests { - use super::*; - - fn short_timeouts() -> ConnectionTimeouts { - ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) } - } - - /// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a - /// busy machine either thread can pause for more than 150 ms between connecting and sending, or - /// between starting a reply and writing it, and a short budget then closes the connection with no - /// reply. - fn line_timeouts() -> ConnectionTimeouts { - ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() } - } - - fn socket_fixture( - timeouts: ConnectionTimeouts, - handle: impl FnMut(String, Value) -> Option + Send + 'static, - ) -> (TcpStream, Receiver<()>, std::thread::JoinHandle<()>) { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let addr = listener.local_addr().unwrap(); - let (finished, done) = channel(); - let worker = std::thread::spawn(move || { - let (stream, _) = listener.accept().unwrap(); - socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap(); - serve_socket(stream, timeouts, handle); - finished.send(()).unwrap(); - }); - let client = TcpStream::connect(addr).unwrap(); - socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap(); - client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); - (client, done, worker) - } - - fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) { - let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); - // Always unblock the old implementation too, so a failed regression leaves no worker behind. - let _ = client.shutdown(std::net::Shutdown::Both); - drop(client); - worker.join().unwrap(); - assert!(stopped, "the server retained a stalled connection beyond its transport deadline"); - } - - #[test] - fn idle_control_socket_releases_its_worker() { - let (client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); - expect_socket_deadline(client, done, worker); - } - - /// An idle connection is closed without a line: a client that sends its next request late - /// must not find an error waiting where its reply should be. - #[test] - fn idle_expiry_closes_without_writing_a_line() { - let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); - let mut got = Vec::new(); - client.read_to_end(&mut got).unwrap(); - assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection"); - expect_socket_deadline(client, done, worker); - } - - /// A line that stalls is refused in words a client's author can act on, not the system's. - #[test] - fn stalled_control_line_is_refused_with_the_reason() { - let (mut client, done, worker) = socket_fixture(line_timeouts(), |_, _| panic!("incomplete requests must not execute")); - client.write_all(b"{\"method\":").unwrap(); - let mut line = String::new(); - BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap(); - assert_eq!( - serde_json::from_str::(&line).unwrap(), - json!({"ok": false, "error": "request line not completed in time; closing the connection"}) - ); - expect_socket_deadline(client, done, worker); - } - - #[test] - fn partial_control_line_releases_its_worker() { - let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); - client.write_all(b"{\"method\":").unwrap(); - expect_socket_deadline(client, done, worker); - } - - #[test] - fn trickled_control_line_has_an_absolute_deadline() { - let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); - client.write_all(b"{").unwrap(); - let mut writer = client.try_clone().unwrap(); - let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let writing = Arc::clone(&stop); - let trickle = std::thread::spawn(move || { - while !writing.load(Ordering::SeqCst) { - if writer.write_all(b" ").is_err() { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - }); - let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); - stop.store(true, Ordering::SeqCst); - // Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well. - let _ = client.shutdown(std::net::Shutdown::Both); - trickle.join().unwrap(); - worker.join().unwrap(); - assert!(stopped, "trickled bytes restarted the line's deadline"); - } - - #[test] - fn a_nonreading_control_peer_cannot_retain_the_writer() { - let payload = "x".repeat(2 * 1024 * 1024); - let (mut client, done, worker) = socket_fixture(short_timeouts(), move |_, _| Some(json!({"ok": true, "result": payload}))); - client.write_all(b"{\"method\":\"large\"}\n").unwrap(); - expect_socket_deadline(client, done, worker); - } - - #[test] - fn persistent_control_requests_get_fresh_transport_deadlines() { - let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() }; - let (mut client, done, worker) = socket_fixture(timeouts, |_, _| { - // Application processing is not transport time; begin the reply budget afterwards. - std::thread::sleep(Duration::from_millis(200)); - Some(json!({"ok": true})) - }); - let mut reader = BufReader::new(client.try_clone().unwrap()); - for id in [1, 2] { - // The client sends each line in one write. `writeln!` on the socket sends the JSON in many - // small writes, and when the machine is busy, a pause of more than the 120 ms line deadline - // between two of them closes the connection. - client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap(); - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - assert_eq!(serde_json::from_str::(&line).unwrap(), json!({"id": id, "ok": true})); - } - client.shutdown(std::net::Shutdown::Write).unwrap(); - done.recv_timeout(Duration::from_secs(3)).unwrap(); - worker.join().unwrap(); - } - - #[test] - fn buffered_partial_control_line_also_expires() { - let (mut client, done, worker) = socket_fixture(line_timeouts(), |_, _| Some(json!({"ok": true}))); - client.write_all(b"{\"id\":1,\"method\":\"inspect\"}\n{").unwrap(); - let mut line = String::new(); - BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap(); - assert_eq!(serde_json::from_str::(&line).unwrap()["id"], 1); - expect_socket_deadline(client, done, worker); - } - - #[test] - fn timed_out_worker_returns_its_connection_slot() { - let open = Arc::new(AtomicUsize::new(0)); - let held: Vec<_> = (0..MAX_CONNECTIONS - 1).map(|_| acquire_slot(&open).unwrap()).collect(); - let slot = acquire_slot(&open).unwrap(); - assert!(acquire_slot(&open).is_none()); - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); - let (stream, _) = listener.accept().unwrap(); - let (tx, _rx) = channel(); - let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap(); - let start = std::time::Instant::now(); - while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) { - std::thread::sleep(Duration::from_millis(10)); - } - let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS; - // Clean up an old implementation that never returned its slot as well. - let _ = client.shutdown(std::net::Shutdown::Both); - worker.join().unwrap(); - assert!(released, "the deadline did not free capacity for the next client"); - let replacement = acquire_slot(&open).expect("a new client can reserve the freed slot"); - drop(replacement); - drop(held); - assert_eq!(open.load(Ordering::SeqCst), 0); - } - - fn run(input: &[u8]) -> (Vec, Vec) { - let mut out = Vec::new(); - let mut calls = Vec::new(); - serve_lines(input, &mut out, |m, _p| { - calls.push(m); - Some(json!({"ok": true, "result": null})) - }); - let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()).collect(); - (replies, calls) - } - - /// Counts the `write` calls made on it and keeps the bytes they write. - #[derive(Default)] - struct Writes { - calls: usize, - bytes: Vec, - } - - impl Write for Writes { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.calls += 1; - self.bytes.extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - /// A reply shaped like `document.inspect`'s for a layer of `n` squares in rows of 200. - fn inspect_reply(n: u64) -> Value { - let paths: Vec = (0..n) - .map(|k| { - let (x, y) = ((k % 200) as f64 * 20.0, (k / 200) as f64 * 20.0); - json!({"anchors": 4, "bounds": {"height": 10.0, "width": 10.0, "x": x, "y": y}, "closed": true, "fill": "#f4a261", - "id": k + 2, "kind": "Path", "locked": false, "name": "", "stroke": "None", "strokeWidth": 0.0, "visible": true}) - }) - .collect(); - json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}}) - } - - /// #1725: a request its caller gave up before the app took it never runs; one the app took - /// first can't be withdrawn, and the timeout says so. - #[test] - fn a_timed_out_request_is_withdrawn_unless_it_started() { - let (waiting, _rx) = ControlRequest::new("engine.execute", json!({})); - assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run")); - assert!(!waiting.claim(), "the app skips a withdrawn request"); - let (running, _rx) = ControlRequest::new("engine.execute", json!({})); - assert!(running.claim()); - assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running")); - assert!(!running.claim(), "a request is taken once"); - } - - #[test] - fn requests_are_answered_in_order_with_ids() { - let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}"); - assert_eq!(calls, ["a", "b", "c"]); - assert_eq!(r.len(), 3); - assert_eq!(r[0]["id"], 1); - assert_eq!(r[1]["id"], 2); - assert_eq!(r[2]["id"], Value::Null); - } - - #[test] - fn replies_are_written_in_few_large_writes() { - for reply in [json!({"ok": true, "result": null}), inspect_reply(2_000)] { - let mut out = Writes::default(); - let mut once = Some(reply.clone()); - serve_lines(&b"{\"id\":1,\"method\":\"document.inspect\"}\n"[..], &mut out, |_, _| once.take()); - let mut want = reply; - want["id"] = json!(1); - let text = String::from_utf8(out.bytes).unwrap(); - // The writer receives the bytes that `writeln!(out, "{want}")` writes, in at most one write per 4 KiB. - assert_eq!(text, format!("{want}\n")); - assert!(out.calls <= text.len().div_ceil(4096), "{} writes for {} bytes", out.calls, text.len()); - } - // A refusal is one write as well. - let mut out = Writes::default(); - serve_lines(&b"hello\n"[..], &mut out, |_, _| None); - assert_eq!(out.calls, 1); - } - - #[test] - fn http_request_closes_before_the_body_runs() { - // A browser's cross-origin `fetch` POST: the request line is rejected and the - // connection closed, so the JSON body is never executed. - let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\ - {\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n"; - let (r, calls) = run(http); - assert!(calls.is_empty(), "nothing may run: {calls:?}"); - assert_eq!(r.len(), 1); - assert_eq!(r[0]["ok"], false); - assert!(r[0]["error"].as_str().unwrap().contains("closing the connection")); - } - - #[test] - fn non_request_json_closes_the_connection() { - for junk in [ - &b"[1,2]\n{\"method\":\"a\"}\n"[..], - b"{\"id\":1}\n{\"method\":\"a\"}\n", - b"{\"method\":5}\n{\"method\":\"a\"}\n", - b"\xff\xfe\n{\"method\":\"a\"}\n", - ] { - let (r, calls) = run(junk); - assert!(calls.is_empty(), "{calls:?}"); - assert_eq!(r.len(), 1); - assert_eq!(r[0]["ok"], false); - } - } - - #[test] - fn a_request_before_junk_still_runs() { - let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n"); - assert_eq!(calls, ["a"]); - assert_eq!(r.len(), 2); - assert_eq!(r[1]["ok"], false); - } - - #[test] - fn overlong_line_is_refused() { - // One byte over the limit, then a valid request: neither runs. - let mut input = vec![b'x'; MAX_LINE + 1]; - input.extend_from_slice(b"\n{\"method\":\"a\"}\n"); - let (r, calls) = run(&input); - assert!(calls.is_empty(), "{calls:?}"); - assert_eq!(r.len(), 1); - assert!(r[0]["error"].as_str().unwrap().contains("longer than")); - } - - #[test] - fn line_at_the_limit_is_accepted() { - let mut buf = Vec::new(); - let line = vec![b'a'; 10]; - let mut input = line.clone(); - input.push(b'\n'); - assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true)); - assert_eq!(buf, line); - assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err()); - } - - #[test] - fn tcp_connection_is_closed_after_junk() { - // End to end over a real loopback socket (the transport the app uses). - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let addr = listener.local_addr().unwrap(); - let server = std::thread::spawn(move || { - let (s, _) = listener.accept().unwrap(); - let read = s.try_clone().unwrap(); - serve_lines(read, s, |_, _| Some(json!({"ok": true}))); - }); - let mut c = TcpStream::connect(addr).unwrap(); - c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); - let mut text = String::new(); - c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection - server.join().unwrap(); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 2, "{text}"); - assert!(lines[0].contains("\"id\":7")); - assert!(lines[1].contains("closing the connection")); - } - - #[test] - fn ten_inspect_replies_for_10000_objects_arrive_within_20_s() { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let addr = listener.local_addr().unwrap(); - let reply = inspect_reply(10_000); - let mut want = reply.clone(); - want["id"] = json!(1); - let want = format!("{want}\n"); - std::thread::spawn(move || { - let (s, _) = listener.accept().unwrap(); - let read = s.try_clone().unwrap(); - serve_lines(read, s, |_, _| Some(reply.clone())); - }); - // The client sends each request line and reads its reply line through a `BufReader`, as `vectorcraft_mcp::Remote` does. - let (tx, rx) = channel(); - std::thread::spawn(move || { - let mut c = TcpStream::connect(addr).unwrap(); - let mut read = BufReader::new(c.try_clone().unwrap()); - let mut lines = vec![]; - for _ in 0..10 { - c.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap(); - let mut line = String::new(); - read.read_line(&mut line).unwrap(); - lines.push(line); - } - tx.send(lines).ok(); - }); - let lines = rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("ten replies did not arrive within 20 s")); - assert!(lines.iter().all(|l| *l == want), "a reply differs from the bytes `writeln!` writes"); - } - - #[test] - fn extra_connections_are_refused() { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let addr = listener.local_addr().unwrap(); - let (tx, _rx) = channel::(); - std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); - // Idle connections are accepted in order and held open. - let held: Vec = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect(); - let mut extra = TcpStream::connect(addr).unwrap(); - extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - let mut line = String::new(); - BufReader::new(&mut extra).read_line(&mut line).unwrap(); - assert!(line.contains("too many control connections"), "{line}"); - drop(held); - } - - // A test process is not an app that App Nap applies to, so this checks the activity the - // channel asks for; the throttling itself shows only in a running app with its window hidden. - #[cfg(target_os = "macos")] - #[test] - fn the_control_channel_keeps_the_app_at_full_priority_on_macos() { - use objc2_foundation::NSActivityOptions; - let options = activity_options(); - assert!(options.contains(NSActivityOptions::LatencyCritical), "without it App Nap still lowers the priority to 20"); - assert!(options.contains(NSActivityOptions::UserInitiatedAllowingIdleSystemSleep)); - assert!(!options.contains(NSActivityOptions::IdleSystemSleepDisabled), "the channel must not keep the Mac awake"); - keep_answering(); - } -} +mod tests; diff --git a/apps/vectorcraft/src/control_server/tests.rs b/apps/vectorcraft/src/control_server/tests.rs new file mode 100644 index 000000000..7fa3f27e7 --- /dev/null +++ b/apps/vectorcraft/src/control_server/tests.rs @@ -0,0 +1,394 @@ +use super::*; + +fn short_timeouts() -> ConnectionTimeouts { + ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) } +} + +/// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a +/// busy machine either thread can pause for more than 150 ms between connecting and sending, or +/// between starting a reply and writing it, and a short budget then closes the connection with no +/// reply. +fn line_timeouts() -> ConnectionTimeouts { + ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() } +} + +fn socket_fixture( + timeouts: ConnectionTimeouts, + handle: impl FnMut(String, Value) -> Option + Send + 'static, +) -> (TcpStream, Receiver<()>, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (finished, done) = channel(); + let worker = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap(); + serve_socket(stream, timeouts, handle); + finished.send(()).unwrap(); + }); + let client = TcpStream::connect(addr).unwrap(); + socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + (client, done, worker) +} + +fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) { + let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); + // Always unblock the old implementation too, so a failed regression leaves no worker behind. + let _ = client.shutdown(std::net::Shutdown::Both); + drop(client); + worker.join().unwrap(); + assert!(stopped, "the server retained a stalled connection beyond its transport deadline"); +} + +#[test] +fn idle_control_socket_releases_its_worker() { + let (client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); + expect_socket_deadline(client, done, worker); +} + +/// An idle connection is closed without a line: a client that sends its next request late +/// must not find an error waiting where its reply should be. +#[test] +fn idle_expiry_closes_without_writing_a_line() { + let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); + let mut got = Vec::new(); + client.read_to_end(&mut got).unwrap(); + assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection"); + expect_socket_deadline(client, done, worker); +} + +/// A line that stalls is refused in words a client's author can act on, not the system's. +#[test] +fn stalled_control_line_is_refused_with_the_reason() { + let (mut client, done, worker) = socket_fixture(line_timeouts(), |_, _| panic!("incomplete requests must not execute")); + client.write_all(b"{\"method\":").unwrap(); + let mut line = String::new(); + BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap(); + assert_eq!( + serde_json::from_str::(&line).unwrap(), + json!({"ok": false, "error": "request line not completed in time; closing the connection"}) + ); + expect_socket_deadline(client, done, worker); +} + +#[test] +fn partial_control_line_releases_its_worker() { + let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); + client.write_all(b"{\"method\":").unwrap(); + expect_socket_deadline(client, done, worker); +} + +#[test] +fn trickled_control_line_has_an_absolute_deadline() { + let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute")); + client.write_all(b"{").unwrap(); + let mut writer = client.try_clone().unwrap(); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let writing = Arc::clone(&stop); + let trickle = std::thread::spawn(move || { + while !writing.load(Ordering::SeqCst) { + if writer.write_all(b" ").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); + stop.store(true, Ordering::SeqCst); + // Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well. + let _ = client.shutdown(std::net::Shutdown::Both); + trickle.join().unwrap(); + worker.join().unwrap(); + assert!(stopped, "trickled bytes restarted the line's deadline"); +} + +#[test] +fn a_nonreading_control_peer_cannot_retain_the_writer() { + let payload = "x".repeat(2 * 1024 * 1024); + let (mut client, done, worker) = socket_fixture(short_timeouts(), move |_, _| Some(json!({"ok": true, "result": payload}))); + client.write_all(b"{\"method\":\"large\"}\n").unwrap(); + expect_socket_deadline(client, done, worker); +} + +#[test] +fn persistent_control_requests_get_fresh_transport_deadlines() { + let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() }; + let (mut client, done, worker) = socket_fixture(timeouts, |_, _| { + // Application processing is not transport time; begin the reply budget afterwards. + std::thread::sleep(Duration::from_millis(200)); + Some(json!({"ok": true})) + }); + let mut reader = BufReader::new(client.try_clone().unwrap()); + for id in [1, 2] { + // The client sends each line in one write. `writeln!` on the socket sends the JSON in many + // small writes, and when the machine is busy, a pause of more than the 120 ms line deadline + // between two of them closes the connection. + client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap(); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap(), json!({"id": id, "ok": true})); + } + client.shutdown(std::net::Shutdown::Write).unwrap(); + done.recv_timeout(Duration::from_secs(3)).unwrap(); + worker.join().unwrap(); +} + +#[test] +fn buffered_partial_control_line_also_expires() { + let (mut client, done, worker) = socket_fixture(line_timeouts(), |_, _| Some(json!({"ok": true}))); + client.write_all(b"{\"id\":1,\"method\":\"inspect\"}\n{").unwrap(); + let mut line = String::new(); + BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["id"], 1); + expect_socket_deadline(client, done, worker); +} + +#[test] +fn timed_out_worker_returns_its_connection_slot() { + let open = Arc::new(AtomicUsize::new(0)); + let held: Vec<_> = (0..MAX_CONNECTIONS - 1).map(|_| acquire_slot(&open).unwrap()).collect(); + let slot = acquire_slot(&open).unwrap(); + assert!(acquire_slot(&open).is_none()); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (stream, _) = listener.accept().unwrap(); + let (tx, _rx) = channel(); + let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap(); + let start = std::time::Instant::now(); + while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) { + std::thread::sleep(Duration::from_millis(10)); + } + let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS; + // Clean up an old implementation that never returned its slot as well. + let _ = client.shutdown(std::net::Shutdown::Both); + worker.join().unwrap(); + assert!(released, "the deadline did not free capacity for the next client"); + let replacement = acquire_slot(&open).expect("a new client can reserve the freed slot"); + drop(replacement); + drop(held); + assert_eq!(open.load(Ordering::SeqCst), 0); +} + +fn run(input: &[u8]) -> (Vec, Vec) { + let mut out = Vec::new(); + let mut calls = Vec::new(); + serve_lines(input, &mut out, |m, _p| { + calls.push(m); + Some(json!({"ok": true, "result": null})) + }); + let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()).collect(); + (replies, calls) +} + +/// Counts the `write` calls made on it and keeps the bytes they write. +#[derive(Default)] +struct Writes { + calls: usize, + bytes: Vec, +} + +impl Write for Writes { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.calls += 1; + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A reply shaped like `document.inspect`'s for a layer of `n` squares in rows of 200. +fn inspect_reply(n: u64) -> Value { + let paths: Vec = (0..n) + .map(|k| { + let (x, y) = ((k % 200) as f64 * 20.0, (k / 200) as f64 * 20.0); + json!({"anchors": 4, "bounds": {"height": 10.0, "width": 10.0, "x": x, "y": y}, "closed": true, "fill": "#f4a261", + "id": k + 2, "kind": "Path", "locked": false, "name": "", "stroke": "None", "strokeWidth": 0.0, "visible": true}) + }) + .collect(); + json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}}) +} + +/// #1725: a request its caller gave up before the app took it never runs; one the app took +/// first can't be withdrawn, and the timeout says so. +#[test] +fn a_timed_out_request_is_withdrawn_unless_it_started() { + let (waiting, _rx) = ControlRequest::new("engine.execute", json!({})); + assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run")); + assert!(!waiting.claim(), "the app skips a withdrawn request"); + let (running, _rx) = ControlRequest::new("engine.execute", json!({})); + assert!(running.claim()); + assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running")); + assert!(!running.claim(), "a request is taken once"); +} + +#[test] +fn requests_are_answered_in_order_with_ids() { + let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}"); + assert_eq!(calls, ["a", "b", "c"]); + assert_eq!(r.len(), 3); + assert_eq!(r[0]["id"], 1); + assert_eq!(r[1]["id"], 2); + assert_eq!(r[2]["id"], Value::Null); +} + +#[test] +fn replies_are_written_in_few_large_writes() { + for reply in [json!({"ok": true, "result": null}), inspect_reply(2_000)] { + let mut out = Writes::default(); + let mut once = Some(reply.clone()); + serve_lines(&b"{\"id\":1,\"method\":\"document.inspect\"}\n"[..], &mut out, |_, _| once.take()); + let mut want = reply; + want["id"] = json!(1); + let text = String::from_utf8(out.bytes).unwrap(); + // The writer receives the bytes that `writeln!(out, "{want}")` writes, in at most one write per 4 KiB. + assert_eq!(text, format!("{want}\n")); + assert!(out.calls <= text.len().div_ceil(4096), "{} writes for {} bytes", out.calls, text.len()); + } + // A refusal is one write as well. + let mut out = Writes::default(); + serve_lines(&b"hello\n"[..], &mut out, |_, _| None); + assert_eq!(out.calls, 1); +} + +#[test] +fn http_request_closes_before_the_body_runs() { + // A browser's cross-origin `fetch` POST: the request line is rejected and the + // connection closed, so the JSON body is never executed. + let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\ + {\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n"; + let (r, calls) = run(http); + assert!(calls.is_empty(), "nothing may run: {calls:?}"); + assert_eq!(r.len(), 1); + assert_eq!(r[0]["ok"], false); + assert!(r[0]["error"].as_str().unwrap().contains("closing the connection")); +} + +#[test] +fn non_request_json_closes_the_connection() { + for junk in [ + &b"[1,2]\n{\"method\":\"a\"}\n"[..], + b"{\"id\":1}\n{\"method\":\"a\"}\n", + b"{\"method\":5}\n{\"method\":\"a\"}\n", + b"\xff\xfe\n{\"method\":\"a\"}\n", + ] { + let (r, calls) = run(junk); + assert!(calls.is_empty(), "{calls:?}"); + assert_eq!(r.len(), 1); + assert_eq!(r[0]["ok"], false); + } +} + +#[test] +fn a_request_before_junk_still_runs() { + let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n"); + assert_eq!(calls, ["a"]); + assert_eq!(r.len(), 2); + assert_eq!(r[1]["ok"], false); +} + +#[test] +fn overlong_line_is_refused() { + // One byte over the limit, then a valid request: neither runs. + let mut input = vec![b'x'; MAX_LINE + 1]; + input.extend_from_slice(b"\n{\"method\":\"a\"}\n"); + let (r, calls) = run(&input); + assert!(calls.is_empty(), "{calls:?}"); + assert_eq!(r.len(), 1); + assert!(r[0]["error"].as_str().unwrap().contains("longer than")); +} + +#[test] +fn line_at_the_limit_is_accepted() { + let mut buf = Vec::new(); + let line = vec![b'a'; 10]; + let mut input = line.clone(); + input.push(b'\n'); + assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true)); + assert_eq!(buf, line); + assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err()); +} + +#[test] +fn tcp_connection_is_closed_after_junk() { + // End to end over a real loopback socket (the transport the app uses). + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + let read = s.try_clone().unwrap(); + serve_lines(read, s, |_, _| Some(json!({"ok": true}))); + }); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); + let mut text = String::new(); + c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection + server.join().unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines[0].contains("\"id\":7")); + assert!(lines[1].contains("closing the connection")); +} + +#[test] +fn ten_inspect_replies_for_10000_objects_arrive_within_20_s() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let reply = inspect_reply(10_000); + let mut want = reply.clone(); + want["id"] = json!(1); + let want = format!("{want}\n"); + std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + let read = s.try_clone().unwrap(); + serve_lines(read, s, |_, _| Some(reply.clone())); + }); + // The client sends each request line and reads its reply line through a `BufReader`, as `vectorcraft_mcp::Remote` does. + let (tx, rx) = channel(); + std::thread::spawn(move || { + let mut c = TcpStream::connect(addr).unwrap(); + let mut read = BufReader::new(c.try_clone().unwrap()); + let mut lines = vec![]; + for _ in 0..10 { + c.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap(); + let mut line = String::new(); + read.read_line(&mut line).unwrap(); + lines.push(line); + } + tx.send(lines).ok(); + }); + let lines = rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("ten replies did not arrive within 20 s")); + assert!(lines.iter().all(|l| *l == want), "a reply differs from the bytes `writeln!` writes"); +} + +#[test] +fn extra_connections_are_refused() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, _rx) = channel::(); + std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); + // Idle connections are accepted in order and held open. + let held: Vec = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect(); + let mut extra = TcpStream::connect(addr).unwrap(); + extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut line = String::new(); + BufReader::new(&mut extra).read_line(&mut line).unwrap(); + assert!(line.contains("too many control connections"), "{line}"); + drop(held); +} + +// A test process is not an app that App Nap applies to, so this checks the activity the +// channel asks for; the throttling itself shows only in a running app with its window hidden. +#[cfg(target_os = "macos")] +#[test] +fn the_control_channel_keeps_the_app_at_full_priority_on_macos() { + use objc2_foundation::NSActivityOptions; + let options = activity_options(); + assert!(options.contains(NSActivityOptions::LatencyCritical), "without it App Nap still lowers the priority to 20"); + assert!(options.contains(NSActivityOptions::UserInitiatedAllowingIdleSystemSleep)); + assert!(!options.contains(NSActivityOptions::IdleSystemSleepDisabled), "the channel must not keep the Mac awake"); + keep_answering(); +} From 04b9425f5e3ba110299967778cacf5410d6d4b80 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:13 +0000 Subject: [PATCH 065/199] refactor(ui-egui): move prefs_dialog.rs tests to prefs_dialog/tests.rs --- crates/ui-egui/src/prefs_dialog.rs | 378 +---------------------- crates/ui-egui/src/prefs_dialog/tests.rs | 375 ++++++++++++++++++++++ 2 files changed, 376 insertions(+), 377 deletions(-) create mode 100644 crates/ui-egui/src/prefs_dialog/tests.rs diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 9057aeb13..79121f85d 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -418,380 +418,4 @@ fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { } #[cfg(test)] -mod tests { - use super::*; - use crate::Services; - use vectorcraft_engine::Session; - - #[test] - fn geometric_snapping_is_a_saved_smart_guides_preference() { - let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - open(&mut a, Some("Smart Guides")); - let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); - assert!(text.contains("Geometric Guides") && text.contains("Snap to Perpendicular") && text.contains("Snap to Tangent"), "{text}"); - a.ui.dialog.as_mut().unwrap().fields.insert("snapPerpendicular".into(), json!(false)); - confirm(&mut a).unwrap(); - assert!(!a.session.prefs.snap_perpendicular && a.session.prefs.snap_tangent); - let saved: Prefs = serde_json::from_value(a.ui.engine_prefs.clone()).unwrap(); - assert!(!saved.snap_perpendicular && saved.snap_tangent); - assert!(a.run("view.snapping", json!({})).is_err()); - } - - /// A resting checkbox must have a visible outline in both states and every brightness. - #[test] - fn preference_checkboxes_draw_outlines_without_hover() { - for brightness in Brightness::ALL { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - theme::apply(&ctx, brightness); - for checked in [false, true] { - let mut d = Dialog::new("preferences", json!({"showToolTips": checked})); - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 200.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| bool_row(ui, &mut d, "showToolTips", "Show Tool Tips")); - out.textures_delta.clear(); - let outlined = out.shapes.iter().any(|shape| match &shape.shape { - egui::Shape::Rect(r) => r.stroke.width > 0.0 && r.rect.width() <= 24.0 && r.rect.height() <= 24.0, - _ => false, - }); - assert!(outlined, "{brightness:?}, checked={checked}: no checkbox outline"); - } - } - } - - /// User Interface › Canvas Color (#663): White and the three greys paint the canvas around the - /// artboards; Match User Interface Brightness keeps the theme's. - #[test] - fn canvas_color_choices_paint_the_pasteboard() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - let ctx = egui::Context::default(); - apply_runtime(&mut app, &ctx); - let themed = Tokens::get(&ctx).pasteboard; - for (key, want) in [ - ("white", egui::Color32::WHITE), - ("lightGray", egui::Color32::from_gray(0xc8)), - ("mediumGray", egui::Color32::from_gray(0x96)), - ("darkGray", egui::Color32::from_gray(0x5a)), - ("matchUi", themed), - ] { - app.run("prefs.set", serde_json::json!({"key": "canvasColor", "value": key})).unwrap(); - apply_runtime(&mut app, &ctx); - assert_eq!(Tokens::get(&ctx).pasteboard, want, "{key}"); - } - assert!(app.run("prefs.set", serde_json::json!({"key": "canvasColor", "value": "pink"})).is_err()); - } - - fn app() -> VectorcraftApp { - VectorcraftApp::new(Session::new(), Services::default()) - } - - #[test] - fn dialog_ok_applies_prefs_and_ui_fields() { - let mut a = app(); - open(&mut a, Some("general")); - let d = a.ui.dialog.as_mut().unwrap(); - assert_eq!(d.str("__category"), "General"); - d.fields.insert("keyboardIncrement".into(), json!(4)); - d.fields.insert("uiBrightness".into(), json!("light")); - d.fields.insert("__smartGuides".into(), json!(false)); - confirm(&mut a).unwrap(); - assert!(a.ui.dialog.is_none()); - assert_eq!(a.session.prefs.keyboard_increment, 4.0); - assert_eq!(a.session.prefs.ui_brightness, "light"); - assert!(!a.ui.view.smart_guides); - assert_eq!(a.ui.engine_prefs["keyboardIncrement"], json!(4.0)); - } - - /// General › Show Tool Tips: off, a button held under the pointer shows no tool tip; back on, - /// it does again (#394). - #[test] - fn show_tool_tips_off_hides_tool_tips() { - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut a = app(); - let ctx = egui::Context::default(); - let mut time = 0.0; - // Come from elsewhere and hover the button for two seconds: did its tool tip show? - let mut hover = |a: &mut VectorcraftApp| { - let mut shown = vec![]; - for i in 0..20 { - time += 0.1; - let events = match i { - 0 => vec![egui::Event::PointerGone], - 1 => vec![egui::Event::PointerMoved(egui::pos2(20.0, 12.0))], - _ => vec![], - }; - let raw = egui::RawInput { - events, - time: Some(time), - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0))), - ..Default::default() - }; - let mut out = ctx.run_ui(raw, |ui| { - apply_runtime(a, ui.ctx()); - ui.button("Button").on_hover_text("The tool tip"); - }); - out.textures_delta.clear(); - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - } - shown.iter().any(|t| t == "The tool tip") - }; - assert!(hover(&mut a), "on by default"); - a.run("prefs.set", json!({"key": "showToolTips", "value": false})).unwrap(); - assert!(!hover(&mut a), "off: no tool tip"); - a.run("prefs.set", json!({"key": "showToolTips", "value": true})).unwrap(); - assert!(hover(&mut a), "on again"); - } - - #[test] - fn dialog_ok_with_invalid_value_keeps_dialog_and_prefs() { - let mut a = app(); - open(&mut a, None); - a.ui.dialog.as_mut().unwrap().fields.insert("anchorSize".into(), json!(42)); - assert!(confirm(&mut a).is_err()); - assert!(a.ui.dialog.is_some()); - assert_eq!(a.session.prefs, Prefs::default()); - } - - #[test] - fn prefs_persist_through_ui_state() { - let mut a = app(); - a.run("prefs.set", json!({"key": "gridlineEvery", "value": 50})).unwrap(); - snapshot(&mut a); - let saved = serde_json::to_vec(&a.ui).unwrap(); - let mut b = app(); - b.ui = serde_json::from_slice::(&saved).unwrap().sanitized(); - restore(&mut b); - assert_eq!(b.session.prefs.gridline_every, 50.0); - } - - #[test] - fn old_pref_files_keep_their_brightness() { - let mut a = app(); - a.ui.brightness = Brightness::Light; - a.ui.engine_prefs = Value::Null; - restore(&mut a); - assert_eq!(a.session.prefs.ui_brightness, "light"); - } - - /// Performance › Graphics Processor (#306, #502): shown with its restart note, applied by OK - /// and saved with the UI state under the key the desktop app reads before the window opens. - #[test] - fn graphics_processor_preference_shows_and_persists() { - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut a = app(); - open(&mut a, Some("Performance")); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - // The window sizes itself on the first frame and draws on the second. - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); - out.textures_delta.clear(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); - out.textures_delta.clear(); - let mut shown = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - assert!(shown.iter().any(|t| t.starts_with("Graphics Processor")), "{shown:?}"); - assert!(shown.iter().any(|t| t == "Automatic"), "{shown:?}"); - assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}"); - a.ui.dialog.as_mut().unwrap().fields.insert("gpuPreference".into(), json!("highPerformance")); - assert!(!a.take_prefs_changed(), "nothing to write before OK"); - confirm(&mut a).unwrap(); - assert!(a.take_prefs_changed(), "OK asks the host to write the preferences file"); - assert!(!a.take_prefs_changed()); - assert_eq!(a.session.prefs.gpu_preference, "highPerformance"); - let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap(); - assert_eq!(saved["engine_prefs"]["gpuPreference"], json!("highPerformance")); - } - - /// User Interface › System Title Bar: shown with its next-launch note, applied by OK and - /// saved with the UI state under the key the desktop app reads before the window opens. - #[test] - fn system_title_bar_preference_shows_and_persists() { - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut a = app(); - open(&mut a, Some("User Interface")); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - // The window sizes itself on the first frame and draws on the second. - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); - out.textures_delta.clear(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); - out.textures_delta.clear(); - let mut shown = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); - assert_eq!(shown.iter().any(|t| t == "System Title Bar"), supported, "{shown:?}"); - assert_eq!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), supported, "{shown:?}"); - a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true)); - confirm(&mut a).unwrap(); - assert!(a.session.prefs.system_title_bar); - let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap(); - assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true)); - } - - /// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and - /// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop - /// app reads before the window opens. - #[test] - fn linux_display_server_offers_the_pen_choice() { - fn texts(s: &egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut a = app(); - open(&mut a, Some("Performance")); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - // The window sizes itself on the first frame and draws on the second. - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); - out.textures_delta.clear(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); - out.textures_delta.clear(); - let mut shown = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - let linux = cfg!(target_os = "linux"); - assert_eq!(shown.iter().any(|t| t.starts_with("Linux Display Server")), linux, "{shown:?}"); - let note = "If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option."; - assert_eq!(shown.iter().any(|t| t == note), linux, "{shown:?}"); - a.ui.dialog.as_mut().unwrap().fields.insert("linuxDisplayServer".into(), json!("x11WithPen")); - confirm(&mut a).unwrap(); - assert_eq!(a.session.prefs.linux_display_server, "x11WithPen"); - let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap(); - assert_eq!(saved["engine_prefs"]["linuxDisplayServer"], json!("x11WithPen")); - } - - /// #1866: the category shown is highlighted in the list while the pointer is elsewhere. - #[test] - fn the_category_shown_is_highlighted() { - fn rects(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) { - match s { - egui::Shape::Rect(r) => out.push((r.rect, r.fill)), - egui::Shape::Vec(v) => v.iter().for_each(|s| rects(s, out)), - _ => {} - } - } - let mut a = app(); - open(&mut a, Some("Units")); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - // The window sizes itself on the first frame and draws on the second. - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); - out.textures_delta.clear(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); - out.textures_delta.clear(); - let mut shown = vec![]; - out.shapes.iter().for_each(|c| rects(&c.shape, &mut shown)); - // The list's rows, top to bottom: only the fourth (Units) is filled. - let rows: Vec<_> = shown.iter().filter(|(r, _)| r.width() == 184.0 && r.height() < 30.0).collect(); - assert_eq!(rows.len(), PREF_CATEGORIES.len(), "{shown:?}"); - let filled: Vec = rows.iter().enumerate().filter(|(_, (_, c))| c.a() > 0).map(|(i, _)| i).collect(); - assert_eq!(filled, vec![3], "{rows:?}"); - } - - /// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it - /// changes, and its checkbox is dimmed unless a unit is Picas. - #[test] - fn numbers_without_units_are_points_reaches_the_fields_and_dims_without_picas() { - let mut a = app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let frame = |a: &mut VectorcraftApp| { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - apply_runtime(a, ui.ctx()); - show(a, ui.ctx()); - }); - out.textures_delta.clear(); - }; - let read = |ctx: &egui::Context| widgets::typed_unit(ctx, vectorcraft_doc::Unit::Picas); - frame(&mut a); - assert_eq!(read(&ctx), vectorcraft_doc::Unit::Points, "on by default"); - a.run("prefs.set", json!({"key": "numbersWithoutUnitsArePoints", "value": false})).unwrap(); - frame(&mut a); - assert_eq!(read(&ctx), vectorcraft_doc::Unit::Picas, "off"); - // The checkbox: dimmed with every unit in points, enabled once a unit is Picas. - open(&mut a, Some("Units")); - let d = a.ui.dialog.as_mut().unwrap(); - assert!(!picas_in_use(d), "dimmed in points"); - d.fields.insert("unitsStroke".into(), json!("picas")); - assert!(picas_in_use(d), "enabled with a unit in picas"); - } - - /// 0.5.0 saved `powerSaving` for everyone (its default): it reads as Automatic, while the - /// choices this version offers are kept (#502). - #[test] - fn a_graphics_processor_this_version_does_not_offer_reads_as_automatic() { - for (saved, read) in [("powerSaving", "automatic"), ("turbo", "automatic"), ("lowPower", "lowPower"), ("highPerformance", "highPerformance")] - { - let mut a = app(); - a.ui.engine_prefs = json!({"gpuPreference": saved}); - restore(&mut a); - assert_eq!(a.session.prefs.gpu_preference, read, "{saved}"); - } - } - - #[test] - fn every_category_renders_fields() { - for c in PREF_CATEGORIES { - assert!(PREF_SPECS.iter().any(|s| s.category == *c)); - } - } - - /// Preferences › Type › Show Font Names in English (#394): OK on the dialog stores the flag the - /// font menus read (Character panel, Type → Font). - #[test] - fn show_font_names_in_english_dialog_ok_stores_the_flag() { - let mut a = app(); - assert!(a.session.prefs.font_names_in_english); - open(&mut a, Some("Type")); - assert_eq!(a.ui.dialog.as_ref().unwrap().str("__category"), "Type"); - a.ui.dialog.as_mut().unwrap().fields.insert("fontNamesInEnglish".into(), json!(false)); - confirm(&mut a).unwrap(); - assert!(a.ui.dialog.is_none()); - assert!(!a.session.prefs.font_names_in_english); - assert!(!crate::font_menu::MenuLook::of(&a).english_names); - } - - /// Preferences › Hyphenation › Exceptions (#394): OK on the dialog pushes the list into the - /// hyphenator the same way `prefs.set` does (no need to drive the live Preferences window). - #[test] - fn hyphenation_exceptions_dialog_ok_reaches_the_hyphenator() { - struct Clear; - impl Drop for Clear { - fn drop(&mut self) { - vectorcraft_text::set_hyphenation_exceptions(""); - } - } - let _clear = Clear; - let mut a = app(); - open(&mut a, Some("Hyphenation")); - let d = a.ui.dialog.as_mut().unwrap(); - assert_eq!(d.str("__category"), "Hyphenation"); - d.fields.insert("hyphenationExceptions".into(), json!("typography, hap-pen")); - confirm(&mut a).unwrap(); - assert!(a.ui.dialog.is_none()); - assert_eq!(a.session.prefs.hyphenation_exceptions, "typography, hap-pen"); - assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen"); - assert!(vectorcraft_text::hyphen::hyphen_points("typography").is_empty()); - assert_eq!(vectorcraft_text::hyphen::hyphen_points("happen"), vec![3]); - } -} +mod tests; diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs new file mode 100644 index 000000000..dd8617977 --- /dev/null +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -0,0 +1,375 @@ +use super::*; +use crate::Services; +use vectorcraft_engine::Session; + +#[test] +fn geometric_snapping_is_a_saved_smart_guides_preference() { + let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + open(&mut a, Some("Smart Guides")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Geometric Guides") && text.contains("Snap to Perpendicular") && text.contains("Snap to Tangent"), "{text}"); + a.ui.dialog.as_mut().unwrap().fields.insert("snapPerpendicular".into(), json!(false)); + confirm(&mut a).unwrap(); + assert!(!a.session.prefs.snap_perpendicular && a.session.prefs.snap_tangent); + let saved: Prefs = serde_json::from_value(a.ui.engine_prefs.clone()).unwrap(); + assert!(!saved.snap_perpendicular && saved.snap_tangent); + assert!(a.run("view.snapping", json!({})).is_err()); +} + +/// A resting checkbox must have a visible outline in both states and every brightness. +#[test] +fn preference_checkboxes_draw_outlines_without_hover() { + for brightness in Brightness::ALL { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + theme::apply(&ctx, brightness); + for checked in [false, true] { + let mut d = Dialog::new("preferences", json!({"showToolTips": checked})); + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 200.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| bool_row(ui, &mut d, "showToolTips", "Show Tool Tips")); + out.textures_delta.clear(); + let outlined = out.shapes.iter().any(|shape| match &shape.shape { + egui::Shape::Rect(r) => r.stroke.width > 0.0 && r.rect.width() <= 24.0 && r.rect.height() <= 24.0, + _ => false, + }); + assert!(outlined, "{brightness:?}, checked={checked}: no checkbox outline"); + } + } +} + +/// User Interface › Canvas Color (#663): White and the three greys paint the canvas around the +/// artboards; Match User Interface Brightness keeps the theme's. +#[test] +fn canvas_color_choices_paint_the_pasteboard() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let ctx = egui::Context::default(); + apply_runtime(&mut app, &ctx); + let themed = Tokens::get(&ctx).pasteboard; + for (key, want) in [ + ("white", egui::Color32::WHITE), + ("lightGray", egui::Color32::from_gray(0xc8)), + ("mediumGray", egui::Color32::from_gray(0x96)), + ("darkGray", egui::Color32::from_gray(0x5a)), + ("matchUi", themed), + ] { + app.run("prefs.set", serde_json::json!({"key": "canvasColor", "value": key})).unwrap(); + apply_runtime(&mut app, &ctx); + assert_eq!(Tokens::get(&ctx).pasteboard, want, "{key}"); + } + assert!(app.run("prefs.set", serde_json::json!({"key": "canvasColor", "value": "pink"})).is_err()); +} + +fn app() -> VectorcraftApp { + VectorcraftApp::new(Session::new(), Services::default()) +} + +#[test] +fn dialog_ok_applies_prefs_and_ui_fields() { + let mut a = app(); + open(&mut a, Some("general")); + let d = a.ui.dialog.as_mut().unwrap(); + assert_eq!(d.str("__category"), "General"); + d.fields.insert("keyboardIncrement".into(), json!(4)); + d.fields.insert("uiBrightness".into(), json!("light")); + d.fields.insert("__smartGuides".into(), json!(false)); + confirm(&mut a).unwrap(); + assert!(a.ui.dialog.is_none()); + assert_eq!(a.session.prefs.keyboard_increment, 4.0); + assert_eq!(a.session.prefs.ui_brightness, "light"); + assert!(!a.ui.view.smart_guides); + assert_eq!(a.ui.engine_prefs["keyboardIncrement"], json!(4.0)); +} + +/// General › Show Tool Tips: off, a button held under the pointer shows no tool tip; back on, +/// it does again (#394). +#[test] +fn show_tool_tips_off_hides_tool_tips() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut a = app(); + let ctx = egui::Context::default(); + let mut time = 0.0; + // Come from elsewhere and hover the button for two seconds: did its tool tip show? + let mut hover = |a: &mut VectorcraftApp| { + let mut shown = vec![]; + for i in 0..20 { + time += 0.1; + let events = match i { + 0 => vec![egui::Event::PointerGone], + 1 => vec![egui::Event::PointerMoved(egui::pos2(20.0, 12.0))], + _ => vec![], + }; + let raw = egui::RawInput { + events, + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + apply_runtime(a, ui.ctx()); + ui.button("Button").on_hover_text("The tool tip"); + }); + out.textures_delta.clear(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + } + shown.iter().any(|t| t == "The tool tip") + }; + assert!(hover(&mut a), "on by default"); + a.run("prefs.set", json!({"key": "showToolTips", "value": false})).unwrap(); + assert!(!hover(&mut a), "off: no tool tip"); + a.run("prefs.set", json!({"key": "showToolTips", "value": true})).unwrap(); + assert!(hover(&mut a), "on again"); +} + +#[test] +fn dialog_ok_with_invalid_value_keeps_dialog_and_prefs() { + let mut a = app(); + open(&mut a, None); + a.ui.dialog.as_mut().unwrap().fields.insert("anchorSize".into(), json!(42)); + assert!(confirm(&mut a).is_err()); + assert!(a.ui.dialog.is_some()); + assert_eq!(a.session.prefs, Prefs::default()); +} + +#[test] +fn prefs_persist_through_ui_state() { + let mut a = app(); + a.run("prefs.set", json!({"key": "gridlineEvery", "value": 50})).unwrap(); + snapshot(&mut a); + let saved = serde_json::to_vec(&a.ui).unwrap(); + let mut b = app(); + b.ui = serde_json::from_slice::(&saved).unwrap().sanitized(); + restore(&mut b); + assert_eq!(b.session.prefs.gridline_every, 50.0); +} + +#[test] +fn old_pref_files_keep_their_brightness() { + let mut a = app(); + a.ui.brightness = Brightness::Light; + a.ui.engine_prefs = Value::Null; + restore(&mut a); + assert_eq!(a.session.prefs.ui_brightness, "light"); +} + +/// Performance › Graphics Processor (#306, #502): shown with its restart note, applied by OK +/// and saved with the UI state under the key the desktop app reads before the window opens. +#[test] +fn graphics_processor_preference_shows_and_persists() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut a = app(); + open(&mut a, Some("Performance")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // The window sizes itself on the first frame and draws on the second. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + assert!(shown.iter().any(|t| t.starts_with("Graphics Processor")), "{shown:?}"); + assert!(shown.iter().any(|t| t == "Automatic"), "{shown:?}"); + assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}"); + a.ui.dialog.as_mut().unwrap().fields.insert("gpuPreference".into(), json!("highPerformance")); + assert!(!a.take_prefs_changed(), "nothing to write before OK"); + confirm(&mut a).unwrap(); + assert!(a.take_prefs_changed(), "OK asks the host to write the preferences file"); + assert!(!a.take_prefs_changed()); + assert_eq!(a.session.prefs.gpu_preference, "highPerformance"); + let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap(); + assert_eq!(saved["engine_prefs"]["gpuPreference"], json!("highPerformance")); +} + +/// User Interface › System Title Bar: shown with its next-launch note, applied by OK and +/// saved with the UI state under the key the desktop app reads before the window opens. +#[test] +fn system_title_bar_preference_shows_and_persists() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut a = app(); + open(&mut a, Some("User Interface")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // The window sizes itself on the first frame and draws on the second. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + assert_eq!(shown.iter().any(|t| t == "System Title Bar"), supported, "{shown:?}"); + assert_eq!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), supported, "{shown:?}"); + a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true)); + confirm(&mut a).unwrap(); + assert!(a.session.prefs.system_title_bar); + let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap(); + assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true)); +} + +/// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and +/// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop +/// app reads before the window opens. +#[test] +fn linux_display_server_offers_the_pen_choice() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut a = app(); + open(&mut a, Some("Performance")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // The window sizes itself on the first frame and draws on the second. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + let linux = cfg!(target_os = "linux"); + assert_eq!(shown.iter().any(|t| t.starts_with("Linux Display Server")), linux, "{shown:?}"); + let note = "If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option."; + assert_eq!(shown.iter().any(|t| t == note), linux, "{shown:?}"); + a.ui.dialog.as_mut().unwrap().fields.insert("linuxDisplayServer".into(), json!("x11WithPen")); + confirm(&mut a).unwrap(); + assert_eq!(a.session.prefs.linux_display_server, "x11WithPen"); + let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap(); + assert_eq!(saved["engine_prefs"]["linuxDisplayServer"], json!("x11WithPen")); +} + +/// #1866: the category shown is highlighted in the list while the pointer is elsewhere. +#[test] +fn the_category_shown_is_highlighted() { + fn rects(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) { + match s { + egui::Shape::Rect(r) => out.push((r.rect, r.fill)), + egui::Shape::Vec(v) => v.iter().for_each(|s| rects(s, out)), + _ => {} + } + } + let mut a = app(); + open(&mut a, Some("Units")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // The window sizes itself on the first frame and draws on the second. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| rects(&c.shape, &mut shown)); + // The list's rows, top to bottom: only the fourth (Units) is filled. + let rows: Vec<_> = shown.iter().filter(|(r, _)| r.width() == 184.0 && r.height() < 30.0).collect(); + assert_eq!(rows.len(), PREF_CATEGORIES.len(), "{shown:?}"); + let filled: Vec = rows.iter().enumerate().filter(|(_, (_, c))| c.a() > 0).map(|(i, _)| i).collect(); + assert_eq!(filled, vec![3], "{rows:?}"); +} + +/// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it +/// changes, and its checkbox is dimmed unless a unit is Picas. +#[test] +fn numbers_without_units_are_points_reaches_the_fields_and_dims_without_picas() { + let mut a = app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |a: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + apply_runtime(a, ui.ctx()); + show(a, ui.ctx()); + }); + out.textures_delta.clear(); + }; + let read = |ctx: &egui::Context| widgets::typed_unit(ctx, vectorcraft_doc::Unit::Picas); + frame(&mut a); + assert_eq!(read(&ctx), vectorcraft_doc::Unit::Points, "on by default"); + a.run("prefs.set", json!({"key": "numbersWithoutUnitsArePoints", "value": false})).unwrap(); + frame(&mut a); + assert_eq!(read(&ctx), vectorcraft_doc::Unit::Picas, "off"); + // The checkbox: dimmed with every unit in points, enabled once a unit is Picas. + open(&mut a, Some("Units")); + let d = a.ui.dialog.as_mut().unwrap(); + assert!(!picas_in_use(d), "dimmed in points"); + d.fields.insert("unitsStroke".into(), json!("picas")); + assert!(picas_in_use(d), "enabled with a unit in picas"); +} + +/// 0.5.0 saved `powerSaving` for everyone (its default): it reads as Automatic, while the +/// choices this version offers are kept (#502). +#[test] +fn a_graphics_processor_this_version_does_not_offer_reads_as_automatic() { + for (saved, read) in [("powerSaving", "automatic"), ("turbo", "automatic"), ("lowPower", "lowPower"), ("highPerformance", "highPerformance")] + { + let mut a = app(); + a.ui.engine_prefs = json!({"gpuPreference": saved}); + restore(&mut a); + assert_eq!(a.session.prefs.gpu_preference, read, "{saved}"); + } +} + +#[test] +fn every_category_renders_fields() { + for c in PREF_CATEGORIES { + assert!(PREF_SPECS.iter().any(|s| s.category == *c)); + } +} + +/// Preferences › Type › Show Font Names in English (#394): OK on the dialog stores the flag the +/// font menus read (Character panel, Type → Font). +#[test] +fn show_font_names_in_english_dialog_ok_stores_the_flag() { + let mut a = app(); + assert!(a.session.prefs.font_names_in_english); + open(&mut a, Some("Type")); + assert_eq!(a.ui.dialog.as_ref().unwrap().str("__category"), "Type"); + a.ui.dialog.as_mut().unwrap().fields.insert("fontNamesInEnglish".into(), json!(false)); + confirm(&mut a).unwrap(); + assert!(a.ui.dialog.is_none()); + assert!(!a.session.prefs.font_names_in_english); + assert!(!crate::font_menu::MenuLook::of(&a).english_names); +} + +/// Preferences › Hyphenation › Exceptions (#394): OK on the dialog pushes the list into the +/// hyphenator the same way `prefs.set` does (no need to drive the live Preferences window). +#[test] +fn hyphenation_exceptions_dialog_ok_reaches_the_hyphenator() { + struct Clear; + impl Drop for Clear { + fn drop(&mut self) { + vectorcraft_text::set_hyphenation_exceptions(""); + } + } + let _clear = Clear; + let mut a = app(); + open(&mut a, Some("Hyphenation")); + let d = a.ui.dialog.as_mut().unwrap(); + assert_eq!(d.str("__category"), "Hyphenation"); + d.fields.insert("hyphenationExceptions".into(), json!("typography, hap-pen")); + confirm(&mut a).unwrap(); + assert!(a.ui.dialog.is_none()); + assert_eq!(a.session.prefs.hyphenation_exceptions, "typography, hap-pen"); + assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen"); + assert!(vectorcraft_text::hyphen::hyphen_points("typography").is_empty()); + assert_eq!(vectorcraft_text::hyphen::hyphen_points("happen"), vec![3]); +} From 4a8e69490c187f6c96e8b286a2f0adb31c2e1265 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:14 +0000 Subject: [PATCH 066/199] refactor(ui-egui): move edit_toolbar.rs tests to edit_toolbar/tests.rs --- crates/ui-egui/src/edit_toolbar.rs | 377 +---------------------- crates/ui-egui/src/edit_toolbar/tests.rs | 374 ++++++++++++++++++++++ 2 files changed, 375 insertions(+), 376 deletions(-) create mode 100644 crates/ui-egui/src/edit_toolbar/tests.rs diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index caec027b6..f61d19a55 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -637,379 +637,4 @@ fn ghost(ctx: &egui::Context, t: &Tokens) { } #[cfg(test)] -mod tests { - use egui::{Color32, Event, PointerButton, RawInput}; - use vectorcraft_engine::Session; - - use super::*; - - /// Tall enough for the whole Basic toolbar and the whole drawer. - const SCREEN: egui::Vec2 = vec2(1000.0, 4000.0); - /// A toolbar tool button. - const BUTTON: egui::Vec2 = vec2(36.0, 29.0); - - fn app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - app - } - - fn slots(app: &VectorcraftApp) -> Vec> { - crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect() - } - - /// The slot holding `tool`. - fn slot_of(app: &VectorcraftApp, tool: &str) -> Option { - slots(app).iter().position(|s| s.contains(&tool)) - } - - /// One headless frame of the toolbar, its keys handled first as in the app; returns the texts - /// painted: (text, a point inside it, colour). - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec<(String, Pos2, Color32)> { - fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2, Color32)>) { - match s { - egui::Shape::Text(t) => { - let color = t.galley.job.sections.first().map_or(t.fallback_color, |s| s.format.color); - out.push((t.galley.text().to_string(), t.pos + vec2(4.0, t.galley.size().y / 2.0), color)); - } - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let input = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| { - crate::shortcuts::handle(app, ui.ctx()); - crate::toolbar::show(app, ui); - }); - out.textures_delta.clear(); - let mut shown = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - shown - } - - fn ctx() -> egui::Context { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, Default::default()); - ctx - } - - fn button(pos: Pos2, pressed: bool) -> Event { - Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } - } - - fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) { - frame(app, ctx, time, vec![Event::PointerMoved(at), button(at, true)]); - frame(app, ctx, time + 0.05, vec![button(at, false)]); - frame(app, ctx, time + 0.1, vec![]); - } - - /// A press at `from` dragged to `to` in steps, one frame each, then released there. - fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) { - frame(app, ctx, time, vec![Event::PointerMoved(from), button(from, true)]); - for k in 1..=6 { - frame(app, ctx, time + 0.05 * f64::from(k), vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]); - } - frame(app, ctx, time + 0.4, vec![button(to, false)]); - frame(app, ctx, time + 0.45, vec![]); - } - - fn drawer_rect(ctx: &egui::Context) -> Option { - ctx.memory(|m| m.area_rect(drawer_id())) - } - - fn drawer_shown(ctx: &egui::Context) -> bool { - ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id()))) - } - - /// A point inside the drawer's row of the tool named `name` (as the drawer shows it), and the - /// colour of its name. - fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2, Color32) { - shown - .iter() - .find(|(s, p, _)| s == name && drawer.contains(*p)) - .map(|(_, p, c)| (*p, *c)) - .unwrap_or_else(|| panic!("no {name} in the drawer {drawer:?}")) - } - - /// The drawer frames each flyout group whole, under the heading most of its tools have: every - /// tool once, the Pencil's group (led by the Shaper) under Draw. - #[test] - fn the_drawer_frames_every_flyout_group_once() { - let listed: Vec<&str> = groups().iter().flat_map(|(_, gs)| gs.iter().flat_map(|g| g.iter().map(|t| t.id))).collect(); - let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); - assert_eq!(listed.len(), all.len()); - assert!(all.iter().all(|id| listed.contains(id))); - let under = |id: &str| groups().iter().find(|(_, gs)| gs.iter().any(|g| g.iter().any(|t| t.id == id))).map(|(c, _)| *c); - assert_eq!(under("pencil"), Some("Draw")); - assert_eq!(under("shaper"), Some("Draw"), "framed with the Pencil"); - assert_eq!(under("rectangle"), Some("Shapes")); - assert_eq!(under("objectsOnPath"), Some("Modify"), "framed with the Blend tool"); - assert_eq!(heading("objectsOnPath"), "Modify", "though not in the Basic toolbar"); - } - - /// The Show section's controls (#1736): each shows (true), hides (false) or toggles, saved with - /// the preferences; an unknown control is an error naming them. - #[test] - fn toolbar_show_turns_the_controls_under_the_tools_on_and_off() { - let mut app = app(); - let all_on = json!({"fillStroke": true, "coloring": true, "drawMode": true, "screenMode": true}); - assert_eq!(app.run("toolbar.show", json!({})).unwrap(), all_on); - assert_eq!(app.run("toolbar.show", json!({"control": "coloring"})).unwrap()["coloring"], json!(false)); - assert_eq!(app.run("toolbar.show", json!({"control": "screenMode", "on": false})).unwrap()["screenMode"], json!(false)); - assert!(!app.ui.toolbar_show.coloring && !app.ui.toolbar_show.screen_mode && app.ui.toolbar_show.fill_stroke); - let err = app.run("toolbar.show", json!({"control": "swatches"})).unwrap_err(); - assert!(err.contains("swatches") && err.contains("fillStroke"), "{err}"); - // Saved with the preferences; older preferences show them all. - let saved = serde_json::to_value(&app.ui).unwrap(); - assert_eq!(serde_json::from_value::(saved).unwrap().toolbar_show, app.ui.toolbar_show); - let mut old = serde_json::to_value(&app.ui).unwrap(); - old.as_object_mut().unwrap().remove("toolbar_show"); - assert_eq!(serde_json::from_value::(old).unwrap().toolbar_show, crate::state::ToolbarShow::default()); - } - - /// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order. - #[test] - fn every_tool_is_listed_once_under_a_heading() { - let s = sections(); - let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect(); - let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); - assert_eq!(listed.len(), all.len()); - assert!(all.iter().all(|id| listed.contains(id))); - let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect(); - assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]); - assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}"); - assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}"); - } - - #[test] - fn the_commands_add_move_remove_and_reset_tools() { - let mut app = app(); - let shipped = slots(&app); - // At the end; the toolbar's slots come back. - let r = app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap(); - assert_eq!(r, json!(slots(&app))); - assert_eq!(slots(&app).last().unwrap(), &["artboard"]); - // Before the first slot, moved out of its group. - app.run("toolbar.addTool", json!({"tool": "magicWand", "slot": 0})).unwrap(); - assert_eq!(&slots(&app)[..3], [&["magicWand"][..], &["selection"], &["directSelection", "groupSelection"]]); - assert_eq!(slots(&app)[3], ["lasso"]); - // After a slot, and into a group (last), moved out of the Rectangle group. - app.run("toolbar.addTool", json!({"tool": "slice", "slot": 1, "place": "after"})).unwrap(); - assert_eq!(slots(&app)[2], ["slice"]); - let ellipse = slot_of(&app, "ellipse").unwrap(); - app.run("toolbar.addTool", json!({"tool": "star", "slot": ellipse, "place": "group"})).unwrap(); - assert_eq!(slots(&app)[ellipse], ["ellipse", "star"]); - assert!(!slots(&app)[slot_of(&app, "rectangle").unwrap()].contains(&"star")); - // Onto its own group: nothing changes. - let before = slots(&app); - app.run("toolbar.addTool", json!({"tool": "ellipse", "slot": ellipse, "place": "group"})).unwrap(); - assert_eq!(slots(&app), before); - // The category label still names the first slot of its category. - assert_eq!(crate::toolbar::slots(&app)[0].0, Some("Select")); - // Out: one tool, or its whole group; an emptied slot goes. - app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap(); - assert_eq!(slots(&app)[ellipse], ["star"]); - app.run("toolbar.removeTool", json!({"tool": "addAnchor", "group": true})).unwrap(); - assert!(slot_of(&app, "pen").is_none() && slot_of(&app, "addAnchor").is_none()); - // Bad params change nothing. - let before = slots(&app); - for p in [ - json!({}), - json!({"tool": "nope"}), - json!({"tool": "pen", "slot": 999}), - json!({"tool": "pen", "slot": -1}), - json!({"tool": "pen", "place": "inside"}), - ] { - assert!(app.run("toolbar.addTool", p.clone()).is_err(), "{p}"); - } - assert_eq!(app.run("toolbar.removeTool", json!({"tool": "pen"})).unwrap(), json!(before), "not in the toolbar: stays out"); - assert!(app.run("toolbar.addTool", json!({"tool": "pen", "slot": "first"})).unwrap_err().contains("between 0 and")); - assert!(app.run("toolbar.removeTool", json!({"tool": "star", "group": "yes"})).is_err()); - assert_eq!(slots(&app), before); - // The Advanced toolbar keeps its own edits. - app.run("window.toolbarAdvanced", json!({})).unwrap(); - assert_eq!(slots(&app).len(), TOOL_GROUPS.len()); - app.run("toolbar.removeTool", json!({"tool": "selection"})).unwrap(); - assert!(app.ui.toolbar_edits.advanced.is_some() && slot_of(&app, "selection").is_none()); - app.run("window.toolbarAdvanced", json!({})).unwrap(); - assert_eq!(slots(&app)[0], ["magicWand"]); - // Reset puts the current one back as it ships. - assert_eq!(app.run("toolbar.reset", json!({})).unwrap(), json!(shipped)); - assert!(app.ui.toolbar_edits.basic.is_none() && app.ui.toolbar_edits.advanced.is_some()); - // An edit that gives back the shipped toolbar forgets the edits. - app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap(); - app.run("toolbar.removeTool", json!({"tool": "artboard"})).unwrap(); - assert!(app.ui.toolbar_edits.basic.is_none()); - // Everything out, then a tool back: it starts its own category. - for tool in shipped.iter().flatten() { - app.run("toolbar.removeTool", json!({"tool": tool})).unwrap(); - } - assert!(slots(&app).is_empty()); - app.run("toolbar.addTool", json!({"tool": "pen"})).unwrap(); - assert_eq!(crate::toolbar::slots(&app), [(Some("Draw"), vec!["pen"])]); - } - - #[test] - fn the_edited_toolbar_is_saved_and_loads_sanitized() { - let mut app = app(); - app.run("toolbar.addTool", json!({"tool": "artboard", "slot": 0})).unwrap(); - let saved = serde_json::to_value(&app.ui).unwrap(); - let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); - assert_eq!(back.toolbar_edits, app.ui.toolbar_edits); - // Preferences saved before Edit Toolbar load the shipped toolbars. - let mut old = saved; - old.as_object_mut().unwrap().remove("toolbar_edits"); - assert_eq!(serde_json::from_value::(old).unwrap().toolbar_edits, ToolbarEdits::default()); - // A hand-edited file: unknown tools and names go, each tool once, emptied slots go. - let cat = |name: Option<&str>, slots: &[&[&str]]| ToolbarCategory { - name: name.map(str::to_string), - slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(), - }; - let ui = crate::state::UiState { - toolbar_edits: ToolbarEdits { - basic: Some(vec![cat(Some("Nope"), &[&["pen", "nope"], &["pen"], &["nope"]]), cat(Some("Draw"), &[&[]])]), - advanced: None, - }, - tools_drawer: true, - ..Default::default() - } - .sanitized(); - assert_eq!(ui.toolbar_edits.basic, Some(vec![cat(None, &[&["pen"]])])); - assert!(!ui.tools_drawer, "the drawer starts closed"); - } - - /// #1018: the ••• button opens the drawer beside the toolbar, by the button, not a centred - /// dialog; Escape, a click outside and the button close it, a click in the toolbar doesn't. - #[test] - fn edit_toolbar_opens_a_drawer_beside_the_toolbar() { - let mut app = app(); - let ctx = ctx(); - frame(&mut app, &ctx, 0.0, vec![]); - let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap(); - click(&mut app, &ctx, 1.0, edit.center()); - frame(&mut app, &ctx, 1.2, vec![]); - assert!(app.ui.tools_drawer && app.ui.dialog.is_none()); - assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(true)); - let drawer = drawer_rect(&ctx).expect("the drawer is shown"); - let bar = ctx.data(|d| d.get_temp::(toolbar_id())).unwrap(); - assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}"); - assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}"); - assert!(drawer.top() >= bar.top() - 0.5, "never above the toolbar (the title bar's there): {drawer:?} {bar:?}"); - assert!(drawer.y_range().contains(edit.center().y), "by the button: {drawer:?} {edit:?}"); - assert!(drawer.width() < WIDTH + 24.0, "one row of tools wide: {drawer:?}"); - // A click on a toolbar tool picks it and leaves the drawer open. - let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); - let ellipse = tools[slot_of(&app, "ellipse").unwrap()].center(); - click(&mut app, &ctx, 2.0, ellipse); - assert!(app.ui.tools_drawer && app.session.tool_id() == "ellipse"); - // Escape closes it. - let esc = |pressed| Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; - frame(&mut app, &ctx, 3.0, vec![esc(true), esc(false)]); - assert!(!app.ui.tools_drawer); - frame(&mut app, &ctx, 3.1, vec![]); - assert!(!drawer_shown(&ctx)); - // A click outside closes it, and so does the ••• button. - app.run("window.editToolbar", json!({"open": true})).unwrap(); - frame(&mut app, &ctx, 4.0, vec![]); - click(&mut app, &ctx, 4.5, pos2(SCREEN.x - 20.0, 300.0)); - assert!(!app.ui.tools_drawer); - click(&mut app, &ctx, 5.0, edit.center()); - assert!(app.ui.tools_drawer); - click(&mut app, &ctx, 6.0, edit.center()); - assert!(!app.ui.tools_drawer); - // Picking a tool in the drawer selects it and closes the drawer. - app.run("window.editToolbar", json!({})).unwrap(); - frame(&mut app, &ctx, 7.0, vec![]); - let shown = frame(&mut app, &ctx, 7.1, vec![]); - let (at, _) = cell_at(&shown, drawer_rect(&ctx).unwrap(), "Artboard Tool"); - click(&mut app, &ctx, 8.0, at); - assert!(!app.ui.tools_drawer && app.session.tool_id() == "artboard"); - } - - /// The drawer dims the tools the toolbar holds. - #[test] - fn tools_in_the_toolbar_are_dimmed_in_the_drawer() { - let mut app = app(); - let ctx = ctx(); - app.run("window.editToolbar", json!({"open": true})).unwrap(); - frame(&mut app, &ctx, 0.0, vec![]); - let shown = frame(&mut app, &ctx, 0.1, vec![]); - let drawer = drawer_rect(&ctx).unwrap(); - let dim = Tokens::get(&ctx).text_disabled; - assert_eq!(cell_at(&shown, drawer, "Ellipse Tool").1, dim); - assert_ne!(cell_at(&shown, drawer, "Artboard Tool").1, dim); - app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap(); - app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap(); - let shown = frame(&mut app, &ctx, 0.2, vec![]); - assert_ne!(cell_at(&shown, drawer, "Ellipse Tool").1, dim); - assert_eq!(cell_at(&shown, drawer, "Artboard Tool").1, dim); - } - - /// #1018: tools drag from the drawer onto the toolbar (between two tools, or onto one to join - /// its group), from the toolbar into the drawer, and within the toolbar, only while the drawer - /// is open. - #[test] - fn tools_drag_between_the_drawer_and_the_toolbar() { - let mut app = app(); - let ctx = ctx(); - app.run("window.editToolbar", json!({"open": true})).unwrap(); - frame(&mut app, &ctx, 0.0, vec![]); - let shown = frame(&mut app, &ctx, 0.1, vec![]); - let drawer = drawer_rect(&ctx).unwrap(); - let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); - // Onto the divider under the Selection tool: its own slot, second. - let (artboard, _) = cell_at(&shown, drawer, "Artboard Tool"); - drag(&mut app, &ctx, 1.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0)); - assert_eq!(&slots(&app)[..2], [&["selection"][..], &["artboard"]]); - assert!(app.ui.tools_drawer, "the drawer stays open"); - // Onto the middle of the Ellipse tool: into its group. - let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); - let ellipse = slot_of(&app, "ellipse").unwrap(); - // The drawer moves with the ••• button the new slot pushed down: read it again. - let shown = frame(&mut app, &ctx, 1.9, vec![]); - let drawer = drawer_rect(&ctx).unwrap(); - let (slice, _) = cell_at(&shown, drawer, "Slice Tool"); - drag(&mut app, &ctx, 2.0, slice, tools[ellipse].center()); - assert_eq!(slots(&app)[ellipse], ["ellipse", "slice"]); - // A dimmed tool (in the toolbar already) doesn't drag. - let before = slots(&app); - let shown = frame(&mut app, &ctx, 2.9, vec![]); - let drawer = drawer_rect(&ctx).unwrap(); - let (rectangle, _) = cell_at(&shown, drawer, "Rectangle Tool"); - drag(&mut app, &ctx, 3.0, rectangle, tools[0].center_bottom() - vec2(0.0, 3.0)); - assert_eq!(slots(&app), before); - // From the toolbar into the drawer: out of the toolbar. - let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); - drag(&mut app, &ctx, 4.0, tools[1].center(), drawer.center()); - assert!(slot_of(&app, "artboard").is_none(), "{:?}", slots(&app)); - // Within the toolbar: Selection onto the divider over the Lasso. - let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); - drag(&mut app, &ctx, 5.0, tools[0].center(), tools[2].center_top() + vec2(0.0, 3.0)); - assert_eq!(&slots(&app)[..3], [&["directSelection", "groupSelection"][..], &["selection"], &["lasso", "magicWand"]]); - // The drawer closed, the toolbar is locked: the same drag moves nothing. - app.run("window.editToolbar", json!({"open": false})).unwrap(); - frame(&mut app, &ctx, 6.0, vec![]); - let before = slots(&app); - let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); - drag(&mut app, &ctx, 7.0, tools[0].center(), tools[2].center_bottom() - vec2(0.0, 3.0)); - assert_eq!(slots(&app), before); - } - - #[test] - fn drop_targets_follow_the_buttons_edges() { - let r = |y: f32| Rect::from_min_size(pos2(6.0, y), BUTTON); - let one = [(0, r(0.0)), (1, r(30.0))]; - assert_eq!(drop_target(&one, pos2(20.0, 2.0), 1), (Some(0), Place::Before)); - assert_eq!(drop_target(&one, pos2(20.0, 14.0), 1), (Some(0), Place::Group)); - assert_eq!(drop_target(&one, pos2(20.0, 27.0), 1), (Some(0), Place::After)); - // Past the last button, or beside them: next to the nearest one. - assert_eq!(drop_target(&one, pos2(20.0, 200.0), 1), (Some(1), Place::After)); - assert_eq!(drop_target(&one, pos2(44.5, 40.0), 1), (Some(1), Place::After)); - // Two columns: the left and right edges. - let two = [(0, Rect::from_min_size(pos2(2.0, 0.0), BUTTON)), (1, Rect::from_min_size(pos2(38.0, 0.0), BUTTON))]; - assert_eq!(drop_target(&two, pos2(40.0, 10.0), 2), (Some(1), Place::Before)); - assert_eq!(drop_target(&two, pos2(36.0, 10.0), 2), (Some(0), Place::After)); - assert_eq!(drop_target(&[], pos2(0.0, 0.0), 1), (None, Place::Before)); - } -} +mod tests; diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs new file mode 100644 index 000000000..5a8f1ce94 --- /dev/null +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -0,0 +1,374 @@ +use egui::{Color32, Event, PointerButton, RawInput}; +use vectorcraft_engine::Session; + +use super::*; + +/// Tall enough for the whole Basic toolbar and the whole drawer. +const SCREEN: egui::Vec2 = vec2(1000.0, 4000.0); +/// A toolbar tool button. +const BUTTON: egui::Vec2 = vec2(36.0, 29.0); + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app +} + +fn slots(app: &VectorcraftApp) -> Vec> { + crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect() +} + +/// The slot holding `tool`. +fn slot_of(app: &VectorcraftApp, tool: &str) -> Option { + slots(app).iter().position(|s| s.contains(&tool)) +} + +/// One headless frame of the toolbar, its keys handled first as in the app; returns the texts +/// painted: (text, a point inside it, colour). +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec<(String, Pos2, Color32)> { + fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2, Color32)>) { + match s { + egui::Shape::Text(t) => { + let color = t.galley.job.sections.first().map_or(t.fallback_color, |s| s.format.color); + out.push((t.galley.text().to_string(), t.pos + vec2(4.0, t.galley.size().y / 2.0), color)); + } + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let input = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + crate::toolbar::show(app, ui); + }); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + shown +} + +fn ctx() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + ctx +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) { + frame(app, ctx, time, vec![Event::PointerMoved(at), button(at, true)]); + frame(app, ctx, time + 0.05, vec![button(at, false)]); + frame(app, ctx, time + 0.1, vec![]); +} + +/// A press at `from` dragged to `to` in steps, one frame each, then released there. +fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) { + frame(app, ctx, time, vec![Event::PointerMoved(from), button(from, true)]); + for k in 1..=6 { + frame(app, ctx, time + 0.05 * f64::from(k), vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]); + } + frame(app, ctx, time + 0.4, vec![button(to, false)]); + frame(app, ctx, time + 0.45, vec![]); +} + +fn drawer_rect(ctx: &egui::Context) -> Option { + ctx.memory(|m| m.area_rect(drawer_id())) +} + +fn drawer_shown(ctx: &egui::Context) -> bool { + ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id()))) +} + +/// A point inside the drawer's row of the tool named `name` (as the drawer shows it), and the +/// colour of its name. +fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2, Color32) { + shown + .iter() + .find(|(s, p, _)| s == name && drawer.contains(*p)) + .map(|(_, p, c)| (*p, *c)) + .unwrap_or_else(|| panic!("no {name} in the drawer {drawer:?}")) +} + +/// The drawer frames each flyout group whole, under the heading most of its tools have: every +/// tool once, the Pencil's group (led by the Shaper) under Draw. +#[test] +fn the_drawer_frames_every_flyout_group_once() { + let listed: Vec<&str> = groups().iter().flat_map(|(_, gs)| gs.iter().flat_map(|g| g.iter().map(|t| t.id))).collect(); + let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); + assert_eq!(listed.len(), all.len()); + assert!(all.iter().all(|id| listed.contains(id))); + let under = |id: &str| groups().iter().find(|(_, gs)| gs.iter().any(|g| g.iter().any(|t| t.id == id))).map(|(c, _)| *c); + assert_eq!(under("pencil"), Some("Draw")); + assert_eq!(under("shaper"), Some("Draw"), "framed with the Pencil"); + assert_eq!(under("rectangle"), Some("Shapes")); + assert_eq!(under("objectsOnPath"), Some("Modify"), "framed with the Blend tool"); + assert_eq!(heading("objectsOnPath"), "Modify", "though not in the Basic toolbar"); +} + +/// The Show section's controls (#1736): each shows (true), hides (false) or toggles, saved with +/// the preferences; an unknown control is an error naming them. +#[test] +fn toolbar_show_turns_the_controls_under_the_tools_on_and_off() { + let mut app = app(); + let all_on = json!({"fillStroke": true, "coloring": true, "drawMode": true, "screenMode": true}); + assert_eq!(app.run("toolbar.show", json!({})).unwrap(), all_on); + assert_eq!(app.run("toolbar.show", json!({"control": "coloring"})).unwrap()["coloring"], json!(false)); + assert_eq!(app.run("toolbar.show", json!({"control": "screenMode", "on": false})).unwrap()["screenMode"], json!(false)); + assert!(!app.ui.toolbar_show.coloring && !app.ui.toolbar_show.screen_mode && app.ui.toolbar_show.fill_stroke); + let err = app.run("toolbar.show", json!({"control": "swatches"})).unwrap_err(); + assert!(err.contains("swatches") && err.contains("fillStroke"), "{err}"); + // Saved with the preferences; older preferences show them all. + let saved = serde_json::to_value(&app.ui).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap().toolbar_show, app.ui.toolbar_show); + let mut old = serde_json::to_value(&app.ui).unwrap(); + old.as_object_mut().unwrap().remove("toolbar_show"); + assert_eq!(serde_json::from_value::(old).unwrap().toolbar_show, crate::state::ToolbarShow::default()); +} + +/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order. +#[test] +fn every_tool_is_listed_once_under_a_heading() { + let s = sections(); + let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect(); + let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); + assert_eq!(listed.len(), all.len()); + assert!(all.iter().all(|id| listed.contains(id))); + let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect(); + assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]); + assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}"); + assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}"); +} + +#[test] +fn the_commands_add_move_remove_and_reset_tools() { + let mut app = app(); + let shipped = slots(&app); + // At the end; the toolbar's slots come back. + let r = app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap(); + assert_eq!(r, json!(slots(&app))); + assert_eq!(slots(&app).last().unwrap(), &["artboard"]); + // Before the first slot, moved out of its group. + app.run("toolbar.addTool", json!({"tool": "magicWand", "slot": 0})).unwrap(); + assert_eq!(&slots(&app)[..3], [&["magicWand"][..], &["selection"], &["directSelection", "groupSelection"]]); + assert_eq!(slots(&app)[3], ["lasso"]); + // After a slot, and into a group (last), moved out of the Rectangle group. + app.run("toolbar.addTool", json!({"tool": "slice", "slot": 1, "place": "after"})).unwrap(); + assert_eq!(slots(&app)[2], ["slice"]); + let ellipse = slot_of(&app, "ellipse").unwrap(); + app.run("toolbar.addTool", json!({"tool": "star", "slot": ellipse, "place": "group"})).unwrap(); + assert_eq!(slots(&app)[ellipse], ["ellipse", "star"]); + assert!(!slots(&app)[slot_of(&app, "rectangle").unwrap()].contains(&"star")); + // Onto its own group: nothing changes. + let before = slots(&app); + app.run("toolbar.addTool", json!({"tool": "ellipse", "slot": ellipse, "place": "group"})).unwrap(); + assert_eq!(slots(&app), before); + // The category label still names the first slot of its category. + assert_eq!(crate::toolbar::slots(&app)[0].0, Some("Select")); + // Out: one tool, or its whole group; an emptied slot goes. + app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap(); + assert_eq!(slots(&app)[ellipse], ["star"]); + app.run("toolbar.removeTool", json!({"tool": "addAnchor", "group": true})).unwrap(); + assert!(slot_of(&app, "pen").is_none() && slot_of(&app, "addAnchor").is_none()); + // Bad params change nothing. + let before = slots(&app); + for p in [ + json!({}), + json!({"tool": "nope"}), + json!({"tool": "pen", "slot": 999}), + json!({"tool": "pen", "slot": -1}), + json!({"tool": "pen", "place": "inside"}), + ] { + assert!(app.run("toolbar.addTool", p.clone()).is_err(), "{p}"); + } + assert_eq!(app.run("toolbar.removeTool", json!({"tool": "pen"})).unwrap(), json!(before), "not in the toolbar: stays out"); + assert!(app.run("toolbar.addTool", json!({"tool": "pen", "slot": "first"})).unwrap_err().contains("between 0 and")); + assert!(app.run("toolbar.removeTool", json!({"tool": "star", "group": "yes"})).is_err()); + assert_eq!(slots(&app), before); + // The Advanced toolbar keeps its own edits. + app.run("window.toolbarAdvanced", json!({})).unwrap(); + assert_eq!(slots(&app).len(), TOOL_GROUPS.len()); + app.run("toolbar.removeTool", json!({"tool": "selection"})).unwrap(); + assert!(app.ui.toolbar_edits.advanced.is_some() && slot_of(&app, "selection").is_none()); + app.run("window.toolbarAdvanced", json!({})).unwrap(); + assert_eq!(slots(&app)[0], ["magicWand"]); + // Reset puts the current one back as it ships. + assert_eq!(app.run("toolbar.reset", json!({})).unwrap(), json!(shipped)); + assert!(app.ui.toolbar_edits.basic.is_none() && app.ui.toolbar_edits.advanced.is_some()); + // An edit that gives back the shipped toolbar forgets the edits. + app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap(); + app.run("toolbar.removeTool", json!({"tool": "artboard"})).unwrap(); + assert!(app.ui.toolbar_edits.basic.is_none()); + // Everything out, then a tool back: it starts its own category. + for tool in shipped.iter().flatten() { + app.run("toolbar.removeTool", json!({"tool": tool})).unwrap(); + } + assert!(slots(&app).is_empty()); + app.run("toolbar.addTool", json!({"tool": "pen"})).unwrap(); + assert_eq!(crate::toolbar::slots(&app), [(Some("Draw"), vec!["pen"])]); +} + +#[test] +fn the_edited_toolbar_is_saved_and_loads_sanitized() { + let mut app = app(); + app.run("toolbar.addTool", json!({"tool": "artboard", "slot": 0})).unwrap(); + let saved = serde_json::to_value(&app.ui).unwrap(); + let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(back.toolbar_edits, app.ui.toolbar_edits); + // Preferences saved before Edit Toolbar load the shipped toolbars. + let mut old = saved; + old.as_object_mut().unwrap().remove("toolbar_edits"); + assert_eq!(serde_json::from_value::(old).unwrap().toolbar_edits, ToolbarEdits::default()); + // A hand-edited file: unknown tools and names go, each tool once, emptied slots go. + let cat = |name: Option<&str>, slots: &[&[&str]]| ToolbarCategory { + name: name.map(str::to_string), + slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(), + }; + let ui = crate::state::UiState { + toolbar_edits: ToolbarEdits { + basic: Some(vec![cat(Some("Nope"), &[&["pen", "nope"], &["pen"], &["nope"]]), cat(Some("Draw"), &[&[]])]), + advanced: None, + }, + tools_drawer: true, + ..Default::default() + } + .sanitized(); + assert_eq!(ui.toolbar_edits.basic, Some(vec![cat(None, &[&["pen"]])])); + assert!(!ui.tools_drawer, "the drawer starts closed"); +} + +/// #1018: the ••• button opens the drawer beside the toolbar, by the button, not a centred +/// dialog; Escape, a click outside and the button close it, a click in the toolbar doesn't. +#[test] +fn edit_toolbar_opens_a_drawer_beside_the_toolbar() { + let mut app = app(); + let ctx = ctx(); + frame(&mut app, &ctx, 0.0, vec![]); + let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap(); + click(&mut app, &ctx, 1.0, edit.center()); + frame(&mut app, &ctx, 1.2, vec![]); + assert!(app.ui.tools_drawer && app.ui.dialog.is_none()); + assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(true)); + let drawer = drawer_rect(&ctx).expect("the drawer is shown"); + let bar = ctx.data(|d| d.get_temp::(toolbar_id())).unwrap(); + assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}"); + assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}"); + assert!(drawer.top() >= bar.top() - 0.5, "never above the toolbar (the title bar's there): {drawer:?} {bar:?}"); + assert!(drawer.y_range().contains(edit.center().y), "by the button: {drawer:?} {edit:?}"); + assert!(drawer.width() < WIDTH + 24.0, "one row of tools wide: {drawer:?}"); + // A click on a toolbar tool picks it and leaves the drawer open. + let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); + let ellipse = tools[slot_of(&app, "ellipse").unwrap()].center(); + click(&mut app, &ctx, 2.0, ellipse); + assert!(app.ui.tools_drawer && app.session.tool_id() == "ellipse"); + // Escape closes it. + let esc = |pressed| Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(&mut app, &ctx, 3.0, vec![esc(true), esc(false)]); + assert!(!app.ui.tools_drawer); + frame(&mut app, &ctx, 3.1, vec![]); + assert!(!drawer_shown(&ctx)); + // A click outside closes it, and so does the ••• button. + app.run("window.editToolbar", json!({"open": true})).unwrap(); + frame(&mut app, &ctx, 4.0, vec![]); + click(&mut app, &ctx, 4.5, pos2(SCREEN.x - 20.0, 300.0)); + assert!(!app.ui.tools_drawer); + click(&mut app, &ctx, 5.0, edit.center()); + assert!(app.ui.tools_drawer); + click(&mut app, &ctx, 6.0, edit.center()); + assert!(!app.ui.tools_drawer); + // Picking a tool in the drawer selects it and closes the drawer. + app.run("window.editToolbar", json!({})).unwrap(); + frame(&mut app, &ctx, 7.0, vec![]); + let shown = frame(&mut app, &ctx, 7.1, vec![]); + let (at, _) = cell_at(&shown, drawer_rect(&ctx).unwrap(), "Artboard Tool"); + click(&mut app, &ctx, 8.0, at); + assert!(!app.ui.tools_drawer && app.session.tool_id() == "artboard"); +} + +/// The drawer dims the tools the toolbar holds. +#[test] +fn tools_in_the_toolbar_are_dimmed_in_the_drawer() { + let mut app = app(); + let ctx = ctx(); + app.run("window.editToolbar", json!({"open": true})).unwrap(); + frame(&mut app, &ctx, 0.0, vec![]); + let shown = frame(&mut app, &ctx, 0.1, vec![]); + let drawer = drawer_rect(&ctx).unwrap(); + let dim = Tokens::get(&ctx).text_disabled; + assert_eq!(cell_at(&shown, drawer, "Ellipse Tool").1, dim); + assert_ne!(cell_at(&shown, drawer, "Artboard Tool").1, dim); + app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap(); + app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap(); + let shown = frame(&mut app, &ctx, 0.2, vec![]); + assert_ne!(cell_at(&shown, drawer, "Ellipse Tool").1, dim); + assert_eq!(cell_at(&shown, drawer, "Artboard Tool").1, dim); +} + +/// #1018: tools drag from the drawer onto the toolbar (between two tools, or onto one to join +/// its group), from the toolbar into the drawer, and within the toolbar, only while the drawer +/// is open. +#[test] +fn tools_drag_between_the_drawer_and_the_toolbar() { + let mut app = app(); + let ctx = ctx(); + app.run("window.editToolbar", json!({"open": true})).unwrap(); + frame(&mut app, &ctx, 0.0, vec![]); + let shown = frame(&mut app, &ctx, 0.1, vec![]); + let drawer = drawer_rect(&ctx).unwrap(); + let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); + // Onto the divider under the Selection tool: its own slot, second. + let (artboard, _) = cell_at(&shown, drawer, "Artboard Tool"); + drag(&mut app, &ctx, 1.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0)); + assert_eq!(&slots(&app)[..2], [&["selection"][..], &["artboard"]]); + assert!(app.ui.tools_drawer, "the drawer stays open"); + // Onto the middle of the Ellipse tool: into its group. + let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); + let ellipse = slot_of(&app, "ellipse").unwrap(); + // The drawer moves with the ••• button the new slot pushed down: read it again. + let shown = frame(&mut app, &ctx, 1.9, vec![]); + let drawer = drawer_rect(&ctx).unwrap(); + let (slice, _) = cell_at(&shown, drawer, "Slice Tool"); + drag(&mut app, &ctx, 2.0, slice, tools[ellipse].center()); + assert_eq!(slots(&app)[ellipse], ["ellipse", "slice"]); + // A dimmed tool (in the toolbar already) doesn't drag. + let before = slots(&app); + let shown = frame(&mut app, &ctx, 2.9, vec![]); + let drawer = drawer_rect(&ctx).unwrap(); + let (rectangle, _) = cell_at(&shown, drawer, "Rectangle Tool"); + drag(&mut app, &ctx, 3.0, rectangle, tools[0].center_bottom() - vec2(0.0, 3.0)); + assert_eq!(slots(&app), before); + // From the toolbar into the drawer: out of the toolbar. + let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); + drag(&mut app, &ctx, 4.0, tools[1].center(), drawer.center()); + assert!(slot_of(&app, "artboard").is_none(), "{:?}", slots(&app)); + // Within the toolbar: Selection onto the divider over the Lasso. + let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); + drag(&mut app, &ctx, 5.0, tools[0].center(), tools[2].center_top() + vec2(0.0, 3.0)); + assert_eq!(&slots(&app)[..3], [&["directSelection", "groupSelection"][..], &["selection"], &["lasso", "magicWand"]]); + // The drawer closed, the toolbar is locked: the same drag moves nothing. + app.run("window.editToolbar", json!({"open": false})).unwrap(); + frame(&mut app, &ctx, 6.0, vec![]); + let before = slots(&app); + let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON); + drag(&mut app, &ctx, 7.0, tools[0].center(), tools[2].center_bottom() - vec2(0.0, 3.0)); + assert_eq!(slots(&app), before); +} + +#[test] +fn drop_targets_follow_the_buttons_edges() { + let r = |y: f32| Rect::from_min_size(pos2(6.0, y), BUTTON); + let one = [(0, r(0.0)), (1, r(30.0))]; + assert_eq!(drop_target(&one, pos2(20.0, 2.0), 1), (Some(0), Place::Before)); + assert_eq!(drop_target(&one, pos2(20.0, 14.0), 1), (Some(0), Place::Group)); + assert_eq!(drop_target(&one, pos2(20.0, 27.0), 1), (Some(0), Place::After)); + // Past the last button, or beside them: next to the nearest one. + assert_eq!(drop_target(&one, pos2(20.0, 200.0), 1), (Some(1), Place::After)); + assert_eq!(drop_target(&one, pos2(44.5, 40.0), 1), (Some(1), Place::After)); + // Two columns: the left and right edges. + let two = [(0, Rect::from_min_size(pos2(2.0, 0.0), BUTTON)), (1, Rect::from_min_size(pos2(38.0, 0.0), BUTTON))]; + assert_eq!(drop_target(&two, pos2(40.0, 10.0), 2), (Some(1), Place::Before)); + assert_eq!(drop_target(&two, pos2(36.0, 10.0), 2), (Some(0), Place::After)); + assert_eq!(drop_target(&[], pos2(0.0, 0.0), 1), (None, Place::Before)); +} From 3de34b12257b4a71933a9c06711f7d879b7dbca9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:15 +0000 Subject: [PATCH 067/199] refactor(ui-egui): move widgets.rs look_tests to widgets/look_tests.rs --- crates/ui-egui/src/widgets.rs | 374 +---------------------- crates/ui-egui/src/widgets/look_tests.rs | 371 ++++++++++++++++++++++ 2 files changed, 372 insertions(+), 373 deletions(-) create mode 100644 crates/ui-egui/src/widgets/look_tests.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 093bb3e9d..147e0f673 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -3406,376 +3406,4 @@ mod button_tests { /// How the shared widgets look once laid out and painted: shapes of whole frames, measured. #[cfg(test)] -pub(crate) mod look_tests { - use super::*; - - /// A context with the fonts and the theme in `brightness`, in English. - pub(crate) fn themed(brightness: theme::Brightness) -> egui::Context { - crate::i18n::set_current(crate::i18n::Lang::EN); - let ctx = egui::Context::default(); - theme::install_fonts(&ctx); - theme::apply(&ctx, brightness); - ctx - } - - /// One frame at `time` seconds of `add` in a 600 × 400 window with `events` → the shapes it - /// drew, `Shape::Vec`s flattened. - pub(crate) fn frame(ctx: &egui::Context, time: f64, events: Vec, add: impl FnMut(&mut Ui)) -> Vec { - fn flat(s: egui::Shape, out: &mut Vec) { - match s { - egui::Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), - s => out.push(s), - } - } - let raw = - egui::RawInput { time: Some(time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0))), events, ..Default::default() }; - let mut out = ctx.run_ui(raw, add); - out.textures_delta.clear(); - let mut shapes = vec![]; - out.shapes.into_iter().for_each(|c| flat(c.shape, &mut shapes)); - shapes - } - - // Regression test for #808: the scroll bar of the toolbar and the dock's icon column widened to - // 10 points while it was dragged or the pointer was on it, covering the group labels and the - // right edge of the tool icons. It stays a slim line. - #[test] - fn the_strip_scroll_bar_stays_a_slim_line_while_the_pointer_is_on_it() { - let ctx = themed(Default::default()); - let area = std::cell::Cell::new(Rect::NOTHING); - let mut add = |ui: &mut Ui| { - area.set( - ui.allocate_ui(vec2(80.0, 200.0), |ui| { - strip_scroll(ui, "strip", |ui| { - for i in 0..40 { - ui.label(format!("Tool {i}")); - } - }); - }) - .response - .rect, - ); - }; - frame(&ctx, 0.0, vec![], &mut add); - let area = area.get(); - assert!(area.height() >= 199.0, "the strip fills the height it was given: {area:?}"); - // The pointer comes onto the bar's edge and stays there while the bar settles. - let on_the_bar = egui::Event::PointerMoved(pos2(area.right() - 1.0, area.center().y)); - let mut widest = 0.0_f32; - for i in 1..=15 { - let shapes = frame(&ctx, f64::from(i) * 0.1, if i == 1 { vec![on_the_bar.clone()] } else { vec![] }, &mut add); - for s in shapes { - // The bar's track and handle are the filled rectangles. - if let egui::Shape::Rect(r) = s - && r.fill != Color32::TRANSPARENT - { - widest = widest.max(r.rect.width()); - } - } - } - assert!(widest > 0.0, "the scroll bar is drawn"); - assert!(widest <= 4.5, "the scroll bar is {widest} points wide"); - } - // Regression test for #900: a number field with a spinner and a preset list drew as three - // boxes side by side: its text box was a point taller than the 26-point row, and each joint had - // two borders. - #[test] - fn a_spinner_field_reads_as_one_box_in_three_parts() { - let ctx = themed(Default::default()); - let mut add = |ui: &mut Ui| { - ui.horizontal(|ui| spin_field(ui, "weight", Some(4.0), Unit::Points, 100.0, 1.0, 0.0, &[1.0, 2.0])); - }; - frame(&ctx, 0.0, vec![], &mut add); - let shapes = frame(&ctx, 0.1, vec![], &mut add); - // The boxes of the row: the spinner, the field and the preset chevron, left to right. - let mut boxes: Vec = vec![]; - for s in &shapes { - if let egui::Shape::Rect(r) = s - && r.rect.height() > 20.0 - // egui 0.37's TextEdit paints its `Frame::NONE` as a transparent rect too: that is - // not a box of the row. - && r.fill != Color32::TRANSPARENT - && !boxes.contains(&r.rect) - { - boxes.push(r.rect); - } - } - boxes.sort_by(|a, b| a.min.x.total_cmp(&b.min.x)); - assert_eq!(boxes.len(), 3, "{boxes:?}"); - for b in &boxes { - assert_eq!((b.min.y, b.height()), (boxes[0].min.y, 26.0), "all as tall as the row: {boxes:?}"); - } - for pair in boxes.windows(2) { - assert!((pair[1].min.x - pair[0].max.x + 1.0).abs() < 0.01, "neighbours share one border: {boxes:?}"); - } - } - - /// The colour of every glyph of the text in `shapes`: selected glyphs are drawn in the - /// selection stroke's colour. - pub(crate) fn glyph_colours(shapes: &[egui::Shape]) -> Vec { - let mut colours = vec![]; - for s in shapes { - if let egui::Shape::Text(t) = s { - for row in &t.galley.rows { - let visuals = &row.row.visuals; - colours.extend(visuals.mesh.vertices[visuals.glyph_vertex_range.clone()].iter().map(|v| v.color)); - } - } - } - colours - } - - // Regression test for #1465: the selected number in a value field was hard to read in every - // brightness: egui draws selected text in the selection stroke's colour, the accent, on a - // selection tinted with the same accent. It is in the strong text colour. - #[test] - fn selected_text_in_a_value_field_is_in_the_strong_text_colour() { - for brightness in theme::Brightness::ALL { - let ctx = themed(brightness); - let t = Tokens::get(&ctx); - let id = std::cell::Cell::new(egui::Id::NULL); - let mut add = |ui: &mut Ui| { - id.set(ui.scope_id().with("w")); - num_field(ui, "w", Some(1234.0), Unit::Points, 80.0); - }; - frame(&ctx, 0.0, vec![], &mut add); - // The field takes focus, and all of its text is selected. - ctx.memory_mut(|m| m.request_focus(id.get())); - frame(&ctx, 0.1, vec![], &mut add); - assert!(ctx.memory(|m| m.has_focus(id.get())), "{brightness:?}: the field has focus"); - let len = Unit::Points.format(1234.0).chars().count(); - let mut state = egui::TextEdit::load_state(&ctx, id.get()).unwrap_or_default(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); - state.store(&ctx, id.get()); - let shapes = frame(&ctx, 0.2, vec![], &mut add); - let colours = glyph_colours(&shapes); - assert!(!colours.is_empty(), "{brightness:?}: the number is drawn"); - let mut used = colours.clone(); - used.dedup(); - assert_eq!(used, [t.text_strong], "{brightness:?}: selected text in {used:?}"); - assert_ne!(t.text_strong, t.accent); - } - } - - // Regression test for #1483: the theme's widget fill is the panel's colour, so a slider in a - // dialog showed only its handle. `slider` draws the rail in the buttons' border colour and - // fills it up to the handle in the accent. - #[test] - fn a_slider_shows_its_rail_filled_up_to_the_handle() { - for brightness in theme::Brightness::ALL { - let ctx = themed(brightness); - let t = Tokens::get(&ctx); - let mut value = 30.0_f64; - let mut add = |ui: &mut Ui| { - slider(ui, egui::Slider::new(&mut value, 0.0..=100.0).show_value(false)); - }; - frame(&ctx, 0.0, vec![], &mut add); - let shapes = frame(&ctx, 0.1, vec![], &mut add); - let rects: Vec<(Rect, Color32)> = - shapes.iter().filter_map(|s| if let egui::Shape::Rect(r) = s { Some((r.rect, r.fill)) } else { None }).collect(); - let rail = rects.iter().find(|(_, c)| *c == t.button_border).map(|(r, _)| *r); - let filled = rects.iter().find(|(_, c)| *c == t.accent).map(|(r, _)| *r); - let (Some(rail), Some(filled)) = (rail, filled) else { - panic!("{brightness:?}: the rail ({:?}) and its fill ({:?}) are drawn: {rects:?}", t.button_border, t.accent); - }; - assert!(filled.width() > 1.0 && filled.width() < rail.width(), "{brightness:?}: filled {filled:?} of the rail {rail:?}"); - assert!((filled.min.x - rail.min.x).abs() < 1.0, "{brightness:?}: the fill starts at the rail's start"); - } - } - - /// A dialog slider that a click focused shows no focus outline; once an arrow key steps it, the - /// outline shows, so a keyboard user sees where the focus is. - #[test] - fn a_slider_focused_by_a_click_shows_its_outline_only_after_a_key() { - let ctx = themed(Default::default()); - let t = Tokens::get(&ctx); - let rail = std::cell::Cell::new(Rect::NOTHING); - let mut add = |ui: &mut Ui| { - let shown = SliderValue::number(5.0, 0.0..=10.0); - let (_, _, _) = color_slider_stepped(ui, "outline-test", "Test", Some(shown), 0.5, 180.0, &|_| Color32::GRAY); - rail.set(ui.min_rect()); - }; - // An outline: a stroked rect in the accent around the rail. - let outlined = |shapes: &[egui::Shape]| { - shapes - .iter() - .any(|s| matches!(s, egui::Shape::Rect(r) if r.stroke.color == t.accent && r.stroke.width > 0.0 && r.fill == Color32::TRANSPARENT)) - }; - frame(&ctx, 0.0, vec![], &mut add); - let at = rail.get().center(); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; - frame(&ctx, 0.1, vec![egui::Event::PointerMoved(at), button(true)], &mut add); - frame(&ctx, 0.2, vec![button(false)], &mut add); - let shapes = frame(&ctx, 0.3, vec![], &mut add); - assert!(ctx.memory(|m| m.focused().is_some()), "the click focused the rail"); - assert!(!outlined(&shapes), "no outline after a click"); - let arrow = - |pressed| egui::Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; - frame(&ctx, 0.4, vec![arrow(true), arrow(false)], &mut add); - let shapes = frame(&ctx, 0.5, vec![], &mut add); - assert!(outlined(&shapes), "an arrow key shows the outline"); - } - - // Regression test for #2083: the rows of the panels' menus didn't light up under the pointer, as - // the menu bar's items do. - #[test] - fn a_panel_menu_row_lights_up_under_the_pointer() { - let ctx = themed(Default::default()); - let row_at = std::cell::Cell::new(Rect::NOTHING); - let mut add = |ui: &mut Ui| { - row_at.set( - ui.scope(|ui| { - menu_item_name(ui, "Rename", true, false); - }) - .response - .rect, - ); - }; - // A filled rectangle behind the row's text. - let lit = |shapes: &[egui::Shape], row: Rect| { - shapes.iter().any(|s| matches!(s, egui::Shape::Rect(r) if r.fill != Color32::TRANSPARENT && r.rect.contains(row.center()))) - }; - frame(&ctx, 0.0, vec![], &mut add); - let row = row_at.get(); - let at_rest = frame(&ctx, 0.1, vec![], &mut add); - assert!(!lit(&at_rest, row), "no frame at rest"); - frame(&ctx, 0.2, vec![egui::Event::PointerMoved(row.center())], &mut add); - let hovered = frame(&ctx, 0.3, vec![], &mut add); - assert!(lit(&hovered, row), "the row under the pointer is lit: {row:?}"); - } - - /// Each painted row label names its own numeric field rather than the field's internal salt. - #[test] - fn a_label_rows_input_is_named_by_the_row_label() { - use egui_kittest::kittest::Queryable; - crate::i18n::set_current(crate::i18n::Lang::EN); - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { - label_row(ui, "Opacity", 70.0, |ui| { - let _ = spin_field(ui, "opacity", Some(50.0), Unit::Points, 80.0, 1.0, 0.0, &[]); - }); - label_row(ui, "Angle", 70.0, |ui| { - let _ = range_field(ui, "plugin-angle", 30.0, 0.0..=360.0, "°", 1, 80.0); - }); - }); - h.run_steps(2); - // The painted row label is the value field's exact accessible name: not its internal id. - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Opacity"); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Angle"); - assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "opacity").is_none(), "no internal widget id"); - assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "plugin-angle").is_none(), "no internal widget id"); - } - - /// A label followed by a checkbox or other widget does not name a later field. - #[test] - fn a_label_does_not_name_a_field_after_another_widget() { - use egui_kittest::kittest::Queryable; - crate::i18n::set_current(crate::i18n::Lang::EN); - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).with_accessibility_check(false).build_ui(|ui| { - field_label(ui, "Opacity"); - let _ = check(ui, "Preview", true, true); - let _ = spin_field(ui, "unrelated", Some(50.0), Unit::Points, 80.0, 1.0, 0.0, &[]); - }); - h.run_steps(2); - assert!( - h.query_by_role_and_label(egui::accesskit::Role::TextInput, "Opacity").is_none(), - "the label belongs to the next widget, not a later field" - ); - assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "unrelated").is_none(), "the field is not named by its id"); - } - - /// A grid's label is in the cell before the field: egui moves the cursor to the next cell - /// without allocating a widget, so the label still names that field exactly. - #[test] - fn a_grid_fields_adjacent_label_names_it_exactly() { - use egui_kittest::kittest::Queryable; - crate::i18n::set_current(crate::i18n::Lang::EN); - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { - egui::Grid::new("field-name-grid").num_columns(2).show(ui, |ui| { - dim_label(ui, "X:"); - let _ = num_field(ui, "xfp-x", Some(12.0), Unit::Points, 80.0); - ui.end_row(); - }); - }); - h.run_steps(2); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "X:"); - assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "xfp-x").is_none(), "no internal widget id"); - } - - /// A row whose visible names are painted below its fields (Stroke's dash/gap cells) passes - /// the exact translated name, never a Debug-formatted widget id. - #[test] - fn an_explicit_field_name_is_exact_and_has_no_widget_id() { - use egui_kittest::kittest::Queryable; - crate::i18n::set_current(crate::i18n::Lang::EN); - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { - let _ = opt_field_named(ui, ("dash", 0), Some("dash 1"), Some(4.0), Unit::Points, 60.0); - let _ = num_field_named(ui, "xfp-x", Some("X:"), Some(12.0), Unit::Points, 80.0); - }); - h.run_steps(2); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "dash 1"); - let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "X:"); - for leaked in ["dash 0", "xfp-x", "(\"dash\", 0)"] { - assert!( - h.query_by_role_and_label(egui::accesskit::Role::TextInput, leaked).is_none(), - "the internal widget id {leaked:?} is not an accessible name" - ); - } - } - - /// The Enter and focus guard's reconstructed id equals the id egui gives the ComboBox button. - #[test] - fn a_combos_keyboard_guard_reads_the_combos_own_button_id() { - crate::i18n::set_current(crate::i18n::Lang::EN); - let same = std::rc::Rc::new(std::cell::Cell::new(false)); - let seen = same.clone(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).with_accessibility_check(false).build_ui(move |ui| { - // The id the Enter/focus guard reconstructs before drawing, and the id egui's - // `ComboBox::from_id_salt` gives the button: they must be equal, or the guard - // reads and gives back the keyboard of nothing. - let expected = combo_button_id(ui, &"combo-guard-test"); - let response = egui::ComboBox::from_id_salt("combo-guard-test").selected_text("One").show_ui(ui, |_| ()); - seen.set(expected == response.response.id); - }); - h.run_steps(2); - assert!(same.get(), "the guard's id is the ComboBox button's id"); - } - - // Regression test for #3440: the Fill/Stroke chip's popover closed on the click that ended a - // drag between its proxies. It stays open for a click on its own anchor and while a panel - // drag is on; a click elsewhere still closes it. - #[test] - fn a_popover_stays_open_for_a_click_on_its_anchor_or_during_a_panel_drag() { - let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - // Opens the popover, then clicks at `click` (with `drag` as the panel drag under way). - let open_after_click = |click: Option, drag: Option| -> bool { - let ctx = themed(Default::default()); - let toggle = std::cell::Cell::new(true); - let shown = std::cell::Cell::new(false); - let anchor = std::cell::Cell::new(Rect::NOTHING); - let mut add = |ui: &mut Ui| { - let resp = ui.allocate_response(vec2(40.0, 24.0), Sense::hover()); - anchor.set(resp.rect); - shown.set(popover(&resp, toggle.replace(false), |ui| ui.label("Colour")).is_some()); - }; - frame(&ctx, 0.0, vec![], &mut add); - assert!(shown.get(), "the toggle opens it"); - frame(&ctx, 0.2, vec![], &mut add); - assert!(shown.get(), "it stays open"); - if let Some(d) = drag { - egui::DragAndDrop::set_payload(&ctx, d); - } - let pos = click.unwrap_or_else(|| anchor.get().center()); - frame(&ctx, 0.3, vec![egui::Event::PointerMoved(pos), press(pos, true)], &mut add); - frame(&ctx, 0.4, vec![press(pos, false)], &mut add); - frame(&ctx, 0.5, vec![], &mut add); - shown.get() - }; - let far = Some(pos2(500.0, 350.0)); - let panel_drag = || Some(PanelDrag::Symbol("s".into())); - // Whether it is open after: a click elsewhere; a click on its anchor that doesn't toggle it; - // a panel drag released elsewhere. - let open = [open_after_click(far, None), open_after_click(None, None), open_after_click(far, panel_drag())]; - assert_eq!(open, [false, true, true]); - } -} +pub(crate) mod look_tests; diff --git a/crates/ui-egui/src/widgets/look_tests.rs b/crates/ui-egui/src/widgets/look_tests.rs new file mode 100644 index 000000000..fd2ec6a81 --- /dev/null +++ b/crates/ui-egui/src/widgets/look_tests.rs @@ -0,0 +1,371 @@ +use super::*; + +/// A context with the fonts and the theme in `brightness`, in English. +pub(crate) fn themed(brightness: theme::Brightness) -> egui::Context { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, brightness); + ctx +} + +/// One frame at `time` seconds of `add` in a 600 × 400 window with `events` → the shapes it +/// drew, `Shape::Vec`s flattened. +pub(crate) fn frame(ctx: &egui::Context, time: f64, events: Vec, add: impl FnMut(&mut Ui)) -> Vec { + fn flat(s: egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), + s => out.push(s), + } + } + let raw = + egui::RawInput { time: Some(time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, add); + out.textures_delta.clear(); + let mut shapes = vec![]; + out.shapes.into_iter().for_each(|c| flat(c.shape, &mut shapes)); + shapes +} + +// Regression test for #808: the scroll bar of the toolbar and the dock's icon column widened to +// 10 points while it was dragged or the pointer was on it, covering the group labels and the +// right edge of the tool icons. It stays a slim line. +#[test] +fn the_strip_scroll_bar_stays_a_slim_line_while_the_pointer_is_on_it() { + let ctx = themed(Default::default()); + let area = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + area.set( + ui.allocate_ui(vec2(80.0, 200.0), |ui| { + strip_scroll(ui, "strip", |ui| { + for i in 0..40 { + ui.label(format!("Tool {i}")); + } + }); + }) + .response + .rect, + ); + }; + frame(&ctx, 0.0, vec![], &mut add); + let area = area.get(); + assert!(area.height() >= 199.0, "the strip fills the height it was given: {area:?}"); + // The pointer comes onto the bar's edge and stays there while the bar settles. + let on_the_bar = egui::Event::PointerMoved(pos2(area.right() - 1.0, area.center().y)); + let mut widest = 0.0_f32; + for i in 1..=15 { + let shapes = frame(&ctx, f64::from(i) * 0.1, if i == 1 { vec![on_the_bar.clone()] } else { vec![] }, &mut add); + for s in shapes { + // The bar's track and handle are the filled rectangles. + if let egui::Shape::Rect(r) = s + && r.fill != Color32::TRANSPARENT + { + widest = widest.max(r.rect.width()); + } + } + } + assert!(widest > 0.0, "the scroll bar is drawn"); + assert!(widest <= 4.5, "the scroll bar is {widest} points wide"); +} +// Regression test for #900: a number field with a spinner and a preset list drew as three +// boxes side by side: its text box was a point taller than the 26-point row, and each joint had +// two borders. +#[test] +fn a_spinner_field_reads_as_one_box_in_three_parts() { + let ctx = themed(Default::default()); + let mut add = |ui: &mut Ui| { + ui.horizontal(|ui| spin_field(ui, "weight", Some(4.0), Unit::Points, 100.0, 1.0, 0.0, &[1.0, 2.0])); + }; + frame(&ctx, 0.0, vec![], &mut add); + let shapes = frame(&ctx, 0.1, vec![], &mut add); + // The boxes of the row: the spinner, the field and the preset chevron, left to right. + let mut boxes: Vec = vec![]; + for s in &shapes { + if let egui::Shape::Rect(r) = s + && r.rect.height() > 20.0 + // egui 0.37's TextEdit paints its `Frame::NONE` as a transparent rect too: that is + // not a box of the row. + && r.fill != Color32::TRANSPARENT + && !boxes.contains(&r.rect) + { + boxes.push(r.rect); + } + } + boxes.sort_by(|a, b| a.min.x.total_cmp(&b.min.x)); + assert_eq!(boxes.len(), 3, "{boxes:?}"); + for b in &boxes { + assert_eq!((b.min.y, b.height()), (boxes[0].min.y, 26.0), "all as tall as the row: {boxes:?}"); + } + for pair in boxes.windows(2) { + assert!((pair[1].min.x - pair[0].max.x + 1.0).abs() < 0.01, "neighbours share one border: {boxes:?}"); + } +} + +/// The colour of every glyph of the text in `shapes`: selected glyphs are drawn in the +/// selection stroke's colour. +pub(crate) fn glyph_colours(shapes: &[egui::Shape]) -> Vec { + let mut colours = vec![]; + for s in shapes { + if let egui::Shape::Text(t) = s { + for row in &t.galley.rows { + let visuals = &row.row.visuals; + colours.extend(visuals.mesh.vertices[visuals.glyph_vertex_range.clone()].iter().map(|v| v.color)); + } + } + } + colours +} + +// Regression test for #1465: the selected number in a value field was hard to read in every +// brightness: egui draws selected text in the selection stroke's colour, the accent, on a +// selection tinted with the same accent. It is in the strong text colour. +#[test] +fn selected_text_in_a_value_field_is_in_the_strong_text_colour() { + for brightness in theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let id = std::cell::Cell::new(egui::Id::NULL); + let mut add = |ui: &mut Ui| { + id.set(ui.scope_id().with("w")); + num_field(ui, "w", Some(1234.0), Unit::Points, 80.0); + }; + frame(&ctx, 0.0, vec![], &mut add); + // The field takes focus, and all of its text is selected. + ctx.memory_mut(|m| m.request_focus(id.get())); + frame(&ctx, 0.1, vec![], &mut add); + assert!(ctx.memory(|m| m.has_focus(id.get())), "{brightness:?}: the field has focus"); + let len = Unit::Points.format(1234.0).chars().count(); + let mut state = egui::TextEdit::load_state(&ctx, id.get()).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); + state.store(&ctx, id.get()); + let shapes = frame(&ctx, 0.2, vec![], &mut add); + let colours = glyph_colours(&shapes); + assert!(!colours.is_empty(), "{brightness:?}: the number is drawn"); + let mut used = colours.clone(); + used.dedup(); + assert_eq!(used, [t.text_strong], "{brightness:?}: selected text in {used:?}"); + assert_ne!(t.text_strong, t.accent); + } +} + +// Regression test for #1483: the theme's widget fill is the panel's colour, so a slider in a +// dialog showed only its handle. `slider` draws the rail in the buttons' border colour and +// fills it up to the handle in the accent. +#[test] +fn a_slider_shows_its_rail_filled_up_to_the_handle() { + for brightness in theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let mut value = 30.0_f64; + let mut add = |ui: &mut Ui| { + slider(ui, egui::Slider::new(&mut value, 0.0..=100.0).show_value(false)); + }; + frame(&ctx, 0.0, vec![], &mut add); + let shapes = frame(&ctx, 0.1, vec![], &mut add); + let rects: Vec<(Rect, Color32)> = + shapes.iter().filter_map(|s| if let egui::Shape::Rect(r) = s { Some((r.rect, r.fill)) } else { None }).collect(); + let rail = rects.iter().find(|(_, c)| *c == t.button_border).map(|(r, _)| *r); + let filled = rects.iter().find(|(_, c)| *c == t.accent).map(|(r, _)| *r); + let (Some(rail), Some(filled)) = (rail, filled) else { + panic!("{brightness:?}: the rail ({:?}) and its fill ({:?}) are drawn: {rects:?}", t.button_border, t.accent); + }; + assert!(filled.width() > 1.0 && filled.width() < rail.width(), "{brightness:?}: filled {filled:?} of the rail {rail:?}"); + assert!((filled.min.x - rail.min.x).abs() < 1.0, "{brightness:?}: the fill starts at the rail's start"); + } +} + +/// A dialog slider that a click focused shows no focus outline; once an arrow key steps it, the +/// outline shows, so a keyboard user sees where the focus is. +#[test] +fn a_slider_focused_by_a_click_shows_its_outline_only_after_a_key() { + let ctx = themed(Default::default()); + let t = Tokens::get(&ctx); + let rail = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + let shown = SliderValue::number(5.0, 0.0..=10.0); + let (_, _, _) = color_slider_stepped(ui, "outline-test", "Test", Some(shown), 0.5, 180.0, &|_| Color32::GRAY); + rail.set(ui.min_rect()); + }; + // An outline: a stroked rect in the accent around the rail. + let outlined = |shapes: &[egui::Shape]| { + shapes + .iter() + .any(|s| matches!(s, egui::Shape::Rect(r) if r.stroke.color == t.accent && r.stroke.width > 0.0 && r.fill == Color32::TRANSPARENT)) + }; + frame(&ctx, 0.0, vec![], &mut add); + let at = rail.get().center(); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&ctx, 0.1, vec![egui::Event::PointerMoved(at), button(true)], &mut add); + frame(&ctx, 0.2, vec![button(false)], &mut add); + let shapes = frame(&ctx, 0.3, vec![], &mut add); + assert!(ctx.memory(|m| m.focused().is_some()), "the click focused the rail"); + assert!(!outlined(&shapes), "no outline after a click"); + let arrow = + |pressed| egui::Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&ctx, 0.4, vec![arrow(true), arrow(false)], &mut add); + let shapes = frame(&ctx, 0.5, vec![], &mut add); + assert!(outlined(&shapes), "an arrow key shows the outline"); +} + +// Regression test for #2083: the rows of the panels' menus didn't light up under the pointer, as +// the menu bar's items do. +#[test] +fn a_panel_menu_row_lights_up_under_the_pointer() { + let ctx = themed(Default::default()); + let row_at = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + row_at.set( + ui.scope(|ui| { + menu_item_name(ui, "Rename", true, false); + }) + .response + .rect, + ); + }; + // A filled rectangle behind the row's text. + let lit = |shapes: &[egui::Shape], row: Rect| { + shapes.iter().any(|s| matches!(s, egui::Shape::Rect(r) if r.fill != Color32::TRANSPARENT && r.rect.contains(row.center()))) + }; + frame(&ctx, 0.0, vec![], &mut add); + let row = row_at.get(); + let at_rest = frame(&ctx, 0.1, vec![], &mut add); + assert!(!lit(&at_rest, row), "no frame at rest"); + frame(&ctx, 0.2, vec![egui::Event::PointerMoved(row.center())], &mut add); + let hovered = frame(&ctx, 0.3, vec![], &mut add); + assert!(lit(&hovered, row), "the row under the pointer is lit: {row:?}"); +} + +/// Each painted row label names its own numeric field rather than the field's internal salt. +#[test] +fn a_label_rows_input_is_named_by_the_row_label() { + use egui_kittest::kittest::Queryable; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { + label_row(ui, "Opacity", 70.0, |ui| { + let _ = spin_field(ui, "opacity", Some(50.0), Unit::Points, 80.0, 1.0, 0.0, &[]); + }); + label_row(ui, "Angle", 70.0, |ui| { + let _ = range_field(ui, "plugin-angle", 30.0, 0.0..=360.0, "°", 1, 80.0); + }); + }); + h.run_steps(2); + // The painted row label is the value field's exact accessible name: not its internal id. + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Opacity"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Angle"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "opacity").is_none(), "no internal widget id"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "plugin-angle").is_none(), "no internal widget id"); +} + +/// A label followed by a checkbox or other widget does not name a later field. +#[test] +fn a_label_does_not_name_a_field_after_another_widget() { + use egui_kittest::kittest::Queryable; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).with_accessibility_check(false).build_ui(|ui| { + field_label(ui, "Opacity"); + let _ = check(ui, "Preview", true, true); + let _ = spin_field(ui, "unrelated", Some(50.0), Unit::Points, 80.0, 1.0, 0.0, &[]); + }); + h.run_steps(2); + assert!( + h.query_by_role_and_label(egui::accesskit::Role::TextInput, "Opacity").is_none(), + "the label belongs to the next widget, not a later field" + ); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "unrelated").is_none(), "the field is not named by its id"); +} + +/// A grid's label is in the cell before the field: egui moves the cursor to the next cell +/// without allocating a widget, so the label still names that field exactly. +#[test] +fn a_grid_fields_adjacent_label_names_it_exactly() { + use egui_kittest::kittest::Queryable; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { + egui::Grid::new("field-name-grid").num_columns(2).show(ui, |ui| { + dim_label(ui, "X:"); + let _ = num_field(ui, "xfp-x", Some(12.0), Unit::Points, 80.0); + ui.end_row(); + }); + }); + h.run_steps(2); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "X:"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "xfp-x").is_none(), "no internal widget id"); +} + +/// A row whose visible names are painted below its fields (Stroke's dash/gap cells) passes +/// the exact translated name, never a Debug-formatted widget id. +#[test] +fn an_explicit_field_name_is_exact_and_has_no_widget_id() { + use egui_kittest::kittest::Queryable; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { + let _ = opt_field_named(ui, ("dash", 0), Some("dash 1"), Some(4.0), Unit::Points, 60.0); + let _ = num_field_named(ui, "xfp-x", Some("X:"), Some(12.0), Unit::Points, 80.0); + }); + h.run_steps(2); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "dash 1"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "X:"); + for leaked in ["dash 0", "xfp-x", "(\"dash\", 0)"] { + assert!( + h.query_by_role_and_label(egui::accesskit::Role::TextInput, leaked).is_none(), + "the internal widget id {leaked:?} is not an accessible name" + ); + } +} + +/// The Enter and focus guard's reconstructed id equals the id egui gives the ComboBox button. +#[test] +fn a_combos_keyboard_guard_reads_the_combos_own_button_id() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let same = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = same.clone(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).with_accessibility_check(false).build_ui(move |ui| { + // The id the Enter/focus guard reconstructs before drawing, and the id egui's + // `ComboBox::from_id_salt` gives the button: they must be equal, or the guard + // reads and gives back the keyboard of nothing. + let expected = combo_button_id(ui, &"combo-guard-test"); + let response = egui::ComboBox::from_id_salt("combo-guard-test").selected_text("One").show_ui(ui, |_| ()); + seen.set(expected == response.response.id); + }); + h.run_steps(2); + assert!(same.get(), "the guard's id is the ComboBox button's id"); +} + +// Regression test for #3440: the Fill/Stroke chip's popover closed on the click that ended a +// drag between its proxies. It stays open for a click on its own anchor and while a panel +// drag is on; a click elsewhere still closes it. +#[test] +fn a_popover_stays_open_for_a_click_on_its_anchor_or_during_a_panel_drag() { + let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Opens the popover, then clicks at `click` (with `drag` as the panel drag under way). + let open_after_click = |click: Option, drag: Option| -> bool { + let ctx = themed(Default::default()); + let toggle = std::cell::Cell::new(true); + let shown = std::cell::Cell::new(false); + let anchor = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + let resp = ui.allocate_response(vec2(40.0, 24.0), Sense::hover()); + anchor.set(resp.rect); + shown.set(popover(&resp, toggle.replace(false), |ui| ui.label("Colour")).is_some()); + }; + frame(&ctx, 0.0, vec![], &mut add); + assert!(shown.get(), "the toggle opens it"); + frame(&ctx, 0.2, vec![], &mut add); + assert!(shown.get(), "it stays open"); + if let Some(d) = drag { + egui::DragAndDrop::set_payload(&ctx, d); + } + let pos = click.unwrap_or_else(|| anchor.get().center()); + frame(&ctx, 0.3, vec![egui::Event::PointerMoved(pos), press(pos, true)], &mut add); + frame(&ctx, 0.4, vec![press(pos, false)], &mut add); + frame(&ctx, 0.5, vec![], &mut add); + shown.get() + }; + let far = Some(pos2(500.0, 350.0)); + let panel_drag = || Some(PanelDrag::Symbol("s".into())); + // Whether it is open after: a click elsewhere; a click on its anchor that doesn't toggle it; + // a panel drag released elsewhere. + let open = [open_after_click(far, None), open_after_click(None, None), open_after_click(far, panel_drag())]; + assert_eq!(open, [false, true, true]); +} From e57586fdc2dd4beb0bb4a7b3b03bea7563dd4bb4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:16 +0000 Subject: [PATCH 068/199] refactor(effects): move group.rs tests to group/tests.rs --- crates/effects/src/group.rs | 357 +----------------------------- crates/effects/src/group/tests.rs | 354 +++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+), 356 deletions(-) create mode 100644 crates/effects/src/group/tests.rs diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 9f0eb549d..ad5927850 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -480,359 +480,4 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_color::{BlendMode, Color, Paint}; - use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; - use vectorcraft_geom::{Point, Rect, shapes}; - - /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. - fn skip_without_craft_fonts() -> bool { - let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); - if skip { - eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); - } - skip - } - - fn sq(id: u64, x: f64, color: Color) -> Arc { - Arc::new(Node::path( - NodeId(id), - shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), - Appearance::basic(Paint::solid(color), Paint::None, 0.0), - )) - } - - fn group_with(effect: &str) -> Node { - let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))]); - g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - g - } - - fn area(n: &Node) -> f64 { - po::area(n.path_data().unwrap(), FillRule::NonZero).abs() - } - - fn overlap_cmyk(g: &Node) -> [f32; 4] { - let faces = pathfinder_children(g, None).unwrap(); - assert_eq!(faces.len(), 3); - let overlap = faces - .iter() - .find(|n| { - let b = n.geometric_bounds().unwrap(); - b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9 - }) - .expect("overlap face"); - assert!(overlap.appearance.stroke_paint().is_none()); - overlap.appearance.fill_paint().color().unwrap().to_cmyk() - } - - fn two_overlapping_cmyk_squares() -> Node { - let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0); - let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0); - Node::group( - NodeId(10), - vec![ - Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)), - Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)), - ], - ) - } - - /// Effect › Pathfinder › Trap keeps the members and adds an overprinting strip of the lighter - /// colour inside the darker one along the edge where they meet. - #[test] - fn trap_effect_adds_an_overprinting_strip_above_the_members() { - let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0); - let blue = Color::cmyk(1.0, 0.6, 0.0, 0.0); - let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); - let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); - g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); - let out = pathfinder_children(&g, None).unwrap(); - assert_eq!(out.len(), 3, "both squares and one trap"); - assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); - let b = out[2].geometric_bounds().unwrap(); - assert!((b.x0 - 10.0).abs() < 0.02 && (b.x1 - 11.0).abs() < 0.02 && b.y0.abs() < 0.02 && (b.y1 - 10.0).abs() < 0.02, "{b:?}"); - let fill = out[2].appearance.fill().unwrap(); - assert!(fill.overprint); - assert_eq!(fill.paint.color(), Some(yellow)); - } - - #[test] - fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() { - let mut g = two_overlapping_cmyk_squares(); - g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true }); - let [c, m, y, k] = overlap_cmyk(&g); - assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}"); - } - - #[test] - fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() { - let mut g = two_overlapping_cmyk_squares(); - g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true }); - let [c, m, y, k] = overlap_cmyk(&g); - assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).abs() < 0.02, "{c} {m} {y} {k}"); - } - - #[test] - fn shape_modes_follow_the_pathfinder_panel() { - let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); - assert_eq!(add.len(), 1); - assert!((area(&add[0]) - 150.0).abs() < 1e-3); - // Add keeps the front object's paint, Subtract the back one's. - assert_eq!(add[0].id, NodeId(2)); - let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap(); - assert_eq!(sub[0].id, NodeId(1)); - assert!((area(&sub[0]) - 50.0).abs() < 1e-3); - let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap(); - assert!((area(&int[0]) - 50.0).abs() < 1e-3); - let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap(); - assert_eq!(div.len(), 3); - } - - /// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either - /// side of x = 100. - fn type_h() -> Node { - let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() }; - Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style)))) - } - - /// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it. - fn square_and(front: Option, effect: &str) -> Node { - let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red); - let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect()); - g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - g - } - - /// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it. - fn results(g: &Node) -> Vec> { - pathfinder_children(g, crate::text_outliner()).unwrap() - } - - #[test] - fn type_changes_the_result_of_every_pathfinder_effect() { - if skip_without_craft_fonts() { - return; - } - let glyphs = crate::outline_text(&type_h()).unwrap(); - let b = glyphs.geometric_bounds().unwrap(); - assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}"); - // `measure` gives the number of results and their total area. - let measure = |front: Option, effect: &str| { - let r = results(&square_and(front, effect)); - (r.len(), r.iter().map(|n| area(n)).sum::()) - }; - let unchanged: Vec<_> = PATHFINDER_EFFECTS - .iter() - .map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id))) - .filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0) - .collect(); - assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}"); - // The shape modes take the glyph's whole area: the part inside the square and the part outside. - let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum(); - let with_type = |id: &str| measure(Some(type_h()), id).1; - assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}"); - assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}"); - // Outline art nested one group deeper takes part as well. - let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)])); - let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); - assert!((sub.iter().map(|n| area(n)).sum::() - with_type("pathfinder.subtract")).abs() < 1e-6); - } - - #[test] - fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() { - if skip_without_craft_fonts() { - return; - } - let mut t = type_h(); - (t.opacity, t.blend) = (0.5, BlendMode::Multiply); - let look = |p: &Arc| (p.appearance.fill_paint(), p.opacity, p.blend); - let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal); - let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply); - let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect(); - assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}"); - // In the shape modes a group with the type in front is one shape, styled like the type. - let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0); - let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green); - let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]); - let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect(); - assert_eq!(added, [glyph]); - } - - #[test] - fn a_random_transform_on_a_group_moves_its_members_together() { - let mut g = group_with("distort.transform"); - g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true}); - let art = evaluate_container(None, &g).unwrap(); - let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap(); - let (a, b) = (corner(0), corner(1)); - assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}"); - assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}"); - } - - /// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`, - /// in a group with a square and Offset Path 0, which leaves every path as it is. - fn type_in_group(style: impl FnOnce(&mut Node)) -> Node { - let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; - let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts); - let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text))); - style(&mut t); - let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]); - g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true }); - let art = evaluate_container(None, &g).unwrap(); - (*art.children().unwrap()[1]).clone() - } - - #[test] - fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() { - if skip_without_craft_fonts() { - return; - } - use vectorcraft_color::BlendMode; - let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply)); - assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}"); - assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply)); - // Its opacity mask, its own shadow, its knockout shape option and its visibility stay. - let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); - let mask = vectorcraft_doc::OpacityMask::new(mask_art, true); - assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask)); - let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true }; - assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]); - assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape); - assert!(!type_in_group(|t| t.visible = false).visible); - // Its own Transform moves its outlines 30 pt before the group's effect reshapes them. - let x0 = |n: Node| n.geometric_bounds().unwrap().x0; - let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }; - let moved = x0(type_in_group(|t| t.appearance.effects.push(transform))); - assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}"); - } - - #[test] - fn hidden_effect_or_plain_group_is_untouched() { - let mut g = group_with("pathfinder.add"); - g.appearance.effects[0].visible = false; - assert!(pathfinder_children(&g, None).is_none()); - assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK))); - } - - #[test] - fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() { - if skip_without_craft_fonts() { - return; - } - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; - let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); - // The type's characters fit the gradient to its layout; the square has it placed across itself. - let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() }; - let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style); - let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text)))); - let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; - let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); - let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0))); - let start = |n: &Node| match n.appearance.fill_paint() { - Paint::Gradient(g) => g.geom.unwrap().start.x, - p => panic!("a gradient: {p:?}"), - }; - let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]); - let mut g = Node::group(NodeId(10), vec![square, t]); - g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); - let art = evaluate_container(None, &g).unwrap(); - let members = art.children().unwrap(); - let after = start(&members[1].children().unwrap()[0]); - assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); - assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed"); - } - - #[test] - fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; - let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); - let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; - let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; - for (k, paint) in paints.into_iter().enumerate() { - let square = - Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); - let mut g = Node::group(NodeId(10), vec![square]); - let params = serde_json::json!({"moveH": 30, "copies": 2}); - g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); - let art = evaluate_container(None, &g).unwrap(); - let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); - assert_eq!(copies.len(), 3); - for (i, c) in copies.iter().enumerate() { - // Each copy's own box: an unplaced gradient fits it, a placed one moved with it. - assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6); - match c.appearance.fill_paint() { - Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"), - Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"), - p => panic!("a gradient: {p:?}"), - } - } - } - } - - /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit - /// pitch, each crossing every bar of the other direction: n² crossings. - fn crossing_bars(n: usize, effect: &str) -> Node { - let len = 4.0 * n as f64; - let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - let mut children = vec![]; - for i in 0..n { - let at = 4.0 * i as f64; - children.push(Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(at, 0.0, at + 2.0, len)), paint.clone()))); - } - for i in 0..n { - let at = 4.0 * i as f64; - let id = NodeId(100 + (n + i) as u64); - children.push(Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, at, len, at + 2.0)), paint.clone()))); - } - let mut g = Node::group(NodeId(10), children); - g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - g - } - - #[test] - fn pathfinder_past_its_budget_draws_the_source_art() { - // n² crossings past the cap: no evaluation (#3591), the source art as it is. - for n in [130, 800] { - let g = crossing_bars(n, "pathfinder.divide"); - let out = pathfinder_children(&g, None).unwrap(); - assert_eq!(out.len(), 2 * n, "{n} x {n} bars"); - let ids: Vec = out.iter().map(|c| c.id).collect(); - let src: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); - assert_eq!(ids, src, "{n} x {n} bars"); - assert!(out.iter().zip(g.children().unwrap()).all(|(a, b)| a.path_data() == b.path_data())); - } - } - - #[test] - fn pathfinder_within_its_budget_still_divides() { - // Each bar is cut into one piece per crossing plus the n pieces between and past them, so - // n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly - // `PATHFINDER_MAX_OVERLAPS` pairs, must still divide. - for n in [8, 128] { - let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap(); - assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars"); - } - } - - #[test] - fn pathfinder_on_many_stacked_bars_still_runs() { - // 3000 bars stacked in y all overlap in x, but each overlaps only the next one in y: 2999 - // overlapping pairs are within the budget, so Add still merges them into one path. - let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - let children = (0..3000) - .map(|i| { - let at = 1.5 * i as f64; - Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(0.0, at, 100.0, at + 2.0)), paint.clone())) - }) - .collect(); - let mut g = Node::group(NodeId(10), children); - g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&g, None).unwrap(); - assert_eq!(out.len(), 1); - } -} +mod tests; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs new file mode 100644 index 000000000..9f7199a12 --- /dev/null +++ b/crates/effects/src/group/tests.rs @@ -0,0 +1,354 @@ +use super::*; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; + +/// Type tests need glyph outlines, which only the optional craft-fonts build input provides. +fn skip_without_craft_fonts() -> bool { + let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); + if skip { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + skip +} + +fn sq(id: u64, x: f64, color: Color) -> Arc { + Arc::new(Node::path( + NodeId(id), + shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), + Appearance::basic(Paint::solid(color), Paint::None, 0.0), + )) +} + +fn group_with(effect: &str) -> Node { + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))]); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + g +} + +fn area(n: &Node) -> f64 { + po::area(n.path_data().unwrap(), FillRule::NonZero).abs() +} + +fn overlap_cmyk(g: &Node) -> [f32; 4] { + let faces = pathfinder_children(g, None).unwrap(); + assert_eq!(faces.len(), 3); + let overlap = faces + .iter() + .find(|n| { + let b = n.geometric_bounds().unwrap(); + b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9 + }) + .expect("overlap face"); + assert!(overlap.appearance.stroke_paint().is_none()); + overlap.appearance.fill_paint().color().unwrap().to_cmyk() +} + +fn two_overlapping_cmyk_squares() -> Node { + let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0); + let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0); + Node::group( + NodeId(10), + vec![ + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)), + Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)), + ], + ) +} + +/// Effect › Pathfinder › Trap keeps the members and adds an overprinting strip of the lighter +/// colour inside the darker one along the edge where they meet. +#[test] +fn trap_effect_adds_an_overprinting_strip_above_the_members() { + let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0); + let blue = Color::cmyk(1.0, 0.6, 0.0, 0.0); + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); + let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); + g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 3, "both squares and one trap"); + assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); + let b = out[2].geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 0.02 && (b.x1 - 11.0).abs() < 0.02 && b.y0.abs() < 0.02 && (b.y1 - 10.0).abs() < 0.02, "{b:?}"); + let fill = out[2].appearance.fill().unwrap(); + assert!(fill.overprint); + assert_eq!(fill.paint.color(), Some(yellow)); +} + +#[test] +fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() { + let mut g = two_overlapping_cmyk_squares(); + g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true }); + let [c, m, y, k] = overlap_cmyk(&g); + assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}"); +} + +#[test] +fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() { + let mut g = two_overlapping_cmyk_squares(); + g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true }); + let [c, m, y, k] = overlap_cmyk(&g); + assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).abs() < 0.02, "{c} {m} {y} {k}"); +} + +#[test] +fn shape_modes_follow_the_pathfinder_panel() { + let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); + assert_eq!(add.len(), 1); + assert!((area(&add[0]) - 150.0).abs() < 1e-3); + // Add keeps the front object's paint, Subtract the back one's. + assert_eq!(add[0].id, NodeId(2)); + let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap(); + assert_eq!(sub[0].id, NodeId(1)); + assert!((area(&sub[0]) - 50.0).abs() < 1e-3); + let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap(); + assert!((area(&int[0]) - 50.0).abs() < 1e-3); + let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap(); + assert_eq!(div.len(), 3); +} + +/// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either +/// side of x = 100. +fn type_h() -> Node { + let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() }; + Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style)))) +} + +/// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it. +fn square_and(front: Option, effect: &str) -> Node { + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red); + let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect()); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + g +} + +/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it. +fn results(g: &Node) -> Vec> { + pathfinder_children(g, crate::text_outliner()).unwrap() +} + +#[test] +fn type_changes_the_result_of_every_pathfinder_effect() { + if skip_without_craft_fonts() { + return; + } + let glyphs = crate::outline_text(&type_h()).unwrap(); + let b = glyphs.geometric_bounds().unwrap(); + assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}"); + // `measure` gives the number of results and their total area. + let measure = |front: Option, effect: &str| { + let r = results(&square_and(front, effect)); + (r.len(), r.iter().map(|n| area(n)).sum::()) + }; + let unchanged: Vec<_> = PATHFINDER_EFFECTS + .iter() + .map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id))) + .filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0) + .collect(); + assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}"); + // The shape modes take the glyph's whole area: the part inside the square and the part outside. + let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum(); + let with_type = |id: &str| measure(Some(type_h()), id).1; + assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}"); + assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}"); + // Outline art nested one group deeper takes part as well. + let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)])); + let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); + assert!((sub.iter().map(|n| area(n)).sum::() - with_type("pathfinder.subtract")).abs() < 1e-6); +} + +#[test] +fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() { + if skip_without_craft_fonts() { + return; + } + let mut t = type_h(); + (t.opacity, t.blend) = (0.5, BlendMode::Multiply); + let look = |p: &Arc| (p.appearance.fill_paint(), p.opacity, p.blend); + let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal); + let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply); + let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect(); + assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}"); + // In the shape modes a group with the type in front is one shape, styled like the type. + let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0); + let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green); + let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]); + let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect(); + assert_eq!(added, [glyph]); +} + +#[test] +fn a_random_transform_on_a_group_moves_its_members_together() { + let mut g = group_with("distort.transform"); + g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true}); + let art = evaluate_container(None, &g).unwrap(); + let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap(); + let (a, b) = (corner(0), corner(1)); + assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}"); + assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}"); +} + +/// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`, +/// in a group with a square and Offset Path 0, which leaves every path as it is. +fn type_in_group(style: impl FnOnce(&mut Node)) -> Node { + let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts); + let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text))); + style(&mut t); + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]); + g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + (*art.children().unwrap()[1]).clone() +} + +#[test] +fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() { + if skip_without_craft_fonts() { + return; + } + use vectorcraft_color::BlendMode; + let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply)); + assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}"); + assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply)); + // Its opacity mask, its own shadow, its knockout shape option and its visibility stay. + let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); + let mask = vectorcraft_doc::OpacityMask::new(mask_art, true); + assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask)); + let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true }; + assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]); + assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape); + assert!(!type_in_group(|t| t.visible = false).visible); + // Its own Transform moves its outlines 30 pt before the group's effect reshapes them. + let x0 = |n: Node| n.geometric_bounds().unwrap().x0; + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }; + let moved = x0(type_in_group(|t| t.appearance.effects.push(transform))); + assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}"); +} + +#[test] +fn hidden_effect_or_plain_group_is_untouched() { + let mut g = group_with("pathfinder.add"); + g.appearance.effects[0].visible = false; + assert!(pathfinder_children(&g, None).is_none()); + assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK))); +} + +#[test] +fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() { + if skip_without_craft_fonts() { + return; + } + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + // The type's characters fit the gradient to its layout; the square has it placed across itself. + let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style); + let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text)))); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0))); + let start = |n: &Node| match n.appearance.fill_paint() { + Paint::Gradient(g) => g.geom.unwrap().start.x, + p => panic!("a gradient: {p:?}"), + }; + let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]); + let mut g = Node::group(NodeId(10), vec![square, t]); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let members = art.children().unwrap(); + let after = start(&members[1].children().unwrap()[0]); + assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); + assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed"); +} + +#[test] +fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; + for (k, paint) in paints.into_iter().enumerate() { + let square = + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); + let mut g = Node::group(NodeId(10), vec![square]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); + assert_eq!(copies.len(), 3); + for (i, c) in copies.iter().enumerate() { + // Each copy's own box: an unplaced gradient fits it, a placed one moved with it. + assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6); + match c.appearance.fill_paint() { + Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"), + Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"), + p => panic!("a gradient: {p:?}"), + } + } + } +} + +/// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit +/// pitch, each crossing every bar of the other direction: n² crossings. +fn crossing_bars(n: usize, effect: &str) -> Node { + let len = 4.0 * n as f64; + let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut children = vec![]; + for i in 0..n { + let at = 4.0 * i as f64; + children.push(Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(at, 0.0, at + 2.0, len)), paint.clone()))); + } + for i in 0..n { + let at = 4.0 * i as f64; + let id = NodeId(100 + (n + i) as u64); + children.push(Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, at, len, at + 2.0)), paint.clone()))); + } + let mut g = Node::group(NodeId(10), children); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + g +} + +#[test] +fn pathfinder_past_its_budget_draws_the_source_art() { + // n² crossings past the cap: no evaluation (#3591), the source art as it is. + for n in [130, 800] { + let g = crossing_bars(n, "pathfinder.divide"); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 2 * n, "{n} x {n} bars"); + let ids: Vec = out.iter().map(|c| c.id).collect(); + let src: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(ids, src, "{n} x {n} bars"); + assert!(out.iter().zip(g.children().unwrap()).all(|(a, b)| a.path_data() == b.path_data())); + } +} + +#[test] +fn pathfinder_within_its_budget_still_divides() { + // Each bar is cut into one piece per crossing plus the n pieces between and past them, so + // n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly + // `PATHFINDER_MAX_OVERLAPS` pairs, must still divide. + for n in [8, 128] { + let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap(); + assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars"); + } +} + +#[test] +fn pathfinder_on_many_stacked_bars_still_runs() { + // 3000 bars stacked in y all overlap in x, but each overlaps only the next one in y: 2999 + // overlapping pairs are within the budget, so Add still merges them into one path. + let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let children = (0..3000) + .map(|i| { + let at = 1.5 * i as f64; + Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(0.0, at, 100.0, at + 2.0)), paint.clone())) + }) + .collect(); + let mut g = Node::group(NodeId(10), children); + g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 1); +} From 4ccf9ddea8300f92ccb3091078c3275294210790 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:17 +0000 Subject: [PATCH 069/199] refactor(tools): move xform/gradient.rs tests to xform/gradient/tests.rs --- crates/tools/src/xform/gradient.rs | 346 +---------------------- crates/tools/src/xform/gradient/tests.rs | 343 ++++++++++++++++++++++ 2 files changed, 344 insertions(+), 345 deletions(-) create mode 100644 crates/tools/src/xform/gradient/tests.rs diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index cdc733bec..263151434 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -584,348 +584,4 @@ impl Tool for GradientTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_color::{GradientPaint, Paint}; - use vectorcraft_doc::{Document, Selection}; - - fn ev(kind: PointerKind, p: Point) -> PointerEvent { - PointerEvent::new(kind, p.x, p.y) - } - - fn with(m: Mods, e: PointerEvent) -> PointerEvent { - e.with_mods(m) - } - - const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false }; - const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false }; - - /// The test rectangle (100..200) with a horizontal gradient of `stops` from (100,150) to (200,150). - fn graded(stops: Vec) -> (Document, Selection) { - let (mut d, id) = doc_with_rect(); - let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); - g.geom = Some(GradientGeom { start: Point::new(100.0, 150.0), end: Point::new(200.0, 150.0), aspect: 1.0, focal: None }); - d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g))); - let mut s = Selection::default(); - s.add(id); - (d, s) - } - - fn two() -> Vec { - Gradient::default().stops - } - - fn three() -> Vec { - insert_stop(&Gradient::default(), 0.5).0 - } - - fn preview(a: &[Action]) -> &Value { - a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v) } else { None }).unwrap_or_else(|| panic!("no preview in {a:?}")) - } - - fn exec(a: &Action) -> (&str, &Value) { - match a { - Action::Exec(c, v) => (c.as_str(), v), - a => panic!("not an Exec: {a:?}"), - } - } - - fn point(v: &Value) -> Point { - Point::new(v[0].as_f64().unwrap(), v[1].as_f64().unwrap()) - } - - /// Stop offsets in percent (rounded: offsets travel as f32). - fn offsets(v: &Value) -> Vec { - v["stops"].as_array().unwrap().iter().map(|s| (s["offset"].as_f64().unwrap() * 100.0).round()).collect() - } - - #[test] - fn drag_sets_gradient_vector_and_constrains() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GradientTool::default(); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)).is_empty()); - let a = t.pointer(&cx, &with(SHIFT, PointerEvent::new(PointerKind::Drag, 190.0, 152.0))); - assert_eq!(a[0], Action::Begin("Gradient".into())); - let Action::Preview(c, v) = &a[1] else { panic!() }; - assert_eq!(c, "paint.setGradientGeom"); - assert_eq!(v["start"], json!([110.0, 150.0])); - assert!((v["end"][1].as_f64().unwrap() - 150.0).abs() < 1e-9); - // While dragging a solid-filled object the annotator follows the drag. - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Handle { p, .. } if p.x == 110.0))); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 150.0)), vec![Action::Commit]); - } - - #[test] - fn stroke_proxy_drags_and_annotates_the_stroke() { - let (mut d, id) = doc_with_rect(); - d.node_mut(id).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())))); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let mut cx = cx(&d, &s, &p); - // The fill is solid: with the Fill proxy in front there is no annotator. - assert!(GradientTool::default().overlays(&cx).is_empty()); - cx.fill_active = false; - // The stroke's annotator spans the stroke-inflated box (1 pt stroke: 99.5..200.5). - let a = Annotator::of(&cx).unwrap(); - assert_eq!((a.geom.start, a.geom.end, a.gradient.stops.len()), (Point::new(99.5, 150.0), Point::new(200.5, 150.0), 2)); - let mut t = GradientTool::default(); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 120.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 120.0)); - assert_eq!(preview(&a)["stroke"], json!(true)); - } - - #[test] - fn empty_selection_targets_hit_object() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GradientTool::default(); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty()); - } - - #[test] - fn the_end_handle_changes_only_the_end_and_the_start_moves_both() { - let (d, s) = graded(two()); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GradientTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); - let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(220.0, 170.0)))).clone(); - assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(100.0, 150.0), Point::new(220.0, 170.0))); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(220.0, 170.0))), vec![Action::Commit]); - // The start handle moves the whole vector. - t.pointer(&cx, &ev(PointerKind::Down, Point::new(100.0, 150.0))); - let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(110.0, 140.0)))).clone(); - assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(110.0, 140.0), Point::new(210.0, 140.0))); - } - - #[test] - fn past_the_end_rotates_and_shift_snaps() { - let (d, s) = graded(two()); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GradientTool::default(); - assert_eq!(t.cursor(&cx, Point::new(210.0, 151.0), Mods::default()), Cursor::Rotate); - t.pointer(&cx, &ev(PointerKind::Down, Point::new(210.0, 151.0))); - let end = point(&preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, Point::new(103.0, 60.0)))))["end"]); - // Snapped to straight up, keeping the 100 pt length. - assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}"); - } - - #[test] - fn handles_snap_to_anchors_and_shift_uses_the_constrain_angle() { - let (d, s) = graded(two()); - let p = paint(); - let mut cx = cx(&d, &s, &p); - let mut t = GradientTool::default(); - // The end handle dragged near the rectangle's corner lands on it, with a guide label. - t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); - let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(202.0, 197.0)))).clone(); - assert_eq!(point(&v["end"]), Point::new(200.0, 200.0)); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor"))); - t.pointer(&cx, &ev(PointerKind::Up, Point::new(202.0, 197.0))); - assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { .. })), "the guides go with the drag"); - // A vector drawn from near a corner starts on it. - let (d2, id) = doc_with_rect(); - let mut s2 = Selection::default(); - s2.add(id); - let cx2 = crate::testutil::cx(&d2, &s2, &p); - t.pointer(&cx2, &ev(PointerKind::Down, Point::new(101.0, 102.0))); - let v = preview(&t.pointer(&cx2, &ev(PointerKind::Drag, Point::new(150.0, 130.0)))).clone(); - assert_eq!(point(&v["start"]), Point::new(100.0, 100.0)); - t.pointer(&cx2, &ev(PointerKind::Up, Point::new(150.0, 130.0))); - // Constrain Angle 30: Shift gives 30 and 75 degrees (and no snapping). - cx.constrain_angle = 30.0; - let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0, focal: None }.angle_deg(); - for (to, want) in [(Point::new(190.0, 100.0), 30.0), (Point::new(125.0, 60.0), 75.0)] { - t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); - let v = preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, to)))).clone(); - assert!((angle(&v) - want).abs() < 1e-9, "{want}: {v}"); - t.pointer(&cx, &ev(PointerKind::Up, to)); - } - } - - #[test] - fn a_bar_click_adds_a_stop_and_selects_it() { - let (d, s) = graded(two()); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GradientTool::default(); - assert_eq!(t.cursor(&cx, Point::new(130.0, 150.0), Mods::default()), Cursor::AddStop); - assert!(t.pointer(&cx, &ev(PointerKind::Down, Point::new(130.0, 150.0))).is_empty()); - let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(130.0, 150.0))); - let (c, v) = exec(&a[0]); - assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 30.0, 100.0])); - assert_eq!(v["keepModel"], json!(true), "the sampled color keeps its model"); - assert_eq!(a[1], GradientTool::select_stop(1)); - } - - #[test] - fn stops_drag_along_the_bar_off_it_to_delete_and_alt_copies() { - let (d, s) = graded(two()); - let p = paint(); - let cx = cx(&d, &s, &p); - let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 0).unwrap(); - let mut t = GradientTool::default(); - // Pressing a stop selects it; dragging moves it. - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, chip)), vec![GradientTool::select_stop(0)]); - assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(140.0, chip.y))))), vec![40.0, 100.0]); - assert_eq!(t.cursor(&cx, chip, Mods::default()), Cursor::Move); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(140.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(0)]); - // Two stops never lose one: dragged off the bar, the stop stays. - t.pointer(&cx, &ev(PointerKind::Down, chip)); - assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 260.0))))), vec![0.0, 100.0]); - t.pointer(&cx, &ev(PointerKind::Up, Point::new(100.0, 260.0))); - // Alt-drag leaves the original and drags a copy. - t.pointer(&cx, &with(ALT, ev(PointerKind::Down, chip))); - assert_eq!(offsets(preview(&t.pointer(&cx, &with(ALT, ev(PointerKind::Drag, Point::new(150.0, chip.y)))))), vec![0.0, 50.0, 100.0]); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(1)]); - - // With three stops, dragging the middle one off the bar deletes it. - let (d, s) = graded(three()); - let cx = crate::testutil::cx(&d, &s, &p); - let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap(); - t.pointer(&cx, &ev(PointerKind::Down, chip)); - assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(150.0, 200.0))))), vec![0.0, 100.0]); - assert_eq!(t.cursor(&cx, Point::new(150.0, 200.0), Mods::default()), Cursor::RemoveStop); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 200.0))), vec![Action::Commit, GradientTool::select_stop(1)]); - } - - #[test] - fn diamonds_move_midpoints() { - let (d, s) = graded(two()); - let p = paint(); - let cx = cx(&d, &s, &p); - let diamond = Annotator::of(&cx).unwrap().mid_point(&cx, 0).unwrap(); - let mut t = GradientTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, diamond)); - let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(130.0, diamond.y)))).clone(); - assert!((v["stops"][0]["midpoint"].as_f64().unwrap() - 0.3).abs() < 1e-6, "{v}"); - } - - #[test] - fn delete_and_arrows_act_on_the_selected_stop() { - let (d, s) = graded(two()); - let p = paint(); - let mut cx = cx(&d, &s, &p); - let mut t = GradientTool::default(); - assert!(!t.claims_key(&cx, ToolKey::Delete), "no stop selected"); - cx.gradient_stop = Some(1); - assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace) && !t.claims_key(&cx, ToolKey::Enter)); - // Two stops: Delete is claimed but does nothing. - assert!(t.key(&cx, ToolKey::Delete, Mods::default()).is_empty()); - let a = t.key(&cx, ToolKey::Left, SHIFT); - let (c, v) = exec(&a[0]); - assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 90.0])); - assert_eq!(a[1], GradientTool::select_stop(1)); - // With three stops Delete removes the selected one and selects its successor. - let (d, s) = graded(three()); - let mut cx = crate::testutil::cx(&d, &s, &p); - cx.gradient_stop = Some(1); - let a = t.key(&cx, ToolKey::Backspace, Mods::default()); - assert_eq!(offsets(exec(&a[0]).1), vec![0.0, 100.0]); - assert_eq!(a[1], GradientTool::select_stop(1)); - } - - #[test] - fn a_click_inside_the_art_applies_the_gradient_from_there() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GradientTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, Point::new(150.0, 120.0))); - let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 120.0))); - let (c, v) = exec(&a[0]); - assert_eq!(c, "paint.setGradientGeom"); - // The default fit's 100 pt horizontal vector, starting at the click. - assert_eq!((point(&v["start"]), point(&v["end"]), &v["ids"]), (Point::new(150.0, 120.0), Point::new(250.0, 120.0), &json!([id.0]))); - // Outside the art a click does nothing. - t.pointer(&cx, &ev(PointerKind::Down, Point::new(400.0, 400.0))); - assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty()); - } - - #[test] - fn double_click_on_a_stop_opens_its_popover() { - let (d, s) = graded(two()); - let p = paint(); - let cx = cx(&d, &s, &p); - let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap(); - let a = GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, chip)); - assert_eq!(a[0], GradientTool::select_stop(1)); - assert_eq!(a[1], Action::Dialog("gradientStop".into(), json!({"index": 1, "x": chip.x, "y": chip.y}))); - } - - #[test] - fn overlays_draw_a_chip_per_stop_marking_the_selected_one() { - let (d, s) = graded(two()); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.gradient_stop = Some(1); - let o = GradientTool::default().overlays(&cx); - let chips: Vec = o.iter().filter_map(|o| if let Overlay::Swatch { selected, .. } = o { Some(*selected) } else { None }).collect(); - assert_eq!(chips, vec![false, true]); - assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1, "one midpoint diamond"); - } - - #[test] - fn annotator_follows_the_active_appearance_item() { - let (mut d, id) = doc_with_rect(); - let geom = GradientGeom { start: Point::new(100.0, 120.0), end: Point::new(200.0, 120.0), aspect: 1.0, focal: None }; - let mut gp = GradientPaint::new(Default::default()); - gp.geom = Some(geom); - d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().paint = Paint::Gradient(Box::new(gp)); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let mut cx = cx(&d, &s, &p); - // The fill is solid: no annotator until the stroke row (item 1) is the active item, which - // brings the Stroke proxy forward. - assert!(Annotator::of(&cx).is_none()); - cx.appearance_item = Some(1); - assert!(Annotator::of(&cx).is_none(), "a stroke row does not stand in for the fill"); - cx.fill_active = false; - assert_eq!(Annotator::of(&cx).unwrap().geom, geom); - // A click inside the art edits that item. - let mut t = GradientTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, Point::new(150.0, 180.0))); - let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 180.0))); - assert_eq!(exec(&a[0]).1["item"], 1); - } - - #[test] - fn a_radial_shows_its_extent_ellipse_aspect_handle_and_focal_dot() { - let (mut d, s) = graded(two()); - let n = d.node_mut(s.objects[0]).unwrap(); - let Paint::Gradient(mut g) = n.appearance.fill_paint() else { panic!("a gradient fill") }; - g.gradient.kind = GradientKind::Radial; - let geom = g.geom.as_mut().unwrap(); - geom.aspect = 0.5; - geom.set_focal(Some(Point::new(120.0, 145.0))); - n.appearance.set_fill(Paint::Gradient(g)); - let p = paint(); - let cx = cx(&d, &s, &p); - let o = GradientTool::default().overlays(&cx); - assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count(), 1, "the extent ellipse"); - // The aspect handle 50 pt above the centre, the focal dot where the focal point is. - assert!( - o.contains(&Overlay::Handle { p: Point::new(100.0, 100.0), color: BAR }) - && o.contains(&Overlay::Handle { p: Point::new(120.0, 145.0), color: BAR }) - ); - let a = Annotator::of(&cx).unwrap(); - let hit = |x: f64, y: f64| a.hit(&cx, Point::new(x, y)); - assert_eq!((hit(120.0, 145.0), hit(104.0, 150.0), hit(100.0, 100.0)), (Some(Part::Focal), Some(Part::Start), Some(Part::Aspect))); - assert_eq!((hit(100.0, 200.0), hit(0.0, 150.0), hit(100.0, 220.0)), (Some(Part::Rotate), Some(Part::Rotate), None)); - assert_eq!(GradientTool::default().cursor(&cx, Point::new(100.0, 200.0), Mods::default()), Cursor::Rotate); - } -} +mod tests; diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs new file mode 100644 index 000000000..2542ec7c5 --- /dev/null +++ b/crates/tools/src/xform/gradient/tests.rs @@ -0,0 +1,343 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_color::{GradientPaint, Paint}; +use vectorcraft_doc::{Document, Selection}; + +fn ev(kind: PointerKind, p: Point) -> PointerEvent { + PointerEvent::new(kind, p.x, p.y) +} + +fn with(m: Mods, e: PointerEvent) -> PointerEvent { + e.with_mods(m) +} + +const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false }; +const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false }; + +/// The test rectangle (100..200) with a horizontal gradient of `stops` from (100,150) to (200,150). +fn graded(stops: Vec) -> (Document, Selection) { + let (mut d, id) = doc_with_rect(); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + g.geom = Some(GradientGeom { start: Point::new(100.0, 150.0), end: Point::new(200.0, 150.0), aspect: 1.0, focal: None }); + d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g))); + let mut s = Selection::default(); + s.add(id); + (d, s) +} + +fn two() -> Vec { + Gradient::default().stops +} + +fn three() -> Vec { + insert_stop(&Gradient::default(), 0.5).0 +} + +fn preview(a: &[Action]) -> &Value { + a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v) } else { None }).unwrap_or_else(|| panic!("no preview in {a:?}")) +} + +fn exec(a: &Action) -> (&str, &Value) { + match a { + Action::Exec(c, v) => (c.as_str(), v), + a => panic!("not an Exec: {a:?}"), + } +} + +fn point(v: &Value) -> Point { + Point::new(v[0].as_f64().unwrap(), v[1].as_f64().unwrap()) +} + +/// Stop offsets in percent (rounded: offsets travel as f32). +fn offsets(v: &Value) -> Vec { + v["stops"].as_array().unwrap().iter().map(|s| (s["offset"].as_f64().unwrap() * 100.0).round()).collect() +} + +#[test] +fn drag_sets_gradient_vector_and_constrains() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)).is_empty()); + let a = t.pointer(&cx, &with(SHIFT, PointerEvent::new(PointerKind::Drag, 190.0, 152.0))); + assert_eq!(a[0], Action::Begin("Gradient".into())); + let Action::Preview(c, v) = &a[1] else { panic!() }; + assert_eq!(c, "paint.setGradientGeom"); + assert_eq!(v["start"], json!([110.0, 150.0])); + assert!((v["end"][1].as_f64().unwrap() - 150.0).abs() < 1e-9); + // While dragging a solid-filled object the annotator follows the drag. + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Handle { p, .. } if p.x == 110.0))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 150.0)), vec![Action::Commit]); +} + +#[test] +fn stroke_proxy_drags_and_annotates_the_stroke() { + let (mut d, id) = doc_with_rect(); + d.node_mut(id).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())))); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut cx = cx(&d, &s, &p); + // The fill is solid: with the Fill proxy in front there is no annotator. + assert!(GradientTool::default().overlays(&cx).is_empty()); + cx.fill_active = false; + // The stroke's annotator spans the stroke-inflated box (1 pt stroke: 99.5..200.5). + let a = Annotator::of(&cx).unwrap(); + assert_eq!((a.geom.start, a.geom.end, a.gradient.stops.len()), (Point::new(99.5, 150.0), Point::new(200.5, 150.0), 2)); + let mut t = GradientTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 120.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 120.0)); + assert_eq!(preview(&a)["stroke"], json!(true)); +} + +#[test] +fn empty_selection_targets_hit_object() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty()); +} + +#[test] +fn the_end_handle_changes_only_the_end_and_the_start_moves_both() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); + let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(220.0, 170.0)))).clone(); + assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(100.0, 150.0), Point::new(220.0, 170.0))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(220.0, 170.0))), vec![Action::Commit]); + // The start handle moves the whole vector. + t.pointer(&cx, &ev(PointerKind::Down, Point::new(100.0, 150.0))); + let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(110.0, 140.0)))).clone(); + assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(110.0, 140.0), Point::new(210.0, 140.0))); +} + +#[test] +fn past_the_end_rotates_and_shift_snaps() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + assert_eq!(t.cursor(&cx, Point::new(210.0, 151.0), Mods::default()), Cursor::Rotate); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(210.0, 151.0))); + let end = point(&preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, Point::new(103.0, 60.0)))))["end"]); + // Snapped to straight up, keeping the 100 pt length. + assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}"); +} + +#[test] +fn handles_snap_to_anchors_and_shift_uses_the_constrain_angle() { + let (d, s) = graded(two()); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + // The end handle dragged near the rectangle's corner lands on it, with a guide label. + t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); + let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(202.0, 197.0)))).clone(); + assert_eq!(point(&v["end"]), Point::new(200.0, 200.0)); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor"))); + t.pointer(&cx, &ev(PointerKind::Up, Point::new(202.0, 197.0))); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { .. })), "the guides go with the drag"); + // A vector drawn from near a corner starts on it. + let (d2, id) = doc_with_rect(); + let mut s2 = Selection::default(); + s2.add(id); + let cx2 = crate::testutil::cx(&d2, &s2, &p); + t.pointer(&cx2, &ev(PointerKind::Down, Point::new(101.0, 102.0))); + let v = preview(&t.pointer(&cx2, &ev(PointerKind::Drag, Point::new(150.0, 130.0)))).clone(); + assert_eq!(point(&v["start"]), Point::new(100.0, 100.0)); + t.pointer(&cx2, &ev(PointerKind::Up, Point::new(150.0, 130.0))); + // Constrain Angle 30: Shift gives 30 and 75 degrees (and no snapping). + cx.constrain_angle = 30.0; + let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0, focal: None }.angle_deg(); + for (to, want) in [(Point::new(190.0, 100.0), 30.0), (Point::new(125.0, 60.0), 75.0)] { + t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); + let v = preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, to)))).clone(); + assert!((angle(&v) - want).abs() < 1e-9, "{want}: {v}"); + t.pointer(&cx, &ev(PointerKind::Up, to)); + } +} + +#[test] +fn a_bar_click_adds_a_stop_and_selects_it() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + assert_eq!(t.cursor(&cx, Point::new(130.0, 150.0), Mods::default()), Cursor::AddStop); + assert!(t.pointer(&cx, &ev(PointerKind::Down, Point::new(130.0, 150.0))).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(130.0, 150.0))); + let (c, v) = exec(&a[0]); + assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 30.0, 100.0])); + assert_eq!(v["keepModel"], json!(true), "the sampled color keeps its model"); + assert_eq!(a[1], GradientTool::select_stop(1)); +} + +#[test] +fn stops_drag_along_the_bar_off_it_to_delete_and_alt_copies() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 0).unwrap(); + let mut t = GradientTool::default(); + // Pressing a stop selects it; dragging moves it. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, chip)), vec![GradientTool::select_stop(0)]); + assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(140.0, chip.y))))), vec![40.0, 100.0]); + assert_eq!(t.cursor(&cx, chip, Mods::default()), Cursor::Move); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(140.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(0)]); + // Two stops never lose one: dragged off the bar, the stop stays. + t.pointer(&cx, &ev(PointerKind::Down, chip)); + assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 260.0))))), vec![0.0, 100.0]); + t.pointer(&cx, &ev(PointerKind::Up, Point::new(100.0, 260.0))); + // Alt-drag leaves the original and drags a copy. + t.pointer(&cx, &with(ALT, ev(PointerKind::Down, chip))); + assert_eq!(offsets(preview(&t.pointer(&cx, &with(ALT, ev(PointerKind::Drag, Point::new(150.0, chip.y)))))), vec![0.0, 50.0, 100.0]); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(1)]); + + // With three stops, dragging the middle one off the bar deletes it. + let (d, s) = graded(three()); + let cx = crate::testutil::cx(&d, &s, &p); + let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap(); + t.pointer(&cx, &ev(PointerKind::Down, chip)); + assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(150.0, 200.0))))), vec![0.0, 100.0]); + assert_eq!(t.cursor(&cx, Point::new(150.0, 200.0), Mods::default()), Cursor::RemoveStop); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 200.0))), vec![Action::Commit, GradientTool::select_stop(1)]); +} + +#[test] +fn diamonds_move_midpoints() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let diamond = Annotator::of(&cx).unwrap().mid_point(&cx, 0).unwrap(); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, diamond)); + let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(130.0, diamond.y)))).clone(); + assert!((v["stops"][0]["midpoint"].as_f64().unwrap() - 0.3).abs() < 1e-6, "{v}"); +} + +#[test] +fn delete_and_arrows_act_on_the_selected_stop() { + let (d, s) = graded(two()); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + assert!(!t.claims_key(&cx, ToolKey::Delete), "no stop selected"); + cx.gradient_stop = Some(1); + assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace) && !t.claims_key(&cx, ToolKey::Enter)); + // Two stops: Delete is claimed but does nothing. + assert!(t.key(&cx, ToolKey::Delete, Mods::default()).is_empty()); + let a = t.key(&cx, ToolKey::Left, SHIFT); + let (c, v) = exec(&a[0]); + assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 90.0])); + assert_eq!(a[1], GradientTool::select_stop(1)); + // With three stops Delete removes the selected one and selects its successor. + let (d, s) = graded(three()); + let mut cx = crate::testutil::cx(&d, &s, &p); + cx.gradient_stop = Some(1); + let a = t.key(&cx, ToolKey::Backspace, Mods::default()); + assert_eq!(offsets(exec(&a[0]).1), vec![0.0, 100.0]); + assert_eq!(a[1], GradientTool::select_stop(1)); +} + +#[test] +fn a_click_inside_the_art_applies_the_gradient_from_there() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(150.0, 120.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 120.0))); + let (c, v) = exec(&a[0]); + assert_eq!(c, "paint.setGradientGeom"); + // The default fit's 100 pt horizontal vector, starting at the click. + assert_eq!((point(&v["start"]), point(&v["end"]), &v["ids"]), (Point::new(150.0, 120.0), Point::new(250.0, 120.0), &json!([id.0]))); + // Outside the art a click does nothing. + t.pointer(&cx, &ev(PointerKind::Down, Point::new(400.0, 400.0))); + assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty()); +} + +#[test] +fn double_click_on_a_stop_opens_its_popover() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap(); + let a = GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, chip)); + assert_eq!(a[0], GradientTool::select_stop(1)); + assert_eq!(a[1], Action::Dialog("gradientStop".into(), json!({"index": 1, "x": chip.x, "y": chip.y}))); +} + +#[test] +fn overlays_draw_a_chip_per_stop_marking_the_selected_one() { + let (d, s) = graded(two()); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.gradient_stop = Some(1); + let o = GradientTool::default().overlays(&cx); + let chips: Vec = o.iter().filter_map(|o| if let Overlay::Swatch { selected, .. } = o { Some(*selected) } else { None }).collect(); + assert_eq!(chips, vec![false, true]); + assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1, "one midpoint diamond"); +} + +#[test] +fn annotator_follows_the_active_appearance_item() { + let (mut d, id) = doc_with_rect(); + let geom = GradientGeom { start: Point::new(100.0, 120.0), end: Point::new(200.0, 120.0), aspect: 1.0, focal: None }; + let mut gp = GradientPaint::new(Default::default()); + gp.geom = Some(geom); + d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().paint = Paint::Gradient(Box::new(gp)); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut cx = cx(&d, &s, &p); + // The fill is solid: no annotator until the stroke row (item 1) is the active item, which + // brings the Stroke proxy forward. + assert!(Annotator::of(&cx).is_none()); + cx.appearance_item = Some(1); + assert!(Annotator::of(&cx).is_none(), "a stroke row does not stand in for the fill"); + cx.fill_active = false; + assert_eq!(Annotator::of(&cx).unwrap().geom, geom); + // A click inside the art edits that item. + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(150.0, 180.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 180.0))); + assert_eq!(exec(&a[0]).1["item"], 1); +} + +#[test] +fn a_radial_shows_its_extent_ellipse_aspect_handle_and_focal_dot() { + let (mut d, s) = graded(two()); + let n = d.node_mut(s.objects[0]).unwrap(); + let Paint::Gradient(mut g) = n.appearance.fill_paint() else { panic!("a gradient fill") }; + g.gradient.kind = GradientKind::Radial; + let geom = g.geom.as_mut().unwrap(); + geom.aspect = 0.5; + geom.set_focal(Some(Point::new(120.0, 145.0))); + n.appearance.set_fill(Paint::Gradient(g)); + let p = paint(); + let cx = cx(&d, &s, &p); + let o = GradientTool::default().overlays(&cx); + assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count(), 1, "the extent ellipse"); + // The aspect handle 50 pt above the centre, the focal dot where the focal point is. + assert!( + o.contains(&Overlay::Handle { p: Point::new(100.0, 100.0), color: BAR }) + && o.contains(&Overlay::Handle { p: Point::new(120.0, 145.0), color: BAR }) + ); + let a = Annotator::of(&cx).unwrap(); + let hit = |x: f64, y: f64| a.hit(&cx, Point::new(x, y)); + assert_eq!((hit(120.0, 145.0), hit(104.0, 150.0), hit(100.0, 100.0)), (Some(Part::Focal), Some(Part::Start), Some(Part::Aspect))); + assert_eq!((hit(100.0, 200.0), hit(0.0, 150.0), hit(100.0, 220.0)), (Some(Part::Rotate), Some(Part::Rotate), None)); + assert_eq!(GradientTool::default().cursor(&cx, Point::new(100.0, 200.0), Mods::default()), Cursor::Rotate); +} From 9d38803cfa36d425cbfd09860cd92e864a8472c2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:18 +0000 Subject: [PATCH 070/199] refactor(render): move pattern.rs tests to pattern/tests.rs --- crates/render/src/pattern.rs | 346 +---------------------------- crates/render/src/pattern/tests.rs | 343 ++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 345 deletions(-) create mode 100644 crates/render/src/pattern/tests.rs diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index c2150c76e..4602ae8ff 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -361,348 +361,4 @@ impl Renderer { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_color::{Color, Paint}; - use vectorcraft_doc::pattern::{PatternEdit, RepeatSpec, pattern_paint}; - use vectorcraft_doc::{Appearance, NodeId, NodeKind}; - use vectorcraft_geom::shapes; - - /// A 20×20 tile with a red 10×10 square in its top-left corner. - fn doc_with_pattern(tt: TileType) -> Document { - let mut d = Document::new(200.0, 200.0); - let red = Node::path( - NodeId(900), - shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), - Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), - ); - let mut def = PatternDef::new("Checks", vec![Arc::new(red)]); - def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); - def.tile_type = tt; - d.patterns.push(def); - let id = d.alloc_id(); - let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); - let l = d.layers[0].id; - d.insert(Some(l), 0, n).unwrap(); - d - } - - fn render(d: &Document, scale: f64) -> Rendered { - let px = (200.0 * scale) as u32; - Renderer::new().render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) - } - - fn is_red(p: [u8; 4]) -> bool { - p[0] > 200 && p[1] < 60 && p[2] < 60 - } - fn is_white(p: [u8; 4]) -> bool { - p[0] > 200 && p[1] > 200 && p[2] > 200 - } - - /// #1536: a raster effect drawn after a pattern fill is the same as without the pattern (the - /// pattern's paint transform must not resample the effect's raster). - #[test] - fn effects_after_a_pattern_fill_keep_their_place_and_size() { - let fx = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true }; - for effect in [ - fx("blur.gaussian", serde_json::json!({"radius": 4.0})), - fx("stylize.dropShadow", serde_json::json!({"x": 6, "y": 6, "blur": 3, "opacity": 100})), - ] { - let make = |with_pattern: bool| { - let mut d = doc_with_pattern(TileType::Grid); - let l = d.layers[0].id; - let filled = d.children(Some(l)).unwrap()[0].id; - d.remove(filled).unwrap(); - if with_pattern { - let id = d.alloc_id(); - let n = Node::path( - id, - shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), - Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0), - ); - d.insert(Some(l), 0, n).unwrap(); - } - let id = d.alloc_id(); - let mut n = Node::path( - id, - shapes::ellipse(Rect::new(120.0, 110.0, 180.0, 170.0)), - Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0), - ); - n.appearance.effects.push(effect.clone()); - let at = d.children(Some(l)).map_or(0, Vec::len); - d.insert(Some(l), at, n).unwrap(); - d - }; - // Supersampled, as raster exports draw. - let one = |d: &Document| { - let mut r = Renderer::new(); - r.render( - d, - 200, - 200, - Affine::IDENTITY, - &RenderOptions { background: Some([255, 255, 255, 255]), supersample: true, ..Default::default() }, - ) - }; - let (with, without) = (one(&make(true)), one(&make(false))); - for (x, y) in [(150, 140), (125, 140), (176, 160), (150, 172), (184, 176)] { - assert_eq!(with.pixel(x, y), without.pixel(x, y), "{} at ({x}, {y})", effect.id); - } - } - } - - #[test] - fn pattern_fill_is_periodic() { - let d = doc_with_pattern(TileType::Grid); - let r = render(&d, 1.0); - for (x, y) in [(5, 5), (25, 5), (125, 145), (185, 65)] { - assert!(is_red(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y)); - } - for (x, y) in [(15, 5), (5, 15), (135, 135), (175, 195)] { - assert!(is_white(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y)); - } - // Periodic: pixel (x, y) equals (x + 20k, y + 20m). - for y in (1..20).step_by(3) { - for x in (1..20).step_by(3) { - if x == 10 || y == 10 { - continue; - } - assert_eq!(r.pixel(x, y), r.pixel(x + 60, y + 100)); - } - } - } - - #[test] - fn pattern_fill_is_clipped_to_shape() { - let mut d = doc_with_pattern(TileType::Grid); - let id = d.layers[0].children().unwrap()[0].id; - *d.node_mut(id).unwrap().path_data_mut().unwrap() = shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)); - let r = render(&d, 1.0); - assert!(is_red(r.pixel(45, 45))); - assert!(is_white(r.pixel(65, 65))); - } - - #[test] - fn brick_pattern_shifts_rows() { - let d = doc_with_pattern(TileType::BrickByRow { offset: 0.5 }); - let r = render(&d, 1.0); - assert!(is_red(r.pixel(5, 5))); - // Row 1 is shifted by half a tile. - assert!(is_white(r.pixel(5, 25)) && is_red(r.pixel(15, 25)), "{:?} {:?}", r.pixel(5, 25), r.pixel(15, 25)); - assert!(is_red(r.pixel(5, 45))); - } - - #[test] - fn pattern_follows_zoom_and_paint_transform() { - let d = doc_with_pattern(TileType::Grid); - let r = render(&d, 2.0); - assert!(is_red(r.pixel(10, 10)) && is_white(r.pixel(30, 10)) && is_red(r.pixel(50, 10))); - let mut d2 = d.clone(); - let id = d2.layers[0].children().unwrap()[0].id; - vectorcraft_doc::pattern::transform_pattern_paints(d2.node_mut(id).unwrap(), Affine::translate((10.0, 0.0))); - let r = render(&d2, 1.0); - assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5))); - } - - /// `doc_with_pattern(TileType::Grid)` with a 40×40 square on top of its rectangle, filled with - /// the same pattern scaled by `s`, as Transform Pattern Tiles leaves it. - fn doc_with_two_scales(s: f64) -> Document { - let mut d = doc_with_pattern(TileType::Grid); - let square = shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0)); - let mut n = Node::path(d.alloc_id(), square, Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); - vectorcraft_doc::pattern::transform_pattern_paints(&mut n, Affine::scale(s)); - let l = d.layers[0].id; - d.insert(Some(l), 1, n).unwrap(); - d - } - - /// `d` drawn by `r` at `scale`. - fn frame(r: &mut Renderer, d: &Document, scale: f64) -> Rendered { - let px = (200.0 * scale) as u32; - r.render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) - } - - /// The tiles rasterized on this thread so far: the tile renderer counts a frame for each. - fn tiles_rasterized() -> u64 { - CACHE.with(|c| c.borrow().renderer.as_ref().map_or(0, |r| r.stamp)) - } - - #[test] - fn objects_at_two_pattern_scales_keep_their_tiles_between_frames() { - // At 100% the rectangle's tile is rasterized at scale 1, and the square's tile is - // rasterized at 0.25, or at 2 when its tiles are scaled to 101%. - for s in [0.25, 1.01] { - let d = doc_with_two_scales(s); - let mut r = Renderer::new(); - let start = tiles_rasterized(); - let first = frame(&mut r, &d, 1.0).pixels; - assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: a tile for each object"); - for _ in 0..3 { - assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}"); - } - assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: later frames draw both objects from their tiles"); - // After a frame at 200%, the next frame at 100% draws from the tiles made at 100%. - frame(&mut r, &d, 2.0); - let zoomed = tiles_rasterized(); - assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}"); - assert_eq!(tiles_rasterized(), zoomed, "tiles scaled by {s}: back at 100%"); - } - } - - #[test] - fn kept_tiles_draw_what_a_new_renderer_draws() { - // A new renderer on a thread of its own starts with no tiles. - let fresh = |d: &Document, zoom: f64| { - let d = d.clone(); - std::thread::spawn(move || frame(&mut Renderer::new(), &d, zoom).pixels).join().unwrap() - }; - let mut d = doc_with_two_scales(0.25); - let mut r = Renderer::new(); - // Pixel (130, 130) is red in the square's tiles and clear in the rectangle's, (133, 133) is - // clear in both, and (103, 103), outside the square, is red in the rectangle's. - let first = frame(&mut r, &d, 1.0); - assert!(is_red(first.pixel(130, 130)) && is_white(first.pixel(133, 133)) && is_red(first.pixel(103, 103))); - for zoom in [1.0, 2.0, 1.0, 0.5, 2.0, 1.0] { - assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "zoom {zoom}"); - } - // Change the tile type as Pattern Options does. - d.patterns[0].tile_type = TileType::BrickByRow { offset: 0.5 }; - for zoom in [2.0, 1.0] { - assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "brick, zoom {zoom}"); - } - // Replace the art as Done Editing Pattern does. - let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); - d.patterns[0].art = vec![Arc::new(Node::path(NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), blue))]; - for zoom in [1.0, 2.0] { - assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "new art, zoom {zoom}"); - } - } - - #[test] - fn kept_tiles_stay_within_the_budget() { - // The rectangle's tile is 20 pixels a side per 100% of zoom, and the square's tile is 5 - // pixels a side per 100% of zoom. The budget holds the rectangle's tile at 400% and both - // tiles at 200%; the tiles drawn from 100% to 400% need more. - let d = doc_with_two_scales(0.25); - let tile = |side: usize| size_of::() + side * side * 4; - let budget = tile(80) + tile(40) + tile(10); - CACHE.with(|c| c.borrow_mut().tiles[Ink::Display as usize] = FrameCache::new(budget, usize::MAX)); - let kept = || { - CACHE.with(|c| { - let c = c.borrow(); - let tiles = &c.tiles[Ink::Display as usize]; - (tiles.len(), tiles.bytes()) - }) - }; - let mut r = Renderer::new(); - for zoom in [1.0, 2.0, 4.0] { - frame(&mut r, &d, zoom); - let drawn = tiles_rasterized(); - frame(&mut r, &d, zoom); - assert_eq!(tiles_rasterized(), drawn, "zoom {zoom}: the next frame draws from the tiles just made"); - let (len, bytes) = kept(); - assert!(len >= 2 && bytes <= budget, "zoom {zoom}: {len} tiles, {bytes} bytes"); - } - // Tiles were rasterized at five scales (0.25, 0.5, 1, 2 and 4); the cache dropped the least - // recently used tiles first. - assert!(kept().0 < 5, "{:?}", kept()); - let before = tiles_rasterized(); - frame(&mut r, &d, 1.0); - assert!(tiles_rasterized() > before, "a tile of 100% was dropped"); - assert!(kept().1 <= budget, "{:?}", kept()); - } - - /// A pattern whose art is an image that can't be decoded: every frame that draws the pattern - /// counts the failure ([`crate::paint::decode_failures`]), from a new tile or from the - /// tile kept. - #[test] - fn kept_tiles_count_the_images_they_could_not_decode() { - use vectorcraft_doc::{ImageBlob, ImageObject}; - let mut d = doc_with_pattern(TileType::Grid); - // A PNG signature with nothing after it. - d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = - ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; - d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; - let mut r = Renderer::new(); - let start = tiles_rasterized(); - for n in 0..3 { - let before = crate::paint::decode_failures(); - frame(&mut r, &d, 1.0); - assert!(crate::paint::decode_failures() > before, "frame {n}"); - } - assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept"); - } - - #[test] - fn missing_pattern_is_grey() { - let mut d = doc_with_pattern(TileType::Grid); - d.patterns.clear(); - let p = render(&d, 1.0).pixel(50, 50); - assert_eq!(p, [128, 128, 128, 255]); - } - - #[test] - fn swatch_thumbnail_renders() { - let d = doc_with_pattern(TileType::Grid); - let r = render_pattern_swatch(&d, "Checks", 20).unwrap(); - assert!(is_red(r.pixel(3, 3))); - assert_eq!(r.pixel(15, 15)[3], 0); - assert!(render_pattern_swatch(&d, "Nope", 20).is_none()); - } - - #[test] - fn edit_mode_draws_only_tile_layer_with_copies() { - let mut d = doc_with_pattern(TileType::Grid); - let l = d.add_layer(Some("Pattern Editing Mode")); - let art = d.patterns[0].art[0].clone(); - d.insert(Some(l), 0, (*art).clone()).unwrap(); - d.pattern_edit = Some(PatternEdit { pattern: "Checks".into(), layer: l, original: None }); - d.patterns[0].dim_copies = 50.0; - d.patterns[0].show_tile_edge = false; - let r = render(&d, 1.0); - // The tile itself at full strength, copies dimmed, the filled document rect hidden. - assert!(is_red(r.pixel(5, 5))); - let c = r.pixel(25, 5); - assert!(c[0] > 200 && c[1] > 100 && c[1] < 160, "{c:?}"); - assert!(is_white(r.pixel(15, 5))); - assert!(is_white(r.pixel(125, 125)), "outside the copies"); - } - - #[test] - fn repeat_renders_instances() { - let mut d = Document::new(200.0, 200.0); - let sq = Node::path( - NodeId(50), - shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), - Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), - ); - let id = d.alloc_id(); - let l = d.layers[0].id; - d.insert(Some(l), 0, Node::new(id, NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(sq)], 10.0, 10.0)))).unwrap(); - let r = render(&d, 1.0); - for (x, y) in [(5, 5), (25, 5), (45, 45)] { - assert_eq!(r.pixel(x, y), [0, 0, 0, 255], "({x},{y})"); - } - assert!(is_white(r.pixel(15, 5)) && is_white(r.pixel(65, 5))); - } - - #[test] - fn raster_scale_is_power_of_two_and_capped() { - assert_eq!(raster_scale(1.0, (10.0, 10.0)), 1.0); - assert_eq!(raster_scale(1.5, (10.0, 10.0)), 2.0); - assert_eq!(raster_scale(3.0, (10.0, 10.0)), 4.0); - // A change within 2× keeps the raster. - assert_eq!(raster_scale(2.1, (10.0, 10.0)), raster_scale(3.9, (10.0, 10.0))); - assert!(raster_scale(64.0, (1000.0, 10.0)) * 1000.0 <= MAX_TILE_PX); - } - - #[test] - fn tile_renderer_depth_survives_caught_panics() { - for _ in 0..4 { - let r = std::panic::catch_unwind(|| with_tile_renderer(|_| panic!("tile boom"))); - assert!(r.is_err()); - } - assert!(with_tile_renderer(|_| ()).is_some(), "depth leaked after caught panics"); - } -} +mod tests; diff --git a/crates/render/src/pattern/tests.rs b/crates/render/src/pattern/tests.rs new file mode 100644 index 000000000..dba6673e1 --- /dev/null +++ b/crates/render/src/pattern/tests.rs @@ -0,0 +1,343 @@ +use super::*; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::pattern::{PatternEdit, RepeatSpec, pattern_paint}; +use vectorcraft_doc::{Appearance, NodeId, NodeKind}; +use vectorcraft_geom::shapes; + +/// A 20×20 tile with a red 10×10 square in its top-left corner. +fn doc_with_pattern(tt: TileType) -> Document { + let mut d = Document::new(200.0, 200.0); + let red = Node::path( + NodeId(900), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + let mut def = PatternDef::new("Checks", vec![Arc::new(red)]); + def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + def.tile_type = tt; + d.patterns.push(def); + let id = d.alloc_id(); + let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn render(d: &Document, scale: f64) -> Rendered { + let px = (200.0 * scale) as u32; + Renderer::new().render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn is_red(p: [u8; 4]) -> bool { + p[0] > 200 && p[1] < 60 && p[2] < 60 +} +fn is_white(p: [u8; 4]) -> bool { + p[0] > 200 && p[1] > 200 && p[2] > 200 +} + +/// #1536: a raster effect drawn after a pattern fill is the same as without the pattern (the +/// pattern's paint transform must not resample the effect's raster). +#[test] +fn effects_after_a_pattern_fill_keep_their_place_and_size() { + let fx = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true }; + for effect in [ + fx("blur.gaussian", serde_json::json!({"radius": 4.0})), + fx("stylize.dropShadow", serde_json::json!({"x": 6, "y": 6, "blur": 3, "opacity": 100})), + ] { + let make = |with_pattern: bool| { + let mut d = doc_with_pattern(TileType::Grid); + let l = d.layers[0].id; + let filled = d.children(Some(l)).unwrap()[0].id; + d.remove(filled).unwrap(); + if with_pattern { + let id = d.alloc_id(); + let n = Node::path( + id, + shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), + Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0), + ); + d.insert(Some(l), 0, n).unwrap(); + } + let id = d.alloc_id(); + let mut n = Node::path( + id, + shapes::ellipse(Rect::new(120.0, 110.0, 180.0, 170.0)), + Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0), + ); + n.appearance.effects.push(effect.clone()); + let at = d.children(Some(l)).map_or(0, Vec::len); + d.insert(Some(l), at, n).unwrap(); + d + }; + // Supersampled, as raster exports draw. + let one = |d: &Document| { + let mut r = Renderer::new(); + r.render( + d, + 200, + 200, + Affine::IDENTITY, + &RenderOptions { background: Some([255, 255, 255, 255]), supersample: true, ..Default::default() }, + ) + }; + let (with, without) = (one(&make(true)), one(&make(false))); + for (x, y) in [(150, 140), (125, 140), (176, 160), (150, 172), (184, 176)] { + assert_eq!(with.pixel(x, y), without.pixel(x, y), "{} at ({x}, {y})", effect.id); + } + } +} + +#[test] +fn pattern_fill_is_periodic() { + let d = doc_with_pattern(TileType::Grid); + let r = render(&d, 1.0); + for (x, y) in [(5, 5), (25, 5), (125, 145), (185, 65)] { + assert!(is_red(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y)); + } + for (x, y) in [(15, 5), (5, 15), (135, 135), (175, 195)] { + assert!(is_white(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y)); + } + // Periodic: pixel (x, y) equals (x + 20k, y + 20m). + for y in (1..20).step_by(3) { + for x in (1..20).step_by(3) { + if x == 10 || y == 10 { + continue; + } + assert_eq!(r.pixel(x, y), r.pixel(x + 60, y + 100)); + } + } +} + +#[test] +fn pattern_fill_is_clipped_to_shape() { + let mut d = doc_with_pattern(TileType::Grid); + let id = d.layers[0].children().unwrap()[0].id; + *d.node_mut(id).unwrap().path_data_mut().unwrap() = shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)); + let r = render(&d, 1.0); + assert!(is_red(r.pixel(45, 45))); + assert!(is_white(r.pixel(65, 65))); +} + +#[test] +fn brick_pattern_shifts_rows() { + let d = doc_with_pattern(TileType::BrickByRow { offset: 0.5 }); + let r = render(&d, 1.0); + assert!(is_red(r.pixel(5, 5))); + // Row 1 is shifted by half a tile. + assert!(is_white(r.pixel(5, 25)) && is_red(r.pixel(15, 25)), "{:?} {:?}", r.pixel(5, 25), r.pixel(15, 25)); + assert!(is_red(r.pixel(5, 45))); +} + +#[test] +fn pattern_follows_zoom_and_paint_transform() { + let d = doc_with_pattern(TileType::Grid); + let r = render(&d, 2.0); + assert!(is_red(r.pixel(10, 10)) && is_white(r.pixel(30, 10)) && is_red(r.pixel(50, 10))); + let mut d2 = d.clone(); + let id = d2.layers[0].children().unwrap()[0].id; + vectorcraft_doc::pattern::transform_pattern_paints(d2.node_mut(id).unwrap(), Affine::translate((10.0, 0.0))); + let r = render(&d2, 1.0); + assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5))); +} + +/// `doc_with_pattern(TileType::Grid)` with a 40×40 square on top of its rectangle, filled with +/// the same pattern scaled by `s`, as Transform Pattern Tiles leaves it. +fn doc_with_two_scales(s: f64) -> Document { + let mut d = doc_with_pattern(TileType::Grid); + let square = shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0)); + let mut n = Node::path(d.alloc_id(), square, Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); + vectorcraft_doc::pattern::transform_pattern_paints(&mut n, Affine::scale(s)); + let l = d.layers[0].id; + d.insert(Some(l), 1, n).unwrap(); + d +} + +/// `d` drawn by `r` at `scale`. +fn frame(r: &mut Renderer, d: &Document, scale: f64) -> Rendered { + let px = (200.0 * scale) as u32; + r.render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +/// The tiles rasterized on this thread so far: the tile renderer counts a frame for each. +fn tiles_rasterized() -> u64 { + CACHE.with(|c| c.borrow().renderer.as_ref().map_or(0, |r| r.stamp)) +} + +#[test] +fn objects_at_two_pattern_scales_keep_their_tiles_between_frames() { + // At 100% the rectangle's tile is rasterized at scale 1, and the square's tile is + // rasterized at 0.25, or at 2 when its tiles are scaled to 101%. + for s in [0.25, 1.01] { + let d = doc_with_two_scales(s); + let mut r = Renderer::new(); + let start = tiles_rasterized(); + let first = frame(&mut r, &d, 1.0).pixels; + assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: a tile for each object"); + for _ in 0..3 { + assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}"); + } + assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: later frames draw both objects from their tiles"); + // After a frame at 200%, the next frame at 100% draws from the tiles made at 100%. + frame(&mut r, &d, 2.0); + let zoomed = tiles_rasterized(); + assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}"); + assert_eq!(tiles_rasterized(), zoomed, "tiles scaled by {s}: back at 100%"); + } +} + +#[test] +fn kept_tiles_draw_what_a_new_renderer_draws() { + // A new renderer on a thread of its own starts with no tiles. + let fresh = |d: &Document, zoom: f64| { + let d = d.clone(); + std::thread::spawn(move || frame(&mut Renderer::new(), &d, zoom).pixels).join().unwrap() + }; + let mut d = doc_with_two_scales(0.25); + let mut r = Renderer::new(); + // Pixel (130, 130) is red in the square's tiles and clear in the rectangle's, (133, 133) is + // clear in both, and (103, 103), outside the square, is red in the rectangle's. + let first = frame(&mut r, &d, 1.0); + assert!(is_red(first.pixel(130, 130)) && is_white(first.pixel(133, 133)) && is_red(first.pixel(103, 103))); + for zoom in [1.0, 2.0, 1.0, 0.5, 2.0, 1.0] { + assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "zoom {zoom}"); + } + // Change the tile type as Pattern Options does. + d.patterns[0].tile_type = TileType::BrickByRow { offset: 0.5 }; + for zoom in [2.0, 1.0] { + assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "brick, zoom {zoom}"); + } + // Replace the art as Done Editing Pattern does. + let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + d.patterns[0].art = vec![Arc::new(Node::path(NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), blue))]; + for zoom in [1.0, 2.0] { + assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "new art, zoom {zoom}"); + } +} + +#[test] +fn kept_tiles_stay_within_the_budget() { + // The rectangle's tile is 20 pixels a side per 100% of zoom, and the square's tile is 5 + // pixels a side per 100% of zoom. The budget holds the rectangle's tile at 400% and both + // tiles at 200%; the tiles drawn from 100% to 400% need more. + let d = doc_with_two_scales(0.25); + let tile = |side: usize| size_of::() + side * side * 4; + let budget = tile(80) + tile(40) + tile(10); + CACHE.with(|c| c.borrow_mut().tiles[Ink::Display as usize] = FrameCache::new(budget, usize::MAX)); + let kept = || { + CACHE.with(|c| { + let c = c.borrow(); + let tiles = &c.tiles[Ink::Display as usize]; + (tiles.len(), tiles.bytes()) + }) + }; + let mut r = Renderer::new(); + for zoom in [1.0, 2.0, 4.0] { + frame(&mut r, &d, zoom); + let drawn = tiles_rasterized(); + frame(&mut r, &d, zoom); + assert_eq!(tiles_rasterized(), drawn, "zoom {zoom}: the next frame draws from the tiles just made"); + let (len, bytes) = kept(); + assert!(len >= 2 && bytes <= budget, "zoom {zoom}: {len} tiles, {bytes} bytes"); + } + // Tiles were rasterized at five scales (0.25, 0.5, 1, 2 and 4); the cache dropped the least + // recently used tiles first. + assert!(kept().0 < 5, "{:?}", kept()); + let before = tiles_rasterized(); + frame(&mut r, &d, 1.0); + assert!(tiles_rasterized() > before, "a tile of 100% was dropped"); + assert!(kept().1 <= budget, "{:?}", kept()); +} + +/// A pattern whose art is an image that can't be decoded: every frame that draws the pattern +/// counts the failure ([`crate::paint::decode_failures`]), from a new tile or from the +/// tile kept. +#[test] +fn kept_tiles_count_the_images_they_could_not_decode() { + use vectorcraft_doc::{ImageBlob, ImageObject}; + let mut d = doc_with_pattern(TileType::Grid); + // A PNG signature with nothing after it. + d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = + ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; + let mut r = Renderer::new(); + let start = tiles_rasterized(); + for n in 0..3 { + let before = crate::paint::decode_failures(); + frame(&mut r, &d, 1.0); + assert!(crate::paint::decode_failures() > before, "frame {n}"); + } + assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept"); +} + +#[test] +fn missing_pattern_is_grey() { + let mut d = doc_with_pattern(TileType::Grid); + d.patterns.clear(); + let p = render(&d, 1.0).pixel(50, 50); + assert_eq!(p, [128, 128, 128, 255]); +} + +#[test] +fn swatch_thumbnail_renders() { + let d = doc_with_pattern(TileType::Grid); + let r = render_pattern_swatch(&d, "Checks", 20).unwrap(); + assert!(is_red(r.pixel(3, 3))); + assert_eq!(r.pixel(15, 15)[3], 0); + assert!(render_pattern_swatch(&d, "Nope", 20).is_none()); +} + +#[test] +fn edit_mode_draws_only_tile_layer_with_copies() { + let mut d = doc_with_pattern(TileType::Grid); + let l = d.add_layer(Some("Pattern Editing Mode")); + let art = d.patterns[0].art[0].clone(); + d.insert(Some(l), 0, (*art).clone()).unwrap(); + d.pattern_edit = Some(PatternEdit { pattern: "Checks".into(), layer: l, original: None }); + d.patterns[0].dim_copies = 50.0; + d.patterns[0].show_tile_edge = false; + let r = render(&d, 1.0); + // The tile itself at full strength, copies dimmed, the filled document rect hidden. + assert!(is_red(r.pixel(5, 5))); + let c = r.pixel(25, 5); + assert!(c[0] > 200 && c[1] > 100 && c[1] < 160, "{c:?}"); + assert!(is_white(r.pixel(15, 5))); + assert!(is_white(r.pixel(125, 125)), "outside the copies"); +} + +#[test] +fn repeat_renders_instances() { + let mut d = Document::new(200.0, 200.0); + let sq = Node::path( + NodeId(50), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + let id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, Node::new(id, NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(sq)], 10.0, 10.0)))).unwrap(); + let r = render(&d, 1.0); + for (x, y) in [(5, 5), (25, 5), (45, 45)] { + assert_eq!(r.pixel(x, y), [0, 0, 0, 255], "({x},{y})"); + } + assert!(is_white(r.pixel(15, 5)) && is_white(r.pixel(65, 5))); +} + +#[test] +fn raster_scale_is_power_of_two_and_capped() { + assert_eq!(raster_scale(1.0, (10.0, 10.0)), 1.0); + assert_eq!(raster_scale(1.5, (10.0, 10.0)), 2.0); + assert_eq!(raster_scale(3.0, (10.0, 10.0)), 4.0); + // A change within 2× keeps the raster. + assert_eq!(raster_scale(2.1, (10.0, 10.0)), raster_scale(3.9, (10.0, 10.0))); + assert!(raster_scale(64.0, (1000.0, 10.0)) * 1000.0 <= MAX_TILE_PX); +} + +#[test] +fn tile_renderer_depth_survives_caught_panics() { + for _ in 0..4 { + let r = std::panic::catch_unwind(|| with_tile_renderer(|_| panic!("tile boom"))); + assert!(r.is_err()); + } + assert!(with_tile_renderer(|_| ()).is_some(), "depth leaked after caught panics"); +} From ee8fd812ff8bb12dcf098418e41e4b1a02ac5832 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:18 +0000 Subject: [PATCH 071/199] refactor(ui-egui): move dialogs/missing_fonts.rs tests to dialogs/missing_fonts/tests.rs --- crates/ui-egui/src/dialogs/missing_fonts.rs | 342 +----------------- .../src/dialogs/missing_fonts/tests.rs | 339 +++++++++++++++++ 2 files changed, 340 insertions(+), 341 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/missing_fonts/tests.rs diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs index d27a56b39..16fa10bea 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts.rs @@ -410,344 +410,4 @@ fn add_fonts(app: &mut VectorcraftApp, d: &Dialog) -> Result { } #[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - use std::time::Instant; - - use vectorcraft_engine::Session; - use vectorcraft_engine::cmd::findfiles::Rules; - - use super::*; - - fn frame(app: &mut VectorcraftApp) -> String { - crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) - } - - /// A fresh temporary folder (canonical, as a search reports it). - fn folder(tag: &str) -> PathBuf { - let dir = vectorcraft_testkit::temp_dir(&format!("missing-fonts-{tag}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()) - } - - /// An app that reads files and whose searches may go anywhere in `root`, on two threads. - fn app_for(root: &Path) -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); - app.session.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() }); - app.session.search_threads = Some(2); - app - } - - fn png() -> Vec { - let mut out = vec![]; - image::RgbaImage::from_pixel(40, 30, image::Rgba([200, 0, 0, 255])) - .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) - .unwrap(); - out - } - - /// Save a new document with type in `font` at `path`, with a linked image that is then deleted - /// when `link`. - fn save_doc(app: &mut VectorcraftApp, path: &Path, font: &str, link: bool) { - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let pic = path.with_extension("png"); - if link { - std::fs::write(&pic, png()).unwrap(); - app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap(); - } - app.run("text.create", json!({"x": 10, "y": 40, "text": "Type", "font": font})).unwrap(); - app.run("document.save", json!({"path": path.to_string_lossy()})).unwrap(); - if link { - std::fs::remove_file(&pic).unwrap(); - } - } - - fn open(app: &mut VectorcraftApp, path: &Path) { - crate::io::open_path(app, &path.to_string_lossy()).unwrap(); - } - - fn kind(app: &VectorcraftApp) -> Option<&str> { - app.ui.dialog.as_ref().map(|d| d.kind.as_str()) - } - - #[test] - fn missing_fonts_are_asked_about_after_the_missing_links() { - let root = folder("after-links"); - let mut app = app_for(&root); - let doc = root.join("doc.vectorcraft"); - save_doc(&mut app, &doc, "No Such Font Family", true); - open(&mut app, &doc); - assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); - super::super::cancel(&mut app); - let d = app.ui.dialog.as_ref().expect("Missing Fonts after the linked files"); - assert_eq!((d.kind.as_str(), d.fields["fonts"][0]["family"].as_str()), (KIND, Some("No Such Font Family"))); - let text = frame(&mut app); - for label in - ["Missing Fonts", "No Such Font Family Regular", "These fonts aren't available. Their text shows in another font.", "Find Fonts", "Close"] - { - assert!(text.contains(label), "{label} in {text}"); - } - assert!(!text.contains("Search Fonts Folder"), "no Fonts folder next to the document"); - assert!(!text.contains("Add Fonts") && !text.contains("Copy only fonts"), "no file found yet: {text}"); - assert_eq!(super::super::confirm(&mut app).err().as_deref(), Some("choose the font files to add")); - assert_eq!(kind(&app), Some(KIND), "nothing to add: the dialog stays open"); - super::super::cancel(&mut app); - assert!(app.ui.dialog.is_none()); - } - - #[test] - fn the_documents_fonts_folder_is_offered_and_searched_off_the_ui_thread() { - let root = folder("package"); - let font = root.join("pkg").join("Fonts").join("sub").join("Findme.otf"); - std::fs::create_dir_all(font.parent().unwrap()).unwrap(); - std::fs::write(&font, vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); - let mut app = app_for(&root); - let doc = root.join("pkg/doc.vectorcraft"); - save_doc(&mut app, &doc, "Findme Sans 3", false); - open(&mut app, &doc); - let next = app.ui.dialog.as_ref().filter(|d| d.kind == KIND).expect("Missing Fonts at once: no links missing").str("fontsNextToDocument"); - assert_eq!(next, root.join("pkg").join("Fonts").to_string_lossy()); - assert!(frame(&mut app).contains("Search Fonts Folder")); - app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(next)); - let r = super::super::confirm(&mut app).unwrap(); - assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); - assert_eq!(kind(&app), Some(KIND), "the dialog stays open while it searches"); - let until = Instant::now() + Duration::from_secs(10); - while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until { - frame(&mut app); - std::thread::sleep(Duration::from_millis(5)); - } - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("state"), &d.fields["found"][0]["files"]), ("done".to_string(), &json!([font]))); - assert_eq!(d.fields["chosen"], json!([font]), "the font's file is chosen"); - let text = frame(&mut app); - for label in ["Findme.otf", "Copy only fonts you own or are licensed to install.", "Add Fonts"] { - assert!(text.contains(label), "{label} in {text}"); - } - // Adding needs VectorCraft's Fonts folder, which only the apps set. - let e = super::super::confirm(&mut app).unwrap_err(); - assert!(e.contains("isn't set here"), "{e}"); - assert_eq!(kind(&app), Some(KIND)); - } - - /// Find Fonts opens Type › Find Font on the first missing font, to replace it, and the Missing - /// Fonts dialog doesn't come back for that open. - #[test] - fn find_fonts_opens_find_font_on_the_missing_font() { - let root = folder("find-fonts"); - let mut app = app_for(&root); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("text.create", json!({"x": 10, "y": 40, "text": "Here", "font": "Source Sans 3"})).unwrap(); - app.run("text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "Zz Missing Family"})).unwrap(); - let doc = root.join("doc.vectorcraft"); - app.run("document.save", json!({"path": doc.to_string_lossy()})).unwrap(); - open(&mut app, &doc); - assert_eq!(kind(&app), Some(KIND)); - app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); - super::super::confirm(&mut app).unwrap(); - let d = app.ui.dialog.as_ref().expect("Find Font"); - assert_eq!(d.kind, "findFont"); - let fonts = app.session.execute("text.fonts", &json!({})).unwrap(); - let selected = d.fields["selected"].as_u64().unwrap() as usize; - assert!(selected > 0 && fonts[selected]["family"] == "Zz Missing Family", "{selected} in {fonts}"); - // Find Font closed: no Missing Fonts dialog follows. - app.ui.dialog = None; - frame(&mut app); - assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); - } - - /// The dialog of a document in another tab: Find Fonts makes that document the active one, so - /// Find Font lists and replaces its fonts. - #[test] - fn find_fonts_works_on_the_dialog_s_own_document() { - let root = folder("find-fonts-tab"); - let mut app = app_for(&root); - let (a, b) = (root.join("a.vectorcraft"), root.join("b.vectorcraft")); - save_doc(&mut app, &a, "Alpha Missing Family", false); - save_doc(&mut app, &b, "Beta Missing Family", true); - open(&mut app, &a); - open(&mut app, &b); - // B's linked files are asked about first; then A's fonts, with B the active tab. - super::super::cancel(&mut app); - let d = app.ui.dialog.as_mut().unwrap(); - assert_eq!(d.fields["fonts"][0]["family"], "Alpha Missing Family"); - assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*b.to_string_lossy())); - app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); - super::super::confirm(&mut app).unwrap(); - assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*a.to_string_lossy())); - let fonts = app.session.execute("text.fonts", &json!({})).unwrap(); - let selected = app.ui.dialog.as_ref().unwrap().fields["selected"].as_u64().unwrap() as usize; - assert_eq!(fonts[selected]["family"], "Alpha Missing Family", "{fonts}"); - } - - #[test] - fn closing_the_dialog_stops_its_search() { - let root = folder("stop"); - // Enough folders that the search usually still runs when the dialog closes. - for a in 0..40 { - for b in 0..40 { - std::fs::create_dir_all(root.join(format!("f{a}/g{b}"))).unwrap(); - } - } - let mut app = app_for(&root); - app.session.search_threads = Some(1); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); - app.run("ui.missingFontsDialog", json!({"folder": root})).unwrap(); - let id = app.ui.dialog_search.expect("the dialog's search"); - super::super::cancel(&mut app); - assert!(app.ui.dialog.is_none() && app.ui.dialog_search.is_none()); - // Stopped by the close, unless the walker finished the folders first. - let r = app.session.execute("text.findFontFiles", &json!({})).unwrap(); - assert_eq!(r["id"].as_u64(), Some(id), "{r}"); - match r["state"].as_str() { - Some("stopped") => assert_eq!(r["stopped"], "stop", "{r}"), - state => assert_eq!(state, Some("done"), "{r}"), - } - // Without missing fonts there is nothing to open. - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - assert_eq!(app.run("ui.missingFontsDialog", json!({})).err().as_deref(), Some("No fonts are missing in this document")); - } - - #[test] - fn one_dialog_at_a_time_document_by_document() { - let root = folder("queue"); - let mut app = app_for(&root); - let (a, b) = (root.join("a.vectorcraft"), root.join("b.vectorcraft")); - save_doc(&mut app, &a, "Alpha Missing Family", false); - save_doc(&mut app, &b, "Beta Missing Family", true); - open(&mut app, &a); - let family = |app: &VectorcraftApp| app.ui.dialog.as_ref().and_then(|d| d.fields["fonts"][0]["family"].as_str().map(str::to_string)); - assert_eq!(family(&app).as_deref(), Some("Alpha Missing Family")); - // The second document's linked files are asked about first; then each document's fonts. - open(&mut app, &b); - assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); - super::super::cancel(&mut app); - assert_eq!(family(&app).as_deref(), Some("Alpha Missing Family")); - super::super::cancel(&mut app); - assert_eq!(family(&app).as_deref(), Some("Beta Missing Family")); - super::super::cancel(&mut app); - assert!(app.ui.dialog.is_none()); - // A document closed before its turn is passed over. - open(&mut app, &a); - open(&mut app, &b); - let index = app.session.documents().iter().rposition(|d| d.path.as_deref() == Some(&*a.to_string_lossy())).unwrap(); - app.session.execute("file.close", &json!({"index": index})).unwrap(); - super::super::cancel(&mut app); - assert_eq!(family(&app).as_deref(), Some("Beta Missing Family")); - } - - /// Another dialog that takes the Missing Fonts dialog's place (Import PDF for a PDF dropped - /// with the document) hands the question back once it closes. - #[test] - fn the_question_comes_back_after_a_dialog_that_took_its_place() { - let root = folder("replaced"); - let mut app = app_for(&root); - let doc = root.join("a.vectorcraft"); - save_doc(&mut app, &doc, "Alpha Missing Family", false); - let pdf = || { - let page = || vectorcraft_testkit::pdf::PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"); - vectorcraft_testkit::pdf::pdf(&[page(), page()], None) - }; - let opened = |name: &str, path: Option, bytes: Vec| (crate::place::DropTarget::Open, (name.to_string(), path, bytes)); - let files = - vec![opened("a.vectorcraft", Some(doc.to_string_lossy().into_owned()), std::fs::read(&doc).unwrap()), opened("b.pdf", None, pdf())]; - crate::place::drop_files(&mut app, files); - assert_eq!(kind(&app), Some(super::super::import_pdf::KIND)); - super::super::cancel(&mut app); - let family = |app: &VectorcraftApp| app.ui.dialog.as_ref().filter(|d| d.kind == KIND).map(|d| d.fields["fonts"][0]["family"].clone()); - assert_eq!(family(&app), Some(json!("Alpha Missing Family"))); - // The same when a frame drew the other dialog before it closed. - crate::io::open_bytes(&mut app, "c.pdf", &pdf(), None).unwrap(); - assert_eq!(kind(&app), Some(super::super::import_pdf::KIND)); - frame(&mut app); - super::super::cancel(&mut app); - assert_eq!(family(&app), Some(json!("Alpha Missing Family"))); - // Closed by Cancel, or by Escape (which empties the dialog slot), it stays closed. - super::super::cancel(&mut app); - frame(&mut app); - assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); - app.run("ui.missingFontsDialog", json!({})).unwrap(); - app.ui.dialog = None; - frame(&mut app); - assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); - } - - /// Type in a CSS generic family (`sans-serif`, the default font of SVG files from some editors) - /// opens without the question. - #[test] - fn generic_families_are_not_asked_about() { - let mut app = app_for(&folder("generic")); - let svg = r#"A"#; - crate::io::open_bytes(&mut app, "generic.svg", svg.as_bytes(), None).unwrap(); - let listed = app.session.execute("text.fonts", &json!({})).unwrap(); - assert!(listed.as_array().unwrap().iter().any(|f| f["family"] == "sans-serif"), "{listed}"); - frame(&mut app); - assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); - } - - /// A `searchFolder` whose search doesn't start is dropped from the dialog: OK doesn't try it - /// again. - #[test] - fn a_search_folder_that_fails_is_not_tried_again() { - let root = folder("retry"); - let mut app = app_for(&root); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); - app.run("ui.missingFontsDialog", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!("relative/folder")); - let e = super::super::confirm(&mut app).unwrap_err(); - assert!(e.contains("absolute"), "{e}"); - assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("searchFolder")); - assert_eq!(super::super::confirm(&mut app).err().as_deref(), Some("choose the font files to add")); - } - - /// A placed document that Links › Edit Original opens asks about its fonts, as File › Open does. - #[test] - fn edit_original_asks_about_the_placed_documents_fonts() { - let root = folder("edit-original"); - let mut app = app_for(&root); - let badge = root.join("badge.vectorcraft"); - save_doc(&mut app, &badge, "Badge Missing Family", false); - app.session.execute("file.close", &json!({})).unwrap(); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("file.place", json!({"path": badge.to_string_lossy(), "link": true})).unwrap(); - let tabs = app.session.documents().len(); - app.run("links.editOriginal", json!({})).unwrap(); - assert_eq!(app.session.documents().len(), tabs + 1); - let d = app.ui.dialog.as_ref().expect("Missing Fonts for the document opened"); - assert_eq!((d.kind.as_str(), d.fields["fonts"][0]["family"].as_str()), (KIND, Some("Badge Missing Family"))); - } - - /// A search leaves out the fonts that became available: the files it found go with their fonts - /// by name, and a file the user unchecked stays unchecked as the search goes on. - #[test] - fn files_go_with_their_fonts_by_name() { - let (a, b) = (json!({"family": "A Sans", "style": "Regular"}), json!({"family": "B Sans", "style": "Bold"})); - let mut d = Dialog::new(KIND, json!({"fonts": [a, b], "chosen": []})); - update(&mut d, &json!({"state": "searching", "fonts": [{"family": "B Sans", "style": "Bold", "files": ["/f/b.otf"]}]})); - let found = d.fields["found"].as_array().unwrap().clone(); - assert!(files_for(&found, &a).is_empty()); - assert_eq!(files_for(&found, &b), [json!("/f/b.otf")]); - assert_eq!(d.fields["chosen"], json!(["/f/b.otf"])); - d.fields.insert("chosen".into(), json!([])); - update(&mut d, &json!({"state": "done", "fonts": [{"family": "B Sans", "style": "Bold", "files": ["/f/b.otf", "/g/b.otf"]}]})); - assert_eq!((d.str("state"), &d.fields["chosen"]), ("done".to_string(), &json!([]))); - } - - #[test] - fn the_web_says_how_many_fonts_are_missing() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); - after_open_for(&mut app, &json!({}), true); - assert_eq!(app.ui.status, "1 font(s) aren't available here: their text shows in another font"); - assert!(app.ui.pending_fonts.is_empty()); - // The open's notes stay on show. - app.ui.status = "Opened with 1 note(s): fonts".into(); - after_open_for(&mut app, &json!({"warnings": ["fonts"]}), true); - assert_eq!(app.ui.status, "Opened with 1 note(s): fonts"); - } -} +mod tests; diff --git a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs new file mode 100644 index 000000000..d22bd372d --- /dev/null +++ b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs @@ -0,0 +1,339 @@ +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::findfiles::Rules; + +use super::*; + +fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) +} + +/// A fresh temporary folder (canonical, as a search reports it). +fn folder(tag: &str) -> PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("missing-fonts-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()) +} + +/// An app that reads files and whose searches may go anywhere in `root`, on two threads. +fn app_for(root: &Path) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.session.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() }); + app.session.search_threads = Some(2); + app +} + +fn png() -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(40, 30, image::Rgba([200, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out +} + +/// Save a new document with type in `font` at `path`, with a linked image that is then deleted +/// when `link`. +fn save_doc(app: &mut VectorcraftApp, path: &Path, font: &str, link: bool) { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let pic = path.with_extension("png"); + if link { + std::fs::write(&pic, png()).unwrap(); + app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap(); + } + app.run("text.create", json!({"x": 10, "y": 40, "text": "Type", "font": font})).unwrap(); + app.run("document.save", json!({"path": path.to_string_lossy()})).unwrap(); + if link { + std::fs::remove_file(&pic).unwrap(); + } +} + +fn open(app: &mut VectorcraftApp, path: &Path) { + crate::io::open_path(app, &path.to_string_lossy()).unwrap(); +} + +fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) +} + +#[test] +fn missing_fonts_are_asked_about_after_the_missing_links() { + let root = folder("after-links"); + let mut app = app_for(&root); + let doc = root.join("doc.vectorcraft"); + save_doc(&mut app, &doc, "No Such Font Family", true); + open(&mut app, &doc); + assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); + super::super::cancel(&mut app); + let d = app.ui.dialog.as_ref().expect("Missing Fonts after the linked files"); + assert_eq!((d.kind.as_str(), d.fields["fonts"][0]["family"].as_str()), (KIND, Some("No Such Font Family"))); + let text = frame(&mut app); + for label in + ["Missing Fonts", "No Such Font Family Regular", "These fonts aren't available. Their text shows in another font.", "Find Fonts", "Close"] + { + assert!(text.contains(label), "{label} in {text}"); + } + assert!(!text.contains("Search Fonts Folder"), "no Fonts folder next to the document"); + assert!(!text.contains("Add Fonts") && !text.contains("Copy only fonts"), "no file found yet: {text}"); + assert_eq!(super::super::confirm(&mut app).err().as_deref(), Some("choose the font files to add")); + assert_eq!(kind(&app), Some(KIND), "nothing to add: the dialog stays open"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); +} + +#[test] +fn the_documents_fonts_folder_is_offered_and_searched_off_the_ui_thread() { + let root = folder("package"); + let font = root.join("pkg").join("Fonts").join("sub").join("Findme.otf"); + std::fs::create_dir_all(font.parent().unwrap()).unwrap(); + std::fs::write(&font, vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); + let mut app = app_for(&root); + let doc = root.join("pkg/doc.vectorcraft"); + save_doc(&mut app, &doc, "Findme Sans 3", false); + open(&mut app, &doc); + let next = app.ui.dialog.as_ref().filter(|d| d.kind == KIND).expect("Missing Fonts at once: no links missing").str("fontsNextToDocument"); + assert_eq!(next, root.join("pkg").join("Fonts").to_string_lossy()); + assert!(frame(&mut app).contains("Search Fonts Folder")); + app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(next)); + let r = super::super::confirm(&mut app).unwrap(); + assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); + assert_eq!(kind(&app), Some(KIND), "the dialog stays open while it searches"); + let until = Instant::now() + Duration::from_secs(10); + while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until { + frame(&mut app); + std::thread::sleep(Duration::from_millis(5)); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("state"), &d.fields["found"][0]["files"]), ("done".to_string(), &json!([font]))); + assert_eq!(d.fields["chosen"], json!([font]), "the font's file is chosen"); + let text = frame(&mut app); + for label in ["Findme.otf", "Copy only fonts you own or are licensed to install.", "Add Fonts"] { + assert!(text.contains(label), "{label} in {text}"); + } + // Adding needs VectorCraft's Fonts folder, which only the apps set. + let e = super::super::confirm(&mut app).unwrap_err(); + assert!(e.contains("isn't set here"), "{e}"); + assert_eq!(kind(&app), Some(KIND)); +} + +/// Find Fonts opens Type › Find Font on the first missing font, to replace it, and the Missing +/// Fonts dialog doesn't come back for that open. +#[test] +fn find_fonts_opens_find_font_on_the_missing_font() { + let root = folder("find-fonts"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Here", "font": "Source Sans 3"})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "Zz Missing Family"})).unwrap(); + let doc = root.join("doc.vectorcraft"); + app.run("document.save", json!({"path": doc.to_string_lossy()})).unwrap(); + open(&mut app, &doc); + assert_eq!(kind(&app), Some(KIND)); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + let d = app.ui.dialog.as_ref().expect("Find Font"); + assert_eq!(d.kind, "findFont"); + let fonts = app.session.execute("text.fonts", &json!({})).unwrap(); + let selected = d.fields["selected"].as_u64().unwrap() as usize; + assert!(selected > 0 && fonts[selected]["family"] == "Zz Missing Family", "{selected} in {fonts}"); + // Find Font closed: no Missing Fonts dialog follows. + app.ui.dialog = None; + frame(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); +} + +/// The dialog of a document in another tab: Find Fonts makes that document the active one, so +/// Find Font lists and replaces its fonts. +#[test] +fn find_fonts_works_on_the_dialog_s_own_document() { + let root = folder("find-fonts-tab"); + let mut app = app_for(&root); + let (a, b) = (root.join("a.vectorcraft"), root.join("b.vectorcraft")); + save_doc(&mut app, &a, "Alpha Missing Family", false); + save_doc(&mut app, &b, "Beta Missing Family", true); + open(&mut app, &a); + open(&mut app, &b); + // B's linked files are asked about first; then A's fonts, with B the active tab. + super::super::cancel(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["fonts"][0]["family"], "Alpha Missing Family"); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*b.to_string_lossy())); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*a.to_string_lossy())); + let fonts = app.session.execute("text.fonts", &json!({})).unwrap(); + let selected = app.ui.dialog.as_ref().unwrap().fields["selected"].as_u64().unwrap() as usize; + assert_eq!(fonts[selected]["family"], "Alpha Missing Family", "{fonts}"); +} + +#[test] +fn closing_the_dialog_stops_its_search() { + let root = folder("stop"); + // Enough folders that the search usually still runs when the dialog closes. + for a in 0..40 { + for b in 0..40 { + std::fs::create_dir_all(root.join(format!("f{a}/g{b}"))).unwrap(); + } + } + let mut app = app_for(&root); + app.session.search_threads = Some(1); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + app.run("ui.missingFontsDialog", json!({"folder": root})).unwrap(); + let id = app.ui.dialog_search.expect("the dialog's search"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.dialog_search.is_none()); + // Stopped by the close, unless the walker finished the folders first. + let r = app.session.execute("text.findFontFiles", &json!({})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(id), "{r}"); + match r["state"].as_str() { + Some("stopped") => assert_eq!(r["stopped"], "stop", "{r}"), + state => assert_eq!(state, Some("done"), "{r}"), + } + // Without missing fonts there is nothing to open. + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + assert_eq!(app.run("ui.missingFontsDialog", json!({})).err().as_deref(), Some("No fonts are missing in this document")); +} + +#[test] +fn one_dialog_at_a_time_document_by_document() { + let root = folder("queue"); + let mut app = app_for(&root); + let (a, b) = (root.join("a.vectorcraft"), root.join("b.vectorcraft")); + save_doc(&mut app, &a, "Alpha Missing Family", false); + save_doc(&mut app, &b, "Beta Missing Family", true); + open(&mut app, &a); + let family = |app: &VectorcraftApp| app.ui.dialog.as_ref().and_then(|d| d.fields["fonts"][0]["family"].as_str().map(str::to_string)); + assert_eq!(family(&app).as_deref(), Some("Alpha Missing Family")); + // The second document's linked files are asked about first; then each document's fonts. + open(&mut app, &b); + assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); + super::super::cancel(&mut app); + assert_eq!(family(&app).as_deref(), Some("Alpha Missing Family")); + super::super::cancel(&mut app); + assert_eq!(family(&app).as_deref(), Some("Beta Missing Family")); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + // A document closed before its turn is passed over. + open(&mut app, &a); + open(&mut app, &b); + let index = app.session.documents().iter().rposition(|d| d.path.as_deref() == Some(&*a.to_string_lossy())).unwrap(); + app.session.execute("file.close", &json!({"index": index})).unwrap(); + super::super::cancel(&mut app); + assert_eq!(family(&app).as_deref(), Some("Beta Missing Family")); +} + +/// Another dialog that takes the Missing Fonts dialog's place (Import PDF for a PDF dropped +/// with the document) hands the question back once it closes. +#[test] +fn the_question_comes_back_after_a_dialog_that_took_its_place() { + let root = folder("replaced"); + let mut app = app_for(&root); + let doc = root.join("a.vectorcraft"); + save_doc(&mut app, &doc, "Alpha Missing Family", false); + let pdf = || { + let page = || vectorcraft_testkit::pdf::PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"); + vectorcraft_testkit::pdf::pdf(&[page(), page()], None) + }; + let opened = |name: &str, path: Option, bytes: Vec| (crate::place::DropTarget::Open, (name.to_string(), path, bytes)); + let files = + vec![opened("a.vectorcraft", Some(doc.to_string_lossy().into_owned()), std::fs::read(&doc).unwrap()), opened("b.pdf", None, pdf())]; + crate::place::drop_files(&mut app, files); + assert_eq!(kind(&app), Some(super::super::import_pdf::KIND)); + super::super::cancel(&mut app); + let family = |app: &VectorcraftApp| app.ui.dialog.as_ref().filter(|d| d.kind == KIND).map(|d| d.fields["fonts"][0]["family"].clone()); + assert_eq!(family(&app), Some(json!("Alpha Missing Family"))); + // The same when a frame drew the other dialog before it closed. + crate::io::open_bytes(&mut app, "c.pdf", &pdf(), None).unwrap(); + assert_eq!(kind(&app), Some(super::super::import_pdf::KIND)); + frame(&mut app); + super::super::cancel(&mut app); + assert_eq!(family(&app), Some(json!("Alpha Missing Family"))); + // Closed by Cancel, or by Escape (which empties the dialog slot), it stays closed. + super::super::cancel(&mut app); + frame(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); + app.run("ui.missingFontsDialog", json!({})).unwrap(); + app.ui.dialog = None; + frame(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); +} + +/// Type in a CSS generic family (`sans-serif`, the default font of SVG files from some editors) +/// opens without the question. +#[test] +fn generic_families_are_not_asked_about() { + let mut app = app_for(&folder("generic")); + let svg = r#"A"#; + crate::io::open_bytes(&mut app, "generic.svg", svg.as_bytes(), None).unwrap(); + let listed = app.session.execute("text.fonts", &json!({})).unwrap(); + assert!(listed.as_array().unwrap().iter().any(|f| f["family"] == "sans-serif"), "{listed}"); + frame(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); +} + +/// A `searchFolder` whose search doesn't start is dropped from the dialog: OK doesn't try it +/// again. +#[test] +fn a_search_folder_that_fails_is_not_tried_again() { + let root = folder("retry"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + app.run("ui.missingFontsDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!("relative/folder")); + let e = super::super::confirm(&mut app).unwrap_err(); + assert!(e.contains("absolute"), "{e}"); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("searchFolder")); + assert_eq!(super::super::confirm(&mut app).err().as_deref(), Some("choose the font files to add")); +} + +/// A placed document that Links › Edit Original opens asks about its fonts, as File › Open does. +#[test] +fn edit_original_asks_about_the_placed_documents_fonts() { + let root = folder("edit-original"); + let mut app = app_for(&root); + let badge = root.join("badge.vectorcraft"); + save_doc(&mut app, &badge, "Badge Missing Family", false); + app.session.execute("file.close", &json!({})).unwrap(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("file.place", json!({"path": badge.to_string_lossy(), "link": true})).unwrap(); + let tabs = app.session.documents().len(); + app.run("links.editOriginal", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), tabs + 1); + let d = app.ui.dialog.as_ref().expect("Missing Fonts for the document opened"); + assert_eq!((d.kind.as_str(), d.fields["fonts"][0]["family"].as_str()), (KIND, Some("Badge Missing Family"))); +} + +/// A search leaves out the fonts that became available: the files it found go with their fonts +/// by name, and a file the user unchecked stays unchecked as the search goes on. +#[test] +fn files_go_with_their_fonts_by_name() { + let (a, b) = (json!({"family": "A Sans", "style": "Regular"}), json!({"family": "B Sans", "style": "Bold"})); + let mut d = Dialog::new(KIND, json!({"fonts": [a, b], "chosen": []})); + update(&mut d, &json!({"state": "searching", "fonts": [{"family": "B Sans", "style": "Bold", "files": ["/f/b.otf"]}]})); + let found = d.fields["found"].as_array().unwrap().clone(); + assert!(files_for(&found, &a).is_empty()); + assert_eq!(files_for(&found, &b), [json!("/f/b.otf")]); + assert_eq!(d.fields["chosen"], json!(["/f/b.otf"])); + d.fields.insert("chosen".into(), json!([])); + update(&mut d, &json!({"state": "done", "fonts": [{"family": "B Sans", "style": "Bold", "files": ["/f/b.otf", "/g/b.otf"]}]})); + assert_eq!((d.str("state"), &d.fields["chosen"]), ("done".to_string(), &json!([]))); +} + +#[test] +fn the_web_says_how_many_fonts_are_missing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + after_open_for(&mut app, &json!({}), true); + assert_eq!(app.ui.status, "1 font(s) aren't available here: their text shows in another font"); + assert!(app.ui.pending_fonts.is_empty()); + // The open's notes stay on show. + app.ui.status = "Opened with 1 note(s): fonts".into(); + after_open_for(&mut app, &json!({"warnings": ["fonts"]}), true); + assert_eq!(app.ui.status, "Opened with 1 note(s): fonts"); +} From 2d9e537dccd4eeeaa447b639b2071fc668bb2f8b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:19 +0000 Subject: [PATCH 072/199] refactor(text): move thread.rs tests to thread/tests.rs --- crates/text/src/thread.rs | 341 +------------------------------- crates/text/src/thread/tests.rs | 338 +++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+), 340 deletions(-) create mode 100644 crates/text/src/thread/tests.rs diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 0ed8ecd93..27c6a2e70 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -177,343 +177,4 @@ pub fn distribute_continuing( } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_doc::{CharStyle, Justify, TextKind}; - use vectorcraft_geom::{Affine, PathData, Rect, shapes}; - - fn frame(x: f64) -> TextObject { - let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", CharStyle::default()); - t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(x, 0.0, x + 120.0, 60.0)) }; - t.xf = Affine::IDENTITY; - t - } - - /// Type on a straight path `length` long. - fn path_frame(length: f64) -> TextObject { - let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", CharStyle::default()); - let line = kurbo::Line::new((0.0, 0.0), (length, 0.0)); - t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Shape::to_path(&line, 0.1)), start: 0.0, end: None }; - t.xf = Affine::IDENTITY; - t - } - - /// Type on a path that overflows ends its line where its text stops fitting, as area type's - /// last line does, so a story flows on from there through area type and type on a path alike. - #[test] - fn story_flows_across_area_type_and_type_on_a_path() { - let db = FontDb::global(); - let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); - let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; - let mut path = path_frame(100.0); - path.runs = story.clone(); - let laid = layout(db, &path); - let end = laid.lines.first().map_or(0, |l| l.end); - assert!(laid.overflow && end > 0 && end < text.len(), "the path's line ends where its text stops: {end}"); - let (a, b, c) = (frame(0.0), path_frame(100.0), frame(400.0)); - let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); - let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); - assert!(lens.iter().all(|l| *l > 0), "each frame takes a share: {lens:?}"); - let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); - assert_eq!(joined, text, "no character lost or duplicated"); - let mut on_path = b.clone(); - on_path.runs = parts[1].clone(); - assert!(!layout(db, &on_path).overflow, "the path's share fits it"); - } - - /// A path that runs out of room at a space holds only what it shows: the space starts the - /// next frame, as a space with no room on a path overflows. - #[test] - fn a_path_that_runs_out_at_a_space_leaves_it_to_the_next_frame() { - let db = FontDb::global(); - let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); - let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; - let mut at_space = 0; - for length in (60..160).step_by(3) { - let (a, b) = (path_frame(f64::from(length)), frame(400.0)); - let parts: Vec> = distribute(db, &[&a, &b], &story, &[]).into_iter().map(|s| s.runs).collect(); - let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); - assert_eq!(joined, text, "no character lost or duplicated"); - let mut on_path = a.clone(); - on_path.runs = parts[0].clone(); - assert!(!layout(db, &on_path).overflow, "the path's share fits it ({length})"); - at_space += usize::from(parts[1].iter().map(|r| r.text.as_str()).collect::().starts_with(' ')); - } - assert!(at_space > 0, "some lengths end at a space"); - } - - /// A right-to-left story flows on from where its text stops fitting a path, in reading order. - #[test] - fn right_to_left_story_flows_on_from_a_path() { - let db = FontDb::global(); - let text = "שלום עולם ".repeat(8); - let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; - let (a, b) = (path_frame(100.0), frame(400.0)); - let parts: Vec> = distribute(db, &[&a, &b], &story, &[]).into_iter().map(|s| s.runs).collect(); - let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); - assert!(lens.iter().all(|l| *l > 0), "each frame takes a share: {lens:?}"); - let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); - assert_eq!(joined, text, "no character lost or duplicated"); - let mut on_path = a.clone(); - on_path.runs = parts[0].clone(); - assert!(!layout(db, &on_path).overflow, "the path's share fits it"); - } - - /// A path with no length holds no text, so the story flows past it. - #[test] - fn story_flows_past_a_path_with_no_length() { - let db = FontDb::global(); - let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); - let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; - let (a, b, c) = (frame(0.0), path_frame(0.0), frame(400.0)); - let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); - let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); - assert!(lens[0] > 0 && lens[1] == 0 && lens[2] > 0, "{lens:?}"); - let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); - assert_eq!(joined, text, "no character lost or duplicated"); - } - - #[test] - fn story_flows_across_frames_and_rejoins() { - let db = FontDb::global(); - let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0)); - let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here."; - let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; - let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); - let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); - assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}"); - let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); - assert_eq!(joined, text, "no character lost or duplicated"); - // Each non-last slice fits its frame. - for (f, p) in [&a, &b].iter().zip(&parts) { - let mut t = (*f).clone(); - t.runs = p.clone(); - assert!(!layout(db, &t).overflow); - } - // Lines never start with the break space. - assert!(!parts[1][0].text.starts_with(' ')); - } - - #[test] - fn paragraph_styles_flow_with_their_paragraphs() { - let db = FontDb::global(); - let (a, b) = (frame(0.0), frame(200.0)); - let text = "The quick brown fox jumps over the lazy dog. ".repeat(4) + "\nSecond paragraph.\nThird."; - let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; - let style = |j| ParaStyle { justify: j, ..Default::default() }; - use vectorcraft_doc::Justify::*; - let paras = vec![style(Left), style(Center), style(Right)]; - let parts = distribute(db, &[&a, &b], &story, ¶s); - assert!(!parts[0].runs.is_empty() && !parts[1].runs.is_empty()); - // The first paragraph overflows into the second frame: it is in both, with its style. - let pa: Vec = parts[0].paras.iter().map(|p| p.justify).collect(); - let pb: Vec = parts[1].paras.iter().map(|p| p.justify).collect(); - assert_eq!(pa, [Left]); - assert_eq!(pb, [Left, Center, Right]); - // Joined again, the shared paragraph counts once. - let mut fa = a.clone(); - fa.runs = parts[0].runs.clone(); - fa.set_paragraph_styles(parts[0].paras.clone()); - let mut fb = b.clone(); - fb.runs = parts[1].runs.clone(); - fb.set_paragraph_styles(parts[1].paras.clone()); - assert_eq!(story_paras(&[&fa, &fb], &[]), paras); - // A frame whose shared first paragraph was restyled wins. - fb.edit_paras(Some(0..1), |p| p.justify = JustifyAll); - assert_eq!(story_paras(&[&fa, &fb], &[false, true])[0].justify, JustifyAll); - assert_eq!(story_paras(&[&fa, &fb], &[false, false])[0].justify, Left); - } - - /// Numbered list paragraphs count on from one frame to the next: the second frame's first - /// whole paragraph takes the next number. A paragraph split between the frames has its number - /// in the first frame only, and the paragraph after it takes the next number. - #[test] - fn numbered_list_paragraphs_count_on_through_the_frames() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; - let db = FontDb::global(); - let (a, mut b) = (frame(0.0), frame(200.0)); - b.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(200.0, 0.0, 320.0, 400.0)) }; - let numbered = - ParaStyle { list: Some(ParaList::numbered(NumberStyle::Arabic)), left_indent: 18.0, first_line_indent: -18.0, ..Default::default() }; - let numbers = |f: &TextObject, s: &Slice| { - let mut t = f.clone(); - t.runs = s.runs.clone(); - t.set_paragraph_styles(s.paras.clone()); - t.list_count = s.list_count; - t.first_para_continues = s.continues; - layout(db, &t).markers.iter().map(|m| m.text.clone()).collect::>() - }; - // Whole paragraphs: the first frame ends with a paragraph break. - let text = (1..=8).map(|i| format!("Item {i}")).collect::>().join("\n"); - let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; - let parts = distribute(db, &[&a, &b], &story, &vec![numbered.clone(); 8]); - let first = numbers(&a, &parts[0]); - assert!((2..8).contains(&first.len()), "both frames hold items: {first:?}"); - assert_eq!(parts.iter().map(|s| s.list_count).collect::>(), [0, first.len() as u32]); - let second = numbers(&b, &parts[1]); - let expected: Vec = (first.len() + 1..=8).map(|n| format!("{n}.")).collect(); - assert_eq!(second, expected, "{first:?}"); - // A paragraph split between the frames. - let text = format!("One\nTwo\n{}\nFour", "The quick brown fox jumps over the lazy dog. ".repeat(4)); - let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; - let parts = distribute(db, &[&a, &b], &story, &vec![numbered; 4]); - assert_eq!(parts.iter().map(|s| s.continues).collect::>(), [false, true]); - assert_eq!((numbers(&a, &parts[0]), numbers(&b, &parts[1])), (vec!["1.".to_string(), "2.".into(), "3.".into()], vec!["4.".to_string()])); - } - - /// Frame `f` holding slice `p`, laid out. - fn laid(db: &FontDb, f: &TextObject, p: &Slice) -> crate::TextLayout { - let mut t = f.clone(); - t.runs = p.runs.clone(); - t.set_paragraph_styles(p.paras.clone()); - t.list_count = p.list_count; - t.first_para_continues = p.continues; - layout(db, &t) - } - - /// Where line `k` of `lay` starts: its first glyph that stands for a character. - fn line_start(lay: &crate::TextLayout, k: usize) -> f64 { - let l = &lay.lines[k]; - lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x) - } - - /// A paragraph's first-line indent and bullet belong to its first line: a paragraph that - /// continues from one frame into the next starts the next frame at its left indent, without its - /// bullet, and every line stays inside its frame. A paragraph that starts at the top of a frame - /// keeps both there. - #[test] - fn a_paragraph_continued_into_the_next_frame_has_no_first_line_indent_or_bullet_there() { - use vectorcraft_doc::text::{Bullet, ParaList}; - let db = FontDb::global(); - let (a, b, mut c) = (frame(0.0), frame(200.0), frame(400.0)); - c.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(400.0, 0.0, 520.0, 300.0)) }; - let bulleted = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; - let indented = ParaStyle { first_line_indent: 30.0, ..Default::default() }; - let long = "The quick brown fox jumps over the lazy dog. ".repeat(9) + "\nSecond paragraph."; - for para in [bulleted, indented] { - let (left, first) = (para.left_indent, para.left_indent + para.first_line_indent); - let list = usize::from(para.list.is_some()); - let story = vec![TextRun { text: long.clone(), style: CharStyle::default(), inline: None }]; - let parts = distribute(db, &[&a, &b, &c], &story, &[para.clone(), para.clone()]); - assert_eq!( - parts.iter().map(|p| p.continues).collect::>(), - [false, true, true], - "{:?}", - parts.iter().map(|p| runs_len(&p.runs)).collect::>() - ); - let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); - assert_eq!(joined, long, "no character lost or duplicated"); - // [`continued`] reads the same marks from the frames' text. - assert_eq!(continued(&filled(&[&a, &b, &c], &parts).iter().collect::>()), [false, true, true]); - let lays: Vec<_> = [&a, &b, &c].iter().zip(&parts).map(|(f, p)| laid(db, f, p)).collect(); - // The first frame: the first line at the first-line indent, after the bullet. - assert_eq!(lays[0].markers.len(), list, "{left}"); - assert!((line_start(&lays[0], 0) - (if list > 0 { left } else { first })).abs() < 1e-6, "{}", line_start(&lays[0], 0)); - if list > 0 { - assert!(lays[0].glyphs[lays[0].markers[0].glyphs.start].origin.x.abs() < 1e-6, "the bullet starts the first frame"); - } - // The frames it continues into: every line at the left indent, no bullet. - for (k, (x0, lay)) in [200.0, 400.0].into_iter().zip(&lays[1..]).enumerate() { - let on_first = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count(); - assert_eq!(on_first, 0, "frame {} starts without the bullet", k + 1); - assert!((line_start(lay, 0) - x0 - left).abs() < 1e-6, "frame {} starts at {}", k + 1, line_start(lay, 0) - x0); - assert!(!lay.overflow, "frame {} holds its share", k + 1); - for l in &lay.lines { - assert!(l.x1 <= x0 + 120.0 + 1e-6, "frame {}: a line ends at {}", k + 1, l.x1 - x0); - } - } - // The second paragraph, in the last frame, starts with its own first line. - let last = &lays[2]; - let shown: String = parts[2].runs.iter().map(|r| r.text.as_str()).collect(); - let second = last.lines.iter().position(|l| shown.get(l.start..).is_some_and(|s| s.starts_with("Second"))).unwrap(); - assert!((line_start(last, second) - 400.0 - if list > 0 { left } else { first }).abs() < 1e-6, "{}", line_start(last, second)); - assert_eq!(last.markers.len(), list, "the second paragraph's bullet"); - - // One-line paragraphs: the second frame starts with a paragraph of its own. - let lines = "Short line.\n".repeat(12); - let story = vec![TextRun { text: lines.clone(), style: CharStyle::default(), inline: None }]; - let parts = distribute(db, &[&a, &b], &story, std::slice::from_ref(¶)); - assert_eq!(parts.iter().map(|p| p.continues).collect::>(), [false, false]); - let lay = laid(db, &b, &parts[1]); - assert!((line_start(&lay, 0) - 200.0 - if list > 0 { left } else { first }).abs() < 1e-6, "{}", line_start(&lay, 0)); - if list > 0 { - assert_eq!(lay.glyphs[lay.markers[0].glyphs.start].line, 0, "the bullet starts the second frame"); - assert!((lay.glyphs[lay.markers[0].glyphs.start].origin.x - 200.0).abs() < 1e-6); - } - assert_eq!(continued(&filled(&[&a, &b], &parts).iter().collect::>()), [false, false]); - } - } - - /// A frame that a paragraph continues into holds what fits it laid out that way: its share does - /// not overflow it, and the share with the next word added does. - #[test] - fn a_continued_paragraph_fills_the_frame_it_continues_into() { - let db = FontDb::global(); - let (a, b, mut c) = (frame(0.0), frame(200.0), frame(400.0)); - c.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(400.0, 0.0, 520.0, 300.0)) }; - let hanging = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, ..Default::default() }; - let indented = ParaStyle { first_line_indent: 30.0, ..Default::default() }; - let mut checked = 0; - for para in [hanging, indented] { - for k in 0..8 { - let text = "a ".repeat(k) + &"The quick brown fox jumps over the lazy dog. ".repeat(9); - let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; - let parts = distribute(db, &[&a, &b, &c], &story, std::slice::from_ref(¶)); - assert!(parts[1].continues, "{k}"); - assert!(!laid(db, &b, &parts[1]).overflow, "the second frame holds its share ({k})"); - let next: String = parts[2].runs.iter().map(|r| r.text.as_str()).collect(); - let word = next.split_inclusive(' ').next().unwrap_or_default(); - let mut more = parts[1].clone(); - more.runs.push(TextRun { text: word.to_string(), style: CharStyle::default(), inline: None }); - normalize(&mut more.runs); - assert!(laid(db, &b, &more).overflow, "the second frame has no room for {word:?} ({k})"); - checked += 1; - } - } - assert_eq!(checked, 16); - } - - /// `frames` holding the runs of `parts`, for [`continued`]. - fn filled(frames: &[&TextObject], parts: &[Slice]) -> Vec { - frames - .iter() - .zip(parts) - .map(|(f, p)| { - let mut t = (*f).clone(); - t.runs = p.runs.clone(); - t - }) - .collect() - } - - /// A paragraph that continues onto type on a path starts the path without its bullet. - #[test] - fn a_list_paragraph_continued_onto_a_path_has_no_bullet_there() { - use vectorcraft_doc::text::{Bullet, ParaList}; - let db = FontDb::global(); - let para = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; - let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); - let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; - let (a, b) = (frame(0.0), path_frame(300.0)); - let parts = distribute(db, &[&a, &b], &story, std::slice::from_ref(¶)); - assert_eq!(parts.iter().map(|p| p.continues).collect::>(), [false, true]); - let (la, lb) = (laid(db, &a, &parts[0]), laid(db, &b, &parts[1])); - assert_eq!((la.markers.len(), lb.markers.len()), (1, 0)); - // The same paragraph starting on the path has its bullet there. - let mut alone = b.clone(); - alone.runs = story; - alone.set_paragraph_styles(vec![para]); - assert_eq!(layout(db, &alone).markers.len(), 1); - } - - #[test] - fn short_story_leaves_later_frames_empty_with_a_style() { - let db = FontDb::global(); - let (a, b) = (frame(0.0), frame(200.0)); - let st = CharStyle { size: 20.0, ..Default::default() }; - let parts: Vec> = - distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st, inline: None }], &[]).into_iter().map(|s| s.runs).collect(); - assert_eq!(parts[0][0].text, "Hi"); - assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0)); - } -} +mod tests; diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs new file mode 100644 index 000000000..cc07dcec1 --- /dev/null +++ b/crates/text/src/thread/tests.rs @@ -0,0 +1,338 @@ +use super::*; +use vectorcraft_doc::{CharStyle, Justify, TextKind}; +use vectorcraft_geom::{Affine, PathData, Rect, shapes}; + +fn frame(x: f64) -> TextObject { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", CharStyle::default()); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(x, 0.0, x + 120.0, 60.0)) }; + t.xf = Affine::IDENTITY; + t +} + +/// Type on a straight path `length` long. +fn path_frame(length: f64) -> TextObject { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", CharStyle::default()); + let line = kurbo::Line::new((0.0, 0.0), (length, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Shape::to_path(&line, 0.1)), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t +} + +/// Type on a path that overflows ends its line where its text stops fitting, as area type's +/// last line does, so a story flows on from there through area type and type on a path alike. +#[test] +fn story_flows_across_area_type_and_type_on_a_path() { + let db = FontDb::global(); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let mut path = path_frame(100.0); + path.runs = story.clone(); + let laid = layout(db, &path); + let end = laid.lines.first().map_or(0, |l| l.end); + assert!(laid.overflow && end > 0 && end < text.len(), "the path's line ends where its text stops: {end}"); + let (a, b, c) = (frame(0.0), path_frame(100.0), frame(400.0)); + let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); + let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); + assert!(lens.iter().all(|l| *l > 0), "each frame takes a share: {lens:?}"); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let mut on_path = b.clone(); + on_path.runs = parts[1].clone(); + assert!(!layout(db, &on_path).overflow, "the path's share fits it"); +} + +/// A path that runs out of room at a space holds only what it shows: the space starts the +/// next frame, as a space with no room on a path overflows. +#[test] +fn a_path_that_runs_out_at_a_space_leaves_it_to_the_next_frame() { + let db = FontDb::global(); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let mut at_space = 0; + for length in (60..160).step_by(3) { + let (a, b) = (path_frame(f64::from(length)), frame(400.0)); + let parts: Vec> = distribute(db, &[&a, &b], &story, &[]).into_iter().map(|s| s.runs).collect(); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let mut on_path = a.clone(); + on_path.runs = parts[0].clone(); + assert!(!layout(db, &on_path).overflow, "the path's share fits it ({length})"); + at_space += usize::from(parts[1].iter().map(|r| r.text.as_str()).collect::().starts_with(' ')); + } + assert!(at_space > 0, "some lengths end at a space"); +} + +/// A right-to-left story flows on from where its text stops fitting a path, in reading order. +#[test] +fn right_to_left_story_flows_on_from_a_path() { + let db = FontDb::global(); + let text = "שלום עולם ".repeat(8); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let (a, b) = (path_frame(100.0), frame(400.0)); + let parts: Vec> = distribute(db, &[&a, &b], &story, &[]).into_iter().map(|s| s.runs).collect(); + let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); + assert!(lens.iter().all(|l| *l > 0), "each frame takes a share: {lens:?}"); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let mut on_path = a.clone(); + on_path.runs = parts[0].clone(); + assert!(!layout(db, &on_path).overflow, "the path's share fits it"); +} + +/// A path with no length holds no text, so the story flows past it. +#[test] +fn story_flows_past_a_path_with_no_length() { + let db = FontDb::global(); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let (a, b, c) = (frame(0.0), path_frame(0.0), frame(400.0)); + let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); + let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); + assert!(lens[0] > 0 && lens[1] == 0 && lens[2] > 0, "{lens:?}"); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); +} + +#[test] +fn story_flows_across_frames_and_rejoins() { + let db = FontDb::global(); + let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0)); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here."; + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); + let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); + assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}"); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + // Each non-last slice fits its frame. + for (f, p) in [&a, &b].iter().zip(&parts) { + let mut t = (*f).clone(); + t.runs = p.clone(); + assert!(!layout(db, &t).overflow); + } + // Lines never start with the break space. + assert!(!parts[1][0].text.starts_with(' ')); +} + +#[test] +fn paragraph_styles_flow_with_their_paragraphs() { + let db = FontDb::global(); + let (a, b) = (frame(0.0), frame(200.0)); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(4) + "\nSecond paragraph.\nThird."; + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; + let style = |j| ParaStyle { justify: j, ..Default::default() }; + use vectorcraft_doc::Justify::*; + let paras = vec![style(Left), style(Center), style(Right)]; + let parts = distribute(db, &[&a, &b], &story, ¶s); + assert!(!parts[0].runs.is_empty() && !parts[1].runs.is_empty()); + // The first paragraph overflows into the second frame: it is in both, with its style. + let pa: Vec = parts[0].paras.iter().map(|p| p.justify).collect(); + let pb: Vec = parts[1].paras.iter().map(|p| p.justify).collect(); + assert_eq!(pa, [Left]); + assert_eq!(pb, [Left, Center, Right]); + // Joined again, the shared paragraph counts once. + let mut fa = a.clone(); + fa.runs = parts[0].runs.clone(); + fa.set_paragraph_styles(parts[0].paras.clone()); + let mut fb = b.clone(); + fb.runs = parts[1].runs.clone(); + fb.set_paragraph_styles(parts[1].paras.clone()); + assert_eq!(story_paras(&[&fa, &fb], &[]), paras); + // A frame whose shared first paragraph was restyled wins. + fb.edit_paras(Some(0..1), |p| p.justify = JustifyAll); + assert_eq!(story_paras(&[&fa, &fb], &[false, true])[0].justify, JustifyAll); + assert_eq!(story_paras(&[&fa, &fb], &[false, false])[0].justify, Left); +} + +/// Numbered list paragraphs count on from one frame to the next: the second frame's first +/// whole paragraph takes the next number. A paragraph split between the frames has its number +/// in the first frame only, and the paragraph after it takes the next number. +#[test] +fn numbered_list_paragraphs_count_on_through_the_frames() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let db = FontDb::global(); + let (a, mut b) = (frame(0.0), frame(200.0)); + b.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(200.0, 0.0, 320.0, 400.0)) }; + let numbered = + ParaStyle { list: Some(ParaList::numbered(NumberStyle::Arabic)), left_indent: 18.0, first_line_indent: -18.0, ..Default::default() }; + let numbers = |f: &TextObject, s: &Slice| { + let mut t = f.clone(); + t.runs = s.runs.clone(); + t.set_paragraph_styles(s.paras.clone()); + t.list_count = s.list_count; + t.first_para_continues = s.continues; + layout(db, &t).markers.iter().map(|m| m.text.clone()).collect::>() + }; + // Whole paragraphs: the first frame ends with a paragraph break. + let text = (1..=8).map(|i| format!("Item {i}")).collect::>().join("\n"); + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b], &story, &vec![numbered.clone(); 8]); + let first = numbers(&a, &parts[0]); + assert!((2..8).contains(&first.len()), "both frames hold items: {first:?}"); + assert_eq!(parts.iter().map(|s| s.list_count).collect::>(), [0, first.len() as u32]); + let second = numbers(&b, &parts[1]); + let expected: Vec = (first.len() + 1..=8).map(|n| format!("{n}.")).collect(); + assert_eq!(second, expected, "{first:?}"); + // A paragraph split between the frames. + let text = format!("One\nTwo\n{}\nFour", "The quick brown fox jumps over the lazy dog. ".repeat(4)); + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b], &story, &vec![numbered; 4]); + assert_eq!(parts.iter().map(|s| s.continues).collect::>(), [false, true]); + assert_eq!((numbers(&a, &parts[0]), numbers(&b, &parts[1])), (vec!["1.".to_string(), "2.".into(), "3.".into()], vec!["4.".to_string()])); +} + +/// Frame `f` holding slice `p`, laid out. +fn laid(db: &FontDb, f: &TextObject, p: &Slice) -> crate::TextLayout { + let mut t = f.clone(); + t.runs = p.runs.clone(); + t.set_paragraph_styles(p.paras.clone()); + t.list_count = p.list_count; + t.first_para_continues = p.continues; + layout(db, &t) +} + +/// Where line `k` of `lay` starts: its first glyph that stands for a character. +fn line_start(lay: &crate::TextLayout, k: usize) -> f64 { + let l = &lay.lines[k]; + lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x) +} + +/// A paragraph's first-line indent and bullet belong to its first line: a paragraph that +/// continues from one frame into the next starts the next frame at its left indent, without its +/// bullet, and every line stays inside its frame. A paragraph that starts at the top of a frame +/// keeps both there. +#[test] +fn a_paragraph_continued_into_the_next_frame_has_no_first_line_indent_or_bullet_there() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let db = FontDb::global(); + let (a, b, mut c) = (frame(0.0), frame(200.0), frame(400.0)); + c.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(400.0, 0.0, 520.0, 300.0)) }; + let bulleted = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; + let indented = ParaStyle { first_line_indent: 30.0, ..Default::default() }; + let long = "The quick brown fox jumps over the lazy dog. ".repeat(9) + "\nSecond paragraph."; + for para in [bulleted, indented] { + let (left, first) = (para.left_indent, para.left_indent + para.first_line_indent); + let list = usize::from(para.list.is_some()); + let story = vec![TextRun { text: long.clone(), style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b, &c], &story, &[para.clone(), para.clone()]); + assert_eq!( + parts.iter().map(|p| p.continues).collect::>(), + [false, true, true], + "{:?}", + parts.iter().map(|p| runs_len(&p.runs)).collect::>() + ); + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, long, "no character lost or duplicated"); + // [`continued`] reads the same marks from the frames' text. + assert_eq!(continued(&filled(&[&a, &b, &c], &parts).iter().collect::>()), [false, true, true]); + let lays: Vec<_> = [&a, &b, &c].iter().zip(&parts).map(|(f, p)| laid(db, f, p)).collect(); + // The first frame: the first line at the first-line indent, after the bullet. + assert_eq!(lays[0].markers.len(), list, "{left}"); + assert!((line_start(&lays[0], 0) - (if list > 0 { left } else { first })).abs() < 1e-6, "{}", line_start(&lays[0], 0)); + if list > 0 { + assert!(lays[0].glyphs[lays[0].markers[0].glyphs.start].origin.x.abs() < 1e-6, "the bullet starts the first frame"); + } + // The frames it continues into: every line at the left indent, no bullet. + for (k, (x0, lay)) in [200.0, 400.0].into_iter().zip(&lays[1..]).enumerate() { + let on_first = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count(); + assert_eq!(on_first, 0, "frame {} starts without the bullet", k + 1); + assert!((line_start(lay, 0) - x0 - left).abs() < 1e-6, "frame {} starts at {}", k + 1, line_start(lay, 0) - x0); + assert!(!lay.overflow, "frame {} holds its share", k + 1); + for l in &lay.lines { + assert!(l.x1 <= x0 + 120.0 + 1e-6, "frame {}: a line ends at {}", k + 1, l.x1 - x0); + } + } + // The second paragraph, in the last frame, starts with its own first line. + let last = &lays[2]; + let shown: String = parts[2].runs.iter().map(|r| r.text.as_str()).collect(); + let second = last.lines.iter().position(|l| shown.get(l.start..).is_some_and(|s| s.starts_with("Second"))).unwrap(); + assert!((line_start(last, second) - 400.0 - if list > 0 { left } else { first }).abs() < 1e-6, "{}", line_start(last, second)); + assert_eq!(last.markers.len(), list, "the second paragraph's bullet"); + + // One-line paragraphs: the second frame starts with a paragraph of its own. + let lines = "Short line.\n".repeat(12); + let story = vec![TextRun { text: lines.clone(), style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b], &story, std::slice::from_ref(¶)); + assert_eq!(parts.iter().map(|p| p.continues).collect::>(), [false, false]); + let lay = laid(db, &b, &parts[1]); + assert!((line_start(&lay, 0) - 200.0 - if list > 0 { left } else { first }).abs() < 1e-6, "{}", line_start(&lay, 0)); + if list > 0 { + assert_eq!(lay.glyphs[lay.markers[0].glyphs.start].line, 0, "the bullet starts the second frame"); + assert!((lay.glyphs[lay.markers[0].glyphs.start].origin.x - 200.0).abs() < 1e-6); + } + assert_eq!(continued(&filled(&[&a, &b], &parts).iter().collect::>()), [false, false]); + } +} + +/// A frame that a paragraph continues into holds what fits it laid out that way: its share does +/// not overflow it, and the share with the next word added does. +#[test] +fn a_continued_paragraph_fills_the_frame_it_continues_into() { + let db = FontDb::global(); + let (a, b, mut c) = (frame(0.0), frame(200.0), frame(400.0)); + c.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(400.0, 0.0, 520.0, 300.0)) }; + let hanging = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, ..Default::default() }; + let indented = ParaStyle { first_line_indent: 30.0, ..Default::default() }; + let mut checked = 0; + for para in [hanging, indented] { + for k in 0..8 { + let text = "a ".repeat(k) + &"The quick brown fox jumps over the lazy dog. ".repeat(9); + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b, &c], &story, std::slice::from_ref(¶)); + assert!(parts[1].continues, "{k}"); + assert!(!laid(db, &b, &parts[1]).overflow, "the second frame holds its share ({k})"); + let next: String = parts[2].runs.iter().map(|r| r.text.as_str()).collect(); + let word = next.split_inclusive(' ').next().unwrap_or_default(); + let mut more = parts[1].clone(); + more.runs.push(TextRun { text: word.to_string(), style: CharStyle::default(), inline: None }); + normalize(&mut more.runs); + assert!(laid(db, &b, &more).overflow, "the second frame has no room for {word:?} ({k})"); + checked += 1; + } + } + assert_eq!(checked, 16); +} + +/// `frames` holding the runs of `parts`, for [`continued`]. +fn filled(frames: &[&TextObject], parts: &[Slice]) -> Vec { + frames + .iter() + .zip(parts) + .map(|(f, p)| { + let mut t = (*f).clone(); + t.runs = p.runs.clone(); + t + }) + .collect() +} + +/// A paragraph that continues onto type on a path starts the path without its bullet. +#[test] +fn a_list_paragraph_continued_onto_a_path_has_no_bullet_there() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let db = FontDb::global(); + let para = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let (a, b) = (frame(0.0), path_frame(300.0)); + let parts = distribute(db, &[&a, &b], &story, std::slice::from_ref(¶)); + assert_eq!(parts.iter().map(|p| p.continues).collect::>(), [false, true]); + let (la, lb) = (laid(db, &a, &parts[0]), laid(db, &b, &parts[1])); + assert_eq!((la.markers.len(), lb.markers.len()), (1, 0)); + // The same paragraph starting on the path has its bullet there. + let mut alone = b.clone(); + alone.runs = story; + alone.set_paragraph_styles(vec![para]); + assert_eq!(layout(db, &alone).markers.len(), 1); +} + +#[test] +fn short_story_leaves_later_frames_empty_with_a_style() { + let db = FontDb::global(); + let (a, b) = (frame(0.0), frame(200.0)); + let st = CharStyle { size: 20.0, ..Default::default() }; + let parts: Vec> = + distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st, inline: None }], &[]).into_iter().map(|s| s.runs).collect(); + assert_eq!(parts[0][0].text, "Hi"); + assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0)); +} From 9c9a2cff2f57722d0974db270cc027ee48ef97d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:20 +0000 Subject: [PATCH 073/199] refactor(ui-egui): move dialogs/freehand.rs tests to dialogs/freehand/tests.rs --- crates/ui-egui/src/dialogs/freehand.rs | 340 +------------------ crates/ui-egui/src/dialogs/freehand/tests.rs | 337 ++++++++++++++++++ 2 files changed, 338 insertions(+), 339 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/freehand/tests.rs diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 7c6e8f3c0..c1dda9b32 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -224,342 +224,4 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_engine::Session; - - fn app() -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app - } - - /// The labels the open dialog paints. - fn painted(app: &mut VectorcraftApp) -> String { - crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) - } - - #[test] - fn the_fidelity_rail_is_logarithmic_and_round_trips_two_decimals() { - let rail = (0.01, FIDELITY_MAX); - assert_eq!((fidelity_at(0.01, rail), fidelity_at(20.0, rail)), (0.0, 1.0)); - assert_eq!((fidelity_from(0.0, rail), fidelity_from(1.0, rail)), (0.01, 20.0)); - // 0.01–1 pt gets more than half of the rail. - assert!(fidelity_at(1.0, rail) > 0.5, "{}", fidelity_at(1.0, rail)); - // The values a user picks come back as they were, and the rail only ever rises. - for v in [0.01, 0.02, 0.05, 0.1, 0.25, 0.5, 1.5, 6.5, 12.0, 20.0] { - assert_eq!(fidelity_from(fidelity_at(v, rail), rail), v, "{v}"); - } - let steps: Vec = (0..=200).map(|i| fidelity_from(i as f32 / 200.0, rail)).collect(); - assert!(steps.windows(2).all(|w| w[0] <= w[1]), "{steps:?}"); - // Past the ends and on bad input it stays on the rail. - assert_eq!((fidelity_at(0.0, rail), fidelity_at(50.0, rail), fidelity_at(f64::NAN, rail)), (0.0, 1.0, 0.0)); - assert_eq!((fidelity_from(-1.0, rail), fidelity_from(2.0, rail)), (0.01, 20.0)); - } - - #[test] - fn the_fidelity_keys_step_finely_at_the_low_end() { - let up: Vec = std::iter::successors(Some(0.01), |&v| Some(fidelity_step(v, 1.0))).take(21).collect(); - assert_eq!(up, [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 2.0, 3.0]); - // Down retraces the same steps. - let down: Vec = std::iter::successors(Some(3.0), |&v| Some(fidelity_step(v, -1.0))).take(21).collect(); - assert_eq!(down, up.iter().rev().copied().collect::>()); - // Ten steps at once (Shift) multiply or divide by ten. - assert_eq!((fidelity_step(0.01, 10.0), fidelity_step(0.1, 10.0), fidelity_step(1.0, 10.0)), (0.1, 1.0, 10.0)); - assert_eq!( - (fidelity_step(10.0, -10.0), fidelity_step(1.0, -10.0), fidelity_step(0.1, -10.0), fidelity_step(0.03, 10.0)), - (1.0, 0.1, 0.01, 0.3) - ); - // A value between steps goes to the next one in its direction. - assert_eq!( - (fidelity_step(0.035, 1.0), fidelity_step(0.035, -1.0), fidelity_step(1.5, 1.0), fidelity_step(1.5, -1.0)), - (0.04, 0.03, 2.0, 1.0) - ); - } - - #[test] - fn the_fidelity_field_takes_a_hundredth_for_the_pencil() { - let mut app = app(); - app.run("tool.options", json!({"tool": "pencil"})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(0.02)); - let text = painted(&mut app); - assert!(text.contains("0.02"), "{text}"); - super::super::confirm(&mut app).unwrap(); - assert_eq!(app.session.tool_options_of("pencil")["fidelity"].as_f64(), Some(0.02)); - } - - #[test] - fn each_tool_shows_its_options_and_ok_sets_them() { - let mut app = app(); - // Pencil: Fidelity, fill, the two tolerances; no Size. - app.run("tool.options", json!({"tool": "pencil"})).unwrap(); - let text = painted(&mut app); - for label in [ - "Pencil Tool Options", - "Fidelity:", - "Accurate", - "Smooth", - "Fill new strokes", - "Alt key toggles to Smooth Tool", - "Close paths when ends are within:", - "Edit selected paths within:", - "Live preview", - ] { - assert!(text.contains(label), "{label} in {text}"); - } - assert!(!text.contains("Size:"), "{text}"); - let d = app.ui.dialog.as_mut().unwrap(); - assert_eq!( - (d.f64("fidelity", 0.0), d.bool("fill"), d.bool("altSmooth"), d.f64("closeWithin", 0.0), d.f64("editWithin", 0.0)), - (1.5, false, false, 15.0, 12.0) - ); - for (k, v) in - [("fidelity", json!(6.5)), ("fill", json!(true)), ("altSmooth", json!(true)), ("closeWithin", json!(0)), ("editWithin", json!(20))] - { - d.fields.insert(k.into(), v); - } - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - let o = app.session.tool_options_of("pencil"); - assert_eq!( - (o["fidelity"].as_f64(), o["fill"].as_bool(), o["altSmooth"].as_bool(), o["closeWithin"].as_f64(), o["editWithin"].as_f64()), - (Some(6.5), Some(true), Some(true), Some(0.0), Some(20.0)) - ); - // The Paintbrush keeps its own: the Pencil's change left it alone. - app.run("tool.options", json!({"tool": "paintbrush"})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.f64("fidelity", 0.0), d.bool("fill")), (1.5, false)); - let pb_text = painted(&mut app); - assert!(pb_text.contains("Paintbrush Tool Options")); - assert!(!pb_text.contains("Alt key toggles to Smooth Tool") && !pb_text.contains("Live preview"), "{pb_text}"); - // Smooth has Fidelity only, the Eraser Size only, and the Blob Brush Size, Keep selected and Merge only with selection. - app.run("tool.options", json!({"tool": "smooth"})).unwrap(); - let text = painted(&mut app); - assert!(text.contains("Smooth Tool Options") && text.contains("Fidelity:"), "{text}"); - assert!( - !text.contains("Fill new strokes") - && !text.contains("Keep selected") - && !text.contains("Alt key toggles to Smooth Tool") - && !text.contains("Size:"), - "{text}" - ); - for tool in ["blobBrush", "eraser"] { - app.run("tool.options", json!({"tool": tool})).unwrap(); - let text = painted(&mut app); - assert!(text.contains("Size:") && !text.contains("Fidelity:"), "{tool}: {text}"); - // The Blob Brush also has Keep selected and Merge only with selection. - let blob = tool == "blobBrush"; - assert_eq!((text.contains("Keep selected"), text.contains("Merge only with selection")), (blob, blob), "{tool}: {text}"); - // The Eraser also has Angle and Roundness. - assert_eq!((text.contains("Angle:"), text.contains("Roundness:")), (!blob, !blob), "{tool}: {text}"); - app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(24)); - super::super::confirm(&mut app).unwrap(); - assert_eq!(app.session.tool_options_of(tool)["size"].as_f64(), Some(24.0), "{tool}"); - } - } - - /// Live curve fitting (the Pencil's, on by default): on, the path shown while a stroke is - /// drawn is fitted; off, it joins the stroke's points with straight segments, and the release - /// fits it as with the option on. - #[test] - fn live_curve_fitting_off_shows_the_points_until_the_release_fits_them() { - let mut app = app(); - // The anchors of the newest path while a wave around height `y0` is drawn and after the - // release, with nothing selected (a stroke starting at the end of a selected path would - // continue it). - let draw = |app: &mut VectorcraftApp, y0: f64| { - app.run("select.set", json!({"ids": []})).unwrap(); - app.select_tool("pencil"); - let view = app.view_info(); - let pts: Vec<_> = (0..=40).map(|i| (20.0 + i as f64 * 8.0, y0 + 30.0 * (i as f64 * 0.25).sin())).collect(); - let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y); - let newest = |app: &VectorcraftApp| { - let doc = &app.session.doc().unwrap().doc; - doc.layers[0].children().unwrap().last().unwrap().path_data().unwrap().subpaths[0].anchors.clone() - }; - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view); - for p in &pts[1..] { - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, *p), view); - } - let drawing = newest(app); - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, pts[40]), view); - (drawing, newest(app)) - }; - let corners = |anchors: &[vectorcraft_geom::Anchor]| anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p); - // On by default: the Pencil's dialog shows it and the Paintbrush's does not. - app.run("tool.options", json!({"tool": "pencil"})).unwrap(); - assert!(painted(&mut app).contains("Live curve fitting")); - assert!(app.ui.dialog.as_ref().unwrap().bool("liveFit")); - super::super::confirm(&mut app).unwrap(); - let (drawing, on) = draw(&mut app, 100.0); - assert!(!corners(&drawing) && drawing.len() < 20, "fitted while drawn: {} anchors", drawing.len()); - // Off: the points while the stroke is drawn, fitted at the release. - app.run("tool.options", json!({"tool": "pencil"})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("liveFit".into(), json!(false)); - super::super::confirm(&mut app).unwrap(); - app.select_tool("selection"); - assert_eq!(app.session.tool_options_of("pencil")["liveFit"].as_bool(), Some(false), "it lasts while another tool is chosen"); - let (drawing, off) = draw(&mut app, 200.0); - assert!(corners(&drawing) && drawing.len() > 30, "the points while drawn: {} anchors", drawing.len()); - assert_eq!(off.len(), on.len(), "the release fits the stroke as with the option on"); - assert!(off.iter().zip(&on).all(|(a, b)| (a.p.x - b.p.x).abs() < 1e-9 && (a.p.y - b.p.y - 100.0).abs() < 1e-9), "{off:?}"); - app.run("tool.options", json!({"tool": "paintbrush"})).unwrap(); - assert!(!painted(&mut app).contains("Live curve fitting")); - } - - #[test] - fn a_new_fidelity_changes_what_the_pencil_draws() { - let mut app = app(); - // The anchors of a wave drawn around height `y0`, with nothing selected (a stroke starting - // at the end of a selected path would continue it). - let anchors = |app: &mut VectorcraftApp, y0: f64| { - app.run("select.set", json!({"ids": []})).unwrap(); - app.select_tool("pencil"); - let view = app.view_info(); - let pts: Vec<_> = (0..=40).map(|i| (20.0 + i as f64 * 8.0, y0 + 30.0 * (i as f64 * 0.25).sin())).collect(); - let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y); - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view); - for p in &pts[1..] { - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, *p), view); - } - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, pts[40]), view); - let doc = &app.session.doc().unwrap().doc; - doc.layers[0].children().unwrap().last().unwrap().path_data().map_or(0, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum::()) - }; - let precise = anchors(&mut app, 100.0); - app.run("tool.options", json!({"tool": "pencil"})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(12)); - super::super::confirm(&mut app).unwrap(); - let smooth = anchors(&mut app, 200.0); - assert!(smooth < precise, "{smooth} anchors at fidelity 12, {precise} at the default"); - } - - #[test] - fn the_new_path_is_selected_only_with_keep_selected_on() { - let mut app = app(); - // A stroke of `tool` along height `y0`; the ids selected afterwards and the new path's id. - let stroke = |app: &mut VectorcraftApp, tool: &str, y0: f64| { - app.select_tool(tool); - let view = app.view_info(); - let pointer = |kind, x| vectorcraft_tools::PointerEvent::new(kind, x, y0); - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, 20.0), view); - for x in [60.0, 100.0, 140.0] { - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, x), view); - } - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, 180.0), view); - let st = app.session.doc().unwrap(); - (st.selection.objects.clone(), st.doc.layers[0].children().unwrap().last().unwrap().id) - }; - for tool in ["pencil", "paintbrush"] { - app.run("select.set", json!({"ids": []})).unwrap(); - // On by default: the new path is selected. - app.run("tool.options", json!({"tool": tool})).unwrap(); - assert!(painted(&mut app).contains("Keep selected"), "{tool}"); - let d = app.ui.dialog.as_mut().unwrap(); - assert!(d.bool("keepSelected"), "{tool}"); - super::super::confirm(&mut app).unwrap(); - let (selected, id) = stroke(&mut app, tool, 100.0); - assert_eq!(selected, vec![id], "{tool}"); - // Off: the next stroke leaves the selection as it was. - app.run("tool.options", json!({"tool": tool})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("keepSelected".into(), json!(false)); - super::super::confirm(&mut app).unwrap(); - // It lasts while another tool is chosen. - app.select_tool("selection"); - assert_eq!(app.session.tool_options_of(tool)["keepSelected"].as_bool(), Some(false), "{tool}"); - let (selected, new) = stroke(&mut app, tool, 200.0); - assert_ne!(new, id, "{tool} drew a new path"); - assert_eq!(selected, vec![id], "{tool}"); - } - } - - #[test] - fn the_blob_brush_options_set_what_a_stroke_merges_with_and_selects() { - let mut app = app(); - // A Blob Brush stroke across the canvas at height 100; the ids selected afterwards. - let stroke = |app: &mut VectorcraftApp| { - app.select_tool("blobBrush"); - let view = app.view_info(); - let pointer = |kind, x| vectorcraft_tools::PointerEvent::new(kind, x, 100.0); - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, 20.0), view); - for x in [80.0, 140.0, 200.0, 260.0] { - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, x), view); - } - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, 300.0), view); - app.session.doc().unwrap().selection.objects.clone() - }; - let ids = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(); - // Two blobs the stroke crosses, drawn with the same color. - let blob = |app: &mut VectorcraftApp, x: f64| { - let r = app.run("path.blob", json!({"points": [[x, 60], [x, 140]], "size": 20})).unwrap(); - vectorcraft_doc::NodeId(r["id"].as_u64().unwrap()) - }; - let (a, b) = (blob(&mut app, 100.0), blob(&mut app, 240.0)); - app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - // Both are off by default. - assert!(!d.bool("keepSelected") && !d.bool("mergeOnlySelection")); - d.fields.insert("keepSelected".into(), json!(true)); - d.fields.insert("mergeOnlySelection".into(), json!(true)); - super::super::confirm(&mut app).unwrap(); - let o = app.session.tool_options_of("blobBrush"); - assert_eq!((o["keepSelected"].as_bool(), o["mergeOnlySelection"].as_bool()), (Some(true), Some(true))); - // With `b` selected, the stroke merges into `b` alone, and leaves it selected. - app.run("select.set", json!({"ids": [b.0]})).unwrap(); - assert_eq!(stroke(&mut app), vec![b]); - assert_eq!(ids(&app), vec![a, b], "merged into b only"); - } - - /// Eraser Tool Options' Angle and Roundness shape the tip the next Eraser drag erases with, - /// and the tool keeps them while another tool is chosen. - #[test] - fn the_eraser_options_set_the_tips_angle_and_roundness() { - let mut app = app(); - // The width of the gap a vertical Eraser drag cuts through the middle of a 100 pt square. - let erase = |app: &mut VectorcraftApp| { - app.run("select.set", json!({"ids": []})).unwrap(); - let r = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); - let id = r["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.select_tool("eraser"); - let view = app.view_info(); - let pointer = |kind, y| vectorcraft_tools::PointerEvent::new(kind, 150.0, y); - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, 50.0), view); - for y in [100.0, 150.0, 200.0] { - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, y), view); - } - crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, 250.0), view); - let st = app.session.doc().unwrap(); - let bounds: Vec<_> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)?.path_data()?.control_bounds()).collect(); - let [a, b] = bounds[..] else { panic!("two halves: {bounds:?}") }; - let (l, r) = if a.x0 < b.x0 { (a, b) } else { (b, a) }; - r.x0 - l.x1 - }; - app.run("tool.options", json!({"tool": "eraser"})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - // A round tip by default. - assert_eq!((d.f64("angle", -1.0), d.f64("roundness", -1.0)), (0.0, 100.0)); - d.fields.insert("size".into(), json!(20)); - super::super::confirm(&mut app).unwrap(); - let round = erase(&mut app); - assert!((round - 20.0).abs() < 0.2, "a 20 pt round tip cuts {round} pt"); - // Upright and a quarter as wide: the band it erases is 5 pt wide. - app.run("tool.options", json!({"tool": "eraser"})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("angle".into(), json!(90)); - d.fields.insert("roundness".into(), json!(25)); - super::super::confirm(&mut app).unwrap(); - app.select_tool("selection"); - let o = app.session.tool_options_of("eraser"); - assert_eq!((o["angle"].as_f64(), o["roundness"].as_f64(), o["size"].as_f64()), (Some(90.0), Some(25.0), Some(20.0))); - let narrow = erase(&mut app); - assert!((narrow - 5.0).abs() < 0.2, "a 20 by 5 pt upright tip cuts {narrow} pt"); - } - - #[test] - fn the_other_tools_have_no_freehand_options() { - let mut app = app(); - assert!(open(&mut app, "pen").is_err()); - assert!(app.ui.dialog.is_none()); - } -} +mod tests; diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs new file mode 100644 index 000000000..1ef9ff49c --- /dev/null +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -0,0 +1,337 @@ +use super::*; +use vectorcraft_engine::Session; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app +} + +/// The labels the open dialog paints. +fn painted(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) +} + +#[test] +fn the_fidelity_rail_is_logarithmic_and_round_trips_two_decimals() { + let rail = (0.01, FIDELITY_MAX); + assert_eq!((fidelity_at(0.01, rail), fidelity_at(20.0, rail)), (0.0, 1.0)); + assert_eq!((fidelity_from(0.0, rail), fidelity_from(1.0, rail)), (0.01, 20.0)); + // 0.01–1 pt gets more than half of the rail. + assert!(fidelity_at(1.0, rail) > 0.5, "{}", fidelity_at(1.0, rail)); + // The values a user picks come back as they were, and the rail only ever rises. + for v in [0.01, 0.02, 0.05, 0.1, 0.25, 0.5, 1.5, 6.5, 12.0, 20.0] { + assert_eq!(fidelity_from(fidelity_at(v, rail), rail), v, "{v}"); + } + let steps: Vec = (0..=200).map(|i| fidelity_from(i as f32 / 200.0, rail)).collect(); + assert!(steps.windows(2).all(|w| w[0] <= w[1]), "{steps:?}"); + // Past the ends and on bad input it stays on the rail. + assert_eq!((fidelity_at(0.0, rail), fidelity_at(50.0, rail), fidelity_at(f64::NAN, rail)), (0.0, 1.0, 0.0)); + assert_eq!((fidelity_from(-1.0, rail), fidelity_from(2.0, rail)), (0.01, 20.0)); +} + +#[test] +fn the_fidelity_keys_step_finely_at_the_low_end() { + let up: Vec = std::iter::successors(Some(0.01), |&v| Some(fidelity_step(v, 1.0))).take(21).collect(); + assert_eq!(up, [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 2.0, 3.0]); + // Down retraces the same steps. + let down: Vec = std::iter::successors(Some(3.0), |&v| Some(fidelity_step(v, -1.0))).take(21).collect(); + assert_eq!(down, up.iter().rev().copied().collect::>()); + // Ten steps at once (Shift) multiply or divide by ten. + assert_eq!((fidelity_step(0.01, 10.0), fidelity_step(0.1, 10.0), fidelity_step(1.0, 10.0)), (0.1, 1.0, 10.0)); + assert_eq!( + (fidelity_step(10.0, -10.0), fidelity_step(1.0, -10.0), fidelity_step(0.1, -10.0), fidelity_step(0.03, 10.0)), + (1.0, 0.1, 0.01, 0.3) + ); + // A value between steps goes to the next one in its direction. + assert_eq!( + (fidelity_step(0.035, 1.0), fidelity_step(0.035, -1.0), fidelity_step(1.5, 1.0), fidelity_step(1.5, -1.0)), + (0.04, 0.03, 2.0, 1.0) + ); +} + +#[test] +fn the_fidelity_field_takes_a_hundredth_for_the_pencil() { + let mut app = app(); + app.run("tool.options", json!({"tool": "pencil"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(0.02)); + let text = painted(&mut app); + assert!(text.contains("0.02"), "{text}"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.tool_options_of("pencil")["fidelity"].as_f64(), Some(0.02)); +} + +#[test] +fn each_tool_shows_its_options_and_ok_sets_them() { + let mut app = app(); + // Pencil: Fidelity, fill, the two tolerances; no Size. + app.run("tool.options", json!({"tool": "pencil"})).unwrap(); + let text = painted(&mut app); + for label in [ + "Pencil Tool Options", + "Fidelity:", + "Accurate", + "Smooth", + "Fill new strokes", + "Alt key toggles to Smooth Tool", + "Close paths when ends are within:", + "Edit selected paths within:", + "Live preview", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + assert!(!text.contains("Size:"), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!( + (d.f64("fidelity", 0.0), d.bool("fill"), d.bool("altSmooth"), d.f64("closeWithin", 0.0), d.f64("editWithin", 0.0)), + (1.5, false, false, 15.0, 12.0) + ); + for (k, v) in + [("fidelity", json!(6.5)), ("fill", json!(true)), ("altSmooth", json!(true)), ("closeWithin", json!(0)), ("editWithin", json!(20))] + { + d.fields.insert(k.into(), v); + } + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let o = app.session.tool_options_of("pencil"); + assert_eq!( + (o["fidelity"].as_f64(), o["fill"].as_bool(), o["altSmooth"].as_bool(), o["closeWithin"].as_f64(), o["editWithin"].as_f64()), + (Some(6.5), Some(true), Some(true), Some(0.0), Some(20.0)) + ); + // The Paintbrush keeps its own: the Pencil's change left it alone. + app.run("tool.options", json!({"tool": "paintbrush"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("fidelity", 0.0), d.bool("fill")), (1.5, false)); + let pb_text = painted(&mut app); + assert!(pb_text.contains("Paintbrush Tool Options")); + assert!(!pb_text.contains("Alt key toggles to Smooth Tool") && !pb_text.contains("Live preview"), "{pb_text}"); + // Smooth has Fidelity only, the Eraser Size only, and the Blob Brush Size, Keep selected and Merge only with selection. + app.run("tool.options", json!({"tool": "smooth"})).unwrap(); + let text = painted(&mut app); + assert!(text.contains("Smooth Tool Options") && text.contains("Fidelity:"), "{text}"); + assert!( + !text.contains("Fill new strokes") + && !text.contains("Keep selected") + && !text.contains("Alt key toggles to Smooth Tool") + && !text.contains("Size:"), + "{text}" + ); + for tool in ["blobBrush", "eraser"] { + app.run("tool.options", json!({"tool": tool})).unwrap(); + let text = painted(&mut app); + assert!(text.contains("Size:") && !text.contains("Fidelity:"), "{tool}: {text}"); + // The Blob Brush also has Keep selected and Merge only with selection. + let blob = tool == "blobBrush"; + assert_eq!((text.contains("Keep selected"), text.contains("Merge only with selection")), (blob, blob), "{tool}: {text}"); + // The Eraser also has Angle and Roundness. + assert_eq!((text.contains("Angle:"), text.contains("Roundness:")), (!blob, !blob), "{tool}: {text}"); + app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(24)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.tool_options_of(tool)["size"].as_f64(), Some(24.0), "{tool}"); + } +} + +/// Live curve fitting (the Pencil's, on by default): on, the path shown while a stroke is +/// drawn is fitted; off, it joins the stroke's points with straight segments, and the release +/// fits it as with the option on. +#[test] +fn live_curve_fitting_off_shows_the_points_until_the_release_fits_them() { + let mut app = app(); + // The anchors of the newest path while a wave around height `y0` is drawn and after the + // release, with nothing selected (a stroke starting at the end of a selected path would + // continue it). + let draw = |app: &mut VectorcraftApp, y0: f64| { + app.run("select.set", json!({"ids": []})).unwrap(); + app.select_tool("pencil"); + let view = app.view_info(); + let pts: Vec<_> = (0..=40).map(|i| (20.0 + i as f64 * 8.0, y0 + 30.0 * (i as f64 * 0.25).sin())).collect(); + let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y); + let newest = |app: &VectorcraftApp| { + let doc = &app.session.doc().unwrap().doc; + doc.layers[0].children().unwrap().last().unwrap().path_data().unwrap().subpaths[0].anchors.clone() + }; + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view); + for p in &pts[1..] { + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, *p), view); + } + let drawing = newest(app); + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, pts[40]), view); + (drawing, newest(app)) + }; + let corners = |anchors: &[vectorcraft_geom::Anchor]| anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p); + // On by default: the Pencil's dialog shows it and the Paintbrush's does not. + app.run("tool.options", json!({"tool": "pencil"})).unwrap(); + assert!(painted(&mut app).contains("Live curve fitting")); + assert!(app.ui.dialog.as_ref().unwrap().bool("liveFit")); + super::super::confirm(&mut app).unwrap(); + let (drawing, on) = draw(&mut app, 100.0); + assert!(!corners(&drawing) && drawing.len() < 20, "fitted while drawn: {} anchors", drawing.len()); + // Off: the points while the stroke is drawn, fitted at the release. + app.run("tool.options", json!({"tool": "pencil"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("liveFit".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + app.select_tool("selection"); + assert_eq!(app.session.tool_options_of("pencil")["liveFit"].as_bool(), Some(false), "it lasts while another tool is chosen"); + let (drawing, off) = draw(&mut app, 200.0); + assert!(corners(&drawing) && drawing.len() > 30, "the points while drawn: {} anchors", drawing.len()); + assert_eq!(off.len(), on.len(), "the release fits the stroke as with the option on"); + assert!(off.iter().zip(&on).all(|(a, b)| (a.p.x - b.p.x).abs() < 1e-9 && (a.p.y - b.p.y - 100.0).abs() < 1e-9), "{off:?}"); + app.run("tool.options", json!({"tool": "paintbrush"})).unwrap(); + assert!(!painted(&mut app).contains("Live curve fitting")); +} + +#[test] +fn a_new_fidelity_changes_what_the_pencil_draws() { + let mut app = app(); + // The anchors of a wave drawn around height `y0`, with nothing selected (a stroke starting + // at the end of a selected path would continue it). + let anchors = |app: &mut VectorcraftApp, y0: f64| { + app.run("select.set", json!({"ids": []})).unwrap(); + app.select_tool("pencil"); + let view = app.view_info(); + let pts: Vec<_> = (0..=40).map(|i| (20.0 + i as f64 * 8.0, y0 + 30.0 * (i as f64 * 0.25).sin())).collect(); + let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y); + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view); + for p in &pts[1..] { + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, *p), view); + } + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, pts[40]), view); + let doc = &app.session.doc().unwrap().doc; + doc.layers[0].children().unwrap().last().unwrap().path_data().map_or(0, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum::()) + }; + let precise = anchors(&mut app, 100.0); + app.run("tool.options", json!({"tool": "pencil"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(12)); + super::super::confirm(&mut app).unwrap(); + let smooth = anchors(&mut app, 200.0); + assert!(smooth < precise, "{smooth} anchors at fidelity 12, {precise} at the default"); +} + +#[test] +fn the_new_path_is_selected_only_with_keep_selected_on() { + let mut app = app(); + // A stroke of `tool` along height `y0`; the ids selected afterwards and the new path's id. + let stroke = |app: &mut VectorcraftApp, tool: &str, y0: f64| { + app.select_tool(tool); + let view = app.view_info(); + let pointer = |kind, x| vectorcraft_tools::PointerEvent::new(kind, x, y0); + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, 20.0), view); + for x in [60.0, 100.0, 140.0] { + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, x), view); + } + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, 180.0), view); + let st = app.session.doc().unwrap(); + (st.selection.objects.clone(), st.doc.layers[0].children().unwrap().last().unwrap().id) + }; + for tool in ["pencil", "paintbrush"] { + app.run("select.set", json!({"ids": []})).unwrap(); + // On by default: the new path is selected. + app.run("tool.options", json!({"tool": tool})).unwrap(); + assert!(painted(&mut app).contains("Keep selected"), "{tool}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.bool("keepSelected"), "{tool}"); + super::super::confirm(&mut app).unwrap(); + let (selected, id) = stroke(&mut app, tool, 100.0); + assert_eq!(selected, vec![id], "{tool}"); + // Off: the next stroke leaves the selection as it was. + app.run("tool.options", json!({"tool": tool})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("keepSelected".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + // It lasts while another tool is chosen. + app.select_tool("selection"); + assert_eq!(app.session.tool_options_of(tool)["keepSelected"].as_bool(), Some(false), "{tool}"); + let (selected, new) = stroke(&mut app, tool, 200.0); + assert_ne!(new, id, "{tool} drew a new path"); + assert_eq!(selected, vec![id], "{tool}"); + } +} + +#[test] +fn the_blob_brush_options_set_what_a_stroke_merges_with_and_selects() { + let mut app = app(); + // A Blob Brush stroke across the canvas at height 100; the ids selected afterwards. + let stroke = |app: &mut VectorcraftApp| { + app.select_tool("blobBrush"); + let view = app.view_info(); + let pointer = |kind, x| vectorcraft_tools::PointerEvent::new(kind, x, 100.0); + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, 20.0), view); + for x in [80.0, 140.0, 200.0, 260.0] { + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, x), view); + } + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, 300.0), view); + app.session.doc().unwrap().selection.objects.clone() + }; + let ids = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(); + // Two blobs the stroke crosses, drawn with the same color. + let blob = |app: &mut VectorcraftApp, x: f64| { + let r = app.run("path.blob", json!({"points": [[x, 60], [x, 140]], "size": 20})).unwrap(); + vectorcraft_doc::NodeId(r["id"].as_u64().unwrap()) + }; + let (a, b) = (blob(&mut app, 100.0), blob(&mut app, 240.0)); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + // Both are off by default. + assert!(!d.bool("keepSelected") && !d.bool("mergeOnlySelection")); + d.fields.insert("keepSelected".into(), json!(true)); + d.fields.insert("mergeOnlySelection".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + let o = app.session.tool_options_of("blobBrush"); + assert_eq!((o["keepSelected"].as_bool(), o["mergeOnlySelection"].as_bool()), (Some(true), Some(true))); + // With `b` selected, the stroke merges into `b` alone, and leaves it selected. + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + assert_eq!(stroke(&mut app), vec![b]); + assert_eq!(ids(&app), vec![a, b], "merged into b only"); +} + +/// Eraser Tool Options' Angle and Roundness shape the tip the next Eraser drag erases with, +/// and the tool keeps them while another tool is chosen. +#[test] +fn the_eraser_options_set_the_tips_angle_and_roundness() { + let mut app = app(); + // The width of the gap a vertical Eraser drag cuts through the middle of a 100 pt square. + let erase = |app: &mut VectorcraftApp| { + app.run("select.set", json!({"ids": []})).unwrap(); + let r = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("eraser"); + let view = app.view_info(); + let pointer = |kind, y| vectorcraft_tools::PointerEvent::new(kind, 150.0, y); + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, 50.0), view); + for y in [100.0, 150.0, 200.0] { + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, y), view); + } + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, 250.0), view); + let st = app.session.doc().unwrap(); + let bounds: Vec<_> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)?.path_data()?.control_bounds()).collect(); + let [a, b] = bounds[..] else { panic!("two halves: {bounds:?}") }; + let (l, r) = if a.x0 < b.x0 { (a, b) } else { (b, a) }; + r.x0 - l.x1 + }; + app.run("tool.options", json!({"tool": "eraser"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + // A round tip by default. + assert_eq!((d.f64("angle", -1.0), d.f64("roundness", -1.0)), (0.0, 100.0)); + d.fields.insert("size".into(), json!(20)); + super::super::confirm(&mut app).unwrap(); + let round = erase(&mut app); + assert!((round - 20.0).abs() < 0.2, "a 20 pt round tip cuts {round} pt"); + // Upright and a quarter as wide: the band it erases is 5 pt wide. + app.run("tool.options", json!({"tool": "eraser"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("angle".into(), json!(90)); + d.fields.insert("roundness".into(), json!(25)); + super::super::confirm(&mut app).unwrap(); + app.select_tool("selection"); + let o = app.session.tool_options_of("eraser"); + assert_eq!((o["angle"].as_f64(), o["roundness"].as_f64(), o["size"].as_f64()), (Some(90.0), Some(25.0), Some(20.0))); + let narrow = erase(&mut app); + assert!((narrow - 5.0).abs() < 0.2, "a 20 by 5 pt upright tip cuts {narrow} pt"); +} + +#[test] +fn the_other_tools_have_no_freehand_options() { + let mut app = app(); + assert!(open(&mut app, "pen").is_err()); + assert!(app.ui.dialog.is_none()); +} From 0f5658d270e4fee05c39891b00baef9820cfe929 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:20 +0000 Subject: [PATCH 074/199] refactor(ui-egui): move control.rs tests to control/tests.rs --- crates/ui-egui/src/control.rs | 340 +--------------------------- crates/ui-egui/src/control/tests.rs | 337 +++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 339 deletions(-) create mode 100644 crates/ui-egui/src/control/tests.rs diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 06eba17c2..cd6efdf7c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -618,342 +618,4 @@ pub(crate) fn save_screenshot_with_limit( } #[cfg(test)] -mod tests { - use serde_json::{Value, json}; - use vectorcraft_engine::Session; - - use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle}; - use crate::state::Dialog; - use crate::{Services, VectorcraftApp}; - - fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value { - let (req, _) = ControlRequest::new(method, params); - let Outcome::Done(r) = handle(app, &egui::Context::default(), &req) else { panic!("expected immediate reply") }; - r - } - - #[test] - fn ui_set_turns_the_image_trace_preview_off_and_on() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - assert!(app.ui.image_trace_preview); - for on in [false, true] { - let r = input_request(&mut app, "ui.set", json!({"imageTracePreview": on})); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(app.ui.image_trace_preview, on); - } - } - - #[test] - fn the_image_trace_preview_is_a_command() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "imageTrace.preview")); - assert_eq!(app.run("imageTrace.preview", json!({})), Ok(json!(false)), "toggles"); - assert!(!app.ui.image_trace_preview); - assert_eq!(app.run("imageTrace.preview", json!({"on": false})), Ok(json!(false)), "stays off"); - assert_eq!(app.run("imageTrace.preview", json!({"on": true})), Ok(json!(true))); - assert!(app.ui.image_trace_preview); - assert!(app.run("imageTrace.preview", json!({"on": "yes"})).is_err()); - assert!(app.ui.image_trace_preview, "a bad value leaves it"); - let r = input_request(&mut app, "engine.execute", json!({"command": "imageTrace.preview", "params": {"on": false}})); - assert_eq!(r["ok"], true, "{r}"); - assert!(!app.ui.image_trace_preview, "the control channel reaches it"); - } - - #[test] - fn control_input_rejects_excessive_counts_before_mutation() { - // Only modest over-limit values on the baseline: never exercise an unbounded loop. - for (method, params) in - [("ui.click", json!({"count": 17})), ("ui.drag", json!({"steps": 257})), ("ui.drag", json!({"steps": 2, "count": 17}))] - { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.synthetic.push(egui::Event::Text("already queued".into())); - let before = format!("{:?}", app.synthetic); - let r = input_request(&mut app, method, params); - assert_eq!(r["ok"], false, "oversized input must be rejected: {r}"); - assert_eq!(format!("{:?}", app.synthetic), before, "rejection must preserve the entire queue"); - } - } - - #[test] - fn control_input_respects_existing_queue_budget() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1023]; - let before = format!("{:?}", app.synthetic); - let r = input_request(&mut app, "ui.click", json!({})); - assert_eq!(r["ok"], false, "a whole click must fit before any part is appended"); - assert_eq!(format!("{:?}", app.synthetic), before); - } - - #[test] - fn control_input_yields_control_channel_after_frame_budget() { - let (tx, rx) = std::sync::mpsc::channel(); - let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); - let mut replies = vec![]; - for _ in 0..65 { - let (req, reply) = ControlRequest::new("ui.move", json!({"x": 1, "y": 2})); - tx.send(req).unwrap(); - replies.push(reply); - } - app.drain_control(&egui::Context::default()); - assert_eq!(replies.iter().filter(|r| r.try_recv().is_ok()).count(), 64); - assert_eq!(app.synthetic.len(), 64); - app.drain_control(&egui::Context::default()); - assert_eq!(replies.last().unwrap().try_recv().unwrap()["ok"], true); - assert_eq!(app.synthetic.len(), 65); - } - - #[test] - fn control_input_rejects_invalid_and_extreme_counts() { - for value in [json!(u64::MAX), json!(-1), json!(1.5), json!("2"), Value::Null] { - for (method, key) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.synthetic.push(egui::Event::Text("pending".into())); - let before = format!("{:?}", app.synthetic); - let mut params = json!({}); - params[key] = value.clone(); - let r = input_request(&mut app, method, params); - assert_eq!(r["ok"], false, "{method}/{key}: {r}"); - assert!(r["error"].as_str().unwrap().contains(key)); - assert_eq!(format!("{:?}", app.synthetic), before); - } - } - } - - #[test] - fn control_input_accepts_boundary_gestures_and_preserves_event_order() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - let r = input_request( - &mut app, - "ui.drag", - json!({"x": 2, "y": 3, "toX": 20, "toY": 30, - "steps": 256, "count": 16, "button": "right", "shift": true}), - ); - assert_eq!(r["ok"], true); - assert_eq!(app.synthetic.len(), 289); - assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(2.0, 3.0))); - assert!( - matches!(app.synthetic.get(1), Some(egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Secondary, modifiers, .. }) if modifiers.shift) - ); - assert!(matches!(app.synthetic.get(257), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(20.0, 30.0))); - assert!(matches!(app.synthetic.get(258), Some(egui::Event::PointerButton { pressed: false, .. }))); - assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, pos, .. }) if *pos == egui::pos2(20.0, 30.0))); - for (method, params, events) in [ - ("ui.click", json!({}), 3), - ("ui.click", json!({"count": 2}), 5), - ("ui.drag", json!({}), 11), - ("ui.drag", json!({"steps": 0, "count": 0}), 4), - ] { - app.synthetic.clear(); - assert_eq!(input_request(&mut app, method, params)["ok"], true); - assert_eq!(app.synthetic.len(), events); - } - app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(0.0, 0.0)); 1021]; - assert_eq!(input_request(&mut app, "ui.click", json!({}))["ok"], true); - assert_eq!(app.synthetic.len(), 1024, "the exact queue boundary is permitted"); - } - - #[test] - fn control_input_budget_covers_every_synthetic_method() { - for (method, params) in [ - ("ui.key", json!({"key": "Enter", "text": "x"})), - ("ui.move", json!({})), - ("ui.wheel", json!({"dy": 1})), - ("ui.text", json!({"text": "hello"})), - ("ui.click", json!({})), - ("ui.drag", json!({"steps": 1})), - ] { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1024]; - let before = format!("{:?}", app.synthetic); - let r = input_request(&mut app, method, params); - assert_eq!(r["ok"], false, "{method}: {r}"); - assert_eq!(format!("{:?}", app.synthetic), before); - app.synthetic.clear(); - assert_eq!(input_request(&mut app, method, json!({"key": "Enter", "steps": 1}))["ok"], true, "queue space becomes usable after draining"); - } - } - - /// `ui.dialog.set {fields}` stores the whole list in order, or nothing when an item is refused. - #[test] - fn dialog_set_stores_a_field_list_whole_or_not_at_all() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt", "dy": "0 pt"}))); - let fields = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| serde_json::to_value(&d.fields).unwrap()); - let before = fields(&app); - for params in [ - json!({"fields": [{"field": "copy", "value": true}, {"value": 1}]}), - json!({"fields": [{"field": "copy", "value": true}, 7]}), - json!({"fields": {"field": "copy"}}), - json!({"field": "dx", "fields": [{"field": "copy", "value": true}]}), - json!({"fields": [{"field": "copy", "value": true}, {"field": "note"}]}), - ] { - let r = input_request(&mut app, "ui.dialog.set", params.clone()); - assert_eq!(r["ok"], false, "{params}: {r}"); - assert_eq!(fields(&app), before, "{params}: a refused list stores no field"); - } - let list = json!([{"field": "copy", "value": true}, {"field": "dx", "value": "3 pt"}, {"field": "dx", "value": "5 pt"}, {"field": "dy"}]); - let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list})); - assert_eq!(r["ok"], true, "{r}"); - let after = fields(&app).unwrap(); - assert_eq!(after, json!({"copy": true, "dx": "5 pt", "dy": null})); - assert_eq!(r["result"]["fields"], after); - let r = input_request(&mut app, "ui.dialog.set", json!({"field": "dy", "value": "2 pt"})); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(fields(&app).unwrap()["dy"], "2 pt"); - app.ui.dialog = None; - assert_eq!(input_request(&mut app, "ui.dialog.set", json!({"fields": []}))["error"], "no dialog open"); - } - - /// `ui.dialog.set {fields}` stores at most `MAX_DIALOG_FIELDS` items; a longer list stores none. - #[test] - fn dialog_set_refuses_a_field_list_over_the_limit() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt"}))); - let list = |n: usize| Value::Array((0..n).map(|i| json!({"field": "dx", "value": format!("{i} pt")})).collect()); - let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list(MAX_DIALOG_FIELDS + 1)})); - assert_eq!(r["ok"], false, "{r}"); - assert_eq!(r["error"], format!("`fields` holds {} items; at most {MAX_DIALOG_FIELDS}", MAX_DIALOG_FIELDS + 1)); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!("0 pt")), "a refused list stores no field"); - let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list(MAX_DIALOG_FIELDS)})); - assert_eq!(r["ok"], true, "{r}"); - let last = format!("{} pt", MAX_DIALOG_FIELDS - 1); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!(last)), "the whole list is stored, in order"); - } - - /// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked - /// for, at the scale asked for; the first without one. - #[test] - fn render_takes_the_artboard_asked_for() { - let (tx, rx) = std::sync::mpsc::channel(); - let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); - let mut request = |method: &str, params: Value| { - let (req, reply) = ControlRequest::new(method, params); - tx.send(req).unwrap(); - app.drain_control(&egui::Context::default()); - reply.try_recv().unwrap() - }; - let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2}); - request("engine.execute", json!({"command": "file.new", "params": new})); - let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48}); - let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board})); - assert_eq!(r["ok"], true, "{r}"); - for (params, size) in [ - (json!({}), [64, 64]), - (json!({"artboard": 0}), [64, 64]), - (json!({"artboard": 1}), [96, 48]), - (json!({"artboard": 1, "scale": 0.5}), [48, 24]), - ] { - let r = request("ui.render", params.clone()); - let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64); - assert_eq!(got, size.map(Some), "{params}: {r}"); - } - assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); - } - - /// `ui.pointer` replies with the key object: a click on one of two selected objects makes it - /// the key, and a click on the key again lets it go. - #[test] - fn pointer_replies_name_the_key_object() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); - let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - app.run("tool.select", json!({"tool": "selection"})).unwrap(); - let ctx = egui::Context::default(); - let click = |app: &mut VectorcraftApp| { - let events = json!([{"kind": "down", "x": 110, "y": 20}, {"kind": "up", "x": 110, "y": 20}]); - let (req, _) = ControlRequest::new("ui.pointer", json!({"events": events})); - let Outcome::Done(r) = handle(app, &ctx, &req) else { panic!("not done") }; - assert_eq!(r["ok"], true, "{r}"); - r["result"].get("key").cloned() - }; - assert_eq!(click(&mut app), Some(b), "a click on a selected object makes it the key"); - assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go"); - } - - /// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields, - /// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard` - /// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`. - #[test] - fn dialog_set_refuses_a_field_the_dialog_does_not_have() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - let fields = json!({"index": 0, "uid": 1, "name": "Untitled-1", "then": "close"}); - app.ui.dialog = Some(Dialog::new(crate::unsaved::KIND, fields.clone())); - let r = input_request(&mut app, "ui.dialog.set", json!({"field": "discrad", "value": true})); - assert_eq!(r["ok"], false, "{r}"); - assert_eq!(r["error"], "the `saveChanges` dialog has no field `discrad`; its fields: discard, index, name, then, uid"); - assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields)); - for field in ["name", "discard", "__note"] { - let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); - assert_eq!(r["ok"], true, "{field}: {r}"); - } - // Move opens without Copy, which its checkbox adds once clicked; Scale's options and the - // reference point belong to the other transform dialogs. - let set = |app: &mut VectorcraftApp, kind: &str, fields: Value, cases: &[(&str, bool)]| { - app.ui.dialog = Some(Dialog::new(kind, fields)); - for (field, ok) in cases { - let r = input_request(app, "ui.dialog.set", json!({"field": field, "value": true})); - assert_eq!(r["ok"], *ok, "{kind} {field}: {r}"); - } - }; - let moved = - [("copy", true), ("preview", true), ("distance", true), ("discard", false), ("copies", false), ("corners", false), ("origin", false)]; - set(&mut app, "move", json!({"dx": "0 pt", "dy": "0 pt"}), &moved); - set( - &mut app, - "scale", - json!({"sx": 100, "sy": 100, "uniform": true}), - &[("corners", true), ("strokes", true), ("origin", true), ("distance", false)], - ); - set(&mut app, "rotate", json!({"angle": 0}), &[("origin", true), ("objects", true), ("corners", false)]); - set(&mut app, "rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}), &[("radius", true), ("sides", false)]); - set(&mut app, "ellipse", json!({"x": 0, "y": 0, "width": 10, "height": 10}), &[("radius", false)]); - // Corners that differ in kind and radius open without either. - let mixed = json!({"id": 1, "corners": [0, 1, 2, 3], "relativeRadii": false}); - set(&mut app, vectorcraft_tools::corners::DIALOG, mixed, &[("kind", true), ("radius", true), ("radii", false)]); - set(&mut app, "saveForOffice", json!({"ppi": 150, "transparent": false, "artboard": 0}), &[("path", true)]); - set(&mut app, "brushOptions", json!({"name": "Dots"}), &[("tips", true), ("tip", false)]); - set(&mut app, "justification", json!({"wordDesired": 100}), &[("wordMin", true), ("singleWord", true), ("wordMid", false)]); - set(&mut app, "hyphenation", json!({"hyphenate": true}), &[("afterFirst", true), ("hyphenationZone", true), ("afterfirst", false)]); - set(&mut app, "effectGallery", json!({"stack": []}), &[("browseEffect", true), ("browseParams", true), ("browse", false)]); - // A new artboard's dialog opens without a background color or display marks. - let new_board = json!({"new": true, "name": "Artboard 2", "x": 0, "y": 0, "width": 10, "height": 10}); - let marks = [ - ("color", true), - ("showCenterMark", true), - ("showCrossHairs", true), - ("showVideoSafeAreas", true), - ("videoRulerPixelAspectRatio", true), - ("showCenterMarks", false), - ]; - set(&mut app, "artboardOptions", new_board, &marks); - // Glass and Texturizer take the texture Load Texture… reads; other effects don't. - set(&mut app, "effect", json!({"__effect": "distort.glass"}), &[("textureImage", true)]); - set(&mut app, "effect", json!({"__effect": "blur.gaussian"}), &[("textureImage", false)]); - // The generic dialog takes every parameter of its command. - set(&mut app, "command", json!({"__command": "shape.rectangle", "x": 0, "y": 0}), &[("width", true), ("widht", false)]); - // Transparency Flattener Presets holds the selected preset's options before its first frame. - crate::dialogs::flattener_presets::open(&mut app, None); - let r = input_request(&mut app, "ui.dialog.set", json!({"field": "textToOutlines", "value": true})); - assert_eq!(r["ok"], true, "{r}"); - // Edit Selection takes `deleted` for each of its rows only. - app.run("file.new", json!({})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - for name in ["A", "B"] { - app.run("select.save", json!({ "name": name })).unwrap(); - } - crate::dialogs::edit_selection::open(&mut app).unwrap(); - for (field, ok) in [("deleted1", true), ("deleted", false), ("deletedx", false), ("deleted01", false), ("deleted2", false)] { - let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); - assert_eq!(r["ok"], ok, "{field}: {r}"); - } - let r = input_request(&mut app, "ui.dialog.set", json!({"field": "name", "value": "A"})); - assert_eq!( - r["error"], - "the `editSelection` dialog has no field `name`; its fields: count, deleted0, deleted1, name0, name1, orig0, orig1, selected" - ); - app.ui.dialog = None; - assert_eq!(input_request(&mut app, "ui.dialog.set", json!({"field": "name", "value": "A"}))["error"], "no dialog open"); - } -} +mod tests; diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs new file mode 100644 index 000000000..61c9193ad --- /dev/null +++ b/crates/ui-egui/src/control/tests.rs @@ -0,0 +1,337 @@ +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle}; +use crate::state::Dialog; +use crate::{Services, VectorcraftApp}; + +fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value { + let (req, _) = ControlRequest::new(method, params); + let Outcome::Done(r) = handle(app, &egui::Context::default(), &req) else { panic!("expected immediate reply") }; + r +} + +#[test] +fn ui_set_turns_the_image_trace_preview_off_and_on() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + assert!(app.ui.image_trace_preview); + for on in [false, true] { + let r = input_request(&mut app, "ui.set", json!({"imageTracePreview": on})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.ui.image_trace_preview, on); + } +} + +#[test] +fn the_image_trace_preview_is_a_command() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "imageTrace.preview")); + assert_eq!(app.run("imageTrace.preview", json!({})), Ok(json!(false)), "toggles"); + assert!(!app.ui.image_trace_preview); + assert_eq!(app.run("imageTrace.preview", json!({"on": false})), Ok(json!(false)), "stays off"); + assert_eq!(app.run("imageTrace.preview", json!({"on": true})), Ok(json!(true))); + assert!(app.ui.image_trace_preview); + assert!(app.run("imageTrace.preview", json!({"on": "yes"})).is_err()); + assert!(app.ui.image_trace_preview, "a bad value leaves it"); + let r = input_request(&mut app, "engine.execute", json!({"command": "imageTrace.preview", "params": {"on": false}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!app.ui.image_trace_preview, "the control channel reaches it"); +} + +#[test] +fn control_input_rejects_excessive_counts_before_mutation() { + // Only modest over-limit values on the baseline: never exercise an unbounded loop. + for (method, params) in + [("ui.click", json!({"count": 17})), ("ui.drag", json!({"steps": 257})), ("ui.drag", json!({"steps": 2, "count": 17}))] + { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.synthetic.push(egui::Event::Text("already queued".into())); + let before = format!("{:?}", app.synthetic); + let r = input_request(&mut app, method, params); + assert_eq!(r["ok"], false, "oversized input must be rejected: {r}"); + assert_eq!(format!("{:?}", app.synthetic), before, "rejection must preserve the entire queue"); + } +} + +#[test] +fn control_input_respects_existing_queue_budget() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1023]; + let before = format!("{:?}", app.synthetic); + let r = input_request(&mut app, "ui.click", json!({})); + assert_eq!(r["ok"], false, "a whole click must fit before any part is appended"); + assert_eq!(format!("{:?}", app.synthetic), before); +} + +#[test] +fn control_input_yields_control_channel_after_frame_budget() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); + let mut replies = vec![]; + for _ in 0..65 { + let (req, reply) = ControlRequest::new("ui.move", json!({"x": 1, "y": 2})); + tx.send(req).unwrap(); + replies.push(reply); + } + app.drain_control(&egui::Context::default()); + assert_eq!(replies.iter().filter(|r| r.try_recv().is_ok()).count(), 64); + assert_eq!(app.synthetic.len(), 64); + app.drain_control(&egui::Context::default()); + assert_eq!(replies.last().unwrap().try_recv().unwrap()["ok"], true); + assert_eq!(app.synthetic.len(), 65); +} + +#[test] +fn control_input_rejects_invalid_and_extreme_counts() { + for value in [json!(u64::MAX), json!(-1), json!(1.5), json!("2"), Value::Null] { + for (method, key) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.synthetic.push(egui::Event::Text("pending".into())); + let before = format!("{:?}", app.synthetic); + let mut params = json!({}); + params[key] = value.clone(); + let r = input_request(&mut app, method, params); + assert_eq!(r["ok"], false, "{method}/{key}: {r}"); + assert!(r["error"].as_str().unwrap().contains(key)); + assert_eq!(format!("{:?}", app.synthetic), before); + } + } +} + +#[test] +fn control_input_accepts_boundary_gestures_and_preserves_event_order() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let r = input_request( + &mut app, + "ui.drag", + json!({"x": 2, "y": 3, "toX": 20, "toY": 30, + "steps": 256, "count": 16, "button": "right", "shift": true}), + ); + assert_eq!(r["ok"], true); + assert_eq!(app.synthetic.len(), 289); + assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(2.0, 3.0))); + assert!( + matches!(app.synthetic.get(1), Some(egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Secondary, modifiers, .. }) if modifiers.shift) + ); + assert!(matches!(app.synthetic.get(257), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(20.0, 30.0))); + assert!(matches!(app.synthetic.get(258), Some(egui::Event::PointerButton { pressed: false, .. }))); + assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, pos, .. }) if *pos == egui::pos2(20.0, 30.0))); + for (method, params, events) in [ + ("ui.click", json!({}), 3), + ("ui.click", json!({"count": 2}), 5), + ("ui.drag", json!({}), 11), + ("ui.drag", json!({"steps": 0, "count": 0}), 4), + ] { + app.synthetic.clear(); + assert_eq!(input_request(&mut app, method, params)["ok"], true); + assert_eq!(app.synthetic.len(), events); + } + app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(0.0, 0.0)); 1021]; + assert_eq!(input_request(&mut app, "ui.click", json!({}))["ok"], true); + assert_eq!(app.synthetic.len(), 1024, "the exact queue boundary is permitted"); +} + +#[test] +fn control_input_budget_covers_every_synthetic_method() { + for (method, params) in [ + ("ui.key", json!({"key": "Enter", "text": "x"})), + ("ui.move", json!({})), + ("ui.wheel", json!({"dy": 1})), + ("ui.text", json!({"text": "hello"})), + ("ui.click", json!({})), + ("ui.drag", json!({"steps": 1})), + ] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1024]; + let before = format!("{:?}", app.synthetic); + let r = input_request(&mut app, method, params); + assert_eq!(r["ok"], false, "{method}: {r}"); + assert_eq!(format!("{:?}", app.synthetic), before); + app.synthetic.clear(); + assert_eq!(input_request(&mut app, method, json!({"key": "Enter", "steps": 1}))["ok"], true, "queue space becomes usable after draining"); + } +} + +/// `ui.dialog.set {fields}` stores the whole list in order, or nothing when an item is refused. +#[test] +fn dialog_set_stores_a_field_list_whole_or_not_at_all() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt", "dy": "0 pt"}))); + let fields = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| serde_json::to_value(&d.fields).unwrap()); + let before = fields(&app); + for params in [ + json!({"fields": [{"field": "copy", "value": true}, {"value": 1}]}), + json!({"fields": [{"field": "copy", "value": true}, 7]}), + json!({"fields": {"field": "copy"}}), + json!({"field": "dx", "fields": [{"field": "copy", "value": true}]}), + json!({"fields": [{"field": "copy", "value": true}, {"field": "note"}]}), + ] { + let r = input_request(&mut app, "ui.dialog.set", params.clone()); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert_eq!(fields(&app), before, "{params}: a refused list stores no field"); + } + let list = json!([{"field": "copy", "value": true}, {"field": "dx", "value": "3 pt"}, {"field": "dx", "value": "5 pt"}, {"field": "dy"}]); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list})); + assert_eq!(r["ok"], true, "{r}"); + let after = fields(&app).unwrap(); + assert_eq!(after, json!({"copy": true, "dx": "5 pt", "dy": null})); + assert_eq!(r["result"]["fields"], after); + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "dy", "value": "2 pt"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(fields(&app).unwrap()["dy"], "2 pt"); + app.ui.dialog = None; + assert_eq!(input_request(&mut app, "ui.dialog.set", json!({"fields": []}))["error"], "no dialog open"); +} + +/// `ui.dialog.set {fields}` stores at most `MAX_DIALOG_FIELDS` items; a longer list stores none. +#[test] +fn dialog_set_refuses_a_field_list_over_the_limit() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt"}))); + let list = |n: usize| Value::Array((0..n).map(|i| json!({"field": "dx", "value": format!("{i} pt")})).collect()); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list(MAX_DIALOG_FIELDS + 1)})); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(r["error"], format!("`fields` holds {} items; at most {MAX_DIALOG_FIELDS}", MAX_DIALOG_FIELDS + 1)); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!("0 pt")), "a refused list stores no field"); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list(MAX_DIALOG_FIELDS)})); + assert_eq!(r["ok"], true, "{r}"); + let last = format!("{} pt", MAX_DIALOG_FIELDS - 1); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!(last)), "the whole list is stored, in order"); +} + +/// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked +/// for, at the scale asked for; the first without one. +#[test] +fn render_takes_the_artboard_asked_for() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); + let mut request = |method: &str, params: Value| { + let (req, reply) = ControlRequest::new(method, params); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + reply.try_recv().unwrap() + }; + let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2}); + request("engine.execute", json!({"command": "file.new", "params": new})); + let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48}); + let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board})); + assert_eq!(r["ok"], true, "{r}"); + for (params, size) in [ + (json!({}), [64, 64]), + (json!({"artboard": 0}), [64, 64]), + (json!({"artboard": 1}), [96, 48]), + (json!({"artboard": 1, "scale": 0.5}), [48, 24]), + ] { + let r = request("ui.render", params.clone()); + let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64); + assert_eq!(got, size.map(Some), "{params}: {r}"); + } + assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); +} + +/// `ui.pointer` replies with the key object: a click on one of two selected objects makes it +/// the key, and a click on the key again lets it go. +#[test] +fn pointer_replies_name_the_key_object() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let ctx = egui::Context::default(); + let click = |app: &mut VectorcraftApp| { + let events = json!([{"kind": "down", "x": 110, "y": 20}, {"kind": "up", "x": 110, "y": 20}]); + let (req, _) = ControlRequest::new("ui.pointer", json!({"events": events})); + let Outcome::Done(r) = handle(app, &ctx, &req) else { panic!("not done") }; + assert_eq!(r["ok"], true, "{r}"); + r["result"].get("key").cloned() + }; + assert_eq!(click(&mut app), Some(b), "a click on a selected object makes it the key"); + assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go"); +} + +/// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields, +/// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard` +/// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`. +#[test] +fn dialog_set_refuses_a_field_the_dialog_does_not_have() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let fields = json!({"index": 0, "uid": 1, "name": "Untitled-1", "then": "close"}); + app.ui.dialog = Some(Dialog::new(crate::unsaved::KIND, fields.clone())); + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "discrad", "value": true})); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(r["error"], "the `saveChanges` dialog has no field `discrad`; its fields: discard, index, name, then, uid"); + assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields)); + for field in ["name", "discard", "__note"] { + let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["ok"], true, "{field}: {r}"); + } + // Move opens without Copy, which its checkbox adds once clicked; Scale's options and the + // reference point belong to the other transform dialogs. + let set = |app: &mut VectorcraftApp, kind: &str, fields: Value, cases: &[(&str, bool)]| { + app.ui.dialog = Some(Dialog::new(kind, fields)); + for (field, ok) in cases { + let r = input_request(app, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["ok"], *ok, "{kind} {field}: {r}"); + } + }; + let moved = + [("copy", true), ("preview", true), ("distance", true), ("discard", false), ("copies", false), ("corners", false), ("origin", false)]; + set(&mut app, "move", json!({"dx": "0 pt", "dy": "0 pt"}), &moved); + set( + &mut app, + "scale", + json!({"sx": 100, "sy": 100, "uniform": true}), + &[("corners", true), ("strokes", true), ("origin", true), ("distance", false)], + ); + set(&mut app, "rotate", json!({"angle": 0}), &[("origin", true), ("objects", true), ("corners", false)]); + set(&mut app, "rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}), &[("radius", true), ("sides", false)]); + set(&mut app, "ellipse", json!({"x": 0, "y": 0, "width": 10, "height": 10}), &[("radius", false)]); + // Corners that differ in kind and radius open without either. + let mixed = json!({"id": 1, "corners": [0, 1, 2, 3], "relativeRadii": false}); + set(&mut app, vectorcraft_tools::corners::DIALOG, mixed, &[("kind", true), ("radius", true), ("radii", false)]); + set(&mut app, "saveForOffice", json!({"ppi": 150, "transparent": false, "artboard": 0}), &[("path", true)]); + set(&mut app, "brushOptions", json!({"name": "Dots"}), &[("tips", true), ("tip", false)]); + set(&mut app, "justification", json!({"wordDesired": 100}), &[("wordMin", true), ("singleWord", true), ("wordMid", false)]); + set(&mut app, "hyphenation", json!({"hyphenate": true}), &[("afterFirst", true), ("hyphenationZone", true), ("afterfirst", false)]); + set(&mut app, "effectGallery", json!({"stack": []}), &[("browseEffect", true), ("browseParams", true), ("browse", false)]); + // A new artboard's dialog opens without a background color or display marks. + let new_board = json!({"new": true, "name": "Artboard 2", "x": 0, "y": 0, "width": 10, "height": 10}); + let marks = [ + ("color", true), + ("showCenterMark", true), + ("showCrossHairs", true), + ("showVideoSafeAreas", true), + ("videoRulerPixelAspectRatio", true), + ("showCenterMarks", false), + ]; + set(&mut app, "artboardOptions", new_board, &marks); + // Glass and Texturizer take the texture Load Texture… reads; other effects don't. + set(&mut app, "effect", json!({"__effect": "distort.glass"}), &[("textureImage", true)]); + set(&mut app, "effect", json!({"__effect": "blur.gaussian"}), &[("textureImage", false)]); + // The generic dialog takes every parameter of its command. + set(&mut app, "command", json!({"__command": "shape.rectangle", "x": 0, "y": 0}), &[("width", true), ("widht", false)]); + // Transparency Flattener Presets holds the selected preset's options before its first frame. + crate::dialogs::flattener_presets::open(&mut app, None); + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "textToOutlines", "value": true})); + assert_eq!(r["ok"], true, "{r}"); + // Edit Selection takes `deleted` for each of its rows only. + app.run("file.new", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + for name in ["A", "B"] { + app.run("select.save", json!({ "name": name })).unwrap(); + } + crate::dialogs::edit_selection::open(&mut app).unwrap(); + for (field, ok) in [("deleted1", true), ("deleted", false), ("deletedx", false), ("deleted01", false), ("deleted2", false)] { + let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["ok"], ok, "{field}: {r}"); + } + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "name", "value": "A"})); + assert_eq!( + r["error"], + "the `editSelection` dialog has no field `name`; its fields: count, deleted0, deleted1, name0, name1, orig0, orig1, selected" + ); + app.ui.dialog = None; + assert_eq!(input_request(&mut app, "ui.dialog.set", json!({"field": "name", "value": "A"}))["error"], "no dialog open"); +} From d133961eb3c87857a1cffbab364da45a535294e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:21 +0000 Subject: [PATCH 075/199] refactor(doc): move text.rs tests to text/tests.rs --- crates/doc/src/text.rs | 338 +---------------------------------- crates/doc/src/text/tests.rs | 335 ++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 337 deletions(-) create mode 100644 crates/doc/src/text/tests.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 067eae7b4..f95c9ee28 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -2201,340 +2201,4 @@ impl crate::Document { } #[cfg(test)] -mod tests { - use super::*; - - /// Character Direction is saved only when set, and styles written before it existed read as - /// Default. - #[test] - fn character_direction_is_saved_only_when_set() { - let st = CharStyle::default(); - assert_eq!(st.char_direction, None); - assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); - for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { - let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; - assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); - assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); - } - let old = serde_json::to_value(&st).unwrap(); - assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); - } - - /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. - #[test] - fn ruby_is_saved_and_missing_ruby_reads_as_none() { - let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default()); - t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }]; - let json = serde_json::to_string(&t).unwrap(); - assert!(json.contains("かんじ")); - let back: TextObject = serde_json::from_str(&json).unwrap(); - assert_eq!(back.ruby, t.ruby); - assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang); - // Old files have no `ruby` key: none. - let mut old: serde_json::Value = serde_json::from_str(&json).unwrap(); - old.as_object_mut().unwrap().remove("ruby"); - assert!(serde_json::from_value::(old).unwrap().ruby.is_empty()); - // A damaged file's range past the text and its empty reading are clamped away. - let mut damaged = t.clone(); - damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")]; - damaged.normalize_ruby(); - assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6)); - } - - /// Digits map every digit of the three sets into the chosen one, leave the rest alone, and - /// are saved only when set. - #[test] - fn digits_map_between_the_numeral_systems() { - let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() }; - for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] { - assert_eq!(map(Digits::Default, s), s); - assert_eq!(map(Digits::Arabic, s), "0123456789"); - assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩"); - assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹"); - } - // Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets. - assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔"); - let st = CharStyle::default(); - assert!(serde_json::to_value(&st).unwrap().get("digits").is_none()); - let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() }; - assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi"); - // Styles written before the attribute existed (no `digits` key) read as Default. - let old = serde_json::to_value(&st).unwrap(); - assert_eq!(serde_json::from_value::(old).unwrap().digits, Digits::Default); - assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); - } - - /// Auto leading follows the paragraph's Auto Leading; a set leading doesn't. - #[test] - fn paragraph_effective_leading_follows_auto_leading() { - let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() }; - let mut para = ParaStyle::default(); - assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%"); - para.justification.auto_leading = 200.0; - assert_eq!(para.effective_leading(&st), 20.0); - para.justification.auto_leading = 0.0; - assert_eq!(para.effective_leading(&st), 0.0); - let set = CharStyle { leading: Some(14.0), ..st.clone() }; - assert_eq!(para.effective_leading(&set), 14.0); - para.justification.auto_leading = f64::NAN; - assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number"); - } - - /// A file can't feed layout justification values `text.setFormat` would refuse: each is - /// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and - /// Maximum out of order go back to their defaults. - #[test] - fn justification_options_read_from_a_file_stay_in_range() { - let d = JustificationOptions::default(); - let read: ParaStyle = serde_json::from_value(serde_json::json!({ - "justification": { - "word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0], - "glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12 - } - })) - .unwrap(); - let j = read.justification; - assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]); - assert_eq!(j.letter_spacing, d.letter_spacing, "out of order"); - assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]); - assert_eq!(j.auto_leading, 0.0); - let mut j = JustificationOptions { - word_spacing: [f64::NAN, 100.0, f64::INFINITY], - letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY], - auto_leading: f64::NAN, - ..d.clone() - }; - j.keep_in_range(); - assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading)); - let mut same = d.clone(); - same.keep_in_range(); - assert_eq!(same, d); - } - - /// Numbered lists: each number style writes its numbers and rolls over (letters after Z, - /// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds - /// a period, and the count runs through list paragraphs, skips empty and bulleted ones and - /// starts again after a paragraph in no list. A list from before number styles reads as - /// bulleted, and a bulleted list saves no number style. - #[test] - fn number_styles_write_their_numbers_and_roll_over() { - let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::>(); - assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]); - assert_eq!( - numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]), - ["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"] - ); - assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]); - assert_eq!( - numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]), - ["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"] - ); - assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]); - assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]); - assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]); - assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); - assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); - let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); - let mut count = 0; - let markers: Vec> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] - .iter() - .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) - .collect(); - assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); - assert_eq!(count, 1); - let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); - assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); - assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); - assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); - assert_eq!( - serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(), - serde_json::json!({"bullet": "dot", "number": "lowerRoman"}) - ); - } - - /// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph - /// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the - /// filled dot. Other text, the same characters later in a paragraph or without the space, and - /// a paragraph already in a list start nothing. - #[test] - fn typed_list_starts_are_found_at_a_paragraphs_start() { - let (arabic, letters, dot) = - (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot)); - let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed); - assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some)); - for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] { - assert_eq!(ParaList::typed(other), None, "{other:?}"); - } - let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default()); - assert_eq!(t.typed_list_at(9), Some((6, arabic))); - assert_eq!(t.typed_list_at(19), Some((16, letters))); - assert_eq!(t.typed_list_at(27), Some((25, dot))); - // Not at the paragraph's start, not right after the space, or past the end. - for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] { - assert_eq!(t.typed_list_at(at), None, "{at}"); - } - let mut styles = t.paragraph_styles(); - styles[1].list = Some(ParaList::bulleted(Bullet::Square)); - t.set_paragraph_styles(styles); - assert_eq!(t.typed_list_at(9), None, "a list paragraph stays as it is"); - assert_eq!(t.typed_list_at(19), Some((16, letters))); - } - - #[test] - fn character_controls_reject_collapsed_and_nonfinite_transforms() { - let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); - for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] { - t.xf = Affine::new(matrix); - assert!(t.effective_char_style(&t.first_style()).is_none()); - } - t.xf = Affine::rotate(0.83); - assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0); - t.xf = Affine::scale(1e308); - assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size"); - } - - #[test] - fn point_text_basics() { - let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); - assert_eq!(t.plain_text(), "Hello"); - let b = t.bounds().unwrap(); - assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0); - assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); - } - - fn justified(j: Justify) -> ParaStyle { - ParaStyle { justify: j, ..ParaStyle::default() } - } - - #[test] - fn paragraph_styles_split_merge_and_normalize() { - let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default()); - assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored"); - assert_eq!(t.paragraphs_in(0, 0), 0..1); - assert_eq!(t.paragraphs_in(4, 4), 1..2); - assert_eq!(t.paragraphs_in(2, 9), 0..3); - assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text"); - t.edit_paras(Some(1..2), |p| p.justify = Justify::Center); - let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::>(); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); - // Return inside "two": the new paragraph continues its style. - t.splice_paras(5, 5, "\n"); - t.runs[0].text.insert(5, '\n'); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]); - // Deleting the break between "one" and "t": the first paragraph's style wins. - t.splice_paras(3, 4, ""); - t.runs[0].text.remove(3); - assert_eq!(t.plain_text(), "onet\nwo\nthree"); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); - // An edit that skips the splice is repaired by normalizing (the last style continues). - t.runs[0].text.push_str("\nfour"); - t.normalize_paras(); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]); - // `para` is paragraph 0's; all alike collapses to `para` alone. - t.edit_paras(Some(0..1), |p| p.justify = Justify::Right); - assert_eq!(t.para.justify, Justify::Right); - t.edit_paras(None, |p| p.justify = Justify::Right); - assert!(t.paras.is_empty() && t.para.justify == Justify::Right); - // A short list repeats its last entry, a long one is cut. - t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]); - assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]); - t.set_paragraph_styles(vec![justified(Justify::Center); 9]); - assert!(t.paras.is_empty() && t.para.justify == Justify::Center); - // Old documents (no `paras`) load; new ones keep paragraph 0 in `para`. - t.edit_paras(Some(3..4), |p| p.space_before = 4.0); - let json = serde_json::to_value(&t).unwrap(); - assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]); - let mut old = json.clone(); - old.as_object_mut().unwrap().remove("paras"); - let back: TextObject = serde_json::from_value(old).unwrap(); - assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center); - let back: TextObject = serde_json::from_value(json).unwrap(); - assert_eq!(back.para_at(3).space_before, 4.0); - } - - /// 120 × 40 area type at (40, 40), its text drawn at twice its size. - fn area_type() -> TextObject { - let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default()); - t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) }; - t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0); - t - } - - #[test] - fn area_resizes_its_frame_and_keeps_its_type() { - let mut t = area_type(); - assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0))); - // Bottom-right handle to (200, 140), about the top-left corner. - let o = Affine::translate((40.0, 40.0)); - assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse())); - assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size"); - let b = t.bounds().unwrap(); - assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}"); - assert_eq!(t.area_frame().unwrap().bounds(), Some(b)); - // Point type has no area; a degenerate transform or a non-finite frame changes nothing. - let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default()); - assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none()); - let before = t.clone(); - assert!(!t.transform_area(Affine::scale(f64::INFINITY))); - t.xf = Affine::scale(0.0); - assert!(!t.transform_area(Affine::scale(2.0))); - t.xf = before.xf; - assert_eq!(t, before); - } - - #[test] - fn area_anchors_move_in_document_space() { - let mut t = area_type(); - // The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×. - assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0))); - let TextKind::Area { frame } = &t.kind else { panic!() }; - assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0)); - assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay"); - // A missing anchor changes nothing. - let before = t.clone(); - assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0))); - assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0))); - assert_eq!(t, before); - } - - /// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore - /// `justify_auto`); this build reads Auto back. - #[test] - fn auto_alignment_saves_readable_by_older_builds() { - #[derive(Deserialize, Debug, PartialEq)] - enum OldJustify { - Left, - Center, - Right, - JustifyLeft, - JustifyCenter, - JustifyRight, - JustifyAll, - } - #[derive(Deserialize)] - struct OldPara { - justify: OldJustify, - } - #[derive(Deserialize)] - struct OldText { - para: OldPara, - } - for (direction, physical) in - [(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)] - { - let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default()); - t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() }; - let json = serde_json::to_string(&t).unwrap(); - let old: OldText = serde_json::from_str(&json).unwrap(); - assert_eq!(old.para.justify, physical, "{json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), t); - } - // Other alignments save as before, without the flag. - let p = ParaStyle { justify: Justify::Right, ..Default::default() }; - let json = serde_json::to_string(&p).unwrap(); - assert!(!json.contains("justify_auto"), "{json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), p); - // Files from before the flag keep their alignment. - assert_eq!(serde_json::from_str::(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center); - } -} +mod tests; diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs new file mode 100644 index 000000000..54142efd7 --- /dev/null +++ b/crates/doc/src/text/tests.rs @@ -0,0 +1,335 @@ +use super::*; + +/// Character Direction is saved only when set, and styles written before it existed read as +/// Default. +#[test] +fn character_direction_is_saved_only_when_set() { + let st = CharStyle::default(); + assert_eq!(st.char_direction, None); + assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); + for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { + let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); + assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); +} + +/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. +#[test] +fn ruby_is_saved_and_missing_ruby_reads_as_none() { + let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default()); + t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }]; + let json = serde_json::to_string(&t).unwrap(); + assert!(json.contains("かんじ")); + let back: TextObject = serde_json::from_str(&json).unwrap(); + assert_eq!(back.ruby, t.ruby); + assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang); + // Old files have no `ruby` key: none. + let mut old: serde_json::Value = serde_json::from_str(&json).unwrap(); + old.as_object_mut().unwrap().remove("ruby"); + assert!(serde_json::from_value::(old).unwrap().ruby.is_empty()); + // A damaged file's range past the text and its empty reading are clamped away. + let mut damaged = t.clone(); + damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")]; + damaged.normalize_ruby(); + assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6)); +} + +/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and +/// are saved only when set. +#[test] +fn digits_map_between_the_numeral_systems() { + let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() }; + for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] { + assert_eq!(map(Digits::Default, s), s); + assert_eq!(map(Digits::Arabic, s), "0123456789"); + assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩"); + assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹"); + } + // Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets. + assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔"); + let st = CharStyle::default(); + assert!(serde_json::to_value(&st).unwrap().get("digits").is_none()); + let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi"); + // Styles written before the attribute existed (no `digits` key) read as Default. + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().digits, Digits::Default); + assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); +} + +/// Auto leading follows the paragraph's Auto Leading; a set leading doesn't. +#[test] +fn paragraph_effective_leading_follows_auto_leading() { + let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() }; + let mut para = ParaStyle::default(); + assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%"); + para.justification.auto_leading = 200.0; + assert_eq!(para.effective_leading(&st), 20.0); + para.justification.auto_leading = 0.0; + assert_eq!(para.effective_leading(&st), 0.0); + let set = CharStyle { leading: Some(14.0), ..st.clone() }; + assert_eq!(para.effective_leading(&set), 14.0); + para.justification.auto_leading = f64::NAN; + assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number"); +} + +/// A file can't feed layout justification values `text.setFormat` would refuse: each is +/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and +/// Maximum out of order go back to their defaults. +#[test] +fn justification_options_read_from_a_file_stay_in_range() { + let d = JustificationOptions::default(); + let read: ParaStyle = serde_json::from_value(serde_json::json!({ + "justification": { + "word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0], + "glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12 + } + })) + .unwrap(); + let j = read.justification; + assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]); + assert_eq!(j.letter_spacing, d.letter_spacing, "out of order"); + assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]); + assert_eq!(j.auto_leading, 0.0); + let mut j = JustificationOptions { + word_spacing: [f64::NAN, 100.0, f64::INFINITY], + letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY], + auto_leading: f64::NAN, + ..d.clone() + }; + j.keep_in_range(); + assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading)); + let mut same = d.clone(); + same.keep_in_range(); + assert_eq!(same, d); +} + +/// Numbered lists: each number style writes its numbers and rolls over (letters after Z, +/// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds +/// a period, and the count runs through list paragraphs, skips empty and bulleted ones and +/// starts again after a paragraph in no list. A list from before number styles reads as +/// bulleted, and a bulleted list saves no number style. +#[test] +fn number_styles_write_their_numbers_and_roll_over() { + let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::>(); + assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]); + assert_eq!( + numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]), + ["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"] + ); + assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]); + assert_eq!( + numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]), + ["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"] + ); + assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]); + assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]); + assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]); + assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); + assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); + let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); + let mut count = 0; + let markers: Vec> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] + .iter() + .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) + .collect(); + assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); + assert_eq!(count, 1); + let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); + assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); + assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); + assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); + assert_eq!( + serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(), + serde_json::json!({"bullet": "dot", "number": "lowerRoman"}) + ); +} + +/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph +/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the +/// filled dot. Other text, the same characters later in a paragraph or without the space, and +/// a paragraph already in a list start nothing. +#[test] +fn typed_list_starts_are_found_at_a_paragraphs_start() { + let (arabic, letters, dot) = + (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot)); + let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed); + assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some)); + for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] { + assert_eq!(ParaList::typed(other), None, "{other:?}"); + } + let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default()); + assert_eq!(t.typed_list_at(9), Some((6, arabic))); + assert_eq!(t.typed_list_at(19), Some((16, letters))); + assert_eq!(t.typed_list_at(27), Some((25, dot))); + // Not at the paragraph's start, not right after the space, or past the end. + for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] { + assert_eq!(t.typed_list_at(at), None, "{at}"); + } + let mut styles = t.paragraph_styles(); + styles[1].list = Some(ParaList::bulleted(Bullet::Square)); + t.set_paragraph_styles(styles); + assert_eq!(t.typed_list_at(9), None, "a list paragraph stays as it is"); + assert_eq!(t.typed_list_at(19), Some((16, letters))); +} + +#[test] +fn character_controls_reject_collapsed_and_nonfinite_transforms() { + let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); + for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] { + t.xf = Affine::new(matrix); + assert!(t.effective_char_style(&t.first_style()).is_none()); + } + t.xf = Affine::rotate(0.83); + assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0); + t.xf = Affine::scale(1e308); + assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size"); +} + +#[test] +fn point_text_basics() { + let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); + assert_eq!(t.plain_text(), "Hello"); + let b = t.bounds().unwrap(); + assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0); + assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); +} + +fn justified(j: Justify) -> ParaStyle { + ParaStyle { justify: j, ..ParaStyle::default() } +} + +#[test] +fn paragraph_styles_split_merge_and_normalize() { + let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default()); + assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored"); + assert_eq!(t.paragraphs_in(0, 0), 0..1); + assert_eq!(t.paragraphs_in(4, 4), 1..2); + assert_eq!(t.paragraphs_in(2, 9), 0..3); + assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text"); + t.edit_paras(Some(1..2), |p| p.justify = Justify::Center); + let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::>(); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); + // Return inside "two": the new paragraph continues its style. + t.splice_paras(5, 5, "\n"); + t.runs[0].text.insert(5, '\n'); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]); + // Deleting the break between "one" and "t": the first paragraph's style wins. + t.splice_paras(3, 4, ""); + t.runs[0].text.remove(3); + assert_eq!(t.plain_text(), "onet\nwo\nthree"); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); + // An edit that skips the splice is repaired by normalizing (the last style continues). + t.runs[0].text.push_str("\nfour"); + t.normalize_paras(); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]); + // `para` is paragraph 0's; all alike collapses to `para` alone. + t.edit_paras(Some(0..1), |p| p.justify = Justify::Right); + assert_eq!(t.para.justify, Justify::Right); + t.edit_paras(None, |p| p.justify = Justify::Right); + assert!(t.paras.is_empty() && t.para.justify == Justify::Right); + // A short list repeats its last entry, a long one is cut. + t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]); + t.set_paragraph_styles(vec![justified(Justify::Center); 9]); + assert!(t.paras.is_empty() && t.para.justify == Justify::Center); + // Old documents (no `paras`) load; new ones keep paragraph 0 in `para`. + t.edit_paras(Some(3..4), |p| p.space_before = 4.0); + let json = serde_json::to_value(&t).unwrap(); + assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]); + let mut old = json.clone(); + old.as_object_mut().unwrap().remove("paras"); + let back: TextObject = serde_json::from_value(old).unwrap(); + assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center); + let back: TextObject = serde_json::from_value(json).unwrap(); + assert_eq!(back.para_at(3).space_before, 4.0); +} + +/// 120 × 40 area type at (40, 40), its text drawn at twice its size. +fn area_type() -> TextObject { + let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default()); + t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) }; + t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0); + t +} + +#[test] +fn area_resizes_its_frame_and_keeps_its_type() { + let mut t = area_type(); + assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0))); + // Bottom-right handle to (200, 140), about the top-left corner. + let o = Affine::translate((40.0, 40.0)); + assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse())); + assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size"); + let b = t.bounds().unwrap(); + assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}"); + assert_eq!(t.area_frame().unwrap().bounds(), Some(b)); + // Point type has no area; a degenerate transform or a non-finite frame changes nothing. + let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default()); + assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none()); + let before = t.clone(); + assert!(!t.transform_area(Affine::scale(f64::INFINITY))); + t.xf = Affine::scale(0.0); + assert!(!t.transform_area(Affine::scale(2.0))); + t.xf = before.xf; + assert_eq!(t, before); +} + +#[test] +fn area_anchors_move_in_document_space() { + let mut t = area_type(); + // The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×. + assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0))); + let TextKind::Area { frame } = &t.kind else { panic!() }; + assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0)); + assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay"); + // A missing anchor changes nothing. + let before = t.clone(); + assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0))); + assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0))); + assert_eq!(t, before); +} + +/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore +/// `justify_auto`); this build reads Auto back. +#[test] +fn auto_alignment_saves_readable_by_older_builds() { + #[derive(Deserialize, Debug, PartialEq)] + enum OldJustify { + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, + } + #[derive(Deserialize)] + struct OldPara { + justify: OldJustify, + } + #[derive(Deserialize)] + struct OldText { + para: OldPara, + } + for (direction, physical) in + [(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)] + { + let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default()); + t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() }; + let json = serde_json::to_string(&t).unwrap(); + let old: OldText = serde_json::from_str(&json).unwrap(); + assert_eq!(old.para.justify, physical, "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), t); + } + // Other alignments save as before, without the flag. + let p = ParaStyle { justify: Justify::Right, ..Default::default() }; + let json = serde_json::to_string(&p).unwrap(); + assert!(!json.contains("justify_auto"), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), p); + // Files from before the flag keep their alignment. + assert_eq!(serde_json::from_str::(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center); +} From 29e311afddb911224429c0d986e3e41a1991e543 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:22 +0000 Subject: [PATCH 076/199] refactor(ui-egui): move dialogs/save_options.rs tests to dialogs/save_options/tests.rs --- crates/ui-egui/src/dialogs/save_options.rs | 337 +----------------- .../ui-egui/src/dialogs/save_options/tests.rs | 334 +++++++++++++++++ 2 files changed, 335 insertions(+), 336 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/save_options/tests.rs diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 1b3c98816..b320897de 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -470,339 +470,4 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { } #[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::rc::Rc; - - use vectorcraft_engine::Session; - - use super::*; - use crate::{FilePick, Services, theme}; - - type Written = Rc)>>>; - - /// A desktop app whose save panel answers `picked` and records what it was shown. - fn desktop(picked: &'static str) -> (VectorcraftApp, Written, Rc>>) { - let (written, picks) = (Written::default(), Rc::new(RefCell::new(vec![]))); - let (w, p) = (written.clone(), picks.clone()); - let services = Services { - pick_save: Some(Box::new(move |f: &FilePick| { - p.borrow_mut().push(f.clone()); - Some(picked.to_string()) - })), - write: Some(Box::new(move |path: &str, b: &[u8]| { - w.borrow_mut().push((path.to_string(), b.to_vec())); - Ok(()) - })), - ..Default::default() - }; - (with_art(services), written, picks) - } - - /// A browser app: saves are downloads. - fn web() -> (VectorcraftApp, Written) { - let written = Written::default(); - let w = written.clone(); - let services = Services { - download: Some(Box::new(move |name: &str, b: &[u8]| { - w.borrow_mut().push((name.to_string(), b.to_vec())); - Ok(()) - })), - ..Default::default() - }; - (with_art(services), written) - } - - fn with_art(services: Services) -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), services); - app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); - app - } - - /// One headless frame of the dialog layer; `enter` presses OK. - fn frame(app: &mut VectorcraftApp, enter: bool) { - let ctx = egui::Context::default(); - theme::install_fonts(&ctx); - let events = if enter { - vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }] - } else { - vec![] - }; - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); - out.textures_delta.clear(); - } - - fn dialog(app: &VectorcraftApp) -> &Dialog { - app.ui.dialog.as_ref().expect("a dialog is open") - } - - #[test] - fn save_as_svg_opens_svg_options_then_writes_svg() { - let (mut app, written, picks) = desktop("art.svg"); - let r = app.run("file.saveAs", json!({})).unwrap(); - assert_eq!((r["pending"].as_str(), r["path"].as_str()), (Some("svgOptions"), Some("art.svg"))); - // The save panel offers every save format, the document's own first. - let pick = &picks.borrow()[0]; - let labels: Vec<&str> = pick.filters.iter().map(|f| f.0.as_str()).collect(); - assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); - assert_eq!(pick.name, "Untitled-1.vectorcraft"); - let d = dialog(&app); - assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save")); - assert!(written.borrow().is_empty(), "nothing is written before OK"); - frame(&mut app, false); - assert!(app.ui.dialog.is_some(), "drawing keeps it open"); - app.ui.dialog.as_mut().unwrap().fields.insert("outlineText".into(), json!(true)); - frame(&mut app, true); - assert!(app.ui.dialog.is_none()); - let (path, bytes) = written.borrow()[0].clone(); - assert_eq!(path, "art.svg"); - assert!(bytes.starts_with(b" = written.borrow().iter().map(|(p, _)| p.clone()).collect(); - assert_eq!(names, ["art-Artboard-1.ai", "art-Back.ai"]); - assert!(written.borrow().iter().all(|(_, b)| b.starts_with(b"%!PS"))); - let st = app.session.active().unwrap(); - assert_eq!((st.format, st.save_options["aiVersion"].clone()), ("ai", json!("7")), "Save remembers the version"); - assert_eq!(st.save_options["transparentImages"], json!("omit")); - assert_eq!((st.save_options["hiddenObjects"].clone(), st.save_options["outlineText"].clone()), (json!("visible"), json!(true))); - } - - /// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the - /// Enter that closed the save panel, and only an Enter after it is let go confirms it. - #[test] - fn legacy_ai_saves_the_artboards_checked_in_its_picker() { - let (mut app, written, _) = desktop("art.ai"); - for (i, name) in ["Back", "Side", "Top"].iter().enumerate() { - let x = 200.0 * (i + 1) as f64; - app.run("artboard.new", json!({"x": x, "y": 0, "width": 100, "height": 80 + 20 * i, "name": name})).unwrap(); - app.run("shape.ellipse", json!({"x": x + 10.0, "y": 10, "width": 40, "height": 40})).unwrap(); - } - app.run("file.saveAs", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("aiVersion".into(), json!("7")); - frame(&mut app, false); - assert_eq!(dialog(&app).fields["boards"], json!([true, true, true, true]), "every artboard to start with"); - if let Some(dir) = std::env::var_os("CRAFT_UI_SAVE_OPTIONS_SHOT") { - let mut ready = false; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 760.0)).wgpu().build_ui_state( - move |ui, app: &mut VectorcraftApp| { - if !ready { - theme::install_fonts(ui.ctx()); - theme::apply(ui.ctx(), theme::Brightness::MediumDark); - ready = true; - return; - } - super::super::show(app, ui.ctx()); - }, - app, - ); - h.run_steps(6); - h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker.png")).unwrap(); - let fields = &mut h.state_mut().ui.dialog.as_mut().unwrap().fields; - fields.insert("path".into(), json!("desktop-ideas1-vc5.ai")); - fields.insert("boards".into(), json!([false, false, false, false])); - h.run_steps(2); - h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([true, true, false, true])); - h.run_steps(2); - h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker-long.png")).unwrap(); - h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([false, false, false, false])); - h.run_steps(2); - h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker-none.png")).unwrap(); - app = h.into_state(); - app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!("art.ai")); - } - // Clearing them all keeps the dialog open. - app.ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([false, false, false, false])); - let d = dialog(&app).clone(); - assert!(!can_save(&app, &d), "OK is greyed out"); - frame(&mut app, true); - assert!(app.ui.dialog.is_some(), "Enter does nothing"); - assert!(confirm(&mut app, &d).is_err()); - assert!(written.borrow().is_empty()); - app.ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([true, false, true, false])); - frame(&mut app, true); - assert!(app.ui.dialog.is_none()); - let names: Vec = written.borrow().iter().map(|(p, _)| p.clone()).collect(); - assert_eq!(names, ["art-Artboard-1.ai", "art-Side.ai"]); - assert_eq!(board_files_of(&app, [true, false, true, false]), [Some("art-Artboard-1.ai".into()), None, Some("art-Side.ai".into()), None]); - assert_eq!(board_files_of(&app, [false, true, false, false]), [None, Some("art.ai".into()), None, None], "one: the file itself"); - // Save As again starts from the artboards saved. - assert_eq!(app.session.active().unwrap().save_options["range"], json!("1, 3")); - app.run("file.saveAs", json!({})).unwrap(); - frame(&mut app, false); - assert_eq!(dialog(&app).fields["boards"], json!([true, false, true, false])); - } - - fn board_files_of(app: &VectorcraftApp, boards: [bool; 4]) -> Vec> { - let d = Dialog { kind: KIND.into(), fields: [("path".to_string(), json!("art.ai"))].into_iter().collect() }; - board_files(app, &d, &boards) - } - - #[test] - fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { - let (mut app, written, _) = desktop("again.ai"); - let dir = vectorcraft_testkit::temp_dir("ai-options-enter"); - let orig = dir.join("orig.ai"); - std::fs::write(&orig, vectorcraft_testkit::ai::ai(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_pdf())).unwrap(); - app.run("document.open", json!({"path": orig.to_string_lossy()})).unwrap(); - assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND); - frame(&mut app, true); - assert!(app.ui.dialog.is_some(), "the panel's Enter doesn't confirm it"); - assert!(written.borrow().is_empty()); - // Let go, then pressed again: OK. - frame(&mut app, false); - frame(&mut app, true); - assert!(app.ui.dialog.is_none()); - assert_eq!(written.borrow()[0].0, "again.ai"); - } - - #[test] - fn save_a_copy_and_template_suggest_their_names() { - let (mut app, written, picks) = desktop("copy.vectorcraft"); - let folder = vectorcraft_testkit::temp_dir("save-template-dialog"); - app.session.prefs.templates_folder = folder.to_string_lossy().into_owned(); - app.run("file.saveCopy", json!({})).unwrap(); - assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); - assert!(app.ui.dialog.is_none(), "the native format has no options"); - assert!(vectorcraft_format::sniff(&written.borrow()[0].1)); - assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); - app.run("file.saveAsTemplate", json!({})).unwrap(); - let pick = &picks.borrow()[1]; - assert_eq!(pick.name, "Untitled-1 template.vctemplate"); - assert_eq!(pick.folder.as_deref().map(std::path::Path::new), Some(folder.as_path())); - assert_eq!( - pick.filters, - crate::FilePick::filters([("VectorCraft Template", &["vctemplate"][..]), ("PDF-compatible .ait template", &["ait"][..])]) - ); - assert_eq!(written.borrow()[1].0, "copy.vctemplate", "the picked name, its extension replaced with the template's"); - } - - #[test] - fn save_as_template_writes_ait_when_it_is_picked() { - let (mut app, written, _) = desktop("flyer.ait"); - app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ait").to_string_lossy().into_owned(); - app.run("file.saveAsTemplate", json!({})).unwrap(); - assert!(app.ui.dialog.is_none(), "a template asks no options"); - let (path, bytes) = written.borrow()[0].clone(); - assert_eq!(path, "flyer.ait", "the picked name is kept"); - assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some(), "a PDF carrying the document"); - assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); - // The web's dialog picks the format: .vctemplate first, .ait beside it. - let (mut app, written) = web(); - app.run("file.saveAsTemplate", json!({})).unwrap(); - assert_eq!((heading(dialog(&app)).as_str(), dialog(&app).str("format").as_str()), ("Save as Template", "template")); - assert_eq!(template_labels(), ["VectorCraft Template", "PDF-compatible .ait template"]); - frame(&mut app, false); - let mut d = app.ui.dialog.take().unwrap(); - switch_format(&mut app, &mut d, "ait"); - assert_eq!((d.str("path").as_str(), d.fields["pdfCompatible"].clone()), ("Untitled-1 template.ait", json!(true))); - app.ui.dialog = Some(d); - frame(&mut app, true); - let (name, bytes) = written.borrow()[0].clone(); - assert_eq!(name, "Untitled-1 template.ait"); - assert!(bytes.starts_with(b"%PDF")); - } - - /// The `.ait` template's options have no Version row: Save as Template writes it - /// PDF-compatible only. A `.ai` file's options keep it. - #[test] - fn the_ait_template_options_have_no_version_row() { - let names = |id: &str| shown(fileio::format(id).unwrap()).map(|o| o.name).collect::>(); - assert!(names("ai").contains(&"aiVersion"), "{:?}", names("ai")); - assert!(!names("ait").contains(&"aiVersion"), "{:?}", names("ait")); - } - - /// A name typed in the Save as Template panel with a Save As format's extension has it - /// replaced with the template's, as Save As replaces the extension of a format it doesn't write. - #[test] - fn save_as_template_replaces_a_save_formats_extension() { - for (picked, saved) in [("card.pdf", "card.vctemplate"), ("card.vectorcraft", "card.vctemplate"), ("card.svg", "card.vctemplate")] { - let (mut app, written, _) = desktop(picked); - app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ext").to_string_lossy().into_owned(); - app.run("file.saveAsTemplate", json!({})).unwrap(); - let (path, bytes) = written.borrow()[0].clone(); - assert_eq!(path, saved, "typed {picked}"); - assert!(vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "typed {picked}: a native template"); - } - } - - #[test] - fn web_save_as_names_the_file_and_picks_the_format() { - let (mut app, written) = web(); - app.run("file.saveAs", json!({})).unwrap(); - let d = dialog(&app); - assert_eq!((heading(d).as_str(), d.str("path").as_str(), d.str("format").as_str()), ("Save As", "Untitled-1.vectorcraft", "vectorcraft")); - frame(&mut app, false); - let mut d = app.ui.dialog.take().unwrap(); - switch_format(&mut app, &mut d, "pdf"); - assert_eq!((d.str("path").as_str(), d.str("format").as_str()), ("Untitled-1.pdf", "pdf")); - assert_eq!(shown(fileio::format("pdf").unwrap()).count(), 0, "PDF options have their own dialog"); - app.ui.dialog = Some(d); - frame(&mut app, true); - // OK goes on to the Save PDF dialog, whose OK saves. - assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("path").as_str()), ("savePdf", "Untitled-1.pdf")); - assert!(written.borrow().is_empty()); - super::super::confirm(&mut app).unwrap(); - let (name, bytes) = written.borrow()[0].clone(); - assert_eq!(name, "Untitled-1.pdf"); - assert!(bytes.starts_with(b"%PDF")); - assert_eq!(app.session.active().unwrap().format, "pdf"); - // A blank name isn't saved. - app.run("file.saveCopy", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(" ")); - assert!(super::super::confirm(&mut app).is_err()); - } - - #[test] - fn save_as_pdf_asks_with_the_save_pdf_dialog_and_remembers_its_options() { - let (mut app, written, _) = desktop("art.pdf"); - assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], "savePdf"); - let d = app.ui.dialog.as_mut().unwrap(); - assert_eq!((d.str("path").as_str(), d.str("__save").as_str()), ("art.pdf", "file.saveAs")); - d.fields.insert("compatibility".into(), json!("1.5")); - d.fields.insert("createLayers".into(), json!(true)); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - assert!(written.borrow()[0].1.starts_with(b"%PDF-1.5")); - let st = app.session.active().unwrap(); - assert_eq!((st.path.as_deref(), st.format, st.is_dirty()), (Some("art.pdf"), "pdf", false)); - assert_eq!((st.save_options["compatibility"].clone(), st.save_options["createLayers"].clone()), (json!("1.5"), json!(true))); - // Save writes the PDF again with those options, without asking. - app.run("shape.ellipse", json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); - app.run("file.save", json!({})).unwrap(); - assert!(app.ui.dialog.is_none() && written.borrow()[1].1.starts_with(b"%PDF-1.5")); - // Save a Copy as PDF starts from them. - app.run("file.saveCopy", json!({"format": "pdf"})).unwrap(); - assert_eq!(dialog(&app).str("compatibility"), "1.5"); - assert_eq!(dialog(&app).str("__save"), "file.saveCopy"); - } -} +mod tests; diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs new file mode 100644 index 000000000..9c8ae60bb --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -0,0 +1,334 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use vectorcraft_engine::Session; + +use super::*; +use crate::{FilePick, Services, theme}; + +type Written = Rc)>>>; + +/// A desktop app whose save panel answers `picked` and records what it was shown. +fn desktop(picked: &'static str) -> (VectorcraftApp, Written, Rc>>) { + let (written, picks) = (Written::default(), Rc::new(RefCell::new(vec![]))); + let (w, p) = (written.clone(), picks.clone()); + let services = Services { + pick_save: Some(Box::new(move |f: &FilePick| { + p.borrow_mut().push(f.clone()); + Some(picked.to_string()) + })), + write: Some(Box::new(move |path: &str, b: &[u8]| { + w.borrow_mut().push((path.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + (with_art(services), written, picks) +} + +/// A browser app: saves are downloads. +fn web() -> (VectorcraftApp, Written) { + let written = Written::default(); + let w = written.clone(); + let services = Services { + download: Some(Box::new(move |name: &str, b: &[u8]| { + w.borrow_mut().push((name.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + (with_art(services), written) +} + +fn with_art(services: Services) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app +} + +/// One headless frame of the dialog layer; `enter` presses OK. +fn frame(app: &mut VectorcraftApp, enter: bool) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let events = if enter { + vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }] + } else { + vec![] + }; + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn dialog(app: &VectorcraftApp) -> &Dialog { + app.ui.dialog.as_ref().expect("a dialog is open") +} + +#[test] +fn save_as_svg_opens_svg_options_then_writes_svg() { + let (mut app, written, picks) = desktop("art.svg"); + let r = app.run("file.saveAs", json!({})).unwrap(); + assert_eq!((r["pending"].as_str(), r["path"].as_str()), (Some("svgOptions"), Some("art.svg"))); + // The save panel offers every save format, the document's own first. + let pick = &picks.borrow()[0]; + let labels: Vec<&str> = pick.filters.iter().map(|f| f.0.as_str()).collect(); + assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); + assert_eq!(pick.name, "Untitled-1.vectorcraft"); + let d = dialog(&app); + assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save")); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + frame(&mut app, false); + assert!(app.ui.dialog.is_some(), "drawing keeps it open"); + app.ui.dialog.as_mut().unwrap().fields.insert("outlineText".into(), json!(true)); + frame(&mut app, true); + assert!(app.ui.dialog.is_none()); + let (path, bytes) = written.borrow()[0].clone(); + assert_eq!(path, "art.svg"); + assert!(bytes.starts_with(b" = written.borrow().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["art-Artboard-1.ai", "art-Back.ai"]); + assert!(written.borrow().iter().all(|(_, b)| b.starts_with(b"%!PS"))); + let st = app.session.active().unwrap(); + assert_eq!((st.format, st.save_options["aiVersion"].clone()), ("ai", json!("7")), "Save remembers the version"); + assert_eq!(st.save_options["transparentImages"], json!("omit")); + assert_eq!((st.save_options["hiddenObjects"].clone(), st.save_options["outlineText"].clone()), (json!("visible"), json!(true))); +} + +/// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the +/// Enter that closed the save panel, and only an Enter after it is let go confirms it. +#[test] +fn legacy_ai_saves_the_artboards_checked_in_its_picker() { + let (mut app, written, _) = desktop("art.ai"); + for (i, name) in ["Back", "Side", "Top"].iter().enumerate() { + let x = 200.0 * (i + 1) as f64; + app.run("artboard.new", json!({"x": x, "y": 0, "width": 100, "height": 80 + 20 * i, "name": name})).unwrap(); + app.run("shape.ellipse", json!({"x": x + 10.0, "y": 10, "width": 40, "height": 40})).unwrap(); + } + app.run("file.saveAs", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("aiVersion".into(), json!("7")); + frame(&mut app, false); + assert_eq!(dialog(&app).fields["boards"], json!([true, true, true, true]), "every artboard to start with"); + if let Some(dir) = std::env::var_os("CRAFT_UI_SAVE_OPTIONS_SHOT") { + let mut ready = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 760.0)).wgpu().build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !ready { + theme::install_fonts(ui.ctx()); + theme::apply(ui.ctx(), theme::Brightness::MediumDark); + ready = true; + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(6); + h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker.png")).unwrap(); + let fields = &mut h.state_mut().ui.dialog.as_mut().unwrap().fields; + fields.insert("path".into(), json!("desktop-ideas1-vc5.ai")); + fields.insert("boards".into(), json!([false, false, false, false])); + h.run_steps(2); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([true, true, false, true])); + h.run_steps(2); + h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker-long.png")).unwrap(); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([false, false, false, false])); + h.run_steps(2); + h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker-none.png")).unwrap(); + app = h.into_state(); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!("art.ai")); + } + // Clearing them all keeps the dialog open. + app.ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([false, false, false, false])); + let d = dialog(&app).clone(); + assert!(!can_save(&app, &d), "OK is greyed out"); + frame(&mut app, true); + assert!(app.ui.dialog.is_some(), "Enter does nothing"); + assert!(confirm(&mut app, &d).is_err()); + assert!(written.borrow().is_empty()); + app.ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([true, false, true, false])); + frame(&mut app, true); + assert!(app.ui.dialog.is_none()); + let names: Vec = written.borrow().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["art-Artboard-1.ai", "art-Side.ai"]); + assert_eq!(board_files_of(&app, [true, false, true, false]), [Some("art-Artboard-1.ai".into()), None, Some("art-Side.ai".into()), None]); + assert_eq!(board_files_of(&app, [false, true, false, false]), [None, Some("art.ai".into()), None, None], "one: the file itself"); + // Save As again starts from the artboards saved. + assert_eq!(app.session.active().unwrap().save_options["range"], json!("1, 3")); + app.run("file.saveAs", json!({})).unwrap(); + frame(&mut app, false); + assert_eq!(dialog(&app).fields["boards"], json!([true, false, true, false])); +} + +fn board_files_of(app: &VectorcraftApp, boards: [bool; 4]) -> Vec> { + let d = Dialog { kind: KIND.into(), fields: [("path".to_string(), json!("art.ai"))].into_iter().collect() }; + board_files(app, &d, &boards) +} + +#[test] +fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { + let (mut app, written, _) = desktop("again.ai"); + let dir = vectorcraft_testkit::temp_dir("ai-options-enter"); + let orig = dir.join("orig.ai"); + std::fs::write(&orig, vectorcraft_testkit::ai::ai(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_pdf())).unwrap(); + app.run("document.open", json!({"path": orig.to_string_lossy()})).unwrap(); + assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND); + frame(&mut app, true); + assert!(app.ui.dialog.is_some(), "the panel's Enter doesn't confirm it"); + assert!(written.borrow().is_empty()); + // Let go, then pressed again: OK. + frame(&mut app, false); + frame(&mut app, true); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow()[0].0, "again.ai"); +} + +#[test] +fn save_a_copy_and_template_suggest_their_names() { + let (mut app, written, picks) = desktop("copy.vectorcraft"); + let folder = vectorcraft_testkit::temp_dir("save-template-dialog"); + app.session.prefs.templates_folder = folder.to_string_lossy().into_owned(); + app.run("file.saveCopy", json!({})).unwrap(); + assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); + assert!(app.ui.dialog.is_none(), "the native format has no options"); + assert!(vectorcraft_format::sniff(&written.borrow()[0].1)); + assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); + app.run("file.saveAsTemplate", json!({})).unwrap(); + let pick = &picks.borrow()[1]; + assert_eq!(pick.name, "Untitled-1 template.vctemplate"); + assert_eq!(pick.folder.as_deref().map(std::path::Path::new), Some(folder.as_path())); + assert_eq!( + pick.filters, + crate::FilePick::filters([("VectorCraft Template", &["vctemplate"][..]), ("PDF-compatible .ait template", &["ait"][..])]) + ); + assert_eq!(written.borrow()[1].0, "copy.vctemplate", "the picked name, its extension replaced with the template's"); +} + +#[test] +fn save_as_template_writes_ait_when_it_is_picked() { + let (mut app, written, _) = desktop("flyer.ait"); + app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ait").to_string_lossy().into_owned(); + app.run("file.saveAsTemplate", json!({})).unwrap(); + assert!(app.ui.dialog.is_none(), "a template asks no options"); + let (path, bytes) = written.borrow()[0].clone(); + assert_eq!(path, "flyer.ait", "the picked name is kept"); + assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some(), "a PDF carrying the document"); + assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); + // The web's dialog picks the format: .vctemplate first, .ait beside it. + let (mut app, written) = web(); + app.run("file.saveAsTemplate", json!({})).unwrap(); + assert_eq!((heading(dialog(&app)).as_str(), dialog(&app).str("format").as_str()), ("Save as Template", "template")); + assert_eq!(template_labels(), ["VectorCraft Template", "PDF-compatible .ait template"]); + frame(&mut app, false); + let mut d = app.ui.dialog.take().unwrap(); + switch_format(&mut app, &mut d, "ait"); + assert_eq!((d.str("path").as_str(), d.fields["pdfCompatible"].clone()), ("Untitled-1 template.ait", json!(true))); + app.ui.dialog = Some(d); + frame(&mut app, true); + let (name, bytes) = written.borrow()[0].clone(); + assert_eq!(name, "Untitled-1 template.ait"); + assert!(bytes.starts_with(b"%PDF")); +} + +/// The `.ait` template's options have no Version row: Save as Template writes it +/// PDF-compatible only. A `.ai` file's options keep it. +#[test] +fn the_ait_template_options_have_no_version_row() { + let names = |id: &str| shown(fileio::format(id).unwrap()).map(|o| o.name).collect::>(); + assert!(names("ai").contains(&"aiVersion"), "{:?}", names("ai")); + assert!(!names("ait").contains(&"aiVersion"), "{:?}", names("ait")); +} + +/// A name typed in the Save as Template panel with a Save As format's extension has it +/// replaced with the template's, as Save As replaces the extension of a format it doesn't write. +#[test] +fn save_as_template_replaces_a_save_formats_extension() { + for (picked, saved) in [("card.pdf", "card.vctemplate"), ("card.vectorcraft", "card.vctemplate"), ("card.svg", "card.vctemplate")] { + let (mut app, written, _) = desktop(picked); + app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ext").to_string_lossy().into_owned(); + app.run("file.saveAsTemplate", json!({})).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert_eq!(path, saved, "typed {picked}"); + assert!(vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "typed {picked}: a native template"); + } +} + +#[test] +fn web_save_as_names_the_file_and_picks_the_format() { + let (mut app, written) = web(); + app.run("file.saveAs", json!({})).unwrap(); + let d = dialog(&app); + assert_eq!((heading(d).as_str(), d.str("path").as_str(), d.str("format").as_str()), ("Save As", "Untitled-1.vectorcraft", "vectorcraft")); + frame(&mut app, false); + let mut d = app.ui.dialog.take().unwrap(); + switch_format(&mut app, &mut d, "pdf"); + assert_eq!((d.str("path").as_str(), d.str("format").as_str()), ("Untitled-1.pdf", "pdf")); + assert_eq!(shown(fileio::format("pdf").unwrap()).count(), 0, "PDF options have their own dialog"); + app.ui.dialog = Some(d); + frame(&mut app, true); + // OK goes on to the Save PDF dialog, whose OK saves. + assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("path").as_str()), ("savePdf", "Untitled-1.pdf")); + assert!(written.borrow().is_empty()); + super::super::confirm(&mut app).unwrap(); + let (name, bytes) = written.borrow()[0].clone(); + assert_eq!(name, "Untitled-1.pdf"); + assert!(bytes.starts_with(b"%PDF")); + assert_eq!(app.session.active().unwrap().format, "pdf"); + // A blank name isn't saved. + app.run("file.saveCopy", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(" ")); + assert!(super::super::confirm(&mut app).is_err()); +} + +#[test] +fn save_as_pdf_asks_with_the_save_pdf_dialog_and_remembers_its_options() { + let (mut app, written, _) = desktop("art.pdf"); + assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], "savePdf"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.str("path").as_str(), d.str("__save").as_str()), ("art.pdf", "file.saveAs")); + d.fields.insert("compatibility".into(), json!("1.5")); + d.fields.insert("createLayers".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(written.borrow()[0].1.starts_with(b"%PDF-1.5")); + let st = app.session.active().unwrap(); + assert_eq!((st.path.as_deref(), st.format, st.is_dirty()), (Some("art.pdf"), "pdf", false)); + assert_eq!((st.save_options["compatibility"].clone(), st.save_options["createLayers"].clone()), (json!("1.5"), json!(true))); + // Save writes the PDF again with those options, without asking. + app.run("shape.ellipse", json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + app.run("file.save", json!({})).unwrap(); + assert!(app.ui.dialog.is_none() && written.borrow()[1].1.starts_with(b"%PDF-1.5")); + // Save a Copy as PDF starts from them. + app.run("file.saveCopy", json!({"format": "pdf"})).unwrap(); + assert_eq!(dialog(&app).str("compatibility"), "1.5"); + assert_eq!(dialog(&app).str("__save"), "file.saveCopy"); +} From 5052b3e5ac98557f53b93df357f6d107259fe15e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:23 +0000 Subject: [PATCH 077/199] refactor(ui-egui): move dialogs/find_links.rs tests to dialogs/find_links/tests.rs --- crates/ui-egui/src/dialogs/find_links.rs | 337 +----------------- .../ui-egui/src/dialogs/find_links/tests.rs | 334 +++++++++++++++++ 2 files changed, 335 insertions(+), 336 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/find_links/tests.rs diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs index 415cf3a8e..6543816ec 100644 --- a/crates/ui-egui/src/dialogs/find_links.rs +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -384,339 +384,4 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } #[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - use std::time::Instant; - - use vectorcraft_engine::Session; - use vectorcraft_engine::cmd::findfiles::Rules; - - use super::*; - - fn frame(app: &mut VectorcraftApp) -> String { - crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) - } - - /// A fresh temporary folder (canonical, as a search reports it). - fn folder(tag: &str) -> PathBuf { - let dir = vectorcraft_testkit::temp_dir(&format!("find-links-{tag}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()) - } - - /// An app that reads files, shows a folder picker and whose searches may go anywhere in `root`, - /// on two threads. - fn app_for(root: &Path) -> VectorcraftApp { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); - app.services.pick_folder = Some(Box::new(|| None)); - app.session.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() }); - app.session.search_threads = Some(2); - app - } - - /// `rel` (`/`-separated) inside `root`, with the platform's separators, as the app reports paths. - fn at(root: &Path, rel: &str) -> PathBuf { - rel.split('/').fold(root.to_path_buf(), |p, c| p.join(c)) - } - - fn write(path: &Path, bytes: &[u8]) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, bytes).unwrap(); - } - - /// A PNG of one color, a different one for each `n`. - fn png(n: u8) -> Vec { - let mut out = vec![]; - image::RgbaImage::from_pixel(40, 30, image::Rgba([200, n.wrapping_mul(40), 0, 255])) - .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) - .unwrap(); - out - } - - /// A document linking to files `names` in `root/orig`, saved as `root/doc/doc.vectorcraft`; the - /// files are then deleted and the document opened again → each file's bytes. - fn opened_without(app: &mut VectorcraftApp, root: &Path, names: &[&str]) -> Vec> { - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let mut files = vec![]; - for (n, name) in (0..).zip(names) { - let path = root.join("orig").join(name); - write(&path, &png(n)); - app.run("file.place", json!({"path": path})).unwrap(); - files.push(png(n)); - } - let doc = at(root, "doc/doc.vectorcraft"); - std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); - app.run("document.save", json!({"path": doc})).unwrap(); - std::fs::remove_dir_all(root.join("orig")).unwrap(); - crate::io::open_path(app, &doc.to_string_lossy()).unwrap(); - files - } - - /// Draw the dialog until its search ends (10 seconds at most). - fn searched(app: &mut VectorcraftApp) { - let until = Instant::now() + Duration::from_secs(10); - while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until { - frame(app); - std::thread::sleep(Duration::from_millis(5)); - } - } - - fn kind(app: &VectorcraftApp) -> Option<&str> { - app.ui.dialog.as_ref().map(|d| d.kind.as_str()) - } - - /// The pick of the row named `name`. - fn pick<'a>(d: &'a Dialog, name: &str) -> &'a Value { - let i = d.fields["missing"].as_array().unwrap().iter().position(|r| r["name"] == name).unwrap(); - &d.fields["picks"][i] - } - - /// Find in Folder… in Linked File Not Found: each missing file's same file is chosen, and Relink - /// relinks them. - #[test] - fn find_in_folder_relinks_the_missing_files_to_the_files_found() { - let root = folder("relink"); - let mut app = app_for(&root); - let bytes = opened_without(&mut app, &root, &["a.png", "b.png"]); - assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); - assert!(frame(&mut app).contains("Find in Folder…")); - let (a, b) = (at(&root, "found/sub/a.png"), at(&root, "found/b.png")); - write(&a, &bytes[0]); - write(&b, &bytes[1]); - write(&at(&root, "found/other/b.png"), b"another file"); - // What Find in Folder… does with the folder picked. - app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(root.join("found"))); - let r = super::super::confirm(&mut app).unwrap(); - assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); - assert_eq!(kind(&app), Some(KIND)); - searched(&mut app); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("state"), pick(d, "a.png"), pick(d, "b.png")), ("done".to_string(), &json!(a), &json!(b))); - let text = frame(&mut app); - for label in - ["Find Linked Files", "a.png", "b.png", "Same file", "Don't Relink", "Find in Folder…", "Copy into the document's folder", "Relink"] - { - assert!(text.contains(label), "{label} in {text}"); - } - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - assert_eq!(app.ui.status, "Relinked 2 image(s)"); - assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 0); - } - - /// The files left without a file, or whose file fails to relink, go back to Linked File Not - /// Found; the failure is named in the status line, and the other files still relink. - #[test] - fn files_not_relinked_go_back_to_linked_file_not_found() { - let root = folder("back"); - let mut app = app_for(&root); - let bytes = opened_without(&mut app, &root, &["a.png", "b.png", "c.png"]); - write(&at(&root, "found/a.png"), &bytes[0]); - write(&at(&root, "found/b.png"), b"not an image"); - app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(root.join("found"))); - super::super::confirm(&mut app).unwrap(); - searched(&mut app); - let d = app.ui.dialog.as_ref().unwrap(); - // b.png's only file is chosen, c.png has none. - assert_eq!((pick(d, "b.png"), pick(d, "c.png")), (&json!(at(&root, "found/b.png")), &Value::Null)); - assert!(frame(&mut app).contains("Not found")); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.status.starts_with("Relinked 1 image(s); b.png couldn't be relinked: "), "{}", app.ui.status); - let d = app.ui.dialog.as_ref().expect("Linked File Not Found again"); - let names: Vec<&str> = d.fields["missing"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap()).collect(); - assert_eq!((d.kind.as_str(), names.len()), (super::super::missing_links::KIND, 2)); - assert!(names.contains(&"b.png") && names.contains(&"c.png"), "{names:?}"); - super::super::cancel(&mut app); - assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 2); - } - - /// A file is chosen when the choice is clear: the only same file, else the only file. A row the - /// user chose for keeps its choice as the search goes on. - #[test] - fn a_file_is_chosen_only_when_the_choice_is_clear() { - let row = |p: &str| json!({"name": "a.png", "path": p, "ids": [1]}); - let mut d = Dialog::new(KIND, json!({"missing": [row("/x/a.png"), row("/y/a.png"), row("/z/a.png")], "picks": [], "touched": []})); - let file = |p: &str, m: &str| json!({"path": p, "match": m}); - let answer = |y: Vec| { - json!({"state": "searching", "links": [ - {"path": "/x/a.png", "ids": [1], "files": [file("/f/a.png", "same"), file("/g/a.png", "name")]}, - {"path": "/y/a.png", "ids": [1], "files": y}, - {"path": "/z/a.png", "ids": [1], "files": [file("/f/a.png", "size"), file("/g/a.png", "name")]}, - ]}) - }; - update(&mut d, &answer(vec![file("/f/a.png", "name")])); - assert_eq!(d.fields["picks"], json!(["/f/a.png", "/f/a.png", null])); - // Don't Relink chosen for the first row: the next answer leaves it, and the second row has - // two files now. - d.fields.insert("picks".into(), json!([null, "/f/a.png", null])); - d.fields.insert("touched".into(), json!([0])); - update(&mut d, &answer(vec![file("/f/a.png", "name"), file("/g/a.png", "name")])); - assert_eq!(d.fields["picks"], json!([null, null, null])); - } - - /// A search of another folder starts the choices over: a file chosen from the last folder's - /// results isn't kept. - #[test] - fn another_folder_starts_the_choices_over() { - let root = folder("again"); - let mut app = app_for(&root); - opened_without(&mut app, &root, &["a.png"]); - super::super::cancel(&mut app); - // Two files of the name and neither the same file: the search reads both folders. - write(&at(&root, "one/a.png"), b"one file"); - write(&at(&root, "one/x/a.png"), b"another file"); - std::fs::create_dir_all(root.join("two")).unwrap(); - app.run("ui.findLinksInFolder", json!({"folder": root.join("one")})).unwrap(); - searched(&mut app); - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["found"][0]["files"].as_array().map(Vec::len), Some(2)); - // The second file chosen, then another folder searched, which has no file of the name. - let d = app.ui.dialog.as_mut().unwrap(); - d.fields.insert("picks".into(), json!([at(&root, "one/x/a.png")])); - d.fields.insert("touched".into(), json!([0])); - d.fields.insert("searchFolder".into(), json!(root.join("two"))); - super::super::confirm(&mut app).unwrap(); - searched(&mut app); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((pick(d, "a.png"), &d.fields["touched"]), (&Value::Null, &json!([]))); - } - - #[test] - fn cancel_closes_the_dialog_and_stops_its_search() { - let root = folder("cancel"); - // Enough folders that the search usually still runs when the dialog closes. - for a in 0..40 { - for b in 0..40 { - std::fs::create_dir_all(root.join(format!("tree/f{a}/g{b}"))).unwrap(); - } - } - let mut app = app_for(&root); - app.session.search_threads = Some(1); - opened_without(&mut app, &root, &["a.png"]); - super::super::cancel(&mut app); - app.run("ui.findLinksInFolder", json!({"folder": root.join("tree")})).unwrap(); - let id = app.ui.links_search.expect("the dialog's search"); - assert_eq!((kind(&app), app.ui.dialog.as_ref().unwrap().bool("back")), (Some(KIND), false)); - super::super::cancel(&mut app); - assert!(app.ui.dialog.is_none() && app.ui.links_search.is_none()); - // Stopped by the close, unless the walker finished the folders first. - let r = app.session.execute("links.findFiles", &json!({})).unwrap(); - assert_eq!(r["id"].as_u64(), Some(id), "{r}"); - match r["state"].as_str() { - Some("stopped") => assert_eq!(r["stopped"], "stop", "{r}"), - state => assert_eq!(state, Some("done"), "{r}"), - } - } - - /// Copy into the document's folder, for a saved document: the file is copied there first. - #[test] - fn copy_into_the_documents_folder_copies_before_relinking() { - let root = folder("copy"); - let mut app = app_for(&root); - let bytes = opened_without(&mut app, &root, &["a.png"]); - super::super::cancel(&mut app); - write(&at(&root, "found/a.png"), &bytes[0]); - app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); - searched(&mut app); - assert!(frame(&mut app).contains("Copy into the document's folder")); - app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); - let r = super::super::confirm(&mut app).unwrap(); - assert_eq!((r["relinked"].as_array().unwrap().len(), app.ui.dialog.is_none()), (1, true), "{r}"); - let copy = at(&root, "doc/a.png"); - assert_eq!(std::fs::read(©).unwrap(), bytes[0]); - let c = app.run("links.check", json!({})).unwrap(); - assert_eq!((c["missing"].as_u64(), c["links"][0]["path"].as_str()), (Some(0), Some(&*copy.to_string_lossy())), "{c}"); - // An unsaved document has no folder to copy into. - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let id = app.run("file.place", json!({"path": at(&root, "found/a.png")})).unwrap()["ids"][0].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); - searched(&mut app); - assert!(!frame(&mut app).contains("Copy into the document's folder")); - } - - /// With Copy into the document's folder, a placed document is relinked where its file is. - #[test] - fn copy_leaves_placed_documents_where_they_are() { - let root = folder("placed"); - let mut app = app_for(&root); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); - let badge = at(&root, "orig/badge.vectorcraft"); - std::fs::create_dir_all(badge.parent().unwrap()).unwrap(); - app.run("document.save", json!({"path": badge})).unwrap(); - let found = at(&root, "found/badge.vectorcraft"); - write(&found, &std::fs::read(&badge).unwrap()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("file.place", json!({"path": badge, "link": true})).unwrap(); - let doc = at(&root, "doc/doc.vectorcraft"); - std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); - app.run("document.save", json!({"path": doc})).unwrap(); - std::fs::remove_dir_all(root.join("orig")).unwrap(); - crate::io::open_path(&mut app, &doc.to_string_lossy()).unwrap(); - super::super::cancel(&mut app); - app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); - searched(&mut app); - app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); - let r = super::super::confirm(&mut app).unwrap(); - assert_eq!((r["relinked"].as_array().unwrap().len(), &r["failed"]), (1, &json!([])), "{r}"); - assert!(!at(&root, "doc/badge.vectorcraft").exists()); - assert_eq!(app.run("links.check", json!({})).unwrap()["links"][0]["path"], json!(found)); - } - - /// The Links panel's Find in Folder… (`ui.findLinksInFolder`): the selected linked files, else - /// the missing ones, in a folder the user picks. - #[test] - fn the_links_panel_looks_for_the_selected_or_the_missing_files() { - let root = folder("panel"); - let mut app = app_for(&root); - app.services.pick_folder = None; - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - assert!(!crate::menus::enabled(&app, "ui.findLinksInFolder"), "no folder picker (the web)"); - let picked = root.join("found").to_string_lossy().into_owned(); - let answer = picked.clone(); - app.services.pick_folder = Some(Box::new(move || Some(answer.clone()))); - assert!(crate::menus::enabled(&app, "ui.findLinksInFolder")); - // Nothing to look for: no folder is asked for. - assert_eq!(app.run("ui.findLinksInFolder", json!({})).err().as_deref(), Some("No linked files to look for")); - let here = at(&root, "found/here.png"); - write(&here, &png(1)); - let id = app.run("file.place", json!({"path": here})).unwrap()["ids"][0].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - let menu = crate::tests_labels::painted_text(&mut app, crate::panels::links::menu); - assert!(menu.contains("Find in Folder…"), "{menu}"); - app.run("ui.findLinksInFolder", json!({})).unwrap(); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.kind.as_str(), d.fields["missing"][0]["name"].as_str(), d.str("folder")), (KIND, Some("here.png"), picked)); - // here.png isn't missing: the dialog asks about linked files. - assert!(frame(&mut app).contains("Choose the file to relink each linked file to.")); - searched(&mut app); - assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here)); - } - - /// The Links panel's Find in Folder… asks for the folder off the UI thread (#867): Find Linked - /// Files opens on the folder when the folder picker answers. - #[test] - fn find_in_folder_asks_for_the_folder_off_the_ui_thread() { - let root = folder("later"); - let mut app = app_for(&root); - let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut app.services); - app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let here = at(&root, "found/here.png"); - write(&here, &png(1)); - let id = app.run("file.place", json!({"path": here})).unwrap()["ids"][0].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - // Nothing opens until the picker answers. - assert!(app.run("ui.findLinksInFolder", json!({})).is_err()); - assert_eq!((shown.requests(), kind(&app)), (vec![PickRequest::Folder], None)); - let found = root.join("found").to_string_lossy().into_owned(); - shown.answer(&[found.as_str()]); - crate::picks::poll(&mut app, &egui::Context::default()); - let d = app.ui.dialog.as_ref().expect("Find Linked Files"); - assert_eq!((d.kind.as_str(), d.str("folder")), (KIND, found)); - searched(&mut app); - assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here)); - } -} +mod tests; diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs new file mode 100644 index 000000000..fe8f38240 --- /dev/null +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -0,0 +1,334 @@ +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::findfiles::Rules; + +use super::*; + +fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) +} + +/// A fresh temporary folder (canonical, as a search reports it). +fn folder(tag: &str) -> PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("find-links-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()) +} + +/// An app that reads files, shows a folder picker and whose searches may go anywhere in `root`, +/// on two threads. +fn app_for(root: &Path) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.services.pick_folder = Some(Box::new(|| None)); + app.session.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() }); + app.session.search_threads = Some(2); + app +} + +/// `rel` (`/`-separated) inside `root`, with the platform's separators, as the app reports paths. +fn at(root: &Path, rel: &str) -> PathBuf { + rel.split('/').fold(root.to_path_buf(), |p, c| p.join(c)) +} + +fn write(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// A PNG of one color, a different one for each `n`. +fn png(n: u8) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(40, 30, image::Rgba([200, n.wrapping_mul(40), 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out +} + +/// A document linking to files `names` in `root/orig`, saved as `root/doc/doc.vectorcraft`; the +/// files are then deleted and the document opened again → each file's bytes. +fn opened_without(app: &mut VectorcraftApp, root: &Path, names: &[&str]) -> Vec> { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut files = vec![]; + for (n, name) in (0..).zip(names) { + let path = root.join("orig").join(name); + write(&path, &png(n)); + app.run("file.place", json!({"path": path})).unwrap(); + files.push(png(n)); + } + let doc = at(root, "doc/doc.vectorcraft"); + std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": doc})).unwrap(); + std::fs::remove_dir_all(root.join("orig")).unwrap(); + crate::io::open_path(app, &doc.to_string_lossy()).unwrap(); + files +} + +/// Draw the dialog until its search ends (10 seconds at most). +fn searched(app: &mut VectorcraftApp) { + let until = Instant::now() + Duration::from_secs(10); + while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until { + frame(app); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) +} + +/// The pick of the row named `name`. +fn pick<'a>(d: &'a Dialog, name: &str) -> &'a Value { + let i = d.fields["missing"].as_array().unwrap().iter().position(|r| r["name"] == name).unwrap(); + &d.fields["picks"][i] +} + +/// Find in Folder… in Linked File Not Found: each missing file's same file is chosen, and Relink +/// relinks them. +#[test] +fn find_in_folder_relinks_the_missing_files_to_the_files_found() { + let root = folder("relink"); + let mut app = app_for(&root); + let bytes = opened_without(&mut app, &root, &["a.png", "b.png"]); + assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); + assert!(frame(&mut app).contains("Find in Folder…")); + let (a, b) = (at(&root, "found/sub/a.png"), at(&root, "found/b.png")); + write(&a, &bytes[0]); + write(&b, &bytes[1]); + write(&at(&root, "found/other/b.png"), b"another file"); + // What Find in Folder… does with the folder picked. + app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(root.join("found"))); + let r = super::super::confirm(&mut app).unwrap(); + assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); + assert_eq!(kind(&app), Some(KIND)); + searched(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("state"), pick(d, "a.png"), pick(d, "b.png")), ("done".to_string(), &json!(a), &json!(b))); + let text = frame(&mut app); + for label in + ["Find Linked Files", "a.png", "b.png", "Same file", "Don't Relink", "Find in Folder…", "Copy into the document's folder", "Relink"] + { + assert!(text.contains(label), "{label} in {text}"); + } + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, "Relinked 2 image(s)"); + assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 0); +} + +/// The files left without a file, or whose file fails to relink, go back to Linked File Not +/// Found; the failure is named in the status line, and the other files still relink. +#[test] +fn files_not_relinked_go_back_to_linked_file_not_found() { + let root = folder("back"); + let mut app = app_for(&root); + let bytes = opened_without(&mut app, &root, &["a.png", "b.png", "c.png"]); + write(&at(&root, "found/a.png"), &bytes[0]); + write(&at(&root, "found/b.png"), b"not an image"); + app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(root.join("found"))); + super::super::confirm(&mut app).unwrap(); + searched(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + // b.png's only file is chosen, c.png has none. + assert_eq!((pick(d, "b.png"), pick(d, "c.png")), (&json!(at(&root, "found/b.png")), &Value::Null)); + assert!(frame(&mut app).contains("Not found")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.status.starts_with("Relinked 1 image(s); b.png couldn't be relinked: "), "{}", app.ui.status); + let d = app.ui.dialog.as_ref().expect("Linked File Not Found again"); + let names: Vec<&str> = d.fields["missing"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap()).collect(); + assert_eq!((d.kind.as_str(), names.len()), (super::super::missing_links::KIND, 2)); + assert!(names.contains(&"b.png") && names.contains(&"c.png"), "{names:?}"); + super::super::cancel(&mut app); + assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 2); +} + +/// A file is chosen when the choice is clear: the only same file, else the only file. A row the +/// user chose for keeps its choice as the search goes on. +#[test] +fn a_file_is_chosen_only_when_the_choice_is_clear() { + let row = |p: &str| json!({"name": "a.png", "path": p, "ids": [1]}); + let mut d = Dialog::new(KIND, json!({"missing": [row("/x/a.png"), row("/y/a.png"), row("/z/a.png")], "picks": [], "touched": []})); + let file = |p: &str, m: &str| json!({"path": p, "match": m}); + let answer = |y: Vec| { + json!({"state": "searching", "links": [ + {"path": "/x/a.png", "ids": [1], "files": [file("/f/a.png", "same"), file("/g/a.png", "name")]}, + {"path": "/y/a.png", "ids": [1], "files": y}, + {"path": "/z/a.png", "ids": [1], "files": [file("/f/a.png", "size"), file("/g/a.png", "name")]}, + ]}) + }; + update(&mut d, &answer(vec![file("/f/a.png", "name")])); + assert_eq!(d.fields["picks"], json!(["/f/a.png", "/f/a.png", null])); + // Don't Relink chosen for the first row: the next answer leaves it, and the second row has + // two files now. + d.fields.insert("picks".into(), json!([null, "/f/a.png", null])); + d.fields.insert("touched".into(), json!([0])); + update(&mut d, &answer(vec![file("/f/a.png", "name"), file("/g/a.png", "name")])); + assert_eq!(d.fields["picks"], json!([null, null, null])); +} + +/// A search of another folder starts the choices over: a file chosen from the last folder's +/// results isn't kept. +#[test] +fn another_folder_starts_the_choices_over() { + let root = folder("again"); + let mut app = app_for(&root); + opened_without(&mut app, &root, &["a.png"]); + super::super::cancel(&mut app); + // Two files of the name and neither the same file: the search reads both folders. + write(&at(&root, "one/a.png"), b"one file"); + write(&at(&root, "one/x/a.png"), b"another file"); + std::fs::create_dir_all(root.join("two")).unwrap(); + app.run("ui.findLinksInFolder", json!({"folder": root.join("one")})).unwrap(); + searched(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["found"][0]["files"].as_array().map(Vec::len), Some(2)); + // The second file chosen, then another folder searched, which has no file of the name. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("picks".into(), json!([at(&root, "one/x/a.png")])); + d.fields.insert("touched".into(), json!([0])); + d.fields.insert("searchFolder".into(), json!(root.join("two"))); + super::super::confirm(&mut app).unwrap(); + searched(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((pick(d, "a.png"), &d.fields["touched"]), (&Value::Null, &json!([]))); +} + +#[test] +fn cancel_closes_the_dialog_and_stops_its_search() { + let root = folder("cancel"); + // Enough folders that the search usually still runs when the dialog closes. + for a in 0..40 { + for b in 0..40 { + std::fs::create_dir_all(root.join(format!("tree/f{a}/g{b}"))).unwrap(); + } + } + let mut app = app_for(&root); + app.session.search_threads = Some(1); + opened_without(&mut app, &root, &["a.png"]); + super::super::cancel(&mut app); + app.run("ui.findLinksInFolder", json!({"folder": root.join("tree")})).unwrap(); + let id = app.ui.links_search.expect("the dialog's search"); + assert_eq!((kind(&app), app.ui.dialog.as_ref().unwrap().bool("back")), (Some(KIND), false)); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.links_search.is_none()); + // Stopped by the close, unless the walker finished the folders first. + let r = app.session.execute("links.findFiles", &json!({})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(id), "{r}"); + match r["state"].as_str() { + Some("stopped") => assert_eq!(r["stopped"], "stop", "{r}"), + state => assert_eq!(state, Some("done"), "{r}"), + } +} + +/// Copy into the document's folder, for a saved document: the file is copied there first. +#[test] +fn copy_into_the_documents_folder_copies_before_relinking() { + let root = folder("copy"); + let mut app = app_for(&root); + let bytes = opened_without(&mut app, &root, &["a.png"]); + super::super::cancel(&mut app); + write(&at(&root, "found/a.png"), &bytes[0]); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); + searched(&mut app); + assert!(frame(&mut app).contains("Copy into the document's folder")); + app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!((r["relinked"].as_array().unwrap().len(), app.ui.dialog.is_none()), (1, true), "{r}"); + let copy = at(&root, "doc/a.png"); + assert_eq!(std::fs::read(©).unwrap(), bytes[0]); + let c = app.run("links.check", json!({})).unwrap(); + assert_eq!((c["missing"].as_u64(), c["links"][0]["path"].as_str()), (Some(0), Some(&*copy.to_string_lossy())), "{c}"); + // An unsaved document has no folder to copy into. + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("file.place", json!({"path": at(&root, "found/a.png")})).unwrap()["ids"][0].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); + searched(&mut app); + assert!(!frame(&mut app).contains("Copy into the document's folder")); +} + +/// With Copy into the document's folder, a placed document is relinked where its file is. +#[test] +fn copy_leaves_placed_documents_where_they_are() { + let root = folder("placed"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let badge = at(&root, "orig/badge.vectorcraft"); + std::fs::create_dir_all(badge.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": badge})).unwrap(); + let found = at(&root, "found/badge.vectorcraft"); + write(&found, &std::fs::read(&badge).unwrap()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("file.place", json!({"path": badge, "link": true})).unwrap(); + let doc = at(&root, "doc/doc.vectorcraft"); + std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": doc})).unwrap(); + std::fs::remove_dir_all(root.join("orig")).unwrap(); + crate::io::open_path(&mut app, &doc.to_string_lossy()).unwrap(); + super::super::cancel(&mut app); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found")})).unwrap(); + searched(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!((r["relinked"].as_array().unwrap().len(), &r["failed"]), (1, &json!([])), "{r}"); + assert!(!at(&root, "doc/badge.vectorcraft").exists()); + assert_eq!(app.run("links.check", json!({})).unwrap()["links"][0]["path"], json!(found)); +} + +/// The Links panel's Find in Folder… (`ui.findLinksInFolder`): the selected linked files, else +/// the missing ones, in a folder the user picks. +#[test] +fn the_links_panel_looks_for_the_selected_or_the_missing_files() { + let root = folder("panel"); + let mut app = app_for(&root); + app.services.pick_folder = None; + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.findLinksInFolder"), "no folder picker (the web)"); + let picked = root.join("found").to_string_lossy().into_owned(); + let answer = picked.clone(); + app.services.pick_folder = Some(Box::new(move || Some(answer.clone()))); + assert!(crate::menus::enabled(&app, "ui.findLinksInFolder")); + // Nothing to look for: no folder is asked for. + assert_eq!(app.run("ui.findLinksInFolder", json!({})).err().as_deref(), Some("No linked files to look for")); + let here = at(&root, "found/here.png"); + write(&here, &png(1)); + let id = app.run("file.place", json!({"path": here})).unwrap()["ids"][0].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let menu = crate::tests_labels::painted_text(&mut app, crate::panels::links::menu); + assert!(menu.contains("Find in Folder…"), "{menu}"); + app.run("ui.findLinksInFolder", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["missing"][0]["name"].as_str(), d.str("folder")), (KIND, Some("here.png"), picked)); + // here.png isn't missing: the dialog asks about linked files. + assert!(frame(&mut app).contains("Choose the file to relink each linked file to.")); + searched(&mut app); + assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here)); +} + +/// The Links panel's Find in Folder… asks for the folder off the UI thread (#867): Find Linked +/// Files opens on the folder when the folder picker answers. +#[test] +fn find_in_folder_asks_for_the_folder_off_the_ui_thread() { + let root = folder("later"); + let mut app = app_for(&root); + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut app.services); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let here = at(&root, "found/here.png"); + write(&here, &png(1)); + let id = app.run("file.place", json!({"path": here})).unwrap()["ids"][0].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + // Nothing opens until the picker answers. + assert!(app.run("ui.findLinksInFolder", json!({})).is_err()); + assert_eq!((shown.requests(), kind(&app)), (vec![PickRequest::Folder], None)); + let found = root.join("found").to_string_lossy().into_owned(); + shown.answer(&[found.as_str()]); + crate::picks::poll(&mut app, &egui::Context::default()); + let d = app.ui.dialog.as_ref().expect("Find Linked Files"); + assert_eq!((d.kind.as_str(), d.str("folder")), (KIND, found)); + searched(&mut app); + assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here)); +} From 1101e04da57e1a8e7ee55a4642ea34dd89761c9c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:23 +0000 Subject: [PATCH 078/199] refactor(svg): move import/css.rs tests to import/css/tests.rs --- crates/svg/src/import/css.rs | 335 +---------------------------- crates/svg/src/import/css/tests.rs | 332 ++++++++++++++++++++++++++++ 2 files changed, 333 insertions(+), 334 deletions(-) create mode 100644 crates/svg/src/import/css/tests.rs diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 4db4b22f1..92b61a70a 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -485,337 +485,4 @@ impl Styles { } #[cfg(test)] -mod tests { - use proptest::prelude::*; - - use super::*; - - #[test] - fn important_flag_is_case_insensitive() { - for s in ["fill: red !IMPORTANT", "fill: red !Important", "fill: red ! important"] { - assert_eq!(parse_decls(s), [("fill".to_string(), "red".to_string(), true)], "{s}"); - } - assert_eq!(parse_decls("fill: red !importantx"), [("fill".to_string(), "red !importantx".to_string(), false)]); - } - - #[test] - fn semicolons_in_quotes_and_parens_do_not_split() { - let d = |k: &str, v: &str| (k.to_string(), v.to_string(), false); - assert_eq!(parse_decls("font-family:'A;B'; fill:red"), [d("font-family", "'A;B'"), d("fill", "red")]); - assert_eq!(parse_decls("font-family:\"A;B\"; fill:red"), [d("font-family", "\"A;B\""), d("fill", "red")]); - assert_eq!( - parse_decls("fill:url(data:image/svg+xml;base64,AAA); stroke:red"), - [d("fill", "url(data:image/svg+xml;base64,AAA)"), d("stroke", "red")] - ); - let _ = parse_decls("font-family:'A;B; fill:red"); - } - - fn with_doc(svg: &str, f: impl FnOnce(&roxmltree::Document, &Styles)) { - let doc = roxmltree::Document::parse(svg).unwrap(); - let st = Styles::new(&doc); - f(&doc, &st); - } - - fn by_id<'a, 'i>(doc: &'a roxmltree::Document<'i>, id: &str) -> XNode<'a, 'i> { - doc.descendants().find(|n| n.attribute("id") == Some(id)).unwrap() - } - - #[test] - fn selectors_and_precedence() { - with_doc( - r#" - x - x - x - x - "#, - |doc, st| { - assert_eq!(st.own(by_id(doc, "t0"), "fill").as_deref(), Some("#111")); - assert_eq!(st.own(by_id(doc, "t1"), "fill").as_deref(), Some("#333"), "child + class beats descendant"); - assert_eq!(st.own(by_id(doc, "t1"), "stroke").as_deref(), Some("blue"), "multiple classes"); - assert_eq!(st.own(by_id(doc, "t2"), "fill").as_deref(), Some("#666"), "style attribute beats #id"); - assert_eq!(st.own(by_id(doc, "t3"), "fill").as_deref(), Some("#555"), "!important beats style"); - assert_eq!(st.own(by_id(doc, "t3"), "font-weight").as_deref(), Some("bold")); - assert_eq!(st.own(by_id(doc, "t2"), "stroke"), None, "`.a.b` needs both classes"); - assert_eq!(st.prop(by_id(doc, "t2"), "font-size").as_deref(), Some("9px")); - }, - ); - } - - #[test] - fn descendant_matching_does_not_backtrack_combinatorially() { - // Many nested groups share class `a` and the first rule stacks eight descendant - // `.a` compounds under a leftmost class that matches nothing: an unmemoized - // matcher enumerates ancestor combinations for it and effectively never - // finishes (#1703). With suffix states memoized this returns at once, and the - // matching descendant and mixed child/descendant rules still apply. - let depth = 48; - let mut svg = String::from( - r#""#, - ); - for _ in 0..depth { - svg.push_str(r#""#); - } - svg.push_str(r#"x"#); - for _ in 0..depth { - svg.push_str(""); - } - svg.push_str(""); - with_doc(&svg, |doc, st| { - let t = by_id(doc, "t"); - assert_eq!(st.own(t, "fill").as_deref(), Some("#222"), "descendant rule matches, the .missing one does not"); - assert_eq!(st.own(t, "stroke").as_deref(), Some("blue"), "mixed child/descendant rule matches"); - }); - } - - #[test] - fn presentation_attributes_lose_to_rules() { - with_doc( - r#"x"#, - |doc, st| { - assert_eq!(st.own(by_id(doc, "a"), "fill").as_deref(), Some("blue")); - assert_eq!(st.own(by_id(doc, "a"), "stroke").as_deref(), Some("red")); - }, - ); - } - - /// What [`Styles::declared`] gave before the rules were indexed: every rule tested against `n`. - fn declared_by_every_rule(st: &Styles, n: XNode) -> HashMap { - type Key = (bool, bool, (u32, u32, u32), usize); - let mut best: HashMap = HashMap::new(); - let mut put = |name: &str, value: &str, key: Key| { - if best.get(name).is_none_or(|(k, _)| key >= *k) { - best.insert(name.to_string(), (key, value.to_string())); - } - }; - let mut order = 0; - for r in st.rules.iter().filter(|r| r.selector.matches(n)) { - for (k, v, imp) in r.decls.iter() { - order += 1; - put(k, v, (*imp, false, r.selector.specificity, order)); - } - } - if let Some(s) = n.attribute("style") { - for (k, v, imp) in parse_decls(s) { - order += 1; - put(&k, &v, (imp, true, (0, 0, 0), order)); - } - } - best.into_iter().map(|(k, (_, v))| (k, v)).collect() - } - - /// Checks that each element of `svg` gets the properties [`declared_by_every_rule`] gives it. - fn assert_same_as_every_rule(svg: &str) { - let doc = roxmltree::Document::parse(svg).unwrap(); - let st = Styles::new(&doc); - for n in doc.descendants().filter(|n| n.is_element()) { - assert_eq!(*st.declared(n), declared_by_every_rule(&st, n), "{n:?} in {svg}"); - } - } - - /// Id, class, type, universal and attribute selectors, descendant and child combinators, and - /// rules that override each other give each element what testing every rule gives it. - #[test] - fn indexed_rules_give_what_testing_every_rule_gives() { - let svg = r#" - - - - - - - tu - - "#; - assert_same_as_every_rule(svg); - with_doc(svg, |doc, st| { - let own = |id: &str, name: &str| st.own(by_id(doc, id), name); - assert_eq!(own("a", "fill").as_deref(), Some("f2"), "#a beats rect.a"); - assert_eq!(own("r2", "fill").as_deref(), Some("f4"), "rect.a beats g rect"); - assert_eq!(own("r2", "stroke").as_deref(), Some("s5"), "classes apart by a tab and a line feed, one repeated"); - assert_eq!(own("r3", "fill").as_deref(), Some("f8"), "!important"); - assert_eq!(own("r3", "display").as_deref(), Some("none")); - assert_eq!(own("r4", "fill").as_deref(), Some("f1"), "g rect beats rect"); - assert_eq!(own("r4", "font-size").as_deref(), Some("9px")); - assert_eq!(own("c5", "fill").as_deref(), Some("f11"), "of rules alike, the last one"); - assert_eq!(own("c5", "stroke").as_deref(), Some("s")); - assert_eq!(own("t7", "font-size").as_deref(), Some("8px")); - assert_eq!(own("r8", "fill").as_deref(), Some("f9")); - }); - } - - /// An element's candidates hold each rule once, however often the element repeats a class. - #[test] - fn repeated_classes_give_each_rule_once() { - with_doc( - r#""#, - |doc, st| assert_eq!(st.index.candidates(by_id(doc, "r")), [0, 1, 2, 3]), - ); - } - - /// A compound selector over a few tags, ids, classes and attributes. - fn arb_compound() -> impl Strategy { - let tag = prop::sample::select(vec!["", "*", "g", "rect", "text", "tspan"]); - let id = prop::sample::select(vec!["", "#a", "#b"]); - let classes = prop::collection::vec(prop::sample::select(vec![".a", ".b", ".c"]), 0..3); - let attr = prop::sample::select(vec!["", "[data-k]", "[data-k=v]"]); - (tag, id, classes, attr).prop_map(|(t, i, c, a)| match format!("{t}{i}{}{a}", c.concat()) { - s if s.is_empty() => "*".to_string(), - s => s, - }) - } - - /// One to three compounds joined by descendant and child combinators. - fn arb_selector() -> impl Strategy { - (arb_compound(), prop::collection::vec((prop::sample::select(vec![" ", " > ", ">"]), arb_compound()), 0..3)) - .prop_map(|(first, rest)| rest.into_iter().fold(first, |s, (comb, c)| format!("{s}{comb}{c}"))) - } - - /// A rule of one or two selectors that sets one to three properties, some of them `!important`. - fn arb_rule() -> impl Strategy { - let decl = (prop::sample::select(vec!["fill", "stroke", "display"]), 0..1000u16, any::()) - .prop_map(|(name, v, important)| format!("{name}: v{v}{}", if important { " !important" } else { "" })); - (prop::collection::vec(arb_selector(), 1..3), prop::collection::vec(decl, 1..4)) - .prop_map(|(selectors, decls)| format!("{} {{ {} }}", selectors.join(", "), decls.join("; "))) - } - - /// An element that may have an id, classes (some repeated, apart by spaces, tabs and line feeds) - /// and a `data-k` or `style` attribute, with up to four children on each of the `depth` levels - /// below it. - fn arb_element(depth: u32) -> BoxedStrategy { - let tag = prop::sample::select(vec!["g", "rect", "text", "tspan"]); - let id = prop::sample::select(vec!["", r#" id="a""#, r#" id="b""#, r#" id="c""#]); - let classes = prop::collection::vec((prop::sample::select(vec![" ", " ", " "]), prop::sample::select(vec!["a", "b", "c", "x"])), 0..4) - .prop_map(|v| match v.iter().map(|(gap, c)| format!("{gap}{c}")).collect::() { - s if s.is_empty() => s, - s => format!(r#" class="{s}""#), - }); - let more = prop::sample::select(vec!["", r#" data-k="v""#, r#" data-k="w""#, r#" style="fill: s""#, r#" style="stroke: s !important""#]); - let children = if depth == 0 { Just(vec![]).boxed() } else { prop::collection::vec(arb_element(depth - 1), 0..5).boxed() }; - (tag, id, classes, more, children).prop_map(|(t, i, c, m, ch)| format!("<{t}{i}{c}{m}>{}", ch.concat())).boxed() - } - - proptest! { - #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] - - /// Random style sheets give each element of a random document what testing every rule - /// gives it. - #[test] - fn random_sheets_give_what_testing_every_rule_gives(rules in prop::collection::vec(arb_rule(), 0..24), body in prop::collection::vec(arb_element(3), 1..6)) { - assert_same_as_every_rule(&format!(r#"{}"#, rules.join("\n"), body.concat())); - } - } - - /// 50,000 class rules give 50,000 elements, each with one of the classes, their fill within - /// 20 s. - #[test] - fn styling_50000_elements_with_50000_rules_finishes_within_20_s() { - const N: usize = 50_000; - let rules: String = (0..N).map(|i| format!(".c{i} {{ fill: #{i:06x} }}\n")).collect(); - let rects: String = (0..N).map(|i| format!(r#""#)).collect(); - let svg = format!(r#"{rects}"#); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let doc = roxmltree::Document::parse(&svg).unwrap(); - let st = Styles::new(&doc); - let fills: Vec> = doc.descendants().filter(|n| n.tag_name().name() == "rect").map(|n| st.own(n, "fill")).collect(); - tx.send(fills).ok(); - }); - let fills = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 20 s")); - assert_eq!(fills.len(), N); - for (i, fill) in fills.iter().enumerate() { - assert_eq!(fill.as_deref(), Some(format!("#{i:06x}").as_str())); - } - } - - #[test] - fn the_font_shorthand_sets_its_longhands() { - let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense"); - let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::>(); - assert_eq!(get("font-style"), [("italic", false)]); - assert_eq!(get("font-weight"), [("bold", false), ("300", true)]); - assert_eq!(get("font-size"), [("30px", false)]); - assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]); - assert_eq!(get("font-variant"), [("normal", false)]); - assert_eq!(get("fill"), [("red", false)]); - assert!(get("font").is_empty(), "the shorthand itself is not kept"); - } - - #[test] - fn unsupported_selectors_are_dropped() { - for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", "", "a:not(b c)", "a:not(.x", "a:not(b > c)", "a:not(:hover)"] { - assert!(Selector::parse(s).is_none(), "{s}"); - } - let s = Selector::parse("g > text.x#y").unwrap(); - assert_eq!(s.specificity, (1, 1, 2)); - assert_eq!(s.parts.len(), 2); - assert!(s.parts[0].1, "text must be a child of g"); - } - - /// #1557: `:not()` of a list of compound selectors; it counts as its most specific argument. - #[test] - fn not_selectors() { - assert_eq!(Selector::parse("rect:not(.keep)").unwrap().specificity, (0, 1, 1)); - assert_eq!(Selector::parse("g :not(#a, .b.c, text)").unwrap().specificity, (1, 0, 1)); - assert_eq!(Selector::parse(":not(rect):not([x])").unwrap().specificity, (0, 1, 1)); - // Nesting is bounded: a hostile sheet can't recurse without end. - let nested = |n: usize| format!("{}.x{}", ":not(".repeat(n), ")".repeat(n)); - assert!(Selector::parse(&nested(MAX_NOT_NESTING)).is_some()); - assert!(Selector::parse(&nested(MAX_NOT_NESTING + 1)).is_none()); - assert!(Selector::parse(&nested(200_000)).is_none()); - with_doc( - r#" - - xx - "#, - |doc, st| { - assert_eq!(st.own(by_id(doc, "r0"), "fill").as_deref(), Some("green")); - assert_eq!(st.own(by_id(doc, "r1"), "fill").as_deref(), Some("red")); - assert_eq!(st.own(by_id(doc, "t0"), "stroke").as_deref(), Some("blue")); - assert_eq!(st.own(by_id(doc, "t1"), "stroke"), None); - assert_eq!(st.own(by_id(doc, "r2"), "stroke"), None); - assert_eq!(st.own(by_id(doc, "r2"), "opacity").as_deref(), Some("0.5")); - assert_eq!(st.own(by_id(doc, "r0"), "opacity"), None); - assert!(st.has_not_rules()); - assert_eq!(st.set_by_not_rules(by_id(doc, "r0")), [("fill".to_string(), "green".to_string())]); - assert!(st.set_by_not_rules(by_id(doc, "r1")).is_empty()); - }, - ); - } -} +mod tests; diff --git a/crates/svg/src/import/css/tests.rs b/crates/svg/src/import/css/tests.rs new file mode 100644 index 000000000..89b519858 --- /dev/null +++ b/crates/svg/src/import/css/tests.rs @@ -0,0 +1,332 @@ +use proptest::prelude::*; + +use super::*; + +#[test] +fn important_flag_is_case_insensitive() { + for s in ["fill: red !IMPORTANT", "fill: red !Important", "fill: red ! important"] { + assert_eq!(parse_decls(s), [("fill".to_string(), "red".to_string(), true)], "{s}"); + } + assert_eq!(parse_decls("fill: red !importantx"), [("fill".to_string(), "red !importantx".to_string(), false)]); +} + +#[test] +fn semicolons_in_quotes_and_parens_do_not_split() { + let d = |k: &str, v: &str| (k.to_string(), v.to_string(), false); + assert_eq!(parse_decls("font-family:'A;B'; fill:red"), [d("font-family", "'A;B'"), d("fill", "red")]); + assert_eq!(parse_decls("font-family:\"A;B\"; fill:red"), [d("font-family", "\"A;B\""), d("fill", "red")]); + assert_eq!( + parse_decls("fill:url(data:image/svg+xml;base64,AAA); stroke:red"), + [d("fill", "url(data:image/svg+xml;base64,AAA)"), d("stroke", "red")] + ); + let _ = parse_decls("font-family:'A;B; fill:red"); +} + +fn with_doc(svg: &str, f: impl FnOnce(&roxmltree::Document, &Styles)) { + let doc = roxmltree::Document::parse(svg).unwrap(); + let st = Styles::new(&doc); + f(&doc, &st); +} + +fn by_id<'a, 'i>(doc: &'a roxmltree::Document<'i>, id: &str) -> XNode<'a, 'i> { + doc.descendants().find(|n| n.attribute("id") == Some(id)).unwrap() +} + +#[test] +fn selectors_and_precedence() { + with_doc( + r#" + x + x + x + x + "#, + |doc, st| { + assert_eq!(st.own(by_id(doc, "t0"), "fill").as_deref(), Some("#111")); + assert_eq!(st.own(by_id(doc, "t1"), "fill").as_deref(), Some("#333"), "child + class beats descendant"); + assert_eq!(st.own(by_id(doc, "t1"), "stroke").as_deref(), Some("blue"), "multiple classes"); + assert_eq!(st.own(by_id(doc, "t2"), "fill").as_deref(), Some("#666"), "style attribute beats #id"); + assert_eq!(st.own(by_id(doc, "t3"), "fill").as_deref(), Some("#555"), "!important beats style"); + assert_eq!(st.own(by_id(doc, "t3"), "font-weight").as_deref(), Some("bold")); + assert_eq!(st.own(by_id(doc, "t2"), "stroke"), None, "`.a.b` needs both classes"); + assert_eq!(st.prop(by_id(doc, "t2"), "font-size").as_deref(), Some("9px")); + }, + ); +} + +#[test] +fn descendant_matching_does_not_backtrack_combinatorially() { + // Many nested groups share class `a` and the first rule stacks eight descendant + // `.a` compounds under a leftmost class that matches nothing: an unmemoized + // matcher enumerates ancestor combinations for it and effectively never + // finishes (#1703). With suffix states memoized this returns at once, and the + // matching descendant and mixed child/descendant rules still apply. + let depth = 48; + let mut svg = String::from( + r#""#, + ); + for _ in 0..depth { + svg.push_str(r#""#); + } + svg.push_str(r#"x"#); + for _ in 0..depth { + svg.push_str(""); + } + svg.push_str(""); + with_doc(&svg, |doc, st| { + let t = by_id(doc, "t"); + assert_eq!(st.own(t, "fill").as_deref(), Some("#222"), "descendant rule matches, the .missing one does not"); + assert_eq!(st.own(t, "stroke").as_deref(), Some("blue"), "mixed child/descendant rule matches"); + }); +} + +#[test] +fn presentation_attributes_lose_to_rules() { + with_doc( + r#"x"#, + |doc, st| { + assert_eq!(st.own(by_id(doc, "a"), "fill").as_deref(), Some("blue")); + assert_eq!(st.own(by_id(doc, "a"), "stroke").as_deref(), Some("red")); + }, + ); +} + +/// What [`Styles::declared`] gave before the rules were indexed: every rule tested against `n`. +fn declared_by_every_rule(st: &Styles, n: XNode) -> HashMap { + type Key = (bool, bool, (u32, u32, u32), usize); + let mut best: HashMap = HashMap::new(); + let mut put = |name: &str, value: &str, key: Key| { + if best.get(name).is_none_or(|(k, _)| key >= *k) { + best.insert(name.to_string(), (key, value.to_string())); + } + }; + let mut order = 0; + for r in st.rules.iter().filter(|r| r.selector.matches(n)) { + for (k, v, imp) in r.decls.iter() { + order += 1; + put(k, v, (*imp, false, r.selector.specificity, order)); + } + } + if let Some(s) = n.attribute("style") { + for (k, v, imp) in parse_decls(s) { + order += 1; + put(&k, &v, (imp, true, (0, 0, 0), order)); + } + } + best.into_iter().map(|(k, (_, v))| (k, v)).collect() +} + +/// Checks that each element of `svg` gets the properties [`declared_by_every_rule`] gives it. +fn assert_same_as_every_rule(svg: &str) { + let doc = roxmltree::Document::parse(svg).unwrap(); + let st = Styles::new(&doc); + for n in doc.descendants().filter(|n| n.is_element()) { + assert_eq!(*st.declared(n), declared_by_every_rule(&st, n), "{n:?} in {svg}"); + } +} + +/// Id, class, type, universal and attribute selectors, descendant and child combinators, and +/// rules that override each other give each element what testing every rule gives it. +#[test] +fn indexed_rules_give_what_testing_every_rule_gives() { + let svg = r#" + + + + + + + tu + + "#; + assert_same_as_every_rule(svg); + with_doc(svg, |doc, st| { + let own = |id: &str, name: &str| st.own(by_id(doc, id), name); + assert_eq!(own("a", "fill").as_deref(), Some("f2"), "#a beats rect.a"); + assert_eq!(own("r2", "fill").as_deref(), Some("f4"), "rect.a beats g rect"); + assert_eq!(own("r2", "stroke").as_deref(), Some("s5"), "classes apart by a tab and a line feed, one repeated"); + assert_eq!(own("r3", "fill").as_deref(), Some("f8"), "!important"); + assert_eq!(own("r3", "display").as_deref(), Some("none")); + assert_eq!(own("r4", "fill").as_deref(), Some("f1"), "g rect beats rect"); + assert_eq!(own("r4", "font-size").as_deref(), Some("9px")); + assert_eq!(own("c5", "fill").as_deref(), Some("f11"), "of rules alike, the last one"); + assert_eq!(own("c5", "stroke").as_deref(), Some("s")); + assert_eq!(own("t7", "font-size").as_deref(), Some("8px")); + assert_eq!(own("r8", "fill").as_deref(), Some("f9")); + }); +} + +/// An element's candidates hold each rule once, however often the element repeats a class. +#[test] +fn repeated_classes_give_each_rule_once() { + with_doc( + r#""#, + |doc, st| assert_eq!(st.index.candidates(by_id(doc, "r")), [0, 1, 2, 3]), + ); +} + +/// A compound selector over a few tags, ids, classes and attributes. +fn arb_compound() -> impl Strategy { + let tag = prop::sample::select(vec!["", "*", "g", "rect", "text", "tspan"]); + let id = prop::sample::select(vec!["", "#a", "#b"]); + let classes = prop::collection::vec(prop::sample::select(vec![".a", ".b", ".c"]), 0..3); + let attr = prop::sample::select(vec!["", "[data-k]", "[data-k=v]"]); + (tag, id, classes, attr).prop_map(|(t, i, c, a)| match format!("{t}{i}{}{a}", c.concat()) { + s if s.is_empty() => "*".to_string(), + s => s, + }) +} + +/// One to three compounds joined by descendant and child combinators. +fn arb_selector() -> impl Strategy { + (arb_compound(), prop::collection::vec((prop::sample::select(vec![" ", " > ", ">"]), arb_compound()), 0..3)) + .prop_map(|(first, rest)| rest.into_iter().fold(first, |s, (comb, c)| format!("{s}{comb}{c}"))) +} + +/// A rule of one or two selectors that sets one to three properties, some of them `!important`. +fn arb_rule() -> impl Strategy { + let decl = (prop::sample::select(vec!["fill", "stroke", "display"]), 0..1000u16, any::()) + .prop_map(|(name, v, important)| format!("{name}: v{v}{}", if important { " !important" } else { "" })); + (prop::collection::vec(arb_selector(), 1..3), prop::collection::vec(decl, 1..4)) + .prop_map(|(selectors, decls)| format!("{} {{ {} }}", selectors.join(", "), decls.join("; "))) +} + +/// An element that may have an id, classes (some repeated, apart by spaces, tabs and line feeds) +/// and a `data-k` or `style` attribute, with up to four children on each of the `depth` levels +/// below it. +fn arb_element(depth: u32) -> BoxedStrategy { + let tag = prop::sample::select(vec!["g", "rect", "text", "tspan"]); + let id = prop::sample::select(vec!["", r#" id="a""#, r#" id="b""#, r#" id="c""#]); + let classes = prop::collection::vec((prop::sample::select(vec![" ", " ", " "]), prop::sample::select(vec!["a", "b", "c", "x"])), 0..4) + .prop_map(|v| match v.iter().map(|(gap, c)| format!("{gap}{c}")).collect::() { + s if s.is_empty() => s, + s => format!(r#" class="{s}""#), + }); + let more = prop::sample::select(vec!["", r#" data-k="v""#, r#" data-k="w""#, r#" style="fill: s""#, r#" style="stroke: s !important""#]); + let children = if depth == 0 { Just(vec![]).boxed() } else { prop::collection::vec(arb_element(depth - 1), 0..5).boxed() }; + (tag, id, classes, more, children).prop_map(|(t, i, c, m, ch)| format!("<{t}{i}{c}{m}>{}", ch.concat())).boxed() +} + +proptest! { + #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] + + /// Random style sheets give each element of a random document what testing every rule + /// gives it. + #[test] + fn random_sheets_give_what_testing_every_rule_gives(rules in prop::collection::vec(arb_rule(), 0..24), body in prop::collection::vec(arb_element(3), 1..6)) { + assert_same_as_every_rule(&format!(r#"{}"#, rules.join("\n"), body.concat())); + } +} + +/// 50,000 class rules give 50,000 elements, each with one of the classes, their fill within +/// 20 s. +#[test] +fn styling_50000_elements_with_50000_rules_finishes_within_20_s() { + const N: usize = 50_000; + let rules: String = (0..N).map(|i| format!(".c{i} {{ fill: #{i:06x} }}\n")).collect(); + let rects: String = (0..N).map(|i| format!(r#""#)).collect(); + let svg = format!(r#"{rects}"#); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let doc = roxmltree::Document::parse(&svg).unwrap(); + let st = Styles::new(&doc); + let fills: Vec> = doc.descendants().filter(|n| n.tag_name().name() == "rect").map(|n| st.own(n, "fill")).collect(); + tx.send(fills).ok(); + }); + let fills = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 20 s")); + assert_eq!(fills.len(), N); + for (i, fill) in fills.iter().enumerate() { + assert_eq!(fill.as_deref(), Some(format!("#{i:06x}").as_str())); + } +} + +#[test] +fn the_font_shorthand_sets_its_longhands() { + let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense"); + let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::>(); + assert_eq!(get("font-style"), [("italic", false)]); + assert_eq!(get("font-weight"), [("bold", false), ("300", true)]); + assert_eq!(get("font-size"), [("30px", false)]); + assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]); + assert_eq!(get("font-variant"), [("normal", false)]); + assert_eq!(get("fill"), [("red", false)]); + assert!(get("font").is_empty(), "the shorthand itself is not kept"); +} + +#[test] +fn unsupported_selectors_are_dropped() { + for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", "", "a:not(b c)", "a:not(.x", "a:not(b > c)", "a:not(:hover)"] { + assert!(Selector::parse(s).is_none(), "{s}"); + } + let s = Selector::parse("g > text.x#y").unwrap(); + assert_eq!(s.specificity, (1, 1, 2)); + assert_eq!(s.parts.len(), 2); + assert!(s.parts[0].1, "text must be a child of g"); +} + +/// #1557: `:not()` of a list of compound selectors; it counts as its most specific argument. +#[test] +fn not_selectors() { + assert_eq!(Selector::parse("rect:not(.keep)").unwrap().specificity, (0, 1, 1)); + assert_eq!(Selector::parse("g :not(#a, .b.c, text)").unwrap().specificity, (1, 0, 1)); + assert_eq!(Selector::parse(":not(rect):not([x])").unwrap().specificity, (0, 1, 1)); + // Nesting is bounded: a hostile sheet can't recurse without end. + let nested = |n: usize| format!("{}.x{}", ":not(".repeat(n), ")".repeat(n)); + assert!(Selector::parse(&nested(MAX_NOT_NESTING)).is_some()); + assert!(Selector::parse(&nested(MAX_NOT_NESTING + 1)).is_none()); + assert!(Selector::parse(&nested(200_000)).is_none()); + with_doc( + r#" + + xx + "#, + |doc, st| { + assert_eq!(st.own(by_id(doc, "r0"), "fill").as_deref(), Some("green")); + assert_eq!(st.own(by_id(doc, "r1"), "fill").as_deref(), Some("red")); + assert_eq!(st.own(by_id(doc, "t0"), "stroke").as_deref(), Some("blue")); + assert_eq!(st.own(by_id(doc, "t1"), "stroke"), None); + assert_eq!(st.own(by_id(doc, "r2"), "stroke"), None); + assert_eq!(st.own(by_id(doc, "r2"), "opacity").as_deref(), Some("0.5")); + assert_eq!(st.own(by_id(doc, "r0"), "opacity"), None); + assert!(st.has_not_rules()); + assert_eq!(st.set_by_not_rules(by_id(doc, "r0")), [("fill".to_string(), "green".to_string())]); + assert!(st.set_by_not_rules(by_id(doc, "r1")).is_empty()); + }, + ); +} From b76a161783b24d48b3c54caaa5a5e454555ed807 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:24 +0000 Subject: [PATCH 079/199] refactor(ui-egui): move font_menu.rs tests to font_menu/tests.rs --- crates/ui-egui/src/font_menu.rs | 334 +------------------------- crates/ui-egui/src/font_menu/tests.rs | 330 +++++++++++++++++++++++++ 2 files changed, 331 insertions(+), 333 deletions(-) create mode 100644 crates/ui-egui/src/font_menu/tests.rs diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 5d77b9508..0243f477d 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -849,336 +849,4 @@ pub(crate) fn sample_text(app: &VectorcraftApp) -> Option { } #[cfg(test)] -mod tests { - - /// The script filter: Thai lists families with Thai; Latin, those with neither Thai nor - /// Japanese. - #[test] - fn the_thai_filter_lists_fonts_with_thai() { - use vectorcraft_text::FontTraits; - let (thai, japanese, latin) = - (FontTraits { thai: true, ..Default::default() }, FontTraits { japanese: true, ..Default::default() }, FontTraits::default()); - let passing = |t: FontTraits| (0..super::SCRIPTS).filter(|s| super::script_matches(*s, t)).collect::>(); - assert_eq!(passing(thai), [0, 3]); - assert_eq!(passing(japanese), [0, 1]); - assert_eq!(passing(latin), [0, 2]); - assert_eq!(super::script_label(3), "Thai"); - let zh = crate::i18n::Lang::from_code("zh-hans").unwrap(); - crate::i18n::set_current(zh); - assert_eq!(super::script_label(1), crate::i18n::tr_ctx(zh, "font-script", "Japanese")); - crate::i18n::set_current(crate::i18n::Lang::EN); - assert_eq!(super::script_label(1), "Japanese"); - } - - /// #2277: the list's scroll bar has a column of its own, so the stars at the rows' right ends - /// stay clear of it. - #[test] - fn the_scroll_bar_leaves_the_stars_clear() { - // Every family starred, so each row draws its star. - let all = vectorcraft_text::FontDb::global().families(); - let families = all.len(); - let ctx = egui::Context::default(); - let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); - let mut st = super::MenuState::default(); - let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; - let mut stars = vec![]; - for _ in 0..3 { - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| { - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); - super::list(&mut child, egui::Id::unique("fonts"), &mut st, "", None, look); - }); - out.textures_delta.clear(); - stars = out - .shapes - .iter() - .filter_map(|c| match &c.shape { - // The rows' stars, not the ★ filter button at the top left. - egui::Shape::Text(t) if t.galley.text() == "★" && t.pos.x > area.center().x => Some(t.visual_bounding_rect().right()), - _ => None, - }) - .collect(); - } - assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); - // The rows don't fill the list without enough families to scroll, and then there is no bar. - if families as f32 * look.row > area.height() { - let bar = super::widgets::list_scroll_style(); - let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin; - for right in stars { - assert!(right <= area.right() - column + 0.5, "a star at {right} runs under the scroll bar"); - } - } - } - - use super::{Row, remap_highlight}; - - fn family(name: &str) -> Row { - Row::Family(name.to_string()) - } - - #[test] - fn recent_fonts_are_first_available_unique_and_expandable() { - let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into(); - let recent: Vec<_> = ["Charlie", "Missing", "BRAVO", "charlie"].map(str::to_string).into(); - let rows = super::grouped_rows(&names, &recent, &["bravo".into()], |_| vec!["Bold".into()]); - assert_eq!( - rows, - [ - Row::Heading("Recent Fonts"), - family("Charlie"), - family("Bravo"), - Row::Style("Bravo".into(), "Bold".into()), - Row::Heading("Fonts"), - family("Alpha") - ] - ); - // A filter removing a recent family also removes it from the recent section. - let filtered = super::grouped_rows(&names[..1], &recent, &[], |_| vec![]); - assert_eq!(filtered, [family("Alpha")]); - assert!(super::grouped_rows(&[], &recent, &[], |_| vec![]).is_empty()); - } - - #[test] - fn recent_section_rebuilds_and_arrows_skip_its_headings() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); - if names.len() < 3 { - return; - } - let recent = vec![names[2].clone(), names[1].clone()]; - let ctx = egui::Context::default(); - let mut st = super::MenuState::default(); - let mut frame = |events, recent: &[String]| { - let mut picked = None; - let input = egui::RawInput { - events, - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), - ..Default::default() - }; - let mut out = ctx.run_ui(input, |ui| { - picked = super::list( - ui, - egui::Id::unique("recent-test"), - &mut st, - &names[2], - None, - super::MenuLook { samples: false, recent, ..Default::default() }, - ); - }); - out.textures_delta.clear(); - (picked, st.rows.to_vec()) - }; - for _ in 0..3 { - frame(vec![], &recent); - } - let key = |key| { - [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }).to_vec() - }; - assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None))); - // Other tests add fonts to the process-wide database while this one runs, so the first - // family under the Fonts heading need not be the one this test's earlier snapshot named. - // Read it from the rows this frame built: the highlight must skip the heading and land on - // that family, not on a heading or a row from the recent section. - let (picked, rows) = frame(key(egui::Key::ArrowDown), &recent); - assert_eq!(rows.get(3), Some(&Row::Heading("Fonts")), "{rows:?}"); - let (first, style) = rows.get(4).and_then(Row::pick).expect("a family below the Fonts heading"); - assert_eq!(picked, Some(super::FontPick::Preview(first, style)), "skip Fonts heading"); - assert_eq!(frame(key(egui::Key::ArrowUp), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None))); - // Updating recent history while the menu is open must not retarget the highlight. - let updated = vec![names[1].clone()]; - let (_, rows) = frame(vec![], &updated); - assert_eq!(rows[1], family(&names[1])); - assert_eq!(frame(key(egui::Key::Enter), &updated).0, Some(super::FontPick::Chosen(names[1].clone(), None))); - } - - #[test] - fn recent_fonts_obey_search_and_favorite_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); - if names.len() < 3 { - return; - } - let recent = vec![names[1].clone(), names[0].clone()]; - let favorites = vec![names[0].clone()]; - let ctx = egui::Context::default(); - let mut st = super::MenuState { favorites_only: true, ..Default::default() }; - let mut frame = |events| { - let mut pick = None; - let mut out = ctx.run_ui( - egui::RawInput { - events, - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), - ..Default::default() - }, - |ui| { - pick = super::list( - ui, - egui::Id::unique("filtered-recent"), - &mut st, - &names[2], - None, - super::MenuLook { samples: false, recent: &recent, favorites: &favorites, ..Default::default() }, - ); - }, - ); - out.textures_delta.clear(); - (pick, st.rows.to_vec()) - }; - for _ in 0..3 { - frame(vec![]); - } - assert_eq!(frame(vec![]).1, [Row::Heading("Recent Fonts"), family(&names[0])]); - let key = [true, false] - .map(|pressed| egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed, repeat: false, modifiers: Default::default() }) - .to_vec(); - assert_eq!(frame(key).0, Some(super::FontPick::Preview(names[0].clone(), None)), "skip Recent Fonts heading"); - let (pick, rows) = frame(vec![egui::Event::Text("NoSuchFontFor3515".into())]); - assert!(rows.is_empty(), "search must filter the recent section too: {rows:?}"); - assert_eq!(pick, Some(super::FontPick::EndPreview)); - } - - #[test] - fn opening_on_another_font_keeps_the_recent_section_visible() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); - let Some(current) = names.last() else { return }; - let recent = vec![names[0].clone()]; - let ctx = egui::Context::default(); - let mut st = super::MenuState::default(); - let mut visible = false; - for _ in 0..3 { - let mut out = ctx.run_ui( - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))), ..Default::default() }, - |ui| { - super::list( - ui, - egui::Id::unique("recent-at-top"), - &mut st, - current, - None, - super::MenuLook { samples: false, recent: &recent, ..Default::default() }, - ); - }, - ); - out.textures_delta.clear(); - visible = out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Recent Fonts")); - } - assert!(visible, "opening the picker must show its recent section"); - } - - #[test] - fn keeps_the_same_family_when_other_rows_are_inserted_or_removed() { - let before = [family("Alpha"), family("Bravo"), family("Charlie")]; - assert_eq!(remap_highlight(&before, Some(2), &[family("Bravo"), family("Charlie")]), Some(1)); - assert_eq!(remap_highlight(&before, Some(2), &[family("Alpha"), family("New"), family("Bravo"), family("Charlie")]), Some(3)); - } - - #[test] - fn clears_the_highlight_when_its_family_is_filtered_out() { - let before = [family("Alpha"), family("Bravo"), family("Charlie")]; - assert_eq!(remap_highlight(&before, Some(1), &[family("Alpha"), family("Charlie")]), None); - assert_eq!(remap_highlight(&before, Some(10), &before), None); - assert_eq!(remap_highlight(&before, None, &before), None); - } - - #[test] - fn expanded_style_rows_do_not_retarget_the_highlight() { - let before = [family("Alpha"), family("Bravo")]; - let after = [family("Alpha"), Row::Style("Alpha".into(), "Bold".into()), family("Bravo")]; - assert_eq!(remap_highlight(&before, Some(1), &after), Some(2)); - assert_eq!(remap_highlight(&after, Some(1), &before), None); - } - - /// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun. - #[test] - fn the_variable_filter_is_named_as_fonts_are() { - use crate::i18n::{Lang, tr, tr_ctx}; - let (ja, zh) = (Lang::from_code("ja").unwrap(), Lang::from_code("zh-hans").unwrap()); - assert_eq!((tr_ctx(ja, "font-technology", "Variable"), tr(ja, "Variable")), ("バリアブル", "変数")); - assert_eq!((tr_ctx(zh, "font-technology", "Variable"), tr(zh, "Variable")), ("可变", "变量")); - assert_eq!(tr(ja, "Font Technology"), "フォントテクノロジー"); - assert_eq!(super::variable_label(), "Variable"); - } - - /// Font Technology › Variable lists only variable font families, together with the other - /// filters; clicked again, every family is back. - #[test] - fn the_variable_filter_lists_only_variable_families() { - use vectorcraft_text::FontClass; - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; - let db = vectorcraft_text::FontDb::global(); - db.add_font(two_axis_font().unwrap()); - let ctx = egui::Context::default(); - let mut st = super::MenuState::default(); - let mut favorites: Vec = vec![]; - let frame = |st: &mut super::MenuState, events, favorites: &[String]| { - let mut out = ctx.run_ui( - egui::RawInput { - events, - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), - ..Default::default() - }, - |ui| { - super::list( - ui, - egui::Id::unique("variable-filter"), - st, - "Source Sans 3", - None, - super::MenuLook { samples: false, favorites, ..Default::default() }, - ); - }, - ); - out.textures_delta.clear(); - let button = out.shapes.iter().find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text() == "Variable" => Some(t.visual_bounding_rect().center()), - _ => None, - }); - let families: Vec = st - .rows - .iter() - .filter_map(|r| match r { - Row::Family(f) => Some(f.clone()), - _ => None, - }) - .collect(); - (families, button) - }; - let has = |list: &[String], f: &str| list.iter().any(|x| x == f); - let click = |st: &mut super::MenuState, favorites: &[String]| { - let (_, at) = frame(st, vec![], favorites); - let at = at.expect("the Variable filter is drawn"); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(st, vec![egui::Event::PointerMoved(at), button(true)], favorites); - frame(st, vec![button(false)], favorites); - frame(st, vec![], favorites).0 - }; - for _ in 0..3 { - frame(&mut st, vec![], &favorites); - } - let all = frame(&mut st, vec![], &favorites).0; - assert!(has(&all, TWO_AXIS_FAMILY) && has(&all, "Source Sans 3") && has(&all, "JetBrains Mono"), "{all:?}"); - let variable = click(&mut st, &favorites); - assert!(st.variable_only, "the click turns the filter on"); - assert!(has(&variable, TWO_AXIS_FAMILY), "{variable:?}"); - assert!(!has(&variable, "Source Sans 3") && !has(&variable, "Inter") && !has(&variable, "JetBrains Mono"), "static families: {variable:?}"); - assert!(variable.iter().all(|f| db.family_traits(f).variable), "{variable:?}"); - // With the kind, script and favourites filters: each must pass. - st.class = Some(FontClass::Sans); - assert!(has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY)); - st.class = Some(FontClass::Monospaced); - assert!(!has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY), "a sans serif family under Monospaced"); - st.class = None; - st.script = 3; - assert!(!has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY), "a family without Thai under Thai"); - st.script = 0; - st.favorites_only = true; - favorites.push("Source Sans 3".into()); - assert!(frame(&mut st, vec![], &favorites).0.is_empty(), "the one favourite is static"); - favorites.push(TWO_AXIS_FAMILY.into()); - assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]); - st.favorites_only = false; - // Off again: every family is listed. - let again = click(&mut st, &favorites); - assert!(!st.variable_only); - assert_eq!(again, frame(&mut st, vec![], &[]).0); - assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs new file mode 100644 index 000000000..1f45627f7 --- /dev/null +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -0,0 +1,330 @@ +/// The script filter: Thai lists families with Thai; Latin, those with neither Thai nor +/// Japanese. +#[test] +fn the_thai_filter_lists_fonts_with_thai() { + use vectorcraft_text::FontTraits; + let (thai, japanese, latin) = + (FontTraits { thai: true, ..Default::default() }, FontTraits { japanese: true, ..Default::default() }, FontTraits::default()); + let passing = |t: FontTraits| (0..super::SCRIPTS).filter(|s| super::script_matches(*s, t)).collect::>(); + assert_eq!(passing(thai), [0, 3]); + assert_eq!(passing(japanese), [0, 1]); + assert_eq!(passing(latin), [0, 2]); + assert_eq!(super::script_label(3), "Thai"); + let zh = crate::i18n::Lang::from_code("zh-hans").unwrap(); + crate::i18n::set_current(zh); + assert_eq!(super::script_label(1), crate::i18n::tr_ctx(zh, "font-script", "Japanese")); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(super::script_label(1), "Japanese"); +} + +/// #2277: the list's scroll bar has a column of its own, so the stars at the rows' right ends +/// stay clear of it. +#[test] +fn the_scroll_bar_leaves_the_stars_clear() { + // Every family starred, so each row draws its star. + let all = vectorcraft_text::FontDb::global().families(); + let families = all.len(); + let ctx = egui::Context::default(); + let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); + let mut st = super::MenuState::default(); + let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; + let mut stars = vec![]; + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); + super::list(&mut child, egui::Id::unique("fonts"), &mut st, "", None, look); + }); + out.textures_delta.clear(); + stars = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + // The rows' stars, not the ★ filter button at the top left. + egui::Shape::Text(t) if t.galley.text() == "★" && t.pos.x > area.center().x => Some(t.visual_bounding_rect().right()), + _ => None, + }) + .collect(); + } + assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); + // The rows don't fill the list without enough families to scroll, and then there is no bar. + if families as f32 * look.row > area.height() { + let bar = super::widgets::list_scroll_style(); + let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin; + for right in stars { + assert!(right <= area.right() - column + 0.5, "a star at {right} runs under the scroll bar"); + } + } +} + +use super::{Row, remap_highlight}; + +fn family(name: &str) -> Row { + Row::Family(name.to_string()) +} + +#[test] +fn recent_fonts_are_first_available_unique_and_expandable() { + let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into(); + let recent: Vec<_> = ["Charlie", "Missing", "BRAVO", "charlie"].map(str::to_string).into(); + let rows = super::grouped_rows(&names, &recent, &["bravo".into()], |_| vec!["Bold".into()]); + assert_eq!( + rows, + [ + Row::Heading("Recent Fonts"), + family("Charlie"), + family("Bravo"), + Row::Style("Bravo".into(), "Bold".into()), + Row::Heading("Fonts"), + family("Alpha") + ] + ); + // A filter removing a recent family also removes it from the recent section. + let filtered = super::grouped_rows(&names[..1], &recent, &[], |_| vec![]); + assert_eq!(filtered, [family("Alpha")]); + assert!(super::grouped_rows(&[], &recent, &[], |_| vec![]).is_empty()); +} + +#[test] +fn recent_section_rebuilds_and_arrows_skip_its_headings() { + let names = vectorcraft_text::FontDb::global().menu_family_list(); + if names.len() < 3 { + return; + } + let recent = vec![names[2].clone(), names[1].clone()]; + let ctx = egui::Context::default(); + let mut st = super::MenuState::default(); + let mut frame = |events, recent: &[String]| { + let mut picked = None; + let input = egui::RawInput { + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + picked = super::list( + ui, + egui::Id::unique("recent-test"), + &mut st, + &names[2], + None, + super::MenuLook { samples: false, recent, ..Default::default() }, + ); + }); + out.textures_delta.clear(); + (picked, st.rows.to_vec()) + }; + for _ in 0..3 { + frame(vec![], &recent); + } + let key = |key| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }).to_vec() + }; + assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None))); + // Other tests add fonts to the process-wide database while this one runs, so the first + // family under the Fonts heading need not be the one this test's earlier snapshot named. + // Read it from the rows this frame built: the highlight must skip the heading and land on + // that family, not on a heading or a row from the recent section. + let (picked, rows) = frame(key(egui::Key::ArrowDown), &recent); + assert_eq!(rows.get(3), Some(&Row::Heading("Fonts")), "{rows:?}"); + let (first, style) = rows.get(4).and_then(Row::pick).expect("a family below the Fonts heading"); + assert_eq!(picked, Some(super::FontPick::Preview(first, style)), "skip Fonts heading"); + assert_eq!(frame(key(egui::Key::ArrowUp), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None))); + // Updating recent history while the menu is open must not retarget the highlight. + let updated = vec![names[1].clone()]; + let (_, rows) = frame(vec![], &updated); + assert_eq!(rows[1], family(&names[1])); + assert_eq!(frame(key(egui::Key::Enter), &updated).0, Some(super::FontPick::Chosen(names[1].clone(), None))); +} + +#[test] +fn recent_fonts_obey_search_and_favorite_filters() { + let names = vectorcraft_text::FontDb::global().menu_family_list(); + if names.len() < 3 { + return; + } + let recent = vec![names[1].clone(), names[0].clone()]; + let favorites = vec![names[0].clone()]; + let ctx = egui::Context::default(); + let mut st = super::MenuState { favorites_only: true, ..Default::default() }; + let mut frame = |events| { + let mut pick = None; + let mut out = ctx.run_ui( + egui::RawInput { + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), + ..Default::default() + }, + |ui| { + pick = super::list( + ui, + egui::Id::unique("filtered-recent"), + &mut st, + &names[2], + None, + super::MenuLook { samples: false, recent: &recent, favorites: &favorites, ..Default::default() }, + ); + }, + ); + out.textures_delta.clear(); + (pick, st.rows.to_vec()) + }; + for _ in 0..3 { + frame(vec![]); + } + assert_eq!(frame(vec![]).1, [Row::Heading("Recent Fonts"), family(&names[0])]); + let key = [true, false] + .map(|pressed| egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed, repeat: false, modifiers: Default::default() }) + .to_vec(); + assert_eq!(frame(key).0, Some(super::FontPick::Preview(names[0].clone(), None)), "skip Recent Fonts heading"); + let (pick, rows) = frame(vec![egui::Event::Text("NoSuchFontFor3515".into())]); + assert!(rows.is_empty(), "search must filter the recent section too: {rows:?}"); + assert_eq!(pick, Some(super::FontPick::EndPreview)); +} + +#[test] +fn opening_on_another_font_keeps_the_recent_section_visible() { + let names = vectorcraft_text::FontDb::global().menu_family_list(); + let Some(current) = names.last() else { return }; + let recent = vec![names[0].clone()]; + let ctx = egui::Context::default(); + let mut st = super::MenuState::default(); + let mut visible = false; + for _ in 0..3 { + let mut out = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))), ..Default::default() }, + |ui| { + super::list( + ui, + egui::Id::unique("recent-at-top"), + &mut st, + current, + None, + super::MenuLook { samples: false, recent: &recent, ..Default::default() }, + ); + }, + ); + out.textures_delta.clear(); + visible = out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Recent Fonts")); + } + assert!(visible, "opening the picker must show its recent section"); +} + +#[test] +fn keeps_the_same_family_when_other_rows_are_inserted_or_removed() { + let before = [family("Alpha"), family("Bravo"), family("Charlie")]; + assert_eq!(remap_highlight(&before, Some(2), &[family("Bravo"), family("Charlie")]), Some(1)); + assert_eq!(remap_highlight(&before, Some(2), &[family("Alpha"), family("New"), family("Bravo"), family("Charlie")]), Some(3)); +} + +#[test] +fn clears_the_highlight_when_its_family_is_filtered_out() { + let before = [family("Alpha"), family("Bravo"), family("Charlie")]; + assert_eq!(remap_highlight(&before, Some(1), &[family("Alpha"), family("Charlie")]), None); + assert_eq!(remap_highlight(&before, Some(10), &before), None); + assert_eq!(remap_highlight(&before, None, &before), None); +} + +#[test] +fn expanded_style_rows_do_not_retarget_the_highlight() { + let before = [family("Alpha"), family("Bravo")]; + let after = [family("Alpha"), Row::Style("Alpha".into(), "Bold".into()), family("Bravo")]; + assert_eq!(remap_highlight(&before, Some(1), &after), Some(2)); + assert_eq!(remap_highlight(&after, Some(1), &before), None); +} + +/// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun. +#[test] +fn the_variable_filter_is_named_as_fonts_are() { + use crate::i18n::{Lang, tr, tr_ctx}; + let (ja, zh) = (Lang::from_code("ja").unwrap(), Lang::from_code("zh-hans").unwrap()); + assert_eq!((tr_ctx(ja, "font-technology", "Variable"), tr(ja, "Variable")), ("バリアブル", "変数")); + assert_eq!((tr_ctx(zh, "font-technology", "Variable"), tr(zh, "Variable")), ("可变", "变量")); + assert_eq!(tr(ja, "Font Technology"), "フォントテクノロジー"); + assert_eq!(super::variable_label(), "Variable"); +} + +/// Font Technology › Variable lists only variable font families, together with the other +/// filters; clicked again, every family is back. +#[test] +fn the_variable_filter_lists_only_variable_families() { + use vectorcraft_text::FontClass; + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(two_axis_font().unwrap()); + let ctx = egui::Context::default(); + let mut st = super::MenuState::default(); + let mut favorites: Vec = vec![]; + let frame = |st: &mut super::MenuState, events, favorites: &[String]| { + let mut out = ctx.run_ui( + egui::RawInput { + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), + ..Default::default() + }, + |ui| { + super::list( + ui, + egui::Id::unique("variable-filter"), + st, + "Source Sans 3", + None, + super::MenuLook { samples: false, favorites, ..Default::default() }, + ); + }, + ); + out.textures_delta.clear(); + let button = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "Variable" => Some(t.visual_bounding_rect().center()), + _ => None, + }); + let families: Vec = st + .rows + .iter() + .filter_map(|r| match r { + Row::Family(f) => Some(f.clone()), + _ => None, + }) + .collect(); + (families, button) + }; + let has = |list: &[String], f: &str| list.iter().any(|x| x == f); + let click = |st: &mut super::MenuState, favorites: &[String]| { + let (_, at) = frame(st, vec![], favorites); + let at = at.expect("the Variable filter is drawn"); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(st, vec![egui::Event::PointerMoved(at), button(true)], favorites); + frame(st, vec![button(false)], favorites); + frame(st, vec![], favorites).0 + }; + for _ in 0..3 { + frame(&mut st, vec![], &favorites); + } + let all = frame(&mut st, vec![], &favorites).0; + assert!(has(&all, TWO_AXIS_FAMILY) && has(&all, "Source Sans 3") && has(&all, "JetBrains Mono"), "{all:?}"); + let variable = click(&mut st, &favorites); + assert!(st.variable_only, "the click turns the filter on"); + assert!(has(&variable, TWO_AXIS_FAMILY), "{variable:?}"); + assert!(!has(&variable, "Source Sans 3") && !has(&variable, "Inter") && !has(&variable, "JetBrains Mono"), "static families: {variable:?}"); + assert!(variable.iter().all(|f| db.family_traits(f).variable), "{variable:?}"); + // With the kind, script and favourites filters: each must pass. + st.class = Some(FontClass::Sans); + assert!(has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY)); + st.class = Some(FontClass::Monospaced); + assert!(!has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY), "a sans serif family under Monospaced"); + st.class = None; + st.script = 3; + assert!(!has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY), "a family without Thai under Thai"); + st.script = 0; + st.favorites_only = true; + favorites.push("Source Sans 3".into()); + assert!(frame(&mut st, vec![], &favorites).0.is_empty(), "the one favourite is static"); + favorites.push(TWO_AXIS_FAMILY.into()); + assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]); + st.favorites_only = false; + // Off again: every family is listed. + let again = click(&mut st, &favorites); + assert!(!st.variable_only); + assert_eq!(again, frame(&mut st, vec![], &[]).0); + assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}"); +} From 36a08619aa4e03faa9338949ba45a0283dd0d5f3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:25 +0000 Subject: [PATCH 080/199] refactor(eps): move import/ai/style.rs tests to import/ai/style/tests.rs --- crates/eps/src/import/ai/style.rs | 331 +----------------------- crates/eps/src/import/ai/style/tests.rs | 328 +++++++++++++++++++++++ 2 files changed, 329 insertions(+), 330 deletions(-) create mode 100644 crates/eps/src/import/ai/style/tests.rs diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 389b0657e..57d7842f2 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -656,333 +656,4 @@ fn style(filters: &[Filter]) -> ArtStyle { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_variable_width_stroke_keeps_its_profile() { - let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - 2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ - /Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ - /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\ - /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n"; - let styles = art_styles(data); - let style = &styles["Tapered"]; - assert!(style.plain); - let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) }; - assert_eq!(*width, 30.0); - assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)])); - } - - #[test] - fn dashes_aligned_to_corners_keep_the_drawn_look() { - // GUARDS: the look holds the fitted pattern and the solid ends the style's dash array doesn't. - let data = b"/KnownStyle :\n(Stitches) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ - /Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n\ - /StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n1 J 0 j 2 w 10 M [5 10 ]0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n"; - assert!(!art_styles(data)["Stitches"].plain); - } - - #[test] - fn a_drop_shadow_offset_down_points_down() { - // GUARDS: the document has y down; flipping `vert` like an art-space y would turn the dialog's - // default shadow (7 pt right, 7 pt down) upwards. - let data = b"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n7 /Real (horz) ,\n7 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(data); - let [shadow] = styles["Shade"].effects.as_slice() else { panic!("{:?}", styles["Shade"]) }; - assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0))); - } - - /// The style `Shade`: a Drop Shadow whose dictionary holds `keys`. - fn shadow_style(keys: &str) -> ArtStyle { - let data = format!( - "/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n" - ); - art_styles(data.as_bytes()).remove("Shade").unwrap_or_default() - } - - #[test] - fn a_drop_shadow_with_darkness_reads_its_darkness() { - // GUARDS: a `csrc` other than 0 made the style not plain, and the object came in as its drawn look. - let style = shadow_style("1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n"); - assert!(style.plain, "{style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{style:?}") }; - let p = &shadow.params; - assert_eq!( - (shadow.id.as_str(), &p["shade"], &p["darkness"], &p["opacity"], &p["blur"]), - ("stylize.dropShadow", &json!("darkness"), &json!(50.0), &json!(75.0), &json!(10.0)) - ); - } - - #[test] - fn a_drop_shadow_darkness_is_100_percent_when_missing_and_kept_within_0_to_100() { - for (dark, want) in [("", 100.0), ("(fifty) /Real (dark) ,\n", 100.0), ("250 /Real (dark) ,\n", 100.0), ("-5 /Real (dark) ,\n", 0.0)] { - let style = shadow_style(&format!("1 /Int (csrc) ,\n{dark}")); - assert!(style.plain, "{dark:?}: {style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{dark:?}: {style:?}") }; - assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(want)), "{dark:?}"); - } - } - - #[test] - fn a_drop_shadow_with_color_keeps_its_params() { - // GUARDS: a `csrc` of 0, or none, is Color; its shadow has neither `shade` nor `darkness`. - for csrc in ["", "0 /Int (csrc) ,\n50 /Real (dark) ,\n"] { - let style = shadow_style(&format!("2 /Int (blnd) ,\n0.5 /Real (opac) ,\n{csrc}")); - assert!(style.plain, "{csrc:?}: {style:?}"); - let [shadow] = style.effects.as_slice() else { panic!("{csrc:?}: {style:?}") }; - let want = json!({"mode": "Screen", "opacity": 50.0, "blur": 10.0, "x": 7.0, "y": 7.0, "color": vectorcraft_color::Color::BLACK}); - assert_eq!(shadow.params, want, "{csrc:?}"); - } - } - - #[test] - fn a_feather_reads_its_radius_from_rad() { - let data = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n12 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(data); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(12.0))); - } - - #[test] - fn a_feather_falls_back_to_radius_and_defaults_to_five_points() { - let with_name = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (radius) ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(with_name); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!(feather.params["radius"].as_f64(), Some(3.0)); - - let bare = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ - /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"; - let styles = art_styles(bare); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); - } - - #[test] - fn art_styles_clear_operands_beyond_the_cap() { - let mut data = Vec::from( - b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n", - ); - for _ in 0..80 { - data.extend_from_slice(b"1 "); - } - data.extend_from_slice(b"/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"); - let styles = art_styles(&data); - let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; - assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); - } - - #[test] - fn art_styles_ignore_filters_beyond_the_cap() { - const UNIT: &[u8] = b"/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n"; - let mut data = - Vec::from(b"/KnownStyle :\n(Capped) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n"); - for _ in 0..MAX_FILTERS + 16 { - data.extend_from_slice(UNIT); - } - data.extend_from_slice(b";\n"); - let style = art_styles(&data).remove("Capped").unwrap_or_default(); - assert!(style.effects.len() <= MAX_FILTERS); - } - - #[test] - fn hostile_dash_offsets_and_width_profiles_are_tamed() { - let mut f = Filter::default(); - paint_op(&mut f, "d", &[V::Arr(vec![V::Num(5.0)]), V::Num(f64::NAN)]); - assert_eq!(f.dash.as_ref().map(|d| d.offset), Some(0.0)); - // Positions outside 0..=1 and out of order, a negative width, a non-finite one. - let arrays = HashMap::from([ - ("BSVariableWidthTValuesArray".to_string(), vec![1.5, -0.2, 0.5, 0.7]), - ("BSCenteredWidthsArray".to_string(), vec![-1.0, 2.0, 0.5, f64::INFINITY]), - ]); - assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)])); - let ok = (0..64).map(|_| V::Num(2.0)).collect::>(); - paint_op(&mut f, "d", &[V::Arr(ok), V::Num(0.0)]); - assert_eq!(f.dash.as_ref().map(|d| d.pattern.len()), Some(64)); - let huge = (0..65).map(|_| V::Num(1.0)).collect::>(); - paint_op(&mut f, "d", &[V::Arr(huge), V::Num(0.0)]); - assert_eq!(f.dash, None); - let long = (0..1025).map(|i| i as f64 / 1024.0).collect::>(); - let wide = vec![1.0; 1025]; - let huge = HashMap::from([("BSVariableWidthTValuesArray".to_string(), long), ("BSCenteredWidthsArray".to_string(), wide)]); - assert_eq!(width_profile(&huge), None); - } - - #[test] - fn hostile_stroke_widths_are_sanitized() { - let mut f = Filter::default(); - paint_op(&mut f, "w", &[V::Num(12.0)]); - assert_eq!(f.width, 12.0); - paint_op(&mut f, "w", &[V::Num(-1.0)]); - assert_eq!(f.width, 12.0); - paint_op(&mut f, "w", &[V::Num(f64::INFINITY)]); - assert_eq!(f.width, 12.0); - } - - #[test] - fn hostile_stroke_paint_flags_ignore_short_operands() { - let mut f = Filter { paints: (true, true), ..Filter::default() }; - paint_op(&mut f, "Xd", &[V::Num(1.0)]); - assert_eq!(f.paints, (true, true)); - paint_op(&mut f, "Xd", &[V::Num(0.0), V::Num(1.0)]); - assert_eq!(f.paints, (false, true)); - paint_op(&mut f, "Xd", &[V::Num(2.0), V::Num(0.0)]); - assert_eq!(f.paints, (true, false)); - } - - #[test] - fn hostile_stroke_miter_limits_are_capped() { - let mut f = Filter::default(); - paint_op(&mut f, "M", &[V::Num(900.0)]); - assert_eq!(f.miter, 500.0); - paint_op(&mut f, "M", &[V::Num(0.5)]); - assert_eq!(f.miter, 500.0); - } - - #[test] - fn hostile_stroke_caps_and_joins_ignore_invalid_values() { - let mut f = Filter::default(); - paint_op(&mut f, "J", &[V::Num(99.0)]); - assert_eq!(f.cap, LineCap::Butt); - paint_op(&mut f, "J", &[V::Num(2.0)]); - assert_eq!(f.cap, LineCap::Square); - paint_op(&mut f, "j", &[V::Num(99.0)]); - assert_eq!(f.join, LineJoin::Miter); - paint_op(&mut f, "j", &[V::Num(1.0)]); - assert_eq!(f.join, LineJoin::Round); - } - - #[test] - fn hostile_blend_opacities_are_clamped() { - let mut f = Filter::default(); - paint_op(&mut f, "Xy", &[V::Num(3.0), V::Num(1.5)]); - assert_eq!(f.xy, Some((3, 1.0))); - paint_op(&mut f, "Xy", &[V::Num(-1.0), V::Num(0.25)]); - assert_eq!(f.xy, Some((0, 0.25))); - paint_op(&mut f, "Xy", &[V::Num(0.0), V::Num(f64::NAN)]); - assert_eq!(f.xy, Some((0, 0.25))); - } - - /// A basic filter `name` whose dictionary holds `body`. - fn basic(name: &str, body: &str) -> String { - format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") - } - - /// A compound filter `name` holding `kids`. - fn compound(name: &str, kids: &[String]) -> String { - format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()) - } - - fn fill(paint: &str) -> String { - basic( - "Fill Style Filter", - &format!("0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"), - ) - } - - fn blend(mode: u8, opacity: f64) -> String { - basic("Blend Style Filter", &format!("/BlendStyle : {mode} {opacity} 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n")) - } - - /// A comic-lettering style: a warp over a stack of a textured fill, a gradient, a white rim - /// repeated by Transform, and the characters at the bottom. - fn lettering() -> String { - let stack = compound( - "Stack Style Filter", - &[ - basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), - compound( - "Chain Style Filter", - &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), basic("PSAdapter_plugin_BsRl", "300 /Int (PrevDres) ,\n"), blend(5, 0.48)], - ), - fill("Bb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB"), - compound( - "Chain Style Filter", - &[ - basic("Adobe Offset Path", "3.5 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"), - fill("0 0 0 0 1 1 1 Xa"), - basic( - "Adobe Transform", - "6 /Int (numCopies) ,\n0.75 /Real (moveH_Pts) ,\n-0.25 /Real (moveV_Pts) ,\n100 /Real (scaleH_Percent) ,\n100 /Real (scaleV_Percent) ,\n4 /Int (pinPoint) ,\n", - ), - ], - ), - basic("Conduit Filter", ""), - ], - ); - let root = compound( - "Chain Style Filter", - &[ - basic( - "Adobe Deform", - "0 /Bool (Rotate) ,\n0.02 /Real (DeformVert) ,\n4 /Int (DeformStyle) ,\n0.05 /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n", - ), - stack, - blend(0, 1.0), - ], - ); - format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n") - } - - #[test] - fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { - // GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type - // with it came in as its drawn look (outlines) instead of type. - let styles = art_styles(lettering().as_bytes()); - let st = &styles["Lettering"]; - assert!(st.plain, "{st:?}"); - // Bottom first: the rim, the gradient, the textured fill; the stroke paints nothing. - let [StyleItem::Fill(rim), StyleItem::Gradient { name, angle }, StyleItem::Pattern { name: pattern, .. }] = st.items.as_slice() else { - panic!("{:?}", st.items) - }; - assert!(matches!(rim, Paint::Solid { .. }), "{rim:?}"); - assert_eq!((name.as_deref(), *angle, pattern.as_str()), (Some("Red ramp"), 90.0, "Dots")); - let ids = |l: &ItemLook| l.effects.iter().map(|e| e.id.clone()).collect::>(); - assert_eq!(ids(&st.looks[0]), ["path.offsetPath", "distort.transform"]); - assert!(ids(&st.looks[1]).is_empty()); - assert_eq!(ids(&st.looks[2]), ["sketch.basRelief"]); - assert_eq!((st.looks[2].blend, st.looks[2].opacity), (BlendMode::HardLight, 0.48)); - let transform = &st.looks[0].effects[1].params; - assert_eq!((&transform["copies"], &transform["moveH"], &transform["moveV"]), (&json!(6), &json!(0.75), &json!(0.25))); - assert_eq!(st.looks[0].effects[0].params["joins"], json!("round")); - // The warp is the whole object's; the characters paint below every item. - let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; - assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); - assert_eq!(st.contents_index, None); - } - - #[test] - fn contents_above_some_items_count_them() { - let stack = compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), basic("Conduit Filter", ""), fill("0 0 0 1 0 0 0 Xa")]); - let data = format!("/KnownStyle :\n(Over) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack])); - let st = &art_styles(data.as_bytes())["Over"]; - assert!(st.plain, "{st:?}"); - assert_eq!((st.items.len(), st.contents_index), (2, Some(1))); - } - - #[test] - fn an_effect_not_read_here_keeps_the_drawn_look() { - let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Scribble Fill", "")]); - let data = - format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); - assert!(!art_styles(data.as_bytes())["Odd"].plain); - } - - #[test] - fn a_hidden_fill_comes_in_hidden() { - let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1); - let data = format!( - "/KnownStyle :\n(Hidden) /Name ,\n/ActiveStyle :\n{}", - compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), hidden])]) - ); - let st = &art_styles(data.as_bytes())["Hidden"]; - assert!(st.plain, "{st:?}"); - assert_eq!(st.looks.iter().map(|l| l.visible).collect::>(), [false, true]); - } -} +mod tests; diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs new file mode 100644 index 000000000..964b74f10 --- /dev/null +++ b/crates/eps/src/import/ai/style/tests.rs @@ -0,0 +1,328 @@ +use super::*; + +#[test] +fn a_variable_width_stroke_keeps_its_profile() { + let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + 2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ + /Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ + /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n"; + let styles = art_styles(data); + let style = &styles["Tapered"]; + assert!(style.plain); + let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) }; + assert_eq!(*width, 30.0); + assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)])); +} + +#[test] +fn dashes_aligned_to_corners_keep_the_drawn_look() { + // GUARDS: the look holds the fitted pattern and the solid ends the style's dash array doesn't. + let data = b"/KnownStyle :\n(Stitches) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n\ + /StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n1 J 0 j 2 w 10 M [5 10 ]0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n"; + assert!(!art_styles(data)["Stitches"].plain); +} + +#[test] +fn a_drop_shadow_offset_down_points_down() { + // GUARDS: the document has y down; flipping `vert` like an art-space y would turn the dialog's + // default shadow (7 pt right, 7 pt down) upwards. + let data = b"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n7 /Real (horz) ,\n7 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(data); + let [shadow] = styles["Shade"].effects.as_slice() else { panic!("{:?}", styles["Shade"]) }; + assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0))); +} + +/// The style `Shade`: a Drop Shadow whose dictionary holds `keys`. +fn shadow_style(keys: &str) -> ArtStyle { + let data = format!( + "/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n" + ); + art_styles(data.as_bytes()).remove("Shade").unwrap_or_default() +} + +#[test] +fn a_drop_shadow_with_darkness_reads_its_darkness() { + // GUARDS: a `csrc` other than 0 made the style not plain, and the object came in as its drawn look. + let style = shadow_style("1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n"); + assert!(style.plain, "{style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{style:?}") }; + let p = &shadow.params; + assert_eq!( + (shadow.id.as_str(), &p["shade"], &p["darkness"], &p["opacity"], &p["blur"]), + ("stylize.dropShadow", &json!("darkness"), &json!(50.0), &json!(75.0), &json!(10.0)) + ); +} + +#[test] +fn a_drop_shadow_darkness_is_100_percent_when_missing_and_kept_within_0_to_100() { + for (dark, want) in [("", 100.0), ("(fifty) /Real (dark) ,\n", 100.0), ("250 /Real (dark) ,\n", 100.0), ("-5 /Real (dark) ,\n", 0.0)] { + let style = shadow_style(&format!("1 /Int (csrc) ,\n{dark}")); + assert!(style.plain, "{dark:?}: {style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{dark:?}: {style:?}") }; + assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(want)), "{dark:?}"); + } +} + +#[test] +fn a_drop_shadow_with_color_keeps_its_params() { + // GUARDS: a `csrc` of 0, or none, is Color; its shadow has neither `shade` nor `darkness`. + for csrc in ["", "0 /Int (csrc) ,\n50 /Real (dark) ,\n"] { + let style = shadow_style(&format!("2 /Int (blnd) ,\n0.5 /Real (opac) ,\n{csrc}")); + assert!(style.plain, "{csrc:?}: {style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{csrc:?}: {style:?}") }; + let want = json!({"mode": "Screen", "opacity": 50.0, "blur": 10.0, "x": 7.0, "y": 7.0, "color": vectorcraft_color::Color::BLACK}); + assert_eq!(shadow.params, want, "{csrc:?}"); + } +} + +#[test] +fn a_feather_reads_its_radius_from_rad() { + let data = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n12 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(data); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(12.0))); +} + +#[test] +fn a_feather_falls_back_to_radius_and_defaults_to_five_points() { + let with_name = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (radius) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(with_name); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(3.0)); + + let bare = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(bare); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); +} + +#[test] +fn art_styles_clear_operands_beyond_the_cap() { + let mut data = Vec::from( + b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n", + ); + for _ in 0..80 { + data.extend_from_slice(b"1 "); + } + data.extend_from_slice(b"/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"); + let styles = art_styles(&data); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); +} + +#[test] +fn art_styles_ignore_filters_beyond_the_cap() { + const UNIT: &[u8] = b"/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n"; + let mut data = + Vec::from(b"/KnownStyle :\n(Capped) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n"); + for _ in 0..MAX_FILTERS + 16 { + data.extend_from_slice(UNIT); + } + data.extend_from_slice(b";\n"); + let style = art_styles(&data).remove("Capped").unwrap_or_default(); + assert!(style.effects.len() <= MAX_FILTERS); +} + +#[test] +fn hostile_dash_offsets_and_width_profiles_are_tamed() { + let mut f = Filter::default(); + paint_op(&mut f, "d", &[V::Arr(vec![V::Num(5.0)]), V::Num(f64::NAN)]); + assert_eq!(f.dash.as_ref().map(|d| d.offset), Some(0.0)); + // Positions outside 0..=1 and out of order, a negative width, a non-finite one. + let arrays = HashMap::from([ + ("BSVariableWidthTValuesArray".to_string(), vec![1.5, -0.2, 0.5, 0.7]), + ("BSCenteredWidthsArray".to_string(), vec![-1.0, 2.0, 0.5, f64::INFINITY]), + ]); + assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)])); + let ok = (0..64).map(|_| V::Num(2.0)).collect::>(); + paint_op(&mut f, "d", &[V::Arr(ok), V::Num(0.0)]); + assert_eq!(f.dash.as_ref().map(|d| d.pattern.len()), Some(64)); + let huge = (0..65).map(|_| V::Num(1.0)).collect::>(); + paint_op(&mut f, "d", &[V::Arr(huge), V::Num(0.0)]); + assert_eq!(f.dash, None); + let long = (0..1025).map(|i| i as f64 / 1024.0).collect::>(); + let wide = vec![1.0; 1025]; + let huge = HashMap::from([("BSVariableWidthTValuesArray".to_string(), long), ("BSCenteredWidthsArray".to_string(), wide)]); + assert_eq!(width_profile(&huge), None); +} + +#[test] +fn hostile_stroke_widths_are_sanitized() { + let mut f = Filter::default(); + paint_op(&mut f, "w", &[V::Num(12.0)]); + assert_eq!(f.width, 12.0); + paint_op(&mut f, "w", &[V::Num(-1.0)]); + assert_eq!(f.width, 12.0); + paint_op(&mut f, "w", &[V::Num(f64::INFINITY)]); + assert_eq!(f.width, 12.0); +} + +#[test] +fn hostile_stroke_paint_flags_ignore_short_operands() { + let mut f = Filter { paints: (true, true), ..Filter::default() }; + paint_op(&mut f, "Xd", &[V::Num(1.0)]); + assert_eq!(f.paints, (true, true)); + paint_op(&mut f, "Xd", &[V::Num(0.0), V::Num(1.0)]); + assert_eq!(f.paints, (false, true)); + paint_op(&mut f, "Xd", &[V::Num(2.0), V::Num(0.0)]); + assert_eq!(f.paints, (true, false)); +} + +#[test] +fn hostile_stroke_miter_limits_are_capped() { + let mut f = Filter::default(); + paint_op(&mut f, "M", &[V::Num(900.0)]); + assert_eq!(f.miter, 500.0); + paint_op(&mut f, "M", &[V::Num(0.5)]); + assert_eq!(f.miter, 500.0); +} + +#[test] +fn hostile_stroke_caps_and_joins_ignore_invalid_values() { + let mut f = Filter::default(); + paint_op(&mut f, "J", &[V::Num(99.0)]); + assert_eq!(f.cap, LineCap::Butt); + paint_op(&mut f, "J", &[V::Num(2.0)]); + assert_eq!(f.cap, LineCap::Square); + paint_op(&mut f, "j", &[V::Num(99.0)]); + assert_eq!(f.join, LineJoin::Miter); + paint_op(&mut f, "j", &[V::Num(1.0)]); + assert_eq!(f.join, LineJoin::Round); +} + +#[test] +fn hostile_blend_opacities_are_clamped() { + let mut f = Filter::default(); + paint_op(&mut f, "Xy", &[V::Num(3.0), V::Num(1.5)]); + assert_eq!(f.xy, Some((3, 1.0))); + paint_op(&mut f, "Xy", &[V::Num(-1.0), V::Num(0.25)]); + assert_eq!(f.xy, Some((0, 0.25))); + paint_op(&mut f, "Xy", &[V::Num(0.0), V::Num(f64::NAN)]); + assert_eq!(f.xy, Some((0, 0.25))); +} + +/// A basic filter `name` whose dictionary holds `body`. +fn basic(name: &str, body: &str) -> String { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") +} + +/// A compound filter `name` holding `kids`. +fn compound(name: &str, kids: &[String]) -> String { + format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()) +} + +fn fill(paint: &str) -> String { + basic( + "Fill Style Filter", + &format!("0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"), + ) +} + +fn blend(mode: u8, opacity: f64) -> String { + basic("Blend Style Filter", &format!("/BlendStyle : {mode} {opacity} 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n")) +} + +/// A comic-lettering style: a warp over a stack of a textured fill, a gradient, a white rim +/// repeated by Transform, and the characters at the bottom. +fn lettering() -> String { + let stack = compound( + "Stack Style Filter", + &[ + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), + compound( + "Chain Style Filter", + &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), basic("PSAdapter_plugin_BsRl", "300 /Int (PrevDres) ,\n"), blend(5, 0.48)], + ), + fill("Bb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB"), + compound( + "Chain Style Filter", + &[ + basic("Adobe Offset Path", "3.5 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"), + fill("0 0 0 0 1 1 1 Xa"), + basic( + "Adobe Transform", + "6 /Int (numCopies) ,\n0.75 /Real (moveH_Pts) ,\n-0.25 /Real (moveV_Pts) ,\n100 /Real (scaleH_Percent) ,\n100 /Real (scaleV_Percent) ,\n4 /Int (pinPoint) ,\n", + ), + ], + ), + basic("Conduit Filter", ""), + ], + ); + let root = compound( + "Chain Style Filter", + &[ + basic( + "Adobe Deform", + "0 /Bool (Rotate) ,\n0.02 /Real (DeformVert) ,\n4 /Int (DeformStyle) ,\n0.05 /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n", + ), + stack, + blend(0, 1.0), + ], + ); + format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n") +} + +#[test] +fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { + // GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type + // with it came in as its drawn look (outlines) instead of type. + let styles = art_styles(lettering().as_bytes()); + let st = &styles["Lettering"]; + assert!(st.plain, "{st:?}"); + // Bottom first: the rim, the gradient, the textured fill; the stroke paints nothing. + let [StyleItem::Fill(rim), StyleItem::Gradient { name, angle }, StyleItem::Pattern { name: pattern, .. }] = st.items.as_slice() else { + panic!("{:?}", st.items) + }; + assert!(matches!(rim, Paint::Solid { .. }), "{rim:?}"); + assert_eq!((name.as_deref(), *angle, pattern.as_str()), (Some("Red ramp"), 90.0, "Dots")); + let ids = |l: &ItemLook| l.effects.iter().map(|e| e.id.clone()).collect::>(); + assert_eq!(ids(&st.looks[0]), ["path.offsetPath", "distort.transform"]); + assert!(ids(&st.looks[1]).is_empty()); + assert_eq!(ids(&st.looks[2]), ["sketch.basRelief"]); + assert_eq!((st.looks[2].blend, st.looks[2].opacity), (BlendMode::HardLight, 0.48)); + let transform = &st.looks[0].effects[1].params; + assert_eq!((&transform["copies"], &transform["moveH"], &transform["moveV"]), (&json!(6), &json!(0.75), &json!(0.25))); + assert_eq!(st.looks[0].effects[0].params["joins"], json!("round")); + // The warp is the whole object's; the characters paint below every item. + let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; + assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); + assert_eq!(st.contents_index, None); +} + +#[test] +fn contents_above_some_items_count_them() { + let stack = compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), basic("Conduit Filter", ""), fill("0 0 0 1 0 0 0 Xa")]); + let data = format!("/KnownStyle :\n(Over) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack])); + let st = &art_styles(data.as_bytes())["Over"]; + assert!(st.plain, "{st:?}"); + assert_eq!((st.items.len(), st.contents_index), (2, Some(1))); +} + +#[test] +fn an_effect_not_read_here_keeps_the_drawn_look() { + let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Scribble Fill", "")]); + let data = + format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + assert!(!art_styles(data.as_bytes())["Odd"].plain); +} + +#[test] +fn a_hidden_fill_comes_in_hidden() { + let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1); + let data = format!( + "/KnownStyle :\n(Hidden) /Name ,\n/ActiveStyle :\n{}", + compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), hidden])]) + ); + let st = &art_styles(data.as_bytes())["Hidden"]; + assert!(st.plain, "{st:?}"); + assert_eq!(st.looks.iter().map(|l| l.visible).collect::>(), [false, true]); +} From 17e36b62b168f7b64e0b5adb90a40ac07b224929 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:26 +0000 Subject: [PATCH 081/199] refactor(ui-egui): move dialogs/save_pdf.rs tests to dialogs/save_pdf/tests.rs --- crates/ui-egui/src/dialogs/save_pdf.rs | 327 +------------------ crates/ui-egui/src/dialogs/save_pdf/tests.rs | 324 ++++++++++++++++++ 2 files changed, 325 insertions(+), 326 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/save_pdf/tests.rs diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 9e608785d..4b8fc33c6 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -831,329 +831,4 @@ pub(super) fn warning_rows(ui: &mut egui::Ui, warnings: &[Value]) { } #[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::rc::Rc; - - use vectorcraft_engine::Session; - - use super::*; - use crate::Services; - - type Log = Rc)>>>; - - /// The built-in presets are told from saved ones by name (only theirs are translated). - #[test] - fn built_in_presets_are_known_by_name() { - assert!(vectorcraft_pdf::builtin_presets().iter().all(|p| is_builtin_preset(&p.name))); - assert!(is_builtin_preset(pdf::DEFAULT_PRESET) && !is_builtin_preset("Default") && !is_builtin_preset("My Preset")); - } - - /// An app with two artboards whose writer and URL opener record what they get. - fn app() -> (VectorcraftApp, Log, Log) { - let (written, opened) = (Log::default(), Log::default()); - let (w, o) = (written.clone(), opened.clone()); - let services = Services { - write: Some(Box::new(move |p: &str, b: &[u8]| { - w.borrow_mut().push((p.to_string(), b.to_vec())); - Ok(()) - })), - open_url: Some(Box::new(move |u: &str| o.borrow_mut().push((u.to_string(), vec![])))), - ..Default::default() - }; - let mut app = VectorcraftApp::new(Session::new(), services); - app.run("file.new", json!({"width": 120, "height": 90, "artboards": 3})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); - (app, written, opened) - } - - /// One headless frame of the dialog layer. - fn frame(app: &mut VectorcraftApp) { - let ctx = egui::Context::default(); - theme::install_fonts(&ctx); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); - out.textures_delta.clear(); - } - - fn set_field(app: &mut VectorcraftApp, path: &str, v: Value) { - set(app.ui.dialog.as_mut().expect("dialog open"), path, v); - } - - #[test] - fn every_section_draws() { - let (mut app, _, _) = app(); - app.run("file.export.pdf", json!({})).unwrap(); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); - assert_eq!(super::super::DialogKind::of(KIND), Some(super::super::DialogKind::SavePdf)); - for s in SECTIONS { - set_field(&mut app, "__section", json!(s)); - frame(&mut app); - assert!(app.ui.dialog.is_some(), "{s} closed the dialog"); - } - // The Summary lists what changed and the warnings for options not applied yet (permissions - // without a password; thumbnails are embedded). - set_field(&mut app, "thumbnails", json!(true)); - set_field(&mut app, "security.printing", json!("low")); - let p = params(app.ui.dialog.as_ref().unwrap()); - let v = summary_of(&mut app, &egui::Context::default(), &p).unwrap(); - let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect(); - assert_eq!(changed, ["security.printing", "thumbnails"]); - let warnings = v["warnings"].as_array().unwrap(); - assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("permissions"), "{warnings:?}"); - } - - #[test] - fn confirm_runs_export_pdf_with_the_dialog_options() { - let (mut app, written, opened) = app(); - app.run("ui.savePdfDialog", json!({"path": "/tmp/out.pdf", "compatibility": "1.5"})).unwrap(); - set_field(&mut app, "__allArtboards", json!(false)); - set_field(&mut app, "range", json!("1,3")); - set_field(&mut app, "compression.compressText", json!(false)); - set_field(&mut app, "viewAfterSaving", json!(true)); - let r = super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none(), "closes after saving"); - assert_eq!(r["path"], "/tmp/out.pdf"); - assert!(r.get("dataBase64").is_none()); - let w = written.borrow(); - assert_eq!(w.len(), 1); - assert_eq!(w[0].0, "/tmp/out.pdf"); - assert!(w[0].1.starts_with(b"%PDF-1.5"), "compatibility reaches the writer"); - assert_eq!(vectorcraft_pdf::import(&w[0].1).unwrap().artboards.len(), 2, "range 1,3"); - assert!(!String::from_utf8_lossy(&w[0].1).contains("/FlateDecode"), "uncompressed content"); - assert_eq!(opened.borrow().len(), 1, "View PDF after Saving opens the file once"); - let url = &opened.borrow()[0].0; - assert!(url.starts_with("file:///") && url.ends_with("/tmp/out.pdf"), "the written file as a URL: {url}"); - assert!(app.ui.status.starts_with("Saved /tmp/out.pdf"), "{}", app.ui.status); - } - - #[test] - fn warnings_reach_the_status_and_errors_keep_the_dialog_open() { - let (mut app, written, opened) = app(); - app.run("ui.savePdfDialog", json!({"path": "/tmp/w.pdf"})).unwrap(); - set_field(&mut app, "security.printing", json!("low")); - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.status.contains("1 note(s)") && app.ui.status.contains("permissions"), "{}", app.ui.status); - assert!(opened.borrow().is_empty(), "not opened unless asked"); - app.run("ui.savePdfDialog", json!({"path": "/tmp/bad.pdf"})).unwrap(); - set_field(&mut app, "__allArtboards", json!(false)); - set_field(&mut app, "range", json!("7")); - assert!(super::super::confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some(), "a bad range keeps the dialog open"); - assert_eq!(written.borrow().len(), 1); - // Without a path, bad options are refused before a save dialog asks for one. - let asked = Rc::new(RefCell::new(0)); - let a = asked.clone(); - app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| { - *a.borrow_mut() += 1; - Some(format!("/tmp/picked-{}", pick.name)) - })); - app.run("ui.savePdfDialog", json!({"range": "7"})).unwrap(); - assert!(super::super::confirm(&mut app).is_err()); - assert_eq!(*asked.borrow(), 0, "no save dialog for a bad range"); - set_field(&mut app, "range", json!("2")); - let r = super::super::confirm(&mut app).unwrap(); - assert_eq!(*asked.borrow(), 1); - assert!(r["path"].as_str().is_some_and(|p| p.starts_with("/tmp/picked-") && p.ends_with(".pdf")), "{r}"); - // Agents can export without the dialog. - let r = app.run("file.export.pdf", json!({"path": "/tmp/direct.pdf", "range": "2"})).unwrap(); - assert_eq!(r["path"], "/tmp/direct.pdf"); - assert!(r["warnings"].is_array()); - } - - /// A placed image whose pixel data can't be decoded is left out of the PDF, and the status bar - /// shows the note raster exports give. - #[test] - fn an_image_that_couldnt_be_decoded_is_named_in_the_status() { - let (mut app, written, _) = app(); - // A PNG whose header reads (so it places at its size) but whose pixel data is cut short. - let mut cut = vec![]; - image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) - .write_to(&mut std::io::Cursor::new(&mut cut), image::ImageFormat::Png) - .unwrap(); - let idat = cut.windows(4).position(|w| w == b"IDAT").unwrap(); - cut.truncate(idat + 8); - // The image is placed on the first artboard. - app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut), "at": [60, 50]})).unwrap(); - app.run("ui.savePdfDialog", json!({"path": "/tmp/cut.pdf"})).unwrap(); - super::super::confirm(&mut app).unwrap(); - assert_eq!(app.ui.status, "Saved /tmp/cut.pdf with 1 note(s): an image couldn't be decoded and is missing from the picture"); - assert_eq!(written.borrow().len(), 1); - } - - #[test] - fn presets_reset_the_settings_and_fields_fall_back_to_defaults() { - let (mut app, _, _) = app(); - app.run("ui.savePdfDialog", json!({"compatibility": "1.4", "marks": {"trim": true}})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - assert_eq!(get(d, "compatibility"), "1.4"); - assert_eq!(get(d, "marks.weight"), 0.25, "the rest of a section keeps its defaults"); - d.fields.remove("compression"); - assert_eq!(get(d, "compression.color.ppi"), 300.0, "a field an agent dropped reads as its default"); - let mut d = d.clone(); - apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET); - assert_eq!(get(&d, "compatibility"), "1.7"); - assert_eq!(get(&d, "marks.trim"), false); - assert!(app.run("ui.savePdfDialog", json!({"compatibility": "1.0"})).is_err(), "bad options are refused"); - } - - #[test] - fn marks_and_bleeds_reach_the_pdf() { - let (mut app, written, _) = app(); - app.run("document.setup", json!({"bleed": 9})).unwrap(); - let notes = app.run("layer.new", json!({})).unwrap()["id"].clone(); - app.run("layer.setProps", json!({"id": notes, "printable": false})).unwrap(); - app.run("shape.ellipse", json!({"x": 60, "y": 40, "width": 20, "height": 20})).unwrap(); - app.run("ui.savePdfDialog", json!({"path": "/tmp/marks.pdf"})).unwrap(); - set_field(&mut app, "__section", json!("Marks and Bleeds")); - set_field(&mut app, "bleed.useDocument", json!(true)); - set_field(&mut app, "marks.trim", json!(true)); - frame(&mut app); - set_field(&mut app, "__section", json!("General")); - set_field(&mut app, "includeNonPrinting", json!(true)); - frame(&mut app); - let r = super::super::confirm(&mut app).unwrap(); - assert_eq!(r["warnings"], json!([]), "marks and bleed are applied"); - let bytes = &written.borrow()[0].1; - let page = &vectorcraft_pdf::info(bytes, None).unwrap().pages[0]; - let trim = page.boxes.iter().find(|(b, _)| *b == vectorcraft_pdf::CropTo::Trim).unwrap().1; - assert_eq!((trim.width(), trim.x0), (120.0, 9.0 + 18.0 + 0.25), "the trim box is the artboard, inside the bleed and marks"); - let mut ellipse = false; - vectorcraft_pdf::import(bytes).unwrap().walk(|n| { - let b = n.path_data().and_then(|p| p.bounds()).unwrap_or_default(); - ellipse |= (b.width() - 20.0).abs() < 0.1 && (b.height() - 20.0).abs() < 0.1; - }); - assert!(ellipse, "the non-printing layer is included"); - } - - #[test] - fn option_labels_read_well() { - assert_eq!(option_label("compression.color.abovePpi"), "Compression › Color › Above Ppi"); - assert_eq!(option_label("compression.compressText"), "Compression › Compress Text"); - assert_eq!(option_label("thumbnails"), "General › Thumbnails"); - assert_eq!(option_label("bleed.top"), "Marks and Bleeds › Bleed › Top"); - assert_eq!(section_of("bleed.top"), 2); - assert_eq!(SECTIONS[section_of("standard")], "General"); - } - - #[test] - fn confirming_preserves_passwords_supplied_when_opening_the_dialog() { - for (open, owner) in [("open", ""), ("", "owner"), ("open", "owner")] { - let (mut app, written, _) = app(); - app.run( - "ui.savePdfDialog", - json!({"path": "/tmp/locked.pdf", "security": {"openPassword": open, "permissionsPassword": owner, "printing": "none"}}), - ) - .unwrap(); - // Drawing the Security and Summary sections must retain the live passwords. - for section in ["Security", "Summary"] { - set_field(&mut app, "__section", json!(section)); - frame(&mut app); - } - let p = params(app.ui.dialog.as_ref().unwrap()); - let summary = summary_of(&mut app, &egui::Context::default(), &p).unwrap(); - assert!(!summary.to_string().contains("Password"), "summaries omit passwords"); - let mut d = app.ui.dialog.clone().unwrap(); - set(&mut d, SAVE_AS, json!("Protected")); - save_preset(&mut app, &mut d).unwrap(); - let saved = &app.session.prefs.pdf_presets[0].settings.security; - assert!(saved.open_password.is_empty() && saved.permissions_password.is_empty(), "presets forget passwords"); - - super::super::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none()); - let written = written.borrow(); - let bytes = &written[0].1; - assert!(String::from_utf8_lossy(bytes).contains("/Encrypt")); - if open.is_empty() { - assert_eq!(vectorcraft_pdf::info(bytes, None).unwrap().pages.len(), 3); - } else { - assert_eq!(vectorcraft_pdf::info(bytes, None).unwrap_err(), vectorcraft_pdf::PdfError::NeedsPassword); - } - assert_eq!(vectorcraft_pdf::info(bytes, Some("wrong")).unwrap_err(), vectorcraft_pdf::PdfError::WrongPassword); - for password in [open, owner].into_iter().filter(|p| !p.is_empty()) { - assert_eq!(vectorcraft_pdf::info(bytes, Some(password)).unwrap().pages.len(), 3); - } - } - } - - #[test] - fn the_security_section_takes_passwords_that_presets_drop() { - let (mut app, _, _) = app(); - app.run("ui.savePdfDialog", json!({})).unwrap(); - set_field(&mut app, "__section", json!("Security")); - set_field(&mut app, OPEN_PASSWORD, json!("open")); - set_field(&mut app, PERMISSIONS_PASSWORD, json!("own")); - set_field(&mut app, "security.copy", json!(false)); - frame(&mut app); - let p = params(app.ui.dialog.as_ref().unwrap()); - assert_eq!((p["security"]["openPassword"].as_str(), p["security"]["permissionsPassword"].as_str()), (Some("open"), Some("own"))); - assert_eq!(p["security"]["copy"], false); - // With a standard the section still draws (greyed); a preset brings its own security. - set_field(&mut app, "standard", json!(Standard::PdfA2b.id())); - frame(&mut app); - let mut d = app.ui.dialog.clone().unwrap(); - apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET); - assert_eq!(get(&d, OPEN_PASSWORD), &Value::Null, "presets carry no passwords"); - assert_eq!(get(&d, "security.copy"), true); - } - - #[test] - fn choosing_a_pdfx_standard_sets_its_version_and_writes_the_standard() { - let (mut app, written, _) = app(); - app.run("ui.savePdfDialog", json!({"path": "/tmp/x.pdf", "createLayers": true})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - set(d, "standard", json!(Standard::PdfX1a.id())); - standard_chosen(d, Standard::PdfX1a); - assert_eq!((get(d, "compatibility"), get(d, "createLayers"), get(d, "preserveEditing")), (&json!("1.3"), &json!(false), &json!(false))); - for s in SECTIONS { - set_field(&mut app, "__section", json!(s)); - frame(&mut app); - } - super::super::confirm(&mut app).unwrap(); - let pdf = String::from_utf8_lossy(&written.borrow()[0].1).into_owned(); - assert!(pdf.starts_with("%PDF-1.3") && pdf.contains("/GTS_PDFXConformance(PDF/X-1a:2001)") && pdf.contains("/S/GTS_PDFX")); - // PDF/X-4 keeps layers and is PDF 1.6. - app.run("ui.savePdfDialog", json!({"compatibility": "2.0", "createLayers": true})).unwrap(); - let d = app.ui.dialog.as_mut().unwrap(); - set(d, "standard", json!(Standard::PdfX4.id())); - standard_chosen(d, Standard::PdfX4); - assert_eq!((get(d, "compatibility"), get(d, "createLayers")), (&json!("1.6"), &json!(true))); - } - - #[test] - fn create_layers_writes_pdf_layers() { - let (mut app, written, _) = app(); - app.run("layer.new", json!({"name": "Notes"})).unwrap(); - app.run("shape.ellipse", json!({"x": 60, "y": 40, "width": 20, "height": 20})).unwrap(); - app.run("ui.savePdfDialog", json!({"path": "/tmp/layers.pdf", "createLayers": true})).unwrap(); - // The option is drawn greyed at PDF 1.4 (no PDF layers there), then live again. - for c in ["1.4", "1.7"] { - set_field(&mut app, "compatibility", json!(c)); - frame(&mut app); - } - let r = super::super::confirm(&mut app).unwrap(); - assert_eq!(r["warnings"], json!([])); - let back = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap(); - let names: Vec<_> = back.layers.iter().filter_map(|l| l.name.clone()).collect(); - // The empty pages of the other artboards come in as page layers. - assert_eq!(names[..2], ["Layer 1", "Notes"]); - } - - #[test] - fn pdf_1_3_takes_a_flattener_preset_in_advanced() { - let (mut app, written, _) = app(); - app.run("ui.savePdfDialog", json!({"path": "/tmp/old.pdf", "compatibility": "1.3"})).unwrap(); - for s in ["Advanced", "Security"] { - set_field(&mut app, "__section", json!(s)); - frame(&mut app); - } - set_field(&mut app, "flattenerPreset", json!("Low Resolution")); - let p = params(app.ui.dialog.as_ref().unwrap()); - assert_eq!((p["compatibility"].as_str(), p["flattenerPreset"].as_str()), (Some("1.3"), Some("Low Resolution"))); - super::super::confirm(&mut app).unwrap(); - assert!(written.borrow()[0].1.starts_with(b"%PDF-1.3")); - // A preset keeps the flattener preset. - app.run("pdf.preset.save", json!({"name": "Old", "compatibility": "1.3", "flattenerPreset": "Low Resolution"})).unwrap(); - let saved = app.session.prefs.pdf_presets.iter().find(|p| p.name == "Old").unwrap(); - assert_eq!((saved.settings.compatibility, saved.settings.flattener_preset.as_str()), (Compatibility::Pdf13, "Low Resolution")); - } -} +mod tests; diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs new file mode 100644 index 000000000..fc766c6d6 --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -0,0 +1,324 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use vectorcraft_engine::Session; + +use super::*; +use crate::Services; + +type Log = Rc)>>>; + +/// The built-in presets are told from saved ones by name (only theirs are translated). +#[test] +fn built_in_presets_are_known_by_name() { + assert!(vectorcraft_pdf::builtin_presets().iter().all(|p| is_builtin_preset(&p.name))); + assert!(is_builtin_preset(pdf::DEFAULT_PRESET) && !is_builtin_preset("Default") && !is_builtin_preset("My Preset")); +} + +/// An app with two artboards whose writer and URL opener record what they get. +fn app() -> (VectorcraftApp, Log, Log) { + let (written, opened) = (Log::default(), Log::default()); + let (w, o) = (written.clone(), opened.clone()); + let services = Services { + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + open_url: Some(Box::new(move |u: &str| o.borrow_mut().push((u.to_string(), vec![])))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 120, "height": 90, "artboards": 3})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + (app, written, opened) +} + +/// One headless frame of the dialog layer. +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn set_field(app: &mut VectorcraftApp, path: &str, v: Value) { + set(app.ui.dialog.as_mut().expect("dialog open"), path, v); +} + +#[test] +fn every_section_draws() { + let (mut app, _, _) = app(); + app.run("file.export.pdf", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + assert_eq!(super::super::DialogKind::of(KIND), Some(super::super::DialogKind::SavePdf)); + for s in SECTIONS { + set_field(&mut app, "__section", json!(s)); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "{s} closed the dialog"); + } + // The Summary lists what changed and the warnings for options not applied yet (permissions + // without a password; thumbnails are embedded). + set_field(&mut app, "thumbnails", json!(true)); + set_field(&mut app, "security.printing", json!("low")); + let p = params(app.ui.dialog.as_ref().unwrap()); + let v = summary_of(&mut app, &egui::Context::default(), &p).unwrap(); + let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect(); + assert_eq!(changed, ["security.printing", "thumbnails"]); + let warnings = v["warnings"].as_array().unwrap(); + assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("permissions"), "{warnings:?}"); +} + +#[test] +fn confirm_runs_export_pdf_with_the_dialog_options() { + let (mut app, written, opened) = app(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/out.pdf", "compatibility": "1.5"})).unwrap(); + set_field(&mut app, "__allArtboards", json!(false)); + set_field(&mut app, "range", json!("1,3")); + set_field(&mut app, "compression.compressText", json!(false)); + set_field(&mut app, "viewAfterSaving", json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "closes after saving"); + assert_eq!(r["path"], "/tmp/out.pdf"); + assert!(r.get("dataBase64").is_none()); + let w = written.borrow(); + assert_eq!(w.len(), 1); + assert_eq!(w[0].0, "/tmp/out.pdf"); + assert!(w[0].1.starts_with(b"%PDF-1.5"), "compatibility reaches the writer"); + assert_eq!(vectorcraft_pdf::import(&w[0].1).unwrap().artboards.len(), 2, "range 1,3"); + assert!(!String::from_utf8_lossy(&w[0].1).contains("/FlateDecode"), "uncompressed content"); + assert_eq!(opened.borrow().len(), 1, "View PDF after Saving opens the file once"); + let url = &opened.borrow()[0].0; + assert!(url.starts_with("file:///") && url.ends_with("/tmp/out.pdf"), "the written file as a URL: {url}"); + assert!(app.ui.status.starts_with("Saved /tmp/out.pdf"), "{}", app.ui.status); +} + +#[test] +fn warnings_reach_the_status_and_errors_keep_the_dialog_open() { + let (mut app, written, opened) = app(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/w.pdf"})).unwrap(); + set_field(&mut app, "security.printing", json!("low")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.status.contains("1 note(s)") && app.ui.status.contains("permissions"), "{}", app.ui.status); + assert!(opened.borrow().is_empty(), "not opened unless asked"); + app.run("ui.savePdfDialog", json!({"path": "/tmp/bad.pdf"})).unwrap(); + set_field(&mut app, "__allArtboards", json!(false)); + set_field(&mut app, "range", json!("7")); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some(), "a bad range keeps the dialog open"); + assert_eq!(written.borrow().len(), 1); + // Without a path, bad options are refused before a save dialog asks for one. + let asked = Rc::new(RefCell::new(0)); + let a = asked.clone(); + app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| { + *a.borrow_mut() += 1; + Some(format!("/tmp/picked-{}", pick.name)) + })); + app.run("ui.savePdfDialog", json!({"range": "7"})).unwrap(); + assert!(super::super::confirm(&mut app).is_err()); + assert_eq!(*asked.borrow(), 0, "no save dialog for a bad range"); + set_field(&mut app, "range", json!("2")); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(*asked.borrow(), 1); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with("/tmp/picked-") && p.ends_with(".pdf")), "{r}"); + // Agents can export without the dialog. + let r = app.run("file.export.pdf", json!({"path": "/tmp/direct.pdf", "range": "2"})).unwrap(); + assert_eq!(r["path"], "/tmp/direct.pdf"); + assert!(r["warnings"].is_array()); +} + +/// A placed image whose pixel data can't be decoded is left out of the PDF, and the status bar +/// shows the note raster exports give. +#[test] +fn an_image_that_couldnt_be_decoded_is_named_in_the_status() { + let (mut app, written, _) = app(); + // A PNG whose header reads (so it places at its size) but whose pixel data is cut short. + let mut cut = vec![]; + image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut cut), image::ImageFormat::Png) + .unwrap(); + let idat = cut.windows(4).position(|w| w == b"IDAT").unwrap(); + cut.truncate(idat + 8); + // The image is placed on the first artboard. + app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut), "at": [60, 50]})).unwrap(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/cut.pdf"})).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.ui.status, "Saved /tmp/cut.pdf with 1 note(s): an image couldn't be decoded and is missing from the picture"); + assert_eq!(written.borrow().len(), 1); +} + +#[test] +fn presets_reset_the_settings_and_fields_fall_back_to_defaults() { + let (mut app, _, _) = app(); + app.run("ui.savePdfDialog", json!({"compatibility": "1.4", "marks": {"trim": true}})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(get(d, "compatibility"), "1.4"); + assert_eq!(get(d, "marks.weight"), 0.25, "the rest of a section keeps its defaults"); + d.fields.remove("compression"); + assert_eq!(get(d, "compression.color.ppi"), 300.0, "a field an agent dropped reads as its default"); + let mut d = d.clone(); + apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET); + assert_eq!(get(&d, "compatibility"), "1.7"); + assert_eq!(get(&d, "marks.trim"), false); + assert!(app.run("ui.savePdfDialog", json!({"compatibility": "1.0"})).is_err(), "bad options are refused"); +} + +#[test] +fn marks_and_bleeds_reach_the_pdf() { + let (mut app, written, _) = app(); + app.run("document.setup", json!({"bleed": 9})).unwrap(); + let notes = app.run("layer.new", json!({})).unwrap()["id"].clone(); + app.run("layer.setProps", json!({"id": notes, "printable": false})).unwrap(); + app.run("shape.ellipse", json!({"x": 60, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/marks.pdf"})).unwrap(); + set_field(&mut app, "__section", json!("Marks and Bleeds")); + set_field(&mut app, "bleed.useDocument", json!(true)); + set_field(&mut app, "marks.trim", json!(true)); + frame(&mut app); + set_field(&mut app, "__section", json!("General")); + set_field(&mut app, "includeNonPrinting", json!(true)); + frame(&mut app); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["warnings"], json!([]), "marks and bleed are applied"); + let bytes = &written.borrow()[0].1; + let page = &vectorcraft_pdf::info(bytes, None).unwrap().pages[0]; + let trim = page.boxes.iter().find(|(b, _)| *b == vectorcraft_pdf::CropTo::Trim).unwrap().1; + assert_eq!((trim.width(), trim.x0), (120.0, 9.0 + 18.0 + 0.25), "the trim box is the artboard, inside the bleed and marks"); + let mut ellipse = false; + vectorcraft_pdf::import(bytes).unwrap().walk(|n| { + let b = n.path_data().and_then(|p| p.bounds()).unwrap_or_default(); + ellipse |= (b.width() - 20.0).abs() < 0.1 && (b.height() - 20.0).abs() < 0.1; + }); + assert!(ellipse, "the non-printing layer is included"); +} + +#[test] +fn option_labels_read_well() { + assert_eq!(option_label("compression.color.abovePpi"), "Compression › Color › Above Ppi"); + assert_eq!(option_label("compression.compressText"), "Compression › Compress Text"); + assert_eq!(option_label("thumbnails"), "General › Thumbnails"); + assert_eq!(option_label("bleed.top"), "Marks and Bleeds › Bleed › Top"); + assert_eq!(section_of("bleed.top"), 2); + assert_eq!(SECTIONS[section_of("standard")], "General"); +} + +#[test] +fn confirming_preserves_passwords_supplied_when_opening_the_dialog() { + for (open, owner) in [("open", ""), ("", "owner"), ("open", "owner")] { + let (mut app, written, _) = app(); + app.run( + "ui.savePdfDialog", + json!({"path": "/tmp/locked.pdf", "security": {"openPassword": open, "permissionsPassword": owner, "printing": "none"}}), + ) + .unwrap(); + // Drawing the Security and Summary sections must retain the live passwords. + for section in ["Security", "Summary"] { + set_field(&mut app, "__section", json!(section)); + frame(&mut app); + } + let p = params(app.ui.dialog.as_ref().unwrap()); + let summary = summary_of(&mut app, &egui::Context::default(), &p).unwrap(); + assert!(!summary.to_string().contains("Password"), "summaries omit passwords"); + let mut d = app.ui.dialog.clone().unwrap(); + set(&mut d, SAVE_AS, json!("Protected")); + save_preset(&mut app, &mut d).unwrap(); + let saved = &app.session.prefs.pdf_presets[0].settings.security; + assert!(saved.open_password.is_empty() && saved.permissions_password.is_empty(), "presets forget passwords"); + + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let written = written.borrow(); + let bytes = &written[0].1; + assert!(String::from_utf8_lossy(bytes).contains("/Encrypt")); + if open.is_empty() { + assert_eq!(vectorcraft_pdf::info(bytes, None).unwrap().pages.len(), 3); + } else { + assert_eq!(vectorcraft_pdf::info(bytes, None).unwrap_err(), vectorcraft_pdf::PdfError::NeedsPassword); + } + assert_eq!(vectorcraft_pdf::info(bytes, Some("wrong")).unwrap_err(), vectorcraft_pdf::PdfError::WrongPassword); + for password in [open, owner].into_iter().filter(|p| !p.is_empty()) { + assert_eq!(vectorcraft_pdf::info(bytes, Some(password)).unwrap().pages.len(), 3); + } + } +} + +#[test] +fn the_security_section_takes_passwords_that_presets_drop() { + let (mut app, _, _) = app(); + app.run("ui.savePdfDialog", json!({})).unwrap(); + set_field(&mut app, "__section", json!("Security")); + set_field(&mut app, OPEN_PASSWORD, json!("open")); + set_field(&mut app, PERMISSIONS_PASSWORD, json!("own")); + set_field(&mut app, "security.copy", json!(false)); + frame(&mut app); + let p = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!((p["security"]["openPassword"].as_str(), p["security"]["permissionsPassword"].as_str()), (Some("open"), Some("own"))); + assert_eq!(p["security"]["copy"], false); + // With a standard the section still draws (greyed); a preset brings its own security. + set_field(&mut app, "standard", json!(Standard::PdfA2b.id())); + frame(&mut app); + let mut d = app.ui.dialog.clone().unwrap(); + apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET); + assert_eq!(get(&d, OPEN_PASSWORD), &Value::Null, "presets carry no passwords"); + assert_eq!(get(&d, "security.copy"), true); +} + +#[test] +fn choosing_a_pdfx_standard_sets_its_version_and_writes_the_standard() { + let (mut app, written, _) = app(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/x.pdf", "createLayers": true})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + set(d, "standard", json!(Standard::PdfX1a.id())); + standard_chosen(d, Standard::PdfX1a); + assert_eq!((get(d, "compatibility"), get(d, "createLayers"), get(d, "preserveEditing")), (&json!("1.3"), &json!(false), &json!(false))); + for s in SECTIONS { + set_field(&mut app, "__section", json!(s)); + frame(&mut app); + } + super::super::confirm(&mut app).unwrap(); + let pdf = String::from_utf8_lossy(&written.borrow()[0].1).into_owned(); + assert!(pdf.starts_with("%PDF-1.3") && pdf.contains("/GTS_PDFXConformance(PDF/X-1a:2001)") && pdf.contains("/S/GTS_PDFX")); + // PDF/X-4 keeps layers and is PDF 1.6. + app.run("ui.savePdfDialog", json!({"compatibility": "2.0", "createLayers": true})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + set(d, "standard", json!(Standard::PdfX4.id())); + standard_chosen(d, Standard::PdfX4); + assert_eq!((get(d, "compatibility"), get(d, "createLayers")), (&json!("1.6"), &json!(true))); +} + +#[test] +fn create_layers_writes_pdf_layers() { + let (mut app, written, _) = app(); + app.run("layer.new", json!({"name": "Notes"})).unwrap(); + app.run("shape.ellipse", json!({"x": 60, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/layers.pdf", "createLayers": true})).unwrap(); + // The option is drawn greyed at PDF 1.4 (no PDF layers there), then live again. + for c in ["1.4", "1.7"] { + set_field(&mut app, "compatibility", json!(c)); + frame(&mut app); + } + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["warnings"], json!([])); + let back = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap(); + let names: Vec<_> = back.layers.iter().filter_map(|l| l.name.clone()).collect(); + // The empty pages of the other artboards come in as page layers. + assert_eq!(names[..2], ["Layer 1", "Notes"]); +} + +#[test] +fn pdf_1_3_takes_a_flattener_preset_in_advanced() { + let (mut app, written, _) = app(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/old.pdf", "compatibility": "1.3"})).unwrap(); + for s in ["Advanced", "Security"] { + set_field(&mut app, "__section", json!(s)); + frame(&mut app); + } + set_field(&mut app, "flattenerPreset", json!("Low Resolution")); + let p = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!((p["compatibility"].as_str(), p["flattenerPreset"].as_str()), (Some("1.3"), Some("Low Resolution"))); + super::super::confirm(&mut app).unwrap(); + assert!(written.borrow()[0].1.starts_with(b"%PDF-1.3")); + // A preset keeps the flattener preset. + app.run("pdf.preset.save", json!({"name": "Old", "compatibility": "1.3", "flattenerPreset": "Low Resolution"})).unwrap(); + let saved = app.session.prefs.pdf_presets.iter().find(|p| p.name == "Old").unwrap(); + assert_eq!((saved.settings.compatibility, saved.settings.flattener_preset.as_str()), (Compatibility::Pdf13, "Low Resolution")); +} From 56b9bf5fd37818fa07c6501ee3b67901ec4b4ed7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:26 +0000 Subject: [PATCH 082/199] refactor(doc): move appearance.rs tests to appearance/tests.rs --- crates/doc/src/appearance.rs | 316 +---------------------------- crates/doc/src/appearance/tests.rs | 313 ++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 315 deletions(-) create mode 100644 crates/doc/src/appearance/tests.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 9895cc7f9..3853f5aec 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -1151,318 +1151,4 @@ impl Appearance { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hidden_attributes_show_again() { - let mut a = Appearance::default_art(); - assert!(!a.has_hidden() && !a.show_all()); - a.stroke_mut().unwrap().visible = false; - a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false }); - assert!(a.has_hidden()); - assert!(a.show_all()); - assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible); - } - - #[test] - fn basic_appearance() { - let a = Appearance::default_art(); - assert!(a.is_basic()); - assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE)); - assert_eq!(a.stroke_width(), 1.0); - } - - #[test] - fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() { - use vectorcraft_color::{Gradient, GradientGeom, GradientPaint}; - use vectorcraft_geom::{Point, Rect}; - let placed = |x: f64| { - let mut g = GradientPaint::new(Gradient::default()); - g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None }); - Paint::Gradient(Box::new(g)) - }; - let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0); - a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0)); - let start = |p: Paint| match p { - Paint::Gradient(g) => g.geom.unwrap().start, - _ => panic!("a gradient"), - }; - // The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95). - assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0))); - } - - #[test] - fn items_by_index() { - let mut a = Appearance::default_art(); - a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); - // [Fill white, Stroke black, Fill none] - assert_eq!(a.fill_at(None).unwrap().paint, Paint::None); - assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE)); - assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none()); - assert_eq!(a.item_of_kind(Some(1), false), Some(1)); - assert_eq!(a.item_of_kind(Some(1), true), None); - assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None); - assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK))); - assert!(!a.set_stroke_at(Some(0), Paint::None)); - assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK)); - a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); - assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1); - assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none()); - assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill")); - } - - #[test] - fn basic_means_one_plain_fill_and_stroke() { - let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); - let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() }; - assert!(!a.is_basic(), "two strokes"); - a.items.pop(); - assert!(a.is_basic() && Appearance::default().is_basic()); - let mut b = Appearance::default_art(); - b.stroke_mut().unwrap().visible = false; - assert!(!b.is_basic(), "a hidden stroke"); - let mut c = Appearance::default_art(); - c.fill_mut().unwrap().opacity = 0.5; - assert!(!c.is_basic(), "fill opacity"); - let mut d = Appearance::default_art(); - d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); - assert!(!d.is_basic(), "two fills"); - let mut e = Appearance::default_art(); - e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); - assert!(!e.is_basic(), "an effect"); - } - - #[test] - fn set_creates_missing() { - let mut a = Appearance::default(); - a.set_fill(Paint::solid(Color::BLACK)); - a.set_stroke(Paint::solid(Color::WHITE)); - assert_eq!(a.items.len(), 2); - assert!(matches!(a.items[0], AppearanceItem::Fill(_))); - } - - /// #852: pen pressure is kept simplified along the stroke and read back by position. - #[test] - fn pressure_profile_samples_simplify_and_interpolate() { - // A steady ramp keeps only its ends; a bump keeps its peak. - let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap(); - assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]); - assert!((ramp.at(0.25) - 0.25).abs() < 1e-9); - let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap(); - assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}"); - // Junk is clamped or dropped; positions never go back; nothing at all is no profile. - let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap(); - assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]); - assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none()); - assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID); - // A long noisy stroke stays bounded. - let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap(); - assert!(noisy.points.len() <= PressureProfile::MAX_POINTS); - // Out-of-order points (a hand-edited file) still read within 0..1. - let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] }; - assert!((0.0..=1.0).contains(&odd.at(0.5))); - } - - #[test] - fn pressure_profile_extends_and_reverses() { - let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }; - let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }; - // A 30 pt path continued by 10 pt: its ramp fills the first three quarters. - let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap(); - assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}"); - // A side without pressure is half way. - let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap(); - assert_eq!(half.at(0.25), PressureProfile::MID); - assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none()); - assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone())); - assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]); - } - - /// Strokes saved before pen pressure load as they were, and are written without it. - #[test] - fn strokes_without_pressure_round_trip_unchanged() { - let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap(); - assert!(json.get("pressure").is_none()); - let st: StrokeLayer = serde_json::from_value(json).unwrap(); - assert_eq!(st.pressure, None); - let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st }; - let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap(); - assert_eq!(back, pressed); - let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0); - ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample()); - assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None); - } - - #[test] - fn profile_interp() { - let p = WidthProfile::lens(); - assert_eq!(p.at(0.5), (1.0, 1.0)); - assert_eq!(p.at(0.25), (0.5, 0.5)); - assert_eq!(p.at(2.0), (0.0, 0.0)); - } - - #[test] - fn profile_presets_round_trip_their_ids() { - for p in WidthProfile::PRESETS { - let prof = WidthProfile::preset(p.id).unwrap(); - assert_eq!(prof.preset_id(), Some(p.id)); - assert_eq!(WidthProfile::id_of(Some(&prof)), p.id); - } - assert_eq!(WidthProfile::id_of(None), "uniform"); - assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom"); - assert!(WidthProfile::preset("nope").is_none()); - assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]); - } - - #[test] - fn arrowhead_names_are_stable_and_every_one_is_listed_once() { - // Saved files store these names: they may only ever be appended to. - const SAVED: [&str; 40] = [ - "Triangle", - "TriangleOpen", - "Circle", - "CircleOpen", - "Square", - "SquareOpen", - "Bar", - "Diamond", - "Arrow", - "ArrowOpen", - "Barbed", - "HalfArrowLeft", - "HalfArrowRight", - "Concave", - "DoubleBar", - "Feather", - "DotOnBar", - "Chevron", - "DoubleArrow", - "Target", - "Star", - "Cross", - "Plus", - "Hexagon", - "HexagonOpen", - "Tag", - "TagOpen", - "HalfCircle", - "Drop", - "Slash", - "DoubleSlash", - "DiamondOpen", - "TriangleReverse", - "Swallowtail", - "Bracket", - "Fork", - "Leaf", - "Kite", - "TriangleBar", - "Oval", - ]; - for name in SAVED { - let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); - assert_eq!(serde_json::to_value(a).unwrap(), name); - assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); - } - let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); - assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); - } - - #[test] - fn arrow_align_defaults_to_extend_and_round_trips() { - let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); - assert_eq!(st.arrow_align, ArrowAlign::Extend); - // The default is not written, so older readers see the same JSON as before. - assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align")); - let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap(); - assert_eq!(old.arrow_align, ArrowAlign::Extend); - st.arrow_align = ArrowAlign::Tip; - let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); - assert_eq!(back, st); - } - - #[test] - fn outset_covers_arrowheads() { - let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); - a.stroke_mut().unwrap().join = LineJoin::Round; - assert_eq!(a.outset(), 2.0); - let st = a.stroke_mut().unwrap(); - st.end_arrow = Some(Arrowhead::Triangle); - st.arrow_scale = (100.0, 200.0); - assert_eq!(st.arrow_weight(false), 4.0); - assert_eq!(st.arrow_weight(true), 8.0); - assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0)); - // A 32 pt head whose tip sits up to its length (plus the cap) past the end point. - assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0); - a.stroke_mut().unwrap().width = 0.01; - assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size"); - } - - #[test] - fn outset_depends_on_align() { - let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); - a.stroke_mut().unwrap().join = LineJoin::Round; - assert_eq!(a.outset(), 5.0); - a.stroke_mut().unwrap().align = StrokeAlign::Outside; - assert_eq!(a.outset(), 10.0); - // Open paths stroke inside-aligned strokes centred. - a.stroke_mut().unwrap().align = StrokeAlign::Inside; - assert_eq!(a.outset(), 5.0); - assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true)))); - } - - #[test] - fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() { - let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); - // Miter joins can spike out to the limit × half the weight. - assert_eq!(a.outset(), 10.0); - let st = a.stroke_mut().unwrap(); - st.join = LineJoin::Bevel; - st.cap = LineCap::Square; - assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner"); - let st = a.stroke_mut().unwrap(); - st.cap = LineCap::Round; - st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] }); - assert_eq!(st.profile_max(), 2.5); - assert_eq!(a.outset(), 2.5); - a.stroke_mut().unwrap().paint = Paint::None; - assert_eq!(a.outset(), 0.0, "an unpainted stroke"); - } - - #[test] - fn contents_slot_follows_item_edits_and_round_trips() { - let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None)); - let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() }; - assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default"); - a.set_contents_at(1); - assert!(!a.is_basic()); - // [0 | 1 2]: removing below shifts the slot, removing above doesn't. - a.remove_item(2); - assert_eq!(a.contents_at(), 1); - a.remove_item(0); - assert_eq!((a.contents_at(), a.contents_index), (0, None)); - // Inserting next to the slot joins the item below it. - a.items.push(fill()); - a.set_contents_at(1); // [0 | 1] - a.insert_item(1, fill()); - assert_eq!(a.contents_at(), 2); - a.insert_item(3, fill()); // [0 1 | 2 3] - assert_eq!(a.contents_at(), 2); - // Moves keep their side when landing next to the slot, cross it otherwise. - assert_eq!(a.move_item(3, 2), Some(2)); - assert_eq!(a.contents_at(), 2, "stays above"); - a.move_item(3, 0); - assert_eq!(a.contents_at(), 3, "crossed below"); - a.move_item(0, 3); - assert_eq!(a.contents_at(), 2, "crossed above"); - assert_eq!(a.move_item(9, 0), None); - // Saved only when set; older files load with every item above. - let json = serde_json::to_string(&a).unwrap(); - assert!(json.contains("\"contents_index\":2")); - assert_eq!(serde_json::from_str::(&json).unwrap(), a); - let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap(); - assert_eq!(old.contents_index, None); - assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index")); - } -} +mod tests; diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs new file mode 100644 index 000000000..49d2f4e77 --- /dev/null +++ b/crates/doc/src/appearance/tests.rs @@ -0,0 +1,313 @@ +use super::*; + +#[test] +fn hidden_attributes_show_again() { + let mut a = Appearance::default_art(); + assert!(!a.has_hidden() && !a.show_all()); + a.stroke_mut().unwrap().visible = false; + a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false }); + assert!(a.has_hidden()); + assert!(a.show_all()); + assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible); +} + +#[test] +fn basic_appearance() { + let a = Appearance::default_art(); + assert!(a.is_basic()); + assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE)); + assert_eq!(a.stroke_width(), 1.0); +} + +#[test] +fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() { + use vectorcraft_color::{Gradient, GradientGeom, GradientPaint}; + use vectorcraft_geom::{Point, Rect}; + let placed = |x: f64| { + let mut g = GradientPaint::new(Gradient::default()); + g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None }); + Paint::Gradient(Box::new(g)) + }; + let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0); + a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0)); + let start = |p: Paint| match p { + Paint::Gradient(g) => g.geom.unwrap().start, + _ => panic!("a gradient"), + }; + // The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95). + assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0))); +} + +#[test] +fn items_by_index() { + let mut a = Appearance::default_art(); + a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + // [Fill white, Stroke black, Fill none] + assert_eq!(a.fill_at(None).unwrap().paint, Paint::None); + assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE)); + assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none()); + assert_eq!(a.item_of_kind(Some(1), false), Some(1)); + assert_eq!(a.item_of_kind(Some(1), true), None); + assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None); + assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK))); + assert!(!a.set_stroke_at(Some(0), Paint::None)); + assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK)); + a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); + assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1); + assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none()); + assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill")); +} + +#[test] +fn basic_means_one_plain_fill_and_stroke() { + let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); + let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() }; + assert!(!a.is_basic(), "two strokes"); + a.items.pop(); + assert!(a.is_basic() && Appearance::default().is_basic()); + let mut b = Appearance::default_art(); + b.stroke_mut().unwrap().visible = false; + assert!(!b.is_basic(), "a hidden stroke"); + let mut c = Appearance::default_art(); + c.fill_mut().unwrap().opacity = 0.5; + assert!(!c.is_basic(), "fill opacity"); + let mut d = Appearance::default_art(); + d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + assert!(!d.is_basic(), "two fills"); + let mut e = Appearance::default_art(); + e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); + assert!(!e.is_basic(), "an effect"); +} + +#[test] +fn set_creates_missing() { + let mut a = Appearance::default(); + a.set_fill(Paint::solid(Color::BLACK)); + a.set_stroke(Paint::solid(Color::WHITE)); + assert_eq!(a.items.len(), 2); + assert!(matches!(a.items[0], AppearanceItem::Fill(_))); +} + +/// #852: pen pressure is kept simplified along the stroke and read back by position. +#[test] +fn pressure_profile_samples_simplify_and_interpolate() { + // A steady ramp keeps only its ends; a bump keeps its peak. + let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap(); + assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]); + assert!((ramp.at(0.25) - 0.25).abs() < 1e-9); + let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap(); + assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}"); + // Junk is clamped or dropped; positions never go back; nothing at all is no profile. + let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap(); + assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]); + assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none()); + assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID); + // A long noisy stroke stays bounded. + let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap(); + assert!(noisy.points.len() <= PressureProfile::MAX_POINTS); + // Out-of-order points (a hand-edited file) still read within 0..1. + let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] }; + assert!((0.0..=1.0).contains(&odd.at(0.5))); +} + +#[test] +fn pressure_profile_extends_and_reverses() { + let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }; + let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }; + // A 30 pt path continued by 10 pt: its ramp fills the first three quarters. + let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap(); + assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}"); + // A side without pressure is half way. + let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap(); + assert_eq!(half.at(0.25), PressureProfile::MID); + assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none()); + assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone())); + assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]); +} + +/// Strokes saved before pen pressure load as they were, and are written without it. +#[test] +fn strokes_without_pressure_round_trip_unchanged() { + let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap(); + assert!(json.get("pressure").is_none()); + let st: StrokeLayer = serde_json::from_value(json).unwrap(); + assert_eq!(st.pressure, None); + let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st }; + let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap(); + assert_eq!(back, pressed); + let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0); + ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample()); + assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None); +} + +#[test] +fn profile_interp() { + let p = WidthProfile::lens(); + assert_eq!(p.at(0.5), (1.0, 1.0)); + assert_eq!(p.at(0.25), (0.5, 0.5)); + assert_eq!(p.at(2.0), (0.0, 0.0)); +} + +#[test] +fn profile_presets_round_trip_their_ids() { + for p in WidthProfile::PRESETS { + let prof = WidthProfile::preset(p.id).unwrap(); + assert_eq!(prof.preset_id(), Some(p.id)); + assert_eq!(WidthProfile::id_of(Some(&prof)), p.id); + } + assert_eq!(WidthProfile::id_of(None), "uniform"); + assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom"); + assert!(WidthProfile::preset("nope").is_none()); + assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]); +} + +#[test] +fn arrowhead_names_are_stable_and_every_one_is_listed_once() { + // Saved files store these names: they may only ever be appended to. + const SAVED: [&str; 40] = [ + "Triangle", + "TriangleOpen", + "Circle", + "CircleOpen", + "Square", + "SquareOpen", + "Bar", + "Diamond", + "Arrow", + "ArrowOpen", + "Barbed", + "HalfArrowLeft", + "HalfArrowRight", + "Concave", + "DoubleBar", + "Feather", + "DotOnBar", + "Chevron", + "DoubleArrow", + "Target", + "Star", + "Cross", + "Plus", + "Hexagon", + "HexagonOpen", + "Tag", + "TagOpen", + "HalfCircle", + "Drop", + "Slash", + "DoubleSlash", + "DiamondOpen", + "TriangleReverse", + "Swallowtail", + "Bracket", + "Fork", + "Leaf", + "Kite", + "TriangleBar", + "Oval", + ]; + for name in SAVED { + let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); + assert_eq!(serde_json::to_value(a).unwrap(), name); + assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); + } + let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); + assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); +} + +#[test] +fn arrow_align_defaults_to_extend_and_round_trips() { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + assert_eq!(st.arrow_align, ArrowAlign::Extend); + // The default is not written, so older readers see the same JSON as before. + assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align")); + let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap(); + assert_eq!(old.arrow_align, ArrowAlign::Extend); + st.arrow_align = ArrowAlign::Tip; + let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); + assert_eq!(back, st); +} + +#[test] +fn outset_covers_arrowheads() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); + a.stroke_mut().unwrap().join = LineJoin::Round; + assert_eq!(a.outset(), 2.0); + let st = a.stroke_mut().unwrap(); + st.end_arrow = Some(Arrowhead::Triangle); + st.arrow_scale = (100.0, 200.0); + assert_eq!(st.arrow_weight(false), 4.0); + assert_eq!(st.arrow_weight(true), 8.0); + assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0)); + // A 32 pt head whose tip sits up to its length (plus the cap) past the end point. + assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0); + a.stroke_mut().unwrap().width = 0.01; + assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size"); +} + +#[test] +fn outset_depends_on_align() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); + a.stroke_mut().unwrap().join = LineJoin::Round; + assert_eq!(a.outset(), 5.0); + a.stroke_mut().unwrap().align = StrokeAlign::Outside; + assert_eq!(a.outset(), 10.0); + // Open paths stroke inside-aligned strokes centred. + a.stroke_mut().unwrap().align = StrokeAlign::Inside; + assert_eq!(a.outset(), 5.0); + assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true)))); +} + +#[test] +fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + // Miter joins can spike out to the limit × half the weight. + assert_eq!(a.outset(), 10.0); + let st = a.stroke_mut().unwrap(); + st.join = LineJoin::Bevel; + st.cap = LineCap::Square; + assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner"); + let st = a.stroke_mut().unwrap(); + st.cap = LineCap::Round; + st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] }); + assert_eq!(st.profile_max(), 2.5); + assert_eq!(a.outset(), 2.5); + a.stroke_mut().unwrap().paint = Paint::None; + assert_eq!(a.outset(), 0.0, "an unpainted stroke"); +} + +#[test] +fn contents_slot_follows_item_edits_and_round_trips() { + let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None)); + let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() }; + assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default"); + a.set_contents_at(1); + assert!(!a.is_basic()); + // [0 | 1 2]: removing below shifts the slot, removing above doesn't. + a.remove_item(2); + assert_eq!(a.contents_at(), 1); + a.remove_item(0); + assert_eq!((a.contents_at(), a.contents_index), (0, None)); + // Inserting next to the slot joins the item below it. + a.items.push(fill()); + a.set_contents_at(1); // [0 | 1] + a.insert_item(1, fill()); + assert_eq!(a.contents_at(), 2); + a.insert_item(3, fill()); // [0 1 | 2 3] + assert_eq!(a.contents_at(), 2); + // Moves keep their side when landing next to the slot, cross it otherwise. + assert_eq!(a.move_item(3, 2), Some(2)); + assert_eq!(a.contents_at(), 2, "stays above"); + a.move_item(3, 0); + assert_eq!(a.contents_at(), 3, "crossed below"); + a.move_item(0, 3); + assert_eq!(a.contents_at(), 2, "crossed above"); + assert_eq!(a.move_item(9, 0), None); + // Saved only when set; older files load with every item above. + let json = serde_json::to_string(&a).unwrap(); + assert!(json.contains("\"contents_index\":2")); + assert_eq!(serde_json::from_str::(&json).unwrap(), a); + let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap(); + assert_eq!(old.contents_index, None); + assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index")); +} From a8999818939bb705a382fde8147336a88f23f6ca Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:27 +0000 Subject: [PATCH 083/199] refactor(ui-egui): move opening.rs tests to opening/tests.rs --- crates/ui-egui/src/opening.rs | 315 +--------------------------- crates/ui-egui/src/opening/tests.rs | 312 +++++++++++++++++++++++++++ 2 files changed, 313 insertions(+), 314 deletions(-) create mode 100644 crates/ui-egui/src/opening/tests.rs diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 37d5d85b1..04dacc0c0 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -124,317 +124,4 @@ fn finish(app: &mut VectorcraftApp, path: &str, r: Result) { } #[cfg(test)] -mod tests { - use vectorcraft_engine::Session; - - use super::*; - use crate::Services; - - #[test] - fn long_operation_busy_while_a_file_opens_or_a_background_save_runs() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - assert!(!long_operation_busy(&app)); - start(&mut app, "/art/Wait.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(10.0, 10.0), false)).unwrap(); - assert!(long_operation_busy(&app)); - let t0 = std::time::Instant::now(); - while long_operation_busy(&app) && t0.elapsed().as_secs() < 30 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(!long_operation_busy(&app)); - let (release, held) = std::sync::mpsc::channel::<()>(); - let services = Services { write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; - app = VectorcraftApp::new(Session::new(), services); - crate::background::run( - &mut app, - true, - "Saving test.vectorcraft".into(), - None, - move |_| { - let _ = held.recv_timeout(std::time::Duration::from_secs(10)); - Ok(json!({})) - }, - |_, r| r, - ) - .unwrap(); - assert!(long_operation_busy(&app)); - let _ = release.send(()); - let t0 = std::time::Instant::now(); - while long_operation_busy(&app) && t0.elapsed().as_secs() < 30 { - crate::background::poll(&mut app); - std::thread::yield_now(); - } - assert!(!long_operation_busy(&app)); - } - - /// #1694: a file that finishes opening while a dialog is up waits for it to close, so the - /// dialog's document stays the active one. - #[test] - fn a_file_ready_while_a_dialog_is_open_waits_for_it_to_close() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); - let (first, before) = (app.session.doc().unwrap().uid, app.session.documents().len()); - app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"}))); - start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert_eq!((app.session.documents().len(), app.session.doc().unwrap().uid), (before, first), "still the dialog's document"); - assert_eq!(app.opening.pending.len(), 1); - app.ui.dialog = None; - let t0 = std::time::Instant::now(); - while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { - poll(&mut app); - std::thread::yield_now(); - } - assert_eq!(app.session.documents().len(), before + 1); - assert_eq!(app.session.doc().unwrap().path.as_deref(), Some("/art/Second.vectorcraft")); - } - - /// A file that finishes opening while an object is being dragged waits for the drag to end, so - /// the drag is not canceled. - #[test] - fn a_file_ready_during_a_drag_waits_for_it_to_end() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let square = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - app.select_tool("selection"); - let view = vectorcraft_engine::ViewInfo::default(); - for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 40.0), (PointerKind::Drag, 60.0)] { - app.session.pointer(&PointerEvent::new(kind, x, 20.0), view).unwrap(); - } - start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(app.session.documents().len() == 1 && app.session.in_interaction(), "the file waits for the drag"); - app.session.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 20.0), view).unwrap(); - let t0 = std::time::Instant::now(); - while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { - poll(&mut app); - std::thread::yield_now(); - } - assert_eq!(app.session.documents().len(), 2); - let moved = app.session.documents()[0].doc.node(vectorcraft_doc::NodeId(square)).and_then(|n| n.geometric_bounds()); - assert_eq!(moved.map(|b| (b.x0, b.y0)), Some((50.0, 10.0)), "the drag was not canceled"); - } - - /// A file that finishes opening while the place cursor is being dragged, a drag the tool keeps - /// to itself, waits for the drag to end: the drag still places its file. - #[test] - fn a_file_ready_during_a_place_cursor_drag_waits_for_it_to_end() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let mut png = vec![]; - image::RgbImage::new(8, 8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - app.run("file.place.queue", json!({"files": [{"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)}]})).unwrap(); - let view = vectorcraft_engine::ViewInfo::default(); - for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 60.0)] { - crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, 20.0), view); - } - start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(app.session.documents().len() == 1 && app.session.tool_busy(), "the file waits for the drag"); - crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Up, 60.0, 20.0), view); - let t0 = std::time::Instant::now(); - while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { - poll(&mut app); - std::thread::yield_now(); - } - assert_eq!(app.session.documents().len(), 2); - let first = &app.session.documents()[0].doc; - let names: Vec<_> = first.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect(); - assert_eq!(names, ["a.png"], "the drag placed its file"); - } - - /// A file that finishes opening while a preview runs waits for the preview to end, so the - /// preview is not canceled: a slider's preview with the tool idle, a slider's preview while the - /// Dimension tool waits after its first point, and the Dimension tool's line following the - /// pointer after its second point. - #[test] - fn a_file_ready_during_a_preview_waits_for_it_to_end() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let view = vectorcraft_engine::ViewInfo::default(); - let click = |app: &mut VectorcraftApp, x: f64| { - for kind in [PointerKind::Down, PointerKind::Up] { - crate::canvas::dispatch(app, &PointerEvent::new(kind, x, 20.0), view); - } - }; - for case in ["slider", "slider between clicks", "line"] { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - if case != "slider" { - app.select_tool("dimension"); - click(&mut app, 20.0); - } - if case == "line" { - click(&mut app, 80.0); - crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Move, 50.0, 60.0), view); - } else { - app.session.begin_interaction("Preview").unwrap(); - } - assert_eq!(app.session.tool_busy(), case != "slider", "{case}"); - start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(app.session.documents().len() == 1 && app.session.in_interaction(), "the file waits for the preview: {case}"); - if case == "line" { - click(&mut app, 50.0); - } else { - app.session.cancel_interaction().unwrap(); - } - let t0 = std::time::Instant::now(); - while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { - poll(&mut app); - std::thread::yield_now(); - } - assert_eq!(app.session.documents().len(), 2, "{case}"); - } - } - - /// A file that finishes opening while a tool waits between its clicks with no preview open (the - /// Dimension tool after its first point, then after its second before the pointer moves) opens - /// without waiting for the next click, and the tool starts afresh in it, as when the user - /// switches documents. - #[test] - fn a_file_ready_between_a_tools_clicks_opens() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.select_tool("dimension"); - let view = vectorcraft_engine::ViewInfo::default(); - let click = |app: &mut VectorcraftApp, x: f64| { - for kind in [PointerKind::Down, PointerKind::Up] { - crate::canvas::dispatch(app, &PointerEvent::new(kind, x, 20.0), view); - } - }; - let opened = |app: &mut VectorcraftApp| { - let t0 = std::time::Instant::now(); - while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { - poll(app); - std::thread::yield_now(); - } - }; - click(&mut app, 20.0); - assert!(app.session.tool_busy() && !app.session.in_interaction(), "the first point waits for the second"); - start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(400.0, 300.0), false)).unwrap(); - opened(&mut app); - assert_eq!(app.session.documents().len(), 2, "the file opens after one click"); - assert!(!app.session.tool_busy(), "the tool starts afresh"); - click(&mut app, 20.0); - click(&mut app, 80.0); - assert!(app.session.tool_busy() && !app.session.in_interaction(), "the second point waits for the line's place"); - start(&mut app, "/art/Third.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); - opened(&mut app); - assert_eq!(app.session.documents().len(), 3, "the file opens after two clicks"); - assert!(!app.session.tool_busy() && !app.session.in_interaction(), "the tool starts afresh"); - let second = &app.session.documents()[1].doc; - assert!(second.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())), "the unfinished dimension is not made"); - } - - #[test] - fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() { - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - let mut d = vectorcraft_doc::Document::new(120.0, 80.0); - d.title = "Poster".into(); - let bytes = vectorcraft_format::save(&d, false); - let before = app.session.documents().len(); - let r = start(&mut app, "/art/Poster.vectorcraft", bytes).unwrap(); - assert_eq!(r["background"], true); - assert_eq!(app.ui.status, "Opening Poster.vectorcraft…"); - let t0 = std::time::Instant::now(); - while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { - poll(&mut app); - std::thread::yield_now(); - } - assert_eq!(app.session.documents().len(), before + 1); - assert!(!app.ui.status.starts_with("Opening"), "{}", app.ui.status); - let st = app.session.doc().unwrap(); - assert_eq!((st.path.as_deref(), st.doc.artboards[0].rect.width()), (Some("/art/Poster.vectorcraft"), 120.0)); - assert_eq!(app.ui.recent_files.first().map(String::as_str), Some("/art/Poster.vectorcraft")); - // A file that can't be read: reported, nothing opened, not a recent file. - start(&mut app, "/art/Broken.vectorcraft", b"{\"format\":\"vectorcraft\"".to_vec()).unwrap(); - let t0 = std::time::Instant::now(); - while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { - poll(&mut app); - std::thread::yield_now(); - } - assert_eq!(app.session.documents().len(), before + 1); - assert!(app.ui.status.starts_with("Couldn't open Broken.vectorcraft"), "{}", app.ui.status); - assert_ne!(app.ui.recent_files.first().map(String::as_str), Some("/art/Broken.vectorcraft")); - } - - /// A file read off the UI thread opens as the document read at once. Its linked TIFF, BMP and JPEG - /// files are found beside it after its folder moved, a missing one shows its preview, a changed - /// one follows Update Links, and its Auto Size area type in two columns is fitted. Neither open - /// leaves a step to undo, and both ask the same question. - #[test] - fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() { - use image::ImageFormat::{Bmp, Jpeg, Png, Tiff}; - let dir = std::env::temp_dir().join(format!("vc-opening-links-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let (before, after) = (dir.join("before"), dir.join("after")); - std::fs::create_dir_all(&before).unwrap(); - let file = |folder: &std::path::Path, name: &str| folder.join(name).to_string_lossy().into_owned(); - let picture = |format: image::ImageFormat, rgb: [u8; 3]| { - let mut out = vec![]; - image::RgbImage::from_pixel(60, 40, image::Rgb(rgb)).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); - out - }; - let mut app = VectorcraftApp::new(Session::new(), Services::default()); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - for (name, format) in [("a.tif", Tiff), ("b.bmp", Bmp), ("c.jpg", Jpeg), ("gone.png", Png), ("changed.png", Png)] { - std::fs::write(file(&before, name), picture(format, [200, 40, 40])).unwrap(); - app.session.execute("file.place", &json!({"path": file(&before, name), "at": [200, 150]})).unwrap(); - } - let area = json!({"width": 150, "height": 20, "fit": "autoHeight"}); - let t = app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type in two columns. ".repeat(12), "area": area})).unwrap(); - app.session.execute("text.areaOptions", &json!({"ids": [t["id"]], "columns": 2})).unwrap(); - app.session.execute("document.save", &json!({"path": file(&before, "poster.vectorcraft")})).unwrap(); - std::fs::remove_file(file(&before, "gone.png")).unwrap(); - let changed = file(&before, "changed.png"); - let stamp = std::fs::metadata(&changed).unwrap().modified().unwrap(); - std::fs::write(&changed, picture(Png, [40, 40, 200])).unwrap(); - // Link stamps use milliseconds: the new file is dated 2 s later. - let later = std::fs::FileTimes::new().set_modified(stamp + std::time::Duration::from_secs(2)); - std::fs::OpenOptions::new().write(true).open(&changed).unwrap().set_times(later).unwrap(); - // The folder moves: the linked files are found beside the document. - std::fs::rename(&before, &after).unwrap(); - let path = file(&after, "poster.vectorcraft"); - let bytes = std::fs::read(&path).unwrap(); - let asked = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.clone())); - for update in ["askWhenModified", "automatically"] { - app.session.prefs.update_links = update.into(); - let r = crate::io::open_bytes(&mut app, &path, &bytes, Some(path.clone())).unwrap(); - let rows = if update == "automatically" { &r["updatedLinks"] } else { &r["modifiedLinks"] }; - assert_eq!(rows[0]["name"], "changed.png", "{update}: {r}"); - let (at_once, question) = (app.session.doc().unwrap().doc.clone(), asked(&app)); - assert!(question.as_ref().is_some_and(|(_, f)| f["missing"][0]["name"] == "gone.png"), "{update}: {question:?}"); - app.ui.dialog = None; - start(&mut app, &path, bytes.clone()).unwrap(); - let t0 = std::time::Instant::now(); - while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { - poll(&mut app); - std::thread::yield_now(); - } - let st = app.session.doc().unwrap(); - assert!(*st.doc == *at_once, "{update}: the same document"); - assert!(st.history.undo.is_empty() && !st.is_dirty(), "{update}"); - assert_eq!(asked(&app), question, "{update}"); - } - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/ui-egui/src/opening/tests.rs b/crates/ui-egui/src/opening/tests.rs new file mode 100644 index 000000000..dcdd1eaa1 --- /dev/null +++ b/crates/ui-egui/src/opening/tests.rs @@ -0,0 +1,312 @@ +use vectorcraft_engine::Session; + +use super::*; +use crate::Services; + +#[test] +fn long_operation_busy_while_a_file_opens_or_a_background_save_runs() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + assert!(!long_operation_busy(&app)); + start(&mut app, "/art/Wait.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(10.0, 10.0), false)).unwrap(); + assert!(long_operation_busy(&app)); + let t0 = std::time::Instant::now(); + while long_operation_busy(&app) && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(!long_operation_busy(&app)); + let (release, held) = std::sync::mpsc::channel::<()>(); + let services = Services { write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + app = VectorcraftApp::new(Session::new(), services); + crate::background::run( + &mut app, + true, + "Saving test.vectorcraft".into(), + None, + move |_| { + let _ = held.recv_timeout(std::time::Duration::from_secs(10)); + Ok(json!({})) + }, + |_, r| r, + ) + .unwrap(); + assert!(long_operation_busy(&app)); + let _ = release.send(()); + let t0 = std::time::Instant::now(); + while long_operation_busy(&app) && t0.elapsed().as_secs() < 30 { + crate::background::poll(&mut app); + std::thread::yield_now(); + } + assert!(!long_operation_busy(&app)); +} + +/// #1694: a file that finishes opening while a dialog is up waits for it to close, so the +/// dialog's document stays the active one. +#[test] +fn a_file_ready_while_a_dialog_is_open_waits_for_it_to_close() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + let (first, before) = (app.session.doc().unwrap().uid, app.session.documents().len()); + app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"}))); + start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!((app.session.documents().len(), app.session.doc().unwrap().uid), (before, first), "still the dialog's document"); + assert_eq!(app.opening.pending.len(), 1); + app.ui.dialog = None; + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), before + 1); + assert_eq!(app.session.doc().unwrap().path.as_deref(), Some("/art/Second.vectorcraft")); +} + +/// A file that finishes opening while an object is being dragged waits for the drag to end, so +/// the drag is not canceled. +#[test] +fn a_file_ready_during_a_drag_waits_for_it_to_end() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let square = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("selection"); + let view = vectorcraft_engine::ViewInfo::default(); + for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 40.0), (PointerKind::Drag, 60.0)] { + app.session.pointer(&PointerEvent::new(kind, x, 20.0), view).unwrap(); + } + start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(app.session.documents().len() == 1 && app.session.in_interaction(), "the file waits for the drag"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 20.0), view).unwrap(); + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), 2); + let moved = app.session.documents()[0].doc.node(vectorcraft_doc::NodeId(square)).and_then(|n| n.geometric_bounds()); + assert_eq!(moved.map(|b| (b.x0, b.y0)), Some((50.0, 10.0)), "the drag was not canceled"); +} + +/// A file that finishes opening while the place cursor is being dragged, a drag the tool keeps +/// to itself, waits for the drag to end: the drag still places its file. +#[test] +fn a_file_ready_during_a_place_cursor_drag_waits_for_it_to_end() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut png = vec![]; + image::RgbImage::new(8, 8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + app.run("file.place.queue", json!({"files": [{"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)}]})).unwrap(); + let view = vectorcraft_engine::ViewInfo::default(); + for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 60.0)] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, 20.0), view); + } + start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(app.session.documents().len() == 1 && app.session.tool_busy(), "the file waits for the drag"); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Up, 60.0, 20.0), view); + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), 2); + let first = &app.session.documents()[0].doc; + let names: Vec<_> = first.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect(); + assert_eq!(names, ["a.png"], "the drag placed its file"); +} + +/// A file that finishes opening while a preview runs waits for the preview to end, so the +/// preview is not canceled: a slider's preview with the tool idle, a slider's preview while the +/// Dimension tool waits after its first point, and the Dimension tool's line following the +/// pointer after its second point. +#[test] +fn a_file_ready_during_a_preview_waits_for_it_to_end() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let view = vectorcraft_engine::ViewInfo::default(); + let click = |app: &mut VectorcraftApp, x: f64| { + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(app, &PointerEvent::new(kind, x, 20.0), view); + } + }; + for case in ["slider", "slider between clicks", "line"] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + if case != "slider" { + app.select_tool("dimension"); + click(&mut app, 20.0); + } + if case == "line" { + click(&mut app, 80.0); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Move, 50.0, 60.0), view); + } else { + app.session.begin_interaction("Preview").unwrap(); + } + assert_eq!(app.session.tool_busy(), case != "slider", "{case}"); + start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(app.session.documents().len() == 1 && app.session.in_interaction(), "the file waits for the preview: {case}"); + if case == "line" { + click(&mut app, 50.0); + } else { + app.session.cancel_interaction().unwrap(); + } + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), 2, "{case}"); + } +} + +/// A file that finishes opening while a tool waits between its clicks with no preview open (the +/// Dimension tool after its first point, then after its second before the pointer moves) opens +/// without waiting for the next click, and the tool starts afresh in it, as when the user +/// switches documents. +#[test] +fn a_file_ready_between_a_tools_clicks_opens() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("dimension"); + let view = vectorcraft_engine::ViewInfo::default(); + let click = |app: &mut VectorcraftApp, x: f64| { + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(app, &PointerEvent::new(kind, x, 20.0), view); + } + }; + let opened = |app: &mut VectorcraftApp| { + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(app); + std::thread::yield_now(); + } + }; + click(&mut app, 20.0); + assert!(app.session.tool_busy() && !app.session.in_interaction(), "the first point waits for the second"); + start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(400.0, 300.0), false)).unwrap(); + opened(&mut app); + assert_eq!(app.session.documents().len(), 2, "the file opens after one click"); + assert!(!app.session.tool_busy(), "the tool starts afresh"); + click(&mut app, 20.0); + click(&mut app, 80.0); + assert!(app.session.tool_busy() && !app.session.in_interaction(), "the second point waits for the line's place"); + start(&mut app, "/art/Third.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); + opened(&mut app); + assert_eq!(app.session.documents().len(), 3, "the file opens after two clicks"); + assert!(!app.session.tool_busy() && !app.session.in_interaction(), "the tool starts afresh"); + let second = &app.session.documents()[1].doc; + assert!(second.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())), "the unfinished dimension is not made"); +} + +#[test] +fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let mut d = vectorcraft_doc::Document::new(120.0, 80.0); + d.title = "Poster".into(); + let bytes = vectorcraft_format::save(&d, false); + let before = app.session.documents().len(); + let r = start(&mut app, "/art/Poster.vectorcraft", bytes).unwrap(); + assert_eq!(r["background"], true); + assert_eq!(app.ui.status, "Opening Poster.vectorcraft…"); + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), before + 1); + assert!(!app.ui.status.starts_with("Opening"), "{}", app.ui.status); + let st = app.session.doc().unwrap(); + assert_eq!((st.path.as_deref(), st.doc.artboards[0].rect.width()), (Some("/art/Poster.vectorcraft"), 120.0)); + assert_eq!(app.ui.recent_files.first().map(String::as_str), Some("/art/Poster.vectorcraft")); + // A file that can't be read: reported, nothing opened, not a recent file. + start(&mut app, "/art/Broken.vectorcraft", b"{\"format\":\"vectorcraft\"".to_vec()).unwrap(); + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), before + 1); + assert!(app.ui.status.starts_with("Couldn't open Broken.vectorcraft"), "{}", app.ui.status); + assert_ne!(app.ui.recent_files.first().map(String::as_str), Some("/art/Broken.vectorcraft")); +} + +/// A file read off the UI thread opens as the document read at once. Its linked TIFF, BMP and JPEG +/// files are found beside it after its folder moved, a missing one shows its preview, a changed +/// one follows Update Links, and its Auto Size area type in two columns is fitted. Neither open +/// leaves a step to undo, and both ask the same question. +#[test] +fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() { + use image::ImageFormat::{Bmp, Jpeg, Png, Tiff}; + let dir = std::env::temp_dir().join(format!("vc-opening-links-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (before, after) = (dir.join("before"), dir.join("after")); + std::fs::create_dir_all(&before).unwrap(); + let file = |folder: &std::path::Path, name: &str| folder.join(name).to_string_lossy().into_owned(); + let picture = |format: image::ImageFormat, rgb: [u8; 3]| { + let mut out = vec![]; + image::RgbImage::from_pixel(60, 40, image::Rgb(rgb)).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out + }; + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (name, format) in [("a.tif", Tiff), ("b.bmp", Bmp), ("c.jpg", Jpeg), ("gone.png", Png), ("changed.png", Png)] { + std::fs::write(file(&before, name), picture(format, [200, 40, 40])).unwrap(); + app.session.execute("file.place", &json!({"path": file(&before, name), "at": [200, 150]})).unwrap(); + } + let area = json!({"width": 150, "height": 20, "fit": "autoHeight"}); + let t = app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type in two columns. ".repeat(12), "area": area})).unwrap(); + app.session.execute("text.areaOptions", &json!({"ids": [t["id"]], "columns": 2})).unwrap(); + app.session.execute("document.save", &json!({"path": file(&before, "poster.vectorcraft")})).unwrap(); + std::fs::remove_file(file(&before, "gone.png")).unwrap(); + let changed = file(&before, "changed.png"); + let stamp = std::fs::metadata(&changed).unwrap().modified().unwrap(); + std::fs::write(&changed, picture(Png, [40, 40, 200])).unwrap(); + // Link stamps use milliseconds: the new file is dated 2 s later. + let later = std::fs::FileTimes::new().set_modified(stamp + std::time::Duration::from_secs(2)); + std::fs::OpenOptions::new().write(true).open(&changed).unwrap().set_times(later).unwrap(); + // The folder moves: the linked files are found beside the document. + std::fs::rename(&before, &after).unwrap(); + let path = file(&after, "poster.vectorcraft"); + let bytes = std::fs::read(&path).unwrap(); + let asked = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.clone())); + for update in ["askWhenModified", "automatically"] { + app.session.prefs.update_links = update.into(); + let r = crate::io::open_bytes(&mut app, &path, &bytes, Some(path.clone())).unwrap(); + let rows = if update == "automatically" { &r["updatedLinks"] } else { &r["modifiedLinks"] }; + assert_eq!(rows[0]["name"], "changed.png", "{update}: {r}"); + let (at_once, question) = (app.session.doc().unwrap().doc.clone(), asked(&app)); + assert!(question.as_ref().is_some_and(|(_, f)| f["missing"][0]["name"] == "gone.png"), "{update}: {question:?}"); + app.ui.dialog = None; + start(&mut app, &path, bytes.clone()).unwrap(); + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + let st = app.session.doc().unwrap(); + assert!(*st.doc == *at_once, "{update}: the same document"); + assert!(st.history.undo.is_empty() && !st.is_dirty(), "{update}"); + assert_eq!(asked(&app), question, "{update}"); + } + let _ = std::fs::remove_dir_all(&dir); +} From 8bafcb04448ae6f54a2d1f5e357c9597cd01c216 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:28 +0000 Subject: [PATCH 084/199] refactor(tools): move shape.rs tests to shape/tests.rs --- crates/tools/src/shape.rs | 312 +------------------------------- crates/tools/src/shape/tests.rs | 309 +++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 311 deletions(-) create mode 100644 crates/tools/src/shape/tests.rs diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 75bac6afe..62679d99e 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -367,314 +367,4 @@ impl Tool for ShapeTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - #[test] - fn rect_drag() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeTool::new("rectangle"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 30.0)); - assert_eq!(a[0], Action::Begin("Rectangle".into())); - assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 20.0}))); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 30.0)), vec![Action::Commit]); - } - - #[test] - fn space_moves_the_shape_being_drawn() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let space = Mods { space: true, ..Default::default() }; - let mut t = ShapeTool::new("rectangle"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 120.0)); - // Space held: the 100 × 70 rectangle follows the pointer. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 170.0).with_mods(space)); - assert_eq!(a.last(), Some(&Action::Preview("shape.rectangle".into(), json!({"x": 150.0, "y": 100.0, "width": 100.0, "height": 70.0})))); - // Let go of Space: it grows again, from where it was moved to. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 270.0, 190.0)); - assert_eq!(a.last(), Some(&Action::Preview("shape.rectangle".into(), json!({"x": 150.0, "y": 100.0, "width": 120.0, "height": 90.0})))); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 270.0, 190.0)), vec![Action::Commit]); - // A star (drawn from its centre) moves its centre. - let mut t = ShapeTool::new("star"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 50.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 60.0).with_mods(space)); - assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["cx"] == 60.0 && v["cy"] == 60.0), "{a:?}"); - } - - #[test] - fn star_drag_measure_label_shows_outer_and_inner_radii() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = ToolContext { measurement_labels: true, smart_guides: false, ..cx(&d, &s, &p) }; - let mut t = ShapeTool::new("star"); - t.star_ratio = 0.5; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0)); - let want = cx.star_radii_label(40.0, 20.0); - let label = t - .overlays(&cx) - .into_iter() - .find_map(|o| match o { - Overlay::Measure { text, .. } => Some(text), - _ => None, - }) - .unwrap_or_default(); - assert_eq!(label, want, "{label}"); - } - - /// The star label shows the radii the preview draws, also with Ctrl/Cmd + Alt and when a - /// Ctrl/Cmd drag shrinks the outer radius below the held inner one (the ratio is clamped). - #[test] - fn star_measure_label_matches_the_preview_with_modifiers_and_shrink() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = ToolContext { measurement_labels: true, smart_guides: false, ..cx(&d, &s, &p) }; - let label = |t: &ShapeTool| { - t.overlays(&cx) - .into_iter() - .find_map(|o| match o { - Overlay::Measure { text, .. } => Some(text), - _ => None, - }) - .unwrap_or_default() - }; - let radii = |a: &[Action]| match a.last() { - Some(Action::Preview(_, v)) => (v["radius1"].as_f64().unwrap(), v["radius2"].as_f64().unwrap()), - other => panic!("{other:?}"), - }; - let cmd = Mods { cmd: true, ..Default::default() }; - let cmd_alt = Mods { cmd: true, alt: true, ..Default::default() }; - let mut t = ShapeTool::new("star"); - t.star_ratio = 0.5; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0)); - // Ctrl/Cmd holds the 20 inner radius while the outer one grows. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 10.0).with_mods(cmd)); - let (r1, r2) = radii(&a); - assert_eq!(label(&t), cx.star_radii_label(r1, r2)); - // Ctrl/Cmd + Alt: the preview straightens the shoulders, and so does the label. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 10.0).with_mods(cmd_alt)); - let (r1, r2) = radii(&a); - assert!((r2 - 20.0).abs() > 1e-6, "Alt should override the held radius: {r2}"); - assert_eq!(label(&t), cx.star_radii_label(r1, r2)); - // Shrinking below the held inner radius clamps the ratio; the label follows the preview. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 20.0, 10.0).with_mods(cmd)); - let (r1, r2) = radii(&a); - assert!(r2 <= r1 + 1e-9, "{r1} {r2}"); - assert_eq!(label(&t), cx.star_radii_label(r1, r2)); - } - - /// During a star drag, Ctrl/Cmd keeps the inner radius while the outer one follows the pointer - /// (the new ratio stays once the key is let go), and Alt keeps the shoulders straight (#812). - #[test] - fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let radii = |a: &[Action]| match a.last() { - Some(Action::Preview(_, v)) => (v["radius1"].as_f64().unwrap(), v["radius2"].as_f64().unwrap()), - other => panic!("{other:?}"), - }; - let ctrl = Mods { cmd: true, ..Default::default() }; - let mut t = ShapeTool::new("star"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); - let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0))); - assert_eq!((r1, r2), (40.0, 20.0)); - // Ctrl: the inner radius stays 20 as the outer one grows to 80. - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(ctrl)); - let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl))); - assert!((r1 - 80.0).abs() < 1e-9 && (r2 - 20.0).abs() < 1e-9, "{r1} {r2}"); - // Let go: the ratio (a quarter) stays. - let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 50.0))); - assert!((r2 / r1 - 0.25).abs() < 1e-9, "{r1} {r2}"); - // Alt: straight shoulders, about 0.382 for five points. - let alt = Mods { alt: true, ..Default::default() }; - let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 50.0).with_mods(alt))); - assert!((r2 / r1 - 0.381_966).abs() < 1e-5, "{}", r2 / r1); - assert_eq!(straight_ratio(4), None, "four points can't line up"); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 50.0)), vec![Action::Commit]); - } - - #[test] - fn shift_square_alt_center() { - let t = ShapeTool::new("ellipse"); - let (c, v) = t.command(Point::new(0.0, 0.0), Point::new(10.0, 4.0), Mods { shift: true, alt: true, ..Default::default() }); - assert_eq!(c, "shape.ellipse"); - assert_eq!(v, json!({"x": -10.0, "y": -10.0, "width": 20.0, "height": 20.0})); - } - - /// General › Constrain Angle: a Shift-drag squares along the turned axes, and the shape turns - /// by the angle about its centre. - #[test] - fn constrain_angle_squares_along_the_turned_axes() { - let t = ShapeTool::new("rectangle"); - let shift = Mods { shift: true, ..Default::default() }; - // 45°: the drag straight right is a diagonal of the turned axes, so a square on it. - let (_, v) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 0.0), shift, 45.0); - let side = 20.0 / 2f64.sqrt(); - let near = |k: &str, want: f64| (v[k].as_f64().unwrap() - want).abs() < 1e-9; - assert!(near("width", side) && near("height", side) && near("rotation", -45.0), "{v}"); - assert!(near("x", 10.0 - side / 2.0) && near("y", -side / 2.0), "centred on the drag's middle: {v}"); - // Whole turns are the page's axes exactly: the same box as 0°, unturned. - let (a, b) = (Point::new(3.0, 4.0), Point::new(41.0, 17.0)); - let flat = t.command_at(a, b, Mods::default(), 0.0); - for turn in [360.0, -360.0, 720.0] { - assert_eq!(t.command_at(a, b, Mods::default(), turn), flat, "{turn}°"); - } - // A quarter turn is exact (no floating-point noise in the box). - let (_, v) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 10.0), Mods::default(), 90.0); - assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v["rotation"].as_f64()), (Some(10.0), Some(20.0), Some(-90.0)), "{v}"); - let (_, w) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 10.0), Mods::default(), 450.0); - assert_eq!(v, w, "450° is 90°"); - } - - /// The size label gives the box as drawn: measured along the Constrain Angle's axes. - #[test] - fn constrain_angle_size_label_measures_the_turned_box() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.constrain_angle = 45.0; - let mut t = ShapeTool::new("rectangle"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 340.0, 300.0)); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - let (w, h) = (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap()); - assert!((w - h).abs() < 1e-9 && (w - 40.0 / 2f64.sqrt()).abs() < 1e-9, "{v}"); - let o = t.overlays(&cx); - assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(w, h))), "{o:?}"); - assert!(!o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(40.0, 0.0))), "not the page box"); - } - - #[test] - fn click_opens_dialog() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeTool::new("star"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), - vec![Action::Dialog("star".into(), json!({"x": 10.0, "y": 10.0}))] - ); - } - - #[test] - fn alt_click_passes_draw_from_centre_to_the_dialog() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Default::default() }; - let mut t = ShapeTool::new("rectangle"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt)); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0).with_mods(alt)), - vec![Action::Dialog("rectangle".into(), json!({"x": 10.0, "y": 10.0, "alt": true}))] - ); - } - - #[test] - fn arrow_keys_change_sides() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeTool::new("polygon"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0)); - let a = t.key(&cx, ToolKey::Up, Mods::default()); - assert!(matches!(&a[0], Action::Preview(_, v) if v["sides"] == 7)); - } - - /// The Rounded Rectangle radius stops at General › Corner Radius's range on both of its input - /// paths: a Tool Options value is clamped to it, and ↑ at the top stays there. - #[test] - fn rounded_rectangle_radius_is_clamped_to_its_range() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeTool::new("roundedRectangle"); - t.set_option("cornerRadius", &json!(5000)); - assert_eq!(t.options()["cornerRadius"], json!(MAX_CORNER_RADIUS)); - t.set_option("cornerRadius", &json!(f64::INFINITY)); - assert_eq!(t.options()["cornerRadius"], json!(12.0), "a non-finite value is the default"); - t.set_option("cornerRadius", &json!(-3)); - assert_eq!(t.options()["cornerRadius"], json!(0.0)); - t.set_option("cornerRadius", &json!(MAX_CORNER_RADIUS)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 40.0)); - let a = t.key(&cx, ToolKey::Up, Mods::default()); - assert!(matches!(&a[0], Action::Preview(_, v) if v["radius"] == json!(MAX_CORNER_RADIUS)), "{a:?}"); - assert_eq!(t.options()["cornerRadius"], json!(MAX_CORNER_RADIUS)); - } - - /// The start and the dragged corner land on another object's anchors (#506), hovering shows - /// it before the press, and the measurement label gives the size drawn. - #[test] - fn corners_snap_to_smart_guides() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ShapeTool::new("rectangle"); - let anchor = |o: &[Overlay], at: Point| o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == at)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 98.0, 102.0)); - assert!(anchor(&t.overlays(&cx), Point::new(100.0, 100.0))); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 98.0, 102.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0)); - assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 100.0, "y": 100.0, "width": 100.0, "height": 100.0}))); - let o = t.overlays(&cx); - assert!(anchor(&o, Point::new(200.0, 200.0)), "{o:?}"); - assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(100.0, 100.0)))); - // Alt draws from the centre: the label gives the whole size. - let alt = Mods { alt: true, ..Mods::default() }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0).with_mods(alt)); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(200.0, 200.0)))); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 198.0, 202.0)); - assert!(t.overlays(&cx).is_empty()); - // A line with Shift keeps to 45° steps, sliding into line with the rect's right side. - let mut t = ShapeTool::new("lineSegment"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); - let shift = Mods { shift: true, ..Mods::default() }; - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 302.0).with_mods(shift)); - assert!(matches!(&a[..], [_, Action::Preview(_, v)] if v["x2"] == 200.0 && (v["y2"].as_f64().unwrap() - 300.0).abs() < 1e-9), "{a:?}"); - let from = Point::new(300.0, 300.0); - let to = Point::new(200.0, 300.0); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); - } - - /// Line Segment Tool Options: Length and Angle are options (for a click's dialog), and Fill - /// Line makes the drag's line take the current fill. - #[test] - fn line_segment_options_and_fill_line() { - let mut t = ShapeTool::new("lineSegment"); - let (a, b) = (Point::new(0.0, 0.0), Point::new(30.0, 40.0)); - assert!(t.command(a, b, Mods::default()).1.get("fill").is_none()); - for (k, v) in [("length", json!(-80.0)), ("angle", json!(-90.0)), ("fillLine", json!(true))] { - t.set_option(k, &v); - } - t.set_option("length", &json!(f64::NAN)); - assert_eq!( - (t.options()["length"].clone(), t.options()["angle"].clone(), t.options()["fillLine"].clone()), - (json!(80.0), json!(270.0), json!(true)) - ); - assert_eq!(t.command(a, b, Mods::default()).1["fill"], json!(true)); - } -} +mod tests; diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs new file mode 100644 index 000000000..5086e393e --- /dev/null +++ b/crates/tools/src/shape/tests.rs @@ -0,0 +1,309 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +#[test] +fn rect_drag() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeTool::new("rectangle"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 30.0)); + assert_eq!(a[0], Action::Begin("Rectangle".into())); + assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 20.0}))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 30.0)), vec![Action::Commit]); +} + +#[test] +fn space_moves_the_shape_being_drawn() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let space = Mods { space: true, ..Default::default() }; + let mut t = ShapeTool::new("rectangle"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 120.0)); + // Space held: the 100 × 70 rectangle follows the pointer. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 170.0).with_mods(space)); + assert_eq!(a.last(), Some(&Action::Preview("shape.rectangle".into(), json!({"x": 150.0, "y": 100.0, "width": 100.0, "height": 70.0})))); + // Let go of Space: it grows again, from where it was moved to. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 270.0, 190.0)); + assert_eq!(a.last(), Some(&Action::Preview("shape.rectangle".into(), json!({"x": 150.0, "y": 100.0, "width": 120.0, "height": 90.0})))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 270.0, 190.0)), vec![Action::Commit]); + // A star (drawn from its centre) moves its centre. + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 50.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 60.0).with_mods(space)); + assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["cx"] == 60.0 && v["cy"] == 60.0), "{a:?}"); +} + +#[test] +fn star_drag_measure_label_shows_outer_and_inner_radii() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = ToolContext { measurement_labels: true, smart_guides: false, ..cx(&d, &s, &p) }; + let mut t = ShapeTool::new("star"); + t.star_ratio = 0.5; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0)); + let want = cx.star_radii_label(40.0, 20.0); + let label = t + .overlays(&cx) + .into_iter() + .find_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .unwrap_or_default(); + assert_eq!(label, want, "{label}"); +} + +/// The star label shows the radii the preview draws, also with Ctrl/Cmd + Alt and when a +/// Ctrl/Cmd drag shrinks the outer radius below the held inner one (the ratio is clamped). +#[test] +fn star_measure_label_matches_the_preview_with_modifiers_and_shrink() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = ToolContext { measurement_labels: true, smart_guides: false, ..cx(&d, &s, &p) }; + let label = |t: &ShapeTool| { + t.overlays(&cx) + .into_iter() + .find_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .unwrap_or_default() + }; + let radii = |a: &[Action]| match a.last() { + Some(Action::Preview(_, v)) => (v["radius1"].as_f64().unwrap(), v["radius2"].as_f64().unwrap()), + other => panic!("{other:?}"), + }; + let cmd = Mods { cmd: true, ..Default::default() }; + let cmd_alt = Mods { cmd: true, alt: true, ..Default::default() }; + let mut t = ShapeTool::new("star"); + t.star_ratio = 0.5; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0)); + // Ctrl/Cmd holds the 20 inner radius while the outer one grows. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 10.0).with_mods(cmd)); + let (r1, r2) = radii(&a); + assert_eq!(label(&t), cx.star_radii_label(r1, r2)); + // Ctrl/Cmd + Alt: the preview straightens the shoulders, and so does the label. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 10.0).with_mods(cmd_alt)); + let (r1, r2) = radii(&a); + assert!((r2 - 20.0).abs() > 1e-6, "Alt should override the held radius: {r2}"); + assert_eq!(label(&t), cx.star_radii_label(r1, r2)); + // Shrinking below the held inner radius clamps the ratio; the label follows the preview. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 20.0, 10.0).with_mods(cmd)); + let (r1, r2) = radii(&a); + assert!(r2 <= r1 + 1e-9, "{r1} {r2}"); + assert_eq!(label(&t), cx.star_radii_label(r1, r2)); +} + +/// During a star drag, Ctrl/Cmd keeps the inner radius while the outer one follows the pointer +/// (the new ratio stays once the key is let go), and Alt keeps the shoulders straight (#812). +#[test] +fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let radii = |a: &[Action]| match a.last() { + Some(Action::Preview(_, v)) => (v["radius1"].as_f64().unwrap(), v["radius2"].as_f64().unwrap()), + other => panic!("{other:?}"), + }; + let ctrl = Mods { cmd: true, ..Default::default() }; + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0))); + assert_eq!((r1, r2), (40.0, 20.0)); + // Ctrl: the inner radius stays 20 as the outer one grows to 80. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(ctrl)); + let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl))); + assert!((r1 - 80.0).abs() < 1e-9 && (r2 - 20.0).abs() < 1e-9, "{r1} {r2}"); + // Let go: the ratio (a quarter) stays. + let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 50.0))); + assert!((r2 / r1 - 0.25).abs() < 1e-9, "{r1} {r2}"); + // Alt: straight shoulders, about 0.382 for five points. + let alt = Mods { alt: true, ..Default::default() }; + let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 50.0).with_mods(alt))); + assert!((r2 / r1 - 0.381_966).abs() < 1e-5, "{}", r2 / r1); + assert_eq!(straight_ratio(4), None, "four points can't line up"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 50.0)), vec![Action::Commit]); +} + +#[test] +fn shift_square_alt_center() { + let t = ShapeTool::new("ellipse"); + let (c, v) = t.command(Point::new(0.0, 0.0), Point::new(10.0, 4.0), Mods { shift: true, alt: true, ..Default::default() }); + assert_eq!(c, "shape.ellipse"); + assert_eq!(v, json!({"x": -10.0, "y": -10.0, "width": 20.0, "height": 20.0})); +} + +/// General › Constrain Angle: a Shift-drag squares along the turned axes, and the shape turns +/// by the angle about its centre. +#[test] +fn constrain_angle_squares_along_the_turned_axes() { + let t = ShapeTool::new("rectangle"); + let shift = Mods { shift: true, ..Default::default() }; + // 45°: the drag straight right is a diagonal of the turned axes, so a square on it. + let (_, v) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 0.0), shift, 45.0); + let side = 20.0 / 2f64.sqrt(); + let near = |k: &str, want: f64| (v[k].as_f64().unwrap() - want).abs() < 1e-9; + assert!(near("width", side) && near("height", side) && near("rotation", -45.0), "{v}"); + assert!(near("x", 10.0 - side / 2.0) && near("y", -side / 2.0), "centred on the drag's middle: {v}"); + // Whole turns are the page's axes exactly: the same box as 0°, unturned. + let (a, b) = (Point::new(3.0, 4.0), Point::new(41.0, 17.0)); + let flat = t.command_at(a, b, Mods::default(), 0.0); + for turn in [360.0, -360.0, 720.0] { + assert_eq!(t.command_at(a, b, Mods::default(), turn), flat, "{turn}°"); + } + // A quarter turn is exact (no floating-point noise in the box). + let (_, v) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 10.0), Mods::default(), 90.0); + assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v["rotation"].as_f64()), (Some(10.0), Some(20.0), Some(-90.0)), "{v}"); + let (_, w) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 10.0), Mods::default(), 450.0); + assert_eq!(v, w, "450° is 90°"); +} + +/// The size label gives the box as drawn: measured along the Constrain Angle's axes. +#[test] +fn constrain_angle_size_label_measures_the_turned_box() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.constrain_angle = 45.0; + let mut t = ShapeTool::new("rectangle"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 340.0, 300.0)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let (w, h) = (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap()); + assert!((w - h).abs() < 1e-9 && (w - 40.0 / 2f64.sqrt()).abs() < 1e-9, "{v}"); + let o = t.overlays(&cx); + assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(w, h))), "{o:?}"); + assert!(!o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(40.0, 0.0))), "not the page box"); +} + +#[test] +fn click_opens_dialog() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), + vec![Action::Dialog("star".into(), json!({"x": 10.0, "y": 10.0}))] + ); +} + +#[test] +fn alt_click_passes_draw_from_centre_to_the_dialog() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = ShapeTool::new("rectangle"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt)); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0).with_mods(alt)), + vec![Action::Dialog("rectangle".into(), json!({"x": 10.0, "y": 10.0, "alt": true}))] + ); +} + +#[test] +fn arrow_keys_change_sides() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeTool::new("polygon"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0)); + let a = t.key(&cx, ToolKey::Up, Mods::default()); + assert!(matches!(&a[0], Action::Preview(_, v) if v["sides"] == 7)); +} + +/// The Rounded Rectangle radius stops at General › Corner Radius's range on both of its input +/// paths: a Tool Options value is clamped to it, and ↑ at the top stays there. +#[test] +fn rounded_rectangle_radius_is_clamped_to_its_range() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeTool::new("roundedRectangle"); + t.set_option("cornerRadius", &json!(5000)); + assert_eq!(t.options()["cornerRadius"], json!(MAX_CORNER_RADIUS)); + t.set_option("cornerRadius", &json!(f64::INFINITY)); + assert_eq!(t.options()["cornerRadius"], json!(12.0), "a non-finite value is the default"); + t.set_option("cornerRadius", &json!(-3)); + assert_eq!(t.options()["cornerRadius"], json!(0.0)); + t.set_option("cornerRadius", &json!(MAX_CORNER_RADIUS)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 40.0)); + let a = t.key(&cx, ToolKey::Up, Mods::default()); + assert!(matches!(&a[0], Action::Preview(_, v) if v["radius"] == json!(MAX_CORNER_RADIUS)), "{a:?}"); + assert_eq!(t.options()["cornerRadius"], json!(MAX_CORNER_RADIUS)); +} + +/// The start and the dragged corner land on another object's anchors (#506), hovering shows +/// it before the press, and the measurement label gives the size drawn. +#[test] +fn corners_snap_to_smart_guides() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeTool::new("rectangle"); + let anchor = |o: &[Overlay], at: Point| o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == at)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 98.0, 102.0)); + assert!(anchor(&t.overlays(&cx), Point::new(100.0, 100.0))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 98.0, 102.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0)); + assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 100.0, "y": 100.0, "width": 100.0, "height": 100.0}))); + let o = t.overlays(&cx); + assert!(anchor(&o, Point::new(200.0, 200.0)), "{o:?}"); + assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(100.0, 100.0)))); + // Alt draws from the centre: the label gives the whole size. + let alt = Mods { alt: true, ..Mods::default() }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0).with_mods(alt)); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(200.0, 200.0)))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 198.0, 202.0)); + assert!(t.overlays(&cx).is_empty()); + // A line with Shift keeps to 45° steps, sliding into line with the rect's right side. + let mut t = ShapeTool::new("lineSegment"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let shift = Mods { shift: true, ..Mods::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 302.0).with_mods(shift)); + assert!(matches!(&a[..], [_, Action::Preview(_, v)] if v["x2"] == 200.0 && (v["y2"].as_f64().unwrap() - 300.0).abs() < 1e-9), "{a:?}"); + let from = Point::new(300.0, 300.0); + let to = Point::new(200.0, 300.0); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); +} + +/// Line Segment Tool Options: Length and Angle are options (for a click's dialog), and Fill +/// Line makes the drag's line take the current fill. +#[test] +fn line_segment_options_and_fill_line() { + let mut t = ShapeTool::new("lineSegment"); + let (a, b) = (Point::new(0.0, 0.0), Point::new(30.0, 40.0)); + assert!(t.command(a, b, Mods::default()).1.get("fill").is_none()); + for (k, v) in [("length", json!(-80.0)), ("angle", json!(-90.0)), ("fillLine", json!(true))] { + t.set_option(k, &v); + } + t.set_option("length", &json!(f64::NAN)); + assert_eq!( + (t.options()["length"].clone(), t.options()["angle"].clone(), t.options()["fillLine"].clone()), + (json!(80.0), json!(270.0), json!(true)) + ); + assert_eq!(t.command(a, b, Mods::default()).1["fill"], json!(true)); +} From ed276d89d462a0f9c682a29f0a2b691c2247ea96 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:29 +0000 Subject: [PATCH 085/199] refactor(ui-egui): move panels/libraries.rs tests to panels/libraries/tests.rs --- crates/ui-egui/src/panels/libraries.rs | 308 +------------------ crates/ui-egui/src/panels/libraries/tests.rs | 305 ++++++++++++++++++ 2 files changed, 306 insertions(+), 307 deletions(-) create mode 100644 crates/ui-egui/src/panels/libraries/tests.rs diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 05c06f7bd..867533a5f 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -778,310 +778,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } #[cfg(test)] -mod tests { - use serde_json::json; - use vectorcraft_engine::Session; - - use super::*; - - fn frame(app: &mut VectorcraftApp) { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - show(app, ui); - menu(app, ui); - }); - out.textures_delta.clear(); - } - - /// The panel draws with no library, an empty one and one holding every kind of item (#745). - #[test] - fn the_panel_draws_its_libraries() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - frame(&mut app); - app.run("library.create", json!({"name": "Brand"})).unwrap(); - frame(&mut app); - let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.run("library.add", json!({"kind": "graphic"})).unwrap(); - app.run("library.add", json!({"kind": "fillColor"})).unwrap(); - let t = app.run("text.create", json!({"x": 10, "y": 80, "text": "Type"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [t]})).unwrap(); - app.run("library.add", json!({"kind": "charStyle"})).unwrap(); - app.run("library.add", json!({"kind": "paraStyle"})).unwrap(); - frame(&mut app); - let lib = app.session.libraries.get("Brand").unwrap(); - assert_eq!((lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()), (1, 1, 1, 1)); - // The thumbnail decodes. - let ctx = egui::Context::default(); - assert!(thumbnail(&ctx, "Brand", &lib.graphics[0], 256).is_some()); - // Shrunk to the size it is drawn at. - let small = thumbnail(&ctx, "Brand", &lib.graphics[0], 40).unwrap(); - assert_eq!(small.size().into_iter().max(), Some(40), "{:?}", small.size()); - } - - /// The panel's text: every label it shows in one frame (`query` typed in the search field). - fn texts(app: &mut VectorcraftApp, query: &str) -> Vec { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("library-search"), query.to_string())); - let mut shapes = vec![]; - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - shapes.append(&mut out.shapes); - let mut texts = vec![]; - for s in shapes { - if let egui::epaint::Shape::Text(t) = s.shape { - texts.push(t.galley.text().to_string()); - } - } - texts - } - - /// The search field filters items by name, ignoring case; groups show after the ungrouped - /// items, and a search nothing matches says so (#926). - #[test] - fn the_panel_searches_and_shows_groups() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app.run("library.create", json!({"name": "Brand"})).unwrap(); - for (name, color) in [("Ocean Blue", "#0044aa"), ("Sun Yellow", "#ffcc00")] { - app.run("library.add", json!({"kind": "fillColor", "color": color, "name": name})).unwrap(); - } - let t = app.run("text.create", json!({"x": 10, "y": 80, "text": "Type"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [t]})).unwrap(); - app.run("library.add", json!({"kind": "charStyle", "name": "Headline"})).unwrap(); - app.run("library.createGroup", json!({"name": "Warm", "items": [{"kind": "fillColor", "item": "Sun Yellow"}]})).unwrap(); - let all = texts(&mut app, ""); - let at = |s: &str| all.iter().position(|t| t == s); - assert!(at("Headline").is_some() && at("Warm").is_some(), "{all:?}"); - assert!(at("Headline") < at("Warm"), "groups come after the ungrouped items: {all:?}"); - // "BLUE" finds the blue swatch only: the style and the group without a match are hidden. - let found = texts(&mut app, "BLUE"); - assert!(!found.iter().any(|t| t == "Headline" || t == "Warm"), "{found:?}"); - let found = texts(&mut app, "yellow"); - assert!(found.iter().any(|t| t == "Warm") && !found.iter().any(|t| t == "Headline"), "{found:?}"); - let none = texts(&mut app, "zzz"); - assert!(none.iter().any(|t| t == "No results"), "{none:?}"); - } - - /// A library imported from `styles` (character style names) and `groups` ([(name, its styles)]) - /// → its id. - fn styled(app: &mut VectorcraftApp, styles: &[&str], groups: &[(&str, &[&str])]) -> String { - let styles: Vec = styles.iter().enumerate().map(|(i, n)| json!({"name": n, "attrs": {"size": 8 + i}})).collect(); - let groups: Vec = groups - .iter() - .map(|(g, items)| json!({"name": g, "items": items.iter().map(|i| json!({"kind": "charStyle", "item": i})).collect::>()})) - .collect(); - let data = json!({"name": "Order", "charStyles": styles, "groups": groups}).to_string(); - app.run("library.import", json!({"data": data})).unwrap()["id"].as_str().unwrap().to_string() - } - - /// One frame of the panel in a 260 × `tall` window with `events` → where each text it shows is - /// (its rect), and the panel's height. - fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, tall: f32, events: Vec) -> (Vec<(String, Rect)>, f32) { - let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(260.0, tall))), events, ..Default::default() }; - let mut height = 0.0; - let mut out = ctx.run_ui(raw, |ui| { - show(app, ui); - height = ui.min_rect().height(); - }); - out.textures_delta.clear(); - let mut texts = vec![]; - for s in out.shapes { - if let egui::epaint::Shape::Text(t) = s.shape { - texts.push((t.galley.text().to_string(), t.galley.rect.translate(t.pos.to_vec2()))); - } - } - (texts, height) - } - - /// Where text `name` is in `texts`. - fn at(texts: &[(String, Rect)], name: &str) -> Rect { - texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} isn't shown: {texts:?}")) - } - - /// `drag` released at `pos`. - fn drop_at(app: &mut VectorcraftApp, ctx: &egui::Context, drag: PanelDrag, pos: egui::Pos2) { - egui::DragAndDrop::set_payload(ctx, drag); - let up = egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - frame_at(app, ctx, 600.0, vec![egui::Event::PointerMoved(pos), up]); - frame_at(app, ctx, 600.0, vec![]); - } - - fn style_order(app: &mut VectorcraftApp) -> Vec { - let got = app.run("library.get", json!({})).unwrap(); - got["charStyles"].as_array().unwrap().iter().map(|s| s["name"].as_str().unwrap().to_string()).collect() - } - - fn group_names(app: &mut VectorcraftApp) -> Vec<(String, Vec)> { - let got = app.run("library.get", json!({})).unwrap(); - let items = |g: &Value| g["items"].as_array().unwrap().iter().map(|i| i["item"].as_str().unwrap().to_string()).collect(); - got["groups"].as_array().unwrap().iter().map(|g| (g["name"].as_str().unwrap().to_string(), items(g))).collect() - } - - /// Dragging in the list: a style dropped on the top or bottom half of another lands before or - /// after it, in its group; one dropped on a group's header goes into that group; a group's - /// header dropped on another group moves the group; and with every item grouped, a strip - /// takes an item out of its group. - #[test] - fn items_and_groups_move_by_dragging() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let lib = styled(&mut app, &["A", "B", "C", "D"], &[("Warm", &["D"]), ("Cool", &[])]); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - let item = |name: &str| PanelDrag::LibraryItem { library: lib.clone(), kind: ItemKind::CharStyle, item: name.into() }; - // C onto A's top half: first. - let a = at(&texts, "A"); - drop_at(&mut app, &ctx, item("C"), egui::pos2(a.center().x, a.top() - 4.0)); - assert_eq!(style_order(&mut app), ["C", "A", "B", "D"]); - // A onto D's bottom half (in Warm): after D, in Warm. - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - let d = at(&texts, "D"); - drop_at(&mut app, &ctx, item("A"), egui::pos2(d.center().x, d.bottom() + 4.0)); - assert_eq!(style_order(&mut app), ["C", "B", "D", "A"]); - assert_eq!(group_names(&mut app), [("Warm".into(), vec!["D".to_string(), "A".into()]), ("Cool".into(), vec![])]); - // B onto Cool's header: into Cool, the order kept. - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - drop_at(&mut app, &ctx, item("B"), at(&texts, "Cool").center()); - assert_eq!(style_order(&mut app), ["C", "B", "D", "A"]); - assert_eq!(group_names(&mut app)[1], ("Cool".into(), vec!["B".to_string()])); - // Cool's header onto Warm's header (its top half): Cool first. - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - let warm = at(&texts, "Warm"); - drop_at(&mut app, &ctx, PanelDrag::LibraryGroup { library: lib.clone(), group: "Cool".into() }, egui::pos2(warm.center().x, warm.top())); - assert_eq!(group_names(&mut app).iter().map(|(g, _)| g.as_str()).collect::>(), ["Cool", "Warm"]); - // Every item grouped: while one is dragged a strip takes it out of its group. - app.run("library.moveItem", json!({"kind": "charStyle", "item": "C", "group": "Warm"})).unwrap(); - egui::DragAndDrop::set_payload(&ctx, item("C")); - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - let strip = at(&texts, "Drop here to take it out of its group"); - drop_at(&mut app, &ctx, item("C"), strip.center()); - assert!(group_names(&mut app).iter().all(|(_, items)| !items.contains(&"C".to_string())), "{:?}", group_names(&mut app)); - // Without a drag there is no strip. - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - assert!(!texts.iter().any(|(t, _)| t == "Drop here to take it out of its group")); - } - - /// The list toggle beside the picker shows the colours and graphics as rows with their names - /// (a colour's hex value too), the icon toggle as chips and tiles again; in the list an item - /// dropped on another's top half lands before it. - #[test] - fn the_list_view_shows_names_and_takes_drops_by_row() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app.run("library.create", json!({"name": "Brand"})).unwrap(); - for (name, color) in [("Ocean Blue", "#0044aa"), ("Sun Yellow", "#ffcc00")] { - app.run("library.add", json!({"kind": "fillColor", "color": color, "name": name})).unwrap(); - } - let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.run("library.add", json!({"kind": "graphic", "name": "Badge"})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let shown = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t.eq_ignore_ascii_case(s)); - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - assert!(!shown(&texts, "Ocean Blue") && !shown(&texts, "Badge"), "icons by default: {texts:?}"); - // The list toggle: the first of the two buttons at the right of the picker's row. - let row = at(&texts, "Brand"); - let list_button = egui::pos2(260.0 - 8.0 - 24.0 - ctx.global_style().spacing.item_spacing.x - 12.0, row.center().y); - let click = - |pressed| egui::Event::PointerButton { pos: list_button, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame_at(&mut app, &ctx, 600.0, vec![egui::Event::PointerMoved(list_button), click(true)]); - frame_at(&mut app, &ctx, 600.0, vec![click(false)]); - assert!(crate::panels::pstate::(&ctx, LIST_VIEW), "the list toggle was clicked"); - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - for s in ["Ocean Blue", "#0044aa", "Sun Yellow", "#ffcc00", "Badge"] { - assert!(shown(&texts, s), "{s} in the list: {texts:?}"); - } - // Sun Yellow onto Ocean Blue's top half: first. - let ocean = at(&texts, "Ocean Blue"); - let drag = PanelDrag::Paint { - paint: Paint::solid(vectorcraft_color::Color::from_hex("#ffcc00").unwrap()), - params: json!({}), - rows: None, - library: Some(("Brand".into(), "Sun Yellow".into())), - }; - drop_at(&mut app, &ctx, drag, egui::pos2(ocean.center().x, ocean.top() - 3.0)); - let got = app.run("library.get", json!({})).unwrap(); - let names: Vec<&str> = got["colors"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); - assert_eq!(names, ["Sun Yellow", "Ocean Blue"]); - // Back to icons: no names. - crate::panels::set_pstate(&ctx, LIST_VIEW, false); - let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); - assert!(!shown(&texts, "Ocean Blue"), "{texts:?}"); - } - - /// A long list scrolls inside the panel (its bottom bar stays in the window), and a drag held - /// near the list's bottom edge scrolls it. - #[test] - fn a_long_list_scrolls_also_while_dragging() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let names: Vec = (0..60).map(|i| format!("Style {i}")).collect(); - let lib = styled(&mut app, &names.iter().map(String::as_str).collect::>(), &[]); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let (texts, height) = frame_at(&mut app, &ctx, 400.0, vec![]); - assert!(height <= 400.0, "the panel fits the window: {height}"); - let first = at(&texts, "Style 0"); - // The list's bottom edge: the last text drawn before the window's bottom bar area. - let bottom = texts.iter().filter(|(t, _)| t.starts_with("Style ")).map(|(_, r)| r.bottom()).fold(0.0, f32::max).min(400.0 - BAR - 4.0); - egui::DragAndDrop::set_payload(&ctx, PanelDrag::LibraryItem { library: lib, kind: ItemKind::CharStyle, item: "Style 0".into() }); - let hold = egui::pos2(first.center().x, bottom - 4.0); - for _ in 0..5 { - frame_at(&mut app, &ctx, 400.0, vec![egui::Event::PointerMoved(hold)]); - } - let (texts, _) = frame_at(&mut app, &ctx, 400.0, vec![egui::Event::PointerMoved(hold)]); - let moved = texts.iter().find(|(t, _)| t == "Style 0").map_or(f32::NEG_INFINITY, |(_, r)| r.top()); - assert!(moved < first.top(), "the list scrolled down: {} → {moved}", first.top()); - } - - /// New Group… and Rename Group… ask for a name and act on the library and group they came - /// from; the dialog shows only the name. - #[test] - fn group_dialogs_run_with_their_library_and_group() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - let lib = app.run("library.create", json!({"name": "Brand"})).unwrap()["id"].as_str().unwrap().to_string(); - app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap(); - ask_group_name(&mut app, &lib, None, json!([{"kind": "fillColor", "item": "Blue"}])); - app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cool")); - crate::dialogs::confirm(&mut app).unwrap(); - ask_group_name(&mut app, &lib, Some("Cool"), Value::Null); - app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cold")); - crate::dialogs::confirm(&mut app).unwrap(); - let got = app.run("library.get", json!({})).unwrap(); - assert_eq!(got["groups"], json!([{"name": "Cold", "items": [{"kind": "color", "item": "Blue"}]}])); - } - - /// Export Library… writes the library's file and Import Library… adds it back as a new library. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn libraries_are_exported_and_imported_from_the_panel_menu() { - let dir = vectorcraft_testkit::temp_dir("libraries-panel"); - let path = dir.join("Brand.vclibrary").to_string_lossy().to_string(); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app.run("library.create", json!({"name": "Brand"})).unwrap(); - app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap(); - let picked = path.clone(); - app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| { - assert_eq!(pick.name, "Brand.vclibrary", "the library's name suggested"); - Some(picked.clone()) - })); - app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| std::fs::write(p, bytes).map_err(|e| e.to_string()))); - export_picked(&mut app, "Brand", "Brand"); - assert!(std::path::Path::new(&path).exists()); - let picked = path.clone(); - app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); - import_picked(&mut app); - let list = app.run("library.list", json!({})).unwrap(); - let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect(); - assert_eq!(names, ["Brand", "Brand (2)"]); - assert_eq!(app.ui.status, "Imported library Brand (2)"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/libraries/tests.rs b/crates/ui-egui/src/panels/libraries/tests.rs new file mode 100644 index 000000000..386be6a3f --- /dev/null +++ b/crates/ui-egui/src/panels/libraries/tests.rs @@ -0,0 +1,305 @@ +use serde_json::json; +use vectorcraft_engine::Session; + +use super::*; + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(app, ui); + menu(app, ui); + }); + out.textures_delta.clear(); +} + +/// The panel draws with no library, an empty one and one holding every kind of item (#745). +#[test] +fn the_panel_draws_its_libraries() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + frame(&mut app); + app.run("library.create", json!({"name": "Brand"})).unwrap(); + frame(&mut app); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("library.add", json!({"kind": "graphic"})).unwrap(); + app.run("library.add", json!({"kind": "fillColor"})).unwrap(); + let t = app.run("text.create", json!({"x": 10, "y": 80, "text": "Type"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [t]})).unwrap(); + app.run("library.add", json!({"kind": "charStyle"})).unwrap(); + app.run("library.add", json!({"kind": "paraStyle"})).unwrap(); + frame(&mut app); + let lib = app.session.libraries.get("Brand").unwrap(); + assert_eq!((lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()), (1, 1, 1, 1)); + // The thumbnail decodes. + let ctx = egui::Context::default(); + assert!(thumbnail(&ctx, "Brand", &lib.graphics[0], 256).is_some()); + // Shrunk to the size it is drawn at. + let small = thumbnail(&ctx, "Brand", &lib.graphics[0], 40).unwrap(); + assert_eq!(small.size().into_iter().max(), Some(40), "{:?}", small.size()); +} + +/// The panel's text: every label it shows in one frame (`query` typed in the search field). +fn texts(app: &mut VectorcraftApp, query: &str) -> Vec { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("library-search"), query.to_string())); + let mut shapes = vec![]; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + shapes.append(&mut out.shapes); + let mut texts = vec![]; + for s in shapes { + if let egui::epaint::Shape::Text(t) = s.shape { + texts.push(t.galley.text().to_string()); + } + } + texts +} + +/// The search field filters items by name, ignoring case; groups show after the ungrouped +/// items, and a search nothing matches says so (#926). +#[test] +fn the_panel_searches_and_shows_groups() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("library.create", json!({"name": "Brand"})).unwrap(); + for (name, color) in [("Ocean Blue", "#0044aa"), ("Sun Yellow", "#ffcc00")] { + app.run("library.add", json!({"kind": "fillColor", "color": color, "name": name})).unwrap(); + } + let t = app.run("text.create", json!({"x": 10, "y": 80, "text": "Type"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [t]})).unwrap(); + app.run("library.add", json!({"kind": "charStyle", "name": "Headline"})).unwrap(); + app.run("library.createGroup", json!({"name": "Warm", "items": [{"kind": "fillColor", "item": "Sun Yellow"}]})).unwrap(); + let all = texts(&mut app, ""); + let at = |s: &str| all.iter().position(|t| t == s); + assert!(at("Headline").is_some() && at("Warm").is_some(), "{all:?}"); + assert!(at("Headline") < at("Warm"), "groups come after the ungrouped items: {all:?}"); + // "BLUE" finds the blue swatch only: the style and the group without a match are hidden. + let found = texts(&mut app, "BLUE"); + assert!(!found.iter().any(|t| t == "Headline" || t == "Warm"), "{found:?}"); + let found = texts(&mut app, "yellow"); + assert!(found.iter().any(|t| t == "Warm") && !found.iter().any(|t| t == "Headline"), "{found:?}"); + let none = texts(&mut app, "zzz"); + assert!(none.iter().any(|t| t == "No results"), "{none:?}"); +} + +/// A library imported from `styles` (character style names) and `groups` ([(name, its styles)]) +/// → its id. +fn styled(app: &mut VectorcraftApp, styles: &[&str], groups: &[(&str, &[&str])]) -> String { + let styles: Vec = styles.iter().enumerate().map(|(i, n)| json!({"name": n, "attrs": {"size": 8 + i}})).collect(); + let groups: Vec = groups + .iter() + .map(|(g, items)| json!({"name": g, "items": items.iter().map(|i| json!({"kind": "charStyle", "item": i})).collect::>()})) + .collect(); + let data = json!({"name": "Order", "charStyles": styles, "groups": groups}).to_string(); + app.run("library.import", json!({"data": data})).unwrap()["id"].as_str().unwrap().to_string() +} + +/// One frame of the panel in a 260 × `tall` window with `events` → where each text it shows is +/// (its rect), and the panel's height. +fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, tall: f32, events: Vec) -> (Vec<(String, Rect)>, f32) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(260.0, tall))), events, ..Default::default() }; + let mut height = 0.0; + let mut out = ctx.run_ui(raw, |ui| { + show(app, ui); + height = ui.min_rect().height(); + }); + out.textures_delta.clear(); + let mut texts = vec![]; + for s in out.shapes { + if let egui::epaint::Shape::Text(t) = s.shape { + texts.push((t.galley.text().to_string(), t.galley.rect.translate(t.pos.to_vec2()))); + } + } + (texts, height) +} + +/// Where text `name` is in `texts`. +fn at(texts: &[(String, Rect)], name: &str) -> Rect { + texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} isn't shown: {texts:?}")) +} + +/// `drag` released at `pos`. +fn drop_at(app: &mut VectorcraftApp, ctx: &egui::Context, drag: PanelDrag, pos: egui::Pos2) { + egui::DragAndDrop::set_payload(ctx, drag); + let up = egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame_at(app, ctx, 600.0, vec![egui::Event::PointerMoved(pos), up]); + frame_at(app, ctx, 600.0, vec![]); +} + +fn style_order(app: &mut VectorcraftApp) -> Vec { + let got = app.run("library.get", json!({})).unwrap(); + got["charStyles"].as_array().unwrap().iter().map(|s| s["name"].as_str().unwrap().to_string()).collect() +} + +fn group_names(app: &mut VectorcraftApp) -> Vec<(String, Vec)> { + let got = app.run("library.get", json!({})).unwrap(); + let items = |g: &Value| g["items"].as_array().unwrap().iter().map(|i| i["item"].as_str().unwrap().to_string()).collect(); + got["groups"].as_array().unwrap().iter().map(|g| (g["name"].as_str().unwrap().to_string(), items(g))).collect() +} + +/// Dragging in the list: a style dropped on the top or bottom half of another lands before or +/// after it, in its group; one dropped on a group's header goes into that group; a group's +/// header dropped on another group moves the group; and with every item grouped, a strip +/// takes an item out of its group. +#[test] +fn items_and_groups_move_by_dragging() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let lib = styled(&mut app, &["A", "B", "C", "D"], &[("Warm", &["D"]), ("Cool", &[])]); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + let item = |name: &str| PanelDrag::LibraryItem { library: lib.clone(), kind: ItemKind::CharStyle, item: name.into() }; + // C onto A's top half: first. + let a = at(&texts, "A"); + drop_at(&mut app, &ctx, item("C"), egui::pos2(a.center().x, a.top() - 4.0)); + assert_eq!(style_order(&mut app), ["C", "A", "B", "D"]); + // A onto D's bottom half (in Warm): after D, in Warm. + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + let d = at(&texts, "D"); + drop_at(&mut app, &ctx, item("A"), egui::pos2(d.center().x, d.bottom() + 4.0)); + assert_eq!(style_order(&mut app), ["C", "B", "D", "A"]); + assert_eq!(group_names(&mut app), [("Warm".into(), vec!["D".to_string(), "A".into()]), ("Cool".into(), vec![])]); + // B onto Cool's header: into Cool, the order kept. + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + drop_at(&mut app, &ctx, item("B"), at(&texts, "Cool").center()); + assert_eq!(style_order(&mut app), ["C", "B", "D", "A"]); + assert_eq!(group_names(&mut app)[1], ("Cool".into(), vec!["B".to_string()])); + // Cool's header onto Warm's header (its top half): Cool first. + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + let warm = at(&texts, "Warm"); + drop_at(&mut app, &ctx, PanelDrag::LibraryGroup { library: lib.clone(), group: "Cool".into() }, egui::pos2(warm.center().x, warm.top())); + assert_eq!(group_names(&mut app).iter().map(|(g, _)| g.as_str()).collect::>(), ["Cool", "Warm"]); + // Every item grouped: while one is dragged a strip takes it out of its group. + app.run("library.moveItem", json!({"kind": "charStyle", "item": "C", "group": "Warm"})).unwrap(); + egui::DragAndDrop::set_payload(&ctx, item("C")); + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + let strip = at(&texts, "Drop here to take it out of its group"); + drop_at(&mut app, &ctx, item("C"), strip.center()); + assert!(group_names(&mut app).iter().all(|(_, items)| !items.contains(&"C".to_string())), "{:?}", group_names(&mut app)); + // Without a drag there is no strip. + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + assert!(!texts.iter().any(|(t, _)| t == "Drop here to take it out of its group")); +} + +/// The list toggle beside the picker shows the colours and graphics as rows with their names +/// (a colour's hex value too), the icon toggle as chips and tiles again; in the list an item +/// dropped on another's top half lands before it. +#[test] +fn the_list_view_shows_names_and_takes_drops_by_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("library.create", json!({"name": "Brand"})).unwrap(); + for (name, color) in [("Ocean Blue", "#0044aa"), ("Sun Yellow", "#ffcc00")] { + app.run("library.add", json!({"kind": "fillColor", "color": color, "name": name})).unwrap(); + } + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("library.add", json!({"kind": "graphic", "name": "Badge"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let shown = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t.eq_ignore_ascii_case(s)); + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + assert!(!shown(&texts, "Ocean Blue") && !shown(&texts, "Badge"), "icons by default: {texts:?}"); + // The list toggle: the first of the two buttons at the right of the picker's row. + let row = at(&texts, "Brand"); + let list_button = egui::pos2(260.0 - 8.0 - 24.0 - ctx.global_style().spacing.item_spacing.x - 12.0, row.center().y); + let click = + |pressed| egui::Event::PointerButton { pos: list_button, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame_at(&mut app, &ctx, 600.0, vec![egui::Event::PointerMoved(list_button), click(true)]); + frame_at(&mut app, &ctx, 600.0, vec![click(false)]); + assert!(crate::panels::pstate::(&ctx, LIST_VIEW), "the list toggle was clicked"); + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + for s in ["Ocean Blue", "#0044aa", "Sun Yellow", "#ffcc00", "Badge"] { + assert!(shown(&texts, s), "{s} in the list: {texts:?}"); + } + // Sun Yellow onto Ocean Blue's top half: first. + let ocean = at(&texts, "Ocean Blue"); + let drag = PanelDrag::Paint { + paint: Paint::solid(vectorcraft_color::Color::from_hex("#ffcc00").unwrap()), + params: json!({}), + rows: None, + library: Some(("Brand".into(), "Sun Yellow".into())), + }; + drop_at(&mut app, &ctx, drag, egui::pos2(ocean.center().x, ocean.top() - 3.0)); + let got = app.run("library.get", json!({})).unwrap(); + let names: Vec<&str> = got["colors"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Sun Yellow", "Ocean Blue"]); + // Back to icons: no names. + crate::panels::set_pstate(&ctx, LIST_VIEW, false); + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + assert!(!shown(&texts, "Ocean Blue"), "{texts:?}"); +} + +/// A long list scrolls inside the panel (its bottom bar stays in the window), and a drag held +/// near the list's bottom edge scrolls it. +#[test] +fn a_long_list_scrolls_also_while_dragging() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let names: Vec = (0..60).map(|i| format!("Style {i}")).collect(); + let lib = styled(&mut app, &names.iter().map(String::as_str).collect::>(), &[]); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (texts, height) = frame_at(&mut app, &ctx, 400.0, vec![]); + assert!(height <= 400.0, "the panel fits the window: {height}"); + let first = at(&texts, "Style 0"); + // The list's bottom edge: the last text drawn before the window's bottom bar area. + let bottom = texts.iter().filter(|(t, _)| t.starts_with("Style ")).map(|(_, r)| r.bottom()).fold(0.0, f32::max).min(400.0 - BAR - 4.0); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::LibraryItem { library: lib, kind: ItemKind::CharStyle, item: "Style 0".into() }); + let hold = egui::pos2(first.center().x, bottom - 4.0); + for _ in 0..5 { + frame_at(&mut app, &ctx, 400.0, vec![egui::Event::PointerMoved(hold)]); + } + let (texts, _) = frame_at(&mut app, &ctx, 400.0, vec![egui::Event::PointerMoved(hold)]); + let moved = texts.iter().find(|(t, _)| t == "Style 0").map_or(f32::NEG_INFINITY, |(_, r)| r.top()); + assert!(moved < first.top(), "the list scrolled down: {} → {moved}", first.top()); +} + +/// New Group… and Rename Group… ask for a name and act on the library and group they came +/// from; the dialog shows only the name. +#[test] +fn group_dialogs_run_with_their_library_and_group() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let lib = app.run("library.create", json!({"name": "Brand"})).unwrap()["id"].as_str().unwrap().to_string(); + app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap(); + ask_group_name(&mut app, &lib, None, json!([{"kind": "fillColor", "item": "Blue"}])); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cool")); + crate::dialogs::confirm(&mut app).unwrap(); + ask_group_name(&mut app, &lib, Some("Cool"), Value::Null); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cold")); + crate::dialogs::confirm(&mut app).unwrap(); + let got = app.run("library.get", json!({})).unwrap(); + assert_eq!(got["groups"], json!([{"name": "Cold", "items": [{"kind": "color", "item": "Blue"}]}])); +} + +/// Export Library… writes the library's file and Import Library… adds it back as a new library. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn libraries_are_exported_and_imported_from_the_panel_menu() { + let dir = vectorcraft_testkit::temp_dir("libraries-panel"); + let path = dir.join("Brand.vclibrary").to_string_lossy().to_string(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("library.create", json!({"name": "Brand"})).unwrap(); + app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap(); + let picked = path.clone(); + app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| { + assert_eq!(pick.name, "Brand.vclibrary", "the library's name suggested"); + Some(picked.clone()) + })); + app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| std::fs::write(p, bytes).map_err(|e| e.to_string()))); + export_picked(&mut app, "Brand", "Brand"); + assert!(std::path::Path::new(&path).exists()); + let picked = path.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + import_picked(&mut app); + let list = app.run("library.list", json!({})).unwrap(); + let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Brand", "Brand (2)"]); + assert_eq!(app.ui.status, "Imported library Brand (2)"); +} From a977a92bb4fb304eba86b42f123bd5445a2c3eb2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:29 +0000 Subject: [PATCH 086/199] refactor(vectorcraft): move logging.rs tests to logging/tests.rs --- apps/vectorcraft/src/logging.rs | 307 +------------------------- apps/vectorcraft/src/logging/tests.rs | 304 +++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 306 deletions(-) create mode 100644 apps/vectorcraft/src/logging/tests.rs diff --git a/apps/vectorcraft/src/logging.rs b/apps/vectorcraft/src/logging.rs index a9fded642..e005d702d 100644 --- a/apps/vectorcraft/src/logging.rs +++ b/apps/vectorcraft/src/logging.rs @@ -296,309 +296,4 @@ pub fn install() -> Option<&'static AppLogger> { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, UNIX_EPOCH}; - - fn temp_dir(tag: &str) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("vectorcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); - let _ = std::fs::remove_dir_all(&d); - d - } - - fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read log") - } - - #[test] - fn the_default_filter_shows_vectorcraft_info_and_other_crates_warnings() { - let f = Filter::parse(DEFAULT_FILTER); - assert_eq!(f.level_for("vectorcraft"), LevelFilter::Info); - assert_eq!(f.level_for("vectorcraft::window"), LevelFilter::Info); - assert_eq!(f.level_for("vectorcraft_engine::guard"), LevelFilter::Info); - assert_eq!(f.level_for("vectorcraft_ui_egui::io"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); - assert_eq!(f.level_for("naga"), LevelFilter::Warn); - assert_eq!(f.max(), LevelFilter::Info); - } - - #[test] - fn directives_follow_env_logger_and_the_most_specific_one_wins() { - let f = Filter::parse("info,wgpu_core=error,vectorcraft_render=trace,vectorcraft_render::tiles=off"); - assert_eq!(f.level_for("eframe"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); - assert_eq!(f.level_for("vectorcraft_render::canvas"), LevelFilter::Trace); - assert_eq!(f.level_for("vectorcraft_render::tiles"), LevelFilter::Off); - assert_eq!(f.max(), LevelFilter::Trace); - // A module name is matched at `::` boundaries, not as a bare prefix. - assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); - // A trailing `*` is a prefix. - assert_eq!(Filter::parse("vectorcraft*=debug").level_for("vectorcraft_text::shape"), LevelFilter::Debug); - assert_eq!(Filter::parse("vectorcraft*=debug").level_for("eframe"), LevelFilter::Error); - // A bare target name sets that target to the most verbose level, as env_logger does. - assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); - assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); - // Levels are case-insensitive and surrounding blanks are ignored. - assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("vectorcraft"), LevelFilter::Debug); - assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("eframe"), LevelFilter::Warn); - } - - #[test] - fn hostile_specs_never_panic_and_fall_back_sensibly() { - let long = "x".repeat(100_000); - let many = "a=info,".repeat(10_000); - for spec in [ - "", - ",,,", - "=", - "==", - "=debug", - "vectorcraft=", - "nonsense=loud", - "🦀=info", - "*", - "*=", - "=*", - "a=b=c", - "warn,,vectorcraft=DEBUG", - "\0", - "é::ü=trace", - &long, - &many, - ] { - let f = Filter::parse(spec); - for target in ["", "vectorcraft", "🦀", "a::b", "*"] { - let _ = f.level_for(target); - } - let _ = f.max(); - } - assert_eq!(Filter::parse("warn,,vectorcraft=DEBUG").level_for("vectorcraft"), LevelFilter::Debug); - // An unknown level is skipped: that target keeps the default. - assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); - assert_eq!(Filter::parse("vectorcraft=").level_for("vectorcraft"), LevelFilter::Error); - assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); - assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); - } - - #[test] - fn timestamps_are_utc_with_milliseconds() { - assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); - // Leap day, and the day after it. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); - // 2000 is a leap year (divisible by 400), 2100 is not. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. - assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); - // Past chrono's year range still converts via the 400-year cycle (the old code did too, - // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' - // SystemTime can't hold this instant, so it is only checked where it exists. - // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { - assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); - } - // Years past 9999 keep the plain zero-padded year (no `+`). - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); - // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. - // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { - assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); - } - } - - #[test] - fn a_line_carries_time_level_thread_target_and_message() { - assert_eq!( - format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "vectorcraft", "rendering with Mesa (Vulkan, IntegratedGpu)"), - "2026-10-08T07:59:17.728Z INFO [main] vectorcraft: rendering with Mesa (Vulkan, IntegratedGpu)\n" - ); - // A multi-line message keeps its lines; the record still ends in one newline. - assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); - } - - #[test] - fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { - let dir = temp_dir("rotate"); - for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); - assert_eq!(path, dir.join(LOG_FILE)); - assert_eq!(read(&path), ""); - std::fs::write(&path, format!("run {run}")).expect("write"); - } - assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); - assert_eq!(read(&dir.join("vectorcraft.1.log")), "run 3"); - assert_eq!(read(&dir.join("vectorcraft.2.log")), "run 2"); - assert!(!dir.join("vectorcraft.3.log").exists(), "only {KEEP} old logs are kept"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { - let dir = temp_dir("blocked"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "a file where the directory should be").expect("block"); - assert!(rotate(&dir).is_err()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { - let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early 1\n"); - sink.write("early 2\n"); - sink.attach(file); - sink.write("late\n"); - assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { - let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING + 5 { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); - assert!(text.contains("5 earlier log lines were dropped"), "{text}"); - assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn exactly_a_full_pending_buffer_drops_nothing() { - let dir = temp_dir("full"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().count(), MAX_PENDING); - assert!(!text.contains("dropped"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn without_a_file_nothing_is_kept_in_memory() { - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early\n"); - sink.no_file(); - sink.write("late\n"); - assert!(sink.pending.is_empty() && sink.dropped == 0); - } - - #[test] - fn the_file_stops_at_its_size_cap_with_one_note() { - let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(100); - sink.attach(file); - for i in 0..50 { - sink.write(&format!("line {i:02} with some padding\n")); - } - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); - assert_eq!(text.matches("log file reached").count(), 1, "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_logger_filters_records_and_writes_them_to_the_attached_file() { - use log::Log; - let dir = temp_dir("logger"); - let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); - let record = |level, target: &'static str, msg: &'static str| { - logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); - }; - record(log::Level::Info, "vectorcraft", "before the settings directory"); - record(log::Level::Info, "wgpu_core::device", "too chatty"); - let path = logger.attach_dir(&dir).expect("attach"); - assert_eq!(path, dir.join(LOG_FILE)); - record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); - record(log::Level::Error, "vectorcraft_engine::guard", "recovered from a panic: boom"); - record(log::Level::Debug, "vectorcraft", "below info"); - let text = read(&path); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 3, "{text}"); - assert!(lines[0].contains(" INFO [") && lines[0].ends_with("vectorcraft: before the settings directory"), "{text}"); - assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); - assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("vectorcraft_engine::guard: recovered from a panic: boom"), "{text}"); - assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("vectorcraft_ui_egui").build())); - assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); - // Attaching again rotates: the first log becomes `.1`. - logger.attach_dir(&dir).expect("attach again"); - assert_eq!(read(&dir.join("vectorcraft.1.log")), text); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn records_from_many_threads_are_whole_lines() { - use log::Log; - let dir = temp_dir("threads"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let path = logger.attach_dir(&dir).expect("attach"); - let workers: Vec<_> = (0..8) - .map(|t| { - let l = std::sync::Arc::clone(&logger); - std::thread::spawn(move || { - for i in 0..100 { - l.log( - &log::Record::builder().level(log::Level::Info).target("vectorcraft").args(format_args!("thread {t} record {i}")).build(), - ); - } - }) - }) - .collect(); - for w in workers { - w.join().expect("worker"); - } - let text = read(&path); - assert_eq!(text.lines().count(), 800); - assert!(text.lines().all(|l| l.contains(" vectorcraft: thread ")), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { - use log::Log; - let dir = temp_dir("unusable"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "not a directory").expect("block"); - let logger = AppLogger::new(Filter::parse("info"), false); - assert!(logger.attach_dir(&dir).is_err()); - logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build()); - // Nothing waits in memory for a file that won't come. - assert!(logger.sink.lock().expect("sink").pending.is_empty()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn a_poisoned_sink_still_logs() { - use log::Log; - let dir = temp_dir("poison"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let l = std::sync::Arc::clone(&logger); - let _ = std::thread::spawn(move || { - let _guard = l.sink.lock().expect("lock"); - panic!("poison the sink"); - }) - .join(); - assert!(logger.sink.is_poisoned()); - let path = logger.attach_dir(&dir).expect("attach"); - logger.log(&log::Record::builder().level(log::Level::Warn).target("vectorcraft").args(format_args!("after the poison")).build()); - assert!(read(&path).contains("vectorcraft: after the poison")); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/apps/vectorcraft/src/logging/tests.rs b/apps/vectorcraft/src/logging/tests.rs new file mode 100644 index 000000000..818f6db77 --- /dev/null +++ b/apps/vectorcraft/src/logging/tests.rs @@ -0,0 +1,304 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; + +fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("vectorcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") +} + +#[test] +fn the_default_filter_shows_vectorcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("vectorcraft"), LevelFilter::Info); + assert_eq!(f.level_for("vectorcraft::window"), LevelFilter::Info); + assert_eq!(f.level_for("vectorcraft_engine::guard"), LevelFilter::Info); + assert_eq!(f.level_for("vectorcraft_ui_egui::io"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); +} + +#[test] +fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,vectorcraft_render=trace,vectorcraft_render::tiles=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("vectorcraft_render::canvas"), LevelFilter::Trace); + assert_eq!(f.level_for("vectorcraft_render::tiles"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("vectorcraft*=debug").level_for("vectorcraft_text::shape"), LevelFilter::Debug); + assert_eq!(Filter::parse("vectorcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("vectorcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("eframe"), LevelFilter::Warn); +} + +#[test] +fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "vectorcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,vectorcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "vectorcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,vectorcraft=DEBUG").level_for("vectorcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("vectorcraft=").level_for("vectorcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); +} + +#[test] +fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } +} + +#[test] +fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "vectorcraft", "rendering with Mesa (Vulkan, IntegratedGpu)"), + "2026-10-08T07:59:17.728Z INFO [main] vectorcraft: rendering with Mesa (Vulkan, IntegratedGpu)\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); +} + +#[test] +fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("vectorcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("vectorcraft.2.log")), "run 2"); + assert!(!dir.join("vectorcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); +} + +#[test] +fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + let record = |level, target: &'static str, msg: &'static str| { + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + }; + record(log::Level::Info, "vectorcraft", "before the settings directory"); + record(log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(log::Level::Error, "vectorcraft_engine::guard", "recovered from a panic: boom"); + record(log::Level::Debug, "vectorcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("vectorcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("vectorcraft_engine::guard: recovered from a panic: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("vectorcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("vectorcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn records_from_many_threads_are_whole_lines() { + use log::Log; + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + l.log( + &log::Record::builder().level(log::Level::Info).target("vectorcraft").args(format_args!("thread {t} record {i}")).build(), + ); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" vectorcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + use log::Log; + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build()); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn a_poisoned_sink_still_logs() { + use log::Log; + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + logger.log(&log::Record::builder().level(log::Level::Warn).target("vectorcraft").args(format_args!("after the poison")).build()); + assert!(read(&path).contains("vectorcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); +} From ebccd3a9ed9129ff4339343de6306a342c957178 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:30 +0000 Subject: [PATCH 087/199] refactor(ui-egui): move screen_color.rs tests to screen_color/tests.rs --- crates/ui-egui/src/screen_color.rs | 306 +---------------------- crates/ui-egui/src/screen_color/tests.rs | 303 ++++++++++++++++++++++ 2 files changed, 304 insertions(+), 305 deletions(-) create mode 100644 crates/ui-egui/src/screen_color/tests.rs diff --git a/crates/ui-egui/src/screen_color.rs b/crates/ui-egui/src/screen_color.rs index 0442b5ba0..9b8140548 100644 --- a/crates/ui-egui/src/screen_color.rs +++ b/crates/ui-egui/src/screen_color.rs @@ -154,308 +154,4 @@ pub(crate) fn button(app: &VectorcraftApp, ui: &mut egui::Ui) -> bool { } #[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::rc::Rc; - use std::sync::mpsc::{Sender, channel}; - - use vectorcraft_color::cms::{ColorSettings, DISPLAY_P3}; - use vectorcraft_engine::Session; - - use super::*; - - /// The senders of the samplers a stand-in host showed, newest last. - type Shown = Rc>>>; - - /// An app with a document and a selected rectangle, whose host has a stand-in sampler. - fn app() -> (VectorcraftApp, Shown) { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); - let shown = Shown::default(); - let host = shown.clone(); - app.services.screen_color = Some(Box::new(move || { - let (tx, rx) = channel(); - host.borrow_mut().push(tx); - Ok(rx) - })); - (app, shown) - } - - /// The person picks `hex` (or cancels, `None`) in the last sampler shown; the next frame takes it. - fn pick(app: &mut VectorcraftApp, shown: &Shown, hex: Option<&str>) { - let srgb = hex.map(|h| Color::from_hex(h).unwrap().to_rgb()); - shown.borrow().last().unwrap().send(Ok(srgb)).unwrap(); - poll(app, &egui::Context::default()); - } - - fn paints(app: &VectorcraftApp) -> (String, String) { - let (f, s) = crate::panels::current_paints(app); - (f.color().map_or("none".into(), |c| c.to_hex()), s.color().map_or("none".into(), |c| c.to_hex())) - } - - fn undo_len(app: &VectorcraftApp) -> usize { - app.session.doc().unwrap().history.undo.len() - } - - /// The color picked on the screen becomes the fill, as one undo step. - #[test] - fn a_sampled_color_fills_the_selection() { - let (mut app, shown) = app(); - let (_, stroke) = paints(&app); - let before = undo_len(&app); - assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap(), Value::Null, "answers at once"); - assert_eq!(undo_len(&app), before, "nothing changes until the person picks"); - pick(&mut app, &shown, Some("#123456")); - assert_eq!(paints(&app), ("#123456".into(), stroke)); - assert_eq!(undo_len(&app), before + 1, "one step"); - app.run("edit.undo", json!({})).unwrap(); - assert_ne!(paints(&app).0, "#123456"); - } - - /// `stroke: true`, or the stroke proxy in front, sets the stroke instead. - #[test] - fn stroke_takes_the_stroke() { - let (mut app, shown) = app(); - let (fill, _) = paints(&app); - app.run("ui.sampleScreenColor", json!({"stroke": true})).unwrap(); - pick(&mut app, &shown, Some("#336699")); - assert_eq!(paints(&app), (fill.clone(), "#336699".into())); - app.session.fill_active = false; - app.run("ui.sampleScreenColor", json!({})).unwrap(); - pick(&mut app, &shown, Some("#996633")); - assert_eq!(paints(&app), (fill, "#996633".into())); - app.run("ui.sampleScreenColor", json!({"stroke": false})).unwrap(); - pick(&mut app, &shown, Some("#669933")); - assert_eq!(paints(&app).0, "#669933"); - } - - /// With `picker`, the color goes to the open Color Picker, which shows it on the selection as any - /// pick; Cancel puts the art back. - #[test] - fn the_color_picker_takes_the_sampled_color() { - let (mut app, shown) = app(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let frame = |app: &mut VectorcraftApp| { - let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(app, ui.ctx())); - out.textures_delta.clear(); - }; - let before = paints(&app); - assert!(app.run("ui.sampleScreenColor", json!({"picker": true})).is_err(), "no Color Picker open"); - assert!(app.screen_sampler.is_none()); - app.run("ui.colorPicker", json!({})).unwrap(); - frame(&mut app); - app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); - pick(&mut app, &shown, Some("#123456")); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.fields.get("color").and_then(vectorcraft_engine::cmd::color_value).map(|c| c.to_hex()).as_deref(), Some("#123456")); - frame(&mut app); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "123456"); - assert_eq!(paints(&app).0, "#123456", "previewed on the selection"); - crate::dialogs::cancel(&mut app); - assert_eq!(paints(&app), before); - // A Color Picker closed before the answer arrives takes nothing, and the art stays as it is. - app.run("ui.colorPicker", json!({})).unwrap(); - app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); - crate::dialogs::cancel(&mut app); - pick(&mut app, &shown, Some("#654321")); - assert_eq!(paints(&app), before); - assert!(app.ui.dialog.is_none()); - // Nor does a new Color Picker for the same proxy and color that replaced it. - app.run("ui.colorPicker", json!({})).unwrap(); - app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); - crate::dialogs::cancel(&mut app); - app.run("ui.colorPicker", json!({})).unwrap(); - pick(&mut app, &shown, Some("#654321")); - frame(&mut app); - assert_eq!(paints(&app), before); - assert_ne!(app.ui.dialog.as_ref().unwrap().str("hex"), "654321"); - crate::dialogs::cancel(&mut app); - // Nor does a Color Picker for the other proxy that replaced it. - app.run("ui.colorPicker", json!({})).unwrap(); - app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); - crate::dialogs::cancel(&mut app); - app.run("ui.colorPicker", json!({"stroke": true})).unwrap(); - pick(&mut app, &shown, Some("#654321")); - frame(&mut app); - assert_eq!(paints(&app), before); - assert_ne!(app.ui.dialog.as_ref().unwrap().str("hex"), "654321"); - crate::dialogs::cancel(&mut app); - // Nor one in another document: the status bar says why. - app.run("ui.colorPicker", json!({})).unwrap(); - app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); - crate::dialogs::cancel(&mut app); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - app.run("ui.colorPicker", json!({})).unwrap(); - pick(&mut app, &shown, Some("#654321")); - assert_ne!(app.ui.dialog.as_ref().unwrap().fields.get("color"), Some(&crate::panels::color_json(&Color::from_hex("#654321").unwrap()))); - assert_eq!(app.ui.status, "The active document changed while the screen color sampler was open"); - } - - /// The Color Picker's eyedropper button shows the sampler for the Color Picker, and the color - /// the frame takes goes there. - #[test] - fn the_color_pickers_eyedropper_samples_into_it() { - let (mut app, shown) = app(); - app.run("ui.colorPicker", json!({})).unwrap(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 900.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { - app.logic(ui.ctx()); - crate::dialogs::show(app, ui.ctx()); - }); - out.textures_delta.clear(); - }; - // The dialog's one square clickable widget of the button's size, once the window has settled. - let buttons = || -> Vec { - ctx.viewport(|v| { - let widgets = v.this_pass.widgets.layers().flat_map(|(_, w)| w.iter()); - widgets.filter(|w| w.sense.senses_click() && w.rect.size() == egui::Vec2::splat(24.0)).map(|w| w.rect).collect() - }) - }; - let mut last = vec![]; - for _ in 0..10 { - frame(&mut app, vec![]); - if !last.is_empty() && buttons() == last { - break; - } - last = buttons(); - } - let [button] = last[..] else { panic!("{last:?}") }; - let pos = button.center(); - let press = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, vec![egui::Event::PointerMoved(pos), press(true), press(false)]); - assert_eq!(shown.borrow().len(), 1, "the sampler is shown"); - assert!(matches!(app.screen_sampler.as_ref().map(|w| &w.target), Some(Target::Picker(_)))); - assert_eq!(app.screen_sampler.as_ref().map(|w| &w.target), open_picker(&app).as_ref()); - shown.borrow().last().unwrap().send(Ok(Some([0x12, 0x34, 0x56].map(|v| v as f32 / 255.0)))).unwrap(); - frame(&mut app, vec![]); - assert!(app.screen_sampler.is_none()); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "123456"); - } - - /// `ui.inspect` reports `screenSampler` while the sampler waits; one at a time. - #[test] - fn inspect_reports_the_open_sampler() { - let (mut app, shown) = app(); - let ctx = egui::Context::default(); - let open = |app: &VectorcraftApp| crate::control::inspect(app, &ctx)["screenSampler"].clone(); - assert_eq!(open(&app), json!(false)); - app.run("ui.sampleScreenColor", json!({})).unwrap(); - assert_eq!(open(&app), json!(true)); - poll(&mut app, &ctx); - assert_eq!(open(&app), json!(true), "still waiting"); - let e = app.run("ui.sampleScreenColor", json!({})).unwrap_err(); - assert!(e.contains("already open"), "{e}"); - assert_eq!(shown.borrow().len(), 1); - pick(&mut app, &shown, None); - assert_eq!(open(&app), json!(false)); - } - - /// Escape (no color) changes nothing; an error from the host reaches the status bar; a sampler - /// that is gone without answering counts as canceled. - #[test] - fn cancel_and_errors_leave_the_art_alone() { - let (mut app, shown) = app(); - let before = (paints(&app), undo_len(&app)); - app.run("ui.sampleScreenColor", json!({})).unwrap(); - pick(&mut app, &shown, None); - assert_eq!((paints(&app), undo_len(&app)), before); - app.run("ui.sampleScreenColor", json!({})).unwrap(); - shown.borrow().last().unwrap().send(Err("the sampler failed".into())).unwrap(); - poll(&mut app, &egui::Context::default()); - assert_eq!(app.ui.status, "the sampler failed"); - app.run("ui.sampleScreenColor", json!({})).unwrap(); - shown.borrow_mut().clear(); - poll(&mut app, &egui::Context::default()); - assert!(app.screen_sampler.is_none()); - assert_eq!((paints(&app), undo_len(&app)), before); - // A sampler that can't be shown answers with its error and waits for nothing. - app.services.screen_color = Some(Box::new(|| Err("no screen".into()))); - assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap_err(), "no screen"); - assert!(app.screen_sampler.is_none()); - } - - /// A color that arrives after another document became active paints nothing. - #[test] - fn a_new_active_document_takes_no_color() { - let (mut app, shown) = app(); - app.run("ui.sampleScreenColor", json!({})).unwrap(); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - let before = paints(&app); - pick(&mut app, &shown, Some("#123456")); - assert_eq!(paints(&app), before); - assert_eq!(app.ui.status, "The active document changed while the screen color sampler was open"); - } - - /// The sampler answers display sRGB: with Display P3 as the working space, the color it becomes - /// still shows as the sampled #123456. - #[test] - fn a_wide_working_space_shows_the_sampled_color() { - let p3 = Cms::new(&ColorSettings { rgb: DISPLAY_P3.into(), ..Default::default() }).unwrap(); - let srgb = [0x12, 0x34, 0x56].map(|v| v as f32 / 255.0); - let Color::Rgb { r, g, b } = sampled(&p3, srgb) else { panic!("not RGB") }; - let shown = p3.rgb_to_srgb([r, g, b]); - for (s, v) in shown.iter().zip(srgb) { - assert!((s - v).abs() < 0.5 / 255.0, "{shown:?} shows as {srgb:?}"); - } - assert!([r, g, b].iter().zip(srgb).any(|(w, v)| (w - v).abs() > 1.0 / 255.0), "Display P3 numbers differ from sRGB's"); - // sRGB keeps the numbers; values out of range or not numbers are clamped. - let srgb_cms = Cms::new(&ColorSettings::default()).unwrap(); - assert_eq!(sampled(&srgb_cms, srgb), Color::rgb(srgb[0], srgb[1], srgb[2])); - assert_eq!(sampled(&srgb_cms, [f32::NAN, 2.0, -1.0]), Color::rgb(0.0, 1.0, 0.0)); - } - - /// The browser's EyeDropper answers `sRGBHex`; anything else isn't a color. - #[test] - fn hex_answers_read_as_srgb() { - assert_eq!(srgb_from_hex("#123456"), Some([0x12, 0x34, 0x56].map(|v| v as f32 / 255.0))); - assert_eq!(srgb_from_hex("#FFFFFF"), Some([1.0; 3])); - for junk in ["", "#", "#12345", "#1234567", "#12345g", "+12345", "rgb(1, 2, 3)", "red"] { - assert_eq!(srgb_from_hex(junk), None, "{junk}"); - } - } - - /// Without a sampler (Windows, Linux, a browser without an EyeDropper) the command answers an - /// error and is disabled, and the Color Picker draws its button gray. - #[test] - fn without_a_sampler_the_command_is_unavailable() { - let (mut app, _) = app(); - assert!(crate::menus::enabled(&app, "ui.sampleScreenColor")); - app.services.screen_color = None; - assert!(!crate::menus::enabled(&app, "ui.sampleScreenColor")); - assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap_err(), "this system has no screen color sampler"); - assert!(app.screen_sampler.is_none()); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut clicked = true; - let mut out = ctx.run_ui(Default::default(), |ui| clicked = button(&app, ui)); - out.textures_delta.clear(); - assert!(!clicked); - // Keyboard Shortcuts lists the command, so it can be given a key. - assert!(crate::shortcut_editor::entries().iter().any(|e| e.key == "ui.sampleScreenColor")); - } - - /// The command answers every junk parameter without a panic. - #[test] - fn junk_params_never_panic() { - let (mut app, shown) = app(); - let doc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == "ui.sampleScreenColor").unwrap().3; - let mut params = vectorcraft_testkit::strategies::junk_params(doc, "/nonexistent"); - params.extend(vectorcraft_testkit::strategies::junk_values()); - assert!(params.len() > 40); - for p in params { - let _ = app.run("ui.sampleScreenColor", p); - if !shown.borrow().is_empty() { - pick(&mut app, &shown, Some("#808080")); - shown.borrow_mut().clear(); - } - } - assert!(app.screen_sampler.is_none()); - } -} +mod tests; diff --git a/crates/ui-egui/src/screen_color/tests.rs b/crates/ui-egui/src/screen_color/tests.rs new file mode 100644 index 000000000..73ac44813 --- /dev/null +++ b/crates/ui-egui/src/screen_color/tests.rs @@ -0,0 +1,303 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::mpsc::{Sender, channel}; + +use vectorcraft_color::cms::{ColorSettings, DISPLAY_P3}; +use vectorcraft_engine::Session; + +use super::*; + +/// The senders of the samplers a stand-in host showed, newest last. +type Shown = Rc>>>; + +/// An app with a document and a selected rectangle, whose host has a stand-in sampler. +fn app() -> (VectorcraftApp, Shown) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let shown = Shown::default(); + let host = shown.clone(); + app.services.screen_color = Some(Box::new(move || { + let (tx, rx) = channel(); + host.borrow_mut().push(tx); + Ok(rx) + })); + (app, shown) +} + +/// The person picks `hex` (or cancels, `None`) in the last sampler shown; the next frame takes it. +fn pick(app: &mut VectorcraftApp, shown: &Shown, hex: Option<&str>) { + let srgb = hex.map(|h| Color::from_hex(h).unwrap().to_rgb()); + shown.borrow().last().unwrap().send(Ok(srgb)).unwrap(); + poll(app, &egui::Context::default()); +} + +fn paints(app: &VectorcraftApp) -> (String, String) { + let (f, s) = crate::panels::current_paints(app); + (f.color().map_or("none".into(), |c| c.to_hex()), s.color().map_or("none".into(), |c| c.to_hex())) +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +/// The color picked on the screen becomes the fill, as one undo step. +#[test] +fn a_sampled_color_fills_the_selection() { + let (mut app, shown) = app(); + let (_, stroke) = paints(&app); + let before = undo_len(&app); + assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap(), Value::Null, "answers at once"); + assert_eq!(undo_len(&app), before, "nothing changes until the person picks"); + pick(&mut app, &shown, Some("#123456")); + assert_eq!(paints(&app), ("#123456".into(), stroke)); + assert_eq!(undo_len(&app), before + 1, "one step"); + app.run("edit.undo", json!({})).unwrap(); + assert_ne!(paints(&app).0, "#123456"); +} + +/// `stroke: true`, or the stroke proxy in front, sets the stroke instead. +#[test] +fn stroke_takes_the_stroke() { + let (mut app, shown) = app(); + let (fill, _) = paints(&app); + app.run("ui.sampleScreenColor", json!({"stroke": true})).unwrap(); + pick(&mut app, &shown, Some("#336699")); + assert_eq!(paints(&app), (fill.clone(), "#336699".into())); + app.session.fill_active = false; + app.run("ui.sampleScreenColor", json!({})).unwrap(); + pick(&mut app, &shown, Some("#996633")); + assert_eq!(paints(&app), (fill, "#996633".into())); + app.run("ui.sampleScreenColor", json!({"stroke": false})).unwrap(); + pick(&mut app, &shown, Some("#669933")); + assert_eq!(paints(&app).0, "#669933"); +} + +/// With `picker`, the color goes to the open Color Picker, which shows it on the selection as any +/// pick; Cancel puts the art back. +#[test] +fn the_color_picker_takes_the_sampled_color() { + let (mut app, shown) = app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let before = paints(&app); + assert!(app.run("ui.sampleScreenColor", json!({"picker": true})).is_err(), "no Color Picker open"); + assert!(app.screen_sampler.is_none()); + app.run("ui.colorPicker", json!({})).unwrap(); + frame(&mut app); + app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); + pick(&mut app, &shown, Some("#123456")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields.get("color").and_then(vectorcraft_engine::cmd::color_value).map(|c| c.to_hex()).as_deref(), Some("#123456")); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "123456"); + assert_eq!(paints(&app).0, "#123456", "previewed on the selection"); + crate::dialogs::cancel(&mut app); + assert_eq!(paints(&app), before); + // A Color Picker closed before the answer arrives takes nothing, and the art stays as it is. + app.run("ui.colorPicker", json!({})).unwrap(); + app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); + crate::dialogs::cancel(&mut app); + pick(&mut app, &shown, Some("#654321")); + assert_eq!(paints(&app), before); + assert!(app.ui.dialog.is_none()); + // Nor does a new Color Picker for the same proxy and color that replaced it. + app.run("ui.colorPicker", json!({})).unwrap(); + app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); + crate::dialogs::cancel(&mut app); + app.run("ui.colorPicker", json!({})).unwrap(); + pick(&mut app, &shown, Some("#654321")); + frame(&mut app); + assert_eq!(paints(&app), before); + assert_ne!(app.ui.dialog.as_ref().unwrap().str("hex"), "654321"); + crate::dialogs::cancel(&mut app); + // Nor does a Color Picker for the other proxy that replaced it. + app.run("ui.colorPicker", json!({})).unwrap(); + app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); + crate::dialogs::cancel(&mut app); + app.run("ui.colorPicker", json!({"stroke": true})).unwrap(); + pick(&mut app, &shown, Some("#654321")); + frame(&mut app); + assert_eq!(paints(&app), before); + assert_ne!(app.ui.dialog.as_ref().unwrap().str("hex"), "654321"); + crate::dialogs::cancel(&mut app); + // Nor one in another document: the status bar says why. + app.run("ui.colorPicker", json!({})).unwrap(); + app.run("ui.sampleScreenColor", json!({"picker": true})).unwrap(); + crate::dialogs::cancel(&mut app); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("ui.colorPicker", json!({})).unwrap(); + pick(&mut app, &shown, Some("#654321")); + assert_ne!(app.ui.dialog.as_ref().unwrap().fields.get("color"), Some(&crate::panels::color_json(&Color::from_hex("#654321").unwrap()))); + assert_eq!(app.ui.status, "The active document changed while the screen color sampler was open"); +} + +/// The Color Picker's eyedropper button shows the sampler for the Color Picker, and the color +/// the frame takes goes there. +#[test] +fn the_color_pickers_eyedropper_samples_into_it() { + let (mut app, shown) = app(); + app.run("ui.colorPicker", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 900.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + crate::dialogs::show(app, ui.ctx()); + }); + out.textures_delta.clear(); + }; + // The dialog's one square clickable widget of the button's size, once the window has settled. + let buttons = || -> Vec { + ctx.viewport(|v| { + let widgets = v.this_pass.widgets.layers().flat_map(|(_, w)| w.iter()); + widgets.filter(|w| w.sense.senses_click() && w.rect.size() == egui::Vec2::splat(24.0)).map(|w| w.rect).collect() + }) + }; + let mut last = vec![]; + for _ in 0..10 { + frame(&mut app, vec![]); + if !last.is_empty() && buttons() == last { + break; + } + last = buttons(); + } + let [button] = last[..] else { panic!("{last:?}") }; + let pos = button.center(); + let press = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(pos), press(true), press(false)]); + assert_eq!(shown.borrow().len(), 1, "the sampler is shown"); + assert!(matches!(app.screen_sampler.as_ref().map(|w| &w.target), Some(Target::Picker(_)))); + assert_eq!(app.screen_sampler.as_ref().map(|w| &w.target), open_picker(&app).as_ref()); + shown.borrow().last().unwrap().send(Ok(Some([0x12, 0x34, 0x56].map(|v| v as f32 / 255.0)))).unwrap(); + frame(&mut app, vec![]); + assert!(app.screen_sampler.is_none()); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "123456"); +} + +/// `ui.inspect` reports `screenSampler` while the sampler waits; one at a time. +#[test] +fn inspect_reports_the_open_sampler() { + let (mut app, shown) = app(); + let ctx = egui::Context::default(); + let open = |app: &VectorcraftApp| crate::control::inspect(app, &ctx)["screenSampler"].clone(); + assert_eq!(open(&app), json!(false)); + app.run("ui.sampleScreenColor", json!({})).unwrap(); + assert_eq!(open(&app), json!(true)); + poll(&mut app, &ctx); + assert_eq!(open(&app), json!(true), "still waiting"); + let e = app.run("ui.sampleScreenColor", json!({})).unwrap_err(); + assert!(e.contains("already open"), "{e}"); + assert_eq!(shown.borrow().len(), 1); + pick(&mut app, &shown, None); + assert_eq!(open(&app), json!(false)); +} + +/// Escape (no color) changes nothing; an error from the host reaches the status bar; a sampler +/// that is gone without answering counts as canceled. +#[test] +fn cancel_and_errors_leave_the_art_alone() { + let (mut app, shown) = app(); + let before = (paints(&app), undo_len(&app)); + app.run("ui.sampleScreenColor", json!({})).unwrap(); + pick(&mut app, &shown, None); + assert_eq!((paints(&app), undo_len(&app)), before); + app.run("ui.sampleScreenColor", json!({})).unwrap(); + shown.borrow().last().unwrap().send(Err("the sampler failed".into())).unwrap(); + poll(&mut app, &egui::Context::default()); + assert_eq!(app.ui.status, "the sampler failed"); + app.run("ui.sampleScreenColor", json!({})).unwrap(); + shown.borrow_mut().clear(); + poll(&mut app, &egui::Context::default()); + assert!(app.screen_sampler.is_none()); + assert_eq!((paints(&app), undo_len(&app)), before); + // A sampler that can't be shown answers with its error and waits for nothing. + app.services.screen_color = Some(Box::new(|| Err("no screen".into()))); + assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap_err(), "no screen"); + assert!(app.screen_sampler.is_none()); +} + +/// A color that arrives after another document became active paints nothing. +#[test] +fn a_new_active_document_takes_no_color() { + let (mut app, shown) = app(); + app.run("ui.sampleScreenColor", json!({})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let before = paints(&app); + pick(&mut app, &shown, Some("#123456")); + assert_eq!(paints(&app), before); + assert_eq!(app.ui.status, "The active document changed while the screen color sampler was open"); +} + +/// The sampler answers display sRGB: with Display P3 as the working space, the color it becomes +/// still shows as the sampled #123456. +#[test] +fn a_wide_working_space_shows_the_sampled_color() { + let p3 = Cms::new(&ColorSettings { rgb: DISPLAY_P3.into(), ..Default::default() }).unwrap(); + let srgb = [0x12, 0x34, 0x56].map(|v| v as f32 / 255.0); + let Color::Rgb { r, g, b } = sampled(&p3, srgb) else { panic!("not RGB") }; + let shown = p3.rgb_to_srgb([r, g, b]); + for (s, v) in shown.iter().zip(srgb) { + assert!((s - v).abs() < 0.5 / 255.0, "{shown:?} shows as {srgb:?}"); + } + assert!([r, g, b].iter().zip(srgb).any(|(w, v)| (w - v).abs() > 1.0 / 255.0), "Display P3 numbers differ from sRGB's"); + // sRGB keeps the numbers; values out of range or not numbers are clamped. + let srgb_cms = Cms::new(&ColorSettings::default()).unwrap(); + assert_eq!(sampled(&srgb_cms, srgb), Color::rgb(srgb[0], srgb[1], srgb[2])); + assert_eq!(sampled(&srgb_cms, [f32::NAN, 2.0, -1.0]), Color::rgb(0.0, 1.0, 0.0)); +} + +/// The browser's EyeDropper answers `sRGBHex`; anything else isn't a color. +#[test] +fn hex_answers_read_as_srgb() { + assert_eq!(srgb_from_hex("#123456"), Some([0x12, 0x34, 0x56].map(|v| v as f32 / 255.0))); + assert_eq!(srgb_from_hex("#FFFFFF"), Some([1.0; 3])); + for junk in ["", "#", "#12345", "#1234567", "#12345g", "+12345", "rgb(1, 2, 3)", "red"] { + assert_eq!(srgb_from_hex(junk), None, "{junk}"); + } +} + +/// Without a sampler (Windows, Linux, a browser without an EyeDropper) the command answers an +/// error and is disabled, and the Color Picker draws its button gray. +#[test] +fn without_a_sampler_the_command_is_unavailable() { + let (mut app, _) = app(); + assert!(crate::menus::enabled(&app, "ui.sampleScreenColor")); + app.services.screen_color = None; + assert!(!crate::menus::enabled(&app, "ui.sampleScreenColor")); + assert_eq!(app.run("ui.sampleScreenColor", json!({})).unwrap_err(), "this system has no screen color sampler"); + assert!(app.screen_sampler.is_none()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut clicked = true; + let mut out = ctx.run_ui(Default::default(), |ui| clicked = button(&app, ui)); + out.textures_delta.clear(); + assert!(!clicked); + // Keyboard Shortcuts lists the command, so it can be given a key. + assert!(crate::shortcut_editor::entries().iter().any(|e| e.key == "ui.sampleScreenColor")); +} + +/// The command answers every junk parameter without a panic. +#[test] +fn junk_params_never_panic() { + let (mut app, shown) = app(); + let doc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == "ui.sampleScreenColor").unwrap().3; + let mut params = vectorcraft_testkit::strategies::junk_params(doc, "/nonexistent"); + params.extend(vectorcraft_testkit::strategies::junk_values()); + assert!(params.len() > 40); + for p in params { + let _ = app.run("ui.sampleScreenColor", p); + if !shown.borrow().is_empty() { + pick(&mut app, &shown, Some("#808080")); + shown.borrow_mut().clear(); + } + } + assert!(app.screen_sampler.is_none()); +} From 391b61f747594c0fa8cee23573e523bea908435c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:30 +0000 Subject: [PATCH 088/199] refactor(tools): move touchtype.rs tests to touchtype/tests.rs --- crates/tools/src/touchtype.rs | 306 +--------------------------- crates/tools/src/touchtype/tests.rs | 303 +++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 305 deletions(-) create mode 100644 crates/tools/src/touchtype/tests.rs diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs index 51d4079ab..356142035 100644 --- a/crates/tools/src/touchtype.rs +++ b/crates/tools/src/touchtype.rs @@ -461,308 +461,4 @@ impl Tool for TouchTypeTool { } #[cfg(test)] -mod tests { - use vectorcraft_doc::{Document, Node, Selection}; - - use super::*; - use crate::testutil::{cx, paint}; - - /// 40 pt point type "Type" at (100, 200), selected. - fn doc_with_type() -> (Document, NodeId, Selection) { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let t = TextObject::point(Point::new(100.0, 200.0), "Type", CharStyle { size: 40.0, ..Default::default() }); - d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); - let mut s = Selection::default(); - s.add(id); - (d, id, s) - } - - /// The selected character's box in document space. - fn quad(t: &TouchTypeTool, cx: &ToolContext) -> [Point; 4] { - let (_, text, b) = t.current(cx).expect("a character is selected"); - b.quad.map(|p| text.xf * p) - } - - /// A press at `from`, a drag to `to` and the release: the actions of each. - fn drag(t: &mut TouchTypeTool, cx: &ToolContext, from: Point, to: Point) -> Vec> { - [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] - .into_iter() - .map(|(kind, p)| t.pointer(cx, &PointerEvent::new(kind, p.x, p.y))) - .collect() - } - - fn preview(acts: &[Action]) -> &Value { - acts.iter() - .find_map(|a| match a { - Action::Preview(c, p) if c == "text.touchType" => Some(p), - _ => None, - }) - .unwrap_or_else(|| panic!("no text.touchType preview: {acts:?}")) - } - - fn close(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-6 - } - - #[test] - fn a_click_selects_one_character_and_shows_its_box() { - let (d, id, s) = doc_with_type(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = TouchTypeTool::default(); - assert!(t.overlays(&cx).is_empty(), "no character yet"); - // Somewhere in the second character, the "y". - let t_object = text_of(&cx, id).unwrap().clone(); - let lay = vectorcraft_text::layout(FontDb::global(), &t_object); - let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap(); - let at = t_object.xf * (y.origin + Vec2::new(y.advance / 2.0, -10.0)); - let acts = drag(&mut t, &cx, at, at); - assert!(acts.iter().all(Vec::is_empty), "a click changes nothing: {acts:?}"); - assert_eq!(t.options(), json!({"id": id.0, "start": 1})); - // Its box: four sides and five handles, the rotate handle in the middle of the top edge. - let ov = t.overlays(&cx); - assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Line { .. })).count(), 4); - assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Anchor { .. })).count(), 4); - assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::CurvePoint { .. })).count(), 1); - let q = quad(&t, &cx); - assert!(q[0].x <= at.x && at.x <= q[1].x && q[0].y < at.y && at.y < q[3].y, "{q:?} holds {at:?}"); - assert_eq!(t.cursor(&cx, q[0].midpoint(q[1]), Mods::default()), Cursor::rotate_about(t_object.xf * y.origin, q[0].midpoint(q[1]))); - assert_eq!(t.cursor(&cx, q[2], Mods::default()), Cursor::ResizeH); - assert_eq!(t.cursor(&cx, q[0], Mods::default()), Cursor::ResizeV); - assert_eq!(t.cursor(&cx, q[1], Mods::default()), Cursor::ResizeNeSw); - assert_eq!(t.cursor(&cx, q[0].midpoint(q[2]), Mods::default()), Cursor::Move); - // Escape lets go of it. - assert!(t.claims_key(&cx, ToolKey::Escape)); - t.key(&cx, ToolKey::Escape, Mods::default()); - assert!(t.overlays(&cx).is_empty() && t.options() == json!({})); - // A click on type that isn't selected selects it too. - let none = Selection::default(); - let cx = crate::testutil::cx(&d, &none, &p); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); - assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); - } - - /// In a list item, the first character's box holds that character and not the bullet before - /// it, and a drag of the box moves that character. - #[test] - fn the_first_character_of_a_list_item_has_its_own_box_and_drags() { - let (mut d, id, s) = doc_with_type(); - if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); - t.para.left_indent = 18.0; - t.para.first_line_indent = -18.0; - } - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = TouchTypeTool::default(); - let t_object = text_of(&cx, id).unwrap().clone(); - let lay = vectorcraft_text::layout(FontDb::global(), &t_object); - let first = lay.glyphs.iter().find(|g| g.byte == 0 && g.len > 0).unwrap(); - let at = t_object.xf * (first.origin + Vec2::new(first.advance / 2.0, -10.0)); - drag(&mut t, &cx, at, at); - assert_eq!(t.options(), json!({"id": id.0, "start": 0})); - let (q, pen) = (quad(&t, &cx), t_object.xf * first.origin); - assert!(q[0].x > pen.x - 5.0, "the box starts at the T, 18 pt past the bullet: {q:?}, the T at {pen:?}"); - let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -4.0)); - assert_eq!(preview(&acts[1])["start"].as_u64(), Some(0)); - // A drag on the character once the type is deselected selects the type and moves it. - let none = Selection::default(); - let cx = crate::testutil::cx(&d, &none, &p); - let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -4.0)); - assert_eq!(acts[0], vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); - assert_eq!(preview(&acts[1])["start"].as_u64(), Some(0)); - } - - #[test] - fn dragging_the_box_moves_the_character_along_its_line_and_up() { - let (d, id, s) = doc_with_type(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = TouchTypeTool::default(); - let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap()); - let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap(); - let at = Point::new(100.0, 200.0) + (y.origin.to_vec2() + Vec2::new(y.advance / 2.0, -10.0)); - // 10 pt right and 4 pt up: one preview, one undo step. - let acts = drag(&mut t, &cx, at, at + Vec2::new(10.0, -4.0)); - assert_eq!(acts[1][0], Action::Begin("Touch Type".into())); - let p1 = preview(&acts[1]); - assert_eq!((p1["id"].as_u64(), p1["start"].as_u64()), (Some(id.0), Some(1))); - assert!(close(p1["dx"].as_f64().unwrap(), 10.0) && close(p1["dy"].as_f64().unwrap(), 4.0), "{p1}"); - assert_eq!(acts[2], vec![Action::Commit]); - // The lower-left handle moves it too. - let bl = quad(&t, &cx)[3]; - let acts = drag(&mut t, &cx, bl, bl + Vec2::new(-3.0, 2.0)); - let p2 = preview(&acts[1]); - assert!(close(p2["dx"].as_f64().unwrap(), -3.0) && close(p2["dy"].as_f64().unwrap(), -2.0), "{p2}"); - // The first character has no character before it on its line: it moves only up and down. - let first = Point::new(100.0, 200.0) + Vec2::new(lay.glyphs[0].advance / 2.0, -10.0); - let acts = drag(&mut t, &cx, first, first + Vec2::new(10.0, -4.0)); - let p3 = preview(&acts[1]); - assert_eq!(p3["start"], json!(0)); - assert!(close(p3.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0), 0.0) && close(p3["dy"].as_f64().unwrap(), 4.0), "{p3}"); - } - - #[test] - fn arrow_keys_nudge_the_selected_character() { - let (d, id, s) = doc_with_type(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = TouchTypeTool::default(); - let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap()); - let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap(); - let at = Point::new(100.0, 200.0) + (y.origin.to_vec2() + Vec2::new(y.advance / 2.0, -10.0)); - drag(&mut t, &cx, at, at); - assert!(t.claims_key(&cx, ToolKey::Right)); - let acts = t.key(&cx, ToolKey::Right, Mods::default()); - let p1 = match &acts[0] { - Action::Exec(c, v) if c == "text.touchType" => v, - other => panic!("{other:?}"), - }; - assert!(close(p1["dx"].as_f64().unwrap(), 1.0) && p1.get("dy").is_none(), "{p1}"); - // The first character can't kern: a horizontal nudge does nothing. - let first = Point::new(100.0, 200.0) + Vec2::new(lay.glyphs[0].advance / 2.0, -10.0); - drag(&mut t, &cx, first, first); - assert!(t.key(&cx, ToolKey::Left, Mods::default()).is_empty()); - let acts = t.key(&cx, ToolKey::Up, Mods::default()); - let p2 = match &acts[0] { - Action::Exec(_, v) => v, - other => panic!("{other:?}"), - }; - assert!(close(p2["dy"].as_f64().unwrap(), 1.0), "{p2}"); - // Shift multiplies the increment (on a character that can kern). - drag(&mut t, &cx, at, at); - let acts = t.key(&cx, ToolKey::Right, Mods { shift: true, ..Mods::default() }); - let p3 = match &acts[0] { - Action::Exec(_, v) => v, - other => panic!("{other:?}"), - }; - assert!(close(p3["dx"].as_f64().unwrap(), 10.0), "{p3}"); - } - - #[test] - fn the_handles_scale_and_rotate_the_character() { - let (d, id, s) = doc_with_type(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = TouchTypeTool::default(); - let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap()); - let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap(); - let at = Point::new(100.0, 200.0) + (y.origin.to_vec2() + Vec2::new(y.advance / 2.0, -10.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)); - let [tl, tr, br, _] = quad(&t, &cx); - let (w, h) = (tr.x - tl.x, br.y - tr.y); - let above = 200.0 - tl.y; - // The lower-right handle: horizontal scale; half the width again is 150 %. - let acts = drag(&mut t, &cx, br, br + Vec2::new(w / 2.0, 7.0)); - let ps = preview(&acts[1]); - assert!(close(ps["hScale"].as_f64().unwrap(), 150.0) && ps.get("vScale").is_none(), "{ps}"); - // The upper-left handle: vertical scale, measured from the baseline. - let acts = drag(&mut t, &cx, tl, tl + Vec2::new(5.0, -above)); - let ps = preview(&acts[1]); - assert!(close(ps["vScale"].as_f64().unwrap(), 200.0) && ps.get("hScale").is_none(), "{ps}"); - // The upper-right handle: both alike. - let pen = Point::new(tl.x, 200.0); - let acts = drag(&mut t, &cx, tr, pen + (tr - pen) * 0.5); - let ps = preview(&acts[1]); - assert!(close(ps["hScale"].as_f64().unwrap(), 50.0) && close(ps["vScale"].as_f64().unwrap(), 50.0), "{ps}"); - // The top handle: a quarter turn counter-clockwise about its pen position on the baseline. - let top = tl.midpoint(tr); - let pivot = Point::new(100.0, 200.0) + y.origin.to_vec2(); - let r = top - pivot; - let acts = drag(&mut t, &cx, top, pivot + Vec2::new(r.y, -r.x)); - let ps = preview(&acts[1]); - assert!(close(ps["rotation"].as_f64().unwrap(), 90.0), "{ps}"); - assert!(h > 0.0); - // Escape mid-drag cancels the drag and keeps the character. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, top.x, top.y)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, top.x + 20.0, top.y)); - assert!(t.claims_key(&cx, ToolKey::Escape)); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, top.x + 20.0, top.y)), vec![]); - assert_eq!(t.options(), json!({"id": id.0, "start": 1})); - // Switching tools mid-drag drops the drag. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, top.x, top.y)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, top.x + 20.0, top.y)); - assert_eq!(t.deactivate(&cx), vec![Action::Cancel]); - } - - #[test] - fn the_box_of_a_character_drawn_with_two_glyphs_holds_both() { - // An All Caps ß is two glyphs (SS) with one byte range. - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let text = TextObject::point(Point::new(100.0, 200.0), "aße", CharStyle { size: 40.0, all_caps: true, ..Default::default() }); - d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); - let mut s = Selection::default(); - s.add(id); - let p = paint(); - let cx = cx(&d, &s, &p); - let lay = vectorcraft_text::layout(FontDb::global(), &text); - let esses: Vec<_> = lay.glyphs.iter().filter(|g| g.byte == 1).collect(); - assert_eq!(esses.len(), 2); - // A click on the second S selects the ß, and its box reaches from the first S to the end of the second. - let second = esses[1]; - let at = text.xf * (second.origin + Vec2::new(second.advance / 2.0, -10.0)); - let mut t = TouchTypeTool::default(); - drag(&mut t, &cx, at, at); - assert_eq!(t.options(), json!({"id": id.0, "start": 1})); - let q = quad(&t, &cx); - let ends = |g: &PositionedGlyph| (text.xf * glyph_quad(g).unwrap()[1]).x; - assert!((q[1].x - ends(second)).abs() < 1e-9 && q[0].x < ends(esses[0]), "{q:?}"); - // Dragging the lower-right handle by half the box's width is 150 %. - let w = q[1].x - q[0].x; - let acts = drag(&mut t, &cx, q[2], q[2] + Vec2::new(w / 2.0, 0.0)); - assert!(close(preview(&acts[1])["hScale"].as_f64().unwrap(), 150.0)); - // Rotated, each S turns about its own origin: the box still holds both, square to the first. - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let text = TextObject::point(Point::new(100.0, 200.0), "aße", CharStyle { size: 40.0, all_caps: true, rotation: 30.0, ..Default::default() }); - d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); - let mut s = Selection::default(); - s.add(id); - let cx = crate::testutil::cx(&d, &s, &p); - let t = TouchTypeTool { target: Some((id, 1)), ..Default::default() }; - let q = quad(&t, &cx); - let (across, down) = (q[1] - q[0], q[3] - q[0]); - assert!(across.dot(down).abs() < 1e-9, "a rectangle: {q:?}"); - let lay = vectorcraft_text::layout(FontDb::global(), &text); - for g in lay.glyphs.iter().filter(|g| g.byte == 1) { - for c in glyph_quad(g).unwrap() { - let v = text.xf * c - q[0]; - let (a, b) = (v.dot(across) / across.hypot2(), v.dot(down) / down.hypot2()); - assert!((-1e-9..=1.0 + 1e-9).contains(&a) && (-1e-9..=1.0 + 1e-9).contains(&b), "{c:?} outside {q:?}"); - } - } - } - - /// A glyph skewed by its transform (the Skew effect of type on a path) keeps its own box. - #[test] - fn a_skewed_glyph_keeps_its_own_box() { - let text = TextObject::point(Point::new(100.0, 200.0), "Type", CharStyle { size: 40.0, ..Default::default() }); - let mut lay = vectorcraft_text::layout(FontDb::global(), &text); - lay.glyphs[1].xf *= Affine::skew(0.5, 0.0); - let b = char_box(&text, &lay, 1).unwrap(); - let own = glyph_quad(&lay.glyphs[1]).unwrap(); - assert!(b.quad.iter().zip(own).all(|(a, b)| (*a - b).hypot() < 1e-9), "{:?} {own:?}", b.quad); - } - - /// `create` makes the Touch Type tool: a click with it selects a character. - #[test] - fn the_tool_is_made_by_its_id() { - let (d, id, s) = doc_with_type(); - let p = paint(); - let cx = cx(&d, &s, &p); - let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap()); - let at = Point::new(100.0, 200.0) + Vec2::new(lay.glyphs[0].advance / 2.0, -10.0); - let mut t = crate::create(ID); - assert_eq!(t.id(), ID); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)); - assert_eq!(t.options(), json!({"id": id.0, "start": 0})); - } -} +mod tests; diff --git a/crates/tools/src/touchtype/tests.rs b/crates/tools/src/touchtype/tests.rs new file mode 100644 index 000000000..252ede4ff --- /dev/null +++ b/crates/tools/src/touchtype/tests.rs @@ -0,0 +1,303 @@ +use vectorcraft_doc::{Document, Node, Selection}; + +use super::*; +use crate::testutil::{cx, paint}; + +/// 40 pt point type "Type" at (100, 200), selected. +fn doc_with_type() -> (Document, NodeId, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let t = TextObject::point(Point::new(100.0, 200.0), "Type", CharStyle { size: 40.0, ..Default::default() }); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let mut s = Selection::default(); + s.add(id); + (d, id, s) +} + +/// The selected character's box in document space. +fn quad(t: &TouchTypeTool, cx: &ToolContext) -> [Point; 4] { + let (_, text, b) = t.current(cx).expect("a character is selected"); + b.quad.map(|p| text.xf * p) +} + +/// A press at `from`, a drag to `to` and the release: the actions of each. +fn drag(t: &mut TouchTypeTool, cx: &ToolContext, from: Point, to: Point) -> Vec> { + [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] + .into_iter() + .map(|(kind, p)| t.pointer(cx, &PointerEvent::new(kind, p.x, p.y))) + .collect() +} + +fn preview(acts: &[Action]) -> &Value { + acts.iter() + .find_map(|a| match a { + Action::Preview(c, p) if c == "text.touchType" => Some(p), + _ => None, + }) + .unwrap_or_else(|| panic!("no text.touchType preview: {acts:?}")) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn a_click_selects_one_character_and_shows_its_box() { + let (d, id, s) = doc_with_type(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TouchTypeTool::default(); + assert!(t.overlays(&cx).is_empty(), "no character yet"); + // Somewhere in the second character, the "y". + let t_object = text_of(&cx, id).unwrap().clone(); + let lay = vectorcraft_text::layout(FontDb::global(), &t_object); + let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap(); + let at = t_object.xf * (y.origin + Vec2::new(y.advance / 2.0, -10.0)); + let acts = drag(&mut t, &cx, at, at); + assert!(acts.iter().all(Vec::is_empty), "a click changes nothing: {acts:?}"); + assert_eq!(t.options(), json!({"id": id.0, "start": 1})); + // Its box: four sides and five handles, the rotate handle in the middle of the top edge. + let ov = t.overlays(&cx); + assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Line { .. })).count(), 4); + assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Anchor { .. })).count(), 4); + assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::CurvePoint { .. })).count(), 1); + let q = quad(&t, &cx); + assert!(q[0].x <= at.x && at.x <= q[1].x && q[0].y < at.y && at.y < q[3].y, "{q:?} holds {at:?}"); + assert_eq!(t.cursor(&cx, q[0].midpoint(q[1]), Mods::default()), Cursor::rotate_about(t_object.xf * y.origin, q[0].midpoint(q[1]))); + assert_eq!(t.cursor(&cx, q[2], Mods::default()), Cursor::ResizeH); + assert_eq!(t.cursor(&cx, q[0], Mods::default()), Cursor::ResizeV); + assert_eq!(t.cursor(&cx, q[1], Mods::default()), Cursor::ResizeNeSw); + assert_eq!(t.cursor(&cx, q[0].midpoint(q[2]), Mods::default()), Cursor::Move); + // Escape lets go of it. + assert!(t.claims_key(&cx, ToolKey::Escape)); + t.key(&cx, ToolKey::Escape, Mods::default()); + assert!(t.overlays(&cx).is_empty() && t.options() == json!({})); + // A click on type that isn't selected selects it too. + let none = Selection::default(); + let cx = crate::testutil::cx(&d, &none, &p); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); +} + +/// In a list item, the first character's box holds that character and not the bullet before +/// it, and a drag of the box moves that character. +#[test] +fn the_first_character_of_a_list_item_has_its_own_box_and_drags() { + let (mut d, id, s) = doc_with_type(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + } + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TouchTypeTool::default(); + let t_object = text_of(&cx, id).unwrap().clone(); + let lay = vectorcraft_text::layout(FontDb::global(), &t_object); + let first = lay.glyphs.iter().find(|g| g.byte == 0 && g.len > 0).unwrap(); + let at = t_object.xf * (first.origin + Vec2::new(first.advance / 2.0, -10.0)); + drag(&mut t, &cx, at, at); + assert_eq!(t.options(), json!({"id": id.0, "start": 0})); + let (q, pen) = (quad(&t, &cx), t_object.xf * first.origin); + assert!(q[0].x > pen.x - 5.0, "the box starts at the T, 18 pt past the bullet: {q:?}, the T at {pen:?}"); + let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -4.0)); + assert_eq!(preview(&acts[1])["start"].as_u64(), Some(0)); + // A drag on the character once the type is deselected selects the type and moves it. + let none = Selection::default(); + let cx = crate::testutil::cx(&d, &none, &p); + let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -4.0)); + assert_eq!(acts[0], vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + assert_eq!(preview(&acts[1])["start"].as_u64(), Some(0)); +} + +#[test] +fn dragging_the_box_moves_the_character_along_its_line_and_up() { + let (d, id, s) = doc_with_type(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TouchTypeTool::default(); + let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap()); + let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap(); + let at = Point::new(100.0, 200.0) + (y.origin.to_vec2() + Vec2::new(y.advance / 2.0, -10.0)); + // 10 pt right and 4 pt up: one preview, one undo step. + let acts = drag(&mut t, &cx, at, at + Vec2::new(10.0, -4.0)); + assert_eq!(acts[1][0], Action::Begin("Touch Type".into())); + let p1 = preview(&acts[1]); + assert_eq!((p1["id"].as_u64(), p1["start"].as_u64()), (Some(id.0), Some(1))); + assert!(close(p1["dx"].as_f64().unwrap(), 10.0) && close(p1["dy"].as_f64().unwrap(), 4.0), "{p1}"); + assert_eq!(acts[2], vec![Action::Commit]); + // The lower-left handle moves it too. + let bl = quad(&t, &cx)[3]; + let acts = drag(&mut t, &cx, bl, bl + Vec2::new(-3.0, 2.0)); + let p2 = preview(&acts[1]); + assert!(close(p2["dx"].as_f64().unwrap(), -3.0) && close(p2["dy"].as_f64().unwrap(), -2.0), "{p2}"); + // The first character has no character before it on its line: it moves only up and down. + let first = Point::new(100.0, 200.0) + Vec2::new(lay.glyphs[0].advance / 2.0, -10.0); + let acts = drag(&mut t, &cx, first, first + Vec2::new(10.0, -4.0)); + let p3 = preview(&acts[1]); + assert_eq!(p3["start"], json!(0)); + assert!(close(p3.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0), 0.0) && close(p3["dy"].as_f64().unwrap(), 4.0), "{p3}"); +} + +#[test] +fn arrow_keys_nudge_the_selected_character() { + let (d, id, s) = doc_with_type(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TouchTypeTool::default(); + let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap()); + let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap(); + let at = Point::new(100.0, 200.0) + (y.origin.to_vec2() + Vec2::new(y.advance / 2.0, -10.0)); + drag(&mut t, &cx, at, at); + assert!(t.claims_key(&cx, ToolKey::Right)); + let acts = t.key(&cx, ToolKey::Right, Mods::default()); + let p1 = match &acts[0] { + Action::Exec(c, v) if c == "text.touchType" => v, + other => panic!("{other:?}"), + }; + assert!(close(p1["dx"].as_f64().unwrap(), 1.0) && p1.get("dy").is_none(), "{p1}"); + // The first character can't kern: a horizontal nudge does nothing. + let first = Point::new(100.0, 200.0) + Vec2::new(lay.glyphs[0].advance / 2.0, -10.0); + drag(&mut t, &cx, first, first); + assert!(t.key(&cx, ToolKey::Left, Mods::default()).is_empty()); + let acts = t.key(&cx, ToolKey::Up, Mods::default()); + let p2 = match &acts[0] { + Action::Exec(_, v) => v, + other => panic!("{other:?}"), + }; + assert!(close(p2["dy"].as_f64().unwrap(), 1.0), "{p2}"); + // Shift multiplies the increment (on a character that can kern). + drag(&mut t, &cx, at, at); + let acts = t.key(&cx, ToolKey::Right, Mods { shift: true, ..Mods::default() }); + let p3 = match &acts[0] { + Action::Exec(_, v) => v, + other => panic!("{other:?}"), + }; + assert!(close(p3["dx"].as_f64().unwrap(), 10.0), "{p3}"); +} + +#[test] +fn the_handles_scale_and_rotate_the_character() { + let (d, id, s) = doc_with_type(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TouchTypeTool::default(); + let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap()); + let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap(); + let at = Point::new(100.0, 200.0) + (y.origin.to_vec2() + Vec2::new(y.advance / 2.0, -10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)); + let [tl, tr, br, _] = quad(&t, &cx); + let (w, h) = (tr.x - tl.x, br.y - tr.y); + let above = 200.0 - tl.y; + // The lower-right handle: horizontal scale; half the width again is 150 %. + let acts = drag(&mut t, &cx, br, br + Vec2::new(w / 2.0, 7.0)); + let ps = preview(&acts[1]); + assert!(close(ps["hScale"].as_f64().unwrap(), 150.0) && ps.get("vScale").is_none(), "{ps}"); + // The upper-left handle: vertical scale, measured from the baseline. + let acts = drag(&mut t, &cx, tl, tl + Vec2::new(5.0, -above)); + let ps = preview(&acts[1]); + assert!(close(ps["vScale"].as_f64().unwrap(), 200.0) && ps.get("hScale").is_none(), "{ps}"); + // The upper-right handle: both alike. + let pen = Point::new(tl.x, 200.0); + let acts = drag(&mut t, &cx, tr, pen + (tr - pen) * 0.5); + let ps = preview(&acts[1]); + assert!(close(ps["hScale"].as_f64().unwrap(), 50.0) && close(ps["vScale"].as_f64().unwrap(), 50.0), "{ps}"); + // The top handle: a quarter turn counter-clockwise about its pen position on the baseline. + let top = tl.midpoint(tr); + let pivot = Point::new(100.0, 200.0) + y.origin.to_vec2(); + let r = top - pivot; + let acts = drag(&mut t, &cx, top, pivot + Vec2::new(r.y, -r.x)); + let ps = preview(&acts[1]); + assert!(close(ps["rotation"].as_f64().unwrap(), 90.0), "{ps}"); + assert!(h > 0.0); + // Escape mid-drag cancels the drag and keeps the character. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, top.x, top.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, top.x + 20.0, top.y)); + assert!(t.claims_key(&cx, ToolKey::Escape)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, top.x + 20.0, top.y)), vec![]); + assert_eq!(t.options(), json!({"id": id.0, "start": 1})); + // Switching tools mid-drag drops the drag. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, top.x, top.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, top.x + 20.0, top.y)); + assert_eq!(t.deactivate(&cx), vec![Action::Cancel]); +} + +#[test] +fn the_box_of_a_character_drawn_with_two_glyphs_holds_both() { + // An All Caps ß is two glyphs (SS) with one byte range. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let text = TextObject::point(Point::new(100.0, 200.0), "aße", CharStyle { size: 40.0, all_caps: true, ..Default::default() }); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let lay = vectorcraft_text::layout(FontDb::global(), &text); + let esses: Vec<_> = lay.glyphs.iter().filter(|g| g.byte == 1).collect(); + assert_eq!(esses.len(), 2); + // A click on the second S selects the ß, and its box reaches from the first S to the end of the second. + let second = esses[1]; + let at = text.xf * (second.origin + Vec2::new(second.advance / 2.0, -10.0)); + let mut t = TouchTypeTool::default(); + drag(&mut t, &cx, at, at); + assert_eq!(t.options(), json!({"id": id.0, "start": 1})); + let q = quad(&t, &cx); + let ends = |g: &PositionedGlyph| (text.xf * glyph_quad(g).unwrap()[1]).x; + assert!((q[1].x - ends(second)).abs() < 1e-9 && q[0].x < ends(esses[0]), "{q:?}"); + // Dragging the lower-right handle by half the box's width is 150 %. + let w = q[1].x - q[0].x; + let acts = drag(&mut t, &cx, q[2], q[2] + Vec2::new(w / 2.0, 0.0)); + assert!(close(preview(&acts[1])["hScale"].as_f64().unwrap(), 150.0)); + // Rotated, each S turns about its own origin: the box still holds both, square to the first. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let text = TextObject::point(Point::new(100.0, 200.0), "aße", CharStyle { size: 40.0, all_caps: true, rotation: 30.0, ..Default::default() }); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let mut s = Selection::default(); + s.add(id); + let cx = crate::testutil::cx(&d, &s, &p); + let t = TouchTypeTool { target: Some((id, 1)), ..Default::default() }; + let q = quad(&t, &cx); + let (across, down) = (q[1] - q[0], q[3] - q[0]); + assert!(across.dot(down).abs() < 1e-9, "a rectangle: {q:?}"); + let lay = vectorcraft_text::layout(FontDb::global(), &text); + for g in lay.glyphs.iter().filter(|g| g.byte == 1) { + for c in glyph_quad(g).unwrap() { + let v = text.xf * c - q[0]; + let (a, b) = (v.dot(across) / across.hypot2(), v.dot(down) / down.hypot2()); + assert!((-1e-9..=1.0 + 1e-9).contains(&a) && (-1e-9..=1.0 + 1e-9).contains(&b), "{c:?} outside {q:?}"); + } + } +} + +/// A glyph skewed by its transform (the Skew effect of type on a path) keeps its own box. +#[test] +fn a_skewed_glyph_keeps_its_own_box() { + let text = TextObject::point(Point::new(100.0, 200.0), "Type", CharStyle { size: 40.0, ..Default::default() }); + let mut lay = vectorcraft_text::layout(FontDb::global(), &text); + lay.glyphs[1].xf *= Affine::skew(0.5, 0.0); + let b = char_box(&text, &lay, 1).unwrap(); + let own = glyph_quad(&lay.glyphs[1]).unwrap(); + assert!(b.quad.iter().zip(own).all(|(a, b)| (*a - b).hypot() < 1e-9), "{:?} {own:?}", b.quad); +} + +/// `create` makes the Touch Type tool: a click with it selects a character. +#[test] +fn the_tool_is_made_by_its_id() { + let (d, id, s) = doc_with_type(); + let p = paint(); + let cx = cx(&d, &s, &p); + let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap()); + let at = Point::new(100.0, 200.0) + Vec2::new(lay.glyphs[0].advance / 2.0, -10.0); + let mut t = crate::create(ID); + assert_eq!(t.id(), ID); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)); + assert_eq!(t.options(), json!({"id": id.0, "start": 0})); +} From a983cf2d5fa0d1a0d4cff4f16c9c1c85b6cfa0f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:53 +0000 Subject: [PATCH 089/199] refactor(vectorcraft): move main.rs tests to tests.rs --- apps/vectorcraft/src/main.rs | 312 +--------------------------------- apps/vectorcraft/src/tests.rs | 309 +++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 311 deletions(-) create mode 100644 apps/vectorcraft/src/tests.rs diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index ee4232dc3..76b18e515 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1182,314 +1182,4 @@ fn main() -> std::process::ExitCode { } #[cfg(all(test, feature = "wgpu"))] -mod tests { - use super::*; - - fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState { - vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() } - } - - /// Shortcut overrides an earlier version saved still load: its spellings, `panel:` keys, an - /// override that can't fire (#487), an entry for a command this version lacks, and an old set - /// name. - #[test] - fn shortcut_overrides_from_an_earlier_version_load() { - use serde_json::json; - use vectorcraft_ui_egui::{keymap, shortcut_editor}; - let mut raw = serde_json::to_value(vectorcraft_ui_egui::UiState::default()).unwrap(); - raw["shortcut_overrides"] = json!({ - "tool:pen": "Shift+9", - "edit.preferences": "", - "panel:layers": "Cmd+Alt+Shift+F7", - "tool:groupSelection": "Shift+ShiftLeft", - "test.unknown": "Cmd+Alt+Shift+F9", - "view.grid": "Cmd+Period", - }); - raw["shortcut_set"] = json!(shortcut_editor::LEGACY_PRESETS[0].0); - let ui = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap().sanitized(); - assert_eq!(ui.shortcut_set, shortcut_editor::LEGACY_PRESETS[0].1); - let km = keymap::keymap_with(&ui.shortcut_overrides); - let text = |key: &str| keymap::effective_with(&km, key); - assert_eq!(text("tool:pen").as_deref(), Some("Shift+9")); - assert_eq!(text("edit.preferences"), None); - assert_eq!(text("panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7")); - assert_eq!(text("tool:groupSelection").as_deref(), shortcut_editor::default_of("tool:groupSelection"), "back on its default"); - assert_eq!(text("view.grid").as_deref(), Some("Cmd+Period")); - let period = keymap::vc_chord("Cmd+.").unwrap(); - assert_eq!(keymap::lookup(&km, &period).map(|c| c.id), Some("view.grid".to_string())); - assert_eq!(text("test.unknown").as_deref(), Some("Cmd+Alt+Shift+F9"), "kept"); - assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported"); - } - - #[test] - fn opening_files_unminimizes_and_focuses_the_window() { - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx())); - // Nothing paints in the test: drop the font atlas upload egui expects to be applied. - out.textures_delta.clear(); - let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands; - assert!(cmds.contains(&egui::ViewportCommand::Minimized(false))); - assert!(cmds.contains(&egui::ViewportCommand::Focus)); - } - - #[test] - fn unreadable_saved_docking_keeps_preferences_and_every_workspace() { - use serde_json::json; - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - app.ui.toolbar = false; - app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"}); - app.ui.custom_workspaces = - vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")]; - app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")]; - let original = serde_json::to_value(&app.ui).unwrap(); - let valid_location = original["docking_hidden"]["layers"].clone(); - assert!(valid_location.is_object()); - for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] { - for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] { - let mut raw = original.clone(); - raw["docking"] = invalid_layout.clone(); - raw["docking_hidden"] = invalid_hidden.clone(); - raw["custom_workspaces"][0]["docking"] = invalid_layout.clone(); - raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone(); - raw["workspace_changes"][0]["docking"] = invalid_layout.clone(); - raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone(); - assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict"); - let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap(); - assert_eq!(loaded.engine_prefs, app.ui.engine_prefs); - assert!(!loaded.toolbar); - assert!(loaded.docking.is_none()); - assert!(!loaded.docking_hidden.contains_key("layers")); - if invalid_hidden.is_object() { - assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location); - } else { - assert!(loaded.docking_hidden.is_empty()); - } - assert_eq!(loaded.custom_workspaces.len(), 2); - assert_eq!(loaded.custom_workspaces[0].name, "Future"); - assert!(loaded.custom_workspaces[0].docking.is_none()); - assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers")); - assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]); - assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost"); - assert!(loaded.workspace_changes[0].docking.is_none()); - } - } - let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap(); - assert_eq!(serde_json::to_value(roundtrip).unwrap(), original); - assert!( - decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(), - "unrelated fields retain normal strict parsing" - ); - } - - #[test] - fn an_unreadable_ui_json_is_kept_beside_it() { - let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("vectorcraft-ui-prefs-{}-{stamp}", std::process::id())); - std::fs::create_dir(&dir).unwrap(); - let path = dir.join("ui.json"); - super::keep_prefs_copy(&path, b"{\"docking\": {\"root\": {\"FutureNode\": {}}}}", "ui.unreadable"); - super::keep_prefs_copy(&path, b"not json", "ui.unreadable"); - super::keep_prefs_copy(&path, b"{}", "ui.before-layout-migration"); - let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap(); - let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap(); - let before = std::fs::read(dir.join("ui.before-layout-migration.json")).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced"); - assert_eq!(second, b"not json"); - assert_eq!(before, b"{}"); - assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); - } - - #[test] - fn the_image_trace_preview_setting_is_kept_across_a_restart() { - for preview in [false, true] { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.ui.image_trace_preview = preview; - let saved = prefs_bytes(&app).unwrap(); - let loaded = decode_saved_prefs(&saved).unwrap().sanitized(); - assert_eq!(loaded.image_trace_preview, preview, "Preview {preview} after a restart"); - } - } - - #[test] - fn the_system_title_bar_preference_keeps_the_window_decorations() { - use craft_window::{Frame, Layout}; - - // The System Title Bar preference (Preferences › User Interface) is read the same way on - // every platform; a missing or unreadable value is `false`. - assert!(!super::opted_into_system_title_bar(None), "no preferences: the default"); - let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); - assert!(!super::opted_into_system_title_bar(Some(&off))); - let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); - assert!(super::opted_into_system_title_bar(Some(&on))); - - // The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title - // bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native - // decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws - // our own title bar — unless the user opted into the system title bar, which forces the - // native frame back on. - let app = Frame::App(Layout::default()); - let desktop = Frame::Desktop; - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own"); - assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native"); - assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame"); - assert!(!super::custom_titlebar_from(&desktop, true)); - } - // macOS and Windows keep today's behavior, independent of the probed frame: the pref still - // forces the native frame back on where a custom one would otherwise be drawn. - #[cfg(any(target_os = "macos", target_os = "windows"))] - { - assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR); - assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows"); - assert!(!super::custom_titlebar_from(&desktop, true)); - } - - assert_eq!(super::native_options(false).viewport.decorations, Some(true)); - assert_eq!(super::native_options(true).viewport.decorations, Some(false)); - } - - /// The file extensions the macOS bundle declares: its document types and its own exported type. - fn plist_extensions(plist: &str) -> Vec<&str> { - ["CFBundleTypeExtensions", "public.filename-extension"] - .iter() - .flat_map(|key| plist.split(key).skip(1)) - .filter_map(|rest| rest.split("").next()) - .flat_map(|array| array.split("").skip(1)) - .filter_map(|s| s.split("").next()) - .collect() - } - - /// Finder offers the app for every file File › Open reads (#295, #354) but those with an - /// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app - /// rather than to AppKit's document machinery. - #[test] - fn the_macos_bundle_opens_every_readable_format() { - let plist = include_str!("../../../packaging/macos/Info.plist.in"); - let declared = plist_extensions(plist); - for e in fileio::OPEN_EXTS.iter().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)) { - assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}"); - } - for e in &declared { - assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open"); - } - assert!(!plist.contains("NSDocumentClass"), "not an NSDocument app: AppKit would refuse the files"); - let types = plist.matches("CFBundleTypeName").count(); - assert!(types > 1 && plist.matches("LSHandlerRank").count() == types, "every document type has a rank"); - assert_eq!(plist.matches("Owner").count(), 2, "only VectorCraft documents and templates are owned"); - } - - /// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS` - /// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml` - /// declares. - const LINUX_TYPES: &[(&str, &str)] = &[ - ("vectorcraft", "application/x-vectorcraft"), - ("drawcraft", "application/x-vectorcraft"), - ("svg", "image/svg+xml"), - ("svgz", "image/svg+xml-compressed"), - ("pdf", "application/pdf"), - ("ai", "application/illustrator"), // brand-ok: shared-mime-info's type - ("ait", "application/x-ai-template"), - ("png", "image/png"), - ("jpg", "image/jpeg"), - ("jpeg", "image/jpeg"), - ("gif", "image/gif"), - ("webp", "image/webp"), - ("tif", "image/tiff"), - ("tiff", "image/tiff"), - ("bmp", "image/bmp"), - ("vctemplate", "application/x-vectorcraft-template"), - ("dxf", "image/vnd.dxf"), - ("emf", "image/emf"), - ("wmf", "image/wmf"), - ("eps", "image/x-eps"), - ("af", "application/x-affinity"), - ("afdesign", "application/x-affinity"), - ("afpub", "application/x-affinity"), - ]; - - /// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every - /// other one. - const LINUX_SHARED_TYPES: &[&str] = &[ - "image/svg+xml", - "image/svg+xml-compressed", - "application/pdf", - "application/illustrator", // brand-ok: shared-mime-info's type - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "image/tiff", - "image/bmp", - "image/vnd.dxf", - "image/emf", - "image/wmf", - "image/x-eps", - ]; - - /// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a - /// glob, with the package's `` rule for that type (its lines joined, indentation removed). - const LINUX_BY_CONTENT: &[(&str, &str)] = &[( - "af", - // `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs). - r#""#, - )]; - - /// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but - /// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't - /// have. - #[test] - fn the_linux_desktop_entry_opens_every_readable_format() { - let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop"); - let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml"); - let listed: Vec<&str> = - desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect(); - // Each type the MIME package declares, with the extensions of its globs and its `` - // rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives. - let declared: Vec<(&str, Vec<&str>, String)> = mime - .split("").next().unwrap_or_default(); - let globs = body.split("")) { - (Some(start), Some(end)) => body[start..end + "".len()].lines().map(str::trim).collect(), - _ => String::new(), - }; - (block.split('"').next().unwrap_or_default(), globs, magic) - }) - .collect(); - let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect(); - let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect(); - associated.sort_unstable(); - typed.sort_unstable(); - typed.dedup(); - assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app"); - for (e, t) in LINUX_TYPES { - assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})"); - let package = declared.iter().find(|(name, ..)| name == t); - if LINUX_SHARED_TYPES.contains(t) { - assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again"); - continue; - } - let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") }; - if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) { - assert!(!globs.contains(e), "{t} matches .{e} files by content only"); - assert_eq!(magic, rule, "{t}'s magic rule for .{e} files"); - } else { - assert!(globs.contains(e), "{t} has no *.{e} glob"); - } - } - for t in &listed { - assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open"); - } - for (t, globs, _) in &declared { - assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}"); - for g in globs { - assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}"); - } - } - } -} +mod tests; diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs new file mode 100644 index 000000000..e6096c54a --- /dev/null +++ b/apps/vectorcraft/src/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState { + vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() } +} + +/// Shortcut overrides an earlier version saved still load: its spellings, `panel:` keys, an +/// override that can't fire (#487), an entry for a command this version lacks, and an old set +/// name. +#[test] +fn shortcut_overrides_from_an_earlier_version_load() { + use serde_json::json; + use vectorcraft_ui_egui::{keymap, shortcut_editor}; + let mut raw = serde_json::to_value(vectorcraft_ui_egui::UiState::default()).unwrap(); + raw["shortcut_overrides"] = json!({ + "tool:pen": "Shift+9", + "edit.preferences": "", + "panel:layers": "Cmd+Alt+Shift+F7", + "tool:groupSelection": "Shift+ShiftLeft", + "test.unknown": "Cmd+Alt+Shift+F9", + "view.grid": "Cmd+Period", + }); + raw["shortcut_set"] = json!(shortcut_editor::LEGACY_PRESETS[0].0); + let ui = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap().sanitized(); + assert_eq!(ui.shortcut_set, shortcut_editor::LEGACY_PRESETS[0].1); + let km = keymap::keymap_with(&ui.shortcut_overrides); + let text = |key: &str| keymap::effective_with(&km, key); + assert_eq!(text("tool:pen").as_deref(), Some("Shift+9")); + assert_eq!(text("edit.preferences"), None); + assert_eq!(text("panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7")); + assert_eq!(text("tool:groupSelection").as_deref(), shortcut_editor::default_of("tool:groupSelection"), "back on its default"); + assert_eq!(text("view.grid").as_deref(), Some("Cmd+Period")); + let period = keymap::vc_chord("Cmd+.").unwrap(); + assert_eq!(keymap::lookup(&km, &period).map(|c| c.id), Some("view.grid".to_string())); + assert_eq!(text("test.unknown").as_deref(), Some("Cmd+Alt+Shift+F9"), "kept"); + assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported"); +} + +#[test] +fn opening_files_unminimizes_and_focuses_the_window() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx())); + // Nothing paints in the test: drop the font atlas upload egui expects to be applied. + out.textures_delta.clear(); + let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands; + assert!(cmds.contains(&egui::ViewportCommand::Minimized(false))); + assert!(cmds.contains(&egui::ViewportCommand::Focus)); +} + +#[test] +fn unreadable_saved_docking_keeps_preferences_and_every_workspace() { + use serde_json::json; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.ui.toolbar = false; + app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"}); + app.ui.custom_workspaces = + vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")]; + app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")]; + let original = serde_json::to_value(&app.ui).unwrap(); + let valid_location = original["docking_hidden"]["layers"].clone(); + assert!(valid_location.is_object()); + for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] { + for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] { + let mut raw = original.clone(); + raw["docking"] = invalid_layout.clone(); + raw["docking_hidden"] = invalid_hidden.clone(); + raw["custom_workspaces"][0]["docking"] = invalid_layout.clone(); + raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone(); + raw["workspace_changes"][0]["docking"] = invalid_layout.clone(); + raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone(); + assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict"); + let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap(); + assert_eq!(loaded.engine_prefs, app.ui.engine_prefs); + assert!(!loaded.toolbar); + assert!(loaded.docking.is_none()); + assert!(!loaded.docking_hidden.contains_key("layers")); + if invalid_hidden.is_object() { + assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location); + } else { + assert!(loaded.docking_hidden.is_empty()); + } + assert_eq!(loaded.custom_workspaces.len(), 2); + assert_eq!(loaded.custom_workspaces[0].name, "Future"); + assert!(loaded.custom_workspaces[0].docking.is_none()); + assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers")); + assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]); + assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost"); + assert!(loaded.workspace_changes[0].docking.is_none()); + } + } + let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(roundtrip).unwrap(), original); + assert!( + decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(), + "unrelated fields retain normal strict parsing" + ); +} + +#[test] +fn an_unreadable_ui_json_is_kept_beside_it() { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("vectorcraft-ui-prefs-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("ui.json"); + super::keep_prefs_copy(&path, b"{\"docking\": {\"root\": {\"FutureNode\": {}}}}", "ui.unreadable"); + super::keep_prefs_copy(&path, b"not json", "ui.unreadable"); + super::keep_prefs_copy(&path, b"{}", "ui.before-layout-migration"); + let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap(); + let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap(); + let before = std::fs::read(dir.join("ui.before-layout-migration.json")).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced"); + assert_eq!(second, b"not json"); + assert_eq!(before, b"{}"); + assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); +} + +#[test] +fn the_image_trace_preview_setting_is_kept_across_a_restart() { + for preview in [false, true] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.ui.image_trace_preview = preview; + let saved = prefs_bytes(&app).unwrap(); + let loaded = decode_saved_prefs(&saved).unwrap().sanitized(); + assert_eq!(loaded.image_trace_preview, preview, "Preview {preview} after a restart"); + } +} + +#[test] +fn the_system_title_bar_preference_keeps_the_window_decorations() { + use craft_window::{Frame, Layout}; + + // The System Title Bar preference (Preferences › User Interface) is read the same way on + // every platform; a missing or unreadable value is `false`. + assert!(!super::opted_into_system_title_bar(None), "no preferences: the default"); + let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); + assert!(!super::opted_into_system_title_bar(Some(&off))); + let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); + assert!(super::opted_into_system_title_bar(Some(&on))); + + // The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title + // bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native + // decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws + // our own title bar — unless the user opted into the system title bar, which forces the + // native frame back on. + let app = Frame::App(Layout::default()); + let desktop = Frame::Desktop; + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own"); + assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native"); + assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame"); + assert!(!super::custom_titlebar_from(&desktop, true)); + } + // macOS and Windows keep today's behavior, independent of the probed frame: the pref still + // forces the native frame back on where a custom one would otherwise be drawn. + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR); + assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows"); + assert!(!super::custom_titlebar_from(&desktop, true)); + } + + assert_eq!(super::native_options(false).viewport.decorations, Some(true)); + assert_eq!(super::native_options(true).viewport.decorations, Some(false)); +} + +/// The file extensions the macOS bundle declares: its document types and its own exported type. +fn plist_extensions(plist: &str) -> Vec<&str> { + ["CFBundleTypeExtensions", "public.filename-extension"] + .iter() + .flat_map(|key| plist.split(key).skip(1)) + .filter_map(|rest| rest.split("").next()) + .flat_map(|array| array.split("").skip(1)) + .filter_map(|s| s.split("").next()) + .collect() +} + +/// Finder offers the app for every file File › Open reads (#295, #354) but those with an +/// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app +/// rather than to AppKit's document machinery. +#[test] +fn the_macos_bundle_opens_every_readable_format() { + let plist = include_str!("../../../packaging/macos/Info.plist.in"); + let declared = plist_extensions(plist); + for e in fileio::OPEN_EXTS.iter().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)) { + assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}"); + } + for e in &declared { + assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open"); + } + assert!(!plist.contains("NSDocumentClass"), "not an NSDocument app: AppKit would refuse the files"); + let types = plist.matches("CFBundleTypeName").count(); + assert!(types > 1 && plist.matches("LSHandlerRank").count() == types, "every document type has a rank"); + assert_eq!(plist.matches("Owner").count(), 2, "only VectorCraft documents and templates are owned"); +} + +/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS` +/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml` +/// declares. +const LINUX_TYPES: &[(&str, &str)] = &[ + ("vectorcraft", "application/x-vectorcraft"), + ("drawcraft", "application/x-vectorcraft"), + ("svg", "image/svg+xml"), + ("svgz", "image/svg+xml-compressed"), + ("pdf", "application/pdf"), + ("ai", "application/illustrator"), // brand-ok: shared-mime-info's type + ("ait", "application/x-ai-template"), + ("png", "image/png"), + ("jpg", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("gif", "image/gif"), + ("webp", "image/webp"), + ("tif", "image/tiff"), + ("tiff", "image/tiff"), + ("bmp", "image/bmp"), + ("vctemplate", "application/x-vectorcraft-template"), + ("dxf", "image/vnd.dxf"), + ("emf", "image/emf"), + ("wmf", "image/wmf"), + ("eps", "image/x-eps"), + ("af", "application/x-affinity"), + ("afdesign", "application/x-affinity"), + ("afpub", "application/x-affinity"), +]; + +/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every +/// other one. +const LINUX_SHARED_TYPES: &[&str] = &[ + "image/svg+xml", + "image/svg+xml-compressed", + "application/pdf", + "application/illustrator", // brand-ok: shared-mime-info's type + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/tiff", + "image/bmp", + "image/vnd.dxf", + "image/emf", + "image/wmf", + "image/x-eps", +]; + +/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a +/// glob, with the package's `` rule for that type (its lines joined, indentation removed). +const LINUX_BY_CONTENT: &[(&str, &str)] = &[( + "af", + // `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs). + r#""#, +)]; + +/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but +/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't +/// have. +#[test] +fn the_linux_desktop_entry_opens_every_readable_format() { + let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop"); + let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml"); + let listed: Vec<&str> = + desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect(); + // Each type the MIME package declares, with the extensions of its globs and its `` + // rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives. + let declared: Vec<(&str, Vec<&str>, String)> = mime + .split("").next().unwrap_or_default(); + let globs = body.split("")) { + (Some(start), Some(end)) => body[start..end + "".len()].lines().map(str::trim).collect(), + _ => String::new(), + }; + (block.split('"').next().unwrap_or_default(), globs, magic) + }) + .collect(); + let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect(); + let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect(); + associated.sort_unstable(); + typed.sort_unstable(); + typed.dedup(); + assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app"); + for (e, t) in LINUX_TYPES { + assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})"); + let package = declared.iter().find(|(name, ..)| name == t); + if LINUX_SHARED_TYPES.contains(t) { + assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again"); + continue; + } + let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") }; + if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) { + assert!(!globs.contains(e), "{t} matches .{e} files by content only"); + assert_eq!(magic, rule, "{t}'s magic rule for .{e} files"); + } else { + assert!(globs.contains(e), "{t} has no *.{e} glob"); + } + } + for t in &listed { + assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open"); + } + for (t, globs, _) in &declared { + assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}"); + for g in globs { + assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}"); + } + } +} From e528a5f8d04be0cedd4325a4f0cb598b422b4c2c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:22:19 +0000 Subject: [PATCH 090/199] refactor(three-d): move lib.rs tests to tests.rs --- crates/three-d/src/lib.rs | 188 +----------------------------------- crates/three-d/src/tests.rs | 185 +++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 187 deletions(-) create mode 100644 crates/three-d/src/tests.rs diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 5589c3d0c..8f6728b97 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -601,190 +601,4 @@ mod tests_caps; mod tests_smooth; #[cfg(test)] -mod tests { - use super::*; - #[test] - fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() { - for rotation in [-170.0f64, -45.0, 0.0, 83.0, 167.0] { - for height in [0.0f64, 15.0, 45.0, 80.0] { - for behind in [false, true] { - let o = Revolve { light_rotation: rotation, light_height: height, light_behind: behind, ..Default::default() }; - let direction = o.light_direction(); - assert!((direction[0].hypot(direction[1]).hypot(direction[2]) - 1.0).abs() < 1e-12); - assert_eq!(direction[2] < 0.0, behind && height > 0.0); - for ([center, u, v], angle) in o.light_orbits().into_iter().zip([rotation, height]) { - let (s, c) = angle.to_radians().sin_cos(); - for i in 0..3 { - assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12); - } - } - let [r, h] = o.light_at_on_side(Point::new(direction[0], direction[1])); - assert!((r - rotation).abs() < 1e-9 && (h - height).abs() < 1e-6, "{rotation}, {height} -> {r}, {h}"); - } - } - } - // Dragged to the centre: straight in front, the Rotation kept; past the rim: at 0°. - let o = Revolve { light_rotation: 135.0, ..Default::default() }; - assert_eq!(o.light_at_on_side(Point::new(0.0, 0.0)), [135.0, 90.0]); - let [r, h] = o.light_at_on_side(Point::new(0.0, -3.0)); - assert!((r - 90.0).abs() < 1e-12 && h == 0.0); - let [r, h] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY)); - assert!(r.is_finite() && h.is_finite()); - } - - /// An effect from before Rotation and Height (azimuth and elevation) gets the same light: - /// the same direction, so the same shading. - #[test] - fn an_earlier_azimuth_and_elevation_give_the_same_light() { - let old = |azimuth: f64, elevation: f64| { - let (sl, cl) = elevation.to_radians().sin_cos(); - let (sa, ca) = azimuth.to_radians().sin_cos(); - [cl * sa, -sl, cl * ca] - }; - for azimuth in [-360.0f64, -170.0, -45.0, 0.0, 90.0, 135.0, 179.0] { - for elevation in [-90.0f64, -60.0, -1.0, 0.0, 30.0, 45.0, 89.0, 90.0] { - let (light_rotation, light_height, light_behind) = light_from_azimuth_elevation(azimuth, elevation); - assert!((-180.0..=180.0).contains(&light_rotation) && (0.0..=90.0).contains(&light_height)); - let new = Revolve { light_rotation, light_height, light_behind, ..Default::default() }.light_direction(); - let want = old(azimuth, elevation); - for i in 0..3 { - assert!((new[i] - want[i]).abs() < 1e-12, "{azimuth}, {elevation}: {new:?} against {want:?}"); - } - } - } - // The default light is the earlier default's. - let (r, h, behind) = light_from_azimuth_elevation(-45.0, 45.0); - assert!((r - DEFAULT_LIGHT.0).abs() < 1e-12 && (h - DEFAULT_LIGHT.1).abs() < 1e-12 && !behind, "{r} {h}"); - } - - /// Softness wraps the light further round the object; without it, the earlier shading. Ambient - /// Light runs to 200 %, never past the art's own colour. - #[test] - fn softness_wraps_the_light_and_ambient_reaches_200_percent() { - let o = Revolve::default().sanitized(); - let l = V3(0.0, 0.0, 1.0); - let n = |deg: f64| { - let (s, c) = deg.to_radians().sin_cos(); - V3(s, 0.0, c) - }; - for deg in [0.0, 30.0, 60.0, 89.0, 120.0] { - let lambert = (o.ambient + o.light_intensity * n(deg).dot(l).max(0.0)).clamp(0.0, 1.0); - assert!((brightness(&o, n(deg), l) - lambert).abs() < 1e-12); - } - let soft = Revolve { light_softness: 1.0, ..o }; - assert!(brightness(&soft, n(120.0), l) > brightness(&o, n(120.0), l) + 0.1, "lit past 90°"); - assert!((brightness(&soft, n(180.0), l) - o.ambient).abs() < 1e-12, "nothing straight away"); - assert!((brightness(&soft, n(0.0), l) - brightness(&o, n(0.0), l)).abs() < 1e-12, "the same facing the light"); - let bright = Revolve { ambient: 2.0, ..o }; - assert_eq!(brightness(&bright, n(180.0), l), 1.0); - assert_eq!( - Revolve { ambient: 5.0, light_softness: -1.0, light_height: 120.0, ..o }.sanitized(), - Revolve { ambient: 2.0, light_softness: 0.0, light_height: 90.0, ..o } - ); - } - - #[test] - fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() { - let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() }; - let seed = V3(0.3, -0.5, 0.7); - let before = o.rotate(seed); - let planes = o.rotation_planes(); - for (axis, [u, v]) in planes.iter().enumerate() { - let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2])); - assert!((normal.dot(normal) - 1.0).abs() < 1e-12); - let mut changed = o; - match axis { - 0 => changed.rotation_x += 0.0001, - 1 => changed.rotation_y += 0.0001, - _ => changed.rotation_z += 0.0001, - } - let actual = changed.rotate(seed).sub(before); - let expected = normal.cross(before); - let step = 0.0001f64.to_radians(); - for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] { - assert!((a - b).abs() < 1e-5); - } - } - assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0)); - } - #[test] - fn guides_match_projection_and_dragged_light_matches_shading_direction() { - let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() }; - assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]); - assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]); - let [light_rotation, light_height] = o.light_at_on_side(Point::new(0.3, -0.4)); - let [x, y, z] = Revolve { light_rotation, light_height, ..o }.light_direction(); - assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0); - let [rotation, height] = o.light_at_on_side(Point::new(2.0, -2.0)); - assert!(rotation.is_finite() && height == 0.0); - } - fn cylinder() -> (PathData, Rect) { - let mut bp = BezPath::new(); - bp.move_to((40.0, 10.0)); - bp.line_to((40.0, 90.0)); - (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)) - } - #[test] - fn cylinder_has_expected_silhouette_depth_order_and_shading() { - let (p, b) = cylinder(); - let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); - let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect(); - assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6); - assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6); - assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth)); - assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4)); - } - #[test] - fn edge_offset_sweep_and_rotation_change_the_surface() { - let (p, b) = cylinder(); - let base = revolve(&p, b, Revolve::default()).unwrap(); - for o in [ - Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() }, - Revolve { angle: 120.0, ..Default::default() }, - Revolve { rotation_z: 60.0, perspective: 40.0, ..Default::default() }, - ] { - assert_ne!(base, revolve(&p, b, o).unwrap()); - } - assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty()); - } - /// The angle that stands for an earlier perspective strength puts the eye where that strength - /// did (4 × the half size / strength), so files from before the angle look as they did. - #[test] - fn the_angle_for_an_earlier_strength_puts_the_eye_where_it_was() { - for strength in [0.05, 0.3, 0.6, 1.0] { - for size in [1.0, 37.5, 900.0] { - let eye = eye_distance(perspective_from_strength(strength), size).unwrap(); - assert!((eye - 4.0 * size / strength).abs() < 1e-9 * eye, "{strength} {size}: {eye}"); - } - } - assert_eq!(perspective_from_strength(0.0), 0.0); - assert!((perspective_from_strength(1.0) - 51.318).abs() < 1e-3, "{}", perspective_from_strength(1.0)); - } - - /// 0° to 160°: none at 0°, beyond either end held there, and at 160° the eye is still outside - /// the sphere the object's box fits in. - #[test] - fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() { - assert_eq!(eye_distance(0.0, 50.0), None); - assert_eq!(eye_distance(-20.0, 50.0), None); - assert_eq!(eye_distance(f64::NAN, 50.0), None); - assert_eq!(eye_distance(500.0, 50.0), eye_distance(160.0, 50.0)); - let widest = eye_distance(160.0, 50.0).unwrap(); - assert!(widest > 3f64.sqrt() * 50.0 && widest < 1.02 * 3f64.sqrt() * 50.0, "{widest}"); - // Wider is closer. - let eyes: Vec = [10.0, 45.0, 90.0, 160.0].iter().map(|a| eye_distance(*a, 50.0).unwrap()).collect(); - assert!(eyes.windows(2).all(|w| w[0] > w[1]), "{eyes:?}"); - let (p, b) = cylinder(); - let wide = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 160.0, ..Default::default() }).unwrap(); - assert!(wide.iter().flat_map(|f| &f.points).all(|q| q.x.is_finite() && q.y.is_finite())); - assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap()); - } - #[test] - fn malformed_and_degenerate_inputs_are_bounded() { - let (p, b) = cylinder(); - let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap(); - assert!(f.len() <= 258); - assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); - assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); - } -} +mod tests; diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs new file mode 100644 index 000000000..6b38a5807 --- /dev/null +++ b/crates/three-d/src/tests.rs @@ -0,0 +1,185 @@ +use super::*; +#[test] +fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() { + for rotation in [-170.0f64, -45.0, 0.0, 83.0, 167.0] { + for height in [0.0f64, 15.0, 45.0, 80.0] { + for behind in [false, true] { + let o = Revolve { light_rotation: rotation, light_height: height, light_behind: behind, ..Default::default() }; + let direction = o.light_direction(); + assert!((direction[0].hypot(direction[1]).hypot(direction[2]) - 1.0).abs() < 1e-12); + assert_eq!(direction[2] < 0.0, behind && height > 0.0); + for ([center, u, v], angle) in o.light_orbits().into_iter().zip([rotation, height]) { + let (s, c) = angle.to_radians().sin_cos(); + for i in 0..3 { + assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12); + } + } + let [r, h] = o.light_at_on_side(Point::new(direction[0], direction[1])); + assert!((r - rotation).abs() < 1e-9 && (h - height).abs() < 1e-6, "{rotation}, {height} -> {r}, {h}"); + } + } + } + // Dragged to the centre: straight in front, the Rotation kept; past the rim: at 0°. + let o = Revolve { light_rotation: 135.0, ..Default::default() }; + assert_eq!(o.light_at_on_side(Point::new(0.0, 0.0)), [135.0, 90.0]); + let [r, h] = o.light_at_on_side(Point::new(0.0, -3.0)); + assert!((r - 90.0).abs() < 1e-12 && h == 0.0); + let [r, h] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY)); + assert!(r.is_finite() && h.is_finite()); +} + +/// An effect from before Rotation and Height (azimuth and elevation) gets the same light: +/// the same direction, so the same shading. +#[test] +fn an_earlier_azimuth_and_elevation_give_the_same_light() { + let old = |azimuth: f64, elevation: f64| { + let (sl, cl) = elevation.to_radians().sin_cos(); + let (sa, ca) = azimuth.to_radians().sin_cos(); + [cl * sa, -sl, cl * ca] + }; + for azimuth in [-360.0f64, -170.0, -45.0, 0.0, 90.0, 135.0, 179.0] { + for elevation in [-90.0f64, -60.0, -1.0, 0.0, 30.0, 45.0, 89.0, 90.0] { + let (light_rotation, light_height, light_behind) = light_from_azimuth_elevation(azimuth, elevation); + assert!((-180.0..=180.0).contains(&light_rotation) && (0.0..=90.0).contains(&light_height)); + let new = Revolve { light_rotation, light_height, light_behind, ..Default::default() }.light_direction(); + let want = old(azimuth, elevation); + for i in 0..3 { + assert!((new[i] - want[i]).abs() < 1e-12, "{azimuth}, {elevation}: {new:?} against {want:?}"); + } + } + } + // The default light is the earlier default's. + let (r, h, behind) = light_from_azimuth_elevation(-45.0, 45.0); + assert!((r - DEFAULT_LIGHT.0).abs() < 1e-12 && (h - DEFAULT_LIGHT.1).abs() < 1e-12 && !behind, "{r} {h}"); +} + +/// Softness wraps the light further round the object; without it, the earlier shading. Ambient +/// Light runs to 200 %, never past the art's own colour. +#[test] +fn softness_wraps_the_light_and_ambient_reaches_200_percent() { + let o = Revolve::default().sanitized(); + let l = V3(0.0, 0.0, 1.0); + let n = |deg: f64| { + let (s, c) = deg.to_radians().sin_cos(); + V3(s, 0.0, c) + }; + for deg in [0.0, 30.0, 60.0, 89.0, 120.0] { + let lambert = (o.ambient + o.light_intensity * n(deg).dot(l).max(0.0)).clamp(0.0, 1.0); + assert!((brightness(&o, n(deg), l) - lambert).abs() < 1e-12); + } + let soft = Revolve { light_softness: 1.0, ..o }; + assert!(brightness(&soft, n(120.0), l) > brightness(&o, n(120.0), l) + 0.1, "lit past 90°"); + assert!((brightness(&soft, n(180.0), l) - o.ambient).abs() < 1e-12, "nothing straight away"); + assert!((brightness(&soft, n(0.0), l) - brightness(&o, n(0.0), l)).abs() < 1e-12, "the same facing the light"); + let bright = Revolve { ambient: 2.0, ..o }; + assert_eq!(brightness(&bright, n(180.0), l), 1.0); + assert_eq!( + Revolve { ambient: 5.0, light_softness: -1.0, light_height: 120.0, ..o }.sanitized(), + Revolve { ambient: 2.0, light_softness: 0.0, light_height: 90.0, ..o } + ); +} + +#[test] +fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() { + let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() }; + let seed = V3(0.3, -0.5, 0.7); + let before = o.rotate(seed); + let planes = o.rotation_planes(); + for (axis, [u, v]) in planes.iter().enumerate() { + let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2])); + assert!((normal.dot(normal) - 1.0).abs() < 1e-12); + let mut changed = o; + match axis { + 0 => changed.rotation_x += 0.0001, + 1 => changed.rotation_y += 0.0001, + _ => changed.rotation_z += 0.0001, + } + let actual = changed.rotate(seed).sub(before); + let expected = normal.cross(before); + let step = 0.0001f64.to_radians(); + for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] { + assert!((a - b).abs() < 1e-5); + } + } + assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0)); +} +#[test] +fn guides_match_projection_and_dragged_light_matches_shading_direction() { + let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() }; + assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]); + assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]); + let [light_rotation, light_height] = o.light_at_on_side(Point::new(0.3, -0.4)); + let [x, y, z] = Revolve { light_rotation, light_height, ..o }.light_direction(); + assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0); + let [rotation, height] = o.light_at_on_side(Point::new(2.0, -2.0)); + assert!(rotation.is_finite() && height == 0.0); +} +fn cylinder() -> (PathData, Rect) { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.line_to((40.0, 90.0)); + (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)) +} +#[test] +fn cylinder_has_expected_silhouette_depth_order_and_shading() { + let (p, b) = cylinder(); + let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect(); + assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6); + assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6); + assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth)); + assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4)); +} +#[test] +fn edge_offset_sweep_and_rotation_change_the_surface() { + let (p, b) = cylinder(); + let base = revolve(&p, b, Revolve::default()).unwrap(); + for o in [ + Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() }, + Revolve { angle: 120.0, ..Default::default() }, + Revolve { rotation_z: 60.0, perspective: 40.0, ..Default::default() }, + ] { + assert_ne!(base, revolve(&p, b, o).unwrap()); + } + assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty()); +} +/// The angle that stands for an earlier perspective strength puts the eye where that strength +/// did (4 × the half size / strength), so files from before the angle look as they did. +#[test] +fn the_angle_for_an_earlier_strength_puts_the_eye_where_it_was() { + for strength in [0.05, 0.3, 0.6, 1.0] { + for size in [1.0, 37.5, 900.0] { + let eye = eye_distance(perspective_from_strength(strength), size).unwrap(); + assert!((eye - 4.0 * size / strength).abs() < 1e-9 * eye, "{strength} {size}: {eye}"); + } + } + assert_eq!(perspective_from_strength(0.0), 0.0); + assert!((perspective_from_strength(1.0) - 51.318).abs() < 1e-3, "{}", perspective_from_strength(1.0)); +} + +/// 0° to 160°: none at 0°, beyond either end held there, and at 160° the eye is still outside +/// the sphere the object's box fits in. +#[test] +fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() { + assert_eq!(eye_distance(0.0, 50.0), None); + assert_eq!(eye_distance(-20.0, 50.0), None); + assert_eq!(eye_distance(f64::NAN, 50.0), None); + assert_eq!(eye_distance(500.0, 50.0), eye_distance(160.0, 50.0)); + let widest = eye_distance(160.0, 50.0).unwrap(); + assert!(widest > 3f64.sqrt() * 50.0 && widest < 1.02 * 3f64.sqrt() * 50.0, "{widest}"); + // Wider is closer. + let eyes: Vec = [10.0, 45.0, 90.0, 160.0].iter().map(|a| eye_distance(*a, 50.0).unwrap()).collect(); + assert!(eyes.windows(2).all(|w| w[0] > w[1]), "{eyes:?}"); + let (p, b) = cylinder(); + let wide = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 160.0, ..Default::default() }).unwrap(); + assert!(wide.iter().flat_map(|f| &f.points).all(|q| q.x.is_finite() && q.y.is_finite())); + assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap()); +} +#[test] +fn malformed_and_degenerate_inputs_are_bounded() { + let (p, b) = cylinder(); + let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap(); + assert!(f.len() <= 258); + assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); + assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); +} From 371c93ffe5b19aeba667363ac8ef3d2e30b1f3a6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:53 +0000 Subject: [PATCH 091/199] refactor(doc): move tests_*.rs to tests/.rs --- crates/doc/src/lib.rs | 7 ------- crates/doc/src/tests.rs | 4 ++++ crates/doc/src/{tests_lookups.rs => tests/lookups.rs} | 2 +- crates/doc/src/{tests_slices.rs => tests/slices.rs} | 0 crates/doc/src/{tests_units.rs => tests/units.rs} | 4 ++-- 5 files changed, 7 insertions(+), 10 deletions(-) rename crates/doc/src/{tests_lookups.rs => tests/lookups.rs} (99%) rename crates/doc/src/{tests_slices.rs => tests/slices.rs} (100%) rename crates/doc/src/{tests_units.rs => tests/units.rs} (98%) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6cd0e3aec..b06ed03f2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -2383,10 +2383,3 @@ impl Document { #[cfg(test)] mod tests; - -#[cfg(test)] -mod tests_lookups; -#[cfg(test)] -mod tests_slices; -#[cfg(test)] -mod tests_units; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 7bb94b3fa..6ef4260a9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -1,6 +1,10 @@ use super::*; use vectorcraft_geom::shapes; +mod lookups; +mod slices; +mod units; + /// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch /// are binary fractions now, and the minor ticks between them stay binary too; every other /// unit is still read in tenths. diff --git a/crates/doc/src/tests_lookups.rs b/crates/doc/src/tests/lookups.rs similarity index 99% rename from crates/doc/src/tests_lookups.rs rename to crates/doc/src/tests/lookups.rs index 0fda214ee..2f83b65c5 100644 --- a/crates/doc/src/tests_lookups.rs +++ b/crates/doc/src/tests/lookups.rs @@ -2,7 +2,7 @@ //! with the one-id ones on nested trees with missing and duplicated ids, and the edits of many nodes //! ([`Document::remove_many`], `wrap`, `insert_beside`) leave the tree that `remove`, `insert` and //! `move_node` leave one node at a time. -use super::*; +use crate::*; use vectorcraft_geom::shapes; /// Layers with paths and groups nested `depth` deep; every 7th id is reused (a damaged file), so diff --git a/crates/doc/src/tests_slices.rs b/crates/doc/src/tests/slices.rs similarity index 100% rename from crates/doc/src/tests_slices.rs rename to crates/doc/src/tests/slices.rs diff --git a/crates/doc/src/tests_units.rs b/crates/doc/src/tests/units.rs similarity index 98% rename from crates/doc/src/tests_units.rs rename to crates/doc/src/tests/units.rs index 5b3319f18..909a0adf3 100644 --- a/crates/doc/src/tests_units.rs +++ b/crates/doc/src/tests/units.rs @@ -3,7 +3,7 @@ use proptest::prelude::*; -use super::Unit; +use crate::Unit; #[test] fn every_unit_round_trips_through_its_field_text() { @@ -133,7 +133,7 @@ fn a_negative_operand_after_an_operator_is_a_sign() { assert_eq!(Unit::Points.parse("5 - -3"), Some(8.0)); assert_eq!(Unit::Points.parse("5+-3"), Some(2.0)); assert_eq!(Unit::Points.parse("10-2"), Some(8.0)); - assert_eq!(super::parse_number("90*-1"), Some(-90.0)); + assert_eq!(crate::parse_number("90*-1"), Some(-90.0)); } #[test] From 113c15c64fe59a7bcd7250a434f82688108c27ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:54 +0000 Subject: [PATCH 092/199] refactor(effects): move tests_*.rs to tests/.rs --- crates/effects/src/lib.rs | 4 ---- crates/effects/src/tests.rs | 3 +++ crates/effects/src/{tests_scale.rs => tests/scale.rs} | 2 +- .../src/{tests_transformfx.rs => tests/transformfx.rs} | 0 4 files changed, 4 insertions(+), 5 deletions(-) rename crates/effects/src/{tests_scale.rs => tests/scale.rs} (99%) rename crates/effects/src/{tests_transformfx.rs => tests/transformfx.rs} (100%) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ce1b1d58d..ac7193498 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -57,10 +57,6 @@ mod warp; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_scale; -#[cfg(test)] -mod tests_transformfx; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ad66a2a97..c5b5b47d2 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,9 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod scale; +mod transformfx; + fn fx(id: &str, params: Value) -> Effect { Effect { id: id.into(), params, visible: true } } diff --git a/crates/effects/src/tests_scale.rs b/crates/effects/src/tests/scale.rs similarity index 99% rename from crates/effects/src/tests_scale.rs rename to crates/effects/src/tests/scale.rs index 6e2c71689..061695e73 100644 --- a/crates/effects/src/tests_scale.rs +++ b/crates/effects/src/tests/scale.rs @@ -3,7 +3,7 @@ use serde_json::json; use vectorcraft_doc::Effect; -use super::*; +use crate::*; #[test] fn every_distance_parameter_is_a_parameter() { diff --git a/crates/effects/src/tests_transformfx.rs b/crates/effects/src/tests/transformfx.rs similarity index 100% rename from crates/effects/src/tests_transformfx.rs rename to crates/effects/src/tests/transformfx.rs From 83eec43b185d50d8a77b0e29d926bfdfdd693d23 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:56 +0000 Subject: [PATCH 093/199] refactor(engine): move tests_*.rs to tests/.rs --- crates/engine/src/lib.rs | 338 +----------------- crates/engine/src/tests.rs | 169 +++++++++ .../src/{tests_adjust.rs => tests/adjust.rs} | 2 +- .../appearance.rs} | 4 +- .../{tests_areafit.rs => tests/areafit.rs} | 2 +- .../src/{tests_assets.rs => tests/assets.rs} | 2 +- .../attributes.rs} | 2 +- .../bboxrotate.rs} | 2 +- .../blendfidelity.rs} | 2 +- .../blendopts.rs} | 2 +- .../blendspine.rs} | 2 +- .../{tests_brushlib.rs => tests/brushlib.rs} | 2 +- .../{tests_brushsym.rs => tests/brushsym.rs} | 4 +- .../src/{tests_build.rs => tests/build.rs} | 6 +- .../src/{tests_cancel.rs => tests/cancel.rs} | 0 .../charstroke.rs} | 2 +- .../src/{tests_clip.rs => tests/clip.rs} | 2 +- .../clipboard.rs} | 2 +- .../clipflavours.rs} | 2 +- .../clippaint.rs} | 2 +- .../{tests_cmdsplit.rs => tests/cmdsplit.rs} | 2 +- .../cmykflatten.rs} | 2 +- .../colorguide.rs} | 2 +- .../colorguidelib.rs} | 2 +- .../colormgmt.rs} | 2 +- .../colorthemes.rs} | 2 +- .../colourfonts.rs} | 2 +- .../compoundpaint.rs} | 2 +- .../constrain_new.rs} | 2 +- .../constrain_tools.rs} | 2 +- .../containers.rs} | 2 +- .../engine/src/{tests_css.rs => tests/css.rs} | 2 +- .../engine/src/{tests_cut.rs => tests/cut.rs} | 2 +- .../dashalign.rs} | 4 +- .../{tests_distort.rs => tests/distort.rs} | 2 +- .../{tests_docsetup.rs => tests/docsetup.rs} | 2 +- .../src/{tests_draw2.rs => tests/draw2.rs} | 6 +- .../editcolors.rs} | 2 +- .../effectedit.rs} | 2 +- .../emptytype.rs} | 2 +- .../{tests_envelope.rs => tests/envelope.rs} | 2 +- .../envelope_distort.rs} | 2 +- .../envelope_edit.rs} | 2 +- .../src/{tests_expand.rs => tests/expand.rs} | 4 +- .../eyedropper.rs} | 2 +- .../src/{tests_file.rs => tests/file.rs} | 2 +- .../{tests_fileinfo.rs => tests/fileinfo.rs} | 2 +- .../flatpresets.rs} | 2 +- .../flatpreview.rs} | 2 +- .../{tests_flatten.rs => tests/flatten.rs} | 6 +- .../src/{tests_focal.rs => tests/focal.rs} | 2 +- .../fontfiles.rs} | 0 .../{tests_fontlist.rs => tests/fontlist.rs} | 2 +- .../{tests_freeform.rs => tests/freeform.rs} | 4 +- .../globaledit.rs} | 2 +- .../{tests_gradient.rs => tests/gradient.rs} | 4 +- .../gradpanel.rs} | 2 +- .../{tests_gradspan.rs => tests/gradspan.rs} | 2 +- .../{tests_halftone.rs => tests/halftone.rs} | 2 +- .../history_mem.rs} | 2 +- .../ids_without_selection.rs} | 2 +- .../src/{tests_inline.rs => tests/inline.rs} | 2 +- .../interactivity.rs} | 2 +- .../intertwine.rs} | 4 +- .../{tests_journal.rs => tests/journal.rs} | 2 +- .../{tests_keyguide.rs => tests/keyguide.rs} | 2 +- .../{tests_knockout.rs => tests/knockout.rs} | 2 +- .../{tests_labspots.rs => tests/labspots.rs} | 4 +- .../large_edits.rs} | 2 +- .../layerclip.rs} | 4 +- .../src/{tests_layers.rs => tests/layers.rs} | 2 +- .../legacy_ai.rs} | 4 +- .../{tests_library.rs => tests/library.rs} | 2 +- .../link_paths.rs} | 2 +- .../linked_stops.rs} | 2 +- .../linkfiles.rs} | 0 .../src/{tests_links.rs => tests/links.rs} | 2 +- .../linkspanel.rs} | 4 +- .../linkwatch.rs} | 2 +- .../{tests_liquify.rs => tests/liquify.rs} | 2 +- .../src/{tests_live.rs => tests/live.rs} | 2 +- .../livecorners.rs} | 2 +- .../{tests_maskview.rs => tests/maskview.rs} | 2 +- .../{tests_menucmds.rs => tests/menucmds.rs} | 8 +- .../nativefile.rs} | 2 +- .../src/{tests_newart.rs => tests/newart.rs} | 2 +- .../src/{tests_newdoc.rs => tests/newdoc.rs} | 2 +- .../objects_on_path.rs} | 4 +- .../objexpand.rs} | 2 +- .../opacitymask.rs} | 2 +- .../outlineobject.rs} | 2 +- .../outlinestroke.rs} | 2 +- .../overprint.rs} | 2 +- .../{tests_package.rs => tests/package.rs} | 4 +- .../paintproxy.rs} | 2 +- .../panelcmds.rs} | 4 +- .../paragraphs.rs} | 2 +- .../src/{tests_params.rs => tests/params.rs} | 0 .../{tests_pathops.rs => tests/pathops.rs} | 2 +- .../{tests_pathtype.rs => tests/pathtype.rs} | 2 +- .../{tests_pattern.rs => tests/pattern.rs} | 16 +- .../pdffidelity.rs} | 2 +- .../{tests_pdfmarks.rs => tests/pdfmarks.rs} | 2 +- .../pdfpresets.rs} | 0 .../pdfraster.rs} | 2 +- .../persp_planes.rs} | 4 +- .../persp_select.rs} | 2 +- .../persp_text.rs} | 4 +- .../perspgrid.rs} | 2 +- .../src/{tests_place.rs => tests/place.rs} | 4 +- .../placed_document.rs} | 6 +- .../{tests_plugins.rs => tests/plugins.rs} | 4 +- .../src/{tests_prefs.rs => tests/prefs.rs} | 2 +- .../previewbounds.rs} | 2 +- .../src/{tests_print.rs => tests/print.rs} | 2 +- .../printadvanced.rs} | 14 +- .../printpresets.rs} | 2 +- .../printpreview.rs} | 2 +- .../{tests_printps.rs => tests/printps.rs} | 2 +- .../printtiling.rs} | 2 +- .../proxyitems.rs} | 2 +- .../puppetwarp.rs} | 2 +- .../rasterfilters.rs} | 2 +- .../rastersettings.rs} | 2 +- .../{tests_recolor.rs => tests/recolor.rs} | 2 +- .../{tests_recovery.rs => tests/recovery.rs} | 2 +- .../reflecthit.rs} | 2 +- .../registration.rs} | 2 +- .../src/{tests_save.rs => tests/save.rs} | 2 +- .../saveoptions.rs} | 2 +- .../scalestrokes.rs} | 2 +- .../{tests_scripts.rs => tests/scripts.rs} | 2 +- .../select_ids_first.rs} | 2 +- .../src/{tests_shaper.rs => tests/shaper.rs} | 2 +- .../shortcutset.rs} | 2 +- .../simplify_part.rs} | 2 +- .../src/{tests_slices.rs => tests/slices.rs} | 2 +- .../smartguides.rs} | 2 +- .../src/{tests_store.rs => tests/store.rs} | 0 .../strokegeom.rs} | 2 +- .../strokegradient.rs} | 2 +- .../strokereach.rs} | 2 +- .../{tests_strokeux.rs => tests/strokeux.rs} | 2 +- .../{tests_stylelib.rs => tests/stylelib.rs} | 2 +- .../stylepanel.rs} | 2 +- .../src/{tests_styles.rs => tests/styles.rs} | 2 +- .../swatchcmds.rs} | 4 +- .../{tests_swatches.rs => tests/swatches.rs} | 2 +- .../swatchlib.rs} | 2 +- .../symbolbounds.rs} | 2 +- .../symbollib.rs} | 2 +- .../targeting.rs} | 2 +- .../textcombos.rs} | 2 +- .../{tests_textedit.rs => tests/textedit.rs} | 4 +- .../textimport.rs} | 2 +- .../{tests_tileedge.rs => tests/tileedge.rs} | 2 +- .../src/{tests_tints.rs => tests/tints.rs} | 2 +- .../toolsettings.rs} | 2 +- .../transformfx.rs} | 2 +- .../transparencygrid.rs} | 2 +- .../src/{tests_trap.rs => tests/trap.rs} | 2 +- .../{tests_typearea.rs => tests/typearea.rs} | 2 +- .../typescale.rs} | 2 +- .../src/{tests_units.rs => tests/units.rs} | 2 +- .../variables.rs} | 4 +- .../webexport.rs} | 2 +- .../widthpoints.rs} | 2 +- .../widthprofiles.rs} | 2 +- .../widthtool.rs} | 2 +- .../src/{tests_xform.rs => tests/xform.rs} | 8 +- 170 files changed, 381 insertions(+), 546 deletions(-) rename crates/engine/src/{tests_adjust.rs => tests/adjust.rs} (99%) rename crates/engine/src/{tests_appearance.rs => tests/appearance.rs} (99%) rename crates/engine/src/{tests_areafit.rs => tests/areafit.rs} (99%) rename crates/engine/src/{tests_assets.rs => tests/assets.rs} (99%) rename crates/engine/src/{tests_attributes.rs => tests/attributes.rs} (99%) rename crates/engine/src/{tests_bboxrotate.rs => tests/bboxrotate.rs} (99%) rename crates/engine/src/{tests_blendfidelity.rs => tests/blendfidelity.rs} (99%) rename crates/engine/src/{tests_blendopts.rs => tests/blendopts.rs} (99%) rename crates/engine/src/{tests_blendspine.rs => tests/blendspine.rs} (99%) rename crates/engine/src/{tests_brushlib.rs => tests/brushlib.rs} (99%) rename crates/engine/src/{tests_brushsym.rs => tests/brushsym.rs} (99%) rename crates/engine/src/{tests_build.rs => tests/build.rs} (99%) rename crates/engine/src/{tests_cancel.rs => tests/cancel.rs} (100%) rename crates/engine/src/{tests_charstroke.rs => tests/charstroke.rs} (99%) rename crates/engine/src/{tests_clip.rs => tests/clip.rs} (99%) rename crates/engine/src/{tests_clipboard.rs => tests/clipboard.rs} (99%) rename crates/engine/src/{tests_clipflavours.rs => tests/clipflavours.rs} (99%) rename crates/engine/src/{tests_clippaint.rs => tests/clippaint.rs} (99%) rename crates/engine/src/{tests_cmdsplit.rs => tests/cmdsplit.rs} (99%) rename crates/engine/src/{tests_cmykflatten.rs => tests/cmykflatten.rs} (99%) rename crates/engine/src/{tests_colorguide.rs => tests/colorguide.rs} (99%) rename crates/engine/src/{tests_colorguidelib.rs => tests/colorguidelib.rs} (99%) rename crates/engine/src/{tests_colormgmt.rs => tests/colormgmt.rs} (99%) rename crates/engine/src/{tests_colorthemes.rs => tests/colorthemes.rs} (99%) rename crates/engine/src/{tests_colourfonts.rs => tests/colourfonts.rs} (99%) rename crates/engine/src/{tests_compoundpaint.rs => tests/compoundpaint.rs} (99%) rename crates/engine/src/{tests_constrain_new.rs => tests/constrain_new.rs} (99%) rename crates/engine/src/{tests_constrain_tools.rs => tests/constrain_tools.rs} (99%) rename crates/engine/src/{tests_containers.rs => tests/containers.rs} (99%) rename crates/engine/src/{tests_css.rs => tests/css.rs} (99%) rename crates/engine/src/{tests_cut.rs => tests/cut.rs} (99%) rename crates/engine/src/{tests_dashalign.rs => tests/dashalign.rs} (98%) rename crates/engine/src/{tests_distort.rs => tests/distort.rs} (99%) rename crates/engine/src/{tests_docsetup.rs => tests/docsetup.rs} (99%) rename crates/engine/src/{tests_draw2.rs => tests/draw2.rs} (99%) rename crates/engine/src/{tests_editcolors.rs => tests/editcolors.rs} (99%) rename crates/engine/src/{tests_effectedit.rs => tests/effectedit.rs} (99%) rename crates/engine/src/{tests_emptytype.rs => tests/emptytype.rs} (99%) rename crates/engine/src/{tests_envelope.rs => tests/envelope.rs} (99%) rename crates/engine/src/{tests_envelope_distort.rs => tests/envelope_distort.rs} (99%) rename crates/engine/src/{tests_envelope_edit.rs => tests/envelope_edit.rs} (99%) rename crates/engine/src/{tests_expand.rs => tests/expand.rs} (99%) rename crates/engine/src/{tests_eyedropper.rs => tests/eyedropper.rs} (99%) rename crates/engine/src/{tests_file.rs => tests/file.rs} (99%) rename crates/engine/src/{tests_fileinfo.rs => tests/fileinfo.rs} (99%) rename crates/engine/src/{tests_flatpresets.rs => tests/flatpresets.rs} (99%) rename crates/engine/src/{tests_flatpreview.rs => tests/flatpreview.rs} (99%) rename crates/engine/src/{tests_flatten.rs => tests/flatten.rs} (99%) rename crates/engine/src/{tests_focal.rs => tests/focal.rs} (99%) rename crates/engine/src/{tests_fontfiles.rs => tests/fontfiles.rs} (100%) rename crates/engine/src/{tests_fontlist.rs => tests/fontlist.rs} (99%) rename crates/engine/src/{tests_freeform.rs => tests/freeform.rs} (99%) rename crates/engine/src/{tests_globaledit.rs => tests/globaledit.rs} (99%) rename crates/engine/src/{tests_gradient.rs => tests/gradient.rs} (99%) rename crates/engine/src/{tests_gradpanel.rs => tests/gradpanel.rs} (99%) rename crates/engine/src/{tests_gradspan.rs => tests/gradspan.rs} (99%) rename crates/engine/src/{tests_halftone.rs => tests/halftone.rs} (99%) rename crates/engine/src/{tests_history_mem.rs => tests/history_mem.rs} (99%) rename crates/engine/src/{tests_ids_without_selection.rs => tests/ids_without_selection.rs} (99%) rename crates/engine/src/{tests_inline.rs => tests/inline.rs} (99%) rename crates/engine/src/{tests_interactivity.rs => tests/interactivity.rs} (99%) rename crates/engine/src/{tests_intertwine.rs => tests/intertwine.rs} (99%) rename crates/engine/src/{tests_journal.rs => tests/journal.rs} (99%) rename crates/engine/src/{tests_keyguide.rs => tests/keyguide.rs} (99%) rename crates/engine/src/{tests_knockout.rs => tests/knockout.rs} (99%) rename crates/engine/src/{tests_labspots.rs => tests/labspots.rs} (99%) rename crates/engine/src/{tests_large_edits.rs => tests/large_edits.rs} (99%) rename crates/engine/src/{tests_layerclip.rs => tests/layerclip.rs} (98%) rename crates/engine/src/{tests_layers.rs => tests/layers.rs} (99%) rename crates/engine/src/{tests_legacy_ai.rs => tests/legacy_ai.rs} (99%) rename crates/engine/src/{tests_library.rs => tests/library.rs} (99%) rename crates/engine/src/{tests_link_paths.rs => tests/link_paths.rs} (99%) rename crates/engine/src/{tests_linked_stops.rs => tests/linked_stops.rs} (99%) rename crates/engine/src/{tests_linkfiles.rs => tests/linkfiles.rs} (100%) rename crates/engine/src/{tests_links.rs => tests/links.rs} (99%) rename crates/engine/src/{tests_linkspanel.rs => tests/linkspanel.rs} (99%) rename crates/engine/src/{tests_linkwatch.rs => tests/linkwatch.rs} (99%) rename crates/engine/src/{tests_liquify.rs => tests/liquify.rs} (99%) rename crates/engine/src/{tests_live.rs => tests/live.rs} (99%) rename crates/engine/src/{tests_livecorners.rs => tests/livecorners.rs} (99%) rename crates/engine/src/{tests_maskview.rs => tests/maskview.rs} (99%) rename crates/engine/src/{tests_menucmds.rs => tests/menucmds.rs} (99%) rename crates/engine/src/{tests_nativefile.rs => tests/nativefile.rs} (99%) rename crates/engine/src/{tests_newart.rs => tests/newart.rs} (99%) rename crates/engine/src/{tests_newdoc.rs => tests/newdoc.rs} (99%) rename crates/engine/src/{tests_objects_on_path.rs => tests/objects_on_path.rs} (99%) rename crates/engine/src/{tests_objexpand.rs => tests/objexpand.rs} (99%) rename crates/engine/src/{tests_opacitymask.rs => tests/opacitymask.rs} (99%) rename crates/engine/src/{tests_outlineobject.rs => tests/outlineobject.rs} (99%) rename crates/engine/src/{tests_outlinestroke.rs => tests/outlinestroke.rs} (99%) rename crates/engine/src/{tests_overprint.rs => tests/overprint.rs} (99%) rename crates/engine/src/{tests_package.rs => tests/package.rs} (99%) rename crates/engine/src/{tests_paintproxy.rs => tests/paintproxy.rs} (99%) rename crates/engine/src/{tests_panelcmds.rs => tests/panelcmds.rs} (99%) rename crates/engine/src/{tests_paragraphs.rs => tests/paragraphs.rs} (99%) rename crates/engine/src/{tests_params.rs => tests/params.rs} (100%) rename crates/engine/src/{tests_pathops.rs => tests/pathops.rs} (99%) rename crates/engine/src/{tests_pathtype.rs => tests/pathtype.rs} (99%) rename crates/engine/src/{tests_pattern.rs => tests/pattern.rs} (99%) rename crates/engine/src/{tests_pdffidelity.rs => tests/pdffidelity.rs} (99%) rename crates/engine/src/{tests_pdfmarks.rs => tests/pdfmarks.rs} (99%) rename crates/engine/src/{tests_pdfpresets.rs => tests/pdfpresets.rs} (100%) rename crates/engine/src/{tests_pdfraster.rs => tests/pdfraster.rs} (99%) rename crates/engine/src/{tests_persp_planes.rs => tests/persp_planes.rs} (99%) rename crates/engine/src/{tests_persp_select.rs => tests/persp_select.rs} (99%) rename crates/engine/src/{tests_persp_text.rs => tests/persp_text.rs} (99%) rename crates/engine/src/{tests_perspgrid.rs => tests/perspgrid.rs} (99%) rename crates/engine/src/{tests_place.rs => tests/place.rs} (99%) rename crates/engine/src/{tests_placed_document.rs => tests/placed_document.rs} (99%) rename crates/engine/src/{tests_plugins.rs => tests/plugins.rs} (99%) rename crates/engine/src/{tests_prefs.rs => tests/prefs.rs} (99%) rename crates/engine/src/{tests_previewbounds.rs => tests/previewbounds.rs} (99%) rename crates/engine/src/{tests_print.rs => tests/print.rs} (99%) rename crates/engine/src/{tests_printadvanced.rs => tests/printadvanced.rs} (95%) rename crates/engine/src/{tests_printpresets.rs => tests/printpresets.rs} (99%) rename crates/engine/src/{tests_printpreview.rs => tests/printpreview.rs} (99%) rename crates/engine/src/{tests_printps.rs => tests/printps.rs} (99%) rename crates/engine/src/{tests_printtiling.rs => tests/printtiling.rs} (99%) rename crates/engine/src/{tests_proxyitems.rs => tests/proxyitems.rs} (99%) rename crates/engine/src/{tests_puppetwarp.rs => tests/puppetwarp.rs} (99%) rename crates/engine/src/{tests_rasterfilters.rs => tests/rasterfilters.rs} (99%) rename crates/engine/src/{tests_rastersettings.rs => tests/rastersettings.rs} (99%) rename crates/engine/src/{tests_recolor.rs => tests/recolor.rs} (99%) rename crates/engine/src/{tests_recovery.rs => tests/recovery.rs} (99%) rename crates/engine/src/{tests_reflecthit.rs => tests/reflecthit.rs} (99%) rename crates/engine/src/{tests_registration.rs => tests/registration.rs} (99%) rename crates/engine/src/{tests_save.rs => tests/save.rs} (99%) rename crates/engine/src/{tests_saveoptions.rs => tests/saveoptions.rs} (99%) rename crates/engine/src/{tests_scalestrokes.rs => tests/scalestrokes.rs} (99%) rename crates/engine/src/{tests_scripts.rs => tests/scripts.rs} (99%) rename crates/engine/src/{tests_select_ids_first.rs => tests/select_ids_first.rs} (99%) rename crates/engine/src/{tests_shaper.rs => tests/shaper.rs} (99%) rename crates/engine/src/{tests_shortcutset.rs => tests/shortcutset.rs} (99%) rename crates/engine/src/{tests_simplify_part.rs => tests/simplify_part.rs} (99%) rename crates/engine/src/{tests_slices.rs => tests/slices.rs} (99%) rename crates/engine/src/{tests_smartguides.rs => tests/smartguides.rs} (99%) rename crates/engine/src/{tests_store.rs => tests/store.rs} (100%) rename crates/engine/src/{tests_strokegeom.rs => tests/strokegeom.rs} (99%) rename crates/engine/src/{tests_strokegradient.rs => tests/strokegradient.rs} (99%) rename crates/engine/src/{tests_strokereach.rs => tests/strokereach.rs} (99%) rename crates/engine/src/{tests_strokeux.rs => tests/strokeux.rs} (99%) rename crates/engine/src/{tests_stylelib.rs => tests/stylelib.rs} (99%) rename crates/engine/src/{tests_stylepanel.rs => tests/stylepanel.rs} (99%) rename crates/engine/src/{tests_styles.rs => tests/styles.rs} (99%) rename crates/engine/src/{tests_swatchcmds.rs => tests/swatchcmds.rs} (99%) rename crates/engine/src/{tests_swatches.rs => tests/swatches.rs} (99%) rename crates/engine/src/{tests_swatchlib.rs => tests/swatchlib.rs} (99%) rename crates/engine/src/{tests_symbolbounds.rs => tests/symbolbounds.rs} (99%) rename crates/engine/src/{tests_symbollib.rs => tests/symbollib.rs} (99%) rename crates/engine/src/{tests_targeting.rs => tests/targeting.rs} (99%) rename crates/engine/src/{tests_textcombos.rs => tests/textcombos.rs} (99%) rename crates/engine/src/{tests_textedit.rs => tests/textedit.rs} (99%) rename crates/engine/src/{tests_textimport.rs => tests/textimport.rs} (99%) rename crates/engine/src/{tests_tileedge.rs => tests/tileedge.rs} (99%) rename crates/engine/src/{tests_tints.rs => tests/tints.rs} (99%) rename crates/engine/src/{tests_toolsettings.rs => tests/toolsettings.rs} (99%) rename crates/engine/src/{tests_transformfx.rs => tests/transformfx.rs} (99%) rename crates/engine/src/{tests_transparencygrid.rs => tests/transparencygrid.rs} (98%) rename crates/engine/src/{tests_trap.rs => tests/trap.rs} (99%) rename crates/engine/src/{tests_typearea.rs => tests/typearea.rs} (99%) rename crates/engine/src/{tests_typescale.rs => tests/typescale.rs} (99%) rename crates/engine/src/{tests_units.rs => tests/units.rs} (99%) rename crates/engine/src/{tests_variables.rs => tests/variables.rs} (99%) rename crates/engine/src/{tests_webexport.rs => tests/webexport.rs} (99%) rename crates/engine/src/{tests_widthpoints.rs => tests/widthpoints.rs} (99%) rename crates/engine/src/{tests_widthprofiles.rs => tests/widthprofiles.rs} (99%) rename crates/engine/src/{tests_widthtool.rs => tests/widthtool.rs} (99%) rename crates/engine/src/{tests_xform.rs => tests/xform.rs} (99%) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ef447b01e..5707e8913 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2596,344 +2596,10 @@ pub(crate) fn adding_fonts() -> std::sync::MutexGuard<'static, ()> { FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } +#[cfg(test)] +use tests::{adjust as tests_adjust, clip as tests_clip, colormgmt as tests_colormgmt, expand as tests_expand, large_edits as tests_large_edits, links as tests_links, linkspanel as tests_linkspanel, menucmds as tests_menucmds, outlinestroke as tests_outlinestroke, persp_select as tests_persp_select, textimport as tests_textimport}; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_adjust; -#[cfg(test)] -mod tests_appearance; -#[cfg(test)] -mod tests_areafit; -#[cfg(test)] -mod tests_assets; -#[cfg(test)] -mod tests_attributes; -#[cfg(test)] -mod tests_bboxrotate; -#[cfg(test)] -mod tests_blendfidelity; -#[cfg(test)] -mod tests_blendopts; -#[cfg(test)] -mod tests_blendspine; -#[cfg(test)] -mod tests_brushlib; -#[cfg(test)] -mod tests_brushsym; -#[cfg(test)] -mod tests_build; -#[cfg(test)] -mod tests_cancel; -#[cfg(test)] -mod tests_charstroke; -#[cfg(test)] -mod tests_clip; -#[cfg(test)] -mod tests_clipboard; -#[cfg(test)] -mod tests_clipflavours; -#[cfg(test)] -mod tests_clippaint; -#[cfg(test)] -mod tests_cmdsplit; -#[cfg(test)] -mod tests_cmykflatten; -#[cfg(test)] -mod tests_colorguide; -#[cfg(test)] -mod tests_colorguidelib; -#[cfg(test)] -mod tests_colormgmt; -#[cfg(test)] -mod tests_colorthemes; -#[cfg(test)] -mod tests_colourfonts; -#[cfg(test)] -mod tests_compoundpaint; -#[cfg(test)] -mod tests_constrain_new; -#[cfg(test)] -mod tests_constrain_tools; -#[cfg(test)] -mod tests_containers; -#[cfg(test)] -mod tests_css; -#[cfg(test)] -mod tests_cut; -#[cfg(test)] -mod tests_dashalign; -#[cfg(test)] -mod tests_distort; -#[cfg(test)] -mod tests_docsetup; -#[cfg(test)] -mod tests_draw2; -#[cfg(test)] -mod tests_editcolors; -#[cfg(test)] -mod tests_effectedit; -#[cfg(test)] -mod tests_emptytype; -#[cfg(test)] -mod tests_envelope; -#[cfg(test)] -mod tests_envelope_distort; -#[cfg(test)] -mod tests_envelope_edit; -#[cfg(test)] -mod tests_expand; -#[cfg(test)] -mod tests_eyedropper; -#[cfg(test)] -mod tests_file; -#[cfg(test)] -mod tests_fileinfo; -#[cfg(test)] -mod tests_flatpresets; -#[cfg(test)] -mod tests_flatpreview; -#[cfg(test)] -mod tests_flatten; -#[cfg(test)] -mod tests_focal; -#[cfg(test)] -mod tests_fontfiles; -#[cfg(test)] -mod tests_fontlist; -#[cfg(test)] -mod tests_freeform; -#[cfg(test)] -mod tests_globaledit; -#[cfg(test)] -mod tests_gradient; -#[cfg(test)] -mod tests_gradpanel; -#[cfg(test)] -mod tests_gradspan; -#[cfg(test)] -mod tests_halftone; -#[cfg(test)] -mod tests_history_mem; -#[cfg(test)] -mod tests_ids_without_selection; -#[cfg(test)] -mod tests_inline; -#[cfg(test)] -mod tests_interactivity; -#[cfg(test)] -mod tests_intertwine; -#[cfg(test)] -mod tests_journal; -#[cfg(test)] -mod tests_keyguide; -#[cfg(test)] -mod tests_knockout; -#[cfg(test)] -mod tests_labspots; -#[cfg(test)] -mod tests_large_edits; -#[cfg(test)] -mod tests_layerclip; -#[cfg(test)] -mod tests_layers; -#[cfg(test)] -mod tests_legacy_ai; -#[cfg(test)] -mod tests_library; -#[cfg(test)] -mod tests_link_paths; -#[cfg(test)] -mod tests_linked_stops; -#[cfg(test)] -mod tests_linkfiles; -#[cfg(test)] -mod tests_links; -#[cfg(test)] -mod tests_linkspanel; -#[cfg(test)] -mod tests_linkwatch; -#[cfg(test)] -mod tests_liquify; -#[cfg(test)] -mod tests_live; -#[cfg(test)] -mod tests_livecorners; -#[cfg(test)] -mod tests_maskview; -#[cfg(test)] -mod tests_menucmds; -#[cfg(test)] -mod tests_nativefile; -#[cfg(test)] -mod tests_newart; -#[cfg(test)] -mod tests_newdoc; -#[cfg(test)] -mod tests_objects_on_path; -#[cfg(test)] -mod tests_objexpand; -#[cfg(test)] -mod tests_opacitymask; -#[cfg(test)] -mod tests_outlineobject; -#[cfg(test)] -mod tests_outlinestroke; -#[cfg(test)] -mod tests_overprint; -#[cfg(test)] -mod tests_package; -#[cfg(test)] -mod tests_paintproxy; -#[cfg(test)] -mod tests_panelcmds; -#[cfg(test)] -mod tests_paragraphs; -#[cfg(test)] -mod tests_params; -#[cfg(test)] -mod tests_pathops; -#[cfg(test)] -mod tests_pathtype; -#[cfg(test)] -mod tests_pattern; -#[cfg(test)] -mod tests_pdffidelity; -#[cfg(test)] -mod tests_pdfmarks; -#[cfg(test)] -mod tests_pdfpresets; -#[cfg(test)] -mod tests_pdfraster; -#[cfg(test)] -mod tests_persp_planes; -#[cfg(test)] -mod tests_persp_select; -#[cfg(test)] -mod tests_persp_text; -#[cfg(test)] -mod tests_perspgrid; -#[cfg(test)] -mod tests_place; -#[cfg(test)] -mod tests_placed_document; -#[cfg(test)] -mod tests_plugins; -#[cfg(test)] -mod tests_prefs; -#[cfg(test)] -mod tests_previewbounds; -#[cfg(test)] -mod tests_print; -#[cfg(test)] -mod tests_printadvanced; -#[cfg(test)] -mod tests_printpresets; -#[cfg(test)] -mod tests_printpreview; -#[cfg(test)] -mod tests_printps; -#[cfg(test)] -mod tests_printtiling; -#[cfg(test)] -mod tests_proxyitems; -#[cfg(test)] -mod tests_puppetwarp; -#[cfg(test)] -mod tests_rasterfilters; -#[cfg(test)] -mod tests_rastersettings; -#[cfg(test)] -mod tests_recolor; -#[cfg(test)] -mod tests_recovery; -#[cfg(test)] -mod tests_reflecthit; -#[cfg(test)] -mod tests_registration; -#[cfg(test)] -mod tests_save; -#[cfg(test)] -mod tests_saveoptions; -#[cfg(test)] -mod tests_scalestrokes; -#[cfg(test)] -mod tests_scripts; -#[cfg(test)] -mod tests_select_ids_first; -#[cfg(test)] -mod tests_shaper; -#[cfg(test)] -mod tests_shortcutset; -#[cfg(test)] -mod tests_simplify_part; -#[cfg(test)] -mod tests_slices; -#[cfg(test)] -mod tests_smartguides; -#[cfg(test)] -mod tests_store; -#[cfg(test)] -mod tests_strokegeom; -#[cfg(test)] -mod tests_strokegradient; -#[cfg(test)] -mod tests_strokereach; -#[cfg(test)] -mod tests_strokeux; -#[cfg(test)] -mod tests_stylelib; -#[cfg(test)] -mod tests_stylepanel; -#[cfg(test)] -mod tests_styles; -#[cfg(test)] -mod tests_swatchcmds; -#[cfg(test)] -mod tests_swatches; -#[cfg(test)] -mod tests_swatchlib; -#[cfg(test)] -mod tests_symbolbounds; -#[cfg(test)] -mod tests_symbollib; -#[cfg(test)] -mod tests_targeting; -#[cfg(test)] -mod tests_textcombos; -#[cfg(test)] -mod tests_textedit; -#[cfg(test)] -mod tests_textimport; -#[cfg(test)] -mod tests_tileedge; -#[cfg(test)] -mod tests_tints; -#[cfg(test)] -mod tests_toolsettings; -#[cfg(test)] -mod tests_transformfx; -#[cfg(test)] -mod tests_transparencygrid; -#[cfg(test)] -mod tests_trap; -#[cfg(test)] -mod tests_typearea; -#[cfg(test)] -mod tests_typescale; -#[cfg(test)] -mod tests_units; -#[cfg(test)] -mod tests_variables; -#[cfg(test)] -mod tests_webexport; -#[cfg(test)] -mod tests_widthpoints; -#[cfg(test)] -mod tests_widthprofiles; -#[cfg(test)] -mod tests_widthtool; -#[cfg(test)] -mod tests_xform; /// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with /// its checks), then running as on the selection: the result and the selection afterwards are the diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9313c3023..bb115d752 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4,6 +4,175 @@ use super::*; use vectorcraft_geom::Point; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; +pub(super) mod adjust; +mod appearance; +mod areafit; +mod assets; +mod attributes; +mod bboxrotate; +mod blendfidelity; +mod blendopts; +mod blendspine; +mod brushlib; +mod brushsym; +mod build; +mod cancel; +mod charstroke; +pub(super) mod clip; +mod clipboard; +mod clipflavours; +mod clippaint; +mod cmdsplit; +mod cmykflatten; +mod colorguide; +mod colorguidelib; +pub(super) mod colormgmt; +mod colorthemes; +mod colourfonts; +mod compoundpaint; +mod constrain_new; +mod constrain_tools; +mod containers; +mod css; +mod cut; +mod dashalign; +mod distort; +mod docsetup; +mod draw2; +mod editcolors; +mod effectedit; +mod emptytype; +mod envelope; +mod envelope_distort; +mod envelope_edit; +pub(super) mod expand; +mod eyedropper; +mod file; +mod fileinfo; +mod flatpresets; +mod flatpreview; +mod flatten; +mod focal; +mod fontfiles; +mod fontlist; +mod freeform; +mod globaledit; +mod gradient; +mod gradpanel; +mod gradspan; +mod halftone; +mod history_mem; +mod ids_without_selection; +mod inline; +mod interactivity; +mod intertwine; +mod journal; +mod keyguide; +mod knockout; +mod labspots; +pub(super) mod large_edits; +mod layerclip; +mod layers; +mod legacy_ai; +mod library; +mod link_paths; +mod linked_stops; +mod linkfiles; +pub(super) mod links; +pub(super) mod linkspanel; +mod linkwatch; +mod liquify; +mod live; +mod livecorners; +mod maskview; +pub(super) mod menucmds; +mod nativefile; +mod newart; +mod newdoc; +mod objects_on_path; +mod objexpand; +mod opacitymask; +mod outlineobject; +pub(super) mod outlinestroke; +mod overprint; +mod package; +mod paintproxy; +mod panelcmds; +mod paragraphs; +mod params; +mod pathops; +mod pathtype; +mod pattern; +mod pdffidelity; +mod pdfmarks; +mod pdfpresets; +mod pdfraster; +mod persp_planes; +pub(super) mod persp_select; +mod persp_text; +mod perspgrid; +mod place; +mod placed_document; +mod plugins; +mod prefs; +mod previewbounds; +mod print; +mod printadvanced; +mod printpresets; +mod printpreview; +mod printps; +mod printtiling; +mod proxyitems; +mod puppetwarp; +mod rasterfilters; +mod rastersettings; +mod recolor; +mod recovery; +mod reflecthit; +mod registration; +mod save; +mod saveoptions; +mod scalestrokes; +mod scripts; +mod select_ids_first; +mod shaper; +mod shortcutset; +mod simplify_part; +mod slices; +mod smartguides; +mod store; +mod strokegeom; +mod strokegradient; +mod strokereach; +mod strokeux; +mod stylelib; +mod stylepanel; +mod styles; +mod swatchcmds; +mod swatches; +mod swatchlib; +mod symbolbounds; +mod symbollib; +mod targeting; +mod textcombos; +mod textedit; +pub(super) mod textimport; +mod tileedge; +mod tints; +mod toolsettings; +mod transformfx; +mod transparencygrid; +mod trap; +mod typearea; +mod typescale; +mod units; +mod variables; +mod webexport; +mod widthpoints; +mod widthprofiles; +mod widthtool; +mod xform; + fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests/adjust.rs similarity index 99% rename from crates/engine/src/tests_adjust.rs rename to crates/engine/src/tests/adjust.rs index fc444ba95..be4dd6a2d 100644 --- a/crates/engine/src/tests_adjust.rs +++ b/crates/engine/src/tests/adjust.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_doc::{Node, NodeKind}; -use super::*; +use crate::*; pub(crate) fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests/appearance.rs similarity index 99% rename from crates/engine/src/tests_appearance.rs rename to crates/engine/src/tests/appearance.rs index d2045f7e1..14d871b58 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, Node}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -425,7 +425,7 @@ fn appearance_commands_that_change_nothing_record_no_undo_step() { /// objects and the undo history stay as they were. Numbers, true, false and null still work. #[test] fn appearance_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let a = rect(&mut s, 50.0); let b = rect(&mut s, 200.0); diff --git a/crates/engine/src/tests_areafit.rs b/crates/engine/src/tests/areafit.rs similarity index 99% rename from crates/engine/src/tests_areafit.rs rename to crates/engine/src/tests/areafit.rs index dd4dadb37..57182baf3 100644 --- a/crates/engine/src/tests_areafit.rs +++ b/crates/engine/src/tests/areafit.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{AreaFit, NodeKind, TextKind, TextObject}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests/assets.rs similarity index 99% rename from crates/engine/src/tests_assets.rs rename to crates/engine/src/tests/assets.rs index 17fd41b65..58a4c3564 100644 --- a/crates/engine/src/tests_assets.rs +++ b/crates/engine/src/tests/assets.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests/attributes.rs similarity index 99% rename from crates/engine/src/tests_attributes.rs rename to crates/engine/src/tests/attributes.rs index b354e9fdb..4dea45940 100644 --- a/crates/engine/src/tests_attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{ImageMap, NodeKind}; use vectorcraft_geom::{Affine, FillRule}; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs similarity index 99% rename from crates/engine/src/tests_bboxrotate.rs rename to crates/engine/src/tests/bboxrotate.rs index 902ed629a..17c6aac3c 100644 --- a/crates/engine/src/tests_bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -8,7 +8,7 @@ use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_blendfidelity.rs b/crates/engine/src/tests/blendfidelity.rs similarity index 99% rename from crates/engine/src/tests_blendfidelity.rs rename to crates/engine/src/tests/blendfidelity.rs index a3c06c95a..e73dc9242 100644 --- a/crates/engine/src/tests_blendfidelity.rs +++ b/crates/engine/src/tests/blendfidelity.rs @@ -7,7 +7,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Knockout, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests/blendopts.rs similarity index 99% rename from crates/engine/src/tests_blendopts.rs rename to crates/engine/src/tests/blendopts.rs index 4682ba1ea..f2bc3f279 100644 --- a/crates/engine/src/tests_blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests/blendspine.rs similarity index 99% rename from crates/engine/src/tests_blendspine.rs rename to crates/engine/src/tests/blendspine.rs index 3a9009a18..78e9335ac 100644 --- a/crates/engine/src/tests_blendspine.rs +++ b/crates/engine/src/tests/blendspine.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::live::BlendSpec; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_brushlib.rs b/crates/engine/src/tests/brushlib.rs similarity index 99% rename from crates/engine/src/tests_brushlib.rs rename to crates/engine/src/tests/brushlib.rs index 1146a4083..f5ac96d50 100644 --- a/crates/engine/src/tests_brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_brush::BrushKind; use vectorcraft_doc::{NodeId, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests/brushsym.rs similarity index 99% rename from crates/engine/src/tests_brushsym.rs rename to crates/engine/src/tests/brushsym.rs index 43df06f14..c6a33ffc6 100644 --- a/crates/engine/src/tests_brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -5,7 +5,7 @@ use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -1074,7 +1074,7 @@ fn symbol_stainer_refuses_a_keep_model_given_as_text() { /// document and the undo history stay as they were. Numbers, true, false and null still work. #[test] fn symbol_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let (_, name, b) = make_symbol(&mut s); let at = json!([[b.center().x, b.center().y]]); diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests/build.rs similarity index 99% rename from crates/engine/src/tests_build.rs rename to crates/engine/src/tests/build.rs index fb16cd5e9..a3f841b4a 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests/build.rs @@ -9,7 +9,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use vectorcraft_testkit::pdf::{PdfPage, pdf}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -1734,7 +1734,7 @@ fn live_paint_stroke_edge() { /// and the undo history stay as they were. True, false and null still work. #[test] fn shape_builder_refuses_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); two(&mut s); let merge = |extra: Value| { @@ -1761,7 +1761,7 @@ fn shape_builder_refuses_a_number_or_boolean_given_as_text() { /// key; the document and the undo history stay as they were. Numbers and null still work. #[test] fn live_paint_and_image_trace_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let g = live(&mut s); let calls = [ diff --git a/crates/engine/src/tests_cancel.rs b/crates/engine/src/tests/cancel.rs similarity index 100% rename from crates/engine/src/tests_cancel.rs rename to crates/engine/src/tests/cancel.rs diff --git a/crates/engine/src/tests_charstroke.rs b/crates/engine/src/tests/charstroke.rs similarity index 99% rename from crates/engine/src/tests_charstroke.rs rename to crates/engine/src/tests/charstroke.rs index 5ccdedce4..ba948cb66 100644 --- a/crates/engine/src/tests_charstroke.rs +++ b/crates/engine/src/tests/charstroke.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, StrokeAlign, TextObject}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_clip.rs b/crates/engine/src/tests/clip.rs similarity index 99% rename from crates/engine/src/tests_clip.rs rename to crates/engine/src/tests/clip.rs index 2cc16daee..cbba2f36c 100644 --- a/crates/engine/src/tests_clip.rs +++ b/crates/engine/src/tests/clip.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_render::Rendered; -use super::*; +use crate::*; pub(crate) fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests/clipboard.rs similarity index 99% rename from crates/engine/src/tests_clipboard.rs rename to crates/engine/src/tests/clipboard.rs index b32b1c44f..410a828b9 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_clipflavours.rs b/crates/engine/src/tests/clipflavours.rs similarity index 99% rename from crates/engine/src/tests_clipflavours.rs rename to crates/engine/src/tests/clipflavours.rs index 173bb4353..4751b7f6b 100644 --- a/crates/engine/src/tests_clipflavours.rs +++ b/crates/engine/src/tests/clipflavours.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{NodeKind, TextObject}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; use crate::cmd::clipboard::{AI, BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour}; fn session() -> Session { diff --git a/crates/engine/src/tests_clippaint.rs b/crates/engine/src/tests/clippaint.rs similarity index 99% rename from crates/engine/src/tests_clippaint.rs rename to crates/engine/src/tests/clippaint.rs index 673466d37..efcb1df70 100644 --- a/crates/engine/src/tests_clippaint.rs +++ b/crates/engine/src/tests/clippaint.rs @@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; fn rect(s: &mut Session, x: f64, w: f64, fill: &str) -> NodeId { let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 50, "width": w, "height": 100})).unwrap()["id"].as_u64().unwrap()); diff --git a/crates/engine/src/tests_cmdsplit.rs b/crates/engine/src/tests/cmdsplit.rs similarity index 99% rename from crates/engine/src/tests_cmdsplit.rs rename to crates/engine/src/tests/cmdsplit.rs index 767b82b4c..8c58d76c5 100644 --- a/crates/engine/src/tests_cmdsplit.rs +++ b/crates/engine/src/tests/cmdsplit.rs @@ -3,7 +3,7 @@ use serde_json::json; -use super::*; +use crate::*; use crate::cmd::{leaf_targets, paint_targets, unique_name}; fn session() -> Session { diff --git a/crates/engine/src/tests_cmykflatten.rs b/crates/engine/src/tests/cmykflatten.rs similarity index 99% rename from crates/engine/src/tests_cmykflatten.rs rename to crates/engine/src/tests/cmykflatten.rs index fb2c8d961..c28ea0f06 100644 --- a/crates/engine/src/tests_cmykflatten.rs +++ b/crates/engine/src/tests/cmykflatten.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::{Point, Rect, Shape}; use vectorcraft_render::Renderer; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap() diff --git a/crates/engine/src/tests_colorguide.rs b/crates/engine/src/tests/colorguide.rs similarity index 99% rename from crates/engine/src/tests_colorguide.rs rename to crates/engine/src/tests/colorguide.rs index e5b979717..d96c4f414 100644 --- a/crates/engine/src/tests_colorguide.rs +++ b/crates/engine/src/tests/colorguide.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Color; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_colorguidelib.rs b/crates/engine/src/tests/colorguidelib.rs similarity index 99% rename from crates/engine/src/tests_colorguidelib.rs rename to crates/engine/src/tests/colorguidelib.rs index 41b592b87..986d8c469 100644 --- a/crates/engine/src/tests_colorguidelib.rs +++ b/crates/engine/src/tests/colorguidelib.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; -use super::*; +use crate::*; fn hexes(v: &Value) -> Vec { v.as_array().unwrap().iter().map(|h| h.as_str().unwrap().to_string()).collect() diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests/colormgmt.rs similarity index 99% rename from crates/engine/src/tests_colormgmt.rs rename to crates/engine/src/tests/colormgmt.rs index 0f370de4e..d1df8f5a1 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -11,7 +11,7 @@ use vectorcraft_geom::Affine; use vectorcraft_render::proof::{ProofSetup, ProofTarget}; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; /// Every test here reads or changes the process-wide proof view or colour settings: one at a time. /// Tests that switch the Color Settings away from the defaults live in `tests/color_settings.rs` diff --git a/crates/engine/src/tests_colorthemes.rs b/crates/engine/src/tests/colorthemes.rs similarity index 99% rename from crates/engine/src/tests_colorthemes.rs rename to crates/engine/src/tests/colorthemes.rs index 83551861c..8f531e20b 100644 --- a/crates/engine/src/tests_colorthemes.rs +++ b/crates/engine/src/tests/colorthemes.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Color; use vectorcraft_color::harmony::Harmony; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) diff --git a/crates/engine/src/tests_colourfonts.rs b/crates/engine/src/tests/colourfonts.rs similarity index 99% rename from crates/engine/src/tests_colourfonts.rs rename to crates/engine/src/tests/colourfonts.rs index 1e62fac02..a61f9f38b 100644 --- a/crates/engine/src/tests_colourfonts.rs +++ b/crates/engine/src/tests/colourfonts.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_text::test_fonts::{SVG_CHAR, source_sans_gid, svg_font}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_compoundpaint.rs b/crates/engine/src/tests/compoundpaint.rs similarity index 99% rename from crates/engine/src/tests_compoundpaint.rs rename to crates/engine/src/tests/compoundpaint.rs index 8c81ef2ce..4e22e31d3 100644 --- a/crates/engine/src/tests_compoundpaint.rs +++ b/crates/engine/src/tests/compoundpaint.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap() diff --git a/crates/engine/src/tests_constrain_new.rs b/crates/engine/src/tests/constrain_new.rs similarity index 99% rename from crates/engine/src/tests_constrain_new.rs rename to crates/engine/src/tests/constrain_new.rs index 3a53333c5..4184cb3c9 100644 --- a/crates/engine/src/tests_constrain_new.rs +++ b/crates/engine/src/tests/constrain_new.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{LiveShape, NodeKind, TextKind}; use vectorcraft_geom::{PathData, Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; use PointerKind::{Down, Drag, Up}; diff --git a/crates/engine/src/tests_constrain_tools.rs b/crates/engine/src/tests/constrain_tools.rs similarity index 99% rename from crates/engine/src/tests_constrain_tools.rs rename to crates/engine/src/tests/constrain_tools.rs index 357f588fd..6a8317cfe 100644 --- a/crates/engine/src/tests_constrain_tools.rs +++ b/crates/engine/src/tests/constrain_tools.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{LiveShape, NodeKind}; use vectorcraft_geom::{Point, Vec2}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn new_doc() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_containers.rs b/crates/engine/src/tests/containers.rs similarity index 99% rename from crates/engine/src/tests_containers.rs rename to crates/engine/src/tests/containers.rs index 003c8a0f3..8b218d9df 100644 --- a/crates/engine/src/tests_containers.rs +++ b/crates/engine/src/tests/containers.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_css.rs b/crates/engine/src/tests/css.rs similarity index 99% rename from crates/engine/src/tests_css.rs rename to crates/engine/src/tests/css.rs index 8a39eb059..764990b10 100644 --- a/crates/engine/src/tests_css.rs +++ b/crates/engine/src/tests/css.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_cut.rs b/crates/engine/src/tests/cut.rs similarity index 99% rename from crates/engine/src/tests_cut.rs rename to crates/engine/src/tests/cut.rs index f599203fd..b87070bd8 100644 --- a/crates/engine/src/tests_cut.rs +++ b/crates/engine/src/tests/cut.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_geom::{FillRule, PathData, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_dashalign.rs b/crates/engine/src/tests/dashalign.rs similarity index 98% rename from crates/engine/src/tests_dashalign.rs rename to crates/engine/src/tests/dashalign.rs index 3c6a9018a..079bfe899 100644 --- a/crates/engine/src/tests_dashalign.rs +++ b/crates/engine/src/tests/dashalign.rs @@ -5,8 +5,8 @@ use kurbo::Shape; use serde_json::json; use vectorcraft_doc::Dash; -use super::tests_outlinestroke::{outline, path_of}; -use super::*; +use crate::tests_outlinestroke::{outline, path_of}; +use crate::*; fn session_with_rect() -> (Session, NodeId) { let mut s = Session::new(); diff --git a/crates/engine/src/tests_distort.rs b/crates/engine/src/tests/distort.rs similarity index 99% rename from crates/engine/src/tests_distort.rs rename to crates/engine/src/tests/distort.rs index 7f326fc4b..d033fadee 100644 --- a/crates/engine/src/tests_distort.rs +++ b/crates/engine/src/tests/distort.rs @@ -5,7 +5,7 @@ use vectorcraft_geom::{PathData, Point, Rect, Shape}; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_docsetup.rs b/crates/engine/src/tests/docsetup.rs similarity index 99% rename from crates/engine/src/tests_docsetup.rs rename to crates/engine/src/tests/docsetup.rs index c4a4fa3e3..e485af2ef 100644 --- a/crates/engine/src/tests_docsetup.rs +++ b/crates/engine/src/tests/docsetup.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{CharPosition, DocSetup, ExportText, GridSize, NodeId, NodeKind, ScriptMetrics, TextObject, TextStyleDef, Unit}; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests/draw2.rs similarity index 99% rename from crates/engine/src/tests_draw2.rs rename to crates/engine/src/tests/draw2.rs index b14e29979..7577991af 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -5,7 +5,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Point}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; use crate::tooling::{UiRequest, ViewInfo}; fn session() -> Session { @@ -1518,8 +1518,8 @@ fn path_commands_refuse_a_boolean_given_as_text() { ("path.blob", json!({"points": stroke, "mergeOnlySelection": 1}), "mergeOnlySelection"), ("path.blob", json!({"points": stroke, "merge": "false"}), "merge"), ] - .map(|(cmd, p, key)| super::tests::not_a_switch(cmd, p, key)); - let wrong = super::tests::not_refused(&mut s, &calls); + .map(|(cmd, p, key)| crate::tests::not_a_switch(cmd, p, key)); + let wrong = crate::tests::not_refused(&mut s, &calls); assert!(wrong.is_empty(), "not refused: {wrong:#?}"); s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "x": 160, "y": 90, "independent": true})).unwrap(); s.execute("path.close", &json!({"id": id.0, "independent": null})).unwrap(); diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests/editcolors.rs similarity index 99% rename from crates/engine/src/tests_editcolors.rs rename to crates/engine/src/tests/editcolors.rs index ee4c050a8..680fa4217 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests/editcolors.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Node; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; use crate::tooling::ViewInfo; fn session() -> Session { diff --git a/crates/engine/src/tests_effectedit.rs b/crates/engine/src/tests/effectedit.rs similarity index 99% rename from crates/engine/src/tests_effectedit.rs rename to crates/engine/src/tests/effectedit.rs index 042425895..b64005b6d 100644 --- a/crates/engine/src/tests_effectedit.rs +++ b/crates/engine/src/tests/effectedit.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Node; -use super::*; +use crate::*; /// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`. fn session_with_rect() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_emptytype.rs b/crates/engine/src/tests/emptytype.rs similarity index 99% rename from crates/engine/src/tests_emptytype.rs rename to crates/engine/src/tests/emptytype.rs index 47e8b0310..0ae0184e0 100644 --- a/crates/engine/src/tests_emptytype.rs +++ b/crates/engine/src/tests/emptytype.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind, TextKind}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, SurroundingUnit, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { // Type placed by the Type tools starts empty (Fill New Type Objects With Placeholder Text off). diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests/envelope.rs similarity index 99% rename from crates/engine/src/tests_envelope.rs rename to crates/engine/src/tests/envelope.rs index a6e917d08..0180acb31 100644 --- a/crates/engine/src/tests_envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::live::{self, EnvelopeKind, EnvelopeOptions, PreserveShape}; use vectorcraft_doc::{Knockout, Node, NodeKind, OpacityMask}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs similarity index 99% rename from crates/engine/src/tests_envelope_distort.rs rename to crates/engine/src/tests/envelope_distort.rs index 00b2bf6e9..c6727d866 100644 --- a/crates/engine/src/tests_envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind, PatternDef, Symbol}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_render::effects; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_envelope_edit.rs b/crates/engine/src/tests/envelope_edit.rs similarity index 99% rename from crates/engine/src/tests_envelope_edit.rs rename to crates/engine/src/tests/envelope_edit.rs index 5a1186dc1..7b87bef2c 100644 --- a/crates/engine/src/tests_envelope_edit.rs +++ b/crates/engine/src/tests/envelope_edit.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{EnvelopeKind, Node, NodeKind}; use vectorcraft_geom::{Affine, Point, Vec2}; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_expand.rs b/crates/engine/src/tests/expand.rs similarity index 99% rename from crates/engine/src/tests_expand.rs rename to crates/engine/src/tests/expand.rs index 3d7e0baea..015439cbe 100644 --- a/crates/engine/src/tests_expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -5,8 +5,8 @@ use serde_json::{Value, json}; use vectorcraft_color::BlendMode; use vectorcraft_doc::{Node, NodeKind}; -use super::tests_menucmds::{place_undecodable_png, warns_undecoded}; -use super::*; +use crate::tests_menucmds::{place_undecodable_png, warns_undecoded}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests/eyedropper.rs similarity index 99% rename from crates/engine/src/tests_eyedropper.rs rename to crates/engine/src/tests/eyedropper.rs index 4ef5773ca..5f5e550b1 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests/eyedropper.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, ImageObject, Justify, LineCap, Node, NodeKind}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests/file.rs similarity index 99% rename from crates/engine/src/tests_file.rs rename to crates/engine/src/tests/file.rs index 01837b0c9..78bf7381e 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests/file.rs @@ -1,6 +1,6 @@ use serde_json::json; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_fileinfo.rs b/crates/engine/src/tests/fileinfo.rs similarity index 99% rename from crates/engine/src/tests_fileinfo.rs rename to crates/engine/src/tests/fileinfo.rs index bbe8eca66..c37cbfe90 100644 --- a/crates/engine/src/tests_fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{CopyrightStatus, DocMetadata}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_flatpresets.rs b/crates/engine/src/tests/flatpresets.rs similarity index 99% rename from crates/engine/src/tests_flatpresets.rs rename to crates/engine/src/tests/flatpresets.rs index df5b2af67..2c73887be 100644 --- a/crates/engine/src/tests_flatpresets.rs +++ b/crates/engine/src/tests/flatpresets.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; use crate::cmd::{FlattenOptions, FlattenerPreset}; fn run(s: &mut Session, id: &str, p: Value) -> Value { diff --git a/crates/engine/src/tests_flatpreview.rs b/crates/engine/src/tests/flatpreview.rs similarity index 99% rename from crates/engine/src/tests_flatpreview.rs rename to crates/engine/src/tests/flatpreview.rs index cd7033dde..5ea702ee1 100644 --- a/crates/engine/src/tests_flatpreview.rs +++ b/crates/engine/src/tests/flatpreview.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; use crate::cmd::flatten::Highlight; fn session() -> Session { diff --git a/crates/engine/src/tests_flatten.rs b/crates/engine/src/tests/flatten.rs similarity index 99% rename from crates/engine/src/tests_flatten.rs rename to crates/engine/src/tests/flatten.rs index 69882a32b..e2b9b4f6a 100644 --- a/crates/engine/src/tests_flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -7,9 +7,9 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::{BezPath, Point, Rect, Shape}; use vectorcraft_render::{Rendered, Renderer}; -use super::tests_expand::{RANDOM_CASES, random_transforms}; -use super::tests_menucmds::{place_undecodable_png, warns_undecoded}; -use super::*; +use crate::tests_expand::{RANDOM_CASES, random_transforms}; +use crate::tests_menucmds::{place_undecodable_png, warns_undecoded}; +use crate::*; /// Colours here go through the process-wide colour settings, which the colour management tests /// change: one at a time with them. diff --git a/crates/engine/src/tests_focal.rs b/crates/engine/src/tests/focal.rs similarity index 99% rename from crates/engine/src/tests_focal.rs rename to crates/engine/src/tests/focal.rs index 214fe774b..166600a86 100644 --- a/crates/engine/src/tests_focal.rs +++ b/crates/engine/src/tests/focal.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{GradientGeom, Paint}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; /// A 200 pt square at (100, 100) with a radial fill centred on it, radius 100. fn radial() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_fontfiles.rs b/crates/engine/src/tests/fontfiles.rs similarity index 100% rename from crates/engine/src/tests_fontfiles.rs rename to crates/engine/src/tests/fontfiles.rs diff --git a/crates/engine/src/tests_fontlist.rs b/crates/engine/src/tests/fontlist.rs similarity index 99% rename from crates/engine/src/tests_fontlist.rs rename to crates/engine/src/tests/fontlist.rs index e66bb1297..d0804b7d0 100644 --- a/crates/engine/src/tests_fontlist.rs +++ b/crates/engine/src/tests/fontlist.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_text::{FontDb, system_font_dirs}; -use super::*; +use crate::*; fn names(v: &Value) -> Vec { serde_json::from_value(v.clone()).unwrap() diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests/freeform.rs similarity index 99% rename from crates/engine/src/tests_freeform.rs rename to crates/engine/src/tests/freeform.rs index e64a9a805..df9ed3e4b 100644 --- a/crates/engine/src/tests_freeform.rs +++ b/crates/engine/src/tests/freeform.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Freeform, GradientKind, GradientPaint, Paint}; use vectorcraft_geom::Point; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -243,7 +243,7 @@ fn lines_join_split_and_follow_the_selection() { /// the undo history stay as they were. Numbers, false and null still work. #[test] fn freeform_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let (mut s, id) = freeform_rect(); run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]})); let calls = [ diff --git a/crates/engine/src/tests_globaledit.rs b/crates/engine/src/tests/globaledit.rs similarity index 99% rename from crates/engine/src/tests_globaledit.rs rename to crates/engine/src/tests/globaledit.rs index c928d1712..e6ad7fac9 100644 --- a/crates/engine/src/tests_globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Affine, PathData, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests/gradient.rs similarity index 99% rename from crates/engine/src/tests_gradient.rs rename to crates/engine/src/tests/gradient.rs index c2e1c0e6a..c36addcbc 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPai use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -101,7 +101,7 @@ fn bad_gradients_are_rejected_without_editing() { /// the object and the undo history stay as they were. True, false, numbers and null still work. #[test] fn gradient_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); diff --git a/crates/engine/src/tests_gradpanel.rs b/crates/engine/src/tests/gradpanel.rs similarity index 99% rename from crates/engine/src/tests_gradpanel.rs rename to crates/engine/src/tests/gradpanel.rs index b01592ab2..85fd4cf02 100644 --- a/crates/engine/src/tests_gradpanel.rs +++ b/crates/engine/src/tests/gradpanel.rs @@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_color::{GradientGeom, Paint}; use vectorcraft_geom::Point; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_gradspan.rs b/crates/engine/src/tests/gradspan.rs similarity index 99% rename from crates/engine/src/tests_gradspan.rs rename to crates/engine/src/tests/gradspan.rs index 59f29a361..967420464 100644 --- a/crates/engine/src/tests_gradspan.rs +++ b/crates/engine/src/tests/gradspan.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{GradientPaint, Paint}; use vectorcraft_geom::{Affine, Point}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_halftone.rs b/crates/engine/src/tests/halftone.rs similarity index 99% rename from crates/engine/src/tests_halftone.rs rename to crates/engine/src/tests/halftone.rs index 134626344..718c7c523 100644 --- a/crates/engine/src/tests_halftone.rs +++ b/crates/engine/src/tests/halftone.rs @@ -2,7 +2,7 @@ use serde_json::json; -use super::*; +use crate::*; use crate::tests_adjust::{fill_hex, node, red_rect, session}; use crate::tests_menucmds::{place_undecodable_png, warns_undecoded}; diff --git a/crates/engine/src/tests_history_mem.rs b/crates/engine/src/tests/history_mem.rs similarity index 99% rename from crates/engine/src/tests_history_mem.rs rename to crates/engine/src/tests/history_mem.rs index 7728672cc..af5d49552 100644 --- a/crates/engine/src/tests_history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::shapes; -use super::*; +use crate::*; /// A session with one document holding `n` rectangles, all selected. fn session_with_rects(n: usize) -> Session { diff --git a/crates/engine/src/tests_ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs similarity index 99% rename from crates/engine/src/tests_ids_without_selection.rs rename to crates/engine/src/tests/ids_without_selection.rs index 1660a7630..f8449c104 100644 --- a/crates/engine/src/tests_ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -3,7 +3,7 @@ use serde_json::json; -use super::*; +use crate::*; fn doc_with_rect() -> (Session, u64) { let mut s = Session::new(); diff --git a/crates/engine/src/tests_inline.rs b/crates/engine/src/tests/inline.rs similarity index 99% rename from crates/engine/src/tests_inline.rs rename to crates/engine/src/tests/inline.rs index 6ed8533b7..4b14b74be 100644 --- a/crates/engine/src/tests_inline.rs +++ b/crates/engine/src/tests/inline.rs @@ -5,7 +5,7 @@ use vectorcraft_doc::text::INLINE_CHAR; use vectorcraft_doc::{NodeId, NodeKind, TextObject}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_interactivity.rs b/crates/engine/src/tests/interactivity.rs similarity index 99% rename from crates/engine/src/tests_interactivity.rs rename to crates/engine/src/tests/interactivity.rs index a3fe01286..36a2855df 100644 --- a/crates/engine/src/tests_interactivity.rs +++ b/crates/engine/src/tests/interactivity.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_intertwine.rs b/crates/engine/src/tests/intertwine.rs similarity index 99% rename from crates/engine/src/tests_intertwine.rs rename to crates/engine/src/tests/intertwine.rs index 515f5a9c9..02a279447 100644 --- a/crates/engine/src/tests_intertwine.rs +++ b/crates/engine/src/tests/intertwine.rs @@ -6,8 +6,8 @@ use serde_json::{Value, json}; use vectorcraft_render::Rendered; -use super::tests_clip::{hit, id_of, render, select, session}; -use super::*; +use crate::tests_clip::{hit, id_of, render, select, session}; +use crate::*; /// Ring A (red, centre 35,50) and ring B (blue, centre 65,50), radius 25, 8 pt strokes, no fill: /// B is in front. The rings cross at (50, 30) and (50, 70). diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests/journal.rs similarity index 99% rename from crates/engine/src/tests_journal.rs rename to crates/engine/src/tests/journal.rs index 311700598..92feb107f 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -4,7 +4,7 @@ use serde_json::json; -use super::*; +use crate::*; fn created(s: &Session) -> Option { s.doc().unwrap().doc.metadata.created diff --git a/crates/engine/src/tests_keyguide.rs b/crates/engine/src/tests/keyguide.rs similarity index 99% rename from crates/engine/src/tests_keyguide.rs rename to crates/engine/src/tests/keyguide.rs index 9e45ae0f5..09acdd726 100644 --- a/crates/engine/src/tests_keyguide.rs +++ b/crates/engine/src/tests/keyguide.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_knockout.rs b/crates/engine/src/tests/knockout.rs similarity index 99% rename from crates/engine/src/tests_knockout.rs rename to crates/engine/src/tests/knockout.rs index 972dd9f74..723ef66dd 100644 --- a/crates/engine/src/tests_knockout.rs +++ b/crates/engine/src/tests/knockout.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Knockout; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_labspots.rs b/crates/engine/src/tests/labspots.rs similarity index 99% rename from crates/engine/src/tests_labspots.rs rename to crates/engine/src/tests/labspots.rs index 24b1a5c92..206411888 100644 --- a/crates/engine/src/tests_labspots.rs +++ b/crates/engine/src/tests/labspots.rs @@ -7,8 +7,8 @@ use vectorcraft_color::cms::{self, Model}; use vectorcraft_color::{Color, Paint}; use vectorcraft_render::proof; -use super::tests_colormgmt::GLOBAL; -use super::*; +use crate::tests_colormgmt::GLOBAL; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_large_edits.rs b/crates/engine/src/tests/large_edits.rs similarity index 99% rename from crates/engine/src/tests_large_edits.rs rename to crates/engine/src/tests/large_edits.rs index 0347ebdf3..97405d6f6 100644 --- a/crates/engine/src/tests_large_edits.rs +++ b/crates/engine/src/tests/large_edits.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{Appearance, Document, LAYER_COLORS, LayerColor, Node, Node use vectorcraft_geom::{FillRule, Rect, shapes}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; /// Squares in a row of [`square_at`]. const ROW: usize = 200; diff --git a/crates/engine/src/tests_layerclip.rs b/crates/engine/src/tests/layerclip.rs similarity index 98% rename from crates/engine/src/tests_layerclip.rs rename to crates/engine/src/tests/layerclip.rs index 21c694d3e..b52437a69 100644 --- a/crates/engine/src/tests_layerclip.rs +++ b/crates/engine/src/tests/layerclip.rs @@ -3,8 +3,8 @@ use serde_json::json; -use super::tests_clip::{assert_outputs_agree, hit, id_of, opaque, render, select, session}; -use super::*; +use crate::tests_clip::{assert_outputs_agree, hit, id_of, opaque, render, select, session}; +use crate::*; fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests/layers.rs similarity index 99% rename from crates/engine/src/tests_layers.rs rename to crates/engine/src/tests/layers.rs index d78645e82..3a8e6403c 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests/layers.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{LayerColor, Node, NodeId, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs similarity index 99% rename from crates/engine/src/tests_legacy_ai.rs rename to crates/engine/src/tests/legacy_ai.rs index 381ea16ae..fdbb29a35 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; -use super::tests_menucmds::{place_undecodable_png, place_undecodable_png_sized}; -use super::*; +use crate::tests_menucmds::{place_undecodable_png, place_undecodable_png_sized}; +use crate::*; use crate::tests_links::Folder; /// Three artboards (named A, B and C) with a rectangle on each, on two layers: the first diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests/library.rs similarity index 99% rename from crates/engine/src/tests_library.rs rename to crates/engine/src/tests/library.rs index c82f471d6..f85eb93a3 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests/library.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_link_paths.rs b/crates/engine/src/tests/link_paths.rs similarity index 99% rename from crates/engine/src/tests_link_paths.rs rename to crates/engine/src/tests/link_paths.rs index b851d94ac..750410de3 100644 --- a/crates/engine/src/tests_link_paths.rs +++ b/crates/engine/src/tests/link_paths.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; -use super::*; +use crate::*; use crate::cmd::fileio::touched; use crate::tests_links::{Folder, RED, place, png, session}; diff --git a/crates/engine/src/tests_linked_stops.rs b/crates/engine/src/tests/linked_stops.rs similarity index 99% rename from crates/engine/src/tests_linked_stops.rs rename to crates/engine/src/tests/linked_stops.rs index 2b0cae9e7..2f25cf83c 100644 --- a/crates/engine/src/tests_linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, GradientPaint, GradientStop, Paint}; use vectorcraft_render::proof; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_linkfiles.rs b/crates/engine/src/tests/linkfiles.rs similarity index 100% rename from crates/engine/src/tests_linkfiles.rs rename to crates/engine/src/tests/linkfiles.rs diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests/links.rs similarity index 99% rename from crates/engine/src/tests_links.rs rename to crates/engine/src/tests/links.rs index 95ab45349..2ac7d5972 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests/links.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; /// A fresh folder for one test (removed when dropped). pub(crate) struct Folder(pub(crate) PathBuf); diff --git a/crates/engine/src/tests_linkspanel.rs b/crates/engine/src/tests/linkspanel.rs similarity index 99% rename from crates/engine/src/tests_linkspanel.rs rename to crates/engine/src/tests/linkspanel.rs index e00b53b8f..56c6f904e 100644 --- a/crates/engine/src/tests_linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeKind, PlacementOptions}; -use super::tests_links::{BLUE, Folder, RED, bounds, centre_colour, image, near, open, place, png, save, session, write}; -use super::*; +use crate::tests_links::{BLUE, Folder, RED, bounds, centre_colour, image, near, open, place, png, save, session, write}; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) diff --git a/crates/engine/src/tests_linkwatch.rs b/crates/engine/src/tests/linkwatch.rs similarity index 99% rename from crates/engine/src/tests_linkwatch.rs rename to crates/engine/src/tests/linkwatch.rs index 26b9252de..027aa6898 100644 --- a/crates/engine/src/tests_linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session}; /// Write `bytes` to `path` so its modification time moves even on coarse file systems. diff --git a/crates/engine/src/tests_liquify.rs b/crates/engine/src/tests/liquify.rs similarity index 99% rename from crates/engine/src/tests_liquify.rs rename to crates/engine/src/tests/liquify.rs index d868d61c4..d5935bd6f 100644 --- a/crates/engine/src/tests_liquify.rs +++ b/crates/engine/src/tests/liquify.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::{PathData, Point}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests/live.rs similarity index 99% rename from crates/engine/src/tests_live.rs rename to crates/engine/src/tests/live.rs index 146148dc9..408838566 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests/live.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, EnvelopeKind}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests/livecorners.rs similarity index 99% rename from crates/engine/src/tests_livecorners.rs rename to crates/engine/src/tests/livecorners.rs index a1658be2e..ff6ca5f87 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests/livecorners.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{AnchorRef, LiveCorners, LiveShape, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_geom::shapes::CornerKind; -use super::*; +use crate::*; /// A 100 × 60 pt live rectangle at (10, 10), selected. fn session() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_maskview.rs b/crates/engine/src/tests/maskview.rs similarity index 99% rename from crates/engine/src/tests_maskview.rs rename to crates/engine/src/tests/maskview.rs index 9f9c58fd9..e222a136d 100644 --- a/crates/engine/src/tests_maskview.rs +++ b/crates/engine/src/tests/maskview.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; fn rect(s: &mut Session, x: f64, w: f64, fill: &str) -> NodeId { let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": w, "height": 200})).unwrap()["id"].as_u64().unwrap()); diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests/menucmds.rs similarity index 99% rename from crates/engine/src/tests_menucmds.rs rename to crates/engine/src/tests/menucmds.rs index 8b16e00da..383fe2143 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -5,8 +5,8 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, ColorMode, LiveShape, Node, NodeKind, TextKind}; use vectorcraft_geom::{Rect, shapes}; -use super::tests_large_edits::{layer, run_soon, square, undo_gives_back}; -use super::*; +use crate::tests_large_edits::{layer, run_soon, square, undo_gives_back}; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -224,7 +224,7 @@ fn tiles_under_squares() -> (Session, Vec, Vec) { let (x, y) = ((t % 40) as f64 * 100.0, (t / 40) as f64 * 200.0); Rect::new(x, y, x + 100.0, y + 200.0) }; - let s = super::tests_large_edits::session(51_000, |d, k| { + let s = crate::tests_large_edits::session(51_000, |d, k| { if k < 1000 { Node::path(d.alloc_id(), shapes::rectangle(tile(k)), Appearance::default_art()) } else { square(d, k - 1000) } }); let mut tiles = layer(&s, 0); @@ -3142,7 +3142,7 @@ fn crop_image_trim_checks_the_rgba_size_before_decoding() { // 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA // (4 bytes a pixel) they are just over or just within the 2 GB allowance. let trim = |w: u32, h: u32| { - let png = super::tests_linkspanel::png_header(w, h); + let png = crate::tests_linkspanel::png_header(w, h); let mut s = Session::new(); s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); s.execute("select.all", &json!({})).unwrap(); diff --git a/crates/engine/src/tests_nativefile.rs b/crates/engine/src/tests/nativefile.rs similarity index 99% rename from crates/engine/src/tests_nativefile.rs rename to crates/engine/src/tests/nativefile.rs index de547d6a4..4f67890b1 100644 --- a/crates/engine/src/tests_nativefile.rs +++ b/crates/engine/src/tests/nativefile.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use serde_json::{Value, json}; use vectorcraft_format::{is_compressed, sniff}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_newart.rs b/crates/engine/src/tests/newart.rs similarity index 99% rename from crates/engine/src/tests_newart.rs rename to crates/engine/src/tests/newart.rs index cc1ced4f7..6bc5eb48b 100644 --- a/crates/engine/src/tests_newart.rs +++ b/crates/engine/src/tests/newart.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, Dash, LineCap, Node}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests/newdoc.rs similarity index 99% rename from crates/engine/src/tests_newdoc.rs rename to crates/engine/src/tests/newdoc.rs index a2f840c67..067631eec 100644 --- a/crates/engine/src/tests_newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Background, ColorMode, Unit}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; fn new_doc(p: Value) -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs similarity index 99% rename from crates/engine/src/tests_objects_on_path.rs rename to crates/engine/src/tests/objects_on_path.rs index 4cd740dab..a6015b158 100644 --- a/crates/engine/src/tests_objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Point, Rect}; -use super::tests_clip::{id_of, select}; -use super::*; +use crate::tests_clip::{id_of, select}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_objexpand.rs b/crates/engine/src/tests/objexpand.rs similarity index 99% rename from crates/engine/src/tests_objexpand.rs rename to crates/engine/src/tests/objexpand.rs index 48435adbb..9d0c9d272 100644 --- a/crates/engine/src/tests_objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -8,7 +8,7 @@ use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests/opacitymask.rs similarity index 99% rename from crates/engine/src/tests_opacitymask.rs rename to crates/engine/src/tests/opacitymask.rs index 96a418405..f1cfc0b3d 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests/opacitymask.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; use crate::cmd::maskedit::MASK_EDIT_LAYER; use crate::tests::{history, select_with_a_step_to_redo}; diff --git a/crates/engine/src/tests_outlineobject.rs b/crates/engine/src/tests/outlineobject.rs similarity index 99% rename from crates/engine/src/tests_outlineobject.rs rename to crates/engine/src/tests/outlineobject.rs index c1ff06b53..7a411e4aa 100644 --- a/crates/engine/src/tests_outlineobject.rs +++ b/crates/engine/src/tests/outlineobject.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_doc::{Node, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs similarity index 99% rename from crates/engine/src/tests_outlinestroke.rs rename to crates/engine/src/tests/outlinestroke.rs index 170f31acd..17a6b5a09 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -7,7 +7,7 @@ use vectorcraft_color::BlendMode; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_overprint.rs b/crates/engine/src/tests/overprint.rs similarity index 99% rename from crates/engine/src/tests_overprint.rs rename to crates/engine/src/tests/overprint.rs index 78950a838..cade55db9 100644 --- a/crates/engine/src/tests_overprint.rs +++ b/crates/engine/src/tests/overprint.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests/package.rs similarity index 99% rename from crates/engine/src/tests_package.rs rename to crates/engine/src/tests/package.rs index e1eacfe0a..dcf3c6217 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests/package.rs @@ -6,8 +6,8 @@ use std::io::Read; use serde_json::{Value, json}; -use super::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write}; -use super::*; +use crate::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write}; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests/paintproxy.rs similarity index 99% rename from crates/engine/src/tests_paintproxy.rs rename to crates/engine/src/tests/paintproxy.rs index 8893ff18c..9ddbc288e 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests/paintproxy.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::NodeKind; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests/panelcmds.rs similarity index 99% rename from crates/engine/src/tests_panelcmds.rs rename to crates/engine/src/tests/panelcmds.rs index 389cfc142..725bdd0b9 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_color::{Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -272,7 +272,7 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() { /// option left out. #[test] fn artboard_commands_refuse_a_boolean_or_number_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let r = rect(&mut s); s.execute("artboard.new", &json!({})).unwrap(); diff --git a/crates/engine/src/tests_paragraphs.rs b/crates/engine/src/tests/paragraphs.rs similarity index 99% rename from crates/engine/src/tests_paragraphs.rs rename to crates/engine/src/tests/paragraphs.rs index 3e4f24907..885806cfc 100644 --- a/crates/engine/src/tests_paragraphs.rs +++ b/crates/engine/src/tests/paragraphs.rs @@ -5,7 +5,7 @@ use serde_json::json; use vectorcraft_doc::{Justify, NodeId, NodeKind, TextObject}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_params.rs b/crates/engine/src/tests/params.rs similarity index 100% rename from crates/engine/src/tests_params.rs rename to crates/engine/src/tests/params.rs diff --git a/crates/engine/src/tests_pathops.rs b/crates/engine/src/tests/pathops.rs similarity index 99% rename from crates/engine/src/tests_pathops.rs rename to crates/engine/src/tests/pathops.rs index f85b77a06..bb62d1459 100644 --- a/crates/engine/src/tests_pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_pathtype.rs b/crates/engine/src/tests/pathtype.rs similarity index 99% rename from crates/engine/src/tests_pathtype.rs rename to crates/engine/src/tests/pathtype.rs index f48d63fd7..c80ca3236 100644 --- a/crates/engine/src/tests_pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{NodeKind, PathAlign, TextKind, TextObject}; use vectorcraft_geom::{Point, Rect, Shape}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests/pattern.rs similarity index 99% rename from crates/engine/src/tests_pattern.rs rename to crates/engine/src/tests/pattern.rs index 787c6ceb2..2b72f6602 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::tests_menucmds::place_undecodable_png; -use super::*; +use crate::tests_menucmds::place_undecodable_png; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -827,12 +827,12 @@ fn mirror_axis_drag_previews_once_and_undo_redo_preserve_source() { let axis = mirror_axis(&s); let v = ViewInfo::default(); s.select_tool("selection", v).unwrap(); - let h = super::tests::history(&s); + let h = crate::tests::history(&s); for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 20.0), (PointerKind::Drag, 30.0), (PointerKind::Up, 40.0)] { s.pointer(&PointerEvent::new(kind, axis.center.x + dx, axis.center.y), v).unwrap(); assert!(close(mirror_axis(&s).center, axis.center + vectorcraft_geom::Vec2::new(dx, 0.0))); } - assert_eq!(super::tests::history(&s).0, h.0 + 1); + assert_eq!(crate::tests::history(&s).0, h.0 + 1); let after = node(&s, id); let (NodeKind::Repeat(a), NodeKind::Repeat(b)) = (&before.kind, &after.kind) else { panic!() }; assert_eq!(a.source, b.source, "moving the mirror axis leaves the source artwork in place"); @@ -852,7 +852,7 @@ fn mirror_axis_rotation_snaps_and_escape_restores_document_and_history() { let v = ViewInfo::default(); s.select_tool("selection", v).unwrap(); let before = node(&s, id); - let h = super::tests::history(&s); + let h = crate::tests::history(&s); let axis = mirror_axis(&s); let end = axis.ends[1]; let destination = axis.center + vectorcraft_geom::Vec2::new(70.0, 60.0); @@ -865,15 +865,15 @@ fn mirror_axis_rotation_snaps_and_escape_restores_document_and_history() { assert!(!s.tool_busy()); s.pointer(&PointerEvent::new(PointerKind::Up, destination.x, destination.y), v).unwrap(); assert_eq!(node(&s, id), before); - assert_eq!(super::tests::history(&s), h); + assert_eq!(crate::tests::history(&s), h); // A click is not an edit, and a subsequent real drag still works after cancellation. s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, end.x, end.y), v).unwrap(); - assert_eq!(super::tests::history(&s), h); + assert_eq!(crate::tests::history(&s), h); s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, destination.x, destination.y).with_mods(shift), v).unwrap(); assert!((mirror_axis(&s).angle - 45.0).abs() < 1e-6); - assert_eq!(super::tests::history(&s).0, h.0 + 1); + assert_eq!(crate::tests::history(&s).0, h.0 + 1); } #[test] diff --git a/crates/engine/src/tests_pdffidelity.rs b/crates/engine/src/tests/pdffidelity.rs similarity index 99% rename from crates/engine/src/tests_pdffidelity.rs rename to crates/engine/src/tests/pdffidelity.rs index 35fe314a2..f30e3fe42 100644 --- a/crates/engine/src/tests_pdffidelity.rs +++ b/crates/engine/src/tests/pdffidelity.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{AppearanceItem, Document, Node}; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) diff --git a/crates/engine/src/tests_pdfmarks.rs b/crates/engine/src/tests/pdfmarks.rs similarity index 99% rename from crates/engine/src/tests_pdfmarks.rs rename to crates/engine/src/tests/pdfmarks.rs index a9266b788..382ffd461 100644 --- a/crates/engine/src/tests_pdfmarks.rs +++ b/crates/engine/src/tests/pdfmarks.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_pdf::CropTo; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs similarity index 100% rename from crates/engine/src/tests_pdfpresets.rs rename to crates/engine/src/tests/pdfpresets.rs diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests/pdfraster.rs similarity index 99% rename from crates/engine/src/tests_pdfraster.rs rename to crates/engine/src/tests/pdfraster.rs index 896db4eef..f6e54bd0c 100644 --- a/crates/engine/src/tests_pdfraster.rs +++ b/crates/engine/src/tests/pdfraster.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, ImageBlob, ImageO use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_render::{Rendered, Renderer}; -use super::*; +use crate::*; fn shadow() -> Effect { Effect { id: "stylize.dropShadow".into(), params: json!({"x": 6.0, "y": 6.0, "blur": 3.0, "opacity": 75.0}), visible: true } diff --git a/crates/engine/src/tests_persp_planes.rs b/crates/engine/src/tests/persp_planes.rs similarity index 99% rename from crates/engine/src/tests_persp_planes.rs rename to crates/engine/src/tests/persp_planes.rs index 669fdf81e..b605b35f1 100644 --- a/crates/engine/src/tests_persp_planes.rs +++ b/crates/engine/src/tests/persp_planes.rs @@ -6,8 +6,8 @@ use vectorcraft_geom::{Point, Vec2}; use vectorcraft_tools::Mods; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; -use super::tests_persp_select::{attached, drag, grid, persp_session, plane_pts, undo_len}; -use super::*; +use crate::tests_persp_select::{attached, drag, grid, persp_session, plane_pts, undo_len}; +use crate::*; fn path(s: &Session, id: NodeId) -> vectorcraft_geom::PathData { s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() diff --git a/crates/engine/src/tests_persp_select.rs b/crates/engine/src/tests/persp_select.rs similarity index 99% rename from crates/engine/src/tests_persp_select.rs rename to crates/engine/src/tests/persp_select.rs index 3c0c3de5b..b76964791 100644 --- a/crates/engine/src/tests_persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -6,7 +6,7 @@ use vectorcraft_geom::{Point, Rect, Vec2}; use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; /// An 800 × 600 document with a two-point grid and a rectangle attached to the right plane. pub(crate) fn persp_session() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests/persp_text.rs similarity index 99% rename from crates/engine/src/tests_persp_text.rs rename to crates/engine/src/tests/persp_text.rs index 846306fc2..91d7b70eb 100644 --- a/crates/engine/src/tests_persp_text.rs +++ b/crates/engine/src/tests/persp_text.rs @@ -8,8 +8,8 @@ use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::Mods; use vectorcraft_tools::distort::perspective::{self as persp, Plane}; -use super::tests_persp_select::{attached, drag, grid, persp_session, undo_len}; -use super::*; +use crate::tests_persp_select::{attached, drag, grid, persp_session, undo_len}; +use crate::*; /// Point type "HELLO" (48 pt) at (430, 400), attached to the right plane. fn persp_text(s: &mut Session) -> NodeId { diff --git a/crates/engine/src/tests_perspgrid.rs b/crates/engine/src/tests/perspgrid.rs similarity index 99% rename from crates/engine/src/tests_perspgrid.rs rename to crates/engine/src/tests/perspgrid.rs index 69c9c9556..b2cb7cba9 100644 --- a/crates/engine/src/tests_perspgrid.rs +++ b/crates/engine/src/tests/perspgrid.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Rgb}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests/place.rs similarity index 99% rename from crates/engine/src/tests_place.rs rename to crates/engine/src/tests/place.rs index bb7ac1999..1d3558eaa 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests/place.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -250,7 +250,7 @@ fn a_file_read_apart_from_the_session_places_as_file_place_does() { p.to_string_lossy().into_owned() }; let (pdf_path, native_path) = (saved("page.pdf", &pdf), saved("art.vectorcraft", &native)); - let words = super::tests_textimport::docx(r#"Placed words"#, super::tests_textimport::STYLES); + let words = crate::tests_textimport::docx(r#"Placed words"#, crate::tests_textimport::STYLES); let cases = [ with(file("red.png", &png(30, 20, [255, 0, 0, 255], Some(144.0))), json!({"at": [150, 100]})), with(file("notes.txt", b"Two lines\nof text"), json!({"text": {}})), diff --git a/crates/engine/src/tests_placed_document.rs b/crates/engine/src/tests/placed_document.rs similarity index 99% rename from crates/engine/src/tests_placed_document.rs rename to crates/engine/src/tests/placed_document.rs index dd9e47462..eb3164db8 100644 --- a/crates/engine/src/tests_placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -8,8 +8,8 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PlacedDocument}; use vectorcraft_geom::{Rect, shapes}; -use super::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write}; -use super::*; +use crate::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write}; +use crate::*; /// A VectorCraft document at `path`: one `w`×`h` artboard filled with `rgb` (and `extra` more /// rectangles, which change the file's size). @@ -75,7 +75,7 @@ fn decode(v: &Value) -> Vec { /// Images and paths in `bytes`, a PDF, read back. fn pdf_contents(bytes: &[u8]) -> (usize, usize) { // The page as drawn (not the VectorCraft document the PDF carries). - let (doc, _) = super::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap(); + let (doc, _) = crate::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap(); let (mut images, mut paths) = (0, 0); doc.walk(|n| match n.kind { NodeKind::Image(_) => images += 1, diff --git a/crates/engine/src/tests_plugins.rs b/crates/engine/src/tests/plugins.rs similarity index 99% rename from crates/engine/src/tests_plugins.rs rename to crates/engine/src/tests/plugins.rs index 685e5c3f4..e2bc451d9 100644 --- a/crates/engine/src/tests_plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -7,10 +7,10 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{PathData, Point, SubPath}; use vectorcraft_plugins::wat as tpl; -use super::*; +use crate::*; /// The example Desaturate plug-in (built from `crates/plugins/example`). -const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); +const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; fn run(s: &mut Session, cmd: &str, p: Value) -> Value { diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests/prefs.rs similarity index 99% rename from crates/engine/src/tests_prefs.rs rename to crates/engine/src/tests/prefs.rs index 9d2355ad2..e01df7695 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; #[test] diff --git a/crates/engine/src/tests_previewbounds.rs b/crates/engine/src/tests/previewbounds.rs similarity index 99% rename from crates/engine/src/tests_previewbounds.rs rename to crates/engine/src/tests/previewbounds.rs index 95440f2ae..998f7fd14 100644 --- a/crates/engine/src/tests_previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind, Symb use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests/print.rs similarity index 99% rename from crates/engine/src/tests_print.rs rename to crates/engine/src/tests/print.rs index 4ba587d45..f685e76af 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests/print.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// A 300 × 200 RGB document with a rectangle; and the rectangle's id. fn session() -> (Session, u64) { diff --git a/crates/engine/src/tests_printadvanced.rs b/crates/engine/src/tests/printadvanced.rs similarity index 95% rename from crates/engine/src/tests_printadvanced.rs rename to crates/engine/src/tests/printadvanced.rs index e716d627b..99cfcd373 100644 --- a/crates/engine/src/tests_printadvanced.rs +++ b/crates/engine/src/tests/printadvanced.rs @@ -7,7 +7,7 @@ use vectorcraft_geom::Rect; use vectorcraft_pdf::PrintSettings; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; /// A 200 × 100 CMYK document: a cyan square under a yellow one whose fill overprints, and a /// half-transparent magenta one; their ids. @@ -84,11 +84,11 @@ fn a_flattener_preset_flattens_transparency_before_printing() { any }; assert!(transparent(doc(&s))); - let none = super::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved, false).unwrap(); + let none = crate::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved, false).unwrap(); assert!(none.is_none(), "transparency prints live without a preset"); for name in ["High Resolution", "low"] { let set = settings(json!({"advanced": {"flattenerPreset": name}})); - let flat = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); + let flat = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); assert!(!transparent(&flat), "{name}: flattened"); } // A saved preset by name; an unknown one is refused by preview and print alike. @@ -105,7 +105,7 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() { s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 100})).unwrap(); let saved = s.prefs.flattener_presets.clone(); let set = settings(json!({"advanced": {"printAsBitmap": true}})); - let bitmap = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); + let bitmap = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); let mut kinds = vec![]; bitmap.walk(|n| kinds.push(matches!(n.kind, NodeKind::Image(_)))); assert_eq!(kinds.iter().filter(|k| **k).count(), 1, "one image: the empty artboard gets none"); @@ -127,7 +127,7 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() { let pv = s.execute("print.preview", &seps).unwrap(); assert!(pv["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("Print as Bitmap"))); let set = settings(seps["settings"].clone()); - assert!(super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none()); + assert!(crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none()); } /// Appearance of Black › Printing / Exporting (#394): Print as Bitmap draws pure CMYK black as RGB @@ -152,8 +152,8 @@ fn print_as_bitmap_honours_rich_black() { let img = image::load_from_memory(bytes).unwrap().to_rgba8(); img.get_pixel(img.width() / 2, img.height() / 2).0 }; - let accurate = centre(&super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap()); + let accurate = centre(&crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap()); assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}"); - let rich = centre(&super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, true).unwrap().unwrap()); + let rich = centre(&crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, true).unwrap().unwrap()); assert_eq!(rich, [0, 0, 0, 255], "rich: RGB black"); } diff --git a/crates/engine/src/tests_printpresets.rs b/crates/engine/src/tests/printpresets.rs similarity index 99% rename from crates/engine/src/tests_printpresets.rs rename to crates/engine/src/tests/printpresets.rs index fafa25cb5..6323cda40 100644 --- a/crates/engine/src/tests_printpresets.rs +++ b/crates/engine/src/tests/printpresets.rs @@ -3,7 +3,7 @@ use serde_json::json; -use super::*; +use crate::*; use crate::cmd::printpresets::DEFAULT_PRESET; fn is_bad_params(r: Result) -> bool { diff --git a/crates/engine/src/tests_printpreview.rs b/crates/engine/src/tests/printpreview.rs similarity index 99% rename from crates/engine/src/tests_printpreview.rs rename to crates/engine/src/tests/printpreview.rs index 88ebdb627..d6a4482b3 100644 --- a/crates/engine/src/tests_printpreview.rs +++ b/crates/engine/src/tests/printpreview.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// `print.preview {settings}`'s first page. fn first_sheet(s: &mut Session, settings: Value) -> Value { diff --git a/crates/engine/src/tests_printps.rs b/crates/engine/src/tests/printps.rs similarity index 99% rename from crates/engine/src/tests_printps.rs rename to crates/engine/src/tests/printps.rs index 57cfbc901..ad83ade90 100644 --- a/crates/engine/src/tests_printps.rs +++ b/crates/engine/src/tests/printps.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// A document of `artboards` 300 × 200 artboards with a rectangle on the first. fn session(artboards: usize) -> Session { diff --git a/crates/engine/src/tests_printtiling.rs b/crates/engine/src/tests/printtiling.rs similarity index 99% rename from crates/engine/src/tests_printtiling.rs rename to crates/engine/src/tests/printtiling.rs index 3df7c1298..6b15284c0 100644 --- a/crates/engine/src/tests_printtiling.rs +++ b/crates/engine/src/tests/printtiling.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; use crate::tooling::ViewInfo; /// A 300 × 200 document with a rectangle. diff --git a/crates/engine/src/tests_proxyitems.rs b/crates/engine/src/tests/proxyitems.rs similarity index 99% rename from crates/engine/src/tests_proxyitems.rs rename to crates/engine/src/tests/proxyitems.rs index 4ce73602f..22c4d0bca 100644 --- a/crates/engine/src/tests_proxyitems.rs +++ b/crates/engine/src/tests/proxyitems.rs @@ -7,7 +7,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::PathData; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_puppetwarp.rs b/crates/engine/src/tests/puppetwarp.rs similarity index 99% rename from crates/engine/src/tests_puppetwarp.rs rename to crates/engine/src/tests/puppetwarp.rs index 6730b5464..54223dda7 100644 --- a/crates/engine/src/tests_puppetwarp.rs +++ b/crates/engine/src/tests/puppetwarp.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests/rasterfilters.rs similarity index 99% rename from crates/engine/src/tests_rasterfilters.rs rename to crates/engine/src/tests/rasterfilters.rs index 45ead1054..236580d5b 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests/rasterfilters.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::NodeKind; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; -use super::*; +use crate::*; /// The new effects with parameters that change a striped square visibly. const EFFECTS: [(&str, &str); 3] = [ diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests/rastersettings.rs similarity index 99% rename from crates/engine/src/tests_rastersettings.rs rename to crates/engine/src/tests/rastersettings.rs index 7b0d61b32..b3f1cf50b 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; -use super::*; +use crate::*; const C: &str = "document.rasterEffectsSettings"; diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests/recolor.rs similarity index 99% rename from crates/engine/src/tests_recolor.rs rename to crates/engine/src/tests/recolor.rs index af53f15ec..86af2b73b 100644 --- a/crates/engine/src/tests_recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Color; -use super::*; +use crate::*; use crate::cmd::color_value; fn session() -> Session { diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests/recovery.rs similarity index 99% rename from crates/engine/src/tests_recovery.rs rename to crates/engine/src/tests/recovery.rs index 2826e250c..d4354620c 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use serde_json::json; -use super::*; +use crate::*; use crate::cmd::recovery::{FolderStore, MemoryStore, RecoveryStore}; /// A session keeping its copies in `store`, with one modified document. diff --git a/crates/engine/src/tests_reflecthit.rs b/crates/engine/src/tests/reflecthit.rs similarity index 99% rename from crates/engine/src/tests_reflecthit.rs rename to crates/engine/src/tests/reflecthit.rs index 89d1c3272..5f12a00d4 100644 --- a/crates/engine/src/tests_reflecthit.rs +++ b/crates/engine/src/tests/reflecthit.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_registration.rs b/crates/engine/src/tests/registration.rs similarity index 99% rename from crates/engine/src/tests_registration.rs rename to crates/engine/src/tests/registration.rs index 9b539f473..07a5dfa9b 100644 --- a/crates/engine/src/tests_registration.rs +++ b/crates/engine/src/tests/registration.rs @@ -9,7 +9,7 @@ use vectorcraft_geom::Affine; use vectorcraft_render::proof::ProofSetup; use vectorcraft_render::{RenderOptions, Renderer}; -use super::*; +use crate::*; use crate::tests_colormgmt::GLOBAL; fn session() -> Session { diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests/save.rs similarity index 99% rename from crates/engine/src/tests_save.rs rename to crates/engine/src/tests/save.rs index 12fc74136..4f2baa6d2 100644 --- a/crates/engine/src/tests_save.rs +++ b/crates/engine/src/tests/save.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::SavedView; use vectorcraft_geom::Point; -use super::*; +use crate::*; use crate::cmd::fileio::{SaveMode, save_plan}; fn session() -> Session { diff --git a/crates/engine/src/tests_saveoptions.rs b/crates/engine/src/tests/saveoptions.rs similarity index 99% rename from crates/engine/src/tests_saveoptions.rs rename to crates/engine/src/tests/saveoptions.rs index ff14cf698..b30e3fb27 100644 --- a/crates/engine/src/tests_saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::cms; -use super::*; +use crate::*; use crate::tests_links::{Folder, png}; /// Three artboards (named A, B and C) with a rectangle on each. diff --git a/crates/engine/src/tests_scalestrokes.rs b/crates/engine/src/tests/scalestrokes.rs similarity index 99% rename from crates/engine/src/tests_scalestrokes.rs rename to crates/engine/src/tests/scalestrokes.rs index 361636001..d03a9ebf8 100644 --- a/crates/engine/src/tests_scalestrokes.rs +++ b/crates/engine/src/tests/scalestrokes.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{LiveShape, Node, NodeId, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_scripts.rs b/crates/engine/src/tests/scripts.rs similarity index 99% rename from crates/engine/src/tests_scripts.rs rename to crates/engine/src/tests/scripts.rs index 44341f2bc..5f620b251 100644 --- a/crates/engine/src/tests_scripts.rs +++ b/crates/engine/src/tests/scripts.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; fn check(p: Value) -> Value { Session::new().execute("script.check", &p).unwrap_or_else(|e| panic!("script.check {p}: {e}")) diff --git a/crates/engine/src/tests_select_ids_first.rs b/crates/engine/src/tests/select_ids_first.rs similarity index 99% rename from crates/engine/src/tests_select_ids_first.rs rename to crates/engine/src/tests/select_ids_first.rs index 3df8d37e6..442b1c0f3 100644 --- a/crates/engine/src/tests_select_ids_first.rs +++ b/crates/engine/src/tests/select_ids_first.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// Three rectangles and an ellipse, nothing selected → the session and their ids. fn doc() -> (Session, Vec) { diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests/shaper.rs similarity index 99% rename from crates/engine/src/tests_shaper.rs rename to crates/engine/src/tests/shaper.rs index 16c7c8b96..c0b4ad888 100644 --- a/crates/engine/src/tests_shaper.rs +++ b/crates/engine/src/tests/shaper.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::*; use serde_json::json; use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX}; use vectorcraft_doc::{LiveShape, Node}; diff --git a/crates/engine/src/tests_shortcutset.rs b/crates/engine/src/tests/shortcutset.rs similarity index 99% rename from crates/engine/src/tests_shortcutset.rs rename to crates/engine/src/tests/shortcutset.rs index 7768baa88..bdb298bc1 100644 --- a/crates/engine/src/tests_shortcutset.rs +++ b/crates/engine/src/tests/shortcutset.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; use crate::cmd::importmatches; use crate::cmd::shortcutset::{KysEntry, NOT_READABLE, read_kys}; diff --git a/crates/engine/src/tests_simplify_part.rs b/crates/engine/src/tests/simplify_part.rs similarity index 99% rename from crates/engine/src/tests_simplify_part.rs rename to crates/engine/src/tests/simplify_part.rs index 4f600f68a..12f333059 100644 --- a/crates/engine/src/tests_simplify_part.rs +++ b/crates/engine/src/tests/simplify_part.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::NodeKind; use vectorcraft_geom::kurbo::ParamCurveNearest; use vectorcraft_geom::{ParamCurve, PathData, SubPath}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests/slices.rs similarity index 99% rename from crates/engine/src/tests_slices.rs rename to crates/engine/src/tests/slices.rs index 227eb011a..f14e878d3 100644 --- a/crates/engine/src/tests_slices.rs +++ b/crates/engine/src/tests/slices.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::SliceKind; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_smartguides.rs b/crates/engine/src/tests/smartguides.rs similarity index 99% rename from crates/engine/src/tests_smartguides.rs rename to crates/engine/src/tests/smartguides.rs index b06b33be6..ee8579626 100644 --- a/crates/engine/src/tests_smartguides.rs +++ b/crates/engine/src/tests/smartguides.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::NodeId; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; -use super::*; +use crate::*; use crate::tooling::ViewInfo; /// An 800 × 600 document with a 100 × 100 square at (100, 100), nothing selected. diff --git a/crates/engine/src/tests_store.rs b/crates/engine/src/tests/store.rs similarity index 100% rename from crates/engine/src/tests_store.rs rename to crates/engine/src/tests/store.rs diff --git a/crates/engine/src/tests_strokegeom.rs b/crates/engine/src/tests/strokegeom.rs similarity index 99% rename from crates/engine/src/tests_strokegeom.rs rename to crates/engine/src/tests/strokegeom.rs index ee43644c4..f7500af35 100644 --- a/crates/engine/src/tests_strokegeom.rs +++ b/crates/engine/src/tests/strokegeom.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_doc::{ArrowAlign, StrokeLayer, WidthProfile}; -use super::*; +use crate::*; fn session_with_line() -> (Session, NodeId) { let mut s = Session::new(); diff --git a/crates/engine/src/tests_strokegradient.rs b/crates/engine/src/tests/strokegradient.rs similarity index 99% rename from crates/engine/src/tests_strokegradient.rs rename to crates/engine/src/tests/strokegradient.rs index c9c0a4e1d..e147c387f 100644 --- a/crates/engine/src/tests_strokegradient.rs +++ b/crates/engine/src/tests/strokegradient.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Document, NodeKind, StrokeGradientMode}; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_strokereach.rs b/crates/engine/src/tests/strokereach.rs similarity index 99% rename from crates/engine/src/tests_strokereach.rs rename to crates/engine/src/tests/strokereach.rs index 9db74845c..1154073a3 100644 --- a/crates/engine/src/tests_strokereach.rs +++ b/crates/engine/src/tests/strokereach.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_strokeux.rs b/crates/engine/src/tests/strokeux.rs similarity index 99% rename from crates/engine/src/tests_strokeux.rs rename to crates/engine/src/tests/strokeux.rs index 8effa2844..82d3aae69 100644 --- a/crates/engine/src/tests_strokeux.rs +++ b/crates/engine/src/tests/strokeux.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, Unit}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_stylelib.rs b/crates/engine/src/tests/stylelib.rs similarity index 99% rename from crates/engine/src/tests_stylelib.rs rename to crates/engine/src/tests/stylelib.rs index 072668a68..2c3a4c6fb 100644 --- a/crates/engine/src/tests_stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -8,7 +8,7 @@ use vectorcraft_color::{Paint, Swatch}; use vectorcraft_doc::{GraphicStyle, Node, NodeId, PatternDef}; use vectorcraft_geom::{Point, Rect, shapes}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_stylepanel.rs b/crates/engine/src/tests/stylepanel.rs similarity index 99% rename from crates/engine/src/tests_stylepanel.rs rename to crates/engine/src/tests/stylepanel.rs index 81a165d1e..d414dcbb6 100644 --- a/crates/engine/src/tests_stylepanel.rs +++ b/crates/engine/src/tests/stylepanel.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{GraphicStyle, NodeId, NodeKind}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests/styles.rs similarity index 99% rename from crates/engine/src/tests_styles.rs rename to crates/engine/src/tests/styles.rs index 2a8a379c8..679186332 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests/styles.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{GraphicStyle, Knockout, Node}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs similarity index 99% rename from crates/engine/src/tests_swatchcmds.rs rename to crates/engine/src/tests/swatchcmds.rs index d62543173..b0d0edf86 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -315,7 +315,7 @@ fn the_swatch_commands_refuse_a_keep_model_given_as_text() { /// undo history stay as they were. True, false, numbers and null still work. #[test] fn the_swatch_commands_refuse_a_boolean_or_number_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})); run(&mut s, "paint.setFill", json!({"color": "#3366cc"})); diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests/swatches.rs similarity index 99% rename from crates/engine/src/tests_swatches.rs rename to crates/engine/src/tests/swatches.rs index 27b949aa1..031b5026d 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests/swatches.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests/swatchlib.rs similarity index 99% rename from crates/engine/src/tests_swatchlib.rs rename to crates/engine/src/tests/swatchlib.rs index 7b17fa586..90425c9dd 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_symbolbounds.rs b/crates/engine/src/tests/symbolbounds.rs similarity index 99% rename from crates/engine/src/tests_symbolbounds.rs rename to crates/engine/src/tests/symbolbounds.rs index 12dfb03a3..5b1c3f2c5 100644 --- a/crates/engine/src/tests_symbolbounds.rs +++ b/crates/engine/src/tests/symbolbounds.rs @@ -11,7 +11,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind, Symbol}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; -use super::*; +use crate::*; /// The symbol of the SVG import tests: a circle of radius 4 around (5, 5) with a 2 pt stroke, and /// an instance of it at x 80. diff --git a/crates/engine/src/tests_symbollib.rs b/crates/engine/src/tests/symbollib.rs similarity index 99% rename from crates/engine/src/tests_symbollib.rs rename to crates/engine/src/tests/symbollib.rs index eed7229ef..226fe830a 100644 --- a/crates/engine/src/tests_symbollib.rs +++ b/crates/engine/src/tests/symbollib.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, SymbolRegistration}; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_targeting.rs b/crates/engine/src/tests/targeting.rs similarity index 99% rename from crates/engine/src/tests_targeting.rs rename to crates/engine/src/tests/targeting.rs index 82abd73ee..74a9ca795 100644 --- a/crates/engine/src/tests_targeting.rs +++ b/crates/engine/src/tests/targeting.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::Node; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_textcombos.rs b/crates/engine/src/tests/textcombos.rs similarity index 99% rename from crates/engine/src/tests_textcombos.rs rename to crates/engine/src/tests/textcombos.rs index 5b3c89b47..c0f3ad5ff 100644 --- a/crates/engine/src/tests_textcombos.rs +++ b/crates/engine/src/tests/textcombos.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::text::INLINE_CHAR; use vectorcraft_doc::{AreaFit, Composer, Justify, NodeKind, TextKind, TextObject, VerticalAlign}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests/textedit.rs similarity index 99% rename from crates/engine/src/tests_textedit.rs rename to crates/engine/src/tests/textedit.rs index 0b8843a31..26efb53f9 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::*; +use crate::*; fn session() -> Session { // Type placed by the Type tools starts empty (Fill New Type Objects With Placeholder Text off). @@ -117,7 +117,7 @@ fn set_range_style_defaults_to_all_text_and_get_range() { /// false, numbers and null still work. #[test] fn type_commands_refuse_a_number_or_boolean_given_as_text() { - use super::tests::{not_a_number, not_a_switch, not_refused}; + use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let id = text(&mut s, "abc"); let frame = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 100, "height": 50})).unwrap()["id"].clone(); diff --git a/crates/engine/src/tests_textimport.rs b/crates/engine/src/tests/textimport.rs similarity index 99% rename from crates/engine/src/tests_textimport.rs rename to crates/engine/src/tests/textimport.rs index 90d95df15..d87909bba 100644 --- a/crates/engine/src/tests_textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeKind, TextKind}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_tileedge.rs b/crates/engine/src/tests/tileedge.rs similarity index 99% rename from crates/engine/src/tests_tileedge.rs rename to crates/engine/src/tests/tileedge.rs index c68650522..eab7018cc 100644 --- a/crates/engine/src/tests_tileedge.rs +++ b/crates/engine/src/tests/tileedge.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::pattern::PatternDef; -use super::*; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) diff --git a/crates/engine/src/tests_tints.rs b/crates/engine/src/tests/tints.rs similarity index 99% rename from crates/engine/src/tests_tints.rs rename to crates/engine/src/tests/tints.rs index caf99043f..25d4cb0d4 100644 --- a/crates/engine/src/tests_tints.rs +++ b/crates/engine/src/tests/tints.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_render::proof; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_toolsettings.rs b/crates/engine/src/tests/toolsettings.rs similarity index 99% rename from crates/engine/src/tests_toolsettings.rs rename to crates/engine/src/tests/toolsettings.rs index 49b930af4..7584aa9cb 100644 --- a/crates/engine/src/tests_toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -2,7 +2,7 @@ use serde_json::json; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_transformfx.rs b/crates/engine/src/tests/transformfx.rs similarity index 99% rename from crates/engine/src/tests_transformfx.rs rename to crates/engine/src/tests/transformfx.rs index 7fdda3928..29ecbffcd 100644 --- a/crates/engine/src/tests_transformfx.rs +++ b/crates/engine/src/tests/transformfx.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Node; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_transparencygrid.rs b/crates/engine/src/tests/transparencygrid.rs similarity index 98% rename from crates/engine/src/tests_transparencygrid.rs rename to crates/engine/src/tests/transparencygrid.rs index 80e104910..974fb0343 100644 --- a/crates/engine/src/tests_transparencygrid.rs +++ b/crates/engine/src/tests/transparencygrid.rs @@ -2,7 +2,7 @@ use serde_json::json; -use super::*; +use crate::*; #[test] fn transparency_grid_is_per_document_view_state() { diff --git a/crates/engine/src/tests_trap.rs b/crates/engine/src/tests/trap.rs similarity index 99% rename from crates/engine/src/tests_trap.rs rename to crates/engine/src/tests/trap.rs index 87fb92a6f..b2ae09908 100644 --- a/crates/engine/src/tests_trap.rs +++ b/crates/engine/src/tests/trap.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; -use super::*; +use crate::*; const YELLOW: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 1.0, k: 0.0 }; const BLUE: Color = Color::Cmyk { c: 1.0, m: 0.6, y: 0.0, k: 0.0 }; diff --git a/crates/engine/src/tests_typearea.rs b/crates/engine/src/tests/typearea.rs similarity index 99% rename from crates/engine/src/tests_typearea.rs rename to crates/engine/src/tests/typearea.rs index cf17edc67..370a7bc2b 100644 --- a/crates/engine/src/tests_typearea.rs +++ b/crates/engine/src/tests/typearea.rs @@ -9,7 +9,7 @@ use vectorcraft_text::TextLayout; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; diff --git a/crates/engine/src/tests_typescale.rs b/crates/engine/src/tests/typescale.rs similarity index 99% rename from crates/engine/src/tests_typescale.rs rename to crates/engine/src/tests/typescale.rs index c05326861..ad2bd1eaa 100644 --- a/crates/engine/src/tests_typescale.rs +++ b/crates/engine/src/tests/typescale.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, TextObject}; use vectorcraft_geom::Point; -use super::*; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_units.rs b/crates/engine/src/tests/units.rs similarity index 99% rename from crates/engine/src/tests_units.rs rename to crates/engine/src/tests/units.rs index b0df220a4..7cf5be9af 100644 --- a/crates/engine/src/tests_units.rs +++ b/crates/engine/src/tests/units.rs @@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_doc::Unit; use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; -use super::*; +use crate::*; use crate::cmd::prefscmds::{PREF_SPECS, PrefKind, UNITS}; use crate::units::Measure; diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests/variables.rs similarity index 99% rename from crates/engine/src/tests_variables.rs rename to crates/engine/src/tests/variables.rs index cae174ce5..281d57515 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; -use super::tests_links::{BLUE, Folder, RED, bounds, image, place, png, write}; -use super::*; +use crate::tests_links::{BLUE, Folder, RED, bounds, image, place, png, write}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_webexport.rs b/crates/engine/src/tests/webexport.rs similarity index 99% rename from crates/engine/src/tests_webexport.rs rename to crates/engine/src/tests/webexport.rs index bbf4db2e1..761d5d42e 100644 --- a/crates/engine/src/tests_webexport.rs +++ b/crates/engine/src/tests/webexport.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use crate::*; /// The default settings as JSON. fn defaults() -> Value { diff --git a/crates/engine/src/tests_widthpoints.rs b/crates/engine/src/tests/widthpoints.rs similarity index 99% rename from crates/engine/src/tests_widthpoints.rs rename to crates/engine/src/tests/widthpoints.rs index e9f50328b..786f3b539 100644 --- a/crates/engine/src/tests_widthpoints.rs +++ b/crates/engine/src/tests/widthpoints.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; -use super::*; +use crate::*; /// A horizontal line from (20, 100) to (180, 100), 10 pt black stroke. fn line_session() -> (Session, NodeId) { diff --git a/crates/engine/src/tests_widthprofiles.rs b/crates/engine/src/tests/widthprofiles.rs similarity index 99% rename from crates/engine/src/tests_widthprofiles.rs rename to crates/engine/src/tests/widthprofiles.rs index 65af9c6fe..fe041dae4 100644 --- a/crates/engine/src/tests_widthprofiles.rs +++ b/crates/engine/src/tests/widthprofiles.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::WidthProfile; -use super::*; +use crate::*; fn session_with_line() -> (Session, NodeId) { let mut s = Session::new(); diff --git a/crates/engine/src/tests_widthtool.rs b/crates/engine/src/tests/widthtool.rs similarity index 99% rename from crates/engine/src/tests_widthtool.rs rename to crates/engine/src/tests/widthtool.rs index 53be26b35..fda761de6 100644 --- a/crates/engine/src/tests_widthtool.rs +++ b/crates/engine/src/tests/widthtool.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_tools::{PointerEvent, PointerKind}; -use super::*; +use crate::*; /// Two 200 pt lines made into a compound path with a 10 pt stroke → (compound, members). fn compound() -> (Session, NodeId, Vec) { diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests/xform.rs similarity index 99% rename from crates/engine/src/tests_xform.rs rename to crates/engine/src/tests/xform.rs index e97d261bd..eb503cce9 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -7,9 +7,9 @@ use vectorcraft_geom::{Point, Rect, Vec2, shapes}; use vectorcraft_tools::distort::perspective::{self as persp, Plane}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use super::tests_large_edits::{soon, undo_gives_back}; -use super::tests_persp_select::{bounds_of, grid, persp_session, plane_pts}; -use super::*; +use crate::tests_large_edits::{soon, undo_gives_back}; +use crate::tests_persp_select::{bounds_of, grid, persp_session, plane_pts}; +use crate::*; fn session() -> Session { let mut s = Session::new(); @@ -415,7 +415,7 @@ fn alt_drag_in_perspective_of_30000_objects_finishes_within_20_s() { Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 6.0, y + 6.0)), Appearance::default_art()) }; let in_perspective = || { - let mut s = super::tests_large_edits::session(30_000, on_the_left); + let mut s = crate::tests_large_edits::session(30_000, on_the_left); s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); s.execute("perspective.attach", &json!({"plane": "left"})).unwrap(); s From bb611d20812b9c27c7bc6e1a270b6da530771d7c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:57 +0000 Subject: [PATCH 094/199] refactor(mcp): move tests_*.rs to tests/.rs --- crates/mcp/src/lib.rs | 34 ------------------- crates/mcp/src/tests.rs | 18 ++++++++++ .../batchdraw.rs} | 0 .../concurrency.rs} | 0 .../conventions.rs} | 0 .../distortkeys.rs} | 0 .../{tests_drawopts.rs => tests/drawopts.rs} | 0 .../{tests_exportas.rs => tests/exportas.rs} | 0 .../src/{tests_fileio.rs => tests/fileio.rs} | 0 .../{tests_freeform.rs => tests/freeform.rs} | 0 .../{tests_gradient.rs => tests/gradient.rs} | 0 .../src/{tests_limits.rs => tests/limits.rs} | 0 .../src/{tests_links.rs => tests/links.rs} | 0 .../{tests_liquify.rs => tests/liquify.rs} | 0 crates/mcp/src/{tests_pen.rs => tests/pen.rs} | 0 .../src/{tests_persp.rs => tests/persp.rs} | 0 .../src/{tests_place.rs => tests/place.rs} | 0 .../{tests_protocol.rs => tests/protocol.rs} | 0 crates/mcp/src/{tests_svg.rs => tests/svg.rs} | 0 19 files changed, 18 insertions(+), 34 deletions(-) rename crates/mcp/src/{tests_batchdraw.rs => tests/batchdraw.rs} (100%) rename crates/mcp/src/{tests_concurrency.rs => tests/concurrency.rs} (100%) rename crates/mcp/src/{tests_conventions.rs => tests/conventions.rs} (100%) rename crates/mcp/src/{tests_distortkeys.rs => tests/distortkeys.rs} (100%) rename crates/mcp/src/{tests_drawopts.rs => tests/drawopts.rs} (100%) rename crates/mcp/src/{tests_exportas.rs => tests/exportas.rs} (100%) rename crates/mcp/src/{tests_fileio.rs => tests/fileio.rs} (100%) rename crates/mcp/src/{tests_freeform.rs => tests/freeform.rs} (100%) rename crates/mcp/src/{tests_gradient.rs => tests/gradient.rs} (100%) rename crates/mcp/src/{tests_limits.rs => tests/limits.rs} (100%) rename crates/mcp/src/{tests_links.rs => tests/links.rs} (100%) rename crates/mcp/src/{tests_liquify.rs => tests/liquify.rs} (100%) rename crates/mcp/src/{tests_pen.rs => tests/pen.rs} (100%) rename crates/mcp/src/{tests_persp.rs => tests/persp.rs} (100%) rename crates/mcp/src/{tests_place.rs => tests/place.rs} (100%) rename crates/mcp/src/{tests_protocol.rs => tests/protocol.rs} (100%) rename crates/mcp/src/{tests_svg.rs => tests/svg.rs} (100%) diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 1c7b7020c..785746a38 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -35,37 +35,3 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979"; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_batchdraw; -#[cfg(test)] -mod tests_concurrency; -#[cfg(test)] -mod tests_conventions; -#[cfg(test)] -mod tests_distortkeys; -#[cfg(test)] -mod tests_drawopts; -#[cfg(test)] -mod tests_exportas; -#[cfg(test)] -mod tests_fileio; -#[cfg(test)] -mod tests_freeform; -#[cfg(test)] -mod tests_gradient; -#[cfg(test)] -mod tests_limits; -#[cfg(test)] -mod tests_links; -#[cfg(test)] -mod tests_liquify; -#[cfg(test)] -mod tests_pen; -#[cfg(test)] -mod tests_persp; -#[cfg(test)] -mod tests_place; -#[cfg(test)] -mod tests_protocol; -#[cfg(test)] -mod tests_svg; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index ca72a5cce..1b8991a93 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -4,6 +4,24 @@ use serde_json::{Value, json}; use crate::{Backend, Headless, PROTOCOL_VERSION, Remote, Server, tool_definitions}; +mod batchdraw; +mod concurrency; +mod conventions; +mod distortkeys; +mod drawopts; +mod exportas; +mod fileio; +mod freeform; +mod gradient; +mod limits; +mod links; +mod liquify; +mod pen; +mod persp; +mod place; +mod protocol; +mod svg; + fn server() -> Server { Server::new(Box::new(Headless::with_document())) } diff --git a/crates/mcp/src/tests_batchdraw.rs b/crates/mcp/src/tests/batchdraw.rs similarity index 100% rename from crates/mcp/src/tests_batchdraw.rs rename to crates/mcp/src/tests/batchdraw.rs diff --git a/crates/mcp/src/tests_concurrency.rs b/crates/mcp/src/tests/concurrency.rs similarity index 100% rename from crates/mcp/src/tests_concurrency.rs rename to crates/mcp/src/tests/concurrency.rs diff --git a/crates/mcp/src/tests_conventions.rs b/crates/mcp/src/tests/conventions.rs similarity index 100% rename from crates/mcp/src/tests_conventions.rs rename to crates/mcp/src/tests/conventions.rs diff --git a/crates/mcp/src/tests_distortkeys.rs b/crates/mcp/src/tests/distortkeys.rs similarity index 100% rename from crates/mcp/src/tests_distortkeys.rs rename to crates/mcp/src/tests/distortkeys.rs diff --git a/crates/mcp/src/tests_drawopts.rs b/crates/mcp/src/tests/drawopts.rs similarity index 100% rename from crates/mcp/src/tests_drawopts.rs rename to crates/mcp/src/tests/drawopts.rs diff --git a/crates/mcp/src/tests_exportas.rs b/crates/mcp/src/tests/exportas.rs similarity index 100% rename from crates/mcp/src/tests_exportas.rs rename to crates/mcp/src/tests/exportas.rs diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests/fileio.rs similarity index 100% rename from crates/mcp/src/tests_fileio.rs rename to crates/mcp/src/tests/fileio.rs diff --git a/crates/mcp/src/tests_freeform.rs b/crates/mcp/src/tests/freeform.rs similarity index 100% rename from crates/mcp/src/tests_freeform.rs rename to crates/mcp/src/tests/freeform.rs diff --git a/crates/mcp/src/tests_gradient.rs b/crates/mcp/src/tests/gradient.rs similarity index 100% rename from crates/mcp/src/tests_gradient.rs rename to crates/mcp/src/tests/gradient.rs diff --git a/crates/mcp/src/tests_limits.rs b/crates/mcp/src/tests/limits.rs similarity index 100% rename from crates/mcp/src/tests_limits.rs rename to crates/mcp/src/tests/limits.rs diff --git a/crates/mcp/src/tests_links.rs b/crates/mcp/src/tests/links.rs similarity index 100% rename from crates/mcp/src/tests_links.rs rename to crates/mcp/src/tests/links.rs diff --git a/crates/mcp/src/tests_liquify.rs b/crates/mcp/src/tests/liquify.rs similarity index 100% rename from crates/mcp/src/tests_liquify.rs rename to crates/mcp/src/tests/liquify.rs diff --git a/crates/mcp/src/tests_pen.rs b/crates/mcp/src/tests/pen.rs similarity index 100% rename from crates/mcp/src/tests_pen.rs rename to crates/mcp/src/tests/pen.rs diff --git a/crates/mcp/src/tests_persp.rs b/crates/mcp/src/tests/persp.rs similarity index 100% rename from crates/mcp/src/tests_persp.rs rename to crates/mcp/src/tests/persp.rs diff --git a/crates/mcp/src/tests_place.rs b/crates/mcp/src/tests/place.rs similarity index 100% rename from crates/mcp/src/tests_place.rs rename to crates/mcp/src/tests/place.rs diff --git a/crates/mcp/src/tests_protocol.rs b/crates/mcp/src/tests/protocol.rs similarity index 100% rename from crates/mcp/src/tests_protocol.rs rename to crates/mcp/src/tests/protocol.rs diff --git a/crates/mcp/src/tests_svg.rs b/crates/mcp/src/tests/svg.rs similarity index 100% rename from crates/mcp/src/tests_svg.rs rename to crates/mcp/src/tests/svg.rs From 7da84565d878a3360bb1c409ec42e7b7e57cf080 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:57 +0000 Subject: [PATCH 095/199] refactor(metafile): move tests_*.rs to tests/.rs --- crates/metafile/src/lib.rs | 3 --- crates/metafile/src/tests.rs | 2 ++ crates/metafile/src/{tests_import.rs => tests/import.rs} | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) rename crates/metafile/src/{tests_import.rs => tests/import.rs} (99%) diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index f51c2c8e7..5326e2f30 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -109,6 +109,3 @@ pub fn import(bytes: &[u8]) -> Result { #[cfg(test)] mod tests; - -#[cfg(test)] -mod tests_import; diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 20612545a..104d9596a 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -11,6 +11,8 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; use crate::*; +mod import; + /// Add `n` (with a fresh id) on top of `d`'s first layer. pub(crate) fn add(d: &mut Document, mut n: Node) { n.id = d.alloc_id(); diff --git a/crates/metafile/src/tests_import.rs b/crates/metafile/src/tests/import.rs similarity index 99% rename from crates/metafile/src/tests_import.rs rename to crates/metafile/src/tests/import.rs index 83d2b8db2..c442a4f69 100644 --- a/crates/metafile/src/tests_import.rs +++ b/crates/metafile/src/tests/import.rs @@ -7,7 +7,6 @@ use vectorcraft_geom::{Affine, Rect, shapes}; use crate::bytes::Out; use crate::tests::*; -use crate::*; /// Every object of the imported document, depth first. fn all(d: &Document) -> Vec { From 8ded395d493f176e17371fef155bb418a8f0d7ad Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:57 +0000 Subject: [PATCH 096/199] refactor(pdf): move tests_*.rs to tests/.rs --- crates/pdf/src/lib.rs | 74 +------------------ crates/pdf/src/tests.rs | 36 +++++++++ .../src/{tests_blend.rs => tests/blend.rs} | 0 .../charstroke.rs} | 0 .../cmykblend.rs} | 0 .../cmykimages.rs} | 0 .../compression.rs} | 0 .../dashalign.rs} | 0 .../{tests_editing.rs => tests/editing.rs} | 0 .../{tests_encrypt.rs => tests/encrypt.rs} | 0 .../file_info.rs} | 0 .../src/{tests_focal.rs => tests/focal.rs} | 0 crates/pdf/src/{tests_fx.rs => tests/fx.rs} | 0 .../import_color.rs} | 0 .../import_fidelity.rs} | 0 .../import_layers.rs} | 0 .../import_lines.rs} | 0 .../import_options.rs} | 0 .../import_overprint.rs} | 0 .../src/{tests_layers.rs => tests/layers.rs} | 0 .../live_text.rs} | 4 +- .../src/{tests_marks.rs => tests/marks.rs} | 0 .../src/{tests_output.rs => tests/output.rs} | 0 .../src/{tests_pdf13.rs => tests/pdf13.rs} | 0 .../pdf/src/{tests_pdfx.rs => tests/pdfx.rs} | 0 .../{tests_presets.rs => tests/presets.rs} | 0 .../src/{tests_print.rs => tests/print.rs} | 0 .../printadvanced.rs} | 0 .../reader_compat.rs} | 2 +- .../reader_streaming.rs} | 0 .../{tests_realtext.rs => tests/realtext.rs} | 0 .../{tests_settings.rs => tests/settings.rs} | 0 .../src/{tests_stroke.rs => tests/stroke.rs} | 0 .../strokeout.rs} | 0 .../text_placement.rs} | 2 +- .../textstroke.rs} | 0 .../{tests_webview.rs => tests/webview.rs} | 0 37 files changed, 42 insertions(+), 76 deletions(-) rename crates/pdf/src/{tests_blend.rs => tests/blend.rs} (100%) rename crates/pdf/src/{tests_charstroke.rs => tests/charstroke.rs} (100%) rename crates/pdf/src/{tests_cmykblend.rs => tests/cmykblend.rs} (100%) rename crates/pdf/src/{tests_cmykimages.rs => tests/cmykimages.rs} (100%) rename crates/pdf/src/{tests_compression.rs => tests/compression.rs} (100%) rename crates/pdf/src/{tests_dashalign.rs => tests/dashalign.rs} (100%) rename crates/pdf/src/{tests_editing.rs => tests/editing.rs} (100%) rename crates/pdf/src/{tests_encrypt.rs => tests/encrypt.rs} (100%) rename crates/pdf/src/{tests_file_info.rs => tests/file_info.rs} (100%) rename crates/pdf/src/{tests_focal.rs => tests/focal.rs} (100%) rename crates/pdf/src/{tests_fx.rs => tests/fx.rs} (100%) rename crates/pdf/src/{tests_import_color.rs => tests/import_color.rs} (100%) rename crates/pdf/src/{tests_import_fidelity.rs => tests/import_fidelity.rs} (100%) rename crates/pdf/src/{tests_import_layers.rs => tests/import_layers.rs} (100%) rename crates/pdf/src/{tests_import_lines.rs => tests/import_lines.rs} (100%) rename crates/pdf/src/{tests_import_options.rs => tests/import_options.rs} (100%) rename crates/pdf/src/{tests_import_overprint.rs => tests/import_overprint.rs} (100%) rename crates/pdf/src/{tests_layers.rs => tests/layers.rs} (100%) rename crates/pdf/src/{tests_live_text.rs => tests/live_text.rs} (99%) rename crates/pdf/src/{tests_marks.rs => tests/marks.rs} (100%) rename crates/pdf/src/{tests_output.rs => tests/output.rs} (100%) rename crates/pdf/src/{tests_pdf13.rs => tests/pdf13.rs} (100%) rename crates/pdf/src/{tests_pdfx.rs => tests/pdfx.rs} (100%) rename crates/pdf/src/{tests_presets.rs => tests/presets.rs} (100%) rename crates/pdf/src/{tests_print.rs => tests/print.rs} (100%) rename crates/pdf/src/{tests_printadvanced.rs => tests/printadvanced.rs} (100%) rename crates/pdf/src/{tests_reader_compat.rs => tests/reader_compat.rs} (99%) rename crates/pdf/src/{tests_reader_streaming.rs => tests/reader_streaming.rs} (100%) rename crates/pdf/src/{tests_realtext.rs => tests/realtext.rs} (100%) rename crates/pdf/src/{tests_settings.rs => tests/settings.rs} (100%) rename crates/pdf/src/{tests_stroke.rs => tests/stroke.rs} (100%) rename crates/pdf/src/{tests_strokeout.rs => tests/strokeout.rs} (100%) rename crates/pdf/src/{tests_text_placement.rs => tests/text_placement.rs} (99%) rename crates/pdf/src/{tests_textstroke.rs => tests/textstroke.rs} (100%) rename crates/pdf/src/{tests_webview.rs => tests/webview.rs} (100%) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index bfb04ab82..95a0260c6 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -244,77 +244,7 @@ impl From for PdfError { } } +#[cfg(test)] +use tests::{import_fidelity as tests_import_fidelity, live_text as tests_live_text, stroke as tests_stroke}; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_blend; -#[cfg(test)] -mod tests_charstroke; -#[cfg(test)] -mod tests_cmykblend; -#[cfg(test)] -mod tests_cmykimages; -#[cfg(test)] -mod tests_compression; -#[cfg(test)] -mod tests_dashalign; -#[cfg(test)] -mod tests_editing; -#[cfg(test)] -mod tests_encrypt; -#[cfg(test)] -mod tests_file_info; -#[cfg(test)] -mod tests_focal; -#[cfg(test)] -mod tests_fx; -#[cfg(test)] -mod tests_import_color; -#[cfg(test)] -mod tests_import_fidelity; -#[cfg(test)] -mod tests_import_layers; -#[cfg(test)] -mod tests_import_lines; -#[cfg(test)] -mod tests_import_options; -#[cfg(test)] -mod tests_import_overprint; -#[cfg(test)] -mod tests_layers; -#[cfg(test)] -mod tests_live_text; -#[cfg(test)] -mod tests_marks; -#[cfg(test)] -mod tests_output; -#[cfg(test)] -mod tests_pdf13; -#[cfg(test)] -mod tests_pdfx; -#[cfg(test)] -mod tests_presets; -#[cfg(test)] -mod tests_print; -#[cfg(test)] -mod tests_printadvanced; -#[cfg(test)] -mod tests_realtext; -#[cfg(test)] -mod tests_settings; -#[cfg(test)] -mod tests_stroke; -#[cfg(test)] -mod tests_strokeout; -#[cfg(test)] -mod tests_text_placement; -#[cfg(test)] -mod tests_textstroke; -#[cfg(test)] -mod tests_webview; - -#[cfg(test)] -mod tests_reader_streaming; - -#[cfg(test)] -mod tests_reader_compat; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 6ebc0ceac..e76589fcf 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -8,6 +8,42 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes}; use crate::*; +mod blend; +mod charstroke; +mod cmykblend; +mod cmykimages; +mod compression; +mod dashalign; +mod editing; +mod encrypt; +mod file_info; +mod focal; +mod fx; +mod import_color; +pub(super) mod import_fidelity; +mod import_layers; +mod import_lines; +mod import_options; +mod import_overprint; +mod layers; +pub(super) mod live_text; +mod marks; +mod output; +mod pdf13; +mod pdfx; +mod presets; +mod print; +mod printadvanced; +mod realtext; +mod settings; +pub(super) mod stroke; +mod strokeout; +mod text_placement; +mod textstroke; +mod webview; +mod reader_streaming; +mod reader_compat; + fn doc(w: f64, h: f64) -> Document { Document::new(w, h) } diff --git a/crates/pdf/src/tests_blend.rs b/crates/pdf/src/tests/blend.rs similarity index 100% rename from crates/pdf/src/tests_blend.rs rename to crates/pdf/src/tests/blend.rs diff --git a/crates/pdf/src/tests_charstroke.rs b/crates/pdf/src/tests/charstroke.rs similarity index 100% rename from crates/pdf/src/tests_charstroke.rs rename to crates/pdf/src/tests/charstroke.rs diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests/cmykblend.rs similarity index 100% rename from crates/pdf/src/tests_cmykblend.rs rename to crates/pdf/src/tests/cmykblend.rs diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs similarity index 100% rename from crates/pdf/src/tests_cmykimages.rs rename to crates/pdf/src/tests/cmykimages.rs diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests/compression.rs similarity index 100% rename from crates/pdf/src/tests_compression.rs rename to crates/pdf/src/tests/compression.rs diff --git a/crates/pdf/src/tests_dashalign.rs b/crates/pdf/src/tests/dashalign.rs similarity index 100% rename from crates/pdf/src/tests_dashalign.rs rename to crates/pdf/src/tests/dashalign.rs diff --git a/crates/pdf/src/tests_editing.rs b/crates/pdf/src/tests/editing.rs similarity index 100% rename from crates/pdf/src/tests_editing.rs rename to crates/pdf/src/tests/editing.rs diff --git a/crates/pdf/src/tests_encrypt.rs b/crates/pdf/src/tests/encrypt.rs similarity index 100% rename from crates/pdf/src/tests_encrypt.rs rename to crates/pdf/src/tests/encrypt.rs diff --git a/crates/pdf/src/tests_file_info.rs b/crates/pdf/src/tests/file_info.rs similarity index 100% rename from crates/pdf/src/tests_file_info.rs rename to crates/pdf/src/tests/file_info.rs diff --git a/crates/pdf/src/tests_focal.rs b/crates/pdf/src/tests/focal.rs similarity index 100% rename from crates/pdf/src/tests_focal.rs rename to crates/pdf/src/tests/focal.rs diff --git a/crates/pdf/src/tests_fx.rs b/crates/pdf/src/tests/fx.rs similarity index 100% rename from crates/pdf/src/tests_fx.rs rename to crates/pdf/src/tests/fx.rs diff --git a/crates/pdf/src/tests_import_color.rs b/crates/pdf/src/tests/import_color.rs similarity index 100% rename from crates/pdf/src/tests_import_color.rs rename to crates/pdf/src/tests/import_color.rs diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests/import_fidelity.rs similarity index 100% rename from crates/pdf/src/tests_import_fidelity.rs rename to crates/pdf/src/tests/import_fidelity.rs diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests/import_layers.rs similarity index 100% rename from crates/pdf/src/tests_import_layers.rs rename to crates/pdf/src/tests/import_layers.rs diff --git a/crates/pdf/src/tests_import_lines.rs b/crates/pdf/src/tests/import_lines.rs similarity index 100% rename from crates/pdf/src/tests_import_lines.rs rename to crates/pdf/src/tests/import_lines.rs diff --git a/crates/pdf/src/tests_import_options.rs b/crates/pdf/src/tests/import_options.rs similarity index 100% rename from crates/pdf/src/tests_import_options.rs rename to crates/pdf/src/tests/import_options.rs diff --git a/crates/pdf/src/tests_import_overprint.rs b/crates/pdf/src/tests/import_overprint.rs similarity index 100% rename from crates/pdf/src/tests_import_overprint.rs rename to crates/pdf/src/tests/import_overprint.rs diff --git a/crates/pdf/src/tests_layers.rs b/crates/pdf/src/tests/layers.rs similarity index 100% rename from crates/pdf/src/tests_layers.rs rename to crates/pdf/src/tests/layers.rs diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests/live_text.rs similarity index 99% rename from crates/pdf/src/tests_live_text.rs rename to crates/pdf/src/tests/live_text.rs index 19b198253..4cb4417bc 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests/live_text.rs @@ -9,8 +9,8 @@ use vectorcraft_doc::{Document, NodeKind, TextObject}; use crate::*; -const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); -const SOURCE_SERIF: &[u8] = include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"); +const SOURCE_SANS: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"); +const SOURCE_SERIF: &[u8] = include_bytes!("../../../../assets/fonts/SourceSerif4-Regular.ttf"); /// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3, and the /// same rotated 90° when `rotated`. diff --git a/crates/pdf/src/tests_marks.rs b/crates/pdf/src/tests/marks.rs similarity index 100% rename from crates/pdf/src/tests_marks.rs rename to crates/pdf/src/tests/marks.rs diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests/output.rs similarity index 100% rename from crates/pdf/src/tests_output.rs rename to crates/pdf/src/tests/output.rs diff --git a/crates/pdf/src/tests_pdf13.rs b/crates/pdf/src/tests/pdf13.rs similarity index 100% rename from crates/pdf/src/tests_pdf13.rs rename to crates/pdf/src/tests/pdf13.rs diff --git a/crates/pdf/src/tests_pdfx.rs b/crates/pdf/src/tests/pdfx.rs similarity index 100% rename from crates/pdf/src/tests_pdfx.rs rename to crates/pdf/src/tests/pdfx.rs diff --git a/crates/pdf/src/tests_presets.rs b/crates/pdf/src/tests/presets.rs similarity index 100% rename from crates/pdf/src/tests_presets.rs rename to crates/pdf/src/tests/presets.rs diff --git a/crates/pdf/src/tests_print.rs b/crates/pdf/src/tests/print.rs similarity index 100% rename from crates/pdf/src/tests_print.rs rename to crates/pdf/src/tests/print.rs diff --git a/crates/pdf/src/tests_printadvanced.rs b/crates/pdf/src/tests/printadvanced.rs similarity index 100% rename from crates/pdf/src/tests_printadvanced.rs rename to crates/pdf/src/tests/printadvanced.rs diff --git a/crates/pdf/src/tests_reader_compat.rs b/crates/pdf/src/tests/reader_compat.rs similarity index 99% rename from crates/pdf/src/tests_reader_compat.rs rename to crates/pdf/src/tests/reader_compat.rs index 83933393e..d292a0319 100644 --- a/crates/pdf/src/tests_reader_compat.rs +++ b/crates/pdf/src/tests/reader_compat.rs @@ -5,7 +5,7 @@ use vectorcraft_doc::{Document, NodeKind}; use crate::{ImportOptions, TextAs, import_with_report}; -const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); +const INTER: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); fn stream(dictionary: &str, content: &[u8]) -> Vec { let mut bytes = format!("<< {dictionary} /Length {} >>\nstream\n", content.len()).into_bytes(); diff --git a/crates/pdf/src/tests_reader_streaming.rs b/crates/pdf/src/tests/reader_streaming.rs similarity index 100% rename from crates/pdf/src/tests_reader_streaming.rs rename to crates/pdf/src/tests/reader_streaming.rs diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests/realtext.rs similarity index 100% rename from crates/pdf/src/tests_realtext.rs rename to crates/pdf/src/tests/realtext.rs diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests/settings.rs similarity index 100% rename from crates/pdf/src/tests_settings.rs rename to crates/pdf/src/tests/settings.rs diff --git a/crates/pdf/src/tests_stroke.rs b/crates/pdf/src/tests/stroke.rs similarity index 100% rename from crates/pdf/src/tests_stroke.rs rename to crates/pdf/src/tests/stroke.rs diff --git a/crates/pdf/src/tests_strokeout.rs b/crates/pdf/src/tests/strokeout.rs similarity index 100% rename from crates/pdf/src/tests_strokeout.rs rename to crates/pdf/src/tests/strokeout.rs diff --git a/crates/pdf/src/tests_text_placement.rs b/crates/pdf/src/tests/text_placement.rs similarity index 99% rename from crates/pdf/src/tests_text_placement.rs rename to crates/pdf/src/tests/text_placement.rs index fc0057a90..45bf5e409 100644 --- a/crates/pdf/src/tests_text_placement.rs +++ b/crates/pdf/src/tests/text_placement.rs @@ -14,7 +14,7 @@ use vectorcraft_text::{FontDb, FontFace}; use crate::tests_import_fidelity::stream; use crate::*; -const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); +const SOURCE_SANS: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"); fn sans() -> Arc { FontDb::global().find_postscript("SourceSans3-Regular").expect("Source Sans 3 is built in") diff --git a/crates/pdf/src/tests_textstroke.rs b/crates/pdf/src/tests/textstroke.rs similarity index 100% rename from crates/pdf/src/tests_textstroke.rs rename to crates/pdf/src/tests/textstroke.rs diff --git a/crates/pdf/src/tests_webview.rs b/crates/pdf/src/tests/webview.rs similarity index 100% rename from crates/pdf/src/tests_webview.rs rename to crates/pdf/src/tests/webview.rs From c21beabf9728a5095ff64f8ec71ccbf8d8693afe Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:58 +0000 Subject: [PATCH 097/199] refactor(render): move tests_*.rs to tests/.rs --- crates/render/src/lib.rs | 52 +------------------ crates/render/src/tests.rs | 26 ++++++++++ .../src/{tests_adjust.rs => tests/adjust.rs} | 2 +- .../alternate.rs} | 2 +- .../bitmapglyphs.rs} | 2 +- .../src/{tests_blend.rs => tests/blend.rs} | 2 +- .../{tests_changed.rs => tests/changed.rs} | 2 +- .../charstroke.rs} | 2 +- .../src/{tests_clip.rs => tests/clip.rs} | 2 +- .../cmykblend.rs} | 2 +- .../colrglyphs.rs} | 2 +- .../container.rs} | 2 +- .../src/{tests_dither.rs => tests/dither.rs} | 2 +- .../fontchange.rs} | 4 +- .../{tests_freeform.rs => tests/freeform.rs} | 2 +- .../src/{tests_fxzoom.rs => tests/fxzoom.rs} | 2 +- .../isolation.rs} | 2 +- .../{tests_knockout.rs => tests/knockout.rs} | 2 +- .../layeropts.rs} | 2 +- .../src/{tests_memory.rs => tests/memory.rs} | 2 +- .../{tests_objectfx.rs => tests/objectfx.rs} | 2 +- .../placed_document.rs} | 2 +- .../rasterize.rs} | 2 +- .../src/{tests_setup.rs => tests/setup.rs} | 2 +- .../strokegradient.rs} | 2 +- .../{tests_tileedge.rs => tests/tileedge.rs} | 2 +- .../viewraster.rs} | 0 27 files changed, 53 insertions(+), 75 deletions(-) rename crates/render/src/{tests_adjust.rs => tests/adjust.rs} (99%) rename crates/render/src/{tests_alternate.rs => tests/alternate.rs} (99%) rename crates/render/src/{tests_bitmapglyphs.rs => tests/bitmapglyphs.rs} (99%) rename crates/render/src/{tests_blend.rs => tests/blend.rs} (99%) rename crates/render/src/{tests_changed.rs => tests/changed.rs} (99%) rename crates/render/src/{tests_charstroke.rs => tests/charstroke.rs} (99%) rename crates/render/src/{tests_clip.rs => tests/clip.rs} (99%) rename crates/render/src/{tests_cmykblend.rs => tests/cmykblend.rs} (99%) rename crates/render/src/{tests_colrglyphs.rs => tests/colrglyphs.rs} (99%) rename crates/render/src/{tests_container.rs => tests/container.rs} (99%) rename crates/render/src/{tests_dither.rs => tests/dither.rs} (99%) rename crates/render/src/{tests_fontchange.rs => tests/fontchange.rs} (99%) rename crates/render/src/{tests_freeform.rs => tests/freeform.rs} (99%) rename crates/render/src/{tests_fxzoom.rs => tests/fxzoom.rs} (99%) rename crates/render/src/{tests_isolation.rs => tests/isolation.rs} (99%) rename crates/render/src/{tests_knockout.rs => tests/knockout.rs} (99%) rename crates/render/src/{tests_layeropts.rs => tests/layeropts.rs} (99%) rename crates/render/src/{tests_memory.rs => tests/memory.rs} (99%) rename crates/render/src/{tests_objectfx.rs => tests/objectfx.rs} (99%) rename crates/render/src/{tests_placed_document.rs => tests/placed_document.rs} (99%) rename crates/render/src/{tests_rasterize.rs => tests/rasterize.rs} (99%) rename crates/render/src/{tests_setup.rs => tests/setup.rs} (99%) rename crates/render/src/{tests_strokegradient.rs => tests/strokegradient.rs} (99%) rename crates/render/src/{tests_tileedge.rs => tests/tileedge.rs} (99%) rename crates/render/src/{tests_viewraster.rs => tests/viewraster.rs} (100%) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83b0f74c4..01c753867 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2847,55 +2847,7 @@ thread_local! { static OFFSCREEN_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +#[cfg(test)] +use tests::fontchange as tests_fontchange; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_adjust; -#[cfg(test)] -mod tests_alternate; -#[cfg(test)] -mod tests_bitmapglyphs; -#[cfg(test)] -mod tests_blend; -#[cfg(test)] -mod tests_changed; -#[cfg(test)] -mod tests_charstroke; -#[cfg(test)] -mod tests_clip; -#[cfg(test)] -mod tests_cmykblend; -#[cfg(test)] -mod tests_colrglyphs; -#[cfg(test)] -mod tests_container; -#[cfg(test)] -mod tests_dither; -#[cfg(test)] -mod tests_fontchange; -#[cfg(test)] -mod tests_freeform; -#[cfg(test)] -mod tests_fxzoom; -#[cfg(test)] -mod tests_isolation; -#[cfg(test)] -mod tests_knockout; -#[cfg(test)] -mod tests_layeropts; -#[cfg(test)] -mod tests_memory; -#[cfg(test)] -mod tests_objectfx; -#[cfg(test)] -mod tests_placed_document; -#[cfg(test)] -mod tests_rasterize; -#[cfg(test)] -mod tests_setup; -#[cfg(test)] -mod tests_strokegradient; -#[cfg(test)] -mod tests_tileedge; -#[cfg(test)] -mod tests_viewraster; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 721634839..9502ef3ff 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -3,6 +3,32 @@ use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::shapes; +mod adjust; +mod alternate; +mod bitmapglyphs; +mod blend; +mod changed; +mod charstroke; +mod clip; +mod cmykblend; +mod colrglyphs; +mod container; +mod dither; +pub(super) mod fontchange; +mod freeform; +mod fxzoom; +mod isolation; +mod knockout; +mod layeropts; +mod memory; +mod objectfx; +mod placed_document; +mod rasterize; +mod setup; +mod strokegradient; +mod tileedge; +mod viewraster; + fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node { let id = d.alloc_id(); Node::path(id, shapes::rectangle(r), Appearance::basic(fill, stroke, w)) diff --git a/crates/render/src/tests_adjust.rs b/crates/render/src/tests/adjust.rs similarity index 99% rename from crates/render/src/tests_adjust.rs rename to crates/render/src/tests/adjust.rs index 1457c4df5..f3e930dca 100644 --- a/crates/render/src/tests_adjust.rs +++ b/crates/render/src/tests/adjust.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::text::{CharStyle, TextObject}; use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol}; use vectorcraft_geom::{Point, shapes}; -use super::*; +use crate::*; fn fx(id: &str, params: Value) -> Effect { Effect { id: id.into(), params, visible: true } diff --git a/crates/render/src/tests_alternate.rs b/crates/render/src/tests/alternate.rs similarity index 99% rename from crates/render/src/tests_alternate.rs rename to crates/render/src/tests/alternate.rs index 4459f37db..ad39ed065 100644 --- a/crates/render/src/tests_alternate.rs +++ b/crates/render/src/tests/alternate.rs @@ -2,7 +2,7 @@ //! cell behind them on screen when the preference is on. Preferences › Type › Highlight //! Substituted Fonts does the same for text drawn in a substitute for a missing font. -use super::*; +use crate::*; /// Highlight Substituted Fonts follows the preference passed in [`RenderOptions`] for every /// document, whatever a document's own setup stored: on, type in a missing font has pink behind diff --git a/crates/render/src/tests_bitmapglyphs.rs b/crates/render/src/tests/bitmapglyphs.rs similarity index 99% rename from crates/render/src/tests_bitmapglyphs.rs rename to crates/render/src/tests/bitmapglyphs.rs index eddff0c36..184b92bcf 100644 --- a/crates/render/src/tests_bitmapglyphs.rs +++ b/crates/render/src/tests/bitmapglyphs.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Point; use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, colour_bitmap_family, colour_bitmap_font}; -use super::*; +use crate::*; /// 100 pt [`COLOUR_CHAR`] in the colour bitmap test font (`sbix` or `CBDT`), filled blue, its /// baseline at (20, 120), on a white 160×160 page. diff --git a/crates/render/src/tests_blend.rs b/crates/render/src/tests/blend.rs similarity index 99% rename from crates/render/src/tests_blend.rs rename to crates/render/src/tests/blend.rs index 986dd43ed..48ebb2665 100644 --- a/crates/render/src/tests_blend.rs +++ b/crates/render/src/tests/blend.rs @@ -1,7 +1,7 @@ //! Blend-mode accuracy: every mode matches the reference formulas ([`blend_rgb`]) within 1/255 //! (see [`tolerance`]), whether it is an object's blend mode, a fill's or a group's. -use super::*; +use crate::*; use vectorcraft_color::blend::blend_rgb; use vectorcraft_color::{BlendMode as Blend, Color, Paint}; use vectorcraft_doc::{Appearance, Node}; diff --git a/crates/render/src/tests_changed.rs b/crates/render/src/tests/changed.rs similarity index 99% rename from crates/render/src/tests_changed.rs rename to crates/render/src/tests/changed.rs index 108e7604e..675ea52ee 100644 --- a/crates/render/src/tests_changed.rs +++ b/crates/render/src/tests/changed.rs @@ -1,6 +1,6 @@ //! Drawing again only what an edit changed gives the pixels of drawing it all again. -use super::*; +use crate::*; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Appearance; use vectorcraft_geom::{Point, shapes}; diff --git a/crates/render/src/tests_charstroke.rs b/crates/render/src/tests/charstroke.rs similarity index 99% rename from crates/render/src/tests_charstroke.rs rename to crates/render/src/tests/charstroke.rs index 1b78eebe2..45daab916 100644 --- a/crates/render/src/tests_charstroke.rs +++ b/crates/render/src/tests/charstroke.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::text::{CharStyle, TextObject}; use vectorcraft_doc::{AppearanceItem, Dash, LineJoin, Node, NodeKind}; use vectorcraft_geom::Point; -use super::*; +use crate::*; use crate::tests_fontchange::fonts_steady; const SIZE: u32 = 200; diff --git a/crates/render/src/tests_clip.rs b/crates/render/src/tests/clip.rs similarity index 99% rename from crates/render/src/tests_clip.rs rename to crates/render/src/tests/clip.rs index 1f72f8ca9..7b04c02e2 100644 --- a/crates/render/src/tests_clip.rs +++ b/crates/render/src/tests/clip.rs @@ -1,7 +1,7 @@ //! Clip groups clip by any clipping path (M3.26): compound holes, even-odd fills, the union of a //! group's members, text outlines; with nothing to clip by the clipped art is hidden. -use super::*; +use crate::*; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, CharStyle}; use vectorcraft_geom::{PathData, Point, SubPath, shapes}; diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests/cmykblend.rs similarity index 99% rename from crates/render/src/tests_cmykblend.rs rename to crates/render/src/tests/cmykblend.rs index 6a603842a..a6ed64ff9 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests/cmykblend.rs @@ -1,6 +1,6 @@ //! Blending in CMYK documents ([`crate::ink`]): blend modes and opacity composite inks. -use super::*; +use crate::*; use vectorcraft_color::blend::MASK_LUM; use vectorcraft_color::{BlendMode as Blend, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{Appearance, ColorMode, ImageBlob, ImageObject, Node, OpacityMask}; diff --git a/crates/render/src/tests_colrglyphs.rs b/crates/render/src/tests/colrglyphs.rs similarity index 99% rename from crates/render/src/tests_colrglyphs.rs rename to crates/render/src/tests/colrglyphs.rs index 38f07f888..be0b040a9 100644 --- a/crates/render/src/tests_colrglyphs.rs +++ b/crates/render/src/tests/colrglyphs.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Point; use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_family, colr_font}; -use super::*; +use crate::*; /// 100 pt `text` in the COLR test font (v1 or v0), filled magenta, its baseline at (20, 120) /// turned by `turn` about there, on a white 160×160 page. diff --git a/crates/render/src/tests_container.rs b/crates/render/src/tests/container.rs similarity index 99% rename from crates/render/src/tests_container.rs rename to crates/render/src/tests/container.rs index 0bb071147..89126cf2a 100644 --- a/crates/render/src/tests_container.rs +++ b/crates/render/src/tests/container.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::text::{CharStyle, TextObject}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, ImageObject, Knockout, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{Point, shapes}; -use super::*; +use crate::*; fn fx(id: &str, params: Value) -> Effect { Effect { id: id.into(), params, visible: true } diff --git a/crates/render/src/tests_dither.rs b/crates/render/src/tests/dither.rs similarity index 99% rename from crates/render/src/tests_dither.rs rename to crates/render/src/tests/dither.rs index 55e70e88e..425d48ea8 100644 --- a/crates/render/src/tests_dither.rs +++ b/crates/render/src/tests/dither.rs @@ -1,7 +1,7 @@ //! Dithered gradient fills: fine noise instead of bands, the same colours on average, the same //! pixels on every render, and nothing changed without dither or on strokes. -use super::*; +use crate::*; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::shapes; diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests/fontchange.rs similarity index 99% rename from crates/render/src/tests_fontchange.rs rename to crates/render/src/tests/fontchange.rs index 5b23eb959..70e6554d3 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::text::{CharStyle, TextObject}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Point; -use super::*; +use crate::*; /// Taken to add fonts, or to look up an installed one by name (`FontDb::face` loads it, which /// changes the fonts too). Renderers lay type out again when the fonts change, so tests of what @@ -28,7 +28,7 @@ pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> { /// The bundled Source Serif 4 renamed `family` (as long as the original name). fn serif_as(family: &str) -> Vec { - let mut data = include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf").to_vec(); + let mut data = include_bytes!("../../../../assets/fonts/SourceSerif4-Regular.ttf").to_vec(); let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); for (from, to) in [(utf16("Source Serif 4"), utf16(family)), (b"Source Serif 4".to_vec(), family.as_bytes().to_vec())] { assert_eq!(from.len(), to.len()); diff --git a/crates/render/src/tests_freeform.rs b/crates/render/src/tests/freeform.rs similarity index 99% rename from crates/render/src/tests_freeform.rs rename to crates/render/src/tests/freeform.rs index 621e04f87..44db363c7 100644 --- a/crates/render/src/tests_freeform.rs +++ b/crates/render/src/tests/freeform.rs @@ -1,6 +1,6 @@ //! Freeform gradients: the rendered colour grid against the colour field. -use super::*; +use crate::*; use vectorcraft_color::freeform::spread_scale; use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, Node}; diff --git a/crates/render/src/tests_fxzoom.rs b/crates/render/src/tests/fxzoom.rs similarity index 99% rename from crates/render/src/tests_fxzoom.rs rename to crates/render/src/tests/fxzoom.rs index a34726671..f1b10001f 100644 --- a/crates/render/src/tests_fxzoom.rs +++ b/crates/render/src/tests/fxzoom.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::text::{CharStyle, TextObject}; use vectorcraft_doc::{Appearance, Effect, Node, NodeKind}; use vectorcraft_geom::{Point, shapes}; -use super::*; +use crate::*; /// Type blurred by `radius` points (Gaussian Blur, or Feather when `feather`) over a rectangle /// that overlays at half opacity; without the type when `radius` is `None`. diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests/isolation.rs similarity index 99% rename from crates/render/src/tests_isolation.rs rename to crates/render/src/tests/isolation.rs index d4d05b48a..ac71592c3 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests/isolation.rs @@ -1,7 +1,7 @@ //! Non-isolated transparency groups: blending inside a group with opacity or a blend mode reaches //! the art below the group unless Isolate Blending is on (checked against the reference formulas). -use super::*; +use crate::*; use vectorcraft_color::blend::{blend_rgb, composite}; use vectorcraft_color::{BlendMode as Blend, Color, Paint}; use vectorcraft_doc::{Appearance, Knockout, Node}; diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests/knockout.rs similarity index 99% rename from crates/render/src/tests_knockout.rs rename to crates/render/src/tests/knockout.rs index 1fbb67f7a..a32f0de9e 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests/knockout.rs @@ -1,6 +1,6 @@ //! Knockout groups, the knockout shape and the page group (Transparency panel). -use super::*; +use crate::*; use vectorcraft_color::{BlendMode as Blend, Color, Paint}; use vectorcraft_doc::{Appearance, Knockout, Node}; use vectorcraft_geom::shapes; diff --git a/crates/render/src/tests_layeropts.rs b/crates/render/src/tests/layeropts.rs similarity index 99% rename from crates/render/src/tests_layeropts.rs rename to crates/render/src/tests/layeropts.rs index 7c76f4c31..bfa7910f1 100644 --- a/crates/render/src/tests_layeropts.rs +++ b/crates/render/src/tests/layeropts.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, ImageBlob, ImageObject, Node, NodeId, NodeKind}; use vectorcraft_geom::shapes; -use super::*; +use crate::*; /// A red square (10..40) and a 30×30 red image at (50, 10), on one layer set up by `layer`. fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document { diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests/memory.rs similarity index 99% rename from crates/render/src/tests_memory.rs rename to crates/render/src/tests/memory.rs index 156edb765..844d983b4 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests/memory.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; -use super::*; +use crate::*; use crate::tests_fontchange::fonts_steady; /// A `w`×`h` PNG whose pixels depend on `seed` (each test its own image: the store is shared by diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests/objectfx.rs similarity index 99% rename from crates/render/src/tests_objectfx.rs rename to crates/render/src/tests/objectfx.rs index 799de969d..97170f9f0 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::text::{CharStyle, TextObject}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeKind, StrokeLayer, Symbol}; use vectorcraft_geom::{Point, shapes}; -use super::*; +use crate::*; fn fx(id: &str, params: Value) -> Effect { Effect { id: id.into(), params, visible: true } diff --git a/crates/render/src/tests_placed_document.rs b/crates/render/src/tests/placed_document.rs similarity index 99% rename from crates/render/src/tests_placed_document.rs rename to crates/render/src/tests/placed_document.rs index add7e3998..60e08a6a5 100644 --- a/crates/render/src/tests_placed_document.rs +++ b/crates/render/src/tests/placed_document.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, ImageBlob, LinkInfo, Node, NodeId, NodeKind, PlacedDocument}; use vectorcraft_geom::shapes; -use super::*; +use crate::*; /// Every file reads as a 40×20 artboard filled blue; a file whose bytes start with `loop` also /// places itself (the same key) in its middle, and one whose bytes start with `bad` can't be read. diff --git a/crates/render/src/tests_rasterize.rs b/crates/render/src/tests/rasterize.rs similarity index 99% rename from crates/render/src/tests_rasterize.rs rename to crates/render/src/tests/rasterize.rs index d317cb507..798d2f6d5 100644 --- a/crates/render/src/tests_rasterize.rs +++ b/crates/render/src/tests/rasterize.rs @@ -6,7 +6,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Effect, Node}; use vectorcraft_geom::{Anchor, PathData, Point, SubPath, shapes}; -use super::*; +use crate::*; /// A red shape (`path`, over 10..30 pt) with the effects `fx` (id, params), in a document whose /// raster effects resolution is `ppi`. diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests/setup.rs similarity index 99% rename from crates/render/src/tests_setup.rs rename to crates/render/src/tests/setup.rs index a03130c5f..e80e807e4 100644 --- a/crates/render/src/tests_setup.rs +++ b/crates/render/src/tests/setup.rs @@ -2,7 +2,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; -use super::*; +use crate::*; fn doc_with_red_image() -> Document { let mut d = Document::new(40.0, 40.0); diff --git a/crates/render/src/tests_strokegradient.rs b/crates/render/src/tests/strokegradient.rs similarity index 99% rename from crates/render/src/tests_strokegradient.rs rename to crates/render/src/tests/strokegradient.rs index 4742c5401..961f18590 100644 --- a/crates/render/src/tests_strokegradient.rs +++ b/crates/render/src/tests/strokegradient.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Gradient, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Node, StrokeGradientMode, StrokeLayer}; use vectorcraft_geom::PathData; -use super::*; +use crate::*; const SIZE: u32 = 200; diff --git a/crates/render/src/tests_tileedge.rs b/crates/render/src/tests/tileedge.rs similarity index 99% rename from crates/render/src/tests_tileedge.rs rename to crates/render/src/tests/tileedge.rs index 06b27fe16..a20432ec2 100644 --- a/crates/render/src/tests_tileedge.rs +++ b/crates/render/src/tests/tileedge.rs @@ -1,6 +1,6 @@ //! Pattern editing mode: the tile edge in the Tile Edge Color, and Show Swatch Bounds. -use super::*; +use crate::*; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::pattern::{PatternDef, PatternEdit, TileType}; use vectorcraft_doc::{Appearance, Node}; diff --git a/crates/render/src/tests_viewraster.rs b/crates/render/src/tests/viewraster.rs similarity index 100% rename from crates/render/src/tests_viewraster.rs rename to crates/render/src/tests/viewraster.rs From 52fb56b43862a12048b2996b7f82534f0f76d9b5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:58 +0000 Subject: [PATCH 098/199] refactor(text): move tests_*.rs to tests/.rs --- crates/text/src/lib.rs | 44 ------------------- crates/text/src/tests.rs | 23 ++++++++++ .../text/src/{tests_axes.rs => tests/axes.rs} | 4 +- .../text/src/{tests_bidi.rs => tests/bidi.rs} | 2 +- .../{tests_bitmaps.rs => tests/bitmaps.rs} | 10 ++--- .../text/src/{tests_colr.rs => tests/colr.rs} | 20 ++++----- .../src/{tests_combos.rs => tests/combos.rs} | 2 +- .../devanagari.rs} | 2 +- .../src/{tests_embed.rs => tests/embed.rs} | 6 +-- .../text/src/{tests_fit.rs => tests/fit.rs} | 2 +- .../text/src/{tests_hang.rs => tests/hang.rs} | 2 +- .../src/{tests_inline.rs => tests/inline.rs} | 2 +- .../{tests_joiners.rs => tests/joiners.rs} | 2 +- .../{tests_justify.rs => tests/justify.rs} | 2 +- .../path_joins.rs} | 2 +- .../text/src/{tests_ruby.rs => tests/ruby.rs} | 2 +- .../{tests_scripts.rs => tests/scripts.rs} | 2 +- .../text/src/{tests_snap.rs => tests/snap.rs} | 2 +- .../svg_glyphs.rs} | 10 ++--- .../{tests_sysfonts.rs => tests/sysfonts.rs} | 2 +- .../text/src/{tests_typo.rs => tests/typo.rs} | 2 +- .../{tests_variable.rs => tests/variable.rs} | 4 +- .../{tests_versions.rs => tests/versions.rs} | 4 +- .../{tests_vertical.rs => tests/vertical.rs} | 2 +- 24 files changed, 67 insertions(+), 88 deletions(-) rename crates/text/src/{tests_axes.rs => tests/axes.rs} (99%) rename crates/text/src/{tests_bidi.rs => tests/bidi.rs} (99%) rename crates/text/src/{tests_bitmaps.rs => tests/bitmaps.rs} (95%) rename crates/text/src/{tests_colr.rs => tests/colr.rs} (97%) rename crates/text/src/{tests_combos.rs => tests/combos.rs} (99%) rename crates/text/src/{tests_devanagari.rs => tests/devanagari.rs} (99%) rename crates/text/src/{tests_embed.rs => tests/embed.rs} (97%) rename crates/text/src/{tests_fit.rs => tests/fit.rs} (99%) rename crates/text/src/{tests_hang.rs => tests/hang.rs} (99%) rename crates/text/src/{tests_inline.rs => tests/inline.rs} (99%) rename crates/text/src/{tests_joiners.rs => tests/joiners.rs} (99%) rename crates/text/src/{tests_justify.rs => tests/justify.rs} (99%) rename crates/text/src/{tests_path_joins.rs => tests/path_joins.rs} (99%) rename crates/text/src/{tests_ruby.rs => tests/ruby.rs} (99%) rename crates/text/src/{tests_scripts.rs => tests/scripts.rs} (99%) rename crates/text/src/{tests_snap.rs => tests/snap.rs} (99%) rename crates/text/src/{tests_svg_glyphs.rs => tests/svg_glyphs.rs} (96%) rename crates/text/src/{tests_sysfonts.rs => tests/sysfonts.rs} (99%) rename crates/text/src/{tests_typo.rs => tests/typo.rs} (99%) rename crates/text/src/{tests_variable.rs => tests/variable.rs} (99%) rename crates/text/src/{tests_versions.rs => tests/versions.rs} (96%) rename crates/text/src/{tests_vertical.rs => tests/vertical.rs} (99%) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 6c913280a..4299e61d2 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -699,49 +699,5 @@ pub fn logical_order(visual: &str) -> Option<(Vec, bool)> { #[cfg(test)] mod tests; #[cfg(test)] -mod tests_axes; -#[cfg(test)] -mod tests_bidi; -#[cfg(test)] -mod tests_bitmaps; -#[cfg(test)] -mod tests_colr; -#[cfg(test)] -mod tests_combos; -#[cfg(test)] -mod tests_devanagari; -#[cfg(test)] -mod tests_embed; -#[cfg(test)] -mod tests_fit; -#[cfg(test)] -mod tests_hang; -#[cfg(test)] -mod tests_inline; -#[cfg(test)] -mod tests_joiners; -#[cfg(test)] -mod tests_justify; -#[cfg(test)] -mod tests_path_joins; -#[cfg(test)] -mod tests_ruby; -#[cfg(test)] -mod tests_scripts; -#[cfg(test)] -mod tests_snap; -#[cfg(test)] -mod tests_svg_glyphs; -#[cfg(test)] -mod tests_sysfonts; -#[cfg(test)] -mod tests_typo; -#[cfg(test)] -mod tests_variable; -#[cfg(test)] -mod tests_versions; -#[cfg(test)] -mod tests_vertical; -#[cfg(test)] #[path = "../build/web_fonts.rs"] mod web_fonts_build; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 7373db683..79ce4f566 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -3,6 +3,29 @@ use kurbo::{Affine, Shape}; use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun}; use vectorcraft_geom::PathData; +mod axes; +mod bidi; +mod bitmaps; +mod colr; +mod combos; +mod devanagari; +mod embed; +mod fit; +mod hang; +mod inline; +mod joiners; +mod justify; +mod path_joins; +mod ruby; +mod scripts; +mod snap; +mod svg_glyphs; +mod sysfonts; +mod typo; +mod variable; +mod versions; +mod vertical; + /// The bundled fonts only: what these tests measure doesn't change with the fonts installed. fn db() -> &'static FontDb { static DB: std::sync::OnceLock = std::sync::OnceLock::new(); diff --git a/crates/text/src/tests_axes.rs b/crates/text/src/tests/axes.rs similarity index 99% rename from crates/text/src/tests_axes.rs rename to crates/text/src/tests/axes.rs index 7bfc57e5a..27b23b6df 100644 --- a/crates/text/src/tests_axes.rs +++ b/crates/text/src/tests/axes.rs @@ -9,8 +9,8 @@ use skrifa::instance::{LocationRef, Size}; use skrifa::raw::TableProvider; use vectorcraft_doc::CharStyle; -use super::test_fonts::{TWO_AXIS_FAMILY as FAMILY, TWO_AXIS_NARROW, VARIABLE_CHARS, VARIABLE_WIDEN, two_axis_font}; -use super::*; +use crate::test_fonts::{TWO_AXIS_FAMILY as FAMILY, TWO_AXIS_NARROW, VARIABLE_CHARS, VARIABLE_WIDEN, two_axis_font}; +use crate::*; /// A database of the bundled fonts and the two-axis test font. fn db() -> FontDb { diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests/bidi.rs similarity index 99% rename from crates/text/src/tests_bidi.rs rename to crates/text/src/tests/bidi.rs index d7738c501..af67365b0 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -1,5 +1,5 @@ //! Direction, source-cluster and editing regressions; no RTL font assets required. -use super::*; +use crate::*; use kurbo::{Affine, BezPath, Point, Rect, Shape}; use std::ops::Range; use std::sync::Arc; diff --git a/crates/text/src/tests_bitmaps.rs b/crates/text/src/tests/bitmaps.rs similarity index 95% rename from crates/text/src/tests_bitmaps.rs rename to crates/text/src/tests/bitmaps.rs index f28656d4a..96661534b 100644 --- a/crates/text/src/tests_bitmaps.rs +++ b/crates/text/src/tests/bitmaps.rs @@ -6,8 +6,8 @@ use skrifa::MetadataProvider; use skrifa::instance::{LocationRef, Size}; use vectorcraft_doc::CharStyle; -use super::test_fonts::{COLOUR_CBDT_BEARINGS, COLOUR_CHAR, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font}; -use super::*; +use crate::test_fonts::{COLOUR_CBDT_BEARINGS, COLOUR_CHAR, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font}; +use crate::*; fn db(sbix: bool) -> FontDb { let db = FontDb::with_font_dirs(vec![]); @@ -32,7 +32,7 @@ fn assert_near(a: Rect, b: Rect) { /// The left side bearing of [`COLOUR_CHAR`] in the bundled Source Sans 3 (font units of 1000). fn lsb() -> f64 { - let font = skrifa::FontRef::new(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); + let font = skrifa::FontRef::new(include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); let gid = font.charmap().map(COLOUR_CHAR).unwrap(); f64::from(font.glyph_metrics(Size::unscaled(), LocationRef::default()).left_side_bearing(gid).unwrap()) } @@ -95,8 +95,8 @@ fn other_glyphs_of_a_colour_font_keep_their_outlines() { /// decoded (a font is untrusted input). #[test] fn a_bitmap_past_the_caps_or_without_a_header_is_no_image() { - use super::bitmap::{MAX_PNG_BYTES, MAX_SIDE}; - use super::test_fonts::{COLOUR_LARGE, colour_bitmap_font_with, solid_png}; + use crate::bitmap::{MAX_PNG_BYTES, MAX_SIDE}; + use crate::test_fonts::{COLOUR_LARGE, colour_bitmap_font_with, solid_png}; let good = solid_png(COLOUR_PPEM.into(), COLOUR_LARGE); // A header claiming a side past the cap (the IHDR width). let mut wide = good.clone(); diff --git a/crates/text/src/tests_colr.rs b/crates/text/src/tests/colr.rs similarity index 97% rename from crates/text/src/tests_colr.rs rename to crates/text/src/tests/colr.rs index 9f51e0d9e..212882ce7 100644 --- a/crates/text/src/tests_colr.rs +++ b/crates/text/src/tests/colr.rs @@ -4,7 +4,7 @@ use skrifa::MetadataProvider; use skrifa::color::ColorGlyphFormat; -use super::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_font}; +use crate::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_font}; #[test] fn colr_fonts_build_and_read() { @@ -24,8 +24,8 @@ use skrifa::instance::LocationRef; use vectorcraft_doc::color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{Node, NodeKind}; -use super::colr::{ColourContent, colour_glyph}; -use super::test_fonts::{COLR_GREEN, COLR_RED}; +use crate::colr::{ColourContent, colour_glyph}; +use crate::test_fonts::{COLR_GREEN, COLR_RED}; const FOREGROUND: Color = Color::Rgb { r: 0.25, g: 0.5, b: 0.75 }; @@ -120,7 +120,7 @@ fn a_colr_graph_that_paints_itself_is_no_colour_glyph() { let base = GlyphId16::new(u16::try_from(base).unwrap()); let mut colr = Colr::new(0, None, None, 0); colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(base, Paint::colr_glyph(base))])).into(); - let font = super::test_fonts::colr_font_with("Colrtest Loop", &colr).unwrap(); + let font = crate::test_fonts::colr_font_with("Colrtest Loop", &colr).unwrap(); assert!(content(&font, COLOUR_CHAR).is_none()); } @@ -136,7 +136,7 @@ fn a_colr_graph_nested_past_the_cap_is_no_colour_glyph() { } let mut colr = Colr::new(0, None, None, 0); colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(base, paint)])).into(); - let font = super::test_fonts::colr_font_with("Colrtest Deep", &colr).unwrap(); + let font = crate::test_fonts::colr_font_with("Colrtest Deep", &colr).unwrap(); assert!(content(&font, COLOUR_CHAR).is_none()); } @@ -145,8 +145,8 @@ fn a_colr_graph_nested_past_the_cap_is_no_colour_glyph() { use kurbo::Point as KPoint; use vectorcraft_doc::{CharStyle, TextObject}; -use super::test_fonts::colr_family; -use super::{FontDb, layout}; +use crate::test_fonts::colr_family; +use crate::{FontDb, layout}; fn colr_db(v1: bool) -> FontDb { let db = FontDb::with_font_dirs(vec![]); @@ -223,7 +223,7 @@ fn a_face_with_colr_and_sbix_draws_colr() { use write_fonts::FontBuilder; use write_fonts::types::Tag; let colr = colr_font(false).unwrap(); - let sbix = super::test_fonts::colour_bitmap_font(true).unwrap(); + let sbix = crate::test_fonts::colour_bitmap_font(true).unwrap(); let sbix = skrifa::FontRef::new(&sbix).unwrap(); let font = skrifa::FontRef::new(&colr).unwrap(); let mut builder = FontBuilder::new(); @@ -251,7 +251,7 @@ fn v1_font(family: &str, base: char, paint: impl Fn(write_fonts::types::GlyphId1 let mut colr = Colr::new(0, None, None, 0); colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(base), paint(gid('O')))])).into(); colr.layer_list = Some(LayerList::new(1, vec![solid(0)])).into(); - super::test_fonts::colr_font_with(family, &colr).unwrap() + crate::test_fonts::colr_font_with(family, &colr).unwrap() } use write_fonts::tables::colr::Paint as ColrPaint; @@ -312,7 +312,7 @@ fn a_collapsed_sweep_glyph_draws_nothing() { // A clip box, so the image is made. let clip = ClipBox::format_1(FWord::new(0), FWord::new(0), FWord::new(500), FWord::new(500)); colr.clip_list = Some(ClipList::new(1, 1, vec![Clip::new(gid(COLOUR_CHAR), gid(COLOUR_CHAR), clip)])).into(); - let font = super::test_fonts::colr_font_with("Colrtest Flat", &colr).unwrap(); + let font = crate::test_fonts::colr_font_with("Colrtest Flat", &colr).unwrap(); let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("an image") }; assert!(image.blob.bytes.starts_with(b"\x89PNG")); } diff --git a/crates/text/src/tests_combos.rs b/crates/text/src/tests/combos.rs similarity index 99% rename from crates/text/src/tests_combos.rs rename to crates/text/src/tests/combos.rs index d61dccc51..02fdd6d79 100644 --- a/crates/text/src/tests_combos.rs +++ b/crates/text/src/tests/combos.rs @@ -3,7 +3,7 @@ //! per-paragraph attributes and inline graphics. Each feature has its own tests; these check that //! one doesn't undo or skip another (as vertical alignment once left inline art behind). -use super::*; +use crate::*; use kurbo::Shape; use vectorcraft_doc::{AreaFit, CharStyle, Composer, InlineArt, Justify, TextKind, TextRun, VerticalAlign}; use vectorcraft_geom::PathData; diff --git a/crates/text/src/tests_devanagari.rs b/crates/text/src/tests/devanagari.rs similarity index 99% rename from crates/text/src/tests_devanagari.rs rename to crates/text/src/tests/devanagari.rs index 37c21ac5a..f7cca3093 100644 --- a/crates/text/src/tests_devanagari.rs +++ b/crates/text/src/tests/devanagari.rs @@ -3,7 +3,7 @@ //! (https://learn.microsoft.com/en-us/typography/script-development/devanagari) and the Unicode //! Standard's Devanagari section (https://www.unicode.org/versions/latest/core-spec/chapter-12/). //! No font is committed: the tests use an installed face and print SKIPPED when none is found. -use super::*; +use crate::*; use kurbo::{BezPath, Point}; use std::sync::Arc; use vectorcraft_doc::{CharStyle, TextKind}; diff --git a/crates/text/src/tests_embed.rs b/crates/text/src/tests/embed.rs similarity index 97% rename from crates/text/src/tests_embed.rs rename to crates/text/src/tests/embed.rs index 8490bfd97..9dd8543e0 100644 --- a/crates/text/src/tests_embed.rs +++ b/crates/text/src/tests/embed.rs @@ -5,8 +5,8 @@ use skrifa::instance::{LocationRef, Size}; use skrifa::raw::types::Tag; use skrifa::raw::{FontData, FontRead, TableProvider}; -use super::embed::Embedding; -use super::*; +use crate::embed::Embedding; +use crate::*; #[test] fn fs_type_flags_decide_the_embedding() { @@ -22,7 +22,7 @@ fn fs_type_flags_decide_the_embedding() { /// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original /// name) with OS/2 `fsType` set to `fs_type`. fn licensed_font(family: &str, fs_type: u16) -> Vec { - let mut data = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec(); + let mut data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").to_vec(); let table = |data: &[u8], tag: &[u8; 4]| { let f = skrifa::FontRef::new(data).unwrap(); let r = f.table_directory.table_records().iter().find(|r| r.tag().to_be_bytes() == *tag).unwrap(); diff --git a/crates/text/src/tests_fit.rs b/crates/text/src/tests/fit.rs similarity index 99% rename from crates/text/src/tests_fit.rs rename to crates/text/src/tests/fit.rs index f0ede1003..415ddc90a 100644 --- a/crates/text/src/tests_fit.rs +++ b/crates/text/src/tests/fit.rs @@ -1,6 +1,6 @@ //! Area type fitting: Shrink Text to Fit. -use super::*; +use crate::*; use kurbo::Shape; use vectorcraft_doc::{AreaFit, CharStyle, TextKind}; use vectorcraft_geom::PathData; diff --git a/crates/text/src/tests_hang.rs b/crates/text/src/tests/hang.rs similarity index 99% rename from crates/text/src/tests_hang.rs rename to crates/text/src/tests/hang.rs index e59d89062..5ccb79a5c 100644 --- a/crates/text/src/tests_hang.rs +++ b/crates/text/src/tests/hang.rs @@ -3,7 +3,7 @@ //! paragraphs" › Hanging punctuation, //! ). -use super::*; +use crate::*; use kurbo::{Affine, Shape}; use vectorcraft_doc::{CharStyle, Composer, Justify, TextKind}; use vectorcraft_geom::PathData; diff --git a/crates/text/src/tests_inline.rs b/crates/text/src/tests/inline.rs similarity index 99% rename from crates/text/src/tests_inline.rs rename to crates/text/src/tests/inline.rs index 495555381..ebc90351d 100644 --- a/crates/text/src/tests_inline.rs +++ b/crates/text/src/tests/inline.rs @@ -1,6 +1,6 @@ //! Inline graphics in text: a symbol's art laid out like a glyph. -use super::*; +use crate::*; use kurbo::{Affine, Shape}; use vectorcraft_doc::text::INLINE_CHAR; use vectorcraft_doc::{CharStyle, InlineArt, Justify, TextKind, TextRun}; diff --git a/crates/text/src/tests_joiners.rs b/crates/text/src/tests/joiners.rs similarity index 99% rename from crates/text/src/tests_joiners.rs rename to crates/text/src/tests/joiners.rs index 6c23cedff..041294394 100644 --- a/crates/text/src/tests_joiners.rs +++ b/crates/text/src/tests/joiners.rs @@ -7,7 +7,7 @@ //! https://learn.microsoft.com/en-us/typography/script-development/bengali). //! No font is committed: the tests use installed faces and print SKIPPED when none is found, except //! the check of the half-form test's face filter, which builds its faces in code. -use super::*; +use crate::*; use kurbo::Point; use std::sync::Arc; use vectorcraft_doc::CharStyle; diff --git a/crates/text/src/tests_justify.rs b/crates/text/src/tests/justify.rs similarity index 99% rename from crates/text/src/tests_justify.rs rename to crates/text/src/tests/justify.rs index 0a15520dc..6cb9e7718 100644 --- a/crates/text/src/tests_justify.rs +++ b/crates/text/src/tests/justify.rs @@ -1,7 +1,7 @@ //! Paragraph panel menu › Justification…: word spacing, letter spacing, glyph scaling, Single Word //! Justification and Auto Leading in layout. -use super::*; +use crate::*; use kurbo::{Affine, Shape}; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind}; diff --git a/crates/text/src/tests_path_joins.rs b/crates/text/src/tests/path_joins.rs similarity index 99% rename from crates/text/src/tests_path_joins.rs rename to crates/text/src/tests/path_joins.rs index a796490f8..1ac96e3f0 100644 --- a/crates/text/src/tests_path_joins.rs +++ b/crates/text/src/tests/path_joins.rs @@ -2,7 +2,7 @@ //! between one glyph's edge and the next one's, at the baseline and at the height of the joining //! stroke, is the measure. Fonts are installed ones (none is kept in the repository): the tests //! that need an Arabic font say so and stop without one. -use super::*; +use crate::*; use kurbo::{Affine, BezPath, Point, Shape}; use std::sync::Arc; use vectorcraft_doc::{CharStyle, TextKind}; diff --git a/crates/text/src/tests_ruby.rs b/crates/text/src/tests/ruby.rs similarity index 99% rename from crates/text/src/tests_ruby.rs rename to crates/text/src/tests/ruby.rs index 5e3a66b0a..e7a7cfc1f 100644 --- a/crates/text/src/tests_ruby.rs +++ b/crates/text/src/tests/ruby.rs @@ -1,7 +1,7 @@ //! Ruby (furigana) tests: geometry over the base text in horizontal and vertical type, line //! growth, overhang/squeeze, splitting over a line break, and the byte ranges following edits. -use super::*; +use crate::*; use kurbo::{Affine, Point, Rect, Shape}; use vectorcraft_doc::{CharStyle, RubyAlign, RubyPosition, RubySpan, TextKind}; use vectorcraft_geom::PathData; diff --git a/crates/text/src/tests_scripts.rs b/crates/text/src/tests/scripts.rs similarity index 99% rename from crates/text/src/tests_scripts.rs rename to crates/text/src/tests/scripts.rs index 1ba84a220..17a6ab6e8 100644 --- a/crates/text/src/tests_scripts.rs +++ b/crates/text/src/tests/scripts.rs @@ -3,7 +3,7 @@ use kurbo::Shape; use vectorcraft_doc::{CharPosition, CharStyle, ScriptMetrics, TextObject}; -use super::*; +use crate::*; fn layout_of(text: &str, st: CharStyle) -> TextLayout { layout(FontDb::global(), &TextObject::point(kurbo::Point::ZERO, text, st)) diff --git a/crates/text/src/tests_snap.rs b/crates/text/src/tests/snap.rs similarity index 99% rename from crates/text/src/tests_snap.rs rename to crates/text/src/tests/snap.rs index a5c65dd59..b288f5e9f 100644 --- a/crates/text/src/tests_snap.rs +++ b/crates/text/src/tests/snap.rs @@ -3,7 +3,7 @@ use kurbo::{Affine, Shape}; use vectorcraft_doc::CharStyle; -use super::*; +use crate::*; fn text_at(x: f64, y: f64) -> TextObject { TextObject::point(Point::new(x, y), "Hill", CharStyle { size: 9.0, ..CharStyle::default() }) diff --git a/crates/text/src/tests_svg_glyphs.rs b/crates/text/src/tests/svg_glyphs.rs similarity index 96% rename from crates/text/src/tests_svg_glyphs.rs rename to crates/text/src/tests/svg_glyphs.rs index dcb729ca1..d82ff9052 100644 --- a/crates/text/src/tests_svg_glyphs.rs +++ b/crates/text/src/tests/svg_glyphs.rs @@ -8,11 +8,11 @@ use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, TextObject}; use vectorcraft_geom::PathData; -use super::test_fonts::{SVG_CHAR, source_sans_gid, svg_font}; +use crate::test_fonts::{SVG_CHAR, source_sans_gid, svg_font}; /// The family of the SVG test fonts here (each database holds one). const FAMILY: &str = "Svgtest Glyphs"; -use super::{FontDb, layout, set_svg_glyph_reader}; +use crate::{FontDb, layout, set_svg_glyph_reader}; /// Stands in for the SVG importer, which this crate can't use (it is a layer up): a square one em /// wide in the fill, stroked when there is a stroke, for a document that has the glyph's element. @@ -88,7 +88,7 @@ fn colr_comes_before_svg() { use write_fonts::FontBuilder; use write_fonts::types::Tag; set_svg_glyph_reader(reader); - let colr = super::test_fonts::colr_font(false).unwrap(); + let colr = crate::test_fonts::colr_font(false).unwrap(); let colr = skrifa::FontRef::new(&colr).unwrap(); let g = gid(SVG_CHAR); let svg = svg_font(FAMILY, &[(g, g, &document(""))]).unwrap(); @@ -103,7 +103,7 @@ fn colr_comes_before_svg() { } let db = FontDb::with_font_dirs(vec![]); assert_eq!(db.add_font(builder.build()), 1); - let st = CharStyle { font_family: super::test_fonts::colr_family(false).into(), size: 100.0, ..CharStyle::default() }; + let st = CharStyle { font_family: crate::test_fonts::colr_family(false).into(), size: 100.0, ..CharStyle::default() }; let l = layout(&db, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), st)); // The COLR glyph has two layers; the reader's art one path. let colour = l.glyphs[0].colour.clone().unwrap(); @@ -122,7 +122,7 @@ fn a_glyph_whose_colr_layers_dont_read_keeps_its_outline() { let g = gid(SVG_CHAR); let mut colr = Colr::new(0, None, None, 0); colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(GlyphId16::new(g), Paint::colr_glyph(GlyphId16::new(g)))])).into(); - let looped = super::test_fonts::colr_font_with(FAMILY, &colr).unwrap(); + let looped = crate::test_fonts::colr_font_with(FAMILY, &colr).unwrap(); let looped = skrifa::FontRef::new(&looped).unwrap(); let svg = svg_font(FAMILY, &[(g, g, &document(""))]).unwrap(); let svg = skrifa::FontRef::new(&svg).unwrap(); diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests/sysfonts.rs similarity index 99% rename from crates/text/src/tests_sysfonts.rs rename to crates/text/src/tests/sysfonts.rs index 40cce9f06..5571d5185 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests/sysfonts.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use super::*; +use crate::*; use crate::fontdb::fonts_outside; const FAMILY: &str = "Sysfont Sans3"; diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests/typo.rs similarity index 99% rename from crates/text/src/tests_typo.rs rename to crates/text/src/tests/typo.rs index 7e9a6448c..c06396a1e 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests/typo.rs @@ -1,7 +1,7 @@ //! Typography tests: rich-text editing, caret navigation, selection, area flow, composer, //! hyphenation, OpenType features, area type options. -use super::*; +use crate::*; use kurbo::{Affine, Circle, Shape}; use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun}; use vectorcraft_geom::PathData; diff --git a/crates/text/src/tests_variable.rs b/crates/text/src/tests/variable.rs similarity index 99% rename from crates/text/src/tests_variable.rs rename to crates/text/src/tests/variable.rs index d17e163ad..8e087f68e 100644 --- a/crates/text/src/tests_variable.rs +++ b/crates/text/src/tests/variable.rs @@ -7,8 +7,8 @@ use skrifa::instance::{LocationRef, Size}; use skrifa::raw::TableProvider; use vectorcraft_doc::CharStyle; -use super::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY as FAMILY, VARIABLE_WIDEN, name_table, variable_font}; -use super::*; +use crate::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY as FAMILY, VARIABLE_WIDEN, name_table, variable_font}; +use crate::*; fn font() -> Vec { variable_font().expect("the test font builds") diff --git a/crates/text/src/tests_versions.rs b/crates/text/src/tests/versions.rs similarity index 96% rename from crates/text/src/tests_versions.rs rename to crates/text/src/tests/versions.rs index 91250fddf..a721e435b 100644 --- a/crates/text/src/tests_versions.rs +++ b/crates/text/src/tests/versions.rs @@ -5,8 +5,8 @@ use kurbo::Point; use vectorcraft_doc::CharStyle; -use super::test_fonts::{TWIN_FAMILY as FAMILY, TWIN_VERSIONS as VERSIONS, twin_font}; -use super::*; +use crate::test_fonts::{TWIN_FAMILY as FAMILY, TWIN_VERSIONS as VERSIONS, twin_font}; +use crate::*; /// A database of the bundled fonts, the newer version of the test family loaded first. fn db() -> FontDb { diff --git a/crates/text/src/tests_vertical.rs b/crates/text/src/tests/vertical.rs similarity index 99% rename from crates/text/src/tests_vertical.rs rename to crates/text/src/tests/vertical.rs index 97154feef..d69b5df29 100644 --- a/crates/text/src/tests_vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -1,6 +1,6 @@ //! Vertical type (bundled fonts: `§` and `×` stand upright like CJK, letters lie on their side; //! Japanese text only with craft-fonts' faces, skipped without them). -use super::*; +use crate::*; use kurbo::Shape; use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun}; use vectorcraft_geom::PathData; From 5a84d5a8e4920a047bfe78081f6bee1db467bc90 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:58 +0000 Subject: [PATCH 099/199] refactor(three-d): move tests_*.rs to tests/.rs --- crates/three-d/src/lib.rs | 8 -------- crates/three-d/src/tests.rs | 6 ++++++ crates/three-d/src/{tests_caps.rs => tests/caps.rs} | 2 +- .../three-d/src/{tests_lighting.rs => tests/lighting.rs} | 2 +- .../multiple_lights.rs} | 2 +- crates/three-d/src/{tests_smooth.rs => tests/smooth.rs} | 2 +- 6 files changed, 10 insertions(+), 12 deletions(-) rename crates/three-d/src/{tests_caps.rs => tests/caps.rs} (99%) rename crates/three-d/src/{tests_lighting.rs => tests/lighting.rs} (99%) rename crates/three-d/src/{tests_multiple_lights.rs => tests/multiple_lights.rs} (99%) rename crates/three-d/src/{tests_smooth.rs => tests/smooth.rs} (99%) diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 8f6728b97..539c6ef80 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -15,10 +15,6 @@ pub use shading::{LightGradient, RgbGradient, ShadeGradient}; mod lights; pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; -#[cfg(test)] -mod tests_lighting; -#[cfg(test)] -mod tests_multiple_lights; use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; @@ -595,10 +591,6 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result (PathData, Rect) { let mut path = BezPath::new(); diff --git a/crates/three-d/src/tests_lighting.rs b/crates/three-d/src/tests/lighting.rs similarity index 99% rename from crates/three-d/src/tests_lighting.rs rename to crates/three-d/src/tests/lighting.rs index 2c9608496..9b1ab94c4 100644 --- a/crates/three-d/src/tests_lighting.rs +++ b/crates/three-d/src/tests/lighting.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::*; #[test] fn color_combines_with_neutral_ambient_before_channel_clamping() { diff --git a/crates/three-d/src/tests_multiple_lights.rs b/crates/three-d/src/tests/multiple_lights.rs similarity index 99% rename from crates/three-d/src/tests_multiple_lights.rs rename to crates/three-d/src/tests/multiple_lights.rs index fe2f434d3..79d28bc24 100644 --- a/crates/three-d/src/tests_multiple_lights.rs +++ b/crates/three-d/src/tests/multiple_lights.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::*; #[test] fn directional_lights_add_before_saturation_and_ambient_is_added_once() { diff --git a/crates/three-d/src/tests_smooth.rs b/crates/three-d/src/tests/smooth.rs similarity index 99% rename from crates/three-d/src/tests_smooth.rs rename to crates/three-d/src/tests/smooth.rs index fd92bbada..c65172326 100644 --- a/crates/three-d/src/tests_smooth.rs +++ b/crates/three-d/src/tests/smooth.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::*; fn path(d: &str) -> PathData { PathData::from_bezpath(&BezPath::from_svg(d).unwrap()) From e5451f157959c8060bad77108f2438b0ceb8ca01 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:26:59 +0000 Subject: [PATCH 100/199] refactor(ui-egui): move tests_*.rs to tests/.rs --- crates/ui-egui/src/lib.rs | 108 +----------------- crates/ui-egui/src/tests.rs | 54 +++++++++ .../src/{tests_adjust.rs => tests/adjust.rs} | 0 .../src/{tests_aisave.rs => tests/aisave.rs} | 0 .../automation.rs} | 0 .../background.rs} | 10 +- .../clipboard.rs} | 0 .../contextmenu.rs} | 0 .../src/{tests_cut.rs => tests/cut.rs} | 0 .../distortkeys.rs} | 0 .../{tests_docsetup.rs => tests/docsetup.rs} | 0 .../document_drag.rs} | 0 .../font_menu.rs} | 0 .../src/{tests_fonts.rs => tests/fonts.rs} | 0 .../guide_escape.rs} | 0 .../src/{tests_home.rs => tests/home.rs} | 0 .../src/{tests_labels.rs => tests/labels.rs} | 0 .../lifecycle.rs} | 0 .../nativemenu.rs} | 0 .../nativeoptions.rs} | 0 .../numfields.rs} | 0 .../overprint.rs} | 0 .../paintchips.rs} | 0 .../pastechords.rs} | 0 .../{tests_pathtype.rs => tests/pathtype.rs} | 0 .../pdfoutput.rs} | 0 .../src/{tests_picks.rs => tests/picks.rs} | 0 .../src/{tests_place.rs => tests/place.rs} | 0 .../plugin_runs.rs} | 0 .../{tests_plugins.rs => tests/plugins.rs} | 2 +- .../{tests_printps.rs => tests/printps.rs} | 0 .../printtiling.rs} | 0 .../puppetwarp.rs} | 0 .../{tests_recolor.rs => tests/recolor.rs} | 0 .../{tests_recovery.rs => tests/recovery.rs} | 0 .../removeanchors.rs} | 0 .../rotateview.rs} | 0 .../ruler_origin.rs} | 0 .../src/{tests_save.rs => tests/save.rs} | 0 .../{tests_saveext.rs => tests/saveext.rs} | 0 .../screenmode.rs} | 0 .../screenshots.rs} | 0 .../script_check.rs} | 0 .../src/{tests_scrub.rs => tests/scrub.rs} | 0 .../selectall.rs} | 0 .../src/{tests_slices.rs => tests/slices.rs} | 0 .../src/{tests_svg.rs => tests/svg.rs} | 0 .../{tests_svgsave.rs => tests/svgsave.rs} | 0 .../synthetic.rs} | 0 .../{tests_sysclip.rs => tests/sysclip.rs} | 2 +- .../sysclip_emf.rs} | 0 .../sysclip_probe.rs} | 0 .../tab_overflow.rs} | 0 .../{tests_taskbar.rs => tests/taskbar.rs} | 0 .../transparencygrid.rs} | 0 .../widthtool.rs} | 0 56 files changed, 63 insertions(+), 113 deletions(-) create mode 100644 crates/ui-egui/src/tests.rs rename crates/ui-egui/src/{tests_adjust.rs => tests/adjust.rs} (100%) rename crates/ui-egui/src/{tests_aisave.rs => tests/aisave.rs} (100%) rename crates/ui-egui/src/{tests_automation.rs => tests/automation.rs} (100%) rename crates/ui-egui/src/{tests_background.rs => tests/background.rs} (98%) rename crates/ui-egui/src/{tests_clipboard.rs => tests/clipboard.rs} (100%) rename crates/ui-egui/src/{tests_contextmenu.rs => tests/contextmenu.rs} (100%) rename crates/ui-egui/src/{tests_cut.rs => tests/cut.rs} (100%) rename crates/ui-egui/src/{tests_distortkeys.rs => tests/distortkeys.rs} (100%) rename crates/ui-egui/src/{tests_docsetup.rs => tests/docsetup.rs} (100%) rename crates/ui-egui/src/{tests_document_drag.rs => tests/document_drag.rs} (100%) rename crates/ui-egui/src/{tests_font_menu.rs => tests/font_menu.rs} (100%) rename crates/ui-egui/src/{tests_fonts.rs => tests/fonts.rs} (100%) rename crates/ui-egui/src/{tests_guide_escape.rs => tests/guide_escape.rs} (100%) rename crates/ui-egui/src/{tests_home.rs => tests/home.rs} (100%) rename crates/ui-egui/src/{tests_labels.rs => tests/labels.rs} (100%) rename crates/ui-egui/src/{tests_lifecycle.rs => tests/lifecycle.rs} (100%) rename crates/ui-egui/src/{tests_nativemenu.rs => tests/nativemenu.rs} (100%) rename crates/ui-egui/src/{tests_nativeoptions.rs => tests/nativeoptions.rs} (100%) rename crates/ui-egui/src/{tests_numfields.rs => tests/numfields.rs} (100%) rename crates/ui-egui/src/{tests_overprint.rs => tests/overprint.rs} (100%) rename crates/ui-egui/src/{tests_paintchips.rs => tests/paintchips.rs} (100%) rename crates/ui-egui/src/{tests_pastechords.rs => tests/pastechords.rs} (100%) rename crates/ui-egui/src/{tests_pathtype.rs => tests/pathtype.rs} (100%) rename crates/ui-egui/src/{tests_pdfoutput.rs => tests/pdfoutput.rs} (100%) rename crates/ui-egui/src/{tests_picks.rs => tests/picks.rs} (100%) rename crates/ui-egui/src/{tests_place.rs => tests/place.rs} (100%) rename crates/ui-egui/src/{tests_plugin_runs.rs => tests/plugin_runs.rs} (100%) rename crates/ui-egui/src/{tests_plugins.rs => tests/plugins.rs} (98%) rename crates/ui-egui/src/{tests_printps.rs => tests/printps.rs} (100%) rename crates/ui-egui/src/{tests_printtiling.rs => tests/printtiling.rs} (100%) rename crates/ui-egui/src/{tests_puppetwarp.rs => tests/puppetwarp.rs} (100%) rename crates/ui-egui/src/{tests_recolor.rs => tests/recolor.rs} (100%) rename crates/ui-egui/src/{tests_recovery.rs => tests/recovery.rs} (100%) rename crates/ui-egui/src/{tests_removeanchors.rs => tests/removeanchors.rs} (100%) rename crates/ui-egui/src/{tests_rotateview.rs => tests/rotateview.rs} (100%) rename crates/ui-egui/src/{tests_ruler_origin.rs => tests/ruler_origin.rs} (100%) rename crates/ui-egui/src/{tests_save.rs => tests/save.rs} (100%) rename crates/ui-egui/src/{tests_saveext.rs => tests/saveext.rs} (100%) rename crates/ui-egui/src/{tests_screenmode.rs => tests/screenmode.rs} (100%) rename crates/ui-egui/src/{tests_screenshots.rs => tests/screenshots.rs} (100%) rename crates/ui-egui/src/{tests_script_check.rs => tests/script_check.rs} (100%) rename crates/ui-egui/src/{tests_scrub.rs => tests/scrub.rs} (100%) rename crates/ui-egui/src/{tests_selectall.rs => tests/selectall.rs} (100%) rename crates/ui-egui/src/{tests_slices.rs => tests/slices.rs} (100%) rename crates/ui-egui/src/{tests_svg.rs => tests/svg.rs} (100%) rename crates/ui-egui/src/{tests_svgsave.rs => tests/svgsave.rs} (100%) rename crates/ui-egui/src/{tests_synthetic.rs => tests/synthetic.rs} (100%) rename crates/ui-egui/src/{tests_sysclip.rs => tests/sysclip.rs} (99%) rename crates/ui-egui/src/{tests_sysclip_emf.rs => tests/sysclip_emf.rs} (100%) rename crates/ui-egui/src/{tests_sysclip_probe.rs => tests/sysclip_probe.rs} (100%) rename crates/ui-egui/src/{tests_tab_overflow.rs => tests/tab_overflow.rs} (100%) rename crates/ui-egui/src/{tests_taskbar.rs => tests/taskbar.rs} (100%) rename crates/ui-egui/src/{tests_transparencygrid.rs => tests/transparencygrid.rs} (100%) rename crates/ui-egui/src/{tests_widthtool.rs => tests/widthtool.rs} (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4efc27211..1e1c1c4b8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -73,113 +73,9 @@ pub mod workspaces; mod document_drag; #[cfg(test)] -mod tests_adjust; +use tests::{background as tests_background, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors, svg as tests_svg, synthetic as tests_synthetic, sysclip as tests_sysclip}; #[cfg(test)] -mod tests_aisave; -#[cfg(test)] -mod tests_automation; -#[cfg(test)] -mod tests_background; -#[cfg(test)] -mod tests_clipboard; -#[cfg(test)] -mod tests_contextmenu; -#[cfg(test)] -mod tests_cut; -#[cfg(test)] -mod tests_distortkeys; -#[cfg(test)] -mod tests_docsetup; -#[cfg(test)] -mod tests_document_drag; -#[cfg(test)] -mod tests_font_menu; -#[cfg(test)] -mod tests_fonts; -#[cfg(test)] -mod tests_guide_escape; -#[cfg(test)] -mod tests_home; -#[cfg(test)] -mod tests_labels; -#[cfg(test)] -mod tests_lifecycle; -#[cfg(test)] -mod tests_nativemenu; -#[cfg(test)] -mod tests_nativeoptions; -#[cfg(test)] -mod tests_numfields; -#[cfg(test)] -mod tests_overprint; -#[cfg(test)] -mod tests_paintchips; -#[cfg(test)] -mod tests_pastechords; -#[cfg(test)] -mod tests_pathtype; -#[cfg(test)] -mod tests_pdfoutput; -#[cfg(test)] -mod tests_picks; -#[cfg(test)] -mod tests_place; -#[cfg(test)] -mod tests_plugin_runs; -#[cfg(test)] -mod tests_plugins; -#[cfg(test)] -mod tests_printps; -#[cfg(test)] -mod tests_printtiling; -#[cfg(test)] -mod tests_puppetwarp; -#[cfg(test)] -mod tests_recolor; -#[cfg(test)] -mod tests_recovery; -#[cfg(test)] -mod tests_removeanchors; -#[cfg(test)] -mod tests_rotateview; -#[cfg(test)] -mod tests_ruler_origin; -#[cfg(test)] -mod tests_save; -#[cfg(test)] -mod tests_saveext; -#[cfg(test)] -mod tests_screenmode; -#[cfg(test)] -mod tests_screenshots; -#[cfg(test)] -mod tests_script_check; -#[cfg(test)] -mod tests_scrub; -#[cfg(test)] -mod tests_selectall; -#[cfg(test)] -mod tests_slices; -#[cfg(test)] -mod tests_svg; -#[cfg(test)] -mod tests_svgsave; -#[cfg(test)] -mod tests_synthetic; -#[cfg(test)] -mod tests_sysclip; -#[cfg(test)] -mod tests_sysclip_emf; -#[cfg(test)] -mod tests_sysclip_probe; -#[cfg(test)] -mod tests_tab_overflow; -#[cfg(test)] -mod tests_taskbar; -#[cfg(test)] -mod tests_transparencygrid; -#[cfg(test)] -mod tests_widthtool; +mod tests; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs new file mode 100644 index 000000000..fc3a59d43 --- /dev/null +++ b/crates/ui-egui/src/tests.rs @@ -0,0 +1,54 @@ +mod adjust; +mod aisave; +mod automation; +pub(super) mod background; +mod clipboard; +mod contextmenu; +mod cut; +mod distortkeys; +mod docsetup; +mod document_drag; +mod font_menu; +mod fonts; +mod guide_escape; +mod home; +pub(super) mod labels; +mod lifecycle; +mod nativemenu; +mod nativeoptions; +mod numfields; +mod overprint; +mod paintchips; +mod pastechords; +mod pathtype; +mod pdfoutput; +pub(super) mod picks; +mod place; +mod plugin_runs; +mod plugins; +mod printps; +mod printtiling; +mod puppetwarp; +mod recolor; +mod recovery; +pub(super) mod removeanchors; +mod rotateview; +mod ruler_origin; +mod save; +mod saveext; +mod screenmode; +mod screenshots; +mod script_check; +mod scrub; +mod selectall; +mod slices; +pub(super) mod svg; +mod svgsave; +pub(super) mod synthetic; +pub(super) mod sysclip; +mod sysclip_emf; +mod sysclip_probe; +mod tab_overflow; +mod taskbar; +mod transparencygrid; +mod widthtool; diff --git a/crates/ui-egui/src/tests_adjust.rs b/crates/ui-egui/src/tests/adjust.rs similarity index 100% rename from crates/ui-egui/src/tests_adjust.rs rename to crates/ui-egui/src/tests/adjust.rs diff --git a/crates/ui-egui/src/tests_aisave.rs b/crates/ui-egui/src/tests/aisave.rs similarity index 100% rename from crates/ui-egui/src/tests_aisave.rs rename to crates/ui-egui/src/tests/aisave.rs diff --git a/crates/ui-egui/src/tests_automation.rs b/crates/ui-egui/src/tests/automation.rs similarity index 100% rename from crates/ui-egui/src/tests_automation.rs rename to crates/ui-egui/src/tests/automation.rs diff --git a/crates/ui-egui/src/tests_background.rs b/crates/ui-egui/src/tests/background.rs similarity index 98% rename from crates/ui-egui/src/tests_background.rs rename to crates/ui-egui/src/tests/background.rs index bc0f67735..add53156e 100644 --- a/crates/ui-egui/src/tests_background.rs +++ b/crates/ui-egui/src/tests/background.rs @@ -12,17 +12,17 @@ use vectorcraft_geom::{Rect, shapes}; use crate::control::ControlRequest; use crate::{Services, VectorcraftApp, background, io}; -pub(super) type Files = Arc)>>>; +pub(crate) type Files = Arc)>>>; /// Holds the background writer until opened. #[derive(Clone, Default)] -pub(super) struct Gate(Arc); +pub(crate) struct Gate(Arc); impl Gate { - pub(super) fn open(&self) { + pub(crate) fn open(&self) { self.0.store(true, Ordering::SeqCst); } - pub(super) fn wait(&self) { + pub(crate) fn wait(&self) { while !self.0.load(Ordering::SeqCst) { std::thread::sleep(std::time::Duration::from_millis(1)); } @@ -57,7 +57,7 @@ fn paths(n: usize) -> Document { d } -pub(super) fn written(files: &Files) -> Vec { +pub(crate) fn written(files: &Files) -> Vec { files.lock().unwrap().iter().map(|(p, _)| p.clone()).collect() } diff --git a/crates/ui-egui/src/tests_clipboard.rs b/crates/ui-egui/src/tests/clipboard.rs similarity index 100% rename from crates/ui-egui/src/tests_clipboard.rs rename to crates/ui-egui/src/tests/clipboard.rs diff --git a/crates/ui-egui/src/tests_contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs similarity index 100% rename from crates/ui-egui/src/tests_contextmenu.rs rename to crates/ui-egui/src/tests/contextmenu.rs diff --git a/crates/ui-egui/src/tests_cut.rs b/crates/ui-egui/src/tests/cut.rs similarity index 100% rename from crates/ui-egui/src/tests_cut.rs rename to crates/ui-egui/src/tests/cut.rs diff --git a/crates/ui-egui/src/tests_distortkeys.rs b/crates/ui-egui/src/tests/distortkeys.rs similarity index 100% rename from crates/ui-egui/src/tests_distortkeys.rs rename to crates/ui-egui/src/tests/distortkeys.rs diff --git a/crates/ui-egui/src/tests_docsetup.rs b/crates/ui-egui/src/tests/docsetup.rs similarity index 100% rename from crates/ui-egui/src/tests_docsetup.rs rename to crates/ui-egui/src/tests/docsetup.rs diff --git a/crates/ui-egui/src/tests_document_drag.rs b/crates/ui-egui/src/tests/document_drag.rs similarity index 100% rename from crates/ui-egui/src/tests_document_drag.rs rename to crates/ui-egui/src/tests/document_drag.rs diff --git a/crates/ui-egui/src/tests_font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs similarity index 100% rename from crates/ui-egui/src/tests_font_menu.rs rename to crates/ui-egui/src/tests/font_menu.rs diff --git a/crates/ui-egui/src/tests_fonts.rs b/crates/ui-egui/src/tests/fonts.rs similarity index 100% rename from crates/ui-egui/src/tests_fonts.rs rename to crates/ui-egui/src/tests/fonts.rs diff --git a/crates/ui-egui/src/tests_guide_escape.rs b/crates/ui-egui/src/tests/guide_escape.rs similarity index 100% rename from crates/ui-egui/src/tests_guide_escape.rs rename to crates/ui-egui/src/tests/guide_escape.rs diff --git a/crates/ui-egui/src/tests_home.rs b/crates/ui-egui/src/tests/home.rs similarity index 100% rename from crates/ui-egui/src/tests_home.rs rename to crates/ui-egui/src/tests/home.rs diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests/labels.rs similarity index 100% rename from crates/ui-egui/src/tests_labels.rs rename to crates/ui-egui/src/tests/labels.rs diff --git a/crates/ui-egui/src/tests_lifecycle.rs b/crates/ui-egui/src/tests/lifecycle.rs similarity index 100% rename from crates/ui-egui/src/tests_lifecycle.rs rename to crates/ui-egui/src/tests/lifecycle.rs diff --git a/crates/ui-egui/src/tests_nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs similarity index 100% rename from crates/ui-egui/src/tests_nativemenu.rs rename to crates/ui-egui/src/tests/nativemenu.rs diff --git a/crates/ui-egui/src/tests_nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs similarity index 100% rename from crates/ui-egui/src/tests_nativeoptions.rs rename to crates/ui-egui/src/tests/nativeoptions.rs diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests/numfields.rs similarity index 100% rename from crates/ui-egui/src/tests_numfields.rs rename to crates/ui-egui/src/tests/numfields.rs diff --git a/crates/ui-egui/src/tests_overprint.rs b/crates/ui-egui/src/tests/overprint.rs similarity index 100% rename from crates/ui-egui/src/tests_overprint.rs rename to crates/ui-egui/src/tests/overprint.rs diff --git a/crates/ui-egui/src/tests_paintchips.rs b/crates/ui-egui/src/tests/paintchips.rs similarity index 100% rename from crates/ui-egui/src/tests_paintchips.rs rename to crates/ui-egui/src/tests/paintchips.rs diff --git a/crates/ui-egui/src/tests_pastechords.rs b/crates/ui-egui/src/tests/pastechords.rs similarity index 100% rename from crates/ui-egui/src/tests_pastechords.rs rename to crates/ui-egui/src/tests/pastechords.rs diff --git a/crates/ui-egui/src/tests_pathtype.rs b/crates/ui-egui/src/tests/pathtype.rs similarity index 100% rename from crates/ui-egui/src/tests_pathtype.rs rename to crates/ui-egui/src/tests/pathtype.rs diff --git a/crates/ui-egui/src/tests_pdfoutput.rs b/crates/ui-egui/src/tests/pdfoutput.rs similarity index 100% rename from crates/ui-egui/src/tests_pdfoutput.rs rename to crates/ui-egui/src/tests/pdfoutput.rs diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests/picks.rs similarity index 100% rename from crates/ui-egui/src/tests_picks.rs rename to crates/ui-egui/src/tests/picks.rs diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests/place.rs similarity index 100% rename from crates/ui-egui/src/tests_place.rs rename to crates/ui-egui/src/tests/place.rs diff --git a/crates/ui-egui/src/tests_plugin_runs.rs b/crates/ui-egui/src/tests/plugin_runs.rs similarity index 100% rename from crates/ui-egui/src/tests_plugin_runs.rs rename to crates/ui-egui/src/tests/plugin_runs.rs diff --git a/crates/ui-egui/src/tests_plugins.rs b/crates/ui-egui/src/tests/plugins.rs similarity index 98% rename from crates/ui-egui/src/tests_plugins.rs rename to crates/ui-egui/src/tests/plugins.rs index c934ef672..3bd693afa 100644 --- a/crates/ui-egui/src/tests_plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -9,7 +9,7 @@ use vectorcraft_plugins::wat as tpl; use crate::state::Dialog; use crate::{VectorcraftApp, dialogs, io, menus, theme}; -const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); +const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; fn app() -> VectorcraftApp { diff --git a/crates/ui-egui/src/tests_printps.rs b/crates/ui-egui/src/tests/printps.rs similarity index 100% rename from crates/ui-egui/src/tests_printps.rs rename to crates/ui-egui/src/tests/printps.rs diff --git a/crates/ui-egui/src/tests_printtiling.rs b/crates/ui-egui/src/tests/printtiling.rs similarity index 100% rename from crates/ui-egui/src/tests_printtiling.rs rename to crates/ui-egui/src/tests/printtiling.rs diff --git a/crates/ui-egui/src/tests_puppetwarp.rs b/crates/ui-egui/src/tests/puppetwarp.rs similarity index 100% rename from crates/ui-egui/src/tests_puppetwarp.rs rename to crates/ui-egui/src/tests/puppetwarp.rs diff --git a/crates/ui-egui/src/tests_recolor.rs b/crates/ui-egui/src/tests/recolor.rs similarity index 100% rename from crates/ui-egui/src/tests_recolor.rs rename to crates/ui-egui/src/tests/recolor.rs diff --git a/crates/ui-egui/src/tests_recovery.rs b/crates/ui-egui/src/tests/recovery.rs similarity index 100% rename from crates/ui-egui/src/tests_recovery.rs rename to crates/ui-egui/src/tests/recovery.rs diff --git a/crates/ui-egui/src/tests_removeanchors.rs b/crates/ui-egui/src/tests/removeanchors.rs similarity index 100% rename from crates/ui-egui/src/tests_removeanchors.rs rename to crates/ui-egui/src/tests/removeanchors.rs diff --git a/crates/ui-egui/src/tests_rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs similarity index 100% rename from crates/ui-egui/src/tests_rotateview.rs rename to crates/ui-egui/src/tests/rotateview.rs diff --git a/crates/ui-egui/src/tests_ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs similarity index 100% rename from crates/ui-egui/src/tests_ruler_origin.rs rename to crates/ui-egui/src/tests/ruler_origin.rs diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests/save.rs similarity index 100% rename from crates/ui-egui/src/tests_save.rs rename to crates/ui-egui/src/tests/save.rs diff --git a/crates/ui-egui/src/tests_saveext.rs b/crates/ui-egui/src/tests/saveext.rs similarity index 100% rename from crates/ui-egui/src/tests_saveext.rs rename to crates/ui-egui/src/tests/saveext.rs diff --git a/crates/ui-egui/src/tests_screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs similarity index 100% rename from crates/ui-egui/src/tests_screenmode.rs rename to crates/ui-egui/src/tests/screenmode.rs diff --git a/crates/ui-egui/src/tests_screenshots.rs b/crates/ui-egui/src/tests/screenshots.rs similarity index 100% rename from crates/ui-egui/src/tests_screenshots.rs rename to crates/ui-egui/src/tests/screenshots.rs diff --git a/crates/ui-egui/src/tests_script_check.rs b/crates/ui-egui/src/tests/script_check.rs similarity index 100% rename from crates/ui-egui/src/tests_script_check.rs rename to crates/ui-egui/src/tests/script_check.rs diff --git a/crates/ui-egui/src/tests_scrub.rs b/crates/ui-egui/src/tests/scrub.rs similarity index 100% rename from crates/ui-egui/src/tests_scrub.rs rename to crates/ui-egui/src/tests/scrub.rs diff --git a/crates/ui-egui/src/tests_selectall.rs b/crates/ui-egui/src/tests/selectall.rs similarity index 100% rename from crates/ui-egui/src/tests_selectall.rs rename to crates/ui-egui/src/tests/selectall.rs diff --git a/crates/ui-egui/src/tests_slices.rs b/crates/ui-egui/src/tests/slices.rs similarity index 100% rename from crates/ui-egui/src/tests_slices.rs rename to crates/ui-egui/src/tests/slices.rs diff --git a/crates/ui-egui/src/tests_svg.rs b/crates/ui-egui/src/tests/svg.rs similarity index 100% rename from crates/ui-egui/src/tests_svg.rs rename to crates/ui-egui/src/tests/svg.rs diff --git a/crates/ui-egui/src/tests_svgsave.rs b/crates/ui-egui/src/tests/svgsave.rs similarity index 100% rename from crates/ui-egui/src/tests_svgsave.rs rename to crates/ui-egui/src/tests/svgsave.rs diff --git a/crates/ui-egui/src/tests_synthetic.rs b/crates/ui-egui/src/tests/synthetic.rs similarity index 100% rename from crates/ui-egui/src/tests_synthetic.rs rename to crates/ui-egui/src/tests/synthetic.rs diff --git a/crates/ui-egui/src/tests_sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs similarity index 99% rename from crates/ui-egui/src/tests_sysclip.rs rename to crates/ui-egui/src/tests/sysclip.rs index 38e08fbf7..2e2197481 100644 --- a/crates/ui-egui/src/tests_sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -143,7 +143,7 @@ fn text_and_image_paste_events_paste_once_as_text() { } /// A 4 × 2 px blue PNG. -pub(super) fn blue_png() -> Vec { +pub(crate) fn blue_png() -> Vec { let img = image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])); let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); diff --git a/crates/ui-egui/src/tests_sysclip_emf.rs b/crates/ui-egui/src/tests/sysclip_emf.rs similarity index 100% rename from crates/ui-egui/src/tests_sysclip_emf.rs rename to crates/ui-egui/src/tests/sysclip_emf.rs diff --git a/crates/ui-egui/src/tests_sysclip_probe.rs b/crates/ui-egui/src/tests/sysclip_probe.rs similarity index 100% rename from crates/ui-egui/src/tests_sysclip_probe.rs rename to crates/ui-egui/src/tests/sysclip_probe.rs diff --git a/crates/ui-egui/src/tests_tab_overflow.rs b/crates/ui-egui/src/tests/tab_overflow.rs similarity index 100% rename from crates/ui-egui/src/tests_tab_overflow.rs rename to crates/ui-egui/src/tests/tab_overflow.rs diff --git a/crates/ui-egui/src/tests_taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs similarity index 100% rename from crates/ui-egui/src/tests_taskbar.rs rename to crates/ui-egui/src/tests/taskbar.rs diff --git a/crates/ui-egui/src/tests_transparencygrid.rs b/crates/ui-egui/src/tests/transparencygrid.rs similarity index 100% rename from crates/ui-egui/src/tests_transparencygrid.rs rename to crates/ui-egui/src/tests/transparencygrid.rs diff --git a/crates/ui-egui/src/tests_widthtool.rs b/crates/ui-egui/src/tests/widthtool.rs similarity index 100% rename from crates/ui-egui/src/tests_widthtool.rs rename to crates/ui-egui/src/tests/widthtool.rs From 37f2376899bd1ab94c822def450b0662a02848ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:45 +0000 Subject: [PATCH 101/199] refactor(three-d): move solid.rs test files to solid/ --- crates/three-d/src/solid.rs | 2 -- crates/three-d/src/{tests_solid.rs => solid/tests.rs} | 0 crates/three-d/src/{ => solid}/tests_inflate_shading.rs | 0 3 files changed, 2 deletions(-) rename crates/three-d/src/{tests_solid.rs => solid/tests.rs} (100%) rename crates/three-d/src/{ => solid}/tests_inflate_shading.rs (100%) diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 140f2a1ef..eb3814b9a 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -1606,9 +1606,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result } #[cfg(test)] -#[path = "tests_solid.rs"] mod tests; #[cfg(test)] -#[path = "tests_inflate_shading.rs"] mod tests_inflate_shading; diff --git a/crates/three-d/src/tests_solid.rs b/crates/three-d/src/solid/tests.rs similarity index 100% rename from crates/three-d/src/tests_solid.rs rename to crates/three-d/src/solid/tests.rs diff --git a/crates/three-d/src/tests_inflate_shading.rs b/crates/three-d/src/solid/tests_inflate_shading.rs similarity index 100% rename from crates/three-d/src/tests_inflate_shading.rs rename to crates/three-d/src/solid/tests_inflate_shading.rs From 0d282bad012439a89036609b0f818e63b4243055 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:45 +0000 Subject: [PATCH 102/199] refactor(effects): move solids.rs tests to solids/tests.rs --- crates/effects/src/solids.rs | 1 - crates/effects/src/{tests_solids.rs => solids/tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/effects/src/{tests_solids.rs => solids/tests.rs} (100%) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index cd09153c6..a68b7f579 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -358,5 +358,4 @@ pub fn solid_art(n: &Node) -> Option { } #[cfg(test)] -#[path = "tests_solids.rs"] mod tests; diff --git a/crates/effects/src/tests_solids.rs b/crates/effects/src/solids/tests.rs similarity index 100% rename from crates/effects/src/tests_solids.rs rename to crates/effects/src/solids/tests.rs From a2ee43381e71f86ea80e74c72fcb0914d8a29bd7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:45 +0000 Subject: [PATCH 103/199] refactor(eps): move legacy_ai.rs tests to legacy_ai/tests.rs --- crates/eps/src/legacy_ai.rs | 1 - crates/eps/src/{tests_legacy_ai.rs => legacy_ai/tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/eps/src/{tests_legacy_ai.rs => legacy_ai/tests.rs} (100%) diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index a2d24a07f..fde4cb505 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -1476,5 +1476,4 @@ fn average(g: &Gradient) -> Color { } #[cfg(test)] -#[path = "tests_legacy_ai.rs"] mod tests; diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/legacy_ai/tests.rs similarity index 100% rename from crates/eps/src/tests_legacy_ai.rs rename to crates/eps/src/legacy_ai/tests.rs From e8417d6c6939f9b65687a8aa931064a60990d072 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:45 +0000 Subject: [PATCH 104/199] refactor(tools): move text.rs tests to text/tests.rs --- crates/tools/src/text.rs | 1 - crates/tools/src/{text_tests.rs => text/tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/tools/src/{text_tests.rs => text/tests.rs} (100%) diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 5e29d69fe..12e9e80bf 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -1587,5 +1587,4 @@ fn ime_surrounding_window( } #[cfg(test)] -#[path = "text_tests.rs"] mod tests; diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text/tests.rs similarity index 100% rename from crates/tools/src/text_tests.rs rename to crates/tools/src/text/tests.rs From 52ff7aea1a7ac137af02c9e34cdd28cfbfe642cd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:45 +0000 Subject: [PATCH 105/199] refactor(engine): move cmd/effectcmd.rs lighting preset tests to cmd/effectcmd/ --- crates/engine/src/cmd/effectcmd.rs | 1 - .../lighting_presets_tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/engine/src/cmd/{tests_lighting_presets.rs => effectcmd/lighting_presets_tests.rs} (100%) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 7f9664482..de6776586 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -713,7 +713,6 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -#[path = "tests_lighting_presets.rs"] mod lighting_presets_tests; #[cfg(test)] diff --git a/crates/engine/src/cmd/tests_lighting_presets.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs similarity index 100% rename from crates/engine/src/cmd/tests_lighting_presets.rs rename to crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs From 1fa3699634f8a71860c275e3ac0b255436941d22 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:45 +0000 Subject: [PATCH 106/199] refactor(ui-egui): move layout_migration.rs tests to layout_migration/tests.rs --- crates/ui-egui/src/layout_migration.rs | 1 - .../{layout_migration_tests.rs => layout_migration/tests.rs} | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) rename crates/ui-egui/src/{layout_migration_tests.rs => layout_migration/tests.rs} (99%) diff --git a/crates/ui-egui/src/layout_migration.rs b/crates/ui-egui/src/layout_migration.rs index 76faee6a0..e5c6b1f9c 100644 --- a/crates/ui-egui/src/layout_migration.rs +++ b/crates/ui-egui/src/layout_migration.rs @@ -456,5 +456,4 @@ pub(crate) fn has_canvas(layout: &Layout) -> bool { } #[cfg(test)] -#[path = "layout_migration_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/layout_migration_tests.rs b/crates/ui-egui/src/layout_migration/tests.rs similarity index 99% rename from crates/ui-egui/src/layout_migration_tests.rs rename to crates/ui-egui/src/layout_migration/tests.rs index 615108598..a717783bd 100644 --- a/crates/ui-egui/src/layout_migration_tests.rs +++ b/crates/ui-egui/src/layout_migration/tests.rs @@ -8,7 +8,7 @@ use serde_json::json; /// column 1; Libraries docked on the icon column; Symbols moved beside Swatches, then closed /// (0.16 then showed it in the icon column again); /// Swatches collapsed to its tab; the dock widened to 380; saved as workspace "Migration Test". -const SAVED_BY_0_16: &str = include_str!("../tests/fixtures/ui-before-canvas-layout.json"); +const SAVED_BY_0_16: &str = include_str!("../../tests/fixtures/ui-before-canvas-layout.json"); fn read(saved: &Value) -> ReadPreferences { UiState::read_preferences(&serde_json::to_vec(saved).unwrap()).unwrap() From 6909734d9ee7ce4049a1f3004b1349e5c5248dbd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:45 +0000 Subject: [PATCH 107/199] refactor(ui-egui): move panel_docking.rs test files to panel_docking/ --- crates/ui-egui/src/panel_docking.rs | 2 -- .../native_tests.rs} | 0 .../src/{panel_docking_tests.rs => panel_docking/tests.rs} | 0 3 files changed, 2 deletions(-) rename crates/ui-egui/src/{panel_docking_native_tests.rs => panel_docking/native_tests.rs} (100%) rename crates/ui-egui/src/{panel_docking_tests.rs => panel_docking/tests.rs} (100%) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index d41c470bc..eba97d88f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1265,9 +1265,7 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } #[cfg(test)] -#[path = "panel_docking_tests.rs"] mod tests; #[cfg(all(test, not(target_arch = "wasm32")))] -#[path = "panel_docking_native_tests.rs"] mod native_tests; diff --git a/crates/ui-egui/src/panel_docking_native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs similarity index 100% rename from crates/ui-egui/src/panel_docking_native_tests.rs rename to crates/ui-egui/src/panel_docking/native_tests.rs diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking/tests.rs similarity index 100% rename from crates/ui-egui/src/panel_docking_tests.rs rename to crates/ui-egui/src/panel_docking/tests.rs From 2b70dc2d9c0f06ca9478719db7b4e838cdf93d4f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:45 +0000 Subject: [PATCH 108/199] refactor(ui-egui): move dialogs/revolve.rs tests to dialogs/revolve/tests.rs --- crates/ui-egui/src/dialogs/revolve.rs | 1 - .../ui-egui/src/dialogs/{tests_revolve.rs => revolve/tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/ui-egui/src/dialogs/{tests_revolve.rs => revolve/tests.rs} (100%) diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index e6f685f28..7146117b0 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -582,5 +582,4 @@ fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool } #[cfg(test)] -#[path = "tests_revolve.rs"] mod tests; diff --git a/crates/ui-egui/src/dialogs/tests_revolve.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs similarity index 100% rename from crates/ui-egui/src/dialogs/tests_revolve.rs rename to crates/ui-egui/src/dialogs/revolve/tests.rs From 142e19abe3f493b83ce6a47167a335fa2a25c964 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:20:51 +0000 Subject: [PATCH 109/199] refactor(doc): move lib.rs skip to skip.rs --- crates/doc/src/lib.rs | 18 +----------------- crates/doc/src/skip.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 17 deletions(-) create mode 100644 crates/doc/src/skip.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b06ed03f2..e87373dad 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -68,23 +68,7 @@ use std::collections::BTreeMap; use std::sync::Arc; /// `skip_serializing_if` predicates: fields equal to their serde default are not written. -pub(crate) mod skip { - pub fn is_default(v: &T) -> bool { - *v == T::default() - } - pub fn is_true(v: &bool) -> bool { - *v - } - pub fn is_one(v: &f32) -> bool { - *v == 1.0 - } - pub fn is_empty_vec(v: &[T]) -> bool { - v.is_empty() - } - pub fn is_empty_map(v: &serde_json::Map) -> bool { - v.is_empty() - } -} +pub(crate) mod skip; pub use appearance::StrokeGradientMode; pub use appearance::{ diff --git a/crates/doc/src/skip.rs b/crates/doc/src/skip.rs new file mode 100644 index 000000000..15f294c00 --- /dev/null +++ b/crates/doc/src/skip.rs @@ -0,0 +1,15 @@ +pub fn is_default(v: &T) -> bool { + *v == T::default() +} +pub fn is_true(v: &bool) -> bool { + *v +} +pub fn is_one(v: &f32) -> bool { + *v == 1.0 +} +pub fn is_empty_vec(v: &[T]) -> bool { + v.is_empty() +} +pub fn is_empty_map(v: &serde_json::Map) -> bool { + v.is_empty() +} From f1d427762b7da11588c592121915b0af738b7f6a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:32:10 +0000 Subject: [PATCH 110/199] style: cargo fmt on files moved without formatting --- apps/vectorcraft/src/gpu/tests.rs | 12 +- apps/vectorcraft/src/logging/tests.rs | 4 +- crates/color/src/gradient/tests.rs | 8 +- crates/color/src/palette_io/tests.rs | 6 +- crates/doc/src/hit/tests.rs | 9 +- crates/doc/src/node/tests.rs | 11 +- crates/doc/src/tests.rs | 10 +- crates/effects/src/group/tests.rs | 9 +- crates/engine/src/cmd/effectcmd/tests.rs | 13 +- crates/engine/src/cmd/graph/tests.rs | 67 ++----- crates/engine/src/cmd/typecmd/area_tests.rs | 65 +++---- crates/engine/src/lib.rs | 6 +- crates/engine/src/tests/clipflavours.rs | 2 +- crates/engine/src/tests/cmdsplit.rs | 2 +- crates/engine/src/tests/draw2.rs | 2 +- crates/engine/src/tests/editcolors.rs | 2 +- crates/engine/src/tests/flatpresets.rs | 2 +- crates/engine/src/tests/flatpreview.rs | 2 +- crates/engine/src/tests/halftone.rs | 2 +- crates/engine/src/tests/legacy_ai.rs | 2 +- crates/engine/src/tests/link_paths.rs | 2 +- crates/engine/src/tests/linkwatch.rs | 2 +- crates/engine/src/tests/opacitymask.rs | 2 +- crates/engine/src/tests/prefs.rs | 2 +- crates/engine/src/tests/printpresets.rs | 2 +- crates/engine/src/tests/printtiling.rs | 2 +- crates/engine/src/tests/recolor.rs | 2 +- crates/engine/src/tests/recovery.rs | 2 +- crates/engine/src/tests/registration.rs | 2 +- crates/engine/src/tests/save.rs | 2 +- crates/engine/src/tests/saveoptions.rs | 2 +- crates/engine/src/tests/shortcutset.rs | 2 +- crates/engine/src/tests/smartguides.rs | 2 +- crates/engine/src/tests/units.rs | 2 +- crates/pathops/src/planar/tests.rs | 11 +- crates/pdf/src/tests.rs | 4 +- crates/render/src/fx/tests.rs | 46 ++--- crates/render/src/pattern/tests.rs | 17 +- crates/render/src/tests/charstroke.rs | 2 +- crates/render/src/tests/memory.rs | 2 +- crates/text/src/tests/sysfonts.rs | 2 +- crates/three-d/src/lib.rs | 1 - crates/three-d/src/tests.rs | 2 +- crates/tools/src/builder/tests.rs | 3 +- crates/tools/src/corners/tests.rs | 3 +- crates/tools/src/direct/tests.rs | 5 +- crates/tools/src/draw2/gesture/tests.rs | 6 +- crates/tools/src/guides/tests.rs | 19 +- crates/tools/src/pen/tests.rs | 31 ++-- crates/tools/src/rulerguide/tests.rs | 6 +- crates/tools/src/select/tests.rs | 18 +- crates/tools/src/shape/tests.rs | 5 +- crates/ui-egui/src/canvas/tests.rs | 165 ++++++------------ crates/ui-egui/src/chrome/tests.rs | 28 +-- crates/ui-egui/src/control/tests.rs | 7 +- .../src/dialogs/brush_options/tests.rs | 10 +- .../ui-egui/src/dialogs/color_picker/tests.rs | 16 +- .../ui-egui/src/dialogs/find_links/tests.rs | 3 +- crates/ui-egui/src/dialogs/freehand/tests.rs | 13 +- .../src/dialogs/missing_fonts/tests.rs | 3 +- .../ui-egui/src/dialogs/width_point/tests.rs | 3 +- crates/ui-egui/src/font_menu/tests.rs | 22 +-- crates/ui-egui/src/i18n.rs | 71 ++++++-- crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/menus/tests.rs | 3 +- crates/ui-egui/src/panels/appearance/tests.rs | 7 +- crates/ui-egui/src/panels/artboards/tests.rs | 26 +-- crates/ui-egui/src/panels/brushes/tests.rs | 18 +- crates/ui-egui/src/panels/color/tests.rs | 3 +- crates/ui-egui/src/panels/gradient/tests.rs | 13 +- .../ui-egui/src/panels/image_trace/tests.rs | 21 +-- crates/ui-egui/src/panels/layers/tests.rs | 6 +- .../ui-egui/src/panels/library_panel/tests.rs | 7 +- crates/ui-egui/src/panels/paragraph/tests.rs | 8 +- crates/ui-egui/src/panels/properties/tests.rs | 23 +-- crates/ui-egui/src/panels/swatches/tests.rs | 15 +- crates/ui-egui/src/panels/tabs/tests.rs | 3 +- crates/ui-egui/src/prefs_dialog/tests.rs | 3 +- crates/ui-egui/src/shortcuts/tests.rs | 40 ++--- crates/ui-egui/src/toolbar/tests.rs | 8 +- crates/ui-egui/src/widgets/look_tests.rs | 3 +- 81 files changed, 352 insertions(+), 648 deletions(-) diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 8a473e664..906e35fb6 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -512,12 +512,9 @@ fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { ] { assert!(in_graphics_stack(file), "{file}"); } - for file in [ - "apps/vectorcraft/src/main.rs", - "/home/wgpu/vectorcraft/crates/ui-egui/src/lib.rs", - "egui-0.36.2/src/context.rs", - "winit-0.30.12/src/x.rs", - ] { + for file in + ["apps/vectorcraft/src/main.rs", "/home/wgpu/vectorcraft/crates/ui-egui/src/lib.rs", "egui-0.36.2/src/context.rs", "winit-0.30.12/src/x.rs"] + { assert!(!in_graphics_stack(file), "{file}"); } } @@ -541,8 +538,7 @@ fn a_normal_exit_or_another_failure_does_not_restart() { let startup = Startup::default(); assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); // No adapter was picked (none can show the window): the error is returned, nothing restarts. - let none = - eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); + let none = eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); assert_eq!( finish(Ok(Err(none)), &startup), Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) diff --git a/apps/vectorcraft/src/logging/tests.rs b/apps/vectorcraft/src/logging/tests.rs index 818f6db77..a1b54b353 100644 --- a/apps/vectorcraft/src/logging/tests.rs +++ b/apps/vectorcraft/src/logging/tests.rs @@ -255,9 +255,7 @@ fn records_from_many_threads_are_whole_lines() { let l = std::sync::Arc::clone(&logger); std::thread::spawn(move || { for i in 0..100 { - l.log( - &log::Record::builder().level(log::Level::Info).target("vectorcraft").args(format_args!("thread {t} record {i}")).build(), - ); + l.log(&log::Record::builder().level(log::Level::Info).target("vectorcraft").args(format_args!("thread {t} record {i}")).build()); } }) }) diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 495980e05..19e9f44fc 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -16,13 +16,7 @@ fn increasing_samples_match_single_samples() { let stop = |offset: f32, hex: &str, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; let g = Gradient::new( GradientKind::Linear, - vec![ - stop(0.1, "#ff0000", 0.3), - stop(0.4, "#00ff00", 0.7), - stop(0.4, "#0000ff", 0.5), - stop(0.2, "#ffff00", 0.5), - stop(0.9, "#000000", 0.5), - ], + vec![stop(0.1, "#ff0000", 0.3), stop(0.4, "#00ff00", 0.7), stop(0.4, "#0000ff", 0.5), stop(0.2, "#ffff00", 0.5), stop(0.9, "#000000", 0.5)], ); let ts: Vec = (0..=40).map(|k| k as f32 / 40.0).collect(); let one_by_one: Vec<_> = ts.iter().map(|&t| g.sample_with(t, Color::lerp_keep_model)).collect(); diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs index 7bfd7623e..b0342d7a5 100644 --- a/crates/color/src/palette_io/tests.rs +++ b/crates/color/src/palette_io/tests.rs @@ -136,8 +136,7 @@ fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() { assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback"); // Colors and groups share names; an unnamed group is "Color Group". let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap(); - let groups: Vec<(&str, Vec<&str>)> = - lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + let groups: Vec<(&str, Vec<&str>)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]); } @@ -299,8 +298,7 @@ fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() { assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0"); assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process"); // A group start ends the open group, and groups and colors share names. - let groups: Vec<(&str, Vec<&str>)> = - lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + let groups: Vec<(&str, Vec<&str>)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]); assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white"); } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 743eeed7e..43db8ee16 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -552,8 +552,7 @@ world", fn objects_at_lists_the_stack() { let mut d = Document::new(200.0, 200.0); let l = d.layers[0].id; - let rect = - |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let rect = |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); let a = rect(&mut d, 0.0); let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0)); let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id()); @@ -702,8 +701,7 @@ fn objects_at_finds_symbol_instances_by_their_art() { let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art()); d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) }); let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id()); - let instance = - |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) }); + let instance = |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) }); d.insert(Some(layer), 0, instance(top, 0.0)).unwrap(); d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap(); let opt = HitOptions::default(); @@ -768,8 +766,7 @@ fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId { fn locked_objects_at_lists_the_locked_stack() { let mut d = Document::new(200.0, 200.0); let l = d.layers[0].id; - let rect = - |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let rect = |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0)); let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b)); let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id()); diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 32ff35e6a..3e7a399f2 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -112,8 +112,7 @@ fn corner_kinds_save_only_when_some_corner_isnt_round() { assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"])); assert_eq!(serde_json::from_value::(json).unwrap(), l); // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. - let p: LiveShape = - serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); + let p: LiveShape = serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; @@ -395,8 +394,7 @@ fn type_visual_bounds_grow_with_the_character_stroke() { /// A group holding a path, a compound, an image and a nested group, as a file writes it. fn nested_group() -> Node { let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let image = - ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; @@ -417,10 +415,7 @@ fn kinds_read_back_with_type_first_or_anywhere() { let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#; assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::Group { children: vec![], clip: true }); let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#; - assert_eq!( - serde_json::from_str::(kind).unwrap(), - NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) } - ); + assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) }); let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#; assert!(matches!(serde_json::from_str::(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3)); // Defaults as before. diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 6ef4260a9..a13fb2168 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -179,8 +179,7 @@ fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] { - d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art())) - .unwrap(); + d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art())).unwrap(); } assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None)); assert!(!d.on_locked_artboard(sub)); @@ -378,11 +377,8 @@ fn setup_round_trips_and_old_files_load() { #[test] fn char_position_round_trips() { - let st = CharStyle { - position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }), - small_caps: Some(75.0), - ..CharStyle::default() - }; + let st = + CharStyle { position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }), small_caps: Some(75.0), ..CharStyle::default() }; let v = serde_json::to_value(&st).unwrap(); assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0})); assert_eq!(serde_json::from_value::(v).unwrap(), st); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 9f7199a12..c3bf4bfe3 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -13,11 +13,7 @@ fn skip_without_craft_fonts() -> bool { } fn sq(id: u64, x: f64, color: Color) -> Arc { - Arc::new(Node::path( - NodeId(id), - shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), - Appearance::basic(Paint::solid(color), Paint::None, 0.0), - )) + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(Paint::solid(color), Paint::None, 0.0))) } fn group_with(effect: &str) -> Node { @@ -271,8 +267,7 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; for (k, paint) in paints.into_iter().enumerate() { - let square = - Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); + let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); let mut g = Node::group(NodeId(10), vec![square]); let params = serde_json::json!({"moveH": 30, "copies": 2}); g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index a2b8a1490..c23c578d0 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -95,11 +95,7 @@ fn an_earlier_perspective_strength_is_set_as_its_angle() { let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap(); let angle = angle_of(60.0); assert!((angle - 30.12).abs() < 0.01, "{angle}"); - assert!( - params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, - "{}", - params(&s) - ); + assert!(params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{}", params(&s)); s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap(); assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12); // A file of then: the strength stored; setting an angle replaces it. @@ -114,11 +110,8 @@ fn an_earlier_perspective_strength_is_set_as_its_angle() { #[test] fn an_earlier_light_direction_is_set_as_rotation_and_height() { let (mut s, id) = session_with_rect(); - s.execute( - "effect.apply", - &json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}}), - ) - .unwrap(); + s.execute("effect.apply", &json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}})) + .unwrap(); let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); // Straight from behind the object, level with it. assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s)); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index e6d33bfaa..36c9589cd 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -17,9 +17,7 @@ fn fmt_value_does_not_saturate_beyond_i64() { fn graph(s: &mut Session, ty: &str) -> NodeId { let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; NodeId( - s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), + s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(), ) } @@ -326,11 +324,7 @@ fn painting_a_line_series_stroke_sets_its_persistent_colour() { assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none()); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); assert!( - group(s.doc().unwrap().doc.node(id).unwrap(), "2024") - .children() - .unwrap() - .iter() - .any(|n| n.appearance.stroke_paint().color() == Some(blue)) + group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap().iter().any(|n| n.appearance.stroke_paint().color() == Some(blue)) ); } @@ -539,8 +533,7 @@ fn a_row_with_an_empty_label_keeps_its_values_in_place() { fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = - s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap(); + let r = s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]])); // A label followed by empty cells is a header with no series names, as before blanks existed. @@ -560,8 +553,7 @@ fn a_huge_ragged_grid_is_capped() { fn a_label_only_row_stays_with_blank_values() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let r = - s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); + let r = s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]); @@ -743,9 +735,7 @@ fn bar_graphs_take_stacked_bar_series() { s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5"; let id = NodeId( - s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), + s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(), ); assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err()); s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap(); @@ -988,13 +978,7 @@ fn data_without_a_right_axis_series_drops_its_assignment() { /// Bounds of the straight lines in the Axes group. fn axis_lines(s: &Session, id: NodeId) -> Vec { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - group(&n, "Axes") - .children() - .unwrap() - .iter() - .filter(|c| matches!(c.kind, NodeKind::Path { .. })) - .filter_map(|c| c.geometric_bounds()) - .collect() + group(&n, "Axes").children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).filter_map(|c| c.geometric_bounds()).collect() } fn axis_texts(s: &Session, id: NodeId) -> Vec { @@ -1560,8 +1544,7 @@ fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last( assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}"); let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) }; assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy"); - let (clip, copy) = - (children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap()); + let (clip, copy) = (children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap()); assert_eq!(clip.y0, 160.0, "cut off at the value"); // Its sides and its foot, strokes included, are not cut. assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}"); @@ -1580,13 +1563,8 @@ fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last( let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]); d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) }); let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() }; - let g = GraphSpec { - rows: vec![vec![35.0]], - axis_min: Some(0.0), - axis_max: Some(50.0), - series_columns: vec![Some(options)], - ..GraphSpec::default() - }; + let g = + GraphSpec { rows: vec![vec![35.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_columns: vec![Some(options)], ..GraphSpec::default() }; let out = super::generate(&mut d, &g); let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); let last = column.children().unwrap().last().unwrap().clone(); @@ -1963,13 +1941,8 @@ fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_b let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]); d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) }); let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() }; - let g = GraphSpec { - rows: vec![vec![35.0]], - axis_min: Some(0.0), - axis_max: Some(50.0), - series_columns: vec![Some(options)], - ..GraphSpec::default() - }; + let g = + GraphSpec { rows: vec![vec![35.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_columns: vec![Some(options)], ..GraphSpec::default() }; let out = super::generate(&mut d, &g); let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0); @@ -2115,9 +2088,7 @@ fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() { fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId { NodeId( - s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] - .as_u64() - .unwrap(), + s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(), ) } @@ -2152,10 +2123,8 @@ fn a_bar_in_a_label_breaks_the_line() { let legend: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect(); let NodeKind::Text(t) = &legend[0].kind else { panic!() }; assert_eq!(t.plain_text(), "Total\nSales\n2023"); - let (a, b) = ( - series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(), - series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(), - ); + let (a, b) = + (series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(), series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap()); assert!((b.y0 - a.y0 - 3.0 * 9.0 * 1.8).abs() < 1e-6, "{a:?} {b:?}"); } @@ -2269,10 +2238,7 @@ fn legends_in_wedges_put_the_series_labels_inside_the_pie() { s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); let g = spec(&s, id); - assert_eq!( - (g.pie_legend_in_wedges, g.pie_position, g.pie_sort), - (true, vectorcraft_doc::PiePosition::Stacked, vectorcraft_doc::PieSort::All) - ); + assert_eq!((g.pie_legend_in_wedges, g.pie_position, g.pie_sort), (true, vectorcraft_doc::PiePosition::Stacked, vectorcraft_doc::PieSort::All)); let v = serde_json::to_value(GraphSpec::default()).unwrap(); for k in ["pieLegendInWedges", "piePosition", "pieSort"] { assert!(v.get(k).is_none(), "{k}"); @@ -2464,8 +2430,7 @@ fn the_legend_across_top_keeps_above_labels_over_the_plot() { s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let axes_top = group(&n, "Axes").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y0).fold(f64::MAX, f64::min); - let legend_bottom = - group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y1).fold(f64::MIN, f64::max); + let legend_bottom = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y1).fold(f64::MIN, f64::max); assert!(legend_bottom < axes_top, "{legend_bottom} above {axes_top}"); // No legend: nothing across the top either. s.execute("graph.setType", &json!({"legend": false})).unwrap(); diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 6b3e44960..a844d0083 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -74,9 +74,8 @@ fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { use vectorcraft_doc::Kinsoku; let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あカッ", "area": {"width": 40, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あカッ", "area": {"width": 40, "height": 100}})).unwrap()["id"].as_u64().unwrap(); let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { NodeKind::Text(t) => t.para.clone(), _ => panic!("text"), @@ -195,9 +194,8 @@ fn system_layout_sets_the_document_and_every_text_object() { _ => panic!("text"), }; let setup = |s: &Session| s.doc().unwrap().doc.setup.clone(); - let advances = |s: &Session| { - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).glyphs.iter().map(|g| g.advance).collect::>() - }; + let advances = + |s: &Session| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).glyphs.iter().map(|g| g.advance).collect::>(); assert!(!setup(&s).system_layout); assert!(!text(&s, id).system_layout && !text(&s, other).system_layout); assert!(serde_json::to_value(setup(&s)).unwrap().get("systemLayout").is_none()); @@ -409,10 +407,9 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { use vectorcraft_doc::text::{Bullet, ParaList}; let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = - s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] - .as_u64() - .unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { NodeKind::Text(t) => (**t).clone(), _ => panic!("text"), @@ -475,10 +472,9 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let id = - s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] - .as_u64() - .unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { NodeKind::Text(t) => (**t).clone(), _ => panic!("text"), @@ -637,10 +633,9 @@ fn convert_to_text_writes_the_markers_where_they_were_drawn() { // text to the next default stop, and 11 to its stop at 72 pt, whose leader the number's tab // draws neither before nor after. let items = (1..=11).map(|i| format!("Item {i}")).collect::>().join("\n"); - let id = - s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()["id"] - .as_u64() - .unwrap(); + let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()["id"] + .as_u64() + .unwrap(); s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); let eleven = 9 * 7 + 8; s.execute("text.tabs.set", &json!({"ids": [id], "start": eleven, "end": eleven, "stops": [{"position": 72, "leader": "."}]})).unwrap(); @@ -669,9 +664,8 @@ fn convert_to_text_writes_the_markers_where_they_were_drawn() { ("One ", 300, json!([{"position": 200, "align": "right"}]), vec![(200.0, right, String::new())]), ("The quick brown fox jumps over the lazy dog", 230, json!([{"position": 100, "align": "right"}, {"position": 150}]), vec![]), ] { - let id = s - .execute("text.create", &json!({"x": 10, "y": 600, "size": 30, "text": text_in, "area": {"width": width, "height": 300}})) - .unwrap()["id"] + let id = s.execute("text.create", &json!({"x": 10, "y": 600, "size": 30, "text": text_in, "area": {"width": width, "height": 300}})).unwrap() + ["id"] .as_u64() .unwrap(); s.execute("text.setFormat", &json!({"ids": [id], "list": "number", "composer": "singleLine"})).unwrap(); @@ -680,11 +674,7 @@ fn convert_to_text_writes_the_markers_where_they_were_drawn() { s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); assert_eq!(drawn_glyphs(&s, id), before, "{text_in:?}"); let after = tabs(&s, id, 0); - assert_eq!( - (after.first().map(|t| (t.1, t.2.as_str())), after.get(1..).map(<[_]>::to_vec)), - (Some((left, "")), Some(kept)), - "{text_in:?}" - ); + assert_eq!((after.first().map(|t| (t.1, t.2.as_str())), after.get(1..).map(<[_]>::to_vec)), (Some((left, "")), Some(kept)), "{text_in:?}"); } // Overset items, which the layout leaves out, get the stop by the same rule: a 30 pt number is // wider than the hanging indent, a 12 pt bullet is not. @@ -703,8 +693,7 @@ fn convert_to_text_writes_the_markers_where_they_were_drawn() { } // Point type and vertical type. for vertical in [false, true] { - let id = - s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + let id = s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); let before = drawn_glyphs(&s, id); s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); @@ -771,9 +760,8 @@ fn threaded_numbers_convert_to_text_as_the_story_counted_them() { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>() }; let thread = |s: &mut Session, text: &str| { - let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"] - .as_u64() - .unwrap(); + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); s.execute("text.thread.create", &json!({})).unwrap(); @@ -814,9 +802,8 @@ fn threaded_numbers_convert_to_text_as_the_story_counted_them() { assert!(second.para_at(0).list.is_none(), "the third paragraph's part in the second frame leaves the list too"); // One item that fits in the first of three frames: the two empty frames after it leave the // list too, so the re-flowed thread draws no number before the converted one. - let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Item", "area": {"width": 150, "height": 60}})).unwrap()["id"] - .as_u64() - .unwrap(); + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Item", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); @@ -1049,9 +1036,8 @@ fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); // Exercise ink bounds with bundled glyphs; Japanese outlines require optional craft-fonts. - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A\nB", "area": {"width": 200, "height": 100}})).unwrap()["id"] - .as_u64() - .unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A\nB", "area": {"width": 200, "height": 100}})).unwrap()["id"].as_u64().unwrap(); let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { NodeKind::Text(t) => (**t).clone(), _ => panic!("text"), @@ -1387,9 +1373,8 @@ fn area_options_columns_change_layout() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); let text = "word ".repeat(80); - let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 120}})).unwrap()["id"] - .as_u64() - .unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 120}})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [id]})).unwrap(); let q = s.execute("text.areaOptions", &json!({})).unwrap(); assert_eq!((q["rows"].as_u64(), q["columns"].as_u64()), (Some(1), Some(1))); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5707e8913..8312ad56f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2597,7 +2597,11 @@ pub(crate) fn adding_fonts() -> std::sync::MutexGuard<'static, ()> { } #[cfg(test)] -use tests::{adjust as tests_adjust, clip as tests_clip, colormgmt as tests_colormgmt, expand as tests_expand, large_edits as tests_large_edits, links as tests_links, linkspanel as tests_linkspanel, menucmds as tests_menucmds, outlinestroke as tests_outlinestroke, persp_select as tests_persp_select, textimport as tests_textimport}; +use tests::{ + adjust as tests_adjust, clip as tests_clip, colormgmt as tests_colormgmt, expand as tests_expand, large_edits as tests_large_edits, + links as tests_links, linkspanel as tests_linkspanel, menucmds as tests_menucmds, outlinestroke as tests_outlinestroke, + persp_select as tests_persp_select, textimport as tests_textimport, +}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/tests/clipflavours.rs b/crates/engine/src/tests/clipflavours.rs index 4751b7f6b..a72e87c1c 100644 --- a/crates/engine/src/tests/clipflavours.rs +++ b/crates/engine/src/tests/clipflavours.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::{NodeKind, TextObject}; use vectorcraft_geom::Point; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use crate::*; use crate::cmd::clipboard::{AI, BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/cmdsplit.rs b/crates/engine/src/tests/cmdsplit.rs index 8c58d76c5..af94ea3f3 100644 --- a/crates/engine/src/tests/cmdsplit.rs +++ b/crates/engine/src/tests/cmdsplit.rs @@ -3,8 +3,8 @@ use serde_json::json; -use crate::*; use crate::cmd::{leaf_targets, paint_targets, unique_name}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 7577991af..5cefe68f3 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -5,8 +5,8 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Point}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; -use crate::*; use crate::tooling::{UiRequest, ViewInfo}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/editcolors.rs b/crates/engine/src/tests/editcolors.rs index 680fa4217..d17ded2a7 100644 --- a/crates/engine/src/tests/editcolors.rs +++ b/crates/engine/src/tests/editcolors.rs @@ -5,8 +5,8 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Node; use vectorcraft_tools::{PointerEvent, PointerKind}; -use crate::*; use crate::tooling::ViewInfo; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/flatpresets.rs b/crates/engine/src/tests/flatpresets.rs index 2c73887be..3eef893bb 100644 --- a/crates/engine/src/tests/flatpresets.rs +++ b/crates/engine/src/tests/flatpresets.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; -use crate::*; use crate::cmd::{FlattenOptions, FlattenerPreset}; +use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap() diff --git a/crates/engine/src/tests/flatpreview.rs b/crates/engine/src/tests/flatpreview.rs index 5ea702ee1..9487da133 100644 --- a/crates/engine/src/tests/flatpreview.rs +++ b/crates/engine/src/tests/flatpreview.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; -use crate::*; use crate::cmd::flatten::Highlight; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/halftone.rs b/crates/engine/src/tests/halftone.rs index 718c7c523..caef57ddb 100644 --- a/crates/engine/src/tests/halftone.rs +++ b/crates/engine/src/tests/halftone.rs @@ -2,9 +2,9 @@ use serde_json::json; -use crate::*; use crate::tests_adjust::{fill_hex, node, red_rect, session}; use crate::tests_menucmds::{place_undecodable_png, warns_undecoded}; +use crate::*; #[test] fn halftone_turns_art_into_dots_by_tone() { diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index fdbb29a35..52706abbd 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -3,9 +3,9 @@ use serde_json::{Value, json}; +use crate::tests_links::Folder; use crate::tests_menucmds::{place_undecodable_png, place_undecodable_png_sized}; use crate::*; -use crate::tests_links::Folder; /// Three artboards (named A, B and C) with a rectangle on each, on two layers: the first /// rectangle's group (with a second one) on `Layer 1`, the others on `Top`. diff --git a/crates/engine/src/tests/link_paths.rs b/crates/engine/src/tests/link_paths.rs index 750410de3..2c96be935 100644 --- a/crates/engine/src/tests/link_paths.rs +++ b/crates/engine/src/tests/link_paths.rs @@ -5,9 +5,9 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; -use crate::*; use crate::cmd::fileio::touched; use crate::tests_links::{Folder, RED, place, png, session}; +use crate::*; /// Paths to other computers (and, on Unix, a local file written as one: `//tmp/…`, which Linux /// reads as `/tmp/…`). diff --git a/crates/engine/src/tests/linkwatch.rs b/crates/engine/src/tests/linkwatch.rs index 027aa6898..8c866b8d9 100644 --- a/crates/engine/src/tests/linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; -use crate::*; use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session}; +use crate::*; /// Write `bytes` to `path` so its modification time moves even on coarse file systems. fn save(path: &str, bytes: &[u8]) { diff --git a/crates/engine/src/tests/opacitymask.rs b/crates/engine/src/tests/opacitymask.rs index f1cfc0b3d..5f69f6607 100644 --- a/crates/engine/src/tests/opacitymask.rs +++ b/crates/engine/src/tests/opacitymask.rs @@ -6,9 +6,9 @@ use serde_json::{Value, json}; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use crate::*; use crate::cmd::maskedit::MASK_EDIT_LAYER; use crate::tests::{history, select_with_a_step_to_redo}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index e01df7695..c1b5c73ce 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_tools::{PointerEvent, PointerKind}; -use crate::*; use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; +use crate::*; #[test] fn every_spec_matches_a_prefs_field_and_back() { diff --git a/crates/engine/src/tests/printpresets.rs b/crates/engine/src/tests/printpresets.rs index 6323cda40..92fe5ab0f 100644 --- a/crates/engine/src/tests/printpresets.rs +++ b/crates/engine/src/tests/printpresets.rs @@ -3,8 +3,8 @@ use serde_json::json; -use crate::*; use crate::cmd::printpresets::DEFAULT_PRESET; +use crate::*; fn is_bad_params(r: Result) -> bool { matches!(r, Err(EngineError::BadParams { .. })) diff --git a/crates/engine/src/tests/printtiling.rs b/crates/engine/src/tests/printtiling.rs index 6b15284c0..9d8282053 100644 --- a/crates/engine/src/tests/printtiling.rs +++ b/crates/engine/src/tests/printtiling.rs @@ -5,8 +5,8 @@ use serde_json::{Value, json}; use vectorcraft_tools::{PointerEvent, PointerKind}; -use crate::*; use crate::tooling::ViewInfo; +use crate::*; /// A 300 × 200 document with a rectangle. fn session() -> Session { diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 86af2b73b..fb9959ded 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use vectorcraft_color::Color; -use crate::*; use crate::cmd::color_value; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index d4354620c..cfd72aedf 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use serde_json::json; -use crate::*; use crate::cmd::recovery::{FolderStore, MemoryStore, RecoveryStore}; +use crate::*; /// A session keeping its copies in `store`, with one modified document. fn session_in(store: &Arc) -> Session { diff --git a/crates/engine/src/tests/registration.rs b/crates/engine/src/tests/registration.rs index 07a5dfa9b..87d8cbc9b 100644 --- a/crates/engine/src/tests/registration.rs +++ b/crates/engine/src/tests/registration.rs @@ -9,8 +9,8 @@ use vectorcraft_geom::Affine; use vectorcraft_render::proof::ProofSetup; use vectorcraft_render::{RenderOptions, Renderer}; -use crate::*; use crate::tests_colormgmt::GLOBAL; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 4f2baa6d2..a24ee0f2f 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -7,8 +7,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::SavedView; use vectorcraft_geom::Point; -use crate::*; use crate::cmd::fileio::{SaveMode, save_plan}; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index b30e3fb27..ff17ef34e 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::cms; -use crate::*; use crate::tests_links::{Folder, png}; +use crate::*; /// Three artboards (named A, B and C) with a rectangle on each. fn three_boards() -> Session { diff --git a/crates/engine/src/tests/shortcutset.rs b/crates/engine/src/tests/shortcutset.rs index bdb298bc1..8f216b8c0 100644 --- a/crates/engine/src/tests/shortcutset.rs +++ b/crates/engine/src/tests/shortcutset.rs @@ -4,9 +4,9 @@ use serde_json::{Value, json}; -use crate::*; use crate::cmd::importmatches; use crate::cmd::shortcutset::{KysEntry, NOT_READABLE, read_kys}; +use crate::*; fn read(text: &str) -> Value { Session::new().execute("shortcutSet.read", &json!({ "data": text, "name": "My Keys.kys" })).unwrap() diff --git a/crates/engine/src/tests/smartguides.rs b/crates/engine/src/tests/smartguides.rs index ee8579626..8bb6a0dfb 100644 --- a/crates/engine/src/tests/smartguides.rs +++ b/crates/engine/src/tests/smartguides.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::NodeId; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; -use crate::*; use crate::tooling::ViewInfo; +use crate::*; /// An 800 × 600 document with a 100 × 100 square at (100, 100), nothing selected. fn session() -> Session { diff --git a/crates/engine/src/tests/units.rs b/crates/engine/src/tests/units.rs index 7cf5be9af..655501c69 100644 --- a/crates/engine/src/tests/units.rs +++ b/crates/engine/src/tests/units.rs @@ -6,9 +6,9 @@ use serde_json::json; use vectorcraft_doc::Unit; use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; -use crate::*; use crate::cmd::prefscmds::{PREF_SPECS, PrefKind, UNITS}; use crate::units::Measure; +use crate::*; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/pathops/src/planar/tests.rs b/crates/pathops/src/planar/tests.rs index 0d819c899..bf9340189 100644 --- a/crates/pathops/src/planar/tests.rs +++ b/crates/pathops/src/planar/tests.rs @@ -433,10 +433,7 @@ fn shape_builder_gap_detection_closes_small_gaps() { assert!((len - 396.0).abs() < 1e-6, "{len}"); // Two paths whose ends miss each other: a U and a lid 3 above it. let u = Shape::new( - PathData::single(SubPath::polyline( - &[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)], - false, - )), + PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)], false)), FillRule::NonZero, 0, ); @@ -493,8 +490,7 @@ fn shape_builder_offers_the_gaps_closed_shapes_enclose() { assert_eq!(shape_builder(&s, true).regions, regions, "the outline edges don't change them"); // A ring of four bars that overlap at the corners: the eight pieces of the bars and the // square they enclose. - let s = - [rect(0.0, 0.0, 300.0, 100.0, 0), rect(200.0, 0.0, 300.0, 300.0, 1), rect(0.0, 200.0, 300.0, 300.0, 2), rect(0.0, 0.0, 100.0, 300.0, 3)]; + let s = [rect(0.0, 0.0, 300.0, 100.0, 0), rect(200.0, 0.0, 300.0, 300.0, 1), rect(0.0, 200.0, 300.0, 300.0, 2), rect(0.0, 0.0, 100.0, 300.0, 3)]; let regions = shape_builder(&s, false).regions; assert_eq!(regions.len(), 9, "{regions:?}"); let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect(); @@ -847,8 +843,7 @@ fn run_around_stops_at_the_stops_either_side() { let edge = &lone.edges[0].path.subpaths[0]; let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(Point::from); let side = |at: (f64, f64)| { - run_around(edge, &corners, Point::from(at), 1e-3) - .map(|sp| (sp.closed, PathData::single(sp.clone()).length(), PathData::single(sp).bounds())) + run_around(edge, &corners, Point::from(at), 1e-3).map(|sp| (sp.closed, PathData::single(sp.clone()).length(), PathData::single(sp).bounds())) }; assert_eq!(side((50.0, 1.0)), Some((false, 100.0, Some(Rect::new(0.0, 0.0, 100.0, 0.0))))); for at in [(0.5, 50.0), (99.0, 50.0), (50.0, 100.0)] { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e76589fcf..aa0acc36f 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -34,6 +34,8 @@ mod pdfx; mod presets; mod print; mod printadvanced; +mod reader_compat; +mod reader_streaming; mod realtext; mod settings; pub(super) mod stroke; @@ -41,8 +43,6 @@ mod strokeout; mod text_placement; mod textstroke; mod webview; -mod reader_streaming; -mod reader_compat; fn doc(w: f64, h: f64) -> Document { Document::new(w, h) diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index fd941dc2c..8c2cd0a3c 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -164,14 +164,12 @@ fn shadow_rasters_keep_their_node_and_a_byte_budget() { #[test] fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { let size = (640, 360); - for (rect, blur, zoom, center, scale) in [ - (Rect::new(200.0, 300.0, 400.0, 500.0), 100.0, 5.0, (440.0, 400.0), 2.0), - (Rect::new(0.0, 0.0, 595.0, 842.0), 2.0, 8.0, (595.0, 421.0), 3.0), - ] { + for (rect, blur, zoom, center, scale) in + [(Rect::new(200.0, 300.0, 400.0, 500.0), 100.0, 5.0, (440.0, 400.0), 2.0), (Rect::new(0.0, 0.0, 595.0, 842.0), 2.0, 8.0, (595.0, 421.0), 3.0)] + { let mut d = Document::new(595.0, 842.0); let layer = d.layers[0].id; - let mut n = - Node::path(d.alloc_id(), shapes::rectangle(rect), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0)); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(rect), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0)); n.appearance.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"blur": blur}), visible: true }]; d.insert(Some(layer), 0, n).unwrap(); let base = Affine::translate((320.0, 180.0)) * Affine::scale(zoom) * Affine::translate((-center.0, -center.1)); @@ -246,8 +244,7 @@ fn per_stroke_raster_effects_use_the_single_threaded_pipeline() { if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))) { - s.effects = - vec![Effect { id: "stylize.outerGlow".into(), params: json!({"color": "#ff0000", "opacity": 100, "blur": 6}), visible: true }]; + s.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"color": "#ff0000", "opacity": 100, "blur": 6}), visible: true }]; } d.remove(id).unwrap(); d.insert(Some(l), 0, n).unwrap(); @@ -275,10 +272,7 @@ fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkne let mut d = doc_with( Rect::new(20.0, 20.0, 60.0, 60.0), fill, - vec![( - "stylize.dropShadow", - json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal", "shade": shade, "darkness": darkness}), - )], + vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal", "shade": shade, "darkness": darkness}))], ); d.color_mode = mode; d @@ -345,11 +339,8 @@ fn a_drop_shadow_with_color_draws_as_before() { #[test] fn shadow_of_offscreen_object_is_not_culled() { - let d = doc_with( - Rect::new(-60.0, 20.0, -5.0, 60.0), - Color::WHITE, - vec![("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 100}))], - ); + let d = + doc_with(Rect::new(-60.0, 20.0, -5.0, 60.0), Color::WHITE, vec![("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 100}))]); let img = render(&d); let row: Vec = (0..10).map(|i| lum(img.pixel(i * 10, 40))).collect(); assert!(lum(img.pixel(10, 40)) < 100, "{row:?}"); @@ -402,8 +393,7 @@ fn an_outer_glow_blurs_a_solid_silhouette_of_faint_art() { // GUARDS: the glow's strength comes from where the art is, not how opaque it is (a glow round a glow relies on it). let r = Rect::new(30.0, 30.0, 70.0, 70.0); let glow = |fill_opacity: f32| { - let mut d = - doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); + let mut d = doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); let id = d.layers[0].children().unwrap()[0].id; d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity; render(&d).pixel(29, 50) @@ -419,8 +409,7 @@ fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() { // of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720. let r = Rect::new(30.0, 30.0, 70.0, 70.0); let green_outside = |ppi: f64| { - let mut d = - doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); + let mut d = doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); d.raster_effects_ppi = ppi; render(&d).pixel(27, 50)[1] }; @@ -452,8 +441,7 @@ fn a_glow_too_large_for_a_full_size_raster_keeps_its_solid_silhouette() { let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let view = Affine::translate((-1100.0, -1900.0)) * Affine::scale(40.0); let glow = |fill_opacity: f32| { - let mut d = - doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); + let mut d = doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); let id = d.layers[0].children().unwrap()[0].id; d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity; Renderer::new().render(&d, 200, 200, view, &opts).pixel(60, 100) @@ -477,10 +465,8 @@ fn thirty_stacked_glows_draw_quickly_cached_or_not() { Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); instanced.symbols.push(vectorcraft_doc::Symbol { name: "Square".into(), art: std::sync::Arc::new(art) }); - let mut instance = Node::new( - instanced.alloc_id(), - vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((30.0, 30.0)) }, - ); + let mut instance = + Node::new(instanced.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((30.0, 30.0)) }); instance.appearance.effects = glows.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); let l = instanced.layers[0].id; instanced.insert(Some(l), 0, instance).unwrap(); @@ -655,8 +641,7 @@ fn rasters_drawn_from_a_kept_rasterize_image_count_the_images_it_could_not_decod /// renderer draws. #[test] fn moved_objects_draw_their_effect_rasters_while_interacting() { - let effects = - vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "opacity": 80})), ("texture.craquelure", json!({"crackSpacing": 6}))]; + let effects = vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "opacity": 80})), ("texture.craquelure", json!({"crackSpacing": 6}))]; let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), effects); let id = base.layers[0].children().unwrap()[0].id; // The object moved from where it started, as each frame of a drag moves it. @@ -851,8 +836,7 @@ fn moved_rasters_need_the_same_content_and_effects() { // Feather clips to the outline: a notch in the top edge where the tiles paint nothing // (x 29 to 33 and y 20 to 24 once moved) leaves the content's pixels as they were. let mut feathered = tiled.clone(); - feathered.node_mut(id).unwrap().appearance.effects = - vec![Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }]; + feathered.node_mut(id).unwrap().appearance.effects = vec![Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }]; assert_eq!(drag(&feathered, &moved(&feathered, true, &|_| {})), (1, true), "feathered"); let notch = |n: &mut Node| { let mut bp = vectorcraft_geom::BezPath::new(); diff --git a/crates/render/src/pattern/tests.rs b/crates/render/src/pattern/tests.rs index dba6673e1..7a577d757 100644 --- a/crates/render/src/pattern/tests.rs +++ b/crates/render/src/pattern/tests.rs @@ -51,11 +51,8 @@ fn effects_after_a_pattern_fill_keep_their_place_and_size() { d.remove(filled).unwrap(); if with_pattern { let id = d.alloc_id(); - let n = Node::path( - id, - shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), - Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0), - ); + let n = + Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); d.insert(Some(l), 0, n).unwrap(); } let id = d.alloc_id(); @@ -257,8 +254,7 @@ fn kept_tiles_count_the_images_they_could_not_decode() { let mut d = doc_with_pattern(TileType::Grid); // A PNG signature with nothing after it. d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = - ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; let mut r = Renderer::new(); let start = tiles_rasterized(); @@ -308,11 +304,8 @@ fn edit_mode_draws_only_tile_layer_with_copies() { #[test] fn repeat_renders_instances() { let mut d = Document::new(200.0, 200.0); - let sq = Node::path( - NodeId(50), - shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), - Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), - ); + let sq = + Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); let id = d.alloc_id(); let l = d.layers[0].id; d.insert(Some(l), 0, Node::new(id, NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(sq)], 10.0, 10.0)))).unwrap(); diff --git a/crates/render/src/tests/charstroke.rs b/crates/render/src/tests/charstroke.rs index 45daab916..1dd1d8646 100644 --- a/crates/render/src/tests/charstroke.rs +++ b/crates/render/src/tests/charstroke.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::text::{CharStyle, TextObject}; use vectorcraft_doc::{AppearanceItem, Dash, LineJoin, Node, NodeKind}; use vectorcraft_geom::Point; -use crate::*; use crate::tests_fontchange::fonts_steady; +use crate::*; const SIZE: u32 = 200; diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 844d983b4..34215f909 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -6,8 +6,8 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; -use crate::*; use crate::tests_fontchange::fonts_steady; +use crate::*; /// A `w`×`h` PNG whose pixels depend on `seed` (each test its own image: the store is shared by /// the tests running alongside). diff --git a/crates/text/src/tests/sysfonts.rs b/crates/text/src/tests/sysfonts.rs index 5571d5185..7ab8d9029 100644 --- a/crates/text/src/tests/sysfonts.rs +++ b/crates/text/src/tests/sysfonts.rs @@ -3,8 +3,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use crate::*; use crate::fontdb::fonts_outside; +use crate::*; const FAMILY: &str = "Sysfont Sans3"; diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 539c6ef80..43ec2d72a 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -591,6 +591,5 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result>(), "x {x}"); } } diff --git a/crates/tools/src/corners/tests.rs b/crates/tools/src/corners/tests.rs index 6c07848fc..a1fb0ee52 100644 --- a/crates/tools/src/corners/tests.rs +++ b/crates/tools/src/corners/tests.rs @@ -150,8 +150,7 @@ fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() { assert_eq!(radius(&a[1]), 40.0); // The four arcs, each a quarter circle of radius 40 starting on a side. let arcs = red(t.as_ref()).expect("the limit shows"); - let starts: Vec<_> = - arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect(); + let starts: Vec<_> = arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect(); assert_eq!(starts, [Point::new(260.0, 100.0), Point::new(300.0, 140.0), Point::new(140.0, 180.0), Point::new(100.0, 140.0)]); // Back below the limit, the outline goes. assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[0]), 30.0); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 06a940ac6..311efe223 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -942,10 +942,7 @@ fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { ) }; assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty()); - assert_eq!( - t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), - vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)] - ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)]); // The tool sees the document as previewed: B moved along, not where the copy leaves it. let (mut moved, _, b2) = two_squares(); let l = moved.layers[0].id; diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 5cc7684ea..2daac5705 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -547,9 +547,9 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; // The release's stroke: a straight line that continues the path. let continues = |acts: &[Vec]| { - acts.last().and_then(|a| a.first()).is_some_and( - |a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2)), - ) + acts.last() + .and_then(|a| a.first()) + .is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2))) }; for alt_smooth in [false, true] { for m in [Mods::default(), shift, alt, shift_alt] { diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 9ed34c4fc..517937c74 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -79,10 +79,8 @@ fn scale_snap_lands_the_handle_on_anchors() { let l = d.layers[0].id; let id = d.alloc_id(); // A triangle whose apex (320, 400) lines up with none of the edges or centres on x. - let tri = vectorcraft_geom::PathData::single(SubPath::polyline( - &[Point::new(300.0, 300.0), Point::new(380.0, 300.0), Point::new(320.0, 400.0)], - true, - )); + let tri = + vectorcraft_geom::PathData::single(SubPath::polyline(&[Point::new(300.0, 300.0), Point::new(380.0, 300.0), Point::new(320.0, 400.0)], true)); d.insert(Some(l), 1, Node::path(id, tri, Appearance::default_art())).unwrap(); let t = Targets::collect(&d, &[], None); let snap = |r: Rect, h: Handle, p: Point, shift: bool| { @@ -133,8 +131,7 @@ fn spacing_guides_space_a_moved_selection_evenly() { // Halfway between B and a box at 220–260: 50 either side. let mut wide = d.clone(); let id = wide.alloc_id(); - wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default())) - .unwrap(); + wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default())).unwrap(); let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); assert_eq!(dv, Vec2::new(2.0, 0.0)); assert!(lines(&ov) >= 6, "{ov:?}"); @@ -197,9 +194,7 @@ fn display_options_colour_and_hide_the_guides_but_keep_the_snap() { let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); assert_eq!(q.y, 200.0); assert!(ov.iter().all(|o| matches!(o, Overlay::Line { color, .. } | Overlay::Label { color, .. } if *color == green)), "{ov:?}"); - assert!( - ov.iter().any(|o| matches!(o, Overlay::Line { .. })) && ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")) - ); + assert!(ov.iter().any(|o| matches!(o, Overlay::Line { .. })) && ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align"))); // Alignment Guides off: the point still lines up and says so, without the line. let c = ToolContext { alignment_guides: false, ..cx(&d, &s, &p) }; let t = Targets::collect(&d, &[], None).styled(&c); @@ -294,10 +289,8 @@ fn dragged_end_of_a_slanted_line_keeps_to_its_extension() { // Two anchors moving, the middle one staying put: no telling which one the pointer drags, // so neither keeps to the extension of its segment. let id = d.alloc_id(); - let bent = vectorcraft_geom::PathData::single(SubPath::polyline( - &[Point::new(250.0, 450.0), Point::new(300.0, 400.0), Point::new(380.0, 340.0)], - false, - )); + let bent = + vectorcraft_geom::PathData::single(SubPath::polyline(&[Point::new(250.0, 450.0), Point::new(300.0, 400.0), Point::new(380.0, 340.0)], false)); d.insert(Some(l), 1, Node::path(id, bent, Appearance::default_art())).unwrap(); let mut s = Selection::default(); s.set([id]); diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 925b76089..408dde110 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -6,8 +6,7 @@ use vectorcraft_doc::Selection; fn press_inside_drag_threshold_places_a_corner() { let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default()); let paint = paint(); - let cx = - ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) }; + let cx = ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) }; let mut pen = PenTool::default(); pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.0, 101.0)).is_empty()); @@ -231,8 +230,7 @@ fn alt_pressed_mid_drag_keeps_the_incoming_handle() { fn space_pressed_inside_the_drag_threshold_moves_the_anchor_by_the_next_move_only() { let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default()); let paint = paint(); - let cx = - ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) }; + let cx = ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) }; let space = Mods { space: true, ..Mods::default() }; let mut pen = PenTool::default(); pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); @@ -282,10 +280,7 @@ fn space_moves_the_anchor_being_placed() { let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 450.0).with_mods(Mods { space: true, ..alt })); assert_eq!( acts, - vec![Action::Preview( - "path.appendAnchor".into(), - json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]}) - )] + vec![Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]}))] ); } @@ -591,9 +586,8 @@ fn a_drag_from_the_first_anchor_always_closes() { // What the tool sees once the preview applied: the path closed. d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; let c = cx(&d, &s, &p); - let close = |x: f64, y: f64, alt: bool| { - vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))] - }; + let close = + |x: f64, y: f64, alt: bool| vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))]; for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] { assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})"); } @@ -623,12 +617,8 @@ fn clicking_a_selected_blend_spine_adds_a_point() { std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) }; let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert( - Some(l), - 1, - vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }), - ) - .unwrap(); + d.insert(Some(l), 1, vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() })) + .unwrap(); let mut s = Selection::default(); s.set([g]); let p = paint(); @@ -697,9 +687,10 @@ fn anchors_snap_to_smart_guides() { t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); let o = t.overlays(&cx); assert!(o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 318.0))), "{o:?}"); - assert!(o.iter().any( - |o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0))) - )); + assert!( + o.iter() + .any(|o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0)))) + ); let from = Point::new(60.0, 300.0); let to = Point::new(150.0, 318.0); assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs index 67b37412b..7fa192d9e 100644 --- a/crates/tools/src/rulerguide/tests.rs +++ b/crates/tools/src/rulerguide/tests.rs @@ -505,8 +505,7 @@ fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { let actions = new.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, Mods::default()), true); assert!((guide_preview_position(&actions, vertical, None) - want).abs() < 1e-8); let mut moved = GuideEdit::default(); - let (start, to) = - if vertical { ((from, pointer.y), (pointer.x, pointer.y)) } else { ((pointer.x, from), (pointer.x, pointer.y)) }; + let (start, to) = if vertical { ((from, pointer.y), (pointer.x, pointer.y)) } else { ((pointer.x, from), (pointer.x, pointer.y)) }; moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods::default())).unwrap(); assert!((guide_preview_position(&actions, vertical, Some(from)) - want).abs() < 1e-8); @@ -584,8 +583,7 @@ fn ruler_guide_readouts_count_from_the_active_artboard_with_artboard_rulers() { (vectorcraft_doc::RulerMode::Global, 1, Point::new(5.0, 5.0)), ] { doc.rulers = rulers; - let c = - ToolContext { ruler_origin: doc.ruler_origin_for(active), smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + let c = ToolContext { ruler_origin: doc.ruler_origin_for(active), smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; assert_eq!(c.ruler_origin, zero, "{rulers:?} {active}"); for vertical in [true, false] { let mut new = NewGuide::new(&c, vertical, None); diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 32798d896..f777d59cc 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -672,12 +672,8 @@ fn a_shift_move_lined_up_by_smart_guides_stays_on_its_line() { let (mut d, id) = doc_with_rect(); let l = d.layers[0].id; let other = d.alloc_id(); - d.insert( - Some(l), - 1, - vectorcraft_doc::Node::path(other, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art()), - ) - .unwrap(); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art())) + .unwrap(); (d, id) }; let p = paint(); @@ -1020,10 +1016,7 @@ fn marquee_selects_ruler_guides() { let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)); assert_eq!( a, - vec![ - Action::Exec("select.set".into(), json!({"ids": [id.0]})), - Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), - ] + vec![Action::Exec("select.set".into(), json!({"ids": [id.0]})), Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),] ); } @@ -1484,10 +1477,7 @@ fn loaded_text_threads_into_the_art_on_a_layer_masks_edge() { t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); t.pointer(&cx, &ev(PointerKind::Down, 498.5, 450.0)); - assert_eq!( - t.pointer(&cx, &ev(PointerKind::Up, 498.5, 450.0)), - vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "to": art.0}))] - ); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 498.5, 450.0)), vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "to": art.0}))]); } /// A double-click on type where a layer's clipping mask edge runs through it edits the type, diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 5086e393e..66a4abad9 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -198,10 +198,7 @@ fn click_opens_dialog() { let cx = cx(&d, &s, &p); let mut t = ShapeTool::new("star"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), - vec![Action::Dialog("star".into(), json!({"x": 10.0, "y": 10.0}))] - ); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Dialog("star".into(), json!({"x": 10.0, "y": 10.0}))]); } #[test] diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 5ba194a9d..55a1341ab 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -735,11 +735,10 @@ fn double_clicking_an_artboard_name_edits_it() { let rect = app.canvas_rect.unwrap(); let xf = Xf::new(rect, app.view().unwrap()); let mut target = None; - let _ = - ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { - let ab = &app.session.active().unwrap().doc.artboards[0]; - target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); - }); + let _ = ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { + let ab = &app.session.active().unwrap().doc.artboards[0]; + target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); + }); let at = target.unwrap(); assert!(rect.contains(at), "the name is in the working area, at {at:?} canvas {rect:?}"); let before = app.session.active().unwrap().doc.artboards.len(); @@ -774,11 +773,10 @@ fn double_clicking_a_locked_artboards_name_leaves_it() { let rect = app.canvas_rect.unwrap(); let xf = Xf::new(rect, app.view().unwrap()); let mut target = None; - let _ = - ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { - let ab = &app.session.active().unwrap().doc.artboards[0]; - target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); - }); + let _ = ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { + let ab = &app.session.active().unwrap().doc.artboards[0]; + target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); + }); let at = target.unwrap(); let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; for _ in 0..2 { @@ -908,8 +906,7 @@ fn a_click_just_outside_the_canvas_does_not_reach_the_tool() { frame_under_bar(&mut app, &ctx, vec![]); let rect = app.canvas_rect.unwrap(); let click = |app: &mut VectorcraftApp, p: Pos2| { - let button = - |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame_under_bar(app, &ctx, vec![egui::Event::PointerMoved(p)]); frame_under_bar(app, &ctx, vec![button(true)]); frame_under_bar(app, &ctx, vec![button(false)]); @@ -1124,9 +1121,8 @@ fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() { let y = xf.to_screen(Point::new(0.0, 300.0)).y; let window = app.canvas_rect.unwrap(); assert!( - s.iter().any( - |s| matches!(s, Shape::LineSegment { points, .. } if points[0] == pos2(window.left(), y) && points[1] == pos2(window.right(), y)) - ) + s.iter() + .any(|s| matches!(s, Shape::LineSegment { points, .. } if points[0] == pos2(window.left(), y) && points[1] == pos2(window.right(), y))) ); } @@ -2014,11 +2010,8 @@ fn a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame() { // for them is shown. let run = |app: &mut VectorcraftApp, events: Vec| { let frame = |app: &mut VectorcraftApp, events| { - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 870.0))), - events, - ..Default::default() - }; + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 870.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(app, ui)); out.textures_delta.clear(); }; @@ -2042,8 +2035,7 @@ fn a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame() { assert_eq!(app.canvas.from_worker, on_worker); assert!(!moved(&app), "a still frame, on the worker: {on_worker}"); let at = app.canvas_rect.unwrap().center(); - let button = - |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; run(&mut app, vec![egui::Event::PointerMoved(at)]); run(&mut app, vec![button(at, true)]); for step in [1.0, 2.0, 3.0] { @@ -2751,9 +2743,8 @@ fn grid_repeat_handles_set_rows_and_spacing_in_one_undo_step_each() { let g = handles(&mut app); let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); let drawn = shapes(&mut app, &ctx); - let circle_at = |drawn: &[Shape], at: Pos2| { - drawn.iter().any(|s| matches!(s, Shape::Circle(c) if c.center.distance(at) < 0.01 && c.radius == HANDLE_RADIUS)) - }; + let circle_at = + |drawn: &[Shape], at: Pos2| drawn.iter().any(|s| matches!(s, Shape::Circle(c) if c.center.distance(at) < 0.01 && c.radius == HANDLE_RADIUS)); assert!(circle_at(&drawn, xf.to_screen(g.rows_at)), "the row handle is drawn"); assert!(circle_at(&drawn, xf.to_screen(g.corner_at)), "the corner handle is drawn"); app.ui.view.bounding_box = false; @@ -3040,8 +3031,7 @@ fn a_moving_view_leaves_the_art_under_the_pointer_unhighlighted_until_it_stops() let mut time = 0.0; let mut outlines = |app: &mut VectorcraftApp, events: Vec, dt: f64| { time += dt; - let mut out = - ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); out.textures_delta.clear(); let mut shapes = vec![]; out.shapes.iter().for_each(|c| flat(&c.shape, &mut shapes)); @@ -3905,12 +3895,8 @@ fn two_fingers_dragged_together_pan_the_canvas() { /// A canvas frame at input time `t` (seconds) with `events`, for the trackpad rotation tests. fn timed_frame(app: &mut VectorcraftApp, ctx: &egui::Context, t: f64, events: Vec) { - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), - time: Some(t), - events, - ..Default::default() - }; + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(t), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(app, ui)); out.textures_delta.clear(); } @@ -4023,10 +4009,8 @@ fn fit_artboard_fits_the_artboard_turned_with_the_view() { let board = app.session.active().unwrap().doc.artboards[0].rect; let q = Xf::new(rect, app.view().unwrap()).quad(board); assert!(q.iter().all(|c| rect.contains(*c)), "the turned artboard is on the canvas: {q:?}"); - let gap = q - .iter() - .map(|c| (c.x - rect.left()).min(rect.right() - c.x).min(c.y - rect.top()).min(rect.bottom() - c.y)) - .fold(f32::INFINITY, f32::min); + let gap = + q.iter().map(|c| (c.x - rect.left()).min(rect.right() - c.x).min(c.y - rect.top()).min(rect.bottom() - c.y)).fold(f32::INFINITY, f32::min); assert!(gap < 31.0, "and fills it but for the margin: {gap}"); } @@ -4306,8 +4290,7 @@ fn a_rotate_view_drag_shows_a_dial_with_the_angle() { } _ => None, }); - let text = - s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None }); + let text = s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None }); (circle, needle, text) }; assert_eq!(dial(&mut app), (None, None, None), "no dial before a drag"); @@ -4391,8 +4374,7 @@ fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() { let ctx = egui::Context::default(); let timed = |app: &mut VectorcraftApp, time: f64, events: Vec| { let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let mut out = - ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); out.textures_delta.clear(); }; timed(&mut app, 0.0, vec![]); @@ -4571,8 +4553,7 @@ fn double_clicking_type_with_the_direct_selection_tool_puts_the_caret_there() { fn escape_while_editing_in_isolation_returns_to_selection_tool_and_keeps_isolation_until_second_escape() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let tid = - app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Nested hello", "size": 24})).unwrap()["id"].as_u64().unwrap(); + let tid = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Nested hello", "size": 24})).unwrap()["id"].as_u64().unwrap(); // Group the text inside a group. app.session.execute("select.all", &json!({})).unwrap(); app.session.execute("object.group", &json!({})).unwrap(); @@ -4680,8 +4661,7 @@ fn a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step( fn a_brush_dropped_on_a_path_is_applied_to_it() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = - app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); + let id = app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); app.session.execute("select.none", &json!({})).unwrap(); let ctx = egui::Context::default(); frame(&mut app, &ctx, vec![]); @@ -4708,8 +4688,7 @@ fn a_brush_dropped_on_a_path_is_applied_to_it() { fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = - app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); + let id = app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); app.session.execute("select.none", &json!({})).unwrap(); let ctx = egui::Context::default(); frame(&mut app, &ctx, vec![]); @@ -4866,12 +4845,8 @@ fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() { let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); let before = app.session.active().unwrap().doc.clone(); let undo = app.session.active().unwrap().history.undo.len(); - let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { - pos, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; + let button = + |pos: Pos2, pressed: bool| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; let start = xf.to_screen(Point::new(100.0, 100.0)); let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0; @@ -4904,12 +4879,8 @@ fn art_moved_off_the_canvas_resumes_when_the_pointer_returns() { frame(&mut app, &ctx, vec![]); let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); let undo = app.session.active().unwrap().history.undo.len(); - let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { - pos, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; + let button = + |pos: Pos2, pressed: bool| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; let start = xf.to_screen(Point::new(100.0, 100.0)); let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0; let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0; @@ -4971,12 +4942,8 @@ fn a_move_released_over_a_panel_ends_before_the_panels_draw() { let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); let before = app.session.active().unwrap().doc.clone(); let undo = app.session.active().unwrap().history.undo.len(); - let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { - pos, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; + let button = + |pos: Pos2, pressed: bool| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; let start = xf.to_screen(Point::new(100.0, 100.0)); frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); @@ -5035,9 +5002,8 @@ fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() { crate::menus::invoke(&mut app, "edit.undo", json!({})); assert!(!crate::menus::enabled(&app, "edit.undo")); assert_eq!(plain(&app, id), "ががく"); - let ime2 = - typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })]) - .unwrap(); + let ime2 = typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })]) + .unwrap(); assert!(ime2.rect.center().x <= ime.rect.center().x + 1.0, "the window stays at the clause being converted"); typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅楽".into()))]); assert_eq!(plain(&app, id), "雅楽"); @@ -5465,12 +5431,11 @@ fn tool_drag_moved_without_pointer_delta() { let b = pos2(150.0, 120.0); let mut first = false; let mut second = false; - let mut out = - ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { - let pointer = ui.input(|i| i.pointer.clone()); - first = tool_drag_moved(ui, &xf, a, &pointer); - second = tool_drag_moved(ui, &xf, a, &pointer); - }); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { + let pointer = ui.input(|i| i.pointer.clone()); + first = tool_drag_moved(ui, &xf, a, &pointer); + second = tool_drag_moved(ui, &xf, a, &pointer); + }); out.textures_delta.clear(); assert!(first, "the first drag sample at a position runs"); assert!(!second, "the same position without a delta is not sent again"); @@ -5494,13 +5459,12 @@ fn stationary_press_is_not_a_drag() { let xf = Xf::new(rect, &v); let a = pos2(100.0, 100.0); let b = pos2(150.0, 120.0); - let mut out = - ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { - ui.data_mut(|d| { - d.remove::(tool_drag_doc_id()); - d.insert_temp(tool_press_doc_id(), xf.to_doc(a)); - }); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { + ui.data_mut(|d| { + d.remove::(tool_drag_doc_id()); + d.insert_temp(tool_press_doc_id(), xf.to_doc(a)); }); + }); out.textures_delta.clear(); let mut held = true; out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { @@ -5751,8 +5715,7 @@ fn the_hosts_pen_pressure_drives_the_tools_under_mouse_events() { // One frame per sample: the reader has set the pressure before the frame's mouse events. let stroke = |app: &mut VectorcraftApp, pts: &[(f64, f64, Option)]| { let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - let button = - |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; let mut at = Pos2::ZERO; for (i, &(x, y, pressure)) in pts.iter().enumerate() { feed.set(pressure); @@ -6017,8 +5980,7 @@ fn a_mouse_stroke_keeps_every_move_of_a_frame() { app.select_tool("pencil"); let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); - let button = - |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let button = |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; let start = at(100.0, 100.0); frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); // One frame of a fast stroke: a zigzag of 20 moves, 6 pt apart. @@ -6050,8 +6012,7 @@ fn a_mouse_stroke_keeps_a_frame_that_ends_where_it_started() { app.select_tool("pencil"); let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); - let button = - |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let button = |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; let (start, a) = (at(100.0, 100.0), at(160.0, 100.0)); frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); @@ -6122,8 +6083,7 @@ fn the_task_bar_recolors_coloured_art() { let bar = task_bar_rect(&ctx).expect("the task bar shows"); // Right to left: the frame's margin, More Options, Lock, then Recolor. let at = pos2(bar.right() - 8.0 - 30.0 - 6.0 - 30.0 - 6.0 - 15.0, bar.center().y); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); frame(app, &ctx, vec![button(true)]); frame(app, &ctx, vec![button(false)]); @@ -6162,8 +6122,7 @@ fn the_task_bar_traces_an_image_then_expands_the_trace() { let bar = task_bar_rect(&ctx).expect("the task bar shows"); // Left to right: the frame's margin, the handle and its spacing, then the first button. let at = pos2(bar.left() + 8.0 + 4.0 + 6.0 + 12.0, bar.center().y); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); frame(app, &ctx, vec![button(true)]); frame(app, &ctx, vec![button(false)]); @@ -6198,9 +6157,7 @@ fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String out.shapes .iter() .filter_map(|c| match &c.shape { - egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => { - Some((t.galley.text().to_string(), t.visual_bounding_rect())) - } + egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), _ => None, }) .collect() @@ -6258,8 +6215,7 @@ fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() { let press = |app: &mut VectorcraftApp, label: &str| { let labels = task_bar_labels(app, &ctx); let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); frame(app, &ctx, vec![button(true)]); frame(app, &ctx, vec![button(false)]); @@ -6300,15 +6256,13 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { let press = |app: &mut VectorcraftApp, label: &str| { let labels = task_bar_labels(app, &ctx); let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); frame(app, &ctx, vec![button(true)]); frame(app, &ctx, vec![button(false)]); }; - let subpath = |app: &VectorcraftApp| { - app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone() - }; + let subpath = + |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone(); let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone(); press(&mut app, "Smooth"); let a = subpath(&app).anchors[1]; @@ -6700,9 +6654,8 @@ fn gap_label(s: &[Shape]) -> Option<(egui::Rect, Option)> { let magenta = c32(vectorcraft_tools::guides::MAGENTA); let text = s.iter().find_map(|s| if let Shape::Text(t) = s { (t.galley.text() == "100.00 pt").then(|| t.visual_bounding_rect()) } else { None })?; - let pill = s - .iter() - .find_map(|s| if let Shape::Rect(r) = s { (r.fill == magenta && r.rect.contains(text.center())).then_some(r.rect) } else { None }); + let pill = + s.iter().find_map(|s| if let Shape::Rect(r) = s { (r.fill == magenta && r.rect.contains(text.center())).then_some(r.rect) } else { None }); Some((text, pill)) } @@ -6981,8 +6934,7 @@ fn live_trace_selection_highlight_excludes_the_retained_source_rectangle() { })), ); source.visible = false; - let art = - Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)), Appearance::default()); + let art = Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)), Appearance::default()); let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art)]); let doc = vectorcraft_doc::Document::new(300.0, 300.0); let symbols = doc.symbol_boxes(); @@ -7100,8 +7052,7 @@ fn a_double_click_on_a_threaded_out_port_breaks_the_thread() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); let text = "Words that fill more than the frame holds. ".repeat(10); - let a = - app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap(); app.run("select.set", json!({"ids": [a]})).unwrap(); app.select_tool("selection"); diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index bd6ab4717..8a340d9f5 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -421,12 +421,8 @@ fn the_artboard_navigator_goes_to_artboards_and_fitting_shows_the_current_one() let buttons = navigator(&mut app, &ctx, vec![]); assert_eq!(buttons.len(), 4); let click = |app: &mut crate::VectorcraftApp, r: egui::Rect| { - let b = |pressed| egui::Event::PointerButton { - pos: r.center(), - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; + let b = + |pressed| egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; navigator(app, &ctx, vec![egui::Event::PointerMoved(r.center())]); navigator(app, &ctx, vec![b(true)]); navigator(app, &ctx, vec![b(false)]); @@ -494,8 +490,7 @@ fn status_texts(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Ve /// position, document, selection or either branch of its edit history. fn assert_status_coordinates(app: &mut crate::VectorcraftApp, expected: Option<&str>) { let hover = app.hover_doc; - let before = - app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); + let before = app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let texts = status_texts(app, &ctx, vec![]); @@ -504,8 +499,7 @@ fn assert_status_coordinates(app: &mut crate::VectorcraftApp, expected: Option<& let expected: Vec<&str> = expected.into_iter().flat_map(|text| text.split(" ")).collect(); assert_eq!(coordinates, expected, "{texts:?}"); assert_eq!(app.hover_doc, hover, "tools still receive the absolute document position"); - let after = - app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); + let after = app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); assert_eq!(after, before, "painting is read-only"); } @@ -698,11 +692,7 @@ fn artboard_navigation_next(app: &mut crate::VectorcraftApp, ctx: &egui::Context } } -fn artboard_navigation_properties_frame( - app: &mut crate::VectorcraftApp, - ctx: &egui::Context, - events: Vec, -) -> Vec<(String, egui::Rect)> { +fn artboard_navigation_properties_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { egui::Panel::right("navigation-properties").exact_size(300.0).show(ui, |ui| crate::panels::properties::show(app, ui)); @@ -911,12 +901,8 @@ fn the_control_bar_isolates_the_selected_object_and_exits_isolation_mode() { crate::theme::install_fonts(&ctx); let click = |app: &mut crate::VectorcraftApp| { let r = isolation_button(app, &ctx, vec![]).expect("the bar shows the isolation button"); - let press = |pressed| egui::Event::PointerButton { - pos: r.center(), - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; + let press = + |pressed| egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; for e in [egui::Event::PointerMoved(r.center()), press(true), press(false)] { isolation_button(app, &ctx, vec![e]); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 61c9193ad..f24c958c4 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -41,9 +41,7 @@ fn the_image_trace_preview_is_a_command() { #[test] fn control_input_rejects_excessive_counts_before_mutation() { // Only modest over-limit values on the baseline: never exercise an unbounded loop. - for (method, params) in - [("ui.click", json!({"count": 17})), ("ui.drag", json!({"steps": 257})), ("ui.drag", json!({"steps": 2, "count": 17}))] - { + for (method, params) in [("ui.click", json!({"count": 17})), ("ui.drag", json!({"steps": 257})), ("ui.drag", json!({"steps": 2, "count": 17}))] { let mut app = VectorcraftApp::new(Session::new(), Services::default()); app.synthetic.push(egui::Event::Text("already queued".into())); let before = format!("{:?}", app.synthetic); @@ -276,8 +274,7 @@ fn dialog_set_refuses_a_field_the_dialog_does_not_have() { assert_eq!(r["ok"], *ok, "{kind} {field}: {r}"); } }; - let moved = - [("copy", true), ("preview", true), ("distance", true), ("discard", false), ("copies", false), ("corners", false), ("origin", false)]; + let moved = [("copy", true), ("preview", true), ("distance", true), ("discard", false), ("copies", false), ("corners", false), ("origin", false)]; set(&mut app, "move", json!({"dx": "0 pt", "dy": "0 pt"}), &moved); set( &mut app, diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index c8f612119..7ca4b316c 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -206,10 +206,7 @@ fn art_options_set_width_scale_direction_flips_and_colorization() { (d.f64("width", 0.0), d.str("scaleMode"), d.f64("guideEnd", 0.0), d.str("direction")), (100.0, "betweenGuides".into(), 78.0, "leftToRight".into()) ); - set( - &mut app, - json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"}), - ); + set(&mut app, json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"})); super::super::confirm(&mut app).unwrap(); let def = app.run("brush.get", json!({"name": "Arrow"})).unwrap(); assert_eq!( @@ -442,10 +439,7 @@ fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() { set(&mut app, json!({"scale": 200, "spacing": 30, "fit": "approximate", "flipAlong": true})); super::super::confirm(&mut app).unwrap(); let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); - assert_eq!( - (&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]), - (&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true)) - ); + assert_eq!((&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]), (&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true))); assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size"); } diff --git a/crates/ui-egui/src/dialogs/color_picker/tests.rs b/crates/ui-egui/src/dialogs/color_picker/tests.rs index 62187a89a..cc650978b 100644 --- a/crates/ui-egui/src/dialogs/color_picker/tests.rs +++ b/crates/ui-egui/src/dialogs/color_picker/tests.rs @@ -371,8 +371,7 @@ impl Drawn { /// Runs a dialog frame with real egui input at a physical window size, accounting for UI zoom, /// and returns the painted controls for layout and interaction assertions. fn draw(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2, events: Vec) -> Drawn { - let input = - egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size / ctx.zoom_factor())), events, ..Default::default() }; + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size / ctx.zoom_factor())), events, ..Default::default() }; let mut out = ctx.run_ui(input, |ui| { crate::shortcuts::handle(app, ui.ctx()); super::super::show(app, ui.ctx()); @@ -502,10 +501,7 @@ fn inline_actions_are_aligned_and_modes_keep_the_window_size() { assert!((drawn.control(tl!("Color Models")).width() - ok.width()).abs() < 1.0); let after = ctx.memory(|m| m.area_rect(id)).unwrap(); assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}"); - assert!( - drawn.boxes.iter().all(|r| r.left() >= after.left() - 1.0 && r.right() <= after.right() + 1.0), - "swatch controls fit horizontally" - ); + assert!(drawn.boxes.iter().all(|r| r.left() >= after.left() - 1.0 && r.right() <= after.right() + 1.0), "swatch controls fit horizontally"); click_at(&ctx, &mut app, drawn.text(tl!("Color Models")).center()); assert!(!app.ui.dialog.as_ref().unwrap().bool("swatches")); } @@ -552,12 +548,8 @@ fn warnings_fit_below_the_preview_without_widening_the_dialog() { let ok = drawn.control(tl!("OK")); let toggle = drawn.control(tl!("Color Swatches")); let h = drawn.text("H"); - let mut warnings: Vec<_> = drawn - .boxes - .iter() - .filter(|r| (r.width() - 16.0).abs() < 0.5 && (r.height() - 16.0).abs() < 0.5 && r.bottom() < h.top()) - .copied() - .collect(); + let mut warnings: Vec<_> = + drawn.boxes.iter().filter(|r| (r.width() - 16.0).abs() < 0.5 && (r.height() - 16.0).abs() < 0.5 && r.bottom() < h.top()).copied().collect(); warnings.dedup(); // The tile's fill and border share one rectangle. assert_eq!(warnings.len(), 2, "gamut and web correction chips are both present"); for r in &warnings { diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index fe8f38240..6db57091f 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -108,8 +108,7 @@ fn find_in_folder_relinks_the_missing_files_to_the_files_found() { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!((d.str("state"), pick(d, "a.png"), pick(d, "b.png")), ("done".to_string(), &json!(a), &json!(b))); let text = frame(&mut app); - for label in - ["Find Linked Files", "a.png", "b.png", "Same file", "Don't Relink", "Find in Folder…", "Copy into the document's folder", "Relink"] + for label in ["Find Linked Files", "a.png", "b.png", "Same file", "Don't Relink", "Find in Folder…", "Copy into the document's folder", "Relink"] { assert!(text.contains(label), "{label} in {text}"); } diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 1ef9ff49c..40db4a3eb 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -39,15 +39,9 @@ fn the_fidelity_keys_step_finely_at_the_low_end() { assert_eq!(down, up.iter().rev().copied().collect::>()); // Ten steps at once (Shift) multiply or divide by ten. assert_eq!((fidelity_step(0.01, 10.0), fidelity_step(0.1, 10.0), fidelity_step(1.0, 10.0)), (0.1, 1.0, 10.0)); - assert_eq!( - (fidelity_step(10.0, -10.0), fidelity_step(1.0, -10.0), fidelity_step(0.1, -10.0), fidelity_step(0.03, 10.0)), - (1.0, 0.1, 0.01, 0.3) - ); + assert_eq!((fidelity_step(10.0, -10.0), fidelity_step(1.0, -10.0), fidelity_step(0.1, -10.0), fidelity_step(0.03, 10.0)), (1.0, 0.1, 0.01, 0.3)); // A value between steps goes to the next one in its direction. - assert_eq!( - (fidelity_step(0.035, 1.0), fidelity_step(0.035, -1.0), fidelity_step(1.5, 1.0), fidelity_step(1.5, -1.0)), - (0.04, 0.03, 2.0, 1.0) - ); + assert_eq!((fidelity_step(0.035, 1.0), fidelity_step(0.035, -1.0), fidelity_step(1.5, 1.0), fidelity_step(1.5, -1.0)), (0.04, 0.03, 2.0, 1.0)); } #[test] @@ -86,8 +80,7 @@ fn each_tool_shows_its_options_and_ok_sets_them() { (d.f64("fidelity", 0.0), d.bool("fill"), d.bool("altSmooth"), d.f64("closeWithin", 0.0), d.f64("editWithin", 0.0)), (1.5, false, false, 15.0, 12.0) ); - for (k, v) in - [("fidelity", json!(6.5)), ("fill", json!(true)), ("altSmooth", json!(true)), ("closeWithin", json!(0)), ("editWithin", json!(20))] + for (k, v) in [("fidelity", json!(6.5)), ("fill", json!(true)), ("altSmooth", json!(true)), ("closeWithin", json!(0)), ("editWithin", json!(20))] { d.fields.insert(k.into(), v); } diff --git a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs index d22bd372d..2dbfdf810 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs @@ -239,8 +239,7 @@ fn the_question_comes_back_after_a_dialog_that_took_its_place() { vectorcraft_testkit::pdf::pdf(&[page(), page()], None) }; let opened = |name: &str, path: Option, bytes: Vec| (crate::place::DropTarget::Open, (name.to_string(), path, bytes)); - let files = - vec![opened("a.vectorcraft", Some(doc.to_string_lossy().into_owned()), std::fs::read(&doc).unwrap()), opened("b.pdf", None, pdf())]; + let files = vec![opened("a.vectorcraft", Some(doc.to_string_lossy().into_owned()), std::fs::read(&doc).unwrap()), opened("b.pdf", None, pdf())]; crate::place::drop_files(&mut app, files); assert_eq!(kind(&app), Some(super::super::import_pdf::KIND)); super::super::cancel(&mut app); diff --git a/crates/ui-egui/src/dialogs/width_point/tests.rs b/crates/ui-egui/src/dialogs/width_point/tests.rs index 536c558cf..f8b686cca 100644 --- a/crates/ui-egui/src/dialogs/width_point/tests.rs +++ b/crates/ui-egui/src/dialogs/width_point/tests.rs @@ -309,8 +309,7 @@ fn creation_dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: _ => {} } } - let input = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0))), events, ..Default::default() }; + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0))), events, ..Default::default() }; let mut output = ctx.run_ui(input, |ui| super::super::show(app, ui.ctx())); output.textures_delta.clear(); let mut texts = vec![]; diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 1f45627f7..4fdfc5733 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -95,11 +95,8 @@ fn recent_section_rebuilds_and_arrows_skip_its_headings() { let mut st = super::MenuState::default(); let mut frame = |events, recent: &[String]| { let mut picked = None; - let input = egui::RawInput { - events, - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), - ..Default::default() - }; + let input = + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() }; let mut out = ctx.run_ui(input, |ui| { picked = super::list( ui, @@ -149,11 +146,7 @@ fn recent_fonts_obey_search_and_favorite_filters() { let mut frame = |events| { let mut pick = None; let mut out = ctx.run_ui( - egui::RawInput { - events, - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), - ..Default::default() - }, + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() }, |ui| { pick = super::list( ui, @@ -256,11 +249,7 @@ fn the_variable_filter_lists_only_variable_families() { let mut favorites: Vec = vec![]; let frame = |st: &mut super::MenuState, events, favorites: &[String]| { let mut out = ctx.run_ui( - egui::RawInput { - events, - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), - ..Default::default() - }, + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() }, |ui| { super::list( ui, @@ -291,8 +280,7 @@ fn the_variable_filter_lists_only_variable_families() { let click = |st: &mut super::MenuState, favorites: &[String]| { let (_, at) = frame(st, vec![], favorites); let at = at.expect("the Variable filter is drawn"); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(st, vec![egui::Event::PointerMoved(at), button(true)], favorites); frame(st, vec![button(false)], favorites); frame(st, vec![], favorites).0 diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e141965bb..f3ef8ee0d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -108,19 +108,35 @@ pub static LANGUAGES: [LangInfo; 25] = [ }, // Japanese: the whole interface (every menu string and `tl!` literal) and the status and error // messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`). - LangInfo { code: "ja", name: "日本語", source: include_str!("i18n/ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { + code: "ja", name: "日本語", source: include_str!("i18n/ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() + }, // Korean: the whole interface and the status and error messages, keeping the same names in // English as Japanese; a count takes no plural, so plurals have one form. Hangul is drawn with an // installed system font (craft-fonts has no Korean UI face yet); every `ko-*` locale resolves here. - LangInfo { code: "ko", name: "한국어", source: include_str!("i18n/ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { + code: "ko", name: "한국어", source: include_str!("i18n/ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() + }, // Vietnamese: complete menus and messages, with one plural form. // Every `vi-*` locale resolves here. LangInfo { - code: "vi", name: "Tiếng Việt", source: include_str!("i18n/vi.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() + code: "vi", + name: "Tiếng Việt", + source: include_str!("i18n/vi.tsv"), + plural: plural_none, + complete_menus: true, + catalog: OnceLock::new(), }, // Czech: the whole interface (every menu string and `tl!` literal) and the status and error // messages, with one/few/other plurals, keeping the same names in English as Spanish. - LangInfo { code: "cs", name: "Čeština", source: include_str!("i18n/cs.tsv"), plural: plural_czech, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { + code: "cs", + name: "Čeština", + source: include_str!("i18n/cs.tsv"), + plural: plural_czech, + complete_menus: true, + catalog: OnceLock::new(), + }, // German: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `de-*` locale (`de-DE`, `de-AT`, `de-CH`, `de-LU` …) resolves here. LangInfo { @@ -143,7 +159,14 @@ pub static LANGUAGES: [LangInfo; 25] = [ }, // French: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `fr-*` locale (`fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` …) resolves here. - LangInfo { code: "fr", name: "Français", source: include_str!("i18n/fr.tsv"), plural: plural_french, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { + code: "fr", + name: "Français", + source: include_str!("i18n/fr.tsv"), + plural: plural_french, + complete_menus: true, + catalog: OnceLock::new(), + }, // Italian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `it-*` locale (`it-IT`, `it-CH`, `it-SM` …) resolves here. LangInfo { @@ -158,7 +181,12 @@ pub static LANGUAGES: [LangInfo; 25] = [ // English as Spanish. Georgian nouns stay singular after a number, so one plural form; // `ka-GE` and `ka_GE.UTF-8` resolve here. LangInfo { - code: "ka", name: "ქართული", source: include_str!("i18n/ka.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() + code: "ka", + name: "ქართული", + source: include_str!("i18n/ka.tsv"), + plural: plural_none, + complete_menus: true, + catalog: OnceLock::new(), }, // Russian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` …) resolves here. @@ -197,7 +225,14 @@ pub static LANGUAGES: [LangInfo; 25] = [ // Polish: the whole interface and the status and error messages, with one/few/many plurals, // keeping the same names in English as Spanish; every `pl-*` locale (including `pl-PL`) // resolves here. - LangInfo { code: "pl", name: "Polski", source: include_str!("i18n/pl.tsv"), plural: plural_polish, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { + code: "pl", + name: "Polski", + source: include_str!("i18n/pl.tsv"), + plural: plural_polish, + complete_menus: true, + catalog: OnceLock::new(), + }, // Swedish: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `sv-*` locale (`sv-SE`, `sv-FI` …) resolves here. LangInfo { @@ -248,7 +283,14 @@ pub static LANGUAGES: [LangInfo; 25] = [ catalog: OnceLock::new(), }, // Danish; every `da-*` locale (`da-DK`, `da_DK.UTF-8`…) resolves here. - LangInfo { code: "da", name: "Dansk", source: include_str!("i18n/da.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { + code: "da", + name: "Dansk", + source: include_str!("i18n/da.tsv"), + plural: plural_one_other, + complete_menus: true, + catalog: OnceLock::new(), + }, // Indonesian; every `id-*` locale (`id-ID`, `id_ID.UTF-8`…) resolves here. CLDR `id` counts // have one form, so `@plural` entries list a single form. LangInfo { @@ -262,11 +304,20 @@ pub static LANGUAGES: [LangInfo; 25] = [ // Finnish: the whole interface and the status and error messages, keeping the same names in // English as Spanish; one/other plurals with the partitive after any number but 1 ("2 tasoa"). // Every `fi-*` locale resolves here. - LangInfo { code: "fi", name: "Suomi", source: include_str!("i18n/fi.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { + code: "fi", + name: "Suomi", + source: include_str!("i18n/fi.tsv"), + plural: plural_one_other, + complete_menus: true, + catalog: OnceLock::new(), + }, // Turkish: the whole interface and the status and error messages, keeping the same names in // English as Spanish; a count takes the singular ("3 katman"), so plurals have one form. Every // `tr-*` locale (including `tr-TR` and `tr-CY`) resolves here. - LangInfo { code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { + code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() + }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1e1c1c4b8..f168c83e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -73,7 +73,10 @@ pub mod workspaces; mod document_drag; #[cfg(test)] -use tests::{background as tests_background, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors, svg as tests_svg, synthetic as tests_synthetic, sysclip as tests_sysclip}; +use tests::{ + background as tests_background, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors, svg as tests_svg, + synthetic as tests_synthetic, sysclip as tests_sysclip, +}; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index b78f5efdb..f4baafa1d 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -114,8 +114,7 @@ fn a_popup_covering_a_title_does_not_switch_menus() { let mut reaches = (true, false); // The popup becomes hit-testable once egui has laid it out: draw a few frames. for _ in 0..3 { - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() }; + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { let ctx = ui.ctx().clone(); let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::unique("covered"), egui::Sense::click()); diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs index 982de7443..5df314659 100644 --- a/crates/ui-egui/src/panels/appearance/tests.rs +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -427,8 +427,7 @@ fn groups_show_a_contents_row_and_type_a_characters_row_in_their_slot() { run(&mut app, "object.group", json!({})); run(&mut app, "appearance.addFill", json!({})); run(&mut app, "appearance.addStroke", json!({})); - let rows = - |t: Vec| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::>(); + let rows = |t: Vec| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::>(); assert_eq!(rows(frame(&mut app)), ["Stroke:", "Fill:", "Contents"]); run(&mut app, "appearance.moveItem", json!({"from": "contents", "to": 1})); assert_eq!(rows(frame(&mut app)), ["Stroke:", "Contents", "Fill:"]); @@ -446,9 +445,7 @@ fn images_show_a_contents_row_with_new_fills_and_strokes_below_it() { run(&mut app, "file.new", json!({"width": 100, "height": 100})); let rows = |t: Vec| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents"].contains(&t.as_str())).collect::>(); let mut png = vec![]; - image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) - .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .unwrap(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); run(&mut app, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png)})); assert_eq!(rows(frame(&mut app)), ["Contents"]); run(&mut app, "appearance.addStroke", json!({})); diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index e0edc9a17..15bab000b 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -195,8 +195,7 @@ fn double_clicks_go_to_or_rename_and_options_edits_the_rows_artboard() { }) .unwrap(); let events = |at: egui::Pos2, clicks: usize| { - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; std::iter::once(egui::Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect::>() }; // Artboard 2's number: it is active, no rename starts. @@ -285,8 +284,7 @@ fn the_panel_menus_rename_numbers_several_selected_artboards() { _ => None, }) .unwrap(); - let button = - |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; ctx.run_ui(raw(vec![egui::Event::PointerMoved(item), button(true), button(false)]), |ui| menu(app, ui)).textures_delta.clear(); }; let frame = |app: &mut VectorcraftApp, events| ctx.run_ui(raw(events), |ui| show(app, ui)).textures_delta.clear(); @@ -530,8 +528,7 @@ fn the_options_icon_opens_artboard_options_for_its_rows_artboard() { .unwrap(); let icon = |k: f32| egui::pos2(row.right() - 14.0, row.center().y + 24.0 * k); let events = |at: egui::Pos2, clicks: usize| { - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; std::iter::once(egui::Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect::>() }; let opened = |app: &mut VectorcraftApp| app.ui.dialog.take().map(|d| (d.kind.clone(), d.f64("index", 9.0), d.f64("width", 0.0))); @@ -680,8 +677,7 @@ fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, width: f32) -> Vec<(String, egui::Rect)> { let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 300.0)); - let mut out = - ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| crate::chrome::control_bar(app, ui)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| crate::chrome::control_bar(app, ui)); out.textures_delta.clear(); out.shapes .iter() @@ -806,12 +802,8 @@ fn the_control_bar_shows_the_artboard_tools_controls() { assert_eq!(boxes.len(), 2, "{boxes:?}"); let scale = |app: &VectorcraftApp| app.session.tool_options()["scaleArt"].as_bool(); assert_eq!(scale(&app), Some(false)); - let press = |pressed| egui::Event::PointerButton { - pos: boxes[1].center(), - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }; + let press = + |pressed| egui::Event::PointerButton { pos: boxes[1].center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; narrow(&mut app, vec![egui::Event::PointerMoved(boxes[1].center()), press(true)]); narrow(&mut app, vec![press(false)]); assert_eq!(scale(&app), Some(true)); @@ -918,8 +910,7 @@ fn the_control_bar_adds_deletes_and_opens_artboard_dialogs() { // Artboard Options and Rearrange All Artboards: the two buttons between the name and the // Transform link. let texts = bar_texts(&mut app, &ctx); - let between = - |texts: &[(String, egui::Rect)]| bar_buttons(&ctx, text_rect(texts, "Artboard 2").right(), text_rect(texts, "Transform").left()); + let between = |texts: &[(String, egui::Rect)]| bar_buttons(&ctx, text_rect(texts, "Artboard 2").right(), text_rect(texts, "Transform").left()); let options = between(&texts)[0]; bar_click(&mut app, &ctx, options.center()); let d = app.ui.dialog.take().unwrap(); @@ -992,8 +983,7 @@ fn dragging_a_row_onto_new_artboard_duplicates_it() { // The bottom bar's buttons sit under its divider, New Artboard second from the right. let bar = rect_of(t.divider).unwrap(); let new = pos2(bar.right() - 24.0 - 4.0 - 12.0, bar.bottom() + 2.0 + 12.0); - let button = - |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(&mut app, vec![egui::Event::PointerMoved(row), button(row, true)]); frame(&mut app, vec![egui::Event::PointerMoved(row + vec2(0.0, 12.0))]); let held = frame(&mut app, vec![egui::Event::PointerMoved(new)]); diff --git a/crates/ui-egui/src/panels/brushes/tests.rs b/crates/ui-egui/src/panels/brushes/tests.rs index 35fa11aaa..47fc962c6 100644 --- a/crates/ui-egui/src/panels/brushes/tests.rs +++ b/crates/ui-egui/src/panels/brushes/tests.rs @@ -79,12 +79,8 @@ fn double_clicking_a_brush_opens_its_options() { std::iter::once(egui::Event::PointerMoved(at)).chain((0..n).flat_map(|_| [button(at, true), button(at, false)])).collect() }; let at_time = |app: &mut VectorcraftApp, time: f64, events: Vec| { - let raw = egui::RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), - time: Some(time), - events, - ..Default::default() - }; + let raw = + egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), time: Some(time), events, ..Default::default() }; ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear(); }; at_time(&mut app, 0.0, vec![]); @@ -443,8 +439,7 @@ fn previews_render_again_only_when_what_they_show_changes() { // Striped Ribbon brings its pattern, Sparkles its symbol: only they render. app.run("brush.addFromLibrary", json!({"library": "art-ribbons", "name": "Striped Ribbon"})).unwrap(); assert_eq!(counted(&ctx, &mut app), (1, n + 1)); - let symbol_key = - |app: &VectorcraftApp, name: &str| super::super::symbols::document_symbols(app.session.active().unwrap()).key(name).unwrap_or(0); + let symbol_key = |app: &VectorcraftApp, name: &str| super::super::symbols::document_symbols(app.session.active().unwrap()).key(name).unwrap_or(0); let sparkle_before = symbol_key(&app, "Sparkle"); app.run("brush.addFromLibrary", json!({"library": "scatter-shapes", "name": "Sparkles"})).unwrap(); assert_eq!(counted(&ctx, &mut app), (1, n + 2)); @@ -458,11 +453,8 @@ fn previews_render_again_only_when_what_they_show_changes() { app.session .edit("Pattern", |d, _| { let p = d.patterns.iter_mut().find(|p| p.name == "Pinstripes").unwrap(); - let dot = Node::path( - NodeId(0), - vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 3.0, 3.0)), - Appearance::default_art(), - ); + let dot = + Node::path(NodeId(0), vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 3.0, 3.0)), Appearance::default_art()); p.art = vec![std::sync::Arc::new(dot)]; Ok(()) }) diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index 8d89d498b..46051b7a9 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -99,8 +99,7 @@ fn menu_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec>(); assert_eq!(colors(&g), [Color::rgb8(255, 128, 0), Color::cmyk(0.0, 0.0, 0.0, 1.0)]); save_to_swatches(&mut app, &g); - let Some(Paint::Gradient(saved)) = app.session.doc().unwrap().doc.swatches.last().map(|w| w.paint.clone()) else { - panic!("a gradient swatch") - }; + let Some(Paint::Gradient(saved)) = app.session.doc().unwrap().doc.swatches.last().map(|w| w.paint.clone()) else { panic!("a gradient swatch") }; assert_eq!(colors(&saved), colors(&g)); } @@ -150,9 +148,7 @@ impl Panel { .layers() .flat_map(|(_, w)| w.iter()) .map(|w| w.rect) - .filter(|r| { - (r.width() - size).abs() < 0.5 && (r.height() - size).abs() < 0.5 && (r.center().y - at.y).abs() < 8.0 && r.left() > at.x - }) + .filter(|r| (r.width() - size).abs() < 0.5 && (r.height() - size).abs() < 0.5 && (r.center().y - at.y).abs() < 8.0 && r.left() > at.x) .collect() }); v.sort_by(|a, b| a.left().total_cmp(&b.left())); @@ -405,10 +401,7 @@ fn double_clicking_a_stop_opens_the_shared_popover_beside_it() { let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE); let d = app.ui.dialog.clone().expect("the popover opened"); - assert_eq!( - (d.kind.as_str(), &d.fields["index"], &d.fields["screen"]), - ("gradientStop", &json!(1), &json!([stop.left(), stop.bottom() + 4.0])) - ); + assert_eq!((d.kind.as_str(), &d.fields["index"], &d.fields["screen"]), ("gradientStop", &json!(1), &json!([stop.left(), stop.bottom() + 4.0]))); assert_eq!(app.session.selected_stop(), Some(1)); } diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs index d388c0e15..268c3ab17 100644 --- a/crates/ui-egui/src/panels/image_trace/tests.rs +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -150,10 +150,7 @@ fn adjusting_sketch_threshold_changes_the_vector_result_and_can_be_undone() { frame( &mut app, &ctx, - vec![ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, - ], + vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }], ); } assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 1, "only the source remains below the ink's luminance"); @@ -180,10 +177,7 @@ fn with_preview_off_a_slider_change_waits_for_trace() { frame( &mut app, &ctx, - vec![ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, - ], + vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }], ); } assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "no trace while Preview is off"); @@ -381,10 +375,7 @@ fn preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it() { for pressed in [true, false] { menu_frame( &mut app, - vec![ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, - ], + vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }], ); } assert!(pstate::(&ctx, "image-trace").params.is_null(), "the panel's settings were reset"); @@ -569,10 +560,8 @@ fn properties_can_reopen_the_trace_controls() { } let at = at.unwrap(); for pressed in [true, false] { - let events = vec![ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, - ]; + let events = + vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }]; let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::properties::show(&mut app, ui)); out.textures_delta.clear(); } diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 7df8a486e..e779e328c 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -323,8 +323,7 @@ fn renaming_a_layer_commits_on_enter_and_escape_cancels() { let name = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].display_name(); let ctx = egui::Context::default(); let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - let select_all = - egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + let select_all = egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; // What a double-click on the row does: the name field opens with the name. let open = |app: &mut VectorcraftApp| { ctx.data_mut(|d| d.insert_temp(egui::Id::unique("layers-rename"), (layer.0, name(app)))); @@ -436,8 +435,7 @@ fn nested() -> (VectorcraftApp, [NodeId; 6]) { /// Press and release at `at` with `mods` held, over three frames. fn click_with(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, mods: egui::Modifiers) { for e in [egui::Event::PointerMoved(at), button(at, true), button(at, false)] { - let mut out = - ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(mods), e], ..Default::default() }, |ui| show(app, ui)); + let mut out = ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(mods), e], ..Default::default() }, |ui| show(app, ui)); out.textures_delta.clear(); } } diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index ac01d4f44..9e1db24a8 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -361,12 +361,7 @@ fn accessible() -> egui::Context { } /// One frame of `draw` with `events`, after the frames `ctx` ran. -fn draw_frame( - app: &mut VectorcraftApp, - ctx: &egui::Context, - draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), - events: Vec, -) -> egui::FullOutput { +fn draw_frame(app: &mut VectorcraftApp, ctx: &egui::Context, draw: &dyn Fn(&mut VectorcraftApp, &mut Ui), events: Vec) -> egui::FullOutput { let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); let time = Some(ctx.input(|i| i.time) + 0.1); let mut out = ctx.run_ui(egui::RawInput { events, time, screen_rect, ..Default::default() }, |ui| draw(app, ui)); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index ea8391896..163427da5 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -142,10 +142,7 @@ fn paragraph_mixed_alignment_click_center_and_undo() { assert_eq!(alignment_frame(&ctx, &mut app, 28.0).0, [Justify::Center]); let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); let NodeKind::Text(text) = &node.kind else { panic!("text") }; - assert_eq!( - (text.para_at(0).justify, text.para_at(1).justify, text.para_at(2).justify), - (Justify::Center, Justify::Center, Justify::JustifyAll) - ); + assert_eq!((text.para_at(0).justify, text.para_at(1).justify, text.para_at(2).justify), (Justify::Center, Justify::Center, Justify::JustifyAll)); let NodeKind::Text(old) = &before.node(node.id).unwrap().kind else { panic!("text") }; for i in 0..3 { let mut expected = old.para_at(i).clone(); @@ -479,8 +476,7 @@ fn convert_to_text_keeps_the_caret_by_its_character_in_threaded_type() { app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); let long = "The quick brown fox jumps over the lazy dog. ".repeat(4); let text = format!("One\nTwo\n{long}\nFour"); - let a = - app.run("text.create", json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); let b = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); app.run("select.set", json!({"ids": [a, b]})).unwrap(); app.run("text.thread.create", json!({})).unwrap(); diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 81286123f..f82c9db91 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -16,11 +16,7 @@ fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec out.shapes .iter() .filter_map(|s| { - if let egui::Shape::Text(t) = &s.shape { - Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))) - } else { - None - } + if let egui::Shape::Text(t) = &s.shape { Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))) } else { None } }) .collect() } @@ -126,8 +122,7 @@ fn start_global_edit_quick_action_and_escape() { assert!(tall(&mut app, vec![]).iter().any(|(t, _)| t == "Stop Global Edit")); assert_eq!(label(&app), "Stop Global Edit"); let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - let raw = - egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events: vec![escape], ..Default::default() }; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events: vec![escape], ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); out.textures_delta.clear(); assert!(!on(&app), "Escape stops Global Edit"); @@ -1331,18 +1326,8 @@ fn the_width_profile_shows_for_a_variable_width_or_the_width_tool() { let depth = undo_depth(&app); press(&mut app, &ctx, text_at(&texts, "Taper Start").center()); assert_eq!(undo_depth(&app), depth + 1, "one undo step"); - let profile = app - .session - .active() - .unwrap() - .doc - .node(vectorcraft_doc::NodeId(line.as_u64().unwrap())) - .unwrap() - .appearance - .stroke() - .unwrap() - .profile - .clone(); + let profile = + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(line.as_u64().unwrap())).unwrap().appearance.stroke().unwrap().profile.clone(); assert_eq!(WidthProfile::id_of(profile.as_ref()), "taperStart"); } diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index 416cd2d76..4175ed0cb 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -229,10 +229,7 @@ fn new_swatch_dialog_prefills_the_active_colour_and_saves_into_the_selected_grou app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap(); new_swatch(&mut app, &["Bright Red".into()], Modifiers::COMMAND); let d = app.ui.dialog.as_ref().expect("New Swatch opens its dialog"); - assert_eq!( - (d.kind.as_str(), d.str("name").as_str(), d.str("group").as_str()), - (crate::dialogs::new_swatch::KIND, "R=255 G=128 B=0", "Brights") - ); + assert_eq!((d.kind.as_str(), d.str("name").as_str(), d.str("group").as_str()), (crate::dialogs::new_swatch::KIND, "R=255 G=128 B=0", "Brights")); assert!(d.bool("spot") && d.bool("global"), "Cmd/Ctrl-click makes a spot colour"); dialog_frame(&mut app, &ctx); crate::dialogs::confirm(&mut app).unwrap(); @@ -487,11 +484,7 @@ fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(Paint::solid(Color::from_hex("#123456").unwrap()))); let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); - assert_eq!( - names_of(&app, Some("Brights")).last().map(String::as_str), - Some("R=18 G=52 B=86"), - "dropped on a group's colour: into that group" - ); + assert_eq!(names_of(&app, Some("Brights")).last().map(String::as_str), Some("R=18 G=52 B=86"), "dropped on a group's colour: into that group"); assert!(egui::DragAndDrop::payload::(&ctx).is_none()); } @@ -679,9 +672,7 @@ fn deselecting_keeps_the_arts_swatch_highlighted() { fn images_and_unswatched_text_highlight_no_swatch() { let mut app = app(); let mut png = vec![]; - image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) - .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .unwrap(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let data = vectorcraft_format::base64_encode(&png); app.run("file.place", json!({"name": "a.png", "dataBase64": data, "link": false})).unwrap(); assert_eq!(active_swatch(&app), None, "an image: no swatch"); diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index e193f0ab5..e7919b448 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -406,8 +406,7 @@ fn an_x_typed_past_the_next_stop_keeps_the_stop_selected() { let type_x = |app: &mut VectorcraftApp, shown: &str, typed: &str| { let texts = panel_frame(&ctx, app, vec![]); let at = texts.iter().find(|(t, _)| t == shown).unwrap_or_else(|| panic!("{shown} in {texts:?}")).1.center(); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; panel_frame(&ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); panel_frame(&ctx, app, vec![button(false)]); panel_frame(&ctx, app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index dd8617977..c433fbc02 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -319,8 +319,7 @@ fn numbers_without_units_are_points_reaches_the_fields_and_dims_without_picas() /// choices this version offers are kept (#502). #[test] fn a_graphics_processor_this_version_does_not_offer_reads_as_automatic() { - for (saved, read) in [("powerSaving", "automatic"), ("turbo", "automatic"), ("lowPower", "lowPower"), ("highPerformance", "highPerformance")] - { + for (saved, read) in [("powerSaving", "automatic"), ("turbo", "automatic"), ("lowPower", "lowPower"), ("highPerformance", "highPerformance")] { let mut a = app(); a.ui.engine_prefs = json!({"gpuPreference": saved}); restore(&mut a); diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 8edeeafd1..f679b1638 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -30,8 +30,7 @@ fn option_and_altgr_still_type_but_cmd_and_ctrl_chords_do_not() { /// the text it typed when `typed` is given. fn ctrl_alt_press(key: Key, typed: Option<&str>) -> Vec { let m = Modifiers::CTRL | Modifiers::ALT | Modifiers::COMMAND; - let mut v = - vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }]; + let mut v = vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }]; v.extend(typed.map(|t| egui::Event::Text(t.into()))); v } @@ -213,8 +212,7 @@ fn cmd_shift_b_while_typing_is_bold_not_the_bounding_box() { } frame(&mut app, vec![egui::Event::Text("Bold".into())]); assert!(app.session.tool_wants_text()); - let chord = - egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }; + let chord = egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }; frame(&mut app, vec![chord.clone()]); assert!(app.ui.view.bounding_box, "the bounding box stays"); // The chord reached Bold: the text is in a bold face, or its family has none and says so. @@ -234,20 +232,15 @@ fn cmd_shift_letters_align_paragraphs() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); - let chord = - |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }; + let chord = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }; let paras = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { vectorcraft_doc::NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justify).collect::>(), _ => panic!("text"), }; app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - for (key, want) in [ - (Key::C, Justify::Center), - (Key::R, Justify::Right), - (Key::J, Justify::JustifyLeft), - (Key::F, Justify::JustifyAll), - (Key::L, Justify::Left), - ] { + for (key, want) in + [(Key::C, Justify::Center), (Key::R, Justify::Right), (Key::J, Justify::JustifyLeft), (Key::F, Justify::JustifyAll), (Key::L, Justify::Left)] + { frame(&mut app, vec![chord(key)]); assert_eq!(paras(&app), [want, want], "{key:?}"); } @@ -271,8 +264,7 @@ fn cmd_alt_q_resets_tracking_and_kerning() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two"})).unwrap()["id"].as_u64().unwrap(); - let spaced = - |app: &mut VectorcraftApp| app.session.execute("text.setRangeStyle", &json!({"id": id, "tracking": 200, "kerning": -40})).unwrap(); + let spaced = |app: &mut VectorcraftApp| app.session.execute("text.setRangeStyle", &json!({"id": id, "tracking": 200, "kerning": -40})).unwrap(); let spacing = |app: &mut VectorcraftApp| { let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); r["runs"] @@ -282,8 +274,7 @@ fn cmd_alt_q_resets_tracking_and_kerning() { .map(|run| (run["text"].clone(), run["style"]["tracking"].clone(), run["style"]["kerning"].clone())) .collect::>() }; - let chord = - egui::Event::Key { key: Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT }; + let chord = egui::Event::Key { key: Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT }; spaced(&mut app); app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); frame(&mut app, vec![chord.clone()]); @@ -679,11 +670,7 @@ fn a_command_on_cmd_escape_runs_without_the_tools_escape() { if bindable { edit(&mut app); frame(&mut app, cmd_escape()); - assert_eq!( - (app.session.tool_wants_text(), app.ui.view.grid), - (true, grid), - "Cmd+Escape runs Show Grid and the Type tool goes on editing" - ); + assert_eq!((app.session.tool_wants_text(), app.ui.view.grid), (true, grid), "Cmd+Escape runs Show Grid and the Type tool goes on editing"); } app.select_tool("selection"); app.session.execute("object.isolate", &json!({"id": id})).unwrap(); @@ -865,8 +852,7 @@ fn shifted_punctuation_shortcuts_work_as_typed() { let a = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); let b = app.session.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); - let order = - |app: &VectorcraftApp| -> Vec { app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect() }; + let order = |app: &VectorcraftApp| -> Vec { app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect() }; let (a, b) = (a.as_u64().unwrap(), b.as_u64().unwrap()); assert_eq!(order(&app), [a, b]); let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; @@ -1026,8 +1012,7 @@ fn digit_keys_reach_a_dragging_tool_once_per_press() { app.session.execute("perspective.attach", &json!({"ids": [id], "plane": "right"})).unwrap(); app.select_tool("perspectiveSelection"); let v = app.view_info(); - let c = - app.session.doc().unwrap().doc.node(vectorcraft_engine::doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().center(); + let c = app.session.doc().unwrap().doc.node(vectorcraft_engine::doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().center(); app.session.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); app.session.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 30.0, c.y), v).unwrap(); let key = |repeat| egui::Event::Key { key: Key::Num5, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }; @@ -1209,8 +1194,7 @@ fn a_new_context_takes_its_own_frame_zero() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); let press = |app: &mut VectorcraftApp| { let ctx = egui::Context::default(); - let ev = - egui::Event::Key { key: Key::N, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT }; + let ev = egui::Event::Key { key: Key::N, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::ALT }; let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| handle(app, ui.ctx())); out.textures_delta.clear(); }; diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 43e4addec..777baa40e 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -281,8 +281,7 @@ fn double_clicking_the_line_arc_and_grid_tools_opens_their_tool_options() { ("rectangularGrid", "rows", json!(2), "shape.rectangularGrid", "rows"), ("polarGrid", "radial", json!(3), "shape.polarGrid", "radial"), ]; - let objects = - |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::(); + let objects = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::(); let drag = |app: &mut VectorcraftApp| { let view = app.view_info(); for (kind, x, y) in [ @@ -701,10 +700,7 @@ fn a_flyout_dragged_by_its_bar_floats_until_its_close_box_puts_it_back() { // A strip of its nine tools' buttons, its bar under the pointer. let drop = bar + vec2(60.0, 40.0); assert!(float.height() < 40.0 && float.width() > 9.0 * STRIP_BUTTON.x, "a one-row strip: {float:?}"); - assert!( - (float.right() - 1.0 - BAR / 2.0 - drop.x).abs() < 1.0 && float.y_range().contains(drop.y), - "its bar is under the pointer: {float:?}" - ); + assert!((float.right() - 1.0 - BAR / 2.0 - drop.x).abs() < 1.0 && float.y_range().contains(drop.y), "its bar is under the pointer: {float:?}"); // The same bar moves it. let bar = pos2(float.right() - 1.0 - BAR / 2.0, float.bottom() - 6.0); drag(&mut app, &ctx, 3.0, bar, &[bar + vec2(0.0, 20.0), bar + vec2(-10.0, 100.0)]); diff --git a/crates/ui-egui/src/widgets/look_tests.rs b/crates/ui-egui/src/widgets/look_tests.rs index fd2ec6a81..d0032aa3f 100644 --- a/crates/ui-egui/src/widgets/look_tests.rs +++ b/crates/ui-egui/src/widgets/look_tests.rs @@ -194,8 +194,7 @@ fn a_slider_focused_by_a_click_shows_its_outline_only_after_a_key() { }; frame(&ctx, 0.0, vec![], &mut add); let at = rail.get().center(); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; frame(&ctx, 0.1, vec![egui::Event::PointerMoved(at), button(true)], &mut add); frame(&ctx, 0.2, vec![button(false)], &mut add); let shapes = frame(&ctx, 0.3, vec![], &mut add); From 349f5e24effb8f8aebe51d27719efa2434835199 Mon Sep 17 00:00:00 2001 From: skylar Date: Mon, 26 Oct 2026 14:13:02 -0600 Subject: [PATCH 111/199] ci: require nextest 0.9.150 Use the fixed release for GHSA-vc25-qg58-w57c. --- .config/nextest.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" From 303d9a0a4dd5f4ed8002de5b4c053819ad0c5bad Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:17:08 +0000 Subject: [PATCH 112/199] test(ui-egui): read tl! literals past test-only imports --- crates/ui-egui/src/i18n/tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 22ade9b79..0ad8e2ace 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -496,7 +496,15 @@ pub fn tl_literals() -> std::collections::BTreeSet { } } else if name.ends_with(".rs") && name != "lib.rs" && !name.starts_with("tests") { let text = std::fs::read_to_string(&path).unwrap_or_default(); - let code = text.split("#[cfg(test)]").next().unwrap_or(""); + // Code up to the first test item; test-only imports (`#[cfg(test)] use …;`) don't end it. + let code = text + .match_indices("#[cfg(test)]") + .find(|&(at, m)| { + let item = text[at + m.len()..].trim_start(); + let item = ["pub(crate) ", "pub(super) ", "pub "].iter().find_map(|p| item.strip_prefix(p)).unwrap_or(item); + !item.starts_with("use ") + }) + .map_or(text.as_str(), |(at, _)| &text[..at]); let mut rest = code; while let Some(at) = rest.find("tl!(\"") { rest = &rest[at + 5..]; From 570f59fddca9d6c35812d505396b91cb8ddc427f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:48:51 +0000 Subject: [PATCH 113/199] refactor(doc): move appearance.rs items to appearance/{layers,profile,stroke_style}.rs --- crates/doc/src/appearance.rs | 658 +--------------------- crates/doc/src/appearance/layers.rs | 240 ++++++++ crates/doc/src/appearance/profile.rs | 222 ++++++++ crates/doc/src/appearance/stroke_style.rs | 200 +++++++ 4 files changed, 669 insertions(+), 651 deletions(-) create mode 100644 crates/doc/src/appearance/layers.rs create mode 100644 crates/doc/src/appearance/profile.rs create mode 100644 crates/doc/src/appearance/stroke_style.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 3853f5aec..2dc6cbabb 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -4,658 +4,14 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{BlendMode, Color, Paint}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum LineCap { - #[default] - Butt, - Round, - Square, -} +mod layers; +mod profile; +mod stroke_style; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum LineJoin { - #[default] - Miter, - Round, - Bevel, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum StrokeAlign { - #[default] - Center, - Inside, - Outside, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct Dash { - /// Dash, gap, dash, gap… (up to 6 values in the Stroke panel). - pub pattern: Vec, - #[serde(default)] - pub offset: f64, - /// "Aligns dashes to corners and path ends, adjusting lengths to fit". - #[serde(default)] - pub align_corners: bool, -} - -impl Dash { - /// Whether the pattern draws dashes. As in PDF (`d` operator) and SVG (`stroke-dasharray`), a - /// pattern with a negative or non-finite value, or whose values are all zero, is invalid and the - /// stroke is drawn solid. - pub fn is_dashed(&self) -> bool { - self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0) - } - /// Scale the dash and gap lengths and the offset by `s`. - pub fn scale(&mut self, s: f64) { - self.pattern.iter_mut().for_each(|v| *v *= s); - self.offset *= s; - } -} - -/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Arrowhead { - Triangle, - TriangleOpen, - Circle, - CircleOpen, - Square, - SquareOpen, - Bar, - Diamond, - Arrow, - ArrowOpen, - Barbed, - /// One barb, on the left of the direction the head points. - HalfArrowLeft, - /// One barb, on the right of the direction the head points. - HalfArrowRight, - Concave, - DoubleBar, - Feather, - DotOnBar, - Chevron, - DoubleArrow, - Target, - Star, - Cross, - Plus, - Hexagon, - HexagonOpen, - Tag, - TagOpen, - HalfCircle, - Drop, - Slash, - DoubleSlash, - DiamondOpen, - TriangleReverse, - Swallowtail, - Bracket, - Fork, - Leaf, - Kite, - TriangleBar, - Oval, -} - -impl Arrowhead { - /// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks). - pub const ALL: [Arrowhead; 40] = [ - Arrowhead::Arrow, - Arrowhead::ArrowOpen, - Arrowhead::Barbed, - Arrowhead::Concave, - Arrowhead::DoubleArrow, - Arrowhead::HalfArrowLeft, - Arrowhead::HalfArrowRight, - Arrowhead::Chevron, - Arrowhead::Feather, - Arrowhead::Swallowtail, - Arrowhead::Triangle, - Arrowhead::TriangleOpen, - Arrowhead::TriangleReverse, - Arrowhead::TriangleBar, - Arrowhead::Kite, - Arrowhead::Leaf, - Arrowhead::Drop, - Arrowhead::Circle, - Arrowhead::CircleOpen, - Arrowhead::HalfCircle, - Arrowhead::Oval, - Arrowhead::Target, - Arrowhead::Square, - Arrowhead::SquareOpen, - Arrowhead::Tag, - Arrowhead::TagOpen, - Arrowhead::Diamond, - Arrowhead::DiamondOpen, - Arrowhead::Hexagon, - Arrowhead::HexagonOpen, - Arrowhead::Star, - Arrowhead::Cross, - Arrowhead::Plus, - Arrowhead::Bar, - Arrowhead::DoubleBar, - Arrowhead::DotOnBar, - Arrowhead::Slash, - Arrowhead::DoubleSlash, - Arrowhead::Bracket, - Arrowhead::Fork, - ]; - - /// The name the arrowhead menus show. - pub fn label(self) -> &'static str { - match self { - Arrowhead::Triangle => "Triangle", - Arrowhead::TriangleOpen => "Triangle (open)", - Arrowhead::Circle => "Circle", - Arrowhead::CircleOpen => "Circle (open)", - Arrowhead::Square => "Square", - Arrowhead::SquareOpen => "Square (open)", - Arrowhead::Bar => "Bar", - Arrowhead::Diamond => "Diamond", - Arrowhead::Arrow => "Arrow", - Arrowhead::ArrowOpen => "Arrow (open)", - Arrowhead::Barbed => "Barbed", - Arrowhead::HalfArrowLeft => "Half Arrow (left)", - Arrowhead::HalfArrowRight => "Half Arrow (right)", - Arrowhead::Concave => "Concave", - Arrowhead::DoubleBar => "Double Bar", - Arrowhead::Feather => "Feather", - Arrowhead::DotOnBar => "Dot on Bar", - Arrowhead::Chevron => "Chevron", - Arrowhead::DoubleArrow => "Double Arrow", - Arrowhead::Target => "Target", - Arrowhead::Star => "Star", - Arrowhead::Cross => "Cross", - Arrowhead::Plus => "Plus", - Arrowhead::Hexagon => "Hexagon", - Arrowhead::HexagonOpen => "Hexagon (open)", - Arrowhead::Tag => "Tag", - Arrowhead::TagOpen => "Tag (open)", - Arrowhead::HalfCircle => "Half Circle", - Arrowhead::Drop => "Drop", - Arrowhead::Slash => "Slash", - Arrowhead::DoubleSlash => "Double Slash", - Arrowhead::DiamondOpen => "Diamond (open)", - Arrowhead::TriangleReverse => "Reverse Triangle", - Arrowhead::Swallowtail => "Swallowtail", - Arrowhead::Bracket => "Bracket", - Arrowhead::Fork => "Fork", - Arrowhead::Leaf => "Leaf", - Arrowhead::Kite => "Kite", - Arrowhead::TriangleBar => "Triangle to Bar", - Arrowhead::Oval => "Oval", - } - } -} - -/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under -/// the head, so the line never shows through a hollow head or past its tip. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ArrowAlign { - /// The tip extends past the end point (the path keeps its length). - #[default] - Extend, - /// The tip sits on the end point (the stroke is shortened by the head). - Tip, -} - -/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor), -/// in order along the path. Two points at the same position make a discontinuous point: the -/// first is the width just before it, the second the width just after it (a step). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct WidthProfile { - pub points: Vec<(f64, f64, f64)>, -} - -/// A built-in width profile (the Stroke panel's Profile list). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ProfilePreset { - /// Stable id used by `stroke.set {profile}`. - pub id: &'static str, - /// Menu label. - pub label: &'static str, - /// (t, left, right) width points. - pub points: &'static [(f64, f64, f64)], -} - -impl WidthProfile { - /// (left, right) width factors at `t`, linear between points. - pub fn at(&self, t: f64) -> (f64, f64) { - Self::at_points(&self.points, t) - } - /// [`Self::at`] of a profile's points (a preset's or a saved profile's). - pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { - if p.is_empty() { - return (1.0, 1.0); - } - if t <= p[0].0 { - return (p[0].1, p[0].2); - } - for w in p.windows(2) { - if t <= w[1].0 { - let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); - return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u); - } - } - p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) - } - /// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile). - pub const PRESETS: [ProfilePreset; 7] = [ - ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }, - ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }, - ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }, - ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }, - // Full width at both ends, pinched to a quarter in the middle. - ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] }, - // A round head a fifth of the way along, then a long taper to the end. - ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] }, - // Two swells between narrow necks. - ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, - ]; - /// The built-in profile with this id. - pub fn preset(id: &str) -> Option { - Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() }) - } - /// The id of the built-in profile these points match, if any. - pub fn preset_id(&self) -> Option<&'static str> { - Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id) - } - /// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset. - pub fn id_of(p: Option<&Self>) -> &'static str { - p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom") - } - /// The lens profile (thin ends, full width in the middle). - pub fn lens() -> Self { - Self::preset("lens").unwrap_or_default() - } - pub fn taper_end() -> Self { - Self::preset("taperEnd").unwrap_or_default() - } - pub fn taper_start() -> Self { - Self::preset("taperStart").unwrap_or_default() - } - /// The (left, right) factors just before and just after `t`: they differ only at a - /// discontinuous point (two points at `t`, within `1e-7`). - pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) { - let p = &self.points; - if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) { - let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1; - if j > i { - return ((p[i].1, p[i].2), (p[j].1, p[j].2)); - } - } - let v = self.at(t); - (v, v) - } -} - -/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1 -/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s -/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct PressureProfile { - pub points: Vec<(f64, f64)>, -} - -impl PressureProfile { - /// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to): - /// half way, so a Pressure brush draws its own value there. - pub const MID: f64 = 0.5; - /// The most samples a stroke keeps (after simplifying them). - pub const MAX_POINTS: usize = 512; - /// How far (in pressure) simplifying may move the recorded curve. - const TOLERANCE: f64 = 0.01; - - /// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any. - pub fn at(&self, t: f64) -> f64 { - let p = &self.points; - let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID }; - // (Positions go forward; a profile read from a file is searched as if they do, in log time.) - let v = if t <= first.0 { - first.1 - } else { - let i = p.partition_point(|q| q.0 < t); - match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { - (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0), - _ => last.1, - } - }; - if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID } - } - - /// A profile from (position, pressure) samples in order along the stroke: values are clamped - /// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to - /// [`Self::MAX_POINTS`]. `None` without a sample. - pub fn from_samples(samples: impl IntoIterator) -> Option { - let pts: Vec<(f64, f64)> = - samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect(); - if pts.is_empty() { - return None; - } - // Simplifying is quadratic at worst: a huge stroke is thinned evenly first. - let mut pts = thin(pts, 16 * Self::MAX_POINTS); - // Positions only ever go forward. - let mut prev = 0.0; - for p in &mut pts { - p.0 = p.0.max(prev); - prev = p.0; - } - let keep = simplify_1d(&pts, Self::TOLERANCE); - let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS); - let round = |v: f64| (v * 1e4).round() / 1e4; - Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() }) - } - - /// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its - /// end by a stroke `added_len` long (`added`). None when neither has pressure. - pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option { - if old.is_none() && added.is_none() { - return None; - } - let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0)); - let total = old_len + added_len; - if !total.is_finite() || total <= 1e-9 { - return old.or(added).cloned(); - } - let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] }; - let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat)); - let o = old_len / total; - let old_pts = old.points.iter().map(|&(t, p)| (t * o, p)); - Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p)))) - } - - /// The pressure of the path reversed. - pub fn reversed(&self) -> Self { - Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() } - } - - /// A light–heavy–light stroke, for previews of pressure-sensitive brushes. - pub fn sample() -> Self { - Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] } - } -} - -/// At most `max` of `pts`, evenly spread (the ends kept). -fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> { - let n = pts.len(); - if n <= max || max < 2 { - return pts; - } - (0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect() -} - -/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through -/// them stays within `tol` (vertically) of every sample. The ends are always kept. -fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec { - let mut keep = vec![false; pts.len()]; - let Some(last) = pts.len().checked_sub(1) else { return keep }; - for i in [0, last] { - if let Some(k) = keep.get_mut(i) { - *k = true; - } - } - let mut stack = vec![(0, last)]; - while let Some((i, j)) = stack.pop() { - let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue }; - let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 }; - let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1)); - if let Some((k, d)) = far - && d > tol - { - if let Some(f) = keep.get_mut(k) { - *f = true; - } - stack.push((i, k)); - stack.push((k, j)); - } - } - keep -} - -/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the -/// preferences. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SavedProfile { - pub name: String, - pub profile: WidthProfile, -} - -/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Effect { - /// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`. - pub id: String, - #[serde(default)] - pub params: serde_json::Value, - #[serde(default = "yes")] - pub visible: bool, -} - -fn yes() -> bool { - true -} -fn one() -> f32 { - 1.0 -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct FillLayer { - pub paint: Paint, - #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] - pub opacity: f32, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub blend: BlendMode, - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] - pub visible: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub effects: Vec, - /// Overprint Fill: on press the fill's inks print over the inks below instead of knocking - /// them out (Overprint Preview and Separations Preview show it). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub overprint: bool, -} - -impl FillLayer { - pub fn new(paint: Paint) -> Self { - Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], overprint: false } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct StrokeLayer { - pub paint: Paint, - /// Weight in points. - pub width: f64, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub cap: LineCap, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub join: LineJoin, - #[serde(default = "ten", skip_serializing_if = "is_ten")] - pub miter_limit: f64, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub align: StrokeAlign, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub start_arrow: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub end_arrow: Option, - /// Arrowhead scale in percent (start, end). - #[serde(default = "hundreds", skip_serializing_if = "is_hundreds")] - pub arrow_scale: (f64, f64), - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, - /// Brush applied to the stroke (by brush name). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub brush: Option, - #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] - pub opacity: f32, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub blend: BlendMode, - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] - pub visible: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub effects: Vec, - /// Arrowhead placement at both ends. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub arrow_align: ArrowAlign, - /// Overprint Stroke (see [`FillLayer::overprint`]). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub overprint: bool, - /// How a gradient paint maps onto the stroke (within, along or across it). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub gradient_mode: StrokeGradientMode, - /// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive - /// brushes vary with it. It belongs to this path's shape, so appearances copied to other - /// art leave it behind ([`Appearance::without_pressure`]). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pressure: Option, -} - -/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant -/// name: new modes are appended, never renamed. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum StrokeGradientMode { - /// Placed on the page like a fill's gradient and seen through the stroke. - #[default] - Within, - /// From the start of each subpath to its end, following the path. - Along, - /// From the stroke's left edge to its right edge (left of the path's direction), all along it. - Across, -} - -impl StrokeGradientMode { - pub const ALL: [StrokeGradientMode; 3] = [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across]; - /// The name commands use (`within`, `along`, `across`). - pub fn name(self) -> &'static str { - match self { - StrokeGradientMode::Within => "within", - StrokeGradientMode::Along => "along", - StrokeGradientMode::Across => "across", - } - } - /// Parse a mode name (any case). - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s)) - } -} - -/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are -/// cut square a little behind its 4 × 4 box). -pub(crate) const HEAD_REACH: f64 = 4.6; - -fn ten() -> f64 { - 10.0 -} -fn hundreds() -> (f64, f64) { - (100.0, 100.0) -} -fn is_ten(v: &f64) -> bool { - *v == 10.0 -} -fn is_hundreds(v: &(f64, f64)) -> bool { - *v == (100.0, 100.0) -} - -impl StrokeLayer { - pub fn new(paint: Paint, width: f64) -> Self { - Self { - paint, - width, - cap: LineCap::Butt, - join: LineJoin::Miter, - miter_limit: 10.0, - align: StrokeAlign::Center, - dash: None, - start_arrow: None, - end_arrow: None, - arrow_scale: (100.0, 100.0), - profile: None, - brush: None, - opacity: 1.0, - blend: BlendMode::Normal, - visible: true, - effects: vec![], - arrow_align: ArrowAlign::Extend, - overprint: false, - gradient_mode: StrokeGradientMode::Within, - pressure: None, - } - } - /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least - /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide. - pub fn arrow_weight(&self, end: bool) -> f64 { - let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 }; - (self.width * pct / 100.0).max(0.25) - } - /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 - /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. - pub fn head_reach(&self, end: bool) -> f64 { - let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0) - } - /// How far the arrowheads can reach from the path's end points (0 without heads). - pub fn arrow_reach(&self) -> f64 { - self.head_reach(false).max(self.head_reach(true)) - } - /// The widest the width profile makes the stroke, as a factor of its weight (1 without one). - pub fn profile_max(&self) -> f64 { - self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0) - } - /// The stroke's body across a path that is `closed` or open: how far it reaches from the path - /// at full width (half the weight centred; the whole weight on one side when aligned inside or - /// outside a closed path, which open paths ignore), and for aligned strokes which side it - /// paints (`Some(true)`: inside). - pub fn body(&self, closed: bool) -> (f64, Option) { - match self.align { - StrokeAlign::Outside if closed => (self.width, Some(false)), - StrokeAlign::Inside if closed => (self.width, Some(true)), - _ => (self.width / 2.0, None), - } - } - /// How far the body paints outside a path that is `closed` or open, at the profile's widest. - pub fn side_reach(&self, closed: bool) -> f64 { - match self.body(closed) { - (_, Some(true)) => 0.0, - (half, _) => half * self.profile_max(), - } - } - /// The farthest this stroke can paint from any path: its body, miter spikes up to the miter - /// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]). - pub fn reach(&self) -> f64 { - let side = self.side_reach(true).max(self.side_reach(false)); - let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 }; - let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; - (side * miter.max(cap)).max(self.arrow_reach()) - } - /// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]). - pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect { - stroke_paint_bounds(geometric, self.width) - } - /// The gradient this stroke lays along or across its path: its linear or radial gradient - /// paint when [`Self::gradient_mode`] isn't Within (freeform gradients always paint within). - pub fn path_gradient(&self) -> Option<&vectorcraft_color::GradientPaint> { - match &self.paint { - Paint::Gradient(g) - if self.gradient_mode != StrokeGradientMode::Within && g.gradient.kind != vectorcraft_color::GradientKind::Freeform => - { - Some(g) - } - _ => None, - } - } -} - -/// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half -/// the weight (object strokes and type strokes alike, on screen and in exports). -pub fn stroke_paint_bounds(geometric: vectorcraft_geom::Rect, width: f64) -> vectorcraft_geom::Rect { - geometric.inflate(width / 2.0, width / 2.0) -} +pub(crate) use layers::HEAD_REACH; +pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; +pub use profile::{PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; +pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; /// One entry of the appearance stack. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs new file mode 100644 index 000000000..6103e7dfd --- /dev/null +++ b/crates/doc/src/appearance/layers.rs @@ -0,0 +1,240 @@ +use super::profile::{PressureProfile, WidthProfile}; +use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; +use super::*; + +/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Effect { + /// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`. + pub id: String, + #[serde(default)] + pub params: serde_json::Value, + #[serde(default = "yes")] + pub visible: bool, +} + +fn yes() -> bool { + true +} + +fn one() -> f32 { + 1.0 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FillLayer { + pub paint: Paint, + #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] + pub opacity: f32, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub blend: BlendMode, + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] + pub visible: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, + /// Overprint Fill: on press the fill's inks print over the inks below instead of knocking + /// them out (Overprint Preview and Separations Preview show it). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint: bool, +} + +impl FillLayer { + pub fn new(paint: Paint) -> Self { + Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], overprint: false } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StrokeLayer { + pub paint: Paint, + /// Weight in points. + pub width: f64, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub cap: LineCap, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub join: LineJoin, + #[serde(default = "ten", skip_serializing_if = "is_ten")] + pub miter_limit: f64, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub align: StrokeAlign, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_arrow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_arrow: Option, + /// Arrowhead scale in percent (start, end). + #[serde(default = "hundreds", skip_serializing_if = "is_hundreds")] + pub arrow_scale: (f64, f64), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + /// Brush applied to the stroke (by brush name). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brush: Option, + #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] + pub opacity: f32, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub blend: BlendMode, + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] + pub visible: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, + /// Arrowhead placement at both ends. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub arrow_align: ArrowAlign, + /// Overprint Stroke (see [`FillLayer::overprint`]). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint: bool, + /// How a gradient paint maps onto the stroke (within, along or across it). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub gradient_mode: StrokeGradientMode, + /// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive + /// brushes vary with it. It belongs to this path's shape, so appearances copied to other + /// art leave it behind ([`Appearance::without_pressure`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pressure: Option, +} + +/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant +/// name: new modes are appended, never renamed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum StrokeGradientMode { + /// Placed on the page like a fill's gradient and seen through the stroke. + #[default] + Within, + /// From the start of each subpath to its end, following the path. + Along, + /// From the stroke's left edge to its right edge (left of the path's direction), all along it. + Across, +} + +impl StrokeGradientMode { + pub const ALL: [StrokeGradientMode; 3] = [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across]; + /// The name commands use (`within`, `along`, `across`). + pub fn name(self) -> &'static str { + match self { + StrokeGradientMode::Within => "within", + StrokeGradientMode::Along => "along", + StrokeGradientMode::Across => "across", + } + } + /// Parse a mode name (any case). + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s)) + } +} + +/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are +/// cut square a little behind its 4 × 4 box). +pub(crate) const HEAD_REACH: f64 = 4.6; + +fn ten() -> f64 { + 10.0 +} + +fn hundreds() -> (f64, f64) { + (100.0, 100.0) +} + +fn is_ten(v: &f64) -> bool { + *v == 10.0 +} + +fn is_hundreds(v: &(f64, f64)) -> bool { + *v == (100.0, 100.0) +} + +impl StrokeLayer { + pub fn new(paint: Paint, width: f64) -> Self { + Self { + paint, + width, + cap: LineCap::Butt, + join: LineJoin::Miter, + miter_limit: 10.0, + align: StrokeAlign::Center, + dash: None, + start_arrow: None, + end_arrow: None, + arrow_scale: (100.0, 100.0), + profile: None, + brush: None, + opacity: 1.0, + blend: BlendMode::Normal, + visible: true, + effects: vec![], + arrow_align: ArrowAlign::Extend, + overprint: false, + gradient_mode: StrokeGradientMode::Within, + pressure: None, + } + } + /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least + /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide. + pub fn arrow_weight(&self, end: bool) -> f64 { + let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 }; + (self.width * pct / 100.0).max(0.25) + } + /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 + /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. + pub fn head_reach(&self, end: bool) -> f64 { + let head = if end { self.end_arrow } else { self.start_arrow }; + head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0) + } + /// How far the arrowheads can reach from the path's end points (0 without heads). + pub fn arrow_reach(&self) -> f64 { + self.head_reach(false).max(self.head_reach(true)) + } + /// The widest the width profile makes the stroke, as a factor of its weight (1 without one). + pub fn profile_max(&self) -> f64 { + self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0) + } + /// The stroke's body across a path that is `closed` or open: how far it reaches from the path + /// at full width (half the weight centred; the whole weight on one side when aligned inside or + /// outside a closed path, which open paths ignore), and for aligned strokes which side it + /// paints (`Some(true)`: inside). + pub fn body(&self, closed: bool) -> (f64, Option) { + match self.align { + StrokeAlign::Outside if closed => (self.width, Some(false)), + StrokeAlign::Inside if closed => (self.width, Some(true)), + _ => (self.width / 2.0, None), + } + } + /// How far the body paints outside a path that is `closed` or open, at the profile's widest. + pub fn side_reach(&self, closed: bool) -> f64 { + match self.body(closed) { + (_, Some(true)) => 0.0, + (half, _) => half * self.profile_max(), + } + } + /// The farthest this stroke can paint from any path: its body, miter spikes up to the miter + /// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]). + pub fn reach(&self) -> f64 { + let side = self.side_reach(true).max(self.side_reach(false)); + let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 }; + let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + (side * miter.max(cap)).max(self.arrow_reach()) + } + /// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]). + pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect { + stroke_paint_bounds(geometric, self.width) + } + /// The gradient this stroke lays along or across its path: its linear or radial gradient + /// paint when [`Self::gradient_mode`] isn't Within (freeform gradients always paint within). + pub fn path_gradient(&self) -> Option<&vectorcraft_color::GradientPaint> { + match &self.paint { + Paint::Gradient(g) + if self.gradient_mode != StrokeGradientMode::Within && g.gradient.kind != vectorcraft_color::GradientKind::Freeform => + { + Some(g) + } + _ => None, + } + } +} + +/// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half +/// the weight (object strokes and type strokes alike, on screen and in exports). +pub fn stroke_paint_bounds(geometric: vectorcraft_geom::Rect, width: f64) -> vectorcraft_geom::Rect { + geometric.inflate(width / 2.0, width / 2.0) +} diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs new file mode 100644 index 000000000..56df51ef6 --- /dev/null +++ b/crates/doc/src/appearance/profile.rs @@ -0,0 +1,222 @@ +use super::*; + +/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor), +/// in order along the path. Two points at the same position make a discontinuous point: the +/// first is the width just before it, the second the width just after it (a step). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct WidthProfile { + pub points: Vec<(f64, f64, f64)>, +} + +/// A built-in width profile (the Stroke panel's Profile list). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProfilePreset { + /// Stable id used by `stroke.set {profile}`. + pub id: &'static str, + /// Menu label. + pub label: &'static str, + /// (t, left, right) width points. + pub points: &'static [(f64, f64, f64)], +} + +impl WidthProfile { + /// (left, right) width factors at `t`, linear between points. + pub fn at(&self, t: f64) -> (f64, f64) { + Self::at_points(&self.points, t) + } + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { + if p.is_empty() { + return (1.0, 1.0); + } + if t <= p[0].0 { + return (p[0].1, p[0].2); + } + for w in p.windows(2) { + if t <= w[1].0 { + let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); + return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u); + } + } + p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) + } + /// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile). + pub const PRESETS: [ProfilePreset; 7] = [ + ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }, + ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }, + ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + // Full width at both ends, pinched to a quarter in the middle. + ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] }, + // A round head a fifth of the way along, then a long taper to the end. + ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + // Two swells between narrow necks. + ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, + ]; + /// The built-in profile with this id. + pub fn preset(id: &str) -> Option { + Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() }) + } + /// The id of the built-in profile these points match, if any. + pub fn preset_id(&self) -> Option<&'static str> { + Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id) + } + /// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset. + pub fn id_of(p: Option<&Self>) -> &'static str { + p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom") + } + /// The lens profile (thin ends, full width in the middle). + pub fn lens() -> Self { + Self::preset("lens").unwrap_or_default() + } + pub fn taper_end() -> Self { + Self::preset("taperEnd").unwrap_or_default() + } + pub fn taper_start() -> Self { + Self::preset("taperStart").unwrap_or_default() + } + /// The (left, right) factors just before and just after `t`: they differ only at a + /// discontinuous point (two points at `t`, within `1e-7`). + pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) { + let p = &self.points; + if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) { + let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1; + if j > i { + return ((p[i].1, p[i].2), (p[j].1, p[j].2)); + } + } + let v = self.at(t); + (v, v) + } +} + +/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1 +/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s +/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PressureProfile { + pub points: Vec<(f64, f64)>, +} + +impl PressureProfile { + /// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to): + /// half way, so a Pressure brush draws its own value there. + pub const MID: f64 = 0.5; + /// The most samples a stroke keeps (after simplifying them). + pub const MAX_POINTS: usize = 512; + /// How far (in pressure) simplifying may move the recorded curve. + const TOLERANCE: f64 = 0.01; + + /// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any. + pub fn at(&self, t: f64) -> f64 { + let p = &self.points; + let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID }; + // (Positions go forward; a profile read from a file is searched as if they do, in log time.) + let v = if t <= first.0 { + first.1 + } else { + let i = p.partition_point(|q| q.0 < t); + match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { + (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0), + _ => last.1, + } + }; + if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID } + } + + /// A profile from (position, pressure) samples in order along the stroke: values are clamped + /// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to + /// [`Self::MAX_POINTS`]. `None` without a sample. + pub fn from_samples(samples: impl IntoIterator) -> Option { + let pts: Vec<(f64, f64)> = + samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect(); + if pts.is_empty() { + return None; + } + // Simplifying is quadratic at worst: a huge stroke is thinned evenly first. + let mut pts = thin(pts, 16 * Self::MAX_POINTS); + // Positions only ever go forward. + let mut prev = 0.0; + for p in &mut pts { + p.0 = p.0.max(prev); + prev = p.0; + } + let keep = simplify_1d(&pts, Self::TOLERANCE); + let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS); + let round = |v: f64| (v * 1e4).round() / 1e4; + Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() }) + } + + /// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its + /// end by a stroke `added_len` long (`added`). None when neither has pressure. + pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option { + if old.is_none() && added.is_none() { + return None; + } + let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0)); + let total = old_len + added_len; + if !total.is_finite() || total <= 1e-9 { + return old.or(added).cloned(); + } + let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] }; + let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat)); + let o = old_len / total; + let old_pts = old.points.iter().map(|&(t, p)| (t * o, p)); + Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p)))) + } + + /// The pressure of the path reversed. + pub fn reversed(&self) -> Self { + Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() } + } + + /// A light–heavy–light stroke, for previews of pressure-sensitive brushes. + pub fn sample() -> Self { + Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] } + } +} + +/// At most `max` of `pts`, evenly spread (the ends kept). +fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> { + let n = pts.len(); + if n <= max || max < 2 { + return pts; + } + (0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect() +} + +/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through +/// them stays within `tol` (vertically) of every sample. The ends are always kept. +fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec { + let mut keep = vec![false; pts.len()]; + let Some(last) = pts.len().checked_sub(1) else { return keep }; + for i in [0, last] { + if let Some(k) = keep.get_mut(i) { + *k = true; + } + } + let mut stack = vec![(0, last)]; + while let Some((i, j)) = stack.pop() { + let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue }; + let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 }; + let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1)); + if let Some((k, d)) = far + && d > tol + { + if let Some(f) = keep.get_mut(k) { + *f = true; + } + stack.push((i, k)); + stack.push((k, j)); + } + } + keep +} + +/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the +/// preferences. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedProfile { + pub name: String, + pub profile: WidthProfile, +} diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs new file mode 100644 index 000000000..3e6a29a32 --- /dev/null +++ b/crates/doc/src/appearance/stroke_style.rs @@ -0,0 +1,200 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineCap { + #[default] + Butt, + Round, + Square, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineJoin { + #[default] + Miter, + Round, + Bevel, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum StrokeAlign { + #[default] + Center, + Inside, + Outside, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Dash { + /// Dash, gap, dash, gap… (up to 6 values in the Stroke panel). + pub pattern: Vec, + #[serde(default)] + pub offset: f64, + /// "Aligns dashes to corners and path ends, adjusting lengths to fit". + #[serde(default)] + pub align_corners: bool, +} + +impl Dash { + /// Whether the pattern draws dashes. As in PDF (`d` operator) and SVG (`stroke-dasharray`), a + /// pattern with a negative or non-finite value, or whose values are all zero, is invalid and the + /// stroke is drawn solid. + pub fn is_dashed(&self) -> bool { + self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0) + } + /// Scale the dash and gap lengths and the offset by `s`. + pub fn scale(&mut self, s: f64) { + self.pattern.iter_mut().for_each(|v| *v *= s); + self.offset *= s; + } +} + +/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Arrowhead { + Triangle, + TriangleOpen, + Circle, + CircleOpen, + Square, + SquareOpen, + Bar, + Diamond, + Arrow, + ArrowOpen, + Barbed, + /// One barb, on the left of the direction the head points. + HalfArrowLeft, + /// One barb, on the right of the direction the head points. + HalfArrowRight, + Concave, + DoubleBar, + Feather, + DotOnBar, + Chevron, + DoubleArrow, + Target, + Star, + Cross, + Plus, + Hexagon, + HexagonOpen, + Tag, + TagOpen, + HalfCircle, + Drop, + Slash, + DoubleSlash, + DiamondOpen, + TriangleReverse, + Swallowtail, + Bracket, + Fork, + Leaf, + Kite, + TriangleBar, + Oval, +} + +impl Arrowhead { + /// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks). + pub const ALL: [Arrowhead; 40] = [ + Arrowhead::Arrow, + Arrowhead::ArrowOpen, + Arrowhead::Barbed, + Arrowhead::Concave, + Arrowhead::DoubleArrow, + Arrowhead::HalfArrowLeft, + Arrowhead::HalfArrowRight, + Arrowhead::Chevron, + Arrowhead::Feather, + Arrowhead::Swallowtail, + Arrowhead::Triangle, + Arrowhead::TriangleOpen, + Arrowhead::TriangleReverse, + Arrowhead::TriangleBar, + Arrowhead::Kite, + Arrowhead::Leaf, + Arrowhead::Drop, + Arrowhead::Circle, + Arrowhead::CircleOpen, + Arrowhead::HalfCircle, + Arrowhead::Oval, + Arrowhead::Target, + Arrowhead::Square, + Arrowhead::SquareOpen, + Arrowhead::Tag, + Arrowhead::TagOpen, + Arrowhead::Diamond, + Arrowhead::DiamondOpen, + Arrowhead::Hexagon, + Arrowhead::HexagonOpen, + Arrowhead::Star, + Arrowhead::Cross, + Arrowhead::Plus, + Arrowhead::Bar, + Arrowhead::DoubleBar, + Arrowhead::DotOnBar, + Arrowhead::Slash, + Arrowhead::DoubleSlash, + Arrowhead::Bracket, + Arrowhead::Fork, + ]; + + /// The name the arrowhead menus show. + pub fn label(self) -> &'static str { + match self { + Arrowhead::Triangle => "Triangle", + Arrowhead::TriangleOpen => "Triangle (open)", + Arrowhead::Circle => "Circle", + Arrowhead::CircleOpen => "Circle (open)", + Arrowhead::Square => "Square", + Arrowhead::SquareOpen => "Square (open)", + Arrowhead::Bar => "Bar", + Arrowhead::Diamond => "Diamond", + Arrowhead::Arrow => "Arrow", + Arrowhead::ArrowOpen => "Arrow (open)", + Arrowhead::Barbed => "Barbed", + Arrowhead::HalfArrowLeft => "Half Arrow (left)", + Arrowhead::HalfArrowRight => "Half Arrow (right)", + Arrowhead::Concave => "Concave", + Arrowhead::DoubleBar => "Double Bar", + Arrowhead::Feather => "Feather", + Arrowhead::DotOnBar => "Dot on Bar", + Arrowhead::Chevron => "Chevron", + Arrowhead::DoubleArrow => "Double Arrow", + Arrowhead::Target => "Target", + Arrowhead::Star => "Star", + Arrowhead::Cross => "Cross", + Arrowhead::Plus => "Plus", + Arrowhead::Hexagon => "Hexagon", + Arrowhead::HexagonOpen => "Hexagon (open)", + Arrowhead::Tag => "Tag", + Arrowhead::TagOpen => "Tag (open)", + Arrowhead::HalfCircle => "Half Circle", + Arrowhead::Drop => "Drop", + Arrowhead::Slash => "Slash", + Arrowhead::DoubleSlash => "Double Slash", + Arrowhead::DiamondOpen => "Diamond (open)", + Arrowhead::TriangleReverse => "Reverse Triangle", + Arrowhead::Swallowtail => "Swallowtail", + Arrowhead::Bracket => "Bracket", + Arrowhead::Fork => "Fork", + Arrowhead::Leaf => "Leaf", + Arrowhead::Kite => "Kite", + Arrowhead::TriangleBar => "Triangle to Bar", + Arrowhead::Oval => "Oval", + } + } +} + +/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under +/// the head, so the line never shows through a hollow head or past its tip. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ArrowAlign { + /// The tip extends past the end point (the path keeps its length). + #[default] + Extend, + /// The tip sits on the end point (the stroke is shortened by the head). + Tip, +} From d63c01628d44d6ef6d803cabda78636a2260c53a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:48:51 +0000 Subject: [PATCH 114/199] refactor(doc): move lib.rs items to {artboard,document,document_ext,document_guides,graphic_style,guides,limits,records,units}.rs --- crates/doc/src/artboard.rs | 152 +++ crates/doc/src/document.rs | 786 +++++++++++ crates/doc/src/document_ext.rs | 285 ++++ crates/doc/src/document_guides.rs | 313 +++++ crates/doc/src/graphic_style.rs | 92 ++ crates/doc/src/guides.rs | 58 + crates/doc/src/lib.rs | 2069 +---------------------------- crates/doc/src/limits.rs | 20 + crates/doc/src/records.rs | 123 ++ crates/doc/src/units.rs | 293 ++++ 10 files changed, 2141 insertions(+), 2050 deletions(-) create mode 100644 crates/doc/src/artboard.rs create mode 100644 crates/doc/src/document.rs create mode 100644 crates/doc/src/document_ext.rs create mode 100644 crates/doc/src/document_guides.rs create mode 100644 crates/doc/src/graphic_style.rs create mode 100644 crates/doc/src/guides.rs create mode 100644 crates/doc/src/limits.rs create mode 100644 crates/doc/src/records.rs create mode 100644 crates/doc/src/units.rs diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs new file mode 100644 index 000000000..bdd20f4c8 --- /dev/null +++ b/crates/doc/src/artboard.rs @@ -0,0 +1,152 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Artboard { + pub id: u32, + pub name: String, + /// Artboard rectangle in document points. + pub rect: Rect, + /// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands + /// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be + /// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays + /// selected ([`Document::new_art`]). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub locked: bool, + /// Show Center Mark (Artboard Options): the canvas draws a mark at the artboard's center. + /// Exports and print leave it out. Always written, as before it could be set. + #[serde(default)] + pub show_center_mark: bool, + /// Show Cross Hairs (Artboard Options): the canvas draws a line from the middle of each side + /// through the center. Exports and print leave them out. Always written, as before it could + /// be set. + #[serde(default)] + pub show_cross_hairs: bool, + /// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and + /// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written + /// only when on, so a document that never shows them saves as before. + #[serde(default, skip_serializing_if = "skip::is_default")] + pub show_video_safe_areas: bool, + /// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its + /// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many + /// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't + /// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is + /// brought into it as it opens. + #[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")] + pub video_ruler_pixel_aspect_ratio: f64, + /// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the + /// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on; + /// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave + /// it out. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times +/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and +/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare. +pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive = 0.1..=10.0; + +/// How far in from each edge of an artboard the video safe areas lie, as a share of its width +/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the +/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television +/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5). +pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05]; + +fn square_pixels() -> f64 { + 1.0 +} + +fn is_square_pixels(v: &f64) -> bool { + *v == 1.0 +} + +/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that +/// isn't a number (NaN, infinite) reads as square pixels. +fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = f64::deserialize(d)?; + let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end()); + Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 }) +} + +impl Artboard { + /// The action-safe and the title-safe area of the artboard, in that order, each inset from its + /// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas). + pub fn video_safe_areas(&self) -> [Rect; 2] { + let r = self.rect; + VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height())) + } +} + +/// A document's locked artboards ([`Document::locked_artboards`]). +pub struct LockedArtboards<'a> { + pub(crate) rects: Vec, + pub(crate) symbols: SymbolBoxes<'a>, + /// The new art, which isn't on them ([`Document::new_art`]). + pub(crate) new_art: Arc>, + /// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no + /// artboard. + pub(crate) pattern_art: std::collections::HashSet, + /// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a + /// group are each answered with one measurement of the group. + pub(crate) held: std::cell::RefCell>, +} + +impl LockedArtboards<'_> { + /// No artboard is locked. + pub fn is_empty(&self) -> bool { + self.rects.is_empty() + } + /// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its + /// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An + /// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or + /// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or + /// new art while it stays selected ([`Document::new_art`]). + pub fn holds(&self, n: &Node) -> bool { + !self.new_art.contains(&n.id) && self.overlaps(n) + } + /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it + /// is new art? + pub fn overlaps(&self, n: &Node) -> bool { + if self.rects.is_empty() + || !n.visible + || n.is_layer() + || matches!(n.kind, NodeKind::Path { guide: true, .. }) + || self.pattern_art.contains(&n.id) + { + return false; + } + if let Some(held) = self.held.borrow().get(&n.id) { + return *held; + } + let held = + n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0)); + self.held.borrow_mut().insert(n.id, held); + held + } + /// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id` + /// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])? + pub fn holds_id(&self, id: NodeId) -> bool { + !self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n)) + } +} + +/// [`Document::retired_artboard_id`]: not document content, so two documents are equal whatever it +/// holds. A clone shares the value with the document it was cloned from. +#[derive(Clone, Debug, Default)] +pub struct RetiredArtboardId(pub(crate) Arc); + +impl RetiredArtboardId { + pub fn get(&self) -> u32 { + self.0.load(std::sync::atomic::Ordering::Relaxed) + } + /// Raise the value to `id` when `id` is higher. + pub fn raise(&self, id: u32) { + self.0.fetch_max(id, std::sync::atomic::Ordering::Relaxed); + } +} + +impl PartialEq for RetiredArtboardId { + fn eq(&self, _: &Self) -> bool { + true + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs new file mode 100644 index 000000000..a17b430be --- /dev/null +++ b/crates/doc/src/document.rs @@ -0,0 +1,786 @@ +use super::artboard::{Artboard, LockedArtboards}; +use super::graphic_style::default_graphic_styles; +use super::guides::{GridPrefs, RulerMode}; +use super::limits::{DocError, FORMAT_VERSION, MAX_COORD, MAX_NEXT_ID}; +use super::units::{ColorMode, Unit}; +use super::*; + +impl Document { + /// Is everything but the layers the same in `other`, as the next version of the document after + /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it + /// doesn't change: quick for every frame of an animation. + pub fn same_outside_layers(&self, other: &Self) -> bool { + let same_art = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)); + let same_pattern = |a: &PatternDef, b: &PatternDef| { + same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() } + }; + let Self { + version, + title, + template, + units, + ruler_origin, + rulers, + artboard_ruler_origins, + color_mode, + artboards, + layers: _, + swatches, + swatch_groups, + graphic_styles, + char_styles, + para_styles, + text_threads, + symbols, + graph_designs, + guides, + views, + saved_selections, + variables, + grid, + raster_effects_ppi, + images, + patterns, + pattern_edit, + mask_edit, + next_id: _, + unknown, + page_isolate, + page_knockout, + spot_use_lab, + setup, + paste_remembers_layers, + metadata, + raster_effects, + last_view, + transparency_grid, + layers_open, + color_profiles, + export_settings, + extra, + slices, + slices_clip_to_artboard, + print_setup, + assets, + svg_scripts, + puppet, + new_art, + dimensions, + retired_artboard_id: _, + index: _, + } = self; + *version == other.version + && *title == other.title + && *template == other.template + && *units == other.units + && *ruler_origin == other.ruler_origin + && *rulers == other.rulers + && *artboard_ruler_origins == other.artboard_ruler_origins + && *color_mode == other.color_mode + && *artboards == other.artboards + && *swatches == other.swatches + && *swatch_groups == other.swatch_groups + && *graphic_styles == other.graphic_styles + && *char_styles == other.char_styles + && *para_styles == other.para_styles + && *text_threads == other.text_threads + && symbols.len() == other.symbols.len() + && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && graph_designs.len() == other.graph_designs.len() + && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && *guides == other.guides + && *views == other.views + && *saved_selections == other.saved_selections + && *variables == other.variables + && *grid == other.grid + && *raster_effects_ppi == other.raster_effects_ppi + && *images == other.images + && patterns.len() == other.patterns.len() + && patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b)) + && match (pattern_edit, &other.pattern_edit) { + (Some(a), Some(b)) => { + a.pattern == b.pattern + && a.layer == b.layer + && match (&a.original, &b.original) { + (Some(a), Some(b)) => same_pattern(a, b), + (a, b) => a.is_none() && b.is_none(), + } + } + (a, b) => a.is_none() && b.is_none(), + } + && *mask_edit == other.mask_edit + && *unknown == other.unknown + && *page_isolate == other.page_isolate + && *page_knockout == other.page_knockout + && *spot_use_lab == other.spot_use_lab + && *setup == other.setup + && *paste_remembers_layers == other.paste_remembers_layers + && *metadata == other.metadata + && *raster_effects == other.raster_effects + && *last_view == other.last_view + && *transparency_grid == other.transparency_grid + && *layers_open == other.layers_open + && *color_profiles == other.color_profiles + && *export_settings == other.export_settings + && *extra == other.extra + && *slices == other.slices + && *slices_clip_to_artboard == other.slices_clip_to_artboard + && *print_setup == other.print_setup + && *assets == other.assets + && *svg_scripts == other.svg_scripts + && match (puppet, &other.puppet) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + } + && *new_art == other.new_art + && *dimensions == other.dimensions + } + + /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value + /// isn't a number). + pub fn raster_ppi(&self) -> f64 { + Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0) + } + + /// The ruler zero point, or zero on both axes when either stored coordinate is invalid. + /// Rulers, coordinate fields and guide snapping use the same supported range. + pub fn ruler_origin_or_zero(&self) -> Point { + let origin = self.ruler_origin; + if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO } + } + + /// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`] + /// with global rulers; with artboard rulers that artboard's top-left corner moved by its own + /// offset (the first artboard's when `index` is out of range, the global zero point when the + /// document has no artboard or the result is out of the supported range). + pub fn ruler_origin_for(&self, index: usize) -> Point { + if self.rulers == RulerMode::Global { + return self.ruler_origin_or_zero(); + } + let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() }; + let corner = Point::new(ab.rect.x0, ab.rect.y0); + let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO); + let origin = corner + offset; + if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { + origin + } else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD { + corner + } else { + Point::ZERO + } + } + + /// A new RGB document with one artboard of `size` and one layer ("Layer 1"). + pub fn new(width: f64, height: f64) -> Self { + Self::new_with_mode(width, height, ColorMode::Rgb) + } + + /// [`Document::new`] in colour `mode`, with that mode's default swatches. + pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self { + let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model()); + let mut d = Self { + version: FORMAT_VERSION, + title: "Untitled-1".into(), + template: false, + units: Unit::Points, + ruler_origin: Point::ZERO, + rulers: RulerMode::Artboard, + artboard_ruler_origins: BTreeMap::new(), + color_mode: mode, + artboards: vec![Artboard { + id: 1, + name: "Artboard 1".into(), + rect: Rect::new(0.0, 0.0, width, height), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }], + layers: vec![], + swatches: swatches.into(), + swatch_groups: swatch_groups.into(), + graphic_styles: default_graphic_styles().into(), + char_styles: Default::default(), + para_styles: Default::default(), + text_threads: vec![], + symbols: vec![], + graph_designs: vec![], + guides: vec![], + views: vec![], + saved_selections: Default::default(), + variables: Default::default(), + grid: GridPrefs::default(), + raster_effects_ppi: 72.0, + images: BTreeMap::new(), + patterns: vec![], + pattern_edit: None, + mask_edit: None, + next_id: 1, + unknown: Default::default(), + page_isolate: false, + page_knockout: false, + spot_use_lab: true, + setup: DocSetup::default(), + paste_remembers_layers: false, + metadata: DocMetadata::default(), + raster_effects: RasterEffectsSettings::default(), + last_view: None, + transparency_grid: false, + layers_open: None, + color_profiles: ColorProfiles::default(), + export_settings: Default::default(), + extra: Default::default(), + slices: vec![], + slices_clip_to_artboard: true, + print_setup: None, + assets: vec![], + svg_scripts: vec![], + puppet: None, + new_art: Default::default(), + dimensions: false, + retired_artboard_id: Default::default(), + index: Default::default(), + }; + let id = d.alloc_id(); + d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); + d + } + + /// Allocate a fresh node id. + pub fn alloc_id(&mut self) -> NodeId { + let id = NodeId(self.next_id); + // Never reached from a counter [`Self::fix_next_id`] has seen (it stays under + // [`MAX_NEXT_ID`]); saturating so that no counter can overflow. + self.next_id = self.next_id.saturating_add(1); + id + } + + pub fn peek_next_id(&self) -> u64 { + self.next_id + } + + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). + pub fn fix_next_id(&mut self) { + // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a + // counter up there: new ids start under them and never reach them, where counting on from + // the largest would run out and hand out ids the document already uses. + let mut max = 0; + let mut see = |id: u64| { + if id < MAX_NEXT_ID { + max = max.max(id); + } + }; + for l in &self.layers { + l.walk(&mut |n| see(n.id.0)); + } + self.slices.iter().for_each(|s| see(s.id.0)); + self.assets.iter().for_each(|a| see(a.id)); + let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); + self.next_id = counter.max(max.saturating_add(1)); + } + + /// Find a node anywhere in the tree. + pub fn node(&self, id: NodeId) -> Option<&Node> { + self.index_path(id).and_then(|p| self.node_at(&p)) + } + + /// [`Document::node`] without building the lookup cache when there is none: for a look at a + /// document kept in the undo history ([`Document::drop_lookup_cache`]). + pub fn node_uncached(&self, id: NodeId) -> Option<&Node> { + match self.index.is_built() { + true => self.node(id), + false => self.walk_index_path(id).and_then(|p| self.node_at(&p)), + } + } + + /// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a + /// table per step would add up. Lookups still work; the first one builds the cache again. + pub fn drop_lookup_cache(&self) { + self.index.clear(); + } + + /// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]). + pub fn has_lookup_cache(&self) -> bool { + self.index.is_built() + } + + /// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in + /// tree order where `id` appears. + pub fn index_path(&self, id: NodeId) -> Option> { + // The cached path, while the node there still has the id ([`id_index`]). + let cached = self.index.get(); + match cached.as_ref().and_then(|m| m.get(&id)) { + Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p), + Some(None) => return self.walk_index_path(id), + _ => {} + } + // A stale path (the tree changed), an id the cache doesn't have (deleted or added since), + // or no cache yet: walk, and rebuild now and then. + let found = self.walk_index_path(id); + let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id)); + if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) { + self.index.rebuild(&self.layers); + } + found + } + + /// [`Document::index_path`] by walking the tree. + pub(crate) fn walk_index_path(&self, id: NodeId) -> Option> { + fn find(nodes: &[Arc], id: NodeId, path: &mut Vec) -> bool { + for (i, n) in nodes.iter().enumerate() { + path.push(i); + if n.id == id { + return true; + } + if let Some(ch) = n.children() + && find(ch, id, path) + { + return true; + } + path.pop(); + } + false + } + let mut p = vec![]; + find(&self.layers, id, &mut p).then_some(p) + } + + /// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an + /// edit of many objects looks them all up for the cost of one search instead of one each. Like + /// `index_path`, an id that appears twice maps to its first place in tree order. + pub fn index_paths(&self, ids: impl IntoIterator) -> std::collections::HashMap> { + use std::collections::{HashMap, HashSet}; + fn walk(nodes: &[Arc], want: &HashSet, path: &mut Vec, out: &mut HashMap>) -> bool { + for (i, n) in nodes.iter().enumerate() { + path.push(i); + if want.contains(&n.id) && !out.contains_key(&n.id) { + out.insert(n.id, path.clone()); + if out.len() == want.len() { + return true; + } + } + if let Some(ch) = n.children() + && walk(ch, want, path, out) + { + return true; + } + path.pop(); + } + false + } + let mut want: HashSet = ids.into_iter().collect(); + let mut out = HashMap::with_capacity(want.len()); + // Cached paths that still check out ([`id_index`]); the rest take one walk. For a good + // share of the document, one walk is quicker than checking each cached path. + if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) { + want.retain(|id| match cache.get(id) { + Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => { + out.insert(*id, p); + false + } + _ => true, + }); + } + if !want.is_empty() { + let mut rest = HashMap::with_capacity(want.len()); + walk(&self.layers, &want, &mut Vec::new(), &mut rest); + out.extend(rest); + } + out + } + + /// The node at index path `path` (as [`Document::index_path`] gives it). + pub fn node_at(&self, path: &[usize]) -> Option<&Node> { + let (first, rest) = path.split_first()?; + let mut cur: &Node = self.layers.get(*first)?; + for &i in rest { + cur = cur.children()?.get(i)?; + } + Some(cur) + } + + /// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as + /// [`Document::node_mut`] does. + pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> { + let (first, rest) = path.split_first()?; + let mut cur: &mut Arc = self.layers.get_mut(*first)?; + for &i in rest { + cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?; + } + Some(Arc::make_mut(cur)) + } + + /// [`Document::ancestry`] of the node at index path `path`. + pub fn ancestry_at(&self, path: &[usize]) -> Option> { + let mut out = Vec::with_capacity(path.len()); + let mut nodes = &self.layers; + for &i in path { + let n = nodes.get(i)?; + out.push(n.id); + if let Some(ch) = n.children() { + nodes = ch; + } + } + Some(out) + } + + /// Ids from the top-level layer down to `id` inclusive. + pub fn ancestry(&self, id: NodeId) -> Option> { + let path = self.index_path(id)?; + let mut out = Vec::with_capacity(path.len()); + let mut nodes = &self.layers; + for &i in &path { + let n = &nodes[i]; + out.push(n.id); + if let Some(ch) = n.children() { + nodes = ch; + } + } + Some(out) + } + + pub fn parent_of(&self, id: NodeId) -> Option { + let a = self.ancestry(id)?; + (a.len() >= 2).then(|| a[a.len() - 2]) + } + + /// The top-level layer that contains `id`. + pub fn layer_of(&self, id: NodeId) -> Option { + self.ancestry(id).and_then(|a| a.first().copied()) + } + + /// Mutable access to a node; clones the Arc path (copy-on-write). + pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> { + let path = self.index_path(id)?; + let mut cur: &mut Arc = &mut self.layers[path[0]]; + for &i in &path[1..] { + cur = &mut Arc::make_mut(cur).children_mut()?[i]; + } + Some(Arc::make_mut(cur)) + } + + /// Children list of a container (or the top-level layer list for `None`). + pub fn children_mut(&mut self, parent: Option) -> Result<&mut Vec>, DocError> { + match parent { + None => Ok(&mut self.layers), + Some(p) => self.node_mut(p).ok_or(DocError::NoNode(p))?.children_mut().ok_or(DocError::NotContainer(p)), + } + } + + pub fn children(&self, parent: Option) -> Option<&Vec>> { + match parent { + None => Some(&self.layers), + Some(p) => self.node(p)?.children(), + } + } + + /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). + pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { + let id = node.id; + let ch = self.children_mut(parent)?; + let i = index.min(ch.len()); + ch.insert(i, Arc::new(node)); + Ok(id) + } + + /// Remove a node and return it. + pub fn remove(&mut self, id: NodeId) -> Result, DocError> { + let parent = self.parent_of(id); + let path = self.index_path(id).ok_or(DocError::NoNode(id))?; + let idx = *path.last().ok_or(DocError::NoNode(id))?; + let ch = self.children_mut(parent)?; + if idx >= ch.len() { + return Err(DocError::NoNode(id)); + } + Ok(ch.remove(idx)) + } + + /// Move a node to `parent` at `index` (index measured after removal). + pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { + if let Some(p) = parent + && self.ancestry(p).is_some_and(|a| a.contains(&id)) + { + return Err(DocError::Invalid("cannot move an object into itself".into())); + } + let n = self.remove(id)?; + let ch = self.children_mut(parent)?; + let i = index.min(ch.len()); + ch.insert(i, n); + Ok(()) + } + + /// Position of `id` within its parent: (parent, index, sibling count). + pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { + let parent = self.parent_of(id); + let idx = *self.index_path(id)?.last()?; + let n = self.children(parent)?.len(); + Some((parent, idx, n)) + } + + /// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the + /// children of each parent. Each id is listed once and none lies inside another listed node, as + /// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies + /// inside another listed node, it fails with `NoNode` and leaves the document as it was. + pub fn remove_many(&mut self, ids: &[NodeId]) -> Result>, DocError> { + let places = self.places(ids)?; + let removed = self.take(places, ids.len())?; + self.index.clear(); + Ok(removed) + } + + /// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make` + /// builds from them (in the order of `ids`) in the parent of the last of them, at that node's + /// index less the number of listed nodes below it there. Returns the new node's id. + pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec>) -> Node) -> Result { + let places = self.places(ids)?; + let last = ids.len().checked_sub(1); + let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i))); + let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) }; + // The parent and each node it is in move down by the listed nodes before them. + let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index)); + let to: Vec = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect(); + let at = i.saturating_sub(below(&up, i)); + let node = make(self.take(places, ids.len())?); + let id = node.id; + let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?; + let at = at.min(children.len()); + children.insert(at, Arc::new(node)); + self.index.clear(); + Ok(id) + } + + /// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in + /// list order, in one pass over the children of each parent. Each anchor is listed once and none + /// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside + /// another listed anchor, it fails with `NoNode` and leaves the document as it was. + pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec)>, replace: bool) -> Result<(), DocError> { + let anchors: Vec = items.iter().map(|(id, _)| *id).collect(); + let places = self.places(&anchors)?; + let mut lists: Vec> = items.into_iter().map(|(_, nodes)| nodes).collect(); + self.edit_children(places, |k, anchor, children| { + if !replace { + children.push(anchor); + } + let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default(); + children.extend(nodes.into_iter().map(Arc::new)); + })?; + self.index.clear(); + Ok(()) + } + + /// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree, + /// is listed twice or lies inside another listed node. + fn places(&self, ids: &[NodeId]) -> Result { + let paths = self.index_paths(ids.iter().copied()); + let mut places = Places::new(); + for (k, id) in ids.iter().enumerate() { + let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?; + match places.get_mut(up) { + Some(kids) => kids.push((i, k)), + None => { + places.insert(up.to_vec(), vec![(i, k)]); + } + } + } + for kids in places.values_mut() { + kids.sort_unstable(); + } + // An id listed twice has one path, so its parent lists the same child twice. A listed node + // lies inside another when its parent's path, or a part of it from the top, is the path of a + // listed node. + let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1); + let listed = |path: &[usize]| { + path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok())) + }; + let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l]))); + let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k); + if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) { + return Err(DocError::NoNode(*id)); + } + Ok(places) + } + + /// Take the nodes `places` lists out of their parents and return them in the order of the list, + /// which is `len` long. + fn take(&mut self, places: Places, len: usize) -> Result>, DocError> { + let mut taken = vec![None; len]; + self.edit_children(places, |k, node, _| { + if let Some(t) = taken.get_mut(k) { + *t = Some(node); + } + })?; + Ok(taken.into_iter().flatten().collect()) + } + + /// Rebuild the children of each parent in `places` once, the parents later in tree order + /// first. `each` gets each listed child, its place in the list and the parent's new children, + /// onto which it pushes what takes the child's place. + fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc, &mut Vec>)) -> Result<(), DocError> { + for (up, kids) in places.into_iter().rev() { + let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?; + let old = std::mem::take(children); + children.reserve(old.len()); + let mut kids = kids.into_iter().peekable(); + for (i, node) in old.into_iter().enumerate() { + match kids.next_if(|(j, _)| *j == i) { + Some((_, k)) => each(k, node, children), + None => children.push(node), + } + } + } + Ok(()) + } + + /// The children of the node at index path `path`, or the top-level layers for an empty path. + fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec>> { + match path { + [] => Some(&mut self.layers), + _ => self.node_mut_at(path)?.children_mut(), + } + } + + /// Default target layer: the topmost visible, unlocked layer. + pub fn default_layer(&self) -> Option { + self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id) + } + + /// Add a new top-level layer above all others. + pub fn add_layer(&mut self, name: Option<&str>) -> NodeId { + let id = self.alloc_id(); + let n = self.layers.len(); + let name = name.map(str::to_string).unwrap_or_else(|| self.next_layer_name()); + self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8)))); + id + } + + /// The default name of a new layer or sublayer, "Layer N". Layers and sublayers share the + /// numbering: it starts after the number of layers there are and skips any layer's name. + pub fn next_layer_name(&self) -> String { + let (mut count, mut taken) = (0, std::collections::HashSet::new()); + self.walk(|n| { + if n.is_layer() { + count += 1; + taken.extend(n.name.as_deref()); + } + }); + (count + 1..).map(|i| format!("Layer {i}")).find(|name| !taken.contains(name.as_str())).unwrap_or_default() + } + + /// Visit every node depth first in paint order (bottom to top). + pub fn walk<'a>(&'a self, mut f: impl FnMut(&'a Node)) { + for l in &self.layers { + l.walk(&mut f); + } + } + + pub fn node_count(&self) -> usize { + self.layers.iter().map(|l| l.count()).sum() + } + + /// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard + /// ([`Document::on_locked_artboard`])? + pub fn is_editable(&self, id: NodeId) -> bool { + self.is_editable_with(&self.locked_artboards(), id) + } + + /// [`Document::is_editable`] with the locked artboards `locks` of this document, measured + /// once for many ids. + pub fn is_editable_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) && !locks.holds_id(id) + } + + /// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once + /// for all of them, a group once for all its members. + pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ { + let locks = self.locked_artboards(); + move |id| self.is_editable_with(&locks, id) + } + + /// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]). + pub fn locked_artboards(&self) -> LockedArtboards<'_> { + self.locked_artboards_except(usize::MAX) + } + + /// [`Document::locked_artboards`] leaving out artboard `index`. + pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> { + // Art flush with an edge from outside can overlap it by a rounding error, as in + // `art_bounds_on` (#1295). + let rects: Vec = + self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect(); + // The objects of pattern editing mode's layer, a sublayer's objects included. + fn objects(n: &Node, out: &mut std::collections::HashSet) { + for c in n.children().into_iter().flatten() { + if c.is_layer() { + objects(c, out); + } else { + out.insert(c.id); + } + } + } + let mut pattern_art = std::collections::HashSet::new(); + if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) { + objects(layer, &mut pattern_art); + } + LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() } + } + + /// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none. + pub fn on_locked_artboard(&self, id: NodeId) -> bool { + self.locked_artboards().holds_id(id) + } + + /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else + /// the group or other object around it right inside a layer. None for a layer or sublayer. + pub fn layer_object(&self, id: NodeId) -> Option<&Node> { + self.layer_object_at(&self.index_path(id)?) + } + + /// [`Document::layer_object`] for the node at index path `path`. + pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> { + // Down the path to the first node that isn't a layer. + let mut nodes = &self.layers; + for &i in path { + let n = nodes.get(i)?; + if !n.is_layer() { + return Some(n.as_ref()); + } + nodes = n.children()?; + } + None + } + + pub fn is_visible(&self, id: NodeId) -> bool { + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible))) + } + + /// The innermost layer or sublayer containing `id` (`id` itself when it is a layer). + pub fn layer_containing(&self, id: NodeId) -> Option { + let a = self.ancestry(id)?; + a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer)) + } + + /// Every art object the Selection tool can reach (Select All): the visible, unlocked objects + /// of the visible, unlocked, non-template layers, looking through sublayers (bottom first), + /// except those on a locked artboard. + pub fn selectable_art(&self) -> Vec { + let locks = self.locked_artboards(); + self.layers + .iter() + .filter(|l| l.visible && !l.locked && !l.is_template()) + .flat_map(|l| l.layer_art(true)) + .filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n))) + .collect() + } + + /// Colour of the innermost layer or sublayer containing `id` (selection highlight colour). + pub fn layer_color(&self, id: NodeId) -> [u8; 3] { + let l = self.layer_containing(id).and_then(|l| self.node(l)); + match l.map(|n| &n.kind) { + Some(NodeKind::Layer { color, .. }) => color.rgb(), + _ => LAYER_COLORS[0].1, + } + } +} diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs new file mode 100644 index 000000000..9d17f689a --- /dev/null +++ b/crates/doc/src/document_ext.rs @@ -0,0 +1,285 @@ +use super::graphic_style::GraphicStyle; +use super::limits::MAX_NEXT_ID; +use super::records::{ImageBlob, SavedSelection, SavedView}; +use super::*; + +impl Document { + /// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's. + pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> { + im.pixels.as_deref().or_else(|| self.images.get(&im.key)) + } + + /// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art + /// made outside a document) into [`Self::images`]: under the image's key, or a free one when + /// the key shows other pixels. + pub fn adopt_pixels(&mut self, n: &mut Node) { + // Only what holds such pixels is copied out of the art it shares. + fn holds(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some()) + || n.extras().mask.as_ref().is_some_and(|m| holds(&m.art)) + || n.children().into_iter().flatten().any(|c| holds(c)) + } + if let NodeKind::Image(im) = &mut n.kind { + self.adopt_image_pixels(im); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) + && holds(&m.art) + { + self.adopt_pixels(Arc::make_mut(&mut m.art)); + } + for c in n.children_mut().into_iter().flatten() { + if holds(c) { + self.adopt_pixels(Arc::make_mut(c)); + } + } + } + + pub(crate) fn adopt_image_pixels(&mut self, im: &mut ImageObject) { + let Some(blob) = im.pixels.take() else { return }; + let mut key = im.key.clone(); + let mut i = 1; + while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) { + i += 1; + key = format!("{}-{i}", im.key); + } + self.images.entry(key.clone()).or_insert_with(|| (*blob).clone()); + im.key = key; + } + + /// Saved selections as a file gives them, made safe to list and recall: at most + /// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document + /// has, each once. (An id it doesn't have would select whatever object takes that id later.) + pub fn tidy_saved_selections(&mut self) { + if self.saved_selections.is_empty() { + return; + } + let mut ids = std::collections::HashSet::new(); + self.walk(|n| { + ids.insert(n.id); + }); + let mut kept: Vec = vec![]; + for s in std::mem::take(&mut self.saved_selections) { + if kept.len() >= SavedSelection::MAX { + break; + } + let Some(name) = SavedSelection::clean_name(&s.name) else { continue }; + if kept.iter().any(|k| k.name == name) { + continue; + } + let mut seen = std::collections::HashSet::new(); + let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect(); + kept.push(SavedSelection { name, objects }); + } + self.saved_selections = kept.into(); + } + + /// Saved views as a file gives them, made safe to list and go to: at most [`SavedView::MAX`], + /// each with a clean, unique name, a finite centre and a finite, positive zoom (a rotation + /// that isn't finite becomes 0°). + pub fn tidy_views(&mut self) { + if self.views.is_empty() { + return; + } + let mut kept: Vec = vec![]; + for v in std::mem::take(&mut self.views) { + if kept.len() >= SavedView::MAX { + break; + } + let Some(name) = SavedView::clean_name(&v.name) else { continue }; + if !v.is_usable() || kept.iter().any(|k| k.name == name) { + continue; + } + let rotation = if v.rotation.is_finite() { v.rotation } else { 0.0 }; + kept.push(SavedView { name, rotation, ..v }); + } + self.views = kept; + } + + /// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the + /// mask already holds (the engine syncs it after every edit). + pub fn drop_edit_modes(&mut self) { + if let Some(me) = self.mask_edit.take() { + self.layers.retain(|l| l.id != me.layer); + } + } + + /// The document as saved and exported: without the opacity-mask editing layer. Borrowed when + /// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.) + pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> { + if self.mask_edit.is_none() { + return std::borrow::Cow::Borrowed(self); + } + let mut d = self.clone(); + d.drop_edit_modes(); + std::borrow::Cow::Owned(d) + } +} + +/// Graphic styles. +impl Document { + /// Index of the graphic style named `name`. + pub fn graphic_style_index(&self, name: &str) -> Option { + self.graphic_styles.iter().position(|g| g.name == name) + } + + /// The graphic style named `name`. + pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> { + self.graphic_styles.iter().find(|g| g.name == name) + } + + /// The graphic style with id `id` (0 never matches). + pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> { + self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id) + } + + /// An id no graphic style has. + pub fn next_graphic_style_id(&self) -> u32 { + free_id(self.graphic_styles.iter().map(|g| g.id)) + } + + /// The id of graphic style `index`, assigning one if it has none yet. + pub fn graphic_style_id(&mut self, index: usize) -> u32 { + if self.graphic_styles[index].id == 0 { + self.graphic_styles[index].id = self.next_graphic_style_id(); + } + self.graphic_styles[index].id + } + + /// The default name of a new graphic style: the first free "Graphic Style N", N counting on + /// from the number of styles. + pub fn new_graphic_style_name(&self) -> String { + (self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default() + } + + /// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name. + pub fn unique_symbol_name(&self, base: &str) -> String { + let taken = |n: &str| self.symbols.iter().any(|s| s.name == n); + if !taken(base) { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) + } + + /// Symbol Options › Registration for `name` (default: centre). + pub fn symbol_registration(&self, name: &str) -> SymbolRegistration { + self.unknown + .get(SYMBOL_REGISTRATION) + .and_then(|m| m.get(name)) + .and_then(|v| v.as_u64()) + .and_then(SymbolRegistration::from_u64) + .unwrap_or_default() + } + + /// Set or clear (when centre) the registration point stored for `name`. + pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) { + let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({})); + let Some(o) = m.as_object_mut() else { return }; + if reg.is_center() { + o.remove(name); + if o.is_empty() { + self.unknown.remove(SYMBOL_REGISTRATION); + } + } else { + o.insert(name.into(), serde_json::json!(reg.0)); + } + } + + /// Move the stored registration when a symbol is renamed. + pub fn rename_symbol_registration(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let reg = self.symbol_registration(from); + self.set_symbol_registration(from, SymbolRegistration::CENTER); + self.set_symbol_registration(to, reg); + } + + /// Symbol Options › Export Type for `name` (default: Movie Clip). + pub fn symbol_export_type(&self, name: &str) -> SymbolExportType { + self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default() + } + + /// Set or clear (when Movie Clip, the default) the export type stored for `name`. + pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) { + if kind.is_default() { + self.clear_symbol_export_type(name); + return; + } + let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({})); + let Some(o) = m.as_object_mut() else { return }; + o.insert(name.into(), serde_json::json!(kind.id())); + } + + /// Drop any stored export type for `name` (Movie Clip default). + pub fn clear_symbol_export_type(&mut self, name: &str) { + let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return }; + let Some(o) = m.as_object_mut() else { return }; + o.remove(name); + if o.is_empty() { + self.unknown.remove(SYMBOL_EXPORT_TYPE); + } + } + + /// Move the stored export type when a symbol is renamed. + pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let kind = self.symbol_export_type(from); + self.clear_symbol_export_type(from); + self.set_symbol_export_type(to, kind); + } + + /// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default). + pub fn symbol_nine_slice(&self, name: &str) -> Option { + self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value) + } + + /// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`. + pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option) { + let Some(g) = guides else { + let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return }; + o.remove(name); + if o.is_empty() { + self.unknown.remove(SYMBOL_NINE_SLICE); + } + return; + }; + let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({})); + if let Some(o) = m.as_object_mut() { + o.insert(name.into(), g.to_value()); + } + } + + /// Move the stored 9-slice guides when a symbol is renamed. + pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let g = self.symbol_nine_slice(from); + self.set_symbol_nine_slice(from, None); + self.set_symbol_nine_slice(to, g); + } +} + +/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there +/// is (a damaged file), the smallest one free. +pub(crate) fn free_id(used: impl Iterator) -> u32 { + let mut used: Vec = used.collect(); + if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) { + return next; + } + used.sort_unstable(); + used.dedup(); + // Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them. + let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count(); + u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1)) +} + +impl Document { + /// Make the next id allocated at least `next`: ids handed out outside the layer tree (such + /// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see. + pub fn reserve_ids(&mut self, next: u64) { + self.next_id = self.next_id.max(next.min(MAX_NEXT_ID)); + } +} diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs new file mode 100644 index 000000000..96af5c75b --- /dev/null +++ b/crates/doc/src/document_guides.rs @@ -0,0 +1,313 @@ +use super::document_ext::free_id; +use super::guides::Guide; +use super::*; + +impl Document { + /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol + /// instances measured by their art ([`Node::geometric_bounds_with`]). + pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option { + let symbols = self.symbol_boxes(); + ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| { + vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) }) + }) + } + + /// Visual bounds of all art, with symbol instances measured by their art. + pub fn art_bounds(&self) -> Option { + let symbols = self.symbol_boxes(); + self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols))) + } + + /// Artboard index containing point `p` (topmost = last), its edges included + /// ([`Document::artboard_holding`]). + pub fn artboard_at(&self, p: Point) -> Option { + self.artboard_holding(p, true) + } + + /// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it + /// inside or on its left or top edge, else the first (the last) that holds it by + /// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an + /// edge. On an edge two artboards share, the artboard to the right of it or below it holds the + /// point, except at the end of the edge that lies on that artboard's bottom or right edge, such + /// as the point where both artboards' bottom edges meet; there the first (the last) holds it. + pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option { + let pick = |on: &dyn Fn(Rect) -> bool| { + let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect)); + let mut all = 0..self.artboards.len(); + if topmost { all.rfind(on) } else { all.find(on) } + }; + pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p))) + } + + /// The art that moves with an artboard at `rect`: top-level objects (children of layers and + /// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and + /// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display › + /// Move Locked and Hidden Artwork with Artboard). + pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec { + // Art drawn flush with the artboard can end up a rounding error outside it (a background + // 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible. + let rect = rect.inflate(1e-6, 1e-6); + fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec) { + for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) { + if c.is_layer() { + collect(c, s, rect, all, out); + } else if let Some(b) = c.geometric_bounds_with(s) + // Inside it, edges included: art flush with the right or bottom edge is on it too + // (`Rect::contains` leaves those out, #890). + && b.x0 >= rect.x0 + && b.y0 >= rect.y0 + && b.x1 <= rect.x1 + && b.y1 <= rect.y1 + { + out.push(c.id); + } + } + } + let symbols = self.symbol_boxes(); + let mut art = vec![]; + for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) { + collect(l, &symbols, rect, locked_and_hidden, &mut art); + } + art + } + + /// Does any artwork lie on `rect` (an artboard)? Artwork is any object under the layers, + /// hidden and locked ones and those on template layers included, whose visual bounds (symbol + /// instances measured by their art) meet `rect`; touching its edge counts. A group (not a clip + /// group) counts by its members, so one whose members sit on two other artboards leaves the one + /// between them empty. A clip group or clipping layer counts by its members inside its clip (and + /// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork + /// (Delete Empty Artboards). + pub fn artboard_has_art(&self, rect: Rect) -> bool { + // As in `art_on_artboard`: a millionth of a point covers rounding at the edges. + let rect = rect.inflate(1e-6, 1e-6); + fn meets(b: Option, r: Rect) -> bool { + b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1) + } + fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool { + match &c.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + let Some((clip, rest)) = children.split_first() else { return false }; + let paint = clip.clip_paint(); + if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) { + return true; + } + // The members show only inside the clip's box. + let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false }; + let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1)); + rest.iter().any(|m| has(m, s, inside)) + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)), + NodeKind::Path { guide: true, .. } => false, + _ => meets(c.visual_bounds_with(s), rect), + } + } + let symbols = self.symbol_boxes(); + self.layers.iter().any(|l| has(l, &symbols, rect)) + } + + /// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`] + /// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as + /// locked art does. + pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec { + let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] }; + let mut art = self.art_on_artboard(rect, locked_and_hidden); + let locks = self.locked_artboards_except(index); + if !locked_and_hidden && !locks.is_empty() { + art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n))); + } + art + } + + /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its + /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose + /// artboard is gone. + pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> { + let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect; + Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) + } + + /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. + fn guide_layer(&self, g: &Guide) -> Option { + g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) + } + + /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is + /// visible? A guide whose layer is gone shows as one on no layer. + pub fn guide_shown(&self, g: &Guide) -> bool { + self.guide_layer(g).is_none_or(|l| self.is_visible(l)) + } + + /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? + pub fn guide_editable(&self, g: &Guide) -> bool { + self.guide_layer(g).is_none_or(|l| self.is_editable(l)) + } + + /// Put the ruler guides on no layer this document has (from files saved before guides had + /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. + pub fn rehome_guides(&mut self, layer: NodeId) -> usize { + let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); + let mut n = 0; + for (g, lost) in self.guides.iter_mut().zip(lost) { + if lost { + g.layer = Some(layer); + n += 1; + } + } + n + } + + /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that + /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. + pub fn adopt_guides(&mut self) -> usize { + self.default_layer().map_or(0, |home| self.rehome_guides(home)) + } + + /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in + /// step. Guides on no layer stay. Returns how many went. + pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { + let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); + self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) + } + + /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made + /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies + /// the objects on it. Returns how many. + pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { + let layers = |n: &Node| { + let mut out = vec![]; + n.walk(&mut |c| { + if c.is_layer() { + out.push(c.id); + } + }); + out + }; + let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); + let copies: Vec = self + .guides + .iter() + .filter_map(|g| { + let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; + Some(Guide { layer: Some(to), ..g.clone() }) + }) + .collect(); + let n = copies.len(); + self.guides.extend(copies); + n + } + + /// The ruler guides on layer `layer` (not its sublayers'), by index. + pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { + self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) + } + + /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? + pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool { + let along = if g.vertical { p.y } else { p.x }; + self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol) + } + + /// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their + /// place among the rest (the selected art stays selected). Returns how many went. + pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize { + let kept: Vec = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect(); + // Where each kept guide ends up. + let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0); + for k in &kept { + to.push(k.then_some(n)); + n += usize::from(*k); + } + sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); + sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); + let before = self.guides.len(); + let mut flags = kept.into_iter(); + self.guides.retain(|_| flags.next().unwrap_or(true)); + before - self.guides.len() + } + + /// Move the guides of artboard `id` by `d` (along with their artboard). + pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) { + for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { + *g = g.moved(d); + } + } + + /// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork + /// with Artboard). + pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) { + for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { + g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y }; + } + } + + /// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their + /// artboard). + pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) { + let copies: Vec = + self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); + self.guides.extend(copies); + } + + /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of + /// the artboards deleted from the document ([`Document::retired_artboard_id`]). The id counts + /// as given out, so an artboard made after an undo does not take the id of one the undo took + /// away. + pub fn next_artboard_id(&self) -> u32 { + let id = free_id(self.artboards.iter().map(|a| a.id).chain([self.retired_artboard_id.get()])); + self.retired_artboard_id.raise(id); + id + } + + /// Record that the artboard with `id` was deleted: no later artboard takes its id. + pub fn retire_artboard_id(&self, id: u32) { + self.retired_artboard_id.raise(id); + } + + pub fn pattern(&self, name: &str) -> Option<&PatternDef> { + self.patterns.iter().find(|p| p.name == name) + } + + pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> { + self.patterns.iter_mut().find(|p| p.name == name) + } + + /// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken + /// in ([`Self::adopt_pixels`]). + pub fn reid(&mut self, node: &Node) -> Node { + let mut n = node.clone(); + n.id = self.alloc_id(); + if let NodeKind::Image(im) = &mut n.kind { + self.adopt_image_pixels(im); + } + // A copied annotation must never bind its source ids to unrelated art in a different + // document. Keep the measured geometry; new measurements can be linked after pasting. + if let Some(e) = n.extras_if_any() + && let Some(dimension) = &mut e.dimension + { + dimension.source = None; + } + // A copy of a threaded frame is in no thread, so its first paragraph starts in it and its + // layout caches are dropped. A caller that keeps the copy as an object fills them again. + if let NodeKind::Text(t) = &mut n.kind + && t.first_para_continues + { + t.first_para_continues = false; + t.cached_bounds = None; + t.cached_baselines.clear(); + } + let mut renamed = std::collections::HashMap::new(); + if let Some(ch) = n.children_mut() { + let old: Vec> = std::mem::take(ch); + *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); + renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id))); + } + // An Objects on Path group names its path and its objects by id: the copy names the copies. + if let Some(e) = n.extras_if_any() + && let Some(on_path) = &mut e.objects_on_path + { + on_path.rename(&renamed); + } + n + } +} diff --git a/crates/doc/src/graphic_style.rs b/crates/doc/src/graphic_style.rs new file mode 100644 index 000000000..95c61b6eb --- /dev/null +++ b/crates/doc/src/graphic_style.rs @@ -0,0 +1,92 @@ +use super::*; + +/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GraphicStyle { + pub name: String, + pub appearance: Appearance, + /// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles + /// from older files get one when an object is first linked to them). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub id: u32, + #[serde(default = "one", skip_serializing_if = "skip::is_one")] + pub opacity: f32, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub blend: vectorcraft_color::BlendMode, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub isolate: bool, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub knockout: Knockout, + /// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style + /// places them on each object's own bounds. Styles saved before this kept document + /// coordinates (false). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unit_box: bool, +} + +fn one() -> f32 { + 1.0 +} + +/// The name of the style new documents list first (Sort by Name keeps it first). +pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style"; + +impl GraphicStyle { + /// The box a style's placed gradients are stored relative to. + pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0); + + /// A style with `appearance` (its placed gradients, if any, in unit-box space) and default + /// transparency (no id yet). + pub fn new(name: impl Into, appearance: Appearance) -> Self { + Self { + name: name.into(), + appearance, + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Knockout::Neutral, + unit_box: true, + } + } + /// A style capturing `n`'s transparency and `appearance` (in unit-box space). + pub fn of(name: impl Into, appearance: Appearance, n: &Node) -> Self { + Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) } + } + /// The style's appearance as object `n` takes it: placed gradients stored in unit-box space + /// land at the same place relative to `n`'s geometric bounds. + pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> { + let placed = self.unit_box && self.appearance.has_placed_gradient(); + match n.geometric_bounds().filter(|_| placed) { + Some(b) => { + let mut ap = self.appearance.clone(); + ap.rebase_gradients(Self::UNIT_BOX, b); + std::borrow::Cow::Owned(ap) + } + None => std::borrow::Cow::Borrowed(&self.appearance), + } + } + /// Give `n` this style's transparency. + pub fn apply_transparency(&self, n: &mut Node) { + (n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout); + } + /// Does `n` have this style's transparency? + pub fn transparency_matches(&self, n: &Node) -> bool { + (n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout) + } +} + +pub(crate) fn default_graphic_styles() -> Vec { + use vectorcraft_color::{Color, Paint}; + let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)); + [ + GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()), + GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), + GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)), + GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)), + ] + .into_iter() + .zip(1..) + .map(|(g, id)| GraphicStyle { id, ..g }) + .collect() +} diff --git a/crates/doc/src/guides.rs b/crates/doc/src/guides.rs new file mode 100644 index 000000000..0c10efff2 --- /dev/null +++ b/crates/doc/src/guides.rs @@ -0,0 +1,58 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Guide { + /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). + pub vertical: bool, + pub pos: f64, + /// An artboard guide: the [`Artboard::id`] it belongs to. It runs across that artboard only + /// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artboard: Option, + /// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed + /// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides + /// from before guides had layers, and from files that don't say), always there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer: Option, +} + +impl Guide { + /// A canvas guide. + pub fn new(vertical: bool, pos: f64) -> Self { + Self { vertical, pos, artboard: None, layer: None } + } + + /// Moved by `d` (a vertical guide across, a horizontal one down). + pub fn moved(&self, d: Vec2) -> Self { + Self { pos: self.pos + if self.vertical { d.x } else { d.y }, ..self.clone() } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GridPrefs { + pub spacing: f64, + pub subdivisions: u32, +} + +impl Default for GridPrefs { + fn default() -> Self { + Self { spacing: 72.0, subdivisions: 8 } + } +} + +/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a +/// new document, or one origin for the whole document. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RulerMode { + #[default] + Artboard, + Global, +} + +impl RulerMode { + /// The mode of a document saved before the choice existed: global, as its rulers measured. + pub fn legacy() -> Self { + Self::Global + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e87373dad..18873d6d9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -116,695 +116,26 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{Swatch, SwatchGroup}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; -/// Coordinates beyond this many points exceed the supported document range. -pub const MAX_COORD: f64 = 4.0e6; +mod artboard; +mod document; +mod document_ext; +mod document_guides; +mod graphic_style; +mod guides; +mod limits; +mod records; +mod units; -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum DocError { - #[error("no such object {0}")] - NoNode(NodeId), - #[error("object {0} cannot have children")] - NotContainer(NodeId), - #[error("{0}")] - Invalid(String), -} - -/// Measurement units (display only; the model is always in points). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Unit { - #[default] - Points, - Picas, - Inches, - Millimeters, - Centimeters, - Pixels, - FeetInches, - Meters, - Yards, - Feet, -} - -impl Unit { - pub const ALL: [Unit; 10] = [ - Unit::Points, - Unit::Picas, - Unit::Inches, - Unit::Millimeters, - Unit::Centimeters, - Unit::Pixels, - Unit::FeetInches, - Unit::Meters, - Unit::Yards, - Unit::Feet, - ]; - /// Points per unit. - pub fn points(self) -> f64 { - match self { - Unit::Points | Unit::Pixels => 1.0, - Unit::Picas => 12.0, - Unit::Inches => 72.0, - Unit::Millimeters => 72.0 / 25.4, - Unit::Centimeters => 72.0 / 2.54, - Unit::Meters => 72.0 / 0.0254, - Unit::Yards => 72.0 * 36.0, - Unit::Feet | Unit::FeetInches => 72.0 * 12.0, - } - } - pub fn suffix(self) -> &'static str { - match self { - Unit::Points => "pt", - Unit::Picas => "p", - Unit::Inches => "in", - Unit::Millimeters => "mm", - Unit::Centimeters => "cm", - Unit::Pixels => "px", - Unit::FeetInches | Unit::Feet => "ft", - Unit::Meters => "m", - Unit::Yards => "yd", - } - } - pub fn label(self) -> &'static str { - match self { - Unit::Points => "Points", - Unit::Picas => "Picas", - Unit::Inches => "Inches", - Unit::Millimeters => "Millimeters", - Unit::Centimeters => "Centimeters", - Unit::Pixels => "Pixels", - Unit::FeetInches => "Feet & Inches", - Unit::Meters => "Meters", - Unit::Yards => "Yards", - Unit::Feet => "Feet", - } - } - /// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of - /// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it. - pub fn ruler_step(self, zoom: f64) -> f64 { - const DECIMAL: [f64; 19] = - [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0]; - // An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler - // carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a - // sixteenth; zooming in past that leaves the labels where they are and only the ticks - // between them grow denser, down to a hundred and twenty-eighth - // ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which - // the left ruler has room for when it spells one down the ruler a character at a time. - // From an inch up the ladder is the shared one. - const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0]; - let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL }; - steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0) - } - /// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric - /// units, points, pixels and picas are read in tenths. - fn ruler_halves(self) -> bool { - matches!(self, Unit::Inches) - } - /// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of - /// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about - /// as dense as the tenths elsewhere; a tenth of it otherwise. - pub fn ruler_minor(self, step: f64) -> f64 { - if self.ruler_halves() { step / 8.0 } else { step / 10.0 } - } - /// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]). - pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 { - let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points(); - (v / tick).round() * tick - } - pub fn from_pt(self, v: f64) -> f64 { - v / self.points() - } - pub fn to_pt(self, v: f64) -> f64 { - v * self.points() - } - /// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`). - pub fn format(self, pt: f64) -> String { - format!("{} {}", self.number(pt), self.suffix()) - } - /// [`Unit::format`] without the suffix (`12.5`), for narrow fields. - pub fn number(self, pt: f64) -> String { - // Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`, - // `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`). - let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 }; - let s = format!("{:.*}", decimals, self.from_pt(pt)); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } - } - /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic. - /// - /// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so - /// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term - /// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away - /// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A - /// percentage on its own (`50%`) reads as nothing. - pub fn parse(self, s: &str) -> Option { - parse_measure(s, self).filter(|v| v.is_finite()) - } - /// The value naming this unit in the Units preferences (`points`, `millimeters`, - /// `feetInches`). - pub fn key(self) -> &'static str { - match self { - Unit::Points => "points", - Unit::Picas => "picas", - Unit::Inches => "inches", - Unit::Millimeters => "millimeters", - Unit::Centimeters => "centimeters", - Unit::Pixels => "pixels", - Unit::FeetInches => "feetInches", - Unit::Meters => "meters", - Unit::Yards => "yards", - Unit::Feet => "feet", - } - } - /// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and - /// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`). - pub fn named(name: &str) -> Option { - let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); - let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); - // Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches - // is left to its label and key. - Unit::ALL - .into_iter() - .find(|u| letters(u.label()).eq(given())) - .or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given()))) - } - /// A point value as the canvas measurement labels show it: two decimals and the suffix - /// (`12.50 mm`). - pub fn readout(self, pt: f64) -> String { - format!("{:.2} {}", self.from_pt(pt), self.suffix()) - } -} - -fn parse_measure(s: &str, default: Unit) -> Option { - let s = s.trim(); - // Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2"). - // `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last - // of its operators (left to right). - for ops in [&['+', '-'][..], &['*', '/'][..]] { - // Past the first character, so a leading sign isn't an operator. - let skip = s.chars().next().map_or(0, char::len_utf8); - // A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`). - let is_sign = |i: usize| { - s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-')) - && s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c)) - }; - let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i)); - if let Some(i) = found.map(|(i, _)| i) { - let (l, r) = (&s[..i], &s[i + 1..]); - if l.trim().is_empty() { - continue; - } - let Some(op) = s[i..].chars().next() else { continue }; - return match op { - '+' | '-' => { - let a = parse_measure(l, default)?; - // A percentage added or taken away is a share of everything before it: - // `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt. - let b = match percent(r) { - Some(k) => a * k, - None => parse_measure(r, default)?, - }; - Some(if op == '+' { a + b } else { a - b }) - } - _ => { - // A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm); - // a unit after it is the expression's. - let (k, unit) = match percent(r) { - Some(k) => (k, None), - None => number_unit(r)?, - }; - let a = parse_measure(l, unit.unwrap_or(default))?; - if op == '*' { - Some(a * k) - } else if k == 0.0 { - None - } else { - Some(a / k) - } - } - }; - } - } - // Plain decimals only, as in `number_unit` (`str::parse` would accept `1e5`, `inf`, `nan`). - let plain = |t: &str| t.trim().chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') && t.trim().parse::().is_ok(); - if let Some((p, pt)) = s.split_once('p') - && !p.is_empty() - && plain(p) - && (pt.is_empty() || plain(pt)) - && !s.ends_with("pt") - && !s.ends_with("px") - { - // The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points). - let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 }; - let picas = p.trim().parse::().ok()?.abs(); - return Some(sign * (picas * 12.0 + pt.trim().parse::().unwrap_or(0.0))); - } - let (v, unit) = number_unit(s)?; - Some(unit.unwrap_or(default).to_pt(v)) -} - -/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit). -fn number_unit(s: &str) -> Option<(f64, Option)> { - let s = s.trim(); - // A leading `+` is a sign (`+5`), like `-`. - let sign = usize::from(s.starts_with('+')); - let num_end = s.get(sign..)?.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).map_or(s.len(), |i| i + sign); - let v: f64 = s.get(..num_end)?.trim().parse().ok()?; - let unit = match s.get(num_end..)?.trim() { - "" => None, - "pt" => Some(Unit::Points), - "px" => Some(Unit::Pixels), - "in" | "\"" => Some(Unit::Inches), - "mm" => Some(Unit::Millimeters), - "cm" => Some(Unit::Centimeters), - "m" => Some(Unit::Meters), - "ft" | "'" => Some(Unit::Feet), - "yd" => Some(Unit::Yards), - "p" | "pc" => Some(Unit::Picas), - _ => return None, - }; - Some((v, unit)) -} - -/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`). -fn percent(s: &str) -> Option { - match number_unit(s.trim().strip_suffix('%')?)? { - (p, None) => Some(p / 100.0), - _ => None, - } -} - -/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as -/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing. -pub fn parse_number(s: &str) -> Option { - if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) { - return None; - } - // A leading `+` is a sign (`+5`), as a plain number read it. - let s = s.trim(); - Unit::Points.parse(s.strip_prefix('+').unwrap_or(s)) -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum ColorMode { - #[default] - Rgb, - Cmyk, -} - -impl ColorMode { - /// The colour model new colours take in a document of this mode. - pub fn model(self) -> vectorcraft_color::cms::Model { - match self { - ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb, - ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk, - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Artboard { - pub id: u32, - pub name: String, - /// Artboard rectangle in document points. - pub rect: Rect, - /// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands - /// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be - /// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays - /// selected ([`Document::new_art`]). - #[serde(default, skip_serializing_if = "skip::is_default")] - pub locked: bool, - /// Show Center Mark (Artboard Options): the canvas draws a mark at the artboard's center. - /// Exports and print leave it out. Always written, as before it could be set. - #[serde(default)] - pub show_center_mark: bool, - /// Show Cross Hairs (Artboard Options): the canvas draws a line from the middle of each side - /// through the center. Exports and print leave them out. Always written, as before it could - /// be set. - #[serde(default)] - pub show_cross_hairs: bool, - /// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and - /// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written - /// only when on, so a document that never shows them saves as before. - #[serde(default, skip_serializing_if = "skip::is_default")] - pub show_video_safe_areas: bool, - /// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its - /// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many - /// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't - /// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is - /// brought into it as it opens. - #[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")] - pub video_ruler_pixel_aspect_ratio: f64, - /// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the - /// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on; - /// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave - /// it out. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option, -} - -/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times -/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and -/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare. -pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive = 0.1..=10.0; - -/// How far in from each edge of an artboard the video safe areas lie, as a share of its width -/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the -/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television -/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5). -pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05]; - -fn square_pixels() -> f64 { - 1.0 -} - -fn is_square_pixels(v: &f64) -> bool { - *v == 1.0 -} - -/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that -/// isn't a number (NaN, infinite) reads as square pixels. -fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let v = f64::deserialize(d)?; - let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end()); - Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 }) -} - -impl Artboard { - /// The action-safe and the title-safe area of the artboard, in that order, each inset from its - /// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas). - pub fn video_safe_areas(&self) -> [Rect; 2] { - let r = self.rect; - VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height())) - } -} - -/// A document's locked artboards ([`Document::locked_artboards`]). -pub struct LockedArtboards<'a> { - rects: Vec, - symbols: SymbolBoxes<'a>, - /// The new art, which isn't on them ([`Document::new_art`]). - new_art: Arc>, - /// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no - /// artboard. - pattern_art: std::collections::HashSet, - /// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a - /// group are each answered with one measurement of the group. - held: std::cell::RefCell>, -} - -impl LockedArtboards<'_> { - /// No artboard is locked. - pub fn is_empty(&self) -> bool { - self.rects.is_empty() - } - /// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its - /// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An - /// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or - /// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or - /// new art while it stays selected ([`Document::new_art`]). - pub fn holds(&self, n: &Node) -> bool { - !self.new_art.contains(&n.id) && self.overlaps(n) - } - /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it - /// is new art? - pub fn overlaps(&self, n: &Node) -> bool { - if self.rects.is_empty() - || !n.visible - || n.is_layer() - || matches!(n.kind, NodeKind::Path { guide: true, .. }) - || self.pattern_art.contains(&n.id) - { - return false; - } - if let Some(held) = self.held.borrow().get(&n.id) { - return *held; - } - let held = - n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0)); - self.held.borrow_mut().insert(n.id, held); - held - } - /// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id` - /// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])? - pub fn holds_id(&self, id: NodeId) -> bool { - !self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n)) - } -} - -/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in -/// place, so a selection change that sets it doesn't replace the document. -#[derive(Default)] -pub struct NewArtIds(std::sync::RwLock>>); - -impl NewArtIds { - /// The objects. - pub fn get(&self) -> Arc> { - self.0.read().map(|g| g.clone()).unwrap_or_default() - } - /// Make `ids` the objects. - pub fn set(&self, ids: Arc>) { - if let Ok(mut g) = self.0.write() { - *g = ids; - } - } -} - -impl Clone for NewArtIds { - fn clone(&self) -> Self { - NewArtIds(std::sync::RwLock::new(self.get())) - } -} - -/// Editing state, not document content: two documents are equal whatever new art they name. -impl PartialEq for NewArtIds { - fn eq(&self, _: &Self) -> bool { - true - } -} - -impl std::fmt::Debug for NewArtIds { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("NewArtIds").field(&self.get()).finish() - } -} - -/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct MaskEdit { - pub object: NodeId, - pub layer: NodeId, -} - -/// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SavedView { - pub name: String, - pub center: Point, - pub zoom: f64, - #[serde(default)] - pub rotation: f64, -} - -impl SavedView { - /// Most views a document keeps (the View menu lists every one). - pub const MAX: usize = 25; - /// Longest name, in characters. - pub const MAX_NAME: usize = 255; - - /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). - pub fn clean_name(name: &str) -> Option { - let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); - (!name.is_empty()).then_some(name) - } - - /// The first "View N" none of `saved` is named. - pub fn default_name(saved: &[SavedView]) -> String { - (1..=saved.len() + 1).map(|i| format!("View {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() - } - - /// Can the canvas show this view: a finite centre and a finite, positive zoom. - pub fn is_usable(&self) -> bool { - self.center.x.is_finite() && self.center.y.is_finite() && self.zoom.is_finite() && self.zoom > 0.0 - } -} - -/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at -/// the bottom of the Select menu. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SavedSelection { - pub name: String, - pub objects: Vec, -} - -impl SavedSelection { - /// Most saved selections a document keeps (the Select menu lists every one). - pub const MAX: usize = 25; - /// Longest name, in characters. - pub const MAX_NAME: usize = 255; - - /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). - pub fn clean_name(name: &str) -> Option { - let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); - (!name.is_empty()).then_some(name) - } - - /// The first "Selection N" none of `saved` is named. - pub fn default_name(saved: &[SavedSelection]) -> String { - (1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Guide { - /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). - pub vertical: bool, - pub pos: f64, - /// An artboard guide: the [`Artboard::id`] it belongs to. It runs across that artboard only - /// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artboard: Option, - /// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed - /// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides - /// from before guides had layers, and from files that don't say), always there. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub layer: Option, -} - -impl Guide { - /// A canvas guide. - pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None, layer: None } - } - - /// Moved by `d` (a vertical guide across, a horizontal one down). - pub fn moved(&self, d: Vec2) -> Self { - Self { pos: self.pos + if self.vertical { d.x } else { d.y }, ..self.clone() } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct GridPrefs { - pub spacing: f64, - pub subdivisions: u32, -} - -impl Default for GridPrefs { - fn default() -> Self { - Self { spacing: 72.0, subdivisions: 8 } - } -} - -/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct GraphicStyle { - pub name: String, - pub appearance: Appearance, - /// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles - /// from older files get one when an object is first linked to them). - #[serde(default, skip_serializing_if = "skip::is_default")] - pub id: u32, - #[serde(default = "one", skip_serializing_if = "skip::is_one")] - pub opacity: f32, - #[serde(default, skip_serializing_if = "skip::is_default")] - pub blend: vectorcraft_color::BlendMode, - #[serde(default, skip_serializing_if = "skip::is_default")] - pub isolate: bool, - #[serde(default, skip_serializing_if = "skip::is_default")] - pub knockout: Knockout, - /// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style - /// places them on each object's own bounds. Styles saved before this kept document - /// coordinates (false). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub unit_box: bool, -} - -fn one() -> f32 { - 1.0 -} - -/// The name of the style new documents list first (Sort by Name keeps it first). -pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style"; - -impl GraphicStyle { - /// The box a style's placed gradients are stored relative to. - pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0); - - /// A style with `appearance` (its placed gradients, if any, in unit-box space) and default - /// transparency (no id yet). - pub fn new(name: impl Into, appearance: Appearance) -> Self { - Self { - name: name.into(), - appearance, - id: 0, - opacity: 1.0, - blend: Default::default(), - isolate: false, - knockout: Knockout::Neutral, - unit_box: true, - } - } - /// A style capturing `n`'s transparency and `appearance` (in unit-box space). - pub fn of(name: impl Into, appearance: Appearance, n: &Node) -> Self { - Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) } - } - /// The style's appearance as object `n` takes it: placed gradients stored in unit-box space - /// land at the same place relative to `n`'s geometric bounds. - pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> { - let placed = self.unit_box && self.appearance.has_placed_gradient(); - match n.geometric_bounds().filter(|_| placed) { - Some(b) => { - let mut ap = self.appearance.clone(); - ap.rebase_gradients(Self::UNIT_BOX, b); - std::borrow::Cow::Owned(ap) - } - None => std::borrow::Cow::Borrowed(&self.appearance), - } - } - /// Give `n` this style's transparency. - pub fn apply_transparency(&self, n: &mut Node) { - (n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout); - } - /// Does `n` have this style's transparency? - pub fn transparency_matches(&self, n: &Node) -> bool { - (n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout) - } -} - -/// A symbol definition. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Symbol { - pub name: String, - pub art: Arc, -} - -/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ImageBlob { - pub mime: String, - #[serde(skip)] - pub bytes: Arc>, - /// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only - /// linked images show this blob, and what `bytes` hold while the linked file can't be read. - #[serde(skip)] - pub proxy: Option>>, -} - -/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a -/// new document, or one origin for the whole document. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum RulerMode { - #[default] - Artboard, - Global, -} - -impl RulerMode { - /// The mode of a document saved before the choice existed: global, as its rulers measured. - pub fn legacy() -> Self { - Self::Global - } -} +pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +#[cfg(test)] +use document_ext::free_id; +pub use graphic_style::{DEFAULT_GRAPHIC_STYLE, GraphicStyle}; +pub use guides::{GridPrefs, Guide, RulerMode}; +#[cfg(test)] +use limits::MAX_NEXT_ID; +pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; +pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol}; +pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { @@ -983,27 +314,6 @@ pub struct Document { index: id_index::IdIndex, } -/// [`Document::retired_artboard_id`]: not document content, so two documents are equal whatever it -/// holds. A clone shares the value with the document it was cloned from. -#[derive(Clone, Debug, Default)] -pub struct RetiredArtboardId(Arc); - -impl RetiredArtboardId { - pub fn get(&self) -> u32 { - self.0.load(std::sync::atomic::Ordering::Relaxed) - } - /// Raise the value to `id` when `id` is higher. - pub fn raise(&self, id: u32) { - self.0.fetch_max(id, std::sync::atomic::Ordering::Relaxed); - } -} - -impl PartialEq for RetiredArtboardId { - fn eq(&self, _: &Self) -> bool { - true - } -} - fn ppi72() -> f64 { 72.0 } @@ -1024,1346 +334,5 @@ pub fn artboard_holds(rect: Rect, p: Point) -> bool { /// of each listed child in it, in order, with the child's place in the list. type Places = BTreeMap, Vec<(usize, usize)>>; -pub const FORMAT_VERSION: u32 = 1; - -impl Document { - /// Is everything but the layers the same in `other`, as the next version of the document after - /// an edit of its art? The id counter and the id index are left out, and art outside the layers - /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it - /// doesn't change: quick for every frame of an animation. - pub fn same_outside_layers(&self, other: &Self) -> bool { - let same_art = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)); - let same_pattern = |a: &PatternDef, b: &PatternDef| { - same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() } - }; - let Self { - version, - title, - template, - units, - ruler_origin, - rulers, - artboard_ruler_origins, - color_mode, - artboards, - layers: _, - swatches, - swatch_groups, - graphic_styles, - char_styles, - para_styles, - text_threads, - symbols, - graph_designs, - guides, - views, - saved_selections, - variables, - grid, - raster_effects_ppi, - images, - patterns, - pattern_edit, - mask_edit, - next_id: _, - unknown, - page_isolate, - page_knockout, - spot_use_lab, - setup, - paste_remembers_layers, - metadata, - raster_effects, - last_view, - transparency_grid, - layers_open, - color_profiles, - export_settings, - extra, - slices, - slices_clip_to_artboard, - print_setup, - assets, - svg_scripts, - puppet, - new_art, - dimensions, - retired_artboard_id: _, - index: _, - } = self; - *version == other.version - && *title == other.title - && *template == other.template - && *units == other.units - && *ruler_origin == other.ruler_origin - && *rulers == other.rulers - && *artboard_ruler_origins == other.artboard_ruler_origins - && *color_mode == other.color_mode - && *artboards == other.artboards - && *swatches == other.swatches - && *swatch_groups == other.swatch_groups - && *graphic_styles == other.graphic_styles - && *char_styles == other.char_styles - && *para_styles == other.para_styles - && *text_threads == other.text_threads - && symbols.len() == other.symbols.len() - && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) - && graph_designs.len() == other.graph_designs.len() - && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) - && *guides == other.guides - && *views == other.views - && *saved_selections == other.saved_selections - && *variables == other.variables - && *grid == other.grid - && *raster_effects_ppi == other.raster_effects_ppi - && *images == other.images - && patterns.len() == other.patterns.len() - && patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b)) - && match (pattern_edit, &other.pattern_edit) { - (Some(a), Some(b)) => { - a.pattern == b.pattern - && a.layer == b.layer - && match (&a.original, &b.original) { - (Some(a), Some(b)) => same_pattern(a, b), - (a, b) => a.is_none() && b.is_none(), - } - } - (a, b) => a.is_none() && b.is_none(), - } - && *mask_edit == other.mask_edit - && *unknown == other.unknown - && *page_isolate == other.page_isolate - && *page_knockout == other.page_knockout - && *spot_use_lab == other.spot_use_lab - && *setup == other.setup - && *paste_remembers_layers == other.paste_remembers_layers - && *metadata == other.metadata - && *raster_effects == other.raster_effects - && *last_view == other.last_view - && *transparency_grid == other.transparency_grid - && *layers_open == other.layers_open - && *color_profiles == other.color_profiles - && *export_settings == other.export_settings - && *extra == other.extra - && *slices == other.slices - && *slices_clip_to_artboard == other.slices_clip_to_artboard - && *print_setup == other.print_setup - && *assets == other.assets - && *svg_scripts == other.svg_scripts - && match (puppet, &other.puppet) { - (Some(a), Some(b)) => Arc::ptr_eq(a, b), - (a, b) => a.is_none() && b.is_none(), - } - && *new_art == other.new_art - && *dimensions == other.dimensions - } - - /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value - /// isn't a number). - pub fn raster_ppi(&self) -> f64 { - Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0) - } - - /// The ruler zero point, or zero on both axes when either stored coordinate is invalid. - /// Rulers, coordinate fields and guide snapping use the same supported range. - pub fn ruler_origin_or_zero(&self) -> Point { - let origin = self.ruler_origin; - if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO } - } - - /// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`] - /// with global rulers; with artboard rulers that artboard's top-left corner moved by its own - /// offset (the first artboard's when `index` is out of range, the global zero point when the - /// document has no artboard or the result is out of the supported range). - pub fn ruler_origin_for(&self, index: usize) -> Point { - if self.rulers == RulerMode::Global { - return self.ruler_origin_or_zero(); - } - let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() }; - let corner = Point::new(ab.rect.x0, ab.rect.y0); - let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO); - let origin = corner + offset; - if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { - origin - } else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD { - corner - } else { - Point::ZERO - } - } - - /// A new RGB document with one artboard of `size` and one layer ("Layer 1"). - pub fn new(width: f64, height: f64) -> Self { - Self::new_with_mode(width, height, ColorMode::Rgb) - } - - /// [`Document::new`] in colour `mode`, with that mode's default swatches. - pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self { - let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model()); - let mut d = Self { - version: FORMAT_VERSION, - title: "Untitled-1".into(), - template: false, - units: Unit::Points, - ruler_origin: Point::ZERO, - rulers: RulerMode::Artboard, - artboard_ruler_origins: BTreeMap::new(), - color_mode: mode, - artboards: vec![Artboard { - id: 1, - name: "Artboard 1".into(), - rect: Rect::new(0.0, 0.0, width, height), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }], - layers: vec![], - swatches: swatches.into(), - swatch_groups: swatch_groups.into(), - graphic_styles: default_graphic_styles().into(), - char_styles: Default::default(), - para_styles: Default::default(), - text_threads: vec![], - symbols: vec![], - graph_designs: vec![], - guides: vec![], - views: vec![], - saved_selections: Default::default(), - variables: Default::default(), - grid: GridPrefs::default(), - raster_effects_ppi: 72.0, - images: BTreeMap::new(), - patterns: vec![], - pattern_edit: None, - mask_edit: None, - next_id: 1, - unknown: Default::default(), - page_isolate: false, - page_knockout: false, - spot_use_lab: true, - setup: DocSetup::default(), - paste_remembers_layers: false, - metadata: DocMetadata::default(), - raster_effects: RasterEffectsSettings::default(), - last_view: None, - transparency_grid: false, - layers_open: None, - color_profiles: ColorProfiles::default(), - export_settings: Default::default(), - extra: Default::default(), - slices: vec![], - slices_clip_to_artboard: true, - print_setup: None, - assets: vec![], - svg_scripts: vec![], - puppet: None, - new_art: Default::default(), - dimensions: false, - retired_artboard_id: Default::default(), - index: Default::default(), - }; - let id = d.alloc_id(); - d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); - d - } - - /// Allocate a fresh node id. - pub fn alloc_id(&mut self) -> NodeId { - let id = NodeId(self.next_id); - // Never reached from a counter [`Self::fix_next_id`] has seen (it stays under - // [`MAX_NEXT_ID`]); saturating so that no counter can overflow. - self.next_id = self.next_id.saturating_add(1); - id - } - pub fn peek_next_id(&self) -> u64 { - self.next_id - } - /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). - pub fn fix_next_id(&mut self) { - // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a - // counter up there: new ids start under them and never reach them, where counting on from - // the largest would run out and hand out ids the document already uses. - let mut max = 0; - let mut see = |id: u64| { - if id < MAX_NEXT_ID { - max = max.max(id); - } - }; - for l in &self.layers { - l.walk(&mut |n| see(n.id.0)); - } - self.slices.iter().for_each(|s| see(s.id.0)); - self.assets.iter().for_each(|a| see(a.id)); - let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); - self.next_id = counter.max(max.saturating_add(1)); - } - - /// Find a node anywhere in the tree. - pub fn node(&self, id: NodeId) -> Option<&Node> { - self.index_path(id).and_then(|p| self.node_at(&p)) - } - - /// [`Document::node`] without building the lookup cache when there is none: for a look at a - /// document kept in the undo history ([`Document::drop_lookup_cache`]). - pub fn node_uncached(&self, id: NodeId) -> Option<&Node> { - match self.index.is_built() { - true => self.node(id), - false => self.walk_index_path(id).and_then(|p| self.node_at(&p)), - } - } - - /// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a - /// table per step would add up. Lookups still work; the first one builds the cache again. - pub fn drop_lookup_cache(&self) { - self.index.clear(); - } - - /// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]). - pub fn has_lookup_cache(&self) -> bool { - self.index.is_built() - } - - /// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in - /// tree order where `id` appears. - pub fn index_path(&self, id: NodeId) -> Option> { - // The cached path, while the node there still has the id ([`id_index`]). - let cached = self.index.get(); - match cached.as_ref().and_then(|m| m.get(&id)) { - Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p), - Some(None) => return self.walk_index_path(id), - _ => {} - } - // A stale path (the tree changed), an id the cache doesn't have (deleted or added since), - // or no cache yet: walk, and rebuild now and then. - let found = self.walk_index_path(id); - let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id)); - if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) { - self.index.rebuild(&self.layers); - } - found - } - - /// [`Document::index_path`] by walking the tree. - fn walk_index_path(&self, id: NodeId) -> Option> { - fn find(nodes: &[Arc], id: NodeId, path: &mut Vec) -> bool { - for (i, n) in nodes.iter().enumerate() { - path.push(i); - if n.id == id { - return true; - } - if let Some(ch) = n.children() - && find(ch, id, path) - { - return true; - } - path.pop(); - } - false - } - let mut p = vec![]; - find(&self.layers, id, &mut p).then_some(p) - } - - /// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an - /// edit of many objects looks them all up for the cost of one search instead of one each. Like - /// `index_path`, an id that appears twice maps to its first place in tree order. - pub fn index_paths(&self, ids: impl IntoIterator) -> std::collections::HashMap> { - use std::collections::{HashMap, HashSet}; - fn walk(nodes: &[Arc], want: &HashSet, path: &mut Vec, out: &mut HashMap>) -> bool { - for (i, n) in nodes.iter().enumerate() { - path.push(i); - if want.contains(&n.id) && !out.contains_key(&n.id) { - out.insert(n.id, path.clone()); - if out.len() == want.len() { - return true; - } - } - if let Some(ch) = n.children() - && walk(ch, want, path, out) - { - return true; - } - path.pop(); - } - false - } - let mut want: HashSet = ids.into_iter().collect(); - let mut out = HashMap::with_capacity(want.len()); - // Cached paths that still check out ([`id_index`]); the rest take one walk. For a good - // share of the document, one walk is quicker than checking each cached path. - if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) { - want.retain(|id| match cache.get(id) { - Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => { - out.insert(*id, p); - false - } - _ => true, - }); - } - if !want.is_empty() { - let mut rest = HashMap::with_capacity(want.len()); - walk(&self.layers, &want, &mut Vec::new(), &mut rest); - out.extend(rest); - } - out - } - - /// The node at index path `path` (as [`Document::index_path`] gives it). - pub fn node_at(&self, path: &[usize]) -> Option<&Node> { - let (first, rest) = path.split_first()?; - let mut cur: &Node = self.layers.get(*first)?; - for &i in rest { - cur = cur.children()?.get(i)?; - } - Some(cur) - } - - /// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as - /// [`Document::node_mut`] does. - pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> { - let (first, rest) = path.split_first()?; - let mut cur: &mut Arc = self.layers.get_mut(*first)?; - for &i in rest { - cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?; - } - Some(Arc::make_mut(cur)) - } - - /// [`Document::ancestry`] of the node at index path `path`. - pub fn ancestry_at(&self, path: &[usize]) -> Option> { - let mut out = Vec::with_capacity(path.len()); - let mut nodes = &self.layers; - for &i in path { - let n = nodes.get(i)?; - out.push(n.id); - if let Some(ch) = n.children() { - nodes = ch; - } - } - Some(out) - } - - /// Ids from the top-level layer down to `id` inclusive. - pub fn ancestry(&self, id: NodeId) -> Option> { - let path = self.index_path(id)?; - let mut out = Vec::with_capacity(path.len()); - let mut nodes = &self.layers; - for &i in &path { - let n = &nodes[i]; - out.push(n.id); - if let Some(ch) = n.children() { - nodes = ch; - } - } - Some(out) - } - - pub fn parent_of(&self, id: NodeId) -> Option { - let a = self.ancestry(id)?; - (a.len() >= 2).then(|| a[a.len() - 2]) - } - - /// The top-level layer that contains `id`. - pub fn layer_of(&self, id: NodeId) -> Option { - self.ancestry(id).and_then(|a| a.first().copied()) - } - - /// Mutable access to a node; clones the Arc path (copy-on-write). - pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> { - let path = self.index_path(id)?; - let mut cur: &mut Arc = &mut self.layers[path[0]]; - for &i in &path[1..] { - cur = &mut Arc::make_mut(cur).children_mut()?[i]; - } - Some(Arc::make_mut(cur)) - } - - /// Children list of a container (or the top-level layer list for `None`). - pub fn children_mut(&mut self, parent: Option) -> Result<&mut Vec>, DocError> { - match parent { - None => Ok(&mut self.layers), - Some(p) => self.node_mut(p).ok_or(DocError::NoNode(p))?.children_mut().ok_or(DocError::NotContainer(p)), - } - } - pub fn children(&self, parent: Option) -> Option<&Vec>> { - match parent { - None => Some(&self.layers), - Some(p) => self.node(p)?.children(), - } - } - - /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). - pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { - let id = node.id; - let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); - ch.insert(i, Arc::new(node)); - Ok(id) - } - - /// Remove a node and return it. - pub fn remove(&mut self, id: NodeId) -> Result, DocError> { - let parent = self.parent_of(id); - let path = self.index_path(id).ok_or(DocError::NoNode(id))?; - let idx = *path.last().ok_or(DocError::NoNode(id))?; - let ch = self.children_mut(parent)?; - if idx >= ch.len() { - return Err(DocError::NoNode(id)); - } - Ok(ch.remove(idx)) - } - - /// Move a node to `parent` at `index` (index measured after removal). - pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { - if let Some(p) = parent - && self.ancestry(p).is_some_and(|a| a.contains(&id)) - { - return Err(DocError::Invalid("cannot move an object into itself".into())); - } - let n = self.remove(id)?; - let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); - ch.insert(i, n); - Ok(()) - } - - /// Position of `id` within its parent: (parent, index, sibling count). - pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { - let parent = self.parent_of(id); - let idx = *self.index_path(id)?.last()?; - let n = self.children(parent)?.len(); - Some((parent, idx, n)) - } - - /// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the - /// children of each parent. Each id is listed once and none lies inside another listed node, as - /// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies - /// inside another listed node, it fails with `NoNode` and leaves the document as it was. - pub fn remove_many(&mut self, ids: &[NodeId]) -> Result>, DocError> { - let places = self.places(ids)?; - let removed = self.take(places, ids.len())?; - self.index.clear(); - Ok(removed) - } - - /// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make` - /// builds from them (in the order of `ids`) in the parent of the last of them, at that node's - /// index less the number of listed nodes below it there. Returns the new node's id. - pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec>) -> Node) -> Result { - let places = self.places(ids)?; - let last = ids.len().checked_sub(1); - let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i))); - let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) }; - // The parent and each node it is in move down by the listed nodes before them. - let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index)); - let to: Vec = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect(); - let at = i.saturating_sub(below(&up, i)); - let node = make(self.take(places, ids.len())?); - let id = node.id; - let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?; - let at = at.min(children.len()); - children.insert(at, Arc::new(node)); - self.index.clear(); - Ok(id) - } - - /// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in - /// list order, in one pass over the children of each parent. Each anchor is listed once and none - /// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside - /// another listed anchor, it fails with `NoNode` and leaves the document as it was. - pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec)>, replace: bool) -> Result<(), DocError> { - let anchors: Vec = items.iter().map(|(id, _)| *id).collect(); - let places = self.places(&anchors)?; - let mut lists: Vec> = items.into_iter().map(|(_, nodes)| nodes).collect(); - self.edit_children(places, |k, anchor, children| { - if !replace { - children.push(anchor); - } - let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default(); - children.extend(nodes.into_iter().map(Arc::new)); - })?; - self.index.clear(); - Ok(()) - } - - /// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree, - /// is listed twice or lies inside another listed node. - fn places(&self, ids: &[NodeId]) -> Result { - let paths = self.index_paths(ids.iter().copied()); - let mut places = Places::new(); - for (k, id) in ids.iter().enumerate() { - let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?; - match places.get_mut(up) { - Some(kids) => kids.push((i, k)), - None => { - places.insert(up.to_vec(), vec![(i, k)]); - } - } - } - for kids in places.values_mut() { - kids.sort_unstable(); - } - // An id listed twice has one path, so its parent lists the same child twice. A listed node - // lies inside another when its parent's path, or a part of it from the top, is the path of a - // listed node. - let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1); - let listed = |path: &[usize]| { - path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok())) - }; - let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l]))); - let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k); - if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) { - return Err(DocError::NoNode(*id)); - } - Ok(places) - } - - /// Take the nodes `places` lists out of their parents and return them in the order of the list, - /// which is `len` long. - fn take(&mut self, places: Places, len: usize) -> Result>, DocError> { - let mut taken = vec![None; len]; - self.edit_children(places, |k, node, _| { - if let Some(t) = taken.get_mut(k) { - *t = Some(node); - } - })?; - Ok(taken.into_iter().flatten().collect()) - } - - /// Rebuild the children of each parent in `places` once, the parents later in tree order - /// first. `each` gets each listed child, its place in the list and the parent's new children, - /// onto which it pushes what takes the child's place. - fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc, &mut Vec>)) -> Result<(), DocError> { - for (up, kids) in places.into_iter().rev() { - let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?; - let old = std::mem::take(children); - children.reserve(old.len()); - let mut kids = kids.into_iter().peekable(); - for (i, node) in old.into_iter().enumerate() { - match kids.next_if(|(j, _)| *j == i) { - Some((_, k)) => each(k, node, children), - None => children.push(node), - } - } - } - Ok(()) - } - - /// The children of the node at index path `path`, or the top-level layers for an empty path. - fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec>> { - match path { - [] => Some(&mut self.layers), - _ => self.node_mut_at(path)?.children_mut(), - } - } - - /// Default target layer: the topmost visible, unlocked layer. - pub fn default_layer(&self) -> Option { - self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id) - } - - /// Add a new top-level layer above all others. - pub fn add_layer(&mut self, name: Option<&str>) -> NodeId { - let id = self.alloc_id(); - let n = self.layers.len(); - let name = name.map(str::to_string).unwrap_or_else(|| self.next_layer_name()); - self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8)))); - id - } - /// The default name of a new layer or sublayer, "Layer N". Layers and sublayers share the - /// numbering: it starts after the number of layers there are and skips any layer's name. - pub fn next_layer_name(&self) -> String { - let (mut count, mut taken) = (0, std::collections::HashSet::new()); - self.walk(|n| { - if n.is_layer() { - count += 1; - taken.extend(n.name.as_deref()); - } - }); - (count + 1..).map(|i| format!("Layer {i}")).find(|name| !taken.contains(name.as_str())).unwrap_or_default() - } - - /// Visit every node depth first in paint order (bottom to top). - pub fn walk<'a>(&'a self, mut f: impl FnMut(&'a Node)) { - for l in &self.layers { - l.walk(&mut f); - } - } - pub fn node_count(&self) -> usize { - self.layers.iter().map(|l| l.count()).sum() - } - /// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard - /// ([`Document::on_locked_artboard`])? - pub fn is_editable(&self, id: NodeId) -> bool { - self.is_editable_with(&self.locked_artboards(), id) - } - /// [`Document::is_editable`] with the locked artboards `locks` of this document, measured - /// once for many ids. - pub fn is_editable_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) && !locks.holds_id(id) - } - /// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once - /// for all of them, a group once for all its members. - pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ { - let locks = self.locked_artboards(); - move |id| self.is_editable_with(&locks, id) - } - /// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]). - pub fn locked_artboards(&self) -> LockedArtboards<'_> { - self.locked_artboards_except(usize::MAX) - } - /// [`Document::locked_artboards`] leaving out artboard `index`. - pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> { - // Art flush with an edge from outside can overlap it by a rounding error, as in - // `art_bounds_on` (#1295). - let rects: Vec = - self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect(); - // The objects of pattern editing mode's layer, a sublayer's objects included. - fn objects(n: &Node, out: &mut std::collections::HashSet) { - for c in n.children().into_iter().flatten() { - if c.is_layer() { - objects(c, out); - } else { - out.insert(c.id); - } - } - } - let mut pattern_art = std::collections::HashSet::new(); - if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) { - objects(layer, &mut pattern_art); - } - LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() } - } - /// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none. - pub fn on_locked_artboard(&self, id: NodeId) -> bool { - self.locked_artboards().holds_id(id) - } - /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else - /// the group or other object around it right inside a layer. None for a layer or sublayer. - pub fn layer_object(&self, id: NodeId) -> Option<&Node> { - self.layer_object_at(&self.index_path(id)?) - } - /// [`Document::layer_object`] for the node at index path `path`. - pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> { - // Down the path to the first node that isn't a layer. - let mut nodes = &self.layers; - for &i in path { - let n = nodes.get(i)?; - if !n.is_layer() { - return Some(n.as_ref()); - } - nodes = n.children()?; - } - None - } - pub fn is_visible(&self, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible))) - } - /// The innermost layer or sublayer containing `id` (`id` itself when it is a layer). - pub fn layer_containing(&self, id: NodeId) -> Option { - let a = self.ancestry(id)?; - a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer)) - } - /// Every art object the Selection tool can reach (Select All): the visible, unlocked objects - /// of the visible, unlocked, non-template layers, looking through sublayers (bottom first), - /// except those on a locked artboard. - pub fn selectable_art(&self) -> Vec { - let locks = self.locked_artboards(); - self.layers - .iter() - .filter(|l| l.visible && !l.locked && !l.is_template()) - .flat_map(|l| l.layer_art(true)) - .filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n))) - .collect() - } - /// Colour of the innermost layer or sublayer containing `id` (selection highlight colour). - pub fn layer_color(&self, id: NodeId) -> [u8; 3] { - let l = self.layer_containing(id).and_then(|l| self.node(l)); - match l.map(|n| &n.kind) { - Some(NodeKind::Layer { color, .. }) => color.rgb(), - _ => LAYER_COLORS[0].1, - } - } - /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol - /// instances measured by their art ([`Node::geometric_bounds_with`]). - pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option { - let symbols = self.symbol_boxes(); - ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| { - vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) }) - }) - } - /// Visual bounds of all art, with symbol instances measured by their art. - pub fn art_bounds(&self) -> Option { - let symbols = self.symbol_boxes(); - self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols))) - } - /// Artboard index containing point `p` (topmost = last), its edges included - /// ([`Document::artboard_holding`]). - pub fn artboard_at(&self, p: Point) -> Option { - self.artboard_holding(p, true) - } - /// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it - /// inside or on its left or top edge, else the first (the last) that holds it by - /// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an - /// edge. On an edge two artboards share, the artboard to the right of it or below it holds the - /// point, except at the end of the edge that lies on that artboard's bottom or right edge, such - /// as the point where both artboards' bottom edges meet; there the first (the last) holds it. - pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option { - let pick = |on: &dyn Fn(Rect) -> bool| { - let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect)); - let mut all = 0..self.artboards.len(); - if topmost { all.rfind(on) } else { all.find(on) } - }; - pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p))) - } - /// The art that moves with an artboard at `rect`: top-level objects (children of layers and - /// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and - /// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display › - /// Move Locked and Hidden Artwork with Artboard). - pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec { - // Art drawn flush with the artboard can end up a rounding error outside it (a background - // 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible. - let rect = rect.inflate(1e-6, 1e-6); - fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec) { - for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) { - if c.is_layer() { - collect(c, s, rect, all, out); - } else if let Some(b) = c.geometric_bounds_with(s) - // Inside it, edges included: art flush with the right or bottom edge is on it too - // (`Rect::contains` leaves those out, #890). - && b.x0 >= rect.x0 - && b.y0 >= rect.y0 - && b.x1 <= rect.x1 - && b.y1 <= rect.y1 - { - out.push(c.id); - } - } - } - let symbols = self.symbol_boxes(); - let mut art = vec![]; - for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) { - collect(l, &symbols, rect, locked_and_hidden, &mut art); - } - art - } - /// Does any artwork lie on `rect` (an artboard)? Artwork is any object under the layers, - /// hidden and locked ones and those on template layers included, whose visual bounds (symbol - /// instances measured by their art) meet `rect`; touching its edge counts. A group (not a clip - /// group) counts by its members, so one whose members sit on two other artboards leaves the one - /// between them empty. A clip group or clipping layer counts by its members inside its clip (and - /// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork - /// (Delete Empty Artboards). - pub fn artboard_has_art(&self, rect: Rect) -> bool { - // As in `art_on_artboard`: a millionth of a point covers rounding at the edges. - let rect = rect.inflate(1e-6, 1e-6); - fn meets(b: Option, r: Rect) -> bool { - b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1) - } - fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool { - match &c.kind { - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - let Some((clip, rest)) = children.split_first() else { return false }; - let paint = clip.clip_paint(); - if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) { - return true; - } - // The members show only inside the clip's box. - let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false }; - let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1)); - rest.iter().any(|m| has(m, s, inside)) - } - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)), - NodeKind::Path { guide: true, .. } => false, - _ => meets(c.visual_bounds_with(s), rect), - } - } - let symbols = self.symbol_boxes(); - self.layers.iter().any(|l| has(l, &symbols, rect)) - } - /// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`] - /// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as - /// locked art does. - pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec { - let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] }; - let mut art = self.art_on_artboard(rect, locked_and_hidden); - let locks = self.locked_artboards_except(index); - if !locked_and_hidden && !locks.is_empty() { - art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n))); - } - art - } - /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its - /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose - /// artboard is gone. - pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> { - let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect; - Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) - } - /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. - fn guide_layer(&self, g: &Guide) -> Option { - g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) - } - /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is - /// visible? A guide whose layer is gone shows as one on no layer. - pub fn guide_shown(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_visible(l)) - } - /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? - pub fn guide_editable(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_editable(l)) - } - /// Put the ruler guides on no layer this document has (from files saved before guides had - /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. - pub fn rehome_guides(&mut self, layer: NodeId) -> usize { - let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); - let mut n = 0; - for (g, lost) in self.guides.iter_mut().zip(lost) { - if lost { - g.layer = Some(layer); - n += 1; - } - } - n - } - /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that - /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. - pub fn adopt_guides(&mut self) -> usize { - self.default_layer().map_or(0, |home| self.rehome_guides(home)) - } - /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in - /// step. Guides on no layer stay. Returns how many went. - pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { - let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); - self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) - } - /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made - /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies - /// the objects on it. Returns how many. - pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { - let layers = |n: &Node| { - let mut out = vec![]; - n.walk(&mut |c| { - if c.is_layer() { - out.push(c.id); - } - }); - out - }; - let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); - let copies: Vec = self - .guides - .iter() - .filter_map(|g| { - let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; - Some(Guide { layer: Some(to), ..g.clone() }) - }) - .collect(); - let n = copies.len(); - self.guides.extend(copies); - n - } - /// The ruler guides on layer `layer` (not its sublayers'), by index. - pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { - self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) - } - /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? - pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool { - let along = if g.vertical { p.y } else { p.x }; - self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol) - } - /// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their - /// place among the rest (the selected art stays selected). Returns how many went. - pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize { - let kept: Vec = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect(); - // Where each kept guide ends up. - let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0); - for k in &kept { - to.push(k.then_some(n)); - n += usize::from(*k); - } - sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); - sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); - let before = self.guides.len(); - let mut flags = kept.into_iter(); - self.guides.retain(|_| flags.next().unwrap_or(true)); - before - self.guides.len() - } - /// Move the guides of artboard `id` by `d` (along with their artboard). - pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - *g = g.moved(d); - } - } - /// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork - /// with Artboard). - pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y }; - } - } - /// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their - /// artboard). - pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) { - let copies: Vec = - self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); - self.guides.extend(copies); - } - /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of - /// the artboards deleted from the document ([`Document::retired_artboard_id`]). The id counts - /// as given out, so an artboard made after an undo does not take the id of one the undo took - /// away. - pub fn next_artboard_id(&self) -> u32 { - let id = free_id(self.artboards.iter().map(|a| a.id).chain([self.retired_artboard_id.get()])); - self.retired_artboard_id.raise(id); - id - } - /// Record that the artboard with `id` was deleted: no later artboard takes its id. - pub fn retire_artboard_id(&self, id: u32) { - self.retired_artboard_id.raise(id); - } - pub fn pattern(&self, name: &str) -> Option<&PatternDef> { - self.patterns.iter().find(|p| p.name == name) - } - pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> { - self.patterns.iter_mut().find(|p| p.name == name) - } - /// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken - /// in ([`Self::adopt_pixels`]). - pub fn reid(&mut self, node: &Node) -> Node { - let mut n = node.clone(); - n.id = self.alloc_id(); - if let NodeKind::Image(im) = &mut n.kind { - self.adopt_image_pixels(im); - } - // A copied annotation must never bind its source ids to unrelated art in a different - // document. Keep the measured geometry; new measurements can be linked after pasting. - if let Some(e) = n.extras_if_any() - && let Some(dimension) = &mut e.dimension - { - dimension.source = None; - } - // A copy of a threaded frame is in no thread, so its first paragraph starts in it and its - // layout caches are dropped. A caller that keeps the copy as an object fills them again. - if let NodeKind::Text(t) = &mut n.kind - && t.first_para_continues - { - t.first_para_continues = false; - t.cached_bounds = None; - t.cached_baselines.clear(); - } - let mut renamed = std::collections::HashMap::new(); - if let Some(ch) = n.children_mut() { - let old: Vec> = std::mem::take(ch); - *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); - renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id))); - } - // An Objects on Path group names its path and its objects by id: the copy names the copies. - if let Some(e) = n.extras_if_any() - && let Some(on_path) = &mut e.objects_on_path - { - on_path.rename(&renamed); - } - n - } -} - -impl Document { - /// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's. - pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> { - im.pixels.as_deref().or_else(|| self.images.get(&im.key)) - } - - /// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art - /// made outside a document) into [`Self::images`]: under the image's key, or a free one when - /// the key shows other pixels. - pub fn adopt_pixels(&mut self, n: &mut Node) { - // Only what holds such pixels is copied out of the art it shares. - fn holds(n: &Node) -> bool { - matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some()) - || n.extras().mask.as_ref().is_some_and(|m| holds(&m.art)) - || n.children().into_iter().flatten().any(|c| holds(c)) - } - if let NodeKind::Image(im) = &mut n.kind { - self.adopt_image_pixels(im); - } - if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) - && holds(&m.art) - { - self.adopt_pixels(Arc::make_mut(&mut m.art)); - } - for c in n.children_mut().into_iter().flatten() { - if holds(c) { - self.adopt_pixels(Arc::make_mut(c)); - } - } - } - - fn adopt_image_pixels(&mut self, im: &mut ImageObject) { - let Some(blob) = im.pixels.take() else { return }; - let mut key = im.key.clone(); - let mut i = 1; - while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) { - i += 1; - key = format!("{}-{i}", im.key); - } - self.images.entry(key.clone()).or_insert_with(|| (*blob).clone()); - im.key = key; - } - - /// Saved selections as a file gives them, made safe to list and recall: at most - /// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document - /// has, each once. (An id it doesn't have would select whatever object takes that id later.) - pub fn tidy_saved_selections(&mut self) { - if self.saved_selections.is_empty() { - return; - } - let mut ids = std::collections::HashSet::new(); - self.walk(|n| { - ids.insert(n.id); - }); - let mut kept: Vec = vec![]; - for s in std::mem::take(&mut self.saved_selections) { - if kept.len() >= SavedSelection::MAX { - break; - } - let Some(name) = SavedSelection::clean_name(&s.name) else { continue }; - if kept.iter().any(|k| k.name == name) { - continue; - } - let mut seen = std::collections::HashSet::new(); - let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect(); - kept.push(SavedSelection { name, objects }); - } - self.saved_selections = kept.into(); - } - - /// Saved views as a file gives them, made safe to list and go to: at most [`SavedView::MAX`], - /// each with a clean, unique name, a finite centre and a finite, positive zoom (a rotation - /// that isn't finite becomes 0°). - pub fn tidy_views(&mut self) { - if self.views.is_empty() { - return; - } - let mut kept: Vec = vec![]; - for v in std::mem::take(&mut self.views) { - if kept.len() >= SavedView::MAX { - break; - } - let Some(name) = SavedView::clean_name(&v.name) else { continue }; - if !v.is_usable() || kept.iter().any(|k| k.name == name) { - continue; - } - let rotation = if v.rotation.is_finite() { v.rotation } else { 0.0 }; - kept.push(SavedView { name, rotation, ..v }); - } - self.views = kept; - } - - /// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the - /// mask already holds (the engine syncs it after every edit). - pub fn drop_edit_modes(&mut self) { - if let Some(me) = self.mask_edit.take() { - self.layers.retain(|l| l.id != me.layer); - } - } - - /// The document as saved and exported: without the opacity-mask editing layer. Borrowed when - /// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.) - pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> { - if self.mask_edit.is_none() { - return std::borrow::Cow::Borrowed(self); - } - let mut d = self.clone(); - d.drop_edit_modes(); - std::borrow::Cow::Owned(d) - } -} - -/// Graphic styles. -impl Document { - /// Index of the graphic style named `name`. - pub fn graphic_style_index(&self, name: &str) -> Option { - self.graphic_styles.iter().position(|g| g.name == name) - } - /// The graphic style named `name`. - pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> { - self.graphic_styles.iter().find(|g| g.name == name) - } - /// The graphic style with id `id` (0 never matches). - pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> { - self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id) - } - /// An id no graphic style has. - pub fn next_graphic_style_id(&self) -> u32 { - free_id(self.graphic_styles.iter().map(|g| g.id)) - } - /// The id of graphic style `index`, assigning one if it has none yet. - pub fn graphic_style_id(&mut self, index: usize) -> u32 { - if self.graphic_styles[index].id == 0 { - self.graphic_styles[index].id = self.next_graphic_style_id(); - } - self.graphic_styles[index].id - } - /// The default name of a new graphic style: the first free "Graphic Style N", N counting on - /// from the number of styles. - pub fn new_graphic_style_name(&self) -> String { - (self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default() - } - /// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name. - pub fn unique_symbol_name(&self, base: &str) -> String { - let taken = |n: &str| self.symbols.iter().any(|s| s.name == n); - if !taken(base) { - return base.to_string(); - } - (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) - } - - /// Symbol Options › Registration for `name` (default: centre). - pub fn symbol_registration(&self, name: &str) -> SymbolRegistration { - self.unknown - .get(SYMBOL_REGISTRATION) - .and_then(|m| m.get(name)) - .and_then(|v| v.as_u64()) - .and_then(SymbolRegistration::from_u64) - .unwrap_or_default() - } - - /// Set or clear (when centre) the registration point stored for `name`. - pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) { - let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({})); - let Some(o) = m.as_object_mut() else { return }; - if reg.is_center() { - o.remove(name); - if o.is_empty() { - self.unknown.remove(SYMBOL_REGISTRATION); - } - } else { - o.insert(name.into(), serde_json::json!(reg.0)); - } - } - - /// Move the stored registration when a symbol is renamed. - pub fn rename_symbol_registration(&mut self, from: &str, to: &str) { - if from == to { - return; - } - let reg = self.symbol_registration(from); - self.set_symbol_registration(from, SymbolRegistration::CENTER); - self.set_symbol_registration(to, reg); - } - - /// Symbol Options › Export Type for `name` (default: Movie Clip). - pub fn symbol_export_type(&self, name: &str) -> SymbolExportType { - self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default() - } - - /// Set or clear (when Movie Clip, the default) the export type stored for `name`. - pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) { - if kind.is_default() { - self.clear_symbol_export_type(name); - return; - } - let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({})); - let Some(o) = m.as_object_mut() else { return }; - o.insert(name.into(), serde_json::json!(kind.id())); - } - - /// Drop any stored export type for `name` (Movie Clip default). - pub fn clear_symbol_export_type(&mut self, name: &str) { - let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return }; - let Some(o) = m.as_object_mut() else { return }; - o.remove(name); - if o.is_empty() { - self.unknown.remove(SYMBOL_EXPORT_TYPE); - } - } - - /// Move the stored export type when a symbol is renamed. - pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) { - if from == to { - return; - } - let kind = self.symbol_export_type(from); - self.clear_symbol_export_type(from); - self.set_symbol_export_type(to, kind); - } - - /// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default). - pub fn symbol_nine_slice(&self, name: &str) -> Option { - self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value) - } - - /// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`. - pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option) { - let Some(g) = guides else { - let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return }; - o.remove(name); - if o.is_empty() { - self.unknown.remove(SYMBOL_NINE_SLICE); - } - return; - }; - let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({})); - if let Some(o) = m.as_object_mut() { - o.insert(name.into(), g.to_value()); - } - } - - /// Move the stored 9-slice guides when a symbol is renamed. - pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) { - if from == to { - return; - } - let g = self.symbol_nine_slice(from); - self.set_symbol_nine_slice(from, None); - self.set_symbol_nine_slice(to, g); - } -} - -fn default_graphic_styles() -> Vec { - use vectorcraft_color::{Color, Paint}; - let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)); - [ - GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()), - GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), - GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)), - GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)), - ] - .into_iter() - .zip(1..) - .map(|(g, id)| GraphicStyle { id, ..g }) - .collect() -} - -/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so -/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). -const MAX_NEXT_ID: u64 = 1 << 62; - -/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there -/// is (a damaged file), the smallest one free. -fn free_id(used: impl Iterator) -> u32 { - let mut used: Vec = used.collect(); - if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) { - return next; - } - used.sort_unstable(); - used.dedup(); - // Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them. - let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count(); - u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1)) -} - -impl Document { - /// Make the next id allocated at least `next`: ids handed out outside the layer tree (such - /// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see. - pub fn reserve_ids(&mut self, next: u64) { - self.next_id = self.next_id.max(next.min(MAX_NEXT_ID)); - } -} - #[cfg(test)] mod tests; diff --git a/crates/doc/src/limits.rs b/crates/doc/src/limits.rs new file mode 100644 index 000000000..e030f3d71 --- /dev/null +++ b/crates/doc/src/limits.rs @@ -0,0 +1,20 @@ +use super::*; + +/// Coordinates beyond this many points exceed the supported document range. +pub const MAX_COORD: f64 = 4.0e6; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum DocError { + #[error("no such object {0}")] + NoNode(NodeId), + #[error("object {0} cannot have children")] + NotContainer(NodeId), + #[error("{0}")] + Invalid(String), +} + +pub const FORMAT_VERSION: u32 = 1; + +/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so +/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). +pub(crate) const MAX_NEXT_ID: u64 = 1 << 62; diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs new file mode 100644 index 000000000..e9af0e682 --- /dev/null +++ b/crates/doc/src/records.rs @@ -0,0 +1,123 @@ +use super::*; + +/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in +/// place, so a selection change that sets it doesn't replace the document. +#[derive(Default)] +pub struct NewArtIds(pub(crate) std::sync::RwLock>>); + +impl NewArtIds { + /// The objects. + pub fn get(&self) -> Arc> { + self.0.read().map(|g| g.clone()).unwrap_or_default() + } + /// Make `ids` the objects. + pub fn set(&self, ids: Arc>) { + if let Ok(mut g) = self.0.write() { + *g = ids; + } + } +} + +impl Clone for NewArtIds { + fn clone(&self) -> Self { + NewArtIds(std::sync::RwLock::new(self.get())) + } +} + +/// Editing state, not document content: two documents are equal whatever new art they name. +impl PartialEq for NewArtIds { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for NewArtIds { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("NewArtIds").field(&self.get()).finish() + } +} + +/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct MaskEdit { + pub object: NodeId, + pub layer: NodeId, +} + +/// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedView { + pub name: String, + pub center: Point, + pub zoom: f64, + #[serde(default)] + pub rotation: f64, +} + +impl SavedView { + /// Most views a document keeps (the View menu lists every one). + pub const MAX: usize = 25; + /// Longest name, in characters. + pub const MAX_NAME: usize = 255; + + /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). + pub fn clean_name(name: &str) -> Option { + let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); + (!name.is_empty()).then_some(name) + } + + /// The first "View N" none of `saved` is named. + pub fn default_name(saved: &[SavedView]) -> String { + (1..=saved.len() + 1).map(|i| format!("View {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() + } + + /// Can the canvas show this view: a finite centre and a finite, positive zoom. + pub fn is_usable(&self) -> bool { + self.center.x.is_finite() && self.center.y.is_finite() && self.zoom.is_finite() && self.zoom > 0.0 + } +} + +/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at +/// the bottom of the Select menu. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedSelection { + pub name: String, + pub objects: Vec, +} + +impl SavedSelection { + /// Most saved selections a document keeps (the Select menu lists every one). + pub const MAX: usize = 25; + /// Longest name, in characters. + pub const MAX_NAME: usize = 255; + + /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). + pub fn clean_name(name: &str) -> Option { + let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); + (!name.is_empty()).then_some(name) + } + + /// The first "Selection N" none of `saved` is named. + pub fn default_name(saved: &[SavedSelection]) -> String { + (1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() + } +} + +/// A symbol definition. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Symbol { + pub name: String, + pub art: Arc, +} + +/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImageBlob { + pub mime: String, + #[serde(skip)] + pub bytes: Arc>, + /// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only + /// linked images show this blob, and what `bytes` hold while the linked file can't be read. + #[serde(skip)] + pub proxy: Option>>, +} diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs new file mode 100644 index 000000000..1b09c4e7b --- /dev/null +++ b/crates/doc/src/units.rs @@ -0,0 +1,293 @@ +use super::*; + +/// Measurement units (display only; the model is always in points). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Unit { + #[default] + Points, + Picas, + Inches, + Millimeters, + Centimeters, + Pixels, + FeetInches, + Meters, + Yards, + Feet, +} + +impl Unit { + pub const ALL: [Unit; 10] = [ + Unit::Points, + Unit::Picas, + Unit::Inches, + Unit::Millimeters, + Unit::Centimeters, + Unit::Pixels, + Unit::FeetInches, + Unit::Meters, + Unit::Yards, + Unit::Feet, + ]; + /// Points per unit. + pub fn points(self) -> f64 { + match self { + Unit::Points | Unit::Pixels => 1.0, + Unit::Picas => 12.0, + Unit::Inches => 72.0, + Unit::Millimeters => 72.0 / 25.4, + Unit::Centimeters => 72.0 / 2.54, + Unit::Meters => 72.0 / 0.0254, + Unit::Yards => 72.0 * 36.0, + Unit::Feet | Unit::FeetInches => 72.0 * 12.0, + } + } + pub fn suffix(self) -> &'static str { + match self { + Unit::Points => "pt", + Unit::Picas => "p", + Unit::Inches => "in", + Unit::Millimeters => "mm", + Unit::Centimeters => "cm", + Unit::Pixels => "px", + Unit::FeetInches | Unit::Feet => "ft", + Unit::Meters => "m", + Unit::Yards => "yd", + } + } + pub fn label(self) -> &'static str { + match self { + Unit::Points => "Points", + Unit::Picas => "Picas", + Unit::Inches => "Inches", + Unit::Millimeters => "Millimeters", + Unit::Centimeters => "Centimeters", + Unit::Pixels => "Pixels", + Unit::FeetInches => "Feet & Inches", + Unit::Meters => "Meters", + Unit::Yards => "Yards", + Unit::Feet => "Feet", + } + } + /// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of + /// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it. + pub fn ruler_step(self, zoom: f64) -> f64 { + const DECIMAL: [f64; 19] = + [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0]; + // An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler + // carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a + // sixteenth; zooming in past that leaves the labels where they are and only the ticks + // between them grow denser, down to a hundred and twenty-eighth + // ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which + // the left ruler has room for when it spells one down the ruler a character at a time. + // From an inch up the ladder is the shared one. + const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0]; + let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL }; + steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0) + } + /// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric + /// units, points, pixels and picas are read in tenths. + fn ruler_halves(self) -> bool { + matches!(self, Unit::Inches) + } + /// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of + /// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about + /// as dense as the tenths elsewhere; a tenth of it otherwise. + pub fn ruler_minor(self, step: f64) -> f64 { + if self.ruler_halves() { step / 8.0 } else { step / 10.0 } + } + /// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]). + pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 { + let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points(); + (v / tick).round() * tick + } + pub fn from_pt(self, v: f64) -> f64 { + v / self.points() + } + pub fn to_pt(self, v: f64) -> f64 { + v * self.points() + } + /// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`). + pub fn format(self, pt: f64) -> String { + format!("{} {}", self.number(pt), self.suffix()) + } + /// [`Unit::format`] without the suffix (`12.5`), for narrow fields. + pub fn number(self, pt: f64) -> String { + // Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`, + // `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`). + let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 }; + let s = format!("{:.*}", decimals, self.from_pt(pt)); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } + } + /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic. + /// + /// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so + /// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term + /// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away + /// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A + /// percentage on its own (`50%`) reads as nothing. + pub fn parse(self, s: &str) -> Option { + parse_measure(s, self).filter(|v| v.is_finite()) + } + /// The value naming this unit in the Units preferences (`points`, `millimeters`, + /// `feetInches`). + pub fn key(self) -> &'static str { + match self { + Unit::Points => "points", + Unit::Picas => "picas", + Unit::Inches => "inches", + Unit::Millimeters => "millimeters", + Unit::Centimeters => "centimeters", + Unit::Pixels => "pixels", + Unit::FeetInches => "feetInches", + Unit::Meters => "meters", + Unit::Yards => "yards", + Unit::Feet => "feet", + } + } + /// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and + /// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`). + pub fn named(name: &str) -> Option { + let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); + let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); + // Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches + // is left to its label and key. + Unit::ALL + .into_iter() + .find(|u| letters(u.label()).eq(given())) + .or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given()))) + } + /// A point value as the canvas measurement labels show it: two decimals and the suffix + /// (`12.50 mm`). + pub fn readout(self, pt: f64) -> String { + format!("{:.2} {}", self.from_pt(pt), self.suffix()) + } +} + +fn parse_measure(s: &str, default: Unit) -> Option { + let s = s.trim(); + // Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2"). + // `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last + // of its operators (left to right). + for ops in [&['+', '-'][..], &['*', '/'][..]] { + // Past the first character, so a leading sign isn't an operator. + let skip = s.chars().next().map_or(0, char::len_utf8); + // A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`). + let is_sign = |i: usize| { + s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-')) + && s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c)) + }; + let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i)); + if let Some(i) = found.map(|(i, _)| i) { + let (l, r) = (&s[..i], &s[i + 1..]); + if l.trim().is_empty() { + continue; + } + let Some(op) = s[i..].chars().next() else { continue }; + return match op { + '+' | '-' => { + let a = parse_measure(l, default)?; + // A percentage added or taken away is a share of everything before it: + // `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt. + let b = match percent(r) { + Some(k) => a * k, + None => parse_measure(r, default)?, + }; + Some(if op == '+' { a + b } else { a - b }) + } + _ => { + // A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm); + // a unit after it is the expression's. + let (k, unit) = match percent(r) { + Some(k) => (k, None), + None => number_unit(r)?, + }; + let a = parse_measure(l, unit.unwrap_or(default))?; + if op == '*' { + Some(a * k) + } else if k == 0.0 { + None + } else { + Some(a / k) + } + } + }; + } + } + // Plain decimals only, as in `number_unit` (`str::parse` would accept `1e5`, `inf`, `nan`). + let plain = |t: &str| t.trim().chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') && t.trim().parse::().is_ok(); + if let Some((p, pt)) = s.split_once('p') + && !p.is_empty() + && plain(p) + && (pt.is_empty() || plain(pt)) + && !s.ends_with("pt") + && !s.ends_with("px") + { + // The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points). + let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 }; + let picas = p.trim().parse::().ok()?.abs(); + return Some(sign * (picas * 12.0 + pt.trim().parse::().unwrap_or(0.0))); + } + let (v, unit) = number_unit(s)?; + Some(unit.unwrap_or(default).to_pt(v)) +} + +/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit). +fn number_unit(s: &str) -> Option<(f64, Option)> { + let s = s.trim(); + // A leading `+` is a sign (`+5`), like `-`. + let sign = usize::from(s.starts_with('+')); + let num_end = s.get(sign..)?.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).map_or(s.len(), |i| i + sign); + let v: f64 = s.get(..num_end)?.trim().parse().ok()?; + let unit = match s.get(num_end..)?.trim() { + "" => None, + "pt" => Some(Unit::Points), + "px" => Some(Unit::Pixels), + "in" | "\"" => Some(Unit::Inches), + "mm" => Some(Unit::Millimeters), + "cm" => Some(Unit::Centimeters), + "m" => Some(Unit::Meters), + "ft" | "'" => Some(Unit::Feet), + "yd" => Some(Unit::Yards), + "p" | "pc" => Some(Unit::Picas), + _ => return None, + }; + Some((v, unit)) +} + +/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`). +fn percent(s: &str) -> Option { + match number_unit(s.trim().strip_suffix('%')?)? { + (p, None) => Some(p / 100.0), + _ => None, + } +} + +/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as +/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing. +pub fn parse_number(s: &str) -> Option { + if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) { + return None; + } + // A leading `+` is a sign (`+5`), as a plain number read it. + let s = s.trim(); + Unit::Points.parse(s.strip_prefix('+').unwrap_or(s)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ColorMode { + #[default] + Rgb, + Cmyk, +} + +impl ColorMode { + /// The colour model new colours take in a document of this mode. + pub fn model(self) -> vectorcraft_color::cms::Model { + match self { + ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb, + ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk, + } + } +} From f5f430a56e6ec96a61c03a97311aa681a4a8849b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:48:51 +0000 Subject: [PATCH 115/199] refactor(doc): move live.rs items to live/{blend_spec,envelope,expand,mesh,warp}.rs --- crates/doc/src/live.rs | 1946 +---------------------------- crates/doc/src/live/blend_spec.rs | 157 +++ crates/doc/src/live/envelope.rs | 332 +++++ crates/doc/src/live/expand.rs | 444 +++++++ crates/doc/src/live/mesh.rs | 761 +++++++++++ crates/doc/src/live/warp.rs | 253 ++++ 6 files changed, 1969 insertions(+), 1924 deletions(-) create mode 100644 crates/doc/src/live/blend_spec.rs create mode 100644 crates/doc/src/live/envelope.rs create mode 100644 crates/doc/src/live/expand.rs create mode 100644 crates/doc/src/live/mesh.rs create mode 100644 crates/doc/src/live/warp.rs diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index f84d0f865..650361374 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -34,299 +34,6 @@ fn fifty() -> f64 { 50.0 } -// ===================================================================================== -// Data -// ===================================================================================== - -/// Blend Options → Spacing. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BlendSpacing { - /// Step count chosen from the colour difference (or the distance when colours match). - #[default] - SmoothColor, - /// Specified number of steps between each pair of key objects (1–1000). - Steps(u32), - /// Specified distance (pt) between the steps. - Distance(f64), -} - -/// Blend Options → Orientation. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BlendOrientation { - #[default] - AlignToPage, - AlignToPath, -} - -/// Distribution of blend steps, as offered by the Blend panel's Type menu. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BlendEaseType { - #[default] - In, - Out, - InOut, - OutIn, -} - -impl BlendEaseType { - pub fn id(self) -> &'static str { - match self { - Self::In => "easeIn", - Self::Out => "easeOut", - Self::InOut => "easeInOut", - Self::OutIn => "easeOutIn", - } - } -} - -/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct BlendEase { - #[serde(default)] - pub kind: BlendEaseType, - #[serde(default, deserialize_with = "blend_ramp")] - pub ramp: f64, -} - -impl BlendEase { - pub fn is_linear(&self) -> bool { - self.ramp <= 0.0 - } - - /// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0. - pub fn is_default(&self) -> bool { - self.kind == BlendEaseType::In && self.ramp == 0.0 - } - - /// A monotone polynomial distribution, blended with even spacing by Ramp. Public help - /// describes the four distributions, not their numeric curves; these curves are original. - pub fn apply(&self, u: f64) -> f64 { - let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; - let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 }; - let eased = match self.kind { - BlendEaseType::In => u * u, - BlendEaseType::Out => u * (2.0 - u), - BlendEaseType::InOut => u * u * (3.0 - 2.0 * u), - BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u), - BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5), - }; - u + r * (eased - u) - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct BlendSpec { - #[serde(default)] - pub spacing: BlendSpacing, - #[serde(default)] - pub orientation: BlendOrientation, - /// Acceleration of the steps' shapes and positions along the spine between two keys. - #[serde(default, skip_serializing_if = "BlendEase::is_default")] - pub ease: BlendEase, - /// Independent color acceleration. -100..100, with 0 preserving the even color ramp. - #[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")] - pub color_shift: f64, - /// Replaced spine. `None` = the straight lines between the key objects' centres. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub spine: Option, - /// The anchor of the spine's first subpath each key object sits on (a spine edited on the - /// canvas: moving a key moves its anchor and the other way round). Empty: the keys spread - /// evenly along the spine by arc length (Replace Spine). - #[serde(default, rename = "keyAnchors", skip_serializing_if = "Vec::is_empty")] - pub key_anchors: Vec, - /// Per key object, the anchor of its first subpath the blend starts from (Blend tool clicks - /// on anchor points); `None`: chosen so closed shapes don't twist. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub starts: Vec>, -} - -impl BlendSpec { - /// The start anchor of key `i` (see [`Self::starts`]). - pub fn start(&self, i: usize) -> Option { - self.starts.get(i).copied().flatten().map(|a| a as usize) - } -} - -/// Blend Options set with no blend selected: what new blends start with. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct BlendDefaults { - #[serde(default)] - pub spacing: BlendSpacing, - #[serde(default)] - pub orientation: BlendOrientation, -} - -/// How an envelope distorts its content. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum EnvelopeKind { - /// One of the 15 warp styles (`arc`, `arcLower`, … — see [`WarpStyle::from_id`]); bend and - /// distortions in percent (-100..100). - Warp { - style: String, - #[serde(default)] - bend: f64, - #[serde(default)] - h: f64, - #[serde(default)] - v: f64, - #[serde(default = "yes")] - horizontal: bool, - }, - /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. - Mesh { - rows: u32, - cols: u32, - points: Vec, - /// Each point's bezier handles (offsets, see [`H_RIGHT`] …), row-major like `points`. - /// Empty: the smooth (Catmull-Rom) mesh through the points, as before handles existed; - /// editing the mesh's points or handles on the canvas fills them in. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - handles: Vec<[Vec2; 4]>, - }, - /// The content's bounding box maps onto the outline of this path. - TopObject { path: PathData }, -} - -/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PreserveShape { - #[default] - ClippingMask, - Transparency, -} - -impl PreserveShape { - pub fn id(self) -> &'static str { - match self { - Self::ClippingMask => "clippingMask", - Self::Transparency => "transparency", - } - } - pub fn parse(s: &str) -> Option { - match s { - "clippingMask" => Some(Self::ClippingMask), - "transparency" => Some(Self::Transparency), - _ => None, - } - } -} - -/// Envelope Options besides Fidelity. `Default` is what envelopes saved before these options -/// existed do (their appearance applies after the distortion); new envelopes start from -/// [`EnvelopeOptions::NEW`], as in the reference app. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct EnvelopeOptions { - /// Rasters: Anti-Alias. - pub anti_alias: bool, - /// Rasters: Preserve Shape Using. - pub preserve_shape: PreserveShape, - /// Distort Appearance: strokes and live effects are applied before the distortion, so they - /// bend with the art (off: they apply to the distorted paths). - pub distort_appearance: bool, - /// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art. - pub distort_linear_gradients: bool, - /// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art. - pub distort_pattern_fills: bool, -} - -impl Default for EnvelopeOptions { - fn default() -> Self { - Self { - anti_alias: true, - preserve_shape: PreserveShape::ClippingMask, - distort_appearance: false, - distort_linear_gradients: false, - distort_pattern_fills: false, - } - } -} - -impl EnvelopeOptions { - /// The options a new envelope gets (the reference app's defaults). - pub const NEW: Self = Self { - anti_alias: true, - preserve_shape: PreserveShape::ClippingMask, - distort_appearance: true, - distort_linear_gradients: false, - distort_pattern_fills: false, - }; - /// Do gradients bend with the art? - pub fn gradients(&self) -> bool { - self.distort_appearance && self.distort_linear_gradients - } - /// Do pattern tiles bend with the art? - pub fn patterns(&self) -> bool { - self.distort_appearance && self.distort_pattern_fills - } -} - -/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row. -pub const H_RIGHT: usize = 0; -pub const H_LEFT: usize = 1; -pub const H_DOWN: usize = 2; -pub const H_UP: usize = 3; - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct MeshPoint { - pub p: Point, - pub color: Color, - #[serde(default = "one")] - pub opacity: f32, - /// Tangent handles as offsets from `p` (see [`H_RIGHT`] …). - #[serde(default)] - pub handles: [Vec2; 4], -} - -/// A gradient mesh: `(rows+1)×(cols+1)` points, row-major. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct GradientMesh { - pub rows: u32, - pub cols: u32, - pub points: Vec, -} - -/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and -/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads -/// meet without a step. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct MeshQuad { - pub pts: [Point; 4], - pub color: Color, - pub opacity: f32, - pub corners: [(Color, f32); 4], -} - -/// Create Gradient Mesh → Appearance. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum MeshAppearance { - #[default] - Flat, - ToCenter, - ToEdge, -} - -impl MeshAppearance { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "flat" => Some(Self::Flat), - "center" | "tocenter" | "to center" => Some(Self::ToCenter), - "edge" | "toedge" | "to edge" => Some(Self::ToEdge), - _ => None, - } - } -} - -/// Default envelope fidelity. -pub fn default_fidelity() -> f64 { - fifty() -} - // ===================================================================================== // Blends (evaluation in `crate::blend`) // ===================================================================================== @@ -336,1637 +43,28 @@ pub use crate::blend::{ steps_knockout_shows, }; -// ===================================================================================== -// Warp maps (shared with vectorcraft-effects' Warp effects) -// ===================================================================================== - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WarpStyle { - Arc, - ArcLower, - ArcUpper, - Arch, - Bulge, - ShellLower, - ShellUpper, - Flag, - Wave, - Fish, - Rise, - Fisheye, - Inflate, - Squeeze, - Twist, -} - -impl WarpStyle { - /// From the id suffix (`arc`, `arcLower`, …). - pub fn from_id(s: &str) -> Option { - use WarpStyle::*; - Some(match s { - "arc" => Arc, - "arcLower" => ArcLower, - "arcUpper" => ArcUpper, - "arch" => Arch, - "bulge" => Bulge, - "shellLower" => ShellLower, - "shellUpper" => ShellUpper, - "flag" => Flag, - "wave" => Wave, - "fish" => Fish, - "rise" => Rise, - "fisheye" => Fisheye, - "inflate" => Inflate, - "squeeze" => Squeeze, - "twist" => Twist, - _ => return None, - }) - } -} - -/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1); -/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real -/// proportions). -pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) { - use WarpStyle::*; - let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom - let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides - let (mut x2, mut y2) = match style { - Arc => { - if b.abs() < 1e-6 { - (x, y) - } else { - // Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in - // the box's real proportions (lengths in half-widths, so y counts `aspect` each): - // the edges are concentric arcs, and one that would pass the centre stops there - // instead of folding over (#1578). - let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; - let sweep = b.abs() * PI; - let r0 = 2.0 / sweep; - let yy = if b > 0.0 { y } else { -y } * k; - let a = x / r0; - let r = (r0 - yy).max(0.0); - let (s, c) = a.sin_cos(); - let (nx, ny) = (r * s, (r0 - r * c) / k); - (nx, if b > 0.0 { ny } else { -ny }) - } - } - ArcLower => (x, y + b * par * t), - ArcUpper => (x, y - b * par * (1.0 - t)), - Arch => (x, y - b * par), - Bulge => (x, y + b * par * y), - ShellLower => (x * (1.0 - 0.5 * b * (1.0 - t)), y + b * par * t), - ShellUpper => (x * (1.0 - 0.5 * b * t), y - b * par * (1.0 - t)), - Flag => (x, y + 0.5 * b * (PI * x).sin()), - Wave => (x + 0.1 * b * (PI * y).sin(), y + 0.3 * b * (2.0 * PI * x).sin() * (0.5 + 0.5 * t)), - Fish => (x, y * (1.0 + 0.5 * b * (0.75 * PI * (x + 1.0)).sin())), - Rise => (x, y - b * ((x + 1.0) / 2.0).powi(2) * 2.0 + b), - Fisheye => { - let r2 = x * x + y * y; - let s = if r2 < 1.0 { 1.0 + 0.5 * b * (1.0 - r2) } else { 1.0 }; - (x * s, y * s) - } - Inflate => (x * (1.0 + 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.5 * b * par)), - Squeeze => (x * (1.0 - 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.3 * b * par)), - Twist => { - let r = (x * x + y * y).sqrt(); - let a = -b * PI * 0.5 * (1.0 - r / std::f64::consts::SQRT_2).max(0.0); - let (s, c) = a.sin_cos(); - (x * c - y * s, x * s + y * c) - } - }; - // Perspective-like distortion: horizontal narrows one side, vertical one end. - if dh != 0.0 { - y2 *= (1.0 + dh * x2 * 0.5).max(0.0); - } - if dv != 0.0 { - x2 *= (1.0 + dv * y2 * 0.5).max(0.0); - } - (x2, y2) -} - -/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. -fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { - let c = sp.segment(i); - if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c } -} - -fn poly_len(c: &CubicBez) -> f64 { - c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) -} - -/// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point). -fn cumulative_lengths(pts: &[Point]) -> Vec { - let mut total = 0.0; - let mut cum = Vec::with_capacity(pts.len()); - cum.push(0.0); - for w in pts.windows(2) { - total += w[0].distance(w[1]); - cum.push(total); - } - cum -} - -/// Most pieces [`map_nonlinear`] splits one segment into. -const MAX_PIECES: usize = 64; - -/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`. -pub fn pieces(len: f64, piece: f64, most: usize) -> usize { - ((len / piece).ceil() as usize).clamp(1, most.max(1)) -} - -/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so -/// that the pieces past each segment's first number at most `room`: `piece` when they already do, -/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path, -/// so pieces of the total length over `room` fit; with no room every segment stays one piece. -pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 { - let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add); - if added <= room { - return piece; - } - if room == 0 { - return f64::INFINITY; - } - // A NaN length is always one piece. - piece.max(lens.iter().filter(|len| !len.is_nan()).sum::() / room as f64) -} - -/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces -/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which -/// approximates the image curve to O(h²). Closedness and subpath structure are preserved. -pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData { - map_nonlinear_within(path, max_piece, usize::MAX, f) -} - -/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more: -/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]), -/// a coarser approximation of the image curve. Geometry effects map within their anchor budget. -pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData { - let mut max_piece = max_piece.max(1e-3); - // Each segment adds one anchor per piece past its first. - let room = max.saturating_sub(path.anchor_count()); - let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add); - if segs.saturating_mul(MAX_PIECES - 1) > room { - let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect(); - max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room); - } - let mut subs = Vec::with_capacity(path.subpaths.len()); - for sp in &path.subpaths { - let n = sp.anchors.len(); - if n == 0 { - continue; - } - let segs = sp.segment_count(); - let mut res: Vec = Vec::new(); - let mut pending_in: Option = None; - for i in 0..n { - let a = &sp.anchors[i]; - let h_in = pending_in.take().unwrap_or_else(|| f(a.h_in)); - res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind }); - if i < segs { - let c = seg_cubic(sp, i); - let k = pieces(poly_len(&c), max_piece, MAX_PIECES); - for j in 0..k { - let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64)); - if let Some(last) = res.last_mut() { - last.h_out = f(sub.p1); - } - if j + 1 < k { - res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() }); - } else { - pending_in = Some(f(sub.p2)); - } - } - } - } - if let Some(h) = pending_in - && sp.closed - && let Some(first) = res.first_mut() - { - first.h_in = h; - } - let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect(); - subs.push(SubPath::new(anchors, sp.closed)); - } - PathData::new(subs) -} - -// ===================================================================================== -// Coons patches from outlines -// ===================================================================================== - -fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { - let mut bp = BezPath::new(); - sp.to_bezpath_into(&mut bp); - let mut pts: Vec = Vec::new(); - vectorcraft_geom::kurbo::flatten(bp.iter(), tol, |el| match el { - vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if pts.last().is_none_or(|q| q.distance(p) > 1e-9) => pts.push(p), - _ => {} - }); - pts -} - -fn resample(poly: &[Point], m: usize) -> Vec { - if poly.is_empty() { - return vec![Point::ZERO; m]; - } - if poly.len() == 1 { - return vec![poly[0]; m]; - } - let cum = cumulative_lengths(poly); - let total = cum.last().copied().unwrap_or(0.0); - let mut out = Vec::with_capacity(m); - let mut i = 0; - for k in 0..m { - let s = if m > 1 { total * k as f64 / (m - 1) as f64 } else { 0.0 }; - while i + 2 < cum.len() && cum[i + 1] < s { - i += 1; - } - let seg = (cum[i + 1] - cum[i]).max(1e-12); - out.push(poly[i].lerp(poly[i + 1], ((s - cum[i]) / seg).clamp(0.0, 1.0))); - } - out -} - -const SIDE: usize = 65; - -/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to -/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding -/// box corners. -#[derive(Clone, Debug)] -pub struct Coons { - top: Vec, - right: Vec, - bottom: Vec, - left: Vec, -} - -impl Coons { - /// A rectangle. - pub fn rect(r: Rect) -> Self { - let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::>(); - let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); - Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) } - } - - pub fn from_path(path: &PathData) -> Option { - // The largest subpath is the outline. - let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; - let r = path.bounds()?; - let tol = (r.width() + r.height()).max(1e-6) / 2000.0; - let mut poly = flatten_subpath(sp, tol); - if poly.len() > 1 - && let (Some(a), Some(b)) = (poly.first(), poly.last()) - && a.distance(*b) < 1e-9 - { - poly.pop(); - } - if poly.len() < 3 { - return Some(Self::rect(r)); - } - // Orient clockwise in y-down (TL → TR → BR → BL). - let area: f64 = (0..poly.len()) - .map(|i| { - let (p, q) = (poly[i], poly[(i + 1) % poly.len()]); - p.x * q.y - q.x * p.y - }) - .sum(); - if area < 0.0 { - poly.reverse(); - } - let nearest = |c: Point| (0..poly.len()).min_by(|i, j| poly[*i].distance_squared(c).total_cmp(&poly[*j].distance_squared(c))).unwrap_or(0); - let tl = nearest(Point::new(r.x0, r.y0)); - let tr = nearest(Point::new(r.x1, r.y0)); - let br = nearest(Point::new(r.x1, r.y1)); - let bl = nearest(Point::new(r.x0, r.y1)); - let n = poly.len(); - let arc = |i: usize, j: usize| -> Vec { - let mut v = vec![poly[i]]; - let mut k = i; - while k != j { - k = (k + 1) % n; - v.push(poly[k]); - } - v - }; - let top = resample(&arc(tl, tr), SIDE); - let right = resample(&arc(tr, br), SIDE); - let mut bottom = resample(&arc(br, bl), SIDE); - bottom.reverse(); - let mut left = resample(&arc(bl, tl), SIDE); - left.reverse(); - Some(Self { top, right, bottom, left }) - } - - /// The point at `u` along a side; past its ends the end segments go on straight (art an - /// envelope bends can reach past the content's box: outlined strokes). - fn sample(side: &[Point], u: f64) -> Point { - let s = u * (side.len() - 1) as f64; - let i = (s.floor().max(0.0) as usize).min(side.len() - 2); - side[i].lerp(side[i + 1], s - i as f64) - } - - /// The surface point at (u, v) ∈ [0,1]². - pub fn eval(&self, u: f64, v: f64) -> Point { - let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v)); - let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]); - let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2() - - ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2()); - c.to_point() - } - - /// The four corners TL, TR, BR, BL. - pub fn corners(&self) -> [Point; 4] { - [self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]] - } -} - -// ===================================================================================== -// Gradient meshes -// ===================================================================================== - -/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps. -fn even_steps(n: u32) -> Vec { - let n = n.clamp(1, 200); - (0..=n).map(|i| i as f64 / n as f64).collect() -} - -/// The number of points of a `(rows+1)×(cols+1)` grid, `None` past `usize`: counts come from -/// files, and u32::MAX rows and columns (65,535 on 32-bit targets) would wrap to an empty grid. -fn grid_len(rows: u32, cols: u32) -> Option { - (rows as usize).checked_add(1)?.checked_mul((cols as usize).checked_add(1)?) -} - -impl GradientMesh { - pub fn idx(&self, r: usize, c: usize) -> usize { - r * (self.cols as usize + 1) + c - } - pub fn is_valid(&self) -> bool { - self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) - } - - /// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v - /// order: row 0 is v = 0, columns follow u), each with its handles ([`H_RIGHT`] …). - fn patch(corners: [(Point, [Vec2; 4], Color); 4]) -> Self { - let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles }; - let [a, b, c, d] = corners; - Self { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] } - } - - /// A 1×1 mesh from a Coons patch's twelve boundary points (in the order PDF and PostScript - /// shadings list them: v runs cp0 → cp3 along cp1 and cp2, u runs cp0 → cp9 along cp11 and - /// cp10) and its corner colours (at cp0, cp3, cp6, cp9); `None` if a point isn't finite. - pub fn coons(cp: &[Point; 12], colors: [Color; 4]) -> Option { - if !cp.iter().all(|p| p.is_finite()) { - return None; - } - let h = |a: usize, b: usize| cp[b] - cp[a]; - let handles = |right: Vec2, left: Vec2, down: Vec2, up: Vec2| { - let mut h = [Vec2::ZERO; 4]; - (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up); - h - }; - let [c0, c1, c2, c3] = colors; - Some(Self::patch([ - (cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0), - (cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1), - (cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2), - (cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3), - ])) - } - - /// A 1×1 mesh of a Gouraud-shaded triangle (a patch with two corners together); `None` if a - /// point isn't finite. - pub fn triangle(corners: [(Point, Color); 3]) -> Option { - if !corners.iter().all(|(p, _)| p.is_finite()) { - return None; - } - let [a, b, c] = corners.map(|(p, color)| (p, [Vec2::ZERO; 4], color)); - Some(Self::patch([a, b, c, c])) - } - - /// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives. - pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self { - let (us, vs) = (even_steps(cols), even_steps(rows)); - Self::from_grid(&us, &vs, s, &|r, c, _| (color(us[c], vs[r]), 1.0)) - } - - /// A mesh over surface `s` (u, v ∈ [0,1]) whose column lines sit at `us` and row lines at `vs` - /// (ascending, at least two each; equal neighbours make a patch of no width, a sharp colour - /// change), with handles from its partial derivatives. `color(row, col, point)` gives each - /// point its colour and opacity. - pub fn from_grid(us: &[f64], vs: &[f64], s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(usize, usize, Point) -> (Color, f32)) -> Self { - let (rows, cols) = (vs.len().saturating_sub(1), us.len().saturating_sub(1)); - let eps = 1e-3; - let mut points = Vec::with_capacity(us.len() * vs.len()); - for (r, &v) in vs.iter().enumerate() { - for (c, &u) in us.iter().enumerate() { - let p = s(u, v); - let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0)); - let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0)); - // A third of the way to the neighbouring line along the tangent; outward handles on - // the border are unused, so they stay at the point. - let h = |d: Vec2, from: f64, to: Option<&f64>| to.map_or(Vec2::ZERO, |to| d * ((to - from) / 3.0)); - let handles = [ - h(d_u, u, us.get(c + 1)), - h(d_u, u, c.checked_sub(1).map(|i| &us[i])), - h(d_v, v, vs.get(r + 1)), - h(d_v, v, r.checked_sub(1).map(|i| &vs[i])), - ]; - let (color, opacity) = color(r, c, p); - points.push(MeshPoint { p, color, opacity, handles }); - } - } - Self { rows: rows as u32, cols: cols as u32, points } - } - - /// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`. - pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option { - let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?; - m.highlight(appearance, highlight); - Some(m) - } - - /// Create Gradient Mesh for a path: the outline split at its corners, each point taking the - /// colour and opacity `color_at` gives at its position (a gradient fill's, say). - pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option { - let coons = Coons::from_path(path)?; - Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p))) - } - - /// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to - /// the edges, by at most `highlight` %. - pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) { - let h = (highlight / 100.0).clamp(0.0, 1.0) as f32; - let (rows, cols) = (self.rows as usize, self.cols as usize); - for r in 0..=rows { - for c in 0..=cols { - let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); - let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge - let w = match appearance { - MeshAppearance::Flat => 0.0, - MeshAppearance::ToCenter => (1.0 - d) * h, - MeshAppearance::ToEdge => d * h, - }; - if w > 0.0 { - let i = self.idx(r, c); - self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w); - } - } - } - } - - /// Horizontal edge from (r, c) to (r, c+1). - pub fn edge_u(&self, r: usize, c: usize) -> CubicBez { - let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)]); - CubicBez::new(a.p, a.p + a.handles[H_RIGHT], b.p + b.handles[H_LEFT], b.p) - } - /// Vertical edge from (r, c) to (r+1, c). - pub fn edge_v(&self, r: usize, c: usize) -> CubicBez { - let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r + 1, c)]); - CubicBez::new(a.p, a.p + a.handles[H_DOWN], b.p + b.handles[H_UP], b.p) - } - - /// Coons patch point of patch (r, c) at local (u, v). - pub fn eval(&self, r: usize, c: usize, u: f64, v: f64) -> Point { - let (t, b) = (self.edge_u(r, c).eval(u), self.edge_u(r + 1, c).eval(u)); - let (l, rr) = (self.edge_v(r, c).eval(v), self.edge_v(r, c + 1).eval(v)); - let p00 = self.points[self.idx(r, c)].p.to_vec2(); - let p10 = self.points[self.idx(r, c + 1)].p.to_vec2(); - let p01 = self.points[self.idx(r + 1, c)].p.to_vec2(); - let p11 = self.points[self.idx(r + 1, c + 1)].p.to_vec2(); - ((1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * rr.to_vec2() - - ((1.0 - u) * (1.0 - v) * p00 + u * (1.0 - v) * p10 + (1.0 - u) * v * p01 + u * v * p11)) - .to_point() - } - - /// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all - /// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out - /// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB. - pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) { - let q = - [&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]]; - let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v]; - let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum(); - let cmyk: Option> = - q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect(); - if let Some(inks) = cmyk { - let mut mix = [0.0f32; 4]; - for (ink, w) in inks.iter().zip(w) { - for (m, x) in mix.iter_mut().zip(ink) { - *m += x * w as f32; - } - } - let [c, m, y, k] = mix; - return (Color::Cmyk { c, m, y, k }, op); - } - let grey: Option> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect(); - if let Some(ks) = grey { - return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op); - } - let mut rgb = [0.0f32; 3]; - for (p, w) in q.iter().zip(w) { - for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) { - *ch += x * w as f32; - } - } - (Color::rgb(rgb[0], rgb[1], rgb[2]), op) - } - - /// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners. - pub fn quads(&self, n: usize) -> Vec { - if !self.is_valid() { - return vec![]; - } - let n = n.clamp(1, 64); - let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n); - let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; - let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)]; - for r in 0..self.rows as usize { - for c in 0..self.cols as usize { - for j in 0..=n { - for i in 0..=n { - let (u, v) = (i as f64 / n as f64, j as f64 / n as f64); - grid[j * (n + 1) + i] = self.eval(r, c, u, v); - colors[j * (n + 1) + i] = self.color_at(r, c, u, v); - } - } - for j in 0..n { - for i in 0..n { - let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); - let g = |a: usize, b: usize| grid[b * (n + 1) + a]; - let k = |a: usize, b: usize| colors[b * (n + 1) + a]; - out.push(MeshQuad { - pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], - color, - opacity, - corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)], - }); - } - } - } - } - out - } - - /// The mesh's outer boundary as a closed path. - pub fn outline(&self) -> PathData { - if !self.is_valid() { - return PathData::default(); - } - let (rows, cols) = (self.rows as usize, self.cols as usize); - let mut segs: Vec = vec![]; - for c in 0..cols { - segs.push(self.edge_u(0, c)); - } - for r in 0..rows { - segs.push(self.edge_v(r, cols)); - } - for c in (0..cols).rev() { - let e = self.edge_u(rows, c); - segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); - } - for r in (0..rows).rev() { - let e = self.edge_v(r, 0); - segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); - } - let mut anchors: Vec = Vec::with_capacity(segs.len()); - for (i, s) in segs.iter().enumerate() { - let prev = &segs[(i + segs.len() - 1) % segs.len()]; - anchors.push(Anchor::with_handles(s.p0, prev.p2, s.p1)); - } - PathData::single(SubPath::new(anchors, true)) - } - - /// Mesh lines (all row and column edges) as open paths, for outline view and editing overlays. - pub fn lines(&self) -> PathData { - if !self.is_valid() { - return PathData::default(); - } - let (rows, cols) = (self.rows as usize, self.cols as usize); - let mut subs = vec![]; - let cub = |e: CubicBez| SubPath::new(vec![Anchor::with_handles(e.p0, e.p0, e.p1), Anchor::with_handles(e.p3, e.p2, e.p3)], false); - for r in 0..=rows { - for c in 0..cols { - subs.push(cub(self.edge_u(r, c))); - } - } - for c in 0..=cols { - for r in 0..rows { - subs.push(cub(self.edge_v(r, c))); - } - } - PathData::new(subs) - } - - /// Control bounds (points and handles). - pub fn bounds(&self) -> Option { - let mut it = self.points.iter().flat_map(|p| std::iter::once(p.p).chain(p.handles.iter().map(move |h| p.p + *h))); - let first = it.next()?; - Some(it.fold(Rect::from_points(first, first), |r, q| r.union_pt(q))) - } - - pub fn transform(&mut self, a: Affine) { - let lin = Affine::new({ - let c = a.as_coeffs(); - [c[0], c[1], c[2], c[3], 0.0, 0.0] - }); - for p in &mut self.points { - p.p = a * p.p; - for h in &mut p.handles { - *h = (lin * h.to_point()).to_vec2(); - } - } - } - - /// Map points (and handle ends) through a non-linear function. - pub fn map(&mut self, f: &dyn Fn(Point) -> Point) { - for p in &mut self.points { - let q = f(p.p); - for h in &mut p.handles { - *h = f(p.p + *h) - q; - } - p.p = q; - } - } - - /// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh). - pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> { - if !self.is_valid() { - return None; - } - let bp = self.outline().to_bezpath(); - if bp.winding(p) == 0 { - return None; - } - const N: usize = 16; - let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); - for r in 0..self.rows as usize { - for c in 0..self.cols as usize { - for j in 0..=N { - for i in 0..=N { - let (u, v) = (i as f64 / N as f64, j as f64 / N as f64); - let d = self.eval(r, c, u, v).distance_squared(p); - if d < best.0 { - best = (d, r, c, u, v); - } - } - } - } - } - // Refine with a few Newton-free local searches. - let (_, r, c, mut u, mut v) = best; - let mut step = 0.5 / N as f64; - for _ in 0..12 { - let mut improved = false; - for (du, dv) in [(step, 0.0), (-step, 0.0), (0.0, step), (0.0, -step)] { - let (nu, nv) = ((u + du).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)); - if self.eval(r, c, nu, nv).distance_squared(p) < self.eval(r, c, u, v).distance_squared(p) { - u = nu; - v = nv; - improved = true; - } - } - if !improved { - step /= 2.0; - } - } - Some((r, c, u, v)) - } - - /// Index of the mesh point within `tol` of `p`. - pub fn point_near(&self, p: Point, tol: f64) -> Option { - self.points - .iter() - .enumerate() - .filter(|(_, q)| q.p.distance(p) <= tol) - .min_by(|a, b| a.1.p.distance(p).total_cmp(&b.1.p.distance(p))) - .map(|(i, _)| i) - } - - fn surface_du(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { - let e = 1e-3; - let (a, b) = ((u - e).max(0.0), (u + e).min(1.0)); - (self.eval(r, c, b, v) - self.eval(r, c, a, v)) / (b - a) - } - fn surface_dv(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { - let e = 1e-3; - let (a, b) = ((v - e).max(0.0), (v + e).min(1.0)); - (self.eval(r, c, u, b) - self.eval(r, c, u, a)) / (b - a) - } - - /// Insert a mesh row through patch row `r` at local `v`. - pub fn insert_row(&mut self, r: usize, v: f64) { - let cols = self.cols as usize; - let v = v.clamp(0.01, 0.99); - let mut row = Vec::with_capacity(cols + 1); - // Split the vertical edges. - let mut ups = vec![]; - for c in 0..=cols { - let e = self.edge_v(r, c); - let (l, rr) = (e.subsegment(0.0..v), e.subsegment(v..1.0)); - ups.push((l.p1 - l.p0, rr.p2 - rr.p3)); - let pc = c.min(cols - 1); - let u = if c == cols { 1.0 } else { 0.0 }; - let (color, opacity) = self.color_at(r, pc, u, v); - // Horizontal handles: isocurve derivatives on either side. - let right = if c < cols { self.surface_du(r, c, 0.0, v) / (3.0) } else { Vec2::ZERO }; - let left = if c > 0 { -self.surface_du(r, c - 1, 1.0, v) / 3.0 } else { Vec2::ZERO }; - row.push(MeshPoint { p: l.p3, color, opacity, handles: [right, left, rr.p1 - rr.p0, l.p2 - l.p3] }); - } - for (c, (down, up)) in ups.into_iter().enumerate() { - let i0 = self.idx(r, c); - let i1 = self.idx(r + 1, c); - self.points[i0].handles[H_DOWN] = down; - self.points[i1].handles[H_UP] = up; - } - let at = self.idx(r + 1, 0); - self.points.splice(at..at, row); - self.rows += 1; - } - - /// Insert a mesh column through patch column `c` at local `u`. - pub fn insert_col(&mut self, c: usize, u: f64) { - let rows = self.rows as usize; - let u = u.clamp(0.01, 0.99); - let mut col = Vec::with_capacity(rows + 1); - for r in 0..=rows { - let e = self.edge_u(r, c); - let (l, rr) = (e.subsegment(0.0..u), e.subsegment(u..1.0)); - let pr = r.min(rows - 1); - let v = if r == rows { 1.0 } else { 0.0 }; - let (color, opacity) = self.color_at(pr, c, u, v); - let down = if r < rows { self.surface_dv(r, c, u, 0.0) / 3.0 } else { Vec2::ZERO }; - let up = if r > 0 { -self.surface_dv(r - 1, c, u, 1.0) / 3.0 } else { Vec2::ZERO }; - col.push((MeshPoint { p: l.p3, color, opacity, handles: [rr.p1 - rr.p0, l.p2 - l.p3, down, up] }, l.p1 - l.p0, rr.p2 - rr.p3)); - } - let cols = self.cols as usize; - let mut pts = Vec::with_capacity((rows + 1) * (cols + 2)); - for (r, (mp, right, left)) in col.into_iter().enumerate() { - for cc in 0..=cols { - let mut q = self.points[r * (cols + 1) + cc].clone(); - if cc == c { - q.handles[H_RIGHT] = right; - } - if cc == c + 1 { - q.handles[H_LEFT] = left; - } - pts.push(q); - if cc == c { - pts.push(mp.clone()); - } - } - } - self.points = pts; - self.cols += 1; - } - - /// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index. - pub fn add_lines_at(&mut self, p: Point) -> Option { - let (r, c, u, v) = self.locate(p)?; - let mut rr = r; - let mut cc = c; - let row_new = v > 0.02 && v < 0.98; - let col_new = u > 0.02 && u < 0.98; - if row_new { - self.insert_row(r, v); - rr = r + 1; - } else if v >= 0.98 { - rr = r + 1; - } - if col_new { - self.insert_col(c, u); - cc = c + 1; - } else if u >= 0.98 { - cc = c + 1; - } - Some(self.idx(rr, cc)) - } - - /// Mesh tool Alt-click: delete the mesh lines through point `index` (interior lines only). - pub fn remove_point_lines(&mut self, index: usize) -> bool { - // A mesh from a file may not have the points its rows and columns need: it is indexed below. - if !self.is_valid() || index >= self.points.len() { - return false; - } - let w = self.cols as usize + 1; - let (r, c) = (index / w, index % w); - let mut changed = false; - if r > 0 && r < self.rows as usize { - // Merge the edges above and below: keep the outer handles. - for cc in 0..w { - let above = self.idx(r - 1, cc); - let below = self.idx(r + 1, cc); - let mid = self.idx(r, cc); - let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p); - let _ = pm; - self.points[above].handles[H_DOWN] = (pb - pa) / 3.0; - self.points[below].handles[H_UP] = (pa - pb) / 3.0; - } - self.points.drain(r * w..(r + 1) * w); - self.rows -= 1; - changed = true; - } - let w = self.cols as usize + 1; - if c > 0 && c < self.cols as usize { - let rows = self.rows as usize; - for rr in 0..=rows { - let (l, rt) = (rr * w + c - 1, rr * w + c + 1); - let (pl, pr) = (self.points[l].p, self.points[rt].p); - self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0; - self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0; - } - let mut k = 0; - self.points.retain(|_| { - let keep = k % w != c; - k += 1; - keep - }); - self.cols -= 1; - changed = true; - } - changed - } -} - -// ===================================================================================== -// Envelopes -// ===================================================================================== - -/// Union of the geometric bounds of `nodes`. -pub fn nodes_bounds(nodes: &[Arc]) -> Option { - nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())) -} - -/// A `(rows+1)×(cols+1)` grid over `r` (row-major). -pub fn grid_points(r: Rect, rows: u32, cols: u32) -> Vec { - let mut v = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize); - for i in 0..=rows { - for j in 0..=cols { - v.push(Point::new(r.x0 + r.width() * j as f64 / cols.max(1) as f64, r.y0 + r.height() * i as f64 / rows.max(1) as f64)); - } - } - v -} - -fn catmull(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: f64) -> Vec2 { - let t2 = t * t; - let t3 = t2 * t; - 0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3) -} - -/// Point map from the unit square onto a point grid (tensor-product Catmull-Rom; linear -/// extrapolation at the borders so an undistorted grid is the identity, also past the square). -fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point { - let at = |r: i64, c: i64| -> Vec2 { - let rr = r.clamp(0, rows as i64); - let cc = c.clamp(0, cols as i64); - let base = pts[rr as usize * (cols + 1) + cc as usize].to_vec2(); - // Linear extrapolation outside the grid. - let er = r - rr; - let ec = c - cc; - let mut p = base; - if er != 0 && rows >= 1 { - let inner = (rr - er.signum()).clamp(0, rows as i64); - p += (base - pts[inner as usize * (cols + 1) + cc as usize].to_vec2()) * er.abs() as f64; - } - if ec != 0 && cols >= 1 { - let inner = (cc - ec.signum()).clamp(0, cols as i64); - p += (base - pts[rr as usize * (cols + 1) + inner as usize].to_vec2()) * ec.abs() as f64; - } - p - }; - // Past the grid's edges the outer patches go on (art can reach past the content's box). - let (su, sv) = (u * cols as f64, v * rows as f64); - let (ci, ri) = ((su.floor() as i64).clamp(0, cols as i64 - 1), (sv.floor() as i64).clamp(0, rows as i64 - 1)); - let (fu, fv) = (su - ci as f64, sv - ri as f64); - let mut rowsv = [Vec2::ZERO; 4]; - for (k, rv) in rowsv.iter_mut().enumerate() { - let r = ri - 1 + k as i64; - *rv = catmull(at(r, ci - 1), at(r, ci), at(r, ci + 1), at(r, ci + 2), fu); - } - catmull(rowsv[0], rowsv[1], rowsv[2], rowsv[3], fv).to_point() -} - -/// The surface (u, v ∈ [0,1]) an envelope of `kind` maps content onto, for content whose bounds in -/// the envelope's own `frame` (its axes → the document) are `src`: a warp bends along the frame's -/// axes; a mesh's points and a top object's path are in the document already. -fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> { - let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); - match kind { - EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { - let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); - let b = bend.clamp(-100.0, 100.0) / 100.0; - let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); - let c = src.center(); - let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); - let horizontal = *horizontal; - Box::new(move |u: f64, v: f64| { - let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0); - let (x2, y2) = if horizontal { - warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y) - } else { - let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x); - (b2, a) - }; - frame * Point::new(c.x + x2 * hw, c.y + y2 * hh) - }) - } - EnvelopeKind::Mesh { rows, cols, points, handles } => { - if !handles.is_empty() - && let Some(m) = envelope_grid(*rows, *cols, points, handles) - { - return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v)); - } - if *rows == 0 || *cols == 0 || grid_len(*rows, *cols) != Some(points.len()) { - return Box::new(flat); - } - let (rows, cols) = (*rows as usize, *cols as usize); - Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v)) - } - EnvelopeKind::TopObject { path } => match Coons::from_path(path) { - Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)), - None => Box::new(flat), - }, - } -} - -/// The envelope's point map for content whose bounding box is `src` (page axes). -pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box Point + 'a> { - let s = surface_of(kind, src, Affine::IDENTITY); - let (w, h) = (src.width().max(1e-9), src.height().max(1e-9)); - Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h)) -} - -/// Catmull-Rom tangents of a `(rows+1)×(cols+1)` point grid (row-major) as mesh handles, a third -/// of the tangent towards each neighbour (past the borders the grid goes on straight): the curves -/// an envelope mesh without handles of its own runs along between its points. -pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]> { - let (rows, cols) = (rows as usize, cols as usize); - let at = |r: usize, c: usize| points.get(r * (cols + 1) + c).copied().unwrap_or_default(); - let tangent = |prev: Option, p: Point, next: Option| match (prev, next) { - (Some(a), Some(b)) => (b - a) / 2.0, - (None, Some(b)) => b - p, - (Some(a), None) => p - a, - (None, None) => Vec2::ZERO, - }; - let mut out = Vec::with_capacity(points.len()); - for r in 0..=rows { - for c in 0..=cols { - let p = at(r, c); - let du = tangent(c.checked_sub(1).map(|c| at(r, c)), p, (c < cols).then(|| at(r, c + 1))) / 3.0; - let dv = tangent(r.checked_sub(1).map(|r| at(r, c)), p, (r < rows).then(|| at(r + 1, c))) / 3.0; - // Handles pointing out of the grid are unused: they stay at the point. - let keep = |inside: bool, d: Vec2| if inside { d } else { Vec2::ZERO }; - let mut h = [Vec2::ZERO; 4]; - (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (keep(c < cols, du), keep(c > 0, -du), keep(r < rows, dv), keep(r > 0, -dv)); - out.push(h); - } - } - out -} - -/// A mesh envelope's grid (`rows`×`cols` patches, `points` and their `handles`; without handles -/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the -/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid. -pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option { - if rows == 0 || cols == 0 || grid_len(rows, cols) != Some(points.len()) { - return None; - } - let smooth; - let handles = if handles.len() == points.len() { - handles - } else { - smooth = smooth_handles(rows, cols, points); - &smooth - }; - let points = points.iter().zip(handles).map(|(p, h)| MeshPoint { p: *p, color: Color::BLACK, opacity: 1.0, handles: *h }).collect(); - Some(GradientMesh { rows, cols, points }) -} - -/// The point of mesh `m` at (u, v) over the whole mesh (each patch an equal share; past the edges -/// the outer patches go on). -fn mesh_at(m: &GradientMesh, u: f64, v: f64) -> Point { - let (rows, cols) = (m.rows.max(1) as i64, m.cols.max(1) as i64); - let (su, sv) = (u * cols as f64, v * rows as f64); - let (c, r) = ((su.floor() as i64).clamp(0, cols - 1), (sv.floor() as i64).clamp(0, rows - 1)); - m.eval(r as usize, c as usize, su - c as f64, sv - r as f64) -} - -/// The envelope `id` is, or the innermost one it sits in. -pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { - doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. }))) -} - -/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid, -/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to -/// edit (its points and handles). -pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { - let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; - match &**kind { - EnvelopeKind::Mesh { rows, cols, points, handles } => { - let grid = envelope_grid(*rows, *cols, points, handles)?; - Some((grid.lines(), Some(grid))) - } - EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)), - EnvelopeKind::TopObject { path } => Some((path.clone(), None)), - } -} - -/// The frame an envelope keeps after transform `m` (its frame so far composed with the -/// transform): identity when its axes stay square to the page, unflipped, since evaluating on -/// the page axes gives the same result then. -pub fn envelope_frame(m: Affine) -> Affine { - let [a, b, c, d, _, _] = m.as_coeffs(); - if b.abs() < 1e-12 && c.abs() < 1e-12 && a > 0.0 && d > 0.0 { Affine::IDENTITY } else { m } -} - -/// An envelope's point map: content placed in the envelope's own frame maps, by its bounds there, -/// onto the unit square and from there onto the envelope's surface ([`surface_of`]). -pub struct EnvelopeMap<'a> { - src: Rect, - frame: Affine, - to_frame: Affine, - surface: Box Point + 'a>, -} - -impl<'a> EnvelopeMap<'a> { - /// The map of an envelope of `kind` around `content` with `frame`. `None` when the content - /// has no bounds. - pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine) -> Option { - let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; - let to_frame = frame.inverse(); - let src = if frame == Affine::IDENTITY { - nodes_bounds(content)? - } else { - content.iter().try_fold(None, |acc, c| { - let mut m = (**c).clone(); - m.transform(to_frame, false); - Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds())) - })?? - }; - Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) - } - - /// The map of envelope node `n` (`None` for other nodes). - pub fn of(n: &'a Node) -> Option { - match &n.kind { - NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame), - _ => None, - } - } - - /// The content's bounds in the envelope's frame. - pub fn src(&self) -> Rect { - self.src - } - - /// The surface point at (u, v) ∈ [0,1]². - pub fn at(&self, u: f64, v: f64) -> Point { - (self.surface)(u, v) - } - - /// Where the envelope puts document point `p`. - pub fn map(&self, p: Point) -> Point { - let q = self.to_frame * p; - let (w, h) = (self.src.width().max(1e-9), self.src.height().max(1e-9)); - self.at((q.x - self.src.x0) / w, (q.y - self.src.y0) / h) - } - - /// A flat `(rows+1)×(cols+1)` grid over the content, in the envelope's frame (row-major). - pub fn grid(&self, rows: u32, cols: u32) -> Vec { - grid_points(self.src, rows, cols).into_iter().map(|p| self.frame * p).collect() - } - - /// The surface sampled on a `rows`×`cols` grid as a mesh whose handles follow it and whose - /// points are `color`: Release gives it back as the envelope's shape, Reset with Mesh starts - /// from it. - pub fn surface_mesh(&self, rows: u32, cols: u32, color: Color) -> GradientMesh { - GradientMesh::from_surface(rows, cols, &|u, v| self.at(u, v), &|_, _| color) - } - - /// The surface's bounds (sampled; a mesh's points and a top object's path bound it exactly). - fn bounds(&self) -> Option { - const N: usize = 16; - let mut r: Option = None; - for j in 0..=N { - for i in 0..=N { - let q = self.at(i as f64 / N as f64, j as f64 / N as f64); - r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); - } - } - r - } -} - -/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines. -pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option>; - -/// Distort Pattern Fills: the tiles pattern swatch `name` painted with placement `xf` lays over -/// `region` (see [`crate::PatternDef::instances_in`]); `None` without such a swatch. -pub type PatternTiles<'a> = &'a dyn Fn(&str, Affine, Rect) -> Option>; - -/// What live evaluation asks of the crates above this one (`vectorcraft-effects` supplies them). -#[derive(Clone, Copy, Default)] -pub struct Hooks<'a> { - /// Type as its glyph outlines, a symbol instance as its art (see [`Outliner`]). - pub outline: Outliner<'a>, - /// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None` - /// when it has none to bake). - pub appearance: Outliner<'a>, - /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). - pub pattern: Option>, - /// The document whose symbols' art counts toward the instance budget of a Repeat of their - /// instances ([`crate::RepeatSpec::expand`]). - pub symbols: Option<&'a crate::Document>, -} - -impl<'a> From> for Hooks<'a> { - fn from(outline: Outliner<'a>) -> Self { - Self { outline, ..Default::default() } - } -} - -/// The affine map that best matches `f` near `p` (finite differences with step `eps`). -pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine { - let o = f(p); - let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps); - let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps); - let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]); - Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2()) -} - -/// How deep hooks may nest art inside art (symbols in symbols, envelopes in envelopes) before -/// the rest is left as it is: a file can make a symbol contain itself. -const MAX_HOOK_DEPTH: u32 = 16; - -/// How an envelope pushes art through its point map. -#[derive(Clone, Copy)] -struct Warper<'a> { - f: &'a dyn Fn(Point) -> Point, - /// Longest piece a segment is cut into before mapping. - piece: f64, - /// Finite-difference step for the local affine maps gradients follow. - eps: f64, - /// Rows and columns of the pieces an image is cut into. - cells: usize, - hooks: Hooks<'a>, - options: EnvelopeOptions, - /// How many hooks the art being mapped came through. - depth: u32, -} - -impl Warper<'_> { - /// This warper one hook deeper (`None` past [`MAX_HOOK_DEPTH`]). - fn deeper(&self) -> Option { - (self.depth < MAX_HOOK_DEPTH).then_some(Self { depth: self.depth + 1, ..*self }) - } -} - -/// `to` (art standing in for `from`: its outlines, its art) with `from`'s name, transparency, -/// opacity mask and effects. -fn carry_look(from: &Node, mut to: Node) -> Node { - to.name = from.name.clone(); - to.opacity = from.opacity; - to.blend = from.blend; - to.isolate = from.isolate; - to.knockout = from.knockout; - to.knockout_shape = from.knockout_shape; - to.extras_mut().mask = from.extras().mask.clone(); - to.appearance.effects = from.appearance.effects.clone(); - to -} - -fn map_node(n: &Node, w: &Warper) -> Node { - let deeper = w.deeper(); - // Distort Appearance: strokes and geometry effects bend with the art. - if w.options.distort_appearance - && let Some(d) = &deeper - && let Some(o) = w.hooks.appearance.and_then(|h| h(n)) - { - return map_node(&o, d); - } - // Distort Pattern Fills: the tiles bend with the art (not the patterns inside them). - if w.options.patterns() - && let Some(d) = &deeper - && let Some(o) = w.hooks.pattern.and_then(|p| pattern_art(n, p)) - { - let inner = Warper { options: EnvelopeOptions { distort_pattern_fills: false, ..d.options }, ..*d }; - return map_node(&o, &inner); - } - let mut out = n.clone(); - // Distort Linear Gradients: the gradient vectors follow the warp where they sit. - if w.options.gradients() - && let Some(b) = n.geometric_bounds() - { - out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); - } - if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { - m.art = Arc::new(map_node(&m.art, w)); - } - match &mut out.kind { - NodeKind::Path { path, live, .. } => { - *path = map_nonlinear(path, w.piece, w.f); - *live = None; - } - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { - for c in children.iter_mut() { - *c = Arc::new(map_node(c, w)); - } - } - NodeKind::Mesh(m) => m.map(w.f), - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { - if let Some(d) = &deeper { - return map_node(&expanded_group_hooks(n, w.hooks), d); - } - } - NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => { - if let Some(d) = &deeper - && let Some(o) = w.hooks.outline.and_then(|h| h(n)) - { - return map_node(&carry_look(n, o), d); - } - } - NodeKind::Image(im) => { - let im = im.clone(); - return warp_image(&out, &im, w); - } - // Its art is plain, so it warps at this depth. - NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), - } - out -} - -/// The affine map taking triangle `s` to triangle `d` (`None` when `s` is degenerate). -fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { - let basis = |p: [Point; 3]| Affine::new([p[1].x - p[0].x, p[1].y - p[0].y, p[2].x - p[0].x, p[2].y - p[0].y, p[0].x, p[0].y]); - let bs = basis(s); - if bs.determinant().abs() < 1e-12 { - return None; - } - let a = basis(d) * bs.inverse(); - a.is_finite().then_some(a) -} - -/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into -/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map -/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its -/// edges (at most half a point) so neighbours overlap and no seams show. -fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { - let k = w.cells.max(1); - // Two triangles a cell, three objects each; an image an inner envelope already cut into - // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image - // is left out. - if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { - let mut g = n.clone(); - g.kind = NodeKind::Group { children: vec![], clip: false }; - return g; - } - let (pw, ph) = (im.width as f64, im.height as f64); - let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); - let mut pieces = Vec::with_capacity(2 * k * k); - for j in 0..k { - for i in 0..k { - let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; - let d = s.map(|p| (w.f)(im.xf * p)); - for t in [[0, 1, 2], [0, 2, 3]] { - let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); - let Some(xf) = affine_between(st, dt) else { continue }; - let c = Point::new((dt[0].x + dt[1].x + dt[2].x) / 3.0, (dt[0].y + dt[1].y + dt[2].y) / 3.0); - let grow = (dt[0].distance(dt[1]).max(dt[1].distance(dt[2])) * 0.02).min(0.5); - let tri: Vec = dt - .iter() - .map(|p| { - let v = *p - c; - let len = v.hypot(); - if len > 1e-12 { *p + v * (grow / len) } else { *p } - }) - .collect(); - let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default()); - if let NodeKind::Path { clipping, .. } = &mut clip.kind { - *clipping = true; - } - let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); - pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); - } - } - } - let mut g = n.clone(); - g.kind = NodeKind::Group { children: pieces, clip: false }; - g -} - -/// Distort Pattern Fills: path or compound path `n` with each visible pattern fill turned into the -/// tiles it paints in a clip group shaped like `n` (with the fill's opacity and blend mode), its -/// other fills and strokes on copies of it, in paint order, under `n`'s transparency. `None` -/// without a pattern fill whose definition `pattern` finds. -fn pattern_art(n: &Node, tiles: PatternTiles) -> Option { - let (path, rule, _) = crate::blend::node_path(n)?; - let pattern_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, Paint::Pattern { .. })); - if !n.appearance.items.iter().any(pattern_fill) { - return None; - } - let bounds = path.bounds()?; - let def = |i: &AppearanceItem| match i { - AppearanceItem::Fill(f) if f.visible => match &f.paint { - Paint::Pattern { pattern: name, xf } => tiles(name, *xf, bounds).map(|t| (t, f.opacity, f.blend)), - _ => None, - }, - _ => None, - }; - let plain = |items: Vec| { - let mut p = Node { - appearance: Appearance { items, ..Default::default() }.into(), - opacity: 1.0, - blend: Default::default(), - isolate: false, - knockout: Default::default(), - knockout_shape: false, - name: None, - ..n.clone() - }; - p.extras_mut().mask = None; - Arc::new(p) - }; - let mut pieces = vec![]; - let mut rest = vec![]; - for i in &n.appearance.items { - let Some((tiles, opacity, blend)) = def(i) else { - rest.push(i.clone()); - continue; - }; - if !rest.is_empty() { - pieces.push(plain(std::mem::take(&mut rest))); - } - let mut clip = Node::path(n.id, path.clone(), Appearance::default()); - if let NodeKind::Path { clipping, rule: r, .. } = &mut clip.kind { - (*clipping, *r) = (true, rule); - } - let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect(); - let mut g = Node::new(n.id, NodeKind::Group { children, clip: true }); - g.opacity = opacity; - g.blend = blend; - pieces.push(Arc::new(g)); - } - if !rest.is_empty() { - pieces.push(plain(rest)); - } - Some(Node { - kind: NodeKind::Group { children: pieces, clip: false }, - appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }.into(), - ..n.clone() - }) -} - -/// Evaluate an envelope of `kind` around `content` on the page axes with the options envelopes -/// had before Envelope Options (see [`expand_live_hooks`] for an envelope node). -pub fn envelope_expand(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec { - expand_envelope(content, kind, fidelity, EnvelopeOptions::default(), Affine::IDENTITY, outline.into()) -} - -/// An envelope's content pushed through its map: Fidelity sets how finely curves are cut (and -/// into how many pieces images are), the options what bends with the art. -fn expand_envelope(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, options: EnvelopeOptions, frame: Affine, hooks: Hooks) -> Vec { - let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() }; - let src = map.src(); - let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 }; - let f = |p: Point| map.map(p); - let w = Warper { - f: &f, - piece: src.width().hypot(src.height()).max(1e-6) / (8.0 + fid * 56.0), - eps: (src.width().max(src.height()) * 0.05).max(0.5), - cells: (4.0 + fid * 12.0).round() as usize, - hooks, - options, - depth: 0, - }; - content.iter().map(|c| map_node(c, &w)).collect() -} - -/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled). -pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine) -> Option { - match kind { - EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(), - EnvelopeKind::Mesh { points, .. } => { - let first = *points.first()?; - Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p))) - } - EnvelopeKind::TopObject { path } => path.bounds(), - EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(), - } -} - -// ===================================================================================== -// Expansion -// ===================================================================================== - -/// Is this one of the live kinds? -pub fn is_live(n: &Node) -> bool { - matches!( - n.kind, - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } - ) -} - -/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. -pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec { - m.quads(n) - .into_iter() - .map(|q| { - let mut ap = Appearance::default(); - let mut fl = FillLayer::new(Paint::solid(q.color)); - fl.opacity = q.opacity; - ap.items.push(AppearanceItem::Fill(fl)); - Node::path(NodeId(0), PathData::single(SubPath::polyline(&q.pts, true)), ap) - }) - .collect() -} - -/// One level of evaluation of a live object (non-live nodes return themselves). Blend steps and -/// envelope results may still contain live objects (e.g. a blend of two meshes). -pub fn expand_live(n: &Node) -> Vec { - expand_live_with(n, None) -} - -pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { - expand_live_hooks(n, outline.into()) -} - -/// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]). -pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { - let _generating = Generating::enter(); - match &n.kind { - NodeKind::Blend { children, spec } => blend_expand(children, spec), - NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), - NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), - NodeKind::Repeat(r) => r.expand(hooks.symbols), - NodeKind::PlacedDocument(p) => p.art(n), - NodeKind::Intertwine { children } => crate::intertwine::expand(children), - _ => vec![n.clone()], - } -} - -/// A live object evaluated into a plain group (keeping the object's id, name and transparency). -pub fn expanded_group(n: &Node, outline: Outliner) -> Node { - expanded_group_hooks(n, outline.into()) -} - -/// [`expanded_group`] with [`Hooks`]. -pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { - let _generating = Generating::enter(); - let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect(); - let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); - g.name = n.name.clone(); - g.visible = n.visible; - g.locked = n.locked; - g.opacity = n.opacity; - g.blend = n.blend; - g.isolate = n.isolate; - g.knockout = n.knockout; - // Knocking out opaque blend steps changes nothing: don't make the outputs work for it. - if matches!(n.kind, NodeKind::Blend { .. }) && g.knockout == crate::Knockout::On && !crate::blend::knockout_shows(&g) { - g.knockout = crate::Knockout::Off; - } - g.knockout_shape = n.knockout_shape; - g.extras_mut().mask = n.extras().mask.clone(); - g -} - -/// Recursively replace every live object in `n` by plain geometry (for exporters). -pub fn expand_deep(n: &Node, outline: Outliner) -> Node { - expand_deep_hooks(n, outline.into()) -} - -/// [`expand_deep`] with [`Hooks`]. The objects live art makes here count against one -/// [`MAX_GENERATED`] ([`Generating`]): blends of blends kept their inner blends live, and each -/// copy expanded on its own, so step counts capped one by one multiplied. Past it, live art -/// expands to nothing. -pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node { - let _generating = Generating::enter(); - let mut out = if is_live(n) { - let g = expanded_group_hooks(n, hooks); - if !Generating::charge(g.count()) { - return Node::new(n.id, NodeKind::Group { children: vec![], clip: false }); - } - g - } else { - n.clone() - }; - if let Some(ch) = out.children_mut() { - for c in ch.iter_mut() { - if subtree_has_live(c) { - *c = Arc::new(expand_deep_hooks(c, hooks)); - } - } - } - out -} - -/// Most objects one evaluation of live art (an expansion, an envelope's mapping and everything -/// inside them) may make. -pub const MAX_GENERATED: usize = 4_000_000; - -std::thread_local! { - /// What is left of [`MAX_GENERATED`] for the evaluation running on this thread; `None` outside - /// one. - static GENERATED_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; -} - -/// The outermost live evaluation on this thread: it starts [`GENERATED_LEFT`] and ends it. -struct Generating(bool); - -impl Generating { - fn enter() -> Self { - let outermost = GENERATED_LEFT.get().is_none(); - if outermost { - GENERATED_LEFT.set(Some(MAX_GENERATED)); - } - Self(outermost) - } - - /// Spend `n` objects; `false` (and nothing left) when there aren't that many. - fn charge(n: usize) -> bool { - match GENERATED_LEFT.get() { - None => true, - Some(left) => { - GENERATED_LEFT.set(Some(left.saturating_sub(n))); - n <= left - } - } - } -} - -impl Drop for Generating { - fn drop(&mut self) { - if self.0 { - GENERATED_LEFT.set(None); - } - } -} - -/// Does `n` or any descendant need live evaluation? -pub fn subtree_has_live(n: &Node) -> bool { - let mut any = false; - n.walk(&mut |c| any |= is_live(c)); - any -} - -/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the -/// outsets in the art of the symbols its instances show count too -/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made -/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad -/// does. -pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxes>) -> f64 { - let mut o = 0.0f64; - for n in nodes { - n.walk(&mut |c| { - o = o.max(c.appearance.outset()); - if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind) - && let Some(art) = s.art(symbol) - { - let reach = if s.art_strokes() { art.reach } else { 0.0 }; - o = o.max(reach + s.brush_pad(symbol)); - } - }); - } - o -} - -/// Reading a native file never fails over one bad blend number: out-of-range values clamp, -/// and anything that is not a finite number (NaN, null, text) becomes the default. The -/// commands still refuse such input (`ease_param`). -fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result { - let v = serde_json::Value::deserialize(d)?; - Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max))) -} - -fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - blend_number(d, 0.0, 1.0) -} - -fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - blend_number(d, -100.0, 100.0) -} - -/// Whether an optional stored color shift has its unchanged default. -fn is_zero(v: &f64) -> bool { - *v == 0.0 -} - -impl BlendSpec { - /// Shift the color transition independently of shape placement. The exponential curve is - /// original; the documented behavior specifies the direction, not numeric sample values. - pub fn color_position(&self, u: f64) -> f64 { - let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; - let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 }; - if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) } - } -} +mod blend_spec; +mod envelope; +mod expand; +mod mesh; +mod warp; + +pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec}; +#[cfg(test)] +use blend_spec::{blend_color_shift, blend_ramp}; +pub use envelope::{ + EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_expand, envelope_frame, + envelope_grid, envelope_mapper, envelope_of, envelope_overlay, smooth_handles, +}; +pub(crate) use expand::max_outset; +pub use expand::{ + Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with, + expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live, +}; +#[cfg(test)] +use mesh::grid_eval; +pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; +pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; #[cfg(test)] mod tests { diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs new file mode 100644 index 000000000..79c129030 --- /dev/null +++ b/crates/doc/src/live/blend_spec.rs @@ -0,0 +1,157 @@ +use super::*; + +// ===================================================================================== +// Data +// ===================================================================================== + +/// Blend Options → Spacing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendSpacing { + /// Step count chosen from the colour difference (or the distance when colours match). + #[default] + SmoothColor, + /// Specified number of steps between each pair of key objects (1–1000). + Steps(u32), + /// Specified distance (pt) between the steps. + Distance(f64), +} + +/// Blend Options → Orientation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendOrientation { + #[default] + AlignToPage, + AlignToPath, +} + +/// Distribution of blend steps, as offered by the Blend panel's Type menu. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendEaseType { + #[default] + In, + Out, + InOut, + OutIn, +} + +impl BlendEaseType { + pub fn id(self) -> &'static str { + match self { + Self::In => "easeIn", + Self::Out => "easeOut", + Self::InOut => "easeInOut", + Self::OutIn => "easeOutIn", + } + } +} + +/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendEase { + #[serde(default)] + pub kind: BlendEaseType, + #[serde(default, deserialize_with = "blend_ramp")] + pub ramp: f64, +} + +impl BlendEase { + pub fn is_linear(&self) -> bool { + self.ramp <= 0.0 + } + + /// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0. + pub fn is_default(&self) -> bool { + self.kind == BlendEaseType::In && self.ramp == 0.0 + } + + /// A monotone polynomial distribution, blended with even spacing by Ramp. Public help + /// describes the four distributions, not their numeric curves; these curves are original. + pub fn apply(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 }; + let eased = match self.kind { + BlendEaseType::In => u * u, + BlendEaseType::Out => u * (2.0 - u), + BlendEaseType::InOut => u * u * (3.0 - 2.0 * u), + BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u), + BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5), + }; + u + r * (eased - u) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendSpec { + #[serde(default)] + pub spacing: BlendSpacing, + #[serde(default)] + pub orientation: BlendOrientation, + /// Acceleration of the steps' shapes and positions along the spine between two keys. + #[serde(default, skip_serializing_if = "BlendEase::is_default")] + pub ease: BlendEase, + /// Independent color acceleration. -100..100, with 0 preserving the even color ramp. + #[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")] + pub color_shift: f64, + /// Replaced spine. `None` = the straight lines between the key objects' centres. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spine: Option, + /// The anchor of the spine's first subpath each key object sits on (a spine edited on the + /// canvas: moving a key moves its anchor and the other way round). Empty: the keys spread + /// evenly along the spine by arc length (Replace Spine). + #[serde(default, rename = "keyAnchors", skip_serializing_if = "Vec::is_empty")] + pub key_anchors: Vec, + /// Per key object, the anchor of its first subpath the blend starts from (Blend tool clicks + /// on anchor points); `None`: chosen so closed shapes don't twist. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub starts: Vec>, +} + +impl BlendSpec { + /// The start anchor of key `i` (see [`Self::starts`]). + pub fn start(&self, i: usize) -> Option { + self.starts.get(i).copied().flatten().map(|a| a as usize) + } +} + +/// Blend Options set with no blend selected: what new blends start with. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendDefaults { + #[serde(default)] + pub spacing: BlendSpacing, + #[serde(default)] + pub orientation: BlendOrientation, +} + +/// Reading a native file never fails over one bad blend number: out-of-range values clamp, +/// and anything that is not a finite number (NaN, null, text) becomes the default. The +/// commands still refuse such input (`ease_param`). +fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max))) +} + +pub(super) fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, 0.0, 1.0) +} + +pub(super) fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, -100.0, 100.0) +} + +/// Whether an optional stored color shift has its unchanged default. +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +impl BlendSpec { + /// Shift the color transition independently of shape placement. The exponential curve is + /// original; the documented behavior specifies the direction, not numeric sample values. + pub fn color_position(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 }; + if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) } + } +} diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs new file mode 100644 index 000000000..13d30cef0 --- /dev/null +++ b/crates/doc/src/live/envelope.rs @@ -0,0 +1,332 @@ +use super::expand::{Hooks, Outliner, Warper, map_node}; +use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of}; +use super::*; + +/// How an envelope distorts its content. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum EnvelopeKind { + /// One of the 15 warp styles (`arc`, `arcLower`, … — see [`WarpStyle::from_id`]); bend and + /// distortions in percent (-100..100). + Warp { + style: String, + #[serde(default)] + bend: f64, + #[serde(default)] + h: f64, + #[serde(default)] + v: f64, + #[serde(default = "yes")] + horizontal: bool, + }, + /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. + Mesh { + rows: u32, + cols: u32, + points: Vec, + /// Each point's bezier handles (offsets, see [`H_RIGHT`] …), row-major like `points`. + /// Empty: the smooth (Catmull-Rom) mesh through the points, as before handles existed; + /// editing the mesh's points or handles on the canvas fills them in. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + handles: Vec<[Vec2; 4]>, + }, + /// The content's bounding box maps onto the outline of this path. + TopObject { path: PathData }, +} + +/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreserveShape { + #[default] + ClippingMask, + Transparency, +} + +impl PreserveShape { + pub fn id(self) -> &'static str { + match self { + Self::ClippingMask => "clippingMask", + Self::Transparency => "transparency", + } + } + pub fn parse(s: &str) -> Option { + match s { + "clippingMask" => Some(Self::ClippingMask), + "transparency" => Some(Self::Transparency), + _ => None, + } + } +} + +/// Envelope Options besides Fidelity. `Default` is what envelopes saved before these options +/// existed do (their appearance applies after the distortion); new envelopes start from +/// [`EnvelopeOptions::NEW`], as in the reference app. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct EnvelopeOptions { + /// Rasters: Anti-Alias. + pub anti_alias: bool, + /// Rasters: Preserve Shape Using. + pub preserve_shape: PreserveShape, + /// Distort Appearance: strokes and live effects are applied before the distortion, so they + /// bend with the art (off: they apply to the distorted paths). + pub distort_appearance: bool, + /// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art. + pub distort_linear_gradients: bool, + /// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art. + pub distort_pattern_fills: bool, +} + +impl Default for EnvelopeOptions { + fn default() -> Self { + Self { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: false, + distort_linear_gradients: false, + distort_pattern_fills: false, + } + } +} + +impl EnvelopeOptions { + /// The options a new envelope gets (the reference app's defaults). + pub const NEW: Self = Self { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: true, + distort_linear_gradients: false, + distort_pattern_fills: false, + }; + /// Do gradients bend with the art? + pub fn gradients(&self) -> bool { + self.distort_appearance && self.distort_linear_gradients + } + /// Do pattern tiles bend with the art? + pub fn patterns(&self) -> bool { + self.distort_appearance && self.distort_pattern_fills + } +} + +/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row. +pub const H_RIGHT: usize = 0; + +pub const H_LEFT: usize = 1; + +pub const H_DOWN: usize = 2; + +pub const H_UP: usize = 3; + +/// The envelope's point map for content whose bounding box is `src` (page axes). +pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box Point + 'a> { + let s = surface_of(kind, src, Affine::IDENTITY); + let (w, h) = (src.width().max(1e-9), src.height().max(1e-9)); + Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h)) +} + +/// Catmull-Rom tangents of a `(rows+1)×(cols+1)` point grid (row-major) as mesh handles, a third +/// of the tangent towards each neighbour (past the borders the grid goes on straight): the curves +/// an envelope mesh without handles of its own runs along between its points. +pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]> { + let (rows, cols) = (rows as usize, cols as usize); + let at = |r: usize, c: usize| points.get(r * (cols + 1) + c).copied().unwrap_or_default(); + let tangent = |prev: Option, p: Point, next: Option| match (prev, next) { + (Some(a), Some(b)) => (b - a) / 2.0, + (None, Some(b)) => b - p, + (Some(a), None) => p - a, + (None, None) => Vec2::ZERO, + }; + let mut out = Vec::with_capacity(points.len()); + for r in 0..=rows { + for c in 0..=cols { + let p = at(r, c); + let du = tangent(c.checked_sub(1).map(|c| at(r, c)), p, (c < cols).then(|| at(r, c + 1))) / 3.0; + let dv = tangent(r.checked_sub(1).map(|r| at(r, c)), p, (r < rows).then(|| at(r + 1, c))) / 3.0; + // Handles pointing out of the grid are unused: they stay at the point. + let keep = |inside: bool, d: Vec2| if inside { d } else { Vec2::ZERO }; + let mut h = [Vec2::ZERO; 4]; + (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (keep(c < cols, du), keep(c > 0, -du), keep(r < rows, dv), keep(r > 0, -dv)); + out.push(h); + } + } + out +} + +/// A mesh envelope's grid (`rows`×`cols` patches, `points` and their `handles`; without handles +/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the +/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid. +pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option { + if rows == 0 || cols == 0 || grid_len(rows, cols) != Some(points.len()) { + return None; + } + let smooth; + let handles = if handles.len() == points.len() { + handles + } else { + smooth = smooth_handles(rows, cols, points); + &smooth + }; + let points = points.iter().zip(handles).map(|(p, h)| MeshPoint { p: *p, color: Color::BLACK, opacity: 1.0, handles: *h }).collect(); + Some(GradientMesh { rows, cols, points }) +} + +/// The point of mesh `m` at (u, v) over the whole mesh (each patch an equal share; past the edges +/// the outer patches go on). +pub(super) fn mesh_at(m: &GradientMesh, u: f64, v: f64) -> Point { + let (rows, cols) = (m.rows.max(1) as i64, m.cols.max(1) as i64); + let (su, sv) = (u * cols as f64, v * rows as f64); + let (c, r) = ((su.floor() as i64).clamp(0, cols - 1), (sv.floor() as i64).clamp(0, rows - 1)); + m.eval(r as usize, c as usize, su - c as f64, sv - r as f64) +} + +/// The envelope `id` is, or the innermost one it sits in. +pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { + doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. }))) +} + +/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid, +/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to +/// edit (its points and handles). +pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { + let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; + match &**kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let grid = envelope_grid(*rows, *cols, points, handles)?; + Some((grid.lines(), Some(grid))) + } + EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)), + EnvelopeKind::TopObject { path } => Some((path.clone(), None)), + } +} + +/// The frame an envelope keeps after transform `m` (its frame so far composed with the +/// transform): identity when its axes stay square to the page, unflipped, since evaluating on +/// the page axes gives the same result then. +pub fn envelope_frame(m: Affine) -> Affine { + let [a, b, c, d, _, _] = m.as_coeffs(); + if b.abs() < 1e-12 && c.abs() < 1e-12 && a > 0.0 && d > 0.0 { Affine::IDENTITY } else { m } +} + +/// An envelope's point map: content placed in the envelope's own frame maps, by its bounds there, +/// onto the unit square and from there onto the envelope's surface ([`surface_of`]). +pub struct EnvelopeMap<'a> { + src: Rect, + frame: Affine, + to_frame: Affine, + surface: Box Point + 'a>, +} + +impl<'a> EnvelopeMap<'a> { + /// The map of an envelope of `kind` around `content` with `frame`. `None` when the content + /// has no bounds. + pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine) -> Option { + let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; + let to_frame = frame.inverse(); + let src = if frame == Affine::IDENTITY { + nodes_bounds(content)? + } else { + content.iter().try_fold(None, |acc, c| { + let mut m = (**c).clone(); + m.transform(to_frame, false); + Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds())) + })?? + }; + Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) + } + + /// The map of envelope node `n` (`None` for other nodes). + pub fn of(n: &'a Node) -> Option { + match &n.kind { + NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame), + _ => None, + } + } + + /// The content's bounds in the envelope's frame. + pub fn src(&self) -> Rect { + self.src + } + + /// The surface point at (u, v) ∈ [0,1]². + pub fn at(&self, u: f64, v: f64) -> Point { + (self.surface)(u, v) + } + + /// Where the envelope puts document point `p`. + pub fn map(&self, p: Point) -> Point { + let q = self.to_frame * p; + let (w, h) = (self.src.width().max(1e-9), self.src.height().max(1e-9)); + self.at((q.x - self.src.x0) / w, (q.y - self.src.y0) / h) + } + + /// A flat `(rows+1)×(cols+1)` grid over the content, in the envelope's frame (row-major). + pub fn grid(&self, rows: u32, cols: u32) -> Vec { + grid_points(self.src, rows, cols).into_iter().map(|p| self.frame * p).collect() + } + + /// The surface sampled on a `rows`×`cols` grid as a mesh whose handles follow it and whose + /// points are `color`: Release gives it back as the envelope's shape, Reset with Mesh starts + /// from it. + pub fn surface_mesh(&self, rows: u32, cols: u32, color: Color) -> GradientMesh { + GradientMesh::from_surface(rows, cols, &|u, v| self.at(u, v), &|_, _| color) + } + + /// The surface's bounds (sampled; a mesh's points and a top object's path bound it exactly). + fn bounds(&self) -> Option { + const N: usize = 16; + let mut r: Option = None; + for j in 0..=N { + for i in 0..=N { + let q = self.at(i as f64 / N as f64, j as f64 / N as f64); + r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + } + r + } +} + +/// Evaluate an envelope of `kind` around `content` on the page axes with the options envelopes +/// had before Envelope Options (see [`expand_live_hooks`] for an envelope node). +pub fn envelope_expand(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec { + expand_envelope(content, kind, fidelity, EnvelopeOptions::default(), Affine::IDENTITY, outline.into()) +} + +/// An envelope's content pushed through its map: Fidelity sets how finely curves are cut (and +/// into how many pieces images are), the options what bends with the art. +pub(super) fn expand_envelope( + content: &[Arc], + kind: &EnvelopeKind, + fidelity: f64, + options: EnvelopeOptions, + frame: Affine, + hooks: Hooks, +) -> Vec { + let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() }; + let src = map.src(); + let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 }; + let f = |p: Point| map.map(p); + let w = Warper { + f: &f, + piece: src.width().hypot(src.height()).max(1e-6) / (8.0 + fid * 56.0), + eps: (src.width().max(src.height()) * 0.05).max(0.5), + cells: (4.0 + fid * 12.0).round() as usize, + hooks, + options, + depth: 0, + }; + content.iter().map(|c| map_node(c, &w)).collect() +} + +/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled). +pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine) -> Option { + match kind { + EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(), + EnvelopeKind::Mesh { points, .. } => { + let first = *points.first()?; + Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p))) + } + EnvelopeKind::TopObject { path } => path.bounds(), + EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(), + } +} diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs new file mode 100644 index 000000000..09eada113 --- /dev/null +++ b/crates/doc/src/live/expand.rs @@ -0,0 +1,444 @@ +use super::envelope::{EnvelopeOptions, expand_envelope}; +use super::mesh::GradientMesh; +use super::warp::map_nonlinear; +use super::*; + +/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines. +pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option>; + +/// Distort Pattern Fills: the tiles pattern swatch `name` painted with placement `xf` lays over +/// `region` (see [`crate::PatternDef::instances_in`]); `None` without such a swatch. +pub type PatternTiles<'a> = &'a dyn Fn(&str, Affine, Rect) -> Option>; + +/// What live evaluation asks of the crates above this one (`vectorcraft-effects` supplies them). +#[derive(Clone, Copy, Default)] +pub struct Hooks<'a> { + /// Type as its glyph outlines, a symbol instance as its art (see [`Outliner`]). + pub outline: Outliner<'a>, + /// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None` + /// when it has none to bake). + pub appearance: Outliner<'a>, + /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). + pub pattern: Option>, + /// The document whose symbols' art counts toward the instance budget of a Repeat of their + /// instances ([`crate::RepeatSpec::expand`]). + pub symbols: Option<&'a crate::Document>, +} + +impl<'a> From> for Hooks<'a> { + fn from(outline: Outliner<'a>) -> Self { + Self { outline, ..Default::default() } + } +} + +/// The affine map that best matches `f` near `p` (finite differences with step `eps`). +pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine { + let o = f(p); + let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps); + let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps); + let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]); + Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2()) +} + +/// How deep hooks may nest art inside art (symbols in symbols, envelopes in envelopes) before +/// the rest is left as it is: a file can make a symbol contain itself. +const MAX_HOOK_DEPTH: u32 = 16; + +/// How an envelope pushes art through its point map. +#[derive(Clone, Copy)] +pub(super) struct Warper<'a> { + pub(super) f: &'a dyn Fn(Point) -> Point, + /// Longest piece a segment is cut into before mapping. + pub(super) piece: f64, + /// Finite-difference step for the local affine maps gradients follow. + pub(super) eps: f64, + /// Rows and columns of the pieces an image is cut into. + pub(super) cells: usize, + pub(super) hooks: Hooks<'a>, + pub(super) options: EnvelopeOptions, + /// How many hooks the art being mapped came through. + pub(super) depth: u32, +} + +impl Warper<'_> { + /// This warper one hook deeper (`None` past [`MAX_HOOK_DEPTH`]). + fn deeper(&self) -> Option { + (self.depth < MAX_HOOK_DEPTH).then_some(Self { depth: self.depth + 1, ..*self }) + } +} + +/// `to` (art standing in for `from`: its outlines, its art) with `from`'s name, transparency, +/// opacity mask and effects. +fn carry_look(from: &Node, mut to: Node) -> Node { + to.name = from.name.clone(); + to.opacity = from.opacity; + to.blend = from.blend; + to.isolate = from.isolate; + to.knockout = from.knockout; + to.knockout_shape = from.knockout_shape; + to.extras_mut().mask = from.extras().mask.clone(); + to.appearance.effects = from.appearance.effects.clone(); + to +} + +pub(super) fn map_node(n: &Node, w: &Warper) -> Node { + let deeper = w.deeper(); + // Distort Appearance: strokes and geometry effects bend with the art. + if w.options.distort_appearance + && let Some(d) = &deeper + && let Some(o) = w.hooks.appearance.and_then(|h| h(n)) + { + return map_node(&o, d); + } + // Distort Pattern Fills: the tiles bend with the art (not the patterns inside them). + if w.options.patterns() + && let Some(d) = &deeper + && let Some(o) = w.hooks.pattern.and_then(|p| pattern_art(n, p)) + { + let inner = Warper { options: EnvelopeOptions { distort_pattern_fills: false, ..d.options }, ..*d }; + return map_node(&o, &inner); + } + let mut out = n.clone(); + // Distort Linear Gradients: the gradient vectors follow the warp where they sit. + if w.options.gradients() + && let Some(b) = n.geometric_bounds() + { + out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); + } + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = Arc::new(map_node(&m.art, w)); + } + match &mut out.kind { + NodeKind::Path { path, live, .. } => { + *path = map_nonlinear(path, w.piece, w.f); + *live = None; + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + for c in children.iter_mut() { + *c = Arc::new(map_node(c, w)); + } + } + NodeKind::Mesh(m) => m.map(w.f), + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + if let Some(d) = &deeper { + return map_node(&expanded_group_hooks(n, w.hooks), d); + } + } + NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => { + if let Some(d) = &deeper + && let Some(o) = w.hooks.outline.and_then(|h| h(n)) + { + return map_node(&carry_look(n, o), d); + } + } + NodeKind::Image(im) => { + let im = im.clone(); + return warp_image(&out, &im, w); + } + // Its art is plain, so it warps at this depth. + NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), + } + out +} + +/// The affine map taking triangle `s` to triangle `d` (`None` when `s` is degenerate). +fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { + let basis = |p: [Point; 3]| Affine::new([p[1].x - p[0].x, p[1].y - p[0].y, p[2].x - p[0].x, p[2].y - p[0].y, p[0].x, p[0].y]); + let bs = basis(s); + if bs.determinant().abs() < 1e-12 { + return None; + } + let a = basis(d) * bs.inverse(); + a.is_finite().then_some(a) +} + +/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into +/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map +/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its +/// edges (at most half a point) so neighbours overlap and no seams show. +fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { + let k = w.cells.max(1); + // Two triangles a cell, three objects each; an image an inner envelope already cut into + // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image + // is left out. + if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![], clip: false }; + return g; + } + let (pw, ph) = (im.width as f64, im.height as f64); + let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); + let mut pieces = Vec::with_capacity(2 * k * k); + for j in 0..k { + for i in 0..k { + let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; + let d = s.map(|p| (w.f)(im.xf * p)); + for t in [[0, 1, 2], [0, 2, 3]] { + let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); + let Some(xf) = affine_between(st, dt) else { continue }; + let c = Point::new((dt[0].x + dt[1].x + dt[2].x) / 3.0, (dt[0].y + dt[1].y + dt[2].y) / 3.0); + let grow = (dt[0].distance(dt[1]).max(dt[1].distance(dt[2])) * 0.02).min(0.5); + let tri: Vec = dt + .iter() + .map(|p| { + let v = *p - c; + let len = v.hypot(); + if len > 1e-12 { *p + v * (grow / len) } else { *p } + }) + .collect(); + let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); + pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); + } + } + } + let mut g = n.clone(); + g.kind = NodeKind::Group { children: pieces, clip: false }; + g +} + +/// Distort Pattern Fills: path or compound path `n` with each visible pattern fill turned into the +/// tiles it paints in a clip group shaped like `n` (with the fill's opacity and blend mode), its +/// other fills and strokes on copies of it, in paint order, under `n`'s transparency. `None` +/// without a pattern fill whose definition `pattern` finds. +fn pattern_art(n: &Node, tiles: PatternTiles) -> Option { + let (path, rule, _) = crate::blend::node_path(n)?; + let pattern_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, Paint::Pattern { .. })); + if !n.appearance.items.iter().any(pattern_fill) { + return None; + } + let bounds = path.bounds()?; + let def = |i: &AppearanceItem| match i { + AppearanceItem::Fill(f) if f.visible => match &f.paint { + Paint::Pattern { pattern: name, xf } => tiles(name, *xf, bounds).map(|t| (t, f.opacity, f.blend)), + _ => None, + }, + _ => None, + }; + let plain = |items: Vec| { + let mut p = Node { + appearance: Appearance { items, ..Default::default() }.into(), + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + knockout_shape: false, + name: None, + ..n.clone() + }; + p.extras_mut().mask = None; + Arc::new(p) + }; + let mut pieces = vec![]; + let mut rest = vec![]; + for i in &n.appearance.items { + let Some((tiles, opacity, blend)) = def(i) else { + rest.push(i.clone()); + continue; + }; + if !rest.is_empty() { + pieces.push(plain(std::mem::take(&mut rest))); + } + let mut clip = Node::path(n.id, path.clone(), Appearance::default()); + if let NodeKind::Path { clipping, rule: r, .. } = &mut clip.kind { + (*clipping, *r) = (true, rule); + } + let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect(); + let mut g = Node::new(n.id, NodeKind::Group { children, clip: true }); + g.opacity = opacity; + g.blend = blend; + pieces.push(Arc::new(g)); + } + if !rest.is_empty() { + pieces.push(plain(rest)); + } + Some(Node { + kind: NodeKind::Group { children: pieces, clip: false }, + appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }.into(), + ..n.clone() + }) +} + +// ===================================================================================== +// Expansion +// ===================================================================================== + +/// Is this one of the live kinds? +pub fn is_live(n: &Node) -> bool { + matches!( + n.kind, + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } + ) +} + +/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. +pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec { + m.quads(n) + .into_iter() + .map(|q| { + let mut ap = Appearance::default(); + let mut fl = FillLayer::new(Paint::solid(q.color)); + fl.opacity = q.opacity; + ap.items.push(AppearanceItem::Fill(fl)); + Node::path(NodeId(0), PathData::single(SubPath::polyline(&q.pts, true)), ap) + }) + .collect() +} + +/// One level of evaluation of a live object (non-live nodes return themselves). Blend steps and +/// envelope results may still contain live objects (e.g. a blend of two meshes). +pub fn expand_live(n: &Node) -> Vec { + expand_live_with(n, None) +} + +pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { + expand_live_hooks(n, outline.into()) +} + +/// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]). +pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { + let _generating = Generating::enter(); + match &n.kind { + NodeKind::Blend { children, spec } => blend_expand(children, spec), + NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), + NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), + NodeKind::Repeat(r) => r.expand(hooks.symbols), + NodeKind::PlacedDocument(p) => p.art(n), + NodeKind::Intertwine { children } => crate::intertwine::expand(children), + _ => vec![n.clone()], + } +} + +/// A live object evaluated into a plain group (keeping the object's id, name and transparency). +pub fn expanded_group(n: &Node, outline: Outliner) -> Node { + expanded_group_hooks(n, outline.into()) +} + +/// [`expanded_group`] with [`Hooks`]. +pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { + let _generating = Generating::enter(); + let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect(); + let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); + g.name = n.name.clone(); + g.visible = n.visible; + g.locked = n.locked; + g.opacity = n.opacity; + g.blend = n.blend; + g.isolate = n.isolate; + g.knockout = n.knockout; + // Knocking out opaque blend steps changes nothing: don't make the outputs work for it. + if matches!(n.kind, NodeKind::Blend { .. }) && g.knockout == crate::Knockout::On && !crate::blend::knockout_shows(&g) { + g.knockout = crate::Knockout::Off; + } + g.knockout_shape = n.knockout_shape; + g.extras_mut().mask = n.extras().mask.clone(); + g +} + +/// Recursively replace every live object in `n` by plain geometry (for exporters). +pub fn expand_deep(n: &Node, outline: Outliner) -> Node { + expand_deep_hooks(n, outline.into()) +} + +/// [`expand_deep`] with [`Hooks`]. The objects live art makes here count against one +/// [`MAX_GENERATED`] ([`Generating`]): blends of blends kept their inner blends live, and each +/// copy expanded on its own, so step counts capped one by one multiplied. Past it, live art +/// expands to nothing. +pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node { + let _generating = Generating::enter(); + let mut out = if is_live(n) { + let g = expanded_group_hooks(n, hooks); + if !Generating::charge(g.count()) { + return Node::new(n.id, NodeKind::Group { children: vec![], clip: false }); + } + g + } else { + n.clone() + }; + if let Some(ch) = out.children_mut() { + for c in ch.iter_mut() { + if subtree_has_live(c) { + *c = Arc::new(expand_deep_hooks(c, hooks)); + } + } + } + out +} + +/// Most objects one evaluation of live art (an expansion, an envelope's mapping and everything +/// inside them) may make. +pub const MAX_GENERATED: usize = 4_000_000; + +std::thread_local! { + /// What is left of [`MAX_GENERATED`] for the evaluation running on this thread; `None` outside + /// one. + static GENERATED_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// The outermost live evaluation on this thread: it starts [`GENERATED_LEFT`] and ends it. +struct Generating(bool); + +impl Generating { + fn enter() -> Self { + let outermost = GENERATED_LEFT.get().is_none(); + if outermost { + GENERATED_LEFT.set(Some(MAX_GENERATED)); + } + Self(outermost) + } + + /// Spend `n` objects; `false` (and nothing left) when there aren't that many. + fn charge(n: usize) -> bool { + match GENERATED_LEFT.get() { + None => true, + Some(left) => { + GENERATED_LEFT.set(Some(left.saturating_sub(n))); + n <= left + } + } + } +} + +impl Drop for Generating { + fn drop(&mut self) { + if self.0 { + GENERATED_LEFT.set(None); + } + } +} + +/// Does `n` or any descendant need live evaluation? +pub fn subtree_has_live(n: &Node) -> bool { + let mut any = false; + n.walk(&mut |c| any |= is_live(c)); + any +} + +/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the +/// outsets in the art of the symbols its instances show count too +/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made +/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad +/// does. +pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxes>) -> f64 { + let mut o = 0.0f64; + for n in nodes { + n.walk(&mut |c| { + o = o.max(c.appearance.outset()); + if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind) + && let Some(art) = s.art(symbol) + { + let reach = if s.art_strokes() { art.reach } else { 0.0 }; + o = o.max(reach + s.brush_pad(symbol)); + } + }); + } + o +} diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs new file mode 100644 index 000000000..2cbe7602c --- /dev/null +++ b/crates/doc/src/live/mesh.rs @@ -0,0 +1,761 @@ +use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at}; +use super::warp::{WarpStyle, flatten_subpath, resample, warp_point}; +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MeshPoint { + pub p: Point, + pub color: Color, + #[serde(default = "one")] + pub opacity: f32, + /// Tangent handles as offsets from `p` (see [`H_RIGHT`] …). + #[serde(default)] + pub handles: [Vec2; 4], +} + +/// A gradient mesh: `(rows+1)×(cols+1)` points, row-major. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientMesh { + pub rows: u32, + pub cols: u32, + pub points: Vec, +} + +/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and +/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads +/// meet without a step. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshQuad { + pub pts: [Point; 4], + pub color: Color, + pub opacity: f32, + pub corners: [(Color, f32); 4], +} + +/// Create Gradient Mesh → Appearance. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MeshAppearance { + #[default] + Flat, + ToCenter, + ToEdge, +} + +impl MeshAppearance { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "flat" => Some(Self::Flat), + "center" | "tocenter" | "to center" => Some(Self::ToCenter), + "edge" | "toedge" | "to edge" => Some(Self::ToEdge), + _ => None, + } + } +} + +/// Default envelope fidelity. +pub fn default_fidelity() -> f64 { + fifty() +} + +const SIDE: usize = 65; + +/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to +/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding +/// box corners. +#[derive(Clone, Debug)] +pub struct Coons { + top: Vec, + right: Vec, + bottom: Vec, + left: Vec, +} + +impl Coons { + /// A rectangle. + pub fn rect(r: Rect) -> Self { + let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::>(); + let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); + Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) } + } + + pub fn from_path(path: &PathData) -> Option { + // The largest subpath is the outline. + let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; + let r = path.bounds()?; + let tol = (r.width() + r.height()).max(1e-6) / 2000.0; + let mut poly = flatten_subpath(sp, tol); + if poly.len() > 1 + && let (Some(a), Some(b)) = (poly.first(), poly.last()) + && a.distance(*b) < 1e-9 + { + poly.pop(); + } + if poly.len() < 3 { + return Some(Self::rect(r)); + } + // Orient clockwise in y-down (TL → TR → BR → BL). + let area: f64 = (0..poly.len()) + .map(|i| { + let (p, q) = (poly[i], poly[(i + 1) % poly.len()]); + p.x * q.y - q.x * p.y + }) + .sum(); + if area < 0.0 { + poly.reverse(); + } + let nearest = |c: Point| (0..poly.len()).min_by(|i, j| poly[*i].distance_squared(c).total_cmp(&poly[*j].distance_squared(c))).unwrap_or(0); + let tl = nearest(Point::new(r.x0, r.y0)); + let tr = nearest(Point::new(r.x1, r.y0)); + let br = nearest(Point::new(r.x1, r.y1)); + let bl = nearest(Point::new(r.x0, r.y1)); + let n = poly.len(); + let arc = |i: usize, j: usize| -> Vec { + let mut v = vec![poly[i]]; + let mut k = i; + while k != j { + k = (k + 1) % n; + v.push(poly[k]); + } + v + }; + let top = resample(&arc(tl, tr), SIDE); + let right = resample(&arc(tr, br), SIDE); + let mut bottom = resample(&arc(br, bl), SIDE); + bottom.reverse(); + let mut left = resample(&arc(bl, tl), SIDE); + left.reverse(); + Some(Self { top, right, bottom, left }) + } + + /// The point at `u` along a side; past its ends the end segments go on straight (art an + /// envelope bends can reach past the content's box: outlined strokes). + fn sample(side: &[Point], u: f64) -> Point { + let s = u * (side.len() - 1) as f64; + let i = (s.floor().max(0.0) as usize).min(side.len() - 2); + side[i].lerp(side[i + 1], s - i as f64) + } + + /// The surface point at (u, v) ∈ [0,1]². + pub fn eval(&self, u: f64, v: f64) -> Point { + let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v)); + let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]); + let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2() + - ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2()); + c.to_point() + } + + /// The four corners TL, TR, BR, BL. + pub fn corners(&self) -> [Point; 4] { + [self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]] + } +} + +// ===================================================================================== +// Gradient meshes +// ===================================================================================== + +/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps. +fn even_steps(n: u32) -> Vec { + let n = n.clamp(1, 200); + (0..=n).map(|i| i as f64 / n as f64).collect() +} + +/// The number of points of a `(rows+1)×(cols+1)` grid, `None` past `usize`: counts come from +/// files, and u32::MAX rows and columns (65,535 on 32-bit targets) would wrap to an empty grid. +pub(super) fn grid_len(rows: u32, cols: u32) -> Option { + (rows as usize).checked_add(1)?.checked_mul((cols as usize).checked_add(1)?) +} + +impl GradientMesh { + pub fn idx(&self, r: usize, c: usize) -> usize { + r * (self.cols as usize + 1) + c + } + pub fn is_valid(&self) -> bool { + self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) + } + + /// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v + /// order: row 0 is v = 0, columns follow u), each with its handles ([`H_RIGHT`] …). + fn patch(corners: [(Point, [Vec2; 4], Color); 4]) -> Self { + let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles }; + let [a, b, c, d] = corners; + Self { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] } + } + + /// A 1×1 mesh from a Coons patch's twelve boundary points (in the order PDF and PostScript + /// shadings list them: v runs cp0 → cp3 along cp1 and cp2, u runs cp0 → cp9 along cp11 and + /// cp10) and its corner colours (at cp0, cp3, cp6, cp9); `None` if a point isn't finite. + pub fn coons(cp: &[Point; 12], colors: [Color; 4]) -> Option { + if !cp.iter().all(|p| p.is_finite()) { + return None; + } + let h = |a: usize, b: usize| cp[b] - cp[a]; + let handles = |right: Vec2, left: Vec2, down: Vec2, up: Vec2| { + let mut h = [Vec2::ZERO; 4]; + (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up); + h + }; + let [c0, c1, c2, c3] = colors; + Some(Self::patch([ + (cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0), + (cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1), + (cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2), + (cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3), + ])) + } + + /// A 1×1 mesh of a Gouraud-shaded triangle (a patch with two corners together); `None` if a + /// point isn't finite. + pub fn triangle(corners: [(Point, Color); 3]) -> Option { + if !corners.iter().all(|(p, _)| p.is_finite()) { + return None; + } + let [a, b, c] = corners.map(|(p, color)| (p, [Vec2::ZERO; 4], color)); + Some(Self::patch([a, b, c, c])) + } + + /// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives. + pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self { + let (us, vs) = (even_steps(cols), even_steps(rows)); + Self::from_grid(&us, &vs, s, &|r, c, _| (color(us[c], vs[r]), 1.0)) + } + + /// A mesh over surface `s` (u, v ∈ [0,1]) whose column lines sit at `us` and row lines at `vs` + /// (ascending, at least two each; equal neighbours make a patch of no width, a sharp colour + /// change), with handles from its partial derivatives. `color(row, col, point)` gives each + /// point its colour and opacity. + pub fn from_grid(us: &[f64], vs: &[f64], s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(usize, usize, Point) -> (Color, f32)) -> Self { + let (rows, cols) = (vs.len().saturating_sub(1), us.len().saturating_sub(1)); + let eps = 1e-3; + let mut points = Vec::with_capacity(us.len() * vs.len()); + for (r, &v) in vs.iter().enumerate() { + for (c, &u) in us.iter().enumerate() { + let p = s(u, v); + let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0)); + let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0)); + // A third of the way to the neighbouring line along the tangent; outward handles on + // the border are unused, so they stay at the point. + let h = |d: Vec2, from: f64, to: Option<&f64>| to.map_or(Vec2::ZERO, |to| d * ((to - from) / 3.0)); + let handles = [ + h(d_u, u, us.get(c + 1)), + h(d_u, u, c.checked_sub(1).map(|i| &us[i])), + h(d_v, v, vs.get(r + 1)), + h(d_v, v, r.checked_sub(1).map(|i| &vs[i])), + ]; + let (color, opacity) = color(r, c, p); + points.push(MeshPoint { p, color, opacity, handles }); + } + } + Self { rows: rows as u32, cols: cols as u32, points } + } + + /// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`. + pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option { + let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?; + m.highlight(appearance, highlight); + Some(m) + } + + /// Create Gradient Mesh for a path: the outline split at its corners, each point taking the + /// colour and opacity `color_at` gives at its position (a gradient fill's, say). + pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option { + let coons = Coons::from_path(path)?; + Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p))) + } + + /// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to + /// the edges, by at most `highlight` %. + pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) { + let h = (highlight / 100.0).clamp(0.0, 1.0) as f32; + let (rows, cols) = (self.rows as usize, self.cols as usize); + for r in 0..=rows { + for c in 0..=cols { + let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); + let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge + let w = match appearance { + MeshAppearance::Flat => 0.0, + MeshAppearance::ToCenter => (1.0 - d) * h, + MeshAppearance::ToEdge => d * h, + }; + if w > 0.0 { + let i = self.idx(r, c); + self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w); + } + } + } + } + + /// Horizontal edge from (r, c) to (r, c+1). + pub fn edge_u(&self, r: usize, c: usize) -> CubicBez { + let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)]); + CubicBez::new(a.p, a.p + a.handles[H_RIGHT], b.p + b.handles[H_LEFT], b.p) + } + /// Vertical edge from (r, c) to (r+1, c). + pub fn edge_v(&self, r: usize, c: usize) -> CubicBez { + let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r + 1, c)]); + CubicBez::new(a.p, a.p + a.handles[H_DOWN], b.p + b.handles[H_UP], b.p) + } + + /// Coons patch point of patch (r, c) at local (u, v). + pub fn eval(&self, r: usize, c: usize, u: f64, v: f64) -> Point { + let (t, b) = (self.edge_u(r, c).eval(u), self.edge_u(r + 1, c).eval(u)); + let (l, rr) = (self.edge_v(r, c).eval(v), self.edge_v(r, c + 1).eval(v)); + let p00 = self.points[self.idx(r, c)].p.to_vec2(); + let p10 = self.points[self.idx(r, c + 1)].p.to_vec2(); + let p01 = self.points[self.idx(r + 1, c)].p.to_vec2(); + let p11 = self.points[self.idx(r + 1, c + 1)].p.to_vec2(); + ((1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * rr.to_vec2() + - ((1.0 - u) * (1.0 - v) * p00 + u * (1.0 - v) * p10 + (1.0 - u) * v * p01 + u * v * p11)) + .to_point() + } + + /// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all + /// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out + /// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB. + pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) { + let q = + [&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]]; + let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v]; + let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum(); + let cmyk: Option> = + q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect(); + if let Some(inks) = cmyk { + let mut mix = [0.0f32; 4]; + for (ink, w) in inks.iter().zip(w) { + for (m, x) in mix.iter_mut().zip(ink) { + *m += x * w as f32; + } + } + let [c, m, y, k] = mix; + return (Color::Cmyk { c, m, y, k }, op); + } + let grey: Option> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect(); + if let Some(ks) = grey { + return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op); + } + let mut rgb = [0.0f32; 3]; + for (p, w) in q.iter().zip(w) { + for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) { + *ch += x * w as f32; + } + } + (Color::rgb(rgb[0], rgb[1], rgb[2]), op) + } + + /// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners. + pub fn quads(&self, n: usize) -> Vec { + if !self.is_valid() { + return vec![]; + } + let n = n.clamp(1, 64); + let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n); + let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; + let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)]; + for r in 0..self.rows as usize { + for c in 0..self.cols as usize { + for j in 0..=n { + for i in 0..=n { + let (u, v) = (i as f64 / n as f64, j as f64 / n as f64); + grid[j * (n + 1) + i] = self.eval(r, c, u, v); + colors[j * (n + 1) + i] = self.color_at(r, c, u, v); + } + } + for j in 0..n { + for i in 0..n { + let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); + let g = |a: usize, b: usize| grid[b * (n + 1) + a]; + let k = |a: usize, b: usize| colors[b * (n + 1) + a]; + out.push(MeshQuad { + pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], + color, + opacity, + corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)], + }); + } + } + } + } + out + } + + /// The mesh's outer boundary as a closed path. + pub fn outline(&self) -> PathData { + if !self.is_valid() { + return PathData::default(); + } + let (rows, cols) = (self.rows as usize, self.cols as usize); + let mut segs: Vec = vec![]; + for c in 0..cols { + segs.push(self.edge_u(0, c)); + } + for r in 0..rows { + segs.push(self.edge_v(r, cols)); + } + for c in (0..cols).rev() { + let e = self.edge_u(rows, c); + segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); + } + for r in (0..rows).rev() { + let e = self.edge_v(r, 0); + segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); + } + let mut anchors: Vec = Vec::with_capacity(segs.len()); + for (i, s) in segs.iter().enumerate() { + let prev = &segs[(i + segs.len() - 1) % segs.len()]; + anchors.push(Anchor::with_handles(s.p0, prev.p2, s.p1)); + } + PathData::single(SubPath::new(anchors, true)) + } + + /// Mesh lines (all row and column edges) as open paths, for outline view and editing overlays. + pub fn lines(&self) -> PathData { + if !self.is_valid() { + return PathData::default(); + } + let (rows, cols) = (self.rows as usize, self.cols as usize); + let mut subs = vec![]; + let cub = |e: CubicBez| SubPath::new(vec![Anchor::with_handles(e.p0, e.p0, e.p1), Anchor::with_handles(e.p3, e.p2, e.p3)], false); + for r in 0..=rows { + for c in 0..cols { + subs.push(cub(self.edge_u(r, c))); + } + } + for c in 0..=cols { + for r in 0..rows { + subs.push(cub(self.edge_v(r, c))); + } + } + PathData::new(subs) + } + + /// Control bounds (points and handles). + pub fn bounds(&self) -> Option { + let mut it = self.points.iter().flat_map(|p| std::iter::once(p.p).chain(p.handles.iter().map(move |h| p.p + *h))); + let first = it.next()?; + Some(it.fold(Rect::from_points(first, first), |r, q| r.union_pt(q))) + } + + pub fn transform(&mut self, a: Affine) { + let lin = Affine::new({ + let c = a.as_coeffs(); + [c[0], c[1], c[2], c[3], 0.0, 0.0] + }); + for p in &mut self.points { + p.p = a * p.p; + for h in &mut p.handles { + *h = (lin * h.to_point()).to_vec2(); + } + } + } + + /// Map points (and handle ends) through a non-linear function. + pub fn map(&mut self, f: &dyn Fn(Point) -> Point) { + for p in &mut self.points { + let q = f(p.p); + for h in &mut p.handles { + *h = f(p.p + *h) - q; + } + p.p = q; + } + } + + /// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh). + pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> { + if !self.is_valid() { + return None; + } + let bp = self.outline().to_bezpath(); + if bp.winding(p) == 0 { + return None; + } + const N: usize = 16; + let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); + for r in 0..self.rows as usize { + for c in 0..self.cols as usize { + for j in 0..=N { + for i in 0..=N { + let (u, v) = (i as f64 / N as f64, j as f64 / N as f64); + let d = self.eval(r, c, u, v).distance_squared(p); + if d < best.0 { + best = (d, r, c, u, v); + } + } + } + } + } + // Refine with a few Newton-free local searches. + let (_, r, c, mut u, mut v) = best; + let mut step = 0.5 / N as f64; + for _ in 0..12 { + let mut improved = false; + for (du, dv) in [(step, 0.0), (-step, 0.0), (0.0, step), (0.0, -step)] { + let (nu, nv) = ((u + du).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)); + if self.eval(r, c, nu, nv).distance_squared(p) < self.eval(r, c, u, v).distance_squared(p) { + u = nu; + v = nv; + improved = true; + } + } + if !improved { + step /= 2.0; + } + } + Some((r, c, u, v)) + } + + /// Index of the mesh point within `tol` of `p`. + pub fn point_near(&self, p: Point, tol: f64) -> Option { + self.points + .iter() + .enumerate() + .filter(|(_, q)| q.p.distance(p) <= tol) + .min_by(|a, b| a.1.p.distance(p).total_cmp(&b.1.p.distance(p))) + .map(|(i, _)| i) + } + + fn surface_du(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { + let e = 1e-3; + let (a, b) = ((u - e).max(0.0), (u + e).min(1.0)); + (self.eval(r, c, b, v) - self.eval(r, c, a, v)) / (b - a) + } + fn surface_dv(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { + let e = 1e-3; + let (a, b) = ((v - e).max(0.0), (v + e).min(1.0)); + (self.eval(r, c, u, b) - self.eval(r, c, u, a)) / (b - a) + } + + /// Insert a mesh row through patch row `r` at local `v`. + pub fn insert_row(&mut self, r: usize, v: f64) { + let cols = self.cols as usize; + let v = v.clamp(0.01, 0.99); + let mut row = Vec::with_capacity(cols + 1); + // Split the vertical edges. + let mut ups = vec![]; + for c in 0..=cols { + let e = self.edge_v(r, c); + let (l, rr) = (e.subsegment(0.0..v), e.subsegment(v..1.0)); + ups.push((l.p1 - l.p0, rr.p2 - rr.p3)); + let pc = c.min(cols - 1); + let u = if c == cols { 1.0 } else { 0.0 }; + let (color, opacity) = self.color_at(r, pc, u, v); + // Horizontal handles: isocurve derivatives on either side. + let right = if c < cols { self.surface_du(r, c, 0.0, v) / (3.0) } else { Vec2::ZERO }; + let left = if c > 0 { -self.surface_du(r, c - 1, 1.0, v) / 3.0 } else { Vec2::ZERO }; + row.push(MeshPoint { p: l.p3, color, opacity, handles: [right, left, rr.p1 - rr.p0, l.p2 - l.p3] }); + } + for (c, (down, up)) in ups.into_iter().enumerate() { + let i0 = self.idx(r, c); + let i1 = self.idx(r + 1, c); + self.points[i0].handles[H_DOWN] = down; + self.points[i1].handles[H_UP] = up; + } + let at = self.idx(r + 1, 0); + self.points.splice(at..at, row); + self.rows += 1; + } + + /// Insert a mesh column through patch column `c` at local `u`. + pub fn insert_col(&mut self, c: usize, u: f64) { + let rows = self.rows as usize; + let u = u.clamp(0.01, 0.99); + let mut col = Vec::with_capacity(rows + 1); + for r in 0..=rows { + let e = self.edge_u(r, c); + let (l, rr) = (e.subsegment(0.0..u), e.subsegment(u..1.0)); + let pr = r.min(rows - 1); + let v = if r == rows { 1.0 } else { 0.0 }; + let (color, opacity) = self.color_at(pr, c, u, v); + let down = if r < rows { self.surface_dv(r, c, u, 0.0) / 3.0 } else { Vec2::ZERO }; + let up = if r > 0 { -self.surface_dv(r - 1, c, u, 1.0) / 3.0 } else { Vec2::ZERO }; + col.push((MeshPoint { p: l.p3, color, opacity, handles: [rr.p1 - rr.p0, l.p2 - l.p3, down, up] }, l.p1 - l.p0, rr.p2 - rr.p3)); + } + let cols = self.cols as usize; + let mut pts = Vec::with_capacity((rows + 1) * (cols + 2)); + for (r, (mp, right, left)) in col.into_iter().enumerate() { + for cc in 0..=cols { + let mut q = self.points[r * (cols + 1) + cc].clone(); + if cc == c { + q.handles[H_RIGHT] = right; + } + if cc == c + 1 { + q.handles[H_LEFT] = left; + } + pts.push(q); + if cc == c { + pts.push(mp.clone()); + } + } + } + self.points = pts; + self.cols += 1; + } + + /// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index. + pub fn add_lines_at(&mut self, p: Point) -> Option { + let (r, c, u, v) = self.locate(p)?; + let mut rr = r; + let mut cc = c; + let row_new = v > 0.02 && v < 0.98; + let col_new = u > 0.02 && u < 0.98; + if row_new { + self.insert_row(r, v); + rr = r + 1; + } else if v >= 0.98 { + rr = r + 1; + } + if col_new { + self.insert_col(c, u); + cc = c + 1; + } else if u >= 0.98 { + cc = c + 1; + } + Some(self.idx(rr, cc)) + } + + /// Mesh tool Alt-click: delete the mesh lines through point `index` (interior lines only). + pub fn remove_point_lines(&mut self, index: usize) -> bool { + // A mesh from a file may not have the points its rows and columns need: it is indexed below. + if !self.is_valid() || index >= self.points.len() { + return false; + } + let w = self.cols as usize + 1; + let (r, c) = (index / w, index % w); + let mut changed = false; + if r > 0 && r < self.rows as usize { + // Merge the edges above and below: keep the outer handles. + for cc in 0..w { + let above = self.idx(r - 1, cc); + let below = self.idx(r + 1, cc); + let mid = self.idx(r, cc); + let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p); + let _ = pm; + self.points[above].handles[H_DOWN] = (pb - pa) / 3.0; + self.points[below].handles[H_UP] = (pa - pb) / 3.0; + } + self.points.drain(r * w..(r + 1) * w); + self.rows -= 1; + changed = true; + } + let w = self.cols as usize + 1; + if c > 0 && c < self.cols as usize { + let rows = self.rows as usize; + for rr in 0..=rows { + let (l, rt) = (rr * w + c - 1, rr * w + c + 1); + let (pl, pr) = (self.points[l].p, self.points[rt].p); + self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0; + self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0; + } + let mut k = 0; + self.points.retain(|_| { + let keep = k % w != c; + k += 1; + keep + }); + self.cols -= 1; + changed = true; + } + changed + } +} + +// ===================================================================================== +// Envelopes +// ===================================================================================== + +/// Union of the geometric bounds of `nodes`. +pub fn nodes_bounds(nodes: &[Arc]) -> Option { + nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())) +} + +/// A `(rows+1)×(cols+1)` grid over `r` (row-major). +pub fn grid_points(r: Rect, rows: u32, cols: u32) -> Vec { + let mut v = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize); + for i in 0..=rows { + for j in 0..=cols { + v.push(Point::new(r.x0 + r.width() * j as f64 / cols.max(1) as f64, r.y0 + r.height() * i as f64 / rows.max(1) as f64)); + } + } + v +} + +fn catmull(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: f64) -> Vec2 { + let t2 = t * t; + let t3 = t2 * t; + 0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3) +} + +/// Point map from the unit square onto a point grid (tensor-product Catmull-Rom; linear +/// extrapolation at the borders so an undistorted grid is the identity, also past the square). +pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point { + let at = |r: i64, c: i64| -> Vec2 { + let rr = r.clamp(0, rows as i64); + let cc = c.clamp(0, cols as i64); + let base = pts[rr as usize * (cols + 1) + cc as usize].to_vec2(); + // Linear extrapolation outside the grid. + let er = r - rr; + let ec = c - cc; + let mut p = base; + if er != 0 && rows >= 1 { + let inner = (rr - er.signum()).clamp(0, rows as i64); + p += (base - pts[inner as usize * (cols + 1) + cc as usize].to_vec2()) * er.abs() as f64; + } + if ec != 0 && cols >= 1 { + let inner = (cc - ec.signum()).clamp(0, cols as i64); + p += (base - pts[rr as usize * (cols + 1) + inner as usize].to_vec2()) * ec.abs() as f64; + } + p + }; + // Past the grid's edges the outer patches go on (art can reach past the content's box). + let (su, sv) = (u * cols as f64, v * rows as f64); + let (ci, ri) = ((su.floor() as i64).clamp(0, cols as i64 - 1), (sv.floor() as i64).clamp(0, rows as i64 - 1)); + let (fu, fv) = (su - ci as f64, sv - ri as f64); + let mut rowsv = [Vec2::ZERO; 4]; + for (k, rv) in rowsv.iter_mut().enumerate() { + let r = ri - 1 + k as i64; + *rv = catmull(at(r, ci - 1), at(r, ci), at(r, ci + 1), at(r, ci + 2), fu); + } + catmull(rowsv[0], rowsv[1], rowsv[2], rowsv[3], fv).to_point() +} + +/// The surface (u, v ∈ [0,1]) an envelope of `kind` maps content onto, for content whose bounds in +/// the envelope's own `frame` (its axes → the document) are `src`: a warp bends along the frame's +/// axes; a mesh's points and a top object's path are in the document already. +pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> { + let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); + match kind { + EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { + let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); + let b = bend.clamp(-100.0, 100.0) / 100.0; + let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); + let c = src.center(); + let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); + let horizontal = *horizontal; + Box::new(move |u: f64, v: f64| { + let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0); + let (x2, y2) = if horizontal { + warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y) + } else { + let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x); + (b2, a) + }; + frame * Point::new(c.x + x2 * hw, c.y + y2 * hh) + }) + } + EnvelopeKind::Mesh { rows, cols, points, handles } => { + if !handles.is_empty() + && let Some(m) = envelope_grid(*rows, *cols, points, handles) + { + return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v)); + } + if *rows == 0 || *cols == 0 || grid_len(*rows, *cols) != Some(points.len()) { + return Box::new(flat); + } + let (rows, cols) = (*rows as usize, *cols as usize); + Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v)) + } + EnvelopeKind::TopObject { path } => match Coons::from_path(path) { + Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)), + None => Box::new(flat), + }, + } +} diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs new file mode 100644 index 000000000..cacf26ec5 --- /dev/null +++ b/crates/doc/src/live/warp.rs @@ -0,0 +1,253 @@ +use super::*; + +// ===================================================================================== +// Warp maps (shared with vectorcraft-effects' Warp effects) +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WarpStyle { + Arc, + ArcLower, + ArcUpper, + Arch, + Bulge, + ShellLower, + ShellUpper, + Flag, + Wave, + Fish, + Rise, + Fisheye, + Inflate, + Squeeze, + Twist, +} + +impl WarpStyle { + /// From the id suffix (`arc`, `arcLower`, …). + pub fn from_id(s: &str) -> Option { + use WarpStyle::*; + Some(match s { + "arc" => Arc, + "arcLower" => ArcLower, + "arcUpper" => ArcUpper, + "arch" => Arch, + "bulge" => Bulge, + "shellLower" => ShellLower, + "shellUpper" => ShellUpper, + "flag" => Flag, + "wave" => Wave, + "fish" => Fish, + "rise" => Rise, + "fisheye" => Fisheye, + "inflate" => Inflate, + "squeeze" => Squeeze, + "twist" => Twist, + _ => return None, + }) + } +} + +/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1); +/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real +/// proportions). +pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) { + use WarpStyle::*; + let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom + let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides + let (mut x2, mut y2) = match style { + Arc => { + if b.abs() < 1e-6 { + (x, y) + } else { + // Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in + // the box's real proportions (lengths in half-widths, so y counts `aspect` each): + // the edges are concentric arcs, and one that would pass the centre stops there + // instead of folding over (#1578). + let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let sweep = b.abs() * PI; + let r0 = 2.0 / sweep; + let yy = if b > 0.0 { y } else { -y } * k; + let a = x / r0; + let r = (r0 - yy).max(0.0); + let (s, c) = a.sin_cos(); + let (nx, ny) = (r * s, (r0 - r * c) / k); + (nx, if b > 0.0 { ny } else { -ny }) + } + } + ArcLower => (x, y + b * par * t), + ArcUpper => (x, y - b * par * (1.0 - t)), + Arch => (x, y - b * par), + Bulge => (x, y + b * par * y), + ShellLower => (x * (1.0 - 0.5 * b * (1.0 - t)), y + b * par * t), + ShellUpper => (x * (1.0 - 0.5 * b * t), y - b * par * (1.0 - t)), + Flag => (x, y + 0.5 * b * (PI * x).sin()), + Wave => (x + 0.1 * b * (PI * y).sin(), y + 0.3 * b * (2.0 * PI * x).sin() * (0.5 + 0.5 * t)), + Fish => (x, y * (1.0 + 0.5 * b * (0.75 * PI * (x + 1.0)).sin())), + Rise => (x, y - b * ((x + 1.0) / 2.0).powi(2) * 2.0 + b), + Fisheye => { + let r2 = x * x + y * y; + let s = if r2 < 1.0 { 1.0 + 0.5 * b * (1.0 - r2) } else { 1.0 }; + (x * s, y * s) + } + Inflate => (x * (1.0 + 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.5 * b * par)), + Squeeze => (x * (1.0 - 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.3 * b * par)), + Twist => { + let r = (x * x + y * y).sqrt(); + let a = -b * PI * 0.5 * (1.0 - r / std::f64::consts::SQRT_2).max(0.0); + let (s, c) = a.sin_cos(); + (x * c - y * s, x * s + y * c) + } + }; + // Perspective-like distortion: horizontal narrows one side, vertical one end. + if dh != 0.0 { + y2 *= (1.0 + dh * x2 * 0.5).max(0.0); + } + if dv != 0.0 { + x2 *= (1.0 + dv * y2 * 0.5).max(0.0); + } + (x2, y2) +} + +/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. +fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { + let c = sp.segment(i); + if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c } +} + +fn poly_len(c: &CubicBez) -> f64 { + c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) +} + +/// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point). +fn cumulative_lengths(pts: &[Point]) -> Vec { + let mut total = 0.0; + let mut cum = Vec::with_capacity(pts.len()); + cum.push(0.0); + for w in pts.windows(2) { + total += w[0].distance(w[1]); + cum.push(total); + } + cum +} + +/// Most pieces [`map_nonlinear`] splits one segment into. +const MAX_PIECES: usize = 64; + +/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`. +pub fn pieces(len: f64, piece: f64, most: usize) -> usize { + ((len / piece).ceil() as usize).clamp(1, most.max(1)) +} + +/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so +/// that the pieces past each segment's first number at most `room`: `piece` when they already do, +/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path, +/// so pieces of the total length over `room` fit; with no room every segment stays one piece. +pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 { + let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add); + if added <= room { + return piece; + } + if room == 0 { + return f64::INFINITY; + } + // A NaN length is always one piece. + piece.max(lens.iter().filter(|len| !len.is_nan()).sum::() / room as f64) +} + +/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces +/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which +/// approximates the image curve to O(h²). Closedness and subpath structure are preserved. +pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData { + map_nonlinear_within(path, max_piece, usize::MAX, f) +} + +/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more: +/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]), +/// a coarser approximation of the image curve. Geometry effects map within their anchor budget. +pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData { + let mut max_piece = max_piece.max(1e-3); + // Each segment adds one anchor per piece past its first. + let room = max.saturating_sub(path.anchor_count()); + let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add); + if segs.saturating_mul(MAX_PIECES - 1) > room { + let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect(); + max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room); + } + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let n = sp.anchors.len(); + if n == 0 { + continue; + } + let segs = sp.segment_count(); + let mut res: Vec = Vec::new(); + let mut pending_in: Option = None; + for i in 0..n { + let a = &sp.anchors[i]; + let h_in = pending_in.take().unwrap_or_else(|| f(a.h_in)); + res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind }); + if i < segs { + let c = seg_cubic(sp, i); + let k = pieces(poly_len(&c), max_piece, MAX_PIECES); + for j in 0..k { + let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64)); + if let Some(last) = res.last_mut() { + last.h_out = f(sub.p1); + } + if j + 1 < k { + res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() }); + } else { + pending_in = Some(f(sub.p2)); + } + } + } + } + if let Some(h) = pending_in + && sp.closed + && let Some(first) = res.first_mut() + { + first.h_in = h; + } + let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect(); + subs.push(SubPath::new(anchors, sp.closed)); + } + PathData::new(subs) +} + +// ===================================================================================== +// Coons patches from outlines +// ===================================================================================== + +pub(super) fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { + let mut bp = BezPath::new(); + sp.to_bezpath_into(&mut bp); + let mut pts: Vec = Vec::new(); + vectorcraft_geom::kurbo::flatten(bp.iter(), tol, |el| match el { + vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if pts.last().is_none_or(|q| q.distance(p) > 1e-9) => pts.push(p), + _ => {} + }); + pts +} + +pub(super) fn resample(poly: &[Point], m: usize) -> Vec { + if poly.is_empty() { + return vec![Point::ZERO; m]; + } + if poly.len() == 1 { + return vec![poly[0]; m]; + } + let cum = cumulative_lengths(poly); + let total = cum.last().copied().unwrap_or(0.0); + let mut out = Vec::with_capacity(m); + let mut i = 0; + for k in 0..m { + let s = if m > 1 { total * k as f64 / (m - 1) as f64 } else { 0.0 }; + while i + 2 < cum.len() && cum[i + 1] < s { + i += 1; + } + let seg = (cum[i + 1] - cum[i]).max(1e-12); + out.push(poly[i].lerp(poly[i + 1], ((s - cum[i]) / seg).clamp(0.0, 1.0))); + } + out +} From 5b70501e225916800c195fa93685f40005792875 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:48:52 +0000 Subject: [PATCH 116/199] refactor(doc): move node.rs items to node/{attributes,clip,extras,ids,kind,live_shape,opacity,ops,wire}.rs --- crates/doc/src/node.rs | 2123 +---------------------------- crates/doc/src/node/attributes.rs | 133 ++ crates/doc/src/node/clip.rs | 120 ++ crates/doc/src/node/extras.rs | 171 +++ crates/doc/src/node/ids.rs | 59 + crates/doc/src/node/kind.rs | 326 +++++ crates/doc/src/node/live_shape.rs | 346 +++++ crates/doc/src/node/opacity.rs | 129 ++ crates/doc/src/node/ops.rs | 712 ++++++++++ crates/doc/src/node/wire.rs | 181 +++ 10 files changed, 2194 insertions(+), 2106 deletions(-) create mode 100644 crates/doc/src/node/attributes.rs create mode 100644 crates/doc/src/node/clip.rs create mode 100644 crates/doc/src/node/extras.rs create mode 100644 crates/doc/src/node/ids.rs create mode 100644 crates/doc/src/node/kind.rs create mode 100644 crates/doc/src/node/live_shape.rs create mode 100644 crates/doc/src/node/opacity.rs create mode 100644 crates/doc/src/node/ops.rs create mode 100644 crates/doc/src/node/wire.rs diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 8f0e1b1d5..2be25e0b1 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -14,530 +14,24 @@ use crate::pattern::RepeatSpec; use crate::symbols::{SYMBOL_SQUARE, SymbolBoxes}; use crate::text::TextObject; -/// Stable per-document object id. Never reused. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct NodeId(pub u64); +mod attributes; +mod clip; +mod extras; +mod ids; +mod kind; +mod live_shape; +mod opacity; +mod ops; +mod wire; -impl std::fmt::Display for NodeId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "#{}", self.0) - } -} - -/// Layer colours used for selection highlighting (index into [`LAYER_COLORS`]) or a custom RGB. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum LayerColor { - Preset(u8), - Custom([u8; 3]), -} - -/// Layer highlight colours, assigned in sequence to new layers. First is Illustrator-like light blue. -pub const LAYER_COLORS: [(&str, [u8; 3]); 27] = [ - ("Light Blue", [0x4f, 0x80, 0xff]), - ("Red", [0xff, 0x4f, 0x4f]), - ("Green", [0x4f, 0xff, 0x4f]), - ("Blue", [0x4f, 0x4f, 0xff]), - ("Yellow", [0xff, 0xff, 0x4f]), - ("Magenta", [0xff, 0x4f, 0xff]), - ("Cyan", [0x4f, 0xff, 0xff]), - ("Gray", [0x80, 0x80, 0x80]), - ("Black", [0x00, 0x00, 0x00]), - ("Orange", [0xff, 0x66, 0x00]), - ("Dark Green", [0x00, 0x80, 0x00]), - ("Teal", [0x00, 0x80, 0x80]), - ("Tan", [0xcc, 0x99, 0x66]), - ("Brown", [0x99, 0x33, 0x00]), - ("Violet", [0x99, 0x33, 0xff]), - ("Gold", [0xff, 0x99, 0x00]), - ("Dark Blue", [0x00, 0x00, 0x80]), - ("Pink", [0xff, 0x99, 0xcc]), - ("Lavender", [0x99, 0x99, 0xff]), - ("Brick Red", [0x99, 0x00, 0x00]), - ("Olive Green", [0x66, 0x66, 0x00]), - ("Peach", [0xff, 0x99, 0x99]), - ("Burgundy", [0x99, 0x00, 0x33]), - ("Grass Green", [0x99, 0xcc, 0x00]), - ("Ochre", [0x99, 0x66, 0x00]), - ("Purple", [0x66, 0x00, 0x99]), - ("Light Gray", [0xbb, 0xbb, 0xbb]), -]; - -impl LayerColor { - pub fn rgb(self) -> [u8; 3] { - match self { - LayerColor::Preset(i) => LAYER_COLORS[i as usize % LAYER_COLORS.len()].1, - LayerColor::Custom(c) => c, - } - } -} - -/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these -/// while the shape stays live; editing anchors directly converts it to a plain path. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "shape", rename_all = "lowercase")] -pub enum LiveShape { - Rectangle { - /// Untransformed width/height. Transforms fold their scale into these (see - /// [`LiveShape::transform`]), so they are document lengths unless `xf` shears. - w: f64, - h: f64, - /// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as - /// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale. - radii: [f64; 4], - /// Corner kinds, in the same order (Live Corners' corner types). - #[serde(default, skip_serializing_if = "all_round")] - kinds: [CornerKind; 4], - /// When true, `radii` are fractions in 0..1 of each corner's largest possible radius. - #[serde(default, skip_serializing_if = "is_false")] - radii_relative: bool, - /// Maps the untransformed shape (origin at its top-left) into the document. A rotation - /// or reflection plus a move, unless a shear (or a file saved before transforms folded - /// their scale into `w`/`h`) put more in it. - xf: Affine, - }, - Ellipse { - w: f64, - h: f64, - /// Pie start/end angles in degrees (0/360 = full ellipse). - pie: (f64, f64), - xf: Affine, - }, - Polygon { - radius: f64, - /// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read. - #[serde(deserialize_with = "de_sides")] - sides: u32, - xf: Affine, - /// Corner radii in document units, by vertex from the first one clockwise (none: sharp). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - radii: Vec, - /// Corner kinds, in the same order (none: round). - #[serde(default, skip_serializing_if = "all_round")] - kinds: Vec, - #[serde(default, skip_serializing_if = "is_false")] - radii_relative: bool, - }, - Line { - a: Point, - b: Point, - }, - /// Any other path whose corners Live Corners cut (a star, a pen path): it keeps its uncut - /// outline so the corners stay editable. Without a cut corner it is a plain path again. - Path { - /// The path with its corners uncut, in the document. - base: PathData, - /// Corner radii in document units, by anchor index of `base` (counting every subpath's - /// anchors in order; missing: sharp). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - radii: Vec, - /// Corner kinds, in the same order (missing: round). - #[serde(default, skip_serializing_if = "all_round")] - kinds: Vec, - #[serde(default, skip_serializing_if = "is_false")] - radii_relative: bool, - }, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -impl LiveShape { - pub fn radii_relative(&self) -> bool { - match self { - LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => { - *radii_relative - } - LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false, - } - } - pub fn label(&self) -> &'static str { - match self { - LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle", - LiveShape::Rectangle { .. } => "Rectangle", - LiveShape::Ellipse { .. } => "Ellipse", - LiveShape::Polygon { .. } => "Polygon", - LiveShape::Line { .. } => "Line", - LiveShape::Path { .. } => "Path", - } - } - /// Regenerate the path. - pub fn to_path(&self) -> PathData { - match self { - LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => { - let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)); - let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative); - let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0)); - shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf) - } - LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf), - LiveShape::Polygon { radii, kinds, radii_relative, .. } => { - let outline = self.polygon_outline(); - let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative); - cut_corners(&outline, &radii, kinds).0 - } - LiveShape::Line { a, b } => shapes::line(*a, *b), - LiveShape::Path { base, radii, kinds, radii_relative } => { - let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative); - cut_corners(base, &radii, kinds).0 - } - } - } - /// A polygon's outline with its corners uncut, in the document (empty for other shapes). - pub fn polygon_outline(&self) -> PathData { - match self { - LiveShape::Polygon { radius, sides, xf, .. } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf), - _ => PathData::default(), - } - } - /// Give a polygon `n` sides (3–1000). Its corners keep the radius and kind they shared; else - /// the corners past the old last one are sharp. - pub fn set_sides(&mut self, n: u64) { - let LiveShape::Polygon { sides, radii, kinds, .. } = self else { return }; - *sides = n.clamp(3, 1000) as u32; - fn spread(v: &mut Vec, n: usize) { - match v.first().copied() { - Some(x) if v.iter().all(|y| *y == x) => *v = vec![x; n], - Some(_) => v.resize(n, T::default()), - None => {} - } - } - spread(radii, *sides as usize); - spread(kinds, *sides as usize); - } - /// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of - /// `xf` (exact while its sides are equal). None for other shapes. - pub fn polygon_radius(&self) -> Option { - let LiveShape::Polygon { radius, xf, .. } = self else { return None }; - Some(radius * xf.determinant().abs().sqrt()) - } - /// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by - /// from straight up (the Rotate field's sense). None for other shapes. - pub fn polygon_angle(&self) -> Option { - let LiveShape::Polygon { .. } = self else { return None }; - // No −0° or 360° from rounding: a polygon drawn upright reads 0°. - let a = (-self.angle_deg()).rem_euclid(360.0); - Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 }) - } - /// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True - /// for other shapes. - pub fn polygon_sides_equal(&self) -> bool { - let LiveShape::Polygon { xf, .. } = self else { return true }; - let [a, b, c, d, _, _] = xf.as_coeffs(); - let (sx, sy) = (a.hypot(b), c.hypot(d)); - (sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy - } - /// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]). - pub fn set_polygon_radius(&mut self, r: f64) { - let LiveShape::Polygon { radius, xf, .. } = self else { return }; - let k = xf.determinant().abs().sqrt(); - if k > 1e-12 && r.is_finite() { - *radius = r.max(0.0) / k; - } - } - /// The document radius that makes a polygon's sides `side` long (with its sides equal). - pub fn polygon_radius_for_side(&self, side: f64) -> Option { - let LiveShape::Polygon { sides, .. } = self else { return None }; - Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin())) - } - /// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]). - pub fn set_polygon_angle(&mut self, angle: f64) { - let Some(now) = self.polygon_angle() else { return }; - if let LiveShape::Polygon { xf, .. } = self - && angle.is_finite() - { - let centre = *xf * Point::ZERO; - *xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf; - } - } - /// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from - /// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`]. - pub fn make_sides_equal(&mut self) { - let Some(r) = self.polygon_radius() else { return }; - let LiveShape::Polygon { radius, xf, .. } = self else { return }; - let [a, b, _, _, e, f] = xf.as_coeffs(); - let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY }; - *xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip; - *radius = r; - } - /// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so - /// the caller must regenerate the path with [`LiveShape::to_path`]. - /// - /// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own - /// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps - /// only the rotation, reflection and position. The radii scale by the mean scale (the square - /// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off. - /// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a - /// scale in `xf` keep their exact geometry until they're next transformed). A polygon's or a - /// path's corner radii are document lengths: they scale by the mean scale and the corners are - /// cut again, circular whatever `a` does. - pub fn transform(&mut self, a: Affine) -> bool { - match self { - LiveShape::Rectangle { xf, .. } => { - *xf = a * *xf; - !moves_only(a) && self.fold_scale() - } - LiveShape::Ellipse { xf, .. } => { - *xf = a * *xf; - false - } - LiveShape::Polygon { xf, radii, radii_relative, .. } => { - *xf = a * *xf; - scale_radii(radii, a, *radii_relative) - } - LiveShape::Path { base, radii, radii_relative, .. } => { - base.transform(a); - scale_radii(radii, a, *radii_relative) - } - LiveShape::Line { a: p, b } => { - *p = a * *p; - *b = a * *b; - false - } - } - } - /// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so - /// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the - /// document. False (and nothing changes) when there is no scale to move, when `xf` shears or - /// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an - /// uneven scale in `xf`: their corners are elliptical until this runs. - pub fn fold_scale(&mut self) -> bool { - let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false }; - let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs(); - let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); - let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy; - let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite(); - let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12; - if !orthogonal || !finite || unscaled { - return false; - } - let k = (sx * sy).sqrt(); - if !*radii_relative { - radii.iter_mut().for_each(|r| *r *= k); - } - *w *= sx; - *h *= sy; - *xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]); - true - } - /// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document - /// units (what Live Corners show and edit). - pub fn folded(&self) -> Self { - let mut s = self.clone(); - s.fold_scale(); - s - } - /// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners - /// keep their size (Scale Corners off). False when nothing changed. - pub fn keep_corners(&mut self, k: f64) -> bool { - let relative = self.radii_relative(); - let radii: &mut [f64] = match self { - LiveShape::Rectangle { radii, .. } => radii, - LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii, - LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false, - }; - if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 { - return false; - } - radii.iter_mut().for_each(|r| *r /= k); - true - } - /// Rotation angle of the live shape in degrees, clockwise on the page (y points down). - pub fn angle_deg(&self) -> f64 { - match self { - LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => { - let c = xf.as_coeffs(); - c[1].atan2(c[0]).to_degrees() - } - LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(), - LiveShape::Path { .. } => 0.0, - } - } -} - -fn all_round(kinds: &[CornerKind]) -> bool { - kinds.iter().all(|k| *k == CornerKind::Round) -} - -/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its -/// corner lists hold one entry per side, so a huge count would allocate gigabytes. -fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - u32::deserialize(d).map(|n| n.clamp(3, 1000)) -} - -/// Whether `a` only moves (no rotation, scale, shear or reflection). -fn moves_only(a: Affine) -> bool { - let [a0, a1, a2, a3, _, _] = a.as_coeffs(); - (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12 -} - -/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True -/// when the path must be cut again: some corner is cut and `a` does more than move it (an -/// uneven scale would otherwise make the cuts elliptical). -fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool { - if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) { - return false; - } - let k = a.determinant().abs().sqrt(); - radii.iter_mut().for_each(|r| *r *= k); - true -} - -/// What a transform scales besides geometry, by its mean scale (the square root of its -/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke -/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects -/// and Scale Corners. -#[derive(Clone, Copy, Debug, Default)] -pub struct Scaling { - /// Stroke weights, dash lengths and dash offsets scale. - pub strokes: bool, - /// With `strokes`: scales an effect's distance parameters (the effects catalogue's). - pub effects: Option, - /// Type keeps its character strokes' weight (they otherwise scale with the type). - pub keep_type_strokes: bool, - /// Live corner radii keep their size. - pub keep_corners: bool, - /// Pattern fills and strokes move with the art (Transform Patterns; General › Transform - /// Pattern Tiles is its default): their tiles transform too, instead of staying put. - pub patterns: bool, -} - -impl Scaling { - /// The mean scale of `a` when it scales (isn't 1): what strokes, effects and corners scale by. - pub fn factor(a: Affine) -> Option { - let k = a.determinant().abs().sqrt(); - ((k - 1.0).abs() > 1e-9).then_some(k) - } -} - -impl From for Scaling { - fn from(strokes: bool) -> Self { - Self { strokes, ..Self::default() } - } -} - -/// Embedded or linked raster image. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ImageObject { - /// Key into [`crate::Document::images`]. - pub key: String, - pub width: u32, - pub height: u32, - /// Maps pixel space (0..w, 0..h) into the document. - pub xf: Affine, - /// The file a linked image shows (File → Place with Link); `None`: embedded. Files saved - /// before links had details hold just the path. - #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")] - pub link: Option, - /// Links panel → Placement Options: how a file read again takes this image's place. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub placement: crate::links::PlacementOptions, - /// The pixels of an image in art made outside a document (type's outlines, a glyph's art), - /// which has no [`crate::Document::images`] to keep them in. A document takes them in - /// ([`crate::Document::adopt_pixels`]): no image in one holds its own. - #[serde(skip)] - pub pixels: Option>, -} - -/// Written with its `type` first, then its fields (an internally tagged enum); read by -/// [`kind_de`], which takes the fields straight from the file when `type` comes first. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum NodeKind { - /// A top-level layer or sublayer. - Layer { - color: LayerColor, - #[serde(default)] - template: bool, - #[serde(default = "yes")] - printable: bool, - children: Vec>, - /// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - clip: bool, - /// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen - /// (Ctrl-click its eye in the Layers panel). - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] - preview: bool, - /// Layer Options → Dim Images to: on screen, images on the layer show faded to this - /// percentage (0–100); `None`: not dimmed. - #[serde(default, skip_serializing_if = "Option::is_none")] - dim_images: Option, - }, - /// A group. With `clip`, the first (bottom-most) child is the clipping path. - Group { - children: Vec>, - #[serde(default)] - clip: bool, - }, - Path { - path: PathData, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - rule: FillRule, - /// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant. - #[serde(default, skip_serializing_if = "Option::is_none")] - live: Option>, - /// This path acts as a clipping path inside a clip group. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - clipping: bool, - /// Guide (non-printing) path. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - guide: bool, - }, - /// Compound path: children are paths painted as one with the compound's appearance. - Compound { - children: Vec>, - #[serde(default)] - rule: FillRule, - }, - Text(Box), - /// Boxed, as the other large kinds: a node is as large as its largest kind. - Image(Box), - SymbolInstance { - symbol: String, - xf: Affine, - }, - /// Live blend: the key objects (paint order) plus spacing/orientation/spine; the intermediate - /// steps are evaluated on demand (`live::blend_expand`). - Blend { - children: Vec>, - #[serde(default)] - spec: Box, - }, - /// Live envelope distortion of `content`. - Envelope { - content: Vec>, - kind: Box, - /// Envelope Options → Fidelity (0–100). - #[serde(default = "crate::live::default_fidelity")] - fidelity: f64, - /// Edit Contents mode (the content, not the envelope, is edited). - #[serde(default)] - editing: bool, - /// Envelope Options besides Fidelity. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - options: crate::live::EnvelopeOptions, - /// The envelope's own axes (→ the document): the transforms it took since it was made that - /// the page axes can't stand for (rotation, shear, reflection). The content maps from its - /// bounds in this frame, and a warp bends along it. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - frame: Box, - }, - /// Gradient mesh. - Mesh(GradientMesh), - /// Live Repeat (radial / grid / mirror) of source art. - Repeat(RepeatSpec), - /// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` - /// file, linked and locked (see [`crate::placed_document`]). - PlacedDocument(Box), - /// Object › Intertwine: the objects keep their own stacking order, except where one of them - /// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see - /// [`crate::intertwine`]). - Intertwine { - children: Vec>, - }, -} +pub use attributes::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS}; +pub use clip::{ClipPaint, Uniter}; +pub use extras::{Extras, ExtrasMut, NodeExtras}; +pub use ids::{LAYER_COLORS, LayerColor, NodeId}; +pub use kind::{ImageObject, NodeKind}; +pub use live_shape::{LiveShape, Scaling}; +pub use opacity::{Knockout, OpacityMask, whiten}; +use wire::NodeWire; fn yes() -> bool { true @@ -578,1588 +72,5 @@ pub struct Node { pub extras: Extras, } -/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without -/// them stay small ([`Node::extras`]). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct NodeExtras { - /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. - pub mask: Option>, - /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows - /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). - pub trace: Option>, - /// Object → Text Wrap: area type below this object (in the same layer) flows around it. - pub wrap: Option, - /// Graph object: the group's children are generated from this spec (Object → Graph). - pub graph: Option>, - /// Index of the graph series this generated group belongs to. Axes, the legend and other - /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. - pub series_index: Option, - /// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale - /// the placed gradients in it with the outlines. - pub type_outlines: bool, - /// Editable Shaper composition; the original art is retained in its source child. - pub shaper: Option>, - /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps - /// that style's look: editing its appearance or transparency breaks the link. - pub graphic_style: Option, - /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). - pub attrs: Option>, - /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with - /// these options. - pub slice: Option>, - /// Object › Perspective: the perspective grid plane the object is attached to. - pub perspective: Option>, - /// Dimension object: the live measurement it draws; its ordinary group children hold its - /// exportable appearance (Object › Dimensions, the Dimension tool). - pub dimension: Option>, - /// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates, - /// filled non-zero) where this object is drawn over the group's other members. - pub intertwine: Option>, - /// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults). - pub live_paint_gaps: Option>, - /// Object › Objects on Path: the group's path and the objects attached to it (see - /// [`crate::objects_on_path`]). - pub objects_on_path: Option>, -} - -/// No extras: what [`Node::extras`] gives for a node without any. -static NO_EXTRAS: NodeExtras = NodeExtras { - mask: None, - trace: None, - wrap: None, - graph: None, - series_index: None, - type_outlines: false, - shaper: None, - graphic_style: None, - attrs: None, - slice: None, - perspective: None, - dimension: None, - intertwine: None, - live_paint_gaps: None, - objects_on_path: None, -}; - -/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal -/// none. -#[derive(Clone, Debug, Default)] -pub struct Extras(Option>); - -impl Extras { - fn get(&self) -> &NodeExtras { - self.0.as_deref().unwrap_or(&NO_EXTRAS) - } -} - -impl PartialEq for Extras { - fn eq(&self, other: &Self) -> bool { - match (&self.0, &other.0) { - (Some(a), Some(b)) => a == b, - _ => self.get() == other.get(), - } - } -} - -impl Node { - /// The rarely set parts of this node (all unset when it has none). - #[inline] - pub fn extras(&self) -> &NodeExtras { - self.extras.get() - } - /// The rarely set parts of this node, to change: when the change is done (the guard dropped), - /// extras that were set are allocated and extras left all unset are let go. Setting one to - /// `None` on a node without extras allocates nothing. - #[inline] - pub fn extras_mut(&mut self) -> ExtrasMut<'_> { - ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() } - } - /// The rarely set parts of this node, if it has any: to change what is there in place. - #[inline] - pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> { - self.extras.0.as_deref_mut() - } - /// The live shape of a path object, if it is one. - pub fn live_shape(&self) -> Option<&LiveShape> { - match &self.kind { - NodeKind::Path { live, .. } => live.as_deref(), - _ => None, - } - } - /// The live shape of a path object, if it is one, to change. - pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> { - match &mut self.kind { - NodeKind::Path { live, .. } => live.as_deref_mut(), - _ => None, - } - } - /// This node with opacity mask `mask` (or none). - pub fn with_mask(mut self, mask: Option>) -> Self { - self.extras_mut().mask = mask; - self - } - /// The extras, if this node has them allocated (for [`crate::mem`]'s estimate). - pub(crate) fn extras_box(&self) -> Option<&NodeExtras> { - self.extras.0.as_deref() - } -} - -/// [`Node::extras_mut`]: the node's extras while they change. -pub struct ExtrasMut<'a> { - slot: &'a mut Option>, - /// Stands for the extras of a node without any, boxed into it when dropped if one was set. - local: NodeExtras, -} - -impl std::ops::Deref for ExtrasMut<'_> { - type Target = NodeExtras; - fn deref(&self) -> &NodeExtras { - self.slot.as_deref().unwrap_or(&self.local) - } -} - -impl std::ops::DerefMut for ExtrasMut<'_> { - fn deref_mut(&mut self) -> &mut NodeExtras { - match self.slot.as_deref_mut() { - Some(e) => e, - None => &mut self.local, - } - } -} - -impl Drop for ExtrasMut<'_> { - fn drop(&mut self) { - match self.slot.as_deref() { - Some(e) if *e == NO_EXTRAS => *self.slot = None, - Some(_) => {} - None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))), - None => {} - } - } -} - -/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from -/// the node and its extras (nothing is copied). The node reads back through `NodeWire`. -impl Serialize for Node { - fn serialize(&self, s: S) -> Result { - use crate::skip::{is_default, is_one, is_true}; - use serde::ser::SerializeStruct; - let e = self.extras(); - // As the derive did: the fields written, then each field written or skipped in order. - let skips = [ - false, - self.name.is_none(), - is_true(&self.visible), - is_default(&self.locked), - is_one(&self.opacity), - is_default(&self.blend), - is_default(&self.isolate), - is_default(&self.knockout), - is_default(&self.knockout_shape), - is_default(&self.appearance), - e.mask.is_none(), - e.trace.is_none(), - e.wrap.is_none(), - e.graph.is_none(), - e.series_index.is_none(), - is_default(&e.type_outlines), - e.shaper.is_none(), - false, - e.graphic_style.is_none(), - e.attrs.is_none(), - e.slice.is_none(), - is_default(&self.bbox_angle), - e.perspective.is_none(), - e.dimension.is_none(), - e.intertwine.is_none(), - e.live_paint_gaps.is_none(), - e.objects_on_path.is_none(), - ]; - let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; - let mut skip = skips.into_iter(); - macro_rules! field { - ($name:literal, $v:expr) => { - if skip.next().unwrap_or(false) { - st.skip_field($name)?; - } else { - st.serialize_field($name, $v)?; - } - }; - } - field!("id", &self.id); - field!("name", &self.name); - field!("visible", &self.visible); - field!("locked", &self.locked); - field!("opacity", &self.opacity); - field!("blend", &self.blend); - field!("isolate", &self.isolate); - field!("knockout", &self.knockout); - field!("knockout_shape", &self.knockout_shape); - field!("appearance", &self.appearance); - field!("mask", &e.mask); - field!("trace", &e.trace); - field!("wrap", &e.wrap); - field!("graph", &e.graph); - field!("series_index", &e.series_index); - field!("type_outlines", &e.type_outlines); - field!("shaper", &e.shaper); - field!("kind", &self.kind); - field!("graphic_style", &e.graphic_style); - field!("attrs", &e.attrs); - field!("slice", &e.slice); - field!("bbox_angle", &self.bbox_angle); - field!("perspective", &e.perspective); - field!("dimension", &e.dimension); - field!("intertwine", &e.intertwine); - field!("live_paint_gaps", &e.live_paint_gaps); - field!("objects_on_path", &e.objects_on_path); - st.end() - } -} - -/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node -/// (and its extras, allocated only when one is set) without a copy of what they own. -#[derive(Deserialize)] -#[serde(rename = "Node")] -struct NodeWire { - id: NodeId, - #[serde(default)] - name: Option, - #[serde(default = "yes")] - visible: bool, - #[serde(default)] - locked: bool, - #[serde(default = "one")] - opacity: f32, - #[serde(default)] - blend: BlendMode, - #[serde(default)] - isolate: bool, - #[serde(default)] - knockout: Knockout, - #[serde(default)] - knockout_shape: bool, - #[serde(default)] - appearance: crate::Shared, - #[serde(default)] - mask: Option>, - #[serde(default)] - trace: Option>, - #[serde(default)] - wrap: Option, - #[serde(default)] - graph: Option>, - #[serde(default)] - series_index: Option, - #[serde(default)] - type_outlines: bool, - #[serde(default)] - shaper: Option>, - kind: NodeKind, - #[serde(default)] - graphic_style: Option, - #[serde(default)] - attrs: Option>, - #[serde(default)] - slice: Option>, - #[serde(default)] - bbox_angle: f64, - #[serde(default)] - perspective: Option>, - #[serde(default)] - dimension: Option>, - #[serde(default)] - intertwine: Option>, - #[serde(default)] - live_paint_gaps: Option>, - #[serde(default)] - objects_on_path: Option>, -} - -impl From for Node { - fn from(w: NodeWire) -> Self { - let extras = NodeExtras { - mask: w.mask, - trace: w.trace, - wrap: w.wrap, - graph: w.graph, - series_index: w.series_index, - type_outlines: w.type_outlines, - shaper: w.shaper, - graphic_style: w.graphic_style, - attrs: w.attrs, - slice: w.slice, - perspective: w.perspective, - dimension: w.dimension, - intertwine: w.intertwine, - live_paint_gaps: w.live_paint_gaps, - objects_on_path: w.objects_on_path, - }; - Node { - id: w.id, - name: w.name, - visible: w.visible, - locked: w.locked, - opacity: w.opacity, - blend: w.blend, - isolate: w.isolate, - knockout: w.knockout, - knockout_shape: w.knockout_shape, - appearance: w.appearance, - kind: w.kind, - bbox_angle: w.bbox_angle, - extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))), - } - } -} - -/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct OpacityMask { - /// Mask art in document coordinates. - pub art: Arc, - /// Clip: outside the mask art is hidden. Off: outside the mask art stays visible. - #[serde(default = "yes")] - pub clip: bool, - /// Invert the mask's luminance. - #[serde(default)] - pub invert: bool, - /// Disabled masks are kept but not applied. - #[serde(default)] - pub disabled: bool, - /// Linked masks move with the object. - #[serde(default = "yes")] - pub linked: bool, -} - -impl OpacityMask { - pub fn new(art: Node, clip: bool) -> Self { - Self { art: Arc::new(art), clip, invert: false, disabled: false, linked: true } - } -} - -/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity -/// mask's, whose luminance over the black outside the art is then the art's alpha. -pub fn whiten(n: &mut Node) { - let white = |p: &mut Paint| match p { - Paint::Solid { color, swatch, tint } => { - (*color, *swatch, *tint) = (Color::WHITE, None, 1.0); - } - Paint::Gradient(g) => { - for s in &mut g.gradient.stops { - (s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0); - } - } - Paint::None | Paint::Pattern { .. } => {} - }; - for item in &mut n.appearance.items { - white(item.paint_mut()); - } - match &mut n.kind { - NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE), - NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| { - white(&mut r.style.fill); - white(&mut r.style.stroke); - }), - _ => {} - } - if let Some(ch) = n.children_mut() { - for c in ch { - whiten(Arc::make_mut(c)); - } - } -} - -impl Node { - pub fn new(id: NodeId, kind: NodeKind) -> Self { - Self { - id, - name: None, - visible: true, - locked: false, - opacity: 1.0, - blend: BlendMode::Normal, - isolate: false, - knockout: Knockout::Neutral, - knockout_shape: false, - appearance: Appearance::default().into(), - kind, - bbox_angle: 0.0, - extras: Extras::default(), - } - } - pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { - let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }); - n.appearance = appearance.into(); - n - } - pub fn group(id: NodeId, children: Vec>) -> Self { - Self::new(id, NodeKind::Group { children, clip: false }) - } - pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self { - let mut n = Self::new( - id, - NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None }, - ); - n.name = Some(name.to_string()); - n - } - pub fn children(&self) -> Option<&Vec>> { - match &self.kind { - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Blend { children, .. } - | NodeKind::Envelope { content: children, .. } - | NodeKind::Repeat(RepeatSpec { source: children, .. }) - | NodeKind::Intertwine { children } => Some(children), - _ => None, - } - } - pub fn children_mut(&mut self) -> Option<&mut Vec>> { - match &mut self.kind { - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Blend { children, .. } - | NodeKind::Envelope { content: children, .. } - | NodeKind::Repeat(RepeatSpec { source: children, .. }) - | NodeKind::Intertwine { children } => Some(children), - _ => None, - } - } - /// Does this object or layer move with its artboard? Locked and hidden ones only with - /// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with - /// Artboard). - pub fn rides_with_artboard(&self, locked_and_hidden: bool) -> bool { - locked_and_hidden || (!self.locked && self.visible) - } - pub fn is_layer(&self) -> bool { - matches!(self.kind, NodeKind::Layer { .. }) - } - /// A template layer (Layer Options → Template). - pub fn is_template(&self) -> bool { - matches!(self.kind, NodeKind::Layer { template: true, .. }) - } - /// The art objects of this container: its children, looking through sublayers, which are not - /// objects (bottom first). With `editable`, only the visible, unlocked objects in visible, - /// unlocked, non-template sublayers. - pub fn layer_art(&self, editable: bool) -> Vec { - fn visit(n: &Node, editable: bool, out: &mut Vec) { - for c in n.children().into_iter().flatten() { - if editable && (!c.visible || c.locked || c.is_template()) { - continue; - } - if c.is_layer() { - visit(c, editable, out); - } else { - out.push(c.id); - } - } - } - let mut out = vec![]; - visit(self, editable, &mut out); - out - } - pub fn is_container(&self) -> bool { - self.children().is_some() - } - /// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel - /// fills an object's target circle otherwise). - pub fn has_default_transparency(&self) -> bool { - self.opacity >= 1.0 - && self.blend == BlendMode::Normal - && !self.isolate - && self.knockout == Knockout::Neutral - && !self.knockout_shape - && self.extras().mask.is_none() - } - /// Kind name as the Layers panel / Properties panel shows it. - pub fn kind_label(&self) -> &'static str { - match &self.kind { - NodeKind::Layer { .. } => "Layer", - NodeKind::Group { clip: true, .. } => "Clip Group", - NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path", - NodeKind::Group { .. } => "Group", - NodeKind::Path { live: Some(l), .. } => l.label(), - NodeKind::Path { guide: true, .. } => "Guide", - NodeKind::Path { .. } => "Path", - NodeKind::Compound { .. } => "Compound Path", - NodeKind::Text(_) => "Type", - NodeKind::Image(_) => "Image", - NodeKind::SymbolInstance { .. } => "Symbol", - NodeKind::Blend { .. } => "Blend", - NodeKind::Envelope { .. } => "Envelope", - NodeKind::Mesh(_) => "Mesh", - NodeKind::Repeat(r) => r.kind.label(), - NodeKind::PlacedDocument(_) => "Placed Document", - NodeKind::Intertwine { .. } => "Intertwine", - } - } - /// Name shown in the Layers panel: explicit name or ``. - pub fn display_name(&self) -> String { - if let Some(n) = &self.name { - return n.clone(); - } - match &self.kind { - NodeKind::Text(t) => { - let s: String = t.plain_text().chars().take(32).collect(); - if s.is_empty() { "".into() } else { s } - } - _ => format!("<{}>", self.kind_label()), - } - } - - /// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID. - pub fn identify_label(&self, xml_id: bool) -> String { - let name = self.display_name(); - if xml_id { crate::xml_id::from_object_name(&name) } else { name } - } - /// Path data for path nodes. - pub fn path_data(&self) -> Option<&PathData> { - match &self.kind { - NodeKind::Path { path, .. } => Some(path), - _ => None, - } - } - pub fn path_data_mut(&mut self) -> Option<&mut PathData> { - match &mut self.kind { - NodeKind::Path { path, .. } => Some(path), - _ => None, - } - } - /// The path a path's or compound path's own strokes follow (a compound's members' together). - pub fn stroke_path(&self) -> Option { - match &self.kind { - NodeKind::Path { path, .. } => Some(path.to_bezpath()), - NodeKind::Compound { children, .. } => { - let mut bp = vectorcraft_geom::BezPath::new(); - children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath())); - Some(bp) - } - _ => None, - } - } - /// A quick box round everything the object can paint, for culling clicks: paths and compound - /// paths grow their geometric bounds by the farthest their strokes can reach from any path - /// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in - /// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers - /// without a clipping mask and Intertwine groups take in their members' boxes, grown by their - /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. - pub fn reach_bounds(&self) -> Option { - self.reach(None) - } - /// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from - /// `symbols`. An instance that only moves its art is measured as the art's visual box moved, - /// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`]) - /// grown by the farthest a stroke in the art can reach. - pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { - symbols.quickly(|| self.reach(Some(symbols))) - } - /// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given. - pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option { - match &self.kind { - NodeKind::Path { .. } | NodeKind::Compound { .. } => { - let o = self.appearance.outset(); - self.geometric(symbols).map(|b| b.inflate(o, o)) - } - // The members' reach, grown by the container's own strokes. - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - let o = self.appearance.outset(); - children - .iter() - .skip(usize::from(self.extras().shaper.is_some())) - .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))) - .map(|b| b.inflate(o, o)) - } - NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))), - // The frame and each shape the image's own fills and strokes paint, a stroke's grown by - // the farthest it can reach from its shape. - NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| { - let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; - vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o))) - }), - _ => self.visual(symbols), - } - } - /// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A - /// symbol instance is measured as its transform applied to the 20 × 20 square around the origin - /// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures - /// its art). - pub fn geometric_bounds(&self) -> Option { - self.geometric(None) - } - /// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to - /// its symbol's art box from `symbols`. An instance is measured as the square when its symbol - /// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and - /// when symbols nest 32 deep in its art. - pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option { - self.geometric(Some(symbols)) - } - /// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a - /// hidden bitmap for re-tracing/release; only source-image views measure that bitmap. - pub fn editing_children(&self) -> &[Arc] { - let children = self.children().map(Vec::as_slice).unwrap_or_default(); - let trace_source = self.extras().trace.is_some() - && !self.trace_view().shows_source() - && children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_))); - let skip = usize::from(self.extras().shaper.is_some() || trace_source); - children.get(skip..).unwrap_or_default() - } - - /// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols` - /// is given. - pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option { - let union = |nodes: &[Arc]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))); - match &self.kind { - NodeKind::Path { path, .. } => path.bounds(), - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - children.first().and_then(|c| c.geometric(symbols)) - } - NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self - .editing_children() - .iter() - .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) - .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))), - NodeKind::Text(t) => self.projected(t.bounds()), - NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))), - NodeKind::SymbolInstance { symbol, xf } => { - let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric); - self.projected(Some(xf.transform_rect_bbox(art))) - } - NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())), - NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame), - NodeKind::Mesh(m) => m.bounds(), - // Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the - // copies out by the source's bounds measured without the document. - NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())), - NodeKind::PlacedDocument(p) => Some(p.bounds()), - // The members drawn over the others are drawn where they are. - NodeKind::Intertwine { children } => union(children), - } - } - /// Visual bounds: what the object paints, its strokes included (paths and compound paths - /// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is - /// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`], - /// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art). - pub fn visual_bounds(&self) -> Option { - self.visual(None) - } - /// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from - /// `symbols`, the strokes in it included. An instance that only moves its art is measured as - /// the art's visual box moved. Any other instance is measured as its art transformed by it with - /// the strokes at their own weight, as the renderer draws it, and never smaller than its - /// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as - /// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of - /// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In - /// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's - /// geometric box placed by its transform, grown by its own strokes. - pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option { - self.visual(Some(symbols)) - } - /// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is - /// given. - pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option { - match &self.kind { - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - // A clipping path that paints (see `clip_paint`) strokes over the clip edge. - let clip = children.first()?; - if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { - clip.visual(symbols) - } else { - clip.geometric(symbols) - } - } - NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))), - NodeKind::Layer { .. } | NodeKind::Group { .. } => { - // The container's own strokes paint around its members. - let o = self.appearance.outset(); - self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o)) - } - NodeKind::Blend { children, spec } => { - let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))); - let o = crate::live::max_outset(children, symbols); - vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o))) - } - NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => { - let o = crate::live::max_outset(content, symbols); - self.geometric(symbols).map(|b| b.inflate(o, o)) - } - NodeKind::Path { .. } | NodeKind::Compound { .. } => { - let b = self.geometric(symbols)?; - Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default())) - } - NodeKind::SymbolInstance { symbol, xf } => { - let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) { - Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric), - Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual), - Some((s, art)) => { - let placed = xf.transform_rect_bbox(art.geometric); - match s.placed_visual(symbol, *xf) { - Some(v) => v.union(placed), - None => placed.inflate(art.reach, art.reach), - } - } - None => xf.transform_rect_bbox(SYMBOL_SQUARE), - }; - let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol)); - self.projected(Some(b)).map(|b| b.inflate(o, o)) - } - NodeKind::Text(t) => { - // The characters' strokes paint around the glyphs. - let o = self.appearance.outset().max(t.char_stroke_outset()); - self.geometric(symbols).map(|b| b.inflate(o, o)) - } - // An image is measured by its frame, without its own fills and strokes. - NodeKind::Image(_) => self.geometric(symbols), - _ => { - let o = self.appearance.outset(); - self.geometric(symbols).map(|b| b.inflate(o, o)) - } - } - } - /// Apply an affine transform to the geometry (and gradients) of this node and its descendants. - /// `scaling` says what else scales by the transform's mean scale (a bool: stroke weights - /// only, see [`Scaling`]). - pub fn transform(&mut self, a: Affine, scaling: impl Into) { - self.transform_scaled(a, &scaling.into()); - } - fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { - // A dimension's measurement moves with it. - if let Some(e) = self.extras_if_any() - && let Some(dimension) = &mut e.dimension - { - dimension.transform(a); - } - // The frames of the objects attached to a path move with them. - if let Some(e) = self.extras_if_any() - && let Some(on_path) = &mut e.objects_on_path - { - on_path.transform(a); - } - if !self.is_layer() { - self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); - } - // Type and symbols in perspective keep looking the same, moved by `a`. - self.transform_projection(a); - // Refitting an unplaced gradient only reproduces moves and uniform scales. - if !keeps_gradient_fit(a) { - self.pin_gradients(); - } - let k = Scaling::factor(a); - if let Some(k) = k - && sc.strokes - { - self.appearance.scale_strokes(k); - if let Some(f) = sc.effects { - self.appearance.scale_effects(k, f); - } - } - // Only through `DerefMut` when there is something to change: the appearance is shared - // copy-on-write (`Shared`), and a move must not copy what it leaves as it is. - if self.appearance.gradients().next().is_some() { - self.appearance.transform_gradients(a); - } - if sc.patterns { - crate::pattern::transform_pattern_paints(self, a); - } - match &mut self.kind { - NodeKind::Path { path, live, .. } => { - path.transform(a); - if let Some(l) = live { - // A rectangle scaled unevenly keeps circular corners: its path is regenerated. - let reshaped = l.transform(a); - let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k)); - if reshaped || kept { - *path = l.to_path(); - } - } - } - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Intertwine { children } => { - for c in children.iter_mut() { - Arc::make_mut(c).transform_scaled(a, sc); - } - } - NodeKind::Text(t) => { - t.transform(a); - // Character strokes scale with the type: undo that when strokes keep their weight. - if sc.keep_type_strokes - && let Some(k) = k.filter(|k| *k > 1e-12) - { - t.scale_char_strokes(1.0 / k); - } - } - NodeKind::Image(im) => im.xf = a * im.xf, - NodeKind::PlacedDocument(p) => p.xf = a * p.xf, - NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf, - NodeKind::Blend { children, spec } => { - for c in children.iter_mut() { - Arc::make_mut(c).transform_scaled(a, sc); - } - if let Some(s) = &mut spec.spine { - s.transform(a); - } - } - NodeKind::Envelope { content, kind, frame, .. } => { - for c in content.iter_mut() { - Arc::make_mut(c).transform_scaled(a, sc); - } - **frame = crate::live::envelope_frame(a * **frame); - match &mut **kind { - EnvelopeKind::Mesh { points, handles, .. } => { - for p in points.iter_mut() { - *p = a * *p; - } - // Handles are offsets: they take the linear part. - let [m0, m1, m2, m3, _, _] = a.as_coeffs(); - let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]); - for h in handles.iter_mut().flatten() { - *h = (lin * h.to_point()).to_vec2(); - } - } - EnvelopeKind::TopObject { path } => path.transform(a), - EnvelopeKind::Warp { .. } => {} - } - } - NodeKind::Mesh(m) => m.transform(a), - NodeKind::Repeat(r) => r.transform(a, *sc), - } - if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) - && m.linked - { - Arc::make_mut(&mut m.art).transform_scaled(a, sc); - } - // The area where an Intertwine member is drawn over the others moves with it. - if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) { - area.transform(a); - } - } - /// Visit this node and all descendants depth first (paint order). - pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { - f(self); - if let Some(ch) = self.children() { - for c in ch { - c.walk(f); - } - } - } - /// Count of nodes in this subtree. - pub fn count(&self) -> usize { - let mut n = 0; - self.walk(&mut |_| n += 1); - n - } - /// [`Self::count`], with the art of the opacity masks in the subtree (and theirs): what copying - /// and transforming the subtree makes, as [`Self::walk`] doesn't go into masks. - pub fn count_with_masks(&self) -> usize { - let mut n = 0; - self.walk(&mut |c| { - n += 1; - if let Some(m) = &c.extras().mask { - n += m.art.count_with_masks(); - } - }); - n - } - /// Fix this node's unplaced gradients to their fit on its current bounds (before a transform - /// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed. - pub fn pin_gradients(&mut self) { - if self.appearance.has_unplaced_gradient() - && let Some(b) = self.geometric_bounds() - { - self.appearance.pin_gradients(b); - } - } - /// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and - /// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active - /// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise - /// type objects paint their runs (the first run speaks for all) in text space, fitted to the - /// layout bounds; everything else paints its fill or stroke in the document, fitted to the - /// geometric bounds. Strokes fit the box grown by half their weight. - pub fn proxy_paint(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> { - let item = self.appearance.item_of_kind(item, !stroke); - if item.is_none() - && let NodeKind::Text(t) = &self.kind - { - let st = &t.runs.first()?.style; - let b = t.local_bounds(); - return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) }); - } - // An object without bounds (an empty path) still shows its paints, fitted to an empty box. - let b = self.geometric_bounds().unwrap_or(Rect::ZERO); - if stroke { - self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b))) - } else { - self.appearance.fill_at(item).map(|f| (&f.paint, Affine::IDENTITY, b)) - } - } - /// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates - /// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits. - pub fn proxy_gradient(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> { - let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; - let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; - let mut geom = g.resolve(b); - if to_doc != Affine::IDENTITY { - geom.transform(to_doc, g.gradient.kind); - } - Some((g, geom)) - } - /// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates - /// (the automatic ones while unplaced), and the length its spreads are fractions of there - /// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits. - pub fn proxy_freeform(&self, stroke: bool, item: Option) -> Option<(vectorcraft_color::Freeform, f64)> { - let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; - let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; - if g.gradient.kind != vectorcraft_color::GradientKind::Freeform { - return None; - } - let mut f = g.freeform_on(b).into_owned(); - let mut scale = vectorcraft_color::freeform::spread_scale(b); - if to_doc != Affine::IDENTITY { - f.transform(to_doc); - scale *= to_doc.determinant().abs().sqrt(); - } - Some((f, scale)) - } -} - -/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans -/// live above this crate (`vectorcraft-pathops`), so callers supply it. -pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath; - -impl Node { - /// The filled regions this object clips to as the clipping path of a clip group, in document - /// space, each with its fill rule: a path, a compound path (with its holes), an image's frame, - /// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible - /// members of a group (their union) and the clipping path of a nested clip group. Live objects - /// are evaluated first. Guides and symbol instances add nothing. - pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> { - let mut out = vec![]; - self.push_clip_shapes(text, &mut out); - out - } - - fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { - match &self.kind { - NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} - NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), - NodeKind::Compound { children, rule } => { - let mut bp = BezPath::new(); - for p in children.iter().filter_map(|c| c.path_data()) { - bp.extend(p.to_bezpath()); - } - out.push((bp, *rule)); - } - NodeKind::Group { children, clip: true } => { - if let Some(c) = children.first() { - c.push_clip_shapes(text, out); - } - } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) { - c.push_clip_shapes(text, out); - } - } - NodeKind::Text(_) => { - if let Some(o) = text.and_then(|f| f(self)) { - o.push_clip_shapes(None, out); - } - } - NodeKind::Image(im) => { - let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); - out.push((frame.to_bezpath(), FillRule::NonZero)); - } - NodeKind::PlacedDocument(p) => { - let frame = shapes::rectangle(p.natural()).transformed(p.xf); - out.push((frame.to_bezpath(), FillRule::NonZero)); - } - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { - crate::live::expand_deep(self, text).push_clip_shapes(text, out); - } - } - } - - /// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared - /// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its - /// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to - /// clip by: the clipped art is then hidden. - pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> { - let mut shapes = self.clip_shapes(text); - match shapes.len() { - 0 => None, - 1 => shapes.pop(), - _ => Some((unite(&shapes), FillRule::NonZero)), - } - } - - /// What this object paints as the clipping path of a clip group, shared by the renderer and - /// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted - /// over it (unclipped). Each part is this object keeping only those appearance items (type: - /// also only its characters' fills or strokes); `None` where it paints nothing, as right after - /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths. - pub fn clip_paint(&self) -> ClipPaint { - use crate::appearance::AppearanceItem; - let runs = match &self.kind { - NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None, - NodeKind::Text(t) => Some(&t.runs), - _ => return ClipPaint::default(), - }; - let paints = |fill: bool| { - let item = |i: &AppearanceItem| match i { - AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), - AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, - }; - let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 }; - self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run)) - }; - let part = |fill: bool| { - paints(fill).then(|| { - let mut n = self.clone(); - for i in (0..n.appearance.items.len()).rev() { - if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill { - n.appearance.remove_item(i); - } - } - if let NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - *(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None; - } - } - n - }) - }; - ClipPaint { fill: part(true), stroke: part(false) } - } -} - -/// What a clipping path paints ([`Node::clip_paint`]). -#[derive(Clone, Debug, Default)] -pub struct ClipPaint { - /// The clipping path with only its fills: painted behind the clipped art (inside the clip). - pub fill: Option, - /// The clipping path with only its strokes: painted over the clipped art, not clipped. - pub stroke: Option, -} - -/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a -/// knockout group each child composites against the group's backdrop, so it hides the children -/// below it instead of showing them through its transparency. Neutral passes the enclosing -/// group's setting through to the children. Files from before the three states stored a bool: -/// `true` loads as On, `false` as Neutral. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum Knockout { - #[default] - Neutral, - Off, - On, -} - -impl Knockout { - pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On]; - - /// Name in files and command parameters. - pub fn label(self) -> &'static str { - match self { - Knockout::Neutral => "neutral", - Knockout::Off => "off", - Knockout::On => "on", - } - } - /// A name ([`Self::label`], any case) or a bool as older files and commands wrote it. - pub fn from_value(v: &serde_json::Value) -> Option { - match v { - serde_json::Value::Bool(b) => Some(Self::from(*b)), - serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())), - _ => None, - } - } - /// The state a click on the checkbox moves to: on → neutral → off → on. - pub fn cycle(self) -> Self { - match self { - Knockout::On => Knockout::Neutral, - Knockout::Neutral => Knockout::Off, - Knockout::Off => Knockout::On, - } - } - /// Whether children of a group with this state knock each other out, inside a group (or page) - /// where that is `enclosing`. - pub fn resolve(self, enclosing: bool) -> bool { - match self { - Knockout::Neutral => enclosing, - Knockout::Off => false, - Knockout::On => true, - } - } -} - -impl From for Knockout { - fn from(b: bool) -> Self { - if b { Knockout::On } else { Knockout::Neutral } - } -} - -impl Serialize for Knockout { - fn serialize(&self, s: S) -> Result { - s.serialize_str(self.label()) - } -} - -impl<'de> Deserialize<'de> for Knockout { - fn deserialize>(d: D) -> Result { - let v = serde_json::Value::deserialize(d)?; - Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}"))) - } -} - -impl Node { - /// Whether the children of this group or layer knock each other out, inside a group (or page) - /// where that is `enclosing`. - pub fn knocks_out(&self, enclosing: bool) -> bool { - matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing) - } - - /// A plain group or layer that is neutral to knockout and has no transparency or appearance - /// of its own: inside a knockout group its children composite as the group's own children. - pub fn passes_knockout_through(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. }) - && self.has_default_transparency() - && self.appearance.items.is_empty() - && self.appearance.effects.is_empty() - } - - /// The visible elements of a knockout group made of `children` (bottom first): neutral plain - /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. - pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { - fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { - for c in children.iter().filter(|c| c.visible) { - match c.children() { - Some(ch) if c.passes_knockout_through() => push(ch, out), - _ => out.push(c), - } - } - } - let mut out = Vec::with_capacity(children.len()); - push(children, &mut out); - out - } - - /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. - pub fn clips(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) - } - - /// Make this group or layer clip its children by its first child, or stop it. - pub fn set_clips(&mut self, on: bool) { - if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind { - *clip = on; - } - } - - /// Whether blending inside this object reaches the art below it unless the object isolates - /// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke), - /// directly or through children that don't isolate their own blending. - pub fn blends_through(&self) -> bool { - self.blends_through_with(&mut |c| c.blends_through()) - } - - /// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache). - pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc) -> bool) -> bool { - match self.children() { - Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner), - _ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal), - } - } - - /// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child). - pub fn children_blend(children: &[Arc], inner: &mut dyn FnMut(&Arc) -> bool) -> bool { - children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c)))) - } -} - -impl Node { - /// The Appearance panel's row for what this object's own fills and strokes paint around: - /// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters" - /// for type; `None` for objects whose fills and strokes paint their own geometry. - pub fn contents_label(&self) -> Option<&'static str> { - match self.kind { - NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"), - NodeKind::Text(_) => Some("Characters"), - _ => None, - } - } -} - -/// Window → Attributes: an object's settings that don't change how it prints. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ObjectAttributes { - /// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub show_center: Option, - /// Image Map: the clickable area the URL covers in web output. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub image_map: ImageMap, - /// URL the object links to (SVG ``; empty: none). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub url: String, - /// The note shown in the Attributes panel. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub note: String, - /// The object's own data, in order: SVG's `data-*` attributes (`data-pivot="100,180"` is - /// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub data: Vec<(String, String)>, - /// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order - /// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as - /// the element's event attributes; nothing in VectorCraft runs them. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub events: Vec<(String, String)>, - /// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG - /// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives - /// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's, - /// as `data` is, so copies, duplicates and pastes keep them and a deleted object takes them - /// with it. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub foreign: Vec, -} - -/// An attribute in another application's XML namespace: `plotter:pen="2"` with `xmlns:plotter= -/// "https://example.org/plotter"` is `{ns: "https://example.org/plotter", prefix: "plotter", name: -/// "pen", value: "2"}`. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ForeignAttribute { - /// The namespace's URI, which names it. - pub ns: String, - /// The prefix the file gave the namespace, which export reuses where it can. - pub prefix: String, - /// The attribute's local name. - pub name: String, - /// The attribute's value, as the file had it. - pub value: String, -} - -/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event -/// attributes that run it. -pub const SVG_EVENTS: [&str; 19] = [ - "onfocusin", - "onfocusout", - "onactivate", - "onmousedown", - "onmouseup", - "onclick", - "onmouseover", - "onmousemove", - "onmouseout", - "onkeydown", - "onkeypress", - "onkeyup", - "onload", - "onerror", - "onabort", - "onunload", - "onzoom", - "onresize", - "onscroll", -]; - -/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the -/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another. -pub const MAX_SVG_SCRIPTS: usize = 64; - -/// The Attributes panel's Image Map shapes. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ImageMap { - #[default] - None, - Rectangle, - Polygon, -} - -impl ImageMap { - pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon]; - - pub fn label(self) -> &'static str { - match self { - ImageMap::None => "None", - ImageMap::Rectangle => "Rectangle", - ImageMap::Polygon => "Polygon", - } - } - - /// Parse a label or serialized name, ignoring case. - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) - } -} - -impl Node { - /// The URL this object links to, if any. - pub fn url(&self) -> Option<&str> { - self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) - } - - /// Whether the canvas shows this object's centre point when it is selected: as set in the - /// Attributes panel, else [`Self::shows_center_by_default`]. - pub fn shows_center(&self) -> bool { - self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) - } - - /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre. - pub fn shows_center_by_default(&self) -> bool { - matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. })) - } - - /// Change this object's attributes with `f`; all-default attributes are dropped. - pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R { - let mut a = self.extras_mut().attrs.take().unwrap_or_default(); - let r = f(&mut a); - self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a); - r - } -} - -impl Node { - /// Whether this object shows any transparency, itself or anything inside it: object, fill or - /// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient - /// stops. Hidden objects show none. - pub fn shows_transparency(&self) -> bool { - use vectorcraft_color::Paint; - let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); - let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible); - self.visible - && (self.opacity < 1.0 - || self.blend != BlendMode::Normal - || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) - || effects(&self.appearance.effects) - || self - .appearance - .items - .iter() - .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) - || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency()))) - } -} - -/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? -fn keeps_gradient_fit(a: Affine) -> bool { - let [m0, m1, m2, m3, _, _] = a.as_coeffs(); - let eps = 1e-12 * m0.abs().max(1.0); - m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps -} - -impl Node { - /// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true - /// everywhere for objects without them, such as type, whose boxes stand in). - pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> { - let shapes = self.clip_shapes(None); - move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)) - } -} - -/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every -/// object inside a group, again at each level of nesting) before reading it, since `type` may come -/// anywhere among its fields. Files have it first (it is written first): then the kind is read -/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the -/// fields before `type` are buffered, and only those. -mod kind_de { - use std::fmt; - use std::sync::Arc; - - use serde::de::value::{MapAccessDeserializer, StringDeserializer}; - use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor}; - use serde::{Deserialize, forward_to_deserialize_any}; - use vectorcraft_geom::{Affine, FillRule, PathData}; - - use super::{LayerColor, LiveShape, Node, NodeKind, yes}; - use crate::live::{BlendSpec, EnvelopeKind, GradientMesh}; - use crate::pattern::RepeatSpec; - use crate::text::TextObject; - - /// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes - /// note (the compiler checks that every field is here), read externally tagged: the kind from - /// `type`, its fields from the rest of the object ([`Tagged`]). - #[derive(Deserialize)] - #[serde(remote = "NodeKind", rename_all = "lowercase")] - enum KindDef { - Layer { - color: LayerColor, - #[serde(default)] - template: bool, - #[serde(default = "yes")] - printable: bool, - children: Vec>, - #[serde(default)] - clip: bool, - #[serde(default = "yes")] - preview: bool, - #[serde(default)] - dim_images: Option, - }, - Group { - children: Vec>, - #[serde(default)] - clip: bool, - }, - Path { - path: PathData, - #[serde(default)] - rule: FillRule, - #[serde(default)] - live: Option>, - #[serde(default)] - clipping: bool, - #[serde(default)] - guide: bool, - }, - Compound { - children: Vec>, - #[serde(default)] - rule: FillRule, - }, - Text(Box), - Image(Box), - SymbolInstance { - symbol: String, - xf: Affine, - }, - Blend { - children: Vec>, - #[serde(default)] - spec: Box, - }, - Envelope { - content: Vec>, - kind: Box, - #[serde(default = "crate::live::default_fidelity")] - fidelity: f64, - #[serde(default)] - editing: bool, - #[serde(default)] - options: crate::live::EnvelopeOptions, - #[serde(default)] - frame: Box, - }, - Mesh(GradientMesh), - Repeat(RepeatSpec), - PlacedDocument(Box), - Intertwine { - children: Vec>, - }, - } - - impl<'de> Deserialize<'de> for NodeKind { - fn deserialize>(d: D) -> Result { - d.deserialize_map(KindVisitor) - } - } - - struct KindVisitor; - - impl<'de> Visitor<'de> for KindVisitor { - type Value = NodeKind; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("an object kind (a map with `type`)") - } - - fn visit_map>(self, mut map: A) -> Result { - let mut before = vec![]; - while let Some(key) = map.next_key::()? { - if key == "type" { - let tag: String = map.next_value()?; - return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } }); - } - before.push((key, map.next_value::()?)); - } - Err(de::Error::missing_field("type")) - } - } - - /// The fields read before `type` (buffered), then the rest of the object. - struct Buffered { - before: std::vec::IntoIter<(String, serde_json::Value)>, - value: Option, - map: A, - } - - impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered { - type Error = A::Error; - - fn next_key_seed>(&mut self, seed: K) -> Result, A::Error> { - match self.before.next() { - Some((key, value)) => { - self.value = Some(value); - seed.deserialize(StringDeserializer::::new(key)).map(Some) - } - None => self.map.next_key_seed(seed), - } - } - - fn next_value_seed>(&mut self, seed: V) -> Result { - match self.value.take() { - Some(value) => seed.deserialize(value).map_err(de::Error::custom), - None => self.map.next_value_seed(seed), - } - } - } - - /// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`. - struct Tagged { - tag: String, - map: A, - } - - impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged { - type Error = A::Error; - - fn deserialize_any>(self, visitor: V) -> Result { - visitor.visit_enum(self) - } - - forward_to_deserialize_any! { - bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf - option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum - identifier ignored_any - } - } - - impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged { - type Error = A::Error; - type Variant = Fields; - - fn variant_seed>(self, seed: V) -> Result<(V::Value, Fields), A::Error> { - let kind = seed.deserialize(self.tag.into_deserializer())?; - Ok((kind, Fields(self.map))) - } - } - - /// A kind's fields: the rest of the object. - struct Fields(A); - - impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields { - type Error = A::Error; - - fn unit_variant(self) -> Result<(), A::Error> { - Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields")) - } - - fn newtype_variant_seed>(self, seed: T) -> Result { - seed.deserialize(MapAccessDeserializer::new(self.0)) - } - - fn tuple_variant>(self, _: usize, _: V) -> Result { - Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields")) - } - - fn struct_variant>(self, _: &'static [&'static str], visitor: V) -> Result { - visitor.visit_map(self.0) - } - } -} - #[cfg(test)] mod tests; diff --git a/crates/doc/src/node/attributes.rs b/crates/doc/src/node/attributes.rs new file mode 100644 index 000000000..70653d778 --- /dev/null +++ b/crates/doc/src/node/attributes.rs @@ -0,0 +1,133 @@ +use super::kind::NodeKind; +use super::live_shape::LiveShape; +use super::*; + +/// Window → Attributes: an object's settings that don't change how it prints. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ObjectAttributes { + /// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show_center: Option, + /// Image Map: the clickable area the URL covers in web output. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub image_map: ImageMap, + /// URL the object links to (SVG ``; empty: none). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub url: String, + /// The note shown in the Attributes panel. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub note: String, + /// The object's own data, in order: SVG's `data-*` attributes (`data-pivot="100,180"` is + /// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub data: Vec<(String, String)>, + /// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order + /// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as + /// the element's event attributes; nothing in VectorCraft runs them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec<(String, String)>, + /// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG + /// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives + /// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's, + /// as `data` is, so copies, duplicates and pastes keep them and a deleted object takes them + /// with it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub foreign: Vec, +} + +/// An attribute in another application's XML namespace: `plotter:pen="2"` with `xmlns:plotter= +/// "https://example.org/plotter"` is `{ns: "https://example.org/plotter", prefix: "plotter", name: +/// "pen", value: "2"}`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForeignAttribute { + /// The namespace's URI, which names it. + pub ns: String, + /// The prefix the file gave the namespace, which export reuses where it can. + pub prefix: String, + /// The attribute's local name. + pub name: String, + /// The attribute's value, as the file had it. + pub value: String, +} + +/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event +/// attributes that run it. +pub const SVG_EVENTS: [&str; 19] = [ + "onfocusin", + "onfocusout", + "onactivate", + "onmousedown", + "onmouseup", + "onclick", + "onmouseover", + "onmousemove", + "onmouseout", + "onkeydown", + "onkeypress", + "onkeyup", + "onload", + "onerror", + "onabort", + "onunload", + "onzoom", + "onresize", + "onscroll", +]; + +/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the +/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another. +pub const MAX_SVG_SCRIPTS: usize = 64; + +/// The Attributes panel's Image Map shapes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ImageMap { + #[default] + None, + Rectangle, + Polygon, +} + +impl ImageMap { + pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon]; + + pub fn label(self) -> &'static str { + match self { + ImageMap::None => "None", + ImageMap::Rectangle => "Rectangle", + ImageMap::Polygon => "Polygon", + } + } + + /// Parse a label or serialized name, ignoring case. + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } +} + +impl Node { + /// The URL this object links to, if any. + pub fn url(&self) -> Option<&str> { + self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) + } + + /// Whether the canvas shows this object's centre point when it is selected: as set in the + /// Attributes panel, else [`Self::shows_center_by_default`]. + pub fn shows_center(&self) -> bool { + self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) + } + + /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre. + pub fn shows_center_by_default(&self) -> bool { + matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. })) + } + + /// Change this object's attributes with `f`; all-default attributes are dropped. + pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R { + let mut a = self.extras_mut().attrs.take().unwrap_or_default(); + let r = f(&mut a); + self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a); + r + } +} diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs new file mode 100644 index 000000000..959b20a5b --- /dev/null +++ b/crates/doc/src/node/clip.rs @@ -0,0 +1,120 @@ +use super::kind::NodeKind; +use super::*; + +/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans +/// live above this crate (`vectorcraft-pathops`), so callers supply it. +pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath; + +impl Node { + /// The filled regions this object clips to as the clipping path of a clip group, in document + /// space, each with its fill rule: a path, a compound path (with its holes), an image's frame, + /// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible + /// members of a group (their union) and the clipping path of a nested clip group. Live objects + /// are evaluated first. Guides and symbol instances add nothing. + pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> { + let mut out = vec![]; + self.push_clip_shapes(text, &mut out); + out + } + + fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { + match &self.kind { + NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} + NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + out.push((bp, *rule)); + } + NodeKind::Group { children, clip: true } => { + if let Some(c) = children.first() { + c.push_clip_shapes(text, out); + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) { + c.push_clip_shapes(text, out); + } + } + NodeKind::Text(_) => { + if let Some(o) = text.and_then(|f| f(self)) { + o.push_clip_shapes(None, out); + } + } + NodeKind::Image(im) => { + let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); + out.push((frame.to_bezpath(), FillRule::NonZero)); + } + NodeKind::PlacedDocument(p) => { + let frame = shapes::rectangle(p.natural()).transformed(p.xf); + out.push((frame.to_bezpath(), FillRule::NonZero)); + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + crate::live::expand_deep(self, text).push_clip_shapes(text, out); + } + } + } + + /// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared + /// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its + /// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to + /// clip by: the clipped art is then hidden. + pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> { + let mut shapes = self.clip_shapes(text); + match shapes.len() { + 0 => None, + 1 => shapes.pop(), + _ => Some((unite(&shapes), FillRule::NonZero)), + } + } + + /// What this object paints as the clipping path of a clip group, shared by the renderer and + /// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted + /// over it (unclipped). Each part is this object keeping only those appearance items (type: + /// also only its characters' fills or strokes); `None` where it paints nothing, as right after + /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths. + pub fn clip_paint(&self) -> ClipPaint { + use crate::appearance::AppearanceItem; + let runs = match &self.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None, + NodeKind::Text(t) => Some(&t.runs), + _ => return ClipPaint::default(), + }; + let paints = |fill: bool| { + let item = |i: &AppearanceItem| match i { + AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, + }; + let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 }; + self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run)) + }; + let part = |fill: bool| { + paints(fill).then(|| { + let mut n = self.clone(); + for i in (0..n.appearance.items.len()).rev() { + if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill { + n.appearance.remove_item(i); + } + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + *(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None; + } + } + n + }) + }; + ClipPaint { fill: part(true), stroke: part(false) } + } +} + +/// What a clipping path paints ([`Node::clip_paint`]). +#[derive(Clone, Debug, Default)] +pub struct ClipPaint { + /// The clipping path with only its fills: painted behind the clipped art (inside the clip). + pub fill: Option, + /// The clipping path with only its strokes: painted over the clipped art, not clipped. + pub stroke: Option, +} diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs new file mode 100644 index 000000000..ee1165303 --- /dev/null +++ b/crates/doc/src/node/extras.rs @@ -0,0 +1,171 @@ +use super::attributes::ObjectAttributes; +use super::kind::NodeKind; +use super::live_shape::LiveShape; +use super::opacity::OpacityMask; +use super::*; + +/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without +/// them stay small ([`Node::extras`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NodeExtras { + /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. + pub mask: Option>, + /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows + /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). + pub trace: Option>, + /// Object → Text Wrap: area type below this object (in the same layer) flows around it. + pub wrap: Option, + /// Graph object: the group's children are generated from this spec (Object → Graph). + pub graph: Option>, + /// Index of the graph series this generated group belongs to. Axes, the legend and other + /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. + pub series_index: Option, + /// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale + /// the placed gradients in it with the outlines. + pub type_outlines: bool, + /// Editable Shaper composition; the original art is retained in its source child. + pub shaper: Option>, + /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps + /// that style's look: editing its appearance or transparency breaks the link. + pub graphic_style: Option, + /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). + pub attrs: Option>, + /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with + /// these options. + pub slice: Option>, + /// Object › Perspective: the perspective grid plane the object is attached to. + pub perspective: Option>, + /// Dimension object: the live measurement it draws; its ordinary group children hold its + /// exportable appearance (Object › Dimensions, the Dimension tool). + pub dimension: Option>, + /// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates, + /// filled non-zero) where this object is drawn over the group's other members. + pub intertwine: Option>, + /// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults). + pub live_paint_gaps: Option>, + /// Object › Objects on Path: the group's path and the objects attached to it (see + /// [`crate::objects_on_path`]). + pub objects_on_path: Option>, +} + +/// No extras: what [`Node::extras`] gives for a node without any. +pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { + mask: None, + trace: None, + wrap: None, + graph: None, + series_index: None, + type_outlines: false, + shaper: None, + graphic_style: None, + attrs: None, + slice: None, + perspective: None, + dimension: None, + intertwine: None, + live_paint_gaps: None, + objects_on_path: None, +}; + +/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal +/// none. +#[derive(Clone, Debug, Default)] +pub struct Extras(pub(crate) Option>); + +impl Extras { + fn get(&self) -> &NodeExtras { + self.0.as_deref().unwrap_or(&NO_EXTRAS) + } +} + +impl PartialEq for Extras { + fn eq(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (Some(a), Some(b)) => a == b, + _ => self.get() == other.get(), + } + } +} + +impl Node { + /// The rarely set parts of this node (all unset when it has none). + #[inline] + pub fn extras(&self) -> &NodeExtras { + self.extras.get() + } + + /// The rarely set parts of this node, to change: when the change is done (the guard dropped), + /// extras that were set are allocated and extras left all unset are let go. Setting one to + /// `None` on a node without extras allocates nothing. + #[inline] + pub fn extras_mut(&mut self) -> ExtrasMut<'_> { + ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() } + } + + /// The rarely set parts of this node, if it has any: to change what is there in place. + #[inline] + pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> { + self.extras.0.as_deref_mut() + } + + /// The live shape of a path object, if it is one. + pub fn live_shape(&self) -> Option<&LiveShape> { + match &self.kind { + NodeKind::Path { live, .. } => live.as_deref(), + _ => None, + } + } + + /// The live shape of a path object, if it is one, to change. + pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> { + match &mut self.kind { + NodeKind::Path { live, .. } => live.as_deref_mut(), + _ => None, + } + } + + /// This node with opacity mask `mask` (or none). + pub fn with_mask(mut self, mask: Option>) -> Self { + self.extras_mut().mask = mask; + self + } + + /// The extras, if this node has them allocated (for [`crate::mem`]'s estimate). + pub(crate) fn extras_box(&self) -> Option<&NodeExtras> { + self.extras.0.as_deref() + } +} + +/// [`Node::extras_mut`]: the node's extras while they change. +pub struct ExtrasMut<'a> { + slot: &'a mut Option>, + /// Stands for the extras of a node without any, boxed into it when dropped if one was set. + local: NodeExtras, +} + +impl std::ops::Deref for ExtrasMut<'_> { + type Target = NodeExtras; + fn deref(&self) -> &NodeExtras { + self.slot.as_deref().unwrap_or(&self.local) + } +} + +impl std::ops::DerefMut for ExtrasMut<'_> { + fn deref_mut(&mut self) -> &mut NodeExtras { + match self.slot.as_deref_mut() { + Some(e) => e, + None => &mut self.local, + } + } +} + +impl Drop for ExtrasMut<'_> { + fn drop(&mut self) { + match self.slot.as_deref() { + Some(e) if *e == NO_EXTRAS => *self.slot = None, + Some(_) => {} + None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))), + None => {} + } + } +} diff --git a/crates/doc/src/node/ids.rs b/crates/doc/src/node/ids.rs new file mode 100644 index 000000000..ea4b1b484 --- /dev/null +++ b/crates/doc/src/node/ids.rs @@ -0,0 +1,59 @@ +use super::*; + +/// Stable per-document object id. Never reused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct NodeId(pub u64); + +impl std::fmt::Display for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{}", self.0) + } +} + +/// Layer colours used for selection highlighting (index into [`LAYER_COLORS`]) or a custom RGB. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LayerColor { + Preset(u8), + Custom([u8; 3]), +} + +/// Layer highlight colours, assigned in sequence to new layers. First is Illustrator-like light blue. +pub const LAYER_COLORS: [(&str, [u8; 3]); 27] = [ + ("Light Blue", [0x4f, 0x80, 0xff]), + ("Red", [0xff, 0x4f, 0x4f]), + ("Green", [0x4f, 0xff, 0x4f]), + ("Blue", [0x4f, 0x4f, 0xff]), + ("Yellow", [0xff, 0xff, 0x4f]), + ("Magenta", [0xff, 0x4f, 0xff]), + ("Cyan", [0x4f, 0xff, 0xff]), + ("Gray", [0x80, 0x80, 0x80]), + ("Black", [0x00, 0x00, 0x00]), + ("Orange", [0xff, 0x66, 0x00]), + ("Dark Green", [0x00, 0x80, 0x00]), + ("Teal", [0x00, 0x80, 0x80]), + ("Tan", [0xcc, 0x99, 0x66]), + ("Brown", [0x99, 0x33, 0x00]), + ("Violet", [0x99, 0x33, 0xff]), + ("Gold", [0xff, 0x99, 0x00]), + ("Dark Blue", [0x00, 0x00, 0x80]), + ("Pink", [0xff, 0x99, 0xcc]), + ("Lavender", [0x99, 0x99, 0xff]), + ("Brick Red", [0x99, 0x00, 0x00]), + ("Olive Green", [0x66, 0x66, 0x00]), + ("Peach", [0xff, 0x99, 0x99]), + ("Burgundy", [0x99, 0x00, 0x33]), + ("Grass Green", [0x99, 0xcc, 0x00]), + ("Ochre", [0x99, 0x66, 0x00]), + ("Purple", [0x66, 0x00, 0x99]), + ("Light Gray", [0xbb, 0xbb, 0xbb]), +]; + +impl LayerColor { + pub fn rgb(self) -> [u8; 3] { + match self { + LayerColor::Preset(i) => LAYER_COLORS[i as usize % LAYER_COLORS.len()].1, + LayerColor::Custom(c) => c, + } + } +} diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs new file mode 100644 index 000000000..5bebc4379 --- /dev/null +++ b/crates/doc/src/node/kind.rs @@ -0,0 +1,326 @@ +use super::ids::LayerColor; +use super::live_shape::LiveShape; +use super::*; + +/// Embedded or linked raster image. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImageObject { + /// Key into [`crate::Document::images`]. + pub key: String, + pub width: u32, + pub height: u32, + /// Maps pixel space (0..w, 0..h) into the document. + pub xf: Affine, + /// The file a linked image shows (File → Place with Link); `None`: embedded. Files saved + /// before links had details hold just the path. + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")] + pub link: Option, + /// Links panel → Placement Options: how a file read again takes this image's place. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub placement: crate::links::PlacementOptions, + /// The pixels of an image in art made outside a document (type's outlines, a glyph's art), + /// which has no [`crate::Document::images`] to keep them in. A document takes them in + /// ([`crate::Document::adopt_pixels`]): no image in one holds its own. + #[serde(skip)] + pub pixels: Option>, +} + +/// Written with its `type` first, then its fields (an internally tagged enum); read by +/// [`kind_de`], which takes the fields straight from the file when `type` comes first. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum NodeKind { + /// A top-level layer or sublayer. + Layer { + color: LayerColor, + #[serde(default)] + template: bool, + #[serde(default = "yes")] + printable: bool, + children: Vec>, + /// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + clip: bool, + /// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen + /// (Ctrl-click its eye in the Layers panel). + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] + preview: bool, + /// Layer Options → Dim Images to: on screen, images on the layer show faded to this + /// percentage (0–100); `None`: not dimmed. + #[serde(default, skip_serializing_if = "Option::is_none")] + dim_images: Option, + }, + /// A group. With `clip`, the first (bottom-most) child is the clipping path. + Group { + children: Vec>, + #[serde(default)] + clip: bool, + }, + Path { + path: PathData, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + rule: FillRule, + /// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant. + #[serde(default, skip_serializing_if = "Option::is_none")] + live: Option>, + /// This path acts as a clipping path inside a clip group. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + clipping: bool, + /// Guide (non-printing) path. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + guide: bool, + }, + /// Compound path: children are paths painted as one with the compound's appearance. + Compound { + children: Vec>, + #[serde(default)] + rule: FillRule, + }, + Text(Box), + /// Boxed, as the other large kinds: a node is as large as its largest kind. + Image(Box), + SymbolInstance { + symbol: String, + xf: Affine, + }, + /// Live blend: the key objects (paint order) plus spacing/orientation/spine; the intermediate + /// steps are evaluated on demand (`live::blend_expand`). + Blend { + children: Vec>, + #[serde(default)] + spec: Box, + }, + /// Live envelope distortion of `content`. + Envelope { + content: Vec>, + kind: Box, + /// Envelope Options → Fidelity (0–100). + #[serde(default = "crate::live::default_fidelity")] + fidelity: f64, + /// Edit Contents mode (the content, not the envelope, is edited). + #[serde(default)] + editing: bool, + /// Envelope Options besides Fidelity. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + options: crate::live::EnvelopeOptions, + /// The envelope's own axes (→ the document): the transforms it took since it was made that + /// the page axes can't stand for (rotation, shear, reflection). The content maps from its + /// bounds in this frame, and a warp bends along it. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + frame: Box, + }, + /// Gradient mesh. + Mesh(GradientMesh), + /// Live Repeat (radial / grid / mirror) of source art. + Repeat(RepeatSpec), + /// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` + /// file, linked and locked (see [`crate::placed_document`]). + PlacedDocument(Box), + /// Object › Intertwine: the objects keep their own stacking order, except where one of them + /// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see + /// [`crate::intertwine`]). + Intertwine { + children: Vec>, + }, +} + +/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every +/// object inside a group, again at each level of nesting) before reading it, since `type` may come +/// anywhere among its fields. Files have it first (it is written first): then the kind is read +/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the +/// fields before `type` are buffered, and only those. +mod kind_de { + use std::fmt; + use std::sync::Arc; + + use serde::de::value::{MapAccessDeserializer, StringDeserializer}; + use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor}; + use serde::{Deserialize, forward_to_deserialize_any}; + use vectorcraft_geom::{Affine, FillRule, PathData}; + + use super::{LayerColor, LiveShape, Node, NodeKind, yes}; + use crate::live::{BlendSpec, EnvelopeKind, GradientMesh}; + use crate::pattern::RepeatSpec; + use crate::text::TextObject; + + /// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes + /// note (the compiler checks that every field is here), read externally tagged: the kind from + /// `type`, its fields from the rest of the object ([`Tagged`]). + #[derive(Deserialize)] + #[serde(remote = "NodeKind", rename_all = "lowercase")] + enum KindDef { + Layer { + color: LayerColor, + #[serde(default)] + template: bool, + #[serde(default = "yes")] + printable: bool, + children: Vec>, + #[serde(default)] + clip: bool, + #[serde(default = "yes")] + preview: bool, + #[serde(default)] + dim_images: Option, + }, + Group { + children: Vec>, + #[serde(default)] + clip: bool, + }, + Path { + path: PathData, + #[serde(default)] + rule: FillRule, + #[serde(default)] + live: Option>, + #[serde(default)] + clipping: bool, + #[serde(default)] + guide: bool, + }, + Compound { + children: Vec>, + #[serde(default)] + rule: FillRule, + }, + Text(Box), + Image(Box), + SymbolInstance { + symbol: String, + xf: Affine, + }, + Blend { + children: Vec>, + #[serde(default)] + spec: Box, + }, + Envelope { + content: Vec>, + kind: Box, + #[serde(default = "crate::live::default_fidelity")] + fidelity: f64, + #[serde(default)] + editing: bool, + #[serde(default)] + options: crate::live::EnvelopeOptions, + #[serde(default)] + frame: Box, + }, + Mesh(GradientMesh), + Repeat(RepeatSpec), + PlacedDocument(Box), + Intertwine { + children: Vec>, + }, + } + + impl<'de> Deserialize<'de> for NodeKind { + fn deserialize>(d: D) -> Result { + d.deserialize_map(KindVisitor) + } + } + + struct KindVisitor; + + impl<'de> Visitor<'de> for KindVisitor { + type Value = NodeKind; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an object kind (a map with `type`)") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut before = vec![]; + while let Some(key) = map.next_key::()? { + if key == "type" { + let tag: String = map.next_value()?; + return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } }); + } + before.push((key, map.next_value::()?)); + } + Err(de::Error::missing_field("type")) + } + } + + /// The fields read before `type` (buffered), then the rest of the object. + struct Buffered { + before: std::vec::IntoIter<(String, serde_json::Value)>, + value: Option, + map: A, + } + + impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered { + type Error = A::Error; + + fn next_key_seed>(&mut self, seed: K) -> Result, A::Error> { + match self.before.next() { + Some((key, value)) => { + self.value = Some(value); + seed.deserialize(StringDeserializer::::new(key)).map(Some) + } + None => self.map.next_key_seed(seed), + } + } + + fn next_value_seed>(&mut self, seed: V) -> Result { + match self.value.take() { + Some(value) => seed.deserialize(value).map_err(de::Error::custom), + None => self.map.next_value_seed(seed), + } + } + } + + /// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`. + struct Tagged { + tag: String, + map: A, + } + + impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged { + type Error = A::Error; + + fn deserialize_any>(self, visitor: V) -> Result { + visitor.visit_enum(self) + } + + forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf + option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum + identifier ignored_any + } + } + + impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged { + type Error = A::Error; + type Variant = Fields; + + fn variant_seed>(self, seed: V) -> Result<(V::Value, Fields), A::Error> { + let kind = seed.deserialize(self.tag.into_deserializer())?; + Ok((kind, Fields(self.map))) + } + } + + /// A kind's fields: the rest of the object. + struct Fields(A); + + impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields { + type Error = A::Error; + + fn unit_variant(self) -> Result<(), A::Error> { + Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields")) + } + + fn newtype_variant_seed>(self, seed: T) -> Result { + seed.deserialize(MapAccessDeserializer::new(self.0)) + } + + fn tuple_variant>(self, _: usize, _: V) -> Result { + Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields")) + } + + fn struct_variant>(self, _: &'static [&'static str], visitor: V) -> Result { + visitor.visit_map(self.0) + } + } +} diff --git a/crates/doc/src/node/live_shape.rs b/crates/doc/src/node/live_shape.rs new file mode 100644 index 000000000..65dad904e --- /dev/null +++ b/crates/doc/src/node/live_shape.rs @@ -0,0 +1,346 @@ +use super::*; + +/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these +/// while the shape stays live; editing anchors directly converts it to a plain path. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "shape", rename_all = "lowercase")] +pub enum LiveShape { + Rectangle { + /// Untransformed width/height. Transforms fold their scale into these (see + /// [`LiveShape::transform`]), so they are document lengths unless `xf` shears. + w: f64, + h: f64, + /// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as + /// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale. + radii: [f64; 4], + /// Corner kinds, in the same order (Live Corners' corner types). + #[serde(default, skip_serializing_if = "all_round")] + kinds: [CornerKind; 4], + /// When true, `radii` are fractions in 0..1 of each corner's largest possible radius. + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, + /// Maps the untransformed shape (origin at its top-left) into the document. A rotation + /// or reflection plus a move, unless a shear (or a file saved before transforms folded + /// their scale into `w`/`h`) put more in it. + xf: Affine, + }, + Ellipse { + w: f64, + h: f64, + /// Pie start/end angles in degrees (0/360 = full ellipse). + pie: (f64, f64), + xf: Affine, + }, + Polygon { + radius: f64, + /// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read. + #[serde(deserialize_with = "de_sides")] + sides: u32, + xf: Affine, + /// Corner radii in document units, by vertex from the first one clockwise (none: sharp). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + radii: Vec, + /// Corner kinds, in the same order (none: round). + #[serde(default, skip_serializing_if = "all_round")] + kinds: Vec, + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, + }, + Line { + a: Point, + b: Point, + }, + /// Any other path whose corners Live Corners cut (a star, a pen path): it keeps its uncut + /// outline so the corners stay editable. Without a cut corner it is a plain path again. + Path { + /// The path with its corners uncut, in the document. + base: PathData, + /// Corner radii in document units, by anchor index of `base` (counting every subpath's + /// anchors in order; missing: sharp). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + radii: Vec, + /// Corner kinds, in the same order (missing: round). + #[serde(default, skip_serializing_if = "all_round")] + kinds: Vec, + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, + }, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +impl LiveShape { + pub fn radii_relative(&self) -> bool { + match self { + LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => { + *radii_relative + } + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false, + } + } + pub fn label(&self) -> &'static str { + match self { + LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle", + LiveShape::Rectangle { .. } => "Rectangle", + LiveShape::Ellipse { .. } => "Ellipse", + LiveShape::Polygon { .. } => "Polygon", + LiveShape::Line { .. } => "Line", + LiveShape::Path { .. } => "Path", + } + } + /// Regenerate the path. + pub fn to_path(&self) -> PathData { + match self { + LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => { + let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)); + let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative); + let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0)); + shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf) + } + LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf), + LiveShape::Polygon { radii, kinds, radii_relative, .. } => { + let outline = self.polygon_outline(); + let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative); + cut_corners(&outline, &radii, kinds).0 + } + LiveShape::Line { a, b } => shapes::line(*a, *b), + LiveShape::Path { base, radii, kinds, radii_relative } => { + let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative); + cut_corners(base, &radii, kinds).0 + } + } + } + /// A polygon's outline with its corners uncut, in the document (empty for other shapes). + pub fn polygon_outline(&self) -> PathData { + match self { + LiveShape::Polygon { radius, sides, xf, .. } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf), + _ => PathData::default(), + } + } + /// Give a polygon `n` sides (3–1000). Its corners keep the radius and kind they shared; else + /// the corners past the old last one are sharp. + pub fn set_sides(&mut self, n: u64) { + let LiveShape::Polygon { sides, radii, kinds, .. } = self else { return }; + *sides = n.clamp(3, 1000) as u32; + fn spread(v: &mut Vec, n: usize) { + match v.first().copied() { + Some(x) if v.iter().all(|y| *y == x) => *v = vec![x; n], + Some(_) => v.resize(n, T::default()), + None => {} + } + } + spread(radii, *sides as usize); + spread(kinds, *sides as usize); + } + /// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of + /// `xf` (exact while its sides are equal). None for other shapes. + pub fn polygon_radius(&self) -> Option { + let LiveShape::Polygon { radius, xf, .. } = self else { return None }; + Some(radius * xf.determinant().abs().sqrt()) + } + /// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by + /// from straight up (the Rotate field's sense). None for other shapes. + pub fn polygon_angle(&self) -> Option { + let LiveShape::Polygon { .. } = self else { return None }; + // No −0° or 360° from rounding: a polygon drawn upright reads 0°. + let a = (-self.angle_deg()).rem_euclid(360.0); + Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 }) + } + /// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True + /// for other shapes. + pub fn polygon_sides_equal(&self) -> bool { + let LiveShape::Polygon { xf, .. } = self else { return true }; + let [a, b, c, d, _, _] = xf.as_coeffs(); + let (sx, sy) = (a.hypot(b), c.hypot(d)); + (sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy + } + /// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]). + pub fn set_polygon_radius(&mut self, r: f64) { + let LiveShape::Polygon { radius, xf, .. } = self else { return }; + let k = xf.determinant().abs().sqrt(); + if k > 1e-12 && r.is_finite() { + *radius = r.max(0.0) / k; + } + } + /// The document radius that makes a polygon's sides `side` long (with its sides equal). + pub fn polygon_radius_for_side(&self, side: f64) -> Option { + let LiveShape::Polygon { sides, .. } = self else { return None }; + Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin())) + } + /// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]). + pub fn set_polygon_angle(&mut self, angle: f64) { + let Some(now) = self.polygon_angle() else { return }; + if let LiveShape::Polygon { xf, .. } = self + && angle.is_finite() + { + let centre = *xf * Point::ZERO; + *xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf; + } + } + /// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from + /// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`]. + pub fn make_sides_equal(&mut self) { + let Some(r) = self.polygon_radius() else { return }; + let LiveShape::Polygon { radius, xf, .. } = self else { return }; + let [a, b, _, _, e, f] = xf.as_coeffs(); + let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY }; + *xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip; + *radius = r; + } + /// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so + /// the caller must regenerate the path with [`LiveShape::to_path`]. + /// + /// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own + /// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps + /// only the rotation, reflection and position. The radii scale by the mean scale (the square + /// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off. + /// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a + /// scale in `xf` keep their exact geometry until they're next transformed). A polygon's or a + /// path's corner radii are document lengths: they scale by the mean scale and the corners are + /// cut again, circular whatever `a` does. + pub fn transform(&mut self, a: Affine) -> bool { + match self { + LiveShape::Rectangle { xf, .. } => { + *xf = a * *xf; + !moves_only(a) && self.fold_scale() + } + LiveShape::Ellipse { xf, .. } => { + *xf = a * *xf; + false + } + LiveShape::Polygon { xf, radii, radii_relative, .. } => { + *xf = a * *xf; + scale_radii(radii, a, *radii_relative) + } + LiveShape::Path { base, radii, radii_relative, .. } => { + base.transform(a); + scale_radii(radii, a, *radii_relative) + } + LiveShape::Line { a: p, b } => { + *p = a * *p; + *b = a * *b; + false + } + } + } + /// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so + /// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the + /// document. False (and nothing changes) when there is no scale to move, when `xf` shears or + /// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an + /// uneven scale in `xf`: their corners are elliptical until this runs. + pub fn fold_scale(&mut self) -> bool { + let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false }; + let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs(); + let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); + let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy; + let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite(); + let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12; + if !orthogonal || !finite || unscaled { + return false; + } + let k = (sx * sy).sqrt(); + if !*radii_relative { + radii.iter_mut().for_each(|r| *r *= k); + } + *w *= sx; + *h *= sy; + *xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]); + true + } + /// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document + /// units (what Live Corners show and edit). + pub fn folded(&self) -> Self { + let mut s = self.clone(); + s.fold_scale(); + s + } + /// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners + /// keep their size (Scale Corners off). False when nothing changed. + pub fn keep_corners(&mut self, k: f64) -> bool { + let relative = self.radii_relative(); + let radii: &mut [f64] = match self { + LiveShape::Rectangle { radii, .. } => radii, + LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii, + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false, + }; + if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 { + return false; + } + radii.iter_mut().for_each(|r| *r /= k); + true + } + /// Rotation angle of the live shape in degrees, clockwise on the page (y points down). + pub fn angle_deg(&self) -> f64 { + match self { + LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => { + let c = xf.as_coeffs(); + c[1].atan2(c[0]).to_degrees() + } + LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(), + LiveShape::Path { .. } => 0.0, + } + } +} + +fn all_round(kinds: &[CornerKind]) -> bool { + kinds.iter().all(|k| *k == CornerKind::Round) +} + +/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its +/// corner lists hold one entry per side, so a huge count would allocate gigabytes. +fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + u32::deserialize(d).map(|n| n.clamp(3, 1000)) +} + +/// Whether `a` only moves (no rotation, scale, shear or reflection). +pub(super) fn moves_only(a: Affine) -> bool { + let [a0, a1, a2, a3, _, _] = a.as_coeffs(); + (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12 +} + +/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True +/// when the path must be cut again: some corner is cut and `a` does more than move it (an +/// uneven scale would otherwise make the cuts elliptical). +fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool { + if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) { + return false; + } + let k = a.determinant().abs().sqrt(); + radii.iter_mut().for_each(|r| *r *= k); + true +} + +/// What a transform scales besides geometry, by its mean scale (the square root of its +/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke +/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects +/// and Scale Corners. +#[derive(Clone, Copy, Debug, Default)] +pub struct Scaling { + /// Stroke weights, dash lengths and dash offsets scale. + pub strokes: bool, + /// With `strokes`: scales an effect's distance parameters (the effects catalogue's). + pub effects: Option, + /// Type keeps its character strokes' weight (they otherwise scale with the type). + pub keep_type_strokes: bool, + /// Live corner radii keep their size. + pub keep_corners: bool, + /// Pattern fills and strokes move with the art (Transform Patterns; General › Transform + /// Pattern Tiles is its default): their tiles transform too, instead of staying put. + pub patterns: bool, +} + +impl Scaling { + /// The mean scale of `a` when it scales (isn't 1): what strokes, effects and corners scale by. + pub fn factor(a: Affine) -> Option { + let k = a.determinant().abs().sqrt(); + ((k - 1.0).abs() > 1e-9).then_some(k) + } +} + +impl From for Scaling { + fn from(strokes: bool) -> Self { + Self { strokes, ..Self::default() } + } +} diff --git a/crates/doc/src/node/opacity.rs b/crates/doc/src/node/opacity.rs new file mode 100644 index 000000000..0996b8c65 --- /dev/null +++ b/crates/doc/src/node/opacity.rs @@ -0,0 +1,129 @@ +use super::kind::NodeKind; +use super::*; + +/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OpacityMask { + /// Mask art in document coordinates. + pub art: Arc, + /// Clip: outside the mask art is hidden. Off: outside the mask art stays visible. + #[serde(default = "yes")] + pub clip: bool, + /// Invert the mask's luminance. + #[serde(default)] + pub invert: bool, + /// Disabled masks are kept but not applied. + #[serde(default)] + pub disabled: bool, + /// Linked masks move with the object. + #[serde(default = "yes")] + pub linked: bool, +} + +impl OpacityMask { + pub fn new(art: Node, clip: bool) -> Self { + Self { art: Arc::new(art), clip, invert: false, disabled: false, linked: true } + } +} + +/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity +/// mask's, whose luminance over the black outside the art is then the art's alpha. +pub fn whiten(n: &mut Node) { + let white = |p: &mut Paint| match p { + Paint::Solid { color, swatch, tint } => { + (*color, *swatch, *tint) = (Color::WHITE, None, 1.0); + } + Paint::Gradient(g) => { + for s in &mut g.gradient.stops { + (s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0); + } + } + Paint::None | Paint::Pattern { .. } => {} + }; + for item in &mut n.appearance.items { + white(item.paint_mut()); + } + match &mut n.kind { + NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE), + NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| { + white(&mut r.style.fill); + white(&mut r.style.stroke); + }), + _ => {} + } + if let Some(ch) = n.children_mut() { + for c in ch { + whiten(Arc::make_mut(c)); + } + } +} + +/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a +/// knockout group each child composites against the group's backdrop, so it hides the children +/// below it instead of showing them through its transparency. Neutral passes the enclosing +/// group's setting through to the children. Files from before the three states stored a bool: +/// `true` loads as On, `false` as Neutral. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Knockout { + #[default] + Neutral, + Off, + On, +} + +impl Knockout { + pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On]; + + /// Name in files and command parameters. + pub fn label(self) -> &'static str { + match self { + Knockout::Neutral => "neutral", + Knockout::Off => "off", + Knockout::On => "on", + } + } + /// A name ([`Self::label`], any case) or a bool as older files and commands wrote it. + pub fn from_value(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Bool(b) => Some(Self::from(*b)), + serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())), + _ => None, + } + } + /// The state a click on the checkbox moves to: on → neutral → off → on. + pub fn cycle(self) -> Self { + match self { + Knockout::On => Knockout::Neutral, + Knockout::Neutral => Knockout::Off, + Knockout::Off => Knockout::On, + } + } + /// Whether children of a group with this state knock each other out, inside a group (or page) + /// where that is `enclosing`. + pub fn resolve(self, enclosing: bool) -> bool { + match self { + Knockout::Neutral => enclosing, + Knockout::Off => false, + Knockout::On => true, + } + } +} + +impl From for Knockout { + fn from(b: bool) -> Self { + if b { Knockout::On } else { Knockout::Neutral } + } +} + +impl Serialize for Knockout { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.label()) + } +} + +impl<'de> Deserialize<'de> for Knockout { + fn deserialize>(d: D) -> Result { + let v = serde_json::Value::deserialize(d)?; + Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}"))) + } +} diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs new file mode 100644 index 000000000..6cc0cb3be --- /dev/null +++ b/crates/doc/src/node/ops.rs @@ -0,0 +1,712 @@ +use super::extras::Extras; +use super::ids::{LayerColor, NodeId}; +use super::kind::NodeKind; +use super::live_shape::{Scaling, moves_only}; +use super::opacity::Knockout; +use super::*; + +impl Node { + pub fn new(id: NodeId, kind: NodeKind) -> Self { + Self { + id, + name: None, + visible: true, + locked: false, + opacity: 1.0, + blend: BlendMode::Normal, + isolate: false, + knockout: Knockout::Neutral, + knockout_shape: false, + appearance: Appearance::default().into(), + kind, + bbox_angle: 0.0, + extras: Extras::default(), + } + } + + pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { + let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }); + n.appearance = appearance.into(); + n + } + + pub fn group(id: NodeId, children: Vec>) -> Self { + Self::new(id, NodeKind::Group { children, clip: false }) + } + + pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self { + let mut n = Self::new( + id, + NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None }, + ); + n.name = Some(name.to_string()); + n + } + + pub fn children(&self) -> Option<&Vec>> { + match &self.kind { + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Blend { children, .. } + | NodeKind::Envelope { content: children, .. } + | NodeKind::Repeat(RepeatSpec { source: children, .. }) + | NodeKind::Intertwine { children } => Some(children), + _ => None, + } + } + + pub fn children_mut(&mut self) -> Option<&mut Vec>> { + match &mut self.kind { + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Blend { children, .. } + | NodeKind::Envelope { content: children, .. } + | NodeKind::Repeat(RepeatSpec { source: children, .. }) + | NodeKind::Intertwine { children } => Some(children), + _ => None, + } + } + + /// Does this object or layer move with its artboard? Locked and hidden ones only with + /// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with + /// Artboard). + pub fn rides_with_artboard(&self, locked_and_hidden: bool) -> bool { + locked_and_hidden || (!self.locked && self.visible) + } + + pub fn is_layer(&self) -> bool { + matches!(self.kind, NodeKind::Layer { .. }) + } + + /// A template layer (Layer Options → Template). + pub fn is_template(&self) -> bool { + matches!(self.kind, NodeKind::Layer { template: true, .. }) + } + + /// The art objects of this container: its children, looking through sublayers, which are not + /// objects (bottom first). With `editable`, only the visible, unlocked objects in visible, + /// unlocked, non-template sublayers. + pub fn layer_art(&self, editable: bool) -> Vec { + fn visit(n: &Node, editable: bool, out: &mut Vec) { + for c in n.children().into_iter().flatten() { + if editable && (!c.visible || c.locked || c.is_template()) { + continue; + } + if c.is_layer() { + visit(c, editable, out); + } else { + out.push(c.id); + } + } + } + let mut out = vec![]; + visit(self, editable, &mut out); + out + } + + pub fn is_container(&self) -> bool { + self.children().is_some() + } + + /// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel + /// fills an object's target circle otherwise). + pub fn has_default_transparency(&self) -> bool { + self.opacity >= 1.0 + && self.blend == BlendMode::Normal + && !self.isolate + && self.knockout == Knockout::Neutral + && !self.knockout_shape + && self.extras().mask.is_none() + } + + /// Kind name as the Layers panel / Properties panel shows it. + pub fn kind_label(&self) -> &'static str { + match &self.kind { + NodeKind::Layer { .. } => "Layer", + NodeKind::Group { clip: true, .. } => "Clip Group", + NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path", + NodeKind::Group { .. } => "Group", + NodeKind::Path { live: Some(l), .. } => l.label(), + NodeKind::Path { guide: true, .. } => "Guide", + NodeKind::Path { .. } => "Path", + NodeKind::Compound { .. } => "Compound Path", + NodeKind::Text(_) => "Type", + NodeKind::Image(_) => "Image", + NodeKind::SymbolInstance { .. } => "Symbol", + NodeKind::Blend { .. } => "Blend", + NodeKind::Envelope { .. } => "Envelope", + NodeKind::Mesh(_) => "Mesh", + NodeKind::Repeat(r) => r.kind.label(), + NodeKind::PlacedDocument(_) => "Placed Document", + NodeKind::Intertwine { .. } => "Intertwine", + } + } + + /// Name shown in the Layers panel: explicit name or ``. + pub fn display_name(&self) -> String { + if let Some(n) = &self.name { + return n.clone(); + } + match &self.kind { + NodeKind::Text(t) => { + let s: String = t.plain_text().chars().take(32).collect(); + if s.is_empty() { "".into() } else { s } + } + _ => format!("<{}>", self.kind_label()), + } + } + + /// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID. + pub fn identify_label(&self, xml_id: bool) -> String { + let name = self.display_name(); + if xml_id { crate::xml_id::from_object_name(&name) } else { name } + } + + /// Path data for path nodes. + pub fn path_data(&self) -> Option<&PathData> { + match &self.kind { + NodeKind::Path { path, .. } => Some(path), + _ => None, + } + } + + pub fn path_data_mut(&mut self) -> Option<&mut PathData> { + match &mut self.kind { + NodeKind::Path { path, .. } => Some(path), + _ => None, + } + } + + /// The path a path's or compound path's own strokes follow (a compound's members' together). + pub fn stroke_path(&self) -> Option { + match &self.kind { + NodeKind::Path { path, .. } => Some(path.to_bezpath()), + NodeKind::Compound { children, .. } => { + let mut bp = vectorcraft_geom::BezPath::new(); + children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath())); + Some(bp) + } + _ => None, + } + } + + /// A quick box round everything the object can paint, for culling clicks: paths and compound + /// paths grow their geometric bounds by the farthest their strokes can reach from any path + /// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in + /// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers + /// without a clipping mask and Intertwine groups take in their members' boxes, grown by their + /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. + pub fn reach_bounds(&self) -> Option { + self.reach(None) + } + + /// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from + /// `symbols`. An instance that only moves its art is measured as the art's visual box moved, + /// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`]) + /// grown by the farthest a stroke in the art can reach. + pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { + symbols.quickly(|| self.reach(Some(symbols))) + } + + /// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given. + pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option { + match &self.kind { + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + let o = self.appearance.outset(); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } + // The members' reach, grown by the container's own strokes. + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + let o = self.appearance.outset(); + children + .iter() + .skip(usize::from(self.extras().shaper.is_some())) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))) + .map(|b| b.inflate(o, o)) + } + NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))), + // The frame and each shape the image's own fills and strokes paint, a stroke's grown by + // the farthest it can reach from its shape. + NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| { + let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; + vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o))) + }), + _ => self.visual(symbols), + } + } + + /// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A + /// symbol instance is measured as its transform applied to the 20 × 20 square around the origin + /// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures + /// its art). + pub fn geometric_bounds(&self) -> Option { + self.geometric(None) + } + + /// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to + /// its symbol's art box from `symbols`. An instance is measured as the square when its symbol + /// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and + /// when symbols nest 32 deep in its art. + pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option { + self.geometric(Some(symbols)) + } + + /// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a + /// hidden bitmap for re-tracing/release; only source-image views measure that bitmap. + pub fn editing_children(&self) -> &[Arc] { + let children = self.children().map(Vec::as_slice).unwrap_or_default(); + let trace_source = self.extras().trace.is_some() + && !self.trace_view().shows_source() + && children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_))); + let skip = usize::from(self.extras().shaper.is_some() || trace_source); + children.get(skip..).unwrap_or_default() + } + + /// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols` + /// is given. + pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option { + let union = |nodes: &[Arc]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))); + match &self.kind { + NodeKind::Path { path, .. } => path.bounds(), + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + children.first().and_then(|c| c.geometric(symbols)) + } + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self + .editing_children() + .iter() + .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))), + NodeKind::Text(t) => self.projected(t.bounds()), + NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))), + NodeKind::SymbolInstance { symbol, xf } => { + let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric); + self.projected(Some(xf.transform_rect_bbox(art))) + } + NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())), + NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame), + NodeKind::Mesh(m) => m.bounds(), + // Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the + // copies out by the source's bounds measured without the document. + NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())), + NodeKind::PlacedDocument(p) => Some(p.bounds()), + // The members drawn over the others are drawn where they are. + NodeKind::Intertwine { children } => union(children), + } + } + + /// Visual bounds: what the object paints, its strokes included (paths and compound paths + /// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is + /// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`], + /// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art). + pub fn visual_bounds(&self) -> Option { + self.visual(None) + } + + /// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from + /// `symbols`, the strokes in it included. An instance that only moves its art is measured as + /// the art's visual box moved. Any other instance is measured as its art transformed by it with + /// the strokes at their own weight, as the renderer draws it, and never smaller than its + /// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as + /// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of + /// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In + /// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's + /// geometric box placed by its transform, grown by its own strokes. + pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option { + self.visual(Some(symbols)) + } + + /// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is + /// given. + pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option { + match &self.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + // A clipping path that paints (see `clip_paint`) strokes over the clip edge. + let clip = children.first()?; + if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { + clip.visual(symbols) + } else { + clip.geometric(symbols) + } + } + NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))), + NodeKind::Layer { .. } | NodeKind::Group { .. } => { + // The container's own strokes paint around its members. + let o = self.appearance.outset(); + self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o)) + } + NodeKind::Blend { children, spec } => { + let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))); + let o = crate::live::max_outset(children, symbols); + vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o))) + } + NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => { + let o = crate::live::max_outset(content, symbols); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + let b = self.geometric(symbols)?; + Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default())) + } + NodeKind::SymbolInstance { symbol, xf } => { + let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) { + Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric), + Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual), + Some((s, art)) => { + let placed = xf.transform_rect_bbox(art.geometric); + match s.placed_visual(symbol, *xf) { + Some(v) => v.union(placed), + None => placed.inflate(art.reach, art.reach), + } + } + None => xf.transform_rect_bbox(SYMBOL_SQUARE), + }; + let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol)); + self.projected(Some(b)).map(|b| b.inflate(o, o)) + } + NodeKind::Text(t) => { + // The characters' strokes paint around the glyphs. + let o = self.appearance.outset().max(t.char_stroke_outset()); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } + // An image is measured by its frame, without its own fills and strokes. + NodeKind::Image(_) => self.geometric(symbols), + _ => { + let o = self.appearance.outset(); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } + } + } + + /// Apply an affine transform to the geometry (and gradients) of this node and its descendants. + /// `scaling` says what else scales by the transform's mean scale (a bool: stroke weights + /// only, see [`Scaling`]). + pub fn transform(&mut self, a: Affine, scaling: impl Into) { + self.transform_scaled(a, &scaling.into()); + } + + fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { + // A dimension's measurement moves with it. + if let Some(e) = self.extras_if_any() + && let Some(dimension) = &mut e.dimension + { + dimension.transform(a); + } + // The frames of the objects attached to a path move with them. + if let Some(e) = self.extras_if_any() + && let Some(on_path) = &mut e.objects_on_path + { + on_path.transform(a); + } + if !self.is_layer() { + self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); + } + // Type and symbols in perspective keep looking the same, moved by `a`. + self.transform_projection(a); + // Refitting an unplaced gradient only reproduces moves and uniform scales. + if !keeps_gradient_fit(a) { + self.pin_gradients(); + } + let k = Scaling::factor(a); + if let Some(k) = k + && sc.strokes + { + self.appearance.scale_strokes(k); + if let Some(f) = sc.effects { + self.appearance.scale_effects(k, f); + } + } + // Only through `DerefMut` when there is something to change: the appearance is shared + // copy-on-write (`Shared`), and a move must not copy what it leaves as it is. + if self.appearance.gradients().next().is_some() { + self.appearance.transform_gradients(a); + } + if sc.patterns { + crate::pattern::transform_pattern_paints(self, a); + } + match &mut self.kind { + NodeKind::Path { path, live, .. } => { + path.transform(a); + if let Some(l) = live { + // A rectangle scaled unevenly keeps circular corners: its path is regenerated. + let reshaped = l.transform(a); + let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k)); + if reshaped || kept { + *path = l.to_path(); + } + } + } + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Intertwine { children } => { + for c in children.iter_mut() { + Arc::make_mut(c).transform_scaled(a, sc); + } + } + NodeKind::Text(t) => { + t.transform(a); + // Character strokes scale with the type: undo that when strokes keep their weight. + if sc.keep_type_strokes + && let Some(k) = k.filter(|k| *k > 1e-12) + { + t.scale_char_strokes(1.0 / k); + } + } + NodeKind::Image(im) => im.xf = a * im.xf, + NodeKind::PlacedDocument(p) => p.xf = a * p.xf, + NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf, + NodeKind::Blend { children, spec } => { + for c in children.iter_mut() { + Arc::make_mut(c).transform_scaled(a, sc); + } + if let Some(s) = &mut spec.spine { + s.transform(a); + } + } + NodeKind::Envelope { content, kind, frame, .. } => { + for c in content.iter_mut() { + Arc::make_mut(c).transform_scaled(a, sc); + } + **frame = crate::live::envelope_frame(a * **frame); + match &mut **kind { + EnvelopeKind::Mesh { points, handles, .. } => { + for p in points.iter_mut() { + *p = a * *p; + } + // Handles are offsets: they take the linear part. + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]); + for h in handles.iter_mut().flatten() { + *h = (lin * h.to_point()).to_vec2(); + } + } + EnvelopeKind::TopObject { path } => path.transform(a), + EnvelopeKind::Warp { .. } => {} + } + } + NodeKind::Mesh(m) => m.transform(a), + NodeKind::Repeat(r) => r.transform(a, *sc), + } + if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + Arc::make_mut(&mut m.art).transform_scaled(a, sc); + } + // The area where an Intertwine member is drawn over the others moves with it. + if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) { + area.transform(a); + } + } + + /// Visit this node and all descendants depth first (paint order). + pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { + f(self); + if let Some(ch) = self.children() { + for c in ch { + c.walk(f); + } + } + } + + /// Count of nodes in this subtree. + pub fn count(&self) -> usize { + let mut n = 0; + self.walk(&mut |_| n += 1); + n + } + + /// [`Self::count`], with the art of the opacity masks in the subtree (and theirs): what copying + /// and transforming the subtree makes, as [`Self::walk`] doesn't go into masks. + pub fn count_with_masks(&self) -> usize { + let mut n = 0; + self.walk(&mut |c| { + n += 1; + if let Some(m) = &c.extras().mask { + n += m.art.count_with_masks(); + } + }); + n + } + + /// Fix this node's unplaced gradients to their fit on its current bounds (before a transform + /// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed. + pub fn pin_gradients(&mut self) { + if self.appearance.has_unplaced_gradient() + && let Some(b) = self.geometric_bounds() + { + self.appearance.pin_gradients(b); + } + } + + /// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and + /// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active + /// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise + /// type objects paint their runs (the first run speaks for all) in text space, fitted to the + /// layout bounds; everything else paints its fill or stroke in the document, fitted to the + /// geometric bounds. Strokes fit the box grown by half their weight. + pub fn proxy_paint(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> { + let item = self.appearance.item_of_kind(item, !stroke); + if item.is_none() + && let NodeKind::Text(t) = &self.kind + { + let st = &t.runs.first()?.style; + let b = t.local_bounds(); + return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) }); + } + // An object without bounds (an empty path) still shows its paints, fitted to an empty box. + let b = self.geometric_bounds().unwrap_or(Rect::ZERO); + if stroke { + self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b))) + } else { + self.appearance.fill_at(item).map(|f| (&f.paint, Affine::IDENTITY, b)) + } + } + + /// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates + /// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits. + pub fn proxy_gradient(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> { + let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; + let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; + let mut geom = g.resolve(b); + if to_doc != Affine::IDENTITY { + geom.transform(to_doc, g.gradient.kind); + } + Some((g, geom)) + } + + /// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates + /// (the automatic ones while unplaced), and the length its spreads are fractions of there + /// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits. + pub fn proxy_freeform(&self, stroke: bool, item: Option) -> Option<(vectorcraft_color::Freeform, f64)> { + let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; + let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; + if g.gradient.kind != vectorcraft_color::GradientKind::Freeform { + return None; + } + let mut f = g.freeform_on(b).into_owned(); + let mut scale = vectorcraft_color::freeform::spread_scale(b); + if to_doc != Affine::IDENTITY { + f.transform(to_doc); + scale *= to_doc.determinant().abs().sqrt(); + } + Some((f, scale)) + } +} + +impl Node { + /// Whether the children of this group or layer knock each other out, inside a group (or page) + /// where that is `enclosing`. + pub fn knocks_out(&self, enclosing: bool) -> bool { + matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing) + } + + /// A plain group or layer that is neutral to knockout and has no transparency or appearance + /// of its own: inside a knockout group its children composite as the group's own children. + pub fn passes_knockout_through(&self) -> bool { + matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. }) + && self.has_default_transparency() + && self.appearance.items.is_empty() + && self.appearance.effects.is_empty() + } + + /// The visible elements of a knockout group made of `children` (bottom first): neutral plain + /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. + pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { + fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { + for c in children.iter().filter(|c| c.visible) { + match c.children() { + Some(ch) if c.passes_knockout_through() => push(ch, out), + _ => out.push(c), + } + } + } + let mut out = Vec::with_capacity(children.len()); + push(children, &mut out); + out + } + + /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. + pub fn clips(&self) -> bool { + matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) + } + + /// Make this group or layer clip its children by its first child, or stop it. + pub fn set_clips(&mut self, on: bool) { + if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind { + *clip = on; + } + } + + /// Whether blending inside this object reaches the art below it unless the object isolates + /// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke), + /// directly or through children that don't isolate their own blending. + pub fn blends_through(&self) -> bool { + self.blends_through_with(&mut |c| c.blends_through()) + } + + /// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache). + pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc) -> bool) -> bool { + match self.children() { + Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner), + _ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal), + } + } + + /// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child). + pub fn children_blend(children: &[Arc], inner: &mut dyn FnMut(&Arc) -> bool) -> bool { + children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c)))) + } +} + +impl Node { + /// The Appearance panel's row for what this object's own fills and strokes paint around: + /// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters" + /// for type; `None` for objects whose fills and strokes paint their own geometry. + pub fn contents_label(&self) -> Option<&'static str> { + match self.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"), + NodeKind::Text(_) => Some("Characters"), + _ => None, + } + } +} + +impl Node { + /// Whether this object shows any transparency, itself or anything inside it: object, fill or + /// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient + /// stops. Hidden objects show none. + pub fn shows_transparency(&self) -> bool { + use vectorcraft_color::Paint; + let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); + let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible); + self.visible + && (self.opacity < 1.0 + || self.blend != BlendMode::Normal + || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) + || effects(&self.appearance.effects) + || self + .appearance + .items + .iter() + .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) + || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency()))) + } +} + +/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? +fn keeps_gradient_fit(a: Affine) -> bool { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let eps = 1e-12 * m0.abs().max(1.0); + m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps +} + +impl Node { + /// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true + /// everywhere for objects without them, such as type, whose boxes stand in). + pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> { + let shapes = self.clip_shapes(None); + move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)) + } +} diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs new file mode 100644 index 000000000..43fcfea92 --- /dev/null +++ b/crates/doc/src/node/wire.rs @@ -0,0 +1,181 @@ +use super::attributes::ObjectAttributes; +use super::extras::{Extras, NO_EXTRAS, NodeExtras}; +use super::ids::NodeId; +use super::kind::NodeKind; +use super::opacity::{Knockout, OpacityMask}; +use super::*; + +/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from +/// the node and its extras (nothing is copied). The node reads back through `NodeWire`. +impl Serialize for Node { + fn serialize(&self, s: S) -> Result { + use crate::skip::{is_default, is_one, is_true}; + use serde::ser::SerializeStruct; + let e = self.extras(); + // As the derive did: the fields written, then each field written or skipped in order. + let skips = [ + false, + self.name.is_none(), + is_true(&self.visible), + is_default(&self.locked), + is_one(&self.opacity), + is_default(&self.blend), + is_default(&self.isolate), + is_default(&self.knockout), + is_default(&self.knockout_shape), + is_default(&self.appearance), + e.mask.is_none(), + e.trace.is_none(), + e.wrap.is_none(), + e.graph.is_none(), + e.series_index.is_none(), + is_default(&e.type_outlines), + e.shaper.is_none(), + false, + e.graphic_style.is_none(), + e.attrs.is_none(), + e.slice.is_none(), + is_default(&self.bbox_angle), + e.perspective.is_none(), + e.dimension.is_none(), + e.intertwine.is_none(), + e.live_paint_gaps.is_none(), + e.objects_on_path.is_none(), + ]; + let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; + let mut skip = skips.into_iter(); + macro_rules! field { + ($name:literal, $v:expr) => { + if skip.next().unwrap_or(false) { + st.skip_field($name)?; + } else { + st.serialize_field($name, $v)?; + } + }; + } + field!("id", &self.id); + field!("name", &self.name); + field!("visible", &self.visible); + field!("locked", &self.locked); + field!("opacity", &self.opacity); + field!("blend", &self.blend); + field!("isolate", &self.isolate); + field!("knockout", &self.knockout); + field!("knockout_shape", &self.knockout_shape); + field!("appearance", &self.appearance); + field!("mask", &e.mask); + field!("trace", &e.trace); + field!("wrap", &e.wrap); + field!("graph", &e.graph); + field!("series_index", &e.series_index); + field!("type_outlines", &e.type_outlines); + field!("shaper", &e.shaper); + field!("kind", &self.kind); + field!("graphic_style", &e.graphic_style); + field!("attrs", &e.attrs); + field!("slice", &e.slice); + field!("bbox_angle", &self.bbox_angle); + field!("perspective", &e.perspective); + field!("dimension", &e.dimension); + field!("intertwine", &e.intertwine); + field!("live_paint_gaps", &e.live_paint_gaps); + field!("objects_on_path", &e.objects_on_path); + st.end() + } +} + +/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node +/// (and its extras, allocated only when one is set) without a copy of what they own. +#[derive(Deserialize)] +#[serde(rename = "Node")] +pub(super) struct NodeWire { + id: NodeId, + #[serde(default)] + name: Option, + #[serde(default = "yes")] + visible: bool, + #[serde(default)] + locked: bool, + #[serde(default = "one")] + opacity: f32, + #[serde(default)] + blend: BlendMode, + #[serde(default)] + isolate: bool, + #[serde(default)] + knockout: Knockout, + #[serde(default)] + knockout_shape: bool, + #[serde(default)] + appearance: crate::Shared, + #[serde(default)] + mask: Option>, + #[serde(default)] + trace: Option>, + #[serde(default)] + wrap: Option, + #[serde(default)] + graph: Option>, + #[serde(default)] + series_index: Option, + #[serde(default)] + type_outlines: bool, + #[serde(default)] + shaper: Option>, + kind: NodeKind, + #[serde(default)] + graphic_style: Option, + #[serde(default)] + attrs: Option>, + #[serde(default)] + slice: Option>, + #[serde(default)] + bbox_angle: f64, + #[serde(default)] + perspective: Option>, + #[serde(default)] + dimension: Option>, + #[serde(default)] + intertwine: Option>, + #[serde(default)] + live_paint_gaps: Option>, + #[serde(default)] + objects_on_path: Option>, +} + +impl From for Node { + fn from(w: NodeWire) -> Self { + let extras = NodeExtras { + mask: w.mask, + trace: w.trace, + wrap: w.wrap, + graph: w.graph, + series_index: w.series_index, + type_outlines: w.type_outlines, + shaper: w.shaper, + graphic_style: w.graphic_style, + attrs: w.attrs, + slice: w.slice, + perspective: w.perspective, + dimension: w.dimension, + intertwine: w.intertwine, + live_paint_gaps: w.live_paint_gaps, + objects_on_path: w.objects_on_path, + }; + Node { + id: w.id, + name: w.name, + visible: w.visible, + locked: w.locked, + opacity: w.opacity, + blend: w.blend, + isolate: w.isolate, + knockout: w.knockout, + knockout_shape: w.knockout_shape, + appearance: w.appearance, + kind: w.kind, + bbox_angle: w.bbox_angle, + extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))), + } + } +} From 901c72b0890de38bb9a317dc90fec9d34b7cd5d4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:48:52 +0000 Subject: [PATCH 117/199] refactor(doc): move text.rs items to text/{area,char_style,object,para_style,runs,tabs_lists}.rs --- crates/doc/src/text.rs | 2120 +---------------------------- crates/doc/src/text/area.rs | 228 ++++ crates/doc/src/text/char_style.rs | 432 ++++++ crates/doc/src/text/object.rs | 493 +++++++ crates/doc/src/text/para_style.rs | 517 +++++++ crates/doc/src/text/runs.rs | 207 +++ crates/doc/src/text/tabs_lists.rs | 249 ++++ 7 files changed, 2142 insertions(+), 2104 deletions(-) create mode 100644 crates/doc/src/text/area.rs create mode 100644 crates/doc/src/text/char_style.rs create mode 100644 crates/doc/src/text/object.rs create mode 100644 crates/doc/src/text/para_style.rs create mode 100644 crates/doc/src/text/runs.rs create mode 100644 crates/doc/src/text/tabs_lists.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index f95c9ee28..a3ab2ccc5 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -6,1623 +6,22 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Justify { - /// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for - /// left-to-right paragraphs, right for right-to-left ones. New type's alignment. - Auto, - #[default] - Left, - Center, - Right, - JustifyLeft, - JustifyCenter, - JustifyRight, - JustifyAll, -} - -/// Character attributes (the Character panel). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct CharStyle { - pub font_family: String, - #[serde(default = "regular")] - pub font_style: String, - /// Which installed version of the font (its version string) when several versions of the - /// family and style are installed and the text was set in one the family and style alone - /// wouldn't pick (an imported file's type matched to it); None = the one they pick. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font_version: Option, - /// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag - /// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of - /// each axis they name; the other axes keep the style's settings. Empty: the style as it is. - #[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub font_axes: std::collections::BTreeMap, - /// Size in points. - pub size: f64, - /// Leading in points; None = Auto (120% of size). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub leading: Option, - /// Tracking in 1/1000 em. - #[serde(default)] - pub tracking: f64, - /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub kerning: Option, - #[serde(default)] - pub baseline_shift: f64, - #[serde(default = "hundred")] - pub h_scale: f64, - #[serde(default = "hundred")] - pub v_scale: f64, - #[serde(default)] - pub rotation: f64, - pub fill: Paint, - #[serde(default)] - pub stroke: Paint, - #[serde(default)] - pub stroke_width: f64, - #[serde(default)] - pub underline: bool, - #[serde(default)] - pub strikethrough: bool, - #[serde(default)] - pub all_caps: bool, - /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns - /// a feature on, `"-liga"` off. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub features: Vec, - /// Character style (Character Styles panel) these attributes come from; None = Normal. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub style_name: Option, - /// Overprint Fill / Overprint Stroke of these characters (see [`crate::FillLayer::overprint`]). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub overprint_fill: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub overprint_stroke: bool, - /// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type - /// selected); defaults as for object strokes. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub stroke_cap: LineCap, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub stroke_join: LineJoin, - #[serde(default = "ten", skip_serializing_if = "is_ten")] - pub stroke_miter_limit: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stroke_dash: Option, - /// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the - /// glyph outlines, inside or outside them, as for object strokes; the type stays live. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub stroke_align: StrokeAlign, - /// Superscript or subscript (Character panel). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub position: CharPosition, - /// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the - /// size (Document Setup → Type → Small Caps); None = off. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub small_caps: Option, - /// Character Alignment (Character panel menu): where characters smaller than the largest on - /// their line line up with it. - #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] - pub char_align: CharAlign, - /// Character Direction (Character panel menu, Middle Eastern options): these characters are - /// set left to right or right to left inside their paragraph, each stretch of them a Unicode - /// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives - /// them. - #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] - pub char_direction: Option, - /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the - /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take - /// its proportional heights, `vpal`. - #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] - pub proportional_metrics: bool, - /// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters - /// stand side by side across one em of the column. Horizontal type keeps the mark and does not - /// move them. Two or three half-width digits do this without the mark. - /// - #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] - pub tate_chu_yoko: bool, - /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of - /// these characters are drawn in, whatever digits were typed. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub digits: Digits, - /// No Break (Character panel menu): these characters are not broken across lines, including - /// at a space or a hyphenation point inside the run. A run longer than the line stays together - /// and can overflow it. - /// - #[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")] - pub no_break: bool, -} - -/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of -/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps -/// the digits that were typed; only the glyphs change (like All Caps). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Digits { - /// Each digit as typed. - #[default] - Default, - /// Western digits 0–9 (the digits Arabic typography calls Arabic). - Arabic, - /// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic. - Hindi, - /// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu. - Farsi, -} - -impl Digits { - /// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c` - /// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set. - pub fn map(self, c: char) -> char { - let value = match c { - '0'..='9' => c as u32 - '0' as u32, - '\u{660}'..='\u{669}' => c as u32 - 0x660, - '\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0, - _ => return c, - }; - let base = match self { - Digits::Default => return c, - Digits::Arabic => '0' as u32, - Digits::Hindi => 0x660, - Digits::Farsi => 0x6F0, - }; - char::from_u32(base + value).unwrap_or(c) - } -} - -/// Where a character smaller than the largest on its line lines up with it: on the Roman -/// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic -/// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the -/// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CharAlign { - #[default] - RomanBaseline, - EmBoxTop, - EmBoxCenter, - EmBoxBottom, - IcfTop, - IcfBottom, -} - -/// Superscript or subscript proportions in percent of the font size (Document Setup → Type). -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct ScriptMetrics { - /// Glyph size. - pub size: f64, - /// Baseline offset: up for superscript, down for subscript. - pub position: f64, -} - -impl ScriptMetrics { - pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 }; - /// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to - /// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside - /// its range becomes the nearer end. - pub fn in_range(self) -> Self { - Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) } - } -} - -/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for -/// `smallCapsSize`. A size outside it becomes the nearer end. -fn small_caps_in_range(size: f64) -> f64 { - size.clamp(1.0, 200.0) -} - -impl Default for ScriptMetrics { - fn default() -> Self { - Self::DEFAULT - } -} - -/// Character position (Character panel Superscript / Subscript). The proportions are the -/// document's when the position is applied, and follow later Document Setup changes. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum CharPosition { - #[default] - Normal, - Superscript(ScriptMetrics), - Subscript(ScriptMetrics), -} - -impl CharPosition { - /// Glyph scale and baseline shift (points, positive = up) for text of `size` points. - pub fn scale_shift(self, size: f64) -> (f64, f64) { - match self { - CharPosition::Normal => (1.0, 0.0), - CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size), - CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size), - } - } - /// `normal`, `superscript` or `subscript`. - pub fn id(self) -> &'static str { - match self { - CharPosition::Normal => "normal", - CharPosition::Superscript(_) => "superscript", - CharPosition::Subscript(_) => "subscript", - } - } - /// Returns this position with its proportions kept by [`ScriptMetrics::in_range`]. - pub fn in_range(self) -> Self { - match self { - CharPosition::Normal => CharPosition::Normal, - CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()), - CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()), - } - } -} - -fn ten() -> f64 { - 10.0 -} -fn is_ten(v: &f64) -> bool { - *v == 10.0 -} -fn regular() -> String { - "Regular".into() -} -fn hundred() -> f64 { - 100.0 -} -fn yes() -> bool { - true -} - -impl Default for CharStyle { - fn default() -> Self { - Self { - font_family: "Source Sans 3".into(), - font_style: "Regular".into(), - font_version: None, - font_axes: std::collections::BTreeMap::new(), - size: 12.0, - leading: None, - tracking: 0.0, - kerning: None, - baseline_shift: 0.0, - h_scale: 100.0, - v_scale: 100.0, - rotation: 0.0, - fill: Paint::solid(Color::BLACK), - stroke: Paint::None, - stroke_width: 0.0, - underline: false, - strikethrough: false, - all_caps: false, - features: vec![], - style_name: None, - overprint_fill: false, - overprint_stroke: false, - stroke_cap: LineCap::Butt, - stroke_join: LineJoin::Miter, - stroke_miter_limit: 10.0, - stroke_dash: None, - stroke_align: StrokeAlign::Center, - position: CharPosition::Normal, - char_align: CharAlign::RomanBaseline, - char_direction: None, - proportional_metrics: false, - tate_chu_yoko: false, - small_caps: None, - digits: Digits::Default, - no_break: false, - } - } -} - -impl CharStyle { - pub fn effective_leading(&self) -> f64 { - self.leading.unwrap_or(self.size * 1.2) - } - /// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size - /// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading - /// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way) - /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a - /// baseline shift that is not a number becomes 0. - pub fn keep_dimensions_in_range(&mut self) { - let default = Self::default(); - let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; - self.size = within(self.size, default.size); - self.h_scale = within(self.h_scale, default.h_scale); - self.v_scale = within(self.v_scale, default.v_scale); - self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD)); - self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) }; - } - /// Do the characters draw a stroke (a paint and a positive weight)? - pub fn has_stroke(&self) -> bool { - !self.stroke.is_none() && self.stroke_width > 0.0 - } - /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes, - /// alignment and overprint), so type strokes share the object strokes' geometry, rendering - /// and export. - pub fn stroke_layer(&self) -> StrokeLayer { - StrokeLayer { - overprint: self.overprint_stroke, - cap: self.stroke_cap, - join: self.stroke_join, - miter_limit: self.stroke_miter_limit, - dash: self.stroke_dash.clone(), - align: self.stroke_align, - ..StrokeLayer::new(self.stroke.clone(), self.stroke_width) - } - } - /// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the - /// character stroke (the options characters have no use for, such as arrowheads and width - /// profiles, are dropped). - pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { - self.stroke = st.paint.clone(); - self.stroke_width = st.width; - self.stroke_cap = st.cap; - self.stroke_join = st.join; - self.stroke_miter_limit = st.miter_limit; - self.stroke_dash = st.dash.clone(); - self.stroke_align = st.align; - } - /// How far the character stroke paints past the glyph outlines (text space): half the weight - /// centred, the whole weight outside, nothing inside (miter spikes aside). - pub fn stroke_outset(&self) -> f64 { - if !self.has_stroke() { - return 0.0; - } - match self.stroke_align { - StrokeAlign::Center => self.stroke_width / 2.0, - StrokeAlign::Outside => self.stroke_width, - StrokeAlign::Inside => 0.0, - } - } - /// The character fill as a fill layer, overprinting as the characters do. - fn fill_layer(&self) -> FillLayer { - FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) } - } - /// The characters' paint as an object appearance (their outlines'): the fill, plus the - /// stroke when it draws one, overprinting as the characters do. - pub fn appearance(&self) -> Appearance { - let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() }; - if self.has_stroke() { - a.items.push(AppearanceItem::Stroke(self.stroke_layer())); - } - a - } - /// The characters' paint as the basic fill and stroke rows (the stroke row even when it has - /// no paint): the appearance the Eyedropper and graphic styles take from type. - pub fn basic_appearance(&self) -> Appearance { - Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() } - } - /// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and - /// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`. - pub fn keep_scripts_in_range(&mut self) { - self.position = self.position.in_range(); - self.small_caps = self.small_caps.map(small_caps_in_range); - } -} - -impl crate::Document { - /// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup` - /// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph - /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts - /// back (opacity-mask art included), in graphs' label styles and in the character styles. It - /// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`]. - pub fn keep_scripts_in_range(&mut self) { - let mut fix = |n: &mut crate::Node| { - if let crate::NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - r.style.keep_scripts_in_range(); - } - } - if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { - let l = &mut g.label_styles; - for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { - st.keep_scripts_in_range(); - } - } - }; - let roots = self - .layers - .iter_mut() - .chain(self.symbols.iter_mut().map(|s| &mut s.art)) - .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) - .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) - .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); - for root in roots { - crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); - } - // Character styles store the attributes they set by their serialized names. - for def in self.char_styles.iter_mut() { - if let Some(v) = def.attrs.get_mut("position") - && let Ok(p) = serde_json::from_value::(v.clone()) - && p.in_range() != p - { - *v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null); - } - if let Some(v) = def.attrs.get_mut("small_caps") - && let Some(size) = v.as_f64() - && small_caps_in_range(size) != size - { - *v = small_caps_in_range(size).into(); - } - } - } -} - -/// Tab stop alignment (Tabs panel). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TabAlign { - #[default] - Left, - Center, - Right, - /// Aligns on the first `align_on` character (a decimal point by default). - Decimal, -} - -/// A tab stop, measured from the left edge of the text (area type: the frame's left edge). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TabStop { - pub position: f64, - #[serde(default)] - pub align: TabAlign, - /// Leader characters repeated across the tab's gap (e.g. ". "). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub leader: String, - /// Decimal tabs align on this character. - #[serde(default = "default_align_on")] - pub align_on: char, -} - -fn default_align_on() -> char { - '.' -} - -/// Distance between default tab stops when no explicit stop applies (½ inch). -pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; - -/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Bullet { - /// A filled dot (•). - #[default] - Dot, - /// A dash (–). - Dash, - /// A hollow circle (◦). - Circle, - /// A filled square (▪). - Square, -} - -impl Bullet { - /// The character the layout draws for the bullet. - pub fn character(self) -> char { - match self { - Bullet::Dot => '\u{2022}', - Bullet::Dash => '\u{2013}', - Bullet::Circle => '\u{25E6}', - Bullet::Square => '\u{25AA}', - } - } -} - -/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered -/// List presets. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum NumberStyle { - /// 1, 2, 3… - #[default] - Arabic, - /// A, B, C… Z, AA, BB… - UpperLetters, - /// a, b, c… z, aa, bb… - LowerLetters, - /// I, II, III, IV… - UpperRoman, - /// i, ii, iii, iv… - LowerRoman, - /// 01, 02… 99, 100… - TwoDigits, - /// 001, 002… 999, 1000… - ThreeDigits, -} - -/// Past this number, roman numerals and letters are written in Arabic digits. -const MAX_LETTERS_OR_ROMAN: u32 = 3999; - -impl NumberStyle { - /// Every style, in the order the Numbered List presets list them. - pub const ALL: [NumberStyle; 7] = [ - NumberStyle::Arabic, - NumberStyle::UpperLetters, - NumberStyle::LowerLetters, - NumberStyle::UpperRoman, - NumberStyle::LowerRoman, - NumberStyle::TwoDigits, - NumberStyle::ThreeDigits, - ]; - - /// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then - /// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD, - /// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and - /// keep every digit of a longer number. - pub fn format(self, n: u32) -> String { - let letters_or_roman = - matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman); - if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) { - return n.to_string(); - } - match self { - NumberStyle::Arabic => n.to_string(), - NumberStyle::TwoDigits => format!("{n:02}"), - NumberStyle::ThreeDigits => format!("{n:03}"), - NumberStyle::UpperLetters => letters(n), - NumberStyle::LowerLetters => letters(n).to_lowercase(), - NumberStyle::UpperRoman => roman(n), - NumberStyle::LowerRoman => roman(n).to_lowercase(), - } - } -} - -/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on. -fn letters(n: u32) -> String { - let n = n - 1; - let letter = char::from(b'A' + (n % 26) as u8); - std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect() -} - -/// `n` (1 to 3999) in capital roman numerals. -fn roman(mut n: u32) -> String { - const DIGITS: [(u32, &str); 13] = [ - (1000, "M"), - (900, "CM"), - (500, "D"), - (400, "CD"), - (100, "C"), - (90, "XC"), - (50, "L"), - (40, "XL"), - (10, "X"), - (9, "IX"), - (5, "V"), - (4, "IV"), - (1, "I"), - ]; - let mut s = String::new(); - for (value, digits) in DIGITS { - while n >= value { - s.push_str(digits); - n -= value; - } - } - s -} - -/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the -/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's -/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual -/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after -/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a -/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves -/// out as their defaults, and a build ignores the fields it doesn't know. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(default)] -pub struct ParaList { - /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for - /// when the list is made bulleted again. - pub bullet: Bullet, - /// A numbered list's number style. A bulleted list has none, and the file saves it only for a - /// numbered list. - #[serde(skip_serializing_if = "Option::is_none")] - pub number: Option, -} - -impl ParaList { - /// A bulleted list with `bullet`. - pub fn bulleted(bullet: Bullet) -> Self { - Self { bullet, number: None } - } - - /// A numbered list with `style`. - pub fn numbered(style: NumberStyle) -> Self { - Self { bullet: Bullet::default(), number: Some(style) } - } - - /// The characters of the marker the layout draws before the paragraph: the bullet, or for a - /// numbered list number `n` in its style followed by a period. - pub fn marker(&self, n: u32) -> String { - match self.number { - Some(style) => format!("{}.", style.format(n)), - None => self.bullet.character().to_string(), - } - } - - /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and - /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. - /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has - /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the - /// count as it is; a numbered paragraph adds one to the count and is numbered with it. - pub fn next_marker(list: Option, empty: bool, count: &mut u32) -> Option { - let Some(list) = list else { - *count = 0; - return None; - }; - if empty { - return None; - } - if list.number.is_some() { - *count = count.saturating_add(1); - } - Some(list.marker(*count)) - } - - /// The style the marker is set in: `first` (the style of the paragraph's first character) - /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// tate-chu-yoko, underline and strikethrough. - pub fn marker_style(first: &CharStyle) -> CharStyle { - CharStyle { - baseline_shift: 0.0, - rotation: 0.0, - position: CharPosition::Normal, - tracking: 0.0, - kerning: None, - tate_chu_yoko: false, - underline: false, - strikethrough: false, - ..first.clone() - } - } - - /// The list that the Type tool starts when `typed` and a space are typed at the start of a - /// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B, - /// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts - /// none. - pub fn typed(typed: &str) -> Option { - match typed { - "1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)), - "A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)), - "*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)), - _ => None, - } - } -} - -/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the -/// marker hangs in the first line's 18 pt before the text. -pub const LIST_HANGING_INDENT: f64 = 18.0; - -/// Paragraph composer (Paragraph panel menu): how lines are broken. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Composer { - /// Break each line as soon as it is full. - SingleLine, - /// Total fit over the whole paragraph (Illustrator's default): justified lines get even word - /// spacing, ragged lines an even rag. - #[default] - EveryLine, -} - -impl Composer { - fn is_default(&self) -> bool { - *self == Self::default() - } -} - -/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic -/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from -/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen -/// limit, no hyphenation zone, and capitalized words hyphenated. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct HyphenationOptions { - /// Words Longer Than: only words with more letters than this are hyphenated. - pub words_longer_than: u32, - /// After First: the fewest letters a word keeps before the hyphen. - pub after_first: u32, - /// Before Last: the fewest letters a word keeps after the hyphen. - pub before_last: u32, - /// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen; - /// 0 sets no limit. - pub hyphen_limit: u32, - /// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next - /// word already ends within this distance of its right edge breaks before that word instead - /// of hyphenating it. 0 allows every hyphenation. - pub zone: f64, - /// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not - /// hyphenated. Words in capitals only are never hyphenated. - pub capitalized: bool, -} - -impl Default for HyphenationOptions { - fn default() -> Self { - Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true } - } -} - -/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum SingleWordJustify { - /// Spread across the line, between its letters. - #[default] - FullJustify, - AlignLeft, - AlignCenter, - AlignRight, -} - -/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent. -/// -/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired -/// applies to every line of the paragraph, justified or not: word spaces are that share of the -/// font's word space, each letter is followed by that share of a word space, and glyphs are that -/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only -/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph -/// scaling 100/100/100, Auto Leading 120 and Full Justify. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct JustificationOptions { - /// Word Spacing, percent of the font's word space (0..1000). - pub word_spacing: [f64; 3], - /// Letter Spacing, percent of the font's word space added after each letter (-100..500). - pub letter_spacing: [f64; 3], - /// Glyph Scaling, percent of the glyphs' width (50..200). - pub glyph_scaling: [f64; 3], - /// Auto Leading: the leading of characters whose leading is Auto, in percent of their size - /// (0..500). - pub auto_leading: f64, - /// Single Word Justification: how a justified line holding one word is set. - pub single_word: SingleWordJustify, -} - -impl JustificationOptions { - /// Word Spacing's range (percent). - pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0); - /// Letter Spacing's range (percent). - pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0); - /// Glyph Scaling's range (percent). - pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0); - /// Auto Leading's range (percent). - pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0); - - /// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change - /// in a line's natural widths)? - pub fn desired_is_default(&self) -> bool { - let d = Self::default(); - self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1] - } - - /// Keeps options read from a file to what `text.setFormat` can set: a value that is not a - /// finite number becomes its default, every value is clamped to its range, and a Minimum, - /// Desired and Maximum out of order go back to their defaults together. - pub fn keep_in_range(&mut self) { - let d = Self::default(); - for (v, dv, (lo, hi)) in [ - (&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING), - (&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING), - (&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING), - ] { - for (x, dx) in v.iter_mut().zip(dv) { - *x = if x.is_finite() { x.clamp(lo, hi) } else { dx }; - } - if !(v[0] <= v[1] && v[1] <= v[2]) { - *v = dv; - } - } - let (lo, hi) = Self::AUTO_LEADING; - self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading }; - } - - /// A paragraph's leading for characters of `size` whose leading is Auto. - pub fn auto_leading_for(&self, size: f64) -> f64 { - size * self.auto_leading / 100.0 - } -} - -impl Default for JustificationOptions { - fn default() -> Self { - Self { - word_spacing: [80.0, 100.0, 133.0], - letter_spacing: [0.0, 0.0, 0.0], - glyph_scaling: [100.0, 100.0, 100.0], - auto_leading: 120.0, - single_word: SingleWordJustify::FullJustify, - } - } -} - -/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of -/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair -/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter -/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font -/// draws them, at most an em long. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Kashida { - /// No kashidas: justified lines widen their word spaces. - #[default] - None, - Short, - Medium, - Long, - Stylistic, -} - -impl Kashida { - /// The longest kashida, in ems of the letter that follows it in the text; 0 for None. - pub fn max_em(self) -> f64 { - match self { - Kashida::None => 0.0, - Kashida::Short => 0.25, - Kashida::Medium => 0.5, - Kashida::Long | Kashida::Stylistic => 1.0, - } - } -} - -/// Paragraph attributes (the Paragraph panel). -/// -/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before -/// [`Justify::Auto`]. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(from = "ParaStyleFile", into = "ParaStyleFile")] -pub struct ParaStyle { - pub justify: Justify, - pub left_indent: f64, - pub right_indent: f64, - pub first_line_indent: f64, - pub space_before: f64, - pub space_after: f64, - pub hyphenate: bool, - /// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…). - pub hyphenation: HyphenationOptions, - /// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification - /// (Paragraph panel menu › Justification…). - pub justification: JustificationOptions, - /// Line breaking: Single-line or Every-line Composer. - pub composer: Composer, - /// Tab stops (Tabs panel), sorted by position. - pub tabs: Vec, - /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not - /// in a list. - pub list: Option, - /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. - pub style_name: Option, - /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made - /// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before - /// it and imported text (already set) keep [`Mojikumi::None`]. - pub mojikumi: Mojikumi, - /// Paragraph direction (Paragraph panel): the base direction of each paragraph for - /// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or - /// Arabic: right to left). - pub direction: Option, - /// How leading is measured (Paragraph panel menu): from baseline to baseline, or from the top - /// of one line's ideographic em box to the next. - pub leading_model: LeadingModel, - /// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line - /// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before - /// it and imported text keep [`Burasagari::None`]. - pub burasagari: Burasagari, - /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a - /// line. - pub kinsoku: Kinsoku, - /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and - /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, - /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. - pub roman_hanging: bool, - /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with - /// kashidas before they widen their word spaces. - pub kashida: Kashida, -} - -impl ParaStyle { - /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to - /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range - /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is - /// not a number becomes 0. - pub fn keep_spacing_in_range(&mut self) { - let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) }; - for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] { - *v = within(*v, -crate::MAX_COORD, crate::MAX_COORD); - } - for t in &mut self.tabs { - t.position = within(t.position, 0.0, 100_000.0); - } - } - - /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own - /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their - /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is - /// not a finite number). - pub fn effective_leading(&self, st: &CharStyle) -> f64 { - let auto = self.justification.auto_leading; - if st.leading.is_some() || auto == JustificationOptions::default().auto_leading { - return st.effective_leading(); - } - let l = self.justification.auto_leading_for(st.size); - if l.is_finite() { l } else { st.effective_leading() } - } -} - -/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph -/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto -/// read the alignment and ignore the flag, and builds with it read Auto back. -#[derive(Serialize, Deserialize)] -struct ParaStyleFile { - #[serde(default)] - justify: Justify, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - justify_auto: bool, - #[serde(default)] - left_indent: f64, - #[serde(default)] - right_indent: f64, - #[serde(default)] - first_line_indent: f64, - #[serde(default)] - space_before: f64, - #[serde(default)] - space_after: f64, - #[serde(default)] - hyphenate: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - hyphenation: HyphenationOptions, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - justification: JustificationOptions, - #[serde(default, skip_serializing_if = "Composer::is_default")] - composer: Composer, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - tabs: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - list: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - style_name: Option, - #[serde(default, skip_serializing_if = "Mojikumi::is_none")] - mojikumi: Mojikumi, - #[serde(default, skip_serializing_if = "Option::is_none")] - direction: Option, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - leading_model: LeadingModel, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - burasagari: Burasagari, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - kinsoku: Kinsoku, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - roman_hanging: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] - kashida: Kashida, -} - -impl From for ParaStyleFile { - fn from(p: ParaStyle) -> Self { - let justify_auto = p.justify == Justify::Auto; - let justify = match p.justify { - Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right, - Justify::Auto => Justify::Left, - j => j, - }; - let ParaStyle { - left_indent, - right_indent, - first_line_indent, - space_before, - space_after, - hyphenate, - hyphenation, - justification, - composer, - tabs, - list, - style_name, - mojikumi, - direction, - leading_model, - burasagari, - kinsoku, - roman_hanging, - kashida, - .. - } = p; - Self { - justify, - justify_auto, - left_indent, - right_indent, - first_line_indent, - space_before, - space_after, - hyphenate, - hyphenation, - justification, - composer, - tabs, - list, - style_name, - mojikumi, - direction, - leading_model, - burasagari, - kinsoku, - roman_hanging, - kashida, - } - } -} - -impl From for ParaStyle { - fn from(f: ParaStyleFile) -> Self { - let justify = if f.justify_auto { Justify::Auto } else { f.justify }; - let mut justification = f.justification; - justification.keep_in_range(); - let ParaStyleFile { - left_indent, - right_indent, - first_line_indent, - space_before, - space_after, - hyphenate, - hyphenation, - composer, - tabs, - list, - style_name, - mojikumi, - direction, - leading_model, - burasagari, - kinsoku, - roman_hanging, - kashida, - .. - } = f; - Self { - justify, - left_indent, - right_indent, - first_line_indent, - space_before, - space_after, - hyphenate, - hyphenation, - justification, - composer, - tabs, - list, - style_name, - mojikumi, - direction, - leading_model, - burasagari, - kinsoku, - roman_hanging, - kashida, - } - } -} - -/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set -/// in ([`CharStyle::char_direction`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ParaDirection { - LeftToRight, - RightToLeft, -} - -/// How a paragraph's leading is measured. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LeadingModel { - /// From one line's baseline to the next's: a line's leading is the space above it, and area - /// type's first baseline follows Area Type Options › First Baseline. - #[default] - RomanBaseline, - /// From the top of one line's ideographic em box (the right side, in vertical type) to the - /// next's: a line's leading is the space below it, and area type's first line touches the top - /// of the frame. Japanese layout's usual model. - EmBoxTop, -} - -/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half -/// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's -/// after it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Mojikumi { - /// Every character takes its full advance. - #[default] - None, - /// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket, - /// comma or full stop ending a line is set half width. - LineEndHalf, -} - -impl Mojikumi { - pub fn is_none(&self) -> bool { - *self == Mojikumi::None - } -} - -/// Which Japanese characters may not start or end a line (kinsoku shori). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Kinsoku { - /// No kinsoku: a line may break between any two characters that allow a break. - None, - /// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small - /// kana and the like don't start a line; opening brackets don't end one. - #[default] - Hard, - /// As Hard, except that 々, the prolonged sound mark ー and small kana may start a line - /// (JLREQ's level 3 line-breaking rules, Appendix C.3). - Soft, -} - -/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) -/// stands outside the line's measure, in the space its punctuation spacing gives it (half width -/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown -/// as None / Regular / Force. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Burasagari { - /// A comma or full stop that doesn't fit goes to the next line with the character before it. - #[default] - None, - /// A comma or full stop that doesn't fit hangs outside the line; one that fits stays inside. - Standard, - /// A comma or full stop ending a line always hangs, and the rest of the line fills the measure. - Forced, -} - -/// Area Type Options "First Baseline" offset. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FirstBaseline { - /// The tallest glyph ascent touches the frame top (Illustrator's default). - #[default] - Ascent, - CapHeight, - XHeight, - /// The first line's leading. - Leading, - /// Exactly `first_baseline_min` below the top. - Fixed, -} - -/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum VerticalAlign { - /// Lines start at the top of the cell (the default). - #[default] - Top, - /// The block of lines is centred in the cell. - Center, - /// The last line's descent touches the cell bottom. - Bottom, - /// The first line stays at the top, the last one moves to the bottom and the space left over - /// is shared equally between the lines (no paragraph spacing limit). - Justify, -} - -impl VerticalAlign { - pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify]; - pub fn id(self) -> &'static str { - match self { - VerticalAlign::Top => "top", - VerticalAlign::Center => "center", - VerticalAlign::Bottom => "bottom", - VerticalAlign::Justify => "justify", - } - } -} - -/// Text Wrap Options of a wrap object (Object → Text Wrap). -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TextWrap { - /// Distance kept between the text and the object, in points (Illustrator's default 6 pt). - pub offset: f64, - /// Invert Wrap: text flows inside the object instead of around it. - pub invert: bool, -} - -impl Default for TextWrap { - fn default() -> Self { - Self { offset: 6.0, invert: false } - } -} - -/// A resolved wrap shape on an area text object: the outline of a wrap object in text space. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct WrapShape { - pub path: PathData, - #[serde(flatten)] - pub wrap: TextWrap, -} - -/// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PathEffect { - /// Glyphs rotate with the path (the default). - #[default] - Rainbow, - /// Vertical edges stay vertical; the baseline follows the path. - Skew, - /// Horizontal edges stay horizontal; vertical edges are perpendicular to the path. - #[serde(rename = "3dRibbon")] - Ribbon3d, - /// No rotation: the left end of each glyph's baseline sits on the path. - StairStep, - /// The baseline centre sits on the path and glyphs point away from the path's centre. - Gravity, -} - -impl PathEffect { - pub const ALL: [PathEffect; 5] = [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity]; - pub fn id(self) -> &'static str { - match self { - PathEffect::Rainbow => "rainbow", - PathEffect::Skew => "skew", - PathEffect::Ribbon3d => "3dRibbon", - PathEffect::StairStep => "stairStep", - PathEffect::Gravity => "gravity", - } - } - pub fn parse(s: &str) -> Option { - let k = s.to_ascii_lowercase().replace([' ', '-', '_'], ""); - Self::ALL.into_iter().find(|e| e.id().to_ascii_lowercase() == k || (k == "ribbon3d" && *e == PathEffect::Ribbon3d)) - } -} - -/// Type on a Path Options › Align to Path: which height of the type runs along the path. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PathAlign { - /// The font's top edge: the type hangs below the path. - Ascender, - /// The font's bottom edge: the type stands above the path. - Descender, - /// Halfway between the ascender and the descender. - Center, - /// The baseline (the default). - #[default] - Baseline, -} - -impl PathAlign { - pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline]; - pub fn id(self) -> &'static str { - match self { - PathAlign::Ascender => "ascender", - PathAlign::Descender => "descender", - PathAlign::Center => "center", - PathAlign::Baseline => "baseline", - } - } - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim())) - } -} - -/// Area Type Options: rows and columns, gutters, inset and first baseline of area type. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct AreaOptions { - #[serde(deserialize_with = "de_rows_or_columns")] - pub rows: usize, - #[serde(deserialize_with = "de_rows_or_columns")] - pub columns: usize, - /// Gutter between rows/columns in points. - pub gutter: f64, - /// Inset from the frame edges in points. - pub inset: f64, - pub first_baseline: FirstBaseline, - /// Minimum first-baseline offset in points. - pub first_baseline_min: f64, - /// Vertical alignment of the lines in each row/column. - pub vertical_align: VerticalAlign, - /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). - #[serde(skip_serializing_if = "crate::skip::is_default")] - pub fit: AreaFit, -} - -impl Default for AreaOptions { - fn default() -> Self { - Self { - rows: 1, - columns: 1, - gutter: 18.0, - inset: 0.0, - first_baseline: FirstBaseline::Ascent, - first_baseline_min: 0.0, - vertical_align: VerticalAlign::Top, - fit: AreaFit::None, - } - } -} - -impl AreaOptions { - /// The most rows, and the most columns, area type can have. - pub const MAX_ROWS_COLUMNS: usize = 100; -} - -/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to -/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`]. -fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX); - Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS)) -} - -/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or -/// `{"shrinkText": {"minPercent": 50}}`. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AreaFit { - /// The frame keeps its size; text that doesn't fit overflows. - #[default] - None, - /// The frame's height follows the text (Illustrator's Auto Size): resolved by the engine after - /// each edit, for rectangular frames of horizontal type in one row. - AutoHeight, - /// Text that overflows is scaled down (size, leading and baseline shift; not paragraph - /// spacing) by the largest factor down to `min_percent` % that makes it fit, at layout time. - ShrinkText { - #[serde(rename = "minPercent", default = "AreaFit::default_min_percent")] - min_percent: f64, - }, -} - -impl AreaFit { - /// The smallest Shrink Text to Fit percentage allowed. - pub const MIN_PERCENT: f64 = 10.0; - /// The Shrink Text to Fit percentage new settings start with. - pub const DEFAULT_MIN_PERCENT: f64 = 50.0; - fn default_min_percent() -> f64 { - Self::DEFAULT_MIN_PERCENT - } - /// Its id: `none`, `autoHeight` or `shrinkText`. - pub fn id(self) -> &'static str { - match self { - AreaFit::None => "none", - AreaFit::AutoHeight => "autoHeight", - AreaFit::ShrinkText { .. } => "shrinkText", - } - } - /// The fit an id names (case, spaces, dashes and underscores ignored), with `min_percent` - /// for Shrink Text (clamped to 10..100; a non-finite value gives the default). - pub fn parse(id: &str, min_percent: Option) -> Option { - match id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { - "none" | "off" => Some(AreaFit::None), - "autoheight" | "autosize" => Some(AreaFit::AutoHeight), - "shrinktext" | "shrinktexttofit" | "shrink" => { - Some(AreaFit::ShrinkText { min_percent: Self::clamp_percent(min_percent.unwrap_or(Self::DEFAULT_MIN_PERCENT)) }) - } - _ => None, - } - } - /// A Shrink Text minimum percentage within 10..100 (the default when not finite). - pub fn clamp_percent(p: f64) -> f64 { - if p.is_finite() { p.clamp(Self::MIN_PERCENT, 100.0) } else { Self::DEFAULT_MIN_PERCENT } - } - /// Shrink Text's minimum scale factor (0.1..1), if this is Shrink Text. - pub fn min_scale(self) -> Option { - match self { - AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0), - _ => None, - } - } -} - -/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or -/// [`ParaStyle`] fields, by their serialized names). Text using it records the name. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TextStyleDef { - pub name: String, - #[serde(default)] - pub attrs: serde_json::Map, -} - -/// A run of text sharing one character style. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TextRun { - pub text: String, - pub style: CharStyle, - /// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows - /// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets - /// the size and tracking; fills and strokes don't apply to the art. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inline: Option, -} - -impl TextRun { - /// A plain text run. - pub fn new(text: impl Into, style: CharStyle) -> Self { - Self { text: text.into(), style, inline: None } - } - /// An inline graphic run showing `art`. - pub fn inline(art: InlineArt, style: CharStyle) -> Self { - Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) } - } -} - -/// The object replacement character: the plain text of an inline graphic run. -pub const INLINE_CHAR: char = '\u{FFFC}'; - -/// Largest inline graphic scale (× the run's size) accepted from input. -pub const INLINE_MAX_SCALE: f64 = 100.0; - -/// A document symbol placed inline in text ([`TextRun::inline`]). -/// -/// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's -/// font size, its left edge at the pen position, and its vertical centre on the middle of the -/// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap -/// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at -/// the default scale, stays inside the font's ascent and descent so the leading doesn't change. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct InlineArt { - /// Name of the document symbol ([`crate::Symbol::name`]). - pub symbol: String, - /// Art height as a multiple of the run's font size. - #[serde(default = "one")] - pub scale: f64, - /// Extra raise in points (positive = up). - #[serde(default)] - pub baseline_shift: f64, - /// The symbol art's visual bounds at its natural size, resolved from the document's symbols by - /// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the - /// layout then reserves a one-em square and nothing is drawn. - #[serde(skip)] - pub bounds: Option, -} - -fn one() -> f64 { - 1.0 -} - -fn fifty() -> f64 { - 50.0 -} - -impl InlineArt { - pub fn new(symbol: impl Into) -> Self { - Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None } - } - /// `scale`, made finite and positive (untrusted input). - pub fn safe_scale(&self) -> f64 { - if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 } - } - /// `baseline_shift`, made finite (untrusted input). - pub fn safe_shift(&self) -> f64 { - if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum TextKind { - /// Point type: anchored at the baseline origin of the first line. - Point, - /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). - Area { frame: PathData }, - /// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the - /// path length; no `end`: the end of the path, or once round a closed path). See - /// [`TextKind::path_span`]. - OnPath { - path: PathData, - start: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - end: Option, - }, -} - -impl TextKind { - /// Type on a path's span: from its start to its end bracket, as fractions of its path's length. - /// Round a closed path the end may be past 1 (the span runs on past the path's start), a full - /// turn without an end or with the end at the start. None for other type. - pub fn path_span(&self) -> Option<(f64, f64)> { - let TextKind::OnPath { path, start, end } = self else { return None }; - let s = fraction(*start); - Some(if path.is_closed() { - let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0)); - (s, s + if run < 1e-9 { 1.0 } else { run }) - } else { - (s, end.map_or(1.0, |e| fraction(e).max(s))) - }) - } -} - -/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the -/// document → text map). False when an anchor doesn't exist. -fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { - // A distance: only the linear part of the document → text map applies. - let local = to_text * d.to_point() - to_text * Point::ZERO; - refs.iter().all(|&(si, ai)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some()) -} - -/// `x` as a fraction of a path's length (0..1; 0 when not finite). -fn fraction(x: f64) -> f64 { - if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 } -} - -/// `path` run the other way, each point as far from its new start as it was from its old end -/// (the subpaths in reverse order, a closed one keeping its first anchor first). -fn reverse_from_end(path: &mut PathData) { - path.subpaths.reverse(); - for sp in &mut path.subpaths { - sp.reverse(); - if sp.closed && !sp.anchors.is_empty() { - sp.anchors.rotate_right(1); - } - } -} - -/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in -/// vertical type (the Japanese convention), or below (left in vertical type). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RubyPosition { - #[default] - Above, - Below, -} - -/// How ruby text lines up over its base text when the two are different widths. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RubyAlign { - /// Centred over the base text. - #[default] - Center, - /// Starting at the base text's start (left in horizontal type, top in vertical). - Start, - /// Ending at the base text's end (right in horizontal type, bottom in vertical). - End, -} - -/// Ruby (furigana, phonetic guide): a reading set over a range of the text. -/// -/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as -/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text; -/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and -/// allowed to overhang neighbouring characters when the reading is longer than its base. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RubySpan { - /// Byte offset of the first base character in the plain text. - pub start: usize, - /// Byte offset just past the last base character. - pub end: usize, - /// The reading, set in the base characters' font (its size comes from `size_percent`). - pub text: String, - #[serde(default)] - pub position: RubyPosition, - #[serde(default)] - pub align: RubyAlign, - /// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual). - #[serde(default = "fifty")] - pub size_percent: f64, - /// Let a reading wider than its base hang over the neighbouring characters instead of being - /// squeezed to the base's width. - #[serde(default = "yes")] - pub overhang: bool, -} - -impl RubySpan { - /// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging). - pub fn new(start: usize, end: usize, text: impl Into) -> Self { - Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true } - } - /// `size_percent` made finite and usable: 1–100% (untrusted input). - pub fn safe_size_percent(&self) -> f64 { - if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 } - } -} +mod area; +mod char_style; +mod object; +mod para_style; +mod runs; +mod tabs_lists; + +pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextWrap, VerticalAlign, WrapShape}; +use char_style::yes; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics}; +pub use para_style::{ + Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, + ParaStyle, SingleWordJustify, +}; +pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; +pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; /// A text object. `runs` split into paragraphs at `\n`. /// @@ -1713,492 +112,5 @@ pub struct TextStyleScale { pub horizontal: f64, } -impl TextObject { - /// None for collapsed or unrepresentable transforms, whose type dimensions cannot - /// be edited by compensating the stored attributes. - pub fn style_scale(&self) -> Option { - let [a, b, c, d, e, f] = self.xf.as_coeffs(); - if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { - return None; - } - let (sx, sy) = (a.hypot(b), c.hypot(d)); - let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy); - let horizontal = sx / sy; - if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 { - return None; - } - // Avoid a pure rotation turning otherwise identical fields into mixed values. - let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v }; - Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) }) - } - - /// Character attributes as presented by controls (stored runs stay local for layout - /// and rich-text clipboard operations). Explicit vertical character scale stays separate. - pub fn effective_char_style(&self, style: &CharStyle) -> Option { - let scale = self.style_scale()?; - let mut style = style.clone(); - style.size *= scale.points; - style.leading = style.leading.map(|v| v * scale.points); - style.baseline_shift *= scale.points; - style.h_scale *= scale.horizontal; - if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) { - return None; - } - Some(style) - } - - /// Bakes the object's scale ([`style_scale`]) into character attributes and removes that - /// scale from `xf`, so panels show document-point sizes without a separate transform factor. - /// Returns `false` when there is nothing to bake (identity scale or an unrepresentable - /// transform). - pub fn scale_text_attributes(&mut self) -> bool { - let Some(scale) = self.style_scale() else { return false }; - if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 { - return false; - } - let [a, b, c, d, _, _] = self.xf.as_coeffs(); - let (sx, sy) = (a.hypot(b), c.hypot(d)); - if sx <= 1e-12 || sy <= 1e-12 { - return false; - } - let mut baked = Vec::with_capacity(self.runs.len()); - for run in &self.runs { - let Some(eff) = self.effective_char_style(&run.style) else { return false }; - baked.push(eff); - } - for (run, style) in self.runs.iter_mut().zip(baked) { - run.style = style; - } - // Character strokes are drawn in text space: widen them by the vertical scale we remove. - self.scale_char_strokes(1.0 / sy); - self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); - self.cached_bounds = None; - self.cached_baselines.clear(); - true - } - - pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { - Self { - vertical: false, - kind: TextKind::Point, - xf: Affine::translate(origin.to_vec2()), - runs: vec![TextRun::new(text, style)], - ruby: Vec::new(), - para: ParaStyle::default(), - paras: Vec::new(), - first_para_continues: false, - area: AreaOptions::default(), - path_effect: PathEffect::default(), - path_align: PathAlign::default(), - path_spacing: 0.0, - wrap: Vec::new(), - optical_margin: false, - system_layout: false, - fractional_widths: true, - list_count: 0, - cached_bounds: None, - cached_baselines: Vec::new(), - } - } - pub fn plain_text(&self) -> String { - self.runs.iter().map(|r| r.text.as_str()).collect() - } - /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. - /// - /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a - /// range past the text or an inverted one would lay out nothing (or panic in a silly place). - pub fn normalize_ruby(&mut self) { - let len: usize = self.runs.iter().map(|r| r.text.len()).sum(); - for r in &mut self.ruby { - r.start = r.start.min(len); - r.end = r.end.min(len); - if r.start > r.end { - std::mem::swap(&mut r.start, &mut r.end); - } - r.text = r.text.trim().to_string(); - } - self.ruby.retain(|r| r.start < r.end && !r.text.is_empty()); - self.ruby.sort_by_key(|r| (r.start, r.end)); - } - /// The ruby span covering byte `byte` of the plain text, if any. - pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> { - self.ruby.iter().find(|r| r.start <= byte && byte < r.end) - } - /// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered. - pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator { - let (a, b) = (start.min(end), start.max(end)); - self.ruby.iter().filter(move |r| r.start < b && a < r.end) - } - /// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with - /// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into - /// keep their start and end up where the replaced part ended. Call it after every edit of the - /// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word. - pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) { - let (start, end) = (start.min(end), start.max(end)); - let delta = inserted as i64 - (end.saturating_sub(start)) as i64; - let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize }; - for r in &mut self.ruby { - if r.end <= start { - continue; - } - if r.start >= end { - r.start = shift(r.start); - r.end = shift(r.end); - } else { - r.start = r.start.min(start); - r.end = shift(r.end).max(r.start); - } - } - self.normalize_ruby(); - } - /// Number of paragraphs (`\n`-separated; empty text has one). - pub fn paragraph_count(&self) -> usize { - 1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::() - } - /// Attributes of paragraph `i` (the last paragraph's past the end). - pub fn para_at(&self, i: usize) -> &ParaStyle { - self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) - } - /// Every paragraph's attributes, one per paragraph. - pub fn paragraph_styles(&self) -> Vec { - (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() - } - /// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its - /// last entry, a long one is cut). - pub fn set_paragraph_styles(&mut self, styles: Vec) { - if let Some(first) = styles.first() { - self.para = first.clone(); - } - self.paras = styles; - self.normalize_paras(); - } - /// The same attributes for every paragraph. - pub fn set_all_paras(&mut self, style: ParaStyle) { - self.para = style; - self.paras.clear(); - } - /// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the - /// last), `paras[0] == para`, and empty when every paragraph is alike. - pub fn normalize_paras(&mut self) { - if self.paras.is_empty() { - return; - } - let n = self.paragraph_count(); - let last = self.paras.last().cloned().unwrap_or_default(); - self.paras.resize(n, last); - if let Some(first) = self.paras.first_mut() { - first.clone_from(&self.para); - } - if self.paras.iter().all(|p| *p == self.para) { - self.paras.clear(); - } - } - /// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret - /// touches its paragraph). - pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range { - let text = self.plain_text(); - let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len())); - let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count()); - index(a)..index(b) + 1 - } - /// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte - /// where their paragraph starts: when the plain text from the start of the paragraph up to - /// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise. - pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> { - let text = self.plain_text(); - let before = text.get(..at)?; - let start = before.rfind('\n').map_or(0, |i| i + 1); - let list = ParaList::typed(before[start..].strip_suffix(' ')?)?; - let index = before.bytes().filter(|&b| b == b'\n').count(); - self.para_at(index).list.is_none().then_some((start, list)) - } - /// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph). - pub fn edit_paras(&mut self, range: Option>, mut f: impl FnMut(&mut ParaStyle)) { - match range { - None => { - f(&mut self.para); - for p in &mut self.paras { - f(p); - } - } - Some(r) => { - let mut v = self.paragraph_styles(); - for p in v.iter_mut().take(r.end).skip(r.start) { - f(p); - } - self.set_paragraph_styles(v); - } - } - self.normalize_paras(); - } - /// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames. - pub fn para_styles_mut(&mut self) -> impl Iterator { - std::iter::once(&mut self.para).chain(self.paras.iter_mut()) - } - /// Every stored paragraph style (`para` and the per-paragraph ones). - pub fn para_styles(&self) -> impl Iterator { - std::iter::once(&self.para).chain(self.paras.iter()) - } - /// Update the paragraph styles for replacing bytes `start..end` of the current plain text by - /// `insert` (call before changing the runs). The paragraph the range starts in keeps its - /// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that - /// style too (Return continues the paragraph it splits); later paragraphs keep theirs. - pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) { - if self.paras.is_empty() { - return; - } - let r = self.paragraphs_in(start, end); - let v = self.paragraph_styles(); - let Some(keep) = v.get(r.start).cloned() else { return }; - let added = insert.bytes().filter(|&b| b == b'\n').count(); - let mut out: Vec = v.iter().take(r.start + 1).cloned().collect(); - out.extend(std::iter::repeat_n(keep, added)); - out.extend(v.iter().skip(r.end).cloned()); - self.paras = out; - if let Some(first) = self.paras.first() { - self.para = first.clone(); - } - } - pub fn first_style(&self) -> CharStyle { - self.runs.first().map(|r| r.style.clone()).unwrap_or_default() - } - /// Approximate bounds when no layout cache is available (0.55 em average advance). - pub fn estimate_bounds(&self) -> Rect { - let st = self.first_style(); - let text = self.plain_text(); - let lines: Vec<&str> = text.split('\n').collect(); - let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55; - let lead = st.effective_leading(); - Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64) - } - pub fn bounds(&self) -> Option { - match &self.kind { - TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)), - TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)), - TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), - } - } - /// How far the characters' strokes paint past their glyphs, in document space: they scale - /// with the type. - pub fn char_stroke_outset(&self) -> f64 { - let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); - self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k - } - /// How far the characters' strokes can paint past their glyphs, their miter spikes and square - /// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must - /// redraw. They stretch with the type, so this is as far as its transform takes them on either - /// axis. The visual bounds take [`Self::char_stroke_outset`]. - pub fn char_stroke_reach(&self) -> f64 { - let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); - let [a, b, c, d, ..] = self.xf.as_coeffs(); - reach * (a.abs() + c.abs()).max(b.abs() + d.abs()) - } - pub fn transform(&mut self, a: Affine) { - self.xf = a * self.xf; - } - /// Type on a path's path in document space; None for other type. - pub fn type_path(&self) -> Option { - match &self.kind { - TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)), - _ => None, - } - } - /// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its - /// centre bracket dragged across the path): the path runs the other way and the brackets swap - /// ends, so the type keeps its stretch of the path. False, changing nothing, for other type. - pub fn flip_on_path(&mut self) -> bool { - let Some((s, e)) = self.kind.path_span() else { return false }; - let TextKind::OnPath { path, start, end } = &mut self.kind else { return false }; - reverse_from_end(path); - if path.is_closed() { - *start = (1.0 - e).rem_euclid(1.0); - *end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0)); - } else { - *start = 1.0 - e; - *end = (s > 1e-9).then_some(1.0 - s); - } - true - } - /// Area type's frame (the type area) in document space; None for other type. - pub fn area_frame(&self) -> Option { - match &self.kind { - TextKind::Area { frame } => Some(frame.transformed(self.xf)), - _ => None, - } - } - /// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size - /// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for - /// other type, a degenerate `xf` or a frame that would not be finite. - pub fn transform_area(&mut self, a: Affine) -> bool { - let xf = self.xf; - self.reshape_area(|frame, to_text| { - frame.transform(to_text * a * xf); - true - }) - } - /// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with - /// their handles (a Direct Selection drag of a frame corner or edge). False, changing - /// nothing, when an anchor doesn't exist, or as [`Self::transform_area`]. - pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { - self.reshape_area(|frame, to_text| move_anchors(frame, to_text, refs, d)) - } - /// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space, - /// with their handles (a Direct Selection drag of an anchor); the brackets keep their - /// fractions of the path. False, changing nothing, for other type, when an anchor doesn't - /// exist, a degenerate `xf` or a path that would not be finite. - pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { - matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d)) - } - /// Edit a copy of the area frame with `f` (given the document → text space map) and keep it - /// when `f` succeeds and the result is finite. - fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { - matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f) - } - /// [`Self::reshape_area`] for the area frame or type on a path's path. - fn reshape_local(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { - let det = self.xf.determinant(); - if !det.is_finite() || det.abs() < 1e-12 { - return false; - } - let to_text = self.xf.inverse(); - let (TextKind::Area { frame } | TextKind::OnPath { path: frame, .. }) = &mut self.kind else { return false }; - let mut next = frame.clone(); - if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { - return false; - } - *frame = next; - true - } - /// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so - /// `1 / scale` keeps their weight through a transform that scales the type). - pub fn scale_char_strokes(&mut self, s: f64) { - for r in &mut self.runs { - r.style.stroke_width *= s; - if let Some(d) = &mut r.style.stroke_dash { - d.scale(s); - } - } - } - /// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run - /// gradient fits, as the renderers lay it out. - pub fn local_bounds(&self) -> Rect { - self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()) - } - /// The baselines in text space (the layout cache): for point type with none cached, the first - /// one across [`Self::local_bounds`] (down the first column's centre line for vertical type). - pub fn baselines(&self) -> impl Iterator + '_ { - let first = (self.cached_baselines.is_empty() && matches!(self.kind, TextKind::Point)).then(|| { - let b = self.local_bounds(); - if self.vertical { (Point::new(0.0, b.y0), Point::new(0.0, b.y1)) } else { (Point::new(b.x0, 0.0), Point::new(b.x1, 0.0)) } - }); - self.cached_baselines.iter().copied().chain(first) - } - /// Keeps every run's size, leading, baseline shift and scales in range - /// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab - /// stops ([`ParaStyle::keep_spacing_in_range`]). - pub fn keep_type_values_in_range(&mut self) { - for r in &mut self.runs { - r.style.keep_dimensions_in_range(); - } - for p in self.para_styles_mut() { - p.keep_spacing_in_range(); - } - } -} - -impl crate::Document { - /// Keeps the character dimensions, indents, paragraph spacing and tab stops in range - /// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph - /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts - /// back (opacity-mask art included), and the same attributes of the character and paragraph - /// styles. It recurses through the art: call it on a document no deeper than - /// [`crate::depth::MAX_DEPTH`]. - pub fn keep_type_values_in_range(&mut self) { - let mut fix = |n: &mut crate::Node| { - if let crate::NodeKind::Text(t) = &mut n.kind { - t.keep_type_values_in_range(); - } - }; - let roots = self - .layers - .iter_mut() - .chain(self.symbols.iter_mut().map(|s| &mut s.art)) - .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) - .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) - .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); - for root in roots { - crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); - } - // Styles store the attributes they set by their serialized names (a number, or null for - // Auto leading); a number is rewritten only when it changes. - for def in self.char_styles.iter_mut() { - let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64); - let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() }; - st.size = num("size").unwrap_or(st.size); - st.h_scale = num("h_scale").unwrap_or(st.h_scale); - st.v_scale = num("v_scale").unwrap_or(st.v_scale); - st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift); - st.keep_dimensions_in_range(); - let kept = [ - ("size", Some(st.size)), - ("h_scale", Some(st.h_scale)), - ("v_scale", Some(st.v_scale)), - ("baseline_shift", Some(st.baseline_shift)), - ("leading", st.leading), - ]; - for (k, v) in kept { - if let Some(old) = def.attrs.get_mut(k) - && old.is_number() - && old.as_f64() != v - { - *old = v.into(); - } - } - } - for def in self.para_styles.iter_mut() { - for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] { - if let Some(v) = def.attrs.get_mut(k) - && let Some(x) = v.as_f64() - && x.abs() > crate::MAX_COORD - { - *v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into(); - } - } - for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() { - if let Some(v) = t.get_mut("position") - && let Some(x) = v.as_f64() - && !(0.0..=100_000.0).contains(&x) - { - *v = x.clamp(0.0, 100_000.0).into(); - } - } - } - } - - /// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches - /// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art - /// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry - /// ranges from outside: a range past the text, an inverted one or an empty reading would lay - /// out nothing. It recurses through the art: call it on a document no deeper than - /// [`crate::depth::MAX_DEPTH`]. - pub fn fix_ruby(&mut self) { - let mut fix = |n: &mut crate::Node| { - if let crate::NodeKind::Text(t) = &mut n.kind { - t.normalize_ruby(); - } - }; - let roots = self - .layers - .iter_mut() - .chain(self.symbols.iter_mut().map(|s| &mut s.art)) - .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) - .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) - .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); - for root in roots { - crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); - } - } -} - #[cfg(test)] mod tests; diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs new file mode 100644 index 000000000..5897d957a --- /dev/null +++ b/crates/doc/src/text/area.rs @@ -0,0 +1,228 @@ +use super::para_style::FirstBaseline; +use super::*; + +/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VerticalAlign { + /// Lines start at the top of the cell (the default). + #[default] + Top, + /// The block of lines is centred in the cell. + Center, + /// The last line's descent touches the cell bottom. + Bottom, + /// The first line stays at the top, the last one moves to the bottom and the space left over + /// is shared equally between the lines (no paragraph spacing limit). + Justify, +} + +impl VerticalAlign { + pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify]; + pub fn id(self) -> &'static str { + match self { + VerticalAlign::Top => "top", + VerticalAlign::Center => "center", + VerticalAlign::Bottom => "bottom", + VerticalAlign::Justify => "justify", + } + } +} + +/// Text Wrap Options of a wrap object (Object → Text Wrap). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextWrap { + /// Distance kept between the text and the object, in points (Illustrator's default 6 pt). + pub offset: f64, + /// Invert Wrap: text flows inside the object instead of around it. + pub invert: bool, +} + +impl Default for TextWrap { + fn default() -> Self { + Self { offset: 6.0, invert: false } + } +} + +/// A resolved wrap shape on an area text object: the outline of a wrap object in text space. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WrapShape { + pub path: PathData, + #[serde(flatten)] + pub wrap: TextWrap, +} + +/// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathEffect { + /// Glyphs rotate with the path (the default). + #[default] + Rainbow, + /// Vertical edges stay vertical; the baseline follows the path. + Skew, + /// Horizontal edges stay horizontal; vertical edges are perpendicular to the path. + #[serde(rename = "3dRibbon")] + Ribbon3d, + /// No rotation: the left end of each glyph's baseline sits on the path. + StairStep, + /// The baseline centre sits on the path and glyphs point away from the path's centre. + Gravity, +} + +impl PathEffect { + pub const ALL: [PathEffect; 5] = [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity]; + pub fn id(self) -> &'static str { + match self { + PathEffect::Rainbow => "rainbow", + PathEffect::Skew => "skew", + PathEffect::Ribbon3d => "3dRibbon", + PathEffect::StairStep => "stairStep", + PathEffect::Gravity => "gravity", + } + } + pub fn parse(s: &str) -> Option { + let k = s.to_ascii_lowercase().replace([' ', '-', '_'], ""); + Self::ALL.into_iter().find(|e| e.id().to_ascii_lowercase() == k || (k == "ribbon3d" && *e == PathEffect::Ribbon3d)) + } +} + +/// Type on a Path Options › Align to Path: which height of the type runs along the path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathAlign { + /// The font's top edge: the type hangs below the path. + Ascender, + /// The font's bottom edge: the type stands above the path. + Descender, + /// Halfway between the ascender and the descender. + Center, + /// The baseline (the default). + #[default] + Baseline, +} + +impl PathAlign { + pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline]; + pub fn id(self) -> &'static str { + match self { + PathAlign::Ascender => "ascender", + PathAlign::Descender => "descender", + PathAlign::Center => "center", + PathAlign::Baseline => "baseline", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim())) + } +} + +/// Area Type Options: rows and columns, gutters, inset and first baseline of area type. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AreaOptions { + #[serde(deserialize_with = "de_rows_or_columns")] + pub rows: usize, + #[serde(deserialize_with = "de_rows_or_columns")] + pub columns: usize, + /// Gutter between rows/columns in points. + pub gutter: f64, + /// Inset from the frame edges in points. + pub inset: f64, + pub first_baseline: FirstBaseline, + /// Minimum first-baseline offset in points. + pub first_baseline_min: f64, + /// Vertical alignment of the lines in each row/column. + pub vertical_align: VerticalAlign, + /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub fit: AreaFit, +} + +impl Default for AreaOptions { + fn default() -> Self { + Self { + rows: 1, + columns: 1, + gutter: 18.0, + inset: 0.0, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + vertical_align: VerticalAlign::Top, + fit: AreaFit::None, + } + } +} + +impl AreaOptions { + /// The most rows, and the most columns, area type can have. + pub const MAX_ROWS_COLUMNS: usize = 100; +} + +/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to +/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`]. +fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX); + Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS)) +} + +/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or +/// `{"shrinkText": {"minPercent": 50}}`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AreaFit { + /// The frame keeps its size; text that doesn't fit overflows. + #[default] + None, + /// The frame's height follows the text (Illustrator's Auto Size): resolved by the engine after + /// each edit, for rectangular frames of horizontal type in one row. + AutoHeight, + /// Text that overflows is scaled down (size, leading and baseline shift; not paragraph + /// spacing) by the largest factor down to `min_percent` % that makes it fit, at layout time. + ShrinkText { + #[serde(rename = "minPercent", default = "AreaFit::default_min_percent")] + min_percent: f64, + }, +} + +impl AreaFit { + /// The smallest Shrink Text to Fit percentage allowed. + pub const MIN_PERCENT: f64 = 10.0; + /// The Shrink Text to Fit percentage new settings start with. + pub const DEFAULT_MIN_PERCENT: f64 = 50.0; + fn default_min_percent() -> f64 { + Self::DEFAULT_MIN_PERCENT + } + /// Its id: `none`, `autoHeight` or `shrinkText`. + pub fn id(self) -> &'static str { + match self { + AreaFit::None => "none", + AreaFit::AutoHeight => "autoHeight", + AreaFit::ShrinkText { .. } => "shrinkText", + } + } + /// The fit an id names (case, spaces, dashes and underscores ignored), with `min_percent` + /// for Shrink Text (clamped to 10..100; a non-finite value gives the default). + pub fn parse(id: &str, min_percent: Option) -> Option { + match id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { + "none" | "off" => Some(AreaFit::None), + "autoheight" | "autosize" => Some(AreaFit::AutoHeight), + "shrinktext" | "shrinktexttofit" | "shrink" => { + Some(AreaFit::ShrinkText { min_percent: Self::clamp_percent(min_percent.unwrap_or(Self::DEFAULT_MIN_PERCENT)) }) + } + _ => None, + } + } + /// A Shrink Text minimum percentage within 10..100 (the default when not finite). + pub fn clamp_percent(p: f64) -> f64 { + if p.is_finite() { p.clamp(Self::MIN_PERCENT, 100.0) } else { Self::DEFAULT_MIN_PERCENT } + } + /// Shrink Text's minimum scale factor (0.1..1), if this is Shrink Text. + pub fn min_scale(self) -> Option { + match self { + AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0), + _ => None, + } + } +} diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs new file mode 100644 index 000000000..a19b5f71c --- /dev/null +++ b/crates/doc/src/text/char_style.rs @@ -0,0 +1,432 @@ +use super::para_style::ParaDirection; +use super::*; + +/// Character attributes (the Character panel). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CharStyle { + pub font_family: String, + #[serde(default = "regular")] + pub font_style: String, + /// Which installed version of the font (its version string) when several versions of the + /// family and style are installed and the text was set in one the family and style alone + /// wouldn't pick (an imported file's type matched to it); None = the one they pick. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_version: Option, + /// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag + /// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of + /// each axis they name; the other axes keep the style's settings. Empty: the style as it is. + #[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub font_axes: std::collections::BTreeMap, + /// Size in points. + pub size: f64, + /// Leading in points; None = Auto (120% of size). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub leading: Option, + /// Tracking in 1/1000 em. + #[serde(default)] + pub tracking: f64, + /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kerning: Option, + #[serde(default)] + pub baseline_shift: f64, + #[serde(default = "hundred")] + pub h_scale: f64, + #[serde(default = "hundred")] + pub v_scale: f64, + #[serde(default)] + pub rotation: f64, + pub fill: Paint, + #[serde(default)] + pub stroke: Paint, + #[serde(default)] + pub stroke_width: f64, + #[serde(default)] + pub underline: bool, + #[serde(default)] + pub strikethrough: bool, + #[serde(default)] + pub all_caps: bool, + /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns + /// a feature on, `"-liga"` off. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub features: Vec, + /// Character style (Character Styles panel) these attributes come from; None = Normal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub style_name: Option, + /// Overprint Fill / Overprint Stroke of these characters (see [`crate::FillLayer::overprint`]). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint_fill: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub overprint_stroke: bool, + /// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type + /// selected); defaults as for object strokes. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_cap: LineCap, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_join: LineJoin, + #[serde(default = "ten", skip_serializing_if = "is_ten")] + pub stroke_miter_limit: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke_dash: Option, + /// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the + /// glyph outlines, inside or outside them, as for object strokes; the type stays live. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_align: StrokeAlign, + /// Superscript or subscript (Character panel). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub position: CharPosition, + /// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the + /// size (Document Setup → Type → Small Caps); None = off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub small_caps: Option, + /// Character Alignment (Character panel menu): where characters smaller than the largest on + /// their line line up with it. + #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] + pub char_align: CharAlign, + /// Character Direction (Character panel menu, Middle Eastern options): these characters are + /// set left to right or right to left inside their paragraph, each stretch of them a Unicode + /// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives + /// them. + #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] + pub char_direction: Option, + /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the + /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take + /// its proportional heights, `vpal`. + #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] + pub proportional_metrics: bool, + /// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters + /// stand side by side across one em of the column. Horizontal type keeps the mark and does not + /// move them. Two or three half-width digits do this without the mark. + /// + #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] + pub tate_chu_yoko: bool, + /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of + /// these characters are drawn in, whatever digits were typed. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub digits: Digits, + /// No Break (Character panel menu): these characters are not broken across lines, including + /// at a space or a hyphenation point inside the run. A run longer than the line stays together + /// and can overflow it. + /// + #[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")] + pub no_break: bool, +} + +/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of +/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps +/// the digits that were typed; only the glyphs change (like All Caps). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Digits { + /// Each digit as typed. + #[default] + Default, + /// Western digits 0–9 (the digits Arabic typography calls Arabic). + Arabic, + /// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic. + Hindi, + /// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu. + Farsi, +} + +impl Digits { + /// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c` + /// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set. + pub fn map(self, c: char) -> char { + let value = match c { + '0'..='9' => c as u32 - '0' as u32, + '\u{660}'..='\u{669}' => c as u32 - 0x660, + '\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0, + _ => return c, + }; + let base = match self { + Digits::Default => return c, + Digits::Arabic => '0' as u32, + Digits::Hindi => 0x660, + Digits::Farsi => 0x6F0, + }; + char::from_u32(base + value).unwrap_or(c) + } +} + +/// Where a character smaller than the largest on its line lines up with it: on the Roman +/// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic +/// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the +/// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CharAlign { + #[default] + RomanBaseline, + EmBoxTop, + EmBoxCenter, + EmBoxBottom, + IcfTop, + IcfBottom, +} + +/// Superscript or subscript proportions in percent of the font size (Document Setup → Type). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct ScriptMetrics { + /// Glyph size. + pub size: f64, + /// Baseline offset: up for superscript, down for subscript. + pub position: f64, +} + +impl ScriptMetrics { + pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 }; + /// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to + /// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside + /// its range becomes the nearer end. + pub fn in_range(self) -> Self { + Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) } + } +} + +/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for +/// `smallCapsSize`. A size outside it becomes the nearer end. +fn small_caps_in_range(size: f64) -> f64 { + size.clamp(1.0, 200.0) +} + +impl Default for ScriptMetrics { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Character position (Character panel Superscript / Subscript). The proportions are the +/// document's when the position is applied, and follow later Document Setup changes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CharPosition { + #[default] + Normal, + Superscript(ScriptMetrics), + Subscript(ScriptMetrics), +} + +impl CharPosition { + /// Glyph scale and baseline shift (points, positive = up) for text of `size` points. + pub fn scale_shift(self, size: f64) -> (f64, f64) { + match self { + CharPosition::Normal => (1.0, 0.0), + CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size), + CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size), + } + } + /// `normal`, `superscript` or `subscript`. + pub fn id(self) -> &'static str { + match self { + CharPosition::Normal => "normal", + CharPosition::Superscript(_) => "superscript", + CharPosition::Subscript(_) => "subscript", + } + } + /// Returns this position with its proportions kept by [`ScriptMetrics::in_range`]. + pub fn in_range(self) -> Self { + match self { + CharPosition::Normal => CharPosition::Normal, + CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()), + CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()), + } + } +} + +fn ten() -> f64 { + 10.0 +} + +fn is_ten(v: &f64) -> bool { + *v == 10.0 +} + +fn regular() -> String { + "Regular".into() +} + +fn hundred() -> f64 { + 100.0 +} + +pub(super) fn yes() -> bool { + true +} + +impl Default for CharStyle { + fn default() -> Self { + Self { + font_family: "Source Sans 3".into(), + font_style: "Regular".into(), + font_version: None, + font_axes: std::collections::BTreeMap::new(), + size: 12.0, + leading: None, + tracking: 0.0, + kerning: None, + baseline_shift: 0.0, + h_scale: 100.0, + v_scale: 100.0, + rotation: 0.0, + fill: Paint::solid(Color::BLACK), + stroke: Paint::None, + stroke_width: 0.0, + underline: false, + strikethrough: false, + all_caps: false, + features: vec![], + style_name: None, + overprint_fill: false, + overprint_stroke: false, + stroke_cap: LineCap::Butt, + stroke_join: LineJoin::Miter, + stroke_miter_limit: 10.0, + stroke_dash: None, + stroke_align: StrokeAlign::Center, + position: CharPosition::Normal, + char_align: CharAlign::RomanBaseline, + char_direction: None, + proportional_metrics: false, + tate_chu_yoko: false, + small_caps: None, + digits: Digits::Default, + no_break: false, + } + } +} + +impl CharStyle { + pub fn effective_leading(&self) -> f64 { + self.leading.unwrap_or(self.size * 1.2) + } + /// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size + /// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading + /// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way) + /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a + /// baseline shift that is not a number becomes 0. + pub fn keep_dimensions_in_range(&mut self) { + let default = Self::default(); + let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; + self.size = within(self.size, default.size); + self.h_scale = within(self.h_scale, default.h_scale); + self.v_scale = within(self.v_scale, default.v_scale); + self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD)); + self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) }; + } + /// Do the characters draw a stroke (a paint and a positive weight)? + pub fn has_stroke(&self) -> bool { + !self.stroke.is_none() && self.stroke_width > 0.0 + } + /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes, + /// alignment and overprint), so type strokes share the object strokes' geometry, rendering + /// and export. + pub fn stroke_layer(&self) -> StrokeLayer { + StrokeLayer { + overprint: self.overprint_stroke, + cap: self.stroke_cap, + join: self.stroke_join, + miter_limit: self.stroke_miter_limit, + dash: self.stroke_dash.clone(), + align: self.stroke_align, + ..StrokeLayer::new(self.stroke.clone(), self.stroke_width) + } + } + /// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the + /// character stroke (the options characters have no use for, such as arrowheads and width + /// profiles, are dropped). + pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { + self.stroke = st.paint.clone(); + self.stroke_width = st.width; + self.stroke_cap = st.cap; + self.stroke_join = st.join; + self.stroke_miter_limit = st.miter_limit; + self.stroke_dash = st.dash.clone(); + self.stroke_align = st.align; + } + /// How far the character stroke paints past the glyph outlines (text space): half the weight + /// centred, the whole weight outside, nothing inside (miter spikes aside). + pub fn stroke_outset(&self) -> f64 { + if !self.has_stroke() { + return 0.0; + } + match self.stroke_align { + StrokeAlign::Center => self.stroke_width / 2.0, + StrokeAlign::Outside => self.stroke_width, + StrokeAlign::Inside => 0.0, + } + } + /// The character fill as a fill layer, overprinting as the characters do. + fn fill_layer(&self) -> FillLayer { + FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) } + } + /// The characters' paint as an object appearance (their outlines'): the fill, plus the + /// stroke when it draws one, overprinting as the characters do. + pub fn appearance(&self) -> Appearance { + let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() }; + if self.has_stroke() { + a.items.push(AppearanceItem::Stroke(self.stroke_layer())); + } + a + } + /// The characters' paint as the basic fill and stroke rows (the stroke row even when it has + /// no paint): the appearance the Eyedropper and graphic styles take from type. + pub fn basic_appearance(&self) -> Appearance { + Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() } + } + /// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and + /// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`. + pub fn keep_scripts_in_range(&mut self) { + self.position = self.position.in_range(); + self.small_caps = self.small_caps.map(small_caps_in_range); + } +} + +impl crate::Document { + /// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup` + /// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph + /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts + /// back (opacity-mask art included), in graphs' label styles and in the character styles. It + /// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`]. + pub fn keep_scripts_in_range(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + r.style.keep_scripts_in_range(); + } + } + if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + let l = &mut g.label_styles; + for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { + st.keep_scripts_in_range(); + } + } + }; + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + // Character styles store the attributes they set by their serialized names. + for def in self.char_styles.iter_mut() { + if let Some(v) = def.attrs.get_mut("position") + && let Ok(p) = serde_json::from_value::(v.clone()) + && p.in_range() != p + { + *v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null); + } + if let Some(v) = def.attrs.get_mut("small_caps") + && let Some(size) = v.as_f64() + && small_caps_in_range(size) != size + { + *v = small_caps_in_range(size).into(); + } + } + } +} diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs new file mode 100644 index 000000000..58caa1602 --- /dev/null +++ b/crates/doc/src/text/object.rs @@ -0,0 +1,493 @@ +use super::area::{AreaOptions, PathAlign, PathEffect}; +use super::char_style::CharStyle; +use super::para_style::ParaStyle; +use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end}; +use super::tabs_lists::ParaList; +use super::*; + +impl TextObject { + /// None for collapsed or unrepresentable transforms, whose type dimensions cannot + /// be edited by compensating the stored attributes. + pub fn style_scale(&self) -> Option { + let [a, b, c, d, e, f] = self.xf.as_coeffs(); + if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { + return None; + } + let (sx, sy) = (a.hypot(b), c.hypot(d)); + let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy); + let horizontal = sx / sy; + if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 { + return None; + } + // Avoid a pure rotation turning otherwise identical fields into mixed values. + let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v }; + Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) }) + } + + /// Character attributes as presented by controls (stored runs stay local for layout + /// and rich-text clipboard operations). Explicit vertical character scale stays separate. + pub fn effective_char_style(&self, style: &CharStyle) -> Option { + let scale = self.style_scale()?; + let mut style = style.clone(); + style.size *= scale.points; + style.leading = style.leading.map(|v| v * scale.points); + style.baseline_shift *= scale.points; + style.h_scale *= scale.horizontal; + if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) { + return None; + } + Some(style) + } + + /// Bakes the object's scale ([`style_scale`]) into character attributes and removes that + /// scale from `xf`, so panels show document-point sizes without a separate transform factor. + /// Returns `false` when there is nothing to bake (identity scale or an unrepresentable + /// transform). + pub fn scale_text_attributes(&mut self) -> bool { + let Some(scale) = self.style_scale() else { return false }; + if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 { + return false; + } + let [a, b, c, d, _, _] = self.xf.as_coeffs(); + let (sx, sy) = (a.hypot(b), c.hypot(d)); + if sx <= 1e-12 || sy <= 1e-12 { + return false; + } + let mut baked = Vec::with_capacity(self.runs.len()); + for run in &self.runs { + let Some(eff) = self.effective_char_style(&run.style) else { return false }; + baked.push(eff); + } + for (run, style) in self.runs.iter_mut().zip(baked) { + run.style = style; + } + // Character strokes are drawn in text space: widen them by the vertical scale we remove. + self.scale_char_strokes(1.0 / sy); + self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); + self.cached_bounds = None; + self.cached_baselines.clear(); + true + } + + pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { + Self { + vertical: false, + kind: TextKind::Point, + xf: Affine::translate(origin.to_vec2()), + runs: vec![TextRun::new(text, style)], + ruby: Vec::new(), + para: ParaStyle::default(), + paras: Vec::new(), + first_para_continues: false, + area: AreaOptions::default(), + path_effect: PathEffect::default(), + path_align: PathAlign::default(), + path_spacing: 0.0, + wrap: Vec::new(), + optical_margin: false, + system_layout: false, + fractional_widths: true, + list_count: 0, + cached_bounds: None, + cached_baselines: Vec::new(), + } + } + pub fn plain_text(&self) -> String { + self.runs.iter().map(|r| r.text.as_str()).collect() + } + /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. + /// + /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a + /// range past the text or an inverted one would lay out nothing (or panic in a silly place). + pub fn normalize_ruby(&mut self) { + let len: usize = self.runs.iter().map(|r| r.text.len()).sum(); + for r in &mut self.ruby { + r.start = r.start.min(len); + r.end = r.end.min(len); + if r.start > r.end { + std::mem::swap(&mut r.start, &mut r.end); + } + r.text = r.text.trim().to_string(); + } + self.ruby.retain(|r| r.start < r.end && !r.text.is_empty()); + self.ruby.sort_by_key(|r| (r.start, r.end)); + } + /// The ruby span covering byte `byte` of the plain text, if any. + pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> { + self.ruby.iter().find(|r| r.start <= byte && byte < r.end) + } + /// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered. + pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator { + let (a, b) = (start.min(end), start.max(end)); + self.ruby.iter().filter(move |r| r.start < b && a < r.end) + } + /// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with + /// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into + /// keep their start and end up where the replaced part ended. Call it after every edit of the + /// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word. + pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) { + let (start, end) = (start.min(end), start.max(end)); + let delta = inserted as i64 - (end.saturating_sub(start)) as i64; + let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize }; + for r in &mut self.ruby { + if r.end <= start { + continue; + } + if r.start >= end { + r.start = shift(r.start); + r.end = shift(r.end); + } else { + r.start = r.start.min(start); + r.end = shift(r.end).max(r.start); + } + } + self.normalize_ruby(); + } + /// Number of paragraphs (`\n`-separated; empty text has one). + pub fn paragraph_count(&self) -> usize { + 1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::() + } + /// Attributes of paragraph `i` (the last paragraph's past the end). + pub fn para_at(&self, i: usize) -> &ParaStyle { + self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) + } + /// Every paragraph's attributes, one per paragraph. + pub fn paragraph_styles(&self) -> Vec { + (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() + } + /// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its + /// last entry, a long one is cut). + pub fn set_paragraph_styles(&mut self, styles: Vec) { + if let Some(first) = styles.first() { + self.para = first.clone(); + } + self.paras = styles; + self.normalize_paras(); + } + /// The same attributes for every paragraph. + pub fn set_all_paras(&mut self, style: ParaStyle) { + self.para = style; + self.paras.clear(); + } + /// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the + /// last), `paras[0] == para`, and empty when every paragraph is alike. + pub fn normalize_paras(&mut self) { + if self.paras.is_empty() { + return; + } + let n = self.paragraph_count(); + let last = self.paras.last().cloned().unwrap_or_default(); + self.paras.resize(n, last); + if let Some(first) = self.paras.first_mut() { + first.clone_from(&self.para); + } + if self.paras.iter().all(|p| *p == self.para) { + self.paras.clear(); + } + } + /// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret + /// touches its paragraph). + pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range { + let text = self.plain_text(); + let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len())); + let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count()); + index(a)..index(b) + 1 + } + /// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte + /// where their paragraph starts: when the plain text from the start of the paragraph up to + /// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise. + pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> { + let text = self.plain_text(); + let before = text.get(..at)?; + let start = before.rfind('\n').map_or(0, |i| i + 1); + let list = ParaList::typed(before[start..].strip_suffix(' ')?)?; + let index = before.bytes().filter(|&b| b == b'\n').count(); + self.para_at(index).list.is_none().then_some((start, list)) + } + /// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph). + pub fn edit_paras(&mut self, range: Option>, mut f: impl FnMut(&mut ParaStyle)) { + match range { + None => { + f(&mut self.para); + for p in &mut self.paras { + f(p); + } + } + Some(r) => { + let mut v = self.paragraph_styles(); + for p in v.iter_mut().take(r.end).skip(r.start) { + f(p); + } + self.set_paragraph_styles(v); + } + } + self.normalize_paras(); + } + /// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames. + pub fn para_styles_mut(&mut self) -> impl Iterator { + std::iter::once(&mut self.para).chain(self.paras.iter_mut()) + } + /// Every stored paragraph style (`para` and the per-paragraph ones). + pub fn para_styles(&self) -> impl Iterator { + std::iter::once(&self.para).chain(self.paras.iter()) + } + /// Update the paragraph styles for replacing bytes `start..end` of the current plain text by + /// `insert` (call before changing the runs). The paragraph the range starts in keeps its + /// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that + /// style too (Return continues the paragraph it splits); later paragraphs keep theirs. + pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) { + if self.paras.is_empty() { + return; + } + let r = self.paragraphs_in(start, end); + let v = self.paragraph_styles(); + let Some(keep) = v.get(r.start).cloned() else { return }; + let added = insert.bytes().filter(|&b| b == b'\n').count(); + let mut out: Vec = v.iter().take(r.start + 1).cloned().collect(); + out.extend(std::iter::repeat_n(keep, added)); + out.extend(v.iter().skip(r.end).cloned()); + self.paras = out; + if let Some(first) = self.paras.first() { + self.para = first.clone(); + } + } + pub fn first_style(&self) -> CharStyle { + self.runs.first().map(|r| r.style.clone()).unwrap_or_default() + } + /// Approximate bounds when no layout cache is available (0.55 em average advance). + pub fn estimate_bounds(&self) -> Rect { + let st = self.first_style(); + let text = self.plain_text(); + let lines: Vec<&str> = text.split('\n').collect(); + let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55; + let lead = st.effective_leading(); + Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64) + } + pub fn bounds(&self) -> Option { + match &self.kind { + TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)), + TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)), + TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), + } + } + /// How far the characters' strokes paint past their glyphs, in document space: they scale + /// with the type. + pub fn char_stroke_outset(&self) -> f64 { + let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); + self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k + } + /// How far the characters' strokes can paint past their glyphs, their miter spikes and square + /// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must + /// redraw. They stretch with the type, so this is as far as its transform takes them on either + /// axis. The visual bounds take [`Self::char_stroke_outset`]. + pub fn char_stroke_reach(&self) -> f64 { + let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); + let [a, b, c, d, ..] = self.xf.as_coeffs(); + reach * (a.abs() + c.abs()).max(b.abs() + d.abs()) + } + pub fn transform(&mut self, a: Affine) { + self.xf = a * self.xf; + } + /// Type on a path's path in document space; None for other type. + pub fn type_path(&self) -> Option { + match &self.kind { + TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)), + _ => None, + } + } + /// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its + /// centre bracket dragged across the path): the path runs the other way and the brackets swap + /// ends, so the type keeps its stretch of the path. False, changing nothing, for other type. + pub fn flip_on_path(&mut self) -> bool { + let Some((s, e)) = self.kind.path_span() else { return false }; + let TextKind::OnPath { path, start, end } = &mut self.kind else { return false }; + reverse_from_end(path); + if path.is_closed() { + *start = (1.0 - e).rem_euclid(1.0); + *end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0)); + } else { + *start = 1.0 - e; + *end = (s > 1e-9).then_some(1.0 - s); + } + true + } + /// Area type's frame (the type area) in document space; None for other type. + pub fn area_frame(&self) -> Option { + match &self.kind { + TextKind::Area { frame } => Some(frame.transformed(self.xf)), + _ => None, + } + } + /// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size + /// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for + /// other type, a degenerate `xf` or a frame that would not be finite. + pub fn transform_area(&mut self, a: Affine) -> bool { + let xf = self.xf; + self.reshape_area(|frame, to_text| { + frame.transform(to_text * a * xf); + true + }) + } + /// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with + /// their handles (a Direct Selection drag of a frame corner or edge). False, changing + /// nothing, when an anchor doesn't exist, or as [`Self::transform_area`]. + pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { + self.reshape_area(|frame, to_text| move_anchors(frame, to_text, refs, d)) + } + /// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space, + /// with their handles (a Direct Selection drag of an anchor); the brackets keep their + /// fractions of the path. False, changing nothing, for other type, when an anchor doesn't + /// exist, a degenerate `xf` or a path that would not be finite. + pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { + matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d)) + } + /// Edit a copy of the area frame with `f` (given the document → text space map) and keep it + /// when `f` succeeds and the result is finite. + fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { + matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f) + } + /// [`Self::reshape_area`] for the area frame or type on a path's path. + fn reshape_local(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { + let det = self.xf.determinant(); + if !det.is_finite() || det.abs() < 1e-12 { + return false; + } + let to_text = self.xf.inverse(); + let (TextKind::Area { frame } | TextKind::OnPath { path: frame, .. }) = &mut self.kind else { return false }; + let mut next = frame.clone(); + if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return false; + } + *frame = next; + true + } + /// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so + /// `1 / scale` keeps their weight through a transform that scales the type). + pub fn scale_char_strokes(&mut self, s: f64) { + for r in &mut self.runs { + r.style.stroke_width *= s; + if let Some(d) = &mut r.style.stroke_dash { + d.scale(s); + } + } + } + /// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run + /// gradient fits, as the renderers lay it out. + pub fn local_bounds(&self) -> Rect { + self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()) + } + /// The baselines in text space (the layout cache): for point type with none cached, the first + /// one across [`Self::local_bounds`] (down the first column's centre line for vertical type). + pub fn baselines(&self) -> impl Iterator + '_ { + let first = (self.cached_baselines.is_empty() && matches!(self.kind, TextKind::Point)).then(|| { + let b = self.local_bounds(); + if self.vertical { (Point::new(0.0, b.y0), Point::new(0.0, b.y1)) } else { (Point::new(b.x0, 0.0), Point::new(b.x1, 0.0)) } + }); + self.cached_baselines.iter().copied().chain(first) + } + /// Keeps every run's size, leading, baseline shift and scales in range + /// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab + /// stops ([`ParaStyle::keep_spacing_in_range`]). + pub fn keep_type_values_in_range(&mut self) { + for r in &mut self.runs { + r.style.keep_dimensions_in_range(); + } + for p in self.para_styles_mut() { + p.keep_spacing_in_range(); + } + } +} + +impl crate::Document { + /// Keeps the character dimensions, indents, paragraph spacing and tab stops in range + /// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph + /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts + /// back (opacity-mask art included), and the same attributes of the character and paragraph + /// styles. It recurses through the art: call it on a document no deeper than + /// [`crate::depth::MAX_DEPTH`]. + pub fn keep_type_values_in_range(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + t.keep_type_values_in_range(); + } + }; + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + // Styles store the attributes they set by their serialized names (a number, or null for + // Auto leading); a number is rewritten only when it changes. + for def in self.char_styles.iter_mut() { + let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64); + let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() }; + st.size = num("size").unwrap_or(st.size); + st.h_scale = num("h_scale").unwrap_or(st.h_scale); + st.v_scale = num("v_scale").unwrap_or(st.v_scale); + st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift); + st.keep_dimensions_in_range(); + let kept = [ + ("size", Some(st.size)), + ("h_scale", Some(st.h_scale)), + ("v_scale", Some(st.v_scale)), + ("baseline_shift", Some(st.baseline_shift)), + ("leading", st.leading), + ]; + for (k, v) in kept { + if let Some(old) = def.attrs.get_mut(k) + && old.is_number() + && old.as_f64() != v + { + *old = v.into(); + } + } + } + for def in self.para_styles.iter_mut() { + for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] { + if let Some(v) = def.attrs.get_mut(k) + && let Some(x) = v.as_f64() + && x.abs() > crate::MAX_COORD + { + *v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into(); + } + } + for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() { + if let Some(v) = t.get_mut("position") + && let Some(x) = v.as_f64() + && !(0.0..=100_000.0).contains(&x) + { + *v = x.clamp(0.0, 100_000.0).into(); + } + } + } + } + + /// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches + /// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art + /// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry + /// ranges from outside: a range past the text, an inverted one or an empty reading would lay + /// out nothing. It recurses through the art: call it on a document no deeper than + /// [`crate::depth::MAX_DEPTH`]. + pub fn fix_ruby(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + t.normalize_ruby(); + } + }; + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + } +} diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs new file mode 100644 index 000000000..6a01a1815 --- /dev/null +++ b/crates/doc/src/text/para_style.rs @@ -0,0 +1,517 @@ +use super::char_style::CharStyle; +use super::tabs_lists::{ParaList, TabStop}; +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Justify { + /// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for + /// left-to-right paragraphs, right for right-to-left ones. New type's alignment. + Auto, + #[default] + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, +} + +/// Paragraph composer (Paragraph panel menu): how lines are broken. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Composer { + /// Break each line as soon as it is full. + SingleLine, + /// Total fit over the whole paragraph (Illustrator's default): justified lines get even word + /// spacing, ragged lines an even rag. + #[default] + EveryLine, +} + +impl Composer { + fn is_default(&self) -> bool { + *self == Self::default() + } +} + +/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic +/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from +/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen +/// limit, no hyphenation zone, and capitalized words hyphenated. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct HyphenationOptions { + /// Words Longer Than: only words with more letters than this are hyphenated. + pub words_longer_than: u32, + /// After First: the fewest letters a word keeps before the hyphen. + pub after_first: u32, + /// Before Last: the fewest letters a word keeps after the hyphen. + pub before_last: u32, + /// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen; + /// 0 sets no limit. + pub hyphen_limit: u32, + /// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next + /// word already ends within this distance of its right edge breaks before that word instead + /// of hyphenating it. 0 allows every hyphenation. + pub zone: f64, + /// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not + /// hyphenated. Words in capitals only are never hyphenated. + pub capitalized: bool, +} + +impl Default for HyphenationOptions { + fn default() -> Self { + Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true } + } +} + +/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SingleWordJustify { + /// Spread across the line, between its letters. + #[default] + FullJustify, + AlignLeft, + AlignCenter, + AlignRight, +} + +/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent. +/// +/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired +/// applies to every line of the paragraph, justified or not: word spaces are that share of the +/// font's word space, each letter is followed by that share of a word space, and glyphs are that +/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only +/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph +/// scaling 100/100/100, Auto Leading 120 and Full Justify. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct JustificationOptions { + /// Word Spacing, percent of the font's word space (0..1000). + pub word_spacing: [f64; 3], + /// Letter Spacing, percent of the font's word space added after each letter (-100..500). + pub letter_spacing: [f64; 3], + /// Glyph Scaling, percent of the glyphs' width (50..200). + pub glyph_scaling: [f64; 3], + /// Auto Leading: the leading of characters whose leading is Auto, in percent of their size + /// (0..500). + pub auto_leading: f64, + /// Single Word Justification: how a justified line holding one word is set. + pub single_word: SingleWordJustify, +} + +impl JustificationOptions { + /// Word Spacing's range (percent). + pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0); + /// Letter Spacing's range (percent). + pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0); + /// Glyph Scaling's range (percent). + pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0); + /// Auto Leading's range (percent). + pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0); + + /// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change + /// in a line's natural widths)? + pub fn desired_is_default(&self) -> bool { + let d = Self::default(); + self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1] + } + + /// Keeps options read from a file to what `text.setFormat` can set: a value that is not a + /// finite number becomes its default, every value is clamped to its range, and a Minimum, + /// Desired and Maximum out of order go back to their defaults together. + pub fn keep_in_range(&mut self) { + let d = Self::default(); + for (v, dv, (lo, hi)) in [ + (&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING), + (&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING), + (&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING), + ] { + for (x, dx) in v.iter_mut().zip(dv) { + *x = if x.is_finite() { x.clamp(lo, hi) } else { dx }; + } + if !(v[0] <= v[1] && v[1] <= v[2]) { + *v = dv; + } + } + let (lo, hi) = Self::AUTO_LEADING; + self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading }; + } + + /// A paragraph's leading for characters of `size` whose leading is Auto. + pub fn auto_leading_for(&self, size: f64) -> f64 { + size * self.auto_leading / 100.0 + } +} + +impl Default for JustificationOptions { + fn default() -> Self { + Self { + word_spacing: [80.0, 100.0, 133.0], + letter_spacing: [0.0, 0.0, 0.0], + glyph_scaling: [100.0, 100.0, 100.0], + auto_leading: 120.0, + single_word: SingleWordJustify::FullJustify, + } + } +} + +/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of +/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair +/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter +/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font +/// draws them, at most an em long. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kashida { + /// No kashidas: justified lines widen their word spaces. + #[default] + None, + Short, + Medium, + Long, + Stylistic, +} + +impl Kashida { + /// The longest kashida, in ems of the letter that follows it in the text; 0 for None. + pub fn max_em(self) -> f64 { + match self { + Kashida::None => 0.0, + Kashida::Short => 0.25, + Kashida::Medium => 0.5, + Kashida::Long | Kashida::Stylistic => 1.0, + } + } +} + +/// Paragraph attributes (the Paragraph panel). +/// +/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before +/// [`Justify::Auto`]. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(from = "ParaStyleFile", into = "ParaStyleFile")] +pub struct ParaStyle { + pub justify: Justify, + pub left_indent: f64, + pub right_indent: f64, + pub first_line_indent: f64, + pub space_before: f64, + pub space_after: f64, + pub hyphenate: bool, + /// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…). + pub hyphenation: HyphenationOptions, + /// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification + /// (Paragraph panel menu › Justification…). + pub justification: JustificationOptions, + /// Line breaking: Single-line or Every-line Composer. + pub composer: Composer, + /// Tab stops (Tabs panel), sorted by position. + pub tabs: Vec, + /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not + /// in a list. + pub list: Option, + /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. + pub style_name: Option, + /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made + /// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before + /// it and imported text (already set) keep [`Mojikumi::None`]. + pub mojikumi: Mojikumi, + /// Paragraph direction (Paragraph panel): the base direction of each paragraph for + /// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or + /// Arabic: right to left). + pub direction: Option, + /// How leading is measured (Paragraph panel menu): from baseline to baseline, or from the top + /// of one line's ideographic em box to the next. + pub leading_model: LeadingModel, + /// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line + /// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before + /// it and imported text keep [`Burasagari::None`]. + pub burasagari: Burasagari, + /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a + /// line. + pub kinsoku: Kinsoku, + /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and + /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, + /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. + pub roman_hanging: bool, + /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with + /// kashidas before they widen their word spaces. + pub kashida: Kashida, +} + +impl ParaStyle { + /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to + /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range + /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is + /// not a number becomes 0. + pub fn keep_spacing_in_range(&mut self) { + let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) }; + for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] { + *v = within(*v, -crate::MAX_COORD, crate::MAX_COORD); + } + for t in &mut self.tabs { + t.position = within(t.position, 0.0, 100_000.0); + } + } + + /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own + /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their + /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is + /// not a finite number). + pub fn effective_leading(&self, st: &CharStyle) -> f64 { + let auto = self.justification.auto_leading; + if st.leading.is_some() || auto == JustificationOptions::default().auto_leading { + return st.effective_leading(); + } + let l = self.justification.auto_leading_for(st.size); + if l.is_finite() { l } else { st.effective_leading() } + } +} + +/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph +/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto +/// read the alignment and ignore the flag, and builds with it read Auto back. +#[derive(Serialize, Deserialize)] +struct ParaStyleFile { + #[serde(default)] + justify: Justify, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + justify_auto: bool, + #[serde(default)] + left_indent: f64, + #[serde(default)] + right_indent: f64, + #[serde(default)] + first_line_indent: f64, + #[serde(default)] + space_before: f64, + #[serde(default)] + space_after: f64, + #[serde(default)] + hyphenate: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + hyphenation: HyphenationOptions, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + justification: JustificationOptions, + #[serde(default, skip_serializing_if = "Composer::is_default")] + composer: Composer, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tabs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + list: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + style_name: Option, + #[serde(default, skip_serializing_if = "Mojikumi::is_none")] + mojikumi: Mojikumi, + #[serde(default, skip_serializing_if = "Option::is_none")] + direction: Option, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + leading_model: LeadingModel, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + burasagari: Burasagari, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kinsoku: Kinsoku, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + roman_hanging: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kashida: Kashida, +} + +impl From for ParaStyleFile { + fn from(p: ParaStyle) -> Self { + let justify_auto = p.justify == Justify::Auto; + let justify = match p.justify { + Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right, + Justify::Auto => Justify::Left, + j => j, + }; + let ParaStyle { + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + hyphenation, + justification, + composer, + tabs, + list, + style_name, + mojikumi, + direction, + leading_model, + burasagari, + kinsoku, + roman_hanging, + kashida, + .. + } = p; + Self { + justify, + justify_auto, + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + hyphenation, + justification, + composer, + tabs, + list, + style_name, + mojikumi, + direction, + leading_model, + burasagari, + kinsoku, + roman_hanging, + kashida, + } + } +} + +impl From for ParaStyle { + fn from(f: ParaStyleFile) -> Self { + let justify = if f.justify_auto { Justify::Auto } else { f.justify }; + let mut justification = f.justification; + justification.keep_in_range(); + let ParaStyleFile { + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + hyphenation, + composer, + tabs, + list, + style_name, + mojikumi, + direction, + leading_model, + burasagari, + kinsoku, + roman_hanging, + kashida, + .. + } = f; + Self { + justify, + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + hyphenation, + justification, + composer, + tabs, + list, + style_name, + mojikumi, + direction, + leading_model, + burasagari, + kinsoku, + roman_hanging, + kashida, + } + } +} + +/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set +/// in ([`CharStyle::char_direction`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ParaDirection { + LeftToRight, + RightToLeft, +} + +/// How a paragraph's leading is measured. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LeadingModel { + /// From one line's baseline to the next's: a line's leading is the space above it, and area + /// type's first baseline follows Area Type Options › First Baseline. + #[default] + RomanBaseline, + /// From the top of one line's ideographic em box (the right side, in vertical type) to the + /// next's: a line's leading is the space below it, and area type's first line touches the top + /// of the frame. Japanese layout's usual model. + EmBoxTop, +} + +/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half +/// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's +/// after it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mojikumi { + /// Every character takes its full advance. + #[default] + None, + /// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket, + /// comma or full stop ending a line is set half width. + LineEndHalf, +} + +impl Mojikumi { + pub fn is_none(&self) -> bool { + *self == Mojikumi::None + } +} + +/// Which Japanese characters may not start or end a line (kinsoku shori). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kinsoku { + /// No kinsoku: a line may break between any two characters that allow a break. + None, + /// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small + /// kana and the like don't start a line; opening brackets don't end one. + #[default] + Hard, + /// As Hard, except that 々, the prolonged sound mark ー and small kana may start a line + /// (JLREQ's level 3 line-breaking rules, Appendix C.3). + Soft, +} + +/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) +/// stands outside the line's measure, in the space its punctuation spacing gives it (half width +/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown +/// as None / Regular / Force. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Burasagari { + /// A comma or full stop that doesn't fit goes to the next line with the character before it. + #[default] + None, + /// A comma or full stop that doesn't fit hangs outside the line; one that fits stays inside. + Standard, + /// A comma or full stop ending a line always hangs, and the rest of the line fills the measure. + Forced, +} + +/// Area Type Options "First Baseline" offset. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FirstBaseline { + /// The tallest glyph ascent touches the frame top (Illustrator's default). + #[default] + Ascent, + CapHeight, + XHeight, + /// The first line's leading. + Leading, + /// Exactly `first_baseline_min` below the top. + Fixed, +} diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs new file mode 100644 index 000000000..4e3de9338 --- /dev/null +++ b/crates/doc/src/text/runs.rs @@ -0,0 +1,207 @@ +use super::char_style::CharStyle; +use super::*; + +/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or +/// [`ParaStyle`] fields, by their serialized names). Text using it records the name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextStyleDef { + pub name: String, + #[serde(default)] + pub attrs: serde_json::Map, +} + +/// A run of text sharing one character style. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextRun { + pub text: String, + pub style: CharStyle, + /// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows + /// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets + /// the size and tracking; fills and strokes don't apply to the art. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inline: Option, +} + +impl TextRun { + /// A plain text run. + pub fn new(text: impl Into, style: CharStyle) -> Self { + Self { text: text.into(), style, inline: None } + } + /// An inline graphic run showing `art`. + pub fn inline(art: InlineArt, style: CharStyle) -> Self { + Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) } + } +} + +/// The object replacement character: the plain text of an inline graphic run. +pub const INLINE_CHAR: char = '\u{FFFC}'; + +/// Largest inline graphic scale (× the run's size) accepted from input. +pub const INLINE_MAX_SCALE: f64 = 100.0; + +/// A document symbol placed inline in text ([`TextRun::inline`]). +/// +/// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's +/// font size, its left edge at the pen position, and its vertical centre on the middle of the +/// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap +/// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at +/// the default scale, stays inside the font's ascent and descent so the leading doesn't change. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct InlineArt { + /// Name of the document symbol ([`crate::Symbol::name`]). + pub symbol: String, + /// Art height as a multiple of the run's font size. + #[serde(default = "one")] + pub scale: f64, + /// Extra raise in points (positive = up). + #[serde(default)] + pub baseline_shift: f64, + /// The symbol art's visual bounds at its natural size, resolved from the document's symbols by + /// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the + /// layout then reserves a one-em square and nothing is drawn. + #[serde(skip)] + pub bounds: Option, +} + +fn one() -> f64 { + 1.0 +} + +fn fifty() -> f64 { + 50.0 +} + +impl InlineArt { + pub fn new(symbol: impl Into) -> Self { + Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None } + } + /// `scale`, made finite and positive (untrusted input). + pub fn safe_scale(&self) -> f64 { + if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 } + } + /// `baseline_shift`, made finite (untrusted input). + pub fn safe_shift(&self) -> f64 { + if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum TextKind { + /// Point type: anchored at the baseline origin of the first line. + Point, + /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). + Area { frame: PathData }, + /// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the + /// path length; no `end`: the end of the path, or once round a closed path). See + /// [`TextKind::path_span`]. + OnPath { + path: PathData, + start: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + end: Option, + }, +} + +impl TextKind { + /// Type on a path's span: from its start to its end bracket, as fractions of its path's length. + /// Round a closed path the end may be past 1 (the span runs on past the path's start), a full + /// turn without an end or with the end at the start. None for other type. + pub fn path_span(&self) -> Option<(f64, f64)> { + let TextKind::OnPath { path, start, end } = self else { return None }; + let s = fraction(*start); + Some(if path.is_closed() { + let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0)); + (s, s + if run < 1e-9 { 1.0 } else { run }) + } else { + (s, end.map_or(1.0, |e| fraction(e).max(s))) + }) + } +} + +/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the +/// document → text map). False when an anchor doesn't exist. +pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { + // A distance: only the linear part of the document → text map applies. + let local = to_text * d.to_point() - to_text * Point::ZERO; + refs.iter().all(|&(si, ai)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some()) +} + +/// `x` as a fraction of a path's length (0..1; 0 when not finite). +fn fraction(x: f64) -> f64 { + if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 } +} + +/// `path` run the other way, each point as far from its new start as it was from its old end +/// (the subpaths in reverse order, a closed one keeping its first anchor first). +pub(super) fn reverse_from_end(path: &mut PathData) { + path.subpaths.reverse(); + for sp in &mut path.subpaths { + sp.reverse(); + if sp.closed && !sp.anchors.is_empty() { + sp.anchors.rotate_right(1); + } + } +} + +/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in +/// vertical type (the Japanese convention), or below (left in vertical type). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RubyPosition { + #[default] + Above, + Below, +} + +/// How ruby text lines up over its base text when the two are different widths. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RubyAlign { + /// Centred over the base text. + #[default] + Center, + /// Starting at the base text's start (left in horizontal type, top in vertical). + Start, + /// Ending at the base text's end (right in horizontal type, bottom in vertical). + End, +} + +/// Ruby (furigana, phonetic guide): a reading set over a range of the text. +/// +/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as +/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text; +/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and +/// allowed to overhang neighbouring characters when the reading is longer than its base. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RubySpan { + /// Byte offset of the first base character in the plain text. + pub start: usize, + /// Byte offset just past the last base character. + pub end: usize, + /// The reading, set in the base characters' font (its size comes from `size_percent`). + pub text: String, + #[serde(default)] + pub position: RubyPosition, + #[serde(default)] + pub align: RubyAlign, + /// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual). + #[serde(default = "fifty")] + pub size_percent: f64, + /// Let a reading wider than its base hang over the neighbouring characters instead of being + /// squeezed to the base's width. + #[serde(default = "yes")] + pub overhang: bool, +} + +impl RubySpan { + /// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging). + pub fn new(start: usize, end: usize, text: impl Into) -> Self { + Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true } + } + /// `size_percent` made finite and usable: 1–100% (untrusted input). + pub fn safe_size_percent(&self) -> f64 { + if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 } + } +} diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs new file mode 100644 index 000000000..763a82c69 --- /dev/null +++ b/crates/doc/src/text/tabs_lists.rs @@ -0,0 +1,249 @@ +use super::char_style::{CharPosition, CharStyle}; +use super::*; + +/// Tab stop alignment (Tabs panel). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TabAlign { + #[default] + Left, + Center, + Right, + /// Aligns on the first `align_on` character (a decimal point by default). + Decimal, +} + +/// A tab stop, measured from the left edge of the text (area type: the frame's left edge). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TabStop { + pub position: f64, + #[serde(default)] + pub align: TabAlign, + /// Leader characters repeated across the tab's gap (e.g. ". "). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub leader: String, + /// Decimal tabs align on this character. + #[serde(default = "default_align_on")] + pub align_on: char, +} + +fn default_align_on() -> char { + '.' +} + +/// Distance between default tab stops when no explicit stop applies (½ inch). +pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; + +/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Bullet { + /// A filled dot (•). + #[default] + Dot, + /// A dash (–). + Dash, + /// A hollow circle (◦). + Circle, + /// A filled square (▪). + Square, +} + +impl Bullet { + /// The character the layout draws for the bullet. + pub fn character(self) -> char { + match self { + Bullet::Dot => '\u{2022}', + Bullet::Dash => '\u{2013}', + Bullet::Circle => '\u{25E6}', + Bullet::Square => '\u{25AA}', + } + } +} + +/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered +/// List presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NumberStyle { + /// 1, 2, 3… + #[default] + Arabic, + /// A, B, C… Z, AA, BB… + UpperLetters, + /// a, b, c… z, aa, bb… + LowerLetters, + /// I, II, III, IV… + UpperRoman, + /// i, ii, iii, iv… + LowerRoman, + /// 01, 02… 99, 100… + TwoDigits, + /// 001, 002… 999, 1000… + ThreeDigits, +} + +/// Past this number, roman numerals and letters are written in Arabic digits. +const MAX_LETTERS_OR_ROMAN: u32 = 3999; + +impl NumberStyle { + /// Every style, in the order the Numbered List presets list them. + pub const ALL: [NumberStyle; 7] = [ + NumberStyle::Arabic, + NumberStyle::UpperLetters, + NumberStyle::LowerLetters, + NumberStyle::UpperRoman, + NumberStyle::LowerRoman, + NumberStyle::TwoDigits, + NumberStyle::ThreeDigits, + ]; + + /// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then + /// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD, + /// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and + /// keep every digit of a longer number. + pub fn format(self, n: u32) -> String { + let letters_or_roman = + matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman); + if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) { + return n.to_string(); + } + match self { + NumberStyle::Arabic => n.to_string(), + NumberStyle::TwoDigits => format!("{n:02}"), + NumberStyle::ThreeDigits => format!("{n:03}"), + NumberStyle::UpperLetters => letters(n), + NumberStyle::LowerLetters => letters(n).to_lowercase(), + NumberStyle::UpperRoman => roman(n), + NumberStyle::LowerRoman => roman(n).to_lowercase(), + } + } +} + +/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on. +fn letters(n: u32) -> String { + let n = n - 1; + let letter = char::from(b'A' + (n % 26) as u8); + std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect() +} + +/// `n` (1 to 3999) in capital roman numerals. +fn roman(mut n: u32) -> String { + const DIGITS: [(u32, &str); 13] = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; + let mut s = String::new(); + for (value, digits) in DIGITS { + while n >= value { + s.push_str(digits); + n -= value; + } + } + s +} + +/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the +/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's +/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual +/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after +/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a +/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves +/// out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct ParaList { + /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for + /// when the list is made bulleted again. + pub bullet: Bullet, + /// A numbered list's number style. A bulleted list has none, and the file saves it only for a + /// numbered list. + #[serde(skip_serializing_if = "Option::is_none")] + pub number: Option, +} + +impl ParaList { + /// A bulleted list with `bullet`. + pub fn bulleted(bullet: Bullet) -> Self { + Self { bullet, number: None } + } + + /// A numbered list with `style`. + pub fn numbered(style: NumberStyle) -> Self { + Self { bullet: Bullet::default(), number: Some(style) } + } + + /// The characters of the marker the layout draws before the paragraph: the bullet, or for a + /// numbered list number `n` in its style followed by a period. + pub fn marker(&self, n: u32) -> String { + match self.number { + Some(style) => format!("{}.", style.format(n)), + None => self.bullet.character().to_string(), + } + } + + /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and + /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. + /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has + /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the + /// count as it is; a numbered paragraph adds one to the count and is numbered with it. + pub fn next_marker(list: Option, empty: bool, count: &mut u32) -> Option { + let Some(list) = list else { + *count = 0; + return None; + }; + if empty { + return None; + } + if list.number.is_some() { + *count = count.saturating_add(1); + } + Some(list.marker(*count)) + } + + /// The style the marker is set in: `first` (the style of the paragraph's first character) + /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, + /// tate-chu-yoko, underline and strikethrough. + pub fn marker_style(first: &CharStyle) -> CharStyle { + CharStyle { + baseline_shift: 0.0, + rotation: 0.0, + position: CharPosition::Normal, + tracking: 0.0, + kerning: None, + tate_chu_yoko: false, + underline: false, + strikethrough: false, + ..first.clone() + } + } + + /// The list that the Type tool starts when `typed` and a space are typed at the start of a + /// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B, + /// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts + /// none. + pub fn typed(typed: &str) -> Option { + match typed { + "1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)), + "A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)), + "*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)), + _ => None, + } + } +} + +/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the +/// marker hangs in the first line's 18 pt before the text. +pub const LIST_HANGING_INDENT: f64 = 18.0; From 83fbc0176009239035aa5e7028a9325ee98f2181 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:59:46 +0000 Subject: [PATCH 118/199] refactor(affinity): move model.rs items to model/{bounds,read}.rs --- crates/affinity/src/model.rs | 750 +--------------------------- crates/affinity/src/model/bounds.rs | 142 ++++++ crates/affinity/src/model/read.rs | 605 ++++++++++++++++++++++ 3 files changed, 757 insertions(+), 740 deletions(-) create mode 100644 crates/affinity/src/model/bounds.rs create mode 100644 crates/affinity/src/model/read.rs diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 7f10f8823..a6d18f339 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -8,6 +8,16 @@ use crate::paint::{self, Paint, Stroke}; use crate::stream::{ObjId, Stream, Tag}; use crate::{Archive, Error, Limits, stream}; +mod bounds; +mod read; + +pub(crate) use bounds::rect; +pub use bounds::{kind_bounds, nodes_bounds}; +pub(crate) use read::Reader; +pub use read::read; +#[cfg(test)] +pub(crate) use read::read_within; + /// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order). #[derive(Debug, Clone, Copy, PartialEq)] pub struct Affine(pub [f64; 6]); @@ -459,743 +469,3 @@ pub struct Document { /// colorimetric intent and black-point compensation in every document checked. pub cmyk_profile: Option>, } - -/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). -const MAX_PROFILE_BYTES: usize = 32 << 20; - -/// Deepest layer nesting followed; deeper content is reported, not imported. -const MAX_TREE_DEPTH: usize = 128; -/// Total layer nodes imported. -const MAX_NODES: usize = 500_000; - -/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits -/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). -const MAX_LAYER_PIXELS: u64 = 512 << 20; - -/// Embedded documents inside embedded documents are followed this deep. -const MAX_EMBED_DEPTH: usize = 4; -/// Characters of all stories together (a story may be shared by many text layers). -const MAX_DOCUMENT_CHARS: usize = 16 << 20; -/// Table cells of all tables together (each becomes a text object with its edge lines). -const MAX_DOCUMENT_CELLS: usize = 1 << 18; - -/// What a whole read may still use. Shared objects can be linked from many layers, and embedded -/// documents are read with what their embedding document left, so linking or embedding the -/// same content many times can't multiply the work. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Budget { - /// Layer nodes ([`MAX_NODES`] in all). - pub(crate) nodes: usize, - /// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all). - pub(crate) pixels: u64, - /// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all). - pub(crate) chars: usize, - /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). - pub(crate) cells: usize, -} - -impl Budget { - pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS }; -} - -/// Read a document: archive, `doc.dat` and the model. -pub fn read(bytes: &[u8], limits: Limits) -> Result { - read_within(bytes, limits, MAX_LAYER_PIXELS) -} - -/// [`read`], its pixel layers holding at most `pixels` pixels together. -pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { - read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document -} - -/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted -/// either way. -struct ReadAt { - document: Result, - extracted: usize, -} - -/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth` -/// deep). What it uses is taken from `budget`, also when it fails part of the way. -/// The most characters of a layer's name kept. -const MAX_NAME_CHARS: usize = 1024; - -fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt { - let mut archive = match Archive::open(bytes, limits) { - Ok(a) => a, - Err(e) => return ReadAt { document: Err(e), extracted: 0 }, - }; - let document = read_archive(&mut archive, budget, base, depth); - ReadAt { document, extracted: archive.extracted() } -} - -fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result { - let doc = archive.read("doc.dat")?; - let s = stream::parse(&doc)?; - let mut reader = Reader::new(&s, archive, base, depth); - reader.budget = *budget; - let document = reader.document(); - *budget = reader.budget; - document -} - -pub(crate) struct Reader<'s, 'a, 'b> { - pub(crate) s: &'s Stream, - pub(crate) archive: &'b mut Archive<'a>, - pub(crate) dpi: f64, - pub(crate) warnings: BTreeMap, - active: HashSet, - /// Where the spreads go: the identity, or an embedding document's placement. - base: Affine, - /// The linear part of `base`, which also scales frame text. - pub(crate) type_base: Affine, - /// How deep inside embedded documents this one is. - depth: usize, - /// An embedded document's content, waiting to become its node's children. - pending: Option>, - /// The master spreads, in the order of [`Document::masters`]. - master_ids: Vec, - /// What the rest of the read may use. - pub(crate) budget: Budget, -} - -impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self { - let [a, b, c, d, ..] = base.0; - Self { - s, - archive, - dpi: 72.0, - warnings: BTreeMap::new(), - active: HashSet::new(), - base, - type_base: Affine([a, b, c, d, 0.0, 0.0]), - depth, - pending: None, - master_ids: Vec::new(), - budget: Budget::FULL, - } - } - - pub(crate) fn warn(&mut self, what: impl Into) { - *self.warnings.entry(what.into()).or_default() += 1; - } - - /// The document. - fn document(&mut self) -> Result { - let s = self.s; - let root = s.root; - self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); - let saved_by = s.obj(root, b"NVer").map(|v| { - let product = s.str(v, b"Prod").unwrap_or("Affinity"); - match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) { - (Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"), - _ => product.to_string(), - } - }); - let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; - self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect(); - let mut spreads = Vec::new(); - for spread in s.objs(doc, b"Chld") { - if let Some(sp) = self.spread(doc, spread)? { - spreads.push(sp); - } - } - if spreads.is_empty() { - return Err(Error::Malformed("document has no pages")); - } - let mut masters = Vec::new(); - for master in self.master_ids.clone() { - match self.spread(doc, master)? { - Some(sp) => masters.push(sp), - // Keep the indices of `MasterInstance::master` valid. - None => masters.push(Spread { - bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }, - pages: Vec::new(), - transparent: true, - nodes: Vec::new(), - }), - } - } - let cmyk_profile = s - .obj(root, b"CMYP") - .and_then(|p| s.bytes(p, b"Data")) - .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice())) - .map(<[u8]>::to_vec); - let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); - Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }) - } - - /// A spread (`Sprd`) of the document `doc`, or of its master spreads. - fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> { - let s = self.s; - if !s.is(spread, b"Sprd") { - self.warn("an unknown kind of page"); - return Ok(None); - } - let pages = pages(s, spread); - let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); - let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { - x0: 0.0, - y0: 0.0, - x1: 0.0, - y1: 0.0, - }); - let transparent = s.bool(spread, b"SprT").unwrap_or(false); - let (nodes, mask) = self.children(spread, self.base, 0)?; - if mask.is_some() { - self.warn("mask layers directly on a page (imported without them)"); - } - Ok(Some(Spread { bounds, pages, transparent, nodes })) - } - - /// The children of `parent`, and the mask layer among them that masks the parent (with its - /// placement), read later over just what it masks. - #[allow(clippy::type_complexity)] - fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> { - let s = self.s; - let mut out = Vec::new(); - let mut mask = None; - for c in s.objs(parent, b"Chld") { - if s.is(c, b"MRst") { - // A mask layer inside a group or layer masks that container. - if s.bool(c, b"Visi") == Some(false) { - continue; - } - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - if mask.is_none() { - mask = Some((c, local.then(world))); - } else { - self.warn("several pixel masks on one layer (only the first is used)"); - } - continue; - } - if let Some(n) = self.node(c, world, depth + 1)? { - out.push(n); - } - } - Ok((out, mask)) - } - - fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { - let s = self.s; - if depth > MAX_TREE_DEPTH { - self.warn("layers nested deeper than 128 levels"); - return Ok(None); - } - let Some(left) = self.budget.nodes.checked_sub(1) else { - return Err(Error::Limit("too many layers")); - }; - self.budget.nodes = left; - // A node that contains itself (a shared object linked back into its own subtree). - if !self.active.insert(id) { - self.warn("a layer that contains itself"); - return Ok(None); - } - let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let world = local.then(parent); - let class = s.class(id).unwrap_or(Tag(0)); - // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); - let visible = s.bool(id, b"Visi").unwrap_or(true); - let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); - let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); - let blend = match s.enumeration(id, b"Blnd") { - None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough, - None => Blend::Normal, - Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { - self.warn("an unknown blend mode (imported as Normal)"); - Blend::Normal - }), - }; - let kind = if !world.is_finite() { - self.warn("a layer with an invalid transform"); - Kind::Unsupported - } else { - self.kind(id, class, world, parent)? - }; - // This node's embedded content, before its children's `node` calls can take it. - let embedded = self.pending.take(); - // A compound's children are its operands, already merged into its outline. - let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; - if let Some(embedded) = embedded { - children.extend(embedded); - } - // Pixel masks are read only over what they mask (they can span a whole spread at print - // resolution, a gigapixel). - let over = mask_extent(&kind, &children); - let child_mask = match child_mask { - Some((c, at)) => crate::raster::mask(self, c, at, over)?, - None => None, - }; - let (mask, attached_mask) = self.attached_masks(id, world, over)?; - let pixel_mask = attached_mask.or(child_mask); - self.active.remove(&id); - let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children }; - // Gradient overlays are laid out over the node's bounds, also while it is hidden. - let bounds = tree_bounds(&node); - node.effects = self.effects(id, world, bounds); - Ok(Some(node)) - } - - fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { - let s = self.s; - Ok(match &class.0.to_be_bytes() { - b"Scop" => Kind::Layer, - b"Grup" => Kind::Group, - b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => { - // Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp` - // property object in `phrp`, while older documents use `ABEn`. Ordinary shapes - // in that fixture have neither. See the pinned corpus and validation audit. - if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) { - return Ok(self.artboard(id, world)); - } - let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else { - self.warn(format!("a {} that could not be read", describe(class))); - return Ok(Kind::Unsupported); - }; - let (fills, strokes) = paint::node_paint(self, id, world); - Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } - } - b"TxtT" => match crate::text::table(self, id, world) { - Some(t) => Kind::Table(t), - None => { - self.warn("tables that could not be read"); - Kind::Unsupported - } - }, - b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { - Some(t) => Kind::Text(t), - None => { - self.warn("text whose story could not be read (master-page text, or an unknown layout)"); - Kind::Unsupported - } - }, - b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? { - Some(img) => Kind::Image(img), - None => Kind::Unsupported, - }, - b"MPIN" => { - // `SLnk` links the instance with the master spread it shows. - let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default(); - let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o)); - let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096); - match master { - Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) }, - None => { - self.warn("master page instances without their master"); - Kind::Unsupported - } - } - } - b"EmbN" => match self.embedded_document(id, world) { - // The embedded document itself, as editable content. - Some(nodes) => { - self.pending = Some(nodes); - Kind::Group - } - None => match crate::raster::embedded(self, id, world)? { - Some(img) => { - self.warn("embedded documents and symbols (imported as pictures of them)"); - Kind::Image(img) - } - None => { - self.warn("embedded documents and symbols without a cached picture"); - Kind::Unsupported - } - }, - }, - b"FRst" => { - // A fill layer paints its whole bitmap area; its gradient lives in page space. - let b = s.obj(id, b"Bitm"); - let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0); - match (size(b"BmpW"), size(b"BmpH")) { - (Some(w), Some(h)) => { - let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] }; - let fills = paint::fills(self, id, parent, b"BFFl"); - Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false } - } - _ => { - self.warn("fill layers without an area"); - Kind::Unsupported - } - } - } - _ => { - self.warn(format!("{} layers", describe(class))); - Kind::Unsupported - } - }) - } - - /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the - /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below. - fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec { - let s = self.s; - let mut out = Vec::new(); - for e in s.objs(id, b"FiEf").into_iter().take(64) { - if s.bool(e, b"Enab") == Some(false) { - continue; - } - // "Scale with object": lengths follow the node's transform; otherwise they are absolute. - let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 }; - let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k; - let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); - let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0); - let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c)); - let class = s.class(e).map(|c| c.0.to_be_bytes()); - match (class.as_ref(), color) { - (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), - (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), - (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }), - (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => { - let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a); - out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align }); - } - (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"), - (Some(b"BevE" | b"EmbE"), _) => { - // No radius, no bevel (as Affinity draws it). - let size = len(b"Radi"); - if size > 0.0 { - let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; - let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; - let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white); - let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black); - let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0); - let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0); - let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0); - let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0); - out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner }); - } - } - (Some(b"ColO"), Some(color)) => { - let blend = self.effect_blend(e); - out.push(Effect::ColorOverlay { color, opacity, blend }); - } - (Some(b"GrdO"), _) => { - let blend = self.effect_blend(e); - // The gradient's unit space spans the node's bounding box from -1 to 1, centred - // on it (y down): checked against the stored previews of real documents. - let space = - bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0])); - match (s.obj(e, b"GrFl"), space) { - (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) { - Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }), - Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }), - _ => {} - }, - // Nothing visible to lay it over. - (Some(_), None) => {} - (None, _) => self.warn("gradient overlay effects that could not be read (left out)"), - } - } - (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"), - (Some(b"Gaus"), _) => self.warn("blur effects (left out)"), - _ => self.warn("layer effects of other kinds (left out)"), - } - } - out - } - - /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned). - fn effect_blend(&mut self, e: ObjId) -> Blend { - match self.s.enumeration(e, b"BlnM") { - None => Blend::Normal, - Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { - self.warn("an unknown blend mode (imported as Normal)"); - Blend::Normal - }), - } - } - - /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; - /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. - fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> { - let s = self.s; - let attached = s.objs(id, b"AdCh"); - let mut mask: Option = None; - let mut pixels: Option = None; - for a in attached { - let class = s.class(a).unwrap_or(Tag(0)); - if s.bool(a, b"Visi") == Some(false) { - continue; - } - if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") { - let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - if let Some((p, _)) = crate::geometry::outline(self, a, class, local) { - mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths); - } - } else if class == Tag::of(b"MRst") { - let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - match crate::raster::mask(self, a, local, over)? { - Some(m) if pixels.is_none() => pixels = Some(m), - Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), - None => {} - } - } else { - self.warn(format!("{} layers attached to other layers (imported without them)", describe(class))); - } - } - Ok((mask, pixels)) - } - - /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds - /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes, - /// placed so the centre of its page, first artboard or content is the node's origin. - fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> { - let s = self.s; - let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string(); - if entry.is_empty() { - return None; - } - if self.depth >= MAX_EMBED_DEPTH { - self.warn("embedded documents nested more than four deep"); - return None; - } - let data = self.archive.read(&entry).ok()?; - let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?; - if !crate::is_affinity(inner) { - return None; - } - // A first reading finds its page and resolution, the second places it. - // Both readings are charged to the budget and the archive's limits, also when they fail: - // each does the work. - let mut budget = self.budget; - let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1); - self.budget = budget; - if self.archive.charge(probe.extracted).is_err() { - self.warn("embedded documents beyond the size limit (left out)"); - return None; - } - let probe = match probe.document { - Ok(d) => d, - Err(e) => { - self.warn(format!("embedded documents that could not be read ({e})")); - return None; - } - }; - let spread = probe.spreads.first()?; - // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page. - let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2); - let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None }); - let page = if content { None } else { spread.pages.first().copied().or(first_board) }; - let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds); - // Its pixels aren't needed any more: never hold both readings' at once. - drop(probe); - // Its document pixels are the node's units, whatever either document's resolution. - let k = 1.0; - let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0); - let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world); - if !base.is_finite() { - self.warn("embedded documents with an invalid placement"); - return None; - } - let mut budget = self.budget; - let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1); - self.budget = budget; - if self.archive.charge(placed.extracted).is_err() { - self.warn("embedded documents beyond the size limit (left out)"); - return None; - } - let doc = placed.document.ok()?; - for w in doc.warnings { - self.warn(format!("{w} [in embedded documents]")); - } - if doc.spreads.len() > 1 { - self.warn("embedded documents with several pages (the first is used)"); - } - let mut nodes = doc.spreads.into_iter().next()?.nodes; - // Its master page instances count its own masters, which aren't kept: in this document - // they would show other masters. - fn strip_masters(nodes: &mut [Node], depth: usize) -> bool { - let mut found = false; - for n in nodes { - if matches!(n.kind, Kind::MasterInstance { .. }) { - n.kind = Kind::Unsupported; - found = true; - } - if depth < MAX_TREE_DEPTH { - found |= strip_masters(&mut n.children, depth + 1); - } - } - found - } - if strip_masters(&mut nodes, 0) { - self.warn("master pages of embedded documents (left out)"); - } - Some(nodes) - } - - fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { - let s = self.s; - let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { - self.warn("an artboard without valid bounds (its children are kept without clipping)"); - return Kind::Unsupported; - }; - let corners = - [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p)); - let [a, b2, c, d, ..] = world.0; - if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 { - self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)"); - } - let xs = corners.map(|p| p.x); - let ys = corners.map(|p| p.y); - let rect = Rect { - x0: xs.iter().copied().fold(f64::INFINITY, f64::min), - y0: ys.iter().copied().fold(f64::INFINITY, f64::min), - x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 { - self.warn("an artboard with an invalid transformed size (its children are kept without clipping)"); - return Kind::Unsupported; - } - let class = s.class(id).unwrap_or(Tag::of(b"ShpN")); - let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| { - self.warn("an artboard whose outline could not be read (clipped to its rectangle)"); - (Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false) - }); - let (background, strokes) = paint::node_paint(self, id, world); - Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } - } -} - -/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames -/// and tables), in document pixels. -pub fn nodes_bounds(nodes: &[Node]) -> Option { - let mut out = None; - walk_bounds(nodes, true, 0, &mut out); - out -} - -/// The bounds of `node` and everything in it, hidden or not. -fn tree_bounds(node: &Node) -> Option { - let mut out = None; - walk_bounds(std::slice::from_ref(node), false, 0, &mut out); - out -} - -fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option) { - if depth > MAX_TREE_DEPTH { - return; - } - for n in nodes.iter().filter(|n| n.visible || !visible_only) { - kind_points(&n.kind, &mut |p| add_point(out, p)); - walk_bounds(&n.children, visible_only, depth + 1, out); - } -} - -/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they -/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here: -/// artistic text, whose glyphs only the layout places. -fn mask_extent(kind: &Kind, children: &[Node]) -> Option { - fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> bool { - if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) { - return false; - } - let mut own = kind_bounds(kind); - if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) { - // As far as a stroke can reach out of its path, miter joins included. - let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max); - own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach }); - } - if let Some(r) = own { - *out = Some(out.map_or(r, |o| union(o, r))); - } - children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out)) - } - let mut out = None; - if visit(kind, children, 0, &mut out) { out } else { None } -} - -/// The bounds of what one node's own kind draws (not its children): its outline's points, its -/// image's or frame's corners, or artistic text's anchor. -pub fn kind_bounds(kind: &Kind) -> Option { - let mut out = None; - kind_points(kind, &mut |p| add_point(&mut out, p)); - out -} - -fn add_point(out: &mut Option, p: Point) { - if p.x.is_finite() && p.y.is_finite() { - let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; - *out = Some(out.map_or(r, |o| union(o, r))); - } -} - -fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) { - let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y })); - match kind { - Kind::Shape { path, .. } | Kind::Artboard { path, .. } => { - for sp in &path.subpaths { - add(sp.start); - for seg in &sp.segments { - seg.iter().for_each(|p| add(*p)); - } - } - } - Kind::Image(img) => { - corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add) - } - Kind::Text(t) => match t.frame { - Some(f) => corners(f, t.transform).into_iter().for_each(add), - None => add(t.transform.apply(t.anchor)), - }, - Kind::Table(t) => { - if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) { - corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add); - } - } - Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} - } -} - -/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` -/// equal pages (Publisher 1 facing spreads). -fn pages(s: &Stream, spread: ObjId) -> Vec { - if let Some(md) = s.obj(spread, b"SpMd") { - let v: Vec = - s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); - if !v.is_empty() { - return v; - } - } - match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { - (Some(b), Some(n @ 2..=16)) => { - let w = (b.x1 - b.x0) / n as f64; - (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() - } - _ => Vec::new(), - } -} - -pub(crate) fn union(a: Rect, b: Rect) -> Rect { - Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) } -} - -fn is_container(class: Tag) -> bool { - class == Tag::of(b"Scop") || class == Tag::of(b"Grup") -} - -pub(crate) fn rect(v: [f64; 4]) -> Rect { - Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] } -} - -/// A readable name for a layer class in warnings. -pub(crate) fn describe(class: Tag) -> &'static str { - match &class.0.to_be_bytes() { - b"PCrv" => "curve", - b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape", - b"Comp" => "compound", - b"Rstr" => "pixel", - b"ImgN" => "image", - b"EmbN" => "embedded document", - b"MPIN" => "master page instance", - b"FRst" => "fill", - b"MRst" => "mask", - b"Slic" | b"SlcP" => "slice", - b"3DLA" => "3D", - [_, _, b'R', b'A'] => "adjustment", - _ => "unknown", - } -} diff --git a/crates/affinity/src/model/bounds.rs b/crates/affinity/src/model/bounds.rs new file mode 100644 index 000000000..0a48193e2 --- /dev/null +++ b/crates/affinity/src/model/bounds.rs @@ -0,0 +1,142 @@ +use super::read::MAX_TREE_DEPTH; +use super::*; + +/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames +/// and tables), in document pixels. +pub fn nodes_bounds(nodes: &[Node]) -> Option { + let mut out = None; + walk_bounds(nodes, true, 0, &mut out); + out +} + +/// The bounds of `node` and everything in it, hidden or not. +pub(super) fn tree_bounds(node: &Node) -> Option { + let mut out = None; + walk_bounds(std::slice::from_ref(node), false, 0, &mut out); + out +} + +fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option) { + if depth > MAX_TREE_DEPTH { + return; + } + for n in nodes.iter().filter(|n| n.visible || !visible_only) { + kind_points(&n.kind, &mut |p| add_point(out, p)); + walk_bounds(&n.children, visible_only, depth + 1, out); + } +} + +/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they +/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here: +/// artistic text, whose glyphs only the layout places. +pub(super) fn mask_extent(kind: &Kind, children: &[Node]) -> Option { + fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> bool { + if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) { + return false; + } + let mut own = kind_bounds(kind); + if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) { + // As far as a stroke can reach out of its path, miter joins included. + let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max); + own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach }); + } + if let Some(r) = own { + *out = Some(out.map_or(r, |o| union(o, r))); + } + children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out)) + } + let mut out = None; + if visit(kind, children, 0, &mut out) { out } else { None } +} + +/// The bounds of what one node's own kind draws (not its children): its outline's points, its +/// image's or frame's corners, or artistic text's anchor. +pub fn kind_bounds(kind: &Kind) -> Option { + let mut out = None; + kind_points(kind, &mut |p| add_point(&mut out, p)); + out +} + +fn add_point(out: &mut Option, p: Point) { + if p.x.is_finite() && p.y.is_finite() { + let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; + *out = Some(out.map_or(r, |o| union(o, r))); + } +} + +fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) { + let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y })); + match kind { + Kind::Shape { path, .. } | Kind::Artboard { path, .. } => { + for sp in &path.subpaths { + add(sp.start); + for seg in &sp.segments { + seg.iter().for_each(|p| add(*p)); + } + } + } + Kind::Image(img) => { + corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add) + } + Kind::Text(t) => match t.frame { + Some(f) => corners(f, t.transform).into_iter().for_each(add), + None => add(t.transform.apply(t.anchor)), + }, + Kind::Table(t) => { + if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) { + corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add); + } + } + Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} + } +} + +/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` +/// equal pages (Publisher 1 facing spreads). +pub(super) fn pages(s: &Stream, spread: ObjId) -> Vec { + if let Some(md) = s.obj(spread, b"SpMd") { + let v: Vec = + s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); + if !v.is_empty() { + return v; + } + } + match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { + (Some(b), Some(n @ 2..=16)) => { + let w = (b.x1 - b.x0) / n as f64; + (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() + } + _ => Vec::new(), + } +} + +pub(crate) fn union(a: Rect, b: Rect) -> Rect { + Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) } +} + +pub(super) fn is_container(class: Tag) -> bool { + class == Tag::of(b"Scop") || class == Tag::of(b"Grup") +} + +pub(crate) fn rect(v: [f64; 4]) -> Rect { + Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] } +} + +/// A readable name for a layer class in warnings. +pub(crate) fn describe(class: Tag) -> &'static str { + match &class.0.to_be_bytes() { + b"PCrv" => "curve", + b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape", + b"Comp" => "compound", + b"Rstr" => "pixel", + b"ImgN" => "image", + b"EmbN" => "embedded document", + b"MPIN" => "master page instance", + b"FRst" => "fill", + b"MRst" => "mask", + b"Slic" | b"SlcP" => "slice", + b"3DLA" => "3D", + [_, _, b'R', b'A'] => "adjustment", + _ => "unknown", + } +} diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs new file mode 100644 index 000000000..755e93745 --- /dev/null +++ b/crates/affinity/src/model/read.rs @@ -0,0 +1,605 @@ +use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union}; +use super::*; + +/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). +const MAX_PROFILE_BYTES: usize = 32 << 20; + +/// Deepest layer nesting followed; deeper content is reported, not imported. +pub(super) const MAX_TREE_DEPTH: usize = 128; + +/// Total layer nodes imported. +const MAX_NODES: usize = 500_000; + +/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits +/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). +const MAX_LAYER_PIXELS: u64 = 512 << 20; + +/// Embedded documents inside embedded documents are followed this deep. +const MAX_EMBED_DEPTH: usize = 4; + +/// Characters of all stories together (a story may be shared by many text layers). +const MAX_DOCUMENT_CHARS: usize = 16 << 20; + +/// Table cells of all tables together (each becomes a text object with its edge lines). +const MAX_DOCUMENT_CELLS: usize = 1 << 18; + +/// What a whole read may still use. Shared objects can be linked from many layers, and embedded +/// documents are read with what their embedding document left, so linking or embedding the +/// same content many times can't multiply the work. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Budget { + /// Layer nodes ([`MAX_NODES`] in all). + pub(crate) nodes: usize, + /// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all). + pub(crate) pixels: u64, + /// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all). + pub(crate) chars: usize, + /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). + pub(crate) cells: usize, +} + +impl Budget { + pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS }; +} + +/// Read a document: archive, `doc.dat` and the model. +pub fn read(bytes: &[u8], limits: Limits) -> Result { + read_within(bytes, limits, MAX_LAYER_PIXELS) +} + +/// [`read`], its pixel layers holding at most `pixels` pixels together. +pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { + read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document +} + +/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted +/// either way. +struct ReadAt { + document: Result, + extracted: usize, +} + +/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth` +/// deep). What it uses is taken from `budget`, also when it fails part of the way. +/// The most characters of a layer's name kept. +const MAX_NAME_CHARS: usize = 1024; + +fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt { + let mut archive = match Archive::open(bytes, limits) { + Ok(a) => a, + Err(e) => return ReadAt { document: Err(e), extracted: 0 }, + }; + let document = read_archive(&mut archive, budget, base, depth); + ReadAt { document, extracted: archive.extracted() } +} + +fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result { + let doc = archive.read("doc.dat")?; + let s = stream::parse(&doc)?; + let mut reader = Reader::new(&s, archive, base, depth); + reader.budget = *budget; + let document = reader.document(); + *budget = reader.budget; + document +} + +pub(crate) struct Reader<'s, 'a, 'b> { + pub(crate) s: &'s Stream, + pub(crate) archive: &'b mut Archive<'a>, + pub(crate) dpi: f64, + pub(crate) warnings: BTreeMap, + active: HashSet, + /// Where the spreads go: the identity, or an embedding document's placement. + base: Affine, + /// The linear part of `base`, which also scales frame text. + pub(crate) type_base: Affine, + /// How deep inside embedded documents this one is. + depth: usize, + /// An embedded document's content, waiting to become its node's children. + pending: Option>, + /// The master spreads, in the order of [`Document::masters`]. + master_ids: Vec, + /// What the rest of the read may use. + pub(crate) budget: Budget, +} + +impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { + pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self { + let [a, b, c, d, ..] = base.0; + Self { + s, + archive, + dpi: 72.0, + warnings: BTreeMap::new(), + active: HashSet::new(), + base, + type_base: Affine([a, b, c, d, 0.0, 0.0]), + depth, + pending: None, + master_ids: Vec::new(), + budget: Budget::FULL, + } + } + + pub(crate) fn warn(&mut self, what: impl Into) { + *self.warnings.entry(what.into()).or_default() += 1; + } + + /// The document. + fn document(&mut self) -> Result { + let s = self.s; + let root = s.root; + self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); + let saved_by = s.obj(root, b"NVer").map(|v| { + let product = s.str(v, b"Prod").unwrap_or("Affinity"); + match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) { + (Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"), + _ => product.to_string(), + } + }); + let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; + self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect(); + let mut spreads = Vec::new(); + for spread in s.objs(doc, b"Chld") { + if let Some(sp) = self.spread(doc, spread)? { + spreads.push(sp); + } + } + if spreads.is_empty() { + return Err(Error::Malformed("document has no pages")); + } + let mut masters = Vec::new(); + for master in self.master_ids.clone() { + match self.spread(doc, master)? { + Some(sp) => masters.push(sp), + // Keep the indices of `MasterInstance::master` valid. + None => masters.push(Spread { + bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }, + pages: Vec::new(), + transparent: true, + nodes: Vec::new(), + }), + } + } + let cmyk_profile = s + .obj(root, b"CMYP") + .and_then(|p| s.bytes(p, b"Data")) + .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice())) + .map(<[u8]>::to_vec); + let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); + Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }) + } + + /// A spread (`Sprd`) of the document `doc`, or of its master spreads. + fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> { + let s = self.s; + if !s.is(spread, b"Sprd") { + self.warn("an unknown kind of page"); + return Ok(None); + } + let pages = pages(s, spread); + let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); + let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { + x0: 0.0, + y0: 0.0, + x1: 0.0, + y1: 0.0, + }); + let transparent = s.bool(spread, b"SprT").unwrap_or(false); + let (nodes, mask) = self.children(spread, self.base, 0)?; + if mask.is_some() { + self.warn("mask layers directly on a page (imported without them)"); + } + Ok(Some(Spread { bounds, pages, transparent, nodes })) + } + + /// The children of `parent`, and the mask layer among them that masks the parent (with its + /// placement), read later over just what it masks. + #[allow(clippy::type_complexity)] + fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> { + let s = self.s; + let mut out = Vec::new(); + let mut mask = None; + for c in s.objs(parent, b"Chld") { + if s.is(c, b"MRst") { + // A mask layer inside a group or layer masks that container. + if s.bool(c, b"Visi") == Some(false) { + continue; + } + let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + if mask.is_none() { + mask = Some((c, local.then(world))); + } else { + self.warn("several pixel masks on one layer (only the first is used)"); + } + continue; + } + if let Some(n) = self.node(c, world, depth + 1)? { + out.push(n); + } + } + Ok((out, mask)) + } + + fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { + let s = self.s; + if depth > MAX_TREE_DEPTH { + self.warn("layers nested deeper than 128 levels"); + return Ok(None); + } + let Some(left) = self.budget.nodes.checked_sub(1) else { + return Err(Error::Limit("too many layers")); + }; + self.budget.nodes = left; + // A node that contains itself (a shared object linked back into its own subtree). + if !self.active.insert(id) { + self.warn("a layer that contains itself"); + return Ok(None); + } + let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + let world = local.then(parent); + let class = s.class(id).unwrap_or(Tag(0)); + // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); + let visible = s.bool(id, b"Visi").unwrap_or(true); + let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); + let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); + let blend = match s.enumeration(id, b"Blnd") { + None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough, + None => Blend::Normal, + Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { + self.warn("an unknown blend mode (imported as Normal)"); + Blend::Normal + }), + }; + let kind = if !world.is_finite() { + self.warn("a layer with an invalid transform"); + Kind::Unsupported + } else { + self.kind(id, class, world, parent)? + }; + // This node's embedded content, before its children's `node` calls can take it. + let embedded = self.pending.take(); + // A compound's children are its operands, already merged into its outline. + let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; + if let Some(embedded) = embedded { + children.extend(embedded); + } + // Pixel masks are read only over what they mask (they can span a whole spread at print + // resolution, a gigapixel). + let over = mask_extent(&kind, &children); + let child_mask = match child_mask { + Some((c, at)) => crate::raster::mask(self, c, at, over)?, + None => None, + }; + let (mask, attached_mask) = self.attached_masks(id, world, over)?; + let pixel_mask = attached_mask.or(child_mask); + self.active.remove(&id); + let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children }; + // Gradient overlays are laid out over the node's bounds, also while it is hidden. + let bounds = tree_bounds(&node); + node.effects = self.effects(id, world, bounds); + Ok(Some(node)) + } + + fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { + let s = self.s; + Ok(match &class.0.to_be_bytes() { + b"Scop" => Kind::Layer, + b"Grup" => Kind::Group, + b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => { + // Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp` + // property object in `phrp`, while older documents use `ABEn`. Ordinary shapes + // in that fixture have neither. See the pinned corpus and validation audit. + if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) { + return Ok(self.artboard(id, world)); + } + let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else { + self.warn(format!("a {} that could not be read", describe(class))); + return Ok(Kind::Unsupported); + }; + let (fills, strokes) = paint::node_paint(self, id, world); + Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } + } + b"TxtT" => match crate::text::table(self, id, world) { + Some(t) => Kind::Table(t), + None => { + self.warn("tables that could not be read"); + Kind::Unsupported + } + }, + b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { + Some(t) => Kind::Text(t), + None => { + self.warn("text whose story could not be read (master-page text, or an unknown layout)"); + Kind::Unsupported + } + }, + b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? { + Some(img) => Kind::Image(img), + None => Kind::Unsupported, + }, + b"MPIN" => { + // `SLnk` links the instance with the master spread it shows. + let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default(); + let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o)); + let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096); + match master { + Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) }, + None => { + self.warn("master page instances without their master"); + Kind::Unsupported + } + } + } + b"EmbN" => match self.embedded_document(id, world) { + // The embedded document itself, as editable content. + Some(nodes) => { + self.pending = Some(nodes); + Kind::Group + } + None => match crate::raster::embedded(self, id, world)? { + Some(img) => { + self.warn("embedded documents and symbols (imported as pictures of them)"); + Kind::Image(img) + } + None => { + self.warn("embedded documents and symbols without a cached picture"); + Kind::Unsupported + } + }, + }, + b"FRst" => { + // A fill layer paints its whole bitmap area; its gradient lives in page space. + let b = s.obj(id, b"Bitm"); + let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0); + match (size(b"BmpW"), size(b"BmpH")) { + (Some(w), Some(h)) => { + let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] }; + let fills = paint::fills(self, id, parent, b"BFFl"); + Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false } + } + _ => { + self.warn("fill layers without an area"); + Kind::Unsupported + } + } + } + _ => { + self.warn(format!("{} layers", describe(class))); + Kind::Unsupported + } + }) + } + + /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the + /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below. + fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec { + let s = self.s; + let mut out = Vec::new(); + for e in s.objs(id, b"FiEf").into_iter().take(64) { + if s.bool(e, b"Enab") == Some(false) { + continue; + } + // "Scale with object": lengths follow the node's transform; otherwise they are absolute. + let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 }; + let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k; + let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); + let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0); + let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c)); + let class = s.class(e).map(|c| c.0.to_be_bytes()); + match (class.as_ref(), color) { + (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), + (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), + (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }), + (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => { + let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a); + out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align }); + } + (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"), + (Some(b"BevE" | b"EmbE"), _) => { + // No radius, no bevel (as Affinity draws it). + let size = len(b"Radi"); + if size > 0.0 { + let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; + let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; + let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white); + let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black); + let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0); + let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0); + let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0); + let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0); + out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner }); + } + } + (Some(b"ColO"), Some(color)) => { + let blend = self.effect_blend(e); + out.push(Effect::ColorOverlay { color, opacity, blend }); + } + (Some(b"GrdO"), _) => { + let blend = self.effect_blend(e); + // The gradient's unit space spans the node's bounding box from -1 to 1, centred + // on it (y down): checked against the stored previews of real documents. + let space = + bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0])); + match (s.obj(e, b"GrFl"), space) { + (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) { + Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }), + Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }), + _ => {} + }, + // Nothing visible to lay it over. + (Some(_), None) => {} + (None, _) => self.warn("gradient overlay effects that could not be read (left out)"), + } + } + (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"), + (Some(b"Gaus"), _) => self.warn("blur effects (left out)"), + _ => self.warn("layer effects of other kinds (left out)"), + } + } + out + } + + /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned). + fn effect_blend(&mut self, e: ObjId) -> Blend { + match self.s.enumeration(e, b"BlnM") { + None => Blend::Normal, + Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { + self.warn("an unknown blend mode (imported as Normal)"); + Blend::Normal + }), + } + } + + /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; + /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. + fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> { + let s = self.s; + let attached = s.objs(id, b"AdCh"); + let mut mask: Option = None; + let mut pixels: Option = None; + for a in attached { + let class = s.class(a).unwrap_or(Tag(0)); + if s.bool(a, b"Visi") == Some(false) { + continue; + } + if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") { + let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); + if let Some((p, _)) = crate::geometry::outline(self, a, class, local) { + mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths); + } + } else if class == Tag::of(b"MRst") { + let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); + match crate::raster::mask(self, a, local, over)? { + Some(m) if pixels.is_none() => pixels = Some(m), + Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), + None => {} + } + } else { + self.warn(format!("{} layers attached to other layers (imported without them)", describe(class))); + } + } + Ok((mask, pixels)) + } + + /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds + /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes, + /// placed so the centre of its page, first artboard or content is the node's origin. + fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> { + let s = self.s; + let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string(); + if entry.is_empty() { + return None; + } + if self.depth >= MAX_EMBED_DEPTH { + self.warn("embedded documents nested more than four deep"); + return None; + } + let data = self.archive.read(&entry).ok()?; + let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?; + if !crate::is_affinity(inner) { + return None; + } + // A first reading finds its page and resolution, the second places it. + // Both readings are charged to the budget and the archive's limits, also when they fail: + // each does the work. + let mut budget = self.budget; + let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1); + self.budget = budget; + if self.archive.charge(probe.extracted).is_err() { + self.warn("embedded documents beyond the size limit (left out)"); + return None; + } + let probe = match probe.document { + Ok(d) => d, + Err(e) => { + self.warn(format!("embedded documents that could not be read ({e})")); + return None; + } + }; + let spread = probe.spreads.first()?; + // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page. + let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2); + let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None }); + let page = if content { None } else { spread.pages.first().copied().or(first_board) }; + let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds); + // Its pixels aren't needed any more: never hold both readings' at once. + drop(probe); + // Its document pixels are the node's units, whatever either document's resolution. + let k = 1.0; + let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0); + let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world); + if !base.is_finite() { + self.warn("embedded documents with an invalid placement"); + return None; + } + let mut budget = self.budget; + let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1); + self.budget = budget; + if self.archive.charge(placed.extracted).is_err() { + self.warn("embedded documents beyond the size limit (left out)"); + return None; + } + let doc = placed.document.ok()?; + for w in doc.warnings { + self.warn(format!("{w} [in embedded documents]")); + } + if doc.spreads.len() > 1 { + self.warn("embedded documents with several pages (the first is used)"); + } + let mut nodes = doc.spreads.into_iter().next()?.nodes; + // Its master page instances count its own masters, which aren't kept: in this document + // they would show other masters. + fn strip_masters(nodes: &mut [Node], depth: usize) -> bool { + let mut found = false; + for n in nodes { + if matches!(n.kind, Kind::MasterInstance { .. }) { + n.kind = Kind::Unsupported; + found = true; + } + if depth < MAX_TREE_DEPTH { + found |= strip_masters(&mut n.children, depth + 1); + } + } + found + } + if strip_masters(&mut nodes, 0) { + self.warn("master pages of embedded documents (left out)"); + } + Some(nodes) + } + + fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { + let s = self.s; + let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { + self.warn("an artboard without valid bounds (its children are kept without clipping)"); + return Kind::Unsupported; + }; + let corners = + [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p)); + let [a, b2, c, d, ..] = world.0; + if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 { + self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)"); + } + let xs = corners.map(|p| p.x); + let ys = corners.map(|p| p.y); + let rect = Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 { + self.warn("an artboard with an invalid transformed size (its children are kept without clipping)"); + return Kind::Unsupported; + } + let class = s.class(id).unwrap_or(Tag::of(b"ShpN")); + let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| { + self.warn("an artboard whose outline could not be read (clipped to its rectangle)"); + (Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false) + }); + let (background, strokes) = paint::node_paint(self, id, world); + Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } + } +} From a8410bab4e4098c61ff01d0d913cb8b6461adb12 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:01:26 +0000 Subject: [PATCH 119/199] refactor(effects): move lib.rs items to {catalog,geometry,params}.rs --- crates/effects/src/catalog.rs | 829 ++++++++++++++++++++++ crates/effects/src/geometry.rs | 219 ++++++ crates/effects/src/lib.rs | 1183 +------------------------------- crates/effects/src/params.rs | 148 ++++ 4 files changed, 1207 insertions(+), 1172 deletions(-) create mode 100644 crates/effects/src/catalog.rs create mode 100644 crates/effects/src/geometry.rs create mode 100644 crates/effects/src/params.rs diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs new file mode 100644 index 000000000..14fd90c13 --- /dev/null +++ b/crates/effects/src/catalog.rs @@ -0,0 +1,829 @@ +use super::*; + +/// Catalogue entry for one effect. +#[derive(Clone, Debug, PartialEq)] +pub struct EffectInfo { + /// Stable id, e.g. `distort.roughen`. + pub id: &'static str, + /// Menu label (with the ellipsis when the effect has a dialog). + pub label: &'static str, + /// Effect-menu path, e.g. `["Effect", "Distort & Transform"]`. + pub menu: &'static [&'static str], + /// Human/agent-readable parameter documentation. + pub params: &'static str, + /// Dialog defaults. + pub defaults: Value, + /// Raster (painted) rather than geometry effect. + pub raster: bool, + /// The parameters that are distances in points (Scale Strokes & Effects scales them). + pub lengths: Lengths, +} + +/// Which of an effect's parameters are distances in points. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Lengths { + /// Always distances. + pub always: &'static [&'static str], + /// Distances while the effect's `relative` parameter is false (percentages of the object's + /// size otherwise). + pub absolute: &'static [&'static str], +} + +impl Lengths { + /// The distance parameters while the effect's `relative` parameter is `relative`. + pub fn keys(&self, relative: bool) -> impl Iterator + '_ { + self.always.iter().chain(if relative { &[][..] } else { self.absolute }).copied() + } +} + +/// Is parameter `key` of effect `id` a distance in points while its `relative` parameter is +/// `relative`? +pub fn is_length(id: &str, key: &str, relative: bool) -> bool { + catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key)) +} + +/// The distance parameters of effect `id` (none for unknown effects). +fn lengths_of(id: &str) -> Lengths { + let always = |always| Lengths { always, absolute: &[] }; + match id { + "convertToShape.rectangle" | "convertToShape.ellipse" => always(&["extraW", "extraH", "width", "height"]), + "convertToShape.roundedRectangle" => always(&["extraW", "extraH", "width", "height", "radius"]), + "distort.roughen" | "distort.zigZag" => Lengths { always: &[], absolute: &["size"] }, + "distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] }, + "distort.transform" => always(&["moveH", "moveV"]), + "path.offsetPath" => always(&["offset"]), + "threeD.revolve" => always(&["offset"]), + "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), + "threeD.inflate" => always(&["depth"]), + "path.outlineStroke" => always(&["width"]), + "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), + "stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]), + "stylize.dropShadow" => always(&["x", "y", "blur"]), + "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), + "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), + "brushStrokes.accentedEdges" => always(&["edgeWidth"]), + "artistic.paletteKnife" => always(&["strokeSize"]), + "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), + "brushStrokes.spatter" => always(&["sprayRadius"]), + "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), + "brushStrokes.sumiE" => always(&["strokeWidth"]), + "pixelate.colorHalftone" => always(&["maxRadius"]), + "pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]), + "texture.craquelure" => always(&["crackSpacing"]), + "texture.mosaicTiles" => always(&["tileSize"]), + "texture.patchwork" => always(&["squareSize"]), + "texture.stainedGlass" => always(&["cellSize"]), + "sketch.graphicPen" => always(&["strokeLength"]), + "sketch.halftonePattern" => always(&["size"]), + "sketch.notePaper" => always(&["relief"]), + "sketch.tornEdges" => always(&["smoothness"]), + "sketch.waterPaper" => always(&["fiberLength"]), + TRAP => always(&["thickness", "precision"]), + RASTERIZE => always(&["addAround"]), + _ => Lengths::default(), + } +} + +const WARP_DOC: &str = + "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; + +const DT: &[&str] = &["Effect", "Distort & Transform"]; + +const PATH: &[&str] = &["Effect", "Path"]; + +const SHAPE: &[&str] = &["Effect", "Convert to Shape"]; + +const STYLIZE: &[&str] = &["Effect", "Stylize"]; + +const WARP: &[&str] = &["Effect", "Warp"]; + +const BLUR: &[&str] = &["Effect", "Blur"]; + +const ARTISTIC: &[&str] = &["Effect", "Artistic"]; + +const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"]; + +const DISTORT: &[&str] = &["Effect", "Distort"]; + +const SHARPEN: &[&str] = &["Effect", "Sharpen"]; + +const SKETCH: &[&str] = &["Effect", "Sketch"]; + +const PIXELATE: &[&str] = &["Effect", "Pixelate"]; + +const TEXTURE: &[&str] = &["Effect", "Texture"]; + +const VIDEO: &[&str] = &["Effect", "Video"]; + +const PATHFINDER: &[&str] = &["Effect", "Pathfinder"]; + +const PLUGINS: &[&str] = &["Effect", "Plug-ins"]; + +const ADJUST: &[&str] = &["Effect", "Color Adjustments"]; + +/// The warp styles in Illustrator's Style menu order: (id suffix, label). +pub const WARP_STYLES: [(&str, &str); 15] = [ + ("arc", "Arc…"), + ("arcLower", "Arc Lower…"), + ("arcUpper", "Arc Upper…"), + ("arch", "Arch…"), + ("bulge", "Bulge…"), + ("shellLower", "Shell Lower…"), + ("shellUpper", "Shell Upper…"), + ("flag", "Flag…"), + ("wave", "Wave…"), + ("fish", "Fish…"), + ("rise", "Rise…"), + ("fisheye", "Fisheye…"), + ("inflate", "Inflate…"), + ("squeeze", "Squeeze…"), + ("twist", "Twist…"), +]; + +/// Every supported effect with its dialog defaults. +pub fn effect_catalog() -> Vec { + let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) }; + let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) }; + let mut v = vec![ + g( + REVOLVE, + "Revolve…", + &["Effect", "3D and Materials"], + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}), + ), + g( + EXTRUDE, + "Extrude & Bevel…", + &["Effect", "3D and Materials"], + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + ), + g( + INFLATE, + "Inflate…", + &["Effect", "3D and Materials"], + "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).", + json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + ), + g( + ROTATE, + "Rotate…", + &["Effect", "3D and Materials"], + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), + ), + g( + "convertToShape.rectangle", + "Rectangle…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width: pt (absolute, 100), height: pt (absolute, 100)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), + ), + g( + "convertToShape.roundedRectangle", + "Rounded Rectangle…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute), radius: pt (9)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0, "radius": 9.0}), + ), + g( + "convertToShape.ellipse", + "Ellipse…", + SHAPE, + "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute)}", + json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), + ), + g( + "distort.freeDistort", + "Free Distort…", + DT, + "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}", + json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}), + ), + g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})), + g( + "distort.roughen", + "Roughen…", + DT, + "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", + json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), + ), + g( + "distort.transform", + "Transform…", + DT, + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), + ), + g( + "distort.tweak", + "Tweak…", + DT, + "{h: amount (10), v: amount (10), relative: bool (true: % of size), anchors: bool (true), in: bool (true), out: bool (true), seed: int (0)}", + json!({"h": 10.0, "v": 10.0, "relative": true, "anchors": true, "in": true, "out": true, "seed": 0}), + ), + g("distort.twist", "Twist…", DT, "{angle: deg (-3600..3600, 10)}", json!({"angle": 10.0})), + g( + "distort.zigZag", + "Zig Zag…", + DT, + "{size: (10), relative: bool (false: pt; true: % of size), ridges: per segment 0..100 (4), points: \"smooth\"|\"corner\"}", + json!({"size": 10.0, "relative": false, "ridges": 4, "points": "smooth"}), + ), + g( + "path.offsetPath", + "Offset Path…", + PATH, + "{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}", + json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}), + ), + g( + "path.outlineObject", + "Outline Object", + PATH, + "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change", + json!({}), + ), + g( + "path.outlineStroke", + "Outline Stroke", + PATH, + "{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads", + json!({}), + ), + g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})), + g( + "stylize.scribble", + "Scribble…", + STYLIZE, + "{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)", + json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}), + ), + r( + "stylize.dropShadow", + "Drop Shadow…", + STYLIZE, + "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)", + json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}), + ), + r( + "stylize.innerGlow", + "Inner Glow…", + STYLIZE, + "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"center\"|\"edge\" (\"edge\")}", + json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}), + ), + r( + "stylize.outerGlow", + "Outer Glow…", + STYLIZE, + "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffff00\")}", + json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}), + ), + r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), + r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), + r( + "artistic.coloredPencil", + "Colored Pencil…", + ARTISTIC, + "{pencilWidth: 1..24 (5; stroke spacing in document units), strokePressure: 0..15 (5; how heavily the strokes darken), paperBrightness: 0..50 (25; lifts lighter areas toward the paper)} redraws the object in coloured pencil strokes along its edges, keeping its hues and coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"pencilWidth": 5.0, "strokePressure": 5.0, "paperBrightness": 25.0}), + ), + r( + "artistic.cutout", + "Cutout…", + ARTISTIC, + "{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}), + ), + r( + "artistic.dryBrush", + "Dry Brush…", + ARTISTIC, + "{brushSize: 1..10 (2; length of the brush strokes), brushDetail: 0..10 (10; more detail keeps finer structure), texture: 1..3 (1; stronger edges and bristle grain at 3)} repaints the object with short strokes along its form and darkens its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), + ), + r( + "artistic.filmGrain", + "Film Grain…", + ARTISTIC, + "{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", + json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}), + ), + r( + "artistic.fresco", + "Fresco…", + ARTISTIC, + "{brushSize: 1..10 (2; wider brush dabs), brushDetail: 0..10 (10; sharper edges and less smoothing), texture: 1..3 (1; deeper plaster relief)} repaints the object as coarse pigment on a textured wall, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), + ), + r( + "artistic.neonGlow", + "Neon Glow…", + ARTISTIC, + "{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}), + ), + r( + "artistic.paintDaubs", + "Paint Daubs…", + ARTISTIC, + "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), + ), + r( + "artistic.paletteKnife", + "Palette Knife…", + ARTISTIC, + "{strokeSize: 1..50 (10; length of the knife strokes in document units), strokeDetail: 1..3 (2; more detail keeps more of the original), softness: 0..10 (3; softens the strokes)} smears the object's colours with short angled knife strokes, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"strokeSize": 10.0, "strokeDetail": 2.0, "softness": 3.0}), + ), + r( + "artistic.plasticWrap", + "Plastic Wrap…", + ARTISTIC, + "{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", + json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}), + ), + r( + "artistic.posterEdges", + "Poster Edges…", + ARTISTIC, + "{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}), + ), + r( + "artistic.roughPastels", + "Rough Pastels…", + ARTISTIC, + "{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.", + json!({ + "strokeLength": 6.0, + "strokeDetail": 10.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), + r( + "artistic.smudgeStick", + "Smudge Stick…", + ARTISTIC, + "{strokeLength: 0..10 (2; length of the short diagonal strokes), highlightArea: 0..20 (12; widens the area counted as light), intensity: 0..10 (10; how far the light areas are lightened)} smudges the dark areas along short diagonal strokes; the lighter areas become brighter and lose detail. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"strokeLength": 2.0, "highlightArea": 12.0, "intensity": 10.0}), + ), + r( + "artistic.sponge", + "Sponge…", + ARTISTIC, + "{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}), + ), + r( + "artistic.underpainting", + "Underpainting…", + ARTISTIC, + "{brushSize: 0..40 (6; wider, softer strokes), textureCoverage: 0..40 (16; more of the relief texture on the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object with broad strokes over a textured ground, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults. Original visual approximation.", + json!({ + "brushSize": 6.0, + "textureCoverage": 16.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), + r( + "artistic.watercolor", + "Watercolor…", + ARTISTIC, + "{brushDetail: 1..14 (9; the larger, the more detail stays), shadowIntensity: 0..10 (1; deepens the shadows), texture: 1..3 (1; paper grain, whole numbers)} paints the object in a watercolor style: detail simplified, colour saturated and darker where tones change at edges. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushDetail": 9.0, "shadowIntensity": 1.0, "texture": 1.0}), + ), + r( + "blur.radial", + "Radial Blur…", + BLUR, + "{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre", + json!({"amount": 10.0, "method": "spin", "quality": "good"}), + ), + r( + "blur.smart", + "Smart Blur…", + BLUR, + "{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of the pixels the search reads within the radius (all of them for a small radius, a grid of samples for a wide one) or one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's, so a step gives a line about as wide as the radius, and at least one pixel wide, on the side with the larger sum; the white and the black keep each pixel's coverage", + json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}), + ), + r( + "brushStrokes.accentedEdges", + "Accented Edges…", + BRUSH_STROKES, + "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges", + json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}), + ), + r( + "brushStrokes.angledStrokes", + "Angled Strokes…", + BRUSH_STROKES, + "{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'", + json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}), + ), + r( + "brushStrokes.crosshatch", + "Crosshatch…", + BRUSH_STROKES, + "{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges", + json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}), + ), + r( + "brushStrokes.darkStrokes", + "Dark Strokes…", + BRUSH_STROKES, + "{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes", + json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}), + ), + r( + "brushStrokes.inkOutlines", + "Ink Outlines…", + BRUSH_STROKES, + "{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows", + json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}), + ), + r( + "brushStrokes.spatter", + "Spatter…", + BRUSH_STROKES, + "{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush", + json!({"sprayRadius": 10.0, "smoothness": 5.0}), + ), + r( + "brushStrokes.sprayedStrokes", + "Sprayed Strokes…", + BRUSH_STROKES, + "{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours", + json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}), + ), + r( + "brushStrokes.sumiE", + "Sumi-e…", + BRUSH_STROKES, + "{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape", + json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}), + ), + r( + "distort.diffuseGlow", + "Diffuse Glow…", + DISTORT, + "{graininess: 0..10 (6; white grain, thicker in the glow), glowAmount: 0..20 (10), clearAmount: 0..20 (15; the higher, the more of the image stays clear of glow)} renders the object as if seen through a soft diffusion filter: its highlights glow white under see-through white grain", + json!({"graininess": 6.0, "glowAmount": 10.0, "clearAmount": 15.0}), + ), + r( + "distort.glass", + "Glass…", + DISTORT, + "{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", + json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}), + ), + r( + "distort.oceanRipple", + "Ocean Ripple…", + DISTORT, + "{rippleSize: 1..15 (9), rippleMagnitude: 0..20 (9)} adds randomly spaced ripples, as if the object were under water", + json!({"rippleSize": 9.0, "rippleMagnitude": 9.0}), + ), + r( + "sharpen.unsharpMask", + "Unsharp Mask…", + SHARPEN, + "{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}", + json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}), + ), + r( + "sketch.basRelief", + "Bas Relief…", + SKETCH, + "{detail: 1..15 (13; the larger, the finer the features that stay), smoothness: 1..15 (3; rounds the carving), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"bottom\"; where the light comes from)} carves the object in low relief in black and white: surfaces facing the light turn white, those turned away black, flat areas grey. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}), + ), + r( + "sketch.chalkCharcoal", + "Chalk & Charcoal…", + SKETCH, + "{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.", + json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}), + ), + r( + "sketch.charcoal", + "Charcoal…", + SKETCH, + "{thickness: 1..7 (1; width of the bold edges and of the strokes' spacing), detail: 0..5 (1; the larger, the finer the edges), lightDarkBalance: 0..100 (50; how far into the lights the charcoal reaches)} redraws the object in black charcoal on white paper: bold edges and diagonal strokes over the midtones. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"thickness": 1.0, "detail": 1.0, "lightDarkBalance": 50.0}), + ), + r( + "sketch.chrome", + "Chrome…", + SKETCH, + "{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"detail": 4.0, "smoothness": 4.0}), + ), + r( + "sketch.conteCrayon", + "Conté Crayon…", + SKETCH, + "{foregroundLevel: 0..20 (12; how far the black crayon reaches from the shadows into the midtones), backgroundLevel: 0..20 (10; how far the white crayon reaches from the highlights into the midtones), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; the paper's surface, made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} redraws the object in a dense black crayon in its dark areas and a pure white crayon in its light areas, on mid-grey paper whose texture breaks the strokes and stands in relief, keeping its coverage. Public help names Foreground Level, Background Level and the texture options but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", + json!({ + "foregroundLevel": 12.0, + "backgroundLevel": 10.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), + r( + "sketch.graphicPen", + "Graphic Pen…", + SKETCH, + "{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.", + json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}), + ), + r( + "sketch.halftonePattern", + "Halftone Pattern…", + SKETCH, + "{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).", + json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}), + ), + r( + "sketch.notePaper", + "Note Paper…", + SKETCH, + "{imageBalance: 0..50 (25; lower tones darken the emboss, higher lighten it), graininess: 0..10 (0; fibrous paper texture), relief: 0..50 (4; emboss strength in points)} embosses the object's tones on warm paper, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).", + json!({"imageBalance": 25.0, "graininess": 0.0, "relief": 4.0}), + ), + r( + "sketch.photocopy", + "Photocopy…", + SKETCH, + "{detail: 1..24 (7; the larger, the finer the features that copy), darkness: 1..50 (25; the larger, the more of the dark copies)} copies the object in black on white: large dark areas copy only around their edges and midtones go to black or white. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"detail": 7.0, "darkness": 25.0}), + ), + r( + "sketch.plaster", + "Plaster…", + SKETCH, + "{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}), + ), + r( + "sketch.reticulation", + "Reticulation…", + SKETCH, + "{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}), + ), + r( + "sketch.stamp", + "Stamp…", + SKETCH, + "{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", + json!({"lightDarkBalance": 25.0, "smoothness": 5.0}), + ), + r( + "sketch.tornEdges", + "Torn Edges…", + SKETCH, + "{imageBalance: 0..50 (25; lower leaves more midtones as ink, higher as paper), smoothness: 0..15 (11; softens tones and the ragged edges), contrast: 0..20 (17; 20 is pure black and white)} redraws the object in high-contrast black and white with torn, ragged edges along its tones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"imageBalance": 25.0, "smoothness": 11.0, "contrast": 17.0}), + ), + r( + "sketch.waterPaper", + "Water Paper…", + SKETCH, + "{fiberLength: pt 3..50 (15; scale of paper fibers and blotchy daubs), brightness: 0..100 (50), contrast: 0..100 (50)} washes the object's colours as if painted on damp fibrous paper, with soft flow and a fibrous modulation. Public helpx names Fiber Length, Brightness and Contrast but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", + json!({"fiberLength": 15.0, "brightness": 50.0, "contrast": 50.0}), + ), + r( + "stylize.glowingEdges", + "Glowing Edges…", + STYLIZE, + "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..20 (6), smoothness: pt 1..15 (5)} finds alpha-weighted Sobel edges and draws bright coloured outlines on black", + json!({"edgeWidth": 2.0, "edgeBrightness": 6.0, "smoothness": 5.0}), + ), + r( + "pixelate.colorHalftone", + "Color Halftone…", + PIXELATE, + "{maxRadius: pt 4..127 (8; the radius of a dot at full strength, which fills its square cell), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell", + json!({"maxRadius": 8.0, "channel1": 108.0, "channel2": 162.0, "channel3": 90.0, "channel4": 45.0}), + ), + r( + "pixelate.crystallize", + "Crystallize…", + PIXELATE, + "{cellSize: pt 3..300 (10)} redraws the object as polygon crystals of solid colour around random points about cellSize apart", + json!({"cellSize": 10.0}), + ), + r( + "pixelate.mezzotint", + "Mezzotint…", + PIXELATE, + "{type: \"fineDots\"|\"mediumDots\"|\"grainyDots\"|\"coarseDots\"|\"shortLines\"|\"mediumLines\"|\"longLines\"|\"shortStrokes\"|\"mediumStrokes\"|\"longStrokes\" (\"fineDots\")} turns each colour channel fully on or off against a random pattern of dots, lines or strokes: fully saturated colours", + json!({"type": "fineDots"}), + ), + r( + "pixelate.pointillize", + "Pointillize…", + PIXELATE, + "{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas", + json!({"cellSize": 5.0}), + ), + r( + "texture.craquelure", + "Craquelure…", + TEXTURE, + "{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones", + json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}), + ), + r( + "texture.grain", + "Grain…", + TEXTURE, + "{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white", + json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}), + ), + r( + "texture.mosaicTiles", + "Mosaic Tiles…", + TEXTURE, + "{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them", + json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}), + ), + r( + "texture.patchwork", + "Patchwork…", + TEXTURE, + "{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left", + json!({"squareSize": 4.0, "relief": 8.0}), + ), + r( + "texture.stainedGlass", + "Stained Glass…", + TEXTURE, + "{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart", + json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}), + ), + r( + "texture.texturizer", + "Texturizer…", + TEXTURE, + "{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", + json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}), + ), + r( + "video.deinterlace", + "De-Interlace…", + VIDEO, + "{eliminate: \"odd\"|\"even\" (\"odd\"; which field lines are taken out), create: \"duplication\"|\"interpolation\" (\"duplication\"; a line taken out becomes a copy of the line above, or the average of the lines above and below)} the field lines are the rows of the document's raster grid (Document Raster Effects Settings › Resolution), numbered from 1 at the top of the page", + json!({"eliminate": "odd", "create": "duplication"}), + ), + r( + "video.ntscColors", + "NTSC Colors", + VIDEO, + "{} makes colours a television signal can't carry (luma plus chroma past 110 % of white, or luma minus chroma below −20 %) less saturated, keeping their brightness", + json!({}), + ), + ]; + v.extend([ + g( + "adjust.brightnessContrast", + "Brightness/Contrast…", + ADJUST, + "{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)", + json!({"brightness": 0.0, "contrast": 0.0}), + ), + g( + "adjust.curves", + "Curves…", + ADJUST, + "{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", + json!({"points": "0,0 128,128 255,255", "channel": "rgb"}), + ), + g( + "adjust.hueSaturation", + "Hue/Saturation…", + ADJUST, + "{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does", + json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}), + ), + g( + "adjust.levels", + "Levels…", + ADJUST, + "{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", + json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}), + ), + g( + "adjust.shiftToColor", + "Shift to Color…", + ADJUST, + "{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does", + json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}), + ), + g( + "adjust.temperatureTint", + "Temperature/Tint…", + ADJUST, + "{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does", + json!({"temperature": 0.0, "tint": 0.0}), + ), + ]); + for (suffix, label) in WARP_STYLES { + let id: &'static str = match suffix { + "arc" => "warp.arc", + "arcLower" => "warp.arcLower", + "arcUpper" => "warp.arcUpper", + "arch" => "warp.arch", + "bulge" => "warp.bulge", + "shellLower" => "warp.shellLower", + "shellUpper" => "warp.shellUpper", + "flag" => "warp.flag", + "wave" => "warp.wave", + "fish" => "warp.fish", + "rise" => "warp.rise", + "fisheye" => "warp.fisheye", + "inflate" => "warp.inflate", + "squeeze" => "warp.squeeze", + _ => "warp.twist", + }; + v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + } + for (id, label, _) in PATHFINDER_EFFECTS { + if id == "pathfinder.softMix" { + v.push(g( + id, + label, + PATHFINDER, + "{mixingRate?: 1..100 % (50)} blend overlapping fills in CMYK bottom to top (groups and layers: live Pathfinder over the members)", + json!({"mixingRate": 50.0}), + )); + } else { + v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); + } + } + v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); + v.push(g( + CROP_MARKS, + "Crop Marks", + &["Effect"], + "{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it", + json!({}), + )); + v.push(r( + RASTERIZE, + "Rasterize…", + &["Effect"], + "{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes", + json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}), + )); + v +} + +/// The catalogue built once, indexed by id (lookups run per effect per rendered frame). +pub(crate) fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInfo> { + static INDEX: std::sync::OnceLock> = std::sync::OnceLock::new(); + INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect()) +} + +/// Catalogue entry for `id` (built-in, or an installed plug-in effect). +pub fn effect_info(id: &str) -> Option { + catalog_index().get(id).cloned().or_else(|| plugin_info(id)) +} + +/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.`). +pub(crate) fn plugin_info(id: &str) -> Option { + use vectorcraft_plugins::registry::intern; + let p = vectorcraft_plugins::effect::installed(id)?; + let m = p.manifest(); + let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" }; + Some(EffectInfo { + id: intern(id), + label: intern(&format!("{}{dots}", m.name)), + menu: PLUGINS, + params: intern(&m.params_doc()), + defaults: Value::Object(m.defaults()), + raster: false, + lengths: Lengths::default(), + }) +} + +/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id. +pub fn plugin_effects() -> Vec { + vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect() +} diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs new file mode 100644 index 000000000..7d25d1955 --- /dev/null +++ b/crates/effects/src/geometry.rs @@ -0,0 +1,219 @@ +use super::params::{is_geometry, merged_params}; +use super::*; + +/// Context the geometry effects may need from the object's appearance. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GeomContext<'a> { + /// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment, + /// profile and arrowheads): the stroke the effect sits on, else the object's top painted + /// stroke. `None` outlines a plain 1 pt stroke. + pub stroke: Option<&'a StrokeLayer>, + /// The object's fill rule (inside and outside alignment). + pub rule: FillRule, + /// The seed of the Transform effect's Random: the object's id, so each object varies its own + /// way and keeps its result on every redraw. + pub seed: u64, + /// The object's appearance, which live-effect plug-ins are given read only (their fills and + /// strokes). + pub appearance: Option<&'a Appearance>, + /// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`: + /// the object's effects. + pub item: Option, +} + +impl<'a> GeomContext<'a> { + /// The context of `n`'s object-level effects. + pub fn of(n: &'a Node) -> Self { + let rule = match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + }; + let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0); + Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None } + } + + /// The context of the effects on `item`, one of the same object's appearance items: a + /// stroke's own effects outline that stroke, and a plug-in is told which item it is on. + pub fn item(self, item: &'a AppearanceItem) -> Self { + let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item))); + match item { + AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self }, + AppearanceItem::Fill(_) => Self { item: at, ..self }, + } + } +} + +/// Most anchors a chain of geometry effects makes of one path, or of all the paths of a type, +/// symbol or image art together; a path that already has more keeps its own count as the limit. +/// Roughen resamples each segment into up to 2000 points, Zig Zag into up to 101 and the warps +/// into up to 64, so stacked effects multiply: three Roughens of a rectangle needed tens of +/// gigabytes (#3578). Each effect builds within it, and one whose result would still pass it is +/// left out. +pub(crate) const MAX_EFFECT_ANCHORS: usize = 2_000_000; + +/// Most lines the renderer is estimated to draw a chain of geometry effects' result with at +/// 1 px/pt ([`util::line_estimate`]), or the path's own estimate when that is more. A large +/// Roughen or Zig Zag makes segments thousands of points long, each drawn as up to hundreds of +/// lines: two million anchors of them came to 141 million lines, 7 GB in the renderer and past +/// the 2²⁷ lines vello takes of one path (#3578). Art of small curves draws its anchors as a few +/// lines each, so this rarely binds before [`MAX_EFFECT_ANCHORS`] does. +pub(crate) const MAX_EFFECT_LINES: usize = 8_000_000; + +/// What geometry effects may make of a path, or what a path costs: its anchors and the lines the +/// renderer is estimated to draw it with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Budget { + pub anchors: usize, + pub lines: usize, +} + +impl Budget { + /// The budget of a chain of geometry effects. + pub(crate) const EFFECTS: Self = Self { anchors: MAX_EFFECT_ANCHORS, lines: MAX_EFFECT_LINES }; + + /// What `path` costs. + pub(crate) fn of(path: &PathData) -> Self { + Self { anchors: path.anchor_count(), lines: util::line_estimate(path) } + } + + /// This budget, raised to `cost` where that is more. + pub(crate) fn at_least(self, cost: Self) -> Self { + Self { anchors: self.anchors.max(cost.anchors), lines: self.lines.max(cost.lines) } + } + + /// Does `cost` fit this budget? + pub(crate) fn fits(self, cost: Self) -> bool { + cost.anchors <= self.anchors && cost.lines <= self.lines + } + + /// What is left of this budget once `cost` is spent. + pub(crate) fn minus(self, cost: Self) -> Self { + Self { anchors: self.anchors.saturating_sub(cost.anchors), lines: self.lines.saturating_sub(cost.lines) } + } + + /// This cost and `cost` together. + pub(crate) fn plus(self, cost: Self) -> Self { + Self { anchors: self.anchors.saturating_add(cost.anchors), lines: self.lines.saturating_add(cost.lines) } + } +} + +/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the +/// reference box of the first effect (usually the object's geometric bounds); each later effect +/// uses the bounds of its input. Raster and unknown effects are skipped, and so is an effect +/// whose result would pass 2 million anchors or 8 million lines drawn (or the path's own counts, +/// when they are more). +pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData { + apply_geometry_with(effects, path, bounds, &GeomContext::default()) +} + +/// [`apply_geometry`] with an explicit [`GeomContext`]. +pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData { + apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS) +} + +/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that +/// is more. +pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData { + // `budget` raised to what `path` costs, measured once, and only for a path that has geometry + // effects. + let mut limit = None; + let mut cur = path.clone(); + let mut b = bounds; + let mut first = true; + for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) { + if !first { + b = cur.bounds().unwrap_or(b); + } + first = false; + if cur.is_empty() { + break; + } + let p = merged_params(&e.id, &e.params); + let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); + if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { + cur = next; + } + } + cur +} + +/// Convenience wrapper on kurbo paths. +pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> BezPath { + apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() +} + +/// Geometry effect `id` with params `p` on `path`, whose reference box is `b`; `None` when its +/// result would not fit `budget` (the effects that multiply anchors build within its anchors). +pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, budget: Budget) -> Option { + let out = apply_unchecked(id, p, path, b, ctx, budget.anchors); + let cost = Budget::of(&out); + if budget.fits(cost) { + return Some(out); + } + if cost.lines <= budget.lines || !builds_within(id) { + return None; + } + // Anchors drawn as many lines each (a large Roughen or Zig Zag): once more with as many fewer + // anchors as there were lines too many, and a tenth to spare, as segments vary in their lines. + let fewer = (cost.anchors as f64 * 0.9 * (budget.lines as f64 / cost.lines as f64)) as usize; + let out = apply_unchecked(id, p, path, b, ctx, fewer.min(budget.anchors)); + budget.fits(Budget::of(&out)).then_some(out) +} + +/// Does geometry effect `id` build within the anchors it is given? +fn builds_within(id: &str) -> bool { + matches!(id, "distort.freeDistort" | "distort.roughen" | "distort.transform" | "distort.twist" | "distort.zigZag") || id.starts_with("warp.") +} + +/// [`apply_one`] whose result may pass `max` when the effect doesn't build within it. +fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { + use util::*; + if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { + let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; + return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone()); + } + match id { + "distort.freeDistort" => distort::free_distort(path, b, p, max), + "distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)), + "distort.roughen" => distort::roughen(path, b, p, max), + "distort.transform" => distort::transform_within(path, b, p, ctx.seed, max), + "distort.tweak" => distort::tweak(path, b, p), + "distort.twist" => distort::twist(path, b, num(p, "angle", 10.0), max), + "distort.zigZag" => distort::zig_zag(path, b, p, max), + "path.offsetPath" => { + let off = num(p, "offset", 10.0).clamp(-1e5, 1e5); + if off.abs() < 1e-9 { + return path.clone(); + } + vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0)) + } + // Outline Object returns the geometry unchanged. Type reaches every geometry effect as its + // glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines. + "path.outlineObject" => path.clone(), + "path.outlineStroke" => { + let fallback; + let base = match ctx.stroke { + Some(s) => s, + None => { + fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0); + &fallback + } + }; + let st = match p.get("width").and_then(Value::as_f64) { + Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }), + None => std::borrow::Cow::Borrowed(base), + }; + if st.width.is_nan() || st.width <= 0.0 { + return path.clone(); + } + stroke::outline_region(path, ctx.rule, &st) + } + "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p), + "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), + "stylize.scribble" => stylize::scribble(path, b, p), + _ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) { + Some(style) => warp::warp(path, b, style, p, max), + None => path.clone(), + }, + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ac7193498..42051b8f7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -89,1176 +89,15 @@ pub use solids::{ pub use trap::TRAP; pub use warp::{WarpStyle, warp_point}; -/// Catalogue entry for one effect. -#[derive(Clone, Debug, PartialEq)] -pub struct EffectInfo { - /// Stable id, e.g. `distort.roughen`. - pub id: &'static str, - /// Menu label (with the ellipsis when the effect has a dialog). - pub label: &'static str, - /// Effect-menu path, e.g. `["Effect", "Distort & Transform"]`. - pub menu: &'static [&'static str], - /// Human/agent-readable parameter documentation. - pub params: &'static str, - /// Dialog defaults. - pub defaults: Value, - /// Raster (painted) rather than geometry effect. - pub raster: bool, - /// The parameters that are distances in points (Scale Strokes & Effects scales them). - pub lengths: Lengths, -} +mod catalog; +mod geometry; +mod params; -/// Which of an effect's parameters are distances in points. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct Lengths { - /// Always distances. - pub always: &'static [&'static str], - /// Distances while the effect's `relative` parameter is false (percentages of the object's - /// size otherwise). - pub absolute: &'static [&'static str], -} - -impl Lengths { - /// The distance parameters while the effect's `relative` parameter is `relative`. - pub fn keys(&self, relative: bool) -> impl Iterator + '_ { - self.always.iter().chain(if relative { &[][..] } else { self.absolute }).copied() - } -} - -/// Is parameter `key` of effect `id` a distance in points while its `relative` parameter is -/// `relative`? -pub fn is_length(id: &str, key: &str, relative: bool) -> bool { - catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key)) -} - -/// The distance parameters of effect `id` (none for unknown effects). -fn lengths_of(id: &str) -> Lengths { - let always = |always| Lengths { always, absolute: &[] }; - match id { - "convertToShape.rectangle" | "convertToShape.ellipse" => always(&["extraW", "extraH", "width", "height"]), - "convertToShape.roundedRectangle" => always(&["extraW", "extraH", "width", "height", "radius"]), - "distort.roughen" | "distort.zigZag" => Lengths { always: &[], absolute: &["size"] }, - "distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] }, - "distort.transform" => always(&["moveH", "moveV"]), - "path.offsetPath" => always(&["offset"]), - "threeD.revolve" => always(&["offset"]), - "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), - "threeD.inflate" => always(&["depth"]), - "path.outlineStroke" => always(&["width"]), - "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), - "stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]), - "stylize.dropShadow" => always(&["x", "y", "blur"]), - "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), - "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), - "brushStrokes.accentedEdges" => always(&["edgeWidth"]), - "artistic.paletteKnife" => always(&["strokeSize"]), - "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), - "brushStrokes.spatter" => always(&["sprayRadius"]), - "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), - "brushStrokes.sumiE" => always(&["strokeWidth"]), - "pixelate.colorHalftone" => always(&["maxRadius"]), - "pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]), - "texture.craquelure" => always(&["crackSpacing"]), - "texture.mosaicTiles" => always(&["tileSize"]), - "texture.patchwork" => always(&["squareSize"]), - "texture.stainedGlass" => always(&["cellSize"]), - "sketch.graphicPen" => always(&["strokeLength"]), - "sketch.halftonePattern" => always(&["size"]), - "sketch.notePaper" => always(&["relief"]), - "sketch.tornEdges" => always(&["smoothness"]), - "sketch.waterPaper" => always(&["fiberLength"]), - TRAP => always(&["thickness", "precision"]), - RASTERIZE => always(&["addAround"]), - _ => Lengths::default(), - } -} - -const WARP_DOC: &str = - "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; -const DT: &[&str] = &["Effect", "Distort & Transform"]; -const PATH: &[&str] = &["Effect", "Path"]; -const SHAPE: &[&str] = &["Effect", "Convert to Shape"]; -const STYLIZE: &[&str] = &["Effect", "Stylize"]; -const WARP: &[&str] = &["Effect", "Warp"]; -const BLUR: &[&str] = &["Effect", "Blur"]; -const ARTISTIC: &[&str] = &["Effect", "Artistic"]; -const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"]; -const DISTORT: &[&str] = &["Effect", "Distort"]; -const SHARPEN: &[&str] = &["Effect", "Sharpen"]; -const SKETCH: &[&str] = &["Effect", "Sketch"]; -const PIXELATE: &[&str] = &["Effect", "Pixelate"]; -const TEXTURE: &[&str] = &["Effect", "Texture"]; -const VIDEO: &[&str] = &["Effect", "Video"]; -const PATHFINDER: &[&str] = &["Effect", "Pathfinder"]; -const PLUGINS: &[&str] = &["Effect", "Plug-ins"]; -const ADJUST: &[&str] = &["Effect", "Color Adjustments"]; - -/// The warp styles in Illustrator's Style menu order: (id suffix, label). -pub const WARP_STYLES: [(&str, &str); 15] = [ - ("arc", "Arc…"), - ("arcLower", "Arc Lower…"), - ("arcUpper", "Arc Upper…"), - ("arch", "Arch…"), - ("bulge", "Bulge…"), - ("shellLower", "Shell Lower…"), - ("shellUpper", "Shell Upper…"), - ("flag", "Flag…"), - ("wave", "Wave…"), - ("fish", "Fish…"), - ("rise", "Rise…"), - ("fisheye", "Fisheye…"), - ("inflate", "Inflate…"), - ("squeeze", "Squeeze…"), - ("twist", "Twist…"), -]; - -/// Every supported effect with its dialog defaults. -pub fn effect_catalog() -> Vec { - let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) }; - let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) }; - let mut v = vec![ - g( - REVOLVE, - "Revolve…", - &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}), - ), - g( - EXTRUDE, - "Extrude & Bevel…", - &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), - ), - g( - INFLATE, - "Inflate…", - &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).", - json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), - ), - g( - ROTATE, - "Rotate…", - &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), - ), - g( - "convertToShape.rectangle", - "Rectangle…", - SHAPE, - "{relative: bool (true), extraW: pt (18), extraH: pt (18), width: pt (absolute, 100), height: pt (absolute, 100)}", - json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), - ), - g( - "convertToShape.roundedRectangle", - "Rounded Rectangle…", - SHAPE, - "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute), radius: pt (9)}", - json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0, "radius": 9.0}), - ), - g( - "convertToShape.ellipse", - "Ellipse…", - SHAPE, - "{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute)}", - json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}), - ), - g( - "distort.freeDistort", - "Free Distort…", - DT, - "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}", - json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}), - ), - g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})), - g( - "distort.roughen", - "Roughen…", - DT, - "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", - json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), - ), - g( - "distort.transform", - "Transform…", - DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", - json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), - ), - g( - "distort.tweak", - "Tweak…", - DT, - "{h: amount (10), v: amount (10), relative: bool (true: % of size), anchors: bool (true), in: bool (true), out: bool (true), seed: int (0)}", - json!({"h": 10.0, "v": 10.0, "relative": true, "anchors": true, "in": true, "out": true, "seed": 0}), - ), - g("distort.twist", "Twist…", DT, "{angle: deg (-3600..3600, 10)}", json!({"angle": 10.0})), - g( - "distort.zigZag", - "Zig Zag…", - DT, - "{size: (10), relative: bool (false: pt; true: % of size), ridges: per segment 0..100 (4), points: \"smooth\"|\"corner\"}", - json!({"size": 10.0, "relative": false, "ridges": 4, "points": "smooth"}), - ), - g( - "path.offsetPath", - "Offset Path…", - PATH, - "{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}", - json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}), - ), - g( - "path.outlineObject", - "Outline Object", - PATH, - "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change", - json!({}), - ), - g( - "path.outlineStroke", - "Outline Stroke", - PATH, - "{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads", - json!({}), - ), - g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})), - g( - "stylize.scribble", - "Scribble…", - STYLIZE, - "{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)", - json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}), - ), - r( - "stylize.dropShadow", - "Drop Shadow…", - STYLIZE, - "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)", - json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}), - ), - r( - "stylize.innerGlow", - "Inner Glow…", - STYLIZE, - "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"center\"|\"edge\" (\"edge\")}", - json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}), - ), - r( - "stylize.outerGlow", - "Outer Glow…", - STYLIZE, - "{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffff00\")}", - json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}), - ), - r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), - r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), - r( - "artistic.coloredPencil", - "Colored Pencil…", - ARTISTIC, - "{pencilWidth: 1..24 (5; stroke spacing in document units), strokePressure: 0..15 (5; how heavily the strokes darken), paperBrightness: 0..50 (25; lifts lighter areas toward the paper)} redraws the object in coloured pencil strokes along its edges, keeping its hues and coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"pencilWidth": 5.0, "strokePressure": 5.0, "paperBrightness": 25.0}), - ), - r( - "artistic.cutout", - "Cutout…", - ARTISTIC, - "{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}), - ), - r( - "artistic.dryBrush", - "Dry Brush…", - ARTISTIC, - "{brushSize: 1..10 (2; length of the brush strokes), brushDetail: 0..10 (10; more detail keeps finer structure), texture: 1..3 (1; stronger edges and bristle grain at 3)} repaints the object with short strokes along its form and darkens its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), - ), - r( - "artistic.filmGrain", - "Film Grain…", - ARTISTIC, - "{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", - json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}), - ), - r( - "artistic.fresco", - "Fresco…", - ARTISTIC, - "{brushSize: 1..10 (2; wider brush dabs), brushDetail: 0..10 (10; sharper edges and less smoothing), texture: 1..3 (1; deeper plaster relief)} repaints the object as coarse pigment on a textured wall, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), - ), - r( - "artistic.neonGlow", - "Neon Glow…", - ARTISTIC, - "{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}), - ), - r( - "artistic.paintDaubs", - "Paint Daubs…", - ARTISTIC, - "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), - ), - r( - "artistic.paletteKnife", - "Palette Knife…", - ARTISTIC, - "{strokeSize: 1..50 (10; length of the knife strokes in document units), strokeDetail: 1..3 (2; more detail keeps more of the original), softness: 0..10 (3; softens the strokes)} smears the object's colours with short angled knife strokes, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"strokeSize": 10.0, "strokeDetail": 2.0, "softness": 3.0}), - ), - r( - "artistic.plasticWrap", - "Plastic Wrap…", - ARTISTIC, - "{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", - json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}), - ), - r( - "artistic.posterEdges", - "Poster Edges…", - ARTISTIC, - "{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}), - ), - r( - "artistic.roughPastels", - "Rough Pastels…", - ARTISTIC, - "{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.", - json!({ - "strokeLength": 6.0, - "strokeDetail": 10.0, - "texture": "canvas", - "scaling": 100.0, - "relief": 4.0, - "lightDirection": "top", - "invert": false - }), - ), - r( - "artistic.smudgeStick", - "Smudge Stick…", - ARTISTIC, - "{strokeLength: 0..10 (2; length of the short diagonal strokes), highlightArea: 0..20 (12; widens the area counted as light), intensity: 0..10 (10; how far the light areas are lightened)} smudges the dark areas along short diagonal strokes; the lighter areas become brighter and lose detail. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"strokeLength": 2.0, "highlightArea": 12.0, "intensity": 10.0}), - ), - r( - "artistic.sponge", - "Sponge…", - ARTISTIC, - "{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}), - ), - r( - "artistic.underpainting", - "Underpainting…", - ARTISTIC, - "{brushSize: 0..40 (6; wider, softer strokes), textureCoverage: 0..40 (16; more of the relief texture on the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object with broad strokes over a textured ground, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults. Original visual approximation.", - json!({ - "brushSize": 6.0, - "textureCoverage": 16.0, - "texture": "canvas", - "scaling": 100.0, - "relief": 4.0, - "lightDirection": "top", - "invert": false - }), - ), - r( - "artistic.watercolor", - "Watercolor…", - ARTISTIC, - "{brushDetail: 1..14 (9; the larger, the more detail stays), shadowIntensity: 0..10 (1; deepens the shadows), texture: 1..3 (1; paper grain, whole numbers)} paints the object in a watercolor style: detail simplified, colour saturated and darker where tones change at edges. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"brushDetail": 9.0, "shadowIntensity": 1.0, "texture": 1.0}), - ), - r( - "blur.radial", - "Radial Blur…", - BLUR, - "{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre", - json!({"amount": 10.0, "method": "spin", "quality": "good"}), - ), - r( - "blur.smart", - "Smart Blur…", - BLUR, - "{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of the pixels the search reads within the radius (all of them for a small radius, a grid of samples for a wide one) or one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's, so a step gives a line about as wide as the radius, and at least one pixel wide, on the side with the larger sum; the white and the black keep each pixel's coverage", - json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}), - ), - r( - "brushStrokes.accentedEdges", - "Accented Edges…", - BRUSH_STROKES, - "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges", - json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}), - ), - r( - "brushStrokes.angledStrokes", - "Angled Strokes…", - BRUSH_STROKES, - "{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'", - json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}), - ), - r( - "brushStrokes.crosshatch", - "Crosshatch…", - BRUSH_STROKES, - "{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges", - json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}), - ), - r( - "brushStrokes.darkStrokes", - "Dark Strokes…", - BRUSH_STROKES, - "{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes", - json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}), - ), - r( - "brushStrokes.inkOutlines", - "Ink Outlines…", - BRUSH_STROKES, - "{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows", - json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}), - ), - r( - "brushStrokes.spatter", - "Spatter…", - BRUSH_STROKES, - "{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush", - json!({"sprayRadius": 10.0, "smoothness": 5.0}), - ), - r( - "brushStrokes.sprayedStrokes", - "Sprayed Strokes…", - BRUSH_STROKES, - "{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours", - json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}), - ), - r( - "brushStrokes.sumiE", - "Sumi-e…", - BRUSH_STROKES, - "{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape", - json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}), - ), - r( - "distort.diffuseGlow", - "Diffuse Glow…", - DISTORT, - "{graininess: 0..10 (6; white grain, thicker in the glow), glowAmount: 0..20 (10), clearAmount: 0..20 (15; the higher, the more of the image stays clear of glow)} renders the object as if seen through a soft diffusion filter: its highlights glow white under see-through white grain", - json!({"graininess": 6.0, "glowAmount": 10.0, "clearAmount": 15.0}), - ), - r( - "distort.glass", - "Glass…", - DISTORT, - "{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", - json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}), - ), - r( - "distort.oceanRipple", - "Ocean Ripple…", - DISTORT, - "{rippleSize: 1..15 (9), rippleMagnitude: 0..20 (9)} adds randomly spaced ripples, as if the object were under water", - json!({"rippleSize": 9.0, "rippleMagnitude": 9.0}), - ), - r( - "sharpen.unsharpMask", - "Unsharp Mask…", - SHARPEN, - "{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}", - json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}), - ), - r( - "sketch.basRelief", - "Bas Relief…", - SKETCH, - "{detail: 1..15 (13; the larger, the finer the features that stay), smoothness: 1..15 (3; rounds the carving), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"bottom\"; where the light comes from)} carves the object in low relief in black and white: surfaces facing the light turn white, those turned away black, flat areas grey. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}), - ), - r( - "sketch.chalkCharcoal", - "Chalk & Charcoal…", - SKETCH, - "{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.", - json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}), - ), - r( - "sketch.charcoal", - "Charcoal…", - SKETCH, - "{thickness: 1..7 (1; width of the bold edges and of the strokes' spacing), detail: 0..5 (1; the larger, the finer the edges), lightDarkBalance: 0..100 (50; how far into the lights the charcoal reaches)} redraws the object in black charcoal on white paper: bold edges and diagonal strokes over the midtones. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"thickness": 1.0, "detail": 1.0, "lightDarkBalance": 50.0}), - ), - r( - "sketch.chrome", - "Chrome…", - SKETCH, - "{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"detail": 4.0, "smoothness": 4.0}), - ), - r( - "sketch.conteCrayon", - "Conté Crayon…", - SKETCH, - "{foregroundLevel: 0..20 (12; how far the black crayon reaches from the shadows into the midtones), backgroundLevel: 0..20 (10; how far the white crayon reaches from the highlights into the midtones), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; the paper's surface, made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} redraws the object in a dense black crayon in its dark areas and a pure white crayon in its light areas, on mid-grey paper whose texture breaks the strokes and stands in relief, keeping its coverage. Public help names Foreground Level, Background Level and the texture options but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", - json!({ - "foregroundLevel": 12.0, - "backgroundLevel": 10.0, - "texture": "canvas", - "scaling": 100.0, - "relief": 4.0, - "lightDirection": "top", - "invert": false - }), - ), - r( - "sketch.graphicPen", - "Graphic Pen…", - SKETCH, - "{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.", - json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}), - ), - r( - "sketch.halftonePattern", - "Halftone Pattern…", - SKETCH, - "{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).", - json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}), - ), - r( - "sketch.notePaper", - "Note Paper…", - SKETCH, - "{imageBalance: 0..50 (25; lower tones darken the emboss, higher lighten it), graininess: 0..10 (0; fibrous paper texture), relief: 0..50 (4; emboss strength in points)} embosses the object's tones on warm paper, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).", - json!({"imageBalance": 25.0, "graininess": 0.0, "relief": 4.0}), - ), - r( - "sketch.photocopy", - "Photocopy…", - SKETCH, - "{detail: 1..24 (7; the larger, the finer the features that copy), darkness: 1..50 (25; the larger, the more of the dark copies)} copies the object in black on white: large dark areas copy only around their edges and midtones go to black or white. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"detail": 7.0, "darkness": 25.0}), - ), - r( - "sketch.plaster", - "Plaster…", - SKETCH, - "{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}), - ), - r( - "sketch.reticulation", - "Reticulation…", - SKETCH, - "{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}), - ), - r( - "sketch.stamp", - "Stamp…", - SKETCH, - "{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", - json!({"lightDarkBalance": 25.0, "smoothness": 5.0}), - ), - r( - "sketch.tornEdges", - "Torn Edges…", - SKETCH, - "{imageBalance: 0..50 (25; lower leaves more midtones as ink, higher as paper), smoothness: 0..15 (11; softens tones and the ragged edges), contrast: 0..20 (17; 20 is pure black and white)} redraws the object in high-contrast black and white with torn, ragged edges along its tones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", - json!({"imageBalance": 25.0, "smoothness": 11.0, "contrast": 17.0}), - ), - r( - "sketch.waterPaper", - "Water Paper…", - SKETCH, - "{fiberLength: pt 3..50 (15; scale of paper fibers and blotchy daubs), brightness: 0..100 (50), contrast: 0..100 (50)} washes the object's colours as if painted on damp fibrous paper, with soft flow and a fibrous modulation. Public helpx names Fiber Length, Brightness and Contrast but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", - json!({"fiberLength": 15.0, "brightness": 50.0, "contrast": 50.0}), - ), - r( - "stylize.glowingEdges", - "Glowing Edges…", - STYLIZE, - "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..20 (6), smoothness: pt 1..15 (5)} finds alpha-weighted Sobel edges and draws bright coloured outlines on black", - json!({"edgeWidth": 2.0, "edgeBrightness": 6.0, "smoothness": 5.0}), - ), - r( - "pixelate.colorHalftone", - "Color Halftone…", - PIXELATE, - "{maxRadius: pt 4..127 (8; the radius of a dot at full strength, which fills its square cell), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell", - json!({"maxRadius": 8.0, "channel1": 108.0, "channel2": 162.0, "channel3": 90.0, "channel4": 45.0}), - ), - r( - "pixelate.crystallize", - "Crystallize…", - PIXELATE, - "{cellSize: pt 3..300 (10)} redraws the object as polygon crystals of solid colour around random points about cellSize apart", - json!({"cellSize": 10.0}), - ), - r( - "pixelate.mezzotint", - "Mezzotint…", - PIXELATE, - "{type: \"fineDots\"|\"mediumDots\"|\"grainyDots\"|\"coarseDots\"|\"shortLines\"|\"mediumLines\"|\"longLines\"|\"shortStrokes\"|\"mediumStrokes\"|\"longStrokes\" (\"fineDots\")} turns each colour channel fully on or off against a random pattern of dots, lines or strokes: fully saturated colours", - json!({"type": "fineDots"}), - ), - r( - "pixelate.pointillize", - "Pointillize…", - PIXELATE, - "{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas", - json!({"cellSize": 5.0}), - ), - r( - "texture.craquelure", - "Craquelure…", - TEXTURE, - "{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones", - json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}), - ), - r( - "texture.grain", - "Grain…", - TEXTURE, - "{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white", - json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}), - ), - r( - "texture.mosaicTiles", - "Mosaic Tiles…", - TEXTURE, - "{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them", - json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}), - ), - r( - "texture.patchwork", - "Patchwork…", - TEXTURE, - "{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left", - json!({"squareSize": 4.0, "relief": 8.0}), - ), - r( - "texture.stainedGlass", - "Stained Glass…", - TEXTURE, - "{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart", - json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}), - ), - r( - "texture.texturizer", - "Texturizer…", - TEXTURE, - "{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", - json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}), - ), - r( - "video.deinterlace", - "De-Interlace…", - VIDEO, - "{eliminate: \"odd\"|\"even\" (\"odd\"; which field lines are taken out), create: \"duplication\"|\"interpolation\" (\"duplication\"; a line taken out becomes a copy of the line above, or the average of the lines above and below)} the field lines are the rows of the document's raster grid (Document Raster Effects Settings › Resolution), numbered from 1 at the top of the page", - json!({"eliminate": "odd", "create": "duplication"}), - ), - r( - "video.ntscColors", - "NTSC Colors", - VIDEO, - "{} makes colours a television signal can't carry (luma plus chroma past 110 % of white, or luma minus chroma below −20 %) less saturated, keeping their brightness", - json!({}), - ), - ]; - v.extend([ - g( - "adjust.brightnessContrast", - "Brightness/Contrast…", - ADJUST, - "{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)", - json!({"brightness": 0.0, "contrast": 0.0}), - ), - g( - "adjust.curves", - "Curves…", - ADJUST, - "{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", - json!({"points": "0,0 128,128 255,255", "channel": "rgb"}), - ), - g( - "adjust.hueSaturation", - "Hue/Saturation…", - ADJUST, - "{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does", - json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}), - ), - g( - "adjust.levels", - "Levels…", - ADJUST, - "{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", - json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}), - ), - g( - "adjust.shiftToColor", - "Shift to Color…", - ADJUST, - "{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does", - json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}), - ), - g( - "adjust.temperatureTint", - "Temperature/Tint…", - ADJUST, - "{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does", - json!({"temperature": 0.0, "tint": 0.0}), - ), - ]); - for (suffix, label) in WARP_STYLES { - let id: &'static str = match suffix { - "arc" => "warp.arc", - "arcLower" => "warp.arcLower", - "arcUpper" => "warp.arcUpper", - "arch" => "warp.arch", - "bulge" => "warp.bulge", - "shellLower" => "warp.shellLower", - "shellUpper" => "warp.shellUpper", - "flag" => "warp.flag", - "wave" => "warp.wave", - "fish" => "warp.fish", - "rise" => "warp.rise", - "fisheye" => "warp.fisheye", - "inflate" => "warp.inflate", - "squeeze" => "warp.squeeze", - _ => "warp.twist", - }; - v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); - } - for (id, label, _) in PATHFINDER_EFFECTS { - if id == "pathfinder.softMix" { - v.push(g( - id, - label, - PATHFINDER, - "{mixingRate?: 1..100 % (50)} blend overlapping fills in CMYK bottom to top (groups and layers: live Pathfinder over the members)", - json!({"mixingRate": 50.0}), - )); - } else { - v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); - } - } - v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); - v.push(g( - CROP_MARKS, - "Crop Marks", - &["Effect"], - "{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it", - json!({}), - )); - v.push(r( - RASTERIZE, - "Rasterize…", - &["Effect"], - "{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes", - json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}), - )); - v -} - -/// The catalogue built once, indexed by id (lookups run per effect per rendered frame). -fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInfo> { - static INDEX: std::sync::OnceLock> = std::sync::OnceLock::new(); - INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect()) -} - -/// Catalogue entry for `id` (built-in, or an installed plug-in effect). -pub fn effect_info(id: &str) -> Option { - catalog_index().get(id).cloned().or_else(|| plugin_info(id)) -} - -/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.`). -fn plugin_info(id: &str) -> Option { - use vectorcraft_plugins::registry::intern; - let p = vectorcraft_plugins::effect::installed(id)?; - let m = p.manifest(); - let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" }; - Some(EffectInfo { - id: intern(id), - label: intern(&format!("{}{dots}", m.name)), - menu: PLUGINS, - params: intern(&m.params_doc()), - defaults: Value::Object(m.defaults()), - raster: false, - lengths: Lengths::default(), - }) -} - -/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id. -pub fn plugin_effects() -> Vec { - vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect() -} - -/// Dialog defaults for `id` (`None` for unknown effects). -pub fn default_params(id: &str) -> Option { - match catalog_index().get(id) { - Some(e) => Some(e.defaults.clone()), - None => plugin_info(id).map(|e| e.defaults), - } -} - -/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see -/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and -/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`. -pub fn merged_params(id: &str, params: &Value) -> Value { - let mut out = match catalog_index().get(id).map(|e| &e.defaults) { - Some(Value::Object(m)) => m.clone(), - Some(_) => Map::new(), - None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(), - }; - // Upgraded before the defaults are under it: a default angle is no angle given. - if let Value::Object(p) = upgrade_params(id, params) { - out.extend(p); - } - Value::Object(out) -} - -/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 % -/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light -/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height -/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its -/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of -/// then) become the current ones that show exactly the same, unless those are given too, which win. -/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). -pub fn upgrade_params(id: &str, params: &Value) -> Value { - match params { - Value::Object(m) => { - let mut m = m.clone(); - upgrade_perspective(id, &mut m); - upgrade_light(id, &mut m); - upgrade_scribble(id, &mut m); - Value::Object(m) - } - other => other.clone(), - } -} - -fn upgrade_light(id: &str, m: &mut Map) { - if !is_three_d(id) { - return; - } - let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation")); - if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") { - return; - } - let degrees = |v: Option, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default); - let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0)); - m.insert("lightRotation".into(), json!(rotation)); - m.insert("lightHeight".into(), json!(height)); - m.insert("lightBehind".into(), json!(behind)); -} - -fn upgrade_perspective(id: &str, m: &mut Map) { - if !is_three_d(id) { - return; - } - let Some(strength) = m.remove("perspective") else { return }; - if !m.contains_key("perspectiveAngle") { - let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0); - m.insert("perspectiveAngle".into(), json!(angle)); - } -} - -fn upgrade_scribble(id: &str, m: &mut Map) { - if id != "stylize.scribble" { - return; - } - if let Some(variation) = m.remove("variation") { - for key in ["overlapVariation", "spacingVariation"] { - m.entry(key).or_insert_with(|| variation.clone()); - } - } - if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) { - let wrapped = angle.rem_euclid(360.0); - m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped })); - } -} - -/// A new effect record with default parameters (overridden by `params`). -pub fn new_effect(id: &str, params: &Value) -> Option { - if !catalog_index().contains_key(id) { - vectorcraft_plugins::effect::installed(id)?; - } - Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) -} - -/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see -/// [`EffectInfo::lengths`]). A distance left at its default is written out scaled; one the -/// effect doesn't have (Outline Stroke's weight following its stroke) stays absent. -pub fn scale_effect(e: &mut Effect, s: f64) { - let Some(info) = catalog_index().get(e.id.as_str()) else { return }; - let merged = merged_params(&e.id, &e.params); - let relative = util::flag(&merged, "relative", false); - for k in info.lengths.keys(relative) { - let v = util::num(&merged, k, f64::NAN); - if v.is_finite() { - if !e.params.is_object() { - e.params = Value::Object(Map::new()); - } - e.params[k] = json!(v * s); - } - } -} - -/// Is `id` a raster (painted) effect? -pub fn is_raster(id: &str) -> bool { - matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE) - || PIXEL_EFFECTS.contains(&id) -} - -/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour -/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in -/// leaves the geometry as it is). -pub fn is_geometry(id: &str) -> bool { - !is_raster(id) - && !is_pathfinder(id) - && !is_adjustment(id) - && id != CROP_MARKS - && !is_three_d(id) - && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) -} - -/// Any visible geometry effect in the list? -pub fn has_geometry(effects: &[Effect]) -> bool { - effects.iter().any(|e| e.visible && is_geometry(&e.id)) -} - -/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws -/// what the plug-in installed under its id draws ([`plugin_revision`]). -pub fn has_plugin_effect(n: &Node) -> bool { - let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some(); - n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin)) -} - -/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed. -pub fn plugin_revision() -> u64 { - vectorcraft_plugins::registry::revision() -} - -/// Context the geometry effects may need from the object's appearance. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct GeomContext<'a> { - /// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment, - /// profile and arrowheads): the stroke the effect sits on, else the object's top painted - /// stroke. `None` outlines a plain 1 pt stroke. - pub stroke: Option<&'a StrokeLayer>, - /// The object's fill rule (inside and outside alignment). - pub rule: FillRule, - /// The seed of the Transform effect's Random: the object's id, so each object varies its own - /// way and keeps its result on every redraw. - pub seed: u64, - /// The object's appearance, which live-effect plug-ins are given read only (their fills and - /// strokes). - pub appearance: Option<&'a Appearance>, - /// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`: - /// the object's effects. - pub item: Option, -} - -impl<'a> GeomContext<'a> { - /// The context of `n`'s object-level effects. - pub fn of(n: &'a Node) -> Self { - let rule = match &n.kind { - NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, - _ => FillRule::NonZero, - }; - let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0); - Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None } - } - - /// The context of the effects on `item`, one of the same object's appearance items: a - /// stroke's own effects outline that stroke, and a plug-in is told which item it is on. - pub fn item(self, item: &'a AppearanceItem) -> Self { - let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item))); - match item { - AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self }, - AppearanceItem::Fill(_) => Self { item: at, ..self }, - } - } -} - -/// Most anchors a chain of geometry effects makes of one path, or of all the paths of a type, -/// symbol or image art together; a path that already has more keeps its own count as the limit. -/// Roughen resamples each segment into up to 2000 points, Zig Zag into up to 101 and the warps -/// into up to 64, so stacked effects multiply: three Roughens of a rectangle needed tens of -/// gigabytes (#3578). Each effect builds within it, and one whose result would still pass it is -/// left out. -pub(crate) const MAX_EFFECT_ANCHORS: usize = 2_000_000; - -/// Most lines the renderer is estimated to draw a chain of geometry effects' result with at -/// 1 px/pt ([`util::line_estimate`]), or the path's own estimate when that is more. A large -/// Roughen or Zig Zag makes segments thousands of points long, each drawn as up to hundreds of -/// lines: two million anchors of them came to 141 million lines, 7 GB in the renderer and past -/// the 2²⁷ lines vello takes of one path (#3578). Art of small curves draws its anchors as a few -/// lines each, so this rarely binds before [`MAX_EFFECT_ANCHORS`] does. -pub(crate) const MAX_EFFECT_LINES: usize = 8_000_000; - -/// What geometry effects may make of a path, or what a path costs: its anchors and the lines the -/// renderer is estimated to draw it with. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct Budget { - pub anchors: usize, - pub lines: usize, -} - -impl Budget { - /// The budget of a chain of geometry effects. - pub(crate) const EFFECTS: Self = Self { anchors: MAX_EFFECT_ANCHORS, lines: MAX_EFFECT_LINES }; - - /// What `path` costs. - pub(crate) fn of(path: &PathData) -> Self { - Self { anchors: path.anchor_count(), lines: util::line_estimate(path) } - } - - /// This budget, raised to `cost` where that is more. - pub(crate) fn at_least(self, cost: Self) -> Self { - Self { anchors: self.anchors.max(cost.anchors), lines: self.lines.max(cost.lines) } - } - - /// Does `cost` fit this budget? - pub(crate) fn fits(self, cost: Self) -> bool { - cost.anchors <= self.anchors && cost.lines <= self.lines - } - - /// What is left of this budget once `cost` is spent. - pub(crate) fn minus(self, cost: Self) -> Self { - Self { anchors: self.anchors.saturating_sub(cost.anchors), lines: self.lines.saturating_sub(cost.lines) } - } - - /// This cost and `cost` together. - pub(crate) fn plus(self, cost: Self) -> Self { - Self { anchors: self.anchors.saturating_add(cost.anchors), lines: self.lines.saturating_add(cost.lines) } - } -} - -/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the -/// reference box of the first effect (usually the object's geometric bounds); each later effect -/// uses the bounds of its input. Raster and unknown effects are skipped, and so is an effect -/// whose result would pass 2 million anchors or 8 million lines drawn (or the path's own counts, -/// when they are more). -pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData { - apply_geometry_with(effects, path, bounds, &GeomContext::default()) -} - -/// [`apply_geometry`] with an explicit [`GeomContext`]. -pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData { - apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS) -} - -/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that -/// is more. -pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData { - // `budget` raised to what `path` costs, measured once, and only for a path that has geometry - // effects. - let mut limit = None; - let mut cur = path.clone(); - let mut b = bounds; - let mut first = true; - for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) { - if !first { - b = cur.bounds().unwrap_or(b); - } - first = false; - if cur.is_empty() { - break; - } - let p = merged_params(&e.id, &e.params); - let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); - if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { - cur = next; - } - } - cur -} - -/// Convenience wrapper on kurbo paths. -pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> BezPath { - apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() -} - -/// Geometry effect `id` with params `p` on `path`, whose reference box is `b`; `None` when its -/// result would not fit `budget` (the effects that multiply anchors build within its anchors). -pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, budget: Budget) -> Option { - let out = apply_unchecked(id, p, path, b, ctx, budget.anchors); - let cost = Budget::of(&out); - if budget.fits(cost) { - return Some(out); - } - if cost.lines <= budget.lines || !builds_within(id) { - return None; - } - // Anchors drawn as many lines each (a large Roughen or Zig Zag): once more with as many fewer - // anchors as there were lines too many, and a tenth to spare, as segments vary in their lines. - let fewer = (cost.anchors as f64 * 0.9 * (budget.lines as f64 / cost.lines as f64)) as usize; - let out = apply_unchecked(id, p, path, b, ctx, fewer.min(budget.anchors)); - budget.fits(Budget::of(&out)).then_some(out) -} - -/// Does geometry effect `id` build within the anchors it is given? -fn builds_within(id: &str) -> bool { - matches!(id, "distort.freeDistort" | "distort.roughen" | "distort.transform" | "distort.twist" | "distort.zigZag") || id.starts_with("warp.") -} - -/// [`apply_one`] whose result may pass `max` when the effect doesn't build within it. -fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { - use util::*; - if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { - let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; - return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone()); - } - match id { - "distort.freeDistort" => distort::free_distort(path, b, p, max), - "distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)), - "distort.roughen" => distort::roughen(path, b, p, max), - "distort.transform" => distort::transform_within(path, b, p, ctx.seed, max), - "distort.tweak" => distort::tweak(path, b, p), - "distort.twist" => distort::twist(path, b, num(p, "angle", 10.0), max), - "distort.zigZag" => distort::zig_zag(path, b, p, max), - "path.offsetPath" => { - let off = num(p, "offset", 10.0).clamp(-1e5, 1e5); - if off.abs() < 1e-9 { - return path.clone(); - } - vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0)) - } - // Outline Object returns the geometry unchanged. Type reaches every geometry effect as its - // glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines. - "path.outlineObject" => path.clone(), - "path.outlineStroke" => { - let fallback; - let base = match ctx.stroke { - Some(s) => s, - None => { - fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0); - &fallback - } - }; - let st = match p.get("width").and_then(Value::as_f64) { - Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }), - None => std::borrow::Cow::Borrowed(base), - }; - if st.width.is_nan() || st.width <= 0.0 { - return path.clone(); - } - stroke::outline_region(path, ctx.rule, &st) - } - "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p), - "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), - "stylize.scribble" => stylize::scribble(path, b, p), - _ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) { - Some(style) => warp::warp(path, b, style, p, max), - None => path.clone(), - }, - } -} +pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, plugin_effects}; +pub(crate) use geometry::{Budget, apply_one}; +pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with}; +#[cfg(test)] +pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within}; +pub use params::{ + default_params, has_geometry, has_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision, scale_effect, upgrade_params, +}; diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs new file mode 100644 index 000000000..dc54e8b51 --- /dev/null +++ b/crates/effects/src/params.rs @@ -0,0 +1,148 @@ +use super::catalog::{catalog_index, plugin_info}; +use super::*; + +/// Dialog defaults for `id` (`None` for unknown effects). +pub fn default_params(id: &str) -> Option { + match catalog_index().get(id) { + Some(e) => Some(e.defaults.clone()), + None => plugin_info(id).map(|e| e.defaults), + } +} + +/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see +/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and +/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`. +pub fn merged_params(id: &str, params: &Value) -> Value { + let mut out = match catalog_index().get(id).map(|e| &e.defaults) { + Some(Value::Object(m)) => m.clone(), + Some(_) => Map::new(), + None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(), + }; + // Upgraded before the defaults are under it: a default angle is no angle given. + if let Value::Object(p) = upgrade_params(id, params) { + out.extend(p); + } + Value::Object(out) +} + +/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 % +/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light +/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height +/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its +/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of +/// then) become the current ones that show exactly the same, unless those are given too, which win. +/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). +pub fn upgrade_params(id: &str, params: &Value) -> Value { + match params { + Value::Object(m) => { + let mut m = m.clone(); + upgrade_perspective(id, &mut m); + upgrade_light(id, &mut m); + upgrade_scribble(id, &mut m); + Value::Object(m) + } + other => other.clone(), + } +} + +fn upgrade_light(id: &str, m: &mut Map) { + if !is_three_d(id) { + return; + } + let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation")); + if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") { + return; + } + let degrees = |v: Option, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default); + let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0)); + m.insert("lightRotation".into(), json!(rotation)); + m.insert("lightHeight".into(), json!(height)); + m.insert("lightBehind".into(), json!(behind)); +} + +fn upgrade_perspective(id: &str, m: &mut Map) { + if !is_three_d(id) { + return; + } + let Some(strength) = m.remove("perspective") else { return }; + if !m.contains_key("perspectiveAngle") { + let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0); + m.insert("perspectiveAngle".into(), json!(angle)); + } +} + +fn upgrade_scribble(id: &str, m: &mut Map) { + if id != "stylize.scribble" { + return; + } + if let Some(variation) = m.remove("variation") { + for key in ["overlapVariation", "spacingVariation"] { + m.entry(key).or_insert_with(|| variation.clone()); + } + } + if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) { + let wrapped = angle.rem_euclid(360.0); + m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped })); + } +} + +/// A new effect record with default parameters (overridden by `params`). +pub fn new_effect(id: &str, params: &Value) -> Option { + if !catalog_index().contains_key(id) { + vectorcraft_plugins::effect::installed(id)?; + } + Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) +} + +/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see +/// [`EffectInfo::lengths`]). A distance left at its default is written out scaled; one the +/// effect doesn't have (Outline Stroke's weight following its stroke) stays absent. +pub fn scale_effect(e: &mut Effect, s: f64) { + let Some(info) = catalog_index().get(e.id.as_str()) else { return }; + let merged = merged_params(&e.id, &e.params); + let relative = util::flag(&merged, "relative", false); + for k in info.lengths.keys(relative) { + let v = util::num(&merged, k, f64::NAN); + if v.is_finite() { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params[k] = json!(v * s); + } + } +} + +/// Is `id` a raster (painted) effect? +pub fn is_raster(id: &str) -> bool { + matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE) + || PIXEL_EFFECTS.contains(&id) +} + +/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour +/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in +/// leaves the geometry as it is). +pub fn is_geometry(id: &str) -> bool { + !is_raster(id) + && !is_pathfinder(id) + && !is_adjustment(id) + && id != CROP_MARKS + && !is_three_d(id) + && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) +} + +/// Any visible geometry effect in the list? +pub fn has_geometry(effects: &[Effect]) -> bool { + effects.iter().any(|e| e.visible && is_geometry(&e.id)) +} + +/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws +/// what the plug-in installed under its id draws ([`plugin_revision`]). +pub fn has_plugin_effect(n: &Node) -> bool { + let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some(); + n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin)) +} + +/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed. +pub fn plugin_revision() -> u64 { + vectorcraft_plugins::registry::revision() +} From 91e9555921222c84267e3a14bd80ec0cbc4c41cf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:02:56 +0000 Subject: [PATCH 120/199] refactor(pathops): move planar.rs items to planar/{graph,index,live_paint,map,shape_builder}.rs --- crates/pathops/src/planar.rs | 1586 +------------------- crates/pathops/src/planar/graph.rs | 218 +++ crates/pathops/src/planar/index.rs | 219 +++ crates/pathops/src/planar/live_paint.rs | 292 ++++ crates/pathops/src/planar/map.rs | 549 +++++++ crates/pathops/src/planar/shape_builder.rs | 307 ++++ 6 files changed, 1602 insertions(+), 1569 deletions(-) create mode 100644 crates/pathops/src/planar/graph.rs create mode 100644 crates/pathops/src/planar/index.rs create mode 100644 crates/pathops/src/planar/live_paint.rs create mode 100644 crates/pathops/src/planar/map.rs create mode 100644 crates/pathops/src/planar/shape_builder.rs diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index c53d3951d..02e8cc346 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -26,1575 +26,23 @@ use vectorcraft_geom::{FillRule, PathData, SubPath}; use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg}; use crate::pathfinder::{Region, Shape}; -/// The sweep's frame: y + x / 32. Only edges sloping -1/32 in the document are horizontal in it. -const SHEAR: Affine = Affine::new([1.0, 1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); -const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); - -/// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s -/// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one -/// to each, so its edges never cancel out of the map. Only the slots that aren't 0 are kept, in -/// order: the sweep keeps one of these for every edge, and a vector as long as the input's number -/// (as this was) made disjoint shapes cost the square of their count in memory. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -struct Crossings(Vec<(usize, u32)>); - -impl Crossings { - fn get(&self, slot: usize) -> u32 { - self.0.binary_search_by_key(&slot, |(s, _)| *s).map_or(0, |i| self.0.get(i).map_or(0, |(_, c)| *c)) - } -} - -impl std::ops::Add for Crossings { - type Output = Crossings; - fn add(mut self, rhs: Self) -> Crossings { - self += rhs; - self - } -} - -impl std::ops::AddAssign for Crossings { - fn add_assign(&mut self, rhs: Self) { - if rhs.0.is_empty() { - return; - } - let mut out = Vec::with_capacity(self.0.len() + rhs.0.len()); - let (mut a, mut b) = (std::mem::take(&mut self.0).into_iter().peekable(), rhs.0.into_iter().peekable()); - loop { - let next = match (a.peek(), b.peek()) { - (Some(x), Some(y)) if x.0 == y.0 => { - let (x, y) = (a.next(), b.next()); - x.zip(y).map(|(x, y)| (x.0, x.1.saturating_add(y.1))) - } - (Some(x), Some(y)) if x.0 < y.0 => a.next(), - (Some(_), Some(_)) | (None, Some(_)) => b.next(), - (Some(_), None) => a.next(), - (None, None) => break, - }; - out.extend(next.filter(|(_, c)| *c != 0)); - } - self.0 = out; - } -} - -impl WindingNumber for Crossings { - type Tag = usize; - fn single(tag: usize, positive: bool) -> Self { - Crossings(vec![(2 * tag + usize::from(!positive), 1)]) - } - fn of_tag(&self, tag: usize) -> Self { - Crossings([2 * tag, 2 * tag + 1].into_iter().map(|slot| (slot, self.get(slot))).filter(|(_, c)| *c != 0).collect()) - } -} - -/// A point in the middle of a run of segments. -fn mid(segs: &[Seg]) -> Option { - segs.get(segs.len() / 2).map(|s| s.c.eval(0.5)) -} - -/// Rectangles looked up by a point inside them, when there are too many to test one by one for -/// every point (the map asks once per edge and once per hole, so a scan of them all made it -/// quadratic in the input, #937). A grid of cells over their bounds, each cell listing the -/// rectangles touching it; a lookup reads the cells around the point. The grid has about one cell -/// per rectangle, so a lookup reads about the rectangles of the point's neighbourhood. A rectangle -/// covering too many cells (a long diagonal's bounds take in most of the art, so an index of the -/// bounds alone scanned a diagonal hatch whole for every point) is filed under the cells of a -/// smaller footprint instead ([`Self::with_footprint`]), or, failing that, scanned for every point. -struct RectIndex { - items: Vec<(kurbo::Rect, T)>, - /// The grid's top-left corner, cell size and column count; `cells` lists the items touching - /// each cell, row by row. - origin: Point, - cell: (f64, f64), - cols: usize, - cells: Vec>, - /// Items the grid doesn't hold (bounds covering most of it, or not finite), scanned for - /// every point. - rest: Vec, -} - -/// The fewest cells one footprint rectangle may always be filed under. The cap is this or two -/// rows or columns of the grid, whichever is more: a thin stripe across the art (one of the faces -/// a hatch cuts a shape into) is filed under the cells along it, while a rectangle covering most -/// of the grid costs more to file and to read than to scan for every point. -const CELLS_PER_FOOTPRINT: usize = 64; -/// The cell references a grid of `cells` cells holds for `n` items before the remaining items go -/// to the scanned list: a few per item, plus a few rows or columns' worth (a long segment is filed -/// under a cell of each row or column it crosses, about `sqrt(cells)` of them in a square grid). -/// Coincident segments make a grid of one column and a row per segment, where each would be filed -/// under every row: a quadratic number of references, which this keeps to `n·sqrt(n)`. -fn reference_budget(n: usize, cells: usize) -> usize { - let across = (cells as f64).sqrt().ceil().min(1e9) as usize; - n.saturating_mul(64 + 4 * across).saturating_add(1024) -} - -/// The cells (columns or rows) an interval `lo..=hi` touches on one axis of a grid: clamped to the -/// grid, so an interval past its edge gets the nearest cell (whose items `contains` then rejects), -/// and `None` for an interval that isn't a number. -fn cell_span(lo: f64, hi: f64, origin: f64, size: f64, count: usize) -> Option> { - let last = count.saturating_sub(1) as f64; - let a = ((lo - origin) / size).floor().clamp(0.0, last); - let b = ((hi - origin) / size).floor().clamp(0.0, last); - (a.is_finite() && b.is_finite()).then(|| (a as usize)..=(b.max(a) as usize)) -} - -impl RectIndex { - fn new(items: Vec<(kurbo::Rect, T)>) -> Self { - Self::with_footprint(items, |r, _, _| vec![*r]) - } - - /// `footprint(bounds, item, cell side)` gives the rectangles an item is filed under: a lookup - /// finds the item when its bounds hold the point and one footprint rectangle is within the - /// lookup's tolerance of it. The bounds themselves (`new`) suit items as compact as their - /// bounds; a long diagonal segment is better filed under the bounds of its pieces, about a - /// cell long each, which take in the cells along it rather than every cell its bounds cover. - fn with_footprint(items: Vec<(kurbo::Rect, T)>, footprint: impl Fn(&kurbo::Rect, &T, f64) -> Vec) -> Self { - let n = items.len().max(1); - let all = items.iter().map(|(r, _)| *r).filter(kurbo::Rect::is_finite).reduce(|a, b| a.union(b)).unwrap_or(kurbo::Rect::ZERO); - // A cell's side holds one rectangle on average; a grid of collinear rectangles (zero area) - // is divided along its length. - let side = (all.area() / n as f64).sqrt(); - let side = if side > 0.0 { side } else { all.width().max(all.height()) / n as f64 }.max(1e-9); - // Saturating casts: an extent of a billion cells is clamped to the item count, then the - // grid is kept to a few cells per item however lopsided its bounds. - let mut cols = ((all.width() / side).ceil() as usize).clamp(1, 2 * n); - let mut rows = ((all.height() / side).ceil() as usize).clamp(1, 2 * n); - while cols * rows > 4 * n + 4 { - if cols > rows { cols = cols.div_ceil(2) } else { rows = rows.div_ceil(2) } - } - let cell = ((all.width() / cols as f64).max(1e-9), (all.height() / rows as f64).max(1e-9)); - let cap = CELLS_PER_FOOTPRINT.max(2 * cols.max(rows)); - let mut budget = reference_budget(n, cols * rows); - let mut cells: Vec> = vec![Vec::new(); cols * rows]; - let mut rest = Vec::new(); - for (i, (r, t)) in items.iter().enumerate() { - // Cells hold `u32` indices to halve the memory of a long stripe filed under a row of them. - let Ok(index) = u32::try_from(i) else { - rest.push(i); - continue; - }; - let mut scan = !r.is_finite(); - let mut spans = Vec::new(); - for f in if scan { Vec::new() } else { footprint(r, t, cell.0.min(cell.1)) } { - match (cell_span(f.x0, f.x1, all.x0, cell.0, cols), cell_span(f.y0, f.y1, all.y0, cell.1, rows)) { - (Some(xs), Some(ys)) if (xs.end() - xs.start() + 1) * (ys.end() - ys.start() + 1) <= cap => spans.push((xs, ys)), - _ => scan = true, - } - } - // The item's cells, counted before they are filed: past the grid's budget the item, - // and the ones after it, are scanned instead (the index stays bounded in memory). - // Pieces of one segment that fall in the same cell (a degenerate grid's tiny cells) - // count and file it once: the pieces come in order, so the repeats are consecutive. - let mut mine: Vec = if scan { - Vec::new() - } else { - spans.iter().flat_map(|(xs, ys)| ys.clone().flat_map(|y| xs.clone().map(move |x| y * cols + x))).collect() - }; - mine.dedup(); - if !scan && mine.len() > budget { - scan = true; - } - if scan { - rest.push(i); - continue; - } - budget -= mine.len(); - for c in mine { - if let Some(c) = cells.get_mut(c) - && c.last() != Some(&index) - { - c.push(index); - } - } - } - Self { items, origin: all.origin(), cell, cols, cells, rest } - } - - /// The items whose rectangle, grown by `tol`, holds `p` (and, with a footprint, one of whose - /// footprint rectangles is within `tol` of it). - fn at(&self, p: Point, tol: f64) -> impl Iterator { - let mut found = self.rest.clone(); - let rows = self.cells.len() / self.cols.max(1); - if let (Some(xs), Some(ys)) = ( - cell_span(p.x - tol, p.x + tol, self.origin.x, self.cell.0, self.cols), - cell_span(p.y - tol, p.y + tol, self.origin.y, self.cell.1, rows), - ) { - for y in ys { - for x in xs.clone() { - found.extend(self.cells.get(y * self.cols + x).into_iter().flatten().map(|&i| i as usize)); - } - } - } - found.sort_unstable(); - found.dedup(); - found.into_iter().filter_map(move |i| self.items.get(i)).filter(move |(r, _)| r.inflate(tol, tol).contains(p)).map(|(_, t)| t) - } -} - -/// A segment's footprint in a [`RectIndex`]: the bounds of its pieces about a cell long, so a long -/// diagonal is filed under the cells along it, not under every cell its bounds cover. -fn segment_footprint(bounds: &kurbo::Rect, seg: &PathSeg, cell: f64) -> Vec { - let pieces = (bounds.width().max(bounds.height()) / cell).ceil(); - // A count past the cap gives longer pieces, each filed under more cells. - if pieces.is_nan() || pieces <= 1.0 { - return vec![*bounds]; - } - let pieces = pieces.min(4096.0) as usize; - (0..pieces).map(|k| seg.subsegment(k as f64 / pieces as f64..(k + 1) as f64 / pieces as f64).bounding_box()).collect() -} - -/// The inputs' segments (in the sweep's frame), to tell which inputs an edge lies on. -struct Outlines { - segs: RectIndex<(PathSeg, usize)>, - tol: f64, -} - -impl Outlines { - fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self { - // A zero-length segment (a point) is the source of no edge, even one running through it. - let segs = inputs - .iter() - .flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), (s, *i)))) - .filter(|(r, ..)| r.width() > 0.0 || r.height() > 0.0) - .collect(); - // The sweep moves points by a few `eps` at most. - Self { segs: RectIndex::with_footprint(segs, |r, (s, _), cell| segment_footprint(r, s, cell)), tol: 16.0 * eps } - } - - /// The front-most input whose outline passes through `p`. - fn source_at(&self, p: Point) -> Option { - use kurbo::ParamCurveNearest; - let tol = self.tol; - self.segs.at(p, tol).filter(|(s, _)| s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, i)| *i).max() - } -} - -/// The area an input's fill covers: its closed subpaths (open ones bound no face). -struct Fill { - path: BezPath, - bounds: kurbo::Rect, - rule: FillRule, -} - -impl Fill { - fn new(s: &Shape) -> Self { - let mut path = BezPath::new(); - for sp in s.path.subpaths.iter().filter(|sp| sp.closed && sp.anchors.len() >= 2) { - sp.to_bezpath_into(&mut path); - } - Self { bounds: path.bounding_box(), path, rule: s.rule } - } - - fn contains(&self, p: Point) -> bool { - !self.path.elements().is_empty() && self.bounds.contains(p) && crate::boolean::inside(self.rule, self.path.winding(p)) - } -} - -/// One output segment of the sweep: an edge of the planar graph between two vertices. -struct Edge { - segs: Vec, - ends: [usize; 2], - /// The input it came from (the front-most, where inputs share it). - source: usize, -} - -/// `segs` (a chain) moved to start at `a` and end at `b`, the handles next to each moving with it. -pub(crate) fn pin_ends(segs: &mut [Seg], a: Point, b: Point) { - if let Some(s) = segs.first_mut() { - let d = a - s.c.p0; - s.c.p0 = a; - s.c.p1 += d; - } - if let Some(s) = segs.last_mut() { - let d = b - s.c.p3; - s.c.p3 = b; - s.c.p2 += d; - } -} - -/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it. -fn twin(h: usize) -> usize { - h ^ 1 -} - -/// The segments of half-edge `h`, in its direction. -fn half_segs(edges: &[Edge], h: usize) -> Vec { - half_seg_iter(edges, h).collect() -} - -/// The segments of half-edge `h` in its direction, without copying the edge first (an edge can -/// hold thousands of segments, and the angle it leaves a vertex at needs the first few). -fn half_seg_iter(edges: &[Edge], h: usize) -> impl Iterator + '_ { - let segs = edges.get(h / 2).map(|e| e.segs.as_slice()).unwrap_or_default(); - let forward = h.is_multiple_of(2); - (0..segs.len()).filter_map(move |k| { - let s = segs.get(if forward { k } else { segs.len() - 1 - k })?; - Some(if forward { *s } else { Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line } }) - }) -} - -/// Where half-edge `h` ends (the vertex at the other end of its edge). -fn far_end(edges: &[Edge], h: usize) -> Option { - let e = edges.get(h / 2)?; - if h.is_multiple_of(2) { e.segs.last().map(|s| s.c.p3) } else { e.segs.first().map(|s| s.c.p0) } -} - -/// The vertex half-edge `h` leaves from. -fn origin(edges: &[Edge], h: usize) -> Option { - edges.get(h / 2).map(|e| e.ends[h % 2]) -} - -/// Where half-edge `h` (leaving `v`) first gets `r` away from it, as an angle around `v`. -/// -/// Edges that only meet at `v` cross a circle around it in the order they leave `v` in, however -/// they start out (tangent curves, or the sweep's short straight stubs at a vertex). -fn leaving_angle(edges: &[Edge], h: usize, v: Point, r: f64) -> f64 { - let first = half_seg_iter(edges, h).next(); - let mut prev = first.map_or(v, |s| s.c.p0); - let mut hit: Option = None; - // One segment at a time, stopping at the first that gets r away: an edge can run on for - // thousands of segments (a traced outline between two crossings), all of which flattening - // the whole edge to this tolerance would visit, at every vertex. - for s in half_seg_iter(edges, h) { - let path = [kurbo::PathEl::MoveTo(s.c.p0), kurbo::PathEl::CurveTo(s.c.p1, s.c.p2, s.c.p3)]; - kurbo::flatten(path, (r * 1e-4).max(1e-12), |el| { - if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) { - if p.distance(v) >= r { - // The point on prev → p at distance r (prev is closer). - let (d, f) = (p - prev, prev - v); - let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r); - let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 }; - hit = Some(prev + d * t); - } - prev = p; - } - }); - if hit.is_some() { - break; - } - } - // An edge too short to reach r (it can't, r is at most half of every chord) leaves along its - // tangent. - let far = hit.unwrap_or_else(|| first.map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3))); - (far - v).atan2() -} - -/// Path of a closed run of half-edges, tidied (split pieces of one input curve joined again). -fn cycle_bezpath(edges: &[Edge], cycle: &[usize]) -> BezPath { - let mut bp = BezPath::new(); - for (k, s) in cycle.iter().flat_map(|&h| half_segs(edges, h)).enumerate() { - if k == 0 { - bp.move_to(s.c.p0); - } - if s.line { - bp.line_to(s.c.p3); - } else { - bp.curve_to(s.c.p1, s.c.p2, s.c.p3); - } - } - bp.close_path(); - bp -} - -/// `cycle` without the spurs it runs out and back along (edges with the same face on both sides, -/// such as a line's loose end). -fn without_spurs(cycle: &[usize]) -> Vec { - let mut out: Vec = Vec::with_capacity(cycle.len()); - for &h in cycle { - if out.last() == Some(&twin(h)) { - out.pop(); - } else { - out.push(h); - } - } - // A spur where the walk started wraps around the ends. - while out.len() >= 2 && out.first().map(|&h| twin(h)) == out.last().copied() { - out.pop(); - out.remove(0); - } - out -} - -/// Append `open` traced there and back (a closed, zero-area path along it) to `out`. -fn there_and_back(open: &BezPath, out: &mut BezPath) { - let segs: Vec = open.segments().collect(); - let Some(first) = segs.first() else { return }; - out.move_to(first.start()); - for s in &segs { - out.push(s.as_path_el()); - } - for s in segs.iter().rev() { - out.push(s.reverse().as_path_el()); - } - out.close_path(); -} - -/// `path` back from the sweep's frame, its anchors within `tol` of an input anchor (`sorted` by x) -/// put exactly on it, handles moving with them. -fn unshear(mut path: PathData, sorted: &[Point], tol: f64) -> PathData { - path.transform(UNSHEAR); - for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) { - let lo = sorted.partition_point(|q| q.x < a.p.x - tol); - if let Some(q) = sorted.get(lo..).unwrap_or_default().iter().take_while(|q| q.x <= a.p.x + tol).find(|q| (q.y - a.p.y).abs() <= tol) { - let d = *q - a.p; - a.p = *q; - a.h_in += d; - a.h_out += d; - } - } - path -} - -fn find(parent: &mut [usize], mut v: usize) -> usize { - while let Some(&p) = parent.get(v) { - if p == v { - break; - } - let gp = parent.get(p).copied().unwrap_or(p); - if let Some(slot) = parent.get_mut(v) { - *slot = gp; - } - v = p; - } - v -} - -/// The Live Paint planar map of `shapes` (back → front). -/// -/// Faces are the bounded areas the paths enclose, holes included, each with the inputs whose fill -/// covers it (ascending; empty for an area only enclosed by paths). Edges are the paths split -/// wherever they meet another path, keyed by the input they came from (the front-most where -/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate). -pub fn live_paint(shapes: &[Shape]) -> (Vec, Vec) { - build(shapes, DEFAULT_PRECISION).unwrap_or_default() -} - -/// [`live_paint`] with Gap Detection: the faces are those of `shapes` with a line across each gap -/// [`find_gaps`] finds up to `max_gap`, so paint stops at the gaps, and the edges are those of -/// `shapes` alone (the lines add none). Also returns the gaps. -pub fn live_paint_with_gaps(shapes: &[Shape], max_gap: f64) -> (Vec, Vec, Vec<[Point; 2]>) { - let gaps = find_gaps(shapes, max_gap); - if gaps.is_empty() { - let (faces, edges) = live_paint(shapes); - return (faces, edges, gaps); - } - let mut closed = shapes.to_vec(); - let lines = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect()); - closed.push(Shape::new(lines, FillRule::NonZero, u64::MAX)); - let (faces, _) = live_paint(&closed); - let (_, edges) = live_paint(shapes); - (faces, edges, gaps) -} - -/// How many times longer than a gap the way between its two ends along the paths must be for it to -/// count: a loose end that crosses a path just before it (an overshoot), or a path that curls back -/// close to its own end (a hook), leaves no gap there. -const GAP_DETOUR: f64 = 8.0; - -/// How exactly [`find_gaps`] measures lengths along the paths. -const GAP_ARC_ACCURACY: f64 = 1e-4; - -/// How many points along a loose end's own segment [`find_gaps`] tries. -const GAP_SAMPLES: u32 = 64; - -/// One subpath's segments for [`find_gaps`], with the length along it where each starts. -struct Track { - segs: Vec, - /// The length at the start of each segment, then the whole length. - at: Vec, - closed: bool, - bounds: kurbo::Rect, -} - -impl Track { - fn new(sp: &SubPath) -> Option { - if sp.anchors.len() < 2 || !sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { - return None; - } - let mut bp = BezPath::new(); - sp.to_bezpath_into(&mut bp); - let segs: Vec = bp.segments().collect(); - let mut at = Vec::with_capacity(segs.len() + 1); - let mut len = 0.0; - for s in &segs { - at.push(len); - len += s.arclen(GAP_ARC_ACCURACY); - } - at.push(len); - (!segs.is_empty()).then(|| Self { bounds: bp.bounding_box(), segs, at, closed: sp.closed }) - } - - fn len(&self) -> f64 { - self.at.last().copied().unwrap_or(0.0) - } - - /// The length along the subpath to parameter `t` of segment `k`. - fn pos(&self, k: usize, t: f64) -> f64 { - let Some(s) = self.segs.get(k) else { return self.len() }; - self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..t).arclen(GAP_ARC_ACCURACY) - } - - /// The length along the subpath between two places on it (the shorter way round a closed one). - fn along(&self, a: f64, b: f64) -> f64 { - let d = (a - b).abs(); - if self.closed { d.min(self.len() - d) } else { d } - } -} - -/// Do two boxes (lines' boxes included, which have no area) touch or overlap? -fn boxes_meet(a: kurbo::Rect, b: kurbo::Rect) -> bool { - a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 -} - -/// Where track `o`, within `reach` along it of `from`, crosses the segments `near` of track `q`: -/// (length along `o`, length along `q`) of each crossing. -fn crossings(o: &Track, from: f64, reach: f64, q: &Track, near: impl Iterator) -> Vec<(f64, f64)> { - let near: Vec<(usize, &PathSeg)> = near.filter_map(|j| Some((j, q.segs.get(j)?))).collect(); - let mut out = vec![]; - // `first` is the first segment that ends within reach. - let first = o.at.partition_point(|&a| a < from - reach).saturating_sub(1); - for (k, s) in o.segs.iter().enumerate().skip(first) { - let (Some(&a0), Some(&a1)) = (o.at.get(k), o.at.get(k + 1)) else { continue }; - if a0 > from + reach { - break; - } - if a1 < from - reach || !boxes_meet(s.bounding_box(), q.bounds) { - continue; - } - // A curve is tried as 16 chords. - let n = if matches!(s, PathSeg::Line(_)) { 1 } else { 16 }; - let mut prev = s.start(); - for i in 1..=n { - let (t0, t1) = (f64::from(i - 1) / f64::from(n), f64::from(i) / f64::from(n)); - let p = s.eval(t1); - let chord = kurbo::Line::new(prev, p); - let cb = chord.bounding_box(); - for &(j, qs) in &near { - if !boxes_meet(qs.bounding_box(), cb) { - continue; - } - for x in qs.intersect_line(chord) { - out.push((o.pos(k, t0 + x.line_t * (t1 - t0)), q.pos(j, x.segment_t))); - } - } - prev = p; - } - } - out -} - -/// Most grid cells one segment is listed in by [`SegGrid`]; a longer one is tried for every point. -const GAP_GRID_SPAN: i64 = 1024; - -/// The farthest cell from the grid's origin, on each axis, that [`SegGrid`] numbers a point in. -const GAP_GRID_LIMIT: f64 = 1_048_576.0; - -/// The segments of [`find_gaps`]' tracks by the cells of a square grid that their bounds cover. -struct SegGrid { - origin: Point, - cell: f64, - /// (track, segment) by cell. - cells: HashMap<(i64, i64), Vec<(usize, usize)>>, - /// Segments whose bounds cover more than [`GAP_GRID_SPAN`] cells, near every point. - wide: Vec<(usize, usize)>, -} - -impl SegGrid { - fn new(tracks: &[Track], origin: Point, cell: f64) -> Self { - let mut grid = Self { origin, cell, cells: HashMap::new(), wide: vec![] }; - for (j, t) in tracks.iter().enumerate() { - for (k, s) in t.segs.iter().enumerate() { - let b = s.bounding_box(); - let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1))); - if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > GAP_GRID_SPAN { - grid.wide.push((j, k)); - continue; - } - for x in x0..=x1 { - for y in y0..=y1 { - grid.cells.entry((x, y)).or_default().push((j, k)); - } - } - } - } - grid - } - - /// The cell `p` is in, counted from the origin (at most [`GAP_GRID_LIMIT`] cells away on each - /// axis). - fn cell_of(&self, p: Point) -> (i64, i64) { - let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-GAP_GRID_LIMIT, GAP_GRID_LIMIT) as i64; - (at(p.x, self.origin.x), at(p.y, self.origin.y)) - } - - /// The segments whose bounds come within `r` of `p` (and some farther ones), each once, in - /// track order. - fn near(&self, p: Point, r: f64) -> Vec<(usize, usize)> { - let mut out = self.wide.clone(); - let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(p.x - r, p.y - r)), self.cell_of(Point::new(p.x + r, p.y + r))); - for x in x0..=x1 { - for y in y0..=y1 { - out.extend(self.cells.get(&(x, y)).into_iter().flatten()); - } - } - out.sort_unstable(); - out.dedup(); - out - } -} - -/// The point a gap from the loose end `e` of track `own` (at `from` along it) closes to: the -/// nearest point of any path within `max_gap` of it, leaving out points the paths already join to -/// `e` along a way less than [`GAP_DETOUR`] times as long as the gap. None when there is no such -/// point, or when `e` lies on a path (it is no loose end). -fn gap_from(tracks: &[Track], grid: &SegGrid, own: usize, e: Point, from: f64, max_gap: f64, eps: f64) -> Option { - use kurbo::ParamCurveNearest; - let o = tracks.get(own)?; - let reach = GAP_DETOUR * max_gap; - let mut best: Option<(f64, Point)> = None; - // `around` holds the segments a way of up to `reach` from `e` can cross, by track (grouped - // once: filtering the whole list again for each track near `e` made clusters of parallel - // paths cubic), and `crossed` the crossings with each track. - let mut around: Option>> = None; - let mut crossed: HashMap> = HashMap::new(); - for (j, k) in grid.near(e, max_gap) { - let Some(q) = tracks.get(j) else { continue }; - let Some(s) = q.segs.get(k) else { continue }; - let b = s.bounding_box(); - if e.x < b.x0 - max_gap || e.x > b.x1 + max_gap || e.y < b.y0 - max_gap || e.y > b.y1 + max_gap { - continue; - } - let near = s.nearest(e, 1e-9); - let d = near.distance_sq.sqrt(); - if d > max_gap { - continue; - } - let at = q.pos(k, near.t); - let way = if j == own { - o.along(from, at) - } else { - let xs = crossed.entry(j).or_insert_with(|| { - let by_track = around.get_or_insert_with(|| { - let mut by_track: HashMap> = HashMap::new(); - for (t, k) in grid.near(e, reach + max_gap) { - by_track.entry(t).or_default().push(k); - } - by_track - }); - crossings(o, from, reach, q, by_track.get(&j).into_iter().flatten().copied()) - }); - xs.iter().map(|&(a, b)| o.along(from, a) + q.along(b, at)).fold(f64::INFINITY, f64::min) - }; - if d <= eps { - // On another path, or on its own away from the end: the end is joined there. - if j != own || way > 4.0 * eps.max(GAP_ARC_ACCURACY) { - return None; - } - // The end's own segment: a curve that comes back round can still pass near it. - for i in 0..=GAP_SAMPLES { - let t = f64::from(i) / f64::from(GAP_SAMPLES); - let p = s.eval(t); - let d = p.distance(e); - if d > eps && d <= max_gap && o.along(from, q.pos(k, t)) > GAP_DETOUR * d && best.is_none_or(|b| d < b.0) { - best = Some((d, p)); - } - } - continue; - } - if way > GAP_DETOUR * d && best.is_none_or(|b| d < b.0) { - best = Some((d, s.eval(near.t))); - } - } - best.map(|b| b.1) -} - -/// The gaps Live Paint's Gap Detection closes in `shapes`: from each loose end of an open subpath -/// (an end that lies on no path) to the nearest point of any path, its own included, at most -/// `max_gap` away, unless the paths already join the two along a way less than [`GAP_DETOUR`] times -/// the gap's length. Each gap is a pair of points: the loose end, then where it closes to. A gap -/// between two loose ends that close to each other is listed once. -pub fn find_gaps(shapes: &[Shape], max_gap: f64) -> Vec<[Point; 2]> { - if !(max_gap.is_finite() && max_gap > 0.0) { - return vec![]; - } - let tracks: Vec = shapes.iter().flat_map(|s| &s.path.subpaths).filter_map(Track::new).collect(); - let eps = (max_gap * 1e-4).max(1e-6); - // The grid cells are as wide as the gap, or 1/4096 of the art where that is wider. - let bounds = tracks.iter().map(|t| t.bounds).reduce(|a, b| a.union(b)).unwrap_or_default(); - let grid = SegGrid::new(&tracks, bounds.origin(), max_gap.max(bounds.width().max(bounds.height()) / 4096.0)); - let mut gaps: Vec<[Point; 2]> = vec![]; - // `by_end` lists the gaps by the cell of their loose end. - let mut by_end: HashMap<(i64, i64), Vec> = HashMap::new(); - for (i, o) in tracks.iter().enumerate() { - let (Some(first), Some(last)) = (o.segs.first(), o.segs.last()) else { continue }; - // An open subpath that ends where it starts encloses its area already. - if o.closed || first.start().distance(last.end()) <= eps { - continue; - } - for (e, from) in [(first.start(), 0.0), (last.end(), o.len())] { - let Some(to) = gap_from(&tracks, &grid, i, e, from, max_gap, eps) else { continue }; - let (cx, cy) = grid.cell_of(to); - let listed = (cx - 1..=cx + 1) - .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) - .filter_map(|c| by_end.get(&c)) - .flatten() - .filter_map(|&g| gaps.get(g)) - .any(|g| g[0].distance(to) <= eps && g[1].distance(e) <= eps); - if !listed { - by_end.entry(grid.cell_of(e)).or_default().push(gaps.len()); - gaps.push([e, to]); - } - } - } - gaps -} - -/// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed -/// paths enclose, outline edges); past this only the filled areas count. The map costs about what -/// the filled areas' arrangement does (both near linear in the input; the cap was 4096 while -/// finding each edge's source scanned every segment, #937), so this only bounds the time a hover -/// can take on art far past what a drawing holds. -pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 65_536; - -/// The Shape Builder's arrangement: see [`shape_builder`]. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct BuilderArrangement { - pub regions: Vec, - /// The pieces of the paths with no closed subpath, keyed by input index. - pub lines: Vec, - /// The pieces of the other paths' outlines, keyed by input index (the front-most where inputs - /// share one); only when asked for. - pub edges: Vec, -} - -/// Does any of `shapes` have an open subpath (a cutting edge for the Shape Builder)? -pub fn has_open_path(shapes: &[Shape]) -> bool { - shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2) -} - -/// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with -/// `edges`, the pieces of their outlines (what erasing an edge deletes). -/// -/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions), and the -/// gaps they enclose that nothing fills are regions too (empty `sources`), such as the middle of -/// four circles that touch (#1691). Open paths (the caller closes the ones whose fill should count) -/// are cutting edges: a line across a shape splits it, lines around an area enclose a region -/// nothing fills, and each path with no closed subpath comes back cut wherever another path meets -/// it, as pieces keyed by its index in `shapes`. Outlines are cut the same way. With more than -/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas -/// count and there are no pieces. -pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement { - shape_builder_with_gap(shapes, edges, 0.0) -} - -/// Most distance (document units) a Shape Builder gap may span. -pub const SHAPE_BUILDER_MAX_GAP: f64 = 72.0; - -/// Original inputs and the shared detector's temporary, unfilled bridges. Keep the originals' -/// order and keys so only their split pieces can be returned as art. -pub(crate) fn gap_inputs(shapes: &[Shape], gap: f64) -> Vec { - let mut closed = shapes.to_vec(); - let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); - if segments <= SHAPE_BUILDER_MAX_SEGMENTS && gap.is_finite() && gap > 0.0 && has_open_path(shapes) { - let gaps = find_gaps(shapes, gap.min(SHAPE_BUILDER_MAX_GAP)); - if !gaps.is_empty() { - let path = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect()); - closed.push(Shape::new(path, FillRule::NonZero, u64::MAX)); - } - } - closed -} - -/// [`shape_builder`] with Gap Detection: open paths whose ends come within `gap` (document units) -/// of another path are bridged for the regions they bound, so a region left open by a small gap is -/// found. A `gap` of 0 is [`shape_builder`]. The bridges are in no region's sources and in no piece -/// of `lines` or `edges`. -pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> BuilderArrangement { - builder(shapes, edges, gap, DEFAULT_PRECISION) -} - -/// [`shape_builder_with_gap`] refitting its results to `precision` (Pathfinder Options › Precision). -pub(crate) fn builder(shapes: &[Shape], edges: bool, gap: f64, precision: f64) -> BuilderArrangement { - let has_open = has_open_path(shapes); - let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); - let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS) - .then(|| { - let keyed: Vec = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect(); - if has_open && gap.is_finite() && gap > 0.0 { - let (faces, pieces) = build(&gap_inputs(&keyed, gap), precision).unwrap_or_default(); - // Gap targets split original outlines too, but the bridges themselves aren't art. - (faces, pieces.into_iter().filter(|p| p.key < shapes.len() as u64).collect()) - } else { - build(&keyed, precision).unwrap_or_default() - } - }) - .filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty()); - let Some((faces, pieces)) = planar else { - return BuilderArrangement { regions: filled_regions(shapes, precision), ..Default::default() }; - }; - let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed)); - let (lines, outline): (Vec, Vec) = pieces.into_iter().partition(|e| is_line(e.key)); - let regions = if has_open { - faces - } else { - // The filled areas from their arrangement, whose faces merge exactly, then the gaps. - let mut regions = crate::pathfinder::regions_with(shapes, precision); - regions.extend(faces.into_iter().filter(|f| f.sources.is_empty())); - regions - }; - BuilderArrangement { regions, lines: if has_open { lines } else { Vec::new() }, edges: if edges { outline } else { Vec::new() } } -} - -/// The regions of the shapes' filled areas. A shape that fills nothing (open paths that enclose no -/// area, such as cutting lines) is left out, so a flood of them cannot push the arrangement past its -/// size budget and take the filled shapes down with it; the regions keep the original indices. -fn filled_regions(shapes: &[Shape], precision: f64) -> Vec { - let fills = |s: &Shape| s.path.subpaths.iter().any(|sp| sp.closed || encloses_area(sp)); - let keep: Vec = (0..shapes.len()).filter(|&i| shapes.get(i).is_some_and(fills)).collect(); - if keep.len() == shapes.len() { - return crate::pathfinder::regions_with(shapes, precision); - } - let kept: Vec = keep.iter().filter_map(|&i| shapes.get(i).cloned()).collect(); - crate::pathfinder::regions_with(&kept, precision) - .into_iter() - .map(|mut r| { - r.sources = r.sources.iter().filter_map(|&s| keep.get(s).copied()).collect(); - r - }) - .collect() -} - -/// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the -/// open subpaths of filled paths that do. -pub fn encloses_area(sp: &SubPath) -> bool { - let mut closed = sp.clone(); - closed.closed = true; - let mut bp = BezPath::new(); - closed.to_bezpath_into(&mut bp); - let size = bp.bounding_box().size(); - bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height) -} - -/// `path` without `piece`, a run along one of its subpaths between two points (a Shape Builder -/// edge): a closed subpath opens there, an open one splits in two (or loses an end), and a piece -/// that runs all the way round takes its subpath. With `fill_closes`, open subpaths that enclose an -/// area ([`encloses_area`]) run on round an invisible closing edge, as the Shape Builder sees a -/// filled path: a piece may run over it, and it never comes back as a visible segment. None when -/// the piece doesn't lie on `path` within `tol`. -pub fn cut_out(path: &PathData, piece: &SubPath, fill_closes: bool, tol: f64) -> Option { - let n = piece.segment_count(); - if n == 0 { - return None; - } - // A point well inside the piece: the middle of its longest segment. - let arclen = |k: usize| kurbo::ParamCurveArclen::arclen(&piece.segment(k), 1e-3); - let mid = (0..n).map(|k| (arclen(k), k)).max_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, k)| piece.segment(k).eval(0.5))?; - let (a, b) = (piece.anchors.first()?.p, piece.anchors.last()?.p); - let mut bp = BezPath::new(); - piece.to_bezpath_into(&mut bp); - if bp.perimeter(1e-6) <= tol { - // A speck (where paths touch): there's nothing to cut. - return None; - } - // A piece that ends where it starts runs all the way round. - let whole = piece.closed || a.distance(b) <= tol; - // Each subpath as the piece may run along it: round its closing edge when it has one. - let ring = |sp: &SubPath| SubPath { anchors: sp.anchors.clone(), closed: sp.closed || (fill_closes && encloses_area(sp)) }; - let (si, geom, um) = path - .subpaths - .iter() - .enumerate() - .filter_map(|(i, sp)| { - let g = ring(sp); - let (u, d) = nearest_u(&g, mid)?; - (d <= tol).then_some((i, g, u, d)) - }) - .min_by(|x, y| x.3.total_cmp(&y.3)) - .map(|(i, g, u, _)| (i, g, u))?; - let len = geom.segment_count() as f64; - let real = path.subpaths.get(si)?.segment_count() as f64; - let at = |p: Point| nearest_u(&geom, p).filter(|(_, d)| *d <= tol).map(|(u, _)| u); - // The parameter spans of `geom` that stay (unrolled past the end of a ring). - let keep: Vec<(f64, f64)> = if whole { - Vec::new() - } else if geom.closed { - let (ua, ub) = (at(a)?, at(b)?); - let fwd = |from: f64, to: f64| (to - from).rem_euclid(len); - // The piece runs from one end through `mid` to the other; what stays runs on round. - let (start, end) = if fwd(ua, um) <= fwd(ua, ub) { (ub, ua) } else { (ua, ub) }; - let rest = fwd(start, end); - if rest <= 1e-9 || len - rest <= 1e-9 { - Vec::new() - } else { - // Where the ring closes with an invisible edge (`real..len`), what stays stops at it. - let parts: &[(f64, f64)] = if real >= len { &[(0.0, 2.0 * len)] } else { &[(0.0, real), (len, len + real)] }; - parts.iter().map(|&(lo, hi)| (start.max(lo), (start + rest).min(hi))).filter(|(x, y)| y - x > 1e-9).collect() - } - } else { - let (ua, ub) = (at(a)?, at(b)?); - let (lo, hi) = (ua.min(ub), ua.max(ub)); - if !(lo - 1e-9..=hi + 1e-9).contains(&um) { - return None; - } - [(0.0, lo), (hi, len)].into_iter().filter(|(x, y)| y - x > 1e-9).collect() - }; - let mut subpaths: Vec = path.subpaths.iter().take(si).cloned().collect(); - subpaths.extend(keep.into_iter().filter_map(|(u0, u1)| span(&geom, u0, u1)).map(|sp| snap_ends(sp, [a, b], tol))); - subpaths.extend(path.subpaths.iter().skip(si + 1).cloned()); - Some(PathData::new(subpaths)) -} - -/// The run of `piece` (a Shape Builder edge or line piece) around the point of it nearest `at`, -/// from the nearest of `stops` before that point to the nearest after it, open. Stops are the -/// points within `tol` of the piece, such as the anchors of the path it lies on; an open piece's -/// own ends bound the run where no stop comes first. None when the piece has no segments, or when -/// it is closed and fewer than two different stops lie on it (the run would be all of it). -pub fn run_around(piece: &SubPath, stops: &[Point], at: Point, tol: f64) -> Option { - let n = piece.segment_count(); - if n == 0 { - return None; - } - let len = n as f64; - let (u, _) = nearest_u(piece, at)?; - // The stops by x, so that each segment measures only the stops within its bounds. - let mut by_x: Vec = stops.iter().copied().filter(|s| s.x.is_finite() && s.y.is_finite()).collect(); - by_x.sort_by(|a, b| a.x.total_cmp(&b.x)); - // The parameters of the stops within `tol` of segment `j` whose nearest point on the whole - // piece lies on it (a ring's segments repeat past either end, `len` apart). - let on = |j: isize| -> Vec { - use kurbo::ParamCurveNearest; - let seg = piece.segment(j.rem_euclid(n as isize) as usize); - let b = kurbo::Shape::bounding_box(&seg).inflate(tol, tol); - let near = by_x.get(by_x.partition_point(|s| s.x < b.x0)..).unwrap_or_default(); - near.iter() - .take_while(|s| s.x <= b.x1) - .filter(|s| s.y >= b.y0 && s.y <= b.y1) - .filter_map(|s| { - let m = seg.nearest(*s, 1e-9); - let d = m.distance_sq.sqrt(); - (d <= tol && nearest_u(piece, *s).is_some_and(|(_, nearest)| d <= nearest + 1e-9)).then_some(j as f64 + m.t) - }) - .collect() - }; - // From the segment under `at`, the walk goes back to the nearest stop at or before it and on - // to the nearest stop after it; an open piece's ends bound it. - let k0 = (u.floor().max(0.0) as isize).min(n as isize - 1); - let (first, last) = if piece.closed { (k0 - n as isize, k0 + n as isize) } else { (0, n as isize - 1) }; - let lo = (first..=k0).rev().find_map(|j| on(j).into_iter().filter(|&x| x <= u).max_by(f64::total_cmp)); - let lo = match lo { - Some(lo) => lo, - None if !piece.closed => 0.0, - None => return None, - }; - let after = u.max(lo + 1e-9); - let hi = (k0..=last).find_map(|j| on(j).into_iter().filter(|&x| x > after).min_by(f64::total_cmp)); - let hi = match hi { - Some(hi) => hi, - None if !piece.closed => len, - None => return None, - }; - // On a ring, a lone stop would make the run all of it. - if piece.closed && hi - lo >= len - 1e-9 { - return None; - } - // A run that passes the ring's start begins before its end and is unrolled past it. - let (lo, hi) = if lo < 0.0 { (lo + len, hi + len) } else { (lo, hi) }; - let run = span(piece, lo, hi)?; - // The run's ends go exactly on the stops they end at. - let stop_at = - |q: Point| stops.iter().copied().filter(|s| s.distance(q) <= tol).min_by(|a, b| a.distance(q).total_cmp(&b.distance(q))).unwrap_or(q); - let ends = [run.anchors.first()?.p, run.anchors.last()?.p].map(stop_at); - Some(snap_ends(run, ends, tol)) -} - -/// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance. -fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> { - use kurbo::ParamCurveNearest; - (0..sp.segment_count()) - .map(|k| { - let n = sp.segment(k).nearest(p, 1e-9); - (k as f64 + n.t, n.distance_sq.sqrt()) - }) - .min_by(|x, y| x.1.total_cmp(&y.1)) -} - -/// `sp` with its end anchors within `tol` of one of `to` put exactly on it (where the planar map -/// put the junction a piece ends at), handles moving with them. -fn snap_ends(mut sp: SubPath, to: [Point; 2], tol: f64) -> SubPath { - let last = sp.anchors.len().saturating_sub(1); - for i in [0, last] { - if let Some(an) = sp.anchors.get_mut(i) - && let Some(q) = to.iter().find(|q| q.distance(an.p) <= tol) - { - let d = *q - an.p; - an.p = *q; - an.h_in += d; - an.h_out += d; - } - } - sp -} - -/// The run of `sp` from parameter `u0` to `u1` (`u0 < u1`, unrolled past the end of a closed one), -/// open. -fn span(sp: &SubPath, u0: f64, u1: f64) -> Option { - let n = sp.segment_count(); - if n == 0 || !(u0.is_finite() && u1.is_finite()) { - return None; - } - let first = u0.floor().max(0.0) as usize; - let last = (u1.ceil().max(0.0) as usize).min(first + 2 * n); - let segs: Vec = (first..last) - .filter_map(|k| { - let (t0, t1) = ((u0 - k as f64).clamp(0.0, 1.0), (u1 - k as f64).clamp(0.0, 1.0)); - (t1 - t0 > 1e-12).then(|| Seg { c: sp.segment(k % n).subsegment(t0..t1), line: sp.segment_is_line(k % n) }) - }) - .collect(); - segs_to_subpath(&segs, false) -} - -/// What is left of input `shape` when the faces that hold `merged` merge on their own, with the -/// inputs that cover them marked in `cover` ([`Map::merge`]). -pub(crate) struct Rest { - pub(crate) shape: usize, - pub(crate) merged: Vec, - pub(crate) cover: Vec, -} - -/// The planar graph of `shapes` (in the sweep's frame) with its faces walked: what [`build`] makes -/// Live Paint faces and edges from, and what [`Map::merge`] merges faces in. -pub(crate) struct Map { - edges: Vec, - around: Vec>, - slot_of: Vec, - /// Bounded faces: their cycle of half-edges, path and area; and, by their bounds, which can - /// hold a point, so finding the face around one tests only those. - faces: Vec<(Vec, BezPath, f64)>, - face_index: RectIndex, - /// Bounded faces left out as slivers ([`is_sliver`]): their cycle of half-edges and the inputs - /// whose fill covers them (none for a gap, such as a thin cutout). - slivers: Vec<(Vec, Vec)>, - /// Outer boundaries of connected pieces, and the face each one is a hole in. - holes: Vec<(Vec, BezPath)>, - face_holes: Vec>, - tidy: Tidy, - /// The inputs' anchors (sorted by x) and how close results snap back onto them. - anchors: Vec, - snap: f64, -} - -/// With `cover`, also finds which inputs cover each sliver, which only [`Map::merge`] reads; -/// without it the slivers' lists of inputs stay empty. -pub(crate) fn map(shapes: &[Shape], cover: bool) -> Option { - map_with(shapes, cover, DEFAULT_PRECISION) -} - -/// [`map`] whose faces and pieces are refit to `precision`, and whose slivers thinner than it are dropped. -fn map_with(shapes: &[Shape], cover: bool, precision: f64) -> Option { - let mut inputs: Vec<(BezPath, usize)> = Vec::new(); - for (i, s) in shapes.iter().enumerate() { - let mut bp = BezPath::new(); - for sp in s.path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) { - if sp.closed { - sp.to_bezpath_into(&mut bp); - } else { - let mut open = BezPath::new(); - sp.to_bezpath_into(&mut open); - there_and_back(&open, &mut bp); - } - } - if !bp.elements().is_empty() { - bp.apply_affine(SHEAR); - inputs.push((bp, i)); - } - } - if inputs.is_empty() { - return None; - } - let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?; - snap_horizontals(&mut inputs.iter_mut().map(|p| &mut p.0).collect::>(), eps); - // Counted on the snapped geometry, which is what the sweep sees: snapping can bring boxes into contact. - if !crate::budget::sweep_fits(inputs.iter().map(|p| &p.0)) { - return None; - } - // The sweep can panic on rare near-degenerate input; that must never take the app down. - let sweep = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps))); - let top = sweep.ok()?.ok()?; - let tidy = Tidy::keeping(precision, inputs.iter().map(|p| &p.0)); - - // The planar graph: vertices are the sweep's points, edges its segments. Points closer than - // the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge - // a rounding error away from where it cuts that edge). - let positions = top.compute_positions(); - let cell = 4.0 * eps; - let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); - let mut points: Vec = Vec::new(); - let mut vertex_id = |p: Point| { - let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64); - let near = (cx - 1..=cx + 1) - .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) - .filter_map(|k| grid.get(&k)) - .flatten() - .copied() - .find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps)); - near.unwrap_or_else(|| { - points.push(p); - grid.entry((cx, cy)).or_default().push(points.len() - 1); - points.len() - 1 - }) - }; - let mut pieces: Vec<(Vec, [usize; 2])> = Vec::new(); - for idx in top.segment_indices() { - let segs: Vec = positions[idx].path.segments().map(to_seg).collect(); - let ends = [vertex_id(top.point(idx.first_half()).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())]; - if !segs.is_empty() && ends[0] != ends[1] { - pieces.push((segs, ends)); - } - } - // The sweep can cut the paths through one crossing at points more than `eps` apart, so copies of - // one edge (such as an open path's two passes) can end on different vertices. Pieces whose - // middles and ends lie within `tol` of each other are such copies, and their ends are one vertex. - let tol = 16.0 * eps; - let cell_of = |p: Point| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64); - let mids: Vec> = pieces.iter().map(|(segs, _)| mid(segs)).collect(); - let mut by_mid: HashMap<(i64, i64), Vec> = HashMap::new(); - for (k, m) in mids.iter().enumerate() { - if let Some(m) = m { - by_mid.entry(cell_of(*m)).or_default().push(k); - } - } - let close = |a: usize, b: usize| matches!((points.get(a), points.get(b)), (Some(p), Some(q)) if p.distance(*q) <= tol); - let mut joined: Vec = (0..points.len()).collect(); - // Where two passes of one path cross another (an open path's way there and back, coincident - // outlines), the sweep can also join its two cuts, a rounding error apart, with a stub of that - // length: a speck of a piece, and a vertex twice over. Such stubs are no edges: their ends are - // one vertex. The sweep moves points by a few `eps` at most up and down its frame; along a path - // that runs nearly level in it (where the height a cut is found at pins its position down - // least) a cut can slide further, here up to a small fraction of the precision results are - // tidied to, below which no edge is drawn anyway. A vertex's cluster stays within those bounds - // as a whole: a run of short, nearly level segments (a finely flattened or traced path) is real - // geometry, not one stub after another, and must not collapse into one vertex. - let (stub_y, stub_x) = (4.0 * eps, (DEFAULT_PRECISION / 16.0).max(4.0 * eps)); - let mut extent: Vec = points.iter().map(|&p| kurbo::Rect::from_points(p, p)).collect(); - for (segs, [v0, v1]) in &pieces { - let Some(&p0) = points.get(*v0) else { continue }; - if !segs.iter().flat_map(|s| [s.c.p1, s.c.p2, s.c.p3]).all(|p| (p.y - p0.y).abs() <= stub_y && (p.x - p0.x).abs() <= stub_x) { - continue; - } - let (a, b) = (find(&mut joined, *v0), find(&mut joined, *v1)); - let (Some(&ra), Some(&rb)) = (extent.get(a), extent.get(b)) else { continue }; - let both = ra.union(rb); - if a == b || both.width() > stub_x || both.height() > stub_y { - continue; - } - let (root, child) = (a.min(b), a.max(b)); - if let Some(p) = joined.get_mut(child) { - *p = root; - } - if let Some(r) = extent.get_mut(root) { - *r = both; - } - } - for (k, (_, [a, b])) in pieces.iter().enumerate() { - let Some(&Some(m)) = mids.get(k) else { continue }; - let (cx, cy) = cell_of(m); - let others = (cx - 1..=cx + 1).flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))).filter_map(|c| by_mid.get(&c)).flatten(); - for &j in others.filter(|&&j| j > k) { - let (Some((_, [c, d])), Some(&Some(n))) = (pieces.get(j), mids.get(j)) else { continue }; - if m.distance(n) > tol { - continue; - } - let pairs = if close(*a, *c) && close(*b, *d) { - [(*a, *c), (*b, *d)] - } else if close(*a, *d) && close(*b, *c) { - [(*a, *d), (*b, *c)] - } else { - continue; - }; - for (x, y) in pairs { - let (x, y) = (find(&mut joined, x), find(&mut joined, y)); - if let Some(r) = joined.get_mut(x.max(y)) { - *r = x.min(y); - } - } - } - } - let mut edges: Vec = Vec::new(); - let mut between: HashMap<(usize, usize), Vec> = HashMap::new(); - let outlines = Outlines::new(&inputs, eps); - for (mut segs, ends) in pieces { - let ends = ends.map(|v| find(&mut joined, v)); - let (Some(&p0), Some(&p1)) = (points.get(ends[0]), points.get(ends[1])) else { continue }; - if ends[0] == ends[1] { - continue; - } - // Every edge at a vertex starts or ends exactly on it, so faces meeting there share its - // coordinates: merging them (a union) then cancels their common edge, which it can't when - // each face carries its own rounding of the corner (#893). - pin_ends(&mut segs, p0, p1); - let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue }; - // Coincident pieces the sweep left apart are one edge (the front-most input's). - let key = (ends[0].min(ends[1]), ends[0].max(ends[1])); - let here = mid(&segs); - let twin_of = between.get(&key).and_then(|v| { - v.iter().copied().find(|&e| { - let other = edges.get(e).and_then(|e| mid(&e.segs)); - matches!((here, other), (Some(a), Some(b)) if a.distance(b) < precision) - }) - }); - if let Some(e) = twin_of { - if let Some(e) = edges.get_mut(e) - && source > e.source - { - e.source = source; - } - continue; - } - between.entry(key).or_default().push(edges.len()); - edges.push(Edge { segs, ends, source }); - } - let nv = points.len(); - - // Half-edges leaving each vertex, sorted by direction; walking a face, the next half-edge is - // the one just clockwise of the way back, so bounded faces come out with positive area and - // the outer boundary of each connected piece with negative area. - let mut around: Vec> = vec![Vec::new(); nv]; - for h in 0..2 * edges.len() { - if let Some(list) = origin(&edges, h).and_then(|v| around.get_mut(v)) { - list.push(h); - } - } - let mut slot_of = vec![0usize; 2 * edges.len()]; - for (v, list) in around.iter_mut().enumerate() { - let Some(&at) = points.get(v) else { continue }; - // Half the shortest chord of the edges here: every one of them gets that far from it. - let r = 0.5 * list.iter().filter_map(|&h| far_end(&edges, h).map(|p| p.distance(at))).fold(f64::INFINITY, f64::min); - let mut keyed: Vec<(f64, usize)> = list.iter().map(|&h| (leaving_angle(&edges, h, at, r), h)).collect(); - keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); - *list = keyed.into_iter().map(|(_, h)| h).collect(); - for (k, &h) in list.iter().enumerate() { - if let Some(s) = slot_of.get_mut(h) { - *s = k; - } - } - } - let next = |h: usize| -> Option { - let back = twin(h); - let list = around.get(origin(&edges, back)?)?; - let k = *slot_of.get(back)?; - list.get((k + list.len() - 1) % list.len()).copied() - }; - - // Connected pieces of the graph. - let mut parent: Vec = (0..nv).collect(); - for e in &edges { - let (a, b) = (find(&mut parent, e.ends[0]), find(&mut parent, e.ends[1])); - if let Some(p) = parent.get_mut(a) { - *p = b; - } - } - let piece: Vec = (0..nv).map(|v| find(&mut parent, v)).collect(); - let piece_of = |cycle: &[usize]| cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| piece.get(v)).copied(); - - // Walk every cycle: positive ones are faces, negative ones outer boundaries (holes in the - // face around them, if any). - let mut seen = vec![false; 2 * edges.len()]; - let mut faces: Vec<(Vec, BezPath, f64)> = Vec::new(); - let mut slivers: Vec<(Vec, BezPath)> = Vec::new(); - let mut holes: Vec<(Vec, BezPath)> = Vec::new(); - for start in 0..2 * edges.len() { - if seen.get(start).copied().unwrap_or(true) { - continue; - } - let mut cycle = vec![]; - let mut h = start; - while let Some(s) = seen.get_mut(h).filter(|s| !**s) { - *s = true; - cycle.push(h); - match next(h) { - Some(n) => h = n, - None => break, - } - } - let cycle = without_spurs(&cycle); - if cycle.is_empty() { - continue; - } - let bp = cycle_bezpath(&edges, &cycle); - let area = bp.area(); - if area > 0.0 && !is_sliver(&bp, tidy.precision) { - faces.push((cycle, bp, area)); - } else if area > 0.0 { - slivers.push((cycle, bp)); - } else if area < -(tidy.precision * tidy.precision) { - holes.push((cycle, bp)); - } - } - - // Each hole goes in the smallest face of another piece around it. - let face_index = RectIndex::new(faces.iter().enumerate().map(|(i, (_, bp, _))| (bp.bounding_box(), i)).collect()); - let mut face_holes: Vec> = vec![Vec::new(); faces.len()]; - for (k, (cycle, _)) in holes.iter().enumerate() { - let Some(p) = cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| points.get(v)).copied() else { continue }; - let own = piece_of(cycle); - let around_it = face_index - .at(p, 0.0) - .filter_map(|&i| faces.get(i).map(|f| (i, f))) - .filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.winding(p) != 0) - .min_by(|a, b| a.1.2.total_cmp(&b.1.2)) - .map(|(i, _)| i); - if let Some(list) = around_it.and_then(|i| face_holes.get_mut(i)) { - list.push(k); - } - } - - // The inputs that cover each sliver, tested at a point inside it. - let fills: Vec = if slivers.is_empty() || !cover { Vec::new() } else { shapes.iter().map(Fill::new).collect() }; - let slivers: Vec<(Vec, Vec)> = slivers - .into_iter() - .map(|(cycle, mut bp)| { - if !cover { - return (cycle, Vec::new()); - } - bp.apply_affine(UNSHEAR); - let sources = interior_point(&PathData::from_bezpath(&bp)) - .map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()) - .unwrap_or_default(); - (cycle, sources) - }) - .collect(); - - // The inputs' anchors, which results land on again up to the sweep's tolerance. - let snap = 2.0 * eps; - let mut anchors: Vec = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect(); - anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); - Some(Map { edges, around, slot_of, faces, face_index, slivers, holes, face_holes, tidy, anchors, snap }) -} - -impl Map { - /// The union of the faces that hold `inside` (document points, one per face), traced along the - /// map's own edges: those between two merged faces drop out exactly, where a boolean union of - /// the faces' outlines can leave them in when its sweep rounds their shared corners apart - /// (#893). A sliver next to a merged face that only inputs marked in `cover` fill merges with - /// them, so it doesn't stay as a hairline gap between them. With `rest`, a sliver goes in only - /// when its input covers it, and not when the merge of the faces merged on their own takes it - /// in. Outer outlines come out positive, holes negative. None when a point is in no face. - pub(crate) fn merge(&self, inside: &[Point], cover: &[bool], rest: Option<&Rest>) -> Option { - let halves = 2 * self.edges.len(); - // The face on each half-edge's side; holes bound the face around them. Slivers come after - // the faces. - let mut face_of: Vec> = vec![None; halves]; - for (i, (cycle, ..)) in self.faces.iter().enumerate() { - let hole_cycles = self.face_holes.get(i)?.iter().filter_map(|&k| self.holes.get(k)).map(|(c, _)| c); - for &h in std::iter::once(cycle).chain(hole_cycles).flatten() { - *face_of.get_mut(h)? = Some(i); - } - } - for (k, (cycle, _)) in self.slivers.iter().enumerate() { - for &h in cycle { - *face_of.get_mut(h)? = Some(self.faces.len() + k); - } - } - // The smallest face around a point: faces of a piece inside a hole lie within the face around. - let face_at = |p: Point| { - let q = SHEAR * p; - self.face_index - .at(q, 0.0) - .filter_map(|&i| self.faces.get(i).map(|f| (i, f))) - .filter(|(_, (_, bp, _))| bp.winding(q) != 0) - .min_by(|a, b| a.1.2.total_cmp(&b.1.2)) - .map(|(i, _)| i) - }; - let mut picked = vec![false; self.faces.len() + self.slivers.len()]; - for &p in inside { - *picked.get_mut(face_at(p)?)? = true; - } - let mut other = vec![false; self.faces.len()]; - for i in rest.iter().flat_map(|r| &r.merged).filter_map(|&p| face_at(p)) { - if let Some(o) = other.get_mut(i) { - *o = true; - } - } - let next_to = |cycle: &[usize], marked: &[bool]| { - cycle.iter().any(|&h| face_of.get(twin(h)).copied().flatten().is_some_and(|f| f < self.faces.len() && marked.get(f) == Some(&true))) - }; - for (k, (cycle, sources)) in self.slivers.iter().enumerate() { - let within = |c: &[bool]| !sources.is_empty() && sources.iter().all(|&i| c.get(i) == Some(&true)); - let ours = rest.is_none_or(|r| sources.contains(&r.shape) && !(within(&r.cover) && next_to(cycle, &other))); - if within(cover) && ours && next_to(cycle, &picked) { - *picked.get_mut(self.faces.len() + k)? = true; - } - } - let merged = |h: usize| face_of.get(h).copied().flatten().is_some_and(|f| picked.get(f) == Some(&true)); - let boundary = |h: usize| merged(h) && !merged(twin(h)); - let mut used = vec![false; halves]; - let mut subpaths = Vec::new(); - for start in 0..halves { - if !boundary(start) || used.get(start).copied().unwrap_or(true) { - continue; - } - let mut cycle = vec![]; - let mut h = start; - while !used.get(h).copied().unwrap_or(true) { - if let Some(u) = used.get_mut(h) { - *u = true; - } - cycle.push(h); - // Turn from the way back as a face walk does, past the edges inside the merged - // faces (between two of them, or a line's loose end), to the next boundary edge. - let back = twin(h); - let list = self.around.get(origin(&self.edges, back)?)?; - let k = *self.slot_of.get(back)?; - let n = list.len(); - h = (1..=n).filter_map(|j| list.get((k + n * 2 - j) % n).copied()).find(|&g| boundary(g))?; - } - if h != start { - return None; - } - let segs: Vec = cycle.iter().flat_map(|&h| half_segs(&self.edges, h)).collect(); - subpaths.push(segs_to_subpath(&tidy_segments(segs, true, &self.tidy), true)?); - } - (!subpaths.is_empty()).then(|| unshear(PathData::new(subpaths), &self.anchors, self.snap)) - } -} - -fn build(shapes: &[Shape], precision: f64) -> Option<(Vec, Vec)> { - let m = map_with(shapes, false, precision)?; - let Map { edges, around, faces, holes, face_holes, tidy, anchors, snap, .. } = &m; - let snap = *snap; - let fills: Vec = shapes.iter().map(Fill::new).collect(); - let subpath = |cycle: &[usize]| { - let segs: Vec = cycle.iter().flat_map(|&h| half_segs(edges, h)).collect(); - segs_to_subpath(&tidy_segments(segs, true, tidy), true) - }; - let regions = faces - .iter() - .zip(face_holes) - .filter_map(|((cycle, _, _), hs)| { - let mut subpaths = vec![subpath(cycle)?]; - subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c))); - let path = unshear(PathData::new(subpaths), anchors, snap); - let sources = - // A face finer than the default precision can need a finer outline to find a point in. - interior_point(&path) - .or_else(|| (precision < DEFAULT_PRECISION).then(|| interior_point_within(&path, precision / 4.0)).flatten()) - .map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()) - .unwrap_or_default(); - Some(Region { path, sources }) - }) - .collect(); - - // Edges: chains of segments through vertices where nothing else meets. - let degree: Vec = around.iter().map(Vec::len).collect(); - let passes = |v: usize| -> Option<(usize, usize)> { - let list = around.get(v)?; - let (&a, &b) = (list.first()?, list.get(1)?); - (degree.get(v) == Some(&2) && edges.get(a / 2)?.source == edges.get(b / 2)?.source).then_some((a, b)) - }; - let mut used = vec![false; edges.len()]; - let mut chains: Vec<(Vec, bool, usize)> = Vec::new(); - // A chain from half-edge `h0` up to the next vertex where it doesn't pass straight through; - // closed when it comes back round to where it started. - let walk = |h0: usize, used: &mut Vec| -> Option<(Vec, bool, usize)> { - let source = edges.get(h0 / 2)?.source; - let mut segs = Vec::new(); - let mut h = h0; - loop { - let u = used.get_mut(h / 2)?; - if *u { - return Some((segs, true, source)); - } - *u = true; - segs.extend(half_segs(edges, h)); - let v = origin(edges, twin(h))?; - let Some((a, b)) = passes(v) else { return Some((segs, origin(edges, h0) == Some(v), source)) }; - h = if a == twin(h) { b } else { a }; - } - }; - for v in 0..around.len() { - if passes(v).is_some() { - continue; - } - for &h in around.get(v).map(Vec::as_slice).unwrap_or_default() { - if !used.get(h / 2).copied().unwrap_or(true) - && let Some(c) = walk(h, &mut used) - { - chains.push(c); - } - } - } - // What's left are closed loops with no junction on them. - for e in 0..edges.len() { - if !used.get(e).copied().unwrap_or(true) - && let Some(c) = walk(2 * e, &mut used) - { - chains.push(c); - } - } - let edge_shapes = chains - .into_iter() - .filter_map(|(segs, closed, source)| { - let sp = segs_to_subpath(&tidy_segments(segs, closed, tidy), closed)?; - Some(Shape::new(unshear(PathData::new(vec![sp]), anchors, snap), FillRule::NonZero, shapes.get(source)?.key)) - }) - .collect(); - Some((regions, edge_shapes)) -} - -/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding -/// a Live Paint face after a rebuild). -pub fn interior_point(path: &PathData) -> Option { - let bb = path.bounds()?; - interior_point_within(path, (bb.width().max(bb.height()) * 1e-3).max(1e-4)) -} - -/// [`interior_point`] with the path's curves flattened to within `tolerance`. -fn interior_point_within(path: &PathData, tolerance: f64) -> Option { - let bb = path.bounds()?; - let bp = path.to_bezpath(); - let mut edges: Vec<(Point, Point)> = vec![]; - let (mut first, mut last) = (Point::ZERO, Point::ZERO); - kurbo::flatten(bp.iter(), tolerance, |el| match el { - kurbo::PathEl::MoveTo(p) => { - if last != first { - edges.push((last, first)); - } - first = p; - last = p; - } - kurbo::PathEl::LineTo(p) => { - edges.push((last, p)); - last = p; - } - kurbo::PathEl::ClosePath => { - if last != first { - edges.push((last, first)); - } - last = first; - } - _ => {} - }); - if last != first { - edges.push((last, first)); - } - let mut best: Option<(f64, Point)> = None; - for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] { - // A little off the round fractions, which on art drawn to a grid fall on the height of - // some corner, where testing the point against other paths is unreliable. - let y = bb.y0 + bb.height() * (f + 0.0061803); - let mut xs: Vec = - edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect(); - xs.sort_by(f64::total_cmp); - for w in xs.windows(2) { - let m = Point::new((w[0] + w[1]) / 2.0, y); - let width = w[1] - w[0]; - if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 { - best = Some((width, m)); - } - } - } - best.map(|b| b.1) -} +mod graph; +mod index; +mod live_paint; +mod map; +mod shape_builder; + +pub(crate) use graph::pin_ends; +#[cfg(test)] +use index::{Crossings, RectIndex, reference_budget, segment_footprint}; +pub use live_paint::{find_gaps, live_paint, live_paint_with_gaps}; +pub use map::interior_point; +pub(crate) use map::{Map, Rest, map}; +pub use shape_builder::{ + BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, has_open_path, run_around, shape_builder, + shape_builder_with_gap, +}; +pub(crate) use shape_builder::{builder, gap_inputs}; #[cfg(test)] mod tests; diff --git a/crates/pathops/src/planar/graph.rs b/crates/pathops/src/planar/graph.rs new file mode 100644 index 000000000..a260953f7 --- /dev/null +++ b/crates/pathops/src/planar/graph.rs @@ -0,0 +1,218 @@ +use super::index::{RectIndex, UNSHEAR, segment_footprint}; +use super::*; + +/// The inputs' segments (in the sweep's frame), to tell which inputs an edge lies on. +pub(super) struct Outlines { + segs: RectIndex<(PathSeg, usize)>, + tol: f64, +} + +impl Outlines { + pub(super) fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self { + // A zero-length segment (a point) is the source of no edge, even one running through it. + let segs = inputs + .iter() + .flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), (s, *i)))) + .filter(|(r, ..)| r.width() > 0.0 || r.height() > 0.0) + .collect(); + // The sweep moves points by a few `eps` at most. + Self { segs: RectIndex::with_footprint(segs, |r, (s, _), cell| segment_footprint(r, s, cell)), tol: 16.0 * eps } + } + + /// The front-most input whose outline passes through `p`. + pub(super) fn source_at(&self, p: Point) -> Option { + use kurbo::ParamCurveNearest; + let tol = self.tol; + self.segs.at(p, tol).filter(|(s, _)| s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, i)| *i).max() + } +} + +/// The area an input's fill covers: its closed subpaths (open ones bound no face). +pub(super) struct Fill { + path: BezPath, + bounds: kurbo::Rect, + rule: FillRule, +} + +impl Fill { + pub(super) fn new(s: &Shape) -> Self { + let mut path = BezPath::new(); + for sp in s.path.subpaths.iter().filter(|sp| sp.closed && sp.anchors.len() >= 2) { + sp.to_bezpath_into(&mut path); + } + Self { bounds: path.bounding_box(), path, rule: s.rule } + } + + pub(super) fn contains(&self, p: Point) -> bool { + !self.path.elements().is_empty() && self.bounds.contains(p) && crate::boolean::inside(self.rule, self.path.winding(p)) + } +} + +/// One output segment of the sweep: an edge of the planar graph between two vertices. +pub(super) struct Edge { + pub(super) segs: Vec, + pub(super) ends: [usize; 2], + /// The input it came from (the front-most, where inputs share it). + pub(super) source: usize, +} + +/// `segs` (a chain) moved to start at `a` and end at `b`, the handles next to each moving with it. +pub(crate) fn pin_ends(segs: &mut [Seg], a: Point, b: Point) { + if let Some(s) = segs.first_mut() { + let d = a - s.c.p0; + s.c.p0 = a; + s.c.p1 += d; + } + if let Some(s) = segs.last_mut() { + let d = b - s.c.p3; + s.c.p3 = b; + s.c.p2 += d; + } +} + +/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it. +pub(super) fn twin(h: usize) -> usize { + h ^ 1 +} + +/// The segments of half-edge `h`, in its direction. +pub(super) fn half_segs(edges: &[Edge], h: usize) -> Vec { + half_seg_iter(edges, h).collect() +} + +/// The segments of half-edge `h` in its direction, without copying the edge first (an edge can +/// hold thousands of segments, and the angle it leaves a vertex at needs the first few). +fn half_seg_iter(edges: &[Edge], h: usize) -> impl Iterator + '_ { + let segs = edges.get(h / 2).map(|e| e.segs.as_slice()).unwrap_or_default(); + let forward = h.is_multiple_of(2); + (0..segs.len()).filter_map(move |k| { + let s = segs.get(if forward { k } else { segs.len() - 1 - k })?; + Some(if forward { *s } else { Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line } }) + }) +} + +/// Where half-edge `h` ends (the vertex at the other end of its edge). +pub(super) fn far_end(edges: &[Edge], h: usize) -> Option { + let e = edges.get(h / 2)?; + if h.is_multiple_of(2) { e.segs.last().map(|s| s.c.p3) } else { e.segs.first().map(|s| s.c.p0) } +} + +/// The vertex half-edge `h` leaves from. +pub(super) fn origin(edges: &[Edge], h: usize) -> Option { + edges.get(h / 2).map(|e| e.ends[h % 2]) +} + +/// Where half-edge `h` (leaving `v`) first gets `r` away from it, as an angle around `v`. +/// +/// Edges that only meet at `v` cross a circle around it in the order they leave `v` in, however +/// they start out (tangent curves, or the sweep's short straight stubs at a vertex). +pub(super) fn leaving_angle(edges: &[Edge], h: usize, v: Point, r: f64) -> f64 { + let first = half_seg_iter(edges, h).next(); + let mut prev = first.map_or(v, |s| s.c.p0); + let mut hit: Option = None; + // One segment at a time, stopping at the first that gets r away: an edge can run on for + // thousands of segments (a traced outline between two crossings), all of which flattening + // the whole edge to this tolerance would visit, at every vertex. + for s in half_seg_iter(edges, h) { + let path = [kurbo::PathEl::MoveTo(s.c.p0), kurbo::PathEl::CurveTo(s.c.p1, s.c.p2, s.c.p3)]; + kurbo::flatten(path, (r * 1e-4).max(1e-12), |el| { + if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) { + if p.distance(v) >= r { + // The point on prev → p at distance r (prev is closer). + let (d, f) = (p - prev, prev - v); + let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r); + let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 }; + hit = Some(prev + d * t); + } + prev = p; + } + }); + if hit.is_some() { + break; + } + } + // An edge too short to reach r (it can't, r is at most half of every chord) leaves along its + // tangent. + let far = hit.unwrap_or_else(|| first.map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3))); + (far - v).atan2() +} + +/// Path of a closed run of half-edges, tidied (split pieces of one input curve joined again). +pub(super) fn cycle_bezpath(edges: &[Edge], cycle: &[usize]) -> BezPath { + let mut bp = BezPath::new(); + for (k, s) in cycle.iter().flat_map(|&h| half_segs(edges, h)).enumerate() { + if k == 0 { + bp.move_to(s.c.p0); + } + if s.line { + bp.line_to(s.c.p3); + } else { + bp.curve_to(s.c.p1, s.c.p2, s.c.p3); + } + } + bp.close_path(); + bp +} + +/// `cycle` without the spurs it runs out and back along (edges with the same face on both sides, +/// such as a line's loose end). +pub(super) fn without_spurs(cycle: &[usize]) -> Vec { + let mut out: Vec = Vec::with_capacity(cycle.len()); + for &h in cycle { + if out.last() == Some(&twin(h)) { + out.pop(); + } else { + out.push(h); + } + } + // A spur where the walk started wraps around the ends. + while out.len() >= 2 && out.first().map(|&h| twin(h)) == out.last().copied() { + out.pop(); + out.remove(0); + } + out +} + +/// Append `open` traced there and back (a closed, zero-area path along it) to `out`. +pub(super) fn there_and_back(open: &BezPath, out: &mut BezPath) { + let segs: Vec = open.segments().collect(); + let Some(first) = segs.first() else { return }; + out.move_to(first.start()); + for s in &segs { + out.push(s.as_path_el()); + } + for s in segs.iter().rev() { + out.push(s.reverse().as_path_el()); + } + out.close_path(); +} + +/// `path` back from the sweep's frame, its anchors within `tol` of an input anchor (`sorted` by x) +/// put exactly on it, handles moving with them. +pub(super) fn unshear(mut path: PathData, sorted: &[Point], tol: f64) -> PathData { + path.transform(UNSHEAR); + for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) { + let lo = sorted.partition_point(|q| q.x < a.p.x - tol); + if let Some(q) = sorted.get(lo..).unwrap_or_default().iter().take_while(|q| q.x <= a.p.x + tol).find(|q| (q.y - a.p.y).abs() <= tol) { + let d = *q - a.p; + a.p = *q; + a.h_in += d; + a.h_out += d; + } + } + path +} + +pub(super) fn find(parent: &mut [usize], mut v: usize) -> usize { + while let Some(&p) = parent.get(v) { + if p == v { + break; + } + let gp = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(v) { + *slot = gp; + } + v = p; + } + v +} diff --git a/crates/pathops/src/planar/index.rs b/crates/pathops/src/planar/index.rs new file mode 100644 index 000000000..1b59f829c --- /dev/null +++ b/crates/pathops/src/planar/index.rs @@ -0,0 +1,219 @@ +use super::*; + +/// The sweep's frame: y + x / 32. Only edges sloping -1/32 in the document are horizontal in it. +pub(super) const SHEAR: Affine = Affine::new([1.0, 1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); + +pub(super) const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); + +/// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s +/// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one +/// to each, so its edges never cancel out of the map. Only the slots that aren't 0 are kept, in +/// order: the sweep keeps one of these for every edge, and a vector as long as the input's number +/// (as this was) made disjoint shapes cost the square of their count in memory. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct Crossings(pub(super) Vec<(usize, u32)>); + +impl Crossings { + pub(super) fn get(&self, slot: usize) -> u32 { + self.0.binary_search_by_key(&slot, |(s, _)| *s).map_or(0, |i| self.0.get(i).map_or(0, |(_, c)| *c)) + } +} + +impl std::ops::Add for Crossings { + type Output = Crossings; + fn add(mut self, rhs: Self) -> Crossings { + self += rhs; + self + } +} + +impl std::ops::AddAssign for Crossings { + fn add_assign(&mut self, rhs: Self) { + if rhs.0.is_empty() { + return; + } + let mut out = Vec::with_capacity(self.0.len() + rhs.0.len()); + let (mut a, mut b) = (std::mem::take(&mut self.0).into_iter().peekable(), rhs.0.into_iter().peekable()); + loop { + let next = match (a.peek(), b.peek()) { + (Some(x), Some(y)) if x.0 == y.0 => { + let (x, y) = (a.next(), b.next()); + x.zip(y).map(|(x, y)| (x.0, x.1.saturating_add(y.1))) + } + (Some(x), Some(y)) if x.0 < y.0 => a.next(), + (Some(_), Some(_)) | (None, Some(_)) => b.next(), + (Some(_), None) => a.next(), + (None, None) => break, + }; + out.extend(next.filter(|(_, c)| *c != 0)); + } + self.0 = out; + } +} + +impl WindingNumber for Crossings { + type Tag = usize; + fn single(tag: usize, positive: bool) -> Self { + Crossings(vec![(2 * tag + usize::from(!positive), 1)]) + } + fn of_tag(&self, tag: usize) -> Self { + Crossings([2 * tag, 2 * tag + 1].into_iter().map(|slot| (slot, self.get(slot))).filter(|(_, c)| *c != 0).collect()) + } +} + +/// A point in the middle of a run of segments. +pub(super) fn mid(segs: &[Seg]) -> Option { + segs.get(segs.len() / 2).map(|s| s.c.eval(0.5)) +} + +/// Rectangles looked up by a point inside them, when there are too many to test one by one for +/// every point (the map asks once per edge and once per hole, so a scan of them all made it +/// quadratic in the input, #937). A grid of cells over their bounds, each cell listing the +/// rectangles touching it; a lookup reads the cells around the point. The grid has about one cell +/// per rectangle, so a lookup reads about the rectangles of the point's neighbourhood. A rectangle +/// covering too many cells (a long diagonal's bounds take in most of the art, so an index of the +/// bounds alone scanned a diagonal hatch whole for every point) is filed under the cells of a +/// smaller footprint instead ([`Self::with_footprint`]), or, failing that, scanned for every point. +pub(super) struct RectIndex { + items: Vec<(kurbo::Rect, T)>, + /// The grid's top-left corner, cell size and column count; `cells` lists the items touching + /// each cell, row by row. + origin: Point, + cell: (f64, f64), + cols: usize, + pub(super) cells: Vec>, + /// Items the grid doesn't hold (bounds covering most of it, or not finite), scanned for + /// every point. + pub(super) rest: Vec, +} + +/// The fewest cells one footprint rectangle may always be filed under. The cap is this or two +/// rows or columns of the grid, whichever is more: a thin stripe across the art (one of the faces +/// a hatch cuts a shape into) is filed under the cells along it, while a rectangle covering most +/// of the grid costs more to file and to read than to scan for every point. +const CELLS_PER_FOOTPRINT: usize = 64; + +/// The cell references a grid of `cells` cells holds for `n` items before the remaining items go +/// to the scanned list: a few per item, plus a few rows or columns' worth (a long segment is filed +/// under a cell of each row or column it crosses, about `sqrt(cells)` of them in a square grid). +/// Coincident segments make a grid of one column and a row per segment, where each would be filed +/// under every row: a quadratic number of references, which this keeps to `n·sqrt(n)`. +pub(super) fn reference_budget(n: usize, cells: usize) -> usize { + let across = (cells as f64).sqrt().ceil().min(1e9) as usize; + n.saturating_mul(64 + 4 * across).saturating_add(1024) +} + +/// The cells (columns or rows) an interval `lo..=hi` touches on one axis of a grid: clamped to the +/// grid, so an interval past its edge gets the nearest cell (whose items `contains` then rejects), +/// and `None` for an interval that isn't a number. +fn cell_span(lo: f64, hi: f64, origin: f64, size: f64, count: usize) -> Option> { + let last = count.saturating_sub(1) as f64; + let a = ((lo - origin) / size).floor().clamp(0.0, last); + let b = ((hi - origin) / size).floor().clamp(0.0, last); + (a.is_finite() && b.is_finite()).then(|| (a as usize)..=(b.max(a) as usize)) +} + +impl RectIndex { + pub(super) fn new(items: Vec<(kurbo::Rect, T)>) -> Self { + Self::with_footprint(items, |r, _, _| vec![*r]) + } + + /// `footprint(bounds, item, cell side)` gives the rectangles an item is filed under: a lookup + /// finds the item when its bounds hold the point and one footprint rectangle is within the + /// lookup's tolerance of it. The bounds themselves (`new`) suit items as compact as their + /// bounds; a long diagonal segment is better filed under the bounds of its pieces, about a + /// cell long each, which take in the cells along it rather than every cell its bounds cover. + pub(super) fn with_footprint(items: Vec<(kurbo::Rect, T)>, footprint: impl Fn(&kurbo::Rect, &T, f64) -> Vec) -> Self { + let n = items.len().max(1); + let all = items.iter().map(|(r, _)| *r).filter(kurbo::Rect::is_finite).reduce(|a, b| a.union(b)).unwrap_or(kurbo::Rect::ZERO); + // A cell's side holds one rectangle on average; a grid of collinear rectangles (zero area) + // is divided along its length. + let side = (all.area() / n as f64).sqrt(); + let side = if side > 0.0 { side } else { all.width().max(all.height()) / n as f64 }.max(1e-9); + // Saturating casts: an extent of a billion cells is clamped to the item count, then the + // grid is kept to a few cells per item however lopsided its bounds. + let mut cols = ((all.width() / side).ceil() as usize).clamp(1, 2 * n); + let mut rows = ((all.height() / side).ceil() as usize).clamp(1, 2 * n); + while cols * rows > 4 * n + 4 { + if cols > rows { cols = cols.div_ceil(2) } else { rows = rows.div_ceil(2) } + } + let cell = ((all.width() / cols as f64).max(1e-9), (all.height() / rows as f64).max(1e-9)); + let cap = CELLS_PER_FOOTPRINT.max(2 * cols.max(rows)); + let mut budget = reference_budget(n, cols * rows); + let mut cells: Vec> = vec![Vec::new(); cols * rows]; + let mut rest = Vec::new(); + for (i, (r, t)) in items.iter().enumerate() { + // Cells hold `u32` indices to halve the memory of a long stripe filed under a row of them. + let Ok(index) = u32::try_from(i) else { + rest.push(i); + continue; + }; + let mut scan = !r.is_finite(); + let mut spans = Vec::new(); + for f in if scan { Vec::new() } else { footprint(r, t, cell.0.min(cell.1)) } { + match (cell_span(f.x0, f.x1, all.x0, cell.0, cols), cell_span(f.y0, f.y1, all.y0, cell.1, rows)) { + (Some(xs), Some(ys)) if (xs.end() - xs.start() + 1) * (ys.end() - ys.start() + 1) <= cap => spans.push((xs, ys)), + _ => scan = true, + } + } + // The item's cells, counted before they are filed: past the grid's budget the item, + // and the ones after it, are scanned instead (the index stays bounded in memory). + // Pieces of one segment that fall in the same cell (a degenerate grid's tiny cells) + // count and file it once: the pieces come in order, so the repeats are consecutive. + let mut mine: Vec = if scan { + Vec::new() + } else { + spans.iter().flat_map(|(xs, ys)| ys.clone().flat_map(|y| xs.clone().map(move |x| y * cols + x))).collect() + }; + mine.dedup(); + if !scan && mine.len() > budget { + scan = true; + } + if scan { + rest.push(i); + continue; + } + budget -= mine.len(); + for c in mine { + if let Some(c) = cells.get_mut(c) + && c.last() != Some(&index) + { + c.push(index); + } + } + } + Self { items, origin: all.origin(), cell, cols, cells, rest } + } + + /// The items whose rectangle, grown by `tol`, holds `p` (and, with a footprint, one of whose + /// footprint rectangles is within `tol` of it). + pub(super) fn at(&self, p: Point, tol: f64) -> impl Iterator { + let mut found = self.rest.clone(); + let rows = self.cells.len() / self.cols.max(1); + if let (Some(xs), Some(ys)) = ( + cell_span(p.x - tol, p.x + tol, self.origin.x, self.cell.0, self.cols), + cell_span(p.y - tol, p.y + tol, self.origin.y, self.cell.1, rows), + ) { + for y in ys { + for x in xs.clone() { + found.extend(self.cells.get(y * self.cols + x).into_iter().flatten().map(|&i| i as usize)); + } + } + } + found.sort_unstable(); + found.dedup(); + found.into_iter().filter_map(move |i| self.items.get(i)).filter(move |(r, _)| r.inflate(tol, tol).contains(p)).map(|(_, t)| t) + } +} + +/// A segment's footprint in a [`RectIndex`]: the bounds of its pieces about a cell long, so a long +/// diagonal is filed under the cells along it, not under every cell its bounds cover. +pub(super) fn segment_footprint(bounds: &kurbo::Rect, seg: &PathSeg, cell: f64) -> Vec { + let pieces = (bounds.width().max(bounds.height()) / cell).ceil(); + // A count past the cap gives longer pieces, each filed under more cells. + if pieces.is_nan() || pieces <= 1.0 { + return vec![*bounds]; + } + let pieces = pieces.min(4096.0) as usize; + (0..pieces).map(|k| seg.subsegment(k as f64 / pieces as f64..(k + 1) as f64 / pieces as f64).bounding_box()).collect() +} diff --git a/crates/pathops/src/planar/live_paint.rs b/crates/pathops/src/planar/live_paint.rs new file mode 100644 index 000000000..92c126e57 --- /dev/null +++ b/crates/pathops/src/planar/live_paint.rs @@ -0,0 +1,292 @@ +use super::map::build; +use super::*; + +/// The Live Paint planar map of `shapes` (back → front). +/// +/// Faces are the bounded areas the paths enclose, holes included, each with the inputs whose fill +/// covers it (ascending; empty for an area only enclosed by paths). Edges are the paths split +/// wherever they meet another path, keyed by the input they came from (the front-most where +/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate). +pub fn live_paint(shapes: &[Shape]) -> (Vec, Vec) { + build(shapes, DEFAULT_PRECISION).unwrap_or_default() +} + +/// [`live_paint`] with Gap Detection: the faces are those of `shapes` with a line across each gap +/// [`find_gaps`] finds up to `max_gap`, so paint stops at the gaps, and the edges are those of +/// `shapes` alone (the lines add none). Also returns the gaps. +pub fn live_paint_with_gaps(shapes: &[Shape], max_gap: f64) -> (Vec, Vec, Vec<[Point; 2]>) { + let gaps = find_gaps(shapes, max_gap); + if gaps.is_empty() { + let (faces, edges) = live_paint(shapes); + return (faces, edges, gaps); + } + let mut closed = shapes.to_vec(); + let lines = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect()); + closed.push(Shape::new(lines, FillRule::NonZero, u64::MAX)); + let (faces, _) = live_paint(&closed); + let (_, edges) = live_paint(shapes); + (faces, edges, gaps) +} + +/// How many times longer than a gap the way between its two ends along the paths must be for it to +/// count: a loose end that crosses a path just before it (an overshoot), or a path that curls back +/// close to its own end (a hook), leaves no gap there. +const GAP_DETOUR: f64 = 8.0; + +/// How exactly [`find_gaps`] measures lengths along the paths. +const GAP_ARC_ACCURACY: f64 = 1e-4; + +/// How many points along a loose end's own segment [`find_gaps`] tries. +const GAP_SAMPLES: u32 = 64; + +/// One subpath's segments for [`find_gaps`], with the length along it where each starts. +struct Track { + segs: Vec, + /// The length at the start of each segment, then the whole length. + at: Vec, + closed: bool, + bounds: kurbo::Rect, +} + +impl Track { + fn new(sp: &SubPath) -> Option { + if sp.anchors.len() < 2 || !sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return None; + } + let mut bp = BezPath::new(); + sp.to_bezpath_into(&mut bp); + let segs: Vec = bp.segments().collect(); + let mut at = Vec::with_capacity(segs.len() + 1); + let mut len = 0.0; + for s in &segs { + at.push(len); + len += s.arclen(GAP_ARC_ACCURACY); + } + at.push(len); + (!segs.is_empty()).then(|| Self { bounds: bp.bounding_box(), segs, at, closed: sp.closed }) + } + + fn len(&self) -> f64 { + self.at.last().copied().unwrap_or(0.0) + } + + /// The length along the subpath to parameter `t` of segment `k`. + fn pos(&self, k: usize, t: f64) -> f64 { + let Some(s) = self.segs.get(k) else { return self.len() }; + self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..t).arclen(GAP_ARC_ACCURACY) + } + + /// The length along the subpath between two places on it (the shorter way round a closed one). + fn along(&self, a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + if self.closed { d.min(self.len() - d) } else { d } + } +} + +/// Do two boxes (lines' boxes included, which have no area) touch or overlap? +fn boxes_meet(a: kurbo::Rect, b: kurbo::Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Where track `o`, within `reach` along it of `from`, crosses the segments `near` of track `q`: +/// (length along `o`, length along `q`) of each crossing. +fn crossings(o: &Track, from: f64, reach: f64, q: &Track, near: impl Iterator) -> Vec<(f64, f64)> { + let near: Vec<(usize, &PathSeg)> = near.filter_map(|j| Some((j, q.segs.get(j)?))).collect(); + let mut out = vec![]; + // `first` is the first segment that ends within reach. + let first = o.at.partition_point(|&a| a < from - reach).saturating_sub(1); + for (k, s) in o.segs.iter().enumerate().skip(first) { + let (Some(&a0), Some(&a1)) = (o.at.get(k), o.at.get(k + 1)) else { continue }; + if a0 > from + reach { + break; + } + if a1 < from - reach || !boxes_meet(s.bounding_box(), q.bounds) { + continue; + } + // A curve is tried as 16 chords. + let n = if matches!(s, PathSeg::Line(_)) { 1 } else { 16 }; + let mut prev = s.start(); + for i in 1..=n { + let (t0, t1) = (f64::from(i - 1) / f64::from(n), f64::from(i) / f64::from(n)); + let p = s.eval(t1); + let chord = kurbo::Line::new(prev, p); + let cb = chord.bounding_box(); + for &(j, qs) in &near { + if !boxes_meet(qs.bounding_box(), cb) { + continue; + } + for x in qs.intersect_line(chord) { + out.push((o.pos(k, t0 + x.line_t * (t1 - t0)), q.pos(j, x.segment_t))); + } + } + prev = p; + } + } + out +} + +/// Most grid cells one segment is listed in by [`SegGrid`]; a longer one is tried for every point. +const GAP_GRID_SPAN: i64 = 1024; + +/// The farthest cell from the grid's origin, on each axis, that [`SegGrid`] numbers a point in. +const GAP_GRID_LIMIT: f64 = 1_048_576.0; + +/// The segments of [`find_gaps`]' tracks by the cells of a square grid that their bounds cover. +struct SegGrid { + origin: Point, + cell: f64, + /// (track, segment) by cell. + cells: HashMap<(i64, i64), Vec<(usize, usize)>>, + /// Segments whose bounds cover more than [`GAP_GRID_SPAN`] cells, near every point. + wide: Vec<(usize, usize)>, +} + +impl SegGrid { + fn new(tracks: &[Track], origin: Point, cell: f64) -> Self { + let mut grid = Self { origin, cell, cells: HashMap::new(), wide: vec![] }; + for (j, t) in tracks.iter().enumerate() { + for (k, s) in t.segs.iter().enumerate() { + let b = s.bounding_box(); + let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1))); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > GAP_GRID_SPAN { + grid.wide.push((j, k)); + continue; + } + for x in x0..=x1 { + for y in y0..=y1 { + grid.cells.entry((x, y)).or_default().push((j, k)); + } + } + } + } + grid + } + + /// The cell `p` is in, counted from the origin (at most [`GAP_GRID_LIMIT`] cells away on each + /// axis). + fn cell_of(&self, p: Point) -> (i64, i64) { + let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-GAP_GRID_LIMIT, GAP_GRID_LIMIT) as i64; + (at(p.x, self.origin.x), at(p.y, self.origin.y)) + } + + /// The segments whose bounds come within `r` of `p` (and some farther ones), each once, in + /// track order. + fn near(&self, p: Point, r: f64) -> Vec<(usize, usize)> { + let mut out = self.wide.clone(); + let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(p.x - r, p.y - r)), self.cell_of(Point::new(p.x + r, p.y + r))); + for x in x0..=x1 { + for y in y0..=y1 { + out.extend(self.cells.get(&(x, y)).into_iter().flatten()); + } + } + out.sort_unstable(); + out.dedup(); + out + } +} + +/// The point a gap from the loose end `e` of track `own` (at `from` along it) closes to: the +/// nearest point of any path within `max_gap` of it, leaving out points the paths already join to +/// `e` along a way less than [`GAP_DETOUR`] times as long as the gap. None when there is no such +/// point, or when `e` lies on a path (it is no loose end). +fn gap_from(tracks: &[Track], grid: &SegGrid, own: usize, e: Point, from: f64, max_gap: f64, eps: f64) -> Option { + use kurbo::ParamCurveNearest; + let o = tracks.get(own)?; + let reach = GAP_DETOUR * max_gap; + let mut best: Option<(f64, Point)> = None; + // `around` holds the segments a way of up to `reach` from `e` can cross, by track (grouped + // once: filtering the whole list again for each track near `e` made clusters of parallel + // paths cubic), and `crossed` the crossings with each track. + let mut around: Option>> = None; + let mut crossed: HashMap> = HashMap::new(); + for (j, k) in grid.near(e, max_gap) { + let Some(q) = tracks.get(j) else { continue }; + let Some(s) = q.segs.get(k) else { continue }; + let b = s.bounding_box(); + if e.x < b.x0 - max_gap || e.x > b.x1 + max_gap || e.y < b.y0 - max_gap || e.y > b.y1 + max_gap { + continue; + } + let near = s.nearest(e, 1e-9); + let d = near.distance_sq.sqrt(); + if d > max_gap { + continue; + } + let at = q.pos(k, near.t); + let way = if j == own { + o.along(from, at) + } else { + let xs = crossed.entry(j).or_insert_with(|| { + let by_track = around.get_or_insert_with(|| { + let mut by_track: HashMap> = HashMap::new(); + for (t, k) in grid.near(e, reach + max_gap) { + by_track.entry(t).or_default().push(k); + } + by_track + }); + crossings(o, from, reach, q, by_track.get(&j).into_iter().flatten().copied()) + }); + xs.iter().map(|&(a, b)| o.along(from, a) + q.along(b, at)).fold(f64::INFINITY, f64::min) + }; + if d <= eps { + // On another path, or on its own away from the end: the end is joined there. + if j != own || way > 4.0 * eps.max(GAP_ARC_ACCURACY) { + return None; + } + // The end's own segment: a curve that comes back round can still pass near it. + for i in 0..=GAP_SAMPLES { + let t = f64::from(i) / f64::from(GAP_SAMPLES); + let p = s.eval(t); + let d = p.distance(e); + if d > eps && d <= max_gap && o.along(from, q.pos(k, t)) > GAP_DETOUR * d && best.is_none_or(|b| d < b.0) { + best = Some((d, p)); + } + } + continue; + } + if way > GAP_DETOUR * d && best.is_none_or(|b| d < b.0) { + best = Some((d, s.eval(near.t))); + } + } + best.map(|b| b.1) +} + +/// The gaps Live Paint's Gap Detection closes in `shapes`: from each loose end of an open subpath +/// (an end that lies on no path) to the nearest point of any path, its own included, at most +/// `max_gap` away, unless the paths already join the two along a way less than [`GAP_DETOUR`] times +/// the gap's length. Each gap is a pair of points: the loose end, then where it closes to. A gap +/// between two loose ends that close to each other is listed once. +pub fn find_gaps(shapes: &[Shape], max_gap: f64) -> Vec<[Point; 2]> { + if !(max_gap.is_finite() && max_gap > 0.0) { + return vec![]; + } + let tracks: Vec = shapes.iter().flat_map(|s| &s.path.subpaths).filter_map(Track::new).collect(); + let eps = (max_gap * 1e-4).max(1e-6); + // The grid cells are as wide as the gap, or 1/4096 of the art where that is wider. + let bounds = tracks.iter().map(|t| t.bounds).reduce(|a, b| a.union(b)).unwrap_or_default(); + let grid = SegGrid::new(&tracks, bounds.origin(), max_gap.max(bounds.width().max(bounds.height()) / 4096.0)); + let mut gaps: Vec<[Point; 2]> = vec![]; + // `by_end` lists the gaps by the cell of their loose end. + let mut by_end: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, o) in tracks.iter().enumerate() { + let (Some(first), Some(last)) = (o.segs.first(), o.segs.last()) else { continue }; + // An open subpath that ends where it starts encloses its area already. + if o.closed || first.start().distance(last.end()) <= eps { + continue; + } + for (e, from) in [(first.start(), 0.0), (last.end(), o.len())] { + let Some(to) = gap_from(&tracks, &grid, i, e, from, max_gap, eps) else { continue }; + let (cx, cy) = grid.cell_of(to); + let listed = (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|c| by_end.get(&c)) + .flatten() + .filter_map(|&g| gaps.get(g)) + .any(|g| g[0].distance(to) <= eps && g[1].distance(e) <= eps); + if !listed { + by_end.entry(grid.cell_of(e)).or_default().push(gaps.len()); + gaps.push([e, to]); + } + } + } + gaps +} diff --git a/crates/pathops/src/planar/map.rs b/crates/pathops/src/planar/map.rs new file mode 100644 index 000000000..311f7f889 --- /dev/null +++ b/crates/pathops/src/planar/map.rs @@ -0,0 +1,549 @@ +use super::graph::{ + Edge, Fill, Outlines, cycle_bezpath, far_end, find, half_segs, leaving_angle, origin, pin_ends, there_and_back, twin, unshear, without_spurs, +}; +use super::index::{Crossings, RectIndex, SHEAR, UNSHEAR, mid}; +use super::*; + +/// What is left of input `shape` when the faces that hold `merged` merge on their own, with the +/// inputs that cover them marked in `cover` ([`Map::merge`]). +pub(crate) struct Rest { + pub(crate) shape: usize, + pub(crate) merged: Vec, + pub(crate) cover: Vec, +} + +/// The planar graph of `shapes` (in the sweep's frame) with its faces walked: what [`build`] makes +/// Live Paint faces and edges from, and what [`Map::merge`] merges faces in. +pub(crate) struct Map { + edges: Vec, + around: Vec>, + slot_of: Vec, + /// Bounded faces: their cycle of half-edges, path and area; and, by their bounds, which can + /// hold a point, so finding the face around one tests only those. + faces: Vec<(Vec, BezPath, f64)>, + face_index: RectIndex, + /// Bounded faces left out as slivers ([`is_sliver`]): their cycle of half-edges and the inputs + /// whose fill covers them (none for a gap, such as a thin cutout). + pub(super) slivers: Vec<(Vec, Vec)>, + /// Outer boundaries of connected pieces, and the face each one is a hole in. + holes: Vec<(Vec, BezPath)>, + face_holes: Vec>, + tidy: Tidy, + /// The inputs' anchors (sorted by x) and how close results snap back onto them. + anchors: Vec, + snap: f64, +} + +/// With `cover`, also finds which inputs cover each sliver, which only [`Map::merge`] reads; +/// without it the slivers' lists of inputs stay empty. +pub(crate) fn map(shapes: &[Shape], cover: bool) -> Option { + map_with(shapes, cover, DEFAULT_PRECISION) +} + +/// [`map`] whose faces and pieces are refit to `precision`, and whose slivers thinner than it are dropped. +fn map_with(shapes: &[Shape], cover: bool, precision: f64) -> Option { + let mut inputs: Vec<(BezPath, usize)> = Vec::new(); + for (i, s) in shapes.iter().enumerate() { + let mut bp = BezPath::new(); + for sp in s.path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) { + if sp.closed { + sp.to_bezpath_into(&mut bp); + } else { + let mut open = BezPath::new(); + sp.to_bezpath_into(&mut open); + there_and_back(&open, &mut bp); + } + } + if !bp.elements().is_empty() { + bp.apply_affine(SHEAR); + inputs.push((bp, i)); + } + } + if inputs.is_empty() { + return None; + } + let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?; + snap_horizontals(&mut inputs.iter_mut().map(|p| &mut p.0).collect::>(), eps); + // Counted on the snapped geometry, which is what the sweep sees: snapping can bring boxes into contact. + if !crate::budget::sweep_fits(inputs.iter().map(|p| &p.0)) { + return None; + } + // The sweep can panic on rare near-degenerate input; that must never take the app down. + let sweep = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps))); + let top = sweep.ok()?.ok()?; + let tidy = Tidy::keeping(precision, inputs.iter().map(|p| &p.0)); + + // The planar graph: vertices are the sweep's points, edges its segments. Points closer than + // the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge + // a rounding error away from where it cuts that edge). + let positions = top.compute_positions(); + let cell = 4.0 * eps; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + let mut points: Vec = Vec::new(); + let mut vertex_id = |p: Point| { + let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64); + let near = (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|k| grid.get(&k)) + .flatten() + .copied() + .find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps)); + near.unwrap_or_else(|| { + points.push(p); + grid.entry((cx, cy)).or_default().push(points.len() - 1); + points.len() - 1 + }) + }; + let mut pieces: Vec<(Vec, [usize; 2])> = Vec::new(); + for idx in top.segment_indices() { + let segs: Vec = positions[idx].path.segments().map(to_seg).collect(); + let ends = [vertex_id(top.point(idx.first_half()).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())]; + if !segs.is_empty() && ends[0] != ends[1] { + pieces.push((segs, ends)); + } + } + // The sweep can cut the paths through one crossing at points more than `eps` apart, so copies of + // one edge (such as an open path's two passes) can end on different vertices. Pieces whose + // middles and ends lie within `tol` of each other are such copies, and their ends are one vertex. + let tol = 16.0 * eps; + let cell_of = |p: Point| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64); + let mids: Vec> = pieces.iter().map(|(segs, _)| mid(segs)).collect(); + let mut by_mid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (k, m) in mids.iter().enumerate() { + if let Some(m) = m { + by_mid.entry(cell_of(*m)).or_default().push(k); + } + } + let close = |a: usize, b: usize| matches!((points.get(a), points.get(b)), (Some(p), Some(q)) if p.distance(*q) <= tol); + let mut joined: Vec = (0..points.len()).collect(); + // Where two passes of one path cross another (an open path's way there and back, coincident + // outlines), the sweep can also join its two cuts, a rounding error apart, with a stub of that + // length: a speck of a piece, and a vertex twice over. Such stubs are no edges: their ends are + // one vertex. The sweep moves points by a few `eps` at most up and down its frame; along a path + // that runs nearly level in it (where the height a cut is found at pins its position down + // least) a cut can slide further, here up to a small fraction of the precision results are + // tidied to, below which no edge is drawn anyway. A vertex's cluster stays within those bounds + // as a whole: a run of short, nearly level segments (a finely flattened or traced path) is real + // geometry, not one stub after another, and must not collapse into one vertex. + let (stub_y, stub_x) = (4.0 * eps, (DEFAULT_PRECISION / 16.0).max(4.0 * eps)); + let mut extent: Vec = points.iter().map(|&p| kurbo::Rect::from_points(p, p)).collect(); + for (segs, [v0, v1]) in &pieces { + let Some(&p0) = points.get(*v0) else { continue }; + if !segs.iter().flat_map(|s| [s.c.p1, s.c.p2, s.c.p3]).all(|p| (p.y - p0.y).abs() <= stub_y && (p.x - p0.x).abs() <= stub_x) { + continue; + } + let (a, b) = (find(&mut joined, *v0), find(&mut joined, *v1)); + let (Some(&ra), Some(&rb)) = (extent.get(a), extent.get(b)) else { continue }; + let both = ra.union(rb); + if a == b || both.width() > stub_x || both.height() > stub_y { + continue; + } + let (root, child) = (a.min(b), a.max(b)); + if let Some(p) = joined.get_mut(child) { + *p = root; + } + if let Some(r) = extent.get_mut(root) { + *r = both; + } + } + for (k, (_, [a, b])) in pieces.iter().enumerate() { + let Some(&Some(m)) = mids.get(k) else { continue }; + let (cx, cy) = cell_of(m); + let others = (cx - 1..=cx + 1).flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))).filter_map(|c| by_mid.get(&c)).flatten(); + for &j in others.filter(|&&j| j > k) { + let (Some((_, [c, d])), Some(&Some(n))) = (pieces.get(j), mids.get(j)) else { continue }; + if m.distance(n) > tol { + continue; + } + let pairs = if close(*a, *c) && close(*b, *d) { + [(*a, *c), (*b, *d)] + } else if close(*a, *d) && close(*b, *c) { + [(*a, *d), (*b, *c)] + } else { + continue; + }; + for (x, y) in pairs { + let (x, y) = (find(&mut joined, x), find(&mut joined, y)); + if let Some(r) = joined.get_mut(x.max(y)) { + *r = x.min(y); + } + } + } + } + let mut edges: Vec = Vec::new(); + let mut between: HashMap<(usize, usize), Vec> = HashMap::new(); + let outlines = Outlines::new(&inputs, eps); + for (mut segs, ends) in pieces { + let ends = ends.map(|v| find(&mut joined, v)); + let (Some(&p0), Some(&p1)) = (points.get(ends[0]), points.get(ends[1])) else { continue }; + if ends[0] == ends[1] { + continue; + } + // Every edge at a vertex starts or ends exactly on it, so faces meeting there share its + // coordinates: merging them (a union) then cancels their common edge, which it can't when + // each face carries its own rounding of the corner (#893). + pin_ends(&mut segs, p0, p1); + let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue }; + // Coincident pieces the sweep left apart are one edge (the front-most input's). + let key = (ends[0].min(ends[1]), ends[0].max(ends[1])); + let here = mid(&segs); + let twin_of = between.get(&key).and_then(|v| { + v.iter().copied().find(|&e| { + let other = edges.get(e).and_then(|e| mid(&e.segs)); + matches!((here, other), (Some(a), Some(b)) if a.distance(b) < precision) + }) + }); + if let Some(e) = twin_of { + if let Some(e) = edges.get_mut(e) + && source > e.source + { + e.source = source; + } + continue; + } + between.entry(key).or_default().push(edges.len()); + edges.push(Edge { segs, ends, source }); + } + let nv = points.len(); + + // Half-edges leaving each vertex, sorted by direction; walking a face, the next half-edge is + // the one just clockwise of the way back, so bounded faces come out with positive area and + // the outer boundary of each connected piece with negative area. + let mut around: Vec> = vec![Vec::new(); nv]; + for h in 0..2 * edges.len() { + if let Some(list) = origin(&edges, h).and_then(|v| around.get_mut(v)) { + list.push(h); + } + } + let mut slot_of = vec![0usize; 2 * edges.len()]; + for (v, list) in around.iter_mut().enumerate() { + let Some(&at) = points.get(v) else { continue }; + // Half the shortest chord of the edges here: every one of them gets that far from it. + let r = 0.5 * list.iter().filter_map(|&h| far_end(&edges, h).map(|p| p.distance(at))).fold(f64::INFINITY, f64::min); + let mut keyed: Vec<(f64, usize)> = list.iter().map(|&h| (leaving_angle(&edges, h, at, r), h)).collect(); + keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); + *list = keyed.into_iter().map(|(_, h)| h).collect(); + for (k, &h) in list.iter().enumerate() { + if let Some(s) = slot_of.get_mut(h) { + *s = k; + } + } + } + let next = |h: usize| -> Option { + let back = twin(h); + let list = around.get(origin(&edges, back)?)?; + let k = *slot_of.get(back)?; + list.get((k + list.len() - 1) % list.len()).copied() + }; + + // Connected pieces of the graph. + let mut parent: Vec = (0..nv).collect(); + for e in &edges { + let (a, b) = (find(&mut parent, e.ends[0]), find(&mut parent, e.ends[1])); + if let Some(p) = parent.get_mut(a) { + *p = b; + } + } + let piece: Vec = (0..nv).map(|v| find(&mut parent, v)).collect(); + let piece_of = |cycle: &[usize]| cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| piece.get(v)).copied(); + + // Walk every cycle: positive ones are faces, negative ones outer boundaries (holes in the + // face around them, if any). + let mut seen = vec![false; 2 * edges.len()]; + let mut faces: Vec<(Vec, BezPath, f64)> = Vec::new(); + let mut slivers: Vec<(Vec, BezPath)> = Vec::new(); + let mut holes: Vec<(Vec, BezPath)> = Vec::new(); + for start in 0..2 * edges.len() { + if seen.get(start).copied().unwrap_or(true) { + continue; + } + let mut cycle = vec![]; + let mut h = start; + while let Some(s) = seen.get_mut(h).filter(|s| !**s) { + *s = true; + cycle.push(h); + match next(h) { + Some(n) => h = n, + None => break, + } + } + let cycle = without_spurs(&cycle); + if cycle.is_empty() { + continue; + } + let bp = cycle_bezpath(&edges, &cycle); + let area = bp.area(); + if area > 0.0 && !is_sliver(&bp, tidy.precision) { + faces.push((cycle, bp, area)); + } else if area > 0.0 { + slivers.push((cycle, bp)); + } else if area < -(tidy.precision * tidy.precision) { + holes.push((cycle, bp)); + } + } + + // Each hole goes in the smallest face of another piece around it. + let face_index = RectIndex::new(faces.iter().enumerate().map(|(i, (_, bp, _))| (bp.bounding_box(), i)).collect()); + let mut face_holes: Vec> = vec![Vec::new(); faces.len()]; + for (k, (cycle, _)) in holes.iter().enumerate() { + let Some(p) = cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| points.get(v)).copied() else { continue }; + let own = piece_of(cycle); + let around_it = face_index + .at(p, 0.0) + .filter_map(|&i| faces.get(i).map(|f| (i, f))) + .filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.winding(p) != 0) + .min_by(|a, b| a.1.2.total_cmp(&b.1.2)) + .map(|(i, _)| i); + if let Some(list) = around_it.and_then(|i| face_holes.get_mut(i)) { + list.push(k); + } + } + + // The inputs that cover each sliver, tested at a point inside it. + let fills: Vec = if slivers.is_empty() || !cover { Vec::new() } else { shapes.iter().map(Fill::new).collect() }; + let slivers: Vec<(Vec, Vec)> = slivers + .into_iter() + .map(|(cycle, mut bp)| { + if !cover { + return (cycle, Vec::new()); + } + bp.apply_affine(UNSHEAR); + let sources = interior_point(&PathData::from_bezpath(&bp)) + .map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()) + .unwrap_or_default(); + (cycle, sources) + }) + .collect(); + + // The inputs' anchors, which results land on again up to the sweep's tolerance. + let snap = 2.0 * eps; + let mut anchors: Vec = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect(); + anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + Some(Map { edges, around, slot_of, faces, face_index, slivers, holes, face_holes, tidy, anchors, snap }) +} + +impl Map { + /// The union of the faces that hold `inside` (document points, one per face), traced along the + /// map's own edges: those between two merged faces drop out exactly, where a boolean union of + /// the faces' outlines can leave them in when its sweep rounds their shared corners apart + /// (#893). A sliver next to a merged face that only inputs marked in `cover` fill merges with + /// them, so it doesn't stay as a hairline gap between them. With `rest`, a sliver goes in only + /// when its input covers it, and not when the merge of the faces merged on their own takes it + /// in. Outer outlines come out positive, holes negative. None when a point is in no face. + pub(crate) fn merge(&self, inside: &[Point], cover: &[bool], rest: Option<&Rest>) -> Option { + let halves = 2 * self.edges.len(); + // The face on each half-edge's side; holes bound the face around them. Slivers come after + // the faces. + let mut face_of: Vec> = vec![None; halves]; + for (i, (cycle, ..)) in self.faces.iter().enumerate() { + let hole_cycles = self.face_holes.get(i)?.iter().filter_map(|&k| self.holes.get(k)).map(|(c, _)| c); + for &h in std::iter::once(cycle).chain(hole_cycles).flatten() { + *face_of.get_mut(h)? = Some(i); + } + } + for (k, (cycle, _)) in self.slivers.iter().enumerate() { + for &h in cycle { + *face_of.get_mut(h)? = Some(self.faces.len() + k); + } + } + // The smallest face around a point: faces of a piece inside a hole lie within the face around. + let face_at = |p: Point| { + let q = SHEAR * p; + self.face_index + .at(q, 0.0) + .filter_map(|&i| self.faces.get(i).map(|f| (i, f))) + .filter(|(_, (_, bp, _))| bp.winding(q) != 0) + .min_by(|a, b| a.1.2.total_cmp(&b.1.2)) + .map(|(i, _)| i) + }; + let mut picked = vec![false; self.faces.len() + self.slivers.len()]; + for &p in inside { + *picked.get_mut(face_at(p)?)? = true; + } + let mut other = vec![false; self.faces.len()]; + for i in rest.iter().flat_map(|r| &r.merged).filter_map(|&p| face_at(p)) { + if let Some(o) = other.get_mut(i) { + *o = true; + } + } + let next_to = |cycle: &[usize], marked: &[bool]| { + cycle.iter().any(|&h| face_of.get(twin(h)).copied().flatten().is_some_and(|f| f < self.faces.len() && marked.get(f) == Some(&true))) + }; + for (k, (cycle, sources)) in self.slivers.iter().enumerate() { + let within = |c: &[bool]| !sources.is_empty() && sources.iter().all(|&i| c.get(i) == Some(&true)); + let ours = rest.is_none_or(|r| sources.contains(&r.shape) && !(within(&r.cover) && next_to(cycle, &other))); + if within(cover) && ours && next_to(cycle, &picked) { + *picked.get_mut(self.faces.len() + k)? = true; + } + } + let merged = |h: usize| face_of.get(h).copied().flatten().is_some_and(|f| picked.get(f) == Some(&true)); + let boundary = |h: usize| merged(h) && !merged(twin(h)); + let mut used = vec![false; halves]; + let mut subpaths = Vec::new(); + for start in 0..halves { + if !boundary(start) || used.get(start).copied().unwrap_or(true) { + continue; + } + let mut cycle = vec![]; + let mut h = start; + while !used.get(h).copied().unwrap_or(true) { + if let Some(u) = used.get_mut(h) { + *u = true; + } + cycle.push(h); + // Turn from the way back as a face walk does, past the edges inside the merged + // faces (between two of them, or a line's loose end), to the next boundary edge. + let back = twin(h); + let list = self.around.get(origin(&self.edges, back)?)?; + let k = *self.slot_of.get(back)?; + let n = list.len(); + h = (1..=n).filter_map(|j| list.get((k + n * 2 - j) % n).copied()).find(|&g| boundary(g))?; + } + if h != start { + return None; + } + let segs: Vec = cycle.iter().flat_map(|&h| half_segs(&self.edges, h)).collect(); + subpaths.push(segs_to_subpath(&tidy_segments(segs, true, &self.tidy), true)?); + } + (!subpaths.is_empty()).then(|| unshear(PathData::new(subpaths), &self.anchors, self.snap)) + } +} + +pub(super) fn build(shapes: &[Shape], precision: f64) -> Option<(Vec, Vec)> { + let m = map_with(shapes, false, precision)?; + let Map { edges, around, faces, holes, face_holes, tidy, anchors, snap, .. } = &m; + let snap = *snap; + let fills: Vec = shapes.iter().map(Fill::new).collect(); + let subpath = |cycle: &[usize]| { + let segs: Vec = cycle.iter().flat_map(|&h| half_segs(edges, h)).collect(); + segs_to_subpath(&tidy_segments(segs, true, tidy), true) + }; + let regions = faces + .iter() + .zip(face_holes) + .filter_map(|((cycle, _, _), hs)| { + let mut subpaths = vec![subpath(cycle)?]; + subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c))); + let path = unshear(PathData::new(subpaths), anchors, snap); + let sources = + // A face finer than the default precision can need a finer outline to find a point in. + interior_point(&path) + .or_else(|| (precision < DEFAULT_PRECISION).then(|| interior_point_within(&path, precision / 4.0)).flatten()) + .map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()) + .unwrap_or_default(); + Some(Region { path, sources }) + }) + .collect(); + + // Edges: chains of segments through vertices where nothing else meets. + let degree: Vec = around.iter().map(Vec::len).collect(); + let passes = |v: usize| -> Option<(usize, usize)> { + let list = around.get(v)?; + let (&a, &b) = (list.first()?, list.get(1)?); + (degree.get(v) == Some(&2) && edges.get(a / 2)?.source == edges.get(b / 2)?.source).then_some((a, b)) + }; + let mut used = vec![false; edges.len()]; + let mut chains: Vec<(Vec, bool, usize)> = Vec::new(); + // A chain from half-edge `h0` up to the next vertex where it doesn't pass straight through; + // closed when it comes back round to where it started. + let walk = |h0: usize, used: &mut Vec| -> Option<(Vec, bool, usize)> { + let source = edges.get(h0 / 2)?.source; + let mut segs = Vec::new(); + let mut h = h0; + loop { + let u = used.get_mut(h / 2)?; + if *u { + return Some((segs, true, source)); + } + *u = true; + segs.extend(half_segs(edges, h)); + let v = origin(edges, twin(h))?; + let Some((a, b)) = passes(v) else { return Some((segs, origin(edges, h0) == Some(v), source)) }; + h = if a == twin(h) { b } else { a }; + } + }; + for v in 0..around.len() { + if passes(v).is_some() { + continue; + } + for &h in around.get(v).map(Vec::as_slice).unwrap_or_default() { + if !used.get(h / 2).copied().unwrap_or(true) + && let Some(c) = walk(h, &mut used) + { + chains.push(c); + } + } + } + // What's left are closed loops with no junction on them. + for e in 0..edges.len() { + if !used.get(e).copied().unwrap_or(true) + && let Some(c) = walk(2 * e, &mut used) + { + chains.push(c); + } + } + let edge_shapes = chains + .into_iter() + .filter_map(|(segs, closed, source)| { + let sp = segs_to_subpath(&tidy_segments(segs, closed, tidy), closed)?; + Some(Shape::new(unshear(PathData::new(vec![sp]), anchors, snap), FillRule::NonZero, shapes.get(source)?.key)) + }) + .collect(); + Some((regions, edge_shapes)) +} + +/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding +/// a Live Paint face after a rebuild). +pub fn interior_point(path: &PathData) -> Option { + let bb = path.bounds()?; + interior_point_within(path, (bb.width().max(bb.height()) * 1e-3).max(1e-4)) +} + +/// [`interior_point`] with the path's curves flattened to within `tolerance`. +fn interior_point_within(path: &PathData, tolerance: f64) -> Option { + let bb = path.bounds()?; + let bp = path.to_bezpath(); + let mut edges: Vec<(Point, Point)> = vec![]; + let (mut first, mut last) = (Point::ZERO, Point::ZERO); + kurbo::flatten(bp.iter(), tolerance, |el| match el { + kurbo::PathEl::MoveTo(p) => { + if last != first { + edges.push((last, first)); + } + first = p; + last = p; + } + kurbo::PathEl::LineTo(p) => { + edges.push((last, p)); + last = p; + } + kurbo::PathEl::ClosePath => { + if last != first { + edges.push((last, first)); + } + last = first; + } + _ => {} + }); + if last != first { + edges.push((last, first)); + } + let mut best: Option<(f64, Point)> = None; + for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] { + // A little off the round fractions, which on art drawn to a grid fall on the height of + // some corner, where testing the point against other paths is unreliable. + let y = bb.y0 + bb.height() * (f + 0.0061803); + let mut xs: Vec = + edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect(); + xs.sort_by(f64::total_cmp); + for w in xs.windows(2) { + let m = Point::new((w[0] + w[1]) / 2.0, y); + let width = w[1] - w[0]; + if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 { + best = Some((width, m)); + } + } + } + best.map(|b| b.1) +} diff --git a/crates/pathops/src/planar/shape_builder.rs b/crates/pathops/src/planar/shape_builder.rs new file mode 100644 index 000000000..1ed84be79 --- /dev/null +++ b/crates/pathops/src/planar/shape_builder.rs @@ -0,0 +1,307 @@ +use super::live_paint::find_gaps; +use super::map::build; +use super::*; + +/// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed +/// paths enclose, outline edges); past this only the filled areas count. The map costs about what +/// the filled areas' arrangement does (both near linear in the input; the cap was 4096 while +/// finding each edge's source scanned every segment, #937), so this only bounds the time a hover +/// can take on art far past what a drawing holds. +pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 65_536; + +/// The Shape Builder's arrangement: see [`shape_builder`]. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BuilderArrangement { + pub regions: Vec, + /// The pieces of the paths with no closed subpath, keyed by input index. + pub lines: Vec, + /// The pieces of the other paths' outlines, keyed by input index (the front-most where inputs + /// share one); only when asked for. + pub edges: Vec, +} + +/// Does any of `shapes` have an open subpath (a cutting edge for the Shape Builder)? +pub fn has_open_path(shapes: &[Shape]) -> bool { + shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2) +} + +/// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with +/// `edges`, the pieces of their outlines (what erasing an edge deletes). +/// +/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions), and the +/// gaps they enclose that nothing fills are regions too (empty `sources`), such as the middle of +/// four circles that touch (#1691). Open paths (the caller closes the ones whose fill should count) +/// are cutting edges: a line across a shape splits it, lines around an area enclose a region +/// nothing fills, and each path with no closed subpath comes back cut wherever another path meets +/// it, as pieces keyed by its index in `shapes`. Outlines are cut the same way. With more than +/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas +/// count and there are no pieces. +pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement { + shape_builder_with_gap(shapes, edges, 0.0) +} + +/// Most distance (document units) a Shape Builder gap may span. +pub const SHAPE_BUILDER_MAX_GAP: f64 = 72.0; + +/// Original inputs and the shared detector's temporary, unfilled bridges. Keep the originals' +/// order and keys so only their split pieces can be returned as art. +pub(crate) fn gap_inputs(shapes: &[Shape], gap: f64) -> Vec { + let mut closed = shapes.to_vec(); + let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); + if segments <= SHAPE_BUILDER_MAX_SEGMENTS && gap.is_finite() && gap > 0.0 && has_open_path(shapes) { + let gaps = find_gaps(shapes, gap.min(SHAPE_BUILDER_MAX_GAP)); + if !gaps.is_empty() { + let path = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect()); + closed.push(Shape::new(path, FillRule::NonZero, u64::MAX)); + } + } + closed +} + +/// [`shape_builder`] with Gap Detection: open paths whose ends come within `gap` (document units) +/// of another path are bridged for the regions they bound, so a region left open by a small gap is +/// found. A `gap` of 0 is [`shape_builder`]. The bridges are in no region's sources and in no piece +/// of `lines` or `edges`. +pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> BuilderArrangement { + builder(shapes, edges, gap, DEFAULT_PRECISION) +} + +/// [`shape_builder_with_gap`] refitting its results to `precision` (Pathfinder Options › Precision). +pub(crate) fn builder(shapes: &[Shape], edges: bool, gap: f64, precision: f64) -> BuilderArrangement { + let has_open = has_open_path(shapes); + let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); + let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS) + .then(|| { + let keyed: Vec = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect(); + if has_open && gap.is_finite() && gap > 0.0 { + let (faces, pieces) = build(&gap_inputs(&keyed, gap), precision).unwrap_or_default(); + // Gap targets split original outlines too, but the bridges themselves aren't art. + (faces, pieces.into_iter().filter(|p| p.key < shapes.len() as u64).collect()) + } else { + build(&keyed, precision).unwrap_or_default() + } + }) + .filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty()); + let Some((faces, pieces)) = planar else { + return BuilderArrangement { regions: filled_regions(shapes, precision), ..Default::default() }; + }; + let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed)); + let (lines, outline): (Vec, Vec) = pieces.into_iter().partition(|e| is_line(e.key)); + let regions = if has_open { + faces + } else { + // The filled areas from their arrangement, whose faces merge exactly, then the gaps. + let mut regions = crate::pathfinder::regions_with(shapes, precision); + regions.extend(faces.into_iter().filter(|f| f.sources.is_empty())); + regions + }; + BuilderArrangement { regions, lines: if has_open { lines } else { Vec::new() }, edges: if edges { outline } else { Vec::new() } } +} + +/// The regions of the shapes' filled areas. A shape that fills nothing (open paths that enclose no +/// area, such as cutting lines) is left out, so a flood of them cannot push the arrangement past its +/// size budget and take the filled shapes down with it; the regions keep the original indices. +fn filled_regions(shapes: &[Shape], precision: f64) -> Vec { + let fills = |s: &Shape| s.path.subpaths.iter().any(|sp| sp.closed || encloses_area(sp)); + let keep: Vec = (0..shapes.len()).filter(|&i| shapes.get(i).is_some_and(fills)).collect(); + if keep.len() == shapes.len() { + return crate::pathfinder::regions_with(shapes, precision); + } + let kept: Vec = keep.iter().filter_map(|&i| shapes.get(i).cloned()).collect(); + crate::pathfinder::regions_with(&kept, precision) + .into_iter() + .map(|mut r| { + r.sources = r.sources.iter().filter_map(|&s| keep.get(s).copied()).collect(); + r + }) + .collect() +} + +/// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the +/// open subpaths of filled paths that do. +pub fn encloses_area(sp: &SubPath) -> bool { + let mut closed = sp.clone(); + closed.closed = true; + let mut bp = BezPath::new(); + closed.to_bezpath_into(&mut bp); + let size = bp.bounding_box().size(); + bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height) +} + +/// `path` without `piece`, a run along one of its subpaths between two points (a Shape Builder +/// edge): a closed subpath opens there, an open one splits in two (or loses an end), and a piece +/// that runs all the way round takes its subpath. With `fill_closes`, open subpaths that enclose an +/// area ([`encloses_area`]) run on round an invisible closing edge, as the Shape Builder sees a +/// filled path: a piece may run over it, and it never comes back as a visible segment. None when +/// the piece doesn't lie on `path` within `tol`. +pub fn cut_out(path: &PathData, piece: &SubPath, fill_closes: bool, tol: f64) -> Option { + let n = piece.segment_count(); + if n == 0 { + return None; + } + // A point well inside the piece: the middle of its longest segment. + let arclen = |k: usize| kurbo::ParamCurveArclen::arclen(&piece.segment(k), 1e-3); + let mid = (0..n).map(|k| (arclen(k), k)).max_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, k)| piece.segment(k).eval(0.5))?; + let (a, b) = (piece.anchors.first()?.p, piece.anchors.last()?.p); + let mut bp = BezPath::new(); + piece.to_bezpath_into(&mut bp); + if bp.perimeter(1e-6) <= tol { + // A speck (where paths touch): there's nothing to cut. + return None; + } + // A piece that ends where it starts runs all the way round. + let whole = piece.closed || a.distance(b) <= tol; + // Each subpath as the piece may run along it: round its closing edge when it has one. + let ring = |sp: &SubPath| SubPath { anchors: sp.anchors.clone(), closed: sp.closed || (fill_closes && encloses_area(sp)) }; + let (si, geom, um) = path + .subpaths + .iter() + .enumerate() + .filter_map(|(i, sp)| { + let g = ring(sp); + let (u, d) = nearest_u(&g, mid)?; + (d <= tol).then_some((i, g, u, d)) + }) + .min_by(|x, y| x.3.total_cmp(&y.3)) + .map(|(i, g, u, _)| (i, g, u))?; + let len = geom.segment_count() as f64; + let real = path.subpaths.get(si)?.segment_count() as f64; + let at = |p: Point| nearest_u(&geom, p).filter(|(_, d)| *d <= tol).map(|(u, _)| u); + // The parameter spans of `geom` that stay (unrolled past the end of a ring). + let keep: Vec<(f64, f64)> = if whole { + Vec::new() + } else if geom.closed { + let (ua, ub) = (at(a)?, at(b)?); + let fwd = |from: f64, to: f64| (to - from).rem_euclid(len); + // The piece runs from one end through `mid` to the other; what stays runs on round. + let (start, end) = if fwd(ua, um) <= fwd(ua, ub) { (ub, ua) } else { (ua, ub) }; + let rest = fwd(start, end); + if rest <= 1e-9 || len - rest <= 1e-9 { + Vec::new() + } else { + // Where the ring closes with an invisible edge (`real..len`), what stays stops at it. + let parts: &[(f64, f64)] = if real >= len { &[(0.0, 2.0 * len)] } else { &[(0.0, real), (len, len + real)] }; + parts.iter().map(|&(lo, hi)| (start.max(lo), (start + rest).min(hi))).filter(|(x, y)| y - x > 1e-9).collect() + } + } else { + let (ua, ub) = (at(a)?, at(b)?); + let (lo, hi) = (ua.min(ub), ua.max(ub)); + if !(lo - 1e-9..=hi + 1e-9).contains(&um) { + return None; + } + [(0.0, lo), (hi, len)].into_iter().filter(|(x, y)| y - x > 1e-9).collect() + }; + let mut subpaths: Vec = path.subpaths.iter().take(si).cloned().collect(); + subpaths.extend(keep.into_iter().filter_map(|(u0, u1)| span(&geom, u0, u1)).map(|sp| snap_ends(sp, [a, b], tol))); + subpaths.extend(path.subpaths.iter().skip(si + 1).cloned()); + Some(PathData::new(subpaths)) +} + +/// The run of `piece` (a Shape Builder edge or line piece) around the point of it nearest `at`, +/// from the nearest of `stops` before that point to the nearest after it, open. Stops are the +/// points within `tol` of the piece, such as the anchors of the path it lies on; an open piece's +/// own ends bound the run where no stop comes first. None when the piece has no segments, or when +/// it is closed and fewer than two different stops lie on it (the run would be all of it). +pub fn run_around(piece: &SubPath, stops: &[Point], at: Point, tol: f64) -> Option { + let n = piece.segment_count(); + if n == 0 { + return None; + } + let len = n as f64; + let (u, _) = nearest_u(piece, at)?; + // The stops by x, so that each segment measures only the stops within its bounds. + let mut by_x: Vec = stops.iter().copied().filter(|s| s.x.is_finite() && s.y.is_finite()).collect(); + by_x.sort_by(|a, b| a.x.total_cmp(&b.x)); + // The parameters of the stops within `tol` of segment `j` whose nearest point on the whole + // piece lies on it (a ring's segments repeat past either end, `len` apart). + let on = |j: isize| -> Vec { + use kurbo::ParamCurveNearest; + let seg = piece.segment(j.rem_euclid(n as isize) as usize); + let b = kurbo::Shape::bounding_box(&seg).inflate(tol, tol); + let near = by_x.get(by_x.partition_point(|s| s.x < b.x0)..).unwrap_or_default(); + near.iter() + .take_while(|s| s.x <= b.x1) + .filter(|s| s.y >= b.y0 && s.y <= b.y1) + .filter_map(|s| { + let m = seg.nearest(*s, 1e-9); + let d = m.distance_sq.sqrt(); + (d <= tol && nearest_u(piece, *s).is_some_and(|(_, nearest)| d <= nearest + 1e-9)).then_some(j as f64 + m.t) + }) + .collect() + }; + // From the segment under `at`, the walk goes back to the nearest stop at or before it and on + // to the nearest stop after it; an open piece's ends bound it. + let k0 = (u.floor().max(0.0) as isize).min(n as isize - 1); + let (first, last) = if piece.closed { (k0 - n as isize, k0 + n as isize) } else { (0, n as isize - 1) }; + let lo = (first..=k0).rev().find_map(|j| on(j).into_iter().filter(|&x| x <= u).max_by(f64::total_cmp)); + let lo = match lo { + Some(lo) => lo, + None if !piece.closed => 0.0, + None => return None, + }; + let after = u.max(lo + 1e-9); + let hi = (k0..=last).find_map(|j| on(j).into_iter().filter(|&x| x > after).min_by(f64::total_cmp)); + let hi = match hi { + Some(hi) => hi, + None if !piece.closed => len, + None => return None, + }; + // On a ring, a lone stop would make the run all of it. + if piece.closed && hi - lo >= len - 1e-9 { + return None; + } + // A run that passes the ring's start begins before its end and is unrolled past it. + let (lo, hi) = if lo < 0.0 { (lo + len, hi + len) } else { (lo, hi) }; + let run = span(piece, lo, hi)?; + // The run's ends go exactly on the stops they end at. + let stop_at = + |q: Point| stops.iter().copied().filter(|s| s.distance(q) <= tol).min_by(|a, b| a.distance(q).total_cmp(&b.distance(q))).unwrap_or(q); + let ends = [run.anchors.first()?.p, run.anchors.last()?.p].map(stop_at); + Some(snap_ends(run, ends, tol)) +} + +/// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance. +fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> { + use kurbo::ParamCurveNearest; + (0..sp.segment_count()) + .map(|k| { + let n = sp.segment(k).nearest(p, 1e-9); + (k as f64 + n.t, n.distance_sq.sqrt()) + }) + .min_by(|x, y| x.1.total_cmp(&y.1)) +} + +/// `sp` with its end anchors within `tol` of one of `to` put exactly on it (where the planar map +/// put the junction a piece ends at), handles moving with them. +fn snap_ends(mut sp: SubPath, to: [Point; 2], tol: f64) -> SubPath { + let last = sp.anchors.len().saturating_sub(1); + for i in [0, last] { + if let Some(an) = sp.anchors.get_mut(i) + && let Some(q) = to.iter().find(|q| q.distance(an.p) <= tol) + { + let d = *q - an.p; + an.p = *q; + an.h_in += d; + an.h_out += d; + } + } + sp +} + +/// The run of `sp` from parameter `u0` to `u1` (`u0 < u1`, unrolled past the end of a closed one), +/// open. +fn span(sp: &SubPath, u0: f64, u1: f64) -> Option { + let n = sp.segment_count(); + if n == 0 || !(u0.is_finite() && u1.is_finite()) { + return None; + } + let first = u0.floor().max(0.0) as usize; + let last = (u1.ceil().max(0.0) as usize).min(first + 2 * n); + let segs: Vec = (first..last) + .filter_map(|k| { + let (t0, t1) = ((u0 - k as f64).clamp(0.0, 1.0), (u1 - k as f64).clamp(0.0, 1.0)); + (t1 - t0 > 1e-12).then(|| Seg { c: sp.segment(k % n).subsegment(t0..t1), line: sp.segment_is_line(k % n) }) + }) + .collect(); + segs_to_subpath(&segs, false) +} From b1ca05d990f94053bd31bafc37fdd393f1819b62 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:04:06 +0000 Subject: [PATCH 121/199] refactor(three-d): move solid.rs items to solid/{build,inflate,membrane,rings}.rs --- crates/three-d/src/solid.rs | 1331 +------------------------- crates/three-d/src/solid/build.rs | 460 +++++++++ crates/three-d/src/solid/inflate.rs | 305 ++++++ crates/three-d/src/solid/membrane.rs | 400 ++++++++ crates/three-d/src/solid/rings.rs | 163 ++++ 5 files changed, 1343 insertions(+), 1316 deletions(-) create mode 100644 crates/three-d/src/solid/build.rs create mode 100644 crates/three-d/src/solid/inflate.rs create mode 100644 crates/three-d/src/solid/membrane.rs create mode 100644 crates/three-d/src/solid/rings.rs diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index eb3814b9a..043e34766 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -13,6 +13,21 @@ use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Vec use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale}; +mod build; +mod inflate; +mod membrane; +mod rings; + +#[cfg(test)] +use build::{Camera, cuts, edge_distance, newell, section}; +pub use build::{extrude, plane}; +pub use inflate::{inflate, inflate_together}; +#[cfg(test)] +use membrane::{Edges, Membrane, cell_triangles}; +use rings::signed_area; +#[cfg(test)] +use rings::{MAX_SOLID_POINTS, rings}; + /// The view shared by every 3D effect: rotation, perspective and directional lighting. #[derive(Clone, Copy, Debug, PartialEq)] pub struct View { @@ -289,1322 +304,6 @@ impl Inflate { } } -/// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel -/// bands and distance tests. -const MAX_SOLID_POINTS: usize = 2048; -/// Outline points Rotate samples at most: it only turns the outline, so an outlined paragraph fits. -const MAX_PLANE_POINTS: usize = 65536; -/// Pieces a twisted wall's strip is cut into along one edge at most. -const MAX_PIECES: usize = 64; - -/// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from -/// its chords), 1 to 32. -fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { - let d = |a: Point, b: Point, c: Point| (a.to_vec2() - 2.0 * b.to_vec2() + c.to_vec2()).hypot(); - let bend = d(c.p0, c.p1, c.p2).max(d(c.p1, c.p2, c.p3)); - let n = (0.75 * bend / tolerance).sqrt().ceil(); - if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } -} - -/// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer -/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves -/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take -/// few points and large ones more; more than `limit` points in all is refused. -/// -/// Under the nonzero `rule` an outline with filled art on both sides is no edge of the shape and -/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would -/// if nesting alone decided (#1724). -fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { - let tolerance = (size / 2000.0).max(0.05); - let mut count = 0; - let mut out = vec![]; - for sp in &path.subpaths { - let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; - if segments > limit { - return Err("The shape is too complex for a 3D effect".into()); - } - let mut ring: Vec = vec![]; - if let Some(first) = sp.anchors.first() { - ring.push(first.p); - } - for i in 0..segments { - let curve = sp.segment(i); - let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance) }; - count += samples as usize; - if count > limit { - return Err("The shape is too complex for a 3D effect".into()); - } - for k in 1..=samples { - ring.push(curve.eval(f64::from(k) / f64::from(samples))); - } - } - if ring.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8) { - return Err("The shape contains invalid coordinates".into()); - } - ring.dedup_by(|a, b| a.distance(*b) < 1e-9); - while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { - ring.pop(); - } - if ring.len() >= 3 && signed_area(&ring).abs() > 1e-9 { - out.push(ring); - } - } - // Which rings hold which: each ring against the others, its edges walked only where its - // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges in all. - let too_complex = || String::from("The shape is too complex for a 3D effect"); - let mut work = 0usize; - let mut within = |out: &[Vec], bounds: &[Rect], i: usize| -> Result, String> { - let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); - let mut held_by = vec![]; - for (j, (o, b)) in out.iter().zip(bounds).enumerate() { - if j == i || !(b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1) { - continue; - } - work = work.saturating_add(o.len()); - if work > MAX_CONTAINMENT_EDGES { - return Err(too_complex()); - } - if contains(o, p) { - held_by.push(j); - } - } - Ok(held_by) - }; - let bounds_of = |out: &[Vec]| -> Vec { - out.iter().map(|r| r.iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() - }; - if rule == FillRule::NonZero { - let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 }; - let bounds = bounds_of(&out); - let mut edge = Vec::with_capacity(out.len()); - for (i, r) in out.iter().enumerate() { - // The winding number just outside the ring, and just inside it. - let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o)).sum(); - edge.push((outside != 0) != (outside + turn(r) != 0)); - } - let mut edge = edge.into_iter(); - out.retain(|_| edge.next().unwrap_or(true)); - } - // A ring inside an odd number of others is a hole. - let bounds = bounds_of(&out); - let holes: Vec = (0..out.len()).map(|i| within(&out, &bounds, i).map(|held| held.len() % 2 == 1)).collect::>()?; - for (r, hole) in out.iter_mut().zip(holes) { - if (signed_area(r) > 0.0) == hole { - r.reverse(); - } - } - Ok(out) -} - -/// Most ring edges [`rings`] walks to tell which rings hold which: every ring is tested against -/// the others, so many rings of many points each would otherwise take billions of steps. -const MAX_CONTAINMENT_EDGES: usize = 50_000_000; - -fn signed_area(r: &[Point]) -> f64 { - r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 -} - -fn contains(r: &[Point], p: Point) -> bool { - let mut inside = false; - for (a, b) in r.iter().zip(r.iter().cycle().skip(1)).take(r.len()) { - if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) { - inside = !inside; - } - } - inside -} - -/// The unit normal on the left of `a`→`b`: towards the filled side. -fn left(a: Point, b: Point) -> (f64, f64) { - let (dx, dy) = (b.x - a.x, b.y - a.y); - let l = dx.hypot(dy); - if l < 1e-12 { (0.0, 0.0) } else { (-dy / l, dx / l) } -} - -/// `ring` moved `w` points towards its filled side (negative: away from it), each corner along -/// its bisector, its mitre limited to four times `w` so sharp corners don't shoot off. Each point -/// stays the partner of its source point, so rings at different insets join face to face. -fn inset(ring: &[Point], w: f64) -> Vec { - if w == 0.0 { - return ring.to_vec(); - } - let n = ring.len(); - (0..n) - .map(|i| { - let p = ring[i]; - let prev = ring[(i + n - 1) % n]; - let next = ring[(i + 1) % n]; - let (ax, ay) = left(prev, p); - let (bx, by) = left(p, next); - let (mut mx, mut my) = (ax + bx, ay + by); - let ml = mx.hypot(my); - if ml < 1e-9 { - (mx, my) = (ax, ay); - } else { - (mx, my) = (mx / ml, my / ml); - } - let cos = (mx * ax + my * ay).max(0.25); - Point::new(p.x + mx * w / cos, p.y + my * w / cos) - }) - .collect() -} - -/// Turns 3D points into document points and shades faces for one view. -struct Camera { - view: Revolve, - origin: Point, - /// The eye's distance in front of the centre; `None` without perspective. - eye: Option, - /// Deeper than any part of the object: faces drawn first go this far behind it. - far: f64, - light: V3, -} - -impl Camera { - fn new(view: View, bounds: Rect, depth: f64) -> Self { - let view = view.revolve(); - let [lx, ly, lz] = view.light_direction(); - let size = (bounds.width() / 2.0).max(bounds.height() / 2.0).max(depth / 2.0).max(1.0); - Self { view, origin: bounds.center(), eye: eye_distance(view.perspective, size), far: 16.0 * size, light: V3(lx, ly, lz) } - } - fn rotate(&self, v: V3) -> V3 { - self.view.rotate(v) - } - /// A point of the art, `z` points in front of its plane, in view space. - fn place(&self, p: Point, z: f64) -> V3 { - self.rotate(V3(p.x - self.origin.x, p.y - self.origin.y, z)) - } - fn project(&self, v: V3) -> Point { - let f = perspective_scale(self.eye, v.2); - Point::new(self.origin.x + v.0 * f, self.origin.y + v.1 * f) - } - /// The cosine between a face's outward unit normal `n` and the direction from `at` (both in - /// view space) to the eye: positive when the face is turned towards the viewer. - fn facing(&self, at: V3, n: V3) -> f64 { - let to_eye = self.eye.map_or(V3(0.0, 0.0, 1.0), |e| V3(0.0, 0.0, e).sub(at).unit()); - n.dot(to_eye) - } - fn brightness(&self, n: V3) -> f64 { - crate::brightness(&self.view, n, self.light) - } -} - -/// Facets being collected for one solid. -struct Builder<'a> { - camera: &'a Camera, - /// A closed solid hides the faces turned away from the viewer; an open one shows their - /// insides. - closed: bool, - facets: Vec, -} - -/// Where a face goes in the order the facets are drawn, back to front. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Order { - /// The face is sorted by the depth of its center, except that a face seen almost edge-on goes - /// first, under everything. - Depth, - /// The face is sorted by the depth of its center even when it is seen almost edge-on, so that - /// a piece of a twisted wall at the solid's outline covers the walls behind it. - Twisted, - /// The face is drawn after every other face when it is turned toward the viewer. A flat end of - /// the solid has the whole solid behind its plane, so it covers whatever it overlaps. Its depth - /// is `f64::MAX`, so the faces drawn last by several solids sorted together keep the order they - /// were added in. - Last, -} - -impl Builder<'_> { - /// Add a face of view-space contours with outward normal `n` (view space), drawn in `order`. - fn face(&mut self, contours: Vec>, n: V3, order: Order) -> Result<(), String> { - let count: usize = contours.iter().map(Vec::len).sum(); - if count < 3 { - return Ok(()); - } - let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); - let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); - let n = n.unit(); - let facing = self.camera.facing(centre, n); - // A closed solid hides the faces turned away. One seen almost exactly edge-on is kept and, - // unless it is a piece of a twisted wall, drawn first, under everything: on a curved - // surface rounding alone turns such faces either way, and dropping every other one would - // leave gaps. - let (n, behind) = if facing > 0.0 { - (n, false) - } else if self.closed && facing > -0.02 { - (n, true) - } else if self.closed { - return Ok(()); - } else { - (V3(-n.0, -n.1, -n.2), false) - }; - #[cfg(test)] - let view = contours.clone(); - let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); - if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) { - return Err("The 3D projection is not finite".into()); - } - if contours.iter().map(|c| signed_area(c).abs()).sum::() < 1e-10 { - return Ok(()); - } - let depth = if behind && order != Order::Twisted { - centre.2 - self.camera.far - } else if order == Order::Last && facing > 0.0 { - f64::MAX - } else { - centre.2 - }; - self.facets.push(Facet { - contours, - depth, - brightness: self.camera.brightness(n), - illumination: crate::illumination(&self.camera.view, n, self.camera.light), - rgb_gradient: None, - #[cfg(test)] - view, - }); - if self.facets.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); - } - Ok(()) - } - - /// A planar surface triangle with shared, smooth outward normals. Culling and sorting - /// still use its geometry, rather than the normals used only for lighting. - fn smooth_triangle(&mut self, vertices: [V3; 3], normals: [V3; 3], back: bool) -> Result<(), String> { - let contours = vec![vertices.to_vec()]; - let n = newell(&contours); - let n = if back { V3(-n.0, -n.1, -n.2) } else { n }; - let centre = vertices.iter().fold(V3::default(), |s, v| V3(s.0 + v.0 / 3.0, s.1 + v.1 / 3.0, s.2 + v.2 / 3.0)); - let flip = !self.closed && self.camera.facing(centre, n.unit()) <= 0.0; - let before = self.facets.len(); - self.face(contours, n, Order::Depth)?; - if self.facets.len() == before || !self.camera.view.shade { - return Ok(()); - } - let values: Vec<_> = normals - .into_iter() - .map(|n| crate::unclipped_illumination(&self.camera.view, if flip { V3(-n.0, -n.1, -n.2) } else { n }, self.camera.light)) - .collect(); - if let Some(face) = self.facets.last_mut() { - let points = face.contours.first().map(Vec::as_slice).unwrap_or_default(); - face.rgb_gradient = crate::RgbGradient::triangle(points, &values); - } - Ok(()) - } - - /// The band between two partner rings (same length), as one quad per edge, or for a twisted - /// wall with `cuts` (see [`cuts`]) as `cuts[i]` quads along edge `i`. `outward` is the - /// direction (before rotation) the band faces away from the solid, for orienting normals. - fn band(&mut self, a: &[V3], b: &[V3], cuts: &[usize], outward: impl Fn(usize) -> V3) -> Result<(), String> { - let n = a.len().min(b.len()); - // At 0 and 1, the corners themselves. - let lerp = |p: V3, q: V3, s: f64| { - if s <= 0.0 { - p - } else if s >= 1.0 { - q - } else { - V3(p.0 + (q.0 - p.0) * s, p.1 + (q.1 - p.1) * s, p.2 + (q.2 - p.2) * s) - } - }; - let order = if cuts.is_empty() { Order::Depth } else { Order::Twisted }; - for i in 0..n { - let j = (i + 1) % n; - let pieces = cuts.get(i).copied().unwrap_or(1).max(1); - for k in 0..pieces { - let (s0, s1) = (k as f64 / pieces as f64, (k + 1) as f64 / pieces as f64); - let quad = vec![lerp(a[i], a[j], s0), lerp(a[i], a[j], s1), lerp(b[i], b[j], s1), lerp(b[i], b[j], s0)]; - // A twist bends the strip between two slices into a saddle: its normal is the whole - // quad's average (Newell's), not one corner's triangle, which leans far to one side. - let mut normal = newell(std::slice::from_ref(&quad)); - if normal.dot(normal) < 1e-18 { - continue; - } - if normal.dot(self.camera.rotate(outward(i))) < 0.0 { - normal = V3(-normal.0, -normal.1, -normal.2); - } - self.face(vec![quad], normal, order)?; - } - } - Ok(()) - } - - fn finish(mut self) -> Vec { - self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - self.facets - } -} - -/// How many pieces each edge of a twisted wall is drawn in, from two neighboring slices of the -/// wall, `a` and `b` (partner rings): as many as make each piece about as long along the edge as -/// the band between the slices is wide there, 1 to [`MAX_PIECES`]. Twisted walls pass in front of -/// one another, and a strip the length of its edge has no one depth where another crosses it. -fn cuts(a: &[V3], b: &[V3]) -> Vec { - let n = a.len().min(b.len()); - let length = |p: V3, q: V3| q.sub(p).dot(q.sub(p)).sqrt(); - (0..n) - .map(|i| { - let j = (i + 1) % n; - let (along, across) = (length(a[i], a[j]).max(length(b[i], b[j])), length(a[i], b[i]).max(length(a[j], b[j]))); - let count = (along / across.max(1e-9)).ceil(); - if count.is_finite() { (count as usize).clamp(1, MAX_PIECES) } else { MAX_PIECES } - }) - .collect() -} - -/// Lowers the [`cuts`] of every ring's edges (one list per ring) so that `bands` bands of each -/// make no more than `room` quads in all, or one per edge and band when that is more: the pieces -/// past each edge's first share the room in proportion. -fn fit(cuts: &mut [Vec], bands: usize, room: usize) { - let edges = cuts.iter().map(Vec::len).sum::().saturating_mul(bands); - let total = cuts.iter().flatten().sum::().saturating_mul(bands); - let room = room.max(edges); - if total > room { - let share = (room - edges) as f64 / (total - edges) as f64; - for c in cuts.iter_mut().flatten() { - *c = 1 + ((c.saturating_sub(1)) as f64 * share).floor() as usize; - } - } -} - -/// The area-weighted normal of (possibly non-planar) contours, by Newell's method: for contours -/// winding with their filled side on the left as seen from in front, it points to the front. -fn newell(contours: &[Vec]) -> V3 { - let mut n = V3::default(); - for c in contours { - for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) { - let x = a.cross(*b); - n = V3(n.0 + x.0, n.1 + x.1, n.2 + x.2); - } - } - n -} - -fn check_bounds(b: Rect) -> Result<(), String> { - if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { - Ok(()) - } else { - Err("3D effects require finite coordinates within 100 million points".into()) - } -} - -/// The art's outward direction at edge `i` of `ring` (before rotation), tilted towards the face -/// it borders by `z` (+1 front, -1 back, 0 a side wall). -fn outward(ring: &[Point], i: usize, z: f64) -> V3 { - let n = ring.len(); - let (lx, ly) = left(ring[i], ring[(i + 1) % n]); - V3(-lx, -ly, z) -} - -/// The cross-section of a side: each ring of the shape at depth fraction `t` (0 front, 1 back), -/// turned by the twist and scaled by the taper about the centre of `bounds`. -fn section(ring: &[Point], bounds: Rect, o: &Extrude, t: f64) -> Vec { - let turn = Turn::new(bounds, o.twist, o.taper, t); - ring.iter().map(|p| turn.apply(*p)).collect() -} - -/// The twist and taper at depth fraction `t` (0 front, 1 back): a turn by `twist · t` and a -/// scale from 1 to `taper`, about the centre of the bounds. -#[derive(Clone, Copy)] -struct Turn { - centre: Point, - sin: f64, - cos: f64, - scale: f64, -} - -impl Turn { - fn new(bounds: Rect, twist: f64, taper: f64, t: f64) -> Self { - let (sin, cos) = (twist * t).to_radians().sin_cos(); - Self { centre: bounds.center(), sin, cos, scale: 1.0 + (taper - 1.0) * t } - } - fn apply(&self, p: Point) -> Point { - let (x, y) = ((p.x - self.centre.x) * self.scale, (p.y - self.centre.y) * self.scale); - Point::new(self.centre.x + x * self.cos - y * self.sin, self.centre.y + x * self.sin + y * self.cos) - } -} - -/// One step of an extrusion's outline: how far it is inset (negative: outset) and its depth -/// fraction (0 front, 1 back), with the face it borders (+1 front bevel, -1 back bevel, 0 wall). -#[derive(Clone, Copy)] -struct Step { - inset: f64, - t: f64, - face: f64, -} - -/// The outline's steps from the front cap's edge to the back cap's edge. -fn steps(o: &Extrude) -> Vec { - let d = o.depth.max(1e-6); - let mut out = vec![]; - let bevel = o.bevel.filter(|b| b.width > 0.0 || b.height > 0.0); - // A bevel can't be deeper than the extrusion (half of it on each side with both sides). - let sides = if bevel.is_some_and(|b| b.both_sides) { 2.0 } else { 1.0 }; - let height = bevel.map_or(0.0, |b| b.height.min(o.depth / sides)); - // The bevel's cross-section across the width: (share across, share down). - let profile = |b: &Bevel| -> Vec<(f64, f64)> { - let mut pts = vec![(0.0, 0.0)]; - let n = b.repeat.max(1) as f64; - let run = (1.0 - b.space) / n; - let gap = if b.repeat > 1 { b.space / (n - 1.0) } else { 0.0 }; - for k in 0..b.repeat.max(1) { - let start = k as f64 * (run + gap); - for s in b.shape.samples().into_iter().skip(1) { - let (x, y) = b.shape.at(s); - pts.push((start + x * run, (k as f64 + y) / n)); - } - if b.repeat > 1 && k + 1 < b.repeat { - pts.push((start + run + gap, (k as f64 + 1.0) / n)); - } - } - if let Some(last) = pts.last_mut() { - last.0 = 1.0; - } - pts - }; - // With the bevel inside, the cap is inset by its width and the wall is the outline; outside, - // the cap is the outline and the wall is outset by the width. - let wall = match bevel { - Some(b) if !b.inside => -b.width, - _ => 0.0, - }; - // A twist turns a bevel's steps too: each step between two samples is cut into steps that - // turn no further than 6°, as the walls' slices do at most. - let refine = |pts: Vec<(f64, f64)>| -> Vec<(f64, f64)> { - if o.twist == 0.0 { - return pts; - } - let mut out = Vec::with_capacity(pts.len()); - for (k, p) in pts.iter().enumerate() { - if let Some(q) = k.checked_sub(1).and_then(|k| pts.get(k)) { - let turn = (p.1 - q.1).abs() * height / d * o.twist.abs(); - let n = ((turn / 6.0).ceil() as usize).clamp(1, 64); - for m in 1..n { - let s = m as f64 / n as f64; - out.push((q.0 + (p.0 - q.0) * s, q.1 + (p.1 - q.1) * s)); - } - } - out.push(*p); - } - out - }; - if let Some(b) = bevel { - for (x, y) in refine(profile(&b)) { - let inset = if b.inside { b.width * (1.0 - x) } else { -b.width * x }; - out.push(Step { inset, t: y * height / d, face: 1.0 }); - } - } else { - out.push(Step { inset: 0.0, t: 0.0, face: 1.0 }); - } - // Twist and taper bend the walls: cut them into slices. - let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; - let back_height = if bevel.is_some_and(|b| b.both_sides) { height } else { 0.0 }; - let (t0, t1) = (height / d, 1.0 - back_height / d); - for k in 0..=slices { - let t = t0 + (t1 - t0) * k as f64 / slices as f64; - out.push(Step { inset: wall, t, face: 0.0 }); - } - if let Some(b) = bevel.filter(|b| b.both_sides) { - for (x, y) in refine(profile(&b)).into_iter().rev() { - let inset = if b.inside { b.width * (1.0 - x) } else { -b.width * x }; - out.push(Step { inset, t: 1.0 - y * height / d, face: -1.0 }); - } - } - out.dedup_by(|a, b| (a.inset - b.inset).abs() < 1e-9 && (a.t - b.t).abs() < 1e-12); - out -} - -/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels -/// and caps. -pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - check_bounds(bounds)?; - let o = options.sanitized(); - // No depth: the flat shape, seen from either side. - if o.depth <= 0.0 { - return plane(path, rule, bounds, view); - } - let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; - let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; - let steps = steps(&o); - let z = |t: f64| o.depth / 2.0 - o.depth * t; - let ring_at = - |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; - // Every band of a twisted outline, bevels included, is cut as its walls' first slice is (with - // bevels and no walls, as its shallowest band is), within the facet limit. - let depth_of = |s: &[Step]| s.get(1).zip(s.first()).map_or(0.0, |(b, a)| (b.t - a.t).abs()); - let wall = steps - .windows(2) - .find(|s| s[0].face == 0.0 && s[1].face == 0.0) - .or_else(|| steps.windows(2).filter(|s| depth_of(s) > 1e-12).min_by(|a, c| depth_of(a).total_cmp(&depth_of(c)))); - let mut pieces: Vec> = rings - .iter() - .map(|ring| match wall { - Some([s0, s1]) if o.twist != 0.0 => cuts(&ring_at(ring, s0), &ring_at(ring, s1)), - _ => vec![], - }) - .collect(); - fit(&mut pieces, steps.len().saturating_sub(1), MAX_FACES); - for (ring, pieces) in rings.iter().zip(&pieces) { - let placed: Vec> = steps.iter().map(|s| ring_at(ring, s)).collect(); - for (k, pair) in placed.windows(2).enumerate() { - let ([a, c], Some(s0), Some(s1)) = (pair, steps.get(k), steps.get(k + 1)) else { continue }; - // A band between two front bevel steps borders the front, one reaching a back bevel - // step the back; the rest are walls. - let face = if s1.face < 0.0 { - -1.0 - } else if s0.face > 0.0 && s1.face > 0.0 { - 1.0 - } else { - 0.0 - }; - b.band(a, c, pieces, |i| outward(ring, i, face))?; - } - } - if o.cap { - // A cap turned toward the viewer covers the walls it overlaps, even those nearer than its - // center: twisted walls, and the walls of a fill inside the solid of its stroke. - for (s, n) in [(steps.first(), 1.0), (steps.last(), -1.0)] { - let Some(s) = s else { continue }; - b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), Order::Last)?; - } - } - Ok(b.finish()) -} - -/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the -/// shape's own, so that every part of one object turns about the same point). -pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - check_bounds(bounds)?; - let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; - let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, facets: vec![] }; - let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); - // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so - // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. - let up = camera.rotate(V3(0.0, 0.0, 1.0)); - let n = if camera.facing(V3::default(), up) < 0.0 { V3(-up.0, -up.1, -up.2) } else { up }; - b.face(contours, n, Order::Last)?; - Ok(b.finish()) -} - -/// The distance from `p` to the segment `a`–`b`. -fn segment_distance(p: Point, a: Point, b: Point) -> f64 { - let (dx, dy) = (b.x - a.x, b.y - a.y); - let l2 = dx * dx + dy * dy; - let t = if l2 < 1e-18 { 0.0 } else { (((p.x - a.x) * dx + (p.y - a.y) * dy) / l2).clamp(0.0, 1.0) }; - p.distance(Point::new(a.x + dx * t, a.y + dy * t)) -} - -/// The distance from `p` to the nearest edge of `rings`, edge by edge. -#[cfg(test)] -fn edge_distance(rings: &[Vec], p: Point) -> f64 { - rings - .iter() - .flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len())) - .map(|(a, b)| segment_distance(p, *a, *b)) - .fold(f64::INFINITY, f64::min) -} - -/// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is -/// found among the buckets around it rather than among all the edges. -struct Edges { - edges: Vec<(Point, Point)>, - normals: Vec<(Vec2, Vec2)>, - origin: Point, - size: f64, - n: usize, - buckets: Vec>, -} - -impl Edges { - fn new(rings: &[Vec]) -> Self { - const N: usize = 64; - let edges: Vec<(Point, Point)> = - rings.iter().flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| (*a, *b))).collect(); - let normals = rings - .iter() - .flat_map(|r| { - let normal = |i: usize| { - let (x, y) = left(r[i], r[(i + 1) % r.len()]); - Vec2::new(x, y) - }; - (0..r.len()).map(move |i| { - let n = normal(i); - let at = |other: Vec2| if n.dot(other) > 0.7 { (n + other).normalize() } else { n }; - (at(normal((i + r.len() - 1) % r.len())), at(normal((i + 1) % r.len()))) - }) - }) - .collect(); - let bounds = edges.iter().fold(None, |b: Option, (a, c)| Some(b.map_or(Rect::from_points(*a, *c), |b| b.union_pt(*a).union_pt(*c)))); - let bounds = bounds.unwrap_or(Rect::ZERO); - let size = (bounds.width().max(bounds.height()) / N as f64).max(1e-9); - let mut buckets = vec![vec![]; N * N]; - let index = |v: f64, o: f64| (((v - o) / size).floor().max(0.0) as usize).min(N - 1); - for (k, (a, b)) in edges.iter().enumerate() { - let (i0, i1) = (index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)); - let (j0, j1) = (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0)); - for j in j0..=j1 { - for i in i0..=i1 { - buckets[j * N + i].push(k as u32); - } - } - } - Self { edges, normals, origin: bounds.origin(), size, n: N, buckets } - } - - /// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis. - fn cell(&self, v: f64, o: f64) -> isize { - (((v - o) / self.size).floor() as isize).clamp(0, self.n as isize - 1) - } - - /// Is `p` within `tolerance` of an edge? Only the buckets `p` could share an edge with are - /// looked at. - fn near(&self, p: Point, tolerance: f64) -> bool { - self.touch(Rect::new(p.x - tolerance, p.y - tolerance, p.x + tolerance, p.y + tolerance)) && { - let n = self.n as isize; - let (i0, i1) = (self.cell(p.x - tolerance, self.origin.x), self.cell(p.x + tolerance, self.origin.x)); - let (j0, j1) = (self.cell(p.y - tolerance, self.origin.y), self.cell(p.y + tolerance, self.origin.y)); - (j0..=j1).any(|j| { - (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] - .iter() - .filter_map(|k| self.edges.get(*k as usize)) - .any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) - }) - }) - } - } - - /// Call `f` with each edge whose bounding box overlaps `r` (an edge in several of the - /// buckets `r` covers comes more than once). - fn each(&self, r: Rect, mut f: impl FnMut(Point, Point)) { - let n = self.n as isize; - for j in self.cell(r.y0, self.origin.y)..=self.cell(r.y1, self.origin.y) { - for i in self.cell(r.x0, self.origin.x)..=self.cell(r.x1, self.origin.x) { - for (a, b) in self.buckets[(j * n + i) as usize].iter().filter_map(|k| self.edges.get(*k as usize)) { - if a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 { - f(*a, *b); - } - } - } - } - } - - /// Does any edge pass through `r` (its bounding box, at least, overlapping it)? - fn touch(&self, r: Rect) -> bool { - let n = self.n as isize; - let (i0, i1) = (self.cell(r.x0, self.origin.x), self.cell(r.x1, self.origin.x)); - let (j0, j1) = (self.cell(r.y0, self.origin.y), self.cell(r.y1, self.origin.y)); - (j0..=j1).any(|j| { - (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] - .iter() - .filter_map(|k| self.edges.get(*k as usize)) - .any(|(a, b)| a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0) - }) - }) - } - - /// The distance from `p` to the nearest edge: the buckets are searched in growing squares - /// around `p`'s, until no unsearched bucket could hold a nearer edge. - fn distance(&self, p: Point) -> f64 { - self.closest(p).2 - } - - /// Closest outline point, its inward normal and distance. Curved outline samples share - /// corner normals, while a sharp source corner keeps each edge's own direction. - fn closest(&self, p: Point) -> (Point, Vec2, f64) { - let (fx, fy) = ((p.x - self.origin.x) / self.size, (p.y - self.origin.y) / self.size); - if !fx.is_finite() || !fy.is_finite() { - return (p, Vec2::ZERO, f64::INFINITY); - } - let n = self.n as isize; - let (ci, cj) = ((fx.floor() as isize).clamp(0, n - 1), (fy.floor() as isize).clamp(0, n - 1)); - // How far `p` is outside the grid: the nearest point of the grid to it lies in `p`'s bucket. - let outside = (fx.clamp(0.0, n as f64) - fx).hypot(fy.clamp(0.0, n as f64) - fy) * self.size; - let mut best = f64::INFINITY; - let mut nearest = (p, Vec2::ZERO); - for r in 0..n { - for j in (cj - r)..=(cj + r) { - for i in (ci - r)..=(ci + r) { - if (j - cj).abs() != r && (i - ci).abs() != r || i < 0 || j < 0 || i >= n || j >= n { - continue; - } - for k in &self.buckets[(j * n + i) as usize] { - if let Some((a, b)) = self.edges.get(*k as usize) { - let d = *b - *a; - let t = if d.hypot2() < 1e-18 { 0.0 } else { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) }; - let q = Point::new(a.x + d.x * t, a.y + d.y * t); - let distance = p.distance(q); - if distance < best { - best = distance; - let n = self.normals.get(*k as usize).map_or(Vec2::ZERO, |(a, b)| *a * (1.0 - t) + *b * t); - nearest = (q, if n.hypot2() > 0.0 { n.normalize() } else { n }); - } - } - } - } - } - // Buckets r + 1 away are at least r buckets from any point of `p`'s own, and that - // far across the grid from a `p` outside it (at right angles to its way in, at worst). - if best <= outside.hypot(r as f64 * self.size) { - break; - } - } - (nearest.0, nearest.1, best) - } -} - -/// Is `p` filled by `rings` (nonzero winding, as they are oriented)? -fn filled(rings: &[Vec], p: Point) -> bool { - rings.iter().filter(|r| contains(r, p)).count() % 2 == 1 -} - -/// `ring` cut to the rectangle `cell` (Sutherland–Hodgman: the rectangle is convex, the ring -/// needn't be). -fn clip(ring: &[Point], cell: Rect) -> Vec { - let mut pts = ring.to_vec(); - let sides: [fn(Point, Rect) -> f64; 4] = [|p, c| p.x - c.x0, |p, c| c.x1 - p.x, |p, c| p.y - c.y0, |p, c| c.y1 - p.y]; - for side in sides { - if pts.is_empty() { - break; - } - let mut out = vec![]; - for (a, b) in pts.iter().zip(pts.iter().cycle().skip(1)).take(pts.len()) { - let (da, db) = (side(*a, cell), side(*b, cell)); - if da >= 0.0 { - out.push(*a); - } - if (da >= 0.0) != (db >= 0.0) { - out.push(a.lerp(*b, da / (da - db))); - } - } - pts = out; - } - pts.dedup_by(|a, b| a.distance(*b) < 1e-9); - if pts.len() >= 3 && signed_area(&pts).abs() > 1e-12 { pts } else { vec![] } -} - -/// The shape's surface as a membrane under pressure, held at zero along its outline (and its -/// holes): the solution of ∇²φ = −1 on a grid, with each node's distance to the outline along the -/// grid lines taken into account where it is less than a grid step (Shortley–Weller), so the -/// outline needn't fall on the grid. √φ rises vertically from the outline and rounds smoothly -/// inside, without the creases a distance-to-the-edge height has along a shape's middle. -struct Membrane { - origin: Point, - step: f64, - nx: usize, - ny: usize, - /// φ at each node; outside the shape, extrapolated (negative) from the inside, so - /// interpolation crosses zero near the outline. - phi: Vec, - peak: f64, -} - -impl Membrane { - /// `edges` is the index of `rings`' edges (`Edges::new(rings)`). - fn solve(rings: &[Vec], edges: &Edges, origin: Point, step: f64, nx: usize, ny: usize) -> Self { - let node = |i: usize, j: usize| Point::new(origin.x + i as f64 * step, origin.y + j as f64 * step); - let idx = |i: usize, j: usize| j * nx + i; - // A node on the outline itself is held at zero with the outside. - let inside: Vec = (0..nx * ny) - .map(|k| { - let p = node(k % nx, k / nx); - filled(rings, p) && !edges.near(p, 1e-6 * step) - }) - .collect(); - // The nearest crossing of the outline from `p` along (dx, dy), within one step. - let crossing = |p: Point, dx: f64, dy: f64| -> f64 { - let mut best = step; - let reach = Rect::from_points(p, Point::new(p.x + dx * step, p.y + dy * step)); - edges.each(reach, |a, b| { - let t = if dy == 0.0 { - if (a.y > p.y) == (b.y > p.y) { - return; - } - (a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) - p.x) * dx - } else { - if (a.x > p.x) == (b.x > p.x) { - return; - } - (a.y + (p.x - a.x) / (b.x - a.x) * (b.y - a.y) - p.y) * dy - }; - if t > 1e-9 * step && t < best { - best = t; - } - }); - best - }; - // Each inside node's four neighbours: (weight, neighbour inside or None at the outline). - let mut stencil: Vec<[(f64, Option); 4]> = vec![[(0.0, None); 4]; nx * ny]; - for j in 0..ny { - for i in 0..nx { - let k = idx(i, j); - if !inside[k] { - continue; - } - let p = node(i, j); - let dirs = [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)]; - let h: Vec = dirs.iter().map(|(dx, dy)| crossing(p, *dx, *dy)).collect(); - for (d, (dx, dy)) in dirs.iter().enumerate() { - let opp = h[d ^ 1]; - let w = 2.0 / (h[d] * (h[d] + opp)); - let (ni, nj) = (i as isize + *dx as isize, j as isize + *dy as isize); - let neighbour = (h[d] >= step && ni >= 0 && nj >= 0 && (ni as usize) < nx && (nj as usize) < ny) - .then(|| idx(ni as usize, nj as usize)) - .filter(|n| inside[*n]); - stencil[k][d] = (w, neighbour); - } - } - } - // Successive over-relaxation. - let mut phi = vec![0.0; nx * ny]; - let tolerance = 1e-9 * step * step * (nx * ny) as f64; - for _ in 0..2000 { - let mut change: f64 = 0.0; - for k in 0..nx * ny { - if !inside[k] { - continue; - } - let (mut sum, mut weight) = (1.0, 0.0); - for (w, n) in stencil[k] { - weight += w; - sum += w * n.map_or(0.0, |n| phi[n]); - } - let next = phi[k] + 1.8 * (sum / weight - phi[k]); - change = change.max((next - phi[k]).abs()); - phi[k] = next; - } - if change < tolerance { - break; - } - } - let peak = phi.iter().copied().fold(0.0, f64::max); - // Outside nodes next to the shape: φ continued past the outline at the slope of their - // inside neighbours, so values interpolated between them reach zero at the outline. - let mut ext = phi.clone(); - for j in 0..ny { - for i in 0..nx { - let k = idx(i, j); - if inside[k] { - continue; - } - let d_out = edges.distance(node(i, j)); - let mut slopes = vec![]; - for (di, dj) in [(1isize, 0isize), (-1, 0), (0, 1), (0, -1)] { - let (ni, nj) = (i as isize + di, j as isize + dj); - if ni < 0 || nj < 0 || ni as usize >= nx || nj as usize >= ny || !inside[idx(ni as usize, nj as usize)] { - continue; - } - let d_in = edges.distance(node(ni as usize, nj as usize)).max(1e-9); - slopes.push(phi[idx(ni as usize, nj as usize)] / d_in); - } - if !slopes.is_empty() { - ext[k] = -d_out * slopes.iter().sum::() / slopes.len() as f64; - } - } - } - Self { origin, step, nx, ny, phi: ext, peak } - } - - /// φ at `p`, interpolated between the four nodes around it. - fn at(&self, p: Point) -> f64 { - self.interpolate(p, |i, j| self.phi.get(j * self.nx + i).copied().unwrap_or(0.0)) - } - - /// Interpolate central node derivatives, rather than differentiating each bilinear cell. - /// The latter jumps at every grid line and shows the solver's grid in the lighting. - fn gradient(&self, p: Point) -> (f64, f64) { - let value = |i, j| self.phi.get(j * self.nx + i).copied().unwrap_or(0.0); - let dx = self.interpolate(p, |i, j| { - let (a, b) = (i.saturating_sub(1), (i + 1).min(self.nx - 1)); - (value(b, j) - value(a, j)) / ((b - a).max(1) as f64 * self.step) - }); - let dy = self.interpolate(p, |i, j| { - let (a, b) = (j.saturating_sub(1), (j + 1).min(self.ny - 1)); - (value(i, b) - value(i, a)) / ((b - a).max(1) as f64 * self.step) - }); - (dx, dy) - } - - fn interpolate(&self, p: Point, v: impl Fn(usize, usize) -> f64) -> f64 { - let fx = ((p.x - self.origin.x) / self.step).clamp(0.0, (self.nx - 1) as f64); - let fy = ((p.y - self.origin.y) / self.step).clamp(0.0, (self.ny - 1) as f64); - let (i, j) = ((fx.floor() as usize).min(self.nx.saturating_sub(2)), (fy.floor() as usize).min(self.ny.saturating_sub(2))); - let (tx, ty) = (fx - i as f64, fy - j as f64); - if self.nx < 2 || self.ny < 2 { - return v(0, 0); - } - let top = v(i, j) * (1.0 - tx) + v(i + 1, j) * tx; - let bottom = v(i, j + 1) * (1.0 - tx) + v(i + 1, j + 1) * tx; - top * (1.0 - ty) + bottom * ty - } -} - -/// A bounded ear clipping pass on a simple cell contour. Keep its collinear border nodes: -/// those shared samples can have different heights once lifted onto the membrane. A tiny -/// cell containing a complete hole, or a complex/degenerate contour, keeps its original fill. -fn cell_triangles(pieces: &[Vec]) -> Option> { - let mut triangles = vec![]; - for ring in pieces { - if ring.len() > 128 || signed_area(ring) <= 0.0 { - return None; - } - let mut ring = ring.clone(); - ring.dedup_by(|a, b| a.distance(*b) < 1e-9); - if ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { - ring.pop(); - } - while ring.len() > 3 { - let ear = (0..ring.len()).find(|&i| { - let n = ring.len(); - let (a, b, c) = (ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n]); - if (b - a).cross(c - a) <= 1e-12 { - return false; - } - !ring.iter().enumerate().any(|(j, p)| { - j != i - && j != (i + n - 1) % n - && j != (i + 1) % n - && (b - a).cross(*p - a) >= -1e-12 - && (c - b).cross(*p - b) >= -1e-12 - && (a - c).cross(*p - c) >= -1e-12 - }) - })?; - let n = ring.len(); - triangles.push([ring[(ear + n - 1) % n], ring[ear], ring[(ear + 1) % n]]); - ring.remove(ear); - } - if let [a, b, c] = ring.as_slice() { - triangles.push([*a, *b, *c]); - } - } - Some(triangles) -} - -/// Most outline segments the booleans of one inflated object's mixed cells take together. -const MAX_MATERIAL_SEGMENTS: usize = 4_000_000; - -/// The part of `outline` (its rings, each with its bounds) inside `cell`, as rings (holes running -/// the other way). Only the rings whose bounds meet the cell's go into the boolean. -fn material(outline: &[(Rect, vectorcraft_geom::SubPath)], cell: Rect) -> Option>> { - let r = PathData::single(vectorcraft_geom::SubPath::polyline( - &[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)], - true, - )); - let near = PathData::new( - outline.iter().filter(|(b, _)| b.x0 <= cell.x1 && cell.x0 <= b.x1 && b.y0 <= cell.y1 && cell.y0 <= b.y1).map(|(_, s)| s.clone()).collect(), - ); - let rule = FillRule::NonZero; - let part = - vectorcraft_pathops::try_boolean(&r, rule, &near, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION) - .ok()?; - rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok() -} - -/// A shape to inflate: its rings and the membrane under them on the grid of the bounds. -struct Puff { - rings: Vec>, - /// The index of the rings' edges. - edges: Edges, - cell: f64, - nx: usize, - ny: usize, - m: Membrane, - /// How high the front rises at its peak. - height: f64, -} - -/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. -fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { - let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; - if rings.is_empty() { - return Ok(None); - } - let size = bounds.width().max(bounds.height()); - if size <= 0.0 { - return Ok(None); - } - const CELLS: usize = 48; - let cell = size / CELLS as f64; - let (nx, ny) = (((bounds.width() / cell).ceil() as usize).max(1), ((bounds.height() / cell).ceil() as usize).max(1)); - let edges = Edges::new(&rings); - let m = Membrane::solve(&rings, &edges, bounds.origin(), cell, nx + 1, ny + 1); - // A circle of radius r puffs into a half sphere of radius r at 50 % (its membrane peaks at r²/4). - let height = 2.0 * m.peak.sqrt() * o.volume / 50.0; - Ok(Some(Puff { rings, edges, cell, nx, ny, m, height })) -} - -/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is. -pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result, String> { - inflate_together(&[(path, rule)], bounds, options, view).pop().unwrap_or(Ok(vec![])) -} - -/// Inflate for several shapes of one object (a fill and the outlines of its strokes): each puffs -/// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest -/// puff needs with Perspective. One result for each shape, in order. -pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { - let o = options.sanitized(); - let puffs: Vec, String>> = - shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); - let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); - puffs - .into_iter() - .map(|p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach), - Ok(None) => Ok(vec![]), - Err(e) => Err(e), - }) - .collect() -} - -/// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the -/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. -fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result, String> { - let Puff { rings, edges, cell, nx, ny, m, height } = p; - let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings - .iter() - .filter_map(|r| { - let b = r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p)); - Some((b, vectorcraft_geom::SubPath::polyline(r, true))) - }) - .collect(); - // When no shape puffs (no volume, or shapes too narrow for the grid), every front (and a back - // that puffs too) lies flat in one plane: its cells are drawn last, as caps are. If one shape - // puffs, a flat front can be behind its puff, so the cells keep their depths. - let flat = reach <= 0.0; - let lift = |p: Point| { - if m.peak <= 0.0 || edges.near(p, 1e-9) { - return 0.0; - } - height * (m.at(p).max(0.0) / m.peak).sqrt() - }; - let normal = |p: Point| { - let (dx, dy) = m.gradient(p); - let (_, inward, distance) = edges.closest(p); - let t = (distance / (2.0 * cell)).clamp(0.0, 1.0); - let weight = (1.0 - t).powi(2) * (1.0 + 2.0 * t); - let gradient = Vec2::new(dx, dy); - let slope = gradient.dot(inward).max(0.0); - // The zero boundary condition also fixes the tangential derivative at the outline. - // Extrapolated outside solver nodes cannot supply it reliably. Blend to that known - // direction and its limiting phi near the rim, without showing the grid in highlights. - let gradient = gradient * (1.0 - weight) + inward * (slope * weight); - let phi = m.at(p).max(0.0) * (1.0 - weight) + slope * distance * weight; - // Multiplying the height-function normal by 2√(peak φ) avoids division by - // zero at the rim, where the limiting normal is horizontal. - let n = V3(-height * gradient.x, -height * gradient.y, 2.0 * (m.peak * phi).sqrt()).unit(); - if n.dot(n) > 0.5 { n } else { V3(0.0, 0.0, 1.0) } - }; - let smooth = view.shade && view.has_colored_lights(); - let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); - // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; - let half = o.depth / 2.0; - // The back: turned and scaled by the twist and taper, its puff too. - let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); - // The grid's cells, those the surface rises steeply across (near the outline) cut into - // halves or quarters each way, so the steep rim is drawn in facets as fine as its curve. - // Every corner sits on one fine lattice (a quarter cell), computed the same way wherever it - // is used, and a cell's border takes its finer neighbour's lattice points too: where cells - // of different fineness meet, their edges follow the surface through the same points, with - // no cracks. - const FINE: usize = 4; - let unit = cell / FINE as f64; - let at = |qx: usize, qy: usize| Point::new(bounds.x0 + qx as f64 * unit, bounds.y0 + qy as f64 * unit); - let base = |i: usize, j: usize| Rect::from_points(at(i * FINE, j * FINE), at((i + 1) * FINE, (j + 1) * FINE)); - // Segments the booleans of mixed cells may take in all ([`MAX_MATERIAL_SEGMENTS`]): each one - // builds the topology of every outline ring near its cell, so a self-crossing outline cost its - // crossings again in every cell it spans. Past it, cells take their clipped pieces. - let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS); - // The shape's part of a rectangle, as rings. - let cut = |r: Rect| -> Vec> { - // A cell no edge crosses is wholly inside the shape or wholly outside it. - if !edges.touch(r) { - let corners = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; - return if filled(&rings, r.center()) { vec![corners] } else { vec![] }; - } - let pieces: Vec> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect(); - if pieces.iter().map(|p| signed_area(p)).sum::() <= 1e-9 * r.area() { - return vec![]; - } - // Where a hole's piece cancels part of an outline's, that only holds flat: lifted onto - // the surface the two no longer meet. Such a cell takes the shape's own part of it. - let mixed = pieces.iter().any(|p| signed_area(p) < 0.0) && pieces.iter().any(|p| signed_area(p) > 0.0); - if !mixed { - return pieces; - } - let near: usize = - outline.iter().filter(|(b, _)| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1).map(|(_, sp)| sp.anchors.len()).sum(); - match material_left.get().checked_sub(near) { - Some(left) => { - material_left.set(left); - material(&outline, r).unwrap_or(pieces) - } - None => pieces, - } - }; - // How many times each cell is halved each way (0, 1 or 2), from how steeply it rises. - let levels: Vec = (0..nx * ny) - .map(|k| { - let pieces = cut(base(k % nx, k / nx)); - let (lo, hi) = pieces.iter().flatten().map(|q| lift(*q)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| (lo.min(h), hi.max(h))); - let rise = if pieces.is_empty() { 0.0 } else { (hi - lo) / cell }; - if rise > 6.0 { - 2 - } else if rise > 2.0 { - 1 - } else { - 0 - } - }) - .collect(); - let level = - |i: isize, j: isize| -> u32 { if i < 0 || j < 0 || i as usize >= nx || j as usize >= ny { 0 } else { levels[j as usize * nx + i as usize] } }; - for j in 0..ny { - for i in 0..nx { - let l = level(i as isize, j as isize); - let step = FINE >> l; - // The lattice spacing along each side of this cell: its own or its neighbour's. - let (ii, jj) = (i as isize, j as isize); - let side_step = |dl: u32| FINE >> l.max(dl); - let (left, right, top, bottom) = - (side_step(level(ii - 1, jj)), side_step(level(ii + 1, jj)), side_step(level(ii, jj - 1)), side_step(level(ii, jj + 1))); - let (bx0, by0, bx1, by1) = (i * FINE, j * FINE, (i + 1) * FINE, (j + 1) * FINE); - for sy in (by0..by1).step_by(step) { - for sx in (bx0..bx1).step_by(step) { - let r = Rect::from_points(at(sx, sy), at(sx + step, sy + step)); - let pieces = cut(r); - if pieces.is_empty() { - continue; - } - // Lattice points along the cell's own border, between each edge's ends. - let stitch = |a: Point, b: Point| -> Vec { - let tol = 1e-7 * unit; - let along = |fixed_x: Option, fixed_y: Option, every: usize| -> Vec { - let mut out = vec![]; - match (fixed_x, fixed_y) { - (Some(qx), None) => { - let (lo, hi) = (a.y.min(b.y), a.y.max(b.y)); - for qy in (by0..=by1).step_by(every) { - let p = at(qx, qy); - if p.y > lo + tol && p.y < hi - tol { - out.push(p); - } - } - } - (None, Some(qy)) => { - let (lo, hi) = (a.x.min(b.x), a.x.max(b.x)); - for qx in (bx0..=bx1).step_by(every) { - let p = at(qx, qy); - if p.x > lo + tol && p.x < hi - tol { - out.push(p); - } - } - } - _ => {} - } - if (a.x > b.x) || (a.y > b.y) { - out.reverse(); - } - out - }; - let on = |v: f64, w: f64| (v - w).abs() <= tol; - let (x0, x1, y0, y1) = (at(bx0, by0).x, at(bx1, by1).x, at(bx0, by0).y, at(bx1, by1).y); - if on(a.x, x0) && on(b.x, x0) { - along(Some(bx0), None, left) - } else if on(a.x, x1) && on(b.x, x1) { - along(Some(bx1), None, right) - } else if on(a.y, y0) && on(b.y, y0) { - along(None, Some(by0), top) - } else if on(a.y, y1) && on(b.y, y1) { - along(None, Some(by1), bottom) - } else { - vec![] - } - }; - let pieces: Vec> = pieces - .iter() - .map(|p| { - let mut out = Vec::with_capacity(p.len()); - for (a, b) in p.iter().zip(p.iter().cycle().skip(1)).take(p.len()) { - out.push(*a); - out.extend(stitch(*a, *b)); - } - out - }) - .collect(); - if height > 0.0 - && smooth - && let Some(triangles) = cell_triangles(&pieces) - { - for points in triangles { - let front = points.map(|q| camera.place(q, half + lift(q))); - b.smooth_triangle(front, points.map(|q| camera.rotate(normal(q))), false)?; - if o.both_sides && o.cap { - let back = points.map(|q| camera.place(back_turn.apply(q), -half - lift(q) * back_turn.scale)); - let normals = points.map(|q| { - let n = normal(q); - // A uniform taper scales all three coordinates equally; - // twist turns the back normal about the shape's z-axis. - camera.rotate(V3(back_turn.cos * n.0 - back_turn.sin * n.1, back_turn.sin * n.0 + back_turn.cos * n.1, -n.2)) - }); - b.smooth_triangle(back, normals, true)?; - } - } - continue; - } - let lifts: Vec> = pieces.iter().map(|p| p.iter().map(|q| lift(*q)).collect()).collect(); - // Each piece lifted onto the surface. Its normal is the lifted piece's own average - // (Newell's), not the slope at one point: near the edge the surface turns - // vertical, and a point sample there misjudges which way a whole piece faces. - let front: Vec> = - pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, half + h)).collect()).collect(); - let n = newell(&front); - let order = if flat { Order::Last } else { Order::Depth }; - b.face(front, n, order)?; - if o.both_sides && o.cap { - // The back winds the same way round as the front, so its outward normal is - // reversed. - let back: Vec> = pieces - .iter() - .zip(&lifts) - .map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(back_turn.apply(*q), -half - h * back_turn.scale)).collect()) - .collect(); - let n = newell(&back); - b.face(back, V3(-n.0, -n.1, -n.2), order)?; - } - } - } - } - } - if !o.both_sides && o.cap { - let back: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(back_turn.apply(*p), -half)).collect()).collect(); - // The back turned toward the viewer covers the walls it overlaps, even those nearer than - // its center: twisted walls, and the walls of a fill inside the solid of its stroke. - b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)), Order::Last)?; - } - if o.depth > 0.0 { - // The sides, cut into slices that follow a twist or taper. - let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; - let at = |ring: &[Point], k: usize| -> Vec { - let t = k as f64 / slices as f64; - let turn = Turn::new(bounds, o.twist, o.taper, t); - ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() - }; - let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); - fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.facets.len())); - for (ring, pieces) in rings.iter().zip(&pieces) { - for k in 0..slices { - b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; - } - } - } - Ok(b.finish()) -} - #[cfg(test)] mod tests; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs new file mode 100644 index 000000000..c9527edaf --- /dev/null +++ b/crates/three-d/src/solid/build.rs @@ -0,0 +1,460 @@ +use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, rings, signed_area}; +use super::*; + +/// Turns 3D points into document points and shades faces for one view. +pub(super) struct Camera { + pub(super) view: Revolve, + origin: Point, + /// The eye's distance in front of the centre; `None` without perspective. + eye: Option, + /// Deeper than any part of the object: faces drawn first go this far behind it. + far: f64, + pub(super) light: V3, +} + +impl Camera { + pub(super) fn new(view: View, bounds: Rect, depth: f64) -> Self { + let view = view.revolve(); + let [lx, ly, lz] = view.light_direction(); + let size = (bounds.width() / 2.0).max(bounds.height() / 2.0).max(depth / 2.0).max(1.0); + Self { view, origin: bounds.center(), eye: eye_distance(view.perspective, size), far: 16.0 * size, light: V3(lx, ly, lz) } + } + pub(super) fn rotate(&self, v: V3) -> V3 { + self.view.rotate(v) + } + /// A point of the art, `z` points in front of its plane, in view space. + pub(super) fn place(&self, p: Point, z: f64) -> V3 { + self.rotate(V3(p.x - self.origin.x, p.y - self.origin.y, z)) + } + pub(super) fn project(&self, v: V3) -> Point { + let f = perspective_scale(self.eye, v.2); + Point::new(self.origin.x + v.0 * f, self.origin.y + v.1 * f) + } + /// The cosine between a face's outward unit normal `n` and the direction from `at` (both in + /// view space) to the eye: positive when the face is turned towards the viewer. + pub(super) fn facing(&self, at: V3, n: V3) -> f64 { + let to_eye = self.eye.map_or(V3(0.0, 0.0, 1.0), |e| V3(0.0, 0.0, e).sub(at).unit()); + n.dot(to_eye) + } + pub(super) fn brightness(&self, n: V3) -> f64 { + crate::brightness(&self.view, n, self.light) + } +} + +/// Facets being collected for one solid. +pub(super) struct Builder<'a> { + pub(super) camera: &'a Camera, + /// A closed solid hides the faces turned away from the viewer; an open one shows their + /// insides. + pub(super) closed: bool, + pub(super) facets: Vec, +} + +/// Where a face goes in the order the facets are drawn, back to front. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Order { + /// The face is sorted by the depth of its center, except that a face seen almost edge-on goes + /// first, under everything. + Depth, + /// The face is sorted by the depth of its center even when it is seen almost edge-on, so that + /// a piece of a twisted wall at the solid's outline covers the walls behind it. + Twisted, + /// The face is drawn after every other face when it is turned toward the viewer. A flat end of + /// the solid has the whole solid behind its plane, so it covers whatever it overlaps. Its depth + /// is `f64::MAX`, so the faces drawn last by several solids sorted together keep the order they + /// were added in. + Last, +} + +impl Builder<'_> { + /// Add a face of view-space contours with outward normal `n` (view space), drawn in `order`. + pub(super) fn face(&mut self, contours: Vec>, n: V3, order: Order) -> Result<(), String> { + let count: usize = contours.iter().map(Vec::len).sum(); + if count < 3 { + return Ok(()); + } + let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); + let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); + let n = n.unit(); + let facing = self.camera.facing(centre, n); + // A closed solid hides the faces turned away. One seen almost exactly edge-on is kept and, + // unless it is a piece of a twisted wall, drawn first, under everything: on a curved + // surface rounding alone turns such faces either way, and dropping every other one would + // leave gaps. + let (n, behind) = if facing > 0.0 { + (n, false) + } else if self.closed && facing > -0.02 { + (n, true) + } else if self.closed { + return Ok(()); + } else { + (V3(-n.0, -n.1, -n.2), false) + }; + #[cfg(test)] + let view = contours.clone(); + let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); + if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err("The 3D projection is not finite".into()); + } + if contours.iter().map(|c| signed_area(c).abs()).sum::() < 1e-10 { + return Ok(()); + } + let depth = if behind && order != Order::Twisted { + centre.2 - self.camera.far + } else if order == Order::Last && facing > 0.0 { + f64::MAX + } else { + centre.2 + }; + self.facets.push(Facet { + contours, + depth, + brightness: self.camera.brightness(n), + illumination: crate::illumination(&self.camera.view, n, self.camera.light), + rgb_gradient: None, + #[cfg(test)] + view, + }); + if self.facets.len() > MAX_FACES { + return Err("The 3D surface is too complex; simplify the shape".into()); + } + Ok(()) + } + + /// A planar surface triangle with shared, smooth outward normals. Culling and sorting + /// still use its geometry, rather than the normals used only for lighting. + pub(super) fn smooth_triangle(&mut self, vertices: [V3; 3], normals: [V3; 3], back: bool) -> Result<(), String> { + let contours = vec![vertices.to_vec()]; + let n = newell(&contours); + let n = if back { V3(-n.0, -n.1, -n.2) } else { n }; + let centre = vertices.iter().fold(V3::default(), |s, v| V3(s.0 + v.0 / 3.0, s.1 + v.1 / 3.0, s.2 + v.2 / 3.0)); + let flip = !self.closed && self.camera.facing(centre, n.unit()) <= 0.0; + let before = self.facets.len(); + self.face(contours, n, Order::Depth)?; + if self.facets.len() == before || !self.camera.view.shade { + return Ok(()); + } + let values: Vec<_> = normals + .into_iter() + .map(|n| crate::unclipped_illumination(&self.camera.view, if flip { V3(-n.0, -n.1, -n.2) } else { n }, self.camera.light)) + .collect(); + if let Some(face) = self.facets.last_mut() { + let points = face.contours.first().map(Vec::as_slice).unwrap_or_default(); + face.rgb_gradient = crate::RgbGradient::triangle(points, &values); + } + Ok(()) + } + + /// The band between two partner rings (same length), as one quad per edge, or for a twisted + /// wall with `cuts` (see [`cuts`]) as `cuts[i]` quads along edge `i`. `outward` is the + /// direction (before rotation) the band faces away from the solid, for orienting normals. + pub(super) fn band(&mut self, a: &[V3], b: &[V3], cuts: &[usize], outward: impl Fn(usize) -> V3) -> Result<(), String> { + let n = a.len().min(b.len()); + // At 0 and 1, the corners themselves. + let lerp = |p: V3, q: V3, s: f64| { + if s <= 0.0 { + p + } else if s >= 1.0 { + q + } else { + V3(p.0 + (q.0 - p.0) * s, p.1 + (q.1 - p.1) * s, p.2 + (q.2 - p.2) * s) + } + }; + let order = if cuts.is_empty() { Order::Depth } else { Order::Twisted }; + for i in 0..n { + let j = (i + 1) % n; + let pieces = cuts.get(i).copied().unwrap_or(1).max(1); + for k in 0..pieces { + let (s0, s1) = (k as f64 / pieces as f64, (k + 1) as f64 / pieces as f64); + let quad = vec![lerp(a[i], a[j], s0), lerp(a[i], a[j], s1), lerp(b[i], b[j], s1), lerp(b[i], b[j], s0)]; + // A twist bends the strip between two slices into a saddle: its normal is the whole + // quad's average (Newell's), not one corner's triangle, which leans far to one side. + let mut normal = newell(std::slice::from_ref(&quad)); + if normal.dot(normal) < 1e-18 { + continue; + } + if normal.dot(self.camera.rotate(outward(i))) < 0.0 { + normal = V3(-normal.0, -normal.1, -normal.2); + } + self.face(vec![quad], normal, order)?; + } + } + Ok(()) + } + + pub(super) fn finish(mut self) -> Vec { + self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + self.facets + } +} + +/// How many pieces each edge of a twisted wall is drawn in, from two neighboring slices of the +/// wall, `a` and `b` (partner rings): as many as make each piece about as long along the edge as +/// the band between the slices is wide there, 1 to [`MAX_PIECES`]. Twisted walls pass in front of +/// one another, and a strip the length of its edge has no one depth where another crosses it. +pub(super) fn cuts(a: &[V3], b: &[V3]) -> Vec { + let n = a.len().min(b.len()); + let length = |p: V3, q: V3| q.sub(p).dot(q.sub(p)).sqrt(); + (0..n) + .map(|i| { + let j = (i + 1) % n; + let (along, across) = (length(a[i], a[j]).max(length(b[i], b[j])), length(a[i], b[i]).max(length(a[j], b[j]))); + let count = (along / across.max(1e-9)).ceil(); + if count.is_finite() { (count as usize).clamp(1, MAX_PIECES) } else { MAX_PIECES } + }) + .collect() +} + +/// Lowers the [`cuts`] of every ring's edges (one list per ring) so that `bands` bands of each +/// make no more than `room` quads in all, or one per edge and band when that is more: the pieces +/// past each edge's first share the room in proportion. +pub(super) fn fit(cuts: &mut [Vec], bands: usize, room: usize) { + let edges = cuts.iter().map(Vec::len).sum::().saturating_mul(bands); + let total = cuts.iter().flatten().sum::().saturating_mul(bands); + let room = room.max(edges); + if total > room { + let share = (room - edges) as f64 / (total - edges) as f64; + for c in cuts.iter_mut().flatten() { + *c = 1 + ((c.saturating_sub(1)) as f64 * share).floor() as usize; + } + } +} + +/// The area-weighted normal of (possibly non-planar) contours, by Newell's method: for contours +/// winding with their filled side on the left as seen from in front, it points to the front. +pub(super) fn newell(contours: &[Vec]) -> V3 { + let mut n = V3::default(); + for c in contours { + for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) { + let x = a.cross(*b); + n = V3(n.0 + x.0, n.1 + x.1, n.2 + x.2); + } + } + n +} + +pub(super) fn check_bounds(b: Rect) -> Result<(), String> { + if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + Ok(()) + } else { + Err("3D effects require finite coordinates within 100 million points".into()) + } +} + +/// The art's outward direction at edge `i` of `ring` (before rotation), tilted towards the face +/// it borders by `z` (+1 front, -1 back, 0 a side wall). +pub(super) fn outward(ring: &[Point], i: usize, z: f64) -> V3 { + let n = ring.len(); + let (lx, ly) = left(ring[i], ring[(i + 1) % n]); + V3(-lx, -ly, z) +} + +/// The cross-section of a side: each ring of the shape at depth fraction `t` (0 front, 1 back), +/// turned by the twist and scaled by the taper about the centre of `bounds`. +pub(super) fn section(ring: &[Point], bounds: Rect, o: &Extrude, t: f64) -> Vec { + let turn = Turn::new(bounds, o.twist, o.taper, t); + ring.iter().map(|p| turn.apply(*p)).collect() +} + +/// The twist and taper at depth fraction `t` (0 front, 1 back): a turn by `twist · t` and a +/// scale from 1 to `taper`, about the centre of the bounds. +#[derive(Clone, Copy)] +pub(super) struct Turn { + centre: Point, + pub(super) sin: f64, + pub(super) cos: f64, + pub(super) scale: f64, +} + +impl Turn { + pub(super) fn new(bounds: Rect, twist: f64, taper: f64, t: f64) -> Self { + let (sin, cos) = (twist * t).to_radians().sin_cos(); + Self { centre: bounds.center(), sin, cos, scale: 1.0 + (taper - 1.0) * t } + } + pub(super) fn apply(&self, p: Point) -> Point { + let (x, y) = ((p.x - self.centre.x) * self.scale, (p.y - self.centre.y) * self.scale); + Point::new(self.centre.x + x * self.cos - y * self.sin, self.centre.y + x * self.sin + y * self.cos) + } +} + +/// One step of an extrusion's outline: how far it is inset (negative: outset) and its depth +/// fraction (0 front, 1 back), with the face it borders (+1 front bevel, -1 back bevel, 0 wall). +#[derive(Clone, Copy)] +struct Step { + inset: f64, + t: f64, + face: f64, +} + +/// The outline's steps from the front cap's edge to the back cap's edge. +fn steps(o: &Extrude) -> Vec { + let d = o.depth.max(1e-6); + let mut out = vec![]; + let bevel = o.bevel.filter(|b| b.width > 0.0 || b.height > 0.0); + // A bevel can't be deeper than the extrusion (half of it on each side with both sides). + let sides = if bevel.is_some_and(|b| b.both_sides) { 2.0 } else { 1.0 }; + let height = bevel.map_or(0.0, |b| b.height.min(o.depth / sides)); + // The bevel's cross-section across the width: (share across, share down). + let profile = |b: &Bevel| -> Vec<(f64, f64)> { + let mut pts = vec![(0.0, 0.0)]; + let n = b.repeat.max(1) as f64; + let run = (1.0 - b.space) / n; + let gap = if b.repeat > 1 { b.space / (n - 1.0) } else { 0.0 }; + for k in 0..b.repeat.max(1) { + let start = k as f64 * (run + gap); + for s in b.shape.samples().into_iter().skip(1) { + let (x, y) = b.shape.at(s); + pts.push((start + x * run, (k as f64 + y) / n)); + } + if b.repeat > 1 && k + 1 < b.repeat { + pts.push((start + run + gap, (k as f64 + 1.0) / n)); + } + } + if let Some(last) = pts.last_mut() { + last.0 = 1.0; + } + pts + }; + // With the bevel inside, the cap is inset by its width and the wall is the outline; outside, + // the cap is the outline and the wall is outset by the width. + let wall = match bevel { + Some(b) if !b.inside => -b.width, + _ => 0.0, + }; + // A twist turns a bevel's steps too: each step between two samples is cut into steps that + // turn no further than 6°, as the walls' slices do at most. + let refine = |pts: Vec<(f64, f64)>| -> Vec<(f64, f64)> { + if o.twist == 0.0 { + return pts; + } + let mut out = Vec::with_capacity(pts.len()); + for (k, p) in pts.iter().enumerate() { + if let Some(q) = k.checked_sub(1).and_then(|k| pts.get(k)) { + let turn = (p.1 - q.1).abs() * height / d * o.twist.abs(); + let n = ((turn / 6.0).ceil() as usize).clamp(1, 64); + for m in 1..n { + let s = m as f64 / n as f64; + out.push((q.0 + (p.0 - q.0) * s, q.1 + (p.1 - q.1) * s)); + } + } + out.push(*p); + } + out + }; + if let Some(b) = bevel { + for (x, y) in refine(profile(&b)) { + let inset = if b.inside { b.width * (1.0 - x) } else { -b.width * x }; + out.push(Step { inset, t: y * height / d, face: 1.0 }); + } + } else { + out.push(Step { inset: 0.0, t: 0.0, face: 1.0 }); + } + // Twist and taper bend the walls: cut them into slices. + let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; + let back_height = if bevel.is_some_and(|b| b.both_sides) { height } else { 0.0 }; + let (t0, t1) = (height / d, 1.0 - back_height / d); + for k in 0..=slices { + let t = t0 + (t1 - t0) * k as f64 / slices as f64; + out.push(Step { inset: wall, t, face: 0.0 }); + } + if let Some(b) = bevel.filter(|b| b.both_sides) { + for (x, y) in refine(profile(&b)).into_iter().rev() { + let inset = if b.inside { b.width * (1.0 - x) } else { -b.width * x }; + out.push(Step { inset, t: 1.0 - y * height / d, face: -1.0 }); + } + } + out.dedup_by(|a, b| (a.inset - b.inset).abs() < 1e-9 && (a.t - b.t).abs() < 1e-12); + out +} + +/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels +/// and caps. +pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { + check_bounds(bounds)?; + let o = options.sanitized(); + // No depth: the flat shape, seen from either side. + if o.depth <= 0.0 { + return plane(path, rule, bounds, view); + } + let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let camera = Camera::new(view, bounds, o.depth); + let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let steps = steps(&o); + let z = |t: f64| o.depth / 2.0 - o.depth * t; + let ring_at = + |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; + // Every band of a twisted outline, bevels included, is cut as its walls' first slice is (with + // bevels and no walls, as its shallowest band is), within the facet limit. + let depth_of = |s: &[Step]| s.get(1).zip(s.first()).map_or(0.0, |(b, a)| (b.t - a.t).abs()); + let wall = steps + .windows(2) + .find(|s| s[0].face == 0.0 && s[1].face == 0.0) + .or_else(|| steps.windows(2).filter(|s| depth_of(s) > 1e-12).min_by(|a, c| depth_of(a).total_cmp(&depth_of(c)))); + let mut pieces: Vec> = rings + .iter() + .map(|ring| match wall { + Some([s0, s1]) if o.twist != 0.0 => cuts(&ring_at(ring, s0), &ring_at(ring, s1)), + _ => vec![], + }) + .collect(); + fit(&mut pieces, steps.len().saturating_sub(1), MAX_FACES); + for (ring, pieces) in rings.iter().zip(&pieces) { + let placed: Vec> = steps.iter().map(|s| ring_at(ring, s)).collect(); + for (k, pair) in placed.windows(2).enumerate() { + let ([a, c], Some(s0), Some(s1)) = (pair, steps.get(k), steps.get(k + 1)) else { continue }; + // A band between two front bevel steps borders the front, one reaching a back bevel + // step the back; the rest are walls. + let face = if s1.face < 0.0 { + -1.0 + } else if s0.face > 0.0 && s1.face > 0.0 { + 1.0 + } else { + 0.0 + }; + b.band(a, c, pieces, |i| outward(ring, i, face))?; + } + } + if o.cap { + // A cap turned toward the viewer covers the walls it overlaps, even those nearer than its + // center: twisted walls, and the walls of a fill inside the solid of its stroke. + for (s, n) in [(steps.first(), 1.0), (steps.last(), -1.0)] { + let Some(s) = s else { continue }; + b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), Order::Last)?; + } + } + Ok(b.finish()) +} + +/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the +/// shape's own, so that every part of one object turns about the same point). +pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { + check_bounds(bounds)?; + let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + let camera = Camera::new(view, bounds, 0.0); + let mut b = Builder { camera: &camera, closed: false, facets: vec![] }; + let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); + // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so + // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. + let up = camera.rotate(V3(0.0, 0.0, 1.0)); + let n = if camera.facing(V3::default(), up) < 0.0 { V3(-up.0, -up.1, -up.2) } else { up }; + b.face(contours, n, Order::Last)?; + Ok(b.finish()) +} + +/// The distance from `p` to the segment `a`–`b`. +pub(super) fn segment_distance(p: Point, a: Point, b: Point) -> f64 { + let (dx, dy) = (b.x - a.x, b.y - a.y); + let l2 = dx * dx + dy * dy; + let t = if l2 < 1e-18 { 0.0 } else { (((p.x - a.x) * dx + (p.y - a.y) * dy) / l2).clamp(0.0, 1.0) }; + p.distance(Point::new(a.x + dx * t, a.y + dy * t)) +} + +/// The distance from `p` to the nearest edge of `rings`, edge by edge. +#[cfg(test)] +pub(super) fn edge_distance(rings: &[Vec], p: Point) -> f64 { + rings + .iter() + .flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len())) + .map(|(a, b)| segment_distance(p, *a, *b)) + .fold(f64::INFINITY, f64::min) +} diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs new file mode 100644 index 000000000..be5aec370 --- /dev/null +++ b/crates/three-d/src/solid/inflate.rs @@ -0,0 +1,305 @@ +use super::build::{Builder, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; +use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; +use super::rings::{MAX_SOLID_POINTS, rings, signed_area}; +use super::*; + +/// A shape to inflate: its rings and the membrane under them on the grid of the bounds. +struct Puff { + rings: Vec>, + /// The index of the rings' edges. + edges: Edges, + cell: f64, + nx: usize, + ny: usize, + m: Membrane, + /// How high the front rises at its peak. + height: f64, +} + +/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. +fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { + let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + if rings.is_empty() { + return Ok(None); + } + let size = bounds.width().max(bounds.height()); + if size <= 0.0 { + return Ok(None); + } + const CELLS: usize = 48; + let cell = size / CELLS as f64; + let (nx, ny) = (((bounds.width() / cell).ceil() as usize).max(1), ((bounds.height() / cell).ceil() as usize).max(1)); + let edges = Edges::new(&rings); + let m = Membrane::solve(&rings, &edges, bounds.origin(), cell, nx + 1, ny + 1); + // A circle of radius r puffs into a half sphere of radius r at 50 % (its membrane peaks at r²/4). + let height = 2.0 * m.peak.sqrt() * o.volume / 50.0; + Ok(Some(Puff { rings, edges, cell, nx, ny, m, height })) +} + +/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is. +pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result, String> { + inflate_together(&[(path, rule)], bounds, options, view).pop().unwrap_or(Ok(vec![])) +} + +/// Inflate for several shapes of one object (a fill and the outlines of its strokes): each puffs +/// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest +/// puff needs with Perspective. One result for each shape, in order. +pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { + let o = options.sanitized(); + let puffs: Vec, String>> = + shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); + let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); + puffs + .into_iter() + .map(|p| match p { + Ok(Some(p)) => inflated(p, bounds, o, view, reach), + Ok(None) => Ok(vec![]), + Err(e) => Err(e), + }) + .collect() +} + +/// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result, String> { + let Puff { rings, edges, cell, nx, ny, m, height } = p; + let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings + .iter() + .filter_map(|r| { + let b = r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p)); + Some((b, vectorcraft_geom::SubPath::polyline(r, true))) + }) + .collect(); + // When no shape puffs (no volume, or shapes too narrow for the grid), every front (and a back + // that puffs too) lies flat in one plane: its cells are drawn last, as caps are. If one shape + // puffs, a flat front can be behind its puff, so the cells keep their depths. + let flat = reach <= 0.0; + let lift = |p: Point| { + if m.peak <= 0.0 || edges.near(p, 1e-9) { + return 0.0; + } + height * (m.at(p).max(0.0) / m.peak).sqrt() + }; + let normal = |p: Point| { + let (dx, dy) = m.gradient(p); + let (_, inward, distance) = edges.closest(p); + let t = (distance / (2.0 * cell)).clamp(0.0, 1.0); + let weight = (1.0 - t).powi(2) * (1.0 + 2.0 * t); + let gradient = Vec2::new(dx, dy); + let slope = gradient.dot(inward).max(0.0); + // The zero boundary condition also fixes the tangential derivative at the outline. + // Extrapolated outside solver nodes cannot supply it reliably. Blend to that known + // direction and its limiting phi near the rim, without showing the grid in highlights. + let gradient = gradient * (1.0 - weight) + inward * (slope * weight); + let phi = m.at(p).max(0.0) * (1.0 - weight) + slope * distance * weight; + // Multiplying the height-function normal by 2√(peak φ) avoids division by + // zero at the rim, where the limiting normal is horizontal. + let n = V3(-height * gradient.x, -height * gradient.y, 2.0 * (m.peak * phi).sqrt()).unit(); + if n.dot(n) > 0.5 { n } else { V3(0.0, 0.0, 1.0) } + }; + let smooth = view.shade && view.has_colored_lights(); + let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); + // Without its back cap the object is a shell open behind, its inside showing. + let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let half = o.depth / 2.0; + // The back: turned and scaled by the twist and taper, its puff too. + let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); + // The grid's cells, those the surface rises steeply across (near the outline) cut into + // halves or quarters each way, so the steep rim is drawn in facets as fine as its curve. + // Every corner sits on one fine lattice (a quarter cell), computed the same way wherever it + // is used, and a cell's border takes its finer neighbour's lattice points too: where cells + // of different fineness meet, their edges follow the surface through the same points, with + // no cracks. + const FINE: usize = 4; + let unit = cell / FINE as f64; + let at = |qx: usize, qy: usize| Point::new(bounds.x0 + qx as f64 * unit, bounds.y0 + qy as f64 * unit); + let base = |i: usize, j: usize| Rect::from_points(at(i * FINE, j * FINE), at((i + 1) * FINE, (j + 1) * FINE)); + // Segments the booleans of mixed cells may take in all ([`MAX_MATERIAL_SEGMENTS`]): each one + // builds the topology of every outline ring near its cell, so a self-crossing outline cost its + // crossings again in every cell it spans. Past it, cells take their clipped pieces. + let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS); + // The shape's part of a rectangle, as rings. + let cut = |r: Rect| -> Vec> { + // A cell no edge crosses is wholly inside the shape or wholly outside it. + if !edges.touch(r) { + let corners = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + return if filled(&rings, r.center()) { vec![corners] } else { vec![] }; + } + let pieces: Vec> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect(); + if pieces.iter().map(|p| signed_area(p)).sum::() <= 1e-9 * r.area() { + return vec![]; + } + // Where a hole's piece cancels part of an outline's, that only holds flat: lifted onto + // the surface the two no longer meet. Such a cell takes the shape's own part of it. + let mixed = pieces.iter().any(|p| signed_area(p) < 0.0) && pieces.iter().any(|p| signed_area(p) > 0.0); + if !mixed { + return pieces; + } + let near: usize = + outline.iter().filter(|(b, _)| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1).map(|(_, sp)| sp.anchors.len()).sum(); + match material_left.get().checked_sub(near) { + Some(left) => { + material_left.set(left); + material(&outline, r).unwrap_or(pieces) + } + None => pieces, + } + }; + // How many times each cell is halved each way (0, 1 or 2), from how steeply it rises. + let levels: Vec = (0..nx * ny) + .map(|k| { + let pieces = cut(base(k % nx, k / nx)); + let (lo, hi) = pieces.iter().flatten().map(|q| lift(*q)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| (lo.min(h), hi.max(h))); + let rise = if pieces.is_empty() { 0.0 } else { (hi - lo) / cell }; + if rise > 6.0 { + 2 + } else if rise > 2.0 { + 1 + } else { + 0 + } + }) + .collect(); + let level = + |i: isize, j: isize| -> u32 { if i < 0 || j < 0 || i as usize >= nx || j as usize >= ny { 0 } else { levels[j as usize * nx + i as usize] } }; + for j in 0..ny { + for i in 0..nx { + let l = level(i as isize, j as isize); + let step = FINE >> l; + // The lattice spacing along each side of this cell: its own or its neighbour's. + let (ii, jj) = (i as isize, j as isize); + let side_step = |dl: u32| FINE >> l.max(dl); + let (left, right, top, bottom) = + (side_step(level(ii - 1, jj)), side_step(level(ii + 1, jj)), side_step(level(ii, jj - 1)), side_step(level(ii, jj + 1))); + let (bx0, by0, bx1, by1) = (i * FINE, j * FINE, (i + 1) * FINE, (j + 1) * FINE); + for sy in (by0..by1).step_by(step) { + for sx in (bx0..bx1).step_by(step) { + let r = Rect::from_points(at(sx, sy), at(sx + step, sy + step)); + let pieces = cut(r); + if pieces.is_empty() { + continue; + } + // Lattice points along the cell's own border, between each edge's ends. + let stitch = |a: Point, b: Point| -> Vec { + let tol = 1e-7 * unit; + let along = |fixed_x: Option, fixed_y: Option, every: usize| -> Vec { + let mut out = vec![]; + match (fixed_x, fixed_y) { + (Some(qx), None) => { + let (lo, hi) = (a.y.min(b.y), a.y.max(b.y)); + for qy in (by0..=by1).step_by(every) { + let p = at(qx, qy); + if p.y > lo + tol && p.y < hi - tol { + out.push(p); + } + } + } + (None, Some(qy)) => { + let (lo, hi) = (a.x.min(b.x), a.x.max(b.x)); + for qx in (bx0..=bx1).step_by(every) { + let p = at(qx, qy); + if p.x > lo + tol && p.x < hi - tol { + out.push(p); + } + } + } + _ => {} + } + if (a.x > b.x) || (a.y > b.y) { + out.reverse(); + } + out + }; + let on = |v: f64, w: f64| (v - w).abs() <= tol; + let (x0, x1, y0, y1) = (at(bx0, by0).x, at(bx1, by1).x, at(bx0, by0).y, at(bx1, by1).y); + if on(a.x, x0) && on(b.x, x0) { + along(Some(bx0), None, left) + } else if on(a.x, x1) && on(b.x, x1) { + along(Some(bx1), None, right) + } else if on(a.y, y0) && on(b.y, y0) { + along(None, Some(by0), top) + } else if on(a.y, y1) && on(b.y, y1) { + along(None, Some(by1), bottom) + } else { + vec![] + } + }; + let pieces: Vec> = pieces + .iter() + .map(|p| { + let mut out = Vec::with_capacity(p.len()); + for (a, b) in p.iter().zip(p.iter().cycle().skip(1)).take(p.len()) { + out.push(*a); + out.extend(stitch(*a, *b)); + } + out + }) + .collect(); + if height > 0.0 + && smooth + && let Some(triangles) = cell_triangles(&pieces) + { + for points in triangles { + let front = points.map(|q| camera.place(q, half + lift(q))); + b.smooth_triangle(front, points.map(|q| camera.rotate(normal(q))), false)?; + if o.both_sides && o.cap { + let back = points.map(|q| camera.place(back_turn.apply(q), -half - lift(q) * back_turn.scale)); + let normals = points.map(|q| { + let n = normal(q); + // A uniform taper scales all three coordinates equally; + // twist turns the back normal about the shape's z-axis. + camera.rotate(V3(back_turn.cos * n.0 - back_turn.sin * n.1, back_turn.sin * n.0 + back_turn.cos * n.1, -n.2)) + }); + b.smooth_triangle(back, normals, true)?; + } + } + continue; + } + let lifts: Vec> = pieces.iter().map(|p| p.iter().map(|q| lift(*q)).collect()).collect(); + // Each piece lifted onto the surface. Its normal is the lifted piece's own average + // (Newell's), not the slope at one point: near the edge the surface turns + // vertical, and a point sample there misjudges which way a whole piece faces. + let front: Vec> = + pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, half + h)).collect()).collect(); + let n = newell(&front); + let order = if flat { Order::Last } else { Order::Depth }; + b.face(front, n, order)?; + if o.both_sides && o.cap { + // The back winds the same way round as the front, so its outward normal is + // reversed. + let back: Vec> = pieces + .iter() + .zip(&lifts) + .map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(back_turn.apply(*q), -half - h * back_turn.scale)).collect()) + .collect(); + let n = newell(&back); + b.face(back, V3(-n.0, -n.1, -n.2), order)?; + } + } + } + } + } + if !o.both_sides && o.cap { + let back: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(back_turn.apply(*p), -half)).collect()).collect(); + // The back turned toward the viewer covers the walls it overlaps, even those nearer than + // its center: twisted walls, and the walls of a fill inside the solid of its stroke. + b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)), Order::Last)?; + } + if o.depth > 0.0 { + // The sides, cut into slices that follow a twist or taper. + let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; + let at = |ring: &[Point], k: usize| -> Vec { + let t = k as f64 / slices as f64; + let turn = Turn::new(bounds, o.twist, o.taper, t); + ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() + }; + let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.facets.len())); + for (ring, pieces) in rings.iter().zip(&pieces) { + for k in 0..slices { + b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + } + } + } + Ok(b.finish()) +} diff --git a/crates/three-d/src/solid/membrane.rs b/crates/three-d/src/solid/membrane.rs new file mode 100644 index 000000000..296b659df --- /dev/null +++ b/crates/three-d/src/solid/membrane.rs @@ -0,0 +1,400 @@ +use super::build::segment_distance; +use super::rings::{MAX_PLANE_POINTS, contains, left, rings, signed_area}; +use super::*; + +/// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is +/// found among the buckets around it rather than among all the edges. +pub(super) struct Edges { + edges: Vec<(Point, Point)>, + normals: Vec<(Vec2, Vec2)>, + origin: Point, + size: f64, + n: usize, + buckets: Vec>, +} + +impl Edges { + pub(super) fn new(rings: &[Vec]) -> Self { + const N: usize = 64; + let edges: Vec<(Point, Point)> = + rings.iter().flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| (*a, *b))).collect(); + let normals = rings + .iter() + .flat_map(|r| { + let normal = |i: usize| { + let (x, y) = left(r[i], r[(i + 1) % r.len()]); + Vec2::new(x, y) + }; + (0..r.len()).map(move |i| { + let n = normal(i); + let at = |other: Vec2| if n.dot(other) > 0.7 { (n + other).normalize() } else { n }; + (at(normal((i + r.len() - 1) % r.len())), at(normal((i + 1) % r.len()))) + }) + }) + .collect(); + let bounds = edges.iter().fold(None, |b: Option, (a, c)| Some(b.map_or(Rect::from_points(*a, *c), |b| b.union_pt(*a).union_pt(*c)))); + let bounds = bounds.unwrap_or(Rect::ZERO); + let size = (bounds.width().max(bounds.height()) / N as f64).max(1e-9); + let mut buckets = vec![vec![]; N * N]; + let index = |v: f64, o: f64| (((v - o) / size).floor().max(0.0) as usize).min(N - 1); + for (k, (a, b)) in edges.iter().enumerate() { + let (i0, i1) = (index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)); + let (j0, j1) = (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0)); + for j in j0..=j1 { + for i in i0..=i1 { + buckets[j * N + i].push(k as u32); + } + } + } + Self { edges, normals, origin: bounds.origin(), size, n: N, buckets } + } + + /// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis. + fn cell(&self, v: f64, o: f64) -> isize { + (((v - o) / self.size).floor() as isize).clamp(0, self.n as isize - 1) + } + + /// Is `p` within `tolerance` of an edge? Only the buckets `p` could share an edge with are + /// looked at. + pub(super) fn near(&self, p: Point, tolerance: f64) -> bool { + self.touch(Rect::new(p.x - tolerance, p.y - tolerance, p.x + tolerance, p.y + tolerance)) && { + let n = self.n as isize; + let (i0, i1) = (self.cell(p.x - tolerance, self.origin.x), self.cell(p.x + tolerance, self.origin.x)); + let (j0, j1) = (self.cell(p.y - tolerance, self.origin.y), self.cell(p.y + tolerance, self.origin.y)); + (j0..=j1).any(|j| { + (i0..=i1).any(|i| { + self.buckets[(j * n + i) as usize] + .iter() + .filter_map(|k| self.edges.get(*k as usize)) + .any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) + }) + }) + } + } + + /// Call `f` with each edge whose bounding box overlaps `r` (an edge in several of the + /// buckets `r` covers comes more than once). + fn each(&self, r: Rect, mut f: impl FnMut(Point, Point)) { + let n = self.n as isize; + for j in self.cell(r.y0, self.origin.y)..=self.cell(r.y1, self.origin.y) { + for i in self.cell(r.x0, self.origin.x)..=self.cell(r.x1, self.origin.x) { + for (a, b) in self.buckets[(j * n + i) as usize].iter().filter_map(|k| self.edges.get(*k as usize)) { + if a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 { + f(*a, *b); + } + } + } + } + } + + /// Does any edge pass through `r` (its bounding box, at least, overlapping it)? + pub(super) fn touch(&self, r: Rect) -> bool { + let n = self.n as isize; + let (i0, i1) = (self.cell(r.x0, self.origin.x), self.cell(r.x1, self.origin.x)); + let (j0, j1) = (self.cell(r.y0, self.origin.y), self.cell(r.y1, self.origin.y)); + (j0..=j1).any(|j| { + (i0..=i1).any(|i| { + self.buckets[(j * n + i) as usize] + .iter() + .filter_map(|k| self.edges.get(*k as usize)) + .any(|(a, b)| a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0) + }) + }) + } + + /// The distance from `p` to the nearest edge: the buckets are searched in growing squares + /// around `p`'s, until no unsearched bucket could hold a nearer edge. + pub(super) fn distance(&self, p: Point) -> f64 { + self.closest(p).2 + } + + /// Closest outline point, its inward normal and distance. Curved outline samples share + /// corner normals, while a sharp source corner keeps each edge's own direction. + pub(super) fn closest(&self, p: Point) -> (Point, Vec2, f64) { + let (fx, fy) = ((p.x - self.origin.x) / self.size, (p.y - self.origin.y) / self.size); + if !fx.is_finite() || !fy.is_finite() { + return (p, Vec2::ZERO, f64::INFINITY); + } + let n = self.n as isize; + let (ci, cj) = ((fx.floor() as isize).clamp(0, n - 1), (fy.floor() as isize).clamp(0, n - 1)); + // How far `p` is outside the grid: the nearest point of the grid to it lies in `p`'s bucket. + let outside = (fx.clamp(0.0, n as f64) - fx).hypot(fy.clamp(0.0, n as f64) - fy) * self.size; + let mut best = f64::INFINITY; + let mut nearest = (p, Vec2::ZERO); + for r in 0..n { + for j in (cj - r)..=(cj + r) { + for i in (ci - r)..=(ci + r) { + if (j - cj).abs() != r && (i - ci).abs() != r || i < 0 || j < 0 || i >= n || j >= n { + continue; + } + for k in &self.buckets[(j * n + i) as usize] { + if let Some((a, b)) = self.edges.get(*k as usize) { + let d = *b - *a; + let t = if d.hypot2() < 1e-18 { 0.0 } else { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) }; + let q = Point::new(a.x + d.x * t, a.y + d.y * t); + let distance = p.distance(q); + if distance < best { + best = distance; + let n = self.normals.get(*k as usize).map_or(Vec2::ZERO, |(a, b)| *a * (1.0 - t) + *b * t); + nearest = (q, if n.hypot2() > 0.0 { n.normalize() } else { n }); + } + } + } + } + } + // Buckets r + 1 away are at least r buckets from any point of `p`'s own, and that + // far across the grid from a `p` outside it (at right angles to its way in, at worst). + if best <= outside.hypot(r as f64 * self.size) { + break; + } + } + (nearest.0, nearest.1, best) + } +} + +/// Is `p` filled by `rings` (nonzero winding, as they are oriented)? +pub(super) fn filled(rings: &[Vec], p: Point) -> bool { + rings.iter().filter(|r| contains(r, p)).count() % 2 == 1 +} + +/// `ring` cut to the rectangle `cell` (Sutherland–Hodgman: the rectangle is convex, the ring +/// needn't be). +pub(super) fn clip(ring: &[Point], cell: Rect) -> Vec { + let mut pts = ring.to_vec(); + let sides: [fn(Point, Rect) -> f64; 4] = [|p, c| p.x - c.x0, |p, c| c.x1 - p.x, |p, c| p.y - c.y0, |p, c| c.y1 - p.y]; + for side in sides { + if pts.is_empty() { + break; + } + let mut out = vec![]; + for (a, b) in pts.iter().zip(pts.iter().cycle().skip(1)).take(pts.len()) { + let (da, db) = (side(*a, cell), side(*b, cell)); + if da >= 0.0 { + out.push(*a); + } + if (da >= 0.0) != (db >= 0.0) { + out.push(a.lerp(*b, da / (da - db))); + } + } + pts = out; + } + pts.dedup_by(|a, b| a.distance(*b) < 1e-9); + if pts.len() >= 3 && signed_area(&pts).abs() > 1e-12 { pts } else { vec![] } +} + +/// The shape's surface as a membrane under pressure, held at zero along its outline (and its +/// holes): the solution of ∇²φ = −1 on a grid, with each node's distance to the outline along the +/// grid lines taken into account where it is less than a grid step (Shortley–Weller), so the +/// outline needn't fall on the grid. √φ rises vertically from the outline and rounds smoothly +/// inside, without the creases a distance-to-the-edge height has along a shape's middle. +pub(super) struct Membrane { + origin: Point, + step: f64, + nx: usize, + ny: usize, + /// φ at each node; outside the shape, extrapolated (negative) from the inside, so + /// interpolation crosses zero near the outline. + phi: Vec, + pub(super) peak: f64, +} + +impl Membrane { + /// `edges` is the index of `rings`' edges (`Edges::new(rings)`). + pub(super) fn solve(rings: &[Vec], edges: &Edges, origin: Point, step: f64, nx: usize, ny: usize) -> Self { + let node = |i: usize, j: usize| Point::new(origin.x + i as f64 * step, origin.y + j as f64 * step); + let idx = |i: usize, j: usize| j * nx + i; + // A node on the outline itself is held at zero with the outside. + let inside: Vec = (0..nx * ny) + .map(|k| { + let p = node(k % nx, k / nx); + filled(rings, p) && !edges.near(p, 1e-6 * step) + }) + .collect(); + // The nearest crossing of the outline from `p` along (dx, dy), within one step. + let crossing = |p: Point, dx: f64, dy: f64| -> f64 { + let mut best = step; + let reach = Rect::from_points(p, Point::new(p.x + dx * step, p.y + dy * step)); + edges.each(reach, |a, b| { + let t = if dy == 0.0 { + if (a.y > p.y) == (b.y > p.y) { + return; + } + (a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) - p.x) * dx + } else { + if (a.x > p.x) == (b.x > p.x) { + return; + } + (a.y + (p.x - a.x) / (b.x - a.x) * (b.y - a.y) - p.y) * dy + }; + if t > 1e-9 * step && t < best { + best = t; + } + }); + best + }; + // Each inside node's four neighbours: (weight, neighbour inside or None at the outline). + let mut stencil: Vec<[(f64, Option); 4]> = vec![[(0.0, None); 4]; nx * ny]; + for j in 0..ny { + for i in 0..nx { + let k = idx(i, j); + if !inside[k] { + continue; + } + let p = node(i, j); + let dirs = [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)]; + let h: Vec = dirs.iter().map(|(dx, dy)| crossing(p, *dx, *dy)).collect(); + for (d, (dx, dy)) in dirs.iter().enumerate() { + let opp = h[d ^ 1]; + let w = 2.0 / (h[d] * (h[d] + opp)); + let (ni, nj) = (i as isize + *dx as isize, j as isize + *dy as isize); + let neighbour = (h[d] >= step && ni >= 0 && nj >= 0 && (ni as usize) < nx && (nj as usize) < ny) + .then(|| idx(ni as usize, nj as usize)) + .filter(|n| inside[*n]); + stencil[k][d] = (w, neighbour); + } + } + } + // Successive over-relaxation. + let mut phi = vec![0.0; nx * ny]; + let tolerance = 1e-9 * step * step * (nx * ny) as f64; + for _ in 0..2000 { + let mut change: f64 = 0.0; + for k in 0..nx * ny { + if !inside[k] { + continue; + } + let (mut sum, mut weight) = (1.0, 0.0); + for (w, n) in stencil[k] { + weight += w; + sum += w * n.map_or(0.0, |n| phi[n]); + } + let next = phi[k] + 1.8 * (sum / weight - phi[k]); + change = change.max((next - phi[k]).abs()); + phi[k] = next; + } + if change < tolerance { + break; + } + } + let peak = phi.iter().copied().fold(0.0, f64::max); + // Outside nodes next to the shape: φ continued past the outline at the slope of their + // inside neighbours, so values interpolated between them reach zero at the outline. + let mut ext = phi.clone(); + for j in 0..ny { + for i in 0..nx { + let k = idx(i, j); + if inside[k] { + continue; + } + let d_out = edges.distance(node(i, j)); + let mut slopes = vec![]; + for (di, dj) in [(1isize, 0isize), (-1, 0), (0, 1), (0, -1)] { + let (ni, nj) = (i as isize + di, j as isize + dj); + if ni < 0 || nj < 0 || ni as usize >= nx || nj as usize >= ny || !inside[idx(ni as usize, nj as usize)] { + continue; + } + let d_in = edges.distance(node(ni as usize, nj as usize)).max(1e-9); + slopes.push(phi[idx(ni as usize, nj as usize)] / d_in); + } + if !slopes.is_empty() { + ext[k] = -d_out * slopes.iter().sum::() / slopes.len() as f64; + } + } + } + Self { origin, step, nx, ny, phi: ext, peak } + } + + /// φ at `p`, interpolated between the four nodes around it. + pub(super) fn at(&self, p: Point) -> f64 { + self.interpolate(p, |i, j| self.phi.get(j * self.nx + i).copied().unwrap_or(0.0)) + } + + /// Interpolate central node derivatives, rather than differentiating each bilinear cell. + /// The latter jumps at every grid line and shows the solver's grid in the lighting. + pub(super) fn gradient(&self, p: Point) -> (f64, f64) { + let value = |i, j| self.phi.get(j * self.nx + i).copied().unwrap_or(0.0); + let dx = self.interpolate(p, |i, j| { + let (a, b) = (i.saturating_sub(1), (i + 1).min(self.nx - 1)); + (value(b, j) - value(a, j)) / ((b - a).max(1) as f64 * self.step) + }); + let dy = self.interpolate(p, |i, j| { + let (a, b) = (j.saturating_sub(1), (j + 1).min(self.ny - 1)); + (value(i, b) - value(i, a)) / ((b - a).max(1) as f64 * self.step) + }); + (dx, dy) + } + + fn interpolate(&self, p: Point, v: impl Fn(usize, usize) -> f64) -> f64 { + let fx = ((p.x - self.origin.x) / self.step).clamp(0.0, (self.nx - 1) as f64); + let fy = ((p.y - self.origin.y) / self.step).clamp(0.0, (self.ny - 1) as f64); + let (i, j) = ((fx.floor() as usize).min(self.nx.saturating_sub(2)), (fy.floor() as usize).min(self.ny.saturating_sub(2))); + let (tx, ty) = (fx - i as f64, fy - j as f64); + if self.nx < 2 || self.ny < 2 { + return v(0, 0); + } + let top = v(i, j) * (1.0 - tx) + v(i + 1, j) * tx; + let bottom = v(i, j + 1) * (1.0 - tx) + v(i + 1, j + 1) * tx; + top * (1.0 - ty) + bottom * ty + } +} + +/// A bounded ear clipping pass on a simple cell contour. Keep its collinear border nodes: +/// those shared samples can have different heights once lifted onto the membrane. A tiny +/// cell containing a complete hole, or a complex/degenerate contour, keeps its original fill. +pub(super) fn cell_triangles(pieces: &[Vec]) -> Option> { + let mut triangles = vec![]; + for ring in pieces { + if ring.len() > 128 || signed_area(ring) <= 0.0 { + return None; + } + let mut ring = ring.clone(); + ring.dedup_by(|a, b| a.distance(*b) < 1e-9); + if ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { + ring.pop(); + } + while ring.len() > 3 { + let ear = (0..ring.len()).find(|&i| { + let n = ring.len(); + let (a, b, c) = (ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n]); + if (b - a).cross(c - a) <= 1e-12 { + return false; + } + !ring.iter().enumerate().any(|(j, p)| { + j != i + && j != (i + n - 1) % n + && j != (i + 1) % n + && (b - a).cross(*p - a) >= -1e-12 + && (c - b).cross(*p - b) >= -1e-12 + && (a - c).cross(*p - c) >= -1e-12 + }) + })?; + let n = ring.len(); + triangles.push([ring[(ear + n - 1) % n], ring[ear], ring[(ear + 1) % n]]); + ring.remove(ear); + } + if let [a, b, c] = ring.as_slice() { + triangles.push([*a, *b, *c]); + } + } + Some(triangles) +} + +/// Most outline segments the booleans of one inflated object's mixed cells take together. +pub(super) const MAX_MATERIAL_SEGMENTS: usize = 4_000_000; + +/// The part of `outline` (its rings, each with its bounds) inside `cell`, as rings (holes running +/// the other way). Only the rings whose bounds meet the cell's go into the boolean. +pub(super) fn material(outline: &[(Rect, vectorcraft_geom::SubPath)], cell: Rect) -> Option>> { + let r = PathData::single(vectorcraft_geom::SubPath::polyline( + &[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)], + true, + )); + let near = PathData::new( + outline.iter().filter(|(b, _)| b.x0 <= cell.x1 && cell.x0 <= b.x1 && b.y0 <= cell.y1 && cell.y0 <= b.y1).map(|(_, s)| s.clone()).collect(), + ); + let rule = FillRule::NonZero; + let part = + vectorcraft_pathops::try_boolean(&r, rule, &near, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION) + .ok()?; + rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok() +} diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs new file mode 100644 index 000000000..57335e4c6 --- /dev/null +++ b/crates/three-d/src/solid/rings.rs @@ -0,0 +1,163 @@ +use super::*; + +/// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel +/// bands and distance tests. +pub(super) const MAX_SOLID_POINTS: usize = 2048; + +/// Outline points Rotate samples at most: it only turns the outline, so an outlined paragraph fits. +pub(super) const MAX_PLANE_POINTS: usize = 65536; + +/// Pieces a twisted wall's strip is cut into along one edge at most. +pub(super) const MAX_PIECES: usize = 64; + +/// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from +/// its chords), 1 to 32. +fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { + let d = |a: Point, b: Point, c: Point| (a.to_vec2() - 2.0 * b.to_vec2() + c.to_vec2()).hypot(); + let bend = d(c.p0, c.p1, c.p2).max(d(c.p1, c.p2, c.p3)); + let n = (0.75 * bend / tolerance).sqrt().ceil(); + if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } +} + +/// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer +/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves +/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take +/// few points and large ones more; more than `limit` points in all is refused. +/// +/// Under the nonzero `rule` an outline with filled art on both sides is no edge of the shape and +/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would +/// if nesting alone decided (#1724). +pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { + let tolerance = (size / 2000.0).max(0.05); + let mut count = 0; + let mut out = vec![]; + for sp in &path.subpaths { + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + if segments > limit { + return Err("The shape is too complex for a 3D effect".into()); + } + let mut ring: Vec = vec![]; + if let Some(first) = sp.anchors.first() { + ring.push(first.p); + } + for i in 0..segments { + let curve = sp.segment(i); + let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance) }; + count += samples as usize; + if count > limit { + return Err("The shape is too complex for a 3D effect".into()); + } + for k in 1..=samples { + ring.push(curve.eval(f64::from(k) / f64::from(samples))); + } + } + if ring.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8) { + return Err("The shape contains invalid coordinates".into()); + } + ring.dedup_by(|a, b| a.distance(*b) < 1e-9); + while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { + ring.pop(); + } + if ring.len() >= 3 && signed_area(&ring).abs() > 1e-9 { + out.push(ring); + } + } + // Which rings hold which: each ring against the others, its edges walked only where its + // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges in all. + let too_complex = || String::from("The shape is too complex for a 3D effect"); + let mut work = 0usize; + let mut within = |out: &[Vec], bounds: &[Rect], i: usize| -> Result, String> { + let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); + let mut held_by = vec![]; + for (j, (o, b)) in out.iter().zip(bounds).enumerate() { + if j == i || !(b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1) { + continue; + } + work = work.saturating_add(o.len()); + if work > MAX_CONTAINMENT_EDGES { + return Err(too_complex()); + } + if contains(o, p) { + held_by.push(j); + } + } + Ok(held_by) + }; + let bounds_of = |out: &[Vec]| -> Vec { + out.iter().map(|r| r.iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() + }; + if rule == FillRule::NonZero { + let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 }; + let bounds = bounds_of(&out); + let mut edge = Vec::with_capacity(out.len()); + for (i, r) in out.iter().enumerate() { + // The winding number just outside the ring, and just inside it. + let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o)).sum(); + edge.push((outside != 0) != (outside + turn(r) != 0)); + } + let mut edge = edge.into_iter(); + out.retain(|_| edge.next().unwrap_or(true)); + } + // A ring inside an odd number of others is a hole. + let bounds = bounds_of(&out); + let holes: Vec = (0..out.len()).map(|i| within(&out, &bounds, i).map(|held| held.len() % 2 == 1)).collect::>()?; + for (r, hole) in out.iter_mut().zip(holes) { + if (signed_area(r) > 0.0) == hole { + r.reverse(); + } + } + Ok(out) +} + +/// Most ring edges [`rings`] walks to tell which rings hold which: every ring is tested against +/// the others, so many rings of many points each would otherwise take billions of steps. +const MAX_CONTAINMENT_EDGES: usize = 50_000_000; + +pub(super) fn signed_area(r: &[Point]) -> f64 { + r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 +} + +pub(super) fn contains(r: &[Point], p: Point) -> bool { + let mut inside = false; + for (a, b) in r.iter().zip(r.iter().cycle().skip(1)).take(r.len()) { + if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) { + inside = !inside; + } + } + inside +} + +/// The unit normal on the left of `a`→`b`: towards the filled side. +pub(super) fn left(a: Point, b: Point) -> (f64, f64) { + let (dx, dy) = (b.x - a.x, b.y - a.y); + let l = dx.hypot(dy); + if l < 1e-12 { (0.0, 0.0) } else { (-dy / l, dx / l) } +} + +/// `ring` moved `w` points towards its filled side (negative: away from it), each corner along +/// its bisector, its mitre limited to four times `w` so sharp corners don't shoot off. Each point +/// stays the partner of its source point, so rings at different insets join face to face. +pub(super) fn inset(ring: &[Point], w: f64) -> Vec { + if w == 0.0 { + return ring.to_vec(); + } + let n = ring.len(); + (0..n) + .map(|i| { + let p = ring[i]; + let prev = ring[(i + n - 1) % n]; + let next = ring[(i + 1) % n]; + let (ax, ay) = left(prev, p); + let (bx, by) = left(p, next); + let (mut mx, mut my) = (ax + bx, ay + by); + let ml = mx.hypot(my); + if ml < 1e-9 { + (mx, my) = (ax, ay); + } else { + (mx, my) = (mx / ml, my / ml); + } + let cos = (mx * ax + my * ay).max(0.25); + Point::new(p.x + mx * w / cos, p.y + my * w / cos) + }) + .collect() +} From 6c562d5f419df467f471ac42595dff952cb60ddd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:05:21 +0000 Subject: [PATCH 122/199] refactor(trace): move lib.rs items to {layers,params,pipeline,raster}.rs --- crates/trace/src/layers.rs | 489 +++++++++++++++ crates/trace/src/lib.rs | 1121 +--------------------------------- crates/trace/src/params.rs | 251 ++++++++ crates/trace/src/pipeline.rs | 238 ++++++++ crates/trace/src/raster.rs | 143 +++++ 5 files changed, 1134 insertions(+), 1108 deletions(-) create mode 100644 crates/trace/src/layers.rs create mode 100644 crates/trace/src/params.rs create mode 100644 crates/trace/src/pipeline.rs create mode 100644 crates/trace/src/raster.rs diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs new file mode 100644 index 000000000..bf5bfc402 --- /dev/null +++ b/crates/trace/src/layers.rs @@ -0,0 +1,489 @@ +use super::params::{Method, Mode, TraceParams}; +#[cfg(test)] +use super::pipeline::bw_tones; +use super::pipeline::{TracedPath, is_ignored}; +use super::raster::Raster; +use super::*; + +/// What tracing one colour layer needs. +pub(crate) struct Layers<'a> { + pub(crate) q: &'a Quantized, + /// Each palette entry's layer, bottom (0) up. + pub(crate) rank: &'a [usize], + pub(crate) params: &'a TraceParams, + pub(crate) img: &'a Raster, + /// The colour Ignore Color leaves out, if not white. + pub(crate) ignore: Option<[u8; 3]>, + /// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded. + pub(crate) boxes: Vec>, + /// Gradients: the gradient each palette entry is, if it is one. + pub(crate) gradients: &'a [Option], + /// Black and White: the luminance of white and of black (see `bw_tones`). + pub(crate) bw_tones: [f64; 2], + /// How outlines and centre lines are fitted. + pub(crate) style: logo::Style, + pub(crate) w: usize, + pub(crate) h: usize, + /// Anchor points made so far, by every layer. + pub(crate) anchors: AtomicUsize, + pub(crate) max_anchors: usize, +} + +impl Layers<'_> { + /// Ignore White: white is no part of any layer (stacked layers reach under the layers above + /// them, and must not reach under white that is left out). + fn ignored(&self, label: u16) -> bool { + self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) + } + /// The region across a crack, for cutting outlines at their junctions: the outside pixel's + /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. + fn neighbour(&self, c: Crack) -> u32 { + if self.params.method == Method::Overlapping { + return 0; + } + c.at(1).and_then(|i| self.q.labels.get(i)).map_or(u32::from(TRANSPARENT), |&l| u32::from(l)) + } + /// The junction at pixel corner `(x, y)`, if there is one: where three or four regions + /// meet, or two parts of a region touch by the corner only, or an edge reaches the image's + /// border. It lies at the corner, moved across by what the edges that run up and down from it + /// say, and up or down by what those to its left and right say (the mean, where there are + /// two). It depends on the corner alone, so every region around it gets the same point; on the + /// image's border it stays on the border. + fn junction(&self, x: i32, y: i32) -> Option { + let label = |px: i32, py: i32| Crack::new((px, py), (1, 0), self.w, self.h).at(0).and_then(|i| self.q.labels.get(i).copied()); + // The four pixels around the corner (`None` off the image), and the edges between them. + let around = [label(x - 1, y - 1), label(x, y - 1), label(x, y), label(x - 1, y)]; + let edges = (0..4).filter(|&i| around[i] != around[(i + 1) % 4]).count(); + let apart = |a: usize, b: usize, c: usize, d: usize| around[a] == around[c] && around[b] != around[a] && around[d] != around[a]; + let touching = apart(0, 1, 2, 3) || apart(1, 2, 3, 0); + // (Stacked layers share no edges: they are cut at the image's border only.) + if edges < 3 && !touching || self.params.method == Method::Overlapping { + return None; + } + // The shift of the crack between `a` and the pixel `step` from it, if both are in the + // image and of different regions. + let shift = |a: (i32, i32), step: (i32, i32)| { + let (la, lb) = (label(a.0, a.1)?, label(a.0 + step.0, a.1 + step.1)?); + (la != lb).then(|| self.shift(Crack::new(a, step, self.w, self.h)).clamp(-0.5, 0.5)) + }; + let mean = |a: Option, b: Option| match (a, b) { + (Some(a), Some(b)) => (a + b) / 2.0, + (Some(v), None) | (None, Some(v)) => v, + (None, None) => 0.0, + }; + let dx = mean(shift((x - 1, y - 1), (1, 0)), shift((x - 1, y), (1, 0))); + let dy = mean(shift((x - 1, y - 1), (0, 1)), shift((x, y - 1), (0, 1))); + Some(vectorcraft_geom::Point::new(f64::from(x) + dx, f64::from(y) + dy)) + } + /// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5). + /// + /// Each pixel of the column across the crack is read as a blend of the tones on its two sides + /// (the two pixels' palette colours; in Black and White the luminance of the image's black and of + /// the white pixels). The edge is where the blends, summed, put it: as far out as there is + /// inside tone in the outside pixels, less the outside tone in the inside ones (two pixels each + /// way, while they are on the same side and no more blended than the pixel before: more would be + /// another edge's doing). + /// + /// Where the inside is at most three pixels deep with the outside's colour behind it (a thin + /// line), its two edges share those pixels and cannot be read apart; the inside tone of the + /// whole column gives the line's middle (its centroid) and width (its sum) instead. A thin gap + /// between two parts of the inside is read the same way, with the tones swapped. + /// + /// Black and White then moves the edge to where the luminance crosses the threshold: by the + /// threshold's distance from the middle tone, over how steep the edge is (nothing for a + /// threshold in the middle, more the softer the edge). + /// + /// The estimate is read from each side of the crack and the two readings averaged, so that + /// the shift is exactly antisymmetric (`a - b == -(b - a)` in floating point) and the two + /// regions of a crack get the same points. A single reading is not: it looks for a thin run on + /// the inside before one on the outside, so where both sides are thin (a one- or two-pixel + /// sliver beside another, as in soft gradients and noisy scans) each region got its own shift, + /// their shared piece different points, and the abutting curves left a sliver uncovered. + fn shift(&self, c: Crack) -> f64 { + (self.shift_one_side(c) - self.shift_one_side(c.flipped())) / 2.0 + } + + /// [`Self::shift`] read from the crack's inside pixel only. + fn shift_one_side(&self, c: Crack) -> f64 { + let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT); + let (Some(la), Some(lb)) = (label(0), label(1)) else { return 0.0 }; + if la == lb { + return 0.0; + } + let bw = self.params.mode == Mode::BlackAndWhite; + // Black and White: black is the image's black; white is the lightest pixel within a few + // pixels of the crack, the paper as it is here (a scan's paper is darker in one corner than + // in another, and read against the image's lightest white it would count as part ink). + let black = self.bw_tones[1]; + let white = if bw { + let light = (-3..=4) + .filter_map(|k| c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3))) + .map(|p| (f64::from(p[0]) * 299.0 + f64::from(p[1]) * 587.0 + f64::from(p[2]) * 114.0) / 1000.0); + light.fold(f64::NEG_INFINITY, f64::max).clamp((black + 32.0).min(self.bw_tones[0]), self.bw_tones[0]) + } else { + self.bw_tones[0] + }; + let (ca, cb) = if bw { + let tone = |l: u16| [if l == 1 { black } else { white }; 3]; + (tone(la), tone(lb)) + } else { + // A side's own colour: its palette entry's, or its gradient's colour here. + let own = |l: u16, k: i32| { + let flat = self.q.palette.get(l as usize)?.map(f64::from); + let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) }; + let i = c.at(k)?; + Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5))) + }; + let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 }; + (ca, cb) + }; + let d = [0, 1, 2].map(|k| cb[k] - ca[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + if len2 <= 0.0 { + return 0.0; + } + // How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image. + let inside = |k: i32| { + let Some(p) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)) else { return 0.0 }; + let p = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]; + let p = if bw { [(p[0] * 299.0 + p[1] * 587.0 + p[2] * 114.0) / 1000.0; 3] } else { p }; + 1.0 - ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::() / len2).clamp(0.0, 1.0) + }; + // The edge from a thin run of `n` pixels starting at pixel `from` and going `dir`, by the + // amounts `tone` of the run's own tone in it and in the two pixels at either end of it. + // Returns the run's middle and width, measured from the crack towards the outside. + let thin = |from: i32, dir: i32, n: i32, tone: &dyn Fn(i32) -> f64| { + let (near, far) = (from - dir, from + n * dir); + let mut cells: Vec<(i32, f64)> = (0..n).map(|j| from + j * dir).chain([near, far]).map(|k| (k, tone(k))).collect(); + for (edge, out) in [(near, -dir), (far, dir)] { + if label(edge + out) == label(edge) { + cells.push((edge + out, tone(edge + out).min(tone(edge)))); + } + } + let width: f64 = cells.iter().map(|c| c.1).sum(); + // Pixel `k`'s centre is `k - 0.5` from the crack. + (width > 1e-6).then(|| (cells.iter().map(|c| c.1 * (f64::from(c.0) - 0.5)).sum::() / width, width)) + }; + // How deep each side is here: up to three pixels of its own label, then the other's. + let depth = |dir: i32, own: u16, other: u16| { + let start = if dir < 0 { 0 } else { 1 }; + let n = (1..=3).take_while(|j| label(start + j * dir) == Some(own)).count() as i32 + 1; + (n <= 3 && label(start + n * dir) == Some(other)).then_some(n) + }; + let mut steep = 1.0; + let mut shift = if let Some((middle, width)) = depth(-1, la, lb).and_then(|n| thin(0, -1, n, &inside)) { + middle + width / 2.0 + } else if let Some((middle, width)) = depth(1, lb, la).and_then(|n| thin(1, 1, n, &|k| 1.0 - inside(k))) { + middle - width / 2.0 + } else { + let (a1, b1) = (1.0 - inside(0), inside(1)); + let a2 = (label(-1) == Some(la)).then(|| (1.0 - inside(-1)).min(a1)); + let b2 = (label(2) == Some(lb)).then(|| inside(2).min(b1)); + // The edge's steepness per pixel: the square of the change over two pixels (1 for an + // anti-aliased edge, less for a blurred one). + let change = [a2.map(|v| 1.0 - v - b1), b2.map(|v| 1.0 - a1 - v)].into_iter().flatten().fold(f64::NAN, f64::max); + if !change.is_nan() { + steep = (change * change).clamp(0.35, 1.0); + } + b1 + b2.unwrap_or(0.0) - a1 - a2.unwrap_or(0.0) + }; + if bw { + // The share of black at which a pixel turns black. + let turn = ((white - f64::from(self.params.threshold)) / (white - black).max(1.0)).clamp(0.02, 0.98); + shift += if la == 1 { 1.0 } else { -1.0 } * (0.5 - turn) / steep; + } + shift + } + + /// Whether the pixels inside `l` (a loop along pixel edges) all have one label. + fn one_colour_within(&self, l: &Loop) -> bool { + let mut first = None; + let (y0, y1) = l.pts.iter().fold((i32::MAX, i32::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1))); + for y in y0..y1 { + // The loop's upright edges that pass this row of pixels, left to right: the pixels + // between the first and the second are inside, and so on. + let mut xs: Vec = l + .pts + .iter() + .zip(l.pts.iter().cycle().skip(1)) + .filter(|(a, b)| a.0 == b.0 && a.1.min(b.1) <= y && y < a.1.max(b.1)) + .map(|(a, _)| a.0) + .collect(); + xs.sort_unstable(); + for pair in xs.as_chunks::<2>().0 { + for x in pair[0]..pair[1] { + let label = usize::try_from(y) + .ok() + .zip(usize::try_from(x).ok()) + .and_then(|(y, x)| self.q.labels.get(y * self.w + x).copied().filter(|_| x < self.w)); + if label.is_none() || *first.get_or_insert(label) != label { + return false; + } + } + } + } + first.is_some() + } + + /// Shapes, Abutting: the shape the outline `sp` (fitted from the loop `l`, `hard` as that + /// loop is) is, where it may be one. The edge is shared by the regions on its two sides, so + /// both must become that same shape or neither: only a closed edge between two regions, a + /// region whose whole outline is the hole of one other (no junction on it, not on the image's + /// border), and only a shape inside the image. The shape is read from the outline run one way + /// from one start, so the region and the hole it fills get the very same one. + fn shared_shape(&self, l: &Loop, sp: &vectorcraft_geom::SubPath, hard: bool) -> Option { + let (w, h) = (i32::try_from(self.w).ok()?, i32::try_from(self.h).ok()?); + if l.pts.iter().any(|&(x, y)| x <= 0 || y <= 0 || x >= w || y >= h || self.junction(x, y).is_some()) { + return None; + } + let mut one_way = sp.clone(); + if one_way.signed_area() < 0.0 { + one_way.reverse(); + } + let first = one_way.anchors.iter().enumerate().min_by(|a, b| a.1.p.x.total_cmp(&b.1.p.x).then(a.1.p.y.total_cmp(&b.1.p.y)))?.0; + one_way.anchors.rotate_left(first); + let shape = shapes::recognise(&one_way, hard)?; + let (wf, hf) = (self.w as f64, self.h as f64); + let inside = shape + .path() + .subpaths + .iter() + .flat_map(|s| &s.anchors) + .flat_map(|a| [a.p, a.h_in, a.h_out]) + .all(|p| (0.0..=wf).contains(&p.x) && (0.0..=hf).contains(&p.y)); + inside.then_some(shape) + } + + /// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the + /// maximum. + pub(crate) fn layer(&self, r: usize, ci: usize) -> Option> { + let params = self.params; + let color = *self.q.palette.get(ci)?; + let overlapping = params.method == Method::Overlapping; + let (w, h) = (self.w, self.h); + // Create Strokes: this colour's lines, left out of its areas. + let (thin, lines) = if params.strokes { + // The ink the lines are read against: the layer's colour; Black and White's own black. + let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) }; + centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) + } else { + (vec![], vec![]) + }; + // The layer is traced within the box its pixels lie in, not the whole image. + let part = |l: u16| if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) && !self.ignored(l) } else { l as usize == ci }; + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); + for (l, b) in self.boxes.iter().enumerate() { + if let Some(b) = b.filter(|_| part(l as u16)) { + (x0, y0, x1, y1) = (x0.min(b.0), y0.min(b.1), x1.max(b.2), y1.max(b.3)); + } + } + if x0 >= x1 || y0 >= y1 { + return Some(vec![]); + } + let (bw, bh) = (x1 - x0, y1 - y0); + let mut mask = Vec::with_capacity(bw * bh); + for y in y0..y1 { + let row = self.q.labels.get(y * w + x0..y * w + x1)?; + mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l))); + } + // Overlapping: a part of the mask with no pixel of this color holds only colors above it, + // whose own layers fill it, and is left out. The layer reaches under the colors above it + // only within the areas that hold its own color. + if overlapping { + let (comp, sizes) = contour::label(&mask, bw, bh); + let mut own = vec![false; sizes.len()]; + for (k, &c) in comp.iter().enumerate() { + if self.q.labels.get((y0 + k / bw) * w + x0 + k % bw).is_some_and(|&l| l as usize == ci) + && let Some(o) = own.get_mut(c as usize) + { + *o = true; + } + } + for (m, &c) in mask.iter_mut().zip(&comp) { + *m = *m && own.get(c as usize).copied().unwrap_or(false); + } + } + // Without Fills, areas are outlined with a 1 px stroke. + let area_stroke = (params.strokes && !params.fills).then_some(1.0); + let min_hole = params.noise.max(1) as i64; + let mut paths = vec![]; + let mut add = |path: PathData, pixels: usize, stroke: Option, shape: Option, square_caps: bool| { + let n = path.anchor_count(); + if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors { + return false; + } + // (A gradient fills; a stroked line of it is its middle colour.) + let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none()); + paths.push(TracedPath { path, color, pixels, stroke, shape, gradient, square_caps }); + true + }; + let mut comps = trace_mask(&mask, bw, bh); + drop(mask); + for comp in &mut comps { + for l in std::iter::once(&mut comp.outer).chain(&mut comp.holes) { + for p in &mut l.pts { + *p = (p.0 + x0 as i32, p.1 + y0 as i32); + } + } + } + for comp in comps { + // (`hard`: some of the outline has no anti-aliasing, and is known less exactly.) + let hard = std::cell::Cell::new(false); + // (Each loop fitted, and whether its own outline is hard.) + let fit = |l: &Loop| { + let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?; + // Every trace straightens the pieces of an outline that lie close to a line (Snap + // Curves To Lines straightens more of them), so the outline of a single pixel or + // of a region one pixel thick can have all its pieces on one chord, and the loop + // then has no area. Filled, such a loop paints nothing, and it is left out. + // Without Fills its 1 px stroke draws it, so it is kept. (`fit::fit_closed` puts + // back the positioned outline of a loop that is one closed piece when its fit has + // no area.) The area is summed about the loop's first anchor. + if area_stroke.is_none() { + use kurbo::ParamCurveArea; + let origin = sp.anchors.first()?.p.to_vec2(); + let area: f64 = (0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum(); + if area.abs() < 1e-6 { + return None; + } + } + hard.set(hard.get() || is_hard); + Some((sp, is_hard)) + }; + let Some((outer, _)) = fit(&comp.outer) else { continue }; + let mut subs = vec![outer]; + for hole in &comp.holes { + if hole.area2.abs() / 2 < min_hole && params.noise > 0 { + continue; + } + let Some((mut sp, own_hard)) = fit(hole) else { continue }; + // Shapes: a hole that is a shape is that shape, as what fills it is (the two are + // one outline: they stay one). Only where one colour fills it: the parts of + // several are not that shape, and would no longer meet it. Abutting, only where the + // region that fills it is that shape too (see `shared_shape`). + let exact = if !params.shapes || area_stroke.is_some() || !self.one_colour_within(hole) { + None + } else if overlapping { + shapes::recognise(&sp, hard.get()) + } else { + // (As hard as this outline is, as the region that fills it reads it.) + self.shared_shape(hole, &sp, own_hard) + }; + if let Some(exact) = exact.and_then(|shape| shape.path().subpaths.into_iter().next()) { + // (Which way round each goes: by the area of the polygon through points along it.) + let turn = |sp: &vectorcraft_geom::SubPath| { + use vectorcraft_geom::ParamCurve; + let pts: Vec<_> = + (0..sp.segment_count()).flat_map(|i| (0..6).map(move |k| sp.segment(i).eval(f64::from(k) / 6.0).to_vec2())).collect(); + pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::() + }; + let mut exact = exact; + if (turn(&exact) < 0.0) != (turn(&sp) < 0.0) { + exact.reverse(); + } + sp = exact; + } + subs.push(sp); + } + // Shapes: a filled outline without holes that is a shape becomes that shape. + let shape = match subs.as_slice() { + [only] if params.shapes && area_stroke.is_none() && overlapping => shapes::recognise(only, hard.get()), + // Abutting: only an island in one other region, whose hole it is (see `shared_shape`). + [only] if params.shapes && area_stroke.is_none() && comp.holes.is_empty() => self.shared_shape(&comp.outer, only, hard.get()), + _ => None, + }; + let path = shape.map_or_else(|| PathData::new(subs), |s| s.path()); + if !add(path, comp.pixels, area_stroke, shape, false) { + return None; + } + } + for line in lines { + if !add(line.path, line.pixels, Some(line.width), None, line.square) { + return None; + } + } + Some(paths) + } +} + +/// Test probe: every crack between two labels of `img` quantised as `trace` does (no gradients), +/// as `[shift, shift from the other side, one-sided reading, one-sided reading from the other +/// side]`. +#[cfg(test)] +pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { + let (w, h) = (img.width as usize, img.height as usize); + let mut q = quantize(img, params); + denoise(&mut q.labels, w, h, params.noise as usize); + let n = q.palette.len(); + let rank: Vec = (0..n).collect(); + let gradients = vec![None; n]; + let job = Layers { + q: &q, + rank: &rank, + params, + img, + ignore: None, + boxes: vec![None; n], + gradients: &gradients, + bw_tones: bw_tones(img, &q), + style: params.outline_style(), + w, + h, + anchors: AtomicUsize::new(0), + max_anchors: usize::MAX, + }; + let mut out = vec![]; + for y in 0..h as i32 { + for x in 0..w as i32 { + for step in [(1, 0), (0, 1)] { + let c = Crack::new((x, y), step, w, h); + let (Some(a), Some(b)) = (c.at(0), c.at(1)) else { continue }; + if q.labels[a] != q.labels[b] { + out.push([job.shift(c), job.shift(c.flipped()), job.shift_one_side(c), job.shift_one_side(c.flipped())]); + } + } + } + } + out +} + +/// `f` of each of `items`, in order, spread over the available cores. +pub(crate) fn parallel_map(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { + parallel_map_at_most(usize::MAX, items, f) +} + +/// `f` of each of `items`, in order, spread over the available cores, at most `at_a_time` at once. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn parallel_map_at_most(at_a_time: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len()).min(at_a_time); + if threads <= 1 { + return items.iter().map(f).collect(); + } + let next = AtomicUsize::new(0); + let mut done: Vec<(usize, R)> = std::thread::scope(|s| { + let workers: Vec<_> = (0..threads) + .map(|_| { + s.spawn(|| { + let mut mine = vec![]; + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(item) = items.get(i) else { break }; + mine.push((i, f(item))); + } + mine + }) + }) + .collect(); + // A worker's panic goes on in this thread, as if it had run here (the engine's guard + // reports it). + workers.into_iter().flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e))).collect() + }); + done.sort_by_key(|(i, _)| *i); + done.into_iter().map(|(_, r)| r).collect() +} + +/// `f` of each of `items`, in order (no threads on the web). +#[cfg(target_arch = "wasm32")] +pub(crate) fn parallel_map_at_most(_: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { + items.iter().map(f).collect() +} diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 88e882f98..85e6373c6 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -68,1116 +68,21 @@ pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize}; pub use shapes::TracedShape; use subpixel::Crack; -/// Errors decoding a raster. -#[derive(Debug, thiserror::Error)] -pub enum TraceError { - #[error("could not decode image: {0}")] - Decode(String), - #[error("image is empty")] - Empty, - #[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")] - TooLarge { width: u32, height: u32, max: u64 }, - #[error("this isn't a flat-colour logo ({:.0}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead", (.fraction * 100.0).floor())] - NotFlat { fraction: f64 }, - #[error("\"{0}\" is not a colour: write it as #rrggbb")] - BadPalette(String), - #[error( - "this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset" - )] - TooComplex { max: usize }, -} +mod layers; +mod params; +mod pipeline; +mod raster; -/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are -/// refused instead of running out of memory. -pub const MAX_PIXELS: u64 = 64 << 20; - -/// The most pixels an image may have for [`Raster::decode_to_fit`] to decode it and scale it down -/// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused. -pub const MAX_SOURCE_PIXELS: u64 = 192 << 20; - -/// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1). -fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { - let pixels = u64::from(w) * u64::from(h); - if pixels <= max || pixels == 0 { - return (w, h); - } - let k = (max as f64 / pixels as f64).sqrt(); - let side = |v: u32| ((f64::from(v) * k).floor() as u32).clamp(1, v); - let (mut nw, mut nh) = (side(w), side(h)); - // A side held at 1 pixel leaves the other too long: cut it to what fits. - if u64::from(nw) * u64::from(nh) > max { - if nw >= nh { - nw = u32::try_from(max / u64::from(nh)).unwrap_or(nw).clamp(1, nw); - } else { - nh = u32::try_from(max / u64::from(nw)).unwrap_or(nh).clamp(1, nh); - } - } - (nw, nh) -} - -/// An RGBA8 raster (row-major, top row first). -#[derive(Clone, Debug, PartialEq)] -pub struct Raster { - pub width: u32, - pub height: u32, - pub rgba: Vec, -} - -impl Raster { - /// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other - /// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not - /// a crash. (That error's text is already translated; its detail names the sizes.) - pub fn new(width: u32, height: u32, rgba: Vec) -> Result { - let want = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4)); - if want != Some(rgba.len()) { - return Err(TraceError::Decode(format!("{} bytes of RGBA data for a {width} × {height} image", rgba.len()))); - } - Ok(Self { width, height, rgba }) - } - /// A raster whose pixel `(x, y)` is `f(x, y)`. - pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self { - let mut rgba = Vec::with_capacity(width as usize * height as usize * 4); - for y in 0..height { - for x in 0..width { - rgba.extend_from_slice(&f(x, y)); - } - } - Self { width, height, rgba } - } - /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). - pub fn decode(bytes: &[u8]) -> Result { - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = reader.into_dimensions().map_err(decode_err)?; - if w == 0 || h == 0 { - return Err(TraceError::Empty); - } - if u64::from(w) * u64::from(h) > MAX_PIXELS { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); - } - let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); - let (w, h) = img.dimensions(); - Ok(Self { width: w, height: h, rgba: img.into_raw() }) - } - /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to - /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within - /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly - /// less than its own resolution. The caller maps the result back with `width` / `height`. - pub fn decode_to_fit(bytes: &[u8]) -> Result { - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = reader.into_dimensions().map_err(decode_err)?; - if w == 0 || h == 0 { - return Err(TraceError::Empty); - } - let pixels = u64::from(w) * u64::from(h); - if pixels <= MAX_PIXELS { - return Self::decode(bytes); - } - if pixels > MAX_SOURCE_PIXELS { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); - } - // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. - // A deeper image (16 bits a channel) that needs more fails with a decode error. - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let mut limits = image::Limits::default(); - limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); - reader.limits(limits); - let img = reader.decode().map_err(decode_err)?.into_rgba8(); - let (w, h) = img.dimensions(); - let (nw, nh) = fit_size(w, h, MAX_PIXELS); - // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - let small = image::imageops::thumbnail(&img, nw, nh); - Ok(Self { width: nw, height: nh, rgba: small.into_raw() }) - } - /// Encode as PNG. - pub fn encode_png(&self) -> Vec { - let mut out = Vec::new(); - if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { - let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png); - } - out - } - /// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose - /// fields were set to a size its bytes don't have). - pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { - if x >= self.width || y >= self.height { - return [0; 4]; - } - let i = (y as usize * self.width as usize + x as usize) * 4; - match self.rgba.get(i..i + 4) { - Some(&[r, g, b, a]) => [r, g, b, a], - _ => [0; 4], - } - } -} - -/// Tracing mode (Illustrator's Mode popup). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Mode { - #[default] - #[serde(alias = "bw", alias = "blackWhite", alias = "Black and White")] - BlackAndWhite, - #[serde(alias = "gray", alias = "Grayscale")] - Grayscale, - #[serde(alias = "Color")] - Color, - /// Flat-colour logos: sub-pixel edges, sharp corners restored, true lines and circles. - #[serde(alias = "Logo", alias = "flat", alias = "flatLogo")] - Logo, -} - -/// How colour layers relate (Advanced → Method). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Method { - /// Each colour traced on its own; neighbouring shapes share their edge. - #[default] - Abutting, - /// Stacked: every layer extends under the layers above it, within the areas that hold its own - /// color. - Overlapping, -} - -/// Where Color mode's colours come from (the Palette popup). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Palette { - /// The `colors` colours that best represent the image. - #[default] - #[serde(alias = "Limited")] - Limited, - /// As many colours as the image's tones need, up to [`FULL_TONE_MAX`] (photos, gradients): - /// `color_detail` sets how close two colours may be and stay apart. - #[serde(alias = "Full Tone")] - FullTone, - /// Flat art (a few colours cover nearly all of it) traces with exactly those colours, anything - /// else as Full Tone. - #[serde(alias = "Automatic")] - Automatic, - /// The most used colours of a swatch library ([`TraceParams::swatches`]), at most `colors` of - /// them, exactly as they are. Each pixel takes the library color nearest in lightness and - /// hue, with hue and chroma counted three times as much as lightness, so that neutral tones - /// stay off saturated colors. - #[serde(alias = "library", alias = "Document Library")] - DocumentLibrary, -} - -/// Image Trace parameters (the Image Trace panel). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct TraceParams { - pub mode: Mode, - /// Black and White: pixels darker than this (luminance 0–255) are black. - pub threshold: u8, - /// Color: where the colours come from. - pub palette: Palette, - /// Palette size for Color (Limited; at most this many for Document Library), number of grays - /// for Grayscale (2–256). - pub colors: u32, - /// Full Tone and Automatic: how many colours, 0 (few) – 100 (many). - pub color_detail: f64, - /// Document Library: the swatch library, `"document"` (the document's swatches) or a library id - /// or name. The caller looks its colours up into `swatches`. - pub library: String, - /// Document Library with the document's swatches: the name of the color group whose colors are - /// used. `None` (or empty) uses every swatch. `imageTrace.make` refuses a name with any other - /// palette, library or mode. - pub color_group: Option, - /// Document Library: the library's colours (sRGB). Not saved: they are looked up from - /// `library` (and `color_group`) at every trace. - #[serde(skip)] - pub swatches: Vec<[u8; 3]>, - /// Paths (fidelity) 0–100: higher follows the pixels more tightly. - pub paths: f64, - /// Corners 0–100: higher keeps more corners. - pub corners: f64, - /// Noise: areas smaller than this many pixels are ignored. (Flat Logo mode: areas smaller than - /// `noise / 10` pixels of the source image.) - pub noise: u32, - pub method: Method, - /// Ignore Color: drop the areas of one colour (no background shape): white, unless - /// `ignore_color` names another. - pub ignore_white: bool, - /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels - /// of it. `None` is white. (Flat Logo mode leaves out white only.) - pub ignore_color: Option, - /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs - /// to true circles. - pub snap_curves_to_lines: bool, - /// Shapes: a filled outline that is a circle, an ellipse, a rectangle (square or rounded - /// corners) or a regular polygon comes out as exactly that shape ([`TracedPath::shape`]). - pub shapes: bool, - /// Auto Grouping: say which path lies inside which ([`TraceResult::parents`]). - pub auto_grouping: bool, - /// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction - /// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour. - /// Grayscale finds them in the image's grays, and their stops are gray. - pub gradients: bool, - /// Create: trace areas as filled shapes. - pub fills: bool, - /// Create: trace lines no wider than `stroke_width` as stroked centre lines. - pub strokes: bool, - /// Stroke: the widest feature (px) traced as a stroke. - pub stroke_width: f64, - /// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is - /// always traced on its own and cannot be named, colours closer than about 12 (RGB distance) - /// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.) - pub logo_colors: Vec, -} - -impl Default for TraceParams { - /// Illustrator's [Default]: black and white, threshold 128. - fn default() -> Self { - Self { - mode: Mode::BlackAndWhite, - threshold: 128, - palette: Palette::Limited, - colors: 6, - color_detail: 50.0, - library: "document".into(), - color_group: None, - swatches: vec![], - paths: 50.0, - corners: 75.0, - noise: 25, - method: Method::Abutting, - ignore_white: false, - ignore_color: None, - snap_curves_to_lines: false, - shapes: false, - auto_grouping: false, - gradients: false, - fills: true, - strokes: false, - stroke_width: 10.0, - logo_colors: Vec::new(), - } - } -} - -impl TraceParams { - /// How outlines are fitted: Paths sets how closely the curves follow the edge (0.08 px at 100, - /// 0.33 px at 50, 0.78 px at 0), Corners the turn that makes a corner (35° at 100, 90° at 0). - /// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones, - /// makes parts of circles true arcs, and makes lines within 2.5° of level or upright exactly so. - fn outline_style(&self) -> logo::Style { - // Parameters come from users and agents: a NaN or infinity must not reach the geometry. - let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default }; - let snap = self.snap_curves_to_lines; - logo::Style { - step: 0.25, - tol: 0.08 + 0.7 * (1.0 - unit(self.paths, 0.5)).powf(1.5), - corner_deg: 90.0 - 55.0 * unit(self.corners, 0.75), - snap: true, - arcs: snap, - line_tol: if snap { 0.5 } else { 0.2 }, - circle_tol: if snap { 0.3 } else { 0.15 }, - restore: Some(logo::RestoreParams::default()), - check_step: 0.15, - upright: snap, - ..logo::Style::default() - } - } -} - -/// Built-in preset names in panel order (our own parameter sets). -pub const PRESET_NAMES: &[&str] = &[ - "Default", - "Auto Color", - "High Fidelity Photo", - "Low Fidelity Photo", - "3 Colors", - "6 Colors", - "16 Colors", - "Shades of Gray", - "Black and White Logo", - "Sketched Art", - "Silhouettes", - "Line Art", - "Technical Drawing", - "Outline", - "Flat Logo", -]; - -/// A built-in preset by name (case-insensitive; `[Default]` is accepted). -pub fn preset(name: &str) -> Option { - let key = name.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(); - let d = TraceParams::default(); - let color = |colors: u32, paths: f64, corners: f64, noise: u32, method: Method| TraceParams { - mode: Mode::Color, - colors, - paths, - corners, - noise, - method, - ..TraceParams::default() - }; - let bw = |threshold: u8, paths: f64, corners: f64, noise: u32, snap: bool| TraceParams { - mode: Mode::BlackAndWhite, - threshold, - paths, - corners, - noise, - ignore_white: true, - snap_curves_to_lines: snap, - ..TraceParams::default() - }; - Some(match key.as_str() { - "default" => d, - // A posterised image of a photo or of artwork: its own colours where it is flat art, as - // many as its tones need where it is not. - // (Color detail 85: a colour on a hundredth of the picture is still one of its colours.) - "auto color" => TraceParams { palette: Palette::Automatic, color_detail: 85.0, ..color(6, 65.0, 60.0, 16, Method::Abutting) }, - "high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping), - "low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping), - "3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting), - "6 colors" => color(6, 65.0, 60.0, 16, Method::Abutting), - "16 colors" => color(16, 70.0, 55.0, 10, Method::Abutting), - "shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 60.0, corners: 50.0, noise: 10, method: Method::Overlapping, ..d }, - "black and white logo" => bw(128, 95.0, 80.0, 8, true), - "sketched art" => bw(150, 50.0, 50.0, 100, false), - "silhouettes" => bw(200, 40.0, 60.0, 30, false), - "line art" => bw(128, 80.0, 70.0, 10, false), - "technical drawing" => bw(128, 95.0, 90.0, 4, true), - // Black outlines: lines as stroked centre lines, wider areas as stroked outlines. - "outline" => TraceParams { fills: false, strokes: true, ..bw(128, 80.0, 70.0, 10, false) }, - // Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel. - "flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d }, - _ => return None, - }) -} - -/// All built-in presets in panel order. -pub fn presets() -> Vec<(&'static str, TraceParams)> { - PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect() -} - -/// The enhanced version of built-in preset `name`: the preset with the options the enhanced -/// presets have built in: Auto Grouping in all of them, Shapes in all but High Fidelity Photo. -/// Gradients stay an option to turn on. Flat Logo, a preset of our own, has none. -pub fn enhanced_preset(name: &str) -> Option { - let p = preset(name).filter(|p| p.mode != Mode::Logo)?; - Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo"), ..p }) -} - -/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`) -/// lines stroked with it. -#[derive(Clone, Debug, PartialEq)] -pub struct TracedPath { - /// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`). - pub path: PathData, - pub color: [u8; 3], - /// Filled pixel count of the component the path came from. - pub pixels: usize, - /// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the - /// outlines of wider areas without Fills). - pub stroke: Option, - /// Shapes: the shape the path is, exactly (`path` is that shape's outline). - pub shape: Option, - /// Gradients: the linear gradient the path is filled with (`color` is its middle colour). - pub gradient: Option, - /// Create Strokes: the stroke's ends are square (a cap that goes half the width past the - /// path's end, cut straight), as the line's ink is; otherwise round. - pub square_caps: bool, -} - -/// The traced result, bottom-most path first. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct TraceResult { - pub paths: Vec, - /// Colours actually used. - pub palette: Vec<[u8; 3]>, - /// Auto Grouping: for each of `paths`, the path it lies inside, if any (empty without the - /// option). Each path drawn together with those inside it, in the order they have here, paints - /// what `paths` paints in its order. - pub parents: Vec>, - /// Flat Logo: the size in pixels the image was traced at when it was averaged down first; - /// `None` when it was traced at its own size. - pub worked_at: Option<(u32, u32)>, -} - -impl TraceResult { - pub fn anchor_count(&self) -> usize { - self.paths.iter().map(|p| p.path.anchor_count()).sum() - } -} - -fn is_white(c: [u8; 3]) -> bool { - c.iter().all(|&v| v >= 245) -} - -/// Whether Ignore Color leaves palette colour `c` out: `target` is the colour picked (`None`: white). -fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { - match target { - None => is_white(c), - Some(t) => (0..3).map(|k| (f64::from(c[k]) - f64::from(t[k])).powi(2)).sum::() <= 30.0 * 30.0 || (is_white(t) && is_white(c)), - } -} - -/// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use -/// [`trace_within`] to see why. -pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { - // No trace has more than `usize::MAX` anchors. - trace_within(img, params, usize::MAX).unwrap_or_default() -} - -/// Trace `img` with `params`, giving up with [`TraceError::TooComplex`] once the paths have more -/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The -/// colour layers are traced in parallel where threads are available. -pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { - let (w, h) = (img.width as usize, img.height as usize); - if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { - return Ok(TraceResult::default()); - } - let mut out = trace_paths(img, params, max_anchors)?; - if params.auto_grouping { - // Abutting areas cannot overlap; stacked layers, Logo's shapes and stroked lines can. - let stacked = params.method == Method::Overlapping || params.mode == Mode::Logo || params.strokes; - out.parents = group::parents(&out.paths, img.width as usize, img.height as usize, stacked); - } - Ok(out) -} - -fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { - let (w, h) = (img.width as usize, img.height as usize); - if params.mode == Mode::Logo { - return logo::trace_logo(img, params, max_anchors); - } - // The colour Ignore Color leaves out, if not white. - let ignore = match ¶ms.ignore_color { - Some(name) if params.ignore_white => Some(logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))?), - _ => None, - }; - // A photo is traced from a copy with its noise smoothed away (see `smooth`). - let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img)); - let img = smoothed.as_ref().unwrap_or(img); - // Gradients are found first and kept out of the palette: no colour is spent on bands of them. - // Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray. - let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| { - let mut grays = img.clone(); - for p in grays.rgba.as_chunks_mut::<4>().0 { - let g = quantize::luma(*p); - p[..3].fill(g); - } - grays - }); - let traced_as = grays.as_ref().unwrap_or(img); - let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty()); - let without = found.as_ref().map(|(of, _)| { - let mut rest = img.clone(); - for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) { - if g != u32::MAX { - p[3] = 0; - } - } - rest - }); - let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params); - // The semi-transparent edge of a cut-out image traces as the color beside it. - if matches!(params.mode, Mode::Color | Mode::Grayscale) { - quantize::settle_cut_edges(&mut q, img, params.mode == Mode::Grayscale); - } - drop(without); - // Each gradient is a palette entry of its own (its colour: the gradient's middle). - let mut gradients: Vec> = vec![None; q.palette.len()]; - if let Some((mut of, found)) = found { - gradient::settle_edges(traced_as, &mut of, &found, &mut q.labels); - let first = q.palette.len(); - for (l, &g) in q.labels.iter_mut().zip(&of) { - if g != u32::MAX { - *l = u16::try_from(first + g as usize).unwrap_or(TRANSPARENT); - } - } - q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8))); - gradients.extend(found.into_iter().map(Some)); - } - drop(grays); - // Noise clears specks, not the regions of a rare colour Automatic kept on purpose. - let keep: Vec = q.palette.iter().map(|c| rare.contains(c)).collect(); - quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); - // Pixel count per palette entry, and the box its pixels lie in. - let mut counts = vec![0usize; q.palette.len()]; - let mut boxes: Vec> = vec![None; q.palette.len()]; - for (i, &l) in q.labels.iter().enumerate() { - if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) { - *c += 1; - let (x, y) = (i % w, i / w); - *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); - } - } - // Layers bottom → top: largest area first. - let mut order: Vec = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); - order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b))); - if params.method == Method::Overlapping { - // Stacked layers go by lightness instead, from the largest colour's end of the scale: each - // layer reaches under those above it in the areas that hold its own color, so where two - // colours meet, every layer between them in the stack that has a pixel in that area ends - // on that same edge and shows through its anti-aliasing. Neighbours in a picture are - // mostly neighbours in tone, and in this order they are neighbours in the stack too (by - // area, an unrelated colour's edge would shine through as a hairline). - let light = |i: usize| q.palette.get(i).map_or(0, |c| u32::from(c[0]) * 299 + u32::from(c[1]) * 587 + u32::from(c[2]) * 114); - let first = order.first().copied(); - order.sort_by(|a, b| light(*a).cmp(&light(*b)).then(a.cmp(b))); - if first.is_some_and(|f| order.iter().position(|&i| i == f).is_some_and(|at| at * 2 >= order.len())) { - order.reverse(); - } - } - let mut rank = vec![usize::MAX; q.palette.len()]; - for (r, &i) in order.iter().enumerate() { - rank[i] = r; - } - let layers: Vec<(usize, usize)> = - order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect(); - let job = Layers { - q: &q, - rank: &rank, - params, - img, - ignore, - boxes, - gradients: &gradients, - bw_tones: bw_tones(img, &q), - style: params.outline_style(), - w, - h, - anchors: AtomicUsize::new(0), - max_anchors, - }; - let mut out = TraceResult::default(); - // A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels - // of its components, the stack that fills them, its edges): as many layers at a time as fit - // in half a gigabyte, so that a large image is not traced at the cost of gigabytes. - // (Overlapping labels a layer's mask once more before it is traced, with its labels and stack - // freed before the trace starts.) - let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1); - let at_a_time = ((512 << 20) / (largest * 10)).max(1); - for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) { - let paths = paths.ok_or(TraceError::TooComplex { max: max_anchors })?; - // A layer with no paths adds no color to the palette. - if paths.is_empty() { - continue; - } - out.paths.extend(paths); - let color = q.palette[ci]; - if !out.palette.contains(&color) { - out.palette.push(color); - } - } - Ok(out) -} - -/// The luminance of white and of black in a Black and White quantisation: what its lightest -/// white pixels and its darkest black ones have (the 98th and 2nd percentiles; pure white and -/// black where a side has no pixel). Not the means: where the black is thin lines, blurred or -/// scanned, most black pixels are part paper, their mean is a gray, and read against it the middle -/// of every line would count as more than all ink, now more and now less as the line crosses the -/// pixel rows, which put a ripple on its edges. -fn bw_tones(img: &Raster, q: &Quantized) -> [f64; 2] { - let mut hist = [[0u64; 256]; 2]; - for (p, &l) in img.rgba.as_chunks::<4>().0.iter().zip(&q.labels) { - if let Some(h) = hist.get_mut(l as usize) { - h[((u32::from(p[0]) * 299 + u32::from(p[1]) * 587 + u32::from(p[2]) * 114 + 500) / 1000).min(255) as usize] += 1; - } - } - // The luminance a share `at` of the side's pixels are darker than. - let percentile = |h: &[u64; 256], at: f64, none: f64| { - let total: u64 = h.iter().sum(); - let mut seen = 0; - for (v, n) in h.iter().enumerate() { - seen += n; - if total > 0 && seen as f64 >= at * total as f64 { - return v as f64; - } - } - none - }; - [percentile(&hist[0], 0.98, 255.0), percentile(&hist[1], 0.02, 0.0)] -} - -/// What tracing one colour layer needs. -struct Layers<'a> { - q: &'a Quantized, - /// Each palette entry's layer, bottom (0) up. - rank: &'a [usize], - params: &'a TraceParams, - img: &'a Raster, - /// The colour Ignore Color leaves out, if not white. - ignore: Option<[u8; 3]>, - /// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded. - boxes: Vec>, - /// Gradients: the gradient each palette entry is, if it is one. - gradients: &'a [Option], - /// Black and White: the luminance of white and of black (see `bw_tones`). - bw_tones: [f64; 2], - /// How outlines and centre lines are fitted. - style: logo::Style, - w: usize, - h: usize, - /// Anchor points made so far, by every layer. - anchors: AtomicUsize, - max_anchors: usize, -} - -impl Layers<'_> { - /// Ignore White: white is no part of any layer (stacked layers reach under the layers above - /// them, and must not reach under white that is left out). - fn ignored(&self, label: u16) -> bool { - self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) - } - /// The region across a crack, for cutting outlines at their junctions: the outside pixel's - /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. - fn neighbour(&self, c: Crack) -> u32 { - if self.params.method == Method::Overlapping { - return 0; - } - c.at(1).and_then(|i| self.q.labels.get(i)).map_or(u32::from(TRANSPARENT), |&l| u32::from(l)) - } - /// The junction at pixel corner `(x, y)`, if there is one: where three or four regions - /// meet, or two parts of a region touch by the corner only, or an edge reaches the image's - /// border. It lies at the corner, moved across by what the edges that run up and down from it - /// say, and up or down by what those to its left and right say (the mean, where there are - /// two). It depends on the corner alone, so every region around it gets the same point; on the - /// image's border it stays on the border. - fn junction(&self, x: i32, y: i32) -> Option { - let label = |px: i32, py: i32| Crack::new((px, py), (1, 0), self.w, self.h).at(0).and_then(|i| self.q.labels.get(i).copied()); - // The four pixels around the corner (`None` off the image), and the edges between them. - let around = [label(x - 1, y - 1), label(x, y - 1), label(x, y), label(x - 1, y)]; - let edges = (0..4).filter(|&i| around[i] != around[(i + 1) % 4]).count(); - let apart = |a: usize, b: usize, c: usize, d: usize| around[a] == around[c] && around[b] != around[a] && around[d] != around[a]; - let touching = apart(0, 1, 2, 3) || apart(1, 2, 3, 0); - // (Stacked layers share no edges: they are cut at the image's border only.) - if edges < 3 && !touching || self.params.method == Method::Overlapping { - return None; - } - // The shift of the crack between `a` and the pixel `step` from it, if both are in the - // image and of different regions. - let shift = |a: (i32, i32), step: (i32, i32)| { - let (la, lb) = (label(a.0, a.1)?, label(a.0 + step.0, a.1 + step.1)?); - (la != lb).then(|| self.shift(Crack::new(a, step, self.w, self.h)).clamp(-0.5, 0.5)) - }; - let mean = |a: Option, b: Option| match (a, b) { - (Some(a), Some(b)) => (a + b) / 2.0, - (Some(v), None) | (None, Some(v)) => v, - (None, None) => 0.0, - }; - let dx = mean(shift((x - 1, y - 1), (1, 0)), shift((x - 1, y), (1, 0))); - let dy = mean(shift((x - 1, y - 1), (0, 1)), shift((x, y - 1), (0, 1))); - Some(vectorcraft_geom::Point::new(f64::from(x) + dx, f64::from(y) + dy)) - } - /// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5). - /// - /// Each pixel of the column across the crack is read as a blend of the tones on its two sides - /// (the two pixels' palette colours; in Black and White the luminance of the image's black and of - /// the white pixels). The edge is where the blends, summed, put it: as far out as there is - /// inside tone in the outside pixels, less the outside tone in the inside ones (two pixels each - /// way, while they are on the same side and no more blended than the pixel before: more would be - /// another edge's doing). - /// - /// Where the inside is at most three pixels deep with the outside's colour behind it (a thin - /// line), its two edges share those pixels and cannot be read apart; the inside tone of the - /// whole column gives the line's middle (its centroid) and width (its sum) instead. A thin gap - /// between two parts of the inside is read the same way, with the tones swapped. - /// - /// Black and White then moves the edge to where the luminance crosses the threshold: by the - /// threshold's distance from the middle tone, over how steep the edge is (nothing for a - /// threshold in the middle, more the softer the edge). - /// - /// The estimate is read from each side of the crack and the two readings averaged, so that - /// the shift is exactly antisymmetric (`a - b == -(b - a)` in floating point) and the two - /// regions of a crack get the same points. A single reading is not: it looks for a thin run on - /// the inside before one on the outside, so where both sides are thin (a one- or two-pixel - /// sliver beside another, as in soft gradients and noisy scans) each region got its own shift, - /// their shared piece different points, and the abutting curves left a sliver uncovered. - fn shift(&self, c: Crack) -> f64 { - (self.shift_one_side(c) - self.shift_one_side(c.flipped())) / 2.0 - } - - /// [`Self::shift`] read from the crack's inside pixel only. - fn shift_one_side(&self, c: Crack) -> f64 { - let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT); - let (Some(la), Some(lb)) = (label(0), label(1)) else { return 0.0 }; - if la == lb { - return 0.0; - } - let bw = self.params.mode == Mode::BlackAndWhite; - // Black and White: black is the image's black; white is the lightest pixel within a few - // pixels of the crack, the paper as it is here (a scan's paper is darker in one corner than - // in another, and read against the image's lightest white it would count as part ink). - let black = self.bw_tones[1]; - let white = if bw { - let light = (-3..=4) - .filter_map(|k| c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3))) - .map(|p| (f64::from(p[0]) * 299.0 + f64::from(p[1]) * 587.0 + f64::from(p[2]) * 114.0) / 1000.0); - light.fold(f64::NEG_INFINITY, f64::max).clamp((black + 32.0).min(self.bw_tones[0]), self.bw_tones[0]) - } else { - self.bw_tones[0] - }; - let (ca, cb) = if bw { - let tone = |l: u16| [if l == 1 { black } else { white }; 3]; - (tone(la), tone(lb)) - } else { - // A side's own colour: its palette entry's, or its gradient's colour here. - let own = |l: u16, k: i32| { - let flat = self.q.palette.get(l as usize)?.map(f64::from); - let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) }; - let i = c.at(k)?; - Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5))) - }; - let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 }; - (ca, cb) - }; - let d = [0, 1, 2].map(|k| cb[k] - ca[k]); - let len2: f64 = d.iter().map(|v| v * v).sum(); - if len2 <= 0.0 { - return 0.0; - } - // How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image. - let inside = |k: i32| { - let Some(p) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)) else { return 0.0 }; - let p = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]; - let p = if bw { [(p[0] * 299.0 + p[1] * 587.0 + p[2] * 114.0) / 1000.0; 3] } else { p }; - 1.0 - ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::() / len2).clamp(0.0, 1.0) - }; - // The edge from a thin run of `n` pixels starting at pixel `from` and going `dir`, by the - // amounts `tone` of the run's own tone in it and in the two pixels at either end of it. - // Returns the run's middle and width, measured from the crack towards the outside. - let thin = |from: i32, dir: i32, n: i32, tone: &dyn Fn(i32) -> f64| { - let (near, far) = (from - dir, from + n * dir); - let mut cells: Vec<(i32, f64)> = (0..n).map(|j| from + j * dir).chain([near, far]).map(|k| (k, tone(k))).collect(); - for (edge, out) in [(near, -dir), (far, dir)] { - if label(edge + out) == label(edge) { - cells.push((edge + out, tone(edge + out).min(tone(edge)))); - } - } - let width: f64 = cells.iter().map(|c| c.1).sum(); - // Pixel `k`'s centre is `k - 0.5` from the crack. - (width > 1e-6).then(|| (cells.iter().map(|c| c.1 * (f64::from(c.0) - 0.5)).sum::() / width, width)) - }; - // How deep each side is here: up to three pixels of its own label, then the other's. - let depth = |dir: i32, own: u16, other: u16| { - let start = if dir < 0 { 0 } else { 1 }; - let n = (1..=3).take_while(|j| label(start + j * dir) == Some(own)).count() as i32 + 1; - (n <= 3 && label(start + n * dir) == Some(other)).then_some(n) - }; - let mut steep = 1.0; - let mut shift = if let Some((middle, width)) = depth(-1, la, lb).and_then(|n| thin(0, -1, n, &inside)) { - middle + width / 2.0 - } else if let Some((middle, width)) = depth(1, lb, la).and_then(|n| thin(1, 1, n, &|k| 1.0 - inside(k))) { - middle - width / 2.0 - } else { - let (a1, b1) = (1.0 - inside(0), inside(1)); - let a2 = (label(-1) == Some(la)).then(|| (1.0 - inside(-1)).min(a1)); - let b2 = (label(2) == Some(lb)).then(|| inside(2).min(b1)); - // The edge's steepness per pixel: the square of the change over two pixels (1 for an - // anti-aliased edge, less for a blurred one). - let change = [a2.map(|v| 1.0 - v - b1), b2.map(|v| 1.0 - a1 - v)].into_iter().flatten().fold(f64::NAN, f64::max); - if !change.is_nan() { - steep = (change * change).clamp(0.35, 1.0); - } - b1 + b2.unwrap_or(0.0) - a1 - a2.unwrap_or(0.0) - }; - if bw { - // The share of black at which a pixel turns black. - let turn = ((white - f64::from(self.params.threshold)) / (white - black).max(1.0)).clamp(0.02, 0.98); - shift += if la == 1 { 1.0 } else { -1.0 } * (0.5 - turn) / steep; - } - shift - } - - /// Whether the pixels inside `l` (a loop along pixel edges) all have one label. - fn one_colour_within(&self, l: &Loop) -> bool { - let mut first = None; - let (y0, y1) = l.pts.iter().fold((i32::MAX, i32::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1))); - for y in y0..y1 { - // The loop's upright edges that pass this row of pixels, left to right: the pixels - // between the first and the second are inside, and so on. - let mut xs: Vec = l - .pts - .iter() - .zip(l.pts.iter().cycle().skip(1)) - .filter(|(a, b)| a.0 == b.0 && a.1.min(b.1) <= y && y < a.1.max(b.1)) - .map(|(a, _)| a.0) - .collect(); - xs.sort_unstable(); - for pair in xs.as_chunks::<2>().0 { - for x in pair[0]..pair[1] { - let label = usize::try_from(y) - .ok() - .zip(usize::try_from(x).ok()) - .and_then(|(y, x)| self.q.labels.get(y * self.w + x).copied().filter(|_| x < self.w)); - if label.is_none() || *first.get_or_insert(label) != label { - return false; - } - } - } - } - first.is_some() - } - - /// Shapes, Abutting: the shape the outline `sp` (fitted from the loop `l`, `hard` as that - /// loop is) is, where it may be one. The edge is shared by the regions on its two sides, so - /// both must become that same shape or neither: only a closed edge between two regions, a - /// region whose whole outline is the hole of one other (no junction on it, not on the image's - /// border), and only a shape inside the image. The shape is read from the outline run one way - /// from one start, so the region and the hole it fills get the very same one. - fn shared_shape(&self, l: &Loop, sp: &vectorcraft_geom::SubPath, hard: bool) -> Option { - let (w, h) = (i32::try_from(self.w).ok()?, i32::try_from(self.h).ok()?); - if l.pts.iter().any(|&(x, y)| x <= 0 || y <= 0 || x >= w || y >= h || self.junction(x, y).is_some()) { - return None; - } - let mut one_way = sp.clone(); - if one_way.signed_area() < 0.0 { - one_way.reverse(); - } - let first = one_way.anchors.iter().enumerate().min_by(|a, b| a.1.p.x.total_cmp(&b.1.p.x).then(a.1.p.y.total_cmp(&b.1.p.y)))?.0; - one_way.anchors.rotate_left(first); - let shape = shapes::recognise(&one_way, hard)?; - let (wf, hf) = (self.w as f64, self.h as f64); - let inside = shape - .path() - .subpaths - .iter() - .flat_map(|s| &s.anchors) - .flat_map(|a| [a.p, a.h_in, a.h_out]) - .all(|p| (0.0..=wf).contains(&p.x) && (0.0..=hf).contains(&p.y)); - inside.then_some(shape) - } - - /// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the - /// maximum. - fn layer(&self, r: usize, ci: usize) -> Option> { - let params = self.params; - let color = *self.q.palette.get(ci)?; - let overlapping = params.method == Method::Overlapping; - let (w, h) = (self.w, self.h); - // Create Strokes: this colour's lines, left out of its areas. - let (thin, lines) = if params.strokes { - // The ink the lines are read against: the layer's colour; Black and White's own black. - let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) }; - centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) - } else { - (vec![], vec![]) - }; - // The layer is traced within the box its pixels lie in, not the whole image. - let part = |l: u16| if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) && !self.ignored(l) } else { l as usize == ci }; - let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); - for (l, b) in self.boxes.iter().enumerate() { - if let Some(b) = b.filter(|_| part(l as u16)) { - (x0, y0, x1, y1) = (x0.min(b.0), y0.min(b.1), x1.max(b.2), y1.max(b.3)); - } - } - if x0 >= x1 || y0 >= y1 { - return Some(vec![]); - } - let (bw, bh) = (x1 - x0, y1 - y0); - let mut mask = Vec::with_capacity(bw * bh); - for y in y0..y1 { - let row = self.q.labels.get(y * w + x0..y * w + x1)?; - mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l))); - } - // Overlapping: a part of the mask with no pixel of this color holds only colors above it, - // whose own layers fill it, and is left out. The layer reaches under the colors above it - // only within the areas that hold its own color. - if overlapping { - let (comp, sizes) = contour::label(&mask, bw, bh); - let mut own = vec![false; sizes.len()]; - for (k, &c) in comp.iter().enumerate() { - if self.q.labels.get((y0 + k / bw) * w + x0 + k % bw).is_some_and(|&l| l as usize == ci) - && let Some(o) = own.get_mut(c as usize) - { - *o = true; - } - } - for (m, &c) in mask.iter_mut().zip(&comp) { - *m = *m && own.get(c as usize).copied().unwrap_or(false); - } - } - // Without Fills, areas are outlined with a 1 px stroke. - let area_stroke = (params.strokes && !params.fills).then_some(1.0); - let min_hole = params.noise.max(1) as i64; - let mut paths = vec![]; - let mut add = |path: PathData, pixels: usize, stroke: Option, shape: Option, square_caps: bool| { - let n = path.anchor_count(); - if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors { - return false; - } - // (A gradient fills; a stroked line of it is its middle colour.) - let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none()); - paths.push(TracedPath { path, color, pixels, stroke, shape, gradient, square_caps }); - true - }; - let mut comps = trace_mask(&mask, bw, bh); - drop(mask); - for comp in &mut comps { - for l in std::iter::once(&mut comp.outer).chain(&mut comp.holes) { - for p in &mut l.pts { - *p = (p.0 + x0 as i32, p.1 + y0 as i32); - } - } - } - for comp in comps { - // (`hard`: some of the outline has no anti-aliasing, and is known less exactly.) - let hard = std::cell::Cell::new(false); - // (Each loop fitted, and whether its own outline is hard.) - let fit = |l: &Loop| { - let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?; - // Every trace straightens the pieces of an outline that lie close to a line (Snap - // Curves To Lines straightens more of them), so the outline of a single pixel or - // of a region one pixel thick can have all its pieces on one chord, and the loop - // then has no area. Filled, such a loop paints nothing, and it is left out. - // Without Fills its 1 px stroke draws it, so it is kept. (`fit::fit_closed` puts - // back the positioned outline of a loop that is one closed piece when its fit has - // no area.) The area is summed about the loop's first anchor. - if area_stroke.is_none() { - use kurbo::ParamCurveArea; - let origin = sp.anchors.first()?.p.to_vec2(); - let area: f64 = (0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum(); - if area.abs() < 1e-6 { - return None; - } - } - hard.set(hard.get() || is_hard); - Some((sp, is_hard)) - }; - let Some((outer, _)) = fit(&comp.outer) else { continue }; - let mut subs = vec![outer]; - for hole in &comp.holes { - if hole.area2.abs() / 2 < min_hole && params.noise > 0 { - continue; - } - let Some((mut sp, own_hard)) = fit(hole) else { continue }; - // Shapes: a hole that is a shape is that shape, as what fills it is (the two are - // one outline: they stay one). Only where one colour fills it: the parts of - // several are not that shape, and would no longer meet it. Abutting, only where the - // region that fills it is that shape too (see `shared_shape`). - let exact = if !params.shapes || area_stroke.is_some() || !self.one_colour_within(hole) { - None - } else if overlapping { - shapes::recognise(&sp, hard.get()) - } else { - // (As hard as this outline is, as the region that fills it reads it.) - self.shared_shape(hole, &sp, own_hard) - }; - if let Some(exact) = exact.and_then(|shape| shape.path().subpaths.into_iter().next()) { - // (Which way round each goes: by the area of the polygon through points along it.) - let turn = |sp: &vectorcraft_geom::SubPath| { - use vectorcraft_geom::ParamCurve; - let pts: Vec<_> = - (0..sp.segment_count()).flat_map(|i| (0..6).map(move |k| sp.segment(i).eval(f64::from(k) / 6.0).to_vec2())).collect(); - pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::() - }; - let mut exact = exact; - if (turn(&exact) < 0.0) != (turn(&sp) < 0.0) { - exact.reverse(); - } - sp = exact; - } - subs.push(sp); - } - // Shapes: a filled outline without holes that is a shape becomes that shape. - let shape = match subs.as_slice() { - [only] if params.shapes && area_stroke.is_none() && overlapping => shapes::recognise(only, hard.get()), - // Abutting: only an island in one other region, whose hole it is (see `shared_shape`). - [only] if params.shapes && area_stroke.is_none() && comp.holes.is_empty() => self.shared_shape(&comp.outer, only, hard.get()), - _ => None, - }; - let path = shape.map_or_else(|| PathData::new(subs), |s| s.path()); - if !add(path, comp.pixels, area_stroke, shape, false) { - return None; - } - } - for line in lines { - if !add(line.path, line.pixels, Some(line.width), None, line.square) { - return None; - } - } - Some(paths) - } -} - -/// Test probe: every crack between two labels of `img` quantised as `trace` does (no gradients), -/// as `[shift, shift from the other side, one-sided reading, one-sided reading from the other -/// side]`. +use layers::parallel_map; #[cfg(test)] -pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { - let (w, h) = (img.width as usize, img.height as usize); - let mut q = quantize(img, params); - denoise(&mut q.labels, w, h, params.noise as usize); - let n = q.palette.len(); - let rank: Vec = (0..n).collect(); - let gradients = vec![None; n]; - let job = Layers { - q: &q, - rank: &rank, - params, - img, - ignore: None, - boxes: vec![None; n], - gradients: &gradients, - bw_tones: bw_tones(img, &q), - style: params.outline_style(), - w, - h, - anchors: AtomicUsize::new(0), - max_anchors: usize::MAX, - }; - let mut out = vec![]; - for y in 0..h as i32 { - for x in 0..w as i32 { - for step in [(1, 0), (0, 1)] { - let c = Crack::new((x, y), step, w, h); - let (Some(a), Some(b)) = (c.at(0), c.at(1)) else { continue }; - if q.labels[a] != q.labels[b] { - out.push([job.shift(c), job.shift(c.flipped()), job.shift_one_side(c), job.shift_one_side(c.flipped())]); - } - } - } - } - out -} - -/// `f` of each of `items`, in order, spread over the available cores. -fn parallel_map(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { - parallel_map_at_most(usize::MAX, items, f) -} - -/// `f` of each of `items`, in order, spread over the available cores, at most `at_a_time` at once. -#[cfg(not(target_arch = "wasm32"))] -fn parallel_map_at_most(at_a_time: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { - let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len()).min(at_a_time); - if threads <= 1 { - return items.iter().map(f).collect(); - } - let next = AtomicUsize::new(0); - let mut done: Vec<(usize, R)> = std::thread::scope(|s| { - let workers: Vec<_> = (0..threads) - .map(|_| { - s.spawn(|| { - let mut mine = vec![]; - loop { - let i = next.fetch_add(1, Ordering::Relaxed); - let Some(item) = items.get(i) else { break }; - mine.push((i, f(item))); - } - mine - }) - }) - .collect(); - // A worker's panic goes on in this thread, as if it had run here (the engine's guard - // reports it). - workers.into_iter().flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e))).collect() - }); - done.sort_by_key(|(i, _)| *i); - done.into_iter().map(|(_, r)| r).collect() -} - -/// `f` of each of `items`, in order (no threads on the web). -#[cfg(target_arch = "wasm32")] -fn parallel_map_at_most(_: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { - items.iter().map(f).collect() -} +use layers::parallel_map_at_most; +#[cfg(test)] +pub(crate) use layers::shift_probe; +pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets}; +pub use pipeline::{TraceResult, TracedPath, trace, trace_within}; +#[cfg(test)] +use raster::fit_size; +pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError}; #[cfg(test)] mod tests; diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs new file mode 100644 index 000000000..5503a84ff --- /dev/null +++ b/crates/trace/src/params.rs @@ -0,0 +1,251 @@ +use super::*; + +/// Tracing mode (Illustrator's Mode popup). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mode { + #[default] + #[serde(alias = "bw", alias = "blackWhite", alias = "Black and White")] + BlackAndWhite, + #[serde(alias = "gray", alias = "Grayscale")] + Grayscale, + #[serde(alias = "Color")] + Color, + /// Flat-colour logos: sub-pixel edges, sharp corners restored, true lines and circles. + #[serde(alias = "Logo", alias = "flat", alias = "flatLogo")] + Logo, +} + +/// How colour layers relate (Advanced → Method). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Method { + /// Each colour traced on its own; neighbouring shapes share their edge. + #[default] + Abutting, + /// Stacked: every layer extends under the layers above it, within the areas that hold its own + /// color. + Overlapping, +} + +/// Where Color mode's colours come from (the Palette popup). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Palette { + /// The `colors` colours that best represent the image. + #[default] + #[serde(alias = "Limited")] + Limited, + /// As many colours as the image's tones need, up to [`FULL_TONE_MAX`] (photos, gradients): + /// `color_detail` sets how close two colours may be and stay apart. + #[serde(alias = "Full Tone")] + FullTone, + /// Flat art (a few colours cover nearly all of it) traces with exactly those colours, anything + /// else as Full Tone. + #[serde(alias = "Automatic")] + Automatic, + /// The most used colours of a swatch library ([`TraceParams::swatches`]), at most `colors` of + /// them, exactly as they are. Each pixel takes the library color nearest in lightness and + /// hue, with hue and chroma counted three times as much as lightness, so that neutral tones + /// stay off saturated colors. + #[serde(alias = "library", alias = "Document Library")] + DocumentLibrary, +} + +/// Image Trace parameters (the Image Trace panel). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TraceParams { + pub mode: Mode, + /// Black and White: pixels darker than this (luminance 0–255) are black. + pub threshold: u8, + /// Color: where the colours come from. + pub palette: Palette, + /// Palette size for Color (Limited; at most this many for Document Library), number of grays + /// for Grayscale (2–256). + pub colors: u32, + /// Full Tone and Automatic: how many colours, 0 (few) – 100 (many). + pub color_detail: f64, + /// Document Library: the swatch library, `"document"` (the document's swatches) or a library id + /// or name. The caller looks its colours up into `swatches`. + pub library: String, + /// Document Library with the document's swatches: the name of the color group whose colors are + /// used. `None` (or empty) uses every swatch. `imageTrace.make` refuses a name with any other + /// palette, library or mode. + pub color_group: Option, + /// Document Library: the library's colours (sRGB). Not saved: they are looked up from + /// `library` (and `color_group`) at every trace. + #[serde(skip)] + pub swatches: Vec<[u8; 3]>, + /// Paths (fidelity) 0–100: higher follows the pixels more tightly. + pub paths: f64, + /// Corners 0–100: higher keeps more corners. + pub corners: f64, + /// Noise: areas smaller than this many pixels are ignored. (Flat Logo mode: areas smaller than + /// `noise / 10` pixels of the source image.) + pub noise: u32, + pub method: Method, + /// Ignore Color: drop the areas of one colour (no background shape): white, unless + /// `ignore_color` names another. + pub ignore_white: bool, + /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels + /// of it. `None` is white. (Flat Logo mode leaves out white only.) + pub ignore_color: Option, + /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs + /// to true circles. + pub snap_curves_to_lines: bool, + /// Shapes: a filled outline that is a circle, an ellipse, a rectangle (square or rounded + /// corners) or a regular polygon comes out as exactly that shape ([`TracedPath::shape`]). + pub shapes: bool, + /// Auto Grouping: say which path lies inside which ([`TraceResult::parents`]). + pub auto_grouping: bool, + /// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction + /// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour. + /// Grayscale finds them in the image's grays, and their stops are gray. + pub gradients: bool, + /// Create: trace areas as filled shapes. + pub fills: bool, + /// Create: trace lines no wider than `stroke_width` as stroked centre lines. + pub strokes: bool, + /// Stroke: the widest feature (px) traced as a stroke. + pub stroke_width: f64, + /// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is + /// always traced on its own and cannot be named, colours closer than about 12 (RGB distance) + /// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.) + pub logo_colors: Vec, +} + +impl Default for TraceParams { + /// Illustrator's [Default]: black and white, threshold 128. + fn default() -> Self { + Self { + mode: Mode::BlackAndWhite, + threshold: 128, + palette: Palette::Limited, + colors: 6, + color_detail: 50.0, + library: "document".into(), + color_group: None, + swatches: vec![], + paths: 50.0, + corners: 75.0, + noise: 25, + method: Method::Abutting, + ignore_white: false, + ignore_color: None, + snap_curves_to_lines: false, + shapes: false, + auto_grouping: false, + gradients: false, + fills: true, + strokes: false, + stroke_width: 10.0, + logo_colors: Vec::new(), + } + } +} + +impl TraceParams { + /// How outlines are fitted: Paths sets how closely the curves follow the edge (0.08 px at 100, + /// 0.33 px at 50, 0.78 px at 0), Corners the turn that makes a corner (35° at 100, 90° at 0). + /// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones, + /// makes parts of circles true arcs, and makes lines within 2.5° of level or upright exactly so. + pub(crate) fn outline_style(&self) -> logo::Style { + // Parameters come from users and agents: a NaN or infinity must not reach the geometry. + let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default }; + let snap = self.snap_curves_to_lines; + logo::Style { + step: 0.25, + tol: 0.08 + 0.7 * (1.0 - unit(self.paths, 0.5)).powf(1.5), + corner_deg: 90.0 - 55.0 * unit(self.corners, 0.75), + snap: true, + arcs: snap, + line_tol: if snap { 0.5 } else { 0.2 }, + circle_tol: if snap { 0.3 } else { 0.15 }, + restore: Some(logo::RestoreParams::default()), + check_step: 0.15, + upright: snap, + ..logo::Style::default() + } + } +} + +/// Built-in preset names in panel order (our own parameter sets). +pub const PRESET_NAMES: &[&str] = &[ + "Default", + "Auto Color", + "High Fidelity Photo", + "Low Fidelity Photo", + "3 Colors", + "6 Colors", + "16 Colors", + "Shades of Gray", + "Black and White Logo", + "Sketched Art", + "Silhouettes", + "Line Art", + "Technical Drawing", + "Outline", + "Flat Logo", +]; + +/// A built-in preset by name (case-insensitive; `[Default]` is accepted). +pub fn preset(name: &str) -> Option { + let key = name.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(); + let d = TraceParams::default(); + let color = |colors: u32, paths: f64, corners: f64, noise: u32, method: Method| TraceParams { + mode: Mode::Color, + colors, + paths, + corners, + noise, + method, + ..TraceParams::default() + }; + let bw = |threshold: u8, paths: f64, corners: f64, noise: u32, snap: bool| TraceParams { + mode: Mode::BlackAndWhite, + threshold, + paths, + corners, + noise, + ignore_white: true, + snap_curves_to_lines: snap, + ..TraceParams::default() + }; + Some(match key.as_str() { + "default" => d, + // A posterised image of a photo or of artwork: its own colours where it is flat art, as + // many as its tones need where it is not. + // (Color detail 85: a colour on a hundredth of the picture is still one of its colours.) + "auto color" => TraceParams { palette: Palette::Automatic, color_detail: 85.0, ..color(6, 65.0, 60.0, 16, Method::Abutting) }, + "high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping), + "low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping), + "3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting), + "6 colors" => color(6, 65.0, 60.0, 16, Method::Abutting), + "16 colors" => color(16, 70.0, 55.0, 10, Method::Abutting), + "shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 60.0, corners: 50.0, noise: 10, method: Method::Overlapping, ..d }, + "black and white logo" => bw(128, 95.0, 80.0, 8, true), + "sketched art" => bw(150, 50.0, 50.0, 100, false), + "silhouettes" => bw(200, 40.0, 60.0, 30, false), + "line art" => bw(128, 80.0, 70.0, 10, false), + "technical drawing" => bw(128, 95.0, 90.0, 4, true), + // Black outlines: lines as stroked centre lines, wider areas as stroked outlines. + "outline" => TraceParams { fills: false, strokes: true, ..bw(128, 80.0, 70.0, 10, false) }, + // Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel. + "flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d }, + _ => return None, + }) +} + +/// All built-in presets in panel order. +pub fn presets() -> Vec<(&'static str, TraceParams)> { + PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect() +} + +/// The enhanced version of built-in preset `name`: the preset with the options the enhanced +/// presets have built in: Auto Grouping in all of them, Shapes in all but High Fidelity Photo. +/// Gradients stay an option to turn on. Flat Logo, a preset of our own, has none. +pub fn enhanced_preset(name: &str) -> Option { + let p = preset(name).filter(|p| p.mode != Mode::Logo)?; + Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo"), ..p }) +} diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs new file mode 100644 index 000000000..6d3ea27f5 --- /dev/null +++ b/crates/trace/src/pipeline.rs @@ -0,0 +1,238 @@ +use super::layers::{Layers, parallel_map_at_most}; +use super::params::{Method, Mode, TraceParams}; +use super::raster::{Raster, TraceError}; +use super::*; + +/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`) +/// lines stroked with it. +#[derive(Clone, Debug, PartialEq)] +pub struct TracedPath { + /// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`). + pub path: PathData, + pub color: [u8; 3], + /// Filled pixel count of the component the path came from. + pub pixels: usize, + /// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the + /// outlines of wider areas without Fills). + pub stroke: Option, + /// Shapes: the shape the path is, exactly (`path` is that shape's outline). + pub shape: Option, + /// Gradients: the linear gradient the path is filled with (`color` is its middle colour). + pub gradient: Option, + /// Create Strokes: the stroke's ends are square (a cap that goes half the width past the + /// path's end, cut straight), as the line's ink is; otherwise round. + pub square_caps: bool, +} + +/// The traced result, bottom-most path first. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TraceResult { + pub paths: Vec, + /// Colours actually used. + pub palette: Vec<[u8; 3]>, + /// Auto Grouping: for each of `paths`, the path it lies inside, if any (empty without the + /// option). Each path drawn together with those inside it, in the order they have here, paints + /// what `paths` paints in its order. + pub parents: Vec>, + /// Flat Logo: the size in pixels the image was traced at when it was averaged down first; + /// `None` when it was traced at its own size. + pub worked_at: Option<(u32, u32)>, +} + +impl TraceResult { + pub fn anchor_count(&self) -> usize { + self.paths.iter().map(|p| p.path.anchor_count()).sum() + } +} + +fn is_white(c: [u8; 3]) -> bool { + c.iter().all(|&v| v >= 245) +} + +/// Whether Ignore Color leaves palette colour `c` out: `target` is the colour picked (`None`: white). +pub(crate) fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { + match target { + None => is_white(c), + Some(t) => (0..3).map(|k| (f64::from(c[k]) - f64::from(t[k])).powi(2)).sum::() <= 30.0 * 30.0 || (is_white(t) && is_white(c)), + } +} + +/// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use +/// [`trace_within`] to see why. +pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { + // No trace has more than `usize::MAX` anchors. + trace_within(img, params, usize::MAX).unwrap_or_default() +} + +/// Trace `img` with `params`, giving up with [`TraceError::TooComplex`] once the paths have more +/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The +/// colour layers are traced in parallel where threads are available. +pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { + let (w, h) = (img.width as usize, img.height as usize); + if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { + return Ok(TraceResult::default()); + } + let mut out = trace_paths(img, params, max_anchors)?; + if params.auto_grouping { + // Abutting areas cannot overlap; stacked layers, Logo's shapes and stroked lines can. + let stacked = params.method == Method::Overlapping || params.mode == Mode::Logo || params.strokes; + out.parents = group::parents(&out.paths, img.width as usize, img.height as usize, stacked); + } + Ok(out) +} + +fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { + let (w, h) = (img.width as usize, img.height as usize); + if params.mode == Mode::Logo { + return logo::trace_logo(img, params, max_anchors); + } + // The colour Ignore Color leaves out, if not white. + let ignore = match ¶ms.ignore_color { + Some(name) if params.ignore_white => Some(logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))?), + _ => None, + }; + // A photo is traced from a copy with its noise smoothed away (see `smooth`). + let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img)); + let img = smoothed.as_ref().unwrap_or(img); + // Gradients are found first and kept out of the palette: no colour is spent on bands of them. + // Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray. + let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| { + let mut grays = img.clone(); + for p in grays.rgba.as_chunks_mut::<4>().0 { + let g = quantize::luma(*p); + p[..3].fill(g); + } + grays + }); + let traced_as = grays.as_ref().unwrap_or(img); + let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty()); + let without = found.as_ref().map(|(of, _)| { + let mut rest = img.clone(); + for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) { + if g != u32::MAX { + p[3] = 0; + } + } + rest + }); + let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params); + // The semi-transparent edge of a cut-out image traces as the color beside it. + if matches!(params.mode, Mode::Color | Mode::Grayscale) { + quantize::settle_cut_edges(&mut q, img, params.mode == Mode::Grayscale); + } + drop(without); + // Each gradient is a palette entry of its own (its colour: the gradient's middle). + let mut gradients: Vec> = vec![None; q.palette.len()]; + if let Some((mut of, found)) = found { + gradient::settle_edges(traced_as, &mut of, &found, &mut q.labels); + let first = q.palette.len(); + for (l, &g) in q.labels.iter_mut().zip(&of) { + if g != u32::MAX { + *l = u16::try_from(first + g as usize).unwrap_or(TRANSPARENT); + } + } + q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8))); + gradients.extend(found.into_iter().map(Some)); + } + drop(grays); + // Noise clears specks, not the regions of a rare colour Automatic kept on purpose. + let keep: Vec = q.palette.iter().map(|c| rare.contains(c)).collect(); + quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); + // Pixel count per palette entry, and the box its pixels lie in. + let mut counts = vec![0usize; q.palette.len()]; + let mut boxes: Vec> = vec![None; q.palette.len()]; + for (i, &l) in q.labels.iter().enumerate() { + if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) { + *c += 1; + let (x, y) = (i % w, i / w); + *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + } + } + // Layers bottom → top: largest area first. + let mut order: Vec = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); + order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b))); + if params.method == Method::Overlapping { + // Stacked layers go by lightness instead, from the largest colour's end of the scale: each + // layer reaches under those above it in the areas that hold its own color, so where two + // colours meet, every layer between them in the stack that has a pixel in that area ends + // on that same edge and shows through its anti-aliasing. Neighbours in a picture are + // mostly neighbours in tone, and in this order they are neighbours in the stack too (by + // area, an unrelated colour's edge would shine through as a hairline). + let light = |i: usize| q.palette.get(i).map_or(0, |c| u32::from(c[0]) * 299 + u32::from(c[1]) * 587 + u32::from(c[2]) * 114); + let first = order.first().copied(); + order.sort_by(|a, b| light(*a).cmp(&light(*b)).then(a.cmp(b))); + if first.is_some_and(|f| order.iter().position(|&i| i == f).is_some_and(|at| at * 2 >= order.len())) { + order.reverse(); + } + } + let mut rank = vec![usize::MAX; q.palette.len()]; + for (r, &i) in order.iter().enumerate() { + rank[i] = r; + } + let layers: Vec<(usize, usize)> = + order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect(); + let job = Layers { + q: &q, + rank: &rank, + params, + img, + ignore, + boxes, + gradients: &gradients, + bw_tones: bw_tones(img, &q), + style: params.outline_style(), + w, + h, + anchors: AtomicUsize::new(0), + max_anchors, + }; + let mut out = TraceResult::default(); + // A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels + // of its components, the stack that fills them, its edges): as many layers at a time as fit + // in half a gigabyte, so that a large image is not traced at the cost of gigabytes. + // (Overlapping labels a layer's mask once more before it is traced, with its labels and stack + // freed before the trace starts.) + let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1); + let at_a_time = ((512 << 20) / (largest * 10)).max(1); + for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) { + let paths = paths.ok_or(TraceError::TooComplex { max: max_anchors })?; + // A layer with no paths adds no color to the palette. + if paths.is_empty() { + continue; + } + out.paths.extend(paths); + let color = q.palette[ci]; + if !out.palette.contains(&color) { + out.palette.push(color); + } + } + Ok(out) +} + +/// The luminance of white and of black in a Black and White quantisation: what its lightest +/// white pixels and its darkest black ones have (the 98th and 2nd percentiles; pure white and +/// black where a side has no pixel). Not the means: where the black is thin lines, blurred or +/// scanned, most black pixels are part paper, their mean is a gray, and read against it the middle +/// of every line would count as more than all ink, now more and now less as the line crosses the +/// pixel rows, which put a ripple on its edges. +pub(crate) fn bw_tones(img: &Raster, q: &Quantized) -> [f64; 2] { + let mut hist = [[0u64; 256]; 2]; + for (p, &l) in img.rgba.as_chunks::<4>().0.iter().zip(&q.labels) { + if let Some(h) = hist.get_mut(l as usize) { + h[((u32::from(p[0]) * 299 + u32::from(p[1]) * 587 + u32::from(p[2]) * 114 + 500) / 1000).min(255) as usize] += 1; + } + } + // The luminance a share `at` of the side's pixels are darker than. + let percentile = |h: &[u64; 256], at: f64, none: f64| { + let total: u64 = h.iter().sum(); + let mut seen = 0; + for (v, n) in h.iter().enumerate() { + seen += n; + if total > 0 && seen as f64 >= at * total as f64 { + return v as f64; + } + } + none + }; + [percentile(&hist[0], 0.98, 255.0), percentile(&hist[1], 0.02, 0.0)] +} diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs new file mode 100644 index 000000000..f096a97d0 --- /dev/null +++ b/crates/trace/src/raster.rs @@ -0,0 +1,143 @@ +/// Errors decoding a raster. +#[derive(Debug, thiserror::Error)] +pub enum TraceError { + #[error("could not decode image: {0}")] + Decode(String), + #[error("image is empty")] + Empty, + #[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")] + TooLarge { width: u32, height: u32, max: u64 }, + #[error("this isn't a flat-colour logo ({:.0}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead", (.fraction * 100.0).floor())] + NotFlat { fraction: f64 }, + #[error("\"{0}\" is not a colour: write it as #rrggbb")] + BadPalette(String), + #[error( + "this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset" + )] + TooComplex { max: usize }, +} + +/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are +/// refused instead of running out of memory. +pub const MAX_PIXELS: u64 = 64 << 20; + +/// The most pixels an image may have for [`Raster::decode_to_fit`] to decode it and scale it down +/// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused. +pub const MAX_SOURCE_PIXELS: u64 = 192 << 20; + +/// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1). +pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { + let pixels = u64::from(w) * u64::from(h); + if pixels <= max || pixels == 0 { + return (w, h); + } + let k = (max as f64 / pixels as f64).sqrt(); + let side = |v: u32| ((f64::from(v) * k).floor() as u32).clamp(1, v); + let (mut nw, mut nh) = (side(w), side(h)); + // A side held at 1 pixel leaves the other too long: cut it to what fits. + if u64::from(nw) * u64::from(nh) > max { + if nw >= nh { + nw = u32::try_from(max / u64::from(nh)).unwrap_or(nw).clamp(1, nw); + } else { + nh = u32::try_from(max / u64::from(nw)).unwrap_or(nh).clamp(1, nh); + } + } + (nw, nh) +} + +/// An RGBA8 raster (row-major, top row first). +#[derive(Clone, Debug, PartialEq)] +pub struct Raster { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +impl Raster { + /// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other + /// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not + /// a crash. (That error's text is already translated; its detail names the sizes.) + pub fn new(width: u32, height: u32, rgba: Vec) -> Result { + let want = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4)); + if want != Some(rgba.len()) { + return Err(TraceError::Decode(format!("{} bytes of RGBA data for a {width} × {height} image", rgba.len()))); + } + Ok(Self { width, height, rgba }) + } + /// A raster whose pixel `(x, y)` is `f(x, y)`. + pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self { + let mut rgba = Vec::with_capacity(width as usize * height as usize * 4); + for y in 0..height { + for x in 0..width { + rgba.extend_from_slice(&f(x, y)); + } + } + Self { width, height, rgba } + } + /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). + pub fn decode(bytes: &[u8]) -> Result { + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; + if w == 0 || h == 0 { + return Err(TraceError::Empty); + } + if u64::from(w) * u64::from(h) > MAX_PIXELS { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); + let (w, h) = img.dimensions(); + Ok(Self { width: w, height: h, rgba: img.into_raw() }) + } + /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to + /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within + /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly + /// less than its own resolution. The caller maps the result back with `width` / `height`. + pub fn decode_to_fit(bytes: &[u8]) -> Result { + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; + if w == 0 || h == 0 { + return Err(TraceError::Empty); + } + let pixels = u64::from(w) * u64::from(h); + if pixels <= MAX_PIXELS { + return Self::decode(bytes); + } + if pixels > MAX_SOURCE_PIXELS { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + } + // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. + // A deeper image (16 bits a channel) that needs more fails with a decode error. + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); + reader.limits(limits); + let img = reader.decode().map_err(decode_err)?.into_rgba8(); + let (w, h) = img.dimensions(); + let (nw, nh) = fit_size(w, h, MAX_PIXELS); + // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. + let small = image::imageops::thumbnail(&img, nw, nh); + Ok(Self { width: nw, height: nh, rgba: small.into_raw() }) + } + /// Encode as PNG. + pub fn encode_png(&self) -> Vec { + let mut out = Vec::new(); + if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { + let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png); + } + out + } + /// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose + /// fields were set to a size its bytes don't have). + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + if x >= self.width || y >= self.height { + return [0; 4]; + } + let i = (y as usize * self.width as usize + x as usize) * 4; + match self.rgba.get(i..i + 4) { + Some(&[r, g, b, a]) => [r, g, b, a], + _ => [0; 4], + } + } +} From 7e5276017494064e202cf23f8161ec2fe61bcf7d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:23:49 +0000 Subject: [PATCH 123/199] refactor(svg): move export.rs items to export/{nodes,output,paint,text}.rs --- crates/svg/src/export.rs | 2417 +------------------------------ crates/svg/src/export/nodes.rs | 705 +++++++++ crates/svg/src/export/output.rs | 422 ++++++ crates/svg/src/export/paint.rs | 503 +++++++ crates/svg/src/export/text.rs | 806 +++++++++++ 5 files changed, 2447 insertions(+), 2406 deletions(-) create mode 100644 crates/svg/src/export/nodes.rs create mode 100644 crates/svg/src/export/output.rs create mode 100644 crates/svg/src/export/paint.rs create mode 100644 crates/svg/src/export/text.rs diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index c393f98f3..9b64f97dd 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -21,6 +21,17 @@ use crate::{ use crate::css::{self, Props, blend_css, css_string, font_descriptor}; +mod nodes; +mod output; +mod paint; +mod text; + +use nodes::SymbolDef; +pub(crate) use nodes::{MASK_FLAGS, Reuse}; +pub(crate) use output::sanitize_id; +use output::{entity_value, latin1_refs, valid_xml_value, xml_attr_escape, xml_text}; +use text::FontUse; + pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output { // Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result. let baked = vectorcraft_effects::bake_document(doc); @@ -182,118 +193,6 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: Output { svg: out, linked: w.linked, warnings: w.warnings, encoding } } -/// XML 1.0 literal attribute whitespace is normalized to spaces; references retain the value. -fn xml_attr_escape(s: &str) -> String { - xml_escape(s).replace('\t', " ").replace('\n', " ").replace('\r', " ") -} - -/// Escape type for XML content. Characters XML 1.0 does not allow are dropped, except the line -/// and page breaks (U+000B, U+000C) other apps paste, which become spaces. -fn xml_text(s: &str) -> String { - let s: String = s - .chars() - .filter_map(|c| match c { - '\u{b}' | '\u{c}' => Some(' '), - c if valid_xml_value(c.encode_utf8(&mut [0; 4])) => Some(c), - _ => None, - }) - .collect(); - xml_escape(&s) -} - -fn valid_xml_value(s: &str) -> bool { - s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) -} - -/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s. -fn xml_ncname(s: &str) -> bool { - let mut chars = s.chars(); - chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char) -} - -/// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar. -fn xml_name_char(c: char) -> bool { - matches!(c, - 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '\u{b7}' | - '\u{c0}'..='\u{d6}' | '\u{d8}'..='\u{f6}' | '\u{f8}'..='\u{2ff}' | - '\u{300}'..='\u{37d}' | '\u{37f}'..='\u{1fff}' | '\u{200c}'..='\u{200d}' | - '\u{203f}'..='\u{2040}' | '\u{2070}'..='\u{218f}' | '\u{2c00}'..='\u{2fef}' | - '\u{3001}'..='\u{d7ff}' | '\u{f900}'..='\u{fdcf}' | '\u{fdf0}'..='\u{fffd}' | - '\u{10000}'..='\u{effff}' - ) -} - -/// Write every character of `s` beyond ISO 8859-1 as a character reference. Called only when -/// names fit Latin-1, so references occur in values, text or style sheets. -fn latin1_refs(s: &mut String) { - if s.chars().all(|c| u32::from(c) < 0x100) { - return; - } - let mut o = String::with_capacity(s.len() + 64); - for c in s.chars() { - if u32::from(c) < 0x100 { - o.push(c); - } else { - let _ = write!(o, "&#x{:X};", u32::from(c)); - } - } - *s = o; -} - -/// `chars` (sorted) as a CSS `unicode-range` (`U+41-5A,U+61`). -fn unicode_range(chars: &BTreeSet) -> String { - let mut runs: Vec<(u32, u32)> = Vec::new(); - for c in chars.iter().map(|c| u32::from(*c)) { - match runs.last_mut() { - Some((_, end)) if c == *end + 1 => *end = c, - _ => runs.push((c, c)), - } - } - let one = |(a, b): (u32, u32)| if a == b { format!("U+{a:X}") } else { format!("U+{a:X}-{b:X}") }; - runs.into_iter().map(one).collect::>().join(",") -} - -/// A CSS declaration block as the literal value of an ``: quotes, `%` and markup -/// characters become references that still read as themselves inside the `style` attribute. -fn entity_value(decl: &str) -> String { - let mut o = String::with_capacity(decl.len()); - for c in decl.chars() { - match c { - '"' => o.push_str("""), - '%' => o.push_str("%"), - // Doubly escaped: the entity's replacement text is parsed again where it is used. - '&' => o.push_str("&#38;"), - '<' => o.push_str("&#60;"), - _ => o.push(c), - } - } - o -} - -/// Image `b` as SVG viewers show it: PNG, JPEG, GIF, WebP and SVG as they are, other formats (a -/// CMYK TIFF) as PNG. -fn web_image(b: &ImageBlob) -> std::borrow::Cow<'_, ImageBlob> { - use std::borrow::Cow; - if matches!(b.mime.as_str(), "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" | "image/svg+xml") { - return Cow::Borrowed(b); - } - let png = image::load_from_memory(&b.bytes).ok().and_then(|img| { - let mut out = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some(out) - }); - png.map_or(Cow::Borrowed(b), |png| Cow::Owned(ImageBlob::png(png))) -} - -/// The usual file extension of an image MIME type. -fn image_ext(mime: &str) -> &str { - match mime { - "image/jpeg" => "jpg", - "image/svg+xml" => "svg", - m => m.strip_prefix("image/").filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin"), - } -} - struct Writer<'a> { doc: &'a Document, opts: &'a ExportOptions, @@ -352,2297 +251,3 @@ struct Writer<'a> { /// ([`Writer::foreign_attrs`]): declared on the root. foreign_ns: BTreeMap, } - -/// The characters type uses from one face, under one `@font-face` description: the family the -/// type names and the weight, italic and width its style asks for. -struct FontUse { - family: String, - weight: u16, - italic: bool, - /// The `font-stretch` percentage ([`css::font_stretch`]), as bits; `None` for the normal width. - stretch: Option, - face: Arc, - chars: BTreeSet, -} - -/// A symbol's `` def and the instance transforms that can `` it. -struct SymbolDef { - /// The def's id, once written. - id: Option, - reuse: Reuse, -} - -/// Which instance transforms paint a symbol's art exactly as the def under that transform (the -/// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked -/// masks stay put). -#[derive(Clone, Copy)] -pub(crate) struct Reuse { - /// Translations (no pattern paints or unlinked masks). - moves: bool, - /// Rotations and reflections (nor effects or brushes). - turns: bool, - /// Any other transform (nor strokes or live objects). - scales: bool, -} - -/// How one stroke is written. -enum StrokePlan { - /// Its brush art (document space). - Brush(Vec), - Written(Written), -} - -/// How deep symbols may sit in one another's art (deeper instances are left out). -const MAX_SYMBOL_NEST: u32 = 8; - -impl Reuse { - const NONE: Self = Self { moves: false, turns: false, scales: false }; - - /// What a def of symbol art `art` stands for. - pub(crate) fn of(doc: &Document, art: &Node) -> Self { - let mut r = Self { moves: true, turns: true, scales: true }; - r.scan(doc, art, 0); - r - } - - /// Narrow down to what `n` (inside symbols nested `depth` deep) allows. - fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { - let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); - let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); - let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked); - if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { - *self = Self::NONE; - return; - } - let effects = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); - if effects(&n.appearance.effects) || n.appearance.items.iter().any(|i| effects(i.effects())) { - (self.turns, self.scales) = (false, false); - } - for i in &n.appearance.items { - if let AppearanceItem::Stroke(s) = i - && s.visible - && !s.paint.is_none() - && s.width > 0.0 - { - // Stroke weights don't scale; brushes can orient their art to the page. - self.scales = false; - self.turns &= s.brush.is_none(); - } - } - match &n.kind { - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false), - NodeKind::SymbolInstance { symbol, .. } => { - if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { - self.scan(doc, &s.art, depth + 1); - } - } - _ => {} - } - if let Some(m) = &n.extras().mask { - self.scan(doc, &m.art, depth); - } - for c in n.children().into_iter().flatten() { - self.scan(doc, c, depth); - } - } - - /// Can the def stand for an instance with transform `xf`? - pub(crate) fn allows(self, xf: Affine) -> bool { - const EPS: f64 = 1e-9; - let [a, b, c, d, _, _] = xf.as_coeffs(); - if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS { - return self.moves; - } - let rigid = (a * a + b * b - 1.0).abs() < EPS && (c * c + d * d - 1.0).abs() < EPS && (a * c + b * d).abs() < EPS; - if rigid { self.turns } else { self.scales } - } -} - -/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas -/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement -/// and the art of unlinked opacity masks. -fn pin_to_page(n: &mut Node, m: Affine) { - vectorcraft_doc::pattern::transform_pattern_paints(n, m); - if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { - let linked = mask.linked; - let art = std::sync::Arc::make_mut(&mut mask.art); - if !linked { - art.transform(m, false); - } - pin_to_page(art, m); - } - for c in n.children_mut().into_iter().flatten() { - pin_to_page(std::sync::Arc::make_mut(c), m); - } -} - -/// The Dublin Core terms File Info writes to ``: the format, title, author (creator), -/// description, each keyword (subject), copyright notice and URL (rights) and created date, the -/// empty ones left out. -fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { - let info = &doc.metadata; - let mut terms = vec![("format", "image/svg+xml".to_string())]; - let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); - let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; - let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; - let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); - terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); - terms -} - -/// What SVG Tiny 1.2 does without masks. -const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups"; -/// A region covering any artwork (mask and filter extents). -const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; -/// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the -/// canvas and PDF take it) rather than from linearised ones. -const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; -/// Attribute of an exported opacity mask listing its options that differ from the defaults -/// (clipping, not inverted): `noclip`, `invert`. -pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask"; - -/// Turn an object name into a valid, readable XML id. -pub(crate) fn sanitize_id(name: &str) -> String { - let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect(); - if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') { - s.insert(0, '_'); - } - s -} - -impl Writer<'_> { - /// Writing the SVG Tiny 1.2 profile? - fn tiny(&self) -> bool { - self.opts.profile == Profile::Tiny12 - } - fn num(&self, v: f64) -> String { - fmt_num(v, self.opts.decimals) - } - fn indent(&self, depth: usize) -> String { - if self.opts.minify { String::new() } else { " ".repeat(depth) } - } - fn line(&mut self, s: &str) { - let ind = self.indent(self.depth); - self.body.push_str(&ind); - self.body.push_str(s); - if !self.opts.minify { - self.body.push('\n'); - } - } - fn def(&mut self, depth: usize, s: &str) { - let ind = self.indent(depth + 1); - self.defs.push_str(&ind); - self.defs.push_str(s); - if !self.opts.minify { - self.defs.push('\n'); - } - } - /// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are - /// missing), else its bytes. `None` when it has neither. - fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option { - let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); - match (im.link.as_ref().filter(|_| link), doc.image_blob(im)) { - (Some(l), _) => Some(l.path.clone()), - (None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(&web_image(b))), - _ => im.link.as_ref().map(|l| l.path.clone()), - } - } - - /// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs - /// once; each piece then draws them with ``. - fn share_images(&mut self, art: &Node) { - // Each image's count and its first piece, which shows what they all show. - let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); - art.walk(&mut |c| { - if let NodeKind::Image(im) = &c.kind { - seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; - } - }); - let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); - repeated.sort_by(|a, b| a.0.cmp(&b.0)); - for ((key, width, height), (_, im)) in repeated { - let Some(href) = self.image_href(&im) else { continue }; - let id = self.fresh_id("image"); - self.def( - 0, - &format!( - "", - xml_escape(&href) - ), - ); - self.shared_images.insert((key, width, height), id); - } - } - - fn fresh_id(&mut self, prefix: &str) -> String { - let mut i = 1; - loop { - let id = format!("{}{prefix}-{i}", self.id_prefix); - if self.used_ids.insert(id.clone()) { - return id; - } - i += 1; - } - } - /// `base` (with the unique prefix) if free, else `base-2`, `base-3`… - fn unique_id(&mut self, base: &str) -> String { - let base = format!("{}{base}", self.id_prefix); - if self.used_ids.insert(base.clone()) { - return base; - } - let next = self.next_suffix.entry(base.clone()).or_insert(2); - loop { - let cand = format!("{base}-{next}"); - *next += 1; - if self.used_ids.insert(cand.clone()) { - return cand; - } - } - } - - /// Reserve ids for every named object up front so generated def ids never collide with them. - fn assign_name_ids(&mut self) { - if self.opts.object_ids == ObjectIds::Minimal { - return; - } - let mut named: Vec<(NodeId, String)> = Vec::new(); - self.doc.walk(|n| { - if let Some(name) = &n.name { - named.push((n.id, sanitize_id(name))); - } - }); - for (id, base) in named { - let cand = self.unique_id(&base); - self.names.insert(id, cand); - } - } - /// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to - /// differ from it (spaces, punctuation, duplicates, the unique prefix). - /// The id of `n`'s element (with its name when the id isn't it), then the object's own data - /// as `data-*` attributes. Sublayers also retain their import marker. - fn id_attr(&mut self, n: &Node) -> String { - if self.anonymous { - return String::new(); - } - let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id))); - if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref()) - && name != id - { - if valid_xml_value(name) { - out.push_str(&format!(" data-name=\"{}\"", xml_attr_escape(name))); - } else { - self.warn("Object names containing invalid XML characters were left out of SVG name attributes"); - } - } - // Preserve sublayer identity independently of object data attributes. - if !out.is_empty() && n.is_layer() && self.layer_nest > 1 { - out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); - } - let mut keys = HashSet::new(); - for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { - // Ours (`name`, `vc-…`) are written by the export itself. - if k == "name" || k.starts_with("vc-") { - continue; - } - if k.is_empty() || !k.chars().all(xml_name_char) || !valid_xml_value(v) { - self.warn("Data attributes with invalid XML names or characters were left out of SVG"); - continue; - } - // Native data is an ordered list. Keep the first valid value of each key. - if !keys.insert(k.as_str()) { - self.warn("Repeated data attribute keys were written only once in SVG"); - continue; - } - self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff); - out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v))); - } - self.foreign_attrs(n, &mut out); - self.event_attrs(n, &mut out); - out - } - /// `n`'s attributes in other applications' namespaces (`plotter:pen="2"`), added to `out`. - /// Each namespace is declared once, on the root, under the prefix the file gave it unless the - /// export uses that prefix itself or for another namespace ([`Writer::foreign_prefix`]). - fn foreign_attrs(&mut self, n: &Node, out: &mut String) { - let attrs = n.extras().attrs.as_deref().map_or(&[][..], |a| a.foreign.as_slice()); - let mut seen = HashSet::new(); - for a in attrs { - if !xml_ncname(&a.prefix) || !xml_ncname(&a.name) || a.ns.is_empty() || !valid_xml_value(&a.ns) || !valid_xml_value(&a.value) { - self.warn("Attributes of other applications with invalid XML names or characters were left out of SVG"); - continue; - } - // Native data is a list: one attribute per namespace and name. - if !seen.insert((a.ns.as_str(), a.name.as_str())) { - self.warn("Repeated attributes of other applications were written only once in SVG"); - continue; - } - let prefix = self.foreign_prefix(&a.ns, &a.prefix); - self.non_latin1_names |= prefix.chars().chain(a.name.chars()).any(|c| u32::from(c) > 0xff); - out.push_str(&format!(" {prefix}:{}=\"{}\"", a.name, xml_attr_escape(&a.value))); - } - } - /// The prefix namespace `ns` is written under: `wanted` (the file's), else `wanted2`, - /// `wanted3`… when the export's own prefixes or another namespace already have it. XML keeps - /// prefixes starting with `xml` for itself: those become `ns`, `ns2`… - fn foreign_prefix(&mut self, ns: &str, wanted: &str) -> String { - if let Some(p) = self.foreign_ns.get(ns) { - return p.clone(); - } - const OURS: [&str; 5] = ["svg", "xlink", "rdf", "dc", "vectorcraft"]; - let base = if wanted.to_ascii_lowercase().starts_with("xml") { "ns" } else { wanted }; - let taken = |p: &str| OURS.contains(&p) || self.foreign_ns.values().any(|q| q == p); - let mut prefix = base.to_string(); - let mut k = 2; - while taken(&prefix) { - prefix = format!("{base}{k}"); - k += 1; - } - self.foreign_ns.insert(ns.to_string(), prefix.clone()); - prefix - } - /// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to - /// `out`. The SVG Tiny 1.2 profile leaves them out with a warning. - fn event_attrs(&mut self, n: &Node, out: &mut String) { - let events = n.extras().attrs.as_deref().map_or(&[][..], |a| a.events.as_slice()); - if events.is_empty() { - return; - } - if self.tiny() { - self.warn("SVG Tiny 1.2 has no event attributes: SVG Interactivity events were left out"); - return; - } - let mut seen = HashSet::new(); - for (event, script) in events { - // Native data can name anything: only the panel's events are written, once each. - if !SVG_EVENTS.contains(&event.as_str()) || !valid_xml_value(script) { - self.warn("SVG Interactivity events with unknown names or invalid XML characters were left out of SVG"); - continue; - } - if !seen.insert(event.as_str()) { - self.warn("Repeated SVG Interactivity events were written only once in SVG"); - continue; - } - out.push_str(&format!(" {event}=\"{}\"", xml_attr_escape(script))); - } - } - fn matrix(&self, m: Affine) -> String { - let c = m.as_coeffs(); - format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5])) - } - - /// Style properties as attributes, an inline style, or a CSS class, per the styling option. - fn attrs(&mut self, props: &Props) -> String { - if props.is_empty() { - return String::new(); - } - // SVG Tiny has presentation attributes alone. - let styling = if self.tiny() { Styling::PresentationAttributes } else { self.opts.styling }; - match styling { - Styling::PresentationAttributes => { - // CSS-only properties go in a style attribute (SVG Tiny has none: left out). - let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| { - matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-variation-settings" | "font-kerning") - }); - let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect(); - if !style.is_empty() { - if self.tiny() { - self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation, font features and font variation settings are left out"); - } else { - s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style)))); - } - } - s - } - Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css::declarations(props))), - Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css::declarations(props))), - Styling::InternalCss => { - let i = self.class(css::declarations(props)); - format!(" class=\"{}cls-{i}\"", self.id_prefix) - } - } - } - - /// The 1-based number of a distinct declaration block (a CSS class or a style entity). - fn class(&mut self, decl: String) -> usize { - if let Some(&i) = self.class_ids.get(decl.as_str()) { - return i; - } - let i = self.classes.len() + 1; - self.class_ids.insert(decl.clone(), i); - self.classes.push(decl); - i - } - - /// ``: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the - /// native document ([`ExportOptions::preserve_editing`]) with the [`body_hash`] of the markup - /// around this element. - fn metadata(&self, out: &mut String, native: Option<(&[u8], String)>) { - if !self.opts.metadata && native.is_none() { - return; - } - let nl = if self.opts.minify { "" } else { "\n" }; - let mut line = |depth: usize, s: &str| { - out.push_str(&self.indent(depth)); - out.push_str(s); - out.push_str(nl); - }; - line(1, ""); - if self.opts.metadata { - line(2, ""); - line(3, ""); - for (term, value) in dublin_core(self.doc) { - line(4, &format!("{}", xml_escape(&value))); - } - line(3, ""); - line(2, ""); - } - if let Some((bytes, hash)) = native { - line( - 2, - &format!( - "", - base64_encode(bytes) - ), - ); - } - line(1, ""); - } - - fn path_d(&self, p: &PathData, m: Affine) -> String { - let mut s = String::new(); - // Each point as "x y", appended in place (no string per number or per point). - let dec = self.opts.decimals; - let pt = |s: &mut String, p: Point| { - let p = m * p; - crate::push_num(s, p.x, dec); - s.push(' '); - crate::push_num(s, p.y, dec); - }; - for sp in &p.subpaths { - let n = sp.anchors.len(); - if n == 0 { - continue; - } - if !s.is_empty() { - s.push(' '); - } - s.push('M'); - pt(&mut s, sp.anchors[0].p); - for i in 0..sp.segment_count() { - let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]); - if sp.segment_is_line(i) { - if sp.closed && i == n - 1 { - break; - } - s.push_str(" L"); - pt(&mut s, b.p); - } else { - s.push_str(" C"); - pt(&mut s, a.h_out); - s.push(' '); - pt(&mut s, b.h_in); - s.push(' '); - pt(&mut s, b.p); - } - } - if sp.closed { - s.push_str(" Z"); - } - } - s - } - - /// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space - /// [`Self::xf`] maps into user space, as the renderer does). - fn paint(&mut self, p: &Paint, bounds: Option) -> String { - match p { - Paint::None => "none".into(), - Paint::Pattern { pattern, xf } => match self.pattern_def(pattern, *xf) { - Some(id) => format!("url(#{id})"), - None => "none".into(), - }, - Paint::Solid { color, .. } => color.to_hex(), - Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))), - } - } - - /// A `` def for pattern `name` placed by `xf`: one period (super-tile) of the tiling, - /// with every instance that reaches into it. - fn pattern_def(&mut self, name: &str, xf: Affine) -> Option { - let doc = self.doc; - let def = doc.pattern(name)?; - let m = self.xf * xf; - let key = format!("{name}|{:?}", m.as_coeffs()); - if let Some(id) = self.patterns.get(&key) { - return Some(id.clone()); - } - if self.pattern_nest > 4 { - return None; - } - let id = self.fresh_id("pattern"); - self.patterns.insert(key, id.clone()); - let (pw, ph) = def.period(); - let (saved_body, saved_xf, saved_depth) = (std::mem::take(&mut self.body), self.xf, self.depth); - self.depth = 3; - self.pattern_nest += 1; - for o in def.offsets_covering(Rect::new(0.0, 0.0, pw, ph)) { - self.xf = def.instance_xf(o); - for a in &def.art { - self.node(a); - } - } - self.pattern_nest -= 1; - let content = std::mem::replace(&mut self.body, saved_body); - self.xf = saved_xf; - self.depth = saved_depth; - let head = format!( - "", - self.num(pw), - self.num(ph), - self.matrix(m) - ); - self.def(1, &head); - self.defs.push_str(&content); - self.def(1, ""); - Some(id) - } - - /// The id of a ``/`` def for `g` resolved against `bounds`. - /// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its - /// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint` - /// (what import turns back into a midpoint). - fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { - if g.gradient.kind == GradientKind::Freeform { - // Object fills and strokes are images (see `raster_paint`); characters' paints aren't. - self.warn("freeform gradients on characters are written as linear gradients"); - } - let mut geom = g.resolve(bounds); - geom.transform(self.xf, g.gradient.kind); - let (s, e) = (geom.start, geom.end); - // An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space. - let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y)); - let (tag, prefix, attrs) = match g.gradient.kind { - GradientKind::Radial => { - let r = self.num((e - s).hypot()); - let attrs = if (geom.aspect - 1.0).abs() < 1e-9 { - let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default(); - format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y)) - } else { - let v = e - s; - let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect); - let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default(); - format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m)) - }; - ("radialGradient", "radial-gradient", attrs) - } - GradientKind::Linear | GradientKind::Freeform => ( - "linearGradient", - "linear-gradient", - format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), - ), - }; - let stops: Vec = g - .gradient - .expanded() - .map(|(offset, c, o, mid)| { - let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() }; - let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default(); - format!("", fmt_num(offset as f64, 4), c.to_hex()) - }) - .collect(); - let key = format!("{tag}{attrs}{}", stops.concat()); - if let Some(id) = self.gradients.get(&key) { - return id.clone(); - } - let id = match &g.swatch { - Some(name) => self.unique_id(&sanitize_id(name)), - None => self.fresh_id(prefix), - }; - self.gradients.insert(key, id.clone()); - self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">")); - for stop in &stops { - self.def(2, stop); - } - self.def(1, &format!("")); - id - } - - fn fill_props(&mut self, f: &FillLayer, rule: FillRule, bounds: Option, p: &mut Props) { - let paint = self.paint(&f.paint, bounds); - p.push(("fill", paint)); - if f.opacity < 1.0 { - p.push(("fill-opacity", fmt_num(f.opacity as f64, 3))); - } - if rule == FillRule::EvenOdd { - p.push(("fill-rule", "evenodd".into())); - } - } - - fn stroke_props(&mut self, s: &StrokeLayer, width: f64, bounds: Option, p: &mut Props) { - let paint = self.paint(&s.paint, bounds); - p.push(("stroke", paint)); - if s.opacity < 1.0 { - p.push(("stroke-opacity", fmt_num(s.opacity as f64, 3))); - } - if (width - 1.0).abs() > 1e-9 { - p.push(("stroke-width", self.num(width))); - } - match s.cap { - LineCap::Butt => {} - LineCap::Round => p.push(("stroke-linecap", "round".into())), - LineCap::Square => p.push(("stroke-linecap", "square".into())), - } - match s.join { - LineJoin::Miter => { - if (s.miter_limit - 4.0).abs() > 1e-9 { - p.push(("stroke-miterlimit", self.num(s.miter_limit))); - } - } - LineJoin::Round => p.push(("stroke-linejoin", "round".into())), - LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())), - } - if let Some(d) = &s.dash - && d.is_dashed() - { - p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::>().join(" "))); - if d.offset != 0.0 { - p.push(("stroke-dashoffset", self.num(d.offset))); - } - } - } - - /// How stroke `st` is written (`bp`: the shape in document space, built on first use from - /// `paths`): its brush art, or a plain stroke or filled outlines matching the canvas. - fn stroke_plan(&mut self, st: &StrokeLayer, bp: &mut Option, paths: &[&PathData]) -> StrokePlan { - if stroke::is_plain(st) { - return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st)); - } - let bp = bp.get_or_insert_with(|| doc_path(paths)); - let doc = self.doc; - let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); - match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { - Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)), - None => StrokePlan::Written(stroke::for_writer(bp, st)), - } - } - - /// The `clip-path` (inside) or `mask` (outside) attribute that keeps an aligned stroke on its - /// side of the shape `d`; `reach` (document space) is what the stroke covers. - fn side_attr(&mut self, side: Option, d: &str, rule: FillRule, reach: Rect) -> String { - match side { - None | Some(StrokeAlign::Center) => String::new(), - Some(StrokeAlign::Inside) => { - let cid = self.fresh_id("clip-path"); - let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - format!(" clip-path=\"url(#{cid})\"") - } - Some(StrokeAlign::Outside) if self.tiny() => { - // No masks: clipped by everything around the shape instead. - let cid = self.fresh_id("clip-path"); - let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0); - let (x0, y0, x1, y1) = (self.num(b.x0), self.num(b.y0), self.num(b.x1), self.num(b.y1)); - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - format!(" clip-path=\"url(#{cid})\"") - } - Some(StrokeAlign::Outside) => { - let mid = self.fresh_id("mask"); - let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0); - let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); - let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - format!(" mask=\"url(#{mid})\"") - } - } - } - - /// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document - /// space, for strokes that aren't plain (it may be empty when all are). - fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option) { - let id = self.id_attr(n); - let items: Vec<&AppearanceItem> = n - .appearance - .items - .iter() - .filter(|i| match i { - AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), - AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, - }) - .collect(); - let mut bp = None; - let plans: Vec> = items - .iter() - .map(|i| match i { - AppearanceItem::Stroke(s) => Some(self.stroke_plan(s, &mut bp, paths)), - AppearanceItem::Fill(_) => None, - }) - .collect(); - let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); - let strokes = items.len() - fills; - // A stroke's paint box is the bounds grown by half its weight, as on the canvas. - let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b)); - let simple = fills <= 1 - && strokes <= 1 - && !items.iter().any(|i| has_raster(i.effects())) - && items.iter().all(|i| i.blend() == BlendMode::Normal) - && !items.iter().any(|i| pixel_gradient(i.paint(), matches!(i, AppearanceItem::Fill(_))).is_some()) - && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None }))); - if simple { - let mut p = Props::new(); - match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) { - Some(f) => self.fill_props(f, rule, bounds, &mut p), - None => { - p.push(("fill", "none".into())); - if rule == FillRule::EvenOdd { - p.push(("fill-rule", "evenodd".into())); - } - } - } - if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) { - self.stroke_props(s, s.width, paint_bounds(s), &mut p); - if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 { - p.push(("paint-order", "stroke".into())); - } - } - p.extend(css::transparency(n)); - let a = self.attrs(&p); - self.line(&format!("")); - return; - } - let a = self.attrs(&css::transparency(n)); - self.line(&format!("")); - self.depth += 1; - for (it, plan) in items.into_iter().zip(plans) { - // The item's own raster effects filter that item alone. - let filters = self.open_filters(n, it.effects()); - let mut p = Props::new(); - match (it, plan) { - (AppearanceItem::Fill(f), _) => { - let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string())); - if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) { - p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3)))); - p.extend(blend); - self.raster_paint(g, b, &[d.to_string()], rule, &p, "", true); - } else { - self.fill_props(f, rule, bounds, &mut p); - p.extend(blend); - let a = self.attrs(&p); - self.line(&format!("")); - } - } - (AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => { - // The brush art takes the stroke's opacity and blend mode as a group. - if s.opacity < 1.0 { - p.push(("opacity", fmt_num(s.opacity as f64, 3))); - } - if s.blend != BlendMode::Normal { - p.push(("mix-blend-mode", blend_css(s.blend).into())); - } - let a = self.attrs(&p); - self.line(&format!("")); - self.depth += 1; - for piece in &art { - self.node(piece); - } - self.depth -= 1; - self.line(""); - } - (AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => { - let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default())); - let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string())); - let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3))); - match (&w.shape, pixel_gradient(&s.paint, false).zip(paint_bounds(s))) { - (WrittenShape::Stroke { width }, Some((g, b))) => { - // The stroke's outline clips the gradient's image. - let bp = bp.get_or_insert_with(|| doc_path(paths)); - let outline = stroke::line_outline(bp, s, *width, OUTLINE_TOLERANCE); - let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf); - p.extend(opacity); - p.extend(blend); - self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side, false); - } - (WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => { - let clip: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); - p.extend(opacity); - p.extend(blend); - self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side, false); - } - (WrittenShape::Stroke { width }, _) => { - p.push(("fill", "none".into())); - self.stroke_props(s, *width, paint_bounds(s), &mut p); - p.extend(blend); - let a = self.attrs(&p); - self.line(&format!("")); - } - (WrittenShape::Fill(outlines), _) if s.path_gradient().is_some() => { - // A gradient along or across the stroke: slices clipped to its outlines. - if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) { - p.extend(opacity); - p.extend(blend); - self.sliced(&ws, &p, &side); - } - } - (WrittenShape::Fill(outlines), _) => { - let paint = self.paint(&s.paint, paint_bounds(s)); - let ds: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); - let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3)); - if let [one] = &ds[..] { - p.push(("fill", paint)); - p.extend(opacity.map(|o| ("fill-opacity", o))); - p.extend(blend); - let a = self.attrs(&p); - self.line(&format!("")); - } else if !ds.is_empty() { - // The line and its arrowheads overlap: one group takes the opacity. - p.extend(opacity.map(|o| ("opacity", o))); - p.extend(blend); - let a = self.attrs(&p); - self.line(&format!("")); - self.depth += 1; - let fill = self.attrs(&vec![("fill", paint)]); - for one in &ds { - self.line(&format!("")); - } - self.depth -= 1; - self.line(""); - } - } - } - } - (AppearanceItem::Stroke(_), None) => {} - } - self.close_filters(filters); - } - self.depth -= 1; - self.line(""); - } - - /// A stroke whose gradient runs along or across it ([`stroke::written_slices`]): a group (with - /// `props` and the `side` attribute) of its slices, clipped to its outlines. - fn sliced(&mut self, ws: &stroke::WrittenSlices, props: &Props, side: &str) { - self.warn("gradients along or across strokes are written as slices of linear gradients"); - let cid = self.fresh_id("clip-path"); - let clip = self.path_d(&PathData::from_bezpath(&ws.clip), self.xf); - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - let a = self.attrs(props); - self.line(&format!("")); - self.depth += 1; - self.line(&format!("")); - self.depth += 1; - for (shape, paint) in &ws.slices { - let fill = self.paint(paint, None); - let fill = self.attrs(&vec![("fill", fill)]); - let d = self.path_d(&PathData::from_bezpath(shape), self.xf); - self.line(&format!("")); - } - self.depth -= 1; - self.line(""); - self.depth -= 1; - self.line(""); - } - - fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) { - self.warnings.push(w.to_string()); - } - } - - /// An object with an opacity mask: `` around the unmasked object. The mask - /// art goes in ``; no-clip adds a white backdrop and invert a colour-inverting filter. - fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) { - if self.tiny() { - self.warn(TINY_MASKS); - let mut bare = n.clone(); - bare.extras_mut().mask = None; - return self.node_body(&bare); - } - let mid = self.fresh_id("mask"); - // Mask art is a picture of its own: it takes no part in a knockout group around the object. - let knockout = std::mem::take(&mut self.knockout); - let art = self.detached(2, |w| w.node(&m.art)); - self.knockout = knockout; - let inv = m.invert.then(|| { - let fid = self.fresh_id("invert"); - self.def(1, &format!("")); - self.def(2, ""); - self.def(1, ""); - fid - }); - // The mask's options, so import restores them (and its art without backdrop and filter). - let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); - let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; - self.def(1, &format!("")); - if let Some(fid) = &inv { - self.def(1, &format!("")); - } - if !m.clip || m.invert { - let c = if m.clip { "black" } else { "white" }; - self.def(1, &format!("")); - } - self.defs.push_str(&art); - if inv.is_some() { - self.def(1, ""); - } - self.def(1, ""); - self.line(&format!("")); - self.depth += 1; - let mut bare = n.clone(); - bare.extras_mut().mask = None; - self.node_body(&bare); - self.depth -= 1; - self.line(""); - } - - /// What `write` writes to the body, taken out of it (written at `depth`, for ``). - fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String { - let (body, saved) = (std::mem::take(&mut self.body), self.depth); - self.depth = depth; - write(self); - self.depth = saved; - std::mem::replace(&mut self.body, body) - } - - /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. - fn group_props(&self, n: &Node) -> Props { - let mut p = css::transparency(n); - if !n.isolate && n.knocks_out(self.knockout) { - p.push(("isolation", "isolate".into())); - } - p - } - - /// The children of group `n`, as the elements of a knockout group when it is one. - fn group_children(&mut self, n: &Node, children: &[std::sync::Arc]) { - let knockout = n.knocks_out(self.knockout); - let enclosing = std::mem::replace(&mut self.knockout, knockout); - self.children(children); - self.knockout = enclosing; - } - - /// Children of the group being written. SVG has no knockout groups: in one, each element is - /// drawn through a mask of where the elements above it don't paint (the same look for Normal - /// blending). The masks nest, so element i sits inside the masks of elements i+1…n. - fn children(&mut self, children: &[std::sync::Arc]) { - if self.knockout && self.tiny() { - self.warn(TINY_MASKS); - } - if !self.knockout || self.tiny() { - for c in children { - self.node(c); - } - return; - } - let elements = Node::knockout_elements(children); - // Where every element over another covers it fully, drawing them in order knocks out as - // much: the masks would only add seams along the shapes' antialiased edges. - if elements.iter().skip(1).all(|c| paints_opaque(c)) { - for c in elements { - self.node(c); - } - return; - } - let Some((first, rest)) = elements.split_first() else { return }; - for c in rest.iter().rev() { - let mid = self.knockout_mask(c); - self.line(&format!("")); - self.depth += 1; - } - self.node(first); - for c in rest { - self.depth -= 1; - self.line(""); - self.node(c); - } - } - - /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object - /// opacity without its own mask, unless those define its shape). Returns the mask id. - fn knockout_mask(&mut self, c: &Node) -> String { - let filter = match &self.knockout_filter { - Some(f) => f.clone(), - None => { - let f = self.fresh_id("knockout-shape"); - self.def(1, &format!("")); - self.def(2, ""); - self.def(1, ""); - self.knockout_filter = Some(f.clone()); - f - } - }; - let shape = if c.knockout_shape { - c.clone() - } else { - let mut s = c.clone().with_mask(None); - s.opacity = 1.0; - s - }; - let anonymous = std::mem::replace(&mut self.anonymous, true); - let art = self.detached(3, |w| w.node(&shape)); - self.anonymous = anonymous; - let mid = self.fresh_id("knockout"); - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(2, &format!("")); - self.defs.push_str(&art); - self.def(2, ""); - self.def(1, ""); - mid - } - - /// An object, inside `` when it links to a URL (Attributes panel). - fn node(&mut self, n: &Node) { - let layer = usize::from(n.is_layer()); - self.layer_nest += layer; - match n.url().filter(|_| n.visible && !self.anonymous) { - Some(url) => { - self.line(&format!("", xml_escape(url))); - self.depth += 1; - self.node_body(n); - self.depth -= 1; - self.line(""); - } - None => self.node_body(n), - } - self.layer_nest -= layer; - } - - fn node_body(&mut self, n: &Node) { - // Hidden layers and objects are left out, unless the options keep them (hidden). Template - // layers never print. - if !(n.visible || self.opts.hidden_layers) { - return; - } - if has_raster(&n.appearance.effects) { - return self.filtered(n); - } - if let Some(m) = n.extras().mask.as_deref() - && !m.disabled - { - return self.masked(n, m); - } - match &n.kind { - NodeKind::Layer { template: true, .. } => {} - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - let id = self.id_attr(n); - let a = self.attrs(&self.group_props(n)); - self.line(&format!("")); - self.depth += 1; - self.group_children(n, children); - self.depth -= 1; - self.line(""); - } - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - let Some((clip, rest)) = children.split_first() else { return }; - let cid = self.fresh_id("clip-path"); - self.def(1, &format!("")); - // The region every output clips to; with nothing to clip by, an empty clip path - // hides the clipped art. - if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) { - let d = self.path_d(&PathData::from_bezpath(&bp), self.xf); - let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - self.def(2, &format!("")); - } - self.def(1, ""); - let id = self.id_attr(n); - let a = self.attrs(&self.group_props(n)); - // The clipping path's fill paints behind the clipped art and its stroke over it, - // outside the clip (the group then wraps both). - let paint = clip.clip_paint(); - if paint.stroke.is_some() { - self.line(&format!("")); - self.depth += 1; - self.line(&format!("")); - } else { - self.line(&format!("")); - } - self.depth += 1; - if let Some(fill) = &paint.fill { - self.node(fill); - } - self.group_children(n, rest); - self.depth -= 1; - self.line(""); - if let Some(stroke) = &paint.stroke { - // One element keeps the clipping path's id. - let anonymous = self.anonymous; - self.anonymous |= paint.fill.is_some(); - self.node(stroke); - self.anonymous = anonymous; - self.depth -= 1; - self.line(""); - } - } - NodeKind::Path { guide: true, .. } => {} - NodeKind::Path { path, rule, .. } => { - if path.is_empty() { - return; - } - let d = self.path_d(path, self.xf); - self.shape(n, &d, &[path], *rule, n.geometric_bounds()); - } - NodeKind::Compound { children, rule } => { - let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect(); - if paths.is_empty() { - return; - } - let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); - self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); - } - NodeKind::Text(t) => self.text_node(n, t), - NodeKind::Image(im) => { - let id = self.id_attr(n); - let m = self.matrix(self.xf * im.xf); - let a = self.attrs(&css::transparency(n)); - if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) { - let shared = shared.clone(); - self.line(&format!("")); - return; - } - let Some(href) = self.image_href(im) else { return }; - self.line(&format!( - "", - im.width, - im.height, - xml_escape(&href) - )); - } - NodeKind::SymbolInstance { symbol, xf } => { - let doc = self.doc; - let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return }; - if self.symbol_nest > MAX_SYMBOL_NEST { - return; - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - if let Some(def) = self.symbol_use(sym, n, *xf) { - let m = self.xf * *xf; - let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - self.line(&format!("")); - return; - } - // An instance of its own: the (stained) art moved by the instance, its pattern - // paints and unlinked masks left in place as on the canvas. - let mut art = vectorcraft_brush::instance_art(&sym.art, n); - let moved = self.instance_xf * *xf; - if moved.determinant().abs() > 1e-12 { - pin_to_page(&mut art, moved.inverse()); - } - let saved = (self.xf, self.instance_xf); - self.xf = saved.0 * *xf; - self.instance_xf = moved; - self.line(&format!("")); - self.depth += 1; - // The symbol's art is the instance's own picture, outside any knockout around it. - let knockout = std::mem::take(&mut self.knockout); - self.symbol_nest += 1; - self.node(&art); - self.symbol_nest -= 1; - self.knockout = knockout; - self.depth -= 1; - self.line(""); - (self.xf, self.instance_xf) = saved; - } - // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => { - let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); - self.share_images(&g); - self.node_body(&g); - } - } - } - - /// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written - /// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across - /// documents). - fn blob_href(&mut self, b: &ImageBlob) -> String { - if self.opts.images != ImageMode::Link { - return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)); - } - let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); - if self.linked_names.insert(name.clone()) { - self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() }); - } - name - } - - /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is - /// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a - /// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled - /// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour - /// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size - /// in points); a linear or radial gradient at the document's raster effects resolution (at most - /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty - /// box. - #[allow(clippy::too_many_arguments)] - fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) { - use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; - let Some(b) = painted_box(bounds) else { return }; - let freeform = g.gradient.kind == GradientKind::Freeform; - let (rgba, cols, rows): (Vec, u16, u16) = if freeform { - let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height())); - let field = g.freeform_on(b).field(spread_scale(b)); - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - (field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows) - } else { - let k = self.doc.raster_effects_ppi / 72.0; - let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; - let (cols, rows) = (along(b.width()), along(b.height())); - (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows) - }; - let mut png = Vec::new(); - let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return }; - if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { - return; - } - self.warn(if freeform { - "freeform gradients are written as images clipped to their shapes" - } else { - "linear and radial gradients drawn as pixels are written as images clipped to their shapes" - }); - let href = self.blob_href(&ImageBlob::new("image/png", png)); - let cid = self.fresh_id("clip-path"); - let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - self.def(1, &format!("")); - for d in clip { - self.def(2, &format!("")); - } - self.def(1, ""); - let a = self.attrs(props); - let nested = !side.is_empty(); - if nested { - self.line(&format!("")); - self.depth += 1; - self.line(&format!("")); - } else { - self.line(&format!("")); - } - self.depth += 1; - let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); - let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) }; - self.line(&format!( - "", - xml_escape(&href) - )); - self.depth -= 1; - self.line(""); - if nested { - self.depth -= 1; - self.line(""); - } - } - - /// The id of the `` def instance `inst` (transform `xf`) can ``, written on first - /// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved - /// by a transform under which the def would not look as the canvas paints it ([`Reuse`]). - fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option { - // SVG Tiny has no ``. - if self.tiny() || vectorcraft_brush::stain(inst).is_some() { - return None; - } - if !self.symbols.contains_key(&sym.name) { - let reuse = Reuse::of(self.doc, &sym.art); - self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse }); - } - let def = self.symbols.get(&sym.name)?; - if !def.reuse.allows(xf) { - return None; - } - if let Some(id) = &def.id { - return Some(id.clone()); - } - // The art in symbol space, as its own picture: no knockout around it, and no object ids - // (every instance shows it). - let id = self.unique_id(&sanitize_id(&sym.name)); - let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous); - (self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false); - self.symbol_nest += 1; - let art = self.detached(3, |w| w.node(&sym.art)); - self.symbol_nest -= 1; - (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; - // The symbol's name, when the id had to differ from it, comes back on import. - let name = if id == sym.name { - String::new() - } else if valid_xml_value(&sym.name) { - format!(" data-name=\"{}\"", xml_attr_escape(&sym.name)) - } else { - self.warn("Symbol names containing invalid XML characters were left out of SVG name attributes"); - String::new() - }; - self.def(1, &format!("")); - self.defs.push_str(&art); - self.def(1, ""); - if let Some(def) = self.symbols.get_mut(&sym.name) { - def.id = Some(id.clone()); - } - Some(id) - } - - /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` - /// per effect, the first effect innermost (the reference app's stacking order). A drop shadow - /// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style - /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn - /// such objects into images first). - fn filtered(&mut self, n: &Node) { - let mut inner = n.clone(); - inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); - let opened = self.open_filters(n, &n.appearance.effects); - self.node_body(&inner); - self.close_filters(opened); - } - - /// Open one `` per visible raster effect of `effects` (an object's, or one fill or - /// stroke's) on `n`, outermost last effect; returns how many to close. - fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { - use vectorcraft_effects::RasterFx; - let fx = vectorcraft_effects::raster_effects(effects); - if self.tiny() { - if !fx.is_empty() { - self.warn("SVG Tiny 1.2 has no filters: shadows, glows, blurs and feathers are left out"); - } - return 0; - } - let bounds = n.visual_bounds_with(&self.doc.symbol_boxes()); - // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. - let grow = 18.0 / self.doc.raster_ppi(); - let reach: f64 = - fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::() - + 2.0; - let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); - let mut opened = 0; - for f in fx.iter().rev() { - if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) { - self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); - continue; - } - let fid = self.fresh_id("filter"); - let region_attr = match region { - Some(r) => format!( - " filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", - self.num(r.x0), - self.num(r.y0), - self.num(r.width()), - self.num(r.height()) - ), - None => String::new(), - }; - let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3); - let flood = |c: &vectorcraft_color::Color, o: f32| { - format!("", c.to_hex(), fmt_num(o as f64, 3)) - }; - let body = match f { - // Darkness: the object blurred and moved, each color channel times 1 − darkness. - RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!( - "", - sd(*blur), - self.num(*dx), - self.num(*dy), - fmt_num(*opacity as f64, 3), - keep = fmt_num(1.0 - *darkness as f64, 3) - ), - RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( - "{}", - sd(*blur), - self.num(*dx), - self.num(*dy), - flood(color, *opacity) - ), - // The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel. - RasterFx::OuterGlow { opacity, blur, color, .. } => format!( - "{}", - fmt_num(grow, 3), - sd(*blur), - flood(color, *opacity) - ), - RasterFx::InnerGlow { opacity, blur, color, center, .. } => { - // Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape. - let src = if *center { - "".replace("SD", &sd(*blur)) - } else { - format!( - "", - sd(*blur) - ) - }; - format!( - "{src}{}", - flood(color, *opacity) - ) - } - RasterFx::Feather { radius } => format!( - "", - sd(*radius) - ), - RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), - RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, - }; - // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. - let mode = match f { - RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } - if *mode != BlendMode::Normal => - { - format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode)) - } - _ => String::new(), - }; - self.def(1, &format!("{body}")); - self.line(&format!("")); - self.depth += 1; - opened += 1; - } - opened - } - - fn close_filters(&mut self, opened: usize) { - for _ in 0..opened { - self.depth -= 1; - self.line(""); - } - } - - /// The ``/`` properties of a character style. `space`: the text's layout bounds - /// and the map from text space to user space, which character gradients resolve against - /// (as on the canvas). - fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { - let decimals = self.opts.decimals; - let len = |v: f64| fmt_num(v, decimals); - let mut p = css::font_props(st, &len); - // Character paints resolve in the text's space (`to_user` maps it into user space). - let (bounds, to_user) = space; - let saved = std::mem::replace(&mut self.xf, to_user); - if let Paint::Gradient(g) = &st.fill - && g.gradient.dither - && g.gradient.kind != GradientKind::Freeform - { - self.warn("dithered gradients on characters are written without dither"); - } - let fill = self.paint(&st.fill, Some(bounds)); - p.push(("fill", fill)); - // An inside or outside stroke is drawn on the glyph outlines instead - // ([`Self::aligned_char_strokes`]): SVG strokes text centred only. - if st.has_stroke() && !aligned(st) { - // Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans - // the text grown by half the weight, as on the canvas. - let layer = st.stroke_layer(); - self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p); - } - self.xf = saved; - p.extend(css::type_props(st, &len)); - p - } - - /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, - /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those - /// below the Characters row under the characters, the others over them. - /// - /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are written after the characters as - /// instances of their symbols (a `` of the symbol's def where it can be shared), all in - /// one group carrying the object's transparency. The live text skips their characters and - /// places the text after each one; type on a path with inline graphics is written as outlines - /// (a `` couldn't leave room for the art). - fn text_node(&mut self, n: &Node, t: &TextObject) { - let doc = self.doc; - let resolved = doc.inline_resolved(t); - let t = &*resolved; - let inline = t.runs.iter().any(|r| r.inline.is_some()); - // Ruby is written as outlines with its base text: a reader's can't place it. So are - // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). - let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) - || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); - let outlined = - self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; - let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; - let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); - // Live type with an inside or outside character stroke writes that stroke apart from the - // text, in the group. - let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style)); - if !painted && !inline && !apart { - return chars(self, n); - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - self.line(&format!("")); - self.depth += 1; - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let mut all = lay.to_bezpath(); - for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { - all.extend(bar.iter()); - } - let pd = PathData::from_bezpath(&all).transformed(t.xf); - let d = self.path_d(&pd, self.xf); - let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); - let (below, above) = n.appearance.split_contents(); - let glyphs = |w: &mut Self, items: &[AppearanceItem]| { - if !items.is_empty() { - let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() }; - w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb); - } - }; - glyphs(self, below); - // The group carries the id and the object's transparency. - let mut bare = n.clone(); - (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); - let anonymous = std::mem::replace(&mut self.anonymous, true); - chars(self, &bare); - for ig in &lay.inlines { - let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; - let inst = Node::new( - NodeId(u64::MAX), - NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) }, - ); - self.node(&inst); - } - glyphs(self, above); - self.anonymous = anonymous; - self.depth -= 1; - self.line(""); - } - - /// Text as glyph outlines: one compound path per run, painted like the run (gradients span - /// the whole text), over its colour glyphs, as on the canvas. - fn text_outlines(&mut self, n: &Node, t: &TextObject) { - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; - // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted - // with the run of the text they annotate. - let bars = vectorcraft_text::decorations(&lay, db, t); - for (run, outline) in lay - .glyphs - .iter() - .map(|g| (g.run, &g.outline)) - .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) - .chain(bars.iter().map(|(r, b)| (*r, b))) - { - if let Some(bp) = runs.get_mut(run) { - bp.extend(outline.iter()); - } - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group - // names its text for screen readers and search. - let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { - format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text())) - } else { - String::new() - }; - self.line(&format!("")); - self.depth += 1; - // The glyphs stay in text space; the text's transform joins the document's meanwhile. - let saved = self.xf; - self.xf = saved * t.xf; - for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { - // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as - // their images, COLR and SVG glyphs as their art. - for g in lay.glyphs.iter().filter(|g| g.run == i) { - if let Some((image, to_text)) = g.placed_image() { - let m = self.matrix(self.xf * to_text); - let href = self.blob_href(&image.blob); - self.line(&format!( - "", - image.width, - image.height, - xml_escape(&href) - )); - } - if let Some(art) = g.placed_colour(Affine::IDENTITY) { - self.node(&art); - } - } - if bp.elements().is_empty() { - continue; - } - let pd = PathData::from_bezpath(bp); - // An unnamed id: the pieces carry no id attribute (the group has it). - let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); - let d = self.path_d(&pd, self.xf); - self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); - } - self.xf = saved; - self.depth -= 1; - self.line(""); - } - - /// Point and area type, every laid-out line at its own position: wraps, indents, tabs and - /// spacing come out as on the canvas. Centred and right-aligned lines are anchored - /// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font - /// differs; other lines are placed where each style run (and, justified, each word) starts. - fn text(&mut self, n: &Node, t: &TextObject) { - // Live SVG text is written in logical order from left-aligned pieces: bidirectional text - // (and right-to-left paragraphs) keep their look as outlines until it is written with - // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose - // `direction="rtl"` would anchor their pieces at the right end. - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); - if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { - self.warn("bidirectional text is outlined to preserve shaping and visual order"); - return self.text_outlines(n, t); - } - if let TextKind::OnPath { path, start, end } = &t.kind { - // A reader lays a ``'s glyphs straight: joined glyphs bent along the path - // keep their look as outlines. - if lay.glyphs.iter().any(|g| g.bend.is_some()) { - self.warn("joined type bent along a path is outlined to keep its look"); - return self.text_outlines(n, t); - } - // `` has a start offset only: an end bracket, Align to Path and Spacing - // keep their look as outlines. - if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 { - self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look"); - return self.text_outlines(n, t); - } - // It cannot hang glyphs outside the start bracket either. - if hangs_on_path(t, &lay) { - self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); - return self.text_outlines(n, t); - } - // A `` holds the characters of the text, not the bullets or numbers drawn - // before them. - if !lay.markers.is_empty() { - self.warn("type on a path with bullets or numbers is outlined to keep its look"); - return self.text_outlines(n, t); - } - return self.text_on_path(n, t, path, *start); - } - self.note_fonts(t, &lay); - let lines = text_lines(t, &lay, self.opts.fewer_tspans); - // `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts. - let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale)); - let drawn = fitted.as_ref().unwrap_or(t); - // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its - // stop, and so does the text after an inline graphic: lines with tabs, and text with - // inline graphics, are never anchored. - let has_inline = t.runs.iter().any(|r| r.inline.is_some()); - let anchors: Vec> = lines - .iter() - .map(|l| { - match t.para_at(l.para).justify { - Justify::Center => Some(("middle", 0.5)), - Justify::Right => Some(("end", 1.0)), - _ => None, - } - .filter(|_| !has_inline && !l.segs.iter().any(|s| s.brk)) - }) - .collect(); - // Every line anchored alike (the text's only paragraph style, typically): the - // carries the anchor. Otherwise each anchored line is a carrying its own. - let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); - // The element's user space is text space. - let space = (lay.bounds, Affine::IDENTITY); - let mut base_props = self.char_props(&drawn.first_style(), space); - // A `` cannot undo the decoration of the `` around it: with bullets, which - // have none, each run's `` carries its own. - if !lay.markers.is_empty() { - base_props.retain(|(k, _)| *k != "text-decoration"); - } - let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; - let mut props = base.props.clone(); - if let Some((a, _)) = uniform { - props.push(("text-anchor", a.into())); - } - props.extend(css::transparency(n)); - let id = self.id_attr(n); - let a = self.attrs(&props); - let m = self.xf * t.xf; - let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - let starts: Vec> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect(); - // The `` carries the first line's position too, so readers that place text by its - // own x/y start where the first line does. - let at = match starts.iter().flatten().next() { - Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)), - None => String::new(), - }; - let mut s = format!(""); - for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) { - let Some(start) = start else { continue }; - match anchor.filter(|_| uniform.is_none()) { - // A line anchored on its own: one positioned with the anchor around it. - Some((a, _)) => { - s.push_str(&format!("", self.num(start.0), self.num(start.1))); - let inner = TextBase { anchored: true, ..base.clone() }; - self.text_line(&mut s, drawn, &line.segs, start, &inner, false); - s.push_str(""); - } - None => self.text_line(&mut s, drawn, &line.segs, start, &base, true), - } - } - s.push_str(""); - self.line(&s); - self.aligned_char_strokes(t); - } - - /// The inside and outside character strokes of live type `t`, over its text (which goes - /// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn - /// twice as wide and clipped or masked to its side as aligned path strokes are - /// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines. - fn aligned_char_strokes(&mut self, t: &TextObject) { - if !t.runs.iter().any(|r| aligned(&r.style)) { - return; - } - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let bars = vectorcraft_text::decorations(&lay, db, t); - // The glyphs stay in text space; the text's transform joins the document's meanwhile. - let saved = self.xf; - self.xf = saved * t.xf; - for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) { - let mut bp = BezPath::new(); - for g in lay.glyphs.iter().filter(|g| g.run == i) { - bp.extend(g.outline.iter()); - } - for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { - bp.extend(bar.iter()); - } - if bp.elements().is_empty() { - continue; - } - let pd = PathData::from_bezpath(&bp); - let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() }; - let d = self.path_d(&pd, self.xf); - self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); - } - self.xf = saved; - } - - /// One line of laid-out text starting at `start`. Default: a positioned `` per segment - /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with - /// the style changes nested in it, positioned again only after tabs and inline graphics. - /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. - /// `positioned`: false when the caller already positioned the line (an outer ``). - fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { - let fewer = self.opts.fewer_tspans && positioned; - if fewer { - s.push_str(&format!("", self.num(start.0), self.num(start.1))); - } - let mut place = !fewer && positioned; - let mut first = true; - // The baseline shift the current text position carries. - let mut shift = 0.0; - for seg in line { - let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; - let marker_style; - let st = if seg.marker { - marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); - &marker_style - } else { - run_style - }; - let mut attrs = String::new(); - if place { - let (x, y) = if first { start } else { (seg.x, seg.y) }; - attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y))); - shift = 0.0; - } - first = false; - if st.baseline_shift != shift { - attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); - shift = st.baseline_shift; - } - // After a tab, an inline graphic or a justified word space, and before a character that a - // reader would draw elsewhere, the next segment is placed. - place = seg.brk || (!fewer && !base.anchored && positioned); - if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 { - attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale))); - } - if st.rotation != 0.0 { - attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); - } - let mut diff = run_diff(&base.props, self.char_props(st, base.space)); - diff.extend(direction_props(st)); - attrs.push_str(&self.attrs(&diff)); - let text = xml_text(&seg.text); - if attrs.is_empty() && fewer { - s.push_str(&text); - } else { - s.push_str(&format!("{text}")); - } - } - if fewer { - s.push_str(""); - } - } - - /// Record the characters of laid-out type `t` under the faces that supply them, for - /// [`Self::font_faces`] (when fonts are embedded). - fn note_fonts(&mut self, t: &TextObject, lay: &vectorcraft_text::TextLayout) { - if !self.opts.embed_fonts || self.opts.outline_text { - return; - } - if self.tiny() { - return self.warn("SVG Tiny 1.2 has no web fonts: fonts are not embedded"); - } - let db = FontDb::global(); - let plain = t.plain_text(); - let mut face: Option> = None; - for (i, g) in lay.glyphs.iter().enumerate() { - let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; - // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the - // viewer. - if g.image.is_some() || g.colour.is_some() { - self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts"); - continue; - } - if face.as_ref().is_none_or(|f| f.id() != g.font_id) { - face = db.face_by_id(g.font_id); - } - let Some(f) = &face else { continue }; - let (weight, italic) = font_descriptor(st); - let stretch = css::font_stretch(st).map(f64::to_bits); - let at = self - .fonts - .iter() - .position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family); - let at = at.unwrap_or_else(|| { - let family = st.font_family.clone(); - self.fonts.push(FontUse { family, weight, italic, stretch, face: f.clone(), chars: BTreeSet::new() }); - self.fonts.len() - 1 - }); - let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue }; - // As written: a list marker's characters, a hyphen where a line breaks, capitals for All - // Caps, the style's Digits. - let src = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }); - for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) { - if st.all_caps { - chars.extend(c.to_uppercase()); - } else { - chars.insert(c); - } - } - } - } - - /// The `@font-face` rules of the faces type uses ([`ExportOptions::embed_fonts`]): each face - /// subset to its characters (whole when its licence forbids subsetting, left out with a - /// warning when it forbids embedding). Faces sharing a description cover their own - /// characters (`unicode-range`); where two of them share characters as well, a warning says - /// that browsers draw those in one of the faces. - fn font_faces(&mut self) -> Vec { - let uses = std::mem::take(&mut self.fonts); - let mut rules = Vec::new(); - fn described(u: &FontUse) -> (&str, u16, bool, Option) { - (&u.family, u.weight, u.italic, u.stretch) - } - let mut clashing: Vec<&String> = vec![]; - for (i, u) in uses.iter().enumerate() { - let clash = uses - .get(..i) - .unwrap_or_default() - .iter() - .any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars)); - if clash && !clashing.contains(&&u.family) { - clashing.push(&u.family); - } - } - for family in clashing { - self.warn(&format!( - "type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces" - )); - } - for u in &uses { - let chars: Vec = u.chars.iter().copied().collect(); - let name = format!("{} {}", u.face.family, u.face.style); - let Some(font) = u.face.embed(&chars) else { - self.warn(&format!("the licence of the font {name} doesn't allow embedding: viewers show its type in their own fonts")); - continue; - }; - if !font.subset { - self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); - } - let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1; - let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; - let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default(); - let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") }; - rules.push(format!( - "@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}", - css_string(&u.family), - u.weight, - if u.italic { "italic" } else { "normal" }, - base64_encode(&font.data) - )); - } - rules - } - - /// Type on a path: a `` along the path (a def). - fn text_on_path(&mut self, n: &Node, t: &TextObject, path: &PathData, start: f64) { - let id = self.id_attr(n); - let pid = self.fresh_id("text-path"); - let d = self.path_d(path, self.xf * t.xf); - self.def(1, &format!("")); - // The element's user space is the document's; gradients span the laid-out text. - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - self.note_fonts(t, &lay); - let space = (lay.bounds, self.xf * t.xf); - let base = self.char_props(&t.first_style(), space); - let mut props = base.clone(); - match t.para_at(0).justify { - Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), - Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), - _ => {} - } - props.extend(css::transparency(n)); - let a = self.attrs(&props); - let offset = fmt_num(start * 100.0, 3); - let mut s = format!(""); - for run in &t.runs { - let mut diff = run_diff(&base, self.char_props(&run.style, space)); - diff.extend(direction_props(&run.style)); - let a = self.attrs(&diff); - s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); - } - s.push_str(""); - self.line(&s); - self.aligned_char_strokes(t); - } -} - -/// Does a character style stroke inside or outside its glyphs (not centred)? -fn aligned(st: &CharStyle) -> bool { - st.has_stroke() && st.stroke_align != StrokeAlign::Center -} - -/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when -/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its -/// glyphs sit elsewhere with both off. -fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { - if !t.optical_margin && !t.para_at(0).roman_hanging { - return false; - } - let mut flush = t.clone(); - flush.optical_margin = false; - let mut styles = flush.paragraph_styles(); - for p in &mut styles { - p.roman_hanging = false; - } - flush.set_paragraph_styles(styles); - let flush = vectorcraft_text::layout(FontDb::global(), &flush); - lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) -} - -/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in -/// its Character Direction, and nothing without one. Each `` of characters with a direction -/// carries them, and the `` does not. Type with a run set Right to Left is written as -/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. -fn direction_props(st: &CharStyle) -> Props { - let Some(d) = st.char_direction else { return Props::new() }; - let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; - vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] -} - -/// What the `` of a run whose properties are `run` sets over its ``'s `base`: the -/// properties that differ, and the initial value of each one `base` sets and the run doesn't. -fn run_diff(base: &Props, run: Props) -> Props { - let reset: Props = - base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect(); - run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect() -} - -/// The initial value of an inherited property a run may leave unset (decorations can't be undone -/// on a ``). -fn initial_value(k: &str) -> Option<&'static str> { - Some(match k { - "font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal", - "stroke" | "stroke-dasharray" => "none", - "stroke-width" | "stroke-opacity" => "1", - "stroke-linecap" => "butt", - "stroke-linejoin" => "miter", - "stroke-miterlimit" => "4", - "stroke-dashoffset" | "letter-spacing" => "0", - "font-kerning" => "auto", - _ => return None, - }) -} - -/// A shape's paths as one path (document space). -fn doc_path(paths: &[&PathData]) -> BezPath { - let mut bp = BezPath::new(); - for p in paths { - bp.extend(p.to_bezpath()); - } - bp -} - -/// How closely stroke outlines that clip an image follow the stroke (points). -const OUTLINE_TOLERANCE: f64 = 0.01; - -/// Most pixels along a side of a linear or radial gradient's image. -const MAX_GRADIENT_PX: f64 = 4096.0; - -/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels -/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one). -fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { - match p { - Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g), - _ => None, - } -} - -/// Any visible raster effect (shadow, glow, blur, feather) in `effects`? -fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { - effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) -} - -/// What the lines of one `` share. -#[derive(Clone)] -struct TextBase { - /// The `` element's character properties, which each `` differs from. - props: Props, - /// The text's layout bounds and the map from text space to user space (character paints). - space: (Rect, Affine), - /// Lines anchored at their centre or right end (`text-anchor`), positioned once each. - anchored: bool, -} - -/// One laid-out line: its paragraph (index) and its segments. -struct Line { - para: usize, - segs: Vec, -} - -/// Characters of one style on one line, written as one ``. -struct Segment { - /// Index into `TextObject::runs`. - run: usize, - /// Where a reader starts drawing the segment (text space; `y` is the baseline). - x: f64, - y: f64, - text: String, - /// The width a reader gives the segment (tracking included). On a line that is justified or - /// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi - /// ([`reader_advances`]), for horizontally scaled type also without its last character's - /// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width. - advance: f64, - /// The next segment needs its own position (after a tab, a word space of a justified line, or - /// the next segment starts with a character the layout draws elsewhere than a reader would). - brk: bool, - /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to - /// (`textLength`); 1 for other segments. - scale: f64, - /// A list marker's characters and tab, written in - /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. - marker: bool, -} - -/// How far a reader may draw a character from where the layout draws it before the character is -/// given its own position (points). -const PLACE_TOLERANCE: f64 = 1e-6; - -/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, -/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters -/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all -/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also -/// starts a new segment at each character the layout draws elsewhere than a reader continuing -/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between -/// the characters of a justified Japanese line, and the spacing Mojikumi changes. -fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { - let plain = t.plain_text(); - let mut lines = Vec::with_capacity(lay.lines.len()); - let (mut kerned, mut unkerned) = (None, None); - for line in &lay.lines { - let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); - let style = t.para_at(para); - let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); - // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. - // Justification…'s Desired word and letter spacing and glyph scaling move characters from - // where a reader draws them, on every line. - let spaced = !fewer && !style.justification.desired_is_default(); - let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) { - Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) - } else { - None - }; - // Where a reader draws the next character of the last segment, and the byte offset and - // reader's advance of the last segment's last cluster. - let mut pen = 0.0; - let mut tail = None; - let mut segs: Vec = Vec::new(); - let mut cluster = None; - // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). - let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { - lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) - }; - let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); - // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. - if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { - while let Some((_, rest)) = - glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) - { - glyphs = rest; - } - } - let mut after_inline = false; - for (k, g) in glyphs.iter().enumerate() { - let Some(run) = t.runs.get(g.run) else { continue }; - // A list marker before an inline graphic is in the graphic's run, and is text. - if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { - // An inline graphic (written as art): the text after it is placed anew. - if let Some(last) = segs.last_mut() { - last.brk = true; - } - after_inline = true; - cluster = None; - continue; - } - // The cluster as a reader sets it (a hyphen the layout adds is set as laid out). - let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0); - if g.len > 0 && cluster == Some(g.byte) { - // Another glyph of the same cluster (a reader's advance covers the whole cluster). - if let Some(last) = segs.last_mut().filter(|_| set.is_none()) { - last.advance += g.advance; - pen += g.advance; - } - continue; - } - // A glyph that stands for no character (a list marker before the paragraph's first - // character) starts no cluster. - cluster = (g.len > 0).then_some(g.byte); - let (advance, x) = match set { - Some(&(advance, offset)) => { - // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. - let x = g.xf.translation().x - offset; - (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) - } - None => (g.advance, g.origin.x), - }; - let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); - let piece = if run.style.all_caps { src.to_uppercase() } else { src }; - let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); - // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. - let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 }; - let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0); - // The layout draws the character elsewhere than a reader continuing the line would. - let moved = reader.is_some() - && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); - pen = x + advance; - // A list marker is a segment of its own, and so is the text after it. In horizontally - // scaled type a tab is a segment apart from the characters before it, so a reader - // stretching those characters to their width (`textLength`) stretches them alone. - let marker = lay.marker_of(line.glyph_start + k).is_some(); - let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; - match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { - last.text.push_str(&piece); - last.advance += advance; - last.brk = brk; - } - last => { - if let Some(last) = last { - last.brk |= moved || last.marker != marker; - end_segment(last, tail, t, lay, &mut unkerned); - } - segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); - } - } - tail = set.map(|_| (g.byte, advance)); - } - if let Some(last) = segs.last_mut() { - end_segment(last, tail, t, lay, &mut unkerned); - } - segs.retain(|s| !s.text.is_empty()); - if !segs.is_empty() { - lines.push(Line { para, segs }); - } - } - lines -} - -/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width -/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance -/// a reader gives it at the end of a ``, without the font's kerning against the character -/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them. -fn end_segment( - seg: &mut Segment, - tail: Option<(usize, f64)>, - t: &TextObject, - lay: &vectorcraft_text::TextLayout, - unkerned: &mut Option>, -) { - let Some((byte, advance)) = tail else { return }; - if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) { - return; - } - if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) { - seg.advance += alone - advance; - } -} - -/// The advance a reader of live SVG text gives each character cluster of `t` and how far from -/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out -/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by -/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual -/// kerning leaves it out). -fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { - let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; - for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { - p.mojikumi = vectorcraft_doc::Mojikumi::None; - // A reader spaces words and letters as the font does. - p.justification = vectorcraft_doc::JustificationOptions::default(); - } - if !kerning { - for r in &mut set.runs { - r.style.kerning.get_or_insert(0.0); - } - } - let mut out: HashMap = HashMap::new(); - for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { - let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); - *advance += g.advance * lay.fit_scale; - } - out -} - -/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1: -/// right end), that point of its width. `None` for an empty line. -fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { - let (first, last) = (line.first()?, line.last()?); - let x = match anchor { - Some(f) => first.x + f * (last.x + last.advance - first.x), - None => first.x, - }; - Some((x, first.y)) -} - -/// Does `n` paint opaquely wherever it paints (no transparency, blending, masks or effects in -/// it)? Anything this can't tell is taken as not. -fn paints_opaque(n: &Node) -> bool { - use vectorcraft_doc::{AppearanceItem, NodeKind}; - let paint = |p: &Paint| match p { - Paint::None | Paint::Solid { .. } => true, - Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0), - _ => false, - }; - let items = n.appearance.items.iter().all(|i| match i { - AppearanceItem::Fill(f) => !f.visible || (f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty() && paint(&f.paint)), - // Brush art draws as pieces with opacities of their own (bristles vary theirs). - AppearanceItem::Stroke(s) => { - !s.visible || (s.opacity >= 1.0 && s.blend == BlendMode::Normal && s.effects.is_empty() && s.brush.is_none() && paint(&s.paint)) - } - }); - let own = n.opacity >= 1.0 && n.blend == BlendMode::Normal && n.extras().mask.is_none() && n.appearance.effects.is_empty() && items; - own && match &n.kind { - NodeKind::Path { .. } => true, - NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(|c| paints_opaque(c)), - NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0), - _ => false, - } -} diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs new file mode 100644 index 000000000..69d700844 --- /dev/null +++ b/crates/svg/src/export/nodes.rs @@ -0,0 +1,705 @@ +use super::output::{sanitize_id, valid_xml_value, xml_attr_escape}; +use super::paint::{StrokePlan, has_raster, pixel_gradient}; +use super::text::OUTLINE_TOLERANCE; +use super::*; + +/// A symbol's `` def and the instance transforms that can `` it. +pub(super) struct SymbolDef { + /// The def's id, once written. + id: Option, + reuse: Reuse, +} + +/// Which instance transforms paint a symbol's art exactly as the def under that transform (the +/// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked +/// masks stay put). +#[derive(Clone, Copy)] +pub(crate) struct Reuse { + /// Translations (no pattern paints or unlinked masks). + moves: bool, + /// Rotations and reflections (nor effects or brushes). + turns: bool, + /// Any other transform (nor strokes or live objects). + scales: bool, +} + +/// How deep symbols may sit in one another's art (deeper instances are left out). +const MAX_SYMBOL_NEST: u32 = 8; + +impl Reuse { + const NONE: Self = Self { moves: false, turns: false, scales: false }; + + /// What a def of symbol art `art` stands for. + pub(crate) fn of(doc: &Document, art: &Node) -> Self { + let mut r = Self { moves: true, turns: true, scales: true }; + r.scan(doc, art, 0); + r + } + + /// Narrow down to what `n` (inside symbols nested `depth` deep) allows. + fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { + let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); + let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked); + if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { + *self = Self::NONE; + return; + } + let effects = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); + if effects(&n.appearance.effects) || n.appearance.items.iter().any(|i| effects(i.effects())) { + (self.turns, self.scales) = (false, false); + } + for i in &n.appearance.items { + if let AppearanceItem::Stroke(s) = i + && s.visible + && !s.paint.is_none() + && s.width > 0.0 + { + // Stroke weights don't scale; brushes can orient their art to the page. + self.scales = false; + self.turns &= s.brush.is_none(); + } + } + match &n.kind { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false), + NodeKind::SymbolInstance { symbol, .. } => { + if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { + self.scan(doc, &s.art, depth + 1); + } + } + _ => {} + } + if let Some(m) = &n.extras().mask { + self.scan(doc, &m.art, depth); + } + for c in n.children().into_iter().flatten() { + self.scan(doc, c, depth); + } + } + + /// Can the def stand for an instance with transform `xf`? + pub(crate) fn allows(self, xf: Affine) -> bool { + const EPS: f64 = 1e-9; + let [a, b, c, d, _, _] = xf.as_coeffs(); + if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS { + return self.moves; + } + let rigid = (a * a + b * b - 1.0).abs() < EPS && (c * c + d * d - 1.0).abs() < EPS && (a * c + b * d).abs() < EPS; + if rigid { self.turns } else { self.scales } + } +} + +/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas +/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement +/// and the art of unlinked opacity masks. +fn pin_to_page(n: &mut Node, m: Affine) { + vectorcraft_doc::pattern::transform_pattern_paints(n, m); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { + let linked = mask.linked; + let art = std::sync::Arc::make_mut(&mut mask.art); + if !linked { + art.transform(m, false); + } + pin_to_page(art, m); + } + for c in n.children_mut().into_iter().flatten() { + pin_to_page(std::sync::Arc::make_mut(c), m); + } +} + +/// What SVG Tiny 1.2 does without masks. +const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups"; + +/// A region covering any artwork (mask and filter extents). +const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; + +/// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the +/// canvas and PDF take it) rather than from linearised ones. +pub(super) const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; + +/// Attribute of an exported opacity mask listing its options that differ from the defaults +/// (clipping, not inverted): `noclip`, `invert`. +pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask"; + +impl Writer<'_> { + /// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document + /// space, for strokes that aren't plain (it may be empty when all are). + pub(super) fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option) { + let id = self.id_attr(n); + let items: Vec<&AppearanceItem> = n + .appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, + }) + .collect(); + let mut bp = None; + let plans: Vec> = items + .iter() + .map(|i| match i { + AppearanceItem::Stroke(s) => Some(self.stroke_plan(s, &mut bp, paths)), + AppearanceItem::Fill(_) => None, + }) + .collect(); + let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); + let strokes = items.len() - fills; + // A stroke's paint box is the bounds grown by half its weight, as on the canvas. + let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b)); + let simple = fills <= 1 + && strokes <= 1 + && !items.iter().any(|i| has_raster(i.effects())) + && items.iter().all(|i| i.blend() == BlendMode::Normal) + && !items.iter().any(|i| pixel_gradient(i.paint(), matches!(i, AppearanceItem::Fill(_))).is_some()) + && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None }))); + if simple { + let mut p = Props::new(); + match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) { + Some(f) => self.fill_props(f, rule, bounds, &mut p), + None => { + p.push(("fill", "none".into())); + if rule == FillRule::EvenOdd { + p.push(("fill-rule", "evenodd".into())); + } + } + } + if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) { + self.stroke_props(s, s.width, paint_bounds(s), &mut p); + if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 { + p.push(("paint-order", "stroke".into())); + } + } + p.extend(css::transparency(n)); + let a = self.attrs(&p); + self.line(&format!("")); + return; + } + let a = self.attrs(&css::transparency(n)); + self.line(&format!("")); + self.depth += 1; + for (it, plan) in items.into_iter().zip(plans) { + // The item's own raster effects filter that item alone. + let filters = self.open_filters(n, it.effects()); + let mut p = Props::new(); + match (it, plan) { + (AppearanceItem::Fill(f), _) => { + let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string())); + if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) { + p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3)))); + p.extend(blend); + self.raster_paint(g, b, &[d.to_string()], rule, &p, "", true); + } else { + self.fill_props(f, rule, bounds, &mut p); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } + } + (AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => { + // The brush art takes the stroke's opacity and blend mode as a group. + if s.opacity < 1.0 { + p.push(("opacity", fmt_num(s.opacity as f64, 3))); + } + if s.blend != BlendMode::Normal { + p.push(("mix-blend-mode", blend_css(s.blend).into())); + } + let a = self.attrs(&p); + self.line(&format!("")); + self.depth += 1; + for piece in &art { + self.node(piece); + } + self.depth -= 1; + self.line(""); + } + (AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => { + let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default())); + let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string())); + let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3))); + match (&w.shape, pixel_gradient(&s.paint, false).zip(paint_bounds(s))) { + (WrittenShape::Stroke { width }, Some((g, b))) => { + // The stroke's outline clips the gradient's image. + let bp = bp.get_or_insert_with(|| doc_path(paths)); + let outline = stroke::line_outline(bp, s, *width, OUTLINE_TOLERANCE); + let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf); + p.extend(opacity); + p.extend(blend); + self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side, false); + } + (WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => { + let clip: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); + p.extend(opacity); + p.extend(blend); + self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side, false); + } + (WrittenShape::Stroke { width }, _) => { + p.push(("fill", "none".into())); + self.stroke_props(s, *width, paint_bounds(s), &mut p); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } + (WrittenShape::Fill(outlines), _) if s.path_gradient().is_some() => { + // A gradient along or across the stroke: slices clipped to its outlines. + if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) { + p.extend(opacity); + p.extend(blend); + self.sliced(&ws, &p, &side); + } + } + (WrittenShape::Fill(outlines), _) => { + let paint = self.paint(&s.paint, paint_bounds(s)); + let ds: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); + let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3)); + if let [one] = &ds[..] { + p.push(("fill", paint)); + p.extend(opacity.map(|o| ("fill-opacity", o))); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } else if !ds.is_empty() { + // The line and its arrowheads overlap: one group takes the opacity. + p.extend(opacity.map(|o| ("opacity", o))); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + self.depth += 1; + let fill = self.attrs(&vec![("fill", paint)]); + for one in &ds { + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + } + } + } + } + (AppearanceItem::Stroke(_), None) => {} + } + self.close_filters(filters); + } + self.depth -= 1; + self.line(""); + } + + /// A stroke whose gradient runs along or across it ([`stroke::written_slices`]): a group (with + /// `props` and the `side` attribute) of its slices, clipped to its outlines. + fn sliced(&mut self, ws: &stroke::WrittenSlices, props: &Props, side: &str) { + self.warn("gradients along or across strokes are written as slices of linear gradients"); + let cid = self.fresh_id("clip-path"); + let clip = self.path_d(&PathData::from_bezpath(&ws.clip), self.xf); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + let a = self.attrs(props); + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + self.depth += 1; + for (shape, paint) in &ws.slices { + let fill = self.paint(paint, None); + let fill = self.attrs(&vec![("fill", fill)]); + let d = self.path_d(&PathData::from_bezpath(shape), self.xf); + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + self.depth -= 1; + self.line(""); + } + + /// An object with an opacity mask: `` around the unmasked object. The mask + /// art goes in ``; no-clip adds a white backdrop and invert a colour-inverting filter. + fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) { + if self.tiny() { + self.warn(TINY_MASKS); + let mut bare = n.clone(); + bare.extras_mut().mask = None; + return self.node_body(&bare); + } + let mid = self.fresh_id("mask"); + // Mask art is a picture of its own: it takes no part in a knockout group around the object. + let knockout = std::mem::take(&mut self.knockout); + let art = self.detached(2, |w| w.node(&m.art)); + self.knockout = knockout; + let inv = m.invert.then(|| { + let fid = self.fresh_id("invert"); + self.def(1, &format!("")); + self.def(2, ""); + self.def(1, ""); + fid + }); + // The mask's options, so import restores them (and its art without backdrop and filter). + let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); + let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; + self.def(1, &format!("")); + if let Some(fid) = &inv { + self.def(1, &format!("")); + } + if !m.clip || m.invert { + let c = if m.clip { "black" } else { "white" }; + self.def(1, &format!("")); + } + self.defs.push_str(&art); + if inv.is_some() { + self.def(1, ""); + } + self.def(1, ""); + self.line(&format!("")); + self.depth += 1; + let mut bare = n.clone(); + bare.extras_mut().mask = None; + self.node_body(&bare); + self.depth -= 1; + self.line(""); + } + + /// What `write` writes to the body, taken out of it (written at `depth`, for ``). + fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String { + let (body, saved) = (std::mem::take(&mut self.body), self.depth); + self.depth = depth; + write(self); + self.depth = saved; + std::mem::replace(&mut self.body, body) + } + + /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. + fn group_props(&self, n: &Node) -> Props { + let mut p = css::transparency(n); + if !n.isolate && n.knocks_out(self.knockout) { + p.push(("isolation", "isolate".into())); + } + p + } + + /// The children of group `n`, as the elements of a knockout group when it is one. + fn group_children(&mut self, n: &Node, children: &[std::sync::Arc]) { + let knockout = n.knocks_out(self.knockout); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + self.children(children); + self.knockout = enclosing; + } + + /// Children of the group being written. SVG has no knockout groups: in one, each element is + /// drawn through a mask of where the elements above it don't paint (the same look for Normal + /// blending). The masks nest, so element i sits inside the masks of elements i+1…n. + pub(super) fn children(&mut self, children: &[std::sync::Arc]) { + if self.knockout && self.tiny() { + self.warn(TINY_MASKS); + } + if !self.knockout || self.tiny() { + for c in children { + self.node(c); + } + return; + } + let elements = Node::knockout_elements(children); + // Where every element over another covers it fully, drawing them in order knocks out as + // much: the masks would only add seams along the shapes' antialiased edges. + if elements.iter().skip(1).all(|c| paints_opaque(c)) { + for c in elements { + self.node(c); + } + return; + } + let Some((first, rest)) = elements.split_first() else { return }; + for c in rest.iter().rev() { + let mid = self.knockout_mask(c); + self.line(&format!("")); + self.depth += 1; + } + self.node(first); + for c in rest { + self.depth -= 1; + self.line(""); + self.node(c); + } + } + + /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object + /// opacity without its own mask, unless those define its shape). Returns the mask id. + fn knockout_mask(&mut self, c: &Node) -> String { + let filter = match &self.knockout_filter { + Some(f) => f.clone(), + None => { + let f = self.fresh_id("knockout-shape"); + self.def(1, &format!("")); + self.def(2, ""); + self.def(1, ""); + self.knockout_filter = Some(f.clone()); + f + } + }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; + let anonymous = std::mem::replace(&mut self.anonymous, true); + let art = self.detached(3, |w| w.node(&shape)); + self.anonymous = anonymous; + let mid = self.fresh_id("knockout"); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.defs.push_str(&art); + self.def(2, ""); + self.def(1, ""); + mid + } + + /// An object, inside `` when it links to a URL (Attributes panel). + pub(super) fn node(&mut self, n: &Node) { + let layer = usize::from(n.is_layer()); + self.layer_nest += layer; + match n.url().filter(|_| n.visible && !self.anonymous) { + Some(url) => { + self.line(&format!("", xml_escape(url))); + self.depth += 1; + self.node_body(n); + self.depth -= 1; + self.line(""); + } + None => self.node_body(n), + } + self.layer_nest -= layer; + } + + pub(super) fn node_body(&mut self, n: &Node) { + // Hidden layers and objects are left out, unless the options keep them (hidden). Template + // layers never print. + if !(n.visible || self.opts.hidden_layers) { + return; + } + if has_raster(&n.appearance.effects) { + return self.filtered(n); + } + if let Some(m) = n.extras().mask.as_deref() + && !m.disabled + { + return self.masked(n, m); + } + match &n.kind { + NodeKind::Layer { template: true, .. } => {} + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + let id = self.id_attr(n); + let a = self.attrs(&self.group_props(n)); + self.line(&format!("")); + self.depth += 1; + self.group_children(n, children); + self.depth -= 1; + self.line(""); + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + let Some((clip, rest)) = children.split_first() else { return }; + let cid = self.fresh_id("clip-path"); + self.def(1, &format!("")); + // The region every output clips to; with nothing to clip by, an empty clip path + // hides the clipped art. + if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) { + let d = self.path_d(&PathData::from_bezpath(&bp), self.xf); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(2, &format!("")); + } + self.def(1, ""); + let id = self.id_attr(n); + let a = self.attrs(&self.group_props(n)); + // The clipping path's fill paints behind the clipped art and its stroke over it, + // outside the clip (the group then wraps both). + let paint = clip.clip_paint(); + if paint.stroke.is_some() { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } + self.depth += 1; + if let Some(fill) = &paint.fill { + self.node(fill); + } + self.group_children(n, rest); + self.depth -= 1; + self.line(""); + if let Some(stroke) = &paint.stroke { + // One element keeps the clipping path's id. + let anonymous = self.anonymous; + self.anonymous |= paint.fill.is_some(); + self.node(stroke); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } + } + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { path, rule, .. } => { + if path.is_empty() { + return; + } + let d = self.path_d(path, self.xf); + self.shape(n, &d, &[path], *rule, n.geometric_bounds()); + } + NodeKind::Compound { children, rule } => { + let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect(); + if paths.is_empty() { + return; + } + let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); + self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); + } + NodeKind::Text(t) => self.text_node(n, t), + NodeKind::Image(im) => { + let id = self.id_attr(n); + let m = self.matrix(self.xf * im.xf); + let a = self.attrs(&css::transparency(n)); + if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) { + let shared = shared.clone(); + self.line(&format!("")); + return; + } + let Some(href) = self.image_href(im) else { return }; + self.line(&format!( + "", + im.width, + im.height, + xml_escape(&href) + )); + } + NodeKind::SymbolInstance { symbol, xf } => { + let doc = self.doc; + let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return }; + if self.symbol_nest > MAX_SYMBOL_NEST { + return; + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + if let Some(def) = self.symbol_use(sym, n, *xf) { + let m = self.xf * *xf; + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + self.line(&format!("")); + return; + } + // An instance of its own: the (stained) art moved by the instance, its pattern + // paints and unlinked masks left in place as on the canvas. + let mut art = vectorcraft_brush::instance_art(&sym.art, n); + let moved = self.instance_xf * *xf; + if moved.determinant().abs() > 1e-12 { + pin_to_page(&mut art, moved.inverse()); + } + let saved = (self.xf, self.instance_xf); + self.xf = saved.0 * *xf; + self.instance_xf = moved; + self.line(&format!("")); + self.depth += 1; + // The symbol's art is the instance's own picture, outside any knockout around it. + let knockout = std::mem::take(&mut self.knockout); + self.symbol_nest += 1; + self.node(&art); + self.symbol_nest -= 1; + self.knockout = knockout; + self.depth -= 1; + self.line(""); + (self.xf, self.instance_xf) = saved; + } + // Live blends/envelopes/meshes export their evaluated (expanded) form. + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.share_images(&g); + self.node_body(&g); + } + } + } + + /// The id of the `` def instance `inst` (transform `xf`) can ``, written on first + /// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved + /// by a transform under which the def would not look as the canvas paints it ([`Reuse`]). + fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option { + // SVG Tiny has no ``. + if self.tiny() || vectorcraft_brush::stain(inst).is_some() { + return None; + } + if !self.symbols.contains_key(&sym.name) { + let reuse = Reuse::of(self.doc, &sym.art); + self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse }); + } + let def = self.symbols.get(&sym.name)?; + if !def.reuse.allows(xf) { + return None; + } + if let Some(id) = &def.id { + return Some(id.clone()); + } + // The art in symbol space, as its own picture: no knockout around it, and no object ids + // (every instance shows it). + let id = self.unique_id(&sanitize_id(&sym.name)); + let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous); + (self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false); + self.symbol_nest += 1; + let art = self.detached(3, |w| w.node(&sym.art)); + self.symbol_nest -= 1; + (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; + // The symbol's name, when the id had to differ from it, comes back on import. + let name = if id == sym.name { + String::new() + } else if valid_xml_value(&sym.name) { + format!(" data-name=\"{}\"", xml_attr_escape(&sym.name)) + } else { + self.warn("Symbol names containing invalid XML characters were left out of SVG name attributes"); + String::new() + }; + self.def(1, &format!("")); + self.defs.push_str(&art); + self.def(1, ""); + if let Some(def) = self.symbols.get_mut(&sym.name) { + def.id = Some(id.clone()); + } + Some(id) + } +} + +/// A shape's paths as one path (document space). +pub(super) fn doc_path(paths: &[&PathData]) -> BezPath { + let mut bp = BezPath::new(); + for p in paths { + bp.extend(p.to_bezpath()); + } + bp +} + +/// Does `n` paint opaquely wherever it paints (no transparency, blending, masks or effects in +/// it)? Anything this can't tell is taken as not. +fn paints_opaque(n: &Node) -> bool { + use vectorcraft_doc::{AppearanceItem, NodeKind}; + let paint = |p: &Paint| match p { + Paint::None | Paint::Solid { .. } => true, + Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0), + _ => false, + }; + let items = n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => !f.visible || (f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty() && paint(&f.paint)), + // Brush art draws as pieces with opacities of their own (bristles vary theirs). + AppearanceItem::Stroke(s) => { + !s.visible || (s.opacity >= 1.0 && s.blend == BlendMode::Normal && s.effects.is_empty() && s.brush.is_none() && paint(&s.paint)) + } + }); + let own = n.opacity >= 1.0 && n.blend == BlendMode::Normal && n.extras().mask.is_none() && n.appearance.effects.is_empty() && items; + own && match &n.kind { + NodeKind::Path { .. } => true, + NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(|c| paints_opaque(c)), + NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0), + _ => false, + } +} diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs new file mode 100644 index 000000000..2197b2682 --- /dev/null +++ b/crates/svg/src/export/output.rs @@ -0,0 +1,422 @@ +use super::*; + +/// XML 1.0 literal attribute whitespace is normalized to spaces; references retain the value. +pub(super) fn xml_attr_escape(s: &str) -> String { + xml_escape(s).replace('\t', " ").replace('\n', " ").replace('\r', " ") +} + +/// Escape type for XML content. Characters XML 1.0 does not allow are dropped, except the line +/// and page breaks (U+000B, U+000C) other apps paste, which become spaces. +pub(super) fn xml_text(s: &str) -> String { + let s: String = s + .chars() + .filter_map(|c| match c { + '\u{b}' | '\u{c}' => Some(' '), + c if valid_xml_value(c.encode_utf8(&mut [0; 4])) => Some(c), + _ => None, + }) + .collect(); + xml_escape(&s) +} + +pub(super) fn valid_xml_value(s: &str) -> bool { + s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) +} + +/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s. +fn xml_ncname(s: &str) -> bool { + let mut chars = s.chars(); + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char) +} + +/// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar. +fn xml_name_char(c: char) -> bool { + matches!(c, + 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '\u{b7}' | + '\u{c0}'..='\u{d6}' | '\u{d8}'..='\u{f6}' | '\u{f8}'..='\u{2ff}' | + '\u{300}'..='\u{37d}' | '\u{37f}'..='\u{1fff}' | '\u{200c}'..='\u{200d}' | + '\u{203f}'..='\u{2040}' | '\u{2070}'..='\u{218f}' | '\u{2c00}'..='\u{2fef}' | + '\u{3001}'..='\u{d7ff}' | '\u{f900}'..='\u{fdcf}' | '\u{fdf0}'..='\u{fffd}' | + '\u{10000}'..='\u{effff}' + ) +} + +/// Write every character of `s` beyond ISO 8859-1 as a character reference. Called only when +/// names fit Latin-1, so references occur in values, text or style sheets. +pub(super) fn latin1_refs(s: &mut String) { + if s.chars().all(|c| u32::from(c) < 0x100) { + return; + } + let mut o = String::with_capacity(s.len() + 64); + for c in s.chars() { + if u32::from(c) < 0x100 { + o.push(c); + } else { + let _ = write!(o, "&#x{:X};", u32::from(c)); + } + } + *s = o; +} + +/// A CSS declaration block as the literal value of an ``: quotes, `%` and markup +/// characters become references that still read as themselves inside the `style` attribute. +pub(super) fn entity_value(decl: &str) -> String { + let mut o = String::with_capacity(decl.len()); + for c in decl.chars() { + match c { + '"' => o.push_str("""), + '%' => o.push_str("%"), + // Doubly escaped: the entity's replacement text is parsed again where it is used. + '&' => o.push_str("&#38;"), + '<' => o.push_str("&#60;"), + _ => o.push(c), + } + } + o +} + +/// The Dublin Core terms File Info writes to ``: the format, title, author (creator), +/// description, each keyword (subject), copyright notice and URL (rights) and created date, the +/// empty ones left out. +fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { + let info = &doc.metadata; + let mut terms = vec![("format", "image/svg+xml".to_string())]; + let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); + let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; + let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; + let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); + terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); + terms +} + +/// Turn an object name into a valid, readable XML id. +pub(crate) fn sanitize_id(name: &str) -> String { + let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect(); + if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') { + s.insert(0, '_'); + } + s +} + +impl Writer<'_> { + /// Writing the SVG Tiny 1.2 profile? + pub(super) fn tiny(&self) -> bool { + self.opts.profile == Profile::Tiny12 + } + + pub(super) fn num(&self, v: f64) -> String { + fmt_num(v, self.opts.decimals) + } + + pub(super) fn indent(&self, depth: usize) -> String { + if self.opts.minify { String::new() } else { " ".repeat(depth) } + } + + pub(super) fn line(&mut self, s: &str) { + let ind = self.indent(self.depth); + self.body.push_str(&ind); + self.body.push_str(s); + if !self.opts.minify { + self.body.push('\n'); + } + } + + pub(super) fn def(&mut self, depth: usize, s: &str) { + let ind = self.indent(depth + 1); + self.defs.push_str(&ind); + self.defs.push_str(s); + if !self.opts.minify { + self.defs.push('\n'); + } + } + + pub(super) fn fresh_id(&mut self, prefix: &str) -> String { + let mut i = 1; + loop { + let id = format!("{}{prefix}-{i}", self.id_prefix); + if self.used_ids.insert(id.clone()) { + return id; + } + i += 1; + } + } + + /// `base` (with the unique prefix) if free, else `base-2`, `base-3`… + pub(super) fn unique_id(&mut self, base: &str) -> String { + let base = format!("{}{base}", self.id_prefix); + if self.used_ids.insert(base.clone()) { + return base; + } + let next = self.next_suffix.entry(base.clone()).or_insert(2); + loop { + let cand = format!("{base}-{next}"); + *next += 1; + if self.used_ids.insert(cand.clone()) { + return cand; + } + } + } + + /// Reserve ids for every named object up front so generated def ids never collide with them. + pub(super) fn assign_name_ids(&mut self) { + if self.opts.object_ids == ObjectIds::Minimal { + return; + } + let mut named: Vec<(NodeId, String)> = Vec::new(); + self.doc.walk(|n| { + if let Some(name) = &n.name { + named.push((n.id, sanitize_id(name))); + } + }); + for (id, base) in named { + let cand = self.unique_id(&base); + self.names.insert(id, cand); + } + } + + /// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to + /// differ from it (spaces, punctuation, duplicates, the unique prefix). + /// The id of `n`'s element (with its name when the id isn't it), then the object's own data + /// as `data-*` attributes. Sublayers also retain their import marker. + pub(super) fn id_attr(&mut self, n: &Node) -> String { + if self.anonymous { + return String::new(); + } + let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id))); + if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref()) + && name != id + { + if valid_xml_value(name) { + out.push_str(&format!(" data-name=\"{}\"", xml_attr_escape(name))); + } else { + self.warn("Object names containing invalid XML characters were left out of SVG name attributes"); + } + } + // Preserve sublayer identity independently of object data attributes. + if !out.is_empty() && n.is_layer() && self.layer_nest > 1 { + out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); + } + let mut keys = HashSet::new(); + for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { + // Ours (`name`, `vc-…`) are written by the export itself. + if k == "name" || k.starts_with("vc-") { + continue; + } + if k.is_empty() || !k.chars().all(xml_name_char) || !valid_xml_value(v) { + self.warn("Data attributes with invalid XML names or characters were left out of SVG"); + continue; + } + // Native data is an ordered list. Keep the first valid value of each key. + if !keys.insert(k.as_str()) { + self.warn("Repeated data attribute keys were written only once in SVG"); + continue; + } + self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff); + out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v))); + } + self.foreign_attrs(n, &mut out); + self.event_attrs(n, &mut out); + out + } + + /// `n`'s attributes in other applications' namespaces (`plotter:pen="2"`), added to `out`. + /// Each namespace is declared once, on the root, under the prefix the file gave it unless the + /// export uses that prefix itself or for another namespace ([`Writer::foreign_prefix`]). + fn foreign_attrs(&mut self, n: &Node, out: &mut String) { + let attrs = n.extras().attrs.as_deref().map_or(&[][..], |a| a.foreign.as_slice()); + let mut seen = HashSet::new(); + for a in attrs { + if !xml_ncname(&a.prefix) || !xml_ncname(&a.name) || a.ns.is_empty() || !valid_xml_value(&a.ns) || !valid_xml_value(&a.value) { + self.warn("Attributes of other applications with invalid XML names or characters were left out of SVG"); + continue; + } + // Native data is a list: one attribute per namespace and name. + if !seen.insert((a.ns.as_str(), a.name.as_str())) { + self.warn("Repeated attributes of other applications were written only once in SVG"); + continue; + } + let prefix = self.foreign_prefix(&a.ns, &a.prefix); + self.non_latin1_names |= prefix.chars().chain(a.name.chars()).any(|c| u32::from(c) > 0xff); + out.push_str(&format!(" {prefix}:{}=\"{}\"", a.name, xml_attr_escape(&a.value))); + } + } + + /// The prefix namespace `ns` is written under: `wanted` (the file's), else `wanted2`, + /// `wanted3`… when the export's own prefixes or another namespace already have it. XML keeps + /// prefixes starting with `xml` for itself: those become `ns`, `ns2`… + fn foreign_prefix(&mut self, ns: &str, wanted: &str) -> String { + if let Some(p) = self.foreign_ns.get(ns) { + return p.clone(); + } + const OURS: [&str; 5] = ["svg", "xlink", "rdf", "dc", "vectorcraft"]; + let base = if wanted.to_ascii_lowercase().starts_with("xml") { "ns" } else { wanted }; + let taken = |p: &str| OURS.contains(&p) || self.foreign_ns.values().any(|q| q == p); + let mut prefix = base.to_string(); + let mut k = 2; + while taken(&prefix) { + prefix = format!("{base}{k}"); + k += 1; + } + self.foreign_ns.insert(ns.to_string(), prefix.clone()); + prefix + } + + /// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to + /// `out`. The SVG Tiny 1.2 profile leaves them out with a warning. + fn event_attrs(&mut self, n: &Node, out: &mut String) { + let events = n.extras().attrs.as_deref().map_or(&[][..], |a| a.events.as_slice()); + if events.is_empty() { + return; + } + if self.tiny() { + self.warn("SVG Tiny 1.2 has no event attributes: SVG Interactivity events were left out"); + return; + } + let mut seen = HashSet::new(); + for (event, script) in events { + // Native data can name anything: only the panel's events are written, once each. + if !SVG_EVENTS.contains(&event.as_str()) || !valid_xml_value(script) { + self.warn("SVG Interactivity events with unknown names or invalid XML characters were left out of SVG"); + continue; + } + if !seen.insert(event.as_str()) { + self.warn("Repeated SVG Interactivity events were written only once in SVG"); + continue; + } + out.push_str(&format!(" {event}=\"{}\"", xml_attr_escape(script))); + } + } + + pub(super) fn matrix(&self, m: Affine) -> String { + let c = m.as_coeffs(); + format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5])) + } + + /// Style properties as attributes, an inline style, or a CSS class, per the styling option. + pub(super) fn attrs(&mut self, props: &Props) -> String { + if props.is_empty() { + return String::new(); + } + // SVG Tiny has presentation attributes alone. + let styling = if self.tiny() { Styling::PresentationAttributes } else { self.opts.styling }; + match styling { + Styling::PresentationAttributes => { + // CSS-only properties go in a style attribute (SVG Tiny has none: left out). + let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| { + matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-variation-settings" | "font-kerning") + }); + let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect(); + if !style.is_empty() { + if self.tiny() { + self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation, font features and font variation settings are left out"); + } else { + s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style)))); + } + } + s + } + Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css::declarations(props))), + Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css::declarations(props))), + Styling::InternalCss => { + let i = self.class(css::declarations(props)); + format!(" class=\"{}cls-{i}\"", self.id_prefix) + } + } + } + + /// The 1-based number of a distinct declaration block (a CSS class or a style entity). + fn class(&mut self, decl: String) -> usize { + if let Some(&i) = self.class_ids.get(decl.as_str()) { + return i; + } + let i = self.classes.len() + 1; + self.class_ids.insert(decl.clone(), i); + self.classes.push(decl); + i + } + + /// ``: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the + /// native document ([`ExportOptions::preserve_editing`]) with the [`body_hash`] of the markup + /// around this element. + pub(super) fn metadata(&self, out: &mut String, native: Option<(&[u8], String)>) { + if !self.opts.metadata && native.is_none() { + return; + } + let nl = if self.opts.minify { "" } else { "\n" }; + let mut line = |depth: usize, s: &str| { + out.push_str(&self.indent(depth)); + out.push_str(s); + out.push_str(nl); + }; + line(1, ""); + if self.opts.metadata { + line(2, ""); + line(3, ""); + for (term, value) in dublin_core(self.doc) { + line(4, &format!("{}", xml_escape(&value))); + } + line(3, ""); + line(2, ""); + } + if let Some((bytes, hash)) = native { + line( + 2, + &format!( + "", + base64_encode(bytes) + ), + ); + } + line(1, ""); + } + + pub(super) fn path_d(&self, p: &PathData, m: Affine) -> String { + let mut s = String::new(); + // Each point as "x y", appended in place (no string per number or per point). + let dec = self.opts.decimals; + let pt = |s: &mut String, p: Point| { + let p = m * p; + crate::push_num(s, p.x, dec); + s.push(' '); + crate::push_num(s, p.y, dec); + }; + for sp in &p.subpaths { + let n = sp.anchors.len(); + if n == 0 { + continue; + } + if !s.is_empty() { + s.push(' '); + } + s.push('M'); + pt(&mut s, sp.anchors[0].p); + for i in 0..sp.segment_count() { + let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]); + if sp.segment_is_line(i) { + if sp.closed && i == n - 1 { + break; + } + s.push_str(" L"); + pt(&mut s, b.p); + } else { + s.push_str(" C"); + pt(&mut s, a.h_out); + s.push(' '); + pt(&mut s, b.h_in); + s.push(' '); + pt(&mut s, b.p); + } + } + if sp.closed { + s.push_str(" Z"); + } + } + s + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } +} diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs new file mode 100644 index 000000000..412098b72 --- /dev/null +++ b/crates/svg/src/export/paint.rs @@ -0,0 +1,503 @@ +use super::nodes::{MASK, doc_path}; +use super::output::sanitize_id; +use super::*; + +/// Image `b` as SVG viewers show it: PNG, JPEG, GIF, WebP and SVG as they are, other formats (a +/// CMYK TIFF) as PNG. +fn web_image(b: &ImageBlob) -> std::borrow::Cow<'_, ImageBlob> { + use std::borrow::Cow; + if matches!(b.mime.as_str(), "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" | "image/svg+xml") { + return Cow::Borrowed(b); + } + let png = image::load_from_memory(&b.bytes).ok().and_then(|img| { + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) + }); + png.map_or(Cow::Borrowed(b), |png| Cow::Owned(ImageBlob::png(png))) +} + +/// The usual file extension of an image MIME type. +fn image_ext(mime: &str) -> &str { + match mime { + "image/jpeg" => "jpg", + "image/svg+xml" => "svg", + m => m.strip_prefix("image/").filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin"), + } +} + +/// How one stroke is written. +pub(super) enum StrokePlan { + /// Its brush art (document space). + Brush(Vec), + Written(Written), +} + +impl Writer<'_> { + /// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are + /// missing), else its bytes. `None` when it has neither. + pub(super) fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option { + let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); + match (im.link.as_ref().filter(|_| link), doc.image_blob(im)) { + (Some(l), _) => Some(l.path.clone()), + (None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(&web_image(b))), + _ => im.link.as_ref().map(|l| l.path.clone()), + } + } + + /// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs + /// once; each piece then draws them with ``. + pub(super) fn share_images(&mut self, art: &Node) { + // Each image's count and its first piece, which shows what they all show. + let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; + } + }); + let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); + repeated.sort_by(|a, b| a.0.cmp(&b.0)); + for ((key, width, height), (_, im)) in repeated { + let Some(href) = self.image_href(&im) else { continue }; + let id = self.fresh_id("image"); + self.def( + 0, + &format!( + "", + xml_escape(&href) + ), + ); + self.shared_images.insert((key, width, height), id); + } + } + + /// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space + /// [`Self::xf`] maps into user space, as the renderer does). + pub(super) fn paint(&mut self, p: &Paint, bounds: Option) -> String { + match p { + Paint::None => "none".into(), + Paint::Pattern { pattern, xf } => match self.pattern_def(pattern, *xf) { + Some(id) => format!("url(#{id})"), + None => "none".into(), + }, + Paint::Solid { color, .. } => color.to_hex(), + Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))), + } + } + + /// A `` def for pattern `name` placed by `xf`: one period (super-tile) of the tiling, + /// with every instance that reaches into it. + fn pattern_def(&mut self, name: &str, xf: Affine) -> Option { + let doc = self.doc; + let def = doc.pattern(name)?; + let m = self.xf * xf; + let key = format!("{name}|{:?}", m.as_coeffs()); + if let Some(id) = self.patterns.get(&key) { + return Some(id.clone()); + } + if self.pattern_nest > 4 { + return None; + } + let id = self.fresh_id("pattern"); + self.patterns.insert(key, id.clone()); + let (pw, ph) = def.period(); + let (saved_body, saved_xf, saved_depth) = (std::mem::take(&mut self.body), self.xf, self.depth); + self.depth = 3; + self.pattern_nest += 1; + for o in def.offsets_covering(Rect::new(0.0, 0.0, pw, ph)) { + self.xf = def.instance_xf(o); + for a in &def.art { + self.node(a); + } + } + self.pattern_nest -= 1; + let content = std::mem::replace(&mut self.body, saved_body); + self.xf = saved_xf; + self.depth = saved_depth; + let head = format!( + "", + self.num(pw), + self.num(ph), + self.matrix(m) + ); + self.def(1, &head); + self.defs.push_str(&content); + self.def(1, ""); + Some(id) + } + + /// The id of a ``/`` def for `g` resolved against `bounds`. + /// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its + /// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint` + /// (what import turns back into a midpoint). + fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { + if g.gradient.kind == GradientKind::Freeform { + // Object fills and strokes are images (see `raster_paint`); characters' paints aren't. + self.warn("freeform gradients on characters are written as linear gradients"); + } + let mut geom = g.resolve(bounds); + geom.transform(self.xf, g.gradient.kind); + let (s, e) = (geom.start, geom.end); + // An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space. + let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y)); + let (tag, prefix, attrs) = match g.gradient.kind { + GradientKind::Radial => { + let r = self.num((e - s).hypot()); + let attrs = if (geom.aspect - 1.0).abs() < 1e-9 { + let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default(); + format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y)) + } else { + let v = e - s; + let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect); + let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default(); + format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m)) + }; + ("radialGradient", "radial-gradient", attrs) + } + GradientKind::Linear | GradientKind::Freeform => ( + "linearGradient", + "linear-gradient", + format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), + ), + }; + let stops: Vec = g + .gradient + .expanded() + .map(|(offset, c, o, mid)| { + let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() }; + let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default(); + format!("", fmt_num(offset as f64, 4), c.to_hex()) + }) + .collect(); + let key = format!("{tag}{attrs}{}", stops.concat()); + if let Some(id) = self.gradients.get(&key) { + return id.clone(); + } + let id = match &g.swatch { + Some(name) => self.unique_id(&sanitize_id(name)), + None => self.fresh_id(prefix), + }; + self.gradients.insert(key, id.clone()); + self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">")); + for stop in &stops { + self.def(2, stop); + } + self.def(1, &format!("")); + id + } + + pub(super) fn fill_props(&mut self, f: &FillLayer, rule: FillRule, bounds: Option, p: &mut Props) { + let paint = self.paint(&f.paint, bounds); + p.push(("fill", paint)); + if f.opacity < 1.0 { + p.push(("fill-opacity", fmt_num(f.opacity as f64, 3))); + } + if rule == FillRule::EvenOdd { + p.push(("fill-rule", "evenodd".into())); + } + } + + pub(super) fn stroke_props(&mut self, s: &StrokeLayer, width: f64, bounds: Option, p: &mut Props) { + let paint = self.paint(&s.paint, bounds); + p.push(("stroke", paint)); + if s.opacity < 1.0 { + p.push(("stroke-opacity", fmt_num(s.opacity as f64, 3))); + } + if (width - 1.0).abs() > 1e-9 { + p.push(("stroke-width", self.num(width))); + } + match s.cap { + LineCap::Butt => {} + LineCap::Round => p.push(("stroke-linecap", "round".into())), + LineCap::Square => p.push(("stroke-linecap", "square".into())), + } + match s.join { + LineJoin::Miter => { + if (s.miter_limit - 4.0).abs() > 1e-9 { + p.push(("stroke-miterlimit", self.num(s.miter_limit))); + } + } + LineJoin::Round => p.push(("stroke-linejoin", "round".into())), + LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())), + } + if let Some(d) = &s.dash + && d.is_dashed() + { + p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::>().join(" "))); + if d.offset != 0.0 { + p.push(("stroke-dashoffset", self.num(d.offset))); + } + } + } + + /// How stroke `st` is written (`bp`: the shape in document space, built on first use from + /// `paths`): its brush art, or a plain stroke or filled outlines matching the canvas. + pub(super) fn stroke_plan(&mut self, st: &StrokeLayer, bp: &mut Option, paths: &[&PathData]) -> StrokePlan { + if stroke::is_plain(st) { + return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st)); + } + let bp = bp.get_or_insert_with(|| doc_path(paths)); + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { + Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)), + None => StrokePlan::Written(stroke::for_writer(bp, st)), + } + } + + /// The `clip-path` (inside) or `mask` (outside) attribute that keeps an aligned stroke on its + /// side of the shape `d`; `reach` (document space) is what the stroke covers. + pub(super) fn side_attr(&mut self, side: Option, d: &str, rule: FillRule, reach: Rect) -> String { + match side { + None | Some(StrokeAlign::Center) => String::new(), + Some(StrokeAlign::Inside) => { + let cid = self.fresh_id("clip-path"); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" clip-path=\"url(#{cid})\"") + } + Some(StrokeAlign::Outside) if self.tiny() => { + // No masks: clipped by everything around the shape instead. + let cid = self.fresh_id("clip-path"); + let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0); + let (x0, y0, x1, y1) = (self.num(b.x0), self.num(b.y0), self.num(b.x1), self.num(b.y1)); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" clip-path=\"url(#{cid})\"") + } + Some(StrokeAlign::Outside) => { + let mid = self.fresh_id("mask"); + let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0); + let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); + let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" mask=\"url(#{mid})\"") + } + } + } + + /// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written + /// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across + /// documents). + pub(super) fn blob_href(&mut self, b: &ImageBlob) -> String { + if self.opts.images != ImageMode::Link { + return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)); + } + let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); + if self.linked_names.insert(name.clone()) { + self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() }); + } + name + } + + /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is + /// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a + /// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled + /// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour + /// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size + /// in points); a linear or radial gradient at the document's raster effects resolution (at most + /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty + /// box. + #[allow(clippy::too_many_arguments)] + pub(super) fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) { + use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; + let Some(b) = painted_box(bounds) else { return }; + let freeform = g.gradient.kind == GradientKind::Freeform; + let (rgba, cols, rows): (Vec, u16, u16) = if freeform { + let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height())); + let field = g.freeform_on(b).field(spread_scale(b)); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + (field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows) + } else { + let k = self.doc.raster_effects_ppi / 72.0; + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; + let (cols, rows) = (along(b.width()), along(b.height())); + (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows) + }; + let mut png = Vec::new(); + let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return }; + if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + return; + } + self.warn(if freeform { + "freeform gradients are written as images clipped to their shapes" + } else { + "linear and radial gradients drawn as pixels are written as images clipped to their shapes" + }); + let href = self.blob_href(&ImageBlob::new("image/png", png)); + let cid = self.fresh_id("clip-path"); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + for d in clip { + self.def(2, &format!("")); + } + self.def(1, ""); + let a = self.attrs(props); + let nested = !side.is_empty(); + if nested { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } + self.depth += 1; + let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); + let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) }; + self.line(&format!( + "", + xml_escape(&href) + )); + self.depth -= 1; + self.line(""); + if nested { + self.depth -= 1; + self.line(""); + } + } + + /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` + /// per effect, the first effect innermost (the reference app's stacking order). A drop shadow + /// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style + /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn + /// such objects into images first). + pub(super) fn filtered(&mut self, n: &Node) { + let mut inner = n.clone(); + inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); + let opened = self.open_filters(n, &n.appearance.effects); + self.node_body(&inner); + self.close_filters(opened); + } + + /// Open one `` per visible raster effect of `effects` (an object's, or one fill or + /// stroke's) on `n`, outermost last effect; returns how many to close. + pub(super) fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { + use vectorcraft_effects::RasterFx; + let fx = vectorcraft_effects::raster_effects(effects); + if self.tiny() { + if !fx.is_empty() { + self.warn("SVG Tiny 1.2 has no filters: shadows, glows, blurs and feathers are left out"); + } + return 0; + } + let bounds = n.visual_bounds_with(&self.doc.symbol_boxes()); + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = 18.0 / self.doc.raster_ppi(); + let reach: f64 = + fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::() + + 2.0; + let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); + let mut opened = 0; + for f in fx.iter().rev() { + if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) { + self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); + continue; + } + let fid = self.fresh_id("filter"); + let region_attr = match region { + Some(r) => format!( + " filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", + self.num(r.x0), + self.num(r.y0), + self.num(r.width()), + self.num(r.height()) + ), + None => String::new(), + }; + let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3); + let flood = |c: &vectorcraft_color::Color, o: f32| { + format!("", c.to_hex(), fmt_num(o as f64, 3)) + }; + let body = match f { + // Darkness: the object blurred and moved, each color channel times 1 − darkness. + RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!( + "", + sd(*blur), + self.num(*dx), + self.num(*dy), + fmt_num(*opacity as f64, 3), + keep = fmt_num(1.0 - *darkness as f64, 3) + ), + RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( + "{}", + sd(*blur), + self.num(*dx), + self.num(*dy), + flood(color, *opacity) + ), + // The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel. + RasterFx::OuterGlow { opacity, blur, color, .. } => format!( + "{}", + fmt_num(grow, 3), + sd(*blur), + flood(color, *opacity) + ), + RasterFx::InnerGlow { opacity, blur, color, center, .. } => { + // Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape. + let src = if *center { + "".replace("SD", &sd(*blur)) + } else { + format!( + "", + sd(*blur) + ) + }; + format!( + "{src}{}", + flood(color, *opacity) + ) + } + RasterFx::Feather { radius } => format!( + "", + sd(*radius) + ), + RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), + RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, + }; + // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. + let mode = match f { + RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } + if *mode != BlendMode::Normal => + { + format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode)) + } + _ => String::new(), + }; + self.def(1, &format!("{body}")); + self.line(&format!("")); + self.depth += 1; + opened += 1; + } + opened + } + + pub(super) fn close_filters(&mut self, opened: usize) { + for _ in 0..opened { + self.depth -= 1; + self.line(""); + } + } +} + +/// Most pixels along a side of a linear or radial gradient's image. +const MAX_GRADIENT_PX: f64 = 4096.0; + +/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels +/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one). +pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { + match p { + Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g), + _ => None, + } +} + +/// Any visible raster effect (shadow, glow, blur, feather) in `effects`? +pub(super) fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { + effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) +} diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs new file mode 100644 index 000000000..7b5e55d84 --- /dev/null +++ b/crates/svg/src/export/text.rs @@ -0,0 +1,806 @@ +use super::output::xml_text; +use super::*; + +/// `chars` (sorted) as a CSS `unicode-range` (`U+41-5A,U+61`). +fn unicode_range(chars: &BTreeSet) -> String { + let mut runs: Vec<(u32, u32)> = Vec::new(); + for c in chars.iter().map(|c| u32::from(*c)) { + match runs.last_mut() { + Some((_, end)) if c == *end + 1 => *end = c, + _ => runs.push((c, c)), + } + } + let one = |(a, b): (u32, u32)| if a == b { format!("U+{a:X}") } else { format!("U+{a:X}-{b:X}") }; + runs.into_iter().map(one).collect::>().join(",") +} + +/// The characters type uses from one face, under one `@font-face` description: the family the +/// type names and the weight, italic and width its style asks for. +pub(super) struct FontUse { + family: String, + weight: u16, + italic: bool, + /// The `font-stretch` percentage ([`css::font_stretch`]), as bits; `None` for the normal width. + stretch: Option, + face: Arc, + chars: BTreeSet, +} + +impl Writer<'_> { + /// The ``/`` properties of a character style. `space`: the text's layout bounds + /// and the map from text space to user space, which character gradients resolve against + /// (as on the canvas). + fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { + let decimals = self.opts.decimals; + let len = |v: f64| fmt_num(v, decimals); + let mut p = css::font_props(st, &len); + // Character paints resolve in the text's space (`to_user` maps it into user space). + let (bounds, to_user) = space; + let saved = std::mem::replace(&mut self.xf, to_user); + if let Paint::Gradient(g) = &st.fill + && g.gradient.dither + && g.gradient.kind != GradientKind::Freeform + { + self.warn("dithered gradients on characters are written without dither"); + } + let fill = self.paint(&st.fill, Some(bounds)); + p.push(("fill", fill)); + // An inside or outside stroke is drawn on the glyph outlines instead + // ([`Self::aligned_char_strokes`]): SVG strokes text centred only. + if st.has_stroke() && !aligned(st) { + // Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans + // the text grown by half the weight, as on the canvas. + let layer = st.stroke_layer(); + self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p); + } + self.xf = saved; + p.extend(css::type_props(st, &len)); + p + } + + /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, + /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those + /// below the Characters row under the characters, the others over them. + /// + /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are written after the characters as + /// instances of their symbols (a `` of the symbol's def where it can be shared), all in + /// one group carrying the object's transparency. The live text skips their characters and + /// places the text after each one; type on a path with inline graphics is written as outlines + /// (a `` couldn't leave room for the art). + pub(super) fn text_node(&mut self, n: &Node, t: &TextObject) { + let doc = self.doc; + let resolved = doc.inline_resolved(t); + let t = &*resolved; + let inline = t.runs.iter().any(|r| r.inline.is_some()); + // Ruby is written as outlines with its base text: a reader's can't place it. So are + // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). + let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) + || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + let outlined = + self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; + let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; + let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); + // Live type with an inside or outside character stroke writes that stroke apart from the + // text, in the group. + let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style)); + if !painted && !inline && !apart { + return chars(self, n); + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + self.line(&format!("")); + self.depth += 1; + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut all = lay.to_bezpath(); + for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { + all.extend(bar.iter()); + } + let pd = PathData::from_bezpath(&all).transformed(t.xf); + let d = self.path_d(&pd, self.xf); + let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); + let (below, above) = n.appearance.split_contents(); + let glyphs = |w: &mut Self, items: &[AppearanceItem]| { + if !items.is_empty() { + let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() }; + w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb); + } + }; + glyphs(self, below); + // The group carries the id and the object's transparency. + let mut bare = n.clone(); + (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); + let anonymous = std::mem::replace(&mut self.anonymous, true); + chars(self, &bare); + for ig in &lay.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let inst = Node::new( + NodeId(u64::MAX), + NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) }, + ); + self.node(&inst); + } + glyphs(self, above); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } + + /// Text as glyph outlines: one compound path per run, painted like the run (gradients span + /// the whole text), over its colour glyphs, as on the canvas. + fn text_outlines(&mut self, n: &Node, t: &TextObject) { + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; + // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted + // with the run of the text they annotate. + let bars = vectorcraft_text::decorations(&lay, db, t); + for (run, outline) in lay + .glyphs + .iter() + .map(|g| (g.run, &g.outline)) + .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) + .chain(bars.iter().map(|(r, b)| (*r, b))) + { + if let Some(bp) = runs.get_mut(run) { + bp.extend(outline.iter()); + } + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group + // names its text for screen readers and search. + let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { + format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text())) + } else { + String::new() + }; + self.line(&format!("")); + self.depth += 1; + // The glyphs stay in text space; the text's transform joins the document's meanwhile. + let saved = self.xf; + self.xf = saved * t.xf; + for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { + // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as + // their images, COLR and SVG glyphs as their art. + for g in lay.glyphs.iter().filter(|g| g.run == i) { + if let Some((image, to_text)) = g.placed_image() { + let m = self.matrix(self.xf * to_text); + let href = self.blob_href(&image.blob); + self.line(&format!( + "", + image.width, + image.height, + xml_escape(&href) + )); + } + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(&art); + } + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(bp); + // An unnamed id: the pieces carry no id attribute (the group has it). + let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); + let d = self.path_d(&pd, self.xf); + self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); + } + self.xf = saved; + self.depth -= 1; + self.line(""); + } + + /// Point and area type, every laid-out line at its own position: wraps, indents, tabs and + /// spacing come out as on the canvas. Centred and right-aligned lines are anchored + /// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font + /// differs; other lines are placed where each style run (and, justified, each word) starts. + fn text(&mut self, n: &Node, t: &TextObject) { + // Live SVG text is written in logical order from left-aligned pieces: bidirectional text + // (and right-to-left paragraphs) keep their look as outlines until it is written with + // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose + // `direction="rtl"` would anchor their pieces at the right end. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); + if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { + self.warn("bidirectional text is outlined to preserve shaping and visual order"); + return self.text_outlines(n, t); + } + if let TextKind::OnPath { path, start, end } = &t.kind { + // A reader lays a ``'s glyphs straight: joined glyphs bent along the path + // keep their look as outlines. + if lay.glyphs.iter().any(|g| g.bend.is_some()) { + self.warn("joined type bent along a path is outlined to keep its look"); + return self.text_outlines(n, t); + } + // `` has a start offset only: an end bracket, Align to Path and Spacing + // keep their look as outlines. + if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 { + self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look"); + return self.text_outlines(n, t); + } + // It cannot hang glyphs outside the start bracket either. + if hangs_on_path(t, &lay) { + self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); + return self.text_outlines(n, t); + } + // A `` holds the characters of the text, not the bullets or numbers drawn + // before them. + if !lay.markers.is_empty() { + self.warn("type on a path with bullets or numbers is outlined to keep its look"); + return self.text_outlines(n, t); + } + return self.text_on_path(n, t, path, *start); + } + self.note_fonts(t, &lay); + let lines = text_lines(t, &lay, self.opts.fewer_tspans); + // `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts. + let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale)); + let drawn = fitted.as_ref().unwrap_or(t); + // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its + // stop, and so does the text after an inline graphic: lines with tabs, and text with + // inline graphics, are never anchored. + let has_inline = t.runs.iter().any(|r| r.inline.is_some()); + let anchors: Vec> = lines + .iter() + .map(|l| { + match t.para_at(l.para).justify { + Justify::Center => Some(("middle", 0.5)), + Justify::Right => Some(("end", 1.0)), + _ => None, + } + .filter(|_| !has_inline && !l.segs.iter().any(|s| s.brk)) + }) + .collect(); + // Every line anchored alike (the text's only paragraph style, typically): the + // carries the anchor. Otherwise each anchored line is a carrying its own. + let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); + // The element's user space is text space. + let space = (lay.bounds, Affine::IDENTITY); + let mut base_props = self.char_props(&drawn.first_style(), space); + // A `` cannot undo the decoration of the `` around it: with bullets, which + // have none, each run's `` carries its own. + if !lay.markers.is_empty() { + base_props.retain(|(k, _)| *k != "text-decoration"); + } + let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; + let mut props = base.props.clone(); + if let Some((a, _)) = uniform { + props.push(("text-anchor", a.into())); + } + props.extend(css::transparency(n)); + let id = self.id_attr(n); + let a = self.attrs(&props); + let m = self.xf * t.xf; + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + let starts: Vec> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect(); + // The `` carries the first line's position too, so readers that place text by its + // own x/y start where the first line does. + let at = match starts.iter().flatten().next() { + Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)), + None => String::new(), + }; + let mut s = format!(""); + for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) { + let Some(start) = start else { continue }; + match anchor.filter(|_| uniform.is_none()) { + // A line anchored on its own: one positioned with the anchor around it. + Some((a, _)) => { + s.push_str(&format!("", self.num(start.0), self.num(start.1))); + let inner = TextBase { anchored: true, ..base.clone() }; + self.text_line(&mut s, drawn, &line.segs, start, &inner, false); + s.push_str(""); + } + None => self.text_line(&mut s, drawn, &line.segs, start, &base, true), + } + } + s.push_str(""); + self.line(&s); + self.aligned_char_strokes(t); + } + + /// The inside and outside character strokes of live type `t`, over its text (which goes + /// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn + /// twice as wide and clipped or masked to its side as aligned path strokes are + /// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines. + fn aligned_char_strokes(&mut self, t: &TextObject) { + if !t.runs.iter().any(|r| aligned(&r.style)) { + return; + } + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let bars = vectorcraft_text::decorations(&lay, db, t); + // The glyphs stay in text space; the text's transform joins the document's meanwhile. + let saved = self.xf; + self.xf = saved * t.xf; + for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) { + let mut bp = BezPath::new(); + for g in lay.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { + bp.extend(bar.iter()); + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(&bp); + let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() }; + let d = self.path_d(&pd, self.xf); + self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); + } + self.xf = saved; + } + + /// One line of laid-out text starting at `start`. Default: a positioned `` per segment + /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with + /// the style changes nested in it, positioned again only after tabs and inline graphics. + /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. + /// `positioned`: false when the caller already positioned the line (an outer ``). + fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { + let fewer = self.opts.fewer_tspans && positioned; + if fewer { + s.push_str(&format!("", self.num(start.0), self.num(start.1))); + } + let mut place = !fewer && positioned; + let mut first = true; + // The baseline shift the current text position carries. + let mut shift = 0.0; + for seg in line { + let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let marker_style; + let st = if seg.marker { + marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); + &marker_style + } else { + run_style + }; + let mut attrs = String::new(); + if place { + let (x, y) = if first { start } else { (seg.x, seg.y) }; + attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y))); + shift = 0.0; + } + first = false; + if st.baseline_shift != shift { + attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); + shift = st.baseline_shift; + } + // After a tab, an inline graphic or a justified word space, and before a character that a + // reader would draw elsewhere, the next segment is placed. + place = seg.brk || (!fewer && !base.anchored && positioned); + if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 { + attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale))); + } + if st.rotation != 0.0 { + attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); + } + let mut diff = run_diff(&base.props, self.char_props(st, base.space)); + diff.extend(direction_props(st)); + attrs.push_str(&self.attrs(&diff)); + let text = xml_text(&seg.text); + if attrs.is_empty() && fewer { + s.push_str(&text); + } else { + s.push_str(&format!("{text}")); + } + } + if fewer { + s.push_str(""); + } + } + + /// Record the characters of laid-out type `t` under the faces that supply them, for + /// [`Self::font_faces`] (when fonts are embedded). + fn note_fonts(&mut self, t: &TextObject, lay: &vectorcraft_text::TextLayout) { + if !self.opts.embed_fonts || self.opts.outline_text { + return; + } + if self.tiny() { + return self.warn("SVG Tiny 1.2 has no web fonts: fonts are not embedded"); + } + let db = FontDb::global(); + let plain = t.plain_text(); + let mut face: Option> = None; + for (i, g) in lay.glyphs.iter().enumerate() { + let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; + // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the + // viewer. + if g.image.is_some() || g.colour.is_some() { + self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts"); + continue; + } + if face.as_ref().is_none_or(|f| f.id() != g.font_id) { + face = db.face_by_id(g.font_id); + } + let Some(f) = &face else { continue }; + let (weight, italic) = font_descriptor(st); + let stretch = css::font_stretch(st).map(f64::to_bits); + let at = self + .fonts + .iter() + .position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family); + let at = at.unwrap_or_else(|| { + let family = st.font_family.clone(); + self.fonts.push(FontUse { family, weight, italic, stretch, face: f.clone(), chars: BTreeSet::new() }); + self.fonts.len() - 1 + }); + let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue }; + // As written: a list marker's characters, a hyphen where a line breaks, capitals for All + // Caps, the style's Digits. + let src = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }); + for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) { + if st.all_caps { + chars.extend(c.to_uppercase()); + } else { + chars.insert(c); + } + } + } + } + + /// The `@font-face` rules of the faces type uses ([`ExportOptions::embed_fonts`]): each face + /// subset to its characters (whole when its licence forbids subsetting, left out with a + /// warning when it forbids embedding). Faces sharing a description cover their own + /// characters (`unicode-range`); where two of them share characters as well, a warning says + /// that browsers draw those in one of the faces. + pub(super) fn font_faces(&mut self) -> Vec { + let uses = std::mem::take(&mut self.fonts); + let mut rules = Vec::new(); + fn described(u: &FontUse) -> (&str, u16, bool, Option) { + (&u.family, u.weight, u.italic, u.stretch) + } + let mut clashing: Vec<&String> = vec![]; + for (i, u) in uses.iter().enumerate() { + let clash = uses + .get(..i) + .unwrap_or_default() + .iter() + .any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars)); + if clash && !clashing.contains(&&u.family) { + clashing.push(&u.family); + } + } + for family in clashing { + self.warn(&format!( + "type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces" + )); + } + for u in &uses { + let chars: Vec = u.chars.iter().copied().collect(); + let name = format!("{} {}", u.face.family, u.face.style); + let Some(font) = u.face.embed(&chars) else { + self.warn(&format!("the licence of the font {name} doesn't allow embedding: viewers show its type in their own fonts")); + continue; + }; + if !font.subset { + self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); + } + let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1; + let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; + let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default(); + let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") }; + rules.push(format!( + "@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}", + css_string(&u.family), + u.weight, + if u.italic { "italic" } else { "normal" }, + base64_encode(&font.data) + )); + } + rules + } + + /// Type on a path: a `` along the path (a def). + fn text_on_path(&mut self, n: &Node, t: &TextObject, path: &PathData, start: f64) { + let id = self.id_attr(n); + let pid = self.fresh_id("text-path"); + let d = self.path_d(path, self.xf * t.xf); + self.def(1, &format!("")); + // The element's user space is the document's; gradients span the laid-out text. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + self.note_fonts(t, &lay); + let space = (lay.bounds, self.xf * t.xf); + let base = self.char_props(&t.first_style(), space); + let mut props = base.clone(); + match t.para_at(0).justify { + Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), + Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), + _ => {} + } + props.extend(css::transparency(n)); + let a = self.attrs(&props); + let offset = fmt_num(start * 100.0, 3); + let mut s = format!(""); + for run in &t.runs { + let mut diff = run_diff(&base, self.char_props(&run.style, space)); + diff.extend(direction_props(&run.style)); + let a = self.attrs(&diff); + s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); + } + s.push_str(""); + self.line(&s); + self.aligned_char_strokes(t); + } +} + +/// Does a character style stroke inside or outside its glyphs (not centred)? +fn aligned(st: &CharStyle) -> bool { + st.has_stroke() && st.stroke_align != StrokeAlign::Center +} + +/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when +/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its +/// glyphs sit elsewhere with both off. +fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { + if !t.optical_margin && !t.para_at(0).roman_hanging { + return false; + } + let mut flush = t.clone(); + flush.optical_margin = false; + let mut styles = flush.paragraph_styles(); + for p in &mut styles { + p.roman_hanging = false; + } + flush.set_paragraph_styles(styles); + let flush = vectorcraft_text::layout(FontDb::global(), &flush); + lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) +} + +/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in +/// its Character Direction, and nothing without one. Each `` of characters with a direction +/// carries them, and the `` does not. Type with a run set Right to Left is written as +/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. +fn direction_props(st: &CharStyle) -> Props { + let Some(d) = st.char_direction else { return Props::new() }; + let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; + vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] +} + +/// What the `` of a run whose properties are `run` sets over its ``'s `base`: the +/// properties that differ, and the initial value of each one `base` sets and the run doesn't. +fn run_diff(base: &Props, run: Props) -> Props { + let reset: Props = + base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect(); + run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect() +} + +/// The initial value of an inherited property a run may leave unset (decorations can't be undone +/// on a ``). +fn initial_value(k: &str) -> Option<&'static str> { + Some(match k { + "font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal", + "stroke" | "stroke-dasharray" => "none", + "stroke-width" | "stroke-opacity" => "1", + "stroke-linecap" => "butt", + "stroke-linejoin" => "miter", + "stroke-miterlimit" => "4", + "stroke-dashoffset" | "letter-spacing" => "0", + "font-kerning" => "auto", + _ => return None, + }) +} + +/// How closely stroke outlines that clip an image follow the stroke (points). +pub(super) const OUTLINE_TOLERANCE: f64 = 0.01; + +/// What the lines of one `` share. +#[derive(Clone)] +struct TextBase { + /// The `` element's character properties, which each `` differs from. + props: Props, + /// The text's layout bounds and the map from text space to user space (character paints). + space: (Rect, Affine), + /// Lines anchored at their centre or right end (`text-anchor`), positioned once each. + anchored: bool, +} + +/// One laid-out line: its paragraph (index) and its segments. +struct Line { + para: usize, + segs: Vec, +} + +/// Characters of one style on one line, written as one ``. +struct Segment { + /// Index into `TextObject::runs`. + run: usize, + /// Where a reader starts drawing the segment (text space; `y` is the baseline). + x: f64, + y: f64, + text: String, + /// The width a reader gives the segment (tracking included). On a line that is justified or + /// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi + /// ([`reader_advances`]), for horizontally scaled type also without its last character's + /// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width. + advance: f64, + /// The next segment needs its own position (after a tab, a word space of a justified line, or + /// the next segment starts with a character the layout draws elsewhere than a reader would). + brk: bool, + /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to + /// (`textLength`); 1 for other segments. + scale: f64, + /// A list marker's characters and tab, written in + /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. + marker: bool, +} + +/// How far a reader may draw a character from where the layout draws it before the character is +/// given its own position (points). +const PLACE_TOLERANCE: f64 = 1e-6; + +/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, +/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters +/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all +/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also +/// starts a new segment at each character the layout draws elsewhere than a reader continuing +/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between +/// the characters of a justified Japanese line, and the spacing Mojikumi changes. +fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { + let plain = t.plain_text(); + let mut lines = Vec::with_capacity(lay.lines.len()); + let (mut kerned, mut unkerned) = (None, None); + for line in &lay.lines { + let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); + let style = t.para_at(para); + let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. + // Justification…'s Desired word and letter spacing and glyph scaling move characters from + // where a reader draws them, on every line. + let spaced = !fewer && !style.justification.desired_is_default(); + let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) { + Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) + } else { + None + }; + // Where a reader draws the next character of the last segment, and the byte offset and + // reader's advance of the last segment's last cluster. + let mut pen = 0.0; + let mut tail = None; + let mut segs: Vec = Vec::new(); + let mut cluster = None; + // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). + let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { + lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) + }; + let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); + // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. + if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { + while let Some((_, rest)) = + glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) + { + glyphs = rest; + } + } + let mut after_inline = false; + for (k, g) in glyphs.iter().enumerate() { + let Some(run) = t.runs.get(g.run) else { continue }; + // A list marker before an inline graphic is in the graphic's run, and is text. + if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { + // An inline graphic (written as art): the text after it is placed anew. + if let Some(last) = segs.last_mut() { + last.brk = true; + } + after_inline = true; + cluster = None; + continue; + } + // The cluster as a reader sets it (a hyphen the layout adds is set as laid out). + let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0); + if g.len > 0 && cluster == Some(g.byte) { + // Another glyph of the same cluster (a reader's advance covers the whole cluster). + if let Some(last) = segs.last_mut().filter(|_| set.is_none()) { + last.advance += g.advance; + pen += g.advance; + } + continue; + } + // A glyph that stands for no character (a list marker before the paragraph's first + // character) starts no cluster. + cluster = (g.len > 0).then_some(g.byte); + let (advance, x) = match set { + Some(&(advance, offset)) => { + // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. + let x = g.xf.translation().x - offset; + (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) + } + None => (g.advance, g.origin.x), + }; + let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); + let piece = if run.style.all_caps { src.to_uppercase() } else { src }; + let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); + // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. + let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 }; + let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0); + // The layout draws the character elsewhere than a reader continuing the line would. + let moved = reader.is_some() + && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); + pen = x + advance; + // A list marker is a segment of its own, and so is the text after it. In horizontally + // scaled type a tab is a segment apart from the characters before it, so a reader + // stretching those characters to their width (`textLength`) stretches them alone. + let marker = lay.marker_of(line.glyph_start + k).is_some(); + let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; + match segs.last_mut() { + Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { + last.text.push_str(&piece); + last.advance += advance; + last.brk = brk; + } + last => { + if let Some(last) = last { + last.brk |= moved || last.marker != marker; + end_segment(last, tail, t, lay, &mut unkerned); + } + segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); + } + } + tail = set.map(|_| (g.byte, advance)); + } + if let Some(last) = segs.last_mut() { + end_segment(last, tail, t, lay, &mut unkerned); + } + segs.retain(|s| !s.text.is_empty()); + if !segs.is_empty() { + lines.push(Line { para, segs }); + } + } + lines +} + +/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width +/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance +/// a reader gives it at the end of a ``, without the font's kerning against the character +/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them. +fn end_segment( + seg: &mut Segment, + tail: Option<(usize, f64)>, + t: &TextObject, + lay: &vectorcraft_text::TextLayout, + unkerned: &mut Option>, +) { + let Some((byte, advance)) = tail else { return }; + if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) { + return; + } + if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) { + seg.advance += alone - advance; + } +} + +/// The advance a reader of live SVG text gives each character cluster of `t` and how far from +/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out +/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by +/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual +/// kerning leaves it out). +fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { + let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; + for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { + p.mojikumi = vectorcraft_doc::Mojikumi::None; + // A reader spaces words and letters as the font does. + p.justification = vectorcraft_doc::JustificationOptions::default(); + } + if !kerning { + for r in &mut set.runs { + r.style.kerning.get_or_insert(0.0); + } + } + let mut out: HashMap = HashMap::new(); + for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { + let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); + *advance += g.advance * lay.fit_scale; + } + out +} + +/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1: +/// right end), that point of its width. `None` for an empty line. +fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { + let (first, last) = (line.first()?, line.last()?); + let x = match anchor { + Some(f) => first.x + f * (last.x + last.advance - first.x), + None => first.x, + }; + Some((x, first.y)) +} From 12ae0ee15894d51a309c7b07a11a4ff7014741b0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:25:58 +0000 Subject: [PATCH 124/199] refactor(mcp): move tools.rs items to tools/{definitions,dispatch,handlers}.rs --- crates/mcp/src/tools.rs | 1429 +-------------------------- crates/mcp/src/tools/definitions.rs | 512 ++++++++++ crates/mcp/src/tools/dispatch.rs | 456 +++++++++ crates/mcp/src/tools/handlers.rs | 474 +++++++++ 4 files changed, 1452 insertions(+), 1419 deletions(-) create mode 100644 crates/mcp/src/tools/definitions.rs create mode 100644 crates/mcp/src/tools/dispatch.rs create mode 100644 crates/mcp/src/tools/handlers.rs diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7355aa6ed..b872f6451 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -6,6 +6,16 @@ use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_ use crate::backend::Backend; +mod definitions; +mod dispatch; +mod handlers; + +pub use definitions::tool_definitions; +#[cfg(test)] +pub(crate) use dispatch::MAX_COMMAND_BATCH_STEPS; +pub use dispatch::call_tool; +pub(crate) use dispatch::check_tool_arguments; + /// The result of `tools/call`: MCP content blocks plus the `isError` flag. #[derive(Clone, Debug, PartialEq)] pub struct ToolResult { @@ -27,1422 +37,3 @@ impl ToolResult { json!({"content": self.content, "isError": self.is_error}) } } - -// ---------- schemas ---------- - -fn num(desc: &str) -> Value { - json!({"type": "number", "description": desc}) -} -fn string(desc: &str) -> Value { - json!({"type": "string", "description": desc}) -} -/// The arguments `save_file` passes on to `document.save` beside `path`. -const SAVE_FILE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "options"]; -/// The arguments `doc_inspect` / `inspect_document` pass on to `document.inspect`. -const INSPECT_KEYS: [&str; 6] = ["depth", "childLimit", "ids", "kind", "limit", "offset"]; - -/// Their schema. -fn inspect_props() -> Value { - json!({ - "depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"}, - "childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"}, - "ids": {"type": "array", "maxItems": 10000, "items": {"type": "integer", "minimum": 0}, "description": "List just these nodes (any level) as `nodes` instead of the whole layer tree; an unknown id is an error"}, - "kind": {"type": "string", "description": "List every node of this panel label (Layer, Group, Path, Type, Image, ...; ignores case) as `nodes`, each with its ancestor `path`; searches the subtrees of `ids` when given"}, - "limit": {"type": "integer", "minimum": 0, "description": "At most this many layers or nodes (the reply adds `total`)"}, - "offset": {"type": "integer", "minimum": 0, "description": "Layers or nodes skipped first (default 0)"} - }) -} -fn paint_schema(what: &str) -> Value { - json!({ - "description": format!("{what}: \"#rrggbb\", \"none\", [r,g,b] (0..1), {{\"c\",\"m\",\"y\",\"k\"}}, {{\"gray\"}}, or a full paint.setFill params object ({{\"gradient\":…}} / {{\"swatch\":name}})"), - "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}}, {"type": "object"}] - }) -} -/// The options `draw_path` and `draw_shape` apply to the object they draw (#2112). -fn with_draw_options(mut props: Value) -> Value { - let more = json!({ - "dash": {"description": "Dash and gap lengths in pt ([d, g, …]), or null for a solid stroke (stroke.set `dash`)", "anyOf": [{"type": "array", "items": {"type": "number"}}, {"type": "null"}]}, - "cap": {"type": "string", "enum": ["butt", "round", "square", "projecting"], "description": "Stroke cap"}, - "join": {"type": "string", "enum": ["miter", "round", "bevel"], "description": "Stroke corner join"}, - "opacity": {"type": "number", "minimum": 0, "maximum": 100, "description": "Opacity in % (object.setProps `opacity`); doc_inspect reads it back as `opacityPct` (its `opacity` is a 0–1 fraction)"}, - "name": string("Name in the Layers panel"), - "effects": { - "type": "array", - "description": "Live effects applied in order, as apply_effect: [{effect: id (effect.list), params?: {…}}]", - "items": obj(json!({"effect": string("Effect id, e.g. \"blur.gaussian\""), "params": {"type": "object"}}), &["effect"]), - }, - }); - if let (Some(p), Value::Object(more)) = (props.as_object_mut(), more) { - p.extend(more); - } - props -} - -fn mods_schema() -> Value { - json!({ - "type": "object", - "description": "Modifier keys held (cmd = Command on macOS / Ctrl elsewhere; alt = Option)", - "properties": {"shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}, "ctrl": {"type": "boolean"}}, - "additionalProperties": false - }) -} -fn obj(props: Value, required: &[&str]) -> Value { - let mut o = json!({"type": "object", "properties": props, "additionalProperties": false}); - if !required.is_empty() { - o["required"] = json!(required); - } - o -} - -/// The extension of each format Save writes, in [`SAVE_FORMATS`] order. -fn save_extensions() -> Vec<&'static str> { - SAVE_FORMATS.iter().filter_map(|id| format(id)?.extensions.first().copied()).collect() -} - -fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value { - let writes_file = matches!(name, "screenshot" | "save_file" | "export"); - let read_only = read_only && !writes_file; - json!({ - "name": name, - "title": title, - "description": desc, - "inputSchema": schema, - "annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only, "idempotentHint": read_only, "openWorldHint": false}, - }) -} - -/// All tools, in `tools/list` order. -pub fn tool_definitions() -> Vec { - let empty = || obj(json!({}), &[]); - vec![ - tool( - "command_list", - "List commands", - "Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", - obj( - json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), - &[], - ), - true, - ), - tool( - "command_run", - "Run a command", - "Run an engine command by id with its JSON params. Command parameters follow the registry's descriptions. run_command runs it the same way; it takes the id as command instead of id.", - obj(json!({"id": string("Command id"), "params": {"type":"object"}}), &["id"]), - false, - ), - tool( - "command_batch", - "Run several commands", - "Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit; `atomic` says which documents). A step's id is a command id, or draw_path / draw_shape with that tool's arguments as params (its result is the tool's: the new object's `id`). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed, results and created (the ids draw_path / draw_shape steps made, in order). Stops on the first error by default. At most 256 steps; kept step results total at most 8 MiB, past which the batch stops with the reason.", - obj( - json!({"steps": {"type":"array", "maxItems": 256, "items":obj(json!({"id":string("Command id, or draw_path / draw_shape"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step in the active document and in each document a step opens or creates; in a document open before the batch that a step switches to, each edit stays its own undo step"}, "label":string("The undo step's name with atomic")}), - &["steps"], - ), - false, - ), - tool( - "doc_inspect", - "Inspect document", - "Summary of the active document, including artboards, layer tree, selection and history. A node below full opacity reports `opacity` as a 0–1 fraction and `opacityPct` in the 0–100 % scale the drawing tools and object.setProps take: pass `opacityPct` back, never `opacity`. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page. inspect_document returns the same summary.", - obj(inspect_props(), &[]), - true, - ), - tool( - "render_preview", - "Render a preview", - "Render an artboard (default the first) as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.", - obj( - json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"},"traceViews":{"type":"boolean","description":TRACE_VIEWS}}), - &[], - ), - true, - ), - tool( - "ui_inspect", - "Inspect the interface", - "Inspect the connected desktop interface; returns a tool error in headless mode. The same call as inspect_ui.", - empty(), - true, - ), - tool( - "ui_screenshot", - "Capture the interface", - "Capture the connected desktop window as inline PNG; requires a running desktop app. The same call as screenshot with window:true. max_side scales the returned image down to at most that many pixels on its longer side (1..4096; default: the full capture).", - obj(json!({"max_side": {"type":"integer","minimum":1,"maximum":4096,"description":MAX_SIDE_WINDOW}}), &[]), - true, - ), - tool( - "list_commands", - "List commands", - "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", - obj( - json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), - &[], - ), - true, - ), - tool( - "run_command", - "Run command", - "Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands). command_run runs it the same way and takes the id as id.", - obj( - json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}), - &["command"], - ), - false, - ), - tool( - "inspect_document", - "Inspect document", - "Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke; `opacity` as a 0–1 fraction and `opacityPct` in the 0–100 % scale the drawing tools take), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node. doc_inspect returns the same summary.", - obj(inspect_props(), &[]), - true, - ), - tool( - "inspect_ui", - "Inspect UI", - "UI state of the running desktop app: tool and options, panels, dialogs, view (zoom/center), window, documents, perf. Desktop app only. The same call as ui_inspect.", - empty(), - true, - ), - tool( - "select_tool", - "Select tool", - "Switch the active tool (as clicking it in the Tools panel). Ids: selection, directSelection, groupSelection, pen, rectangle, roundedRectangle, ellipse, polygon, star, lineSegment, …", - obj(json!({"tool": string("Tool id")}), &["tool"]), - false, - ), - tool( - "pointer_gesture", - "Pointer gesture", - "Drive the active tool with mouse events in document coordinates, exactly like the mouse would (e.g. a rectangle drag: down at (100,100), drag to (200,150), up at (200,150)). Optionally selects `tool` first.", - obj( - json!({ - "tool": string("Optional tool to select before the gesture"), - "events": { - "type": "array", - "minItems": 1, - "items": obj(json!({ - "kind": {"type": "string", "enum": ["down", "drag", "up", "move", "doubleclick"]}, - "x": num("Document x (pt)"), - "y": num("Document y (pt)"), - "mods": mods_schema(), - "pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on"), - "holdMs": num("Milliseconds the pointer then holds still, button down (0..60000): Twirl, Pucker and Bloat keep applying"), - }), &["kind", "x", "y"]), - }, - "mods": mods_schema(), - }), - &["events"], - ), - false, - ), - tool( - "draw_path", - "Draw path", - "Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint, stroke dash/cap/join, opacity, name and live effects. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke). For a filled path without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.", - obj( - with_draw_options(json!({ - "points": {"type": "array", "description": "Anchors: [x,y] pairs or {x, y, in?, out?, smooth?} objects", "items": {"anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 2}, {"type": "object"}]}}, - "d": string("SVG path data (alternative to points), e.g. \"M0 0 L100 0 L50 80 Z\""), - "closed": {"type": "boolean", "description": "Close the path (points only)"}, - "fill": paint_schema("Fill"), - "stroke": paint_schema("Stroke"), - "strokeWidth": num("Stroke weight in pt"), - })), - &[], - ), - false, - ), - tool( - "draw_shape", - "Draw shape", - "Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke); lines start with no fill and a visible stroke. For a filled shape without an outline, pass an explicit fill and stroke:\"none\". Stroke dash/cap/join, opacity, name and live effects apply to the new shape as in draw_path. Returns the new object id.", - obj( - with_draw_options(json!({ - "shape": {"type": "string", "enum": ["rectangle", "ellipse", "polygon", "star", "line"]}, - "x": num("Left (rectangle/ellipse)"), - "y": num("Top (rectangle/ellipse)"), - "width": num("Width (rectangle/ellipse)"), - "height": num("Height (rectangle/ellipse)"), - "radius": num("Corner radius (rectangle) or radius (polygon)"), - "cx": num("Centre x (polygon/star)"), - "cy": num("Centre y (polygon/star)"), - "sides": {"type": "integer", "minimum": 3, "description": "Polygon sides (default 6)"}, - "radius1": num("Star outer radius"), - "radius2": num("Star inner radius"), - "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, - "rotation": num("Rotation in degrees (polygon/star)"), - "x1": num("Line start x"), - "y1": num("Line start y"), - "x2": num("Line end x"), - "y2": num("Line end y"), - "fill": paint_schema("Fill"), - "stroke": paint_schema("Stroke"), - "strokeWidth": num("Stroke weight in pt"), - })), - &["shape"], - ), - false, - ), - tool( - "set_paint", - "Set paint", - "Set fill, stroke and/or stroke weight on the selection (or `ids`) and as the default for new art. Returns {applied, objects}: `objects` counts the objects painted, as paint.setFill, paint.setStroke and stroke.set report them (the largest of their counts); when it is 0, a `note` says that only the default for new art changed.", - obj( - json!({ - "fill": paint_schema("Fill"), - "stroke": paint_schema("Stroke"), - "strokeWidth": num("Stroke weight in pt"), - "ids": {"type": "array", "items": {"type": "integer"}, "description": "Object ids (default: the selection)"}, - }), - &[], - ), - false, - ), - tool( - "press_key", - "Press key", - "Send a key press with modifiers, e.g. {key:\"Z\", mods:{cmd:true}} (undo), {key:\"Escape\"}, {key:\"Enter\"}. In the desktop app it goes through the real keyboard path; headless, it runs the command or tool bound to that shortcut.", - obj( - json!({"key": string("Key name: A–Z, 0–9, Enter, Escape, Delete, Backspace, Tab, Space, Left/Right/Up/Down, [ ]…"), "mods": mods_schema()}), - &["key"], - ), - false, - ), - tool( - "type_text", - "Type text", - "Type text into the focused widget (text tool, dialog field). Desktop app only.", - obj(json!({"text": string("Text to type")}), &["text"]), - false, - ), - tool( - "answer_dialog", - "Answer dialog", - "Set fields of the desktop app's open dialog in order, then confirm (OK) or cancel it, as the control protocol's ui.dialog.set, ui.dialog.confirm and ui.dialog.cancel do. The app stores all the fields or none. When the app refuses the ui.dialog.set, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. inspect_ui shows the open dialog and its fields (ui.dialog). After invoke_menu app.quit or file.close replies {\"pending\":\"saveChanges\"}: {action:\"confirm\"} saves, {set:[{field:\"discard\",value:true}],action:\"confirm\"} closes without saving, {action:\"cancel\"} cancels. Returns {dialog, result}: the dialog after the fields were set, and the reply to confirm or cancel. Desktop app only.", - obj( - json!({ - "set": { - "type": "array", - "maxItems": MAX_DIALOG_FIELDS, - "items": obj(json!({"field": string("Dialog field"), "value": {"description": "The field's new value (any JSON; default null)"}}), &["field"]), - "description": "Fields to set, in order (one ui.dialog.set that sets all of them or none)", - }, - "action": {"type": "string", "enum": ["confirm", "cancel"], "description": "Then confirm (OK) or cancel the dialog"}, - }), - &[], - ), - false, - ), - tool( - "invoke_menu", - "Invoke menu", - "Invoke a menu item by its command id (same ids as list_commands, including UI-only commands like view.zoomIn or window.* in the desktop app).", - obj(json!({"command": string("Menu command id"), "params": {"type": "object"}}), &["command"]), - false, - ), - tool( - "open_panel", - "Open panel", - "Open a panel in the desktop app's dock by id (layers, swatches, stroke, align, pathfinder, transform, …; case-insensitive, display labels like \"Layers\" work too). Desktop app only.", - obj(json!({"panel": string("Panel id or display label")}), &["panel"]), - false, - ), - tool( - "screenshot", - "Screenshot", - "Render the active artboard to PNG and return it as an image (optionally also saving it to `path`). With window:true (desktop app only) captures the whole app window instead, as ui_screenshot does; max_side then scales the returned image down (a file written to `path` keeps the full capture).", - obj( - json!({ - "path": string("Also write the PNG here"), - "scale": num("Pixels per point (default 1)"), - "artboard": {"type": "integer", "minimum": 0, "description": "Artboard index (default 0)"}, - "window": {"type": "boolean", "description": "Capture the app window (desktop app only)"}, - "max_side": {"type": "integer", "minimum": 1, "maximum": 4096, "description": MAX_SIDE_WINDOW}, - "traceViews": {"type": "boolean", "description": TRACE_VIEWS}, - }), - &[], - ), - true, - ), - tool( - "open_file", - "Open file", - &format!( - "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read).", - OPEN_EXTS.join(", .") - ), - obj(json!({"path": string("File path")}), &["path"]), - false, - ), - tool( - "save_file", - "Save file", - &format!( - "Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). A native file is gzipped when `compress` is true (default: the useCompression preference, Preferences > File Handling > Use Compression); `version` writes it for an older VectorCraft and `preview` embeds a PNG of the first artboard. `options` carries the format's save options (see run_command document.formats); giving `compress`, `version` or `preview` both at the top level and in `options` with different values is an error. The reply's `format` says what was written and `warnings` what that format loses and each option given that the format doesn't read (such as `compress` for .svg). In the running app the call returns once the file is written, or with the error, even with Background Save on. When the save asks first (before replacing a file that opened with losses, or with a file dialog for an untitled document without `path`), the reply is `pending` or \"cancelled\" as before.", - save_extensions().join(", .") - ), - obj( - json!({ - "path": string("Destination; its extension picks the format (default: the document's own file)"), - "compress": {"type": "boolean", "description": "Native files: gzip the file (default: the useCompression preference)"}, - "version": {"type": "integer", "minimum": 1, "maximum": vectorcraft_format::VERSION, "description": "Native files: the format version to write (default: the current one; 1 and 2 are never compressed)"}, - "preview": {"type": "boolean", "description": "Native files: embed a PNG of the first artboard, at most 256 px (default false)"}, - "options": {"type": "object", "description": "The format's save options, passed through (see run_command document.formats); default: as last saved"}, - }), - &[], - ), - false, - ), - tool( - "export", - "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", - obj( - json!({ - "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, - "path": string("Destination file (omit to get dataBase64)"), - "scale": num("Raster pixels per point (default 1)"), - "artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF and .ai default: all)"}, - "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"), - "selection": {"type": "boolean", "description": "Export only the selection, cropped to it"}, - "outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"}, - "options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"}, - }), - &[], - ), - false, - ), - tool( - "add_text", - "Add text", - "Create point type at (x, y) (the first baseline), area type when width/height are given (`fit` autoHeight grows the frame to the text, shrinkText scales the text down to fit), or type in/on an existing path (`path` id + `mode` area|onPath; `pathEffect` rainbow|skew|3dRibbon|stairStep|gravity). Returns the text object id, with `overflow: true` when the text doesn't fit its frame or path (the lines past it aren't drawn: enlarge the frame, shrink the text or give `fit`).", - obj( - json!({ - "text": {"type": "string"}, - "x": num("Baseline origin x (area type: left; point type: where the line starts, or its centre or end with justify center/right)"), - "y": num("First baseline y (area type: top)"), - "width": num("Area type frame width"), - "height": num("Area type frame height"), - "vertical": {"type": "boolean", "description": "Vertical writing for point, area or path type (default false, horizontal), as text.create / text.createInPath"}, - "fit": {"type": "string", "enum": ["none", "autoHeight", "shrinkText"], "description": "Area type: keep the frame (none), fit its height to the text (autoHeight) or scale overflowing text down (shrinkText)"}, - "fitMinPercent": num("shrinkText: the smallest scale in percent, 10 to 100 (default 50)"), - "path": {"type": "integer", "description": "Existing path id to flow the text in (area) or along (onPath)"}, - "mode": {"type": "string", "enum": ["area", "onPath"]}, - "pathEffect": {"type": "string", "enum": ["rainbow", "skew", "3dRibbon", "stairStep", "gravity"]}, - "size": num("Font size in pt"), - "font": {"type": "string", "description": "Font family"}, - "color": paint_schema("Text colour (#rrggbb)"), - "justify": {"type": "string", "enum": ["auto", "left", "center", "right", "justifyAll"], "description": "Paragraph alignment (default auto: the start of the text's direction). Point type lines up on x: center centres each line on it, right ends it there"}, - }), - &["text"], - ), - false, - ), - tool( - "apply_effect", - "Apply live effect", - "Append a live effect (Effect menu) to the selection or `ids`. Omit `effect` to get the catalogue (ids, parameters, defaults). Examples: stylize.dropShadow {x, y, blur, opacity}, distort.roughen, warp.arc {bend}, path.offsetPath {offset}, pathfinder.add (on groups), blur.gaussian {radius}.", - obj( - json!({ - "effect": {"type": "string", "description": "Effect id (see the catalogue)"}, - "params": {"type": "object", "description": "Effect parameters; missing keys take the dialog defaults"}, - "ids": {"type": "array", "items": {"type": "integer"}}, - }), - &[], - ), - false, - ), - tool( - "pathfinder", - "Pathfinder", - "Combine the selected (or `ids`) objects destructively, back to front: unite, minusFront, intersect, exclude, divide, trim, merge, crop, outline, minusBack. For a non-destructive version apply the pathfinder.* effect to a group.", - obj( - json!({ - "operation": {"type": "string", "enum": ["unite", "minusFront", "intersect", "exclude", "divide", "trim", "merge", "crop", "outline", "minusBack"]}, - "ids": {"type": "array", "items": {"type": "integer"}}, - }), - &["operation"], - ), - false, - ), - tool( - "transform", - "Transform", - "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", - obj( - json!({ - "ids": {"type": "array", "items": {"type": "integer"}}, - "dx": num("Move right"), - "dy": num("Move down"), - "rotate": num("Rotation in degrees"), - "scale": num("Uniform scale in %"), - "scaleX": num("Horizontal scale in %"), - "scaleY": num("Vertical scale in %"), - "reflect": {"type": "string", "enum": ["vertical", "horizontal"]}, - "shear": num("Shear angle in degrees (horizontal axis)"), - "origin": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, - "copy": {"type": "boolean"}, - }), - &[], - ), - false, - ), - tool( - "create_graph", - "Create graph", - "Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. An empty CSV cell or a null in `rows` is a blank value (no column; lines break around it); a number in straight quotes is a label. Edit later with run_command graph.setData / graph.setType.", - obj( - json!({ - "type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]}, - "x": num("Plot left"), - "y": num("Plot top"), - "width": num("Plot width"), - "height": num("Plot height"), - "csv": {"type": "string"}, - "series": {"type": "array", "items": {"type": "string"}}, - "categories": {"type": "array", "items": {"type": "string"}}, - "rows": {"type": "array", "items": {"type": "array", "items": {"type": ["number", "null"]}}}, - }), - &["x", "y", "width", "height"], - ), - false, - ), - tool( - "text_wrap", - "Text wrap", - "Make the selected (or `ids`) objects wrap objects: area type below them in the same layer flows around them (offset in pt, invert = flow inside). `release: true` removes the wrap.", - obj( - json!({ - "ids": {"type": "array", "items": {"type": "integer"}}, - "offset": num("Gap between text and object (default 6 pt)"), - "invert": {"type": "boolean"}, - "release": {"type": "boolean"}, - }), - &[], - ), - false, - ), - tool("undo", "Undo", "Undo the last change (Edit → Undo). redo reapplies it.", empty(), false), - tool("redo", "Redo", "Redo the last undone change (Edit → Redo): the change the last undo took back.", empty(), false), - ] -} - -// ---------- implementation ---------- - -type Args = Map; - -fn req_str<'a>(a: &'a Args, k: &str) -> Result<&'a str, String> { - a.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| format!("missing string argument `{k}`")) -} - -/// A file tool's path made absolute against this process's working directory when it goes to the app -/// (`--connect`). Headless, and for an empty path, it is returned as given. -fn absolute_path(b: &dyn Backend, p: &str) -> Result { - if p.is_empty() || !b.has_ui() { - return Ok(p.to_owned()); - } - let abs = std::path::absolute(p).map_err(|e| format!("{p}: {e}"))?; - abs.into_os_string().into_string().map_err(|abs| format!("{p}: the absolute path {} is not UTF-8", abs.to_string_lossy())) -} - -fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result { - b.call("engine.execute", json!({"command": cmd, "params": params})) -} - -fn need_ui(b: &dyn Backend, tool: &str) -> Result<(), String> { - if b.has_ui() { - Ok(()) - } else { - Err(format!( - "`{tool}` needs the desktop app: run `vectorcraft --control 7979` and start the server with `vectorcraft-cli mcp --connect 127.0.0.1:7979`" - )) - } -} - -/// Paint argument → `paint.setFill` / `paint.setStroke` params. -fn paint_params(v: &Value) -> Result { - Ok(match v { - Value::Null => json!({"none": true}), - Value::String(s) if s.eq_ignore_ascii_case("none") => json!({"none": true}), - Value::String(_) | Value::Array(_) => json!({"color": v}), - Value::Object(o) if ["color", "none", "swatch", "gradient"].iter().any(|k| o.contains_key(*k)) => v.clone(), - Value::Object(_) => json!({"color": v}), - other => return Err(format!("unsupported paint value {other}")), - }) -} - -/// A command a drawing tool runs and its params. -type Step<'a> = (&'a str, Value); - -/// The commands that apply `a`'s `fill`, `stroke`, `strokeWidth`, `dash`, `cap` and `join`, without -/// targets, and what each applies. -fn paint_steps(a: &Args) -> Result<(Vec>, Map), String> { - let (mut steps, mut applied) = (vec![], Map::new()); - if let Some(f) = a.get("fill") { - steps.push(("paint.setFill", paint_params(f)?)); - applied.insert("fill".into(), f.clone()); - } - if let Some(s) = a.get("stroke") { - steps.push(("paint.setStroke", paint_params(s)?)); - applied.insert("stroke".into(), s.clone()); - } - // Weight, dash, cap and join go to one stroke.set. - let mut stroke = Map::new(); - if let Some(w) = a.get("strokeWidth") { - let w = w.as_f64().ok_or("`strokeWidth` must be a number")?; - stroke.insert("weight".into(), json!(w)); - applied.insert("strokeWidth".into(), json!(w)); - } - for k in ["dash", "cap", "join"] { - if let Some(v) = a.get(k) { - stroke.insert(k.into(), v.clone()); - applied.insert(k.into(), v.clone()); - } - } - if !stroke.is_empty() { - steps.push(("stroke.set", Value::Object(stroke))); - } - Ok((steps, applied)) -} - -/// Sets the paint `a` gives on `ids` (default: the selection) and as the default for new art. -/// Returns the paint applied and the most objects one of its edits painted (the `objects` of the -/// `paint.setFill`, `paint.setStroke` and `stroke.set` replies). -fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option) -> Result<(Value, u64), String> { - let (steps, applied) = paint_steps(a)?; - let mut objects = 0; - for (cmd, mut p) in steps { - if let Some(ids) = &ids { - p["ids"] = ids.clone(); - } - let r = exec(b, cmd, p)?; - objects = objects.max(r.get("objects").and_then(Value::as_u64).unwrap_or(0)); - } - Ok((Value::Object(applied), objects)) -} - -/// Checks `draw_path` / `draw_shape`'s `dash`, `cap`, `join`, `opacity`, `name` and `effects` -/// before anything is drawn, so a bad one leaves no half-styled object behind (#2112). -fn check_draw_options(b: &mut dyn Backend, a: &Args) -> Result<(), String> { - match a.get("dash") { - None | Some(Value::Null) => {} - Some(Value::Array(d)) if d.iter().all(|v| v.as_f64().is_some_and(f64::is_finite)) => {} - Some(_) => return Err("`dash` must be a list of lengths (numbers) or null".into()), - } - let one_of = |k: &str, names: &[&str]| match a.get(k) { - None => Ok(()), - Some(v) if v.as_str().is_some_and(|s| names.contains(&s)) => Ok(()), - Some(_) => Err(format!("`{k}` must be one of {}", names.join(", "))), - }; - one_of("cap", &["butt", "round", "square", "projecting"])?; - one_of("join", &["miter", "round", "bevel"])?; - if let Some(o) = a.get("opacity") - && !o.as_f64().is_some_and(|o| (0.0..=100.0).contains(&o)) - { - return Err("`opacity` must be a number from 0 to 100 (%)".into()); - } - if a.get("name").is_some_and(|n| !n.is_string()) { - return Err("`name` must be a string".into()); - } - let Some(effects) = a.get("effects") else { return Ok(()) }; - let effects = effects.as_array().ok_or("`effects` must be a list of {effect, params?}")?; - if effects.is_empty() { - return Ok(()); - } - let catalog = exec(b, "effect.list", json!({}))?; - let known: Vec<&str> = catalog.get("catalog").and_then(Value::as_array).into_iter().flatten().filter_map(|e| e.get("id")?.as_str()).collect(); - for (i, e) in effects.iter().enumerate() { - let e = e.as_object().ok_or_else(|| format!("effect {i} must be {{effect, params?}}"))?; - if let Some(k) = e.keys().find(|k| *k != "effect" && *k != "params") { - return Err(format!("effect {i}: unknown key `{k}` (expected effect, params)")); - } - let id = e.get("effect").and_then(Value::as_str).ok_or_else(|| format!("effect {i}: missing string `effect`"))?; - if !known.contains(&id) { - return Err(format!("effect {i}: unknown effect `{id}` (see apply_effect without arguments)")); - } - if e.get("params").is_some_and(|p| !p.is_object()) { - return Err(format!("effect {i}: `params` must be an object")); - } - } - Ok(()) -} - -/// Draws an object with `create` (a creating command and its params) and styles it with `a`'s -/// paint and draw options ([`check_draw_options`], checked first) as ONE `command.batch`, named -/// `label` (the undo step the creating command makes on its own): any step the engine refuses — a -/// colour it can't read, a swatch the document lacks — rolls the whole call back, so it leaves no -/// object and no undo step behind (#3545). The styling steps act on the new object, which the -/// creating command selects, and on its whole appearance (`item: null`) → `made` with the new -/// object's `id`, the `paint` applied and the other options applied. -fn draw(b: &mut dyn Backend, a: &Args, label: &str, create: Step<'_>, mut made: Value) -> Result { - check_draw_options(b, a)?; - let (paint, applied) = paint_steps(a)?; - let mut steps = vec![create]; - steps.extend(paint); - let mut props = json!({}); - for k in ["name", "opacity"] { - if let Some(v) = a.get(k) { - props[k] = v.clone(); - made[k] = v.clone(); - } - } - if a.contains_key("name") || a.contains_key("opacity") { - steps.push(("object.setProps", props)); - } - // The fill, stroke and effects go to the new object's whole appearance (item: null), as the - // Fill and Stroke proxies and the Effect menu apply them to a selected object. - let effects = a.get("effects").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); - for e in effects { - steps.push(("effect.apply", json!({"effect": e.get("effect"), "params": e.get("params").cloned().unwrap_or(json!({}))}))); - } - let commands: Vec = steps - .into_iter() - .map(|(cmd, mut p)| { - if matches!(cmd, "paint.setFill" | "paint.setStroke" | "stroke.set" | "effect.apply") { - p["item"] = Value::Null; - } - json!({"command": cmd, "params": p}) - }) - .collect(); - // The engine names the failed step of its batch; the caller sent no batch, so the reply gives - // the step's own error. - let r = exec(b, "command.batch", json!({"label": label, "commands": commands})).map_err(|e| match e.split_once(" failed: ") { - Some((step, error)) if step.starts_with("batch step ") => error.to_string(), - _ => e, - })?; - let results = r.get("results").and_then(Value::as_array).ok_or("command.batch returned no results")?; - made["id"] = results.first().and_then(|r| r.get("id")).cloned().unwrap_or(Value::Null); - made["paint"] = Value::Object(applied); - if !effects.is_empty() { - made["effects"] = Value::Array(results.iter().skip(results.len().saturating_sub(effects.len())).cloned().collect()); - } - Ok(made) -} - -fn draw_path(b: &mut dyn Backend, a: &Args) -> Result { - let mut p = json!({}); - if let Some(d) = a.get("d").and_then(Value::as_str) { - p["d"] = json!(d); - } else { - let pts = a.get("points").and_then(Value::as_array).ok_or("give `points` or `d`")?; - let mut anchors = vec![]; - for (i, pt) in pts.iter().enumerate() { - let anchor = match pt { - Value::Array(xy) if xy.len() >= 2 && xy[0].is_number() && xy[1].is_number() => json!({"x": xy[0], "y": xy[1]}), - Value::Object(o) if o.get("x").is_some_and(Value::is_number) && o.get("y").is_some_and(Value::is_number) => pt.clone(), - _ => return Err(format!("point {i} must be [x,y] or {{x,y,…}}")), - }; - anchors.push(anchor); - } - if anchors.is_empty() { - return Err("`points` is empty".into()); - } - p["anchors"] = Value::Array(anchors); - p["closed"] = json!(a.get("closed").and_then(Value::as_bool).unwrap_or(false)); - } - draw(b, a, "Pen", ("path.create", p), json!({})) -} - -fn draw_shape(b: &mut dyn Backend, a: &Args) -> Result { - let shape = req_str(a, "shape")?; - // Each command and the name of the undo step it makes. - let (cmd, label) = match shape { - "rectangle" | "rect" if a.get("radius").and_then(Value::as_f64).is_some_and(|r| r > 0.0) => ("shape.rectangle", "Rounded Rectangle"), - "rectangle" | "rect" => ("shape.rectangle", "Rectangle"), - "ellipse" | "circle" => ("shape.ellipse", "Ellipse"), - "polygon" => ("shape.polygon", "Polygon"), - "star" => ("shape.star", "Star"), - "line" => ("shape.line", "Line"), - other => return Err(format!("unknown shape `{other}` (rectangle, ellipse, polygon, star, line)")), - }; - let mut p = a.clone(); - for k in ["shape", "fill", "stroke", "strokeWidth", "dash", "cap", "join", "opacity", "name", "effects"] { - p.remove(k); - } - draw(b, a, label, (cmd, Value::Object(p)), json!({"shape": shape})) -} - -/// Select `ids` when given (tools that act on the selection). -fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> { - if let Some(ids) = a.get("ids") { - exec(b, "select.set", json!({"ids": ids}))?; - } - Ok(()) -} - -fn add_text(b: &mut dyn Backend, a: &Args) -> Result { - let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?; - let mut style = json!({}); - for k in ["size", "font", "color", "justify", "vertical"] { - if let Some(v) = a.get(k) { - style[k] = v.clone(); - } - } - let r = if let Some(path) = a.get("path") { - let mut p = json!({"path": path, "mode": a.get("mode").cloned().unwrap_or(json!("onPath")), "text": text}); - for (k, v) in style.as_object().into_iter().flatten() { - p[k.as_str()] = v.clone(); - } - exec(b, "text.createInPath", p)? - } else { - let x = a.get("x").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?; - let y = a.get("y").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?; - let mut p = json!({"x": x, "y": y, "text": text}); - for (k, v) in style.as_object().into_iter().flatten() { - p[k.as_str()] = v.clone(); - } - if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) { - p["area"] = json!({"width": w, "height": h}); - for k in ["fit", "fitMinPercent"] { - if let Some(v) = a.get(k) { - p["area"][k] = v.clone(); - } - } - } - exec(b, "text.create", p)? - }; - if let Some(e) = a.get("pathEffect").and_then(Value::as_str) { - exec(b, "type.pathOptions", json!({"effect": e}))?; - } - let mut made = json!({"id": r.get("id")}); - // A path effect is applied after the text is made: ask again how it fits then. - let overflow = match r.get("id").filter(|_| a.get("pathEffect").is_some()) { - Some(id) => exec(b, "document.node", json!({"id": id, "summary": true}))?.get("overflow").and_then(Value::as_bool) == Some(true), - None => r.get("overflow").is_some(), - }; - if overflow { - made["overflow"] = json!(true); - } - Ok(made) -} - -fn transform(b: &mut dyn Backend, a: &Args) -> Result { - select_ids(b, a)?; - // With `copy`, the first operation duplicates and the rest transform the copy (the new selection). - let copy = std::cell::Cell::new(a.get("copy").and_then(Value::as_bool).unwrap_or(false)); - let with = |mut p: Value| { - if let Some(o) = a.get("origin") { - p["origin"] = o.clone(); - } - p["copy"] = json!(copy.replace(false)); - p - }; - let mut done = vec![]; - if a.contains_key("dx") || a.contains_key("dy") { - let num = |k: &str| a.get(k).and_then(Value::as_f64).unwrap_or(0.0); - exec(b, "object.move", json!({"dx": num("dx"), "dy": num("dy"), "copy": copy.replace(false)}))?; - done.push("move"); - } - if let Some(r) = a.get("rotate").and_then(Value::as_f64) { - exec(b, "object.rotate", with(json!({"angle": r})))?; - done.push("rotate"); - } - let s = a.get("scale").and_then(Value::as_f64); - let (sx, sy) = (a.get("scaleX").and_then(Value::as_f64).or(s), a.get("scaleY").and_then(Value::as_f64).or(s)); - if sx.is_some() || sy.is_some() { - exec(b, "object.scale", with(json!({"sx": sx.unwrap_or(100.0), "sy": sy.unwrap_or(100.0)})))?; - done.push("scale"); - } - if let Some(axis) = a.get("reflect").and_then(Value::as_str) { - exec(b, "object.reflect", with(json!({"axis": axis})))?; - done.push("reflect"); - } - if let Some(sh) = a.get("shear").and_then(Value::as_f64) { - exec(b, "object.shear", with(json!({"angle": sh})))?; - done.push("shear"); - } - if done.is_empty() { - return Err("give at least one of dx/dy, rotate, scale/scaleX/scaleY, reflect, shear".into()); - } - Ok(json!({"applied": done})) -} - -/// Most fields one `answer_dialog` call may set. -const MAX_DIALOG_FIELDS: usize = 256; - -/// Sets the open dialog's fields in order with one `ui.dialog.set {fields}`, which stores all of -/// them or none, then confirms or cancels it. Every argument is checked before anything is sent to -/// the app. -fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result { - let set = match a.get("set") { - None => &[][..], - Some(Value::Array(s)) => s.as_slice(), - Some(_) => return Err("`set` must be a list of {field, value?}".into()), - }; - if set.len() > MAX_DIALOG_FIELDS { - return Err(format!("`set` holds {} fields; at most {MAX_DIALOG_FIELDS}", set.len())); - } - let mut fields = vec![]; - for (i, item) in set.iter().enumerate() { - let item = item.as_object().ok_or_else(|| format!("set {i} must be {{field, value?}}"))?; - if let Some(k) = item.keys().find(|k| *k != "field" && *k != "value") { - return Err(format!("set {i}: unknown key `{k}` (expected field, value)")); - } - let field = item.get("field").and_then(Value::as_str).ok_or_else(|| format!("set {i}: missing string `field`"))?; - fields.push(json!({"field": field, "value": item.get("value").cloned().unwrap_or(Value::Null)})); - } - let then = match a.get("action") { - None => None, - Some(v) if v == "confirm" => Some("ui.dialog.confirm"), - Some(v) if v == "cancel" => Some("ui.dialog.cancel"), - Some(_) => return Err("`action` must be confirm or cancel".into()), - }; - if fields.is_empty() && then.is_none() { - return Err("give `set` and/or `action`".into()); - } - let dialog = if fields.is_empty() { Value::Null } else { b.call("ui.dialog.set", json!({"fields": fields}))? }; - let result = match then { - Some(method) => b.call(method, json!({}))?, - None => Value::Null, - }; - Ok(json!({"dialog": dialog, "result": result})) -} - -fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { - let path = a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?; - let path = path.as_deref(); - let window = a.get("window").and_then(Value::as_bool) == Some(true); - let max = max_side(a)?; - if max.is_some() && !window { - return Err("`max_side` applies to a window capture (`window: true`); size an artboard render with `scale`".into()); - } - let trace_views = trace_views(a)?; - // The backend writes the file, where its automation roots allow, and sends the image back too - // (`data`): this process writes nothing. - let mut p = json!({"data": true}); - if let Some(path) = path { - p["path"] = json!(path); - } - let r = if window { - need_ui(b, "screenshot {window:true}")?; - b.call("ui.screenshot", p)? - } else { - for k in ["scale", "artboard"] { - if let Some(v) = a.get(k) { - p[k] = v.clone(); - } - } - if trace_views { - p["traceViews"] = json!(true); - } - b.call("ui.render", p)? - }; - let png = match r.get("pngBase64").and_then(Value::as_str) { - Some(b64) => vectorcraft_format::base64_decode(b64).ok_or("the backend returned bad base64")?, - // An app from before `data` wrote the file without sending the image: read it back - // (loopback: the same machine). - None => match path { - Some(path) => std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?, - None if window => return old_window_capture(b, max), - None => return Err("the renderer returned no image".into()), - }, - }; - if window { - let mut info = json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path}); - let png = fit_capture(png, max, &mut info)?; - Ok(image_result(&png, info)) - } else { - Ok(image_result(&png, json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) - } -} - -/// The window of an app from before `ui.screenshot {data}`, captured through a temporary file. -fn old_window_capture(b: &mut dyn Backend, max: Option) -> Result { - let tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string(); - let r = b.call("ui.screenshot", json!({"path": tmp}))?; - let png = std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?; - // Best effort: a temporary file left behind is harmless. - std::fs::remove_file(&tmp).ok(); - let mut info = json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null}); - let png = fit_capture(png, max, &mut info)?; - Ok(image_result(&png, info)) -} - -/// `max_side`: absent, or an integer in 1..4096. -fn max_side(a: &Args) -> Result, String> { - match a.get("max_side") { - None => Ok(None), - Some(v) => Ok(Some( - v.as_u64().filter(|v| (1..=4096).contains(v)).and_then(|v| u32::try_from(v).ok()).ok_or("`max_side` must be an integer in 1..4096")?, - )), - } -} - -/// A window capture with `max_side`: scaled down when its longer side is larger, with its own size -/// in `capturedWidth` / `capturedHeight` and the returned image's in `width` / `height`. Without -/// `max_side` the capture and `info` stay as they are. -fn fit_capture(png: Vec, max: Option, info: &mut Value) -> Result, String> { - let Some(max) = max else { return Ok(png) }; - let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read capture: {e}"))?; - info["capturedWidth"] = json!(img.width()); - info["capturedHeight"] = json!(img.height()); - if img.width().max(img.height()) <= max { - info["width"] = json!(img.width()); - info["height"] = json!(img.height()); - return Ok(png); - } - let img = img.thumbnail(max, max); - info["width"] = json!(img.width()); - info["height"] = json!(img.height()); - let mut out = std::io::Cursor::new(Vec::new()); - img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode capture: {e}"))?; - Ok(out.into_inner()) -} - -fn image_result(png: &[u8], info: Value) -> ToolResult { - ToolResult { - content: vec![ - json!({"type": "image", "data": vectorcraft_format::base64_encode(png), "mimeType": "image/png"}), - json!({"type": "text", "text": info.to_string()}), - ], - is_error: false, - } -} - -/// The `menu` argument of the command listings. -const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")"; -/// The `brief` argument of the command listings. -const BRIEF: &str = "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it"; -/// The `max_side` argument of the window captures. -const MAX_SIDE_WINDOW: &str = - "Window capture only: longest side of the returned image in pixels; a larger capture is scaled down to it (default: the full capture)"; -/// The `traceViews` argument of the artboard renders. -const TRACE_VIEWS: &str = "Draw Image Trace objects in the view set with imageTrace.setView (outlines, the source image…), as the app's window does; by default they draw their tracing result, as exports do"; - -/// `traceViews`: false when absent. -fn trace_views(a: &Args) -> Result { - match a.get("traceViews") { - None => Ok(false), - Some(v) => v.as_bool().ok_or_else(|| "`traceViews` must be a boolean".into()), - } -} - -/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the -/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params` -/// text: the full catalogue is too large for many clients, #2173). -fn filter_commands(all: Value, a: &Args) -> Value { - let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); - let needle = text("filter").or_else(|| text("query")); - let in_menu = text("menu"); - let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false); - let brief = a.get("brief").and_then(Value::as_bool).unwrap_or(false); - let Value::Array(list) = all else { return all }; - let menu_path = |c: &Value, sep: &str| { - c.get("menu") - .and_then(Value::as_array) - .map(|m| m.iter().filter_map(Value::as_str).collect::>().join(sep)) - .unwrap_or_default() - .to_lowercase() - }; - let hit = |c: &Value| { - if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) { - return false; - } - if let Some(m) = &in_menu - && !menu_path(c, " > ").contains(m.as_str()) - { - return false; - } - let Some(n) = &needle else { return true }; - let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase(); - field("id").contains(n.as_str()) || field("label").contains(n.as_str()) || menu_path(c, " ").contains(n.as_str()) - }; - Value::Array( - list.into_iter() - .filter(hit) - .map(|mut c| { - if brief && let Some(o) = c.as_object_mut() { - o.remove("params"); - } - c - }) - .collect(), - ) -} - -fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { - let j = |v: Value| Ok(ToolResult::json(&v)); - match name { - "command_list" => { - let all = b.call("engine.commands", json!({}))?; - let mut filters = a.clone(); - if let Some(v) = filters.remove("enabled_only") { - filters.insert("enabledOnly".into(), v); - } - j(filter_commands(all, &filters)) - } - "command_batch" => command_batch(b, a), - "render_preview" => render_preview(b, a), - "ui_screenshot" => { - let mut args = a.clone(); - args.insert("window".into(), json!(true)); - screenshot(b, &args) - } - "list_commands" => { - let all = b.call("engine.commands", json!({}))?; - let list = filter_commands(all, a); - j(json!({"count": list.as_array().map_or(0, Vec::len), "commands": list})) - } - "command_run" | "run_command" | "invoke_menu" => { - let cmd = req_str(a, if name == "command_run" { "id" } else { "command" })?; - let params = match a.get("params") { - None | Some(Value::Null) => json!({}), - Some(v @ Value::Object(_)) => v.clone(), - Some(_) => return Err("`params` must be an object".into()), - }; - let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" }; - j(b.call(method, json!({"command": cmd, "params": params}))?) - } - "doc_inspect" | "inspect_document" => { - // Only the slicing and scope options reach the command, which validates them. - let slice: Map = - a.iter().filter(|(k, _)| INSPECT_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); - j(b.call("document.inspect", Value::Object(slice))?) - } - "ui_inspect" | "inspect_ui" => { - need_ui(b, name)?; - j(b.call("ui.inspect", json!({}))?) - } - "select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?), - "pointer_gesture" => { - let events = a.get("events").and_then(Value::as_array).filter(|e| !e.is_empty()).ok_or("`events` must be a non-empty array")?; - for (i, e) in events.iter().enumerate() { - let kind = e.get("kind").and_then(Value::as_str).unwrap_or(""); - if crate::headless::pointer_kind(kind).is_none() { - return Err(format!("event {i}: unknown kind `{kind}` (down, drag, up, move, doubleclick)")); - } - if !e.get("x").is_some_and(Value::is_number) || !e.get("y").is_some_and(Value::is_number) { - return Err(format!("event {i}: numeric `x` and `y` required")); - } - } - if let Some(t) = a.get("tool").and_then(Value::as_str) { - b.call("ui.tool.select", json!({"tool": t}))?; - } - let events: Vec = events - .iter() - .map(|e| { - let mut e = e.clone(); - e["space"] = json!("doc"); - e - }) - .collect(); - let mut p = json!({"events": events}); - if let Some(m) = a.get("mods") { - p["mods"] = m.clone(); - } - j(b.call("ui.pointer", p)?) - } - "draw_path" => j(draw_path(b, a)?), - "draw_shape" => j(draw_shape(b, a)?), - "set_paint" => { - if !["fill", "stroke", "strokeWidth"].iter().any(|k| a.contains_key(*k)) { - return Err("give at least one of `fill`, `stroke`, `strokeWidth`".into()); - } - let (applied, objects) = apply_paint(b, a, a.get("ids").cloned())?; - let mut reply = json!({"applied": applied, "objects": objects}); - if objects == 0 { - reply["note"] = json!( - "no object was painted (nothing selected, or `ids` is empty or holds no object this paint applies to): only the paint for new art changed" - ); - } - j(reply) - } - "press_key" => { - let mut p = json!({"key": req_str(a, "key")?}); - if let Some(Value::Object(m)) = a.get("mods") { - for (k, v) in m { - p[k.as_str()] = v.clone(); - } - } - j(b.call("ui.key", p)?) - } - "type_text" => { - need_ui(b, name)?; - j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?) - } - "answer_dialog" => { - need_ui(b, name)?; - j(answer_dialog(b, a)?) - } - "open_panel" => { - need_ui(b, name)?; - j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?) - } - "screenshot" => screenshot(b, a), - "open_file" => { - let path = absolute_path(b, req_str(a, "path")?)?; - j(b.call("app.open", json!({"path": path}))?) - } - "save_file" => { - let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?}); - for k in SAVE_FILE_OPTIONS { - if let Some(v) = a.get(k).filter(|v| !v.is_null()) { - p[k] = v.clone(); - } - } - // The app writes the file before it replies, even with Background Save on, so a failed - // save is this call's error. - if b.has_ui() { - p["wait"] = json!(true); - } - let mut r = b.call("app.save", p)?; - let notes = save_file_warnings(a, r.get("format").and_then(Value::as_str)); - if let Some(o) = r.as_object_mut().filter(|_| !notes.is_empty()) { - let mut all = o.get("warnings").and_then(Value::as_array).cloned().unwrap_or_default(); - all.extend(notes.into_iter().map(Value::from)); - o.insert("warnings".into(), Value::Array(all)); - } - j(r) - } - "export" => { - // One params object for every backend; the named arguments win over `options` (a named - // artboard or range replaces every artboard choice in `options`). - let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default(); - if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) { - for k in ARTBOARD_PARAMS { - params.remove(k); - } - } - for k in named_export_params(a) { - if let Some(v) = a.get(*k).filter(|v| !v.is_null()) { - params.insert((*k).into(), v.clone()); - } - } - if let Some(path) = params.get("path").and_then(Value::as_str) { - let path = absolute_path(b, path)?; - params.insert("path".into(), json!(path)); - } - let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" }; - j(exec(b, cmd, Value::Object(params))?) - } - "add_text" => j(add_text(b, a)?), - "apply_effect" => { - let Some(effect) = a.get("effect").and_then(Value::as_str) else { - let all = exec(b, "effect.list", json!({}))?; - return j(json!({"catalog": all.get("catalog")})); - }; - let mut p = json!({"effect": effect, "params": a.get("params").cloned().unwrap_or(json!({}))}); - if let Some(ids) = a.get("ids") { - p["ids"] = ids.clone(); - } - j(exec(b, "effect.apply", p)?) - } - "pathfinder" => { - select_ids(b, a)?; - j(exec(b, &format!("object.pathfinder.{}", req_str(a, "operation")?), json!({}))?) - } - "transform" => j(transform(b, a)?), - "create_graph" => { - let mut p = Value::Object(a.clone()); - if p.get("type").is_none() { - p["type"] = json!("column"); - } - j(exec(b, "graph.create", p)?) - } - "text_wrap" => { - select_ids(b, a)?; - if a.get("release").and_then(Value::as_bool) == Some(true) { - return j(exec(b, "object.textWrap.release", json!({}))?); - } - let mut p = json!({}); - for k in ["offset", "invert"] { - if let Some(v) = a.get(k) { - p[k] = v.clone(); - } - } - j(exec(b, "object.textWrap.make", p)?) - } - "undo" => j(exec(b, "edit.undo", json!({}))?), - "redo" => j(exec(b, "edit.redo", json!({}))?), - other => Err(format!("unknown tool `{other}` (see tools/list)")), - } -} - -/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per -/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`) -/// would resize the drawing and leave its units unset. -fn named_export_params(a: &Args) -> &'static [&'static str] { - let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str)); - if format.is_ok_and(|f| f.raster) { - &["path", "format", "scale", "artboard", "range", "outlineText"] - } else { - &["path", "format", "artboard", "range", "outlineText"] - } -} - -/// What `save_file` with arguments `a` adds to the save's `warnings`, for a file written in format -/// `written` (none when nothing was written yet): each key in `options` that the format doesn't -/// read. The save itself warns about `compress`, `version` and `preview` given at the top level. -fn save_file_warnings(a: &Args, written: Option<&str>) -> Vec { - let Some(f) = written.and_then(format) else { return vec![] }; - let nested = a.get("options").and_then(Value::as_object); - nested - .into_iter() - .flatten() - .filter(|(key, v)| !v.is_null() && !save_reads_option(f, key)) - .map(|(key, _)| format!("`options.{key}` was not used: {} files don't read it", f.label)) - .collect() -} - -/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an -/// `isError` result so the model can read and correct them. -pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { - let empty = Map::new(); - let a = match args { - Value::Object(o) => o, - Value::Null => &empty, - _ => return ToolResult::error("tool arguments must be a JSON object"), - }; - match vectorcraft_engine::guard::catch_panic(|| dispatch(b, name, a)) { - Ok(Ok(r)) => r, - Ok(Err(e)) => ToolResult::error(format!("{name}: {e}")), - Err(e) => ToolResult::error(format!("internal error in `{name}`: {e} (please report this bug)")), - } -} - -/// Most steps one `command_batch` may hold, and most bytes its kept step results may total. -/// Batches run sequentially and every result is kept for later steps' `"$N.path"` references, so -/// without caps one batch could do unbounded work and pile up an unbounded reply (#1727). -pub(crate) const MAX_COMMAND_BATCH_STEPS: usize = 256; -pub(crate) const MAX_COMMAND_BATCH_BYTES: usize = 8 * 1024 * 1024; - -/// Refuses an argument the tool's schema doesn't list, as `tools/call` does (#713). -pub(crate) fn check_tool_arguments(name: &str, args: &Args) -> Result<(), String> { - let defs = tool_definitions(); - let props = - defs.iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name)).and_then(|t| t.get("inputSchema")?.get("properties")?.as_object()); - match props.and_then(|p| args.keys().find(|k| !p.contains_key(*k))) { - Some(key) => Err(format!("unknown argument `{key}` for `{name}`")), - None => Ok(()), - } -} - -fn command_batch(b: &mut dyn Backend, a: &Args) -> Result { - let steps = a.get("steps").and_then(Value::as_array).ok_or("`steps` must be an array")?; - if steps.len() > MAX_COMMAND_BATCH_STEPS { - return Err(format!("`steps` holds {} steps; at most {MAX_COMMAND_BATCH_STEPS}", steps.len())); - } - let stop = match a.get("stop_on_error") { - None => true, - Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?, - }; - let atomic = match a.get("atomic") { - None => false, - Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?, - }; - if atomic { - let label = a.get("label").cloned().unwrap_or(Value::Null); - b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?; - } else if a.get("label").is_some() { - return Err("`label` names the undo step of an atomic batch: give atomic:true too".into()); - } - let (mut completed, mut failed, mut results) = (0, 0, Vec::new()); - // Each step's result (null for a failed one), for later steps' `"$N.path"` references. - let mut values: Vec = vec![]; - // The kept results all go back in the reply, so their total size is capped: one batch can't - // pile up an unbounded answer (#1727). - let mut kept_bytes = 0usize; - // The objects the drawing steps made (#2113). - let mut created: Vec = vec![]; - for step in steps { - let result = vectorcraft_engine::guard::catch_panic(|| { - let args = step.as_object().ok_or("each step must be an object")?; - if args.keys().any(|k| k != "id" && k != "params") { - return Err("unknown step argument (expected id, params)".into()); - } - let id = req_str(args, "id")?; - let params = match args.get("params") { - None | Some(Value::Null) => json!({}), - Some(v @ Value::Object(_)) => vectorcraft_engine::steps::resolve(v, &values)?, - Some(_) => return Err("`params` must be an object".into()), - }; - match id { - // The drawing tools, with the arguments `tools/call` takes (#2113). - "draw_path" | "draw_shape" => { - let args = params.as_object().cloned().unwrap_or_default(); - check_tool_arguments(id, &args)?; - let made = if id == "draw_path" { draw_path(b, &args)? } else { draw_shape(b, &args)? }; - if let Some(new) = made.get("id").filter(|v| v.is_u64()) { - created.push(new.clone()); - } - Ok(made) - } - _ => b.call("engine.execute", json!({"command":id,"params":params})), - } - }) - .unwrap_or_else(|e| Err(format!("internal error: {e}"))); - match result { - Ok(v) => { - let entry = json!({"ok": true, "result": v}); - let size = entry.to_string().len(); - if kept_bytes.saturating_add(size) > MAX_COMMAND_BATCH_BYTES { - // The results already kept stay; this step ran, and its result is replaced by - // the reason the batch stops (saying it ran, so nobody runs it again). The loop breaks either way — carrying on would defeat the cap — - // and the atomic group below still closes, as for any stopped batch. - failed += 1; - values.push(Value::Null); - results.push(json!({"ok": false, "error": format!("this step ran, but the batch's results exceed {MAX_COMMAND_BATCH_BYTES} bytes: its result is dropped and the batch stops here")})); - break; - } - kept_bytes += size; - completed += 1; - values.push(entry["result"].clone()); - results.push(entry); - } - Err(e) => { - failed += 1; - values.push(Value::Null); - results.push(json!({"ok":false,"error":e})); - if stop { - break; - } - } - } - } - // The steps that ran stay, as one undo step. - if atomic { - b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?; - } - let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results,"created":created})); - r.is_error = failed > 0; - Ok(r) -} - -fn render_preview(b: &mut dyn Backend, a: &Args) -> Result { - let max = max_side(a)?.unwrap_or(1024); - let index = match a.get("artboard") { - None => 0, - Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?, - }; - let trace_views = trace_views(a)?; - let doc = b.call("document.inspect", json!({"depth":0}))?; - let board = - doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?; - let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?; - let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?; - if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { - return Err("invalid artboard dimensions".into()); - } - // The headless renderer clamps scale at 0.01. Bound its allocation even for - // a huge artboard, then downsize to the requested dimensions. - let scale = (f64::from(max) / width.max(height)).clamp(0.01, 1.0); - if width.max(height) * scale > 4096.0 { - return Err("artboard is too large for a bounded preview".into()); - } - let mut p = json!({"scale":scale, "artboard": index}); - if trace_views { - p["traceViews"] = json!(true); - } - let r = b.call("ui.render", p)?; - let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?; - let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?; - let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?; - let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img }; - let mut out = std::io::Cursor::new(Vec::new()); - img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?; - Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()}))) -} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs new file mode 100644 index 000000000..89432e9fc --- /dev/null +++ b/crates/mcp/src/tools/definitions.rs @@ -0,0 +1,512 @@ +use super::dispatch::{BRIEF, MAX_SIDE_WINDOW, MENU_FILTER, TRACE_VIEWS}; +use super::handlers::MAX_DIALOG_FIELDS; +use super::*; + +// ---------- schemas ---------- + +fn num(desc: &str) -> Value { + json!({"type": "number", "description": desc}) +} + +fn string(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} + +/// The arguments `save_file` passes on to `document.save` beside `path`. +pub(super) const SAVE_FILE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "options"]; + +/// The arguments `doc_inspect` / `inspect_document` pass on to `document.inspect`. +pub(super) const INSPECT_KEYS: [&str; 6] = ["depth", "childLimit", "ids", "kind", "limit", "offset"]; + +/// Their schema. +fn inspect_props() -> Value { + json!({ + "depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"}, + "childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"}, + "ids": {"type": "array", "maxItems": 10000, "items": {"type": "integer", "minimum": 0}, "description": "List just these nodes (any level) as `nodes` instead of the whole layer tree; an unknown id is an error"}, + "kind": {"type": "string", "description": "List every node of this panel label (Layer, Group, Path, Type, Image, ...; ignores case) as `nodes`, each with its ancestor `path`; searches the subtrees of `ids` when given"}, + "limit": {"type": "integer", "minimum": 0, "description": "At most this many layers or nodes (the reply adds `total`)"}, + "offset": {"type": "integer", "minimum": 0, "description": "Layers or nodes skipped first (default 0)"} + }) +} + +fn paint_schema(what: &str) -> Value { + json!({ + "description": format!("{what}: \"#rrggbb\", \"none\", [r,g,b] (0..1), {{\"c\",\"m\",\"y\",\"k\"}}, {{\"gray\"}}, or a full paint.setFill params object ({{\"gradient\":…}} / {{\"swatch\":name}})"), + "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}}, {"type": "object"}] + }) +} + +/// The options `draw_path` and `draw_shape` apply to the object they draw (#2112). +fn with_draw_options(mut props: Value) -> Value { + let more = json!({ + "dash": {"description": "Dash and gap lengths in pt ([d, g, …]), or null for a solid stroke (stroke.set `dash`)", "anyOf": [{"type": "array", "items": {"type": "number"}}, {"type": "null"}]}, + "cap": {"type": "string", "enum": ["butt", "round", "square", "projecting"], "description": "Stroke cap"}, + "join": {"type": "string", "enum": ["miter", "round", "bevel"], "description": "Stroke corner join"}, + "opacity": {"type": "number", "minimum": 0, "maximum": 100, "description": "Opacity in % (object.setProps `opacity`); doc_inspect reads it back as `opacityPct` (its `opacity` is a 0–1 fraction)"}, + "name": string("Name in the Layers panel"), + "effects": { + "type": "array", + "description": "Live effects applied in order, as apply_effect: [{effect: id (effect.list), params?: {…}}]", + "items": obj(json!({"effect": string("Effect id, e.g. \"blur.gaussian\""), "params": {"type": "object"}}), &["effect"]), + }, + }); + if let (Some(p), Value::Object(more)) = (props.as_object_mut(), more) { + p.extend(more); + } + props +} + +fn mods_schema() -> Value { + json!({ + "type": "object", + "description": "Modifier keys held (cmd = Command on macOS / Ctrl elsewhere; alt = Option)", + "properties": {"shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}, "ctrl": {"type": "boolean"}}, + "additionalProperties": false + }) +} + +fn obj(props: Value, required: &[&str]) -> Value { + let mut o = json!({"type": "object", "properties": props, "additionalProperties": false}); + if !required.is_empty() { + o["required"] = json!(required); + } + o +} + +/// The extension of each format Save writes, in [`SAVE_FORMATS`] order. +fn save_extensions() -> Vec<&'static str> { + SAVE_FORMATS.iter().filter_map(|id| format(id)?.extensions.first().copied()).collect() +} + +fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value { + let writes_file = matches!(name, "screenshot" | "save_file" | "export"); + let read_only = read_only && !writes_file; + json!({ + "name": name, + "title": title, + "description": desc, + "inputSchema": schema, + "annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only, "idempotentHint": read_only, "openWorldHint": false}, + }) +} + +/// All tools, in `tools/list` order. +pub fn tool_definitions() -> Vec { + let empty = || obj(json!({}), &[]); + vec![ + tool( + "command_list", + "List commands", + "Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", + obj( + json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), + &[], + ), + true, + ), + tool( + "command_run", + "Run a command", + "Run an engine command by id with its JSON params. Command parameters follow the registry's descriptions. run_command runs it the same way; it takes the id as command instead of id.", + obj(json!({"id": string("Command id"), "params": {"type":"object"}}), &["id"]), + false, + ), + tool( + "command_batch", + "Run several commands", + "Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit; `atomic` says which documents). A step's id is a command id, or draw_path / draw_shape with that tool's arguments as params (its result is the tool's: the new object's `id`). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed, results and created (the ids draw_path / draw_shape steps made, in order). Stops on the first error by default. At most 256 steps; kept step results total at most 8 MiB, past which the batch stops with the reason.", + obj( + json!({"steps": {"type":"array", "maxItems": 256, "items":obj(json!({"id":string("Command id, or draw_path / draw_shape"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step in the active document and in each document a step opens or creates; in a document open before the batch that a step switches to, each edit stays its own undo step"}, "label":string("The undo step's name with atomic")}), + &["steps"], + ), + false, + ), + tool( + "doc_inspect", + "Inspect document", + "Summary of the active document, including artboards, layer tree, selection and history. A node below full opacity reports `opacity` as a 0–1 fraction and `opacityPct` in the 0–100 % scale the drawing tools and object.setProps take: pass `opacityPct` back, never `opacity`. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page. inspect_document returns the same summary.", + obj(inspect_props(), &[]), + true, + ), + tool( + "render_preview", + "Render a preview", + "Render an artboard (default the first) as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.", + obj( + json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"},"traceViews":{"type":"boolean","description":TRACE_VIEWS}}), + &[], + ), + true, + ), + tool( + "ui_inspect", + "Inspect the interface", + "Inspect the connected desktop interface; returns a tool error in headless mode. The same call as inspect_ui.", + empty(), + true, + ), + tool( + "ui_screenshot", + "Capture the interface", + "Capture the connected desktop window as inline PNG; requires a running desktop app. The same call as screenshot with window:true. max_side scales the returned image down to at most that many pixels on its longer side (1..4096; default: the full capture).", + obj(json!({"max_side": {"type":"integer","minimum":1,"maximum":4096,"description":MAX_SIDE_WINDOW}}), &[]), + true, + ), + tool( + "list_commands", + "List commands", + "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", + obj( + json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), + &[], + ), + true, + ), + tool( + "run_command", + "Run command", + "Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands). command_run runs it the same way and takes the id as id.", + obj( + json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}), + &["command"], + ), + false, + ), + tool( + "inspect_document", + "Inspect document", + "Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke; `opacity` as a 0–1 fraction and `opacityPct` in the 0–100 % scale the drawing tools take), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node. doc_inspect returns the same summary.", + obj(inspect_props(), &[]), + true, + ), + tool( + "inspect_ui", + "Inspect UI", + "UI state of the running desktop app: tool and options, panels, dialogs, view (zoom/center), window, documents, perf. Desktop app only. The same call as ui_inspect.", + empty(), + true, + ), + tool( + "select_tool", + "Select tool", + "Switch the active tool (as clicking it in the Tools panel). Ids: selection, directSelection, groupSelection, pen, rectangle, roundedRectangle, ellipse, polygon, star, lineSegment, …", + obj(json!({"tool": string("Tool id")}), &["tool"]), + false, + ), + tool( + "pointer_gesture", + "Pointer gesture", + "Drive the active tool with mouse events in document coordinates, exactly like the mouse would (e.g. a rectangle drag: down at (100,100), drag to (200,150), up at (200,150)). Optionally selects `tool` first.", + obj( + json!({ + "tool": string("Optional tool to select before the gesture"), + "events": { + "type": "array", + "minItems": 1, + "items": obj(json!({ + "kind": {"type": "string", "enum": ["down", "drag", "up", "move", "doubleclick"]}, + "x": num("Document x (pt)"), + "y": num("Document y (pt)"), + "mods": mods_schema(), + "pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on"), + "holdMs": num("Milliseconds the pointer then holds still, button down (0..60000): Twirl, Pucker and Bloat keep applying"), + }), &["kind", "x", "y"]), + }, + "mods": mods_schema(), + }), + &["events"], + ), + false, + ), + tool( + "draw_path", + "Draw path", + "Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint, stroke dash/cap/join, opacity, name and live effects. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke). For a filled path without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.", + obj( + with_draw_options(json!({ + "points": {"type": "array", "description": "Anchors: [x,y] pairs or {x, y, in?, out?, smooth?} objects", "items": {"anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 2}, {"type": "object"}]}}, + "d": string("SVG path data (alternative to points), e.g. \"M0 0 L100 0 L50 80 Z\""), + "closed": {"type": "boolean", "description": "Close the path (points only)"}, + "fill": paint_schema("Fill"), + "stroke": paint_schema("Stroke"), + "strokeWidth": num("Stroke weight in pt"), + })), + &[], + ), + false, + ), + tool( + "draw_shape", + "Draw shape", + "Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke); lines start with no fill and a visible stroke. For a filled shape without an outline, pass an explicit fill and stroke:\"none\". Stroke dash/cap/join, opacity, name and live effects apply to the new shape as in draw_path. Returns the new object id.", + obj( + with_draw_options(json!({ + "shape": {"type": "string", "enum": ["rectangle", "ellipse", "polygon", "star", "line"]}, + "x": num("Left (rectangle/ellipse)"), + "y": num("Top (rectangle/ellipse)"), + "width": num("Width (rectangle/ellipse)"), + "height": num("Height (rectangle/ellipse)"), + "radius": num("Corner radius (rectangle) or radius (polygon)"), + "cx": num("Centre x (polygon/star)"), + "cy": num("Centre y (polygon/star)"), + "sides": {"type": "integer", "minimum": 3, "description": "Polygon sides (default 6)"}, + "radius1": num("Star outer radius"), + "radius2": num("Star inner radius"), + "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, + "rotation": num("Rotation in degrees (polygon/star)"), + "x1": num("Line start x"), + "y1": num("Line start y"), + "x2": num("Line end x"), + "y2": num("Line end y"), + "fill": paint_schema("Fill"), + "stroke": paint_schema("Stroke"), + "strokeWidth": num("Stroke weight in pt"), + })), + &["shape"], + ), + false, + ), + tool( + "set_paint", + "Set paint", + "Set fill, stroke and/or stroke weight on the selection (or `ids`) and as the default for new art. Returns {applied, objects}: `objects` counts the objects painted, as paint.setFill, paint.setStroke and stroke.set report them (the largest of their counts); when it is 0, a `note` says that only the default for new art changed.", + obj( + json!({ + "fill": paint_schema("Fill"), + "stroke": paint_schema("Stroke"), + "strokeWidth": num("Stroke weight in pt"), + "ids": {"type": "array", "items": {"type": "integer"}, "description": "Object ids (default: the selection)"}, + }), + &[], + ), + false, + ), + tool( + "press_key", + "Press key", + "Send a key press with modifiers, e.g. {key:\"Z\", mods:{cmd:true}} (undo), {key:\"Escape\"}, {key:\"Enter\"}. In the desktop app it goes through the real keyboard path; headless, it runs the command or tool bound to that shortcut.", + obj( + json!({"key": string("Key name: A–Z, 0–9, Enter, Escape, Delete, Backspace, Tab, Space, Left/Right/Up/Down, [ ]…"), "mods": mods_schema()}), + &["key"], + ), + false, + ), + tool( + "type_text", + "Type text", + "Type text into the focused widget (text tool, dialog field). Desktop app only.", + obj(json!({"text": string("Text to type")}), &["text"]), + false, + ), + tool( + "answer_dialog", + "Answer dialog", + "Set fields of the desktop app's open dialog in order, then confirm (OK) or cancel it, as the control protocol's ui.dialog.set, ui.dialog.confirm and ui.dialog.cancel do. The app stores all the fields or none. When the app refuses the ui.dialog.set, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. inspect_ui shows the open dialog and its fields (ui.dialog). After invoke_menu app.quit or file.close replies {\"pending\":\"saveChanges\"}: {action:\"confirm\"} saves, {set:[{field:\"discard\",value:true}],action:\"confirm\"} closes without saving, {action:\"cancel\"} cancels. Returns {dialog, result}: the dialog after the fields were set, and the reply to confirm or cancel. Desktop app only.", + obj( + json!({ + "set": { + "type": "array", + "maxItems": MAX_DIALOG_FIELDS, + "items": obj(json!({"field": string("Dialog field"), "value": {"description": "The field's new value (any JSON; default null)"}}), &["field"]), + "description": "Fields to set, in order (one ui.dialog.set that sets all of them or none)", + }, + "action": {"type": "string", "enum": ["confirm", "cancel"], "description": "Then confirm (OK) or cancel the dialog"}, + }), + &[], + ), + false, + ), + tool( + "invoke_menu", + "Invoke menu", + "Invoke a menu item by its command id (same ids as list_commands, including UI-only commands like view.zoomIn or window.* in the desktop app).", + obj(json!({"command": string("Menu command id"), "params": {"type": "object"}}), &["command"]), + false, + ), + tool( + "open_panel", + "Open panel", + "Open a panel in the desktop app's dock by id (layers, swatches, stroke, align, pathfinder, transform, …; case-insensitive, display labels like \"Layers\" work too). Desktop app only.", + obj(json!({"panel": string("Panel id or display label")}), &["panel"]), + false, + ), + tool( + "screenshot", + "Screenshot", + "Render the active artboard to PNG and return it as an image (optionally also saving it to `path`). With window:true (desktop app only) captures the whole app window instead, as ui_screenshot does; max_side then scales the returned image down (a file written to `path` keeps the full capture).", + obj( + json!({ + "path": string("Also write the PNG here"), + "scale": num("Pixels per point (default 1)"), + "artboard": {"type": "integer", "minimum": 0, "description": "Artboard index (default 0)"}, + "window": {"type": "boolean", "description": "Capture the app window (desktop app only)"}, + "max_side": {"type": "integer", "minimum": 1, "maximum": 4096, "description": MAX_SIDE_WINDOW}, + "traceViews": {"type": "boolean", "description": TRACE_VIEWS}, + }), + &[], + ), + true, + ), + tool( + "open_file", + "Open file", + &format!( + "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read).", + OPEN_EXTS.join(", .") + ), + obj(json!({"path": string("File path")}), &["path"]), + false, + ), + tool( + "save_file", + "Save file", + &format!( + "Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). A native file is gzipped when `compress` is true (default: the useCompression preference, Preferences > File Handling > Use Compression); `version` writes it for an older VectorCraft and `preview` embeds a PNG of the first artboard. `options` carries the format's save options (see run_command document.formats); giving `compress`, `version` or `preview` both at the top level and in `options` with different values is an error. The reply's `format` says what was written and `warnings` what that format loses and each option given that the format doesn't read (such as `compress` for .svg). In the running app the call returns once the file is written, or with the error, even with Background Save on. When the save asks first (before replacing a file that opened with losses, or with a file dialog for an untitled document without `path`), the reply is `pending` or \"cancelled\" as before.", + save_extensions().join(", .") + ), + obj( + json!({ + "path": string("Destination; its extension picks the format (default: the document's own file)"), + "compress": {"type": "boolean", "description": "Native files: gzip the file (default: the useCompression preference)"}, + "version": {"type": "integer", "minimum": 1, "maximum": vectorcraft_format::VERSION, "description": "Native files: the format version to write (default: the current one; 1 and 2 are never compressed)"}, + "preview": {"type": "boolean", "description": "Native files: embed a PNG of the first artboard, at most 256 px (default false)"}, + "options": {"type": "object", "description": "The format's save options, passed through (see run_command document.formats); default: as last saved"}, + }), + &[], + ), + false, + ), + tool( + "export", + "Export", + "Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", + obj( + json!({ + "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, + "path": string("Destination file (omit to get dataBase64)"), + "scale": num("Raster pixels per point (default 1)"), + "artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF and .ai default: all)"}, + "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"), + "selection": {"type": "boolean", "description": "Export only the selection, cropped to it"}, + "outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"}, + "options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"}, + }), + &[], + ), + false, + ), + tool( + "add_text", + "Add text", + "Create point type at (x, y) (the first baseline), area type when width/height are given (`fit` autoHeight grows the frame to the text, shrinkText scales the text down to fit), or type in/on an existing path (`path` id + `mode` area|onPath; `pathEffect` rainbow|skew|3dRibbon|stairStep|gravity). Returns the text object id, with `overflow: true` when the text doesn't fit its frame or path (the lines past it aren't drawn: enlarge the frame, shrink the text or give `fit`).", + obj( + json!({ + "text": {"type": "string"}, + "x": num("Baseline origin x (area type: left; point type: where the line starts, or its centre or end with justify center/right)"), + "y": num("First baseline y (area type: top)"), + "width": num("Area type frame width"), + "height": num("Area type frame height"), + "vertical": {"type": "boolean", "description": "Vertical writing for point, area or path type (default false, horizontal), as text.create / text.createInPath"}, + "fit": {"type": "string", "enum": ["none", "autoHeight", "shrinkText"], "description": "Area type: keep the frame (none), fit its height to the text (autoHeight) or scale overflowing text down (shrinkText)"}, + "fitMinPercent": num("shrinkText: the smallest scale in percent, 10 to 100 (default 50)"), + "path": {"type": "integer", "description": "Existing path id to flow the text in (area) or along (onPath)"}, + "mode": {"type": "string", "enum": ["area", "onPath"]}, + "pathEffect": {"type": "string", "enum": ["rainbow", "skew", "3dRibbon", "stairStep", "gravity"]}, + "size": num("Font size in pt"), + "font": {"type": "string", "description": "Font family"}, + "color": paint_schema("Text colour (#rrggbb)"), + "justify": {"type": "string", "enum": ["auto", "left", "center", "right", "justifyAll"], "description": "Paragraph alignment (default auto: the start of the text's direction). Point type lines up on x: center centres each line on it, right ends it there"}, + }), + &["text"], + ), + false, + ), + tool( + "apply_effect", + "Apply live effect", + "Append a live effect (Effect menu) to the selection or `ids`. Omit `effect` to get the catalogue (ids, parameters, defaults). Examples: stylize.dropShadow {x, y, blur, opacity}, distort.roughen, warp.arc {bend}, path.offsetPath {offset}, pathfinder.add (on groups), blur.gaussian {radius}.", + obj( + json!({ + "effect": {"type": "string", "description": "Effect id (see the catalogue)"}, + "params": {"type": "object", "description": "Effect parameters; missing keys take the dialog defaults"}, + "ids": {"type": "array", "items": {"type": "integer"}}, + }), + &[], + ), + false, + ), + tool( + "pathfinder", + "Pathfinder", + "Combine the selected (or `ids`) objects destructively, back to front: unite, minusFront, intersect, exclude, divide, trim, merge, crop, outline, minusBack. For a non-destructive version apply the pathfinder.* effect to a group.", + obj( + json!({ + "operation": {"type": "string", "enum": ["unite", "minusFront", "intersect", "exclude", "divide", "trim", "merge", "crop", "outline", "minusBack"]}, + "ids": {"type": "array", "items": {"type": "integer"}}, + }), + &["operation"], + ), + false, + ), + tool( + "transform", + "Transform", + "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", + obj( + json!({ + "ids": {"type": "array", "items": {"type": "integer"}}, + "dx": num("Move right"), + "dy": num("Move down"), + "rotate": num("Rotation in degrees"), + "scale": num("Uniform scale in %"), + "scaleX": num("Horizontal scale in %"), + "scaleY": num("Vertical scale in %"), + "reflect": {"type": "string", "enum": ["vertical", "horizontal"]}, + "shear": num("Shear angle in degrees (horizontal axis)"), + "origin": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "copy": {"type": "boolean"}, + }), + &[], + ), + false, + ), + tool( + "create_graph", + "Create graph", + "Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. An empty CSV cell or a null in `rows` is a blank value (no column; lines break around it); a number in straight quotes is a label. Edit later with run_command graph.setData / graph.setType.", + obj( + json!({ + "type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]}, + "x": num("Plot left"), + "y": num("Plot top"), + "width": num("Plot width"), + "height": num("Plot height"), + "csv": {"type": "string"}, + "series": {"type": "array", "items": {"type": "string"}}, + "categories": {"type": "array", "items": {"type": "string"}}, + "rows": {"type": "array", "items": {"type": "array", "items": {"type": ["number", "null"]}}}, + }), + &["x", "y", "width", "height"], + ), + false, + ), + tool( + "text_wrap", + "Text wrap", + "Make the selected (or `ids`) objects wrap objects: area type below them in the same layer flows around them (offset in pt, invert = flow inside). `release: true` removes the wrap.", + obj( + json!({ + "ids": {"type": "array", "items": {"type": "integer"}}, + "offset": num("Gap between text and object (default 6 pt)"), + "invert": {"type": "boolean"}, + "release": {"type": "boolean"}, + }), + &[], + ), + false, + ), + tool("undo", "Undo", "Undo the last change (Edit → Undo). redo reapplies it.", empty(), false), + tool("redo", "Redo", "Redo the last undone change (Edit → Redo): the change the last undo took back.", empty(), false), + ] +} diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs new file mode 100644 index 000000000..d5c11e85c --- /dev/null +++ b/crates/mcp/src/tools/dispatch.rs @@ -0,0 +1,456 @@ +use super::definitions::{INSPECT_KEYS, SAVE_FILE_OPTIONS, tool_definitions}; +use super::handlers::{ + Args, absolute_path, add_text, answer_dialog, apply_paint, draw_path, draw_shape, exec, image_result, max_side, need_ui, req_str, screenshot, + select_ids, transform, +}; +use super::*; + +/// The `menu` argument of the command listings. +pub(super) const MENU_FILTER: &str = + "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")"; + +/// The `brief` argument of the command listings. +pub(super) const BRIEF: &str = + "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it"; + +/// The `max_side` argument of the window captures. +pub(super) const MAX_SIDE_WINDOW: &str = + "Window capture only: longest side of the returned image in pixels; a larger capture is scaled down to it (default: the full capture)"; + +/// The `traceViews` argument of the artboard renders. +pub(super) const TRACE_VIEWS: &str = "Draw Image Trace objects in the view set with imageTrace.setView (outlines, the source image…), as the app's window does; by default they draw their tracing result, as exports do"; + +/// `traceViews`: false when absent. +pub(super) fn trace_views(a: &Args) -> Result { + match a.get("traceViews") { + None => Ok(false), + Some(v) => v.as_bool().ok_or_else(|| "`traceViews` must be a boolean".into()), + } +} + +/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the +/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params` +/// text: the full catalogue is too large for many clients, #2173). +fn filter_commands(all: Value, a: &Args) -> Value { + let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); + let needle = text("filter").or_else(|| text("query")); + let in_menu = text("menu"); + let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false); + let brief = a.get("brief").and_then(Value::as_bool).unwrap_or(false); + let Value::Array(list) = all else { return all }; + let menu_path = |c: &Value, sep: &str| { + c.get("menu") + .and_then(Value::as_array) + .map(|m| m.iter().filter_map(Value::as_str).collect::>().join(sep)) + .unwrap_or_default() + .to_lowercase() + }; + let hit = |c: &Value| { + if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) { + return false; + } + if let Some(m) = &in_menu + && !menu_path(c, " > ").contains(m.as_str()) + { + return false; + } + let Some(n) = &needle else { return true }; + let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase(); + field("id").contains(n.as_str()) || field("label").contains(n.as_str()) || menu_path(c, " ").contains(n.as_str()) + }; + Value::Array( + list.into_iter() + .filter(hit) + .map(|mut c| { + if brief && let Some(o) = c.as_object_mut() { + o.remove("params"); + } + c + }) + .collect(), + ) +} + +fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { + let j = |v: Value| Ok(ToolResult::json(&v)); + match name { + "command_list" => { + let all = b.call("engine.commands", json!({}))?; + let mut filters = a.clone(); + if let Some(v) = filters.remove("enabled_only") { + filters.insert("enabledOnly".into(), v); + } + j(filter_commands(all, &filters)) + } + "command_batch" => command_batch(b, a), + "render_preview" => render_preview(b, a), + "ui_screenshot" => { + let mut args = a.clone(); + args.insert("window".into(), json!(true)); + screenshot(b, &args) + } + "list_commands" => { + let all = b.call("engine.commands", json!({}))?; + let list = filter_commands(all, a); + j(json!({"count": list.as_array().map_or(0, Vec::len), "commands": list})) + } + "command_run" | "run_command" | "invoke_menu" => { + let cmd = req_str(a, if name == "command_run" { "id" } else { "command" })?; + let params = match a.get("params") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err("`params` must be an object".into()), + }; + let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" }; + j(b.call(method, json!({"command": cmd, "params": params}))?) + } + "doc_inspect" | "inspect_document" => { + // Only the slicing and scope options reach the command, which validates them. + let slice: Map = + a.iter().filter(|(k, _)| INSPECT_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); + j(b.call("document.inspect", Value::Object(slice))?) + } + "ui_inspect" | "inspect_ui" => { + need_ui(b, name)?; + j(b.call("ui.inspect", json!({}))?) + } + "select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?), + "pointer_gesture" => { + let events = a.get("events").and_then(Value::as_array).filter(|e| !e.is_empty()).ok_or("`events` must be a non-empty array")?; + for (i, e) in events.iter().enumerate() { + let kind = e.get("kind").and_then(Value::as_str).unwrap_or(""); + if crate::headless::pointer_kind(kind).is_none() { + return Err(format!("event {i}: unknown kind `{kind}` (down, drag, up, move, doubleclick)")); + } + if !e.get("x").is_some_and(Value::is_number) || !e.get("y").is_some_and(Value::is_number) { + return Err(format!("event {i}: numeric `x` and `y` required")); + } + } + if let Some(t) = a.get("tool").and_then(Value::as_str) { + b.call("ui.tool.select", json!({"tool": t}))?; + } + let events: Vec = events + .iter() + .map(|e| { + let mut e = e.clone(); + e["space"] = json!("doc"); + e + }) + .collect(); + let mut p = json!({"events": events}); + if let Some(m) = a.get("mods") { + p["mods"] = m.clone(); + } + j(b.call("ui.pointer", p)?) + } + "draw_path" => j(draw_path(b, a)?), + "draw_shape" => j(draw_shape(b, a)?), + "set_paint" => { + if !["fill", "stroke", "strokeWidth"].iter().any(|k| a.contains_key(*k)) { + return Err("give at least one of `fill`, `stroke`, `strokeWidth`".into()); + } + let (applied, objects) = apply_paint(b, a, a.get("ids").cloned())?; + let mut reply = json!({"applied": applied, "objects": objects}); + if objects == 0 { + reply["note"] = json!( + "no object was painted (nothing selected, or `ids` is empty or holds no object this paint applies to): only the paint for new art changed" + ); + } + j(reply) + } + "press_key" => { + let mut p = json!({"key": req_str(a, "key")?}); + if let Some(Value::Object(m)) = a.get("mods") { + for (k, v) in m { + p[k.as_str()] = v.clone(); + } + } + j(b.call("ui.key", p)?) + } + "type_text" => { + need_ui(b, name)?; + j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?) + } + "answer_dialog" => { + need_ui(b, name)?; + j(answer_dialog(b, a)?) + } + "open_panel" => { + need_ui(b, name)?; + j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?) + } + "screenshot" => screenshot(b, a), + "open_file" => { + let path = absolute_path(b, req_str(a, "path")?)?; + j(b.call("app.open", json!({"path": path}))?) + } + "save_file" => { + let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?}); + for k in SAVE_FILE_OPTIONS { + if let Some(v) = a.get(k).filter(|v| !v.is_null()) { + p[k] = v.clone(); + } + } + // The app writes the file before it replies, even with Background Save on, so a failed + // save is this call's error. + if b.has_ui() { + p["wait"] = json!(true); + } + let mut r = b.call("app.save", p)?; + let notes = save_file_warnings(a, r.get("format").and_then(Value::as_str)); + if let Some(o) = r.as_object_mut().filter(|_| !notes.is_empty()) { + let mut all = o.get("warnings").and_then(Value::as_array).cloned().unwrap_or_default(); + all.extend(notes.into_iter().map(Value::from)); + o.insert("warnings".into(), Value::Array(all)); + } + j(r) + } + "export" => { + // One params object for every backend; the named arguments win over `options` (a named + // artboard or range replaces every artboard choice in `options`). + let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default(); + if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) { + for k in ARTBOARD_PARAMS { + params.remove(k); + } + } + for k in named_export_params(a) { + if let Some(v) = a.get(*k).filter(|v| !v.is_null()) { + params.insert((*k).into(), v.clone()); + } + } + if let Some(path) = params.get("path").and_then(Value::as_str) { + let path = absolute_path(b, path)?; + params.insert("path".into(), json!(path)); + } + let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" }; + j(exec(b, cmd, Value::Object(params))?) + } + "add_text" => j(add_text(b, a)?), + "apply_effect" => { + let Some(effect) = a.get("effect").and_then(Value::as_str) else { + let all = exec(b, "effect.list", json!({}))?; + return j(json!({"catalog": all.get("catalog")})); + }; + let mut p = json!({"effect": effect, "params": a.get("params").cloned().unwrap_or(json!({}))}); + if let Some(ids) = a.get("ids") { + p["ids"] = ids.clone(); + } + j(exec(b, "effect.apply", p)?) + } + "pathfinder" => { + select_ids(b, a)?; + j(exec(b, &format!("object.pathfinder.{}", req_str(a, "operation")?), json!({}))?) + } + "transform" => j(transform(b, a)?), + "create_graph" => { + let mut p = Value::Object(a.clone()); + if p.get("type").is_none() { + p["type"] = json!("column"); + } + j(exec(b, "graph.create", p)?) + } + "text_wrap" => { + select_ids(b, a)?; + if a.get("release").and_then(Value::as_bool) == Some(true) { + return j(exec(b, "object.textWrap.release", json!({}))?); + } + let mut p = json!({}); + for k in ["offset", "invert"] { + if let Some(v) = a.get(k) { + p[k] = v.clone(); + } + } + j(exec(b, "object.textWrap.make", p)?) + } + "undo" => j(exec(b, "edit.undo", json!({}))?), + "redo" => j(exec(b, "edit.redo", json!({}))?), + other => Err(format!("unknown tool `{other}` (see tools/list)")), + } +} + +/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per +/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`) +/// would resize the drawing and leave its units unset. +fn named_export_params(a: &Args) -> &'static [&'static str] { + let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str)); + if format.is_ok_and(|f| f.raster) { + &["path", "format", "scale", "artboard", "range", "outlineText"] + } else { + &["path", "format", "artboard", "range", "outlineText"] + } +} + +/// What `save_file` with arguments `a` adds to the save's `warnings`, for a file written in format +/// `written` (none when nothing was written yet): each key in `options` that the format doesn't +/// read. The save itself warns about `compress`, `version` and `preview` given at the top level. +fn save_file_warnings(a: &Args, written: Option<&str>) -> Vec { + let Some(f) = written.and_then(format) else { return vec![] }; + let nested = a.get("options").and_then(Value::as_object); + nested + .into_iter() + .flatten() + .filter(|(key, v)| !v.is_null() && !save_reads_option(f, key)) + .map(|(key, _)| format!("`options.{key}` was not used: {} files don't read it", f.label)) + .collect() +} + +/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an +/// `isError` result so the model can read and correct them. +pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { + let empty = Map::new(); + let a = match args { + Value::Object(o) => o, + Value::Null => &empty, + _ => return ToolResult::error("tool arguments must be a JSON object"), + }; + match vectorcraft_engine::guard::catch_panic(|| dispatch(b, name, a)) { + Ok(Ok(r)) => r, + Ok(Err(e)) => ToolResult::error(format!("{name}: {e}")), + Err(e) => ToolResult::error(format!("internal error in `{name}`: {e} (please report this bug)")), + } +} + +/// Most steps one `command_batch` may hold, and most bytes its kept step results may total. +/// Batches run sequentially and every result is kept for later steps' `"$N.path"` references, so +/// without caps one batch could do unbounded work and pile up an unbounded reply (#1727). +pub(crate) const MAX_COMMAND_BATCH_STEPS: usize = 256; + +pub(crate) const MAX_COMMAND_BATCH_BYTES: usize = 8 * 1024 * 1024; + +/// Refuses an argument the tool's schema doesn't list, as `tools/call` does (#713). +pub(crate) fn check_tool_arguments(name: &str, args: &Args) -> Result<(), String> { + let defs = tool_definitions(); + let props = + defs.iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name)).and_then(|t| t.get("inputSchema")?.get("properties")?.as_object()); + match props.and_then(|p| args.keys().find(|k| !p.contains_key(*k))) { + Some(key) => Err(format!("unknown argument `{key}` for `{name}`")), + None => Ok(()), + } +} + +fn command_batch(b: &mut dyn Backend, a: &Args) -> Result { + let steps = a.get("steps").and_then(Value::as_array).ok_or("`steps` must be an array")?; + if steps.len() > MAX_COMMAND_BATCH_STEPS { + return Err(format!("`steps` holds {} steps; at most {MAX_COMMAND_BATCH_STEPS}", steps.len())); + } + let stop = match a.get("stop_on_error") { + None => true, + Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?, + }; + let atomic = match a.get("atomic") { + None => false, + Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?, + }; + if atomic { + let label = a.get("label").cloned().unwrap_or(Value::Null); + b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?; + } else if a.get("label").is_some() { + return Err("`label` names the undo step of an atomic batch: give atomic:true too".into()); + } + let (mut completed, mut failed, mut results) = (0, 0, Vec::new()); + // Each step's result (null for a failed one), for later steps' `"$N.path"` references. + let mut values: Vec = vec![]; + // The kept results all go back in the reply, so their total size is capped: one batch can't + // pile up an unbounded answer (#1727). + let mut kept_bytes = 0usize; + // The objects the drawing steps made (#2113). + let mut created: Vec = vec![]; + for step in steps { + let result = vectorcraft_engine::guard::catch_panic(|| { + let args = step.as_object().ok_or("each step must be an object")?; + if args.keys().any(|k| k != "id" && k != "params") { + return Err("unknown step argument (expected id, params)".into()); + } + let id = req_str(args, "id")?; + let params = match args.get("params") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => vectorcraft_engine::steps::resolve(v, &values)?, + Some(_) => return Err("`params` must be an object".into()), + }; + match id { + // The drawing tools, with the arguments `tools/call` takes (#2113). + "draw_path" | "draw_shape" => { + let args = params.as_object().cloned().unwrap_or_default(); + check_tool_arguments(id, &args)?; + let made = if id == "draw_path" { draw_path(b, &args)? } else { draw_shape(b, &args)? }; + if let Some(new) = made.get("id").filter(|v| v.is_u64()) { + created.push(new.clone()); + } + Ok(made) + } + _ => b.call("engine.execute", json!({"command":id,"params":params})), + } + }) + .unwrap_or_else(|e| Err(format!("internal error: {e}"))); + match result { + Ok(v) => { + let entry = json!({"ok": true, "result": v}); + let size = entry.to_string().len(); + if kept_bytes.saturating_add(size) > MAX_COMMAND_BATCH_BYTES { + // The results already kept stay; this step ran, and its result is replaced by + // the reason the batch stops (saying it ran, so nobody runs it again). The loop breaks either way — carrying on would defeat the cap — + // and the atomic group below still closes, as for any stopped batch. + failed += 1; + values.push(Value::Null); + results.push(json!({"ok": false, "error": format!("this step ran, but the batch's results exceed {MAX_COMMAND_BATCH_BYTES} bytes: its result is dropped and the batch stops here")})); + break; + } + kept_bytes += size; + completed += 1; + values.push(entry["result"].clone()); + results.push(entry); + } + Err(e) => { + failed += 1; + values.push(Value::Null); + results.push(json!({"ok":false,"error":e})); + if stop { + break; + } + } + } + } + // The steps that ran stay, as one undo step. + if atomic { + b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?; + } + let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results,"created":created})); + r.is_error = failed > 0; + Ok(r) +} + +fn render_preview(b: &mut dyn Backend, a: &Args) -> Result { + let max = max_side(a)?.unwrap_or(1024); + let index = match a.get("artboard") { + None => 0, + Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?, + }; + let trace_views = trace_views(a)?; + let doc = b.call("document.inspect", json!({"depth":0}))?; + let board = + doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?; + let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?; + let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return Err("invalid artboard dimensions".into()); + } + // The headless renderer clamps scale at 0.01. Bound its allocation even for + // a huge artboard, then downsize to the requested dimensions. + let scale = (f64::from(max) / width.max(height)).clamp(0.01, 1.0); + if width.max(height) * scale > 4096.0 { + return Err("artboard is too large for a bounded preview".into()); + } + let mut p = json!({"scale":scale, "artboard": index}); + if trace_views { + p["traceViews"] = json!(true); + } + let r = b.call("ui.render", p)?; + let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?; + let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?; + let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?; + let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img }; + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?; + Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()}))) +} diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs new file mode 100644 index 000000000..0448e122f --- /dev/null +++ b/crates/mcp/src/tools/handlers.rs @@ -0,0 +1,474 @@ +use super::dispatch::trace_views; +use super::*; + +// ---------- implementation ---------- + +pub(crate) type Args = Map; + +pub(super) fn req_str<'a>(a: &'a Args, k: &str) -> Result<&'a str, String> { + a.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| format!("missing string argument `{k}`")) +} + +/// A file tool's path made absolute against this process's working directory when it goes to the app +/// (`--connect`). Headless, and for an empty path, it is returned as given. +pub(super) fn absolute_path(b: &dyn Backend, p: &str) -> Result { + if p.is_empty() || !b.has_ui() { + return Ok(p.to_owned()); + } + let abs = std::path::absolute(p).map_err(|e| format!("{p}: {e}"))?; + abs.into_os_string().into_string().map_err(|abs| format!("{p}: the absolute path {} is not UTF-8", abs.to_string_lossy())) +} + +pub(super) fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result { + b.call("engine.execute", json!({"command": cmd, "params": params})) +} + +pub(super) fn need_ui(b: &dyn Backend, tool: &str) -> Result<(), String> { + if b.has_ui() { + Ok(()) + } else { + Err(format!( + "`{tool}` needs the desktop app: run `vectorcraft --control 7979` and start the server with `vectorcraft-cli mcp --connect 127.0.0.1:7979`" + )) + } +} + +/// Paint argument → `paint.setFill` / `paint.setStroke` params. +fn paint_params(v: &Value) -> Result { + Ok(match v { + Value::Null => json!({"none": true}), + Value::String(s) if s.eq_ignore_ascii_case("none") => json!({"none": true}), + Value::String(_) | Value::Array(_) => json!({"color": v}), + Value::Object(o) if ["color", "none", "swatch", "gradient"].iter().any(|k| o.contains_key(*k)) => v.clone(), + Value::Object(_) => json!({"color": v}), + other => return Err(format!("unsupported paint value {other}")), + }) +} + +/// A command a drawing tool runs and its params. +type Step<'a> = (&'a str, Value); + +/// The commands that apply `a`'s `fill`, `stroke`, `strokeWidth`, `dash`, `cap` and `join`, without +/// targets, and what each applies. +fn paint_steps(a: &Args) -> Result<(Vec>, Map), String> { + let (mut steps, mut applied) = (vec![], Map::new()); + if let Some(f) = a.get("fill") { + steps.push(("paint.setFill", paint_params(f)?)); + applied.insert("fill".into(), f.clone()); + } + if let Some(s) = a.get("stroke") { + steps.push(("paint.setStroke", paint_params(s)?)); + applied.insert("stroke".into(), s.clone()); + } + // Weight, dash, cap and join go to one stroke.set. + let mut stroke = Map::new(); + if let Some(w) = a.get("strokeWidth") { + let w = w.as_f64().ok_or("`strokeWidth` must be a number")?; + stroke.insert("weight".into(), json!(w)); + applied.insert("strokeWidth".into(), json!(w)); + } + for k in ["dash", "cap", "join"] { + if let Some(v) = a.get(k) { + stroke.insert(k.into(), v.clone()); + applied.insert(k.into(), v.clone()); + } + } + if !stroke.is_empty() { + steps.push(("stroke.set", Value::Object(stroke))); + } + Ok((steps, applied)) +} + +/// Sets the paint `a` gives on `ids` (default: the selection) and as the default for new art. +/// Returns the paint applied and the most objects one of its edits painted (the `objects` of the +/// `paint.setFill`, `paint.setStroke` and `stroke.set` replies). +pub(super) fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option) -> Result<(Value, u64), String> { + let (steps, applied) = paint_steps(a)?; + let mut objects = 0; + for (cmd, mut p) in steps { + if let Some(ids) = &ids { + p["ids"] = ids.clone(); + } + let r = exec(b, cmd, p)?; + objects = objects.max(r.get("objects").and_then(Value::as_u64).unwrap_or(0)); + } + Ok((Value::Object(applied), objects)) +} + +/// Checks `draw_path` / `draw_shape`'s `dash`, `cap`, `join`, `opacity`, `name` and `effects` +/// before anything is drawn, so a bad one leaves no half-styled object behind (#2112). +fn check_draw_options(b: &mut dyn Backend, a: &Args) -> Result<(), String> { + match a.get("dash") { + None | Some(Value::Null) => {} + Some(Value::Array(d)) if d.iter().all(|v| v.as_f64().is_some_and(f64::is_finite)) => {} + Some(_) => return Err("`dash` must be a list of lengths (numbers) or null".into()), + } + let one_of = |k: &str, names: &[&str]| match a.get(k) { + None => Ok(()), + Some(v) if v.as_str().is_some_and(|s| names.contains(&s)) => Ok(()), + Some(_) => Err(format!("`{k}` must be one of {}", names.join(", "))), + }; + one_of("cap", &["butt", "round", "square", "projecting"])?; + one_of("join", &["miter", "round", "bevel"])?; + if let Some(o) = a.get("opacity") + && !o.as_f64().is_some_and(|o| (0.0..=100.0).contains(&o)) + { + return Err("`opacity` must be a number from 0 to 100 (%)".into()); + } + if a.get("name").is_some_and(|n| !n.is_string()) { + return Err("`name` must be a string".into()); + } + let Some(effects) = a.get("effects") else { return Ok(()) }; + let effects = effects.as_array().ok_or("`effects` must be a list of {effect, params?}")?; + if effects.is_empty() { + return Ok(()); + } + let catalog = exec(b, "effect.list", json!({}))?; + let known: Vec<&str> = catalog.get("catalog").and_then(Value::as_array).into_iter().flatten().filter_map(|e| e.get("id")?.as_str()).collect(); + for (i, e) in effects.iter().enumerate() { + let e = e.as_object().ok_or_else(|| format!("effect {i} must be {{effect, params?}}"))?; + if let Some(k) = e.keys().find(|k| *k != "effect" && *k != "params") { + return Err(format!("effect {i}: unknown key `{k}` (expected effect, params)")); + } + let id = e.get("effect").and_then(Value::as_str).ok_or_else(|| format!("effect {i}: missing string `effect`"))?; + if !known.contains(&id) { + return Err(format!("effect {i}: unknown effect `{id}` (see apply_effect without arguments)")); + } + if e.get("params").is_some_and(|p| !p.is_object()) { + return Err(format!("effect {i}: `params` must be an object")); + } + } + Ok(()) +} + +/// Draws an object with `create` (a creating command and its params) and styles it with `a`'s +/// paint and draw options ([`check_draw_options`], checked first) as ONE `command.batch`, named +/// `label` (the undo step the creating command makes on its own): any step the engine refuses — a +/// colour it can't read, a swatch the document lacks — rolls the whole call back, so it leaves no +/// object and no undo step behind (#3545). The styling steps act on the new object, which the +/// creating command selects, and on its whole appearance (`item: null`) → `made` with the new +/// object's `id`, the `paint` applied and the other options applied. +fn draw(b: &mut dyn Backend, a: &Args, label: &str, create: Step<'_>, mut made: Value) -> Result { + check_draw_options(b, a)?; + let (paint, applied) = paint_steps(a)?; + let mut steps = vec![create]; + steps.extend(paint); + let mut props = json!({}); + for k in ["name", "opacity"] { + if let Some(v) = a.get(k) { + props[k] = v.clone(); + made[k] = v.clone(); + } + } + if a.contains_key("name") || a.contains_key("opacity") { + steps.push(("object.setProps", props)); + } + // The fill, stroke and effects go to the new object's whole appearance (item: null), as the + // Fill and Stroke proxies and the Effect menu apply them to a selected object. + let effects = a.get("effects").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + for e in effects { + steps.push(("effect.apply", json!({"effect": e.get("effect"), "params": e.get("params").cloned().unwrap_or(json!({}))}))); + } + let commands: Vec = steps + .into_iter() + .map(|(cmd, mut p)| { + if matches!(cmd, "paint.setFill" | "paint.setStroke" | "stroke.set" | "effect.apply") { + p["item"] = Value::Null; + } + json!({"command": cmd, "params": p}) + }) + .collect(); + // The engine names the failed step of its batch; the caller sent no batch, so the reply gives + // the step's own error. + let r = exec(b, "command.batch", json!({"label": label, "commands": commands})).map_err(|e| match e.split_once(" failed: ") { + Some((step, error)) if step.starts_with("batch step ") => error.to_string(), + _ => e, + })?; + let results = r.get("results").and_then(Value::as_array).ok_or("command.batch returned no results")?; + made["id"] = results.first().and_then(|r| r.get("id")).cloned().unwrap_or(Value::Null); + made["paint"] = Value::Object(applied); + if !effects.is_empty() { + made["effects"] = Value::Array(results.iter().skip(results.len().saturating_sub(effects.len())).cloned().collect()); + } + Ok(made) +} + +pub(super) fn draw_path(b: &mut dyn Backend, a: &Args) -> Result { + let mut p = json!({}); + if let Some(d) = a.get("d").and_then(Value::as_str) { + p["d"] = json!(d); + } else { + let pts = a.get("points").and_then(Value::as_array).ok_or("give `points` or `d`")?; + let mut anchors = vec![]; + for (i, pt) in pts.iter().enumerate() { + let anchor = match pt { + Value::Array(xy) if xy.len() >= 2 && xy[0].is_number() && xy[1].is_number() => json!({"x": xy[0], "y": xy[1]}), + Value::Object(o) if o.get("x").is_some_and(Value::is_number) && o.get("y").is_some_and(Value::is_number) => pt.clone(), + _ => return Err(format!("point {i} must be [x,y] or {{x,y,…}}")), + }; + anchors.push(anchor); + } + if anchors.is_empty() { + return Err("`points` is empty".into()); + } + p["anchors"] = Value::Array(anchors); + p["closed"] = json!(a.get("closed").and_then(Value::as_bool).unwrap_or(false)); + } + draw(b, a, "Pen", ("path.create", p), json!({})) +} + +pub(super) fn draw_shape(b: &mut dyn Backend, a: &Args) -> Result { + let shape = req_str(a, "shape")?; + // Each command and the name of the undo step it makes. + let (cmd, label) = match shape { + "rectangle" | "rect" if a.get("radius").and_then(Value::as_f64).is_some_and(|r| r > 0.0) => ("shape.rectangle", "Rounded Rectangle"), + "rectangle" | "rect" => ("shape.rectangle", "Rectangle"), + "ellipse" | "circle" => ("shape.ellipse", "Ellipse"), + "polygon" => ("shape.polygon", "Polygon"), + "star" => ("shape.star", "Star"), + "line" => ("shape.line", "Line"), + other => return Err(format!("unknown shape `{other}` (rectangle, ellipse, polygon, star, line)")), + }; + let mut p = a.clone(); + for k in ["shape", "fill", "stroke", "strokeWidth", "dash", "cap", "join", "opacity", "name", "effects"] { + p.remove(k); + } + draw(b, a, label, (cmd, Value::Object(p)), json!({"shape": shape})) +} + +/// Select `ids` when given (tools that act on the selection). +pub(super) fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> { + if let Some(ids) = a.get("ids") { + exec(b, "select.set", json!({"ids": ids}))?; + } + Ok(()) +} + +pub(super) fn add_text(b: &mut dyn Backend, a: &Args) -> Result { + let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?; + let mut style = json!({}); + for k in ["size", "font", "color", "justify", "vertical"] { + if let Some(v) = a.get(k) { + style[k] = v.clone(); + } + } + let r = if let Some(path) = a.get("path") { + let mut p = json!({"path": path, "mode": a.get("mode").cloned().unwrap_or(json!("onPath")), "text": text}); + for (k, v) in style.as_object().into_iter().flatten() { + p[k.as_str()] = v.clone(); + } + exec(b, "text.createInPath", p)? + } else { + let x = a.get("x").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?; + let y = a.get("y").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?; + let mut p = json!({"x": x, "y": y, "text": text}); + for (k, v) in style.as_object().into_iter().flatten() { + p[k.as_str()] = v.clone(); + } + if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) { + p["area"] = json!({"width": w, "height": h}); + for k in ["fit", "fitMinPercent"] { + if let Some(v) = a.get(k) { + p["area"][k] = v.clone(); + } + } + } + exec(b, "text.create", p)? + }; + if let Some(e) = a.get("pathEffect").and_then(Value::as_str) { + exec(b, "type.pathOptions", json!({"effect": e}))?; + } + let mut made = json!({"id": r.get("id")}); + // A path effect is applied after the text is made: ask again how it fits then. + let overflow = match r.get("id").filter(|_| a.get("pathEffect").is_some()) { + Some(id) => exec(b, "document.node", json!({"id": id, "summary": true}))?.get("overflow").and_then(Value::as_bool) == Some(true), + None => r.get("overflow").is_some(), + }; + if overflow { + made["overflow"] = json!(true); + } + Ok(made) +} + +pub(super) fn transform(b: &mut dyn Backend, a: &Args) -> Result { + select_ids(b, a)?; + // With `copy`, the first operation duplicates and the rest transform the copy (the new selection). + let copy = std::cell::Cell::new(a.get("copy").and_then(Value::as_bool).unwrap_or(false)); + let with = |mut p: Value| { + if let Some(o) = a.get("origin") { + p["origin"] = o.clone(); + } + p["copy"] = json!(copy.replace(false)); + p + }; + let mut done = vec![]; + if a.contains_key("dx") || a.contains_key("dy") { + let num = |k: &str| a.get(k).and_then(Value::as_f64).unwrap_or(0.0); + exec(b, "object.move", json!({"dx": num("dx"), "dy": num("dy"), "copy": copy.replace(false)}))?; + done.push("move"); + } + if let Some(r) = a.get("rotate").and_then(Value::as_f64) { + exec(b, "object.rotate", with(json!({"angle": r})))?; + done.push("rotate"); + } + let s = a.get("scale").and_then(Value::as_f64); + let (sx, sy) = (a.get("scaleX").and_then(Value::as_f64).or(s), a.get("scaleY").and_then(Value::as_f64).or(s)); + if sx.is_some() || sy.is_some() { + exec(b, "object.scale", with(json!({"sx": sx.unwrap_or(100.0), "sy": sy.unwrap_or(100.0)})))?; + done.push("scale"); + } + if let Some(axis) = a.get("reflect").and_then(Value::as_str) { + exec(b, "object.reflect", with(json!({"axis": axis})))?; + done.push("reflect"); + } + if let Some(sh) = a.get("shear").and_then(Value::as_f64) { + exec(b, "object.shear", with(json!({"angle": sh})))?; + done.push("shear"); + } + if done.is_empty() { + return Err("give at least one of dx/dy, rotate, scale/scaleX/scaleY, reflect, shear".into()); + } + Ok(json!({"applied": done})) +} + +/// Most fields one `answer_dialog` call may set. +pub(super) const MAX_DIALOG_FIELDS: usize = 256; + +/// Sets the open dialog's fields in order with one `ui.dialog.set {fields}`, which stores all of +/// them or none, then confirms or cancels it. Every argument is checked before anything is sent to +/// the app. +pub(super) fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result { + let set = match a.get("set") { + None => &[][..], + Some(Value::Array(s)) => s.as_slice(), + Some(_) => return Err("`set` must be a list of {field, value?}".into()), + }; + if set.len() > MAX_DIALOG_FIELDS { + return Err(format!("`set` holds {} fields; at most {MAX_DIALOG_FIELDS}", set.len())); + } + let mut fields = vec![]; + for (i, item) in set.iter().enumerate() { + let item = item.as_object().ok_or_else(|| format!("set {i} must be {{field, value?}}"))?; + if let Some(k) = item.keys().find(|k| *k != "field" && *k != "value") { + return Err(format!("set {i}: unknown key `{k}` (expected field, value)")); + } + let field = item.get("field").and_then(Value::as_str).ok_or_else(|| format!("set {i}: missing string `field`"))?; + fields.push(json!({"field": field, "value": item.get("value").cloned().unwrap_or(Value::Null)})); + } + let then = match a.get("action") { + None => None, + Some(v) if v == "confirm" => Some("ui.dialog.confirm"), + Some(v) if v == "cancel" => Some("ui.dialog.cancel"), + Some(_) => return Err("`action` must be confirm or cancel".into()), + }; + if fields.is_empty() && then.is_none() { + return Err("give `set` and/or `action`".into()); + } + let dialog = if fields.is_empty() { Value::Null } else { b.call("ui.dialog.set", json!({"fields": fields}))? }; + let result = match then { + Some(method) => b.call(method, json!({}))?, + None => Value::Null, + }; + Ok(json!({"dialog": dialog, "result": result})) +} + +pub(super) fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { + let path = a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?; + let path = path.as_deref(); + let window = a.get("window").and_then(Value::as_bool) == Some(true); + let max = max_side(a)?; + if max.is_some() && !window { + return Err("`max_side` applies to a window capture (`window: true`); size an artboard render with `scale`".into()); + } + let trace_views = trace_views(a)?; + // The backend writes the file, where its automation roots allow, and sends the image back too + // (`data`): this process writes nothing. + let mut p = json!({"data": true}); + if let Some(path) = path { + p["path"] = json!(path); + } + let r = if window { + need_ui(b, "screenshot {window:true}")?; + b.call("ui.screenshot", p)? + } else { + for k in ["scale", "artboard"] { + if let Some(v) = a.get(k) { + p[k] = v.clone(); + } + } + if trace_views { + p["traceViews"] = json!(true); + } + b.call("ui.render", p)? + }; + let png = match r.get("pngBase64").and_then(Value::as_str) { + Some(b64) => vectorcraft_format::base64_decode(b64).ok_or("the backend returned bad base64")?, + // An app from before `data` wrote the file without sending the image: read it back + // (loopback: the same machine). + None => match path { + Some(path) => std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?, + None if window => return old_window_capture(b, max), + None => return Err("the renderer returned no image".into()), + }, + }; + if window { + let mut info = json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path}); + let png = fit_capture(png, max, &mut info)?; + Ok(image_result(&png, info)) + } else { + Ok(image_result(&png, json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) + } +} + +/// The window of an app from before `ui.screenshot {data}`, captured through a temporary file. +fn old_window_capture(b: &mut dyn Backend, max: Option) -> Result { + let tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string(); + let r = b.call("ui.screenshot", json!({"path": tmp}))?; + let png = std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?; + // Best effort: a temporary file left behind is harmless. + std::fs::remove_file(&tmp).ok(); + let mut info = json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null}); + let png = fit_capture(png, max, &mut info)?; + Ok(image_result(&png, info)) +} + +/// `max_side`: absent, or an integer in 1..4096. +pub(super) fn max_side(a: &Args) -> Result, String> { + match a.get("max_side") { + None => Ok(None), + Some(v) => Ok(Some( + v.as_u64().filter(|v| (1..=4096).contains(v)).and_then(|v| u32::try_from(v).ok()).ok_or("`max_side` must be an integer in 1..4096")?, + )), + } +} + +/// A window capture with `max_side`: scaled down when its longer side is larger, with its own size +/// in `capturedWidth` / `capturedHeight` and the returned image's in `width` / `height`. Without +/// `max_side` the capture and `info` stay as they are. +fn fit_capture(png: Vec, max: Option, info: &mut Value) -> Result, String> { + let Some(max) = max else { return Ok(png) }; + let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read capture: {e}"))?; + info["capturedWidth"] = json!(img.width()); + info["capturedHeight"] = json!(img.height()); + if img.width().max(img.height()) <= max { + info["width"] = json!(img.width()); + info["height"] = json!(img.height()); + return Ok(png); + } + let img = img.thumbnail(max, max); + info["width"] = json!(img.width()); + info["height"] = json!(img.height()); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode capture: {e}"))?; + Ok(out.into_inner()) +} + +pub(super) fn image_result(png: &[u8], info: Value) -> ToolResult { + ToolResult { + content: vec![ + json!({"type": "image", "data": vectorcraft_format::base64_encode(png), "mimeType": "image/png"}), + json!({"type": "text", "text": info.to_string()}), + ], + is_error: false, + } +} From aaa456cfd0cc32f4c27a06d90ff6eea8387b7d9a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:30:38 +0000 Subject: [PATCH 125/199] refactor(eps): move import/ai/read.rs items to import/ai/read/{art,containers,directives,document,ops,types}.rs --- crates/eps/src/import/ai/read.rs | 2573 +------------------ crates/eps/src/import/ai/read/art.rs | 566 ++++ crates/eps/src/import/ai/read/containers.rs | 469 ++++ crates/eps/src/import/ai/read/directives.rs | 227 ++ crates/eps/src/import/ai/read/document.rs | 437 ++++ crates/eps/src/import/ai/read/ops.rs | 646 +++++ crates/eps/src/import/ai/read/types.rs | 255 ++ 7 files changed, 2616 insertions(+), 2557 deletions(-) create mode 100644 crates/eps/src/import/ai/read/art.rs create mode 100644 crates/eps/src/import/ai/read/containers.rs create mode 100644 crates/eps/src/import/ai/read/directives.rs create mode 100644 crates/eps/src/import/ai/read/document.rs create mode 100644 crates/eps/src/import/ai/read/ops.rs create mode 100644 crates/eps/src/import/ai/read/types.rs diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b037ca2af..0b1000f67 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -66,47 +66,25 @@ use super::obj::{self, Obj, V}; use super::paint::{self, GradientDef, Instance, Named}; use crate::ps; +mod art; +mod containers; +mod directives; +mod document; +mod ops; +mod types; + +use art::preview_png; +pub use document::{VariableLibrary, artboards, variables}; +pub(crate) use document::{slot_frame, slot_of}; +pub use types::IMAGE_TOO_LARGE; +use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster}; +#[cfg(test)] +pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD}; +pub(super) use types::{blend_mode, blend_name}; + #[path = "legacy_text.rs"] mod legacy_text; -/// Deepest nesting of layers, groups and dictionaries. -const MAX_DEPTH: usize = 256; -/// Most operands on the stack. -const MAX_STACK: usize = 1 << 20; -/// Most objects read. -const MAX_NODES: usize = 4_000_000; -/// The most objects all the blends of one import may lay out to be checked (each twice) and made -/// live; past it, blends keep their drawn steps. -const BLEND_BUDGET: usize = 50_000; -/// Most points in one path. -const MAX_POINTS: usize = 4_000_000; -/// Largest image read (pixels). -const MAX_PIXELS: u64 = 1 << 25; -/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads -/// larger images (#1479). -pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; -/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). -const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; -/// Largest side of the document and its artboards (points). -const MAX_SIDE: f64 = 1e6; - -/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). -fn ruler_unit(v: u8) -> Option { - Some(match v { - 0 => Unit::Inches, - 1 => Unit::Millimeters, - 2 => Unit::Points, - 3 => Unit::Picas, - 4 => Unit::Centimeters, - 6 => Unit::Pixels, - _ => return None, - }) -} -/// Art space (y up) → a pattern's space in the document's terms (y down). -const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); -/// Most patterns read (others can't fill art). -const MAX_PATTERNS: usize = 4096; - /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] pub struct Structure { @@ -131,20 +109,6 @@ pub struct Structure { pub warnings: Vec, } -/// The name of the empty group that stands for a text object, followed by the number of its -/// story in the text document when the file says. -pub(crate) const TEXT_SLOT: &str = "\u{0}text"; - -/// Is `name` that of a text slot, and which story is it of? -pub(crate) fn slot_of(name: Option<&str>) -> Option> { - name?.strip_prefix(TEXT_SLOT).map(|rest| rest.split(':').next().and_then(|s| s.parse().ok())) -} - -/// Which of its story's frames a text slot named `name` is (0 when it doesn't say). -pub(crate) fn slot_frame(name: Option<&str>) -> usize { - name.and_then(|n| n.strip_prefix(TEXT_SLOT)).and_then(|rest| rest.split_once(':')).and_then(|(_, f)| f.parse().ok()).unwrap_or(0) -} - /// Read the structure of the editing data `data`, applying the file's `%AI24_LargeCanvasScale` only /// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may /// keep the reduced one, which the PDF import reads as it is (#2031). @@ -155,307 +119,6 @@ pub fn read(data: &[u8], large_canvas: bool) -> Result { r.finish() } -/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't -/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to -/// call them (#1068). -pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> { - let found = artboards_read(data); - if !found.is_empty() { - return found; - } - // Reading can stop before the artboards (art kept in the document's data comes first): read - // their array on its own. - artboard_array(data).map_or(found, |array| { - let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec(); - alone.extend_from_slice(array); - alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n"); - artboards_read(&alone) - }) -} - -/// The `ArtboardArray` entry of the editing data `data`, from its `/Array :` to its key: found -/// from the key back, counting the dictionaries its artboards open and close. -fn artboard_array(data: &[u8]) -> Option<&[u8]> { - const KEY: &[u8] = b"%_; (ArtboardArray) ,"; - let end = data.windows(KEY.len()).rposition(|w| w == KEY)? + KEY.len(); - let head = data.get(..end)?; - let mut depth = 0usize; - let mut line_end = head.len(); - loop { - let line_start = head.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); - if let Some(text) = head.get(line_start..line_end)?.strip_prefix(b"%_") { - if text.starts_with(b";") || text.starts_with(b"/Def ;") { - depth += 1; - } else if text.starts_with(b"/") && text.windows(2).any(|w| w == b" :") { - depth = depth.checked_sub(1)?; - if depth == 0 { - return head.get(line_start..); - } - } - } - line_end = line_start.checked_sub(1)?; - } -} - -/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and -/// those of a `/Document` still open when it stopped (its art can be what stopped it). -fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { - let mut r = Reader::new(data); - let _ = r.run(); - let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); - for o in &open { - if r.artboards.is_empty() { - r.document(o); - } - } - r.artboards -} - -/// How many variables and data sets the variable library of the editing data has: XML nodes -/// (`/XMLNode`) of the `/Document` dictionary, a `variableSets` element holding `variableSet`s, each -/// with its `variables` (`variable` elements) and `sampleDataSets` (`sampleDataSet` elements). -/// VectorCraft doesn't read them. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct VariableLibrary { - pub variables: usize, - pub data_sets: usize, -} - -impl VariableLibrary { - /// The import's note that the library wasn't read, when it has a variable or a data set. - pub fn note(self) -> Option { - let Self { variables, data_sets } = self; - // Counts after a colon need no plural, so every language can translate the note whole. - (variables + data_sets > 0).then(|| { - format!("the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them") - }) - } -} - -/// The variable library of the editing data `data` ([`VariableLibrary`]), for a file whose layers -/// [`read`] can't read. Data without a `variableSets` element isn't read. -pub fn variables(data: &[u8]) -> VariableLibrary { - if super::find(data, b"variableSets").is_none() { - return VariableLibrary::default(); - } - let mut r = Reader::new(data); - let _ = r.run(); - // A `/Document` still open when reading stopped. - let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); - for o in open.iter().filter(|o| o.ty == "Document") { - variable_library(o, 0, &mut r.variables); - } - if r.variables != VariableLibrary::default() { - return r.variables; - } - // Reading can stop before the library: read its node on its own. - variable_sets_node(data).map_or(r.variables, |node| { - let mut alone = b"%_/Document :\n%_/Dictionary :\n%_/Array :\n".to_vec(); - alone.extend_from_slice(node); - alone.extend_from_slice(b"\n%_; (xmlnode-children) ,\n%_; /Recorded ,\n%_;\n"); - let mut r = Reader::new(&alone); - let _ = r.run(); - r.variables - }) -} - -/// The `variableSets` XML node of the editing data `data`, from its `/XMLNode :` line to the line -/// that closes it: found from its name back, counting the dictionaries and arrays it holds. -fn variable_sets_node(data: &[u8]) -> Option<&[u8]> { - const NAME: &[u8] = b"variableSets) /String (xmlnode-nodename) ,"; - let at = data.windows(NAME.len()).position(|w| w == NAME)?; - let opens = |text: &[u8]| text.starts_with(b"/") && text.windows(2).any(|w| w == b" :"); - let closes = |text: &[u8]| text.starts_with(b";") || text.starts_with(b"/Def ;"); - let mut depth = 0usize; - let mut line_end = data.get(..at)?.iter().rposition(|&b| b == b'\n' || b == b'\r')?; - let start = loop { - let line_start = data.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); - if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { - if closes(text) { - depth += 1; - } else if opens(text) { - match depth.checked_sub(1) { - Some(d) => depth = d, - None => break line_start, - } - } - } - line_end = line_start.checked_sub(1)?; - }; - let mut line_start = start; - loop { - let rest = data.get(line_start..)?; - let line_end = line_start + rest.iter().position(|&b| b == b'\n' || b == b'\r').unwrap_or(rest.len()); - if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { - if closes(text) { - depth = depth.checked_sub(1)?; - if depth == 0 { - return data.get(start..line_end); - } - } else if opens(text) { - depth += 1; - } - } - if line_end >= data.len() { - return None; - } - line_start = line_end + 1; - } -} - -#[derive(Clone, Debug)] -struct GState { - fill: Paint, - stroke: Paint, - width: f64, - cap: LineCap, - join: LineJoin, - miter: f64, - dash: Option, - rule: FillRule, - opacity: f32, - blend: BlendMode, - isolate: bool, - knockout: Knockout, - knockout_shape: bool, - overprint_fill: bool, - overprint_stroke: bool, - locked: bool, - hidden: bool, -} - -impl Default for GState { - fn default() -> Self { - let black = Paint::solid(vectorcraft_color::Color::BLACK); - Self { - fill: black.clone(), - stroke: black, - width: 1.0, - cap: LineCap::Butt, - join: LineJoin::Miter, - miter: 10.0, - dash: None, - rule: FillRule::NonZero, - opacity: 1.0, - blend: BlendMode::Normal, - isolate: false, - knockout: Knockout::Off, - knockout_shape: false, - overprint_fill: false, - overprint_stroke: false, - locked: false, - hidden: false, - } - } -} - -/// The blend modes by their number in `Xy`. -const BLENDS: [BlendMode; 16] = [ - BlendMode::Normal, - BlendMode::Multiply, - BlendMode::Screen, - BlendMode::Overlay, - BlendMode::SoftLight, - BlendMode::HardLight, - BlendMode::ColorDodge, - BlendMode::ColorBurn, - BlendMode::Darken, - BlendMode::Lighten, - BlendMode::Difference, - BlendMode::Exclusion, - BlendMode::Hue, - BlendMode::Saturation, - BlendMode::Color, - BlendMode::Luminosity, -]; - -/// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`). -pub(super) fn blend_name(n: usize) -> String { - format!("{:?}", blend_mode(n)) -} - -/// Blend mode `n` of `Xy` (Normal when it is out of range). -pub(super) fn blend_mode(n: usize) -> BlendMode { - BLENDS.get(n).copied().unwrap_or(BlendMode::Normal) -} - -#[derive(Debug, Default)] -struct LayerAttrs { - name: String, - visible: bool, - preview: bool, - locked: bool, - printable: bool, - dim: Option, - color: Option, -} - -#[derive(Debug)] -enum Kind { - Layer(Box), - Group, - Compound, - Clip, - Mask, - Obj(Box), -} - -#[derive(Debug)] -struct Frame { - kind: Kind, - children: Vec, - /// Opened on a `%_` line. - hidden: bool, - /// [`Reader::after_object`] when it opened. - after: bool, - /// The state when it opened (a container's lock and visibility; a dictionary's state to go - /// back to). - gs: GState, - /// The operand stack's height when it opened. - base: usize, - /// The clipping paths' indexes among the children. - clips: Vec, - /// A compound member group explicitly declares its subpaths' winding with `D`. - compound_members: bool, -} - -/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands -/// before its first operator. -#[derive(Debug, Default)] -struct PatternHead { - taken: bool, - name: String, - /// The tile in the pattern's space (y up). - tile: Option, -} - -/// An image's operands, from `XN` and before `XI`. -#[derive(Debug, Default)] -struct Raster { - space: Option, - /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to - /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). - operands: Option>, - /// Those hex digits. - hex: Vec, -} - -/// A placed file's operands, from `` ` `` and its preview, until `~`. -#[derive(Debug, Default)] -struct Place { - /// The canvas, where the file's pixels are → art space. - matrix: Option, - /// The file's box (`llx lly urx ury`): its size is the file's, in pixels. - bounds: Option, - path: String, - /// The preview as a PNG, and its size in pixels. - preview: Option<(Vec, u32, u32)>, -} - -/// What the open result says of placed files: they show the preview the file keeps of them. -const LINKED_PREVIEWS: &str = - "linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel"; - struct Reader<'a> { lex: Lexer<'a>, stack: Vec, @@ -562,71 +225,6 @@ struct Reader<'a> { type_state: legacy_text::TypeState, } -/// Operators that change nothing the reader keeps. -const IGNORED: &[&str] = &[ - "Ae", - "AE", - "Ap", - "As", - "Xd", - "Xr", - "XG", - // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. - "XC", - "Xh", - "XH", - "XF", - "Bc", - "XP", - "Np", - // Flatness (`i`): how finely curves print. - "i", - // Path resolution (`Ar`, §12): how finely curves print. - "Ar", - "TZ", - "Xs", - "Xt", - // A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path. - "XD", - "Xi", - "XI", - "`", - "LB2", - "Lc", - "Xv", - "XV", - "Xq", - "XQ", - "Xg", - "Xn", - "Bn", - "gsave", - "grestore", - "showpage", - "annotatepage", -]; - -/// Sections skipped whole, by their markers without the `AI_` prefix: begin → end. -const SKIPPED: &[(&str, &str)] = &[ - ("BeginPattern", "EndPattern"), - ("BeginBrushPattern", "EndBrushPattern"), - ("BeginSVGFilter", "EndSVGFilter"), - ("BeginSymbol", "EndSymbol"), - ("BeginPluginObject", "EndPluginObject"), - ("BeginArtStyles", "EndArtStyles"), - ("BeginArtStyleList", "EndArtStyleList"), - ("BeginSymbolList", "EndSymbolList"), - ("BeginTextDocument", "EndTextDocument"), - ("BeginEncoding", "EndEncoding"), - ("Alternate_Content", "End_Versioned_Content"), -]; - -/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor -/// shows but doesn't edit) whose PDF can't be read. -pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out"; -/// The name of the group a `/ForeignObject`'s art comes in as. -pub const NON_NATIVE_ART: &str = "Non-native art"; - /// A section comment's marker without its `AI_` prefix and value (`AI14_BeginSymbol` → /// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`. fn marker(c: &str) -> &str { @@ -733,2143 +331,4 @@ impl<'a> Reader<'a> { } Ok(()) } - - fn push(&mut self, v: V) -> Result<(), String> { - if self.stack.len() >= MAX_STACK { - return Err("it has too many values in a row".into()); - } - self.stack.push(v); - Ok(()) - } - - /// The operands of the operator being run (above the innermost dictionary's), taken off. - fn operands(&mut self) -> Vec { - let base = self.frames.last().map_or(0, |f| f.base).min(self.stack.len()); - self.stack.split_off(base) - } - - fn nums(vals: &[V]) -> Vec { - vals.iter().filter_map(V::num).collect() - } - - // ---- comments -------------------------------------------------------------------------- - - fn comment(&mut self, c: &[u8]) -> Result<(), String> { - let line = String::from_utf8_lossy(c); - let line = line.trim_end(); - if let Some((len, hex)) = self.blend_record.as_mut() { - if !line.is_empty() && line.bytes().all(|b| b.is_ascii_hexdigit()) { - // As many digits as the record says it has, no more. - let wanted = len.saturating_mul(2); - let take = wanted.saturating_sub(hex.len()).min(line.len()); - hex.extend_from_slice(line.as_bytes().get(..take).unwrap_or_default()); - if hex.len() >= wanted { - self.end_blend(); - } - return Ok(()); - } - self.end_blend(); - } - if let Some(r) = self.raster.as_mut() - && r.operands.is_some() - && !line.is_empty() - && line.bytes().all(|b| b.is_ascii_hexdigit()) - { - if r.hex.len() < MAX_HEX { - r.hex.extend_from_slice(line.as_bytes()); - } - return Ok(()); - } - let m = marker(line); - // A pattern's definition (one inside another's isn't read). - if m == "BeginPattern" && self.pattern.is_none() { - let section = self.section("EndPattern")?; - self.define_pattern(section); - return Ok(()); - } - if m == "BeginArtStyles" { - let section = self.section("EndArtStyles")?; - self.styles = super::style::art_styles(section); - return Ok(()); - } - if m == "BeginPalette" { - let section = self.section("EndPalette")?; - self.palette = super::palette::palette(section); - return Ok(()); - } - if m == "BeginEncoding" { - self.type_state.encoding_comment(line); - } - if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { - return self.skip_to(end); - } - match m { - "%BeginProlog" => return self.skip_to("%EndProlog"), - "%BeginResource" => return self.skip_to("%EndResource"), - // The art ends with the page. - "%PageTrailer" | "%Trailer" => { - self.done = true; - return Ok(()); - } - _ => {} - } - if line.starts_with("AI5_BeginLayer") { - return self.open(Kind::Layer(Box::default()), false); - } - if line.starts_with("AI5_BeginRaster") { - self.raster = Some(Raster::default()); - } else if line.starts_with("AI5_EndRaster") { - if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() { - return self.image_from(vals, &hex); - } - } else if line.starts_with("AI5_BeginPlace") { - self.place = Some(Place::default()); - } else if line.starts_with("AI5_EndPlace") { - // One that `~` didn't end isn't read. - if self.place.take().is_some() { - self.unreadable("placed files"); - } - } else if self.frames.is_empty() - && let Some(v) = line.strip_prefix("%HiResBoundingBox:") - { - self.hires = four(v).or(self.hires); - } else if self.frames.is_empty() - && let Some(v) = line.strip_prefix("%BoundingBox:") - { - self.bbox = four(v).or(self.bbox); - } else if let Some(v) = line.strip_prefix("AI3_Cropmarks:") { - let n: Vec = v.split_whitespace().filter_map(|w| w.parse().ok()).collect(); - if let [x0, y0, x1, y1] = n.as_slice() { - let r = Rect::new(*x0, *y0, *x1, *y1); - if sane(r) { - self.cropmarks = Some(r); - } - } - } else if self.frames.is_empty() - && let Some(v) = line.strip_prefix("AI5_RulerUnits:") - && let Ok(n) = v.trim().parse::() - { - self.ruler_units = Some(n); - } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { - self.art_size = two(v); - } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:") { - self.template_center = two(v); - self.template = four(v).or(self.template); - } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { - // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N - // being this; the printed page applies the same factor, so the art reaches the page's - // size once it is applied to the art space ([`Self::ensure_space`]). A value outside - // the range Illustrator writes is left alone. - if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::()) - && (1.0..=100.0).contains(&n) - { - *scale = n; - } - } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { - self.color_model = Some(v.trim() == "2"); - } - Ok(()) - } - - /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and - /// versioned content in an alternate). - fn skip_to(&mut self, end: &str) -> Result<(), String> { - self.section(end).map(|_| ()) - } - - /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. - fn section(&mut self, end: &str) -> Result<&'a [u8], String> { - let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); - let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; - let start = self.lex.offset(); - let mut depth = 0usize; - loop { - let before = self.lex.offset(); - let Some(t) = self.lex.next_token() else { break }; - if let Tok::Comment(c) = t.tok { - let line = String::from_utf8_lossy(c); - let m = marker(&line); - if !begin.is_empty() && m == begin { - depth += 1; - } else if m == end { - if depth == 0 { - return Ok(self.lex.slice(start, before)); - } - depth -= 1; - } - } - } - Err(format!("its section ending with `%{end}` doesn't end")) - } - - /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its - /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't - /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art - /// filled with it can't be read. - fn define_pattern(&mut self, section: &[u8]) { - if self.pattern_tiles.len() >= MAX_PATTERNS { - return; - } - let mut sub = Reader::new(section); - sub.pattern = Some(PatternHead::default()); - // The gradients defined so far, lent (and those it defines kept). - sub.gradients = std::mem::take(&mut self.gradients); - sub.nodes = self.nodes; - sub.template = self.template; - let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); - self.nodes = sub.nodes; - self.gradients = std::mem::take(&mut sub.gradients); - let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; - let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; - let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { - return; - }; - let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); - let mut def = PatternDef::new(&head.name, art); - def.tile = FLIP.transform_rect_bbox(tile); - if self.pattern_tiles.insert(def.name.clone(), def.tile).is_some() { - self.doc.patterns.retain(|p| p.name != def.name); - } - self.doc.patterns.push(def); - for (key, blob) in sub.doc.images { - self.doc.images.entry(key).or_insert(blob); - } - self.spots.extend(sub.spots); - self.stop_spots.extend(sub.stop_spots); - self.cmyk_colors += sub.cmyk_colors; - self.rgb_colors += sub.rgb_colors; - for w in &sub.warnings { - self.warn(w); - } - } - - /// In a pattern's definition, the operands of its first operator start with its head, - /// `(name) llx lly urx ury`: taken off them. - fn take_head(&mut self, mut vals: Vec) -> Vec { - let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals }; - head.taken = true; - let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; - let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); - let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; - head.name = name; - head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); - vals.split_off(at + 5) - } - - // ---- frames ---------------------------------------------------------------------------- - - fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> { - if self.frames.len() >= MAX_DEPTH { - return Err("its objects are nested too deeply".into()); - } - if matches!(kind, Kind::Layer(_)) { - self.ensure_space(); - } - let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) }; - self.frames.push(Frame { - kind, - children: Vec::new(), - hidden, - after: self.after_object, - gs: self.gs.clone(), - base, - clips: Vec::new(), - compound_members: false, - }); - self.after_object = false; - Ok(()) - } - - /// Close the innermost frame, which must be of the kind `is` accepts. - fn close(&mut self, what: &str, is: impl Fn(&Kind) -> bool) -> Result { - match self.frames.last() { - Some(f) if is(&f.kind) => self.frames.pop().ok_or_else(|| format!("`{what}` closes nothing")), - _ => Err(format!("`{what}` closes something it didn't open")), - } - } - - /// Add a node to the innermost container. - fn add(&mut self, node: Node, hidden: bool) -> Result<(), String> { - self.nodes += 1; - if self.nodes > MAX_NODES { - return Err(format!("it has more than {MAX_NODES} objects")); - } - if hidden { - self.hidden_ids.insert(node.id); - } - match self.frames.last_mut() { - Some(f) => f.children.push(node), - None => return Err("it has art outside its layers".into()), - } - self.after_object = true; - Ok(()) - } - - /// The last object read, while nothing else started. - fn last_object(&mut self) -> Option<&mut Node> { - if !self.after_object { - return None; - } - self.frames.last_mut()?.children.last_mut() - } - - fn new_node(&mut self, kind: NodeKind, gs: &GState) -> Node { - let mut n = Node::new(self.doc.alloc_id(), kind); - n.visible = !gs.hidden; - n.locked = gs.locked; - n - } - - fn end_container(&mut self, what: &str, hidden: bool) -> Result<(), String> { - let f = match what { - "U" => self.close(what, |k| matches!(k, Kind::Group))?, - "*U" => self.close(what, |k| matches!(k, Kind::Compound))?, - _ => self.close(what, |k| matches!(k, Kind::Clip))?, - }; - let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f; - // A group holding only clipping paths (`q … u W n U … Q`) doesn't clip on its own: `u`/`U` don't save - // the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it. - if matches!(kind, Kind::Group) - && !clips.is_empty() - && clips.len() == kids.len() - && matches!(self.frames.last(), Some(Frame { kind: Kind::Clip, .. })) - { - for kid in kids { - if let Some(parent) = self.frames.last_mut() { - parent.clips.push(parent.children.len()); - } - self.add(kid, opened_hidden || hidden)?; - } - return Ok(()); - } - // A compound path of clipping paths clips its clipping group. - let clipping = matches!(kind, Kind::Compound) && !clips.is_empty(); - self.group_ended = !matches!(kind, Kind::Compound); - let mut painted = None; - let node = match kind { - // A compound path holding a group (its drawn look) is that look: its paths paint apart. - // Some files mark every group so, the plain one holding the subpaths too (#980): one - // paint for all its paths keeps it one compound path, its holes cut. - Kind::Compound if kids.iter().any(|k| self.looks.contains(&k.id) && paints_apart(k)) => { - let (children, clip) = self.clip_first(kids, &clips); - self.new_node(NodeKind::Group { children, clip }, &gs) - } - Kind::Compound => { - // A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths' - // painted copies make a compound of their own, under what it clips. - let (fills, kids): (Vec, Vec) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id)); - painted = self.compound(fills, &gs); - let Some(n) = self.compound(kids, &gs) else { return Ok(()) }; - n - } - // A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group. - _ => { - let (children, clip) = self.clip_first(kids, &clips); - self.new_node(NodeKind::Group { children, clip }, &gs) - } - }; - if clipping && let Some(f) = self.frames.last_mut() { - f.clips.push(f.children.len()); - } - if compound_members { - self.compound_groups.insert(node.id); - } - self.add(node, opened_hidden || hidden)?; - if let Some(p) = painted { - self.clip_fills.insert(p.id); - self.add(p, opened_hidden || hidden)?; - } - Ok(()) - } - - /// The compound path of `kids`, painted and made transparent as its subpaths say. - fn compound(&mut self, mut kids: Vec, gs: &GState) -> Option { - // The compound path's paint is its last path's (a gradient's instance wraps only - // that one); the last painted one, should the last have none. - let shown = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last())?; - let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown)); - for k in &mut kids { - k.opacity = 1.0; - k.blend = BlendMode::Normal; - k.visible = true; - k.locked = false; - } - let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, gs); - n.appearance = appearance; - n.opacity = opacity; - n.blend = blend; - Some(n) - } - - /// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound - /// path → the children and whether they clip. - fn clip_first(&mut self, kids: Vec, clips: &[usize]) -> (Vec>, bool) { - let clips: std::collections::HashSet = clips.iter().copied().collect(); - let (mut clip, rest): (Vec, Vec) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i)); - let clip_node = match clip.len() { - 0 => None, - 1 => clip.pop().map(|(_, n)| n), - _ => { - let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n)); - let children = clip.into_iter().map(|(_, n)| Arc::new(n)).collect(); - Some(Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })) - } - }; - let clips = clip_node.is_some(); - // A clipping path's own fill paints under the art it clips. - let (fills, rest): (Vec, Vec) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id)); - let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect(); - (children, clips) - } - - fn end_layer(&mut self) -> Result<(), String> { - let f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; - let Kind::Layer(a) = f.kind else { return Ok(()) }; - let (children, clip) = self.clip_first(f.children, &f.clips); - let color = a.color.unwrap_or(LayerColor::Preset(0)); - let mut layer = Node::new( - self.doc.alloc_id(), - NodeKind::Layer { color, template: false, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim }, - ); - layer.name = Some(a.name.clone()); - layer.visible = a.visible; - layer.locked = a.locked; - // The layer's own opacity, blend and knockout, written after its art (#2003). - if let Some((opacity, blend, isolate, knockout)) = self.layer_state.take() { - (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); - } - match self.frames.last_mut() { - Some(parent) => { - parent.children.push(layer); - self.after_object = true; - } - None => { - self.doc.layers.push(Arc::new(layer)); - self.after_object = false; - } - } - Ok(()) - } - - // ---- operators ------------------------------------------------------------------------- - - fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { - if self.blend_record.is_some() { - self.end_blend(); - } - match w { - // The writer's placeholder for a number the object doesn't have (a radial gradient's - // highlight `-- -- angle length Bh`): a NaN keeps the other operands where they are. - "--" => return self.push(V::Num(f64::NAN)), - "[" => { - self.marks.push(self.stack.len()); - return self.push(V::Mark); - } - "]" => { - // The innermost `[` still on the stack (the stack is cut back by operators). - let mut at = self.stack.len(); - while let Some(m) = self.marks.pop() { - if matches!(self.stack.get(m), Some(V::Mark)) { - at = m; - break; - } - } - let items: Vec = self.stack.split_off(at).into_iter().skip(1).collect(); - return self.push(V::Arr(items)); - } - ":" => return self.begin_obj(hidden), - "," => { - if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() { - obj::add_entry(o, &mut self.stack, *base); - // Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's - // (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s - // `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line - // of it may start with `%_` and read as hidden tokens: a `(` there would open - // a string that swallows the rest of the layer. - let key = o.entries.last().and_then(|(k, _)| k.as_deref()); - if o.ty == "Binary" && key == Some("ASCII85Decode") { - self.lex.skip_past(b"~>"); - } else if o.ty == "ForeignObject" && key == Some("Data") { - self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec()); - } - } - return Ok(()); - } - ";" => return self.end_obj(), - "X=" => { - if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { - // `X=` also introduces legacy text frame art, read by `text_frames`. - return Ok(()); - } - self.end_path_quietly(); - return self.open(Kind::Mask, hidden); - } - "X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(), - "X+" => return Ok(()), - _ => {} - } - // What follows an object (its name, transparency and style) ends with anything else. The - // state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the `LB`. - if !matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { - self.after_object = false; - self.layer_state = None; - } - let group_ended = std::mem::take(&mut self.group_ended); - let vals = self.operands(); - let vals = self.take_head(vals); - // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so - // the operator is one the reader doesn't know, as before. - if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) { - self.note_unknown("--"); - return Ok(()); - } - if let Some(d) = self.gradient_def.as_mut() { - match w { - "Bs" => d.stop(&vals, &mut self.stop_spots), - "BD" => { - if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) { - self.gradients.insert(name, g); - } - } - _ => {} - } - return Ok(()); - } - if w == "X!" || self.mesh.is_some() { - let name = vals.iter().rev().find_map(|v| if let V::Name(n) = v { Some(n.as_str()) } else { None }); - match (w, name) { - ("X!", Some("Mesh")) => { - self.end_path_quietly(); - self.mesh = Some((super::mesh::MeshReader::default(), hidden)); - return Ok(()); - } - ("X!", Some("End")) if self.mesh.is_some() => return self.end_mesh(), - ("X#", _) => { - let to_doc = self.space(); - if let Some((m, _)) = self.mesh.as_mut() { - m.entry(&vals, to_doc); - } - return Ok(()); - } - _ if self.mesh.is_some() => return Ok(()), - _ => {} - } - } - match w { - // Containers. - "u" => { - self.end_path_quietly(); - self.open(Kind::Group, hidden)? - } - "*u" => { - self.end_path_quietly(); - self.open(Kind::Compound, hidden)? - } - "q" => { - self.end_path_quietly(); - self.open(Kind::Clip, hidden)? - } - "U" | "*U" | "Q" => self.end_container(w, hidden)?, - "Lb" => self.layer_attrs(&vals), - "Ln" => { - if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() { - a.name = vals.iter().rev().find_map(V::text).unwrap_or_default(); - } - } - "LB" => self.end_layer()?, - // Paths. - "m" | "l" | "L" | "c" | "C" | "v" | "V" | "y" | "Y" => self.path_op(w, &vals)?, - "h" => close(&mut self.path), - // Ends a path that stays open. - "H" => {} - "W" => self.clip_next = true, - // A text object's frame or path. - "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" if self.in_text_path() => self.text_path(), - "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" => self.paint(w, hidden, false)?, - "*" => { - let op = vals.iter().rev().find_map(V::text).unwrap_or_else(|| "N".into()); - self.paint(&op, hidden, true)?; - } - // Colours. - "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" | "Xs" | "XS" | "Xz" | "XZ" => self.color(w, &vals), - "p" | "P" => self.pattern_paint(w, &vals), - // A pattern's definition ends. - "E" if self.pattern.is_some() => self.done = true, - "O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0), - "R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0), - "XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero }, - "w" => { - if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 0.0) { - self.gs.width = *v; - } - } - "J" => { - self.gs.cap = match Self::nums(&vals).last().map(|v| *v as i64) { - Some(1) => LineCap::Round, - Some(2) => LineCap::Square, - _ => LineCap::Butt, - } - } - "j" => { - self.gs.join = match Self::nums(&vals).last().map(|v| *v as i64) { - Some(1) => LineJoin::Round, - Some(2) => LineJoin::Bevel, - _ => LineJoin::Miter, - } - } - "M" => { - if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 1.0) { - self.gs.miter = v.min(500.0); - } - } - "d" => self.dash(&vals), - "D" => { - // The published Adobe Illustrator File Format Specification (version 3.0), §6.8, - // permits `u` groups of compound members. - // Their explicit winding declarations describe subpaths, not separate paints; - // modern streams can also end these groups with `9 () XW`. - let n = self.frames.len(); - if n >= 2 - && self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound)) - && let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group)) - { - f.compound_members = true; - } - } - // Transparency and state. - "Xy" => self.transparency(&vals), - // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as - // the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own. - "Xd" if group_ended => { - let gs = self.gs.clone(); - if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. })) { - (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); - } - } - "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), - "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), - "XW" => self.style_marker(&vals, hidden), - // Gradients. - "Bd" => self.gradient_def = GradientDef::begin(&vals), - "Bs" | "BD" => {} - "Bb" => self.instance = Some(Instance::begin(&vals)), - "Bg" => { - if let Some(i) = self.instance.as_mut() { - i.bg(&vals); - } - } - "Bm" => { - if let Some(i) = self.instance.as_mut() { - i.bm(&vals); - } - } - "Xm" => { - if let Some(i) = self.instance.as_mut() { - i.xm(&vals); - } - } - "Bh" => { - if let Some(i) = self.instance.as_mut() { - i.bh(&vals); - } - } - "BB" => { - // An instance that closes with no path to paint is an object's own note of its - // gradient, written after it: it paints nothing (not the next path either). - if let Some(i) = self.instance.take() - && !self.path.elements().is_empty() - { - self.set_instance(&i); - } - // `1 BB` strokes the path the gradient just filled, `2 BB` closes it first (§3.5.3). - if matches!(Self::nums(&vals).last(), Some(f) if *f == 1.0 || *f == 2.0) { - self.stroke_last(); - } - } - // Images. - "XN" => { - if let Some(r) = self.raster.as_mut() { - r.space = vals.iter().rev().find_map(V::text); - } - } - // Placed files. - "`" if self.place.is_some() => { - let matrix = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }); - let path = vals.iter().rev().find_map(V::text).unwrap_or_default(); - if let Some(p) = self.place.as_mut() { - p.matrix = matrix.and_then(|m| <[f64; 6]>::try_from(m).ok()).map(Affine::new); - p.bounds = - Self::nums(&vals).get(..4).and_then(|b| <[f64; 4]>::try_from(b).ok()).map(|[x0, y0, x1, y1]| Rect::new(x0, y0, x1, y1)); - p.path = path; - } - } - "~" if self.place.is_some() => self.end_place(hidden)?, - // Type in the legacy format. - "To" => self.begin_text(&vals, hidden), - "Tp" => self.begin_text_path(&vals), - "TP" => self.end_text_path(), - "TO" => self.end_text(hidden)?, - "Tx" | "Tj" | "TX" => self.text_body(w, &vals), - "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals), - // Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation, - // Far-Eastern type) and final-form positions (§15.3). - "Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" - | "Tu" | "Xu" => {} - // Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th` - // hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe` - // tab descriptions between them). - "Th" | "XL" | "Xb" | "XB" | "Xe" => {} - "TE" => self.te(&vals), - // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). - "XI" if self.raster.is_some() => { - if let Some(r) = self.raster.as_mut() { - r.operands = Some(vals); - } - } - // A plugin group ends; a blend's record follows on `%` lines. - "XP" => { - let name = vals.iter().find_map(V::text).unwrap_or_default(); - let len = Self::nums(&vals).last().copied().unwrap_or(0.0); - if name == super::blend::PLUGIN && len.is_finite() && len > 0.0 && len <= super::blend::MAX_RECORD as f64 { - self.blend_record = Some((len as usize, vec![])); - } - } - _ if IGNORED.contains(&w) => {} - // Before the art, the setup runs procedures of the printing format. - _ if self.frames.is_empty() => {} - _ => self.note_unknown(w), - } - Ok(()) - } - - /// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file - /// is read from its PDF part. - fn note_unknown(&mut self, w: &str) { - let op: String = w.chars().take(24).collect(); - if !self.shown() { - if self.hidden_unread.len() < 16 { - self.hidden_unread.insert(format!("`{op}`")); - } - } else if self.unknown.len() < 16 { - self.unknown.insert(op); - } - } - - /// `:` after `/Type`: a dictionary opens. - fn begin_obj(&mut self, hidden: bool) -> Result<(), String> { - let ty = match self.stack.pop() { - Some(V::Name(n)) => n, - Some(other) => { - self.stack.push(other); - String::new() - } - None => String::new(), - }; - self.open(Kind::Obj(Box::new(Obj { ty, ..Obj::default() })), hidden)?; - // A dictionary's state starts afresh (styles set theirs inside). - self.gs = GState::default(); - Ok(()) - } - - /// Capture mask art in document coordinates, without adding it to the owner's siblings. - fn end_mask(&mut self) -> Result<(), String> { - self.end_path_quietly(); - let f = self.close("X+", |k| matches!(k, Kind::Mask))?; - let (children, clip) = self.clip_first(f.children, &f.clips); - let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); - self.gs = f.gs; - self.after_object = f.after; - let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { - return Err("mask art ends outside a mask dictionary".into()); - }; - if o.art.replace(Arc::new(art)).is_some() { - return Err("a mask has more than one art block".into()); - } - Ok(()) - } - - /// `;`: the innermost dictionary closes. - fn end_obj(&mut self) -> Result<(), String> { - if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { - return Ok(()); - } - let Some(f) = self.frames.pop() else { return Ok(()) }; - let Frame { kind, children, base, hidden, gs, after, .. } = f; - let Kind::Obj(mut o) = kind else { return Ok(()) }; - obj::close(&mut o, &mut self.stack, base); - self.obj_hidden = hidden; - // Art made inside a dictionary (type's paths) isn't kept. - self.gs = gs; - self.after_object = after; - if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { - return self.push(V::Obj(Rc::new(*o))); - } - match o.ty.as_str() { - "ArtDictionary" => { - let envelope = o.get("AIDeformOptionsKey").is_some(); - self.art_dictionary(&o); - if envelope { - let mut slots = vec![]; - for child in &children { - envelope_text_slots(child, &mut slots); - } - for mut slot in slots { - // The editing data keeps the source text inside the envelope's options; - // its visible result is the outlined art the PDF page carries. - slot.visible = false; - self.add(slot, false)?; - } - } - } - "Document" => self.document(&o), - "AI11Text" => self.text_slot(&o)?, - "SymbolInstance" => self.unreadable("symbols"), - "ForeignObject" => self.foreign_art(&o)?, - _ => {} - } - Ok(()) - } - - /// A text object (`/AI11Text`): an empty group standing for it, named after its story. - fn text_slot(&mut self, o: &Obj) -> Result<(), String> { - if self.pattern.is_some() { - self.unreadable("type in a pattern"); - } - // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its - // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its - // stories on its own; the object itself follows the look, on `%_` lines, and is the type. - if o.nums("FreeUndo").first() == Some(&1.0) { - return Ok(()); - } - let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); - let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); - let gs = self.gs.clone(); - let mut n = self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs); - n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}:{frame}"))); - // The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own. - n.opacity = gs.opacity; - n.blend = gs.blend; - let hidden = self.obj_hidden; - self.add(n, hidden) - } - - /// Non-native art (`/ForeignObject`): the content of a placed PDF, which Illustrator shows but - /// doesn't edit. Its `/Data` is that PDF (ASCII85 in comment lines, kept by the `,` handler), - /// `/Bounds` the art's box in a space measured from `/Origin` with y down, and `/RTransform` - /// maps that space onto the art (both read off files: the box lands on the clipping paths - /// around it). The PDF importer reads the PDF, and its art is fitted to the box, as one - /// group. Without data, or with a PDF that can't be read, nothing is drawn. - fn foreign_art(&mut self, o: &Obj) -> Result<(), String> { - let Some(text) = self.foreign_data.take() else { return Ok(()) }; - // One `%` starts each line; the rest, `_` included, is data. - let mut encoded = String::with_capacity(text.len()); - for line in text.split(|b| matches!(b, b'\r' | b'\n')) { - encoded.push_str(&String::from_utf8_lossy(line.strip_prefix(b"%").unwrap_or(line))); - } - let Some(pdf) = ps::ascii85_decode(&encoded) else { - self.warn(NON_NATIVE_ART_UNREAD); - return Ok(()); - }; - let (rt, origin, bounds) = (o.get("RTransform").map(Self::nums), o.get("Origin").map(Self::nums), o.get("Bounds").map(Self::nums)); - let (Some([a, b, c, d, tx, ty]), Some([ox, oy]), Some([x0, y0, x1, y1])) = (rt.as_deref(), origin.as_deref(), bounds.as_deref()) else { - self.warn(NON_NATIVE_ART_UNREAD); - return Ok(()); - }; - let place = Affine::new([*a, *b, *c, *d, *tx, *ty]) * Affine::new([1.0, 0.0, 0.0, -1.0, *ox, *oy]); - let art_box = place.transform_rect_bbox(Rect::new(*x0, *y0, *x1, *y1)); - if !sane(art_box) || art_box.width() <= 0.0 || art_box.height() <= 0.0 { - self.warn(NON_NATIVE_ART_UNREAD); - return Ok(()); - } - let target = self.space().transform_rect_bbox(art_box); - let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() }; - let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) { - Ok(r) => r, - Err(e) => { - self.warn(&format!("{NON_NATIVE_ART_UNREAD} ({e})")); - return Ok(()); - } - }; - let mut art: Vec = read.document.layers.iter().filter_map(|l| l.children()).flatten().map(|n| n.as_ref().clone()).collect(); - let Some(bbox) = art.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.geometric_bounds())) else { return Ok(()) }; - if bbox.width() <= 0.0 || bbox.height() <= 0.0 { - return Ok(()); - } - // The art's own box onto the object's box (its measure of the same art). The PDF importer - // hands the art back as the page shows it, and it keeps that orientation unless its - // placement mirrors it (a negative determinant): the usual `/RTransform` `1 0 0 -1 …` with - // the origin's flip doesn't, and the art then shows as the file's own PDF part draws it. - let fit = if place.determinant() < 0.0 { - Affine::translate((target.x0, target.y1)) * Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height()) - } else { - Affine::translate((target.x0, target.y0)) * Affine::scale_non_uniform(target.width() / bbox.width(), target.height() / bbox.height()) - } * Affine::translate((-bbox.x0, -bbox.y0)); - for n in &mut art { - n.transform(fit, true); - // The PDF's swatches stay with it: its colours keep their values, unlinked. - self.reid(n); - } - for (key, blob) in read.document.images { - self.doc.images.entry(key).or_insert(blob); - } - let gs = self.gs.clone(); - let mut group = self.new_node(NodeKind::Group { children: art.into_iter().map(Arc::new).collect(), clip: false }, &gs); - group.name = Some(NON_NATIVE_ART.into()); - self.add(group, self.obj_hidden) - } - - /// Give `n` and its descendants ids of this document, and unlink their swatches. - fn reid(&mut self, n: &mut Node) { - n.id = self.doc.alloc_id(); - for it in &mut n.appearance.items { - let p = match it { - AppearanceItem::Fill(f) => &mut f.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Gradient(g) = p { - g.swatch = None; - } - p.map_links(&mut |_, link, _| { - *link = None; - true - }); - } - if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { - self.reid(Arc::make_mut(&mut m.art)); - } - if let Some(children) = n.children_mut() { - for c in children { - self.reid(Arc::make_mut(c)); - } - } - } - - /// Note something the data has that isn't read: an error on a layer that shows, left out of a - /// layer that doesn't. - fn unreadable(&mut self, what: &str) { - if self.shown() { - self.unsupported.insert(what.into()); - } else { - self.hidden_unread.insert(what.into()); - } - } - - /// Drop the object just read when it is an envelope's own mesh: a group on `%_` lines holding - /// only gradient meshes. Whether it was dropped. - fn drop_envelope_mesh(&mut self) -> bool { - fn only_meshes(n: &Node) -> bool { - match &n.kind { - NodeKind::Mesh(_) => true, - NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| only_meshes(c)), - _ => false, - } - } - let Some(n) = self.last_object() else { return false }; - let id = n.id; - if !matches!(n.kind, NodeKind::Group { .. }) || !only_meshes(n) || !self.hidden_ids.contains(&id) { - return false; - } - if let Some(f) = self.frames.last_mut() { - f.children.pop(); - } - self.hidden_ids.remove(&id); - self.after_object = false; - true - } - - /// Do the layers being read show? - fn shown(&self) -> bool { - self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible)) - } - - /// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_` - /// lines) and its drawn steps (the group after it) become one live blend, or, where they can't, - /// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]). - fn end_blend(&mut self) { - let Some((_, hex)) = self.blend_record.take() else { return }; - let ids = super::blend::hex_bytes(&hex).as_deref().and_then(super::blend::record_ids); - let roles = &self.blend_roles; - let Some(f) = self.frames.last_mut() else { return }; - let n = f.children.len(); - let (Some(at), Some(drawn)) = (n.checked_sub(2), n.checked_sub(1)) else { return }; - let part_of_blend = |node: &Node| node.children().is_some_and(|c| c.iter().any(|k| roles.contains_key(&k.id))); - let (Some(edit), Some(steps)) = (f.children.get(at), f.children.get(drawn)) else { return }; - if !part_of_blend(edit) { - return; - } - if !matches!(steps.kind, NodeKind::Group { clip: false, .. }) { - // The steps aren't a group of drawn steps: the blend's own art goes all the same, so - // its keys and spine aren't drawn over what the file draws. - f.children.remove(at); - self.warn(super::blend::DRAWN); - return; - } - let mut budget = self.blend_budget; - let live = ids.and_then(|ids| super::blend::live_blend(edit, steps, &ids, roles, &mut budget)); - self.blend_budget = budget; - let Some(mut steps) = f.children.pop() else { return }; - let Some(_edit) = f.children.pop() else { return }; - match live { - Some(kind) => { - // The steps the blend lays out count as the objects they draw, as its drawn steps - // did (they were counted as read). - let mut blend = Node::new(self.doc.alloc_id(), kind); - (blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked); - (blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout); - f.children.push(blend); - } - None => { - f.children.push(steps); - self.warn(super::blend::DRAWN); - } - } - } - - /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the - /// opacity mask it may carry. - fn art_dictionary(&mut self, o: &Obj) { - // A blend's key object or spine. - if let Some(kind) = o.text("ShapeBlendPathType") - && let Some(id) = self.last_object().map(|n| n.id) - && self.blend_roles.len() < 100_000 - { - self.blend_roles.insert(id, (kind, o.text("ShapeBlendPathID"))); - } - // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as - // a group holding the mesh that shapes it; the distorted art the file's PDF part draws - // follows on plain lines. That mesh isn't art, so it goes (#2405). - if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_mesh() { - return; - } - if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { - if let Some(art) = &mask.art { - let on = |key: &str| mask.nums(key).first().copied() == Some(1.0); - let value = - OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") }; - if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { - n.extras_mut().mask = Some(Box::new(value)); - } else if !on("Disabled") { - self.unreadable("opacity masks without an owning object"); - } - } else if mask.nums("Disabled").first().copied() != Some(1.0) { - self.unreadable("opacity masks without readable art"); - } - } - let name = match o.text("AIArtName") { - Some(name) => name, - None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) { - Some(id) => obj::xml_name(&id), - None => return, - }, - }; - let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. }) && !name.is_empty()) else { return }; - if slot_of(n.name.as_deref()).is_some() { - let id = n.id; - self.slot_names.insert(id, name); - } else { - n.name = Some(name); - } - } - - /// The `/Document` dictionary: the artboards, and how many variables and data sets its variable - /// library has. - fn document(&mut self, o: &Obj) { - variable_library(o, 0, &mut self.variables); - let Some(list) = find_entry(o, "ArtboardArray", 0) else { return }; - for (i, ab) in list.items().enumerate().take(1000) { - let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2")); - let ([x0, y0, ..], [x1, y1, ..]) = (p1.as_slice(), p2.as_slice()) else { continue }; - let r = Rect::new(*x0, *y0, *x1, *y1).abs(); - if !sane(r) || r.area() <= 0.0 { - continue; - } - let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); - self.artboards.push((name, r)); - } - } - - /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place - /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). - fn layer_attrs(&mut self, vals: &[V]) { - let n = Self::nums(vals); - let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; - let flag = |i: usize, default: bool| n.get(i).map_or(default, |v| *v != 0.0); - a.visible = flag(0, true); - a.preview = flag(1, true); - a.locked = !flag(2, true); - a.printable = flag(3, true); - let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); - a.dim = flag(4, false).then_some(dim_pct.round() as u8); - let at = if n.len() <= 10 { 6 } else { 7 }; - let index = n.get(at).copied().unwrap_or(0.0); - let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); - a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { - LayerColor::Preset(index as u8) - } else { - LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) - }); - } - - /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): - /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't - /// read can't be drawn. - fn pattern_paint(&mut self, w: &str, vals: &[V]) { - // In the setup it paints nothing (and the art's space isn't fixed yet). - if self.frames.is_empty() { - return; - } - let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); - let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { - self.unreadable("pattern fills"); - return; - }; - let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); - let Some(xf) = pattern_xf(self.space(), matrix, tile) else { - self.unreadable("pattern fills"); - return; - }; - let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; - if w == "p" { - self.gs.fill = p; - } else { - self.gs.stroke = p; - } - } - - fn color(&mut self, w: &str, vals: &[V]) { - let Some((p, named)) = paint::color_op(w, vals) else { return }; - // Registration (`Xs`, `Xz`) says nothing of the document's colour model. - let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ"); - match p.color().filter(|_| !registration) { - Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, - Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, - _ => {} - } - if let Some(n) = named { - self.spots.push(n); - } - // A colour set after a gradient instance that painted nothing (`Bb` … `0 BB` with no - // path) is the next object's paint, not the gradient. - if w.ends_with(|c: char| c.is_ascii_lowercase()) { - self.gs.fill = p; - self.pending_fill = None; - } else { - self.gs.stroke = p; - self.pending_stroke = None; - } - } - - /// `[dashes] phase d`. - fn dash(&mut self, vals: &[V]) { - let pattern: Vec = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }).unwrap_or_default(); - let offset = Self::nums(vals).last().copied().unwrap_or(0.0); - self.gs.dash = (!pattern.is_empty() && pattern.len() <= 64 && pattern.iter().all(|v| v.is_finite() && *v >= 0.0)).then(|| Dash { - pattern, - offset: if offset.is_finite() { offset } else { 0.0 }, - align_corners: false, - }); - } - - /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too. - fn transparency(&mut self, vals: &[V]) { - let n = Self::nums(vals); - let [mode, opacity, rest @ ..] = n.as_slice() else { return }; - let gs = &mut self.gs; - gs.blend = BLENDS.get(*mode as usize).copied().filter(|_| *mode >= 0.0).unwrap_or(BlendMode::Normal); - gs.opacity = if opacity.is_finite() { opacity.clamp(0.0, 1.0) as f32 } else { 1.0 }; - gs.isolate = rest.first().is_some_and(|v| *v != 0.0); - gs.knockout = match rest.get(1).map(|v| *v as i64) { - Some(1) => Knockout::On, - Some(2) => Knockout::Neutral, - _ => Knockout::Off, - }; - gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); - let gs = self.gs.clone(); - if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { - if let Some(n) = self.last_object() { - n.opacity = gs.opacity; - n.blend = gs.blend; - n.isolate = gs.isolate; - n.knockout = gs.knockout; - } - self.layer_state = None; - } else { - // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until - // an operator that isn't part of the state block (or the `LB` that takes it) says the - // art moves on, so a path's state before its paint isn't read as its layer's. - self.layer_state = - matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })).then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); - } - } - - /// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its - /// drawn look before it (all on `%_` lines inside another such object). Other markers end what - /// follows an object. - fn style_marker(&mut self, vals: &[V], hidden: bool) { - let code = Self::nums(vals).last().copied().unwrap_or(0.0); - let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); - let after = std::mem::take(&mut self.after_object); - if code == 9.0 - && let Some(look) = self - .frames - .last() - .and_then(|f| f.children.last()) - .filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id)) - { - self.looks.insert(look.id); - } - if code != 1.0 || style.is_empty() || !after { - return; - } - let space = self.space(); - let Some(f) = self.frames.last_mut() else { return }; - let n = f.children.len(); - let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else { - return; - }; - // The look is a group of paths, or one image where an effect (a blur, a shadow) rasterizes it. - if !self.hidden_ids.contains(&object.id) - || self.hidden_ids.contains(&look.id) != hidden - || self.text_looks.contains(&look.id) - || !matches!(look.kind, NodeKind::Group { clip: false, .. } | NodeKind::Image(_)) - { - return; - } - // A style read here: the object, painted as it says. - if let Some(appearance) = self - .styles - .get(&style) - .and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space })) - { - let Some(mut object) = f.children.pop() else { return }; - let Some(look) = f.children.pop() else { return }; - if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { - self.restyled.push(b); - } - object.appearance = appearance.into(); - // The object's transparency is written on its look, after the look's group ends. - (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); - // Now the object itself, not a copy an enclosing look's `strip` would remove. - self.hidden_ids.remove(&object.id); - f.children.push(object); - return; - } - let is_text = slot_of(object.name.as_deref()).is_some(); - let Some(mut object) = f.children.pop() else { return }; - let Some(look) = f.children.last_mut() else { return }; - // Inside a copy on `%_` lines everything is, so nothing tells the look's own copies apart. - if !hidden { - strip(look, &self.hidden_ids); - } - if is_text { - // A text object stays the type it is: over what else its look draws, or in place of a - // look that was only its type. Its transparency is written on its look. - let empty = look.children().is_some_and(|c| c.is_empty()); - if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate) { - (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); - if empty { - *look = object; - } else { - f.children.push(object); - } - return; - } - // The look and the type go into a group that has the transparency. - let Some(mut look) = f.children.pop() else { return }; - let state = (look.opacity, look.blend, look.isolate, look.knockout); - (look.opacity, look.blend, look.isolate, look.knockout) = (1.0, BlendMode::Normal, false, Knockout::Neutral); - (object.opacity, object.blend, object.isolate) = (1.0, BlendMode::Normal, false); - let mut group = Node::group(self.doc.alloc_id(), vec![Arc::new(look), Arc::new(object)]); - (group.opacity, group.blend, group.isolate, group.knockout) = state; - self.text_looks.insert(group.id); - if let Some(f) = self.frames.last_mut() { - f.children.push(group); - } - return; - } - look.name = object.name.clone().or(look.name.take()); - look.visible = object.visible; - look.locked = object.locked; - if !hidden { - self.drawn_looks += 1; - } - } - - // ---- paths ----------------------------------------------------------------------------- - - fn path_op(&mut self, w: &str, vals: &[V]) -> Result<(), String> { - let n = Self::nums(vals); - let p = |i: usize| -> Option { - let at = n.len().checked_sub(i)?; - Some(Point::new(*n.get(at)?, *n.get(at + 1)?)) - }; - self.points += 1; - if self.points > MAX_POINTS { - return Err("a path has too many points".into()); - } - let current = self.path.elements().last().and_then(|e| e.end_point()); - match w { - "m" => { - let Some(a) = p(2) else { return Ok(()) }; - self.path.move_to(a); - } - _ if current.is_none() => {} - "l" | "L" => { - if let Some(a) = p(2) { - self.path.line_to(a); - } - } - "c" | "C" => { - if let (Some(a), Some(b), Some(c)) = (p(6), p(4), p(2)) { - self.path.curve_to(a, b, c); - } - } - "v" | "V" => { - if let (Some(cur), Some(b), Some(c)) = (current, p(4), p(2)) { - self.path.curve_to(cur, b, c); - } - } - _ => { - if let (Some(a), Some(c)) = (p(4), p(2)) { - self.path.curve_to(a, c, c); - } - } - } - Ok(()) - } - - /// A path left unpainted before a container opens. - fn end_path_quietly(&mut self) { - self.path = BezPath::new(); - self.points = 0; - self.clip_next = false; - } - - /// Paint the current path (`op`), as a guide when `guide`. - fn paint(&mut self, op: &str, hidden: bool, guide: bool) -> Result<(), String> { - let mut bp = std::mem::take(&mut self.path); - self.points = 0; - let clip = std::mem::take(&mut self.clip_next); - if let Some(i) = self.instance.take() { - self.set_instance(&i); - } - let (fill_paint, stroke_paint) = (self.pending_fill.take(), self.pending_stroke.take()); - if bp.elements().is_empty() { - return Ok(()); - } - if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { - close(&mut bp); - } - let to_doc = self.space(); - let path = PathData::from_bezpath(&(to_doc * bp)); - let gs = self.gs.clone(); - let (fill, stroke) = match op.to_ascii_lowercase().as_str() { - "f" => (true, false), - "s" => (false, true), - "b" => (true, true), - _ => (false, false), - }; - let mut items = Vec::new(); - if fill && !clip { - items.push(AppearanceItem::Fill(FillLayer { - overprint: gs.overprint_fill, - ..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone())) - })); - } - if stroke && !clip { - let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width); - st.cap = gs.cap; - st.join = gs.join; - st.miter_limit = gs.miter; - st.dash = gs.dash.clone(); - st.overprint = gs.overprint_stroke; - items.push(AppearanceItem::Stroke(st)); - } - let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide }; - let mut node = self.new_node(kind, &gs); - node.appearance = Appearance { items, ..Appearance::default() }.into(); - node.opacity = gs.opacity; - node.blend = gs.blend; - node.knockout_shape = gs.knockout_shape; - if clip && let Some(f) = self.frames.last_mut() { - f.clips.push(f.children.len()); - } - // A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it. - let painted = clip && fill && !matches!(gs.fill, Paint::None); - let copy = painted.then(|| { - let mut copy = node.clone(); - copy.id = self.doc.alloc_id(); - if let NodeKind::Path { clipping, .. } = &mut copy.kind { - *clipping = false; - } - let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone()); - copy.appearance = Appearance { - items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })], - ..Appearance::default() - } - .into(); - copy - }); - self.add(node, hidden)?; - if let Some(copy) = copy { - self.clip_fills.insert(copy.id); - self.add(copy, hidden)?; - } - Ok(()) - } - - /// `/End X!`: the gradient mesh read, as one object. - fn end_mesh(&mut self) -> Result<(), String> { - let Some((m, hidden)) = self.mesh.take() else { return Ok(()) }; - if m.unread || m.patches.is_empty() { - self.unreadable("gradient meshes it can't read"); - return Ok(()); - } - let gs = self.gs.clone(); - // One mesh where the patches join up into the grid they say they make; else each patch is - // a mesh of its own, in a group. - match m.one_mesh() { - Ok(one) => { - let mut node = self.new_node(NodeKind::Mesh(one), &gs); - node.opacity = gs.opacity; - node.blend = gs.blend; - return self.add(node, hidden); - } - Err(super::mesh::NotOne::Unjoined) => self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches"), - Err(super::mesh::NotOne::Unplaced) => {} - } - let mut patches: Vec = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect(); - let mut node = match patches.len() { - 1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)), - _ => self.new_node(NodeKind::Group { children: patches.into_iter().map(Arc::new).collect(), clip: false }, &gs), - }; - node.opacity = gs.opacity; - node.blend = gs.blend; - self.add(node, hidden) - } - - /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. - fn stroke_last(&mut self) { - let gs = self.gs.clone(); - let Some(n) = self.last_object() else { return }; - if !matches!(n.kind, NodeKind::Path { clipping: false, .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) { - return; - } - let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width); - st.cap = gs.cap; - st.join = gs.join; - st.miter_limit = gs.miter; - st.dash = gs.dash.clone(); - st.overprint = gs.overprint_stroke; - let mut a = (*n.appearance).clone(); - a.items.push(AppearanceItem::Stroke(st)); - n.appearance = a.into(); - } - - fn set_instance(&mut self, i: &Instance) { - let mut to_doc = self.space(); - // In a pattern's definition a gradient's `Xm` and `Bm` are written y down about the canvas point of art space's - // origin, as the file's PDF part draws them. - if self.pattern.is_some() - && i.has_matrix() - && let Some(t) = self.template - { - let (cx, cy) = ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0); - to_doc *= Affine::new([1.0, 0.0, 0.0, -1.0, CANVAS_CENTRE - cx, CANVAS_CENTRE + cy]); - } - match i.paint(&self.gradients, to_doc) { - Some(p) if i.stroke => self.pending_stroke = Some(p), - Some(p) => self.pending_fill = Some(p), - None => { - let name = i.name.clone().unwrap_or_default(); - self.warn(&format!("gradient “{name}” isn't defined in the file: a plain colour stands in for it")); - } - } - } - - // ---- images ---------------------------------------------------------------------------- - - /// The samples after `XI`, its operands on the stack. - fn image(&mut self, data: &[u8]) -> Result<(), String> { - let vals = self.operands(); - // Operands that came before an `XI` on their own line. - let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) { - Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, - _ => vals, - }; - self.image_from(vals, data) - } - - /// An image of operands `vals` and samples `data`. - fn image_from(&mut self, vals: Vec, data: &[u8]) -> Result<(), String> { - let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); - let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - }; - let n = Self::nums(&vals); - let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - }; - let (w, h) = (*w as u64, *h as u64); - if w.saturating_mul(h) > MAX_PIXELS { - self.warn(IMAGE_TOO_LARGE); - return Ok(()); - } - if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - } - let channels: usize = match space.as_str() { - "DeviceGray" => 1, - "DeviceCMYK" => 4, - "DeviceRGB" => 3, - _ => match *kind as i64 { - 1 => 1, - 4 => 4, - _ => 3, - }, - }; - let decoded; - let data = if *binary == 0.0 { - let digits: Vec = data.iter().copied().filter(|b| b.is_ascii_hexdigit()).collect(); - decoded = super::super::lex::hex_decode(&digits).unwrap_or_default(); - decoded.as_slice() - } else { - data - }; - let row = if *bits == 1.0 { (w as usize * channels).div_ceil(8) } else { w as usize * channels }; - let need = row.saturating_mul(h as usize); - // An alpha channel follows the colour samples, one byte a pixel. - let alpha_len = if *alpha > 0.0 { (w * h) as usize } else { 0 }; - let (Some(samples), opacity) = (data.get(..need), data.get(need..need.saturating_add(alpha_len))) else { - self.warn("an image whose data is cut short was left out"); - return Ok(()); - }; - let opacity = opacity.filter(|_| alpha_len > 0); - // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK - // image's, and an alpha channel that hides part of the image becomes its opacity mask. - if channels == 4 - && *bits == 8.0 - && let Some(blob) = Inks::new(w as u32, h as u32, samples.to_vec()).and_then(|i| ImageBlob::cmyk_tiff(&i)) - { - return self.add_raster(blob, opacity, &m, (w as u32, h as u32)); - } - let mut rgba = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h as usize { - let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); - for x in 0..w as usize { - // A 1-bit row holds each pixel's channels in turn, a bit each, padded to a whole - // byte (`row`); a set bit is the channel at full strength (255). - let s = |c: usize| { - let i = x * channels + c; - if *bits == 1.0 { line.get(i / 8).map_or(0, |b| ((b >> (7 - i % 8)) & 1) * 255) } else { line.get(i).copied().unwrap_or(0) } - }; - let px = match channels { - 1 => [s(0); 3], - 4 => { - let key = [s(0), s(1), s(2), s(3)]; - if self.cmyk_cache.len() > 1 << 16 { - self.cmyk_cache.clear(); - } - *self.cmyk_cache.entry(key).or_insert_with(|| cmyk_rgb(key)) - } - _ => [s(0), s(1), s(2)], - }; - let a = opacity.and_then(|o| o.get(y * w as usize + x)).copied().unwrap_or(255); - rgba.extend_from_slice(&[px[0], px[1], px[2], a]); - } - } - let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - }; - let mut png = vec![]; - if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - } - self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32)) - } - - /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with - /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the - /// image. - fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { - // The matrix is an art-space matrix (y up) applied to the pixels with their rows running - // downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead - // gives the same place only when the image isn't rotated (b = c = 0). - let [a, b, c, d, tx, ty] = m else { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - }; - let to_doc = self.space(); - let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP; - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - self.warn("an image VectorCraft can't read was left out"); - return Ok(()); - } - let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf)); - let key = blob.content_key(); - self.doc.images.entry(key.clone()).or_insert(blob); - self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }, mask, false) - } - - /// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its - /// opacity mask, as in PDF import. `None` when it hides nothing. - fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { - if alpha.iter().all(|v| *v == 255) { - return None; - } - let mut png = Vec::new(); - image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - let blob = ImageBlob::new("image/png", png); - let key = blob.content_key(); - self.doc.images.entry(key.clone()).or_insert(blob); - let art = Node::new( - self.doc.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), - ); - Some(OpacityMask::new(art, true)) - } - - /// Add an image object with the state's transparency. - fn add_image(&mut self, im: ImageObject, hidden: bool) -> Result<(), String> { - self.add_image_masked(im, None, hidden) - } - - /// Add an image object with the state's transparency and `mask` as its opacity mask. - fn add_image_masked(&mut self, im: ImageObject, mask: Option, hidden: bool) -> Result<(), String> { - let gs = self.gs.clone(); - let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); - node.opacity = gs.opacity; - node.blend = gs.blend; - node.extras_mut().mask = mask.map(Box::new); - self.add(node, hidden) - } - - /// `~`: the placed file read since `%AI5_BeginPlace` becomes a linked image in its place that - /// shows the preview the data keeps of it, as a linked image whose file is missing does. One - /// without a preview, a path or a matrix isn't read. - fn end_place(&mut self, hidden: bool) -> Result<(), String> { - let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty()) - else { - self.unreadable("placed files"); - return Ok(()); - }; - // The file's pixels (y down) start at the canvas point of art space's origin (the box's own - // corner when the file doesn't say where the canvas is); the preview's are spread over them. - let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); - let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y)); - let over_file = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(b.width() / f64::from(w), b.height() / f64::from(h)); - let xf = self.space() * m * over_file; - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - self.unreadable("placed files"); - return Ok(()); - } - // The preview is all the document has of the file: what a missing link shows. - let bytes = Arc::new(png); - let blob = ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }; - let key = blob.content_key(); - self.doc.images.entry(key.clone()).or_insert(blob); - self.warn(LINKED_PREVIEWS); - self.add_image( - ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None }, - hidden, - ) - } - - // ---- the document ---------------------------------------------------------------------- - - /// The artboard of a file without artboards or crop marks: its size around the template - /// box's centre (half a point off the grid). - fn sized_board(&self) -> Option { - let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?); - // Rounded out, but not off a whole point the decimals in the file only just miss. - let near = |v: f64| (v - v.round()).abs() < 1e-6; - let (left, top) = (cx - w / 2.0, cy + h / 2.0); - let x0 = if near(left) { left.round() } else { left.floor() }; - let y1 = if near(top) { top.round() } else { top.ceil() }; - let r = Rect::new(x0, y1 - h, x0 + w, y1); - (sane(r) && w > 0.0 && h > 0.0).then_some(r) - } - - /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. - fn first_board(&self) -> Option { - self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) - } - - /// The page of a file that says nothing of its artboard (the legacy format as other apps write - /// it): its art's bounding box. - fn bbox_board(&self) -> Option { - let [x0, y0, x1, y1] = self.hires.or(self.bbox)?; - Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0) - } - - /// Art space → the document (fixed when the art starts, from the artboards read by then). A - /// Large Canvas editing copy is written at 1/N scale ([`Self::large_canvas`]): the factor makes - /// its art and boxes reach the page's size, which is what the page it is opened beside holds. - fn ensure_space(&mut self) -> Affine { - if let Some(m) = self.to_doc { - return m; - } - let first = self.first_board().or_else(|| self.bbox_board()); - let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); - let s = self.large_canvas.unwrap_or(1.0); - let m = Affine::new([s, 0.0, 0.0, -s, -s * top_left.x, s * top_left.y]); - self.to_doc = Some(m); - m - } - - /// Art space → where the art being read goes: the document, or while a pattern's definition is - /// read, the pattern's space (y down). - fn space(&mut self) -> Affine { - if self.pattern.is_some() { FLIP } else { self.ensure_space() } - } - - fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 { - self.warnings.push(w.to_string()); - } - } - - fn finish(mut self) -> Result { - if !self.unknown.is_empty() { - let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); - self.unsupported.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", "))); - } - if !self.unsupported.is_empty() { - let list: Vec<&str> = self.unsupported.iter().map(String::as_str).collect(); - return Err(format!("it has {}, which VectorCraft doesn't read from it yet", join_and(&list))); - } - if self.doc.layers.is_empty() { - return Err("it has no layers".into()); - } - let to_doc = self.ensure_space(); - let artboard = self.first_board(); - drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); - let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); - let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { - artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() - } else { - self.artboards.clone() - }; - doc.artboards = boards - .into_iter() - .enumerate() - .map(|(i, (name, r))| Artboard { - id: i as u32 + 1, - name, - rect: to_doc.transform_rect_bbox(r), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }) - .collect(); - if doc.artboards.is_empty() { - let rect = doc.art_bounds().filter(|r| sane(*r) && r.area() > 0.0).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); - doc.artboards = vec![Artboard { - id: 1, - name: "Artboard 1".into(), - rect, - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }]; - } - if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { - doc.color_mode = ColorMode::Cmyk; - let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); - (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); - } - if let Some(u) = self.ruler_units.and_then(ruler_unit) { - doc.units = u; - } - // The file's own swatches in place of the defaults, in its order and colour groups. - if !self.palette.is_empty() { - let palette = std::mem::take(&mut self.palette); - let swatch = |e: super::palette::Entry| -> Option { - use super::palette::Cell; - let (paint, global, spot) = match e.cell { - Cell::Color { color, global, spot } => (Paint::solid(color), global, spot), - Cell::Gradient(g) => { - let mut gp = vectorcraft_color::gradient::GradientPaint::new(self.gradients.get(&g)?.clone()); - gp.swatch = Some(e.name.clone()); - (Paint::Gradient(Box::new(gp)), false, false) - } - Cell::Pattern(p) => { - doc.patterns.iter().find(|d| d.name == p)?; - (Paint::Pattern { pattern: p, xf: Affine::IDENTITY }, false, false) - } - }; - Some(Swatch { name: e.name, paint, global, spot }) - }; - let mut swatches = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }]; - swatches.extend(palette.swatches.into_iter().filter_map(swatch)); - let groups: Vec<_> = palette - .groups - .into_iter() - .map(|(name, s)| vectorcraft_color::swatch::SwatchGroup { name, swatches: s.into_iter().filter_map(swatch).collect() }) - .collect(); - (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); - } - // The colour operators' definitions first: a gradient, defined before the art, names a - // global process colour as a stop and would otherwise make it an ink (#1771). - for n in self.spots.iter().chain(&self.stop_spots) { - if doc.swatch(&n.name).is_none() { - doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); - } - } - for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { - if doc.swatch(&name).is_none() { - doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); - } - } - let mut warnings = self.warnings; - if self.drawn_looks > 0 { - warnings.push(format!( - "objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet", - self.drawn_looks - )); - } - warnings.extend(self.variables.note()); - Ok(Structure { - doc, - to_doc, - bbox: self.hires.or(self.bbox), - artboard, - template: self.template, - hidden_unread: self.hidden_unread, - slot_names: self.slot_names, - restyled: self.restyled, - warnings, - }) - } -} - -/// The source text inside an envelope options dictionary, carried through its containing metadata. -fn envelope_text_slots(node: &Node, out: &mut Vec) { - if slot_of(node.name.as_deref()).is_some() { - out.push(node.clone()); - } else if let Some(children) = node.children() { - for child in children { - envelope_text_slots(child, out); - } - } -} - -/// The text objects written twice, plainly and on `%_` lines: the commented copy (`hidden`) of a -/// story that also has a plain one goes. -fn drop_commented_copies(layers: &mut [Arc], hidden: &HashSet) { - fn plain(nodes: &[Arc], hidden: &HashSet, out: &mut HashSet) { - for n in nodes { - match slot_of(n.name.as_deref()) { - Some(Some(story)) if !hidden.contains(&n.id) => { - out.insert(story); - } - _ => { - if let Some(c) = n.children() { - plain(c, hidden, out); - } - } - } - } - } - fn drop(nodes: &mut Vec>, hidden: &HashSet, plain: &HashSet) { - nodes.retain(|n| !(hidden.contains(&n.id) && slot_of(n.name.as_deref()).flatten().is_some_and(|s| plain.contains(&s)))); - for n in nodes.iter_mut() { - if n.children().is_some_and(|c| !c.is_empty()) - && let Some(c) = Arc::make_mut(n).children_mut() - { - drop(c, hidden, plain); - } - } - } - let mut stories = HashSet::new(); - plain(layers, hidden, &mut stories); - if stories.is_empty() { - return; - } - for l in layers.iter_mut() { - if let Some(c) = Arc::make_mut(l).children_mut() { - drop(c, hidden, &stories); - } - } -} - -/// A node and its index among its siblings. -type Indexed = (usize, Node); - -/// Close the subpath `bp` ends with, if it has one open. -fn close(bp: &mut BezPath) { - if bp.elements().last().is_some_and(|e| !matches!(e, PathEl::ClosePath)) { - bp.close_path(); - } -} - -/// The fill rule of a path or compound node. -/// `kids` with the members of each plain group among them in the group's place, at any depth. -fn ungrouped(kids: Vec) -> Vec { - let mut out = Vec::with_capacity(kids.len()); - for k in kids { - match k.kind { - NodeKind::Group { children, clip: false } => out.extend(ungrouped(children.into_iter().map(Arc::unwrap_or_clone).collect())), - _ => out.push(k), - } - } - out -} - -/// Whether `look`'s paths don't all paint the same. -fn paints_apart(look: &Node) -> bool { - fn leaves<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { - match &n.kind { - NodeKind::Group { children, .. } => children.iter().for_each(|c| leaves(c, out)), - _ => out.push(n), - } - } - let mut paths = vec![]; - leaves(look, &mut paths); - paths.windows(2).any(|w| matches!(w, [a, b] if a.appearance != b.appearance)) -} - -fn path_rule(n: &Node) -> FillRule { - match &n.kind { - NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, - _ => FillRule::NonZero, - } -} - -/// The tile's art (y down from the tile's top left) → the pattern's space (`matrix`) → art space -/// → the document (`space`). The tile's lower left is the pattern's origin wherever the -/// definition drew it, as the file's PDF part tiles it. `None` when it can't be inverted. -fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option { - let xf = space * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); - (xf.determinant().abs() >= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) -} - -/// What a style's fills paint with: the file's gradients and pattern tiles, and art space → the document. -struct StylePaints<'a> { - gradients: &'a HashMap, - patterns: &'a HashMap, - space: Affine, -} - -/// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name -/// (the first of each; a pattern's name after `PATTERN_KEY`). -fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { - if depth > MAX_DEPTH { - return; - } - for item in &look.appearance.items { - let key = match item.paint() { - Paint::Gradient(g) => g.swatch.clone(), - Paint::Pattern { pattern, .. } => Some(format!("{PATTERN_KEY}{pattern}")), - _ => None, - }; - if let Some(key) = key { - out.entry(key).or_insert_with(|| item.paint().clone()); - } - } - for c in look.children().into_iter().flatten() { - placed_paints(c, out, depth + 1); - } -} - -/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. -const PATTERN_KEY: &str = "\u{1}pattern "; - -/// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one -/// this reads, or asks a group for fills or strokes. -fn styled(object: &Node, look: &Node, st: &super::style::ArtStyle, paints: &StylePaints) -> Option { - use super::style::StyleItem; - // A text object's slot (an empty group standing for it) takes the style as the type will. - let slot = slot_of(object.name.as_deref()).is_some(); - if !st.plain - || (!slot && !st.simple) - || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) - { - return None; - } - // A style with one gradient fill: the object's own gradient, placed as the object's fill is. - let gradients = st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count(); - let own_gradient = object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_))); - // A style's gradients are written in a space of the object's bounds that isn't documented; its - // drawn look has each where the app placed it. - let mut placed = HashMap::new(); - placed_paints(look, &mut placed, 0); - let mut items = vec![]; - for (n, item) in st.items.iter().enumerate() { - let mut layer = match item { - StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), - StyleItem::Gradient { name, angle } => { - let paint = match (name.as_ref().and_then(|n| placed.get(n)), gradients, own_gradient) { - // Other objects than type: the object's own, as they always were. - (_, 1, Some(own)) if !slot => own.paint.clone(), - (Some(p), ..) => p.clone(), - (None, 1, Some(own)) => own.paint.clone(), - _ => { - let gradient = name.as_ref().and_then(|n| paints.gradients.get(n))?; - let mut g = vectorcraft_color::gradient::GradientPaint::new(gradient.clone()); - // Fitted to the object's bounds at its angle (art space turns counter-clockwise, as the document's angles do). - g.angle = *angle; - Paint::Gradient(Box::new(g)) - } - }; - AppearanceItem::Fill(FillLayer::new(paint)) - } - StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) { - Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), - None => { - let tile = paints.patterns.get(name).copied()?; - let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?; - AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf })) - } - }, - StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => { - let mut layer = StrokeLayer::new(paint.clone(), *width); - (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); - AppearanceItem::Stroke(layer) - } - }; - if let Some(look) = st.looks.get(n) { - match &mut layer { - AppearanceItem::Fill(f) => { - (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) - } - AppearanceItem::Stroke(s) => { - (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) - } - } - } - items.push(layer); - } - let contents_index = if object.is_container() || matches!(object.kind, NodeKind::Text(_) | NodeKind::Image(_)) { - st.contents_index - } else { - object.appearance.contents_index - }; - Some(Appearance { items, effects: st.effects.clone(), contents_index }) -} - -/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). -fn strip(n: &mut Node, hidden: &HashSet) { - if let Some(children) = n.children_mut() { - children.retain(|c| !hidden.contains(&c.id)); - for c in children.iter_mut() { - strip(Arc::make_mut(c), hidden); - } - } -} - -/// The dictionary of entry `key` in `o` or the dictionaries it holds. -fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { - if depth > 8 { - return None; - } - o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1))) -} - -/// Add the variables and data sets of the variable libraries in `o` or the dictionaries it holds -/// (see [`VariableLibrary`]) to `found`. -fn variable_library(o: &Obj, depth: usize, found: &mut VariableLibrary) { - if depth > MAX_DEPTH { - return; - } - if o.ty == "XMLNode" && element_name(o).as_deref() == Some("variableSets") { - for set in elements(o, "variableSet") { - found.variables += elements(set, "variables").flat_map(|v| elements(v, "variable")).count(); - found.data_sets += elements(set, "sampleDataSets").flat_map(|d| elements(d, "sampleDataSet")).count(); - } - return; - } - for c in o.items() { - variable_library(c, depth + 1, found); - } -} - -/// The name of the XML node `o` (its `xmlnode-nodename`) without a namespace prefix (`v:`). -fn element_name(o: &Obj) -> Option { - let name = o.text("xmlnode-nodename")?; - Some(name.rsplit(':').next().unwrap_or(&name).to_string()) -} - -/// The child nodes named `name` (see [`element_name`]) of the XML node `o`. -fn elements<'o>(o: &'o Obj, name: &'o str) -> impl Iterator { - o.obj("xmlnode-children").into_iter().flat_map(Obj::items).filter(move |c| c.ty == "XMLNode" && element_name(c).as_deref() == Some(name)) -} - -/// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by -/// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is -/// larger than [`MAX_PIXELS`] or is cut short. -fn preview_png(data: &[u8]) -> Option<(Vec, u32, u32)> { - let mut rest = data.trim_ascii_start(); - let mut head = [0usize; 5]; - for v in &mut head { - let n = rest.iter().position(|b| *b == b' ')?; - *v = std::str::from_utf8(rest.get(..n)?).ok()?.parse().ok()?; - rest = rest.get(n + 1..)?; - } - let [w, h, row, bits, _] = head; - let (width, height) = (u32::try_from(w).ok()?, u32::try_from(h).ok()?); - if w == 0 || h == 0 || u64::from(width) * u64::from(height) > MAX_PIXELS || bits != 32 || row < w * 4 { - return None; - } - // Every row is there before anything is allocated for them. - if rest.len() < (h - 1).checked_mul(row)?.checked_add(w * 4)? { - return None; - } - let mut rgba = Vec::with_capacity(w * h * 4); - for line in rest.chunks(row).take(h) { - for &[a, r, g, b] in line.get(..w * 4)?.as_chunks::<4>().0 { - let straight = |c: u8| if a == 0 { 0 } else { (u32::from(c) * 255 / u32::from(a)).min(255) as u8 }; - rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]); - } - } - let mut png = vec![]; - image::RgbaImage::from_raw(width, height, rgba)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - Some((png, width, height)) -} - -fn sane(r: Rect) -> bool { - [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE) -} - -/// The four numbers of a comment's value (a box that may be a point). -fn four(v: &str) -> Option<[f64; 4]> { - let n: Vec = v.split_whitespace().map_while(|w| w.parse::().ok()).collect(); - let [a, b, c, d] = n.as_slice() else { return None }; - [a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE).then_some([*a, *b, *c, *d]) -} - -/// The first two numbers of a comment's value. -fn two(v: &str) -> Option<(f64, f64)> { - let n: Vec = v.split_whitespace().filter_map(|w| w.parse().ok()).filter(|v: &f64| v.is_finite()).collect(); - match n.as_slice() { - [a, b, ..] => Some((*a, *b)), - _ => None, - } -} - -/// A CMYK sample in RGB. -fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] { - let f = |v: u8| f32::from(v) / 255.0; - let rgb = vectorcraft_color::Color::cmyk(f(c), f(m), f(y), f(k)).to_rgba8(1.0); - [rgb[0], rgb[1], rgb[2]] -} - -/// `a`, `a and b`, `a, b and c`. -fn join_and(items: &[&str]) -> String { - match items { - [] => String::new(), - [one] => (*one).to_string(), - [rest @ .., last] => format!("{} and {last}", rest.join(", ")), - } } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs new file mode 100644 index 000000000..fcc2cb3c4 --- /dev/null +++ b/crates/eps/src/import/ai/read/art.rs @@ -0,0 +1,566 @@ +use super::containers::close; +use super::document::TEXT_SLOT; +use super::types::{FLIP, IMAGE_TOO_LARGE, LINKED_PREVIEWS, MAX_PIXELS, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, Place}; +use super::*; + +impl<'a> Reader<'a> { + /// A text object (`/AI11Text`): an empty group standing for it, named after its story. + pub(super) fn text_slot(&mut self, o: &Obj) -> Result<(), String> { + if self.pattern.is_some() { + self.unreadable("type in a pattern"); + } + // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its + // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its + // stories on its own; the object itself follows the look, on `%_` lines, and is the type. + if o.nums("FreeUndo").first() == Some(&1.0) { + return Ok(()); + } + let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); + let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); + let gs = self.gs.clone(); + let mut n = self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs); + n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}:{frame}"))); + // The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own. + n.opacity = gs.opacity; + n.blend = gs.blend; + let hidden = self.obj_hidden; + self.add(n, hidden) + } + + /// Non-native art (`/ForeignObject`): the content of a placed PDF, which Illustrator shows but + /// doesn't edit. Its `/Data` is that PDF (ASCII85 in comment lines, kept by the `,` handler), + /// `/Bounds` the art's box in a space measured from `/Origin` with y down, and `/RTransform` + /// maps that space onto the art (both read off files: the box lands on the clipping paths + /// around it). The PDF importer reads the PDF, and its art is fitted to the box, as one + /// group. Without data, or with a PDF that can't be read, nothing is drawn. + pub(super) fn foreign_art(&mut self, o: &Obj) -> Result<(), String> { + let Some(text) = self.foreign_data.take() else { return Ok(()) }; + // One `%` starts each line; the rest, `_` included, is data. + let mut encoded = String::with_capacity(text.len()); + for line in text.split(|b| matches!(b, b'\r' | b'\n')) { + encoded.push_str(&String::from_utf8_lossy(line.strip_prefix(b"%").unwrap_or(line))); + } + let Some(pdf) = ps::ascii85_decode(&encoded) else { + self.warn(NON_NATIVE_ART_UNREAD); + return Ok(()); + }; + let (rt, origin, bounds) = (o.get("RTransform").map(Self::nums), o.get("Origin").map(Self::nums), o.get("Bounds").map(Self::nums)); + let (Some([a, b, c, d, tx, ty]), Some([ox, oy]), Some([x0, y0, x1, y1])) = (rt.as_deref(), origin.as_deref(), bounds.as_deref()) else { + self.warn(NON_NATIVE_ART_UNREAD); + return Ok(()); + }; + let place = Affine::new([*a, *b, *c, *d, *tx, *ty]) * Affine::new([1.0, 0.0, 0.0, -1.0, *ox, *oy]); + let art_box = place.transform_rect_bbox(Rect::new(*x0, *y0, *x1, *y1)); + if !sane(art_box) || art_box.width() <= 0.0 || art_box.height() <= 0.0 { + self.warn(NON_NATIVE_ART_UNREAD); + return Ok(()); + } + let target = self.space().transform_rect_bbox(art_box); + let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() }; + let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) { + Ok(r) => r, + Err(e) => { + self.warn(&format!("{NON_NATIVE_ART_UNREAD} ({e})")); + return Ok(()); + } + }; + let mut art: Vec = read.document.layers.iter().filter_map(|l| l.children()).flatten().map(|n| n.as_ref().clone()).collect(); + let Some(bbox) = art.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.geometric_bounds())) else { return Ok(()) }; + if bbox.width() <= 0.0 || bbox.height() <= 0.0 { + return Ok(()); + } + // The art's own box onto the object's box (its measure of the same art). The PDF importer + // hands the art back as the page shows it, and it keeps that orientation unless its + // placement mirrors it (a negative determinant): the usual `/RTransform` `1 0 0 -1 …` with + // the origin's flip doesn't, and the art then shows as the file's own PDF part draws it. + let fit = if place.determinant() < 0.0 { + Affine::translate((target.x0, target.y1)) * Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height()) + } else { + Affine::translate((target.x0, target.y0)) * Affine::scale_non_uniform(target.width() / bbox.width(), target.height() / bbox.height()) + } * Affine::translate((-bbox.x0, -bbox.y0)); + for n in &mut art { + n.transform(fit, true); + // The PDF's swatches stay with it: its colours keep their values, unlinked. + self.reid(n); + } + for (key, blob) in read.document.images { + self.doc.images.entry(key).or_insert(blob); + } + let gs = self.gs.clone(); + let mut group = self.new_node(NodeKind::Group { children: art.into_iter().map(Arc::new).collect(), clip: false }, &gs); + group.name = Some(NON_NATIVE_ART.into()); + self.add(group, self.obj_hidden) + } + + // ---- paths ----------------------------------------------------------------------------- + + pub(super) fn path_op(&mut self, w: &str, vals: &[V]) -> Result<(), String> { + let n = Self::nums(vals); + let p = |i: usize| -> Option { + let at = n.len().checked_sub(i)?; + Some(Point::new(*n.get(at)?, *n.get(at + 1)?)) + }; + self.points += 1; + if self.points > MAX_POINTS { + return Err("a path has too many points".into()); + } + let current = self.path.elements().last().and_then(|e| e.end_point()); + match w { + "m" => { + let Some(a) = p(2) else { return Ok(()) }; + self.path.move_to(a); + } + _ if current.is_none() => {} + "l" | "L" => { + if let Some(a) = p(2) { + self.path.line_to(a); + } + } + "c" | "C" => { + if let (Some(a), Some(b), Some(c)) = (p(6), p(4), p(2)) { + self.path.curve_to(a, b, c); + } + } + "v" | "V" => { + if let (Some(cur), Some(b), Some(c)) = (current, p(4), p(2)) { + self.path.curve_to(cur, b, c); + } + } + _ => { + if let (Some(a), Some(c)) = (p(4), p(2)) { + self.path.curve_to(a, c, c); + } + } + } + Ok(()) + } + + /// A path left unpainted before a container opens. + pub(crate) fn end_path_quietly(&mut self) { + self.path = BezPath::new(); + self.points = 0; + self.clip_next = false; + } + + /// Paint the current path (`op`), as a guide when `guide`. + pub(super) fn paint(&mut self, op: &str, hidden: bool, guide: bool) -> Result<(), String> { + let mut bp = std::mem::take(&mut self.path); + self.points = 0; + let clip = std::mem::take(&mut self.clip_next); + if let Some(i) = self.instance.take() { + self.set_instance(&i); + } + let (fill_paint, stroke_paint) = (self.pending_fill.take(), self.pending_stroke.take()); + if bp.elements().is_empty() { + return Ok(()); + } + if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { + close(&mut bp); + } + let to_doc = self.space(); + let path = PathData::from_bezpath(&(to_doc * bp)); + let gs = self.gs.clone(); + let (fill, stroke) = match op.to_ascii_lowercase().as_str() { + "f" => (true, false), + "s" => (false, true), + "b" => (true, true), + _ => (false, false), + }; + let mut items = Vec::new(); + if fill && !clip { + items.push(AppearanceItem::Fill(FillLayer { + overprint: gs.overprint_fill, + ..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone())) + })); + } + if stroke && !clip { + let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width); + st.cap = gs.cap; + st.join = gs.join; + st.miter_limit = gs.miter; + st.dash = gs.dash.clone(); + st.overprint = gs.overprint_stroke; + items.push(AppearanceItem::Stroke(st)); + } + let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide }; + let mut node = self.new_node(kind, &gs); + node.appearance = Appearance { items, ..Appearance::default() }.into(); + node.opacity = gs.opacity; + node.blend = gs.blend; + node.knockout_shape = gs.knockout_shape; + if clip && let Some(f) = self.frames.last_mut() { + f.clips.push(f.children.len()); + } + // A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it. + let painted = clip && fill && !matches!(gs.fill, Paint::None); + let copy = painted.then(|| { + let mut copy = node.clone(); + copy.id = self.doc.alloc_id(); + if let NodeKind::Path { clipping, .. } = &mut copy.kind { + *clipping = false; + } + let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone()); + copy.appearance = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })], + ..Appearance::default() + } + .into(); + copy + }); + self.add(node, hidden)?; + if let Some(copy) = copy { + self.clip_fills.insert(copy.id); + self.add(copy, hidden)?; + } + Ok(()) + } + + /// `/End X!`: the gradient mesh read, as one object. + pub(super) fn end_mesh(&mut self) -> Result<(), String> { + let Some((m, hidden)) = self.mesh.take() else { return Ok(()) }; + if m.unread || m.patches.is_empty() { + self.unreadable("gradient meshes it can't read"); + return Ok(()); + } + let gs = self.gs.clone(); + // One mesh where the patches join up into the grid they say they make; else each patch is + // a mesh of its own, in a group. + match m.one_mesh() { + Ok(one) => { + let mut node = self.new_node(NodeKind::Mesh(one), &gs); + node.opacity = gs.opacity; + node.blend = gs.blend; + return self.add(node, hidden); + } + Err(crate::import::ai::mesh::NotOne::Unjoined) => { + self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches") + } + Err(crate::import::ai::mesh::NotOne::Unplaced) => {} + } + let mut patches: Vec = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect(); + let mut node = match patches.len() { + 1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)), + _ => self.new_node(NodeKind::Group { children: patches.into_iter().map(Arc::new).collect(), clip: false }, &gs), + }; + node.opacity = gs.opacity; + node.blend = gs.blend; + self.add(node, hidden) + } + + /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. + pub(super) fn stroke_last(&mut self) { + let gs = self.gs.clone(); + let Some(n) = self.last_object() else { return }; + if !matches!(n.kind, NodeKind::Path { clipping: false, .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) { + return; + } + let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width); + st.cap = gs.cap; + st.join = gs.join; + st.miter_limit = gs.miter; + st.dash = gs.dash.clone(); + st.overprint = gs.overprint_stroke; + let mut a = (*n.appearance).clone(); + a.items.push(AppearanceItem::Stroke(st)); + n.appearance = a.into(); + } + + pub(super) fn set_instance(&mut self, i: &Instance) { + let mut to_doc = self.space(); + // In a pattern's definition a gradient's `Xm` and `Bm` are written y down about the canvas point of art space's + // origin, as the file's PDF part draws them. + if self.pattern.is_some() + && i.has_matrix() + && let Some(t) = self.template + { + let (cx, cy) = ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0); + to_doc *= Affine::new([1.0, 0.0, 0.0, -1.0, CANVAS_CENTRE - cx, CANVAS_CENTRE + cy]); + } + match i.paint(&self.gradients, to_doc) { + Some(p) if i.stroke => self.pending_stroke = Some(p), + Some(p) => self.pending_fill = Some(p), + None => { + let name = i.name.clone().unwrap_or_default(); + self.warn(&format!("gradient “{name}” isn't defined in the file: a plain colour stands in for it")); + } + } + } + + // ---- images ---------------------------------------------------------------------------- + + /// The samples after `XI`, its operands on the stack. + pub(super) fn image(&mut self, data: &[u8]) -> Result<(), String> { + let vals = self.operands(); + // Operands that came before an `XI` on their own line. + let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) { + Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, + _ => vals, + }; + self.image_from(vals, data) + } + + /// An image of operands `vals` and samples `data`. + pub(super) fn image_from(&mut self, vals: Vec, data: &[u8]) -> Result<(), String> { + let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); + let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let n = Self::nums(&vals); + let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let (w, h) = (*w as u64, *h as u64); + if w.saturating_mul(h) > MAX_PIXELS { + self.warn(IMAGE_TOO_LARGE); + return Ok(()); + } + if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + } + let channels: usize = match space.as_str() { + "DeviceGray" => 1, + "DeviceCMYK" => 4, + "DeviceRGB" => 3, + _ => match *kind as i64 { + 1 => 1, + 4 => 4, + _ => 3, + }, + }; + let decoded; + let data = if *binary == 0.0 { + let digits: Vec = data.iter().copied().filter(|b| b.is_ascii_hexdigit()).collect(); + decoded = crate::import::lex::hex_decode(&digits).unwrap_or_default(); + decoded.as_slice() + } else { + data + }; + let row = if *bits == 1.0 { (w as usize * channels).div_ceil(8) } else { w as usize * channels }; + let need = row.saturating_mul(h as usize); + // An alpha channel follows the colour samples, one byte a pixel. + let alpha_len = if *alpha > 0.0 { (w * h) as usize } else { 0 }; + let (Some(samples), opacity) = (data.get(..need), data.get(need..need.saturating_add(alpha_len))) else { + self.warn("an image whose data is cut short was left out"); + return Ok(()); + }; + let opacity = opacity.filter(|_| alpha_len > 0); + // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK + // image's, and an alpha channel that hides part of the image becomes its opacity mask. + if channels == 4 + && *bits == 8.0 + && let Some(blob) = Inks::new(w as u32, h as u32, samples.to_vec()).and_then(|i| ImageBlob::cmyk_tiff(&i)) + { + return self.add_raster(blob, opacity, &m, (w as u32, h as u32)); + } + let mut rgba = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h as usize { + let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); + for x in 0..w as usize { + // A 1-bit row holds each pixel's channels in turn, a bit each, padded to a whole + // byte (`row`); a set bit is the channel at full strength (255). + let s = |c: usize| { + let i = x * channels + c; + if *bits == 1.0 { line.get(i / 8).map_or(0, |b| ((b >> (7 - i % 8)) & 1) * 255) } else { line.get(i).copied().unwrap_or(0) } + }; + let px = match channels { + 1 => [s(0); 3], + 4 => { + let key = [s(0), s(1), s(2), s(3)]; + if self.cmyk_cache.len() > 1 << 16 { + self.cmyk_cache.clear(); + } + *self.cmyk_cache.entry(key).or_insert_with(|| cmyk_rgb(key)) + } + _ => [s(0), s(1), s(2)], + }; + let a = opacity.and_then(|o| o.get(y * w as usize + x)).copied().unwrap_or(255); + rgba.extend_from_slice(&[px[0], px[1], px[2], a]); + } + } + let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let mut png = vec![]; + if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + } + self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32)) + } + + /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with + /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the + /// image. + fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { + // The matrix is an art-space matrix (y up) applied to the pixels with their rows running + // downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead + // gives the same place only when the image isn't rotated (b = c = 0). + let [a, b, c, d, tx, ty] = m else { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let to_doc = self.space(); + let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP; + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + } + let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf)); + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }, mask, false) + } + + /// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its + /// opacity mask, as in PDF import. `None` when it hides nothing. + fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { + if alpha.iter().all(|v| *v == 255) { + return None; + } + let mut png = Vec::new(); + image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + let art = Node::new( + self.doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ); + Some(OpacityMask::new(art, true)) + } + + /// Add an image object with the state's transparency. + fn add_image(&mut self, im: ImageObject, hidden: bool) -> Result<(), String> { + self.add_image_masked(im, None, hidden) + } + + /// Add an image object with the state's transparency and `mask` as its opacity mask. + fn add_image_masked(&mut self, im: ImageObject, mask: Option, hidden: bool) -> Result<(), String> { + let gs = self.gs.clone(); + let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); + node.opacity = gs.opacity; + node.blend = gs.blend; + node.extras_mut().mask = mask.map(Box::new); + self.add(node, hidden) + } + + /// `~`: the placed file read since `%AI5_BeginPlace` becomes a linked image in its place that + /// shows the preview the data keeps of it, as a linked image whose file is missing does. One + /// without a preview, a path or a matrix isn't read. + pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> { + let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty()) + else { + self.unreadable("placed files"); + return Ok(()); + }; + // The file's pixels (y down) start at the canvas point of art space's origin (the box's own + // corner when the file doesn't say where the canvas is); the preview's are spread over them. + let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); + let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y)); + let over_file = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(b.width() / f64::from(w), b.height() / f64::from(h)); + let xf = self.space() * m * over_file; + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + self.unreadable("placed files"); + return Ok(()); + } + // The preview is all the document has of the file: what a missing link shows. + let bytes = Arc::new(png); + let blob = ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }; + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + self.warn(LINKED_PREVIEWS); + self.add_image( + ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None }, + hidden, + ) + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 { + self.warnings.push(w.to_string()); + } + } +} + +/// The name of the XML node `o` (its `xmlnode-nodename`) without a namespace prefix (`v:`). +pub(super) fn element_name(o: &Obj) -> Option { + let name = o.text("xmlnode-nodename")?; + Some(name.rsplit(':').next().unwrap_or(&name).to_string()) +} + +/// The child nodes named `name` (see [`element_name`]) of the XML node `o`. +pub(super) fn elements<'o>(o: &'o Obj, name: &'o str) -> impl Iterator { + o.obj("xmlnode-children").into_iter().flat_map(Obj::items).filter(move |c| c.ty == "XMLNode" && element_name(c).as_deref() == Some(name)) +} + +/// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by +/// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is +/// larger than [`MAX_PIXELS`] or is cut short. +pub(super) fn preview_png(data: &[u8]) -> Option<(Vec, u32, u32)> { + let mut rest = data.trim_ascii_start(); + let mut head = [0usize; 5]; + for v in &mut head { + let n = rest.iter().position(|b| *b == b' ')?; + *v = std::str::from_utf8(rest.get(..n)?).ok()?.parse().ok()?; + rest = rest.get(n + 1..)?; + } + let [w, h, row, bits, _] = head; + let (width, height) = (u32::try_from(w).ok()?, u32::try_from(h).ok()?); + if w == 0 || h == 0 || u64::from(width) * u64::from(height) > MAX_PIXELS || bits != 32 || row < w * 4 { + return None; + } + // Every row is there before anything is allocated for them. + if rest.len() < (h - 1).checked_mul(row)?.checked_add(w * 4)? { + return None; + } + let mut rgba = Vec::with_capacity(w * h * 4); + for line in rest.chunks(row).take(h) { + for &[a, r, g, b] in line.get(..w * 4)?.as_chunks::<4>().0 { + let straight = |c: u8| if a == 0 { 0 } else { (u32::from(c) * 255 / u32::from(a)).min(255) as u8 }; + rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]); + } + } + let mut png = vec![]; + image::RgbaImage::from_raw(width, height, rgba)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some((png, width, height)) +} + +pub(super) fn sane(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE) +} + +/// The four numbers of a comment's value (a box that may be a point). +pub(super) fn four(v: &str) -> Option<[f64; 4]> { + let n: Vec = v.split_whitespace().map_while(|w| w.parse::().ok()).collect(); + let [a, b, c, d] = n.as_slice() else { return None }; + [a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE).then_some([*a, *b, *c, *d]) +} + +/// The first two numbers of a comment's value. +pub(super) fn two(v: &str) -> Option<(f64, f64)> { + let n: Vec = v.split_whitespace().filter_map(|w| w.parse().ok()).filter(|v: &f64| v.is_finite()).collect(); + match n.as_slice() { + [a, b, ..] => Some((*a, *b)), + _ => None, + } +} + +/// A CMYK sample in RGB. +fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] { + let f = |v: u8| f32::from(v) / 255.0; + let rgb = vectorcraft_color::Color::cmyk(f(c), f(m), f(y), f(k)).to_rgba8(1.0); + [rgb[0], rgb[1], rgb[2]] +} + +/// `a`, `a and b`, `a, b and c`. +pub(super) fn join_and(items: &[&str]) -> String { + match items { + [] => String::new(), + [one] => (*one).to_string(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs new file mode 100644 index 000000000..b1168b2bc --- /dev/null +++ b/crates/eps/src/import/ai/read/containers.rs @@ -0,0 +1,469 @@ +use super::document::slot_of; +use super::ops::{paints_apart, path_rule}; +use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES}; +use super::*; + +impl<'a> Reader<'a> { + // ---- frames ---------------------------------------------------------------------------- + + pub(super) fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> { + if self.frames.len() >= MAX_DEPTH { + return Err("its objects are nested too deeply".into()); + } + if matches!(kind, Kind::Layer(_)) { + self.ensure_space(); + } + let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) }; + self.frames.push(Frame { + kind, + children: Vec::new(), + hidden, + after: self.after_object, + gs: self.gs.clone(), + base, + clips: Vec::new(), + compound_members: false, + }); + self.after_object = false; + Ok(()) + } + + /// Close the innermost frame, which must be of the kind `is` accepts. + fn close(&mut self, what: &str, is: impl Fn(&Kind) -> bool) -> Result { + match self.frames.last() { + Some(f) if is(&f.kind) => self.frames.pop().ok_or_else(|| format!("`{what}` closes nothing")), + _ => Err(format!("`{what}` closes something it didn't open")), + } + } + + /// Add a node to the innermost container. + pub(crate) fn add(&mut self, node: Node, hidden: bool) -> Result<(), String> { + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(format!("it has more than {MAX_NODES} objects")); + } + if hidden { + self.hidden_ids.insert(node.id); + } + match self.frames.last_mut() { + Some(f) => f.children.push(node), + None => return Err("it has art outside its layers".into()), + } + self.after_object = true; + Ok(()) + } + + /// The last object read, while nothing else started. + pub(super) fn last_object(&mut self) -> Option<&mut Node> { + if !self.after_object { + return None; + } + self.frames.last_mut()?.children.last_mut() + } + + pub(crate) fn new_node(&mut self, kind: NodeKind, gs: &GState) -> Node { + let mut n = Node::new(self.doc.alloc_id(), kind); + n.visible = !gs.hidden; + n.locked = gs.locked; + n + } + + pub(super) fn end_container(&mut self, what: &str, hidden: bool) -> Result<(), String> { + let f = match what { + "U" => self.close(what, |k| matches!(k, Kind::Group))?, + "*U" => self.close(what, |k| matches!(k, Kind::Compound))?, + _ => self.close(what, |k| matches!(k, Kind::Clip))?, + }; + let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f; + // A group holding only clipping paths (`q … u W n U … Q`) doesn't clip on its own: `u`/`U` don't save + // the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it. + if matches!(kind, Kind::Group) + && !clips.is_empty() + && clips.len() == kids.len() + && matches!(self.frames.last(), Some(Frame { kind: Kind::Clip, .. })) + { + for kid in kids { + if let Some(parent) = self.frames.last_mut() { + parent.clips.push(parent.children.len()); + } + self.add(kid, opened_hidden || hidden)?; + } + return Ok(()); + } + // A compound path of clipping paths clips its clipping group. + let clipping = matches!(kind, Kind::Compound) && !clips.is_empty(); + self.group_ended = !matches!(kind, Kind::Compound); + let mut painted = None; + let node = match kind { + // A compound path holding a group (its drawn look) is that look: its paths paint apart. + // Some files mark every group so, the plain one holding the subpaths too (#980): one + // paint for all its paths keeps it one compound path, its holes cut. + Kind::Compound if kids.iter().any(|k| self.looks.contains(&k.id) && paints_apart(k)) => { + let (children, clip) = self.clip_first(kids, &clips); + self.new_node(NodeKind::Group { children, clip }, &gs) + } + Kind::Compound => { + // A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths' + // painted copies make a compound of their own, under what it clips. + let (fills, kids): (Vec, Vec) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id)); + painted = self.compound(fills, &gs); + let Some(n) = self.compound(kids, &gs) else { return Ok(()) }; + n + } + // A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group. + _ => { + let (children, clip) = self.clip_first(kids, &clips); + self.new_node(NodeKind::Group { children, clip }, &gs) + } + }; + if clipping && let Some(f) = self.frames.last_mut() { + f.clips.push(f.children.len()); + } + if compound_members { + self.compound_groups.insert(node.id); + } + self.add(node, opened_hidden || hidden)?; + if let Some(p) = painted { + self.clip_fills.insert(p.id); + self.add(p, opened_hidden || hidden)?; + } + Ok(()) + } + + /// The compound path of `kids`, painted and made transparent as its subpaths say. + fn compound(&mut self, mut kids: Vec, gs: &GState) -> Option { + // The compound path's paint is its last path's (a gradient's instance wraps only + // that one); the last painted one, should the last have none. + let shown = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last())?; + let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown)); + for k in &mut kids { + k.opacity = 1.0; + k.blend = BlendMode::Normal; + k.visible = true; + k.locked = false; + } + let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, gs); + n.appearance = appearance; + n.opacity = opacity; + n.blend = blend; + Some(n) + } + + /// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound + /// path → the children and whether they clip. + fn clip_first(&mut self, kids: Vec, clips: &[usize]) -> (Vec>, bool) { + let clips: std::collections::HashSet = clips.iter().copied().collect(); + let (mut clip, rest): (Vec, Vec) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i)); + let clip_node = match clip.len() { + 0 => None, + 1 => clip.pop().map(|(_, n)| n), + _ => { + let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n)); + let children = clip.into_iter().map(|(_, n)| Arc::new(n)).collect(); + Some(Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })) + } + }; + let clips = clip_node.is_some(); + // A clipping path's own fill paints under the art it clips. + let (fills, rest): (Vec, Vec) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id)); + let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect(); + (children, clips) + } + + pub(super) fn end_layer(&mut self) -> Result<(), String> { + let f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let Kind::Layer(a) = f.kind else { return Ok(()) }; + let (children, clip) = self.clip_first(f.children, &f.clips); + let color = a.color.unwrap_or(LayerColor::Preset(0)); + let mut layer = Node::new( + self.doc.alloc_id(), + NodeKind::Layer { color, template: false, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim }, + ); + layer.name = Some(a.name.clone()); + layer.visible = a.visible; + layer.locked = a.locked; + // The layer's own opacity, blend and knockout, written after its art (#2003). + if let Some((opacity, blend, isolate, knockout)) = self.layer_state.take() { + (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); + } + match self.frames.last_mut() { + Some(parent) => { + parent.children.push(layer); + self.after_object = true; + } + None => { + self.doc.layers.push(Arc::new(layer)); + self.after_object = false; + } + } + Ok(()) + } + + /// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file + /// is read from its PDF part. + pub(super) fn note_unknown(&mut self, w: &str) { + let op: String = w.chars().take(24).collect(); + if !self.shown() { + if self.hidden_unread.len() < 16 { + self.hidden_unread.insert(format!("`{op}`")); + } + } else if self.unknown.len() < 16 { + self.unknown.insert(op); + } + } + + /// `:` after `/Type`: a dictionary opens. + pub(super) fn begin_obj(&mut self, hidden: bool) -> Result<(), String> { + let ty = match self.stack.pop() { + Some(V::Name(n)) => n, + Some(other) => { + self.stack.push(other); + String::new() + } + None => String::new(), + }; + self.open(Kind::Obj(Box::new(Obj { ty, ..Obj::default() })), hidden)?; + // A dictionary's state starts afresh (styles set theirs inside). + self.gs = GState::default(); + Ok(()) + } + + /// Capture mask art in document coordinates, without adding it to the owner's siblings. + pub(super) fn end_mask(&mut self) -> Result<(), String> { + self.end_path_quietly(); + let f = self.close("X+", |k| matches!(k, Kind::Mask))?; + let (children, clip) = self.clip_first(f.children, &f.clips); + let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); + self.gs = f.gs; + self.after_object = f.after; + let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { + return Err("mask art ends outside a mask dictionary".into()); + }; + if o.art.replace(Arc::new(art)).is_some() { + return Err("a mask has more than one art block".into()); + } + Ok(()) + } + + /// `;`: the innermost dictionary closes. + pub(super) fn end_obj(&mut self) -> Result<(), String> { + if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { + return Ok(()); + } + let Some(f) = self.frames.pop() else { return Ok(()) }; + let Frame { kind, children, base, hidden, gs, after, .. } = f; + let Kind::Obj(mut o) = kind else { return Ok(()) }; + obj::close(&mut o, &mut self.stack, base); + self.obj_hidden = hidden; + // Art made inside a dictionary (type's paths) isn't kept. + self.gs = gs; + self.after_object = after; + if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { + return self.push(V::Obj(Rc::new(*o))); + } + match o.ty.as_str() { + "ArtDictionary" => { + let envelope = o.get("AIDeformOptionsKey").is_some(); + self.art_dictionary(&o); + if envelope { + let mut slots = vec![]; + for child in &children { + envelope_text_slots(child, &mut slots); + } + for mut slot in slots { + // The editing data keeps the source text inside the envelope's options; + // its visible result is the outlined art the PDF page carries. + slot.visible = false; + self.add(slot, false)?; + } + } + } + "Document" => self.document(&o), + "AI11Text" => self.text_slot(&o)?, + "SymbolInstance" => self.unreadable("symbols"), + "ForeignObject" => self.foreign_art(&o)?, + _ => {} + } + Ok(()) + } + + /// Give `n` and its descendants ids of this document, and unlink their swatches. + pub(super) fn reid(&mut self, n: &mut Node) { + n.id = self.doc.alloc_id(); + for it in &mut n.appearance.items { + let p = match it { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Gradient(g) = p { + g.swatch = None; + } + p.map_links(&mut |_, link, _| { + *link = None; + true + }); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + self.reid(Arc::make_mut(&mut m.art)); + } + if let Some(children) = n.children_mut() { + for c in children { + self.reid(Arc::make_mut(c)); + } + } + } + + /// Note something the data has that isn't read: an error on a layer that shows, left out of a + /// layer that doesn't. + pub(super) fn unreadable(&mut self, what: &str) { + if self.shown() { + self.unsupported.insert(what.into()); + } else { + self.hidden_unread.insert(what.into()); + } + } + + /// Drop the object just read when it is an envelope's own mesh: a group on `%_` lines holding + /// only gradient meshes. Whether it was dropped. + pub(super) fn drop_envelope_mesh(&mut self) -> bool { + fn only_meshes(n: &Node) -> bool { + match &n.kind { + NodeKind::Mesh(_) => true, + NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| only_meshes(c)), + _ => false, + } + } + let Some(n) = self.last_object() else { return false }; + let id = n.id; + if !matches!(n.kind, NodeKind::Group { .. }) || !only_meshes(n) || !self.hidden_ids.contains(&id) { + return false; + } + if let Some(f) = self.frames.last_mut() { + f.children.pop(); + } + self.hidden_ids.remove(&id); + self.after_object = false; + true + } + + /// Do the layers being read show? + fn shown(&self) -> bool { + self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible)) + } + + /// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_` + /// lines) and its drawn steps (the group after it) become one live blend, or, where they can't, + /// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]). + pub(super) fn end_blend(&mut self) { + let Some((_, hex)) = self.blend_record.take() else { return }; + let ids = crate::import::ai::blend::hex_bytes(&hex).as_deref().and_then(crate::import::ai::blend::record_ids); + let roles = &self.blend_roles; + let Some(f) = self.frames.last_mut() else { return }; + let n = f.children.len(); + let (Some(at), Some(drawn)) = (n.checked_sub(2), n.checked_sub(1)) else { return }; + let part_of_blend = |node: &Node| node.children().is_some_and(|c| c.iter().any(|k| roles.contains_key(&k.id))); + let (Some(edit), Some(steps)) = (f.children.get(at), f.children.get(drawn)) else { return }; + if !part_of_blend(edit) { + return; + } + if !matches!(steps.kind, NodeKind::Group { clip: false, .. }) { + // The steps aren't a group of drawn steps: the blend's own art goes all the same, so + // its keys and spine aren't drawn over what the file draws. + f.children.remove(at); + self.warn(crate::import::ai::blend::DRAWN); + return; + } + let mut budget = self.blend_budget; + let live = ids.and_then(|ids| crate::import::ai::blend::live_blend(edit, steps, &ids, roles, &mut budget)); + self.blend_budget = budget; + let Some(mut steps) = f.children.pop() else { return }; + let Some(_edit) = f.children.pop() else { return }; + match live { + Some(kind) => { + // The steps the blend lays out count as the objects they draw, as its drawn steps + // did (they were counted as read). + let mut blend = Node::new(self.doc.alloc_id(), kind); + (blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked); + (blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout); + f.children.push(blend); + } + None => { + f.children.push(steps); + self.warn(crate::import::ai::blend::DRAWN); + } + } + } +} + +/// The source text inside an envelope options dictionary, carried through its containing metadata. +fn envelope_text_slots(node: &Node, out: &mut Vec) { + if slot_of(node.name.as_deref()).is_some() { + out.push(node.clone()); + } else if let Some(children) = node.children() { + for child in children { + envelope_text_slots(child, out); + } + } +} + +/// The text objects written twice, plainly and on `%_` lines: the commented copy (`hidden`) of a +/// story that also has a plain one goes. +pub(super) fn drop_commented_copies(layers: &mut [Arc], hidden: &HashSet) { + fn plain(nodes: &[Arc], hidden: &HashSet, out: &mut HashSet) { + for n in nodes { + match slot_of(n.name.as_deref()) { + Some(Some(story)) if !hidden.contains(&n.id) => { + out.insert(story); + } + _ => { + if let Some(c) = n.children() { + plain(c, hidden, out); + } + } + } + } + } + fn drop(nodes: &mut Vec>, hidden: &HashSet, plain: &HashSet) { + nodes.retain(|n| !(hidden.contains(&n.id) && slot_of(n.name.as_deref()).flatten().is_some_and(|s| plain.contains(&s)))); + for n in nodes.iter_mut() { + if n.children().is_some_and(|c| !c.is_empty()) + && let Some(c) = Arc::make_mut(n).children_mut() + { + drop(c, hidden, plain); + } + } + } + let mut stories = HashSet::new(); + plain(layers, hidden, &mut stories); + if stories.is_empty() { + return; + } + for l in layers.iter_mut() { + if let Some(c) = Arc::make_mut(l).children_mut() { + drop(c, hidden, &stories); + } + } +} + +/// A node and its index among its siblings. +type Indexed = (usize, Node); + +/// Close the subpath `bp` ends with, if it has one open. +pub(super) fn close(bp: &mut BezPath) { + if bp.elements().last().is_some_and(|e| !matches!(e, PathEl::ClosePath)) { + bp.close_path(); + } +} + +/// The fill rule of a path or compound node. +/// `kids` with the members of each plain group among them in the group's place, at any depth. +fn ungrouped(kids: Vec) -> Vec { + let mut out = Vec::with_capacity(kids.len()); + for k in kids { + match k.kind { + NodeKind::Group { children, clip: false } => out.extend(ungrouped(children.into_iter().map(Arc::unwrap_or_clone).collect())), + _ => out.push(k), + } + } + out +} diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs new file mode 100644 index 000000000..b3e7d6801 --- /dev/null +++ b/crates/eps/src/import/ai/read/directives.rs @@ -0,0 +1,227 @@ +use super::art::{four, sane, two}; +use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED}; +use super::*; + +impl<'a> Reader<'a> { + pub(super) fn push(&mut self, v: V) -> Result<(), String> { + if self.stack.len() >= MAX_STACK { + return Err("it has too many values in a row".into()); + } + self.stack.push(v); + Ok(()) + } + + /// The operands of the operator being run (above the innermost dictionary's), taken off. + pub(super) fn operands(&mut self) -> Vec { + let base = self.frames.last().map_or(0, |f| f.base).min(self.stack.len()); + self.stack.split_off(base) + } + + pub(super) fn nums(vals: &[V]) -> Vec { + vals.iter().filter_map(V::num).collect() + } + + // ---- comments -------------------------------------------------------------------------- + + pub(super) fn comment(&mut self, c: &[u8]) -> Result<(), String> { + let line = String::from_utf8_lossy(c); + let line = line.trim_end(); + if let Some((len, hex)) = self.blend_record.as_mut() { + if !line.is_empty() && line.bytes().all(|b| b.is_ascii_hexdigit()) { + // As many digits as the record says it has, no more. + let wanted = len.saturating_mul(2); + let take = wanted.saturating_sub(hex.len()).min(line.len()); + hex.extend_from_slice(line.as_bytes().get(..take).unwrap_or_default()); + if hex.len() >= wanted { + self.end_blend(); + } + return Ok(()); + } + self.end_blend(); + } + if let Some(r) = self.raster.as_mut() + && r.operands.is_some() + && !line.is_empty() + && line.bytes().all(|b| b.is_ascii_hexdigit()) + { + if r.hex.len() < MAX_HEX { + r.hex.extend_from_slice(line.as_bytes()); + } + return Ok(()); + } + let m = marker(line); + // A pattern's definition (one inside another's isn't read). + if m == "BeginPattern" && self.pattern.is_none() { + let section = self.section("EndPattern")?; + self.define_pattern(section); + return Ok(()); + } + if m == "BeginArtStyles" { + let section = self.section("EndArtStyles")?; + self.styles = crate::import::ai::style::art_styles(section); + return Ok(()); + } + if m == "BeginPalette" { + let section = self.section("EndPalette")?; + self.palette = crate::import::ai::palette::palette(section); + return Ok(()); + } + if m == "BeginEncoding" { + self.type_state.encoding_comment(line); + } + if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { + return self.skip_to(end); + } + match m { + "%BeginProlog" => return self.skip_to("%EndProlog"), + "%BeginResource" => return self.skip_to("%EndResource"), + // The art ends with the page. + "%PageTrailer" | "%Trailer" => { + self.done = true; + return Ok(()); + } + _ => {} + } + if line.starts_with("AI5_BeginLayer") { + return self.open(Kind::Layer(Box::default()), false); + } + if line.starts_with("AI5_BeginRaster") { + self.raster = Some(Raster::default()); + } else if line.starts_with("AI5_EndRaster") { + if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() { + return self.image_from(vals, &hex); + } + } else if line.starts_with("AI5_BeginPlace") { + self.place = Some(Place::default()); + } else if line.starts_with("AI5_EndPlace") { + // One that `~` didn't end isn't read. + if self.place.take().is_some() { + self.unreadable("placed files"); + } + } else if self.frames.is_empty() + && let Some(v) = line.strip_prefix("%HiResBoundingBox:") + { + self.hires = four(v).or(self.hires); + } else if self.frames.is_empty() + && let Some(v) = line.strip_prefix("%BoundingBox:") + { + self.bbox = four(v).or(self.bbox); + } else if let Some(v) = line.strip_prefix("AI3_Cropmarks:") { + let n: Vec = v.split_whitespace().filter_map(|w| w.parse().ok()).collect(); + if let [x0, y0, x1, y1] = n.as_slice() { + let r = Rect::new(*x0, *y0, *x1, *y1); + if sane(r) { + self.cropmarks = Some(r); + } + } + } else if self.frames.is_empty() + && let Some(v) = line.strip_prefix("AI5_RulerUnits:") + && let Ok(n) = v.trim().parse::() + { + self.ruler_units = Some(n); + } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { + self.art_size = two(v); + } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:") { + self.template_center = two(v); + self.template = four(v).or(self.template); + } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { + // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N + // being this; the printed page applies the same factor, so the art reaches the page's + // size once it is applied to the art space ([`Self::ensure_space`]). A value outside + // the range Illustrator writes is left alone. + if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::()) + && (1.0..=100.0).contains(&n) + { + *scale = n; + } + } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { + self.color_model = Some(v.trim() == "2"); + } + Ok(()) + } + + /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and + /// versioned content in an alternate). + fn skip_to(&mut self, end: &str) -> Result<(), String> { + self.section(end).map(|_| ()) + } + + /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. + fn section(&mut self, end: &str) -> Result<&'a [u8], String> { + let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); + let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; + let start = self.lex.offset(); + let mut depth = 0usize; + loop { + let before = self.lex.offset(); + let Some(t) = self.lex.next_token() else { break }; + if let Tok::Comment(c) = t.tok { + let line = String::from_utf8_lossy(c); + let m = marker(&line); + if !begin.is_empty() && m == begin { + depth += 1; + } else if m == end { + if depth == 0 { + return Ok(self.lex.slice(start, before)); + } + depth -= 1; + } + } + } + Err(format!("its section ending with `%{end}` doesn't end")) + } + + /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its + /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't + /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art + /// filled with it can't be read. + fn define_pattern(&mut self, section: &[u8]) { + if self.pattern_tiles.len() >= MAX_PATTERNS { + return; + } + let mut sub = Reader::new(section); + sub.pattern = Some(PatternHead::default()); + // The gradients defined so far, lent (and those it defines kept). + sub.gradients = std::mem::take(&mut self.gradients); + sub.nodes = self.nodes; + sub.template = self.template; + let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.nodes = sub.nodes; + self.gradients = std::mem::take(&mut sub.gradients); + let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; + let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; + let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { + return; + }; + let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let mut def = PatternDef::new(&head.name, art); + def.tile = FLIP.transform_rect_bbox(tile); + if self.pattern_tiles.insert(def.name.clone(), def.tile).is_some() { + self.doc.patterns.retain(|p| p.name != def.name); + } + self.doc.patterns.push(def); + for (key, blob) in sub.doc.images { + self.doc.images.entry(key).or_insert(blob); + } + self.spots.extend(sub.spots); + self.stop_spots.extend(sub.stop_spots); + self.cmyk_colors += sub.cmyk_colors; + self.rgb_colors += sub.rgb_colors; + for w in &sub.warnings { + self.warn(w); + } + } + + /// In a pattern's definition, the operands of its first operator start with its head, + /// `(name) llx lly urx ury`: taken off them. + pub(super) fn take_head(&mut self, mut vals: Vec) -> Vec { + let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals }; + head.taken = true; + let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; + let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); + let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; + head.name = name; + head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); + vals.split_off(at + 5) + } +} diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs new file mode 100644 index 000000000..b61b74953 --- /dev/null +++ b/crates/eps/src/import/ai/read/document.rs @@ -0,0 +1,437 @@ +use super::art::{element_name, elements, join_and, sane}; +use super::containers::drop_commented_copies; +use super::types::{FLIP, Kind, MAX_DEPTH}; +use super::*; + +/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). +fn ruler_unit(v: u8) -> Option { + Some(match v { + 0 => Unit::Inches, + 1 => Unit::Millimeters, + 2 => Unit::Points, + 3 => Unit::Picas, + 4 => Unit::Centimeters, + 6 => Unit::Pixels, + _ => return None, + }) +} + +/// The name of the empty group that stands for a text object, followed by the number of its +/// story in the text document when the file says. +pub(crate) const TEXT_SLOT: &str = "\u{0}text"; + +/// Is `name` that of a text slot, and which story is it of? +pub(crate) fn slot_of(name: Option<&str>) -> Option> { + name?.strip_prefix(TEXT_SLOT).map(|rest| rest.split(':').next().and_then(|s| s.parse().ok())) +} + +/// Which of its story's frames a text slot named `name` is (0 when it doesn't say). +pub(crate) fn slot_frame(name: Option<&str>) -> usize { + name.and_then(|n| n.strip_prefix(TEXT_SLOT)).and_then(|rest| rest.split_once(':')).and_then(|(_, f)| f.parse().ok()).unwrap_or(0) +} + +/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't +/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to +/// call them (#1068). +pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> { + let found = artboards_read(data); + if !found.is_empty() { + return found; + } + // Reading can stop before the artboards (art kept in the document's data comes first): read + // their array on its own. + artboard_array(data).map_or(found, |array| { + let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec(); + alone.extend_from_slice(array); + alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n"); + artboards_read(&alone) + }) +} + +/// The `ArtboardArray` entry of the editing data `data`, from its `/Array :` to its key: found +/// from the key back, counting the dictionaries its artboards open and close. +fn artboard_array(data: &[u8]) -> Option<&[u8]> { + const KEY: &[u8] = b"%_; (ArtboardArray) ,"; + let end = data.windows(KEY.len()).rposition(|w| w == KEY)? + KEY.len(); + let head = data.get(..end)?; + let mut depth = 0usize; + let mut line_end = head.len(); + loop { + let line_start = head.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); + if let Some(text) = head.get(line_start..line_end)?.strip_prefix(b"%_") { + if text.starts_with(b";") || text.starts_with(b"/Def ;") { + depth += 1; + } else if text.starts_with(b"/") && text.windows(2).any(|w| w == b" :") { + depth = depth.checked_sub(1)?; + if depth == 0 { + return head.get(line_start..); + } + } + } + line_end = line_start.checked_sub(1)?; + } +} + +/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and +/// those of a `/Document` still open when it stopped (its art can be what stopped it). +fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { + let mut r = Reader::new(data); + let _ = r.run(); + let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); + for o in &open { + if r.artboards.is_empty() { + r.document(o); + } + } + r.artboards +} + +/// How many variables and data sets the variable library of the editing data has: XML nodes +/// (`/XMLNode`) of the `/Document` dictionary, a `variableSets` element holding `variableSet`s, each +/// with its `variables` (`variable` elements) and `sampleDataSets` (`sampleDataSet` elements). +/// VectorCraft doesn't read them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VariableLibrary { + pub variables: usize, + pub data_sets: usize, +} + +impl VariableLibrary { + /// The import's note that the library wasn't read, when it has a variable or a data set. + pub fn note(self) -> Option { + let Self { variables, data_sets } = self; + // Counts after a colon need no plural, so every language can translate the note whole. + (variables + data_sets > 0).then(|| { + format!("the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them") + }) + } +} + +/// The variable library of the editing data `data` ([`VariableLibrary`]), for a file whose layers +/// [`read`] can't read. Data without a `variableSets` element isn't read. +pub fn variables(data: &[u8]) -> VariableLibrary { + if crate::import::ai::find(data, b"variableSets").is_none() { + return VariableLibrary::default(); + } + let mut r = Reader::new(data); + let _ = r.run(); + // A `/Document` still open when reading stopped. + let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); + for o in open.iter().filter(|o| o.ty == "Document") { + variable_library(o, 0, &mut r.variables); + } + if r.variables != VariableLibrary::default() { + return r.variables; + } + // Reading can stop before the library: read its node on its own. + variable_sets_node(data).map_or(r.variables, |node| { + let mut alone = b"%_/Document :\n%_/Dictionary :\n%_/Array :\n".to_vec(); + alone.extend_from_slice(node); + alone.extend_from_slice(b"\n%_; (xmlnode-children) ,\n%_; /Recorded ,\n%_;\n"); + let mut r = Reader::new(&alone); + let _ = r.run(); + r.variables + }) +} + +/// The `variableSets` XML node of the editing data `data`, from its `/XMLNode :` line to the line +/// that closes it: found from its name back, counting the dictionaries and arrays it holds. +fn variable_sets_node(data: &[u8]) -> Option<&[u8]> { + const NAME: &[u8] = b"variableSets) /String (xmlnode-nodename) ,"; + let at = data.windows(NAME.len()).position(|w| w == NAME)?; + let opens = |text: &[u8]| text.starts_with(b"/") && text.windows(2).any(|w| w == b" :"); + let closes = |text: &[u8]| text.starts_with(b";") || text.starts_with(b"/Def ;"); + let mut depth = 0usize; + let mut line_end = data.get(..at)?.iter().rposition(|&b| b == b'\n' || b == b'\r')?; + let start = loop { + let line_start = data.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); + if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { + if closes(text) { + depth += 1; + } else if opens(text) { + match depth.checked_sub(1) { + Some(d) => depth = d, + None => break line_start, + } + } + } + line_end = line_start.checked_sub(1)?; + }; + let mut line_start = start; + loop { + let rest = data.get(line_start..)?; + let line_end = line_start + rest.iter().position(|&b| b == b'\n' || b == b'\r').unwrap_or(rest.len()); + if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { + if closes(text) { + depth = depth.checked_sub(1)?; + if depth == 0 { + return data.get(start..line_end); + } + } else if opens(text) { + depth += 1; + } + } + if line_end >= data.len() { + return None; + } + line_start = line_end + 1; + } +} + +impl<'a> Reader<'a> { + /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the + /// opacity mask it may carry. + pub(super) fn art_dictionary(&mut self, o: &Obj) { + // A blend's key object or spine. + if let Some(kind) = o.text("ShapeBlendPathType") + && let Some(id) = self.last_object().map(|n| n.id) + && self.blend_roles.len() < 100_000 + { + self.blend_roles.insert(id, (kind, o.text("ShapeBlendPathID"))); + } + // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as + // a group holding the mesh that shapes it; the distorted art the file's PDF part draws + // follows on plain lines. That mesh isn't art, so it goes (#2405). + if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_mesh() { + return; + } + if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { + if let Some(art) = &mask.art { + let on = |key: &str| mask.nums(key).first().copied() == Some(1.0); + let value = + OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") }; + if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { + n.extras_mut().mask = Some(Box::new(value)); + } else if !on("Disabled") { + self.unreadable("opacity masks without an owning object"); + } + } else if mask.nums("Disabled").first().copied() != Some(1.0) { + self.unreadable("opacity masks without readable art"); + } + } + let name = match o.text("AIArtName") { + Some(name) => name, + None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) { + Some(id) => obj::xml_name(&id), + None => return, + }, + }; + let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. }) && !name.is_empty()) else { return }; + if slot_of(n.name.as_deref()).is_some() { + let id = n.id; + self.slot_names.insert(id, name); + } else { + n.name = Some(name); + } + } + + /// The `/Document` dictionary: the artboards, and how many variables and data sets its variable + /// library has. + pub(super) fn document(&mut self, o: &Obj) { + variable_library(o, 0, &mut self.variables); + let Some(list) = find_entry(o, "ArtboardArray", 0) else { return }; + for (i, ab) in list.items().enumerate().take(1000) { + let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2")); + let ([x0, y0, ..], [x1, y1, ..]) = (p1.as_slice(), p2.as_slice()) else { continue }; + let r = Rect::new(*x0, *y0, *x1, *y1).abs(); + if !sane(r) || r.area() <= 0.0 { + continue; + } + let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); + self.artboards.push((name, r)); + } + } + + // ---- the document ---------------------------------------------------------------------- + + /// The artboard of a file without artboards or crop marks: its size around the template + /// box's centre (half a point off the grid). + fn sized_board(&self) -> Option { + let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?); + // Rounded out, but not off a whole point the decimals in the file only just miss. + let near = |v: f64| (v - v.round()).abs() < 1e-6; + let (left, top) = (cx - w / 2.0, cy + h / 2.0); + let x0 = if near(left) { left.round() } else { left.floor() }; + let y1 = if near(top) { top.round() } else { top.ceil() }; + let r = Rect::new(x0, y1 - h, x0 + w, y1); + (sane(r) && w > 0.0 && h > 0.0).then_some(r) + } + + /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. + fn first_board(&self) -> Option { + self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) + } + + /// The page of a file that says nothing of its artboard (the legacy format as other apps write + /// it): its art's bounding box. + fn bbox_board(&self) -> Option { + let [x0, y0, x1, y1] = self.hires.or(self.bbox)?; + Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0) + } + + /// Art space → the document (fixed when the art starts, from the artboards read by then). A + /// Large Canvas editing copy is written at 1/N scale ([`Self::large_canvas`]): the factor makes + /// its art and boxes reach the page's size, which is what the page it is opened beside holds. + pub(super) fn ensure_space(&mut self) -> Affine { + if let Some(m) = self.to_doc { + return m; + } + let first = self.first_board().or_else(|| self.bbox_board()); + let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); + let s = self.large_canvas.unwrap_or(1.0); + let m = Affine::new([s, 0.0, 0.0, -s, -s * top_left.x, s * top_left.y]); + self.to_doc = Some(m); + m + } + + /// Art space → where the art being read goes: the document, or while a pattern's definition is + /// read, the pattern's space (y down). + pub(crate) fn space(&mut self) -> Affine { + if self.pattern.is_some() { FLIP } else { self.ensure_space() } + } + + pub(super) fn finish(mut self) -> Result { + if !self.unknown.is_empty() { + let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); + self.unsupported.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", "))); + } + if !self.unsupported.is_empty() { + let list: Vec<&str> = self.unsupported.iter().map(String::as_str).collect(); + return Err(format!("it has {}, which VectorCraft doesn't read from it yet", join_and(&list))); + } + if self.doc.layers.is_empty() { + return Err("it has no layers".into()); + } + let to_doc = self.ensure_space(); + let artboard = self.first_board(); + drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); + let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); + let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { + artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() + } else { + self.artboards.clone() + }; + doc.artboards = boards + .into_iter() + .enumerate() + .map(|(i, (name, r))| Artboard { + id: i as u32 + 1, + name, + rect: to_doc.transform_rect_bbox(r), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }) + .collect(); + if doc.artboards.is_empty() { + let rect = doc.art_bounds().filter(|r| sane(*r) && r.area() > 0.0).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); + doc.artboards = vec![Artboard { + id: 1, + name: "Artboard 1".into(), + rect, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }]; + } + if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { + doc.color_mode = ColorMode::Cmyk; + let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + } + if let Some(u) = self.ruler_units.and_then(ruler_unit) { + doc.units = u; + } + // The file's own swatches in place of the defaults, in its order and colour groups. + if !self.palette.is_empty() { + let palette = std::mem::take(&mut self.palette); + let swatch = |e: crate::import::ai::palette::Entry| -> Option { + use crate::import::ai::palette::Cell; + let (paint, global, spot) = match e.cell { + Cell::Color { color, global, spot } => (Paint::solid(color), global, spot), + Cell::Gradient(g) => { + let mut gp = vectorcraft_color::gradient::GradientPaint::new(self.gradients.get(&g)?.clone()); + gp.swatch = Some(e.name.clone()); + (Paint::Gradient(Box::new(gp)), false, false) + } + Cell::Pattern(p) => { + doc.patterns.iter().find(|d| d.name == p)?; + (Paint::Pattern { pattern: p, xf: Affine::IDENTITY }, false, false) + } + }; + Some(Swatch { name: e.name, paint, global, spot }) + }; + let mut swatches = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }]; + swatches.extend(palette.swatches.into_iter().filter_map(swatch)); + let groups: Vec<_> = palette + .groups + .into_iter() + .map(|(name, s)| vectorcraft_color::swatch::SwatchGroup { name, swatches: s.into_iter().filter_map(swatch).collect() }) + .collect(); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + } + // The colour operators' definitions first: a gradient, defined before the art, names a + // global process colour as a stop and would otherwise make it an ink (#1771). + for n in self.spots.iter().chain(&self.stop_spots) { + if doc.swatch(&n.name).is_none() { + doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); + } + } + for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { + if doc.swatch(&name).is_none() { + doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); + } + } + let mut warnings = self.warnings; + if self.drawn_looks > 0 { + warnings.push(format!( + "objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet", + self.drawn_looks + )); + } + warnings.extend(self.variables.note()); + Ok(Structure { + doc, + to_doc, + bbox: self.hires.or(self.bbox), + artboard, + template: self.template, + hidden_unread: self.hidden_unread, + slot_names: self.slot_names, + restyled: self.restyled, + warnings, + }) + } +} + +/// The dictionary of entry `key` in `o` or the dictionaries it holds. +fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { + if depth > 8 { + return None; + } + o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1))) +} + +/// Add the variables and data sets of the variable libraries in `o` or the dictionaries it holds +/// (see [`VariableLibrary`]) to `found`. +fn variable_library(o: &Obj, depth: usize, found: &mut VariableLibrary) { + if depth > MAX_DEPTH { + return; + } + if o.ty == "XMLNode" && element_name(o).as_deref() == Some("variableSets") { + for set in elements(o, "variableSet") { + found.variables += elements(set, "variables").flat_map(|v| elements(v, "variable")).count(); + found.data_sets += elements(set, "sampleDataSets").flat_map(|d| elements(d, "sampleDataSet")).count(); + } + return; + } + for c in o.items() { + variable_library(c, depth + 1, found); + } +} diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs new file mode 100644 index 000000000..24105cf28 --- /dev/null +++ b/crates/eps/src/import/ai/read/ops.rs @@ -0,0 +1,646 @@ +use super::containers::close; +use super::document::slot_of; +use super::types::{BLENDS, FLIP, Frame, IGNORED, Kind, MAX_DEPTH}; +use super::*; + +impl<'a> Reader<'a> { + // ---- operators ------------------------------------------------------------------------- + + pub(super) fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { + if self.blend_record.is_some() { + self.end_blend(); + } + match w { + // The writer's placeholder for a number the object doesn't have (a radial gradient's + // highlight `-- -- angle length Bh`): a NaN keeps the other operands where they are. + "--" => return self.push(V::Num(f64::NAN)), + "[" => { + self.marks.push(self.stack.len()); + return self.push(V::Mark); + } + "]" => { + // The innermost `[` still on the stack (the stack is cut back by operators). + let mut at = self.stack.len(); + while let Some(m) = self.marks.pop() { + if matches!(self.stack.get(m), Some(V::Mark)) { + at = m; + break; + } + } + let items: Vec = self.stack.split_off(at).into_iter().skip(1).collect(); + return self.push(V::Arr(items)); + } + ":" => return self.begin_obj(hidden), + "," => { + if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() { + obj::add_entry(o, &mut self.stack, *base); + // Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's + // (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s + // `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line + // of it may start with `%_` and read as hidden tokens: a `(` there would open + // a string that swallows the rest of the layer. + let key = o.entries.last().and_then(|(k, _)| k.as_deref()); + if o.ty == "Binary" && key == Some("ASCII85Decode") { + self.lex.skip_past(b"~>"); + } else if o.ty == "ForeignObject" && key == Some("Data") { + self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec()); + } + } + return Ok(()); + } + ";" => return self.end_obj(), + "X=" => { + if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { + // `X=` also introduces legacy text frame art, read by `text_frames`. + return Ok(()); + } + self.end_path_quietly(); + return self.open(Kind::Mask, hidden); + } + "X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(), + "X+" => return Ok(()), + _ => {} + } + // What follows an object (its name, transparency and style) ends with anything else. The + // state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the `LB`. + if !matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { + self.after_object = false; + self.layer_state = None; + } + let group_ended = std::mem::take(&mut self.group_ended); + let vals = self.operands(); + let vals = self.take_head(vals); + // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so + // the operator is one the reader doesn't know, as before. + if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) { + self.note_unknown("--"); + return Ok(()); + } + if let Some(d) = self.gradient_def.as_mut() { + match w { + "Bs" => d.stop(&vals, &mut self.stop_spots), + "BD" => { + if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) { + self.gradients.insert(name, g); + } + } + _ => {} + } + return Ok(()); + } + if w == "X!" || self.mesh.is_some() { + let name = vals.iter().rev().find_map(|v| if let V::Name(n) = v { Some(n.as_str()) } else { None }); + match (w, name) { + ("X!", Some("Mesh")) => { + self.end_path_quietly(); + self.mesh = Some((crate::import::ai::mesh::MeshReader::default(), hidden)); + return Ok(()); + } + ("X!", Some("End")) if self.mesh.is_some() => return self.end_mesh(), + ("X#", _) => { + let to_doc = self.space(); + if let Some((m, _)) = self.mesh.as_mut() { + m.entry(&vals, to_doc); + } + return Ok(()); + } + _ if self.mesh.is_some() => return Ok(()), + _ => {} + } + } + match w { + // Containers. + "u" => { + self.end_path_quietly(); + self.open(Kind::Group, hidden)? + } + "*u" => { + self.end_path_quietly(); + self.open(Kind::Compound, hidden)? + } + "q" => { + self.end_path_quietly(); + self.open(Kind::Clip, hidden)? + } + "U" | "*U" | "Q" => self.end_container(w, hidden)?, + "Lb" => self.layer_attrs(&vals), + "Ln" => { + if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() { + a.name = vals.iter().rev().find_map(V::text).unwrap_or_default(); + } + } + "LB" => self.end_layer()?, + // Paths. + "m" | "l" | "L" | "c" | "C" | "v" | "V" | "y" | "Y" => self.path_op(w, &vals)?, + "h" => close(&mut self.path), + // Ends a path that stays open. + "H" => {} + "W" => self.clip_next = true, + // A text object's frame or path. + "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" if self.in_text_path() => self.text_path(), + "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" => self.paint(w, hidden, false)?, + "*" => { + let op = vals.iter().rev().find_map(V::text).unwrap_or_else(|| "N".into()); + self.paint(&op, hidden, true)?; + } + // Colours. + "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" | "Xs" | "XS" | "Xz" | "XZ" => self.color(w, &vals), + "p" | "P" => self.pattern_paint(w, &vals), + // A pattern's definition ends. + "E" if self.pattern.is_some() => self.done = true, + "O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0), + "R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0), + "XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero }, + "w" => { + if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 0.0) { + self.gs.width = *v; + } + } + "J" => { + self.gs.cap = match Self::nums(&vals).last().map(|v| *v as i64) { + Some(1) => LineCap::Round, + Some(2) => LineCap::Square, + _ => LineCap::Butt, + } + } + "j" => { + self.gs.join = match Self::nums(&vals).last().map(|v| *v as i64) { + Some(1) => LineJoin::Round, + Some(2) => LineJoin::Bevel, + _ => LineJoin::Miter, + } + } + "M" => { + if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 1.0) { + self.gs.miter = v.min(500.0); + } + } + "d" => self.dash(&vals), + "D" => { + // The published Adobe Illustrator File Format Specification (version 3.0), §6.8, + // permits `u` groups of compound members. + // Their explicit winding declarations describe subpaths, not separate paints; + // modern streams can also end these groups with `9 () XW`. + let n = self.frames.len(); + if n >= 2 + && self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound)) + && let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group)) + { + f.compound_members = true; + } + } + // Transparency and state. + "Xy" => self.transparency(&vals), + // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as + // the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own. + "Xd" if group_ended => { + let gs = self.gs.clone(); + if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. })) { + (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); + } + } + "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), + "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), + "XW" => self.style_marker(&vals, hidden), + // Gradients. + "Bd" => self.gradient_def = GradientDef::begin(&vals), + "Bs" | "BD" => {} + "Bb" => self.instance = Some(Instance::begin(&vals)), + "Bg" => { + if let Some(i) = self.instance.as_mut() { + i.bg(&vals); + } + } + "Bm" => { + if let Some(i) = self.instance.as_mut() { + i.bm(&vals); + } + } + "Xm" => { + if let Some(i) = self.instance.as_mut() { + i.xm(&vals); + } + } + "Bh" => { + if let Some(i) = self.instance.as_mut() { + i.bh(&vals); + } + } + "BB" => { + // An instance that closes with no path to paint is an object's own note of its + // gradient, written after it: it paints nothing (not the next path either). + if let Some(i) = self.instance.take() + && !self.path.elements().is_empty() + { + self.set_instance(&i); + } + // `1 BB` strokes the path the gradient just filled, `2 BB` closes it first (§3.5.3). + if matches!(Self::nums(&vals).last(), Some(f) if *f == 1.0 || *f == 2.0) { + self.stroke_last(); + } + } + // Images. + "XN" => { + if let Some(r) = self.raster.as_mut() { + r.space = vals.iter().rev().find_map(V::text); + } + } + // Placed files. + "`" if self.place.is_some() => { + let matrix = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }); + let path = vals.iter().rev().find_map(V::text).unwrap_or_default(); + if let Some(p) = self.place.as_mut() { + p.matrix = matrix.and_then(|m| <[f64; 6]>::try_from(m).ok()).map(Affine::new); + p.bounds = + Self::nums(&vals).get(..4).and_then(|b| <[f64; 4]>::try_from(b).ok()).map(|[x0, y0, x1, y1]| Rect::new(x0, y0, x1, y1)); + p.path = path; + } + } + "~" if self.place.is_some() => self.end_place(hidden)?, + // Type in the legacy format. + "To" => self.begin_text(&vals, hidden), + "Tp" => self.begin_text_path(&vals), + "TP" => self.end_text_path(), + "TO" => self.end_text(hidden)?, + "Tx" | "Tj" | "TX" => self.text_body(w, &vals), + "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals), + // Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation, + // Far-Eastern type) and final-form positions (§15.3). + "Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" + | "Tu" | "Xu" => {} + // Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th` + // hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe` + // tab descriptions between them). + "Th" | "XL" | "Xb" | "XB" | "Xe" => {} + "TE" => self.te(&vals), + // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). + "XI" if self.raster.is_some() => { + if let Some(r) = self.raster.as_mut() { + r.operands = Some(vals); + } + } + // A plugin group ends; a blend's record follows on `%` lines. + "XP" => { + let name = vals.iter().find_map(V::text).unwrap_or_default(); + let len = Self::nums(&vals).last().copied().unwrap_or(0.0); + if name == crate::import::ai::blend::PLUGIN && len.is_finite() && len > 0.0 && len <= crate::import::ai::blend::MAX_RECORD as f64 { + self.blend_record = Some((len as usize, vec![])); + } + } + _ if IGNORED.contains(&w) => {} + // Before the art, the setup runs procedures of the printing format. + _ if self.frames.is_empty() => {} + _ => self.note_unknown(w), + } + Ok(()) + } + + /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place + /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). + fn layer_attrs(&mut self, vals: &[V]) { + let n = Self::nums(vals); + let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; + let flag = |i: usize, default: bool| n.get(i).map_or(default, |v| *v != 0.0); + a.visible = flag(0, true); + a.preview = flag(1, true); + a.locked = !flag(2, true); + a.printable = flag(3, true); + let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); + a.dim = flag(4, false).then_some(dim_pct.round() as u8); + let at = if n.len() <= 10 { 6 } else { 7 }; + let index = n.get(at).copied().unwrap_or(0.0); + let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); + a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { + LayerColor::Preset(index as u8) + } else { + LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) + }); + } + + /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): + /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't + /// read can't be drawn. + fn pattern_paint(&mut self, w: &str, vals: &[V]) { + // In the setup it paints nothing (and the art's space isn't fixed yet). + if self.frames.is_empty() { + return; + } + let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { + self.unreadable("pattern fills"); + return; + }; + let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + let Some(xf) = pattern_xf(self.space(), matrix, tile) else { + self.unreadable("pattern fills"); + return; + }; + let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; + if w == "p" { + self.gs.fill = p; + } else { + self.gs.stroke = p; + } + } + + fn color(&mut self, w: &str, vals: &[V]) { + let Some((p, named)) = paint::color_op(w, vals) else { return }; + // Registration (`Xs`, `Xz`) says nothing of the document's colour model. + let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ"); + match p.color().filter(|_| !registration) { + Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, + Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, + _ => {} + } + if let Some(n) = named { + self.spots.push(n); + } + // A colour set after a gradient instance that painted nothing (`Bb` … `0 BB` with no + // path) is the next object's paint, not the gradient. + if w.ends_with(|c: char| c.is_ascii_lowercase()) { + self.gs.fill = p; + self.pending_fill = None; + } else { + self.gs.stroke = p; + self.pending_stroke = None; + } + } + + /// `[dashes] phase d`. + fn dash(&mut self, vals: &[V]) { + let pattern: Vec = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }).unwrap_or_default(); + let offset = Self::nums(vals).last().copied().unwrap_or(0.0); + self.gs.dash = (!pattern.is_empty() && pattern.len() <= 64 && pattern.iter().all(|v| v.is_finite() && *v >= 0.0)).then(|| Dash { + pattern, + offset: if offset.is_finite() { offset } else { 0.0 }, + align_corners: false, + }); + } + + /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too. + fn transparency(&mut self, vals: &[V]) { + let n = Self::nums(vals); + let [mode, opacity, rest @ ..] = n.as_slice() else { return }; + let gs = &mut self.gs; + gs.blend = BLENDS.get(*mode as usize).copied().filter(|_| *mode >= 0.0).unwrap_or(BlendMode::Normal); + gs.opacity = if opacity.is_finite() { opacity.clamp(0.0, 1.0) as f32 } else { 1.0 }; + gs.isolate = rest.first().is_some_and(|v| *v != 0.0); + gs.knockout = match rest.get(1).map(|v| *v as i64) { + Some(1) => Knockout::On, + Some(2) => Knockout::Neutral, + _ => Knockout::Off, + }; + gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); + let gs = self.gs.clone(); + if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { + if let Some(n) = self.last_object() { + n.opacity = gs.opacity; + n.blend = gs.blend; + n.isolate = gs.isolate; + n.knockout = gs.knockout; + } + self.layer_state = None; + } else { + // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until + // an operator that isn't part of the state block (or the `LB` that takes it) says the + // art moves on, so a path's state before its paint isn't read as its layer's. + self.layer_state = + matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })).then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + } + } + + /// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its + /// drawn look before it (all on `%_` lines inside another such object). Other markers end what + /// follows an object. + fn style_marker(&mut self, vals: &[V], hidden: bool) { + let code = Self::nums(vals).last().copied().unwrap_or(0.0); + let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); + let after = std::mem::take(&mut self.after_object); + if code == 9.0 + && let Some(look) = self + .frames + .last() + .and_then(|f| f.children.last()) + .filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id)) + { + self.looks.insert(look.id); + } + if code != 1.0 || style.is_empty() || !after { + return; + } + let space = self.space(); + let Some(f) = self.frames.last_mut() else { return }; + let n = f.children.len(); + let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else { + return; + }; + // The look is a group of paths, or one image where an effect (a blur, a shadow) rasterizes it. + if !self.hidden_ids.contains(&object.id) + || self.hidden_ids.contains(&look.id) != hidden + || self.text_looks.contains(&look.id) + || !matches!(look.kind, NodeKind::Group { clip: false, .. } | NodeKind::Image(_)) + { + return; + } + // A style read here: the object, painted as it says. + if let Some(appearance) = self + .styles + .get(&style) + .and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space })) + { + let Some(mut object) = f.children.pop() else { return }; + let Some(look) = f.children.pop() else { return }; + if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { + self.restyled.push(b); + } + object.appearance = appearance.into(); + // The object's transparency is written on its look, after the look's group ends. + (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); + // Now the object itself, not a copy an enclosing look's `strip` would remove. + self.hidden_ids.remove(&object.id); + f.children.push(object); + return; + } + let is_text = slot_of(object.name.as_deref()).is_some(); + let Some(mut object) = f.children.pop() else { return }; + let Some(look) = f.children.last_mut() else { return }; + // Inside a copy on `%_` lines everything is, so nothing tells the look's own copies apart. + if !hidden { + strip(look, &self.hidden_ids); + } + if is_text { + // A text object stays the type it is: over what else its look draws, or in place of a + // look that was only its type. Its transparency is written on its look. + let empty = look.children().is_some_and(|c| c.is_empty()); + if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate) { + (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); + if empty { + *look = object; + } else { + f.children.push(object); + } + return; + } + // The look and the type go into a group that has the transparency. + let Some(mut look) = f.children.pop() else { return }; + let state = (look.opacity, look.blend, look.isolate, look.knockout); + (look.opacity, look.blend, look.isolate, look.knockout) = (1.0, BlendMode::Normal, false, Knockout::Neutral); + (object.opacity, object.blend, object.isolate) = (1.0, BlendMode::Normal, false); + let mut group = Node::group(self.doc.alloc_id(), vec![Arc::new(look), Arc::new(object)]); + (group.opacity, group.blend, group.isolate, group.knockout) = state; + self.text_looks.insert(group.id); + if let Some(f) = self.frames.last_mut() { + f.children.push(group); + } + return; + } + look.name = object.name.clone().or(look.name.take()); + look.visible = object.visible; + look.locked = object.locked; + if !hidden { + self.drawn_looks += 1; + } + } +} + +/// Whether `look`'s paths don't all paint the same. +pub(super) fn paints_apart(look: &Node) -> bool { + fn leaves<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { + match &n.kind { + NodeKind::Group { children, .. } => children.iter().for_each(|c| leaves(c, out)), + _ => out.push(n), + } + } + let mut paths = vec![]; + leaves(look, &mut paths); + paths.windows(2).any(|w| matches!(w, [a, b] if a.appearance != b.appearance)) +} + +pub(super) fn path_rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + } +} + +/// The tile's art (y down from the tile's top left) → the pattern's space (`matrix`) → art space +/// → the document (`space`). The tile's lower left is the pattern's origin wherever the +/// definition drew it, as the file's PDF part tiles it. `None` when it can't be inverted. +fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option { + let xf = space * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); + (xf.determinant().abs() >= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) +} + +/// What a style's fills paint with: the file's gradients and pattern tiles, and art space → the document. +struct StylePaints<'a> { + gradients: &'a HashMap, + patterns: &'a HashMap, + space: Affine, +} + +/// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name +/// (the first of each; a pattern's name after `PATTERN_KEY`). +fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for item in &look.appearance.items { + let key = match item.paint() { + Paint::Gradient(g) => g.swatch.clone(), + Paint::Pattern { pattern, .. } => Some(format!("{PATTERN_KEY}{pattern}")), + _ => None, + }; + if let Some(key) = key { + out.entry(key).or_insert_with(|| item.paint().clone()); + } + } + for c in look.children().into_iter().flatten() { + placed_paints(c, out, depth + 1); + } +} + +/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. +const PATTERN_KEY: &str = "\u{1}pattern "; + +/// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one +/// this reads, or asks a group for fills or strokes. +fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, paints: &StylePaints) -> Option { + use crate::import::ai::style::StyleItem; + // A text object's slot (an empty group standing for it) takes the style as the type will. + let slot = slot_of(object.name.as_deref()).is_some(); + if !st.plain + || (!slot && !st.simple) + || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) + { + return None; + } + // A style with one gradient fill: the object's own gradient, placed as the object's fill is. + let gradients = st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count(); + let own_gradient = object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_))); + // A style's gradients are written in a space of the object's bounds that isn't documented; its + // drawn look has each where the app placed it. + let mut placed = HashMap::new(); + placed_paints(look, &mut placed, 0); + let mut items = vec![]; + for (n, item) in st.items.iter().enumerate() { + let mut layer = match item { + StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), + StyleItem::Gradient { name, angle } => { + let paint = match (name.as_ref().and_then(|n| placed.get(n)), gradients, own_gradient) { + // Other objects than type: the object's own, as they always were. + (_, 1, Some(own)) if !slot => own.paint.clone(), + (Some(p), ..) => p.clone(), + (None, 1, Some(own)) => own.paint.clone(), + _ => { + let gradient = name.as_ref().and_then(|n| paints.gradients.get(n))?; + let mut g = vectorcraft_color::gradient::GradientPaint::new(gradient.clone()); + // Fitted to the object's bounds at its angle (art space turns counter-clockwise, as the document's angles do). + g.angle = *angle; + Paint::Gradient(Box::new(g)) + } + }; + AppearanceItem::Fill(FillLayer::new(paint)) + } + StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) { + Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), + None => { + let tile = paints.patterns.get(name).copied()?; + let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?; + AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf })) + } + }, + StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => { + let mut layer = StrokeLayer::new(paint.clone(), *width); + (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); + AppearanceItem::Stroke(layer) + } + }; + if let Some(look) = st.looks.get(n) { + match &mut layer { + AppearanceItem::Fill(f) => { + (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) + } + AppearanceItem::Stroke(s) => { + (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) + } + } + } + items.push(layer); + } + let contents_index = if object.is_container() || matches!(object.kind, NodeKind::Text(_) | NodeKind::Image(_)) { + st.contents_index + } else { + object.appearance.contents_index + }; + Some(Appearance { items, effects: st.effects.clone(), contents_index }) +} + +/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). +fn strip(n: &mut Node, hidden: &HashSet) { + if let Some(children) = n.children_mut() { + children.retain(|c| !hidden.contains(&c.id)); + for c in children.iter_mut() { + strip(Arc::make_mut(c), hidden); + } + } +} diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs new file mode 100644 index 000000000..4c15b04fa --- /dev/null +++ b/crates/eps/src/import/ai/read/types.rs @@ -0,0 +1,255 @@ +use super::*; + +/// Deepest nesting of layers, groups and dictionaries. +pub(super) const MAX_DEPTH: usize = 256; + +/// Most operands on the stack. +pub(super) const MAX_STACK: usize = 1 << 20; + +/// Most objects read. +pub(super) const MAX_NODES: usize = 4_000_000; + +/// The most objects all the blends of one import may lay out to be checked (each twice) and made +/// live; past it, blends keep their drawn steps. +pub(super) const BLEND_BUDGET: usize = 50_000; + +/// Most points in one path. +pub(super) const MAX_POINTS: usize = 4_000_000; + +/// Largest image read (pixels). +pub(super) const MAX_PIXELS: u64 = 1 << 25; + +/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads +/// larger images (#1479). +pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; + +/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). +pub(super) const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; + +/// Largest side of the document and its artboards (points). +pub(super) const MAX_SIDE: f64 = 1e6; + +/// Art space (y up) → a pattern's space in the document's terms (y down). +pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); + +/// Most patterns read (others can't fill art). +pub(super) const MAX_PATTERNS: usize = 4096; + +#[derive(Clone, Debug)] +pub(crate) struct GState { + pub(crate) fill: Paint, + pub(crate) stroke: Paint, + pub(crate) width: f64, + pub(crate) cap: LineCap, + pub(crate) join: LineJoin, + pub(crate) miter: f64, + pub(crate) dash: Option, + pub(super) rule: FillRule, + pub(super) opacity: f32, + pub(super) blend: BlendMode, + pub(super) isolate: bool, + pub(super) knockout: Knockout, + pub(super) knockout_shape: bool, + pub(crate) overprint_fill: bool, + pub(crate) overprint_stroke: bool, + pub(super) locked: bool, + pub(super) hidden: bool, +} + +impl Default for GState { + fn default() -> Self { + let black = Paint::solid(vectorcraft_color::Color::BLACK); + Self { + fill: black.clone(), + stroke: black, + width: 1.0, + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 10.0, + dash: None, + rule: FillRule::NonZero, + opacity: 1.0, + blend: BlendMode::Normal, + isolate: false, + knockout: Knockout::Off, + knockout_shape: false, + overprint_fill: false, + overprint_stroke: false, + locked: false, + hidden: false, + } + } +} + +/// The blend modes by their number in `Xy`. +pub(super) const BLENDS: [BlendMode; 16] = [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Screen, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::ColorDodge, + BlendMode::ColorBurn, + BlendMode::Darken, + BlendMode::Lighten, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, +]; + +/// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`). +pub(in super::super) fn blend_name(n: usize) -> String { + format!("{:?}", blend_mode(n)) +} + +/// Blend mode `n` of `Xy` (Normal when it is out of range). +pub(in super::super) fn blend_mode(n: usize) -> BlendMode { + BLENDS.get(n).copied().unwrap_or(BlendMode::Normal) +} + +#[derive(Debug, Default)] +pub(super) struct LayerAttrs { + pub(super) name: String, + pub(super) visible: bool, + pub(super) preview: bool, + pub(super) locked: bool, + pub(super) printable: bool, + pub(super) dim: Option, + pub(super) color: Option, +} + +#[derive(Debug)] +pub(super) enum Kind { + Layer(Box), + Group, + Compound, + Clip, + Mask, + Obj(Box), +} + +#[derive(Debug)] +pub(super) struct Frame { + pub(super) kind: Kind, + pub(super) children: Vec, + /// Opened on a `%_` line. + pub(super) hidden: bool, + /// [`Reader::after_object`] when it opened. + pub(super) after: bool, + /// The state when it opened (a container's lock and visibility; a dictionary's state to go + /// back to). + pub(super) gs: GState, + /// The operand stack's height when it opened. + pub(super) base: usize, + /// The clipping paths' indexes among the children. + pub(super) clips: Vec, + /// A compound member group explicitly declares its subpaths' winding with `D`. + pub(super) compound_members: bool, +} + +/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands +/// before its first operator. +#[derive(Debug, Default)] +pub(super) struct PatternHead { + pub(super) taken: bool, + pub(super) name: String, + /// The tile in the pattern's space (y up). + pub(super) tile: Option, +} + +/// An image's operands, from `XN` and before `XI`. +#[derive(Debug, Default)] +pub(super) struct Raster { + pub(super) space: Option, + /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to + /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). + pub(super) operands: Option>, + /// Those hex digits. + pub(super) hex: Vec, +} + +/// A placed file's operands, from `` ` `` and its preview, until `~`. +#[derive(Debug, Default)] +pub(super) struct Place { + /// The canvas, where the file's pixels are → art space. + pub(super) matrix: Option, + /// The file's box (`llx lly urx ury`): its size is the file's, in pixels. + pub(super) bounds: Option, + pub(super) path: String, + /// The preview as a PNG, and its size in pixels. + pub(super) preview: Option<(Vec, u32, u32)>, +} + +/// What the open result says of placed files: they show the preview the file keeps of them. +pub(super) const LINKED_PREVIEWS: &str = + "linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel"; + +/// Operators that change nothing the reader keeps. +pub(super) const IGNORED: &[&str] = &[ + "Ae", + "AE", + "Ap", + "As", + "Xd", + "Xr", + "XG", + // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. + "XC", + "Xh", + "XH", + "XF", + "Bc", + "XP", + "Np", + // Flatness (`i`): how finely curves print. + "i", + // Path resolution (`Ar`, §12): how finely curves print. + "Ar", + "TZ", + "Xs", + "Xt", + // A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path. + "XD", + "Xi", + "XI", + "`", + "LB2", + "Lc", + "Xv", + "XV", + "Xq", + "XQ", + "Xg", + "Xn", + "Bn", + "gsave", + "grestore", + "showpage", + "annotatepage", +]; + +/// Sections skipped whole, by their markers without the `AI_` prefix: begin → end. +pub(super) const SKIPPED: &[(&str, &str)] = &[ + ("BeginPattern", "EndPattern"), + ("BeginBrushPattern", "EndBrushPattern"), + ("BeginSVGFilter", "EndSVGFilter"), + ("BeginSymbol", "EndSymbol"), + ("BeginPluginObject", "EndPluginObject"), + ("BeginArtStyles", "EndArtStyles"), + ("BeginArtStyleList", "EndArtStyleList"), + ("BeginSymbolList", "EndSymbolList"), + ("BeginTextDocument", "EndTextDocument"), + ("BeginEncoding", "EndEncoding"), + ("Alternate_Content", "End_Versioned_Content"), +]; + +/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor +/// shows but doesn't edit) whose PDF can't be read. +pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out"; + +/// The name of the group a `/ForeignObject`'s art comes in as. +pub const NON_NATIVE_ART: &str = "Non-native art"; From 6e6b0bb3eb57adbb3ddbeb5b73c70a4deec3ffbd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:30:38 +0000 Subject: [PATCH 126/199] refactor(eps): move import/data.rs items to import/data/{filters,ops,pixels}.rs --- crates/eps/src/import/data.rs | 1336 +------------------------ crates/eps/src/import/data/filters.rs | 477 +++++++++ crates/eps/src/import/data/ops.rs | 761 ++++++++++++++ crates/eps/src/import/data/pixels.rs | 106 ++ 4 files changed, 1348 insertions(+), 1332 deletions(-) create mode 100644 crates/eps/src/import/data/filters.rs create mode 100644 crates/eps/src/import/data/ops.rs create mode 100644 crates/eps/src/import/data/pixels.rs diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index 4db791972..53848af89 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -17,1336 +17,8 @@ use super::lex::{Lexer, find, hex_decode}; use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; use crate::ps; -/// Most bytes one filter or image reads. -const MAX_DATA: usize = 1 << 28; -/// Most pixels an image may have. -const MAX_PIXELS: usize = 1 << 26; -/// Why an image is missing. -const TOO_LARGE: &str = "images too large to read were left out"; -/// Most distinct colours an image converts through a tint transform. -const MAX_TINTS: usize = 1 << 16; +mod filters; +mod ops; +mod pixels; -/// A file object: the program itself, or data a filter decoded. -#[derive(Debug)] -pub(crate) enum Stream { - Current, - /// Filters over the program's own data, decoded when first read. - Pending(Vec), - Buf { - data: Vec, - pos: usize, - /// The samples are 8-bit gray, RGB or CMYK (1, 3 or 4 channels) whatever the image's - /// color space: a decoded JPEG. - rgb: Option, - }, -} - -fn io(at: &str) -> Res { - ps_err("ioerror", at) -} - -/// The bytes `RunLengthDecode` makes of `raw`, and how many it read. -fn run_length(raw: &[u8]) -> Res<(Vec, usize)> { - let mut out = vec![]; - let mut i = 0; - while let Some(&n) = raw.get(i) { - i += 1; - match n { - 128 => break, - 0..=127 => { - let len = usize::from(n) + 1; - out.extend_from_slice(raw.get(i..i + len).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?); - i += len; - } - _ => { - let b = *raw.get(i).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?; - i += 1; - out.extend(std::iter::repeat_n(b, 257 - usize::from(n))); - } - } - if out.len() > MAX_DATA { - return Err(PsError::Limit("decoded data is too large")); - } - } - Ok((out, i)) -} - -/// zlib data inflated, and how many bytes of `raw` it took. -fn flate(raw: &[u8]) -> Res<(Vec, usize)> { - let mut z = flate2::Decompress::new(true); - let mut out = Vec::with_capacity(raw.len().saturating_mul(3).min(MAX_DATA)); - loop { - let before = (z.total_in(), z.total_out()); - if out.capacity() - out.len() < 64 * 1024 { - out.reserve(256 * 1024); - } - let input = raw.get(z.total_in() as usize..).unwrap_or_default(); - let status = z.decompress_vec(input, &mut out, flate2::FlushDecompress::None).map_err(|_| PsError::Ps("ioerror", "FlateDecode".into()))?; - if out.len() > MAX_DATA { - return Err(PsError::Limit("decoded data is too large")); - } - match status { - flate2::Status::StreamEnd => break, - _ if (z.total_in(), z.total_out()) == before => break, - _ => {} - } - } - Ok((out, z.total_in() as usize)) -} - -/// LZW data (`EarlyChange` 1) decoded, and how many bytes of `raw` it took. -fn lzw(raw: &[u8]) -> Res<(Vec, usize)> { - let mut table: Vec> = (0..=255u8).map(|b| vec![b]).collect(); - table.push(vec![]); - table.push(vec![]); - let (mut out, mut prev): (Vec, Option>) = (vec![], None); - let (mut acc, mut bits, mut width, mut i) = (0u32, 0u32, 9u32, 0usize); - loop { - while bits < width { - let Some(&b) = raw.get(i) else { return Ok((out, i)) }; - i += 1; - acc = (acc << 8) | u32::from(b); - bits += 8; - } - let code = ((acc >> (bits - width)) & ((1 << width) - 1)) as usize; - bits -= width; - match code { - 256 => { - table.truncate(258); - width = 9; - prev = None; - } - 257 => return Ok((out, i)), - _ => { - let entry = match (table.get(code), &prev) { - (Some(e), _) if !e.is_empty() => e.clone(), - (_, Some(p)) if code == table.len() => { - let mut e = p.clone(); - e.extend(p.first()); - e - } - _ => return io("LZWDecode"), - }; - out.extend_from_slice(&entry); - if let Some(mut p) = prev.take() - && table.len() < 4096 - { - p.extend(entry.first()); - table.push(p); - } - width = match table.len() + 1 { - n if n >= 2048 => 12, - n if n >= 1024 => 11, - n if n >= 512 => 10, - _ => 9, - }; - prev = Some(entry); - if out.len() > MAX_DATA { - return Err(PsError::Limit("decoded data is too large")); - } - } - } - } -} - -/// A JPEG's pixels (gray, RGB or CMYK, 8 bits) with its channel count, and how many bytes of -/// `raw` it took. A CMYK JPEG gives its samples as stored, for the image's Decode array to read -/// (Adobe's CMYK JPEGs store them inverted, which a Decode array of `[1 0 1 0 1 0 1 0]` undoes). -fn dct(raw: &[u8]) -> Res<(Vec, usize, u8)> { - let end = find(raw, &[0xFF, 0xD9]).map_or(raw.len(), |e| e + 2); - let jpeg = raw.get(..end).unwrap_or_default(); - // `cmyk` gives the ink amounts of inverted samples: inverted back, they are the samples. - if jpeg_components(jpeg) == Some(4) - && let Some(inks) = ImageBlob::new("image/jpeg", jpeg.to_vec()).cmyk() - { - return Ok((inks.data.into_iter().map(|v| 255 - v).collect(), end, 4)); - } - let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; - if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) } -} - -/// CCITT fax data (ITU-T T.4 Group 3, one- or two-dimensional, and T.6 Group 4) decoded to rows -/// of 1-bit samples, each padded to a whole byte, and how many bytes of `raw` it took. With -/// `EndOfBlock` the rows end at the end-of-block code (EOFB, or RTC in Group 3) regardless of -/// `Rows`; without it they end after `Rows` rows, or at the end of the data when `Rows` is 0. -/// Damaged data ends the rows: those decoded before it are kept, and damage in the first row is an -/// `ioerror`. Rows that would take more than `MAX_DATA` bytes are refused. -fn fax(raw: &[u8], settings: DecodeSettings) -> Res<(Vec, usize)> { - let row = (settings.columns as usize).div_ceil(8).max(1); - let most = u32::try_from(MAX_DATA / row).unwrap_or(u32::MAX); - if !settings.end_of_block && settings.rows > most { - return Err(PsError::Limit("decoded data is too large")); - } - // Without a row count that ends the data, a row past `most` shows that the data is too large. - let rows = if settings.end_of_block || settings.rows == 0 { most.saturating_add(1) } else { settings.rows }; - // The decoder reads the RTC of Group 3 two-dimensional data as rows: it gets the data before it. - let rtc = match settings.encoding { - EncodingMode::Group3_2D { .. } if settings.end_of_block => rtc(raw), - _ => None, - }; - let data = rtc.and_then(|(cut, _)| raw.get(..cut)).unwrap_or(raw); - let mut out = FaxRows::default(); - let read = hayro_ccitt::decode(data, &mut out, &mut DecoderContext::new(DecodeSettings { rows, ..settings })); - if out.rows > most { - return Err(PsError::Limit("decoded data is too large")); - } - let used = match read { - Ok(used) => used, - Err(_) if out.rows > 0 => { - out.data.truncate(out.rows as usize * row); - // The whole rows decoded again, for how many bytes they took. - let used = hayro_ccitt::decode(data, &mut Dropped, &mut DecoderContext::new(DecodeSettings { rows: out.rows, ..settings })); - used.unwrap_or(data.len()) - } - Err(_) => return io("CCITTFaxDecode"), - }; - Ok((out.data, rtc.map_or(used, |(_, end)| end))) -} - -/// The RTC that ends Group 3 two-dimensional data (six EOLs, each followed by a 1 tag bit, ITU-T -/// T.4 §4.2.4): the byte that holds the 1 bit of its first EOL, and the byte after its last tag -/// bit. The first EOL with a 1 tag bit that another EOL follows starts it. -fn rtc(raw: &[u8]) -> Option<(usize, usize)> { - let bits = raw.len().saturating_mul(8); - let bit = |i: usize| raw.get(i / 8).is_some_and(|b| b & (0x80 >> (i % 8)) != 0); - let zeros = |i: usize| (i..bits).take_while(|j| !bit(*j)).count(); - // An EOL (its fill bits first) and a 1 tag bit from `i`: the EOL's 1 bit and where they end. - let eol = |i: usize| { - let one = i + zeros(i); - (one - i >= 11 && bit(one) && bit(one + 1)).then_some((one, one + 2)) - }; - let mut i = 0; - while i < bits { - if let Some((one, next)) = eol(i) - && let Some((_, mut end)) = eol(next) - { - for _ in 2..6 { - match eol(end) { - Some((_, e)) => end = e, - None => break, - } - } - return Some((one / 8, end.div_ceil(8))); - } - i += zeros(i) + 1; - } - None -} - -/// Rows of 1-bit samples from a CCITT decoder, each padded to a whole byte: 1 is white, or black -/// with `BlackIs1`. -#[derive(Default)] -struct FaxRows { - data: Vec, - /// Samples in the row being decoded. - x: u32, - /// Whole rows decoded. - rows: u32, -} - -impl Decoder for FaxRows { - fn push_pixel(&mut self, white: bool) { - if self.x.is_multiple_of(8) { - self.data.push(0); - } - if white && let Some(b) = self.data.last_mut() { - *b |= 0x80 >> (self.x % 8); - } - self.x = self.x.saturating_add(1); - } - - fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) { - self.data.extend(std::iter::repeat_n(if white { 0xff } else { 0 }, chunk_count as usize)); - self.x = self.x.saturating_add(chunk_count.saturating_mul(8)); - } - - fn next_line(&mut self) { - self.x = 0; - self.rows = self.rows.saturating_add(1); - } -} - -/// A CCITT decoder that drops what it decodes. -struct Dropped; - -impl Decoder for Dropped { - fn push_pixel(&mut self, _: bool) {} - - fn push_pixel_chunk(&mut self, _: bool, _: u32) {} - - fn next_line(&mut self) {} -} - -/// A decoding filter and its parameters. -#[derive(Clone, Debug)] -pub(crate) struct Filter { - name: Rc, - /// `SubFileDecode`'s count and end-of-data string. - sub: (usize, Vec), - /// `FlateDecode`'s and `LZWDecode`'s `Predictor`: what it undoes. - predictor: Option, - /// `CCITTFaxDecode`'s parameters. - fax: Option, -} - -/// A `Predictor` (2: TIFF, 10–15: PNG) with its `Colors`, `BitsPerComponent` and `Columns`. -#[derive(Clone, Copy, Debug)] -struct Predictor { - kind: u8, - colors: usize, - bpc: usize, - columns: usize, -} - -impl Predictor { - /// The predictor of a filter's parameters, if it has one. - fn of(d: &DictRef) -> Res> { - let num = |k: &str, default: f64| d.borrow().get(&Key::name(k)).and_then(Obj::as_num).unwrap_or(default); - let kind = num("Predictor", 1.0); - if kind <= 1.0 { - return Ok(None); - } - let (colors, bpc, columns) = (num("Colors", 1.0), num("BitsPerComponent", 8.0), num("Columns", 1.0)); - if !(kind == 2.0 || (10.0..=15.0).contains(&kind)) - || !(1.0..=32.0).contains(&colors) - || ![1.0, 2.0, 4.0, 8.0, 16.0].contains(&bpc) - || !(1.0..=1e6).contains(&columns) - { - return ps_err("rangecheck", "Predictor"); - } - Ok(Some(Self { kind: kind as u8, colors: colors as usize, bpc: bpc as usize, columns: columns as usize })) - } - - /// The data the predictor made `data` from. - fn undo(self, data: &[u8]) -> Vec { - // A row longer than the data is the data. - let row = (self.colors * self.bpc * self.columns).div_ceil(8).min(data.len().max(1)); - // Bytes per pixel (at least one) is how far back the left neighbour is. - let bpp = (self.colors * self.bpc).div_ceil(8).max(1); - if self.kind == 2 { - // Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its - // difference from the same component of the pixel to its left, wrapping within its bit - // width. Each row starts again, and the padding bits at a row's end stay as they are. - let mut out = data.to_vec(); - for line in out.chunks_mut(row.max(1)) { - match self.bpc { - 8 => { - for i in bpp..line.len() { - let left = line.get(i - bpp).copied().unwrap_or(0); - if let Some(b) = line.get_mut(i) { - *b = b.wrapping_add(left); - } - } - } - // 16-bit components are big-endian and add as 16-bit numbers. - 16 => { - let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0); - for i in (bpp..line.len().saturating_sub(1)).step_by(2) { - let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]); - let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left); - if let Some(w) = line.get_mut(i..i + 2) { - w.copy_from_slice(&sum.to_be_bytes()); - } - } - } - // 1, 2 or 4 bits: components are packed from each byte's high bit. - bits @ (1 | 2 | 4) => { - let mask = (1u8 << bits) - 1; - let shift = |k: usize| 8 - bits - k * bits % 8; - let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask); - let samples = (line.len() * 8 / bits).min(self.colors * self.columns); - for k in self.colors..samples { - let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask; - if let Some(b) = line.get_mut(k * bits / 8) { - *b = (*b & !(mask << shift(k))) | (sum << shift(k)); - } - } - } - // `Predictor::of` accepts no other depth. - _ => {} - } - } - return out; - } - // PNG: each row starts with its filter type. - let mut out: Vec = Vec::with_capacity(data.len()); - let mut prev = vec![0u8; row]; - for chunk in data.chunks(row + 1) { - let Some((&kind, line)) = chunk.split_first() else { break }; - let mut cur = line.to_vec(); - cur.resize(row, 0); - for i in 0..row { - let a = if i >= bpp { cur.get(i - bpp).copied().unwrap_or(0) } else { 0 }; - let b = prev.get(i).copied().unwrap_or(0); - let c = if i >= bpp { prev.get(i - bpp).copied().unwrap_or(0) } else { 0 }; - let add = match kind { - 1 => a, - 2 => b, - 3 => ((u16::from(a) + u16::from(b)) / 2) as u8, - 4 => paeth(a, b, c), - _ => 0, - }; - if let Some(v) = cur.get_mut(i) { - *v = v.wrapping_add(add); - } - } - out.extend_from_slice(cur.get(..line.len().min(row)).unwrap_or_default()); - prev = cur; - } - out - } -} - -/// The PNG Paeth predictor. -fn paeth(a: u8, b: u8, c: u8) -> u8 { - let (ia, ib, ic) = (i16::from(a), i16::from(b), i16::from(c)); - let p = ia + ib - ic; - let (pa, pb, pc) = ((p - ia).abs(), (p - ib).abs(), (p - ic).abs()); - if pa <= pb && pa <= pc { - a - } else if pb <= pc { - b - } else { - c - } -} - -/// `CCITTFaxDecode`'s parameters (PLRM 3rd ed., §3.13, `CCITTFaxDecode` parameters; ISO 32000-2, -/// §7.4.6): `K` (below 0 Group 4, 0 Group 3 one-dimensional, above 0 Group 3 mixed), `Columns`, -/// `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`, with their defaults where -/// `d` leaves them out. `Columns` outside 1 to 65,535 and `Rows` below 0 are a `rangecheck`. -fn fax_settings(d: Option<&DictRef>) -> Res { - let get = |k: &str| d.and_then(|d| d.borrow().get(&Key::name(k)).cloned()); - let num = |k: &str, default: f64| get(k).and_then(|o| o.as_num()).unwrap_or(default); - let flag = |k: &str, default: bool| match get(k) { - Some(Obj::Bool(b)) => b, - _ => default, - }; - let (k, columns, rows) = (num("K", 0.0), num("Columns", 1728.0), num("Rows", 0.0)); - if !k.is_finite() || !(1.0..=65_535.0).contains(&columns) || !(0.0..=f64::from(u32::MAX)).contains(&rows) { - return ps_err("rangecheck", "CCITTFaxDecode"); - } - let encoding = if k < 0.0 { - EncodingMode::Group4 - } else if k == 0.0 { - EncodingMode::Group3_1D - } else { - EncodingMode::Group3_2D { k: k.min(f64::from(u32::MAX)) as u32 } - }; - Ok(DecodeSettings { - columns: columns as u32, - rows: rows as u32, - end_of_block: flag("EndOfBlock", true), - end_of_line: flag("EndOfLine", false), - rows_are_byte_aligned: flag("EncodedByteAlign", false), - encoding, - invert_black: flag("BlackIs1", false), - }) -} - -/// The bytes `f` decodes from `raw`, how many it read, and the channels of a decoded JPEG. -fn decode(f: &Filter, raw: &[u8]) -> Res<(Vec, usize, Option)> { - let (data, used) = match &*f.name { - "ASCIIHexDecode" => { - let end = raw.iter().position(|b| *b == b'>'); - let body = raw.get(..end.unwrap_or(raw.len())).unwrap_or_default(); - (hex_decode(body).ok_or(PsError::Ps("ioerror", "ASCIIHexDecode".into()))?, end.map_or(raw.len(), |e| e + 1)) - } - "ASCII85Decode" => { - let end = find(raw, b"~>"); - let mut text = String::from_utf8_lossy(raw.get(..end.unwrap_or(raw.len())).unwrap_or_default()).into_owned(); - text.push_str("~>"); - (ps::ascii85_decode(&text).ok_or(PsError::Ps("ioerror", "ASCII85Decode".into()))?, end.map_or(raw.len(), |e| e + 2)) - } - "RunLengthDecode" => run_length(raw)?, - "FlateDecode" | "LZWDecode" => { - let (data, used) = if &*f.name == "FlateDecode" { flate(raw)? } else { lzw(raw)? }; - match f.predictor { - Some(p) => (p.undo(&data), used), - None => (data, used), - } - } - "DCTDecode" => { - let (d, used, channels) = dct(raw)?; - return Ok((d, used, Some(channels))); - } - "CCITTFaxDecode" => match f.fax { - Some(settings) => fax(raw, settings)?, - None => return io("CCITTFaxDecode"), - }, - "SubFileDecode" => { - let (count, eod) = &f.sub; - if eod.is_empty() { - // A count of 0 without an end string sets no end: the data passes through whole. - let n = if *count == 0 { raw.len() } else { (*count).min(raw.len()) }; - (raw.get(..n).unwrap_or_default().to_vec(), n) - } else { - let (mut at, mut seen) = (0, 0); - loop { - match find(raw.get(at..).unwrap_or_default(), eod) { - Some(i) if seen < *count => { - seen += 1; - at += i + eod.len(); - } - Some(i) => break (raw.get(..at + i).unwrap_or_default().to_vec(), at + i + eod.len()), - None => break (raw.to_vec(), raw.len()), - } - } - } - } - // Reads all of its source (up to the end of the filters below it) and keeps it. - "NullEncode" | "ReusableStreamDecode" => (raw.to_vec(), raw.len()), - other => return ps_err("undefined", other), - }; - Ok((data, used, None)) -} - -/// What a source of image data gives. -#[derive(Clone)] -enum Source { - File(Rc>), - Str(Vec), - Proc(Obj), -} - -impl Interp<'_> { - pub fn data_op(&mut self, op: Op) -> Res { - use Op::*; - match op { - CurrentFile => self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Current)), exec: false })?, - Filter => self.filter()?, - ReadHexString | ReadString => { - let s = self.pop_str()?; - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - let n = s.len(); - let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? }; - self.fill(s, &got)?; - self.push(Obj::Bool(got.len() == n))?; - } - ReadLine => { - let s = self.pop_str()?; - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - let n = s.len(); - let mut line = vec![]; - let mut ended = false; - while line.len() < n { - match self.read(&f, 1)?.first() { - Some(b'\n') | None => { - ended = true; - break; - } - Some(b'\r') => { - self.skip_lf(&f); - ended = true; - break; - } - Some(b) => line.push(*b), - } - } - self.fill(s, &line)?; - self.push(Obj::Bool(ended))?; - } - FlushFile => { - // An input file is read to its end: a filter over the program's data skips it - // (metadata read with `flushfile`). The program's own file goes on being run. - if let Obj::File { stream, .. } = self.pop()? { - self.materialize(&stream)?; - } - } - CloseFile => { - self.pop()?; - } - File => { - self.pop()?; - let name = self.pop()?.text().unwrap_or_else(|| Rc::from("")); - // The standard files take what programs print (and give nothing); there is no - // file system to open others in. - if !matches!(&*name, "%stdout" | "%stderr" | "%stdin" | "%lineedit" | "%statementedit") { - return ps_err("invalidfileaccess", "file"); - } - self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Buf { data: vec![], pos: 0, rgb: None })), exec: false })?; - } - Status => match self.pop()? { - Obj::File { .. } => self.push(Obj::Bool(true))?, - // A file name: there are no files. - Obj::Str(_) => self.push(Obj::Bool(false))?, - _ => return ps_err("typecheck", ""), - }, - BytesAvailable => { - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - self.materialize(&f)?; - let n = match &*f.borrow() { - Stream::Current => self.lex.rest().len(), - Stream::Buf { data, pos, .. } => data.len().saturating_sub(*pos), - Stream::Pending(_) => 0, - }; - self.push(Obj::Int(n as i64))?; - } - ResetFile => { - // A decoded (reusable) stream reads again from its start. - if let Obj::File { stream: f, .. } = self.pop()? - && let Stream::Buf { pos, .. } = &mut *f.borrow_mut() - { - *pos = 0; - } - } - Write | WriteString | WriteHexString => { - self.pop()?; - self.pop()?; - } - Read => { - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - match self.read(&f, 1)?.first() { - Some(b) => { - self.push(Obj::Int(i64::from(*b)))?; - self.push(Obj::Bool(true))?; - } - None => self.push(Obj::Bool(false))?, - } - } - FilePosition => { - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - let Stream::Buf { pos, .. } = &*f.borrow() else { return ps_err("ioerror", "") }; - self.push(Obj::Int(*pos as i64))?; - } - SetFilePosition => { - let at = self.pop_count()?; - let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; - let Stream::Buf { data, pos, .. } = &mut *f.borrow_mut() else { return ps_err("ioerror", "") }; - *pos = at.min(data.len()); - } - // There are no files to delete, rename or run. - DeleteFile | Run => { - self.pop_str()?; - return ps_err("undefinedfilename", ""); - } - RenameFile => { - self.pop_str()?; - self.pop_str()?; - return ps_err("undefinedfilename", ""); - } - Token => self.token()?, - Eexec => self.eexec()?, - Image | ImageMask | ColorImage => self.image(op)?, - _ => self.text_op(op)?, - } - Ok(()) - } - - /// `token`: the next object read from a string (and the rest of it) or a file. - fn token(&mut self) -> Res { - match self.pop()? { - Obj::Str(s) => { - let bytes = s.to_vec(); - let mut lex = Lexer::new(&bytes); - let Some(o) = lex.next()? else { return self.push(Obj::Bool(false)) }; - let o = self.scanned(o, lex.immediate)?; - // The whitespace after the token is read with it. - let used = bytes.len() - lex.data().len(); - self.push_interval(Obj::Str(s), used, bytes.len() - used)?; - self.push(o)?; - } - Obj::File { stream: f, .. } => { - self.materialize(&f)?; - let (o, immediate) = match &mut *f.borrow_mut() { - Stream::Current => (self.lex.next()?, self.lex.immediate), - Stream::Buf { data, pos, .. } => { - let rest = data.get(*pos..).unwrap_or_default(); - let mut lex = Lexer::new(rest); - let o = lex.next()?; - *pos += rest.len() - lex.data().len(); - (o, lex.immediate) - } - Stream::Pending(_) => (None, false), - }; - let Some(o) = o else { return self.push(Obj::Bool(false)) }; - let o = self.scanned(o, immediate)?; - self.push(o)?; - } - _ => return ps_err("typecheck", "token"), - } - self.push(Obj::Bool(true)) - } - - /// Up to `n` bytes of file `f`. - fn read(&mut self, f: &Rc>, n: usize) -> Res> { - self.materialize(f)?; - Ok(match &mut *f.borrow_mut() { - Stream::Current => { - let rest = self.lex.data(); - let k = n.min(rest.len()); - let v = rest.get(..k).unwrap_or_default().to_vec(); - self.lex.advance(k); - v - } - Stream::Buf { data, pos, .. } => { - let start = (*pos).min(data.len()); - let end = start.saturating_add(n).min(data.len()); - *pos = end; - data.get(start..end).unwrap_or_default().to_vec() - } - Stream::Pending(_) => vec![], - }) - } - - /// After `readline` consumes CR, consume only an immediately following LF. PostScript - /// treats CR/LF as one line ending (PLRM §3.8); raw reads must keep every other byte. - /// The preceding `read` has already materialized any pending filter. - fn skip_lf(&mut self, f: &Rc>) { - match &mut *f.borrow_mut() { - Stream::Current => { - if self.lex.rest().first() == Some(&b'\n') { - self.lex.advance(1); - } - } - Stream::Buf { data, pos, .. } => { - if data.get(*pos) == Some(&b'\n') { - *pos += 1; - } - } - Stream::Pending(_) => {} - } - } - - /// Up to `n` bytes read as hexadecimal digits from `f` (other characters skipped). - fn read_hex(&mut self, f: &Rc>, n: usize) -> Res> { - let mut out = Vec::with_capacity(n); - let mut high: Option = None; - while out.len() < n { - let Some(&b) = self.read(f, 1)?.first() else { break }; - let Some(v) = char::from(b).to_digit(16) else { continue }; - match high.take() { - Some(h) => out.push(h << 4 | v as u8), - None => high = Some(v as u8), - } - } - Ok(out) - } - - /// Read bytes `got` into string `s` and push the part of `s` they fill. - fn fill(&mut self, s: Shared, got: &[u8]) -> Res { - if !s.write(0, got) { - return ps_err("rangecheck", ""); - } - self.push_interval(Obj::Str(s), 0, got.len()) - } - - /// Count `n` decoded bytes against the memory budget, and the time to decode them. - fn charge_decoded(&mut self, n: usize) -> Res { - self.alloc(n)?; - self.spend((n / 4096) as u64) - } - - /// `filter`: a file that decodes its source. Over the program's own data it decodes when - /// first read (the data follows the operator that reads it); over other data, at once. - fn filter(&mut self) -> Res { - let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?; - let sub = if &*name == "SubFileDecode" { - let eod = self.pop_str()?.to_vec(); - let count = self.pop_count()?; - (count, eod) - } else { - (0, vec![]) - }; - let (mut predictor, mut params) = (None, None); - if let Some(Obj::Dict(d)) = self.stack.last().cloned() { - self.pop()?; - predictor = Predictor::of(&d)?; - params = Some(d); - } - let fax = if &*name == "CCITTFaxDecode" { Some(fax_settings(params.as_ref())?) } else { None }; - let spec_name = name.clone(); - let spec = Filter { name, sub, predictor, fax }; - let stream = match self.pop()? { - Obj::File { stream: f, .. } => { - if let Stream::Buf { data, pos, .. } = &*f.borrow() { - self.alloc(data.len().saturating_sub(*pos))?; - } - let chain = match &*f.borrow() { - Stream::Current => Some(vec![]), - Stream::Pending(c) => Some(c.clone()), - Stream::Buf { .. } => None, - }; - match chain { - Some(mut c) => { - // The new filter reads a pending one's data: it has none left of its own - // (`flushfile` on it skips nothing more). - if !c.is_empty() { - *f.borrow_mut() = Stream::Buf { data: vec![], pos: 0, rgb: None }; - } - c.push(spec); - Stream::Pending(c) - } - None => { - let mut b = f.borrow_mut(); - let Stream::Buf { data, pos, .. } = &mut *b else { return io("filter") }; - let (out, used, rgb) = decode(&spec, data.get(*pos..).unwrap_or_default())?; - self.charge_decoded(out.len())?; - *pos = pos.saturating_add(used); - Stream::Buf { data: out, pos: 0, rgb } - } - } - } - Obj::Str(s) => { - self.alloc(s.borrow().len())?; - let (data, _, rgb) = decode(&spec, &s.borrow())?; - self.charge_decoded(data.len())?; - Stream::Buf { data, pos: 0, rgb } - } - p @ Obj::Array { .. } => { - let raw = self.gather(&p, MAX_DATA)?; - let (data, _, rgb) = decode(&spec, &raw)?; - self.charge_decoded(data.len())?; - Stream::Buf { data, pos: 0, rgb } - } - _ => return ps_err("typecheck", "filter"), - }; - let stream = Rc::new(RefCell::new(stream)); - // A reusable stream reads its source when it is made: the data follows the operator. - if &*spec_name == "ReusableStreamDecode" { - self.materialize(&stream)?; - } - self.push(Obj::File { stream, exec: false }) - } - - /// The data an executable file runs as a program: what is left of it. `None` for the - /// program's own file, which is being run already. - pub(super) fn program_of(&mut self, f: &Rc>) -> Res>> { - if matches!(&*f.borrow(), Stream::Current) { - return Ok(None); - } - self.read(f, MAX_DATA).map(Some) - } - - /// The decoded data left in file `f`, without reading it (a shading's or function's data). - pub(super) fn peek_all(&mut self, f: &Rc>) -> Res> { - self.materialize(f)?; - Ok(match &*f.borrow() { - Stream::Buf { data, pos, .. } => data.get(*pos..).unwrap_or_default().to_vec(), - _ => vec![], - }) - } - - /// Decode the filters a file waits to run over the program's data, from where it is now. - fn materialize(&mut self, f: &Rc>) -> Res { - let chain = match &*f.borrow() { - Stream::Pending(c) => c.clone(), - _ => return Ok(()), - }; - let mut buf: Option<(Vec, Option)> = None; - for spec in &chain { - buf = Some(match buf.take() { - None => { - let raw = self.lex.data(); - let (data, used, rgb) = decode(spec, raw)?; - self.charge_decoded(data.len())?; - self.lex.advance(used); - (data, rgb) - } - Some((prev, was)) => { - let (data, _, rgb) = decode(spec, &prev)?; - self.charge_decoded(data.len())?; - (data, rgb.or(was)) - } - }); - } - let (data, rgb) = buf.unwrap_or_default(); - *f.borrow_mut() = Stream::Buf { data, pos: 0, rgb }; - Ok(()) - } - - /// The bytes a data procedure gives, called until it gives an empty string or `n` bytes. - fn gather(&mut self, p: &Obj, n: usize) -> Res> { - Ok(self.read_sources(&[Source::Proc(p.clone())], n, n)?.pop().unwrap_or_default()) - } - - /// `n` bytes of image data from `src` (zeros past its end). - fn source_bytes(&mut self, src: &Source, n: usize) -> Res> { - let mut v = self.read_sources(std::slice::from_ref(src), n, n)?.pop().unwrap_or_default(); - v.resize(n, 0); - Ok(v) - } - - /// Up to `n` bytes from each of `sources`, read in turn as an image reads several data sources - /// (PLRM 3rd ed., §4.10 "Images" and `image`/`colorimage` in chapter 8: the sources are read - /// in turn, the same amount from each): each procedure called once a round (its string, - /// whatever its length), each file and string giving `step` bytes a round, until each has - /// given `n` bytes or ended. Procedures that read one file get their own rows that way. - fn read_sources(&mut self, sources: &[Source], n: usize, step: usize) -> Res>> { - let mut out: Vec> = vec![vec![]; sources.len()]; - let mut ended = vec![false; sources.len()]; - while out.iter().zip(&ended).any(|(o, e)| o.len() < n && !e) { - for ((src, o), e) in sources.iter().zip(out.iter_mut()).zip(ended.iter_mut()) { - if *e || o.len() >= n { - continue; - } - let want = step.min(n - o.len()); - let chunk = match src { - Source::File(f) => self.read(f, want)?, - // A string gives its bytes over and over. - Source::Str(s) => { - let at = o.len() % s.len().max(1); - s.get(at..).unwrap_or_default().iter().chain(s.iter().cycle()).take(want).copied().collect() - } - Source::Proc(p) => { - self.call(p.clone())?; - let s = self.pop_str()?.to_vec(); - self.alloc(s.len())?; - s - } - }; - *e = chunk.is_empty(); - o.extend_from_slice(chunk.get(..chunk.len().min(n - o.len())).unwrap_or_default()); - } - } - Ok(out) - } - - /// `eexec`: an encrypted font program follows. It is skipped (type is drawn in the app's - /// fonts, by name): the font dictionary it would complete is defined as it is. - fn eexec(&mut self) -> Res { - self.pop()?; - if let Some(Obj::Dict(d)) = self.stack.last().cloned() { - let name = d.borrow().get(&Key::name("FontName")).cloned(); - if let Some(k) = name.and_then(|n| n.key()) { - self.pop()?; - self.fonts.borrow_mut().insert(k, Obj::Dict(d)); - } - } - let rest = self.lex.data(); - let at = find(rest, b"cleartomark").ok_or(PsError::Ps("ioerror", "eexec".into()))?; - self.lex.advance(at); - self.push(Obj::Mark) - } - - // ---------- images ---------- - - fn image(&mut self, op: Op) -> Res { - let mask = op == Op::ImageMask; - let img = match self.stack.last() { - Some(Obj::Dict(_)) if op != Op::ColorImage => { - let d = self.pop_dict()?; - let kind = d.borrow().get(&Key::name("ImageType")).and_then(Obj::as_num); - if kind == Some(3.0) && !mask { - self.masked_image(&d)? - } else { - let spec = self.image_dict(&d, mask)?; - self.decode_image(spec)? - } - } - _ => { - let spec = self.image_operands(op)?; - self.decode_image(spec)? - } - }; - let Some(img) = img else { return Ok(()) }; - self.place_image(img) - } - - /// ImageType 3: an image and the mask that says where it paints (where the mask's samples - /// read below one half through its `Decode`, as an image mask paints its 0 bits), in separate - /// sources (`InterleaveType` 3) or interleaved with the image's by row (2) or by sample (1). - fn masked_image(&mut self, d: &DictRef) -> Res> { - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let (Some(Obj::Dict(data)), Some(Obj::Dict(mask))) = (get("DataDict"), get("MaskDict")) else { - return ps_err("typecheck", "ImageType 3"); - }; - let interleave = get("InterleaveType").and_then(|o| o.as_num()).unwrap_or(1.0); - let mut image = self.image_dict(&data, false)?; - let mut stencil = self.image_dict(&mask, false)?; - stencil.space = Space::Gray; - let n = image.space.n(); - if image.w.saturating_mul(image.h) > MAX_PIXELS || stencil.w.saturating_mul(stencil.h) > MAX_PIXELS { - self.out.warn(TOO_LARGE); - return Ok(None); - } - if interleave != 3.0 { - let src = image.sources.first().cloned().ok_or(PsError::Ps("undefined", "DataSource".into()))?; - let (pixels, samples) = if interleave == 2.0 { - let (mrow, drow) = (stencil.row_bytes(1), image.row_bytes(n)); - let total = mrow.saturating_mul(stencil.h).saturating_add(drow.saturating_mul(image.h)); - self.alloc(total)?; - let raw = self.source_bytes(&src, total.min(MAX_DATA))?; - by_rows(&raw, (mrow, stencil.h), (drow, image.h)) - } else { - // Each sample: the mask's value, then the colour's, as many bits each. - stencil.bpc = image.bpc; - (stencil.w, stencil.h) = (image.w, image.h); - let row = (image.w * (n + 1) * usize::from(image.bpc)).div_ceil(8); - self.alloc(row.saturating_mul(image.h))?; - let raw = self.source_bytes(&src, row.saturating_mul(image.h).min(MAX_DATA))?; - by_samples(&raw, image.w, image.h, n, image.bpc) - }; - stencil.sources = vec![Source::Str(pixels)]; - image.sources = vec![Source::Str(samples)]; - } - let Some(mut img) = self.decode_image(image)? else { return Ok(None) }; - let Some(m) = self.decode_image(stencil)? else { return Ok(Some(img)) }; - let (w, h, mw, mh) = (img.w as usize, img.h as usize, m.w as usize, m.h as usize); - for (i, px) in img.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - // The mask's pixel over this one (in 64 bits: rows times rows overflow 32). - let at = (y as u64 * mh as u64 / h.max(1) as u64) * mw as u64 + x as u64 * mw as u64 / w.max(1) as u64; - if usize::try_from(at * 4).ok().and_then(|i| m.rgba.get(i)).is_none_or(|v| *v >= 128) - && let Some(a) = px.get_mut(3) - { - *a = 0; - } - } - Ok(Some(img)) - } - - /// The dictionary form of `image` and `imagemask`. - fn image_dict(&mut self, d: &DictRef, mask: bool) -> Res { - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let num = |k: &str| get(k).and_then(|o| o.as_num()); - let kind = num("ImageType").unwrap_or(1.0); - if kind != 1.0 && kind != 4.0 { - return ps_err("rangecheck", "ImageType"); - } - let size = |k: &str| num(k).filter(|v| *v >= 1.0 && *v <= 65_535.0).map(|v| v as usize).ok_or(PsError::Ps("rangecheck", k.to_string())); - let (w, h) = (size("Width")?, size("Height")?); - let bpc = if mask { 1 } else { num("BitsPerComponent").unwrap_or(8.0) as u8 }; - let matrix = - get("ImageMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).ok_or(PsError::Ps("typecheck", "ImageMatrix".into()))?; - let decode: Vec = get("Decode").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); - let space = if mask { Space::Gray } else { (*self.g.space).clone() }; - let multi = get("MultipleDataSources").is_some_and(|o| matches!(o, Obj::Bool(true))); - let sources = match (get("DataSource"), multi) { - (None, _) => vec![], - (Some(Obj::Array { items, exec: false }), true) => items.borrow().iter().map(source).collect::>>()?, - (Some(src), _) => vec![source(&src)?], - }; - let mask_color: Option> = (kind == 4.0) - .then(|| { - get("MaskColor").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(|v| v.as_num().map(|v| v.max(0.0) as u32)).collect())) - }) - .flatten(); - // An image mask paints its 0 bits with Decode [0 1], its 1 bits with [1 0]. - let stencil = mask.then(|| decode.first().is_some_and(|v| *v > 0.5)); - Ok(Spec { w, h, bpc, space, decode, matrix, sources, mask_color, stencil }) - } - - /// The operand forms: `w h bpc matrix src image`, `w h polarity matrix src imagemask`, and - /// `w h bpc matrix src… multi ncomp colorimage`. - fn image_operands(&mut self, op: Op) -> Res { - let (n, multi) = if op == Op::ColorImage { - let n = self.pop_count()?; - (n, self.pop_bool()?) - } else { - (1, false) - }; - let space = match n { - 1 => Space::Gray, - 3 => Space::Rgb, - 4 => Space::Cmyk, - _ => return ps_err("rangecheck", "colorimage"), - }; - let mut sources = vec![]; - for _ in 0..if multi { n } else { 1 } { - let o = self.pop()?; - sources.push(source(&o)?); - } - sources.reverse(); - let matrix = self.pop_matrix()?; - let (bpc, stencil) = if op == Op::ImageMask { (1, Some(self.pop_bool()?)) } else { (self.pop_int()?.clamp(1, 16) as u8, None) }; - let size = |v: i64| usize::try_from(v).ok().filter(|v| (1..=65_535).contains(v)).ok_or(PsError::Ps("rangecheck", "image".into())); - let h = size(self.pop_int()?)?; - let w = size(self.pop_int()?)?; - Ok(Spec { w, h, bpc, space, decode: vec![], matrix, sources, mask_color: None, stencil }) - } - - /// The image's pixels, straight RGBA, rows from the first one read, and a CMYK image's ink - /// amounts. - fn decode_image(&mut self, s: Spec) -> Res> { - if !matches!(s.bpc, 1 | 2 | 4 | 8 | 12 | 16) { - return ps_err("rangecheck", "BitsPerComponent"); - } - if s.w.saturating_mul(s.h) > MAX_PIXELS { - self.out.warn(TOO_LARGE); - return Ok(None); - } - self.alloc(s.w * s.h * 4)?; - let n = if s.stencil.is_some() { 1 } else { s.space.n() }; - let bpc = usize::from(s.bpc); - for src in &s.sources { - if let Source::File(f) = src { - self.materialize(f)?; - } - } - // A decoded JPEG gives 8-bit gray, RGB or CMYK samples whatever the color space says. - let jpeg = s.sources.iter().find_map(|src| match src { - Source::File(f) => match &*f.borrow() { - Stream::Buf { rgb, .. } => *rgb, - _ => None, - }, - _ => None, - }); - let (n, bpc, space) = match jpeg { - Some(3) => (3, 8, Space::Rgb), - Some(4) => (4, 8, Space::Cmyk), - Some(_) => (1, 8, Space::Gray), - None => (n, bpc, s.space.clone()), - }; - let multi = s.sources.len() > 1; - let row_bits = if multi { s.w * bpc } else { s.w * n * bpc }; - let row = row_bits.div_ceil(8); - // Several sources are read in turn, a row at a time each (zeros past their ends). - let sources = if multi { &s.sources[..] } else { s.sources.get(..1).ok_or(PsError::Ps("undefined", "DataSource".into()))? }; - if sources.len() > n { - return ps_err("rangecheck", "DataSource"); - } - let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?; - self.alloc(plane.saturating_mul(sources.len()))?; - let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?; - for p in &mut planes { - p.resize(row * s.h, 0); - } - let max = ((1u32 << bpc.min(16)) - 1) as f64; - let sample = |plane: &[u8], y: usize, i: usize| -> u32 { - let bit = y * row * 8 + i * bpc; - let byte = bit / 8; - match bpc { - 8 => u32::from(plane.get(byte).copied().unwrap_or(0)), - 16 => u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)), - 12 => { - let v = u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)); - if bit.is_multiple_of(8) { v >> 4 } else { v & 0xfff } - } - _ => (u32::from(plane.get(byte).copied().unwrap_or(0)) >> (8 - bpc - bit % 8)) & ((1 << bpc) - 1), - } - }; - let default_decode: Vec = match &space { - Space::Indexed { .. } => vec![0.0, max], - _ => (0..n).flat_map(|_| [0.0, 1.0]).collect(), - }; - // The Decode array applies to a CMYK JPEG's samples as stored; gray and RGB JPEGs are read - // with the default Decode array. - let decode = if s.decode.len() >= 2 * n && jpeg.is_none_or(|c| c == 4) { s.decode.clone() } else { default_decode }; - let paint = self.g.paint.color().map(|c| c.to_rgba8(1.0)); - let mut rgba = Vec::with_capacity(s.w * s.h * 4); - // CMYK samples keep their ink amounts, as the Decode array reads them. - let mut inks = if matches!(space, Space::Cmyk) && s.stencil.is_none() { - self.alloc(s.w * s.h * 4)?; - Some(Vec::with_capacity(s.w * s.h * 4)) - } else { - None - }; - let mut cache: HashMap, [u8; 3]> = HashMap::new(); - let mut raw = vec![0u32; n]; - let mut comps = vec![0f64; n]; - for y in 0..s.h { - for x in 0..s.w { - for (c, slot) in raw.iter_mut().enumerate() { - *slot = match planes.as_slice() { - [one] => sample(one, y, x * n + c), - many => many.get(c).map_or(0, |p| sample(p, y, x)), - }; - } - if let Some(paint_ones) = s.stencil { - let on = (raw.first().copied().unwrap_or(0) == 1) == paint_ones; - let [r, g, b, _] = paint.unwrap_or([0, 0, 0, 255]); - rgba.extend([r, g, b, if on { 255 } else { 0 }]); - continue; - } - let masked = s.mask_color.as_ref().is_some_and(|m| masked(m, &raw)); - for (c, slot) in comps.iter_mut().enumerate() { - let (d0, d1) = (decode.get(2 * c).copied().unwrap_or(0.0), decode.get(2 * c + 1).copied().unwrap_or(1.0)); - *slot = d0 + f64::from(*raw.get(c).unwrap_or(&0)) * (d1 - d0) / max; - } - if let Some(inks) = inks.as_mut() { - inks.extend(comps.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); - } - let rgb = match (&space, raw.as_slice()) { - (Space::Gray, [v]) if bpc == 8 && decode == [0.0, 1.0] => [*v as u8; 3], - (Space::Rgb, [r, g, b]) if bpc == 8 && decode == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] => [*r as u8, *g as u8, *b as u8], - _ => match cache.get(&raw) { - Some(c) => *c, - None => { - let c = self.sample_rgb(&space, &comps)?; - if cache.len() < MAX_TINTS { - cache.insert(raw.clone(), c); - } - c - } - }, - }; - rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]); - } - } - Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, inks, matrix: s.matrix })) - } - - /// One pixel's colour in `space` as display RGB. - fn sample_rgb(&mut self, space: &Space, comps: &[f64]) -> Res<[u8; 3]> { - let color = match space { - Space::Gray | Space::Rgb | Space::Cmyk => process(space, comps), - _ => match self.paint_of(space, comps)? { - Paint::Solid { color, .. } => Some(color), - _ => None, - }, - }; - let [r, g, b, _] = color.unwrap_or(Color::BLACK).to_rgba8(1.0); - Ok([r, g, b]) - } - - /// Place decoded pixels in user space (its unit square through the image matrix). - fn place_image(&mut self, img: Pixels) -> Res { - let m = img.matrix; - if m.determinant().abs() < 1e-12 { - return Ok(()); - } - let xf = self.xf() * m.inverse(); - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - return Ok(()); - } - if self.g.null { - return Ok(()); - } - // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps an 8- or - // 16-bit CMYK image's samples, and transparent pixels (`MaskColor`, an ImageType 3 mask) - // become its opacity mask. - if let Some(blob) = img.inks.and_then(|i| Inks::new(img.w, img.h, i)).and_then(|i| ImageBlob::cmyk_tiff(&i)) { - let alpha: Vec = img.rgba.iter().skip(3).step_by(4).copied().collect(); - let mask = self.alpha_mask(&alpha, img.w, img.h, xf); - return self.add_image(blob, img.w, img.h, xf, mask); - } - let Some(rgba) = image::RgbaImage::from_raw(img.w, img.h, img.rgba) else { return Ok(()) }; - let mut png = vec![]; - if rgba.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { - self.out.warn("an image that couldn't be read was left out"); - return Ok(()); - } - self.add_image(ImageBlob::new("image/png", png), img.w, img.h, xf, None) - } - - /// Add image `blob` (`w` × `h` pixels, mapped into user space by `xf`) with `mask` as its - /// opacity mask. - fn add_image(&mut self, blob: ImageBlob, w: u32, h: u32, xf: Affine, mask: Option) -> Res { - let key = blob.content_key(); - self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))).with_mask(mask.map(Box::new)), &clips); - Ok(()) - } - - /// An image's alpha (`w` × `h` bytes) as a grayscale image over the image (`xf`): its opacity - /// mask, as in PDF import. `None` when it hides nothing. - fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { - if alpha.iter().all(|v| *v == 255) { - return None; - } - let mut png = Vec::new(); - image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - let blob = ImageBlob::new("image/png", png); - let key = blob.content_key(); - self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; - Some(OpacityMask::new(Node::new(self.out.doc.alloc_id(), NodeKind::Image(Box::new(im))), true)) - } -} - -/// An image to decode. -struct Spec { - w: usize, - h: usize, - bpc: u8, - space: Space, - decode: Vec, - /// User space → image space. - matrix: Affine, - /// One source, or one per component. - sources: Vec, - /// `MaskColor` (ImageType 4): samples that are transparent. - mask_color: Option>, - /// An image mask: whether its 1 bits paint. - stencil: Option, -} - -impl Spec { - /// Bytes a row of `n` components takes. - fn row_bytes(&self, n: usize) -> usize { - (self.w * n * usize::from(self.bpc)).div_ceil(8) - } -} - -/// Data of an ImageType 3 interleaved by row, split into the mask's and the image's: the one -/// with fewer rows gives one row before each run of the other's (`(row bytes, rows)` each). -fn by_rows(raw: &[u8], (mrow, mh): (usize, usize), (drow, dh): (usize, usize)) -> (Vec, Vec) { - let room = |row: usize, rows: usize| row.saturating_mul(rows).min(raw.len()); - let (mut mask, mut data) = (Vec::with_capacity(room(mrow, mh)), Vec::with_capacity(room(drow, dh))); - let mut at = 0; - let mut take = |into: &mut Vec, n: usize| { - into.extend_from_slice(raw.get(at..at + n).unwrap_or_default()); - at += n; - }; - let (blocks, masks, rows) = if mh >= dh { (dh, mh / dh.max(1), 1) } else { (mh, 1, dh / mh.max(1)) }; - for _ in 0..blocks { - for _ in 0..masks { - take(&mut mask, mrow); - } - for _ in 0..rows { - take(&mut data, drow); - } - } - (mask, data) -} - -/// Data of an ImageType 3 interleaved by sample (the mask's value first, then `n` colour -/// components, `bpc` bits each, rows from whole bytes), split into the mask's and the image's. -fn by_samples(raw: &[u8], w: usize, h: usize, n: usize, bpc: u8) -> (Vec, Vec) { - let bpc = usize::from(bpc); - let bit = |at: usize| raw.get(at / 8).is_some_and(|b| b & (0x80 >> (at % 8)) != 0); - let (mrow, drow, row) = ((w * bpc).div_ceil(8), (w * n * bpc).div_ceil(8), (w * (n + 1) * bpc).div_ceil(8)); - let (mut mask, mut data) = (vec![0u8; mrow * h], vec![0u8; drow * h]); - let put = |into: &mut [u8], at: usize| { - if let Some(b) = into.get_mut(at / 8) { - *b |= 0x80 >> (at % 8); - } - }; - for y in 0..h { - for x in 0..w { - let from = y * row * 8 + x * (n + 1) * bpc; - for k in 0..bpc { - if bit(from + k) { - put(&mut mask, y * mrow * 8 + x * bpc + k); - } - } - for k in 0..n * bpc { - if bit(from + bpc + k) { - put(&mut data, y * drow * 8 + x * n * bpc + k); - } - } - } - } - (mask, data) -} - -/// Decoded pixels. -struct Pixels { - w: u32, - h: u32, - rgba: Vec, - /// A CMYK image's ink amounts: C, M, Y and K bytes a pixel, 0 is no ink. - inks: Option>, - matrix: Affine, -} - -fn source(o: &Obj) -> Res { - Ok(match o { - Obj::File { stream: f, .. } => Source::File(f.clone()), - Obj::Str(s) => Source::Str(s.to_vec()), - Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }), - _ => return ps_err("typecheck", "DataSource"), - }) -} - -/// Do samples `raw` match `MaskColor` `m` (one value per component, or a range)? -fn masked(m: &[u32], raw: &[u32]) -> bool { - if m.len() == raw.len() { - return m.iter().zip(raw).all(|(a, b)| a == b); - } - m.len() == raw.len() * 2 - && raw.iter().enumerate().all(|(i, v)| (m.get(2 * i).copied().unwrap_or(0)..=m.get(2 * i + 1).copied().unwrap_or(0)).contains(v)) -} +pub(crate) use filters::Stream; diff --git a/crates/eps/src/import/data/filters.rs b/crates/eps/src/import/data/filters.rs new file mode 100644 index 000000000..088dd1b3a --- /dev/null +++ b/crates/eps/src/import/data/filters.rs @@ -0,0 +1,477 @@ +use super::ops::MAX_DATA; +use super::*; + +/// A file object: the program itself, or data a filter decoded. +#[derive(Debug)] +pub(crate) enum Stream { + Current, + /// Filters over the program's own data, decoded when first read. + Pending(Vec), + Buf { + data: Vec, + pos: usize, + /// The samples are 8-bit gray, RGB or CMYK (1, 3 or 4 channels) whatever the image's + /// color space: a decoded JPEG. + rgb: Option, + }, +} + +pub(super) fn io(at: &str) -> Res { + ps_err("ioerror", at) +} + +/// The bytes `RunLengthDecode` makes of `raw`, and how many it read. +fn run_length(raw: &[u8]) -> Res<(Vec, usize)> { + let mut out = vec![]; + let mut i = 0; + while let Some(&n) = raw.get(i) { + i += 1; + match n { + 128 => break, + 0..=127 => { + let len = usize::from(n) + 1; + out.extend_from_slice(raw.get(i..i + len).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?); + i += len; + } + _ => { + let b = *raw.get(i).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?; + i += 1; + out.extend(std::iter::repeat_n(b, 257 - usize::from(n))); + } + } + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + } + Ok((out, i)) +} + +/// zlib data inflated, and how many bytes of `raw` it took. +fn flate(raw: &[u8]) -> Res<(Vec, usize)> { + let mut z = flate2::Decompress::new(true); + let mut out = Vec::with_capacity(raw.len().saturating_mul(3).min(MAX_DATA)); + loop { + let before = (z.total_in(), z.total_out()); + if out.capacity() - out.len() < 64 * 1024 { + out.reserve(256 * 1024); + } + let input = raw.get(z.total_in() as usize..).unwrap_or_default(); + let status = z.decompress_vec(input, &mut out, flate2::FlushDecompress::None).map_err(|_| PsError::Ps("ioerror", "FlateDecode".into()))?; + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + match status { + flate2::Status::StreamEnd => break, + _ if (z.total_in(), z.total_out()) == before => break, + _ => {} + } + } + Ok((out, z.total_in() as usize)) +} + +/// LZW data (`EarlyChange` 1) decoded, and how many bytes of `raw` it took. +fn lzw(raw: &[u8]) -> Res<(Vec, usize)> { + let mut table: Vec> = (0..=255u8).map(|b| vec![b]).collect(); + table.push(vec![]); + table.push(vec![]); + let (mut out, mut prev): (Vec, Option>) = (vec![], None); + let (mut acc, mut bits, mut width, mut i) = (0u32, 0u32, 9u32, 0usize); + loop { + while bits < width { + let Some(&b) = raw.get(i) else { return Ok((out, i)) }; + i += 1; + acc = (acc << 8) | u32::from(b); + bits += 8; + } + let code = ((acc >> (bits - width)) & ((1 << width) - 1)) as usize; + bits -= width; + match code { + 256 => { + table.truncate(258); + width = 9; + prev = None; + } + 257 => return Ok((out, i)), + _ => { + let entry = match (table.get(code), &prev) { + (Some(e), _) if !e.is_empty() => e.clone(), + (_, Some(p)) if code == table.len() => { + let mut e = p.clone(); + e.extend(p.first()); + e + } + _ => return io("LZWDecode"), + }; + out.extend_from_slice(&entry); + if let Some(mut p) = prev.take() + && table.len() < 4096 + { + p.extend(entry.first()); + table.push(p); + } + width = match table.len() + 1 { + n if n >= 2048 => 12, + n if n >= 1024 => 11, + n if n >= 512 => 10, + _ => 9, + }; + prev = Some(entry); + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + } + } + } +} + +/// A JPEG's pixels (gray, RGB or CMYK, 8 bits) with its channel count, and how many bytes of +/// `raw` it took. A CMYK JPEG gives its samples as stored, for the image's Decode array to read +/// (Adobe's CMYK JPEGs store them inverted, which a Decode array of `[1 0 1 0 1 0 1 0]` undoes). +fn dct(raw: &[u8]) -> Res<(Vec, usize, u8)> { + let end = find(raw, &[0xFF, 0xD9]).map_or(raw.len(), |e| e + 2); + let jpeg = raw.get(..end).unwrap_or_default(); + // `cmyk` gives the ink amounts of inverted samples: inverted back, they are the samples. + if jpeg_components(jpeg) == Some(4) + && let Some(inks) = ImageBlob::new("image/jpeg", jpeg.to_vec()).cmyk() + { + return Ok((inks.data.into_iter().map(|v| 255 - v).collect(), end, 4)); + } + let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; + if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) } +} + +/// CCITT fax data (ITU-T T.4 Group 3, one- or two-dimensional, and T.6 Group 4) decoded to rows +/// of 1-bit samples, each padded to a whole byte, and how many bytes of `raw` it took. With +/// `EndOfBlock` the rows end at the end-of-block code (EOFB, or RTC in Group 3) regardless of +/// `Rows`; without it they end after `Rows` rows, or at the end of the data when `Rows` is 0. +/// Damaged data ends the rows: those decoded before it are kept, and damage in the first row is an +/// `ioerror`. Rows that would take more than `MAX_DATA` bytes are refused. +fn fax(raw: &[u8], settings: DecodeSettings) -> Res<(Vec, usize)> { + let row = (settings.columns as usize).div_ceil(8).max(1); + let most = u32::try_from(MAX_DATA / row).unwrap_or(u32::MAX); + if !settings.end_of_block && settings.rows > most { + return Err(PsError::Limit("decoded data is too large")); + } + // Without a row count that ends the data, a row past `most` shows that the data is too large. + let rows = if settings.end_of_block || settings.rows == 0 { most.saturating_add(1) } else { settings.rows }; + // The decoder reads the RTC of Group 3 two-dimensional data as rows: it gets the data before it. + let rtc = match settings.encoding { + EncodingMode::Group3_2D { .. } if settings.end_of_block => rtc(raw), + _ => None, + }; + let data = rtc.and_then(|(cut, _)| raw.get(..cut)).unwrap_or(raw); + let mut out = FaxRows::default(); + let read = hayro_ccitt::decode(data, &mut out, &mut DecoderContext::new(DecodeSettings { rows, ..settings })); + if out.rows > most { + return Err(PsError::Limit("decoded data is too large")); + } + let used = match read { + Ok(used) => used, + Err(_) if out.rows > 0 => { + out.data.truncate(out.rows as usize * row); + // The whole rows decoded again, for how many bytes they took. + let used = hayro_ccitt::decode(data, &mut Dropped, &mut DecoderContext::new(DecodeSettings { rows: out.rows, ..settings })); + used.unwrap_or(data.len()) + } + Err(_) => return io("CCITTFaxDecode"), + }; + Ok((out.data, rtc.map_or(used, |(_, end)| end))) +} + +/// The RTC that ends Group 3 two-dimensional data (six EOLs, each followed by a 1 tag bit, ITU-T +/// T.4 §4.2.4): the byte that holds the 1 bit of its first EOL, and the byte after its last tag +/// bit. The first EOL with a 1 tag bit that another EOL follows starts it. +fn rtc(raw: &[u8]) -> Option<(usize, usize)> { + let bits = raw.len().saturating_mul(8); + let bit = |i: usize| raw.get(i / 8).is_some_and(|b| b & (0x80 >> (i % 8)) != 0); + let zeros = |i: usize| (i..bits).take_while(|j| !bit(*j)).count(); + // An EOL (its fill bits first) and a 1 tag bit from `i`: the EOL's 1 bit and where they end. + let eol = |i: usize| { + let one = i + zeros(i); + (one - i >= 11 && bit(one) && bit(one + 1)).then_some((one, one + 2)) + }; + let mut i = 0; + while i < bits { + if let Some((one, next)) = eol(i) + && let Some((_, mut end)) = eol(next) + { + for _ in 2..6 { + match eol(end) { + Some((_, e)) => end = e, + None => break, + } + } + return Some((one / 8, end.div_ceil(8))); + } + i += zeros(i) + 1; + } + None +} + +/// Rows of 1-bit samples from a CCITT decoder, each padded to a whole byte: 1 is white, or black +/// with `BlackIs1`. +#[derive(Default)] +struct FaxRows { + data: Vec, + /// Samples in the row being decoded. + x: u32, + /// Whole rows decoded. + rows: u32, +} + +impl Decoder for FaxRows { + fn push_pixel(&mut self, white: bool) { + if self.x.is_multiple_of(8) { + self.data.push(0); + } + if white && let Some(b) = self.data.last_mut() { + *b |= 0x80 >> (self.x % 8); + } + self.x = self.x.saturating_add(1); + } + + fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) { + self.data.extend(std::iter::repeat_n(if white { 0xff } else { 0 }, chunk_count as usize)); + self.x = self.x.saturating_add(chunk_count.saturating_mul(8)); + } + + fn next_line(&mut self) { + self.x = 0; + self.rows = self.rows.saturating_add(1); + } +} + +/// A CCITT decoder that drops what it decodes. +struct Dropped; + +impl Decoder for Dropped { + fn push_pixel(&mut self, _: bool) {} + + fn push_pixel_chunk(&mut self, _: bool, _: u32) {} + + fn next_line(&mut self) {} +} + +/// A decoding filter and its parameters. +#[derive(Clone, Debug)] +pub(crate) struct Filter { + pub(super) name: Rc, + /// `SubFileDecode`'s count and end-of-data string. + pub(super) sub: (usize, Vec), + /// `FlateDecode`'s and `LZWDecode`'s `Predictor`: what it undoes. + pub(super) predictor: Option, + /// `CCITTFaxDecode`'s parameters. + pub(super) fax: Option, +} + +/// A `Predictor` (2: TIFF, 10–15: PNG) with its `Colors`, `BitsPerComponent` and `Columns`. +#[derive(Clone, Copy, Debug)] +pub(super) struct Predictor { + kind: u8, + colors: usize, + bpc: usize, + columns: usize, +} + +impl Predictor { + /// The predictor of a filter's parameters, if it has one. + pub(super) fn of(d: &DictRef) -> Res> { + let num = |k: &str, default: f64| d.borrow().get(&Key::name(k)).and_then(Obj::as_num).unwrap_or(default); + let kind = num("Predictor", 1.0); + if kind <= 1.0 { + return Ok(None); + } + let (colors, bpc, columns) = (num("Colors", 1.0), num("BitsPerComponent", 8.0), num("Columns", 1.0)); + if !(kind == 2.0 || (10.0..=15.0).contains(&kind)) + || !(1.0..=32.0).contains(&colors) + || ![1.0, 2.0, 4.0, 8.0, 16.0].contains(&bpc) + || !(1.0..=1e6).contains(&columns) + { + return ps_err("rangecheck", "Predictor"); + } + Ok(Some(Self { kind: kind as u8, colors: colors as usize, bpc: bpc as usize, columns: columns as usize })) + } + + /// The data the predictor made `data` from. + fn undo(self, data: &[u8]) -> Vec { + // A row longer than the data is the data. + let row = (self.colors * self.bpc * self.columns).div_ceil(8).min(data.len().max(1)); + // Bytes per pixel (at least one) is how far back the left neighbour is. + let bpp = (self.colors * self.bpc).div_ceil(8).max(1); + if self.kind == 2 { + // Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its + // difference from the same component of the pixel to its left, wrapping within its bit + // width. Each row starts again, and the padding bits at a row's end stay as they are. + let mut out = data.to_vec(); + for line in out.chunks_mut(row.max(1)) { + match self.bpc { + 8 => { + for i in bpp..line.len() { + let left = line.get(i - bpp).copied().unwrap_or(0); + if let Some(b) = line.get_mut(i) { + *b = b.wrapping_add(left); + } + } + } + // 16-bit components are big-endian and add as 16-bit numbers. + 16 => { + let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0); + for i in (bpp..line.len().saturating_sub(1)).step_by(2) { + let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]); + let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left); + if let Some(w) = line.get_mut(i..i + 2) { + w.copy_from_slice(&sum.to_be_bytes()); + } + } + } + // 1, 2 or 4 bits: components are packed from each byte's high bit. + bits @ (1 | 2 | 4) => { + let mask = (1u8 << bits) - 1; + let shift = |k: usize| 8 - bits - k * bits % 8; + let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask); + let samples = (line.len() * 8 / bits).min(self.colors * self.columns); + for k in self.colors..samples { + let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask; + if let Some(b) = line.get_mut(k * bits / 8) { + *b = (*b & !(mask << shift(k))) | (sum << shift(k)); + } + } + } + // `Predictor::of` accepts no other depth. + _ => {} + } + } + return out; + } + // PNG: each row starts with its filter type. + let mut out: Vec = Vec::with_capacity(data.len()); + let mut prev = vec![0u8; row]; + for chunk in data.chunks(row + 1) { + let Some((&kind, line)) = chunk.split_first() else { break }; + let mut cur = line.to_vec(); + cur.resize(row, 0); + for i in 0..row { + let a = if i >= bpp { cur.get(i - bpp).copied().unwrap_or(0) } else { 0 }; + let b = prev.get(i).copied().unwrap_or(0); + let c = if i >= bpp { prev.get(i - bpp).copied().unwrap_or(0) } else { 0 }; + let add = match kind { + 1 => a, + 2 => b, + 3 => ((u16::from(a) + u16::from(b)) / 2) as u8, + 4 => paeth(a, b, c), + _ => 0, + }; + if let Some(v) = cur.get_mut(i) { + *v = v.wrapping_add(add); + } + } + out.extend_from_slice(cur.get(..line.len().min(row)).unwrap_or_default()); + prev = cur; + } + out + } +} + +/// The PNG Paeth predictor. +fn paeth(a: u8, b: u8, c: u8) -> u8 { + let (ia, ib, ic) = (i16::from(a), i16::from(b), i16::from(c)); + let p = ia + ib - ic; + let (pa, pb, pc) = ((p - ia).abs(), (p - ib).abs(), (p - ic).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } +} + +/// `CCITTFaxDecode`'s parameters (PLRM 3rd ed., §3.13, `CCITTFaxDecode` parameters; ISO 32000-2, +/// §7.4.6): `K` (below 0 Group 4, 0 Group 3 one-dimensional, above 0 Group 3 mixed), `Columns`, +/// `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`, with their defaults where +/// `d` leaves them out. `Columns` outside 1 to 65,535 and `Rows` below 0 are a `rangecheck`. +pub(super) fn fax_settings(d: Option<&DictRef>) -> Res { + let get = |k: &str| d.and_then(|d| d.borrow().get(&Key::name(k)).cloned()); + let num = |k: &str, default: f64| get(k).and_then(|o| o.as_num()).unwrap_or(default); + let flag = |k: &str, default: bool| match get(k) { + Some(Obj::Bool(b)) => b, + _ => default, + }; + let (k, columns, rows) = (num("K", 0.0), num("Columns", 1728.0), num("Rows", 0.0)); + if !k.is_finite() || !(1.0..=65_535.0).contains(&columns) || !(0.0..=f64::from(u32::MAX)).contains(&rows) { + return ps_err("rangecheck", "CCITTFaxDecode"); + } + let encoding = if k < 0.0 { + EncodingMode::Group4 + } else if k == 0.0 { + EncodingMode::Group3_1D + } else { + EncodingMode::Group3_2D { k: k.min(f64::from(u32::MAX)) as u32 } + }; + Ok(DecodeSettings { + columns: columns as u32, + rows: rows as u32, + end_of_block: flag("EndOfBlock", true), + end_of_line: flag("EndOfLine", false), + rows_are_byte_aligned: flag("EncodedByteAlign", false), + encoding, + invert_black: flag("BlackIs1", false), + }) +} + +/// The bytes `f` decodes from `raw`, how many it read, and the channels of a decoded JPEG. +pub(super) fn decode(f: &Filter, raw: &[u8]) -> Res<(Vec, usize, Option)> { + let (data, used) = match &*f.name { + "ASCIIHexDecode" => { + let end = raw.iter().position(|b| *b == b'>'); + let body = raw.get(..end.unwrap_or(raw.len())).unwrap_or_default(); + (hex_decode(body).ok_or(PsError::Ps("ioerror", "ASCIIHexDecode".into()))?, end.map_or(raw.len(), |e| e + 1)) + } + "ASCII85Decode" => { + let end = find(raw, b"~>"); + let mut text = String::from_utf8_lossy(raw.get(..end.unwrap_or(raw.len())).unwrap_or_default()).into_owned(); + text.push_str("~>"); + (ps::ascii85_decode(&text).ok_or(PsError::Ps("ioerror", "ASCII85Decode".into()))?, end.map_or(raw.len(), |e| e + 2)) + } + "RunLengthDecode" => run_length(raw)?, + "FlateDecode" | "LZWDecode" => { + let (data, used) = if &*f.name == "FlateDecode" { flate(raw)? } else { lzw(raw)? }; + match f.predictor { + Some(p) => (p.undo(&data), used), + None => (data, used), + } + } + "DCTDecode" => { + let (d, used, channels) = dct(raw)?; + return Ok((d, used, Some(channels))); + } + "CCITTFaxDecode" => match f.fax { + Some(settings) => fax(raw, settings)?, + None => return io("CCITTFaxDecode"), + }, + "SubFileDecode" => { + let (count, eod) = &f.sub; + if eod.is_empty() { + // A count of 0 without an end string sets no end: the data passes through whole. + let n = if *count == 0 { raw.len() } else { (*count).min(raw.len()) }; + (raw.get(..n).unwrap_or_default().to_vec(), n) + } else { + let (mut at, mut seen) = (0, 0); + loop { + match find(raw.get(at..).unwrap_or_default(), eod) { + Some(i) if seen < *count => { + seen += 1; + at += i + eod.len(); + } + Some(i) => break (raw.get(..at + i).unwrap_or_default().to_vec(), at + i + eod.len()), + None => break (raw.to_vec(), raw.len()), + } + } + } + } + // Reads all of its source (up to the end of the filters below it) and keeps it. + "NullEncode" | "ReusableStreamDecode" => (raw.to_vec(), raw.len()), + other => return ps_err("undefined", other), + }; + Ok((data, used, None)) +} diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs new file mode 100644 index 000000000..63db3611f --- /dev/null +++ b/crates/eps/src/import/data/ops.rs @@ -0,0 +1,761 @@ +use super::filters::{Filter, Predictor, Stream, decode, fax_settings, io}; +use super::pixels::{Pixels, Spec, by_rows, by_samples, masked, source}; +use super::*; + +/// Most bytes one filter or image reads. +pub(super) const MAX_DATA: usize = 1 << 28; + +/// Most pixels an image may have. +const MAX_PIXELS: usize = 1 << 26; + +/// Why an image is missing. +const TOO_LARGE: &str = "images too large to read were left out"; + +/// Most distinct colours an image converts through a tint transform. +const MAX_TINTS: usize = 1 << 16; + +/// What a source of image data gives. +#[derive(Clone)] +pub(super) enum Source { + File(Rc>), + Str(Vec), + Proc(Obj), +} + +impl Interp<'_> { + pub fn data_op(&mut self, op: Op) -> Res { + use Op::*; + match op { + CurrentFile => self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Current)), exec: false })?, + Filter => self.filter()?, + ReadHexString | ReadString => { + let s = self.pop_str()?; + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + let n = s.len(); + let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? }; + self.fill(s, &got)?; + self.push(Obj::Bool(got.len() == n))?; + } + ReadLine => { + let s = self.pop_str()?; + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + let n = s.len(); + let mut line = vec![]; + let mut ended = false; + while line.len() < n { + match self.read(&f, 1)?.first() { + Some(b'\n') | None => { + ended = true; + break; + } + Some(b'\r') => { + self.skip_lf(&f); + ended = true; + break; + } + Some(b) => line.push(*b), + } + } + self.fill(s, &line)?; + self.push(Obj::Bool(ended))?; + } + FlushFile => { + // An input file is read to its end: a filter over the program's data skips it + // (metadata read with `flushfile`). The program's own file goes on being run. + if let Obj::File { stream, .. } = self.pop()? { + self.materialize(&stream)?; + } + } + CloseFile => { + self.pop()?; + } + File => { + self.pop()?; + let name = self.pop()?.text().unwrap_or_else(|| Rc::from("")); + // The standard files take what programs print (and give nothing); there is no + // file system to open others in. + if !matches!(&*name, "%stdout" | "%stderr" | "%stdin" | "%lineedit" | "%statementedit") { + return ps_err("invalidfileaccess", "file"); + } + self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Buf { data: vec![], pos: 0, rgb: None })), exec: false })?; + } + Status => match self.pop()? { + Obj::File { .. } => self.push(Obj::Bool(true))?, + // A file name: there are no files. + Obj::Str(_) => self.push(Obj::Bool(false))?, + _ => return ps_err("typecheck", ""), + }, + BytesAvailable => { + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + self.materialize(&f)?; + let n = match &*f.borrow() { + Stream::Current => self.lex.rest().len(), + Stream::Buf { data, pos, .. } => data.len().saturating_sub(*pos), + Stream::Pending(_) => 0, + }; + self.push(Obj::Int(n as i64))?; + } + ResetFile => { + // A decoded (reusable) stream reads again from its start. + if let Obj::File { stream: f, .. } = self.pop()? + && let Stream::Buf { pos, .. } = &mut *f.borrow_mut() + { + *pos = 0; + } + } + Write | WriteString | WriteHexString => { + self.pop()?; + self.pop()?; + } + Read => { + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + match self.read(&f, 1)?.first() { + Some(b) => { + self.push(Obj::Int(i64::from(*b)))?; + self.push(Obj::Bool(true))?; + } + None => self.push(Obj::Bool(false))?, + } + } + FilePosition => { + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + let Stream::Buf { pos, .. } = &*f.borrow() else { return ps_err("ioerror", "") }; + self.push(Obj::Int(*pos as i64))?; + } + SetFilePosition => { + let at = self.pop_count()?; + let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") }; + let Stream::Buf { data, pos, .. } = &mut *f.borrow_mut() else { return ps_err("ioerror", "") }; + *pos = at.min(data.len()); + } + // There are no files to delete, rename or run. + DeleteFile | Run => { + self.pop_str()?; + return ps_err("undefinedfilename", ""); + } + RenameFile => { + self.pop_str()?; + self.pop_str()?; + return ps_err("undefinedfilename", ""); + } + Token => self.token()?, + Eexec => self.eexec()?, + Image | ImageMask | ColorImage => self.image(op)?, + _ => self.text_op(op)?, + } + Ok(()) + } + + /// `token`: the next object read from a string (and the rest of it) or a file. + fn token(&mut self) -> Res { + match self.pop()? { + Obj::Str(s) => { + let bytes = s.to_vec(); + let mut lex = Lexer::new(&bytes); + let Some(o) = lex.next()? else { return self.push(Obj::Bool(false)) }; + let o = self.scanned(o, lex.immediate)?; + // The whitespace after the token is read with it. + let used = bytes.len() - lex.data().len(); + self.push_interval(Obj::Str(s), used, bytes.len() - used)?; + self.push(o)?; + } + Obj::File { stream: f, .. } => { + self.materialize(&f)?; + let (o, immediate) = match &mut *f.borrow_mut() { + Stream::Current => (self.lex.next()?, self.lex.immediate), + Stream::Buf { data, pos, .. } => { + let rest = data.get(*pos..).unwrap_or_default(); + let mut lex = Lexer::new(rest); + let o = lex.next()?; + *pos += rest.len() - lex.data().len(); + (o, lex.immediate) + } + Stream::Pending(_) => (None, false), + }; + let Some(o) = o else { return self.push(Obj::Bool(false)) }; + let o = self.scanned(o, immediate)?; + self.push(o)?; + } + _ => return ps_err("typecheck", "token"), + } + self.push(Obj::Bool(true)) + } + + /// Up to `n` bytes of file `f`. + fn read(&mut self, f: &Rc>, n: usize) -> Res> { + self.materialize(f)?; + Ok(match &mut *f.borrow_mut() { + Stream::Current => { + let rest = self.lex.data(); + let k = n.min(rest.len()); + let v = rest.get(..k).unwrap_or_default().to_vec(); + self.lex.advance(k); + v + } + Stream::Buf { data, pos, .. } => { + let start = (*pos).min(data.len()); + let end = start.saturating_add(n).min(data.len()); + *pos = end; + data.get(start..end).unwrap_or_default().to_vec() + } + Stream::Pending(_) => vec![], + }) + } + + /// After `readline` consumes CR, consume only an immediately following LF. PostScript + /// treats CR/LF as one line ending (PLRM §3.8); raw reads must keep every other byte. + /// The preceding `read` has already materialized any pending filter. + fn skip_lf(&mut self, f: &Rc>) { + match &mut *f.borrow_mut() { + Stream::Current => { + if self.lex.rest().first() == Some(&b'\n') { + self.lex.advance(1); + } + } + Stream::Buf { data, pos, .. } => { + if data.get(*pos) == Some(&b'\n') { + *pos += 1; + } + } + Stream::Pending(_) => {} + } + } + + /// Up to `n` bytes read as hexadecimal digits from `f` (other characters skipped). + fn read_hex(&mut self, f: &Rc>, n: usize) -> Res> { + let mut out = Vec::with_capacity(n); + let mut high: Option = None; + while out.len() < n { + let Some(&b) = self.read(f, 1)?.first() else { break }; + let Some(v) = char::from(b).to_digit(16) else { continue }; + match high.take() { + Some(h) => out.push(h << 4 | v as u8), + None => high = Some(v as u8), + } + } + Ok(out) + } + + /// Read bytes `got` into string `s` and push the part of `s` they fill. + fn fill(&mut self, s: Shared, got: &[u8]) -> Res { + if !s.write(0, got) { + return ps_err("rangecheck", ""); + } + self.push_interval(Obj::Str(s), 0, got.len()) + } + + /// Count `n` decoded bytes against the memory budget, and the time to decode them. + fn charge_decoded(&mut self, n: usize) -> Res { + self.alloc(n)?; + self.spend((n / 4096) as u64) + } + + /// `filter`: a file that decodes its source. Over the program's own data it decodes when + /// first read (the data follows the operator that reads it); over other data, at once. + fn filter(&mut self) -> Res { + let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?; + let sub = if &*name == "SubFileDecode" { + let eod = self.pop_str()?.to_vec(); + let count = self.pop_count()?; + (count, eod) + } else { + (0, vec![]) + }; + let (mut predictor, mut params) = (None, None); + if let Some(Obj::Dict(d)) = self.stack.last().cloned() { + self.pop()?; + predictor = Predictor::of(&d)?; + params = Some(d); + } + let fax = if &*name == "CCITTFaxDecode" { Some(fax_settings(params.as_ref())?) } else { None }; + let spec_name = name.clone(); + let spec = Filter { name, sub, predictor, fax }; + let stream = match self.pop()? { + Obj::File { stream: f, .. } => { + if let Stream::Buf { data, pos, .. } = &*f.borrow() { + self.alloc(data.len().saturating_sub(*pos))?; + } + let chain = match &*f.borrow() { + Stream::Current => Some(vec![]), + Stream::Pending(c) => Some(c.clone()), + Stream::Buf { .. } => None, + }; + match chain { + Some(mut c) => { + // The new filter reads a pending one's data: it has none left of its own + // (`flushfile` on it skips nothing more). + if !c.is_empty() { + *f.borrow_mut() = Stream::Buf { data: vec![], pos: 0, rgb: None }; + } + c.push(spec); + Stream::Pending(c) + } + None => { + let mut b = f.borrow_mut(); + let Stream::Buf { data, pos, .. } = &mut *b else { return io("filter") }; + let (out, used, rgb) = decode(&spec, data.get(*pos..).unwrap_or_default())?; + self.charge_decoded(out.len())?; + *pos = pos.saturating_add(used); + Stream::Buf { data: out, pos: 0, rgb } + } + } + } + Obj::Str(s) => { + self.alloc(s.borrow().len())?; + let (data, _, rgb) = decode(&spec, &s.borrow())?; + self.charge_decoded(data.len())?; + Stream::Buf { data, pos: 0, rgb } + } + p @ Obj::Array { .. } => { + let raw = self.gather(&p, MAX_DATA)?; + let (data, _, rgb) = decode(&spec, &raw)?; + self.charge_decoded(data.len())?; + Stream::Buf { data, pos: 0, rgb } + } + _ => return ps_err("typecheck", "filter"), + }; + let stream = Rc::new(RefCell::new(stream)); + // A reusable stream reads its source when it is made: the data follows the operator. + if &*spec_name == "ReusableStreamDecode" { + self.materialize(&stream)?; + } + self.push(Obj::File { stream, exec: false }) + } + + /// The data an executable file runs as a program: what is left of it. `None` for the + /// program's own file, which is being run already. + pub(in super::super) fn program_of(&mut self, f: &Rc>) -> Res>> { + if matches!(&*f.borrow(), Stream::Current) { + return Ok(None); + } + self.read(f, MAX_DATA).map(Some) + } + + /// The decoded data left in file `f`, without reading it (a shading's or function's data). + pub(in super::super) fn peek_all(&mut self, f: &Rc>) -> Res> { + self.materialize(f)?; + Ok(match &*f.borrow() { + Stream::Buf { data, pos, .. } => data.get(*pos..).unwrap_or_default().to_vec(), + _ => vec![], + }) + } + + /// Decode the filters a file waits to run over the program's data, from where it is now. + fn materialize(&mut self, f: &Rc>) -> Res { + let chain = match &*f.borrow() { + Stream::Pending(c) => c.clone(), + _ => return Ok(()), + }; + let mut buf: Option<(Vec, Option)> = None; + for spec in &chain { + buf = Some(match buf.take() { + None => { + let raw = self.lex.data(); + let (data, used, rgb) = decode(spec, raw)?; + self.charge_decoded(data.len())?; + self.lex.advance(used); + (data, rgb) + } + Some((prev, was)) => { + let (data, _, rgb) = decode(spec, &prev)?; + self.charge_decoded(data.len())?; + (data, rgb.or(was)) + } + }); + } + let (data, rgb) = buf.unwrap_or_default(); + *f.borrow_mut() = Stream::Buf { data, pos: 0, rgb }; + Ok(()) + } + + /// The bytes a data procedure gives, called until it gives an empty string or `n` bytes. + fn gather(&mut self, p: &Obj, n: usize) -> Res> { + Ok(self.read_sources(&[Source::Proc(p.clone())], n, n)?.pop().unwrap_or_default()) + } + + /// `n` bytes of image data from `src` (zeros past its end). + fn source_bytes(&mut self, src: &Source, n: usize) -> Res> { + let mut v = self.read_sources(std::slice::from_ref(src), n, n)?.pop().unwrap_or_default(); + v.resize(n, 0); + Ok(v) + } + + /// Up to `n` bytes from each of `sources`, read in turn as an image reads several data sources + /// (PLRM 3rd ed., §4.10 "Images" and `image`/`colorimage` in chapter 8: the sources are read + /// in turn, the same amount from each): each procedure called once a round (its string, + /// whatever its length), each file and string giving `step` bytes a round, until each has + /// given `n` bytes or ended. Procedures that read one file get their own rows that way. + fn read_sources(&mut self, sources: &[Source], n: usize, step: usize) -> Res>> { + let mut out: Vec> = vec![vec![]; sources.len()]; + let mut ended = vec![false; sources.len()]; + while out.iter().zip(&ended).any(|(o, e)| o.len() < n && !e) { + for ((src, o), e) in sources.iter().zip(out.iter_mut()).zip(ended.iter_mut()) { + if *e || o.len() >= n { + continue; + } + let want = step.min(n - o.len()); + let chunk = match src { + Source::File(f) => self.read(f, want)?, + // A string gives its bytes over and over. + Source::Str(s) => { + let at = o.len() % s.len().max(1); + s.get(at..).unwrap_or_default().iter().chain(s.iter().cycle()).take(want).copied().collect() + } + Source::Proc(p) => { + self.call(p.clone())?; + let s = self.pop_str()?.to_vec(); + self.alloc(s.len())?; + s + } + }; + *e = chunk.is_empty(); + o.extend_from_slice(chunk.get(..chunk.len().min(n - o.len())).unwrap_or_default()); + } + } + Ok(out) + } + + /// `eexec`: an encrypted font program follows. It is skipped (type is drawn in the app's + /// fonts, by name): the font dictionary it would complete is defined as it is. + fn eexec(&mut self) -> Res { + self.pop()?; + if let Some(Obj::Dict(d)) = self.stack.last().cloned() { + let name = d.borrow().get(&Key::name("FontName")).cloned(); + if let Some(k) = name.and_then(|n| n.key()) { + self.pop()?; + self.fonts.borrow_mut().insert(k, Obj::Dict(d)); + } + } + let rest = self.lex.data(); + let at = find(rest, b"cleartomark").ok_or(PsError::Ps("ioerror", "eexec".into()))?; + self.lex.advance(at); + self.push(Obj::Mark) + } + + // ---------- images ---------- + + fn image(&mut self, op: Op) -> Res { + let mask = op == Op::ImageMask; + let img = match self.stack.last() { + Some(Obj::Dict(_)) if op != Op::ColorImage => { + let d = self.pop_dict()?; + let kind = d.borrow().get(&Key::name("ImageType")).and_then(Obj::as_num); + if kind == Some(3.0) && !mask { + self.masked_image(&d)? + } else { + let spec = self.image_dict(&d, mask)?; + self.decode_image(spec)? + } + } + _ => { + let spec = self.image_operands(op)?; + self.decode_image(spec)? + } + }; + let Some(img) = img else { return Ok(()) }; + self.place_image(img) + } + + /// ImageType 3: an image and the mask that says where it paints (where the mask's samples + /// read below one half through its `Decode`, as an image mask paints its 0 bits), in separate + /// sources (`InterleaveType` 3) or interleaved with the image's by row (2) or by sample (1). + fn masked_image(&mut self, d: &DictRef) -> Res> { + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let (Some(Obj::Dict(data)), Some(Obj::Dict(mask))) = (get("DataDict"), get("MaskDict")) else { + return ps_err("typecheck", "ImageType 3"); + }; + let interleave = get("InterleaveType").and_then(|o| o.as_num()).unwrap_or(1.0); + let mut image = self.image_dict(&data, false)?; + let mut stencil = self.image_dict(&mask, false)?; + stencil.space = Space::Gray; + let n = image.space.n(); + if image.w.saturating_mul(image.h) > MAX_PIXELS || stencil.w.saturating_mul(stencil.h) > MAX_PIXELS { + self.out.warn(TOO_LARGE); + return Ok(None); + } + if interleave != 3.0 { + let src = image.sources.first().cloned().ok_or(PsError::Ps("undefined", "DataSource".into()))?; + let (pixels, samples) = if interleave == 2.0 { + let (mrow, drow) = (stencil.row_bytes(1), image.row_bytes(n)); + let total = mrow.saturating_mul(stencil.h).saturating_add(drow.saturating_mul(image.h)); + self.alloc(total)?; + let raw = self.source_bytes(&src, total.min(MAX_DATA))?; + by_rows(&raw, (mrow, stencil.h), (drow, image.h)) + } else { + // Each sample: the mask's value, then the colour's, as many bits each. + stencil.bpc = image.bpc; + (stencil.w, stencil.h) = (image.w, image.h); + let row = (image.w * (n + 1) * usize::from(image.bpc)).div_ceil(8); + self.alloc(row.saturating_mul(image.h))?; + let raw = self.source_bytes(&src, row.saturating_mul(image.h).min(MAX_DATA))?; + by_samples(&raw, image.w, image.h, n, image.bpc) + }; + stencil.sources = vec![Source::Str(pixels)]; + image.sources = vec![Source::Str(samples)]; + } + let Some(mut img) = self.decode_image(image)? else { return Ok(None) }; + let Some(m) = self.decode_image(stencil)? else { return Ok(Some(img)) }; + let (w, h, mw, mh) = (img.w as usize, img.h as usize, m.w as usize, m.h as usize); + for (i, px) in img.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + // The mask's pixel over this one (in 64 bits: rows times rows overflow 32). + let at = (y as u64 * mh as u64 / h.max(1) as u64) * mw as u64 + x as u64 * mw as u64 / w.max(1) as u64; + if usize::try_from(at * 4).ok().and_then(|i| m.rgba.get(i)).is_none_or(|v| *v >= 128) + && let Some(a) = px.get_mut(3) + { + *a = 0; + } + } + Ok(Some(img)) + } + + /// The dictionary form of `image` and `imagemask`. + fn image_dict(&mut self, d: &DictRef, mask: bool) -> Res { + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let num = |k: &str| get(k).and_then(|o| o.as_num()); + let kind = num("ImageType").unwrap_or(1.0); + if kind != 1.0 && kind != 4.0 { + return ps_err("rangecheck", "ImageType"); + } + let size = |k: &str| num(k).filter(|v| *v >= 1.0 && *v <= 65_535.0).map(|v| v as usize).ok_or(PsError::Ps("rangecheck", k.to_string())); + let (w, h) = (size("Width")?, size("Height")?); + let bpc = if mask { 1 } else { num("BitsPerComponent").unwrap_or(8.0) as u8 }; + let matrix = + get("ImageMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).ok_or(PsError::Ps("typecheck", "ImageMatrix".into()))?; + let decode: Vec = get("Decode").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); + let space = if mask { Space::Gray } else { (*self.g.space).clone() }; + let multi = get("MultipleDataSources").is_some_and(|o| matches!(o, Obj::Bool(true))); + let sources = match (get("DataSource"), multi) { + (None, _) => vec![], + (Some(Obj::Array { items, exec: false }), true) => items.borrow().iter().map(source).collect::>>()?, + (Some(src), _) => vec![source(&src)?], + }; + let mask_color: Option> = (kind == 4.0) + .then(|| { + get("MaskColor").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(|v| v.as_num().map(|v| v.max(0.0) as u32)).collect())) + }) + .flatten(); + // An image mask paints its 0 bits with Decode [0 1], its 1 bits with [1 0]. + let stencil = mask.then(|| decode.first().is_some_and(|v| *v > 0.5)); + Ok(Spec { w, h, bpc, space, decode, matrix, sources, mask_color, stencil }) + } + + /// The operand forms: `w h bpc matrix src image`, `w h polarity matrix src imagemask`, and + /// `w h bpc matrix src… multi ncomp colorimage`. + fn image_operands(&mut self, op: Op) -> Res { + let (n, multi) = if op == Op::ColorImage { + let n = self.pop_count()?; + (n, self.pop_bool()?) + } else { + (1, false) + }; + let space = match n { + 1 => Space::Gray, + 3 => Space::Rgb, + 4 => Space::Cmyk, + _ => return ps_err("rangecheck", "colorimage"), + }; + let mut sources = vec![]; + for _ in 0..if multi { n } else { 1 } { + let o = self.pop()?; + sources.push(source(&o)?); + } + sources.reverse(); + let matrix = self.pop_matrix()?; + let (bpc, stencil) = if op == Op::ImageMask { (1, Some(self.pop_bool()?)) } else { (self.pop_int()?.clamp(1, 16) as u8, None) }; + let size = |v: i64| usize::try_from(v).ok().filter(|v| (1..=65_535).contains(v)).ok_or(PsError::Ps("rangecheck", "image".into())); + let h = size(self.pop_int()?)?; + let w = size(self.pop_int()?)?; + Ok(Spec { w, h, bpc, space, decode: vec![], matrix, sources, mask_color: None, stencil }) + } + + /// The image's pixels, straight RGBA, rows from the first one read, and a CMYK image's ink + /// amounts. + fn decode_image(&mut self, s: Spec) -> Res> { + if !matches!(s.bpc, 1 | 2 | 4 | 8 | 12 | 16) { + return ps_err("rangecheck", "BitsPerComponent"); + } + if s.w.saturating_mul(s.h) > MAX_PIXELS { + self.out.warn(TOO_LARGE); + return Ok(None); + } + self.alloc(s.w * s.h * 4)?; + let n = if s.stencil.is_some() { 1 } else { s.space.n() }; + let bpc = usize::from(s.bpc); + for src in &s.sources { + if let Source::File(f) = src { + self.materialize(f)?; + } + } + // A decoded JPEG gives 8-bit gray, RGB or CMYK samples whatever the color space says. + let jpeg = s.sources.iter().find_map(|src| match src { + Source::File(f) => match &*f.borrow() { + Stream::Buf { rgb, .. } => *rgb, + _ => None, + }, + _ => None, + }); + let (n, bpc, space) = match jpeg { + Some(3) => (3, 8, Space::Rgb), + Some(4) => (4, 8, Space::Cmyk), + Some(_) => (1, 8, Space::Gray), + None => (n, bpc, s.space.clone()), + }; + let multi = s.sources.len() > 1; + let row_bits = if multi { s.w * bpc } else { s.w * n * bpc }; + let row = row_bits.div_ceil(8); + // Several sources are read in turn, a row at a time each (zeros past their ends). + let sources = if multi { &s.sources[..] } else { s.sources.get(..1).ok_or(PsError::Ps("undefined", "DataSource".into()))? }; + if sources.len() > n { + return ps_err("rangecheck", "DataSource"); + } + let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?; + self.alloc(plane.saturating_mul(sources.len()))?; + let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?; + for p in &mut planes { + p.resize(row * s.h, 0); + } + let max = ((1u32 << bpc.min(16)) - 1) as f64; + let sample = |plane: &[u8], y: usize, i: usize| -> u32 { + let bit = y * row * 8 + i * bpc; + let byte = bit / 8; + match bpc { + 8 => u32::from(plane.get(byte).copied().unwrap_or(0)), + 16 => u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)), + 12 => { + let v = u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)); + if bit.is_multiple_of(8) { v >> 4 } else { v & 0xfff } + } + _ => (u32::from(plane.get(byte).copied().unwrap_or(0)) >> (8 - bpc - bit % 8)) & ((1 << bpc) - 1), + } + }; + let default_decode: Vec = match &space { + Space::Indexed { .. } => vec![0.0, max], + _ => (0..n).flat_map(|_| [0.0, 1.0]).collect(), + }; + // The Decode array applies to a CMYK JPEG's samples as stored; gray and RGB JPEGs are read + // with the default Decode array. + let decode = if s.decode.len() >= 2 * n && jpeg.is_none_or(|c| c == 4) { s.decode.clone() } else { default_decode }; + let paint = self.g.paint.color().map(|c| c.to_rgba8(1.0)); + let mut rgba = Vec::with_capacity(s.w * s.h * 4); + // CMYK samples keep their ink amounts, as the Decode array reads them. + let mut inks = if matches!(space, Space::Cmyk) && s.stencil.is_none() { + self.alloc(s.w * s.h * 4)?; + Some(Vec::with_capacity(s.w * s.h * 4)) + } else { + None + }; + let mut cache: HashMap, [u8; 3]> = HashMap::new(); + let mut raw = vec![0u32; n]; + let mut comps = vec![0f64; n]; + for y in 0..s.h { + for x in 0..s.w { + for (c, slot) in raw.iter_mut().enumerate() { + *slot = match planes.as_slice() { + [one] => sample(one, y, x * n + c), + many => many.get(c).map_or(0, |p| sample(p, y, x)), + }; + } + if let Some(paint_ones) = s.stencil { + let on = (raw.first().copied().unwrap_or(0) == 1) == paint_ones; + let [r, g, b, _] = paint.unwrap_or([0, 0, 0, 255]); + rgba.extend([r, g, b, if on { 255 } else { 0 }]); + continue; + } + let masked = s.mask_color.as_ref().is_some_and(|m| masked(m, &raw)); + for (c, slot) in comps.iter_mut().enumerate() { + let (d0, d1) = (decode.get(2 * c).copied().unwrap_or(0.0), decode.get(2 * c + 1).copied().unwrap_or(1.0)); + *slot = d0 + f64::from(*raw.get(c).unwrap_or(&0)) * (d1 - d0) / max; + } + if let Some(inks) = inks.as_mut() { + inks.extend(comps.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + let rgb = match (&space, raw.as_slice()) { + (Space::Gray, [v]) if bpc == 8 && decode == [0.0, 1.0] => [*v as u8; 3], + (Space::Rgb, [r, g, b]) if bpc == 8 && decode == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] => [*r as u8, *g as u8, *b as u8], + _ => match cache.get(&raw) { + Some(c) => *c, + None => { + let c = self.sample_rgb(&space, &comps)?; + if cache.len() < MAX_TINTS { + cache.insert(raw.clone(), c); + } + c + } + }, + }; + rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]); + } + } + Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, inks, matrix: s.matrix })) + } + + /// One pixel's colour in `space` as display RGB. + fn sample_rgb(&mut self, space: &Space, comps: &[f64]) -> Res<[u8; 3]> { + let color = match space { + Space::Gray | Space::Rgb | Space::Cmyk => process(space, comps), + _ => match self.paint_of(space, comps)? { + Paint::Solid { color, .. } => Some(color), + _ => None, + }, + }; + let [r, g, b, _] = color.unwrap_or(Color::BLACK).to_rgba8(1.0); + Ok([r, g, b]) + } + + /// Place decoded pixels in user space (its unit square through the image matrix). + fn place_image(&mut self, img: Pixels) -> Res { + let m = img.matrix; + if m.determinant().abs() < 1e-12 { + return Ok(()); + } + let xf = self.xf() * m.inverse(); + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return Ok(()); + } + if self.g.null { + return Ok(()); + } + // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps an 8- or + // 16-bit CMYK image's samples, and transparent pixels (`MaskColor`, an ImageType 3 mask) + // become its opacity mask. + if let Some(blob) = img.inks.and_then(|i| Inks::new(img.w, img.h, i)).and_then(|i| ImageBlob::cmyk_tiff(&i)) { + let alpha: Vec = img.rgba.iter().skip(3).step_by(4).copied().collect(); + let mask = self.alpha_mask(&alpha, img.w, img.h, xf); + return self.add_image(blob, img.w, img.h, xf, mask); + } + let Some(rgba) = image::RgbaImage::from_raw(img.w, img.h, img.rgba) else { return Ok(()) }; + let mut png = vec![]; + if rgba.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + self.out.warn("an image that couldn't be read was left out"); + return Ok(()); + } + self.add_image(ImageBlob::new("image/png", png), img.w, img.h, xf, None) + } + + /// Add image `blob` (`w` × `h` pixels, mapped into user space by `xf`) with `mask` as its + /// opacity mask. + fn add_image(&mut self, blob: ImageBlob, w: u32, h: u32, xf: Affine, mask: Option) -> Res { + let key = blob.content_key(); + self.out.doc.images.entry(key.clone()).or_insert(blob); + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; + let clips = self.g.clips.clone(); + self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))).with_mask(mask.map(Box::new)), &clips); + Ok(()) + } + + /// An image's alpha (`w` × `h` bytes) as a grayscale image over the image (`xf`): its opacity + /// mask, as in PDF import. `None` when it hides nothing. + fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { + if alpha.iter().all(|v| *v == 255) { + return None; + } + let mut png = Vec::new(); + image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + self.out.doc.images.entry(key.clone()).or_insert(blob); + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; + Some(OpacityMask::new(Node::new(self.out.doc.alloc_id(), NodeKind::Image(Box::new(im))), true)) + } +} diff --git a/crates/eps/src/import/data/pixels.rs b/crates/eps/src/import/data/pixels.rs new file mode 100644 index 000000000..f61d10448 --- /dev/null +++ b/crates/eps/src/import/data/pixels.rs @@ -0,0 +1,106 @@ +use super::ops::Source; +use super::*; + +/// An image to decode. +pub(super) struct Spec { + pub(super) w: usize, + pub(super) h: usize, + pub(super) bpc: u8, + pub(super) space: Space, + pub(super) decode: Vec, + /// User space → image space. + pub(super) matrix: Affine, + /// One source, or one per component. + pub(super) sources: Vec, + /// `MaskColor` (ImageType 4): samples that are transparent. + pub(super) mask_color: Option>, + /// An image mask: whether its 1 bits paint. + pub(super) stencil: Option, +} + +impl Spec { + /// Bytes a row of `n` components takes. + pub(super) fn row_bytes(&self, n: usize) -> usize { + (self.w * n * usize::from(self.bpc)).div_ceil(8) + } +} + +/// Data of an ImageType 3 interleaved by row, split into the mask's and the image's: the one +/// with fewer rows gives one row before each run of the other's (`(row bytes, rows)` each). +pub(super) fn by_rows(raw: &[u8], (mrow, mh): (usize, usize), (drow, dh): (usize, usize)) -> (Vec, Vec) { + let room = |row: usize, rows: usize| row.saturating_mul(rows).min(raw.len()); + let (mut mask, mut data) = (Vec::with_capacity(room(mrow, mh)), Vec::with_capacity(room(drow, dh))); + let mut at = 0; + let mut take = |into: &mut Vec, n: usize| { + into.extend_from_slice(raw.get(at..at + n).unwrap_or_default()); + at += n; + }; + let (blocks, masks, rows) = if mh >= dh { (dh, mh / dh.max(1), 1) } else { (mh, 1, dh / mh.max(1)) }; + for _ in 0..blocks { + for _ in 0..masks { + take(&mut mask, mrow); + } + for _ in 0..rows { + take(&mut data, drow); + } + } + (mask, data) +} + +/// Data of an ImageType 3 interleaved by sample (the mask's value first, then `n` colour +/// components, `bpc` bits each, rows from whole bytes), split into the mask's and the image's. +pub(super) fn by_samples(raw: &[u8], w: usize, h: usize, n: usize, bpc: u8) -> (Vec, Vec) { + let bpc = usize::from(bpc); + let bit = |at: usize| raw.get(at / 8).is_some_and(|b| b & (0x80 >> (at % 8)) != 0); + let (mrow, drow, row) = ((w * bpc).div_ceil(8), (w * n * bpc).div_ceil(8), (w * (n + 1) * bpc).div_ceil(8)); + let (mut mask, mut data) = (vec![0u8; mrow * h], vec![0u8; drow * h]); + let put = |into: &mut [u8], at: usize| { + if let Some(b) = into.get_mut(at / 8) { + *b |= 0x80 >> (at % 8); + } + }; + for y in 0..h { + for x in 0..w { + let from = y * row * 8 + x * (n + 1) * bpc; + for k in 0..bpc { + if bit(from + k) { + put(&mut mask, y * mrow * 8 + x * bpc + k); + } + } + for k in 0..n * bpc { + if bit(from + bpc + k) { + put(&mut data, y * drow * 8 + x * n * bpc + k); + } + } + } + } + (mask, data) +} + +/// Decoded pixels. +pub(super) struct Pixels { + pub(super) w: u32, + pub(super) h: u32, + pub(super) rgba: Vec, + /// A CMYK image's ink amounts: C, M, Y and K bytes a pixel, 0 is no ink. + pub(super) inks: Option>, + pub(super) matrix: Affine, +} + +pub(super) fn source(o: &Obj) -> Res { + Ok(match o { + Obj::File { stream: f, .. } => Source::File(f.clone()), + Obj::Str(s) => Source::Str(s.to_vec()), + Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }), + _ => return ps_err("typecheck", "DataSource"), + }) +} + +/// Do samples `raw` match `MaskColor` `m` (one value per component, or a range)? +pub(super) fn masked(m: &[u32], raw: &[u32]) -> bool { + if m.len() == raw.len() { + return m.iter().zip(raw).all(|(a, b)| a == b); + } + m.len() == raw.len() * 2 + && raw.iter().enumerate().all(|(i, v)| (m.get(2 * i).copied().unwrap_or(0)..=m.get(2 * i + 1).copied().unwrap_or(0)).contains(v)) +} From f8fe7075f4cf1049b8a8407af9ae08412d0dcdd2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:30:38 +0000 Subject: [PATCH 127/199] refactor(eps): move import/graphics.rs items to import/graphics/{ops,paint,path,state}.rs --- crates/eps/src/import/graphics.rs | 1780 +---------------------- crates/eps/src/import/graphics/ops.rs | 533 +++++++ crates/eps/src/import/graphics/paint.rs | 495 +++++++ crates/eps/src/import/graphics/path.rs | 428 ++++++ crates/eps/src/import/graphics/state.rs | 328 +++++ 5 files changed, 1792 insertions(+), 1772 deletions(-) create mode 100644 crates/eps/src/import/graphics/ops.rs create mode 100644 crates/eps/src/import/graphics/paint.rs create mode 100644 crates/eps/src/import/graphics/path.rs create mode 100644 crates/eps/src/import/graphics/state.rs diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 5d4f7b9fa..bd6f25287 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -17,6 +17,14 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; use super::interp::{Interp, MAX_GSAVE, matrix_obj}; use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, clamp_between, ps_err}; +mod ops; +mod paint; +mod path; +mod state; + +pub(crate) use paint::collapse; +pub(crate) use state::{GState, Out, Space, process}; + /// Most objects one file makes. const MAX_NODES: usize = 1 << 20; /// Objects reaching further than this from the page (points) come from damaged coordinates and @@ -61,1775 +69,3 @@ const NESTED_PATTERNS: &str = "patterns nested too deeply are filled with mid-gr pub(crate) const MAX_APART: u32 = 8; const UNKNOWN_FUNCTIONS: &str = "shadings whose functions are of an unknown type are filled with their middle colour"; const UNKNOWN_SHADINGS: &str = "shadings of an unknown type were left out"; - -/// A colour space. -#[derive(Clone, Debug)] -pub(crate) enum Space { - Gray, - Rgb, - Cmyk, - /// Separation and DeviceN: the ink names, the alternate space and the tint transform. - Inks { - names: Vec>, - alt: Rc, - tint: Obj, - }, - Indexed { - base: Rc, - table: Rc>, - }, - /// Patterns; an uncoloured one paints in a colour of the underlying space. - Pattern(Option>), -} - -impl Space { - /// Components a colour has in it. - pub fn n(&self) -> usize { - match self { - Self::Gray | Self::Indexed { .. } | Self::Pattern(_) => 1, - Self::Rgb => 3, - Self::Cmyk => 4, - Self::Inks { names, .. } => names.len().max(1), - } - } - - /// The colour `setcolorspace` starts with. - fn initial(&self) -> Vec { - match self { - Self::Cmyk => vec![0.0, 0.0, 0.0, 1.0], - Self::Inks { names, .. } => vec![1.0; names.len().max(1)], - s => vec![0.0; s.n()], - } - } -} - -/// A process colour from components in `space` (grey, RGB or CMYK), each clamped to 0–1. -pub(crate) fn process(space: &Space, c: &[f64]) -> Option { - let v = |i: usize| c.get(i).map_or(0.0, |x| if x.is_finite() { x.clamp(0.0, 1.0) as f32 } else { 0.0 }); - Some(match space { - Space::Gray => Color::gray(1.0 - v(0)), - Space::Rgb => Color::rgb(v(0), v(1), v(2)), - Space::Cmyk => Color::cmyk(v(0), v(1), v(2), v(3)), - _ => return None, - }) -} - -#[derive(Clone, Debug)] -pub(crate) struct GState { - /// User space → PostScript's default space. - pub ctm: Affine, - pub space: Rc, - /// The `setcolorspace` operand, for `currentcolorspace`. - pub space_obj: Obj, - pub comps: Vec, - pub paint: Paint, - pub width: f64, - pub cap: LineCap, - pub join: LineJoin, - pub miter: f64, - /// The dash pattern, shared by the states saved or copied from this one. - pub dash: Rc<[f64]>, - pub dash_offset: f64, - /// The current path, in document space. - pub path: BezPath, - pub cur: Option, - pub start: Option, - pub clips: Vec>, - /// The clips `clipsave` saved, for `cliprestore`. - pub saved_clips: Vec]>>, - pub overprint: bool, - pub font: Option, - /// `nulldevice`: painting draws nothing. - pub null: bool, - /// The colour is a shading pattern whose shading is a mesh: a fill paints it (the shading - /// and pattern space → the document). - pub mesh: Option<(DictRef, Affine)>, -} - -impl Default for GState { - fn default() -> Self { - Self { - ctm: Affine::IDENTITY, - space: Rc::new(Space::Gray), - space_obj: Obj::name("DeviceGray"), - comps: vec![0.0], - paint: Paint::solid(Color::gray(1.0)), - width: 1.0, - cap: LineCap::Butt, - join: LineJoin::Miter, - miter: 10.0, - dash: Rc::from([]), - dash_offset: 0.0, - path: BezPath::new(), - cur: None, - start: None, - clips: vec![], - saved_clips: vec![], - overprint: false, - font: None, - null: false, - mesh: None, - } - } -} - -/// What the program draws. -pub(crate) struct Out { - /// The document being made: ids, images and swatches. - pub doc: Document, - pub drawn: Drawn, - pub warnings: Vec, - /// PostScript's default space → document space. - pub page: Affine, - /// The page (the bounding box) in document space. - pub frame: Rect, - /// Clip and group ids are given in the order they open, so a chain of both sorts by id. - next_clip: u32, - /// The groups open (outermost first), and how many more opened past [`MAX_NEST`] (their art - /// goes in the innermost group kept). - groups: Vec>, - too_deep: usize, - /// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]). - elements: usize, - /// The path the last object was filled with: a stroke of the same path right after joins it. - merge: Option, - /// Process colours painted, by model (the document opens in CMYK when most are). - pub cmyk: usize, - pub rgb: usize, - /// Spot inks used: name and colour at full tint. - pub spots: Vec<(String, Color)>, - /// The color at full tint of each spot ink in [`Self::spots`], by name. - spot_colors: HashMap, Color>, - /// Rectangles painted with a shading (`shfill`): a clipping group of one is the clip path - /// filled with it. - pub shadings: HashSet, - /// Tiling patterns made into pattern swatches: the pattern's `PaintProc` (a pattern made - /// again with `makepattern` shares it) and cell, the colour an uncoloured one painted in, and - /// the swatch's name. Each list holds the patterns of one [`tiling_hash`]; a pattern whose - /// cell has a NaN corner is left out. - tilings: HashMap>, - /// How deep art drawn apart (a pattern's cell, a Type 3 glyph) is nested. - pub apart: u32, -} - -fn sane(r: Rect) -> bool { - [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) -} - -/// A tiling pattern made into a pattern swatch, as [`Out::tilings`] lists it. -type Tiling = (Shared, Rect, Option, String); - -/// A hash of what tells tiling patterns apart in [`Out::tilings`]. Patterns that compare equal -/// hash alike: each number goes in by its bits, and −0 goes in as 0. -fn tiling_hash(proc: &Shared, tile: Rect, under: Option<&Paint>) -> u64 { - let mut h = DefaultHasher::new(); - proc.hash(&mut h); - [tile.x0, tile.y0, tile.x1, tile.y1].map(|v| (v + 0.0).to_bits()).hash(&mut h); - if let Some(Paint::Solid { color, swatch, tint }) = under { - let c = match *color { - Color::Rgb { r, g, b } => [r, g, b, 0.0], - Color::Cmyk { c, m, y, k } => [c, m, y, k], - Color::Gray { k } => [k, 0.0, 0.0, 0.0], - Color::Lab { l, a, b } => [l, a, b, 0.0], - }; - c.map(|v| (v + 0.0).to_bits()).hash(&mut h); - swatch.hash(&mut h); - (tint + 0.0).to_bits().hash(&mut h); - } - h.finish() -} - -impl Out { - pub fn new(doc: Document, page: Affine, frame: Rect) -> Self { - Self { - doc, - drawn: vec![], - warnings: vec![], - page, - frame, - next_clip: 0, - groups: vec![], - too_deep: 0, - elements: 0, - merge: None, - cmyk: 0, - rgb: 0, - spots: vec![], - spot_colors: HashMap::new(), - shadings: HashSet::new(), - tilings: HashMap::new(), - apart: 0, - } - } - - pub fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) { - self.warnings.push(w.to_string()); - } - } - - /// Open a group: what is drawn until it ends ([`Self::end_group`]) goes in it. - pub fn begin_group(&mut self) { - if self.groups.len() >= MAX_NEST { - self.too_deep += 1; - self.warn(&format!("groups nested more than {MAX_NEST} deep were read as part of the group around them")); - return; - } - self.next_clip += 1; - self.groups.push(Arc::new(Clip { id: self.next_clip, region: None })); - } - - /// End the innermost group open (an end without a beginning ends nothing). - pub fn end_group(&mut self) { - if self.too_deep > 0 { - self.too_deep -= 1; - } else { - self.groups.pop(); - } - } - - /// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the - /// order they opened. - pub fn chain(&self, clips: &[Arc]) -> Vec> { - if self.groups.is_empty() { - return clips.to_vec(); - } - let mut chain = Vec::with_capacity(clips.len() + self.groups.len()); - let (mut a, mut b) = (clips.iter().peekable(), self.groups.iter().peekable()); - while let (Some(x), Some(y)) = (a.peek(), b.peek()) { - let next = if x.id < y.id { a.next() } else { b.next() }; - chain.extend(next.cloned()); - } - chain.extend(a.chain(b).cloned()); - chain - } - - /// Add `node` under `clips` (in the groups open); `None` when it was left out. - pub fn push(&mut self, mut node: Node, clips: &[Arc]) -> Option { - self.merge = None; - let chain = self.chain(clips); - let size = node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum()) + chain.len(); - self.elements = self.elements.saturating_add(size); - if self.drawn.len() >= MAX_NODES || self.elements > MAX_DRAWN { - self.warn(TOO_MUCH); - return None; - } - if !node.visual_bounds().is_none_or(sane) { - self.warn(FAR_AWAY); - return None; - } - node.id = self.doc.alloc_id(); - let id = node.id; - self.drawn.push((chain, node)); - Some(id) - } - - /// Count a paint's colour model. - fn count(&mut self, p: &Paint) { - match p { - Paint::Solid { color: Color::Cmyk { .. }, swatch: None, .. } => self.cmyk += 1, - Paint::Solid { color: Color::Rgb { .. }, swatch: None, .. } => self.rgb += 1, - _ => {} - } - } - - /// Fill `bp` with `paint`. - fn fill(&mut self, bp: BezPath, rule: FillRule, paint: Paint, overprint: bool, clips: &[Arc]) { - self.count(&paint); - let item = AppearanceItem::Fill(FillLayer { overprint, ..FillLayer::new(paint) }); - let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); - if let NodeKind::Path { rule: r, .. } = &mut n.kind { - *r = rule; - } - if self.push(n, clips).is_some() { - self.merge = Some(bp); - } - } - - /// Stroke `bp` with `st`: joins the object just filled with the same path, under the same - /// clips (`gsave fill grestore stroke`). - fn stroke(&mut self, bp: BezPath, st: StrokeLayer, clips: &[Arc]) { - self.count(&st.paint); - if self.merge.as_ref() == Some(&bp) { - let here = self.chain(clips); - if let Some((chain, last)) = self.drawn.last_mut() - && chain.iter().map(|c| c.id).eq(here.iter().map(|c| c.id)) - { - last.appearance.items.push(AppearanceItem::Stroke(st)); - self.merge = None; - return; - } - } - let n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Appearance::default() }); - self.push(n, clips); - } - - /// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which - /// clips nothing. - fn clip(&mut self, path: BezPath, rule: FillRule) -> Option> { - // Only a polygon that really covers the page: a diamond touching its edges has the page as - // its bounding box but cuts its corners away (#1542). - if path.elements().len() <= 6 && vectorcraft_geom::hit::polygon_covers_rect(&path, self.frame, 0.5) { - return None; - } - self.next_clip += 1; - Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })) - } -} - -/// The smallest angle from `a` to `b` (radians, −π to π). -fn turn(a: f64, b: f64) -> f64 { - let d = (b - a).rem_euclid(std::f64::consts::TAU); - if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d } -} - -/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left -/// of each after whole turns. -fn ccw_turn(a: f64, b: f64) -> f64 { - (b % 360.0 - a % 360.0).rem_euclid(360.0) -} - -impl Interp<'_> { - /// User space → document space. - pub fn xf(&self) -> Affine { - self.out.page * self.g.ctm - } - - /// How much user-space lengths grow in the document. - pub fn scale(&self) -> f64 { - let s = self.xf().determinant().abs().sqrt(); - if s.is_finite() { s } else { 0.0 } - } - - /// The current point in user space. - pub fn user_point(&self) -> Res { - let p = self.g.cur.ok_or(PsError::Ps("nocurrentpoint", String::new()))?; - let inv = self.xf(); - if inv.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - Ok(inv.inverse() * p) - } - - pub fn gsave(&mut self) -> Res { - if self.saved.len() >= MAX_GSAVE { - return Err(PsError::Limit("too many saved graphics states")); - } - self.alloc(std::mem::size_of_val(self.g.path.elements()))?; - self.saved.push(self.g.clone()); - Ok(()) - } - - pub fn grestore(&mut self) { - if let Some(g) = self.saved.pop() { - self.g = g; - } - } - - // ---------- paths ---------- - - /// Room for one more element in the current path. - pub fn grow(&self) -> Res { - if self.g.path.elements().len() >= MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - Ok(()) - } - - /// Count `n` path elements an operator makes, reads or copies against the operation budget, - /// an operation each: making or reading an element takes up to about as long as an operation, - /// and copying one takes less (measured). - fn spend_elements(&mut self, n: usize) -> Res { - self.spend(n as u64) - } - - fn move_to(&mut self, p: Point) -> Res { - self.grow()?; - // Consecutive movetos: the last one counts. - if let Some(PathEl::MoveTo(_)) = self.g.path.elements().last() { - self.g.path.pop(); - } - self.g.path.move_to(p); - self.g.cur = Some(p); - self.g.start = Some(p); - Ok(()) - } - - fn line_to(&mut self, p: Point) -> Res { - self.grow()?; - if self.g.cur.is_none() { - return ps_err("nocurrentpoint", ""); - } - self.g.path.line_to(p); - self.g.cur = Some(p); - Ok(()) - } - - /// Path elements in user space appended (the first move a line when there is a current point), - /// each counted against the operation budget. - fn append_user(&mut self, els: impl Iterator) -> Res { - let xf = self.xf(); - for el in els { - self.spend_elements(1)?; - match el { - PathEl::MoveTo(p) => { - let p = xf * p; - if self.g.cur.is_some() { self.line_to(p)? } else { self.move_to(p)? } - } - PathEl::LineTo(p) => self.line_to(xf * p)?, - PathEl::QuadTo(a, p) => { - self.grow()?; - self.g.path.quad_to(xf * a, xf * p); - self.g.cur = Some(xf * p); - } - PathEl::CurveTo(a, b, p) => { - self.grow()?; - self.g.path.curve_to(xf * a, xf * b, xf * p); - self.g.cur = Some(xf * p); - } - PathEl::ClosePath => { - self.grow()?; - self.g.path.close_path(); - } - } - } - Ok(()) - } - - /// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise). - fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res { - // `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it - // until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps, - // and the first endpoint, are worked out from what is left of each angle after whole turns. - let sweep = if ccw && a2 < a1 { - ccw_turn(a1, a2) - } else if !ccw && a2 > a1 { - -ccw_turn(a2, a1) - } else { - a2 - a1 - }; - let tau = std::f64::consts::TAU; - let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0); - let a1 = (a1 % 360.0).to_radians(); - let start = c + Vec2::new(r * a1.cos(), r * a1.sin()); - self.append_user(std::iter::once(PathEl::MoveTo(start)))?; - let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0); - let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); - self.append_user(arc.append_iter(tol)) - } - - /// `arct` / `arcto`: a line towards `p1` rounded with radius `r` into the line to `p2`; the - /// tangent points (user space). - fn arct(&mut self, p1: Point, p2: Point, r: f64) -> Res<[Point; 2]> { - let p0 = self.user_point()?; - let (u, v) = (p0 - p1, p2 - p1); - let (lu, lv) = (u.hypot(), v.hypot()); - let cross = u.cross(v); - if lu < 1e-12 || lv < 1e-12 || cross.abs() < 1e-12 || r <= 0.0 { - self.append_user(std::iter::once(PathEl::LineTo(p1)))?; - return Ok([p1, p1]); - } - let (u, v) = (u / lu, v / lv); - let half = u.dot(v).clamp(-1.0, 1.0).acos() / 2.0; - let dist = r / half.tan(); - let (t1, t2) = (p1 + u * dist, p1 + v * dist); - let bis = (u + v).normalize(); - let c = p1 + bis * (r / half.sin()); - let (a1, a2) = ((t1 - c).atan2(), (t2 - c).atan2()); - self.append_user(std::iter::once(PathEl::LineTo(t1)))?; - let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); - let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, turn(a1, a2), 0.0); - self.append_user(arc.append_iter(tol))?; - Ok([t1, t2]) - } - - fn rect_path(&self, x: f64, y: f64, w: f64, h: f64) -> BezPath { - let xf = self.xf(); - let mut bp = BezPath::new(); - bp.move_to(xf * Point::new(x, y)); - bp.line_to(xf * Point::new(x + w, y)); - bp.line_to(xf * Point::new(x + w, y + h)); - bp.line_to(xf * Point::new(x, y + h)); - bp.close_path(); - bp - } - - /// The rectangles of `rectfill`, `rectstroke` and `rectclip`: `x y w h` or an array of them. - fn rects(&mut self) -> Res { - let nums: Vec = match self.stack.last() { - Some(Obj::Array { .. }) => { - let items = self.pop_array()?; - // Five elements a rectangle, counted before the array is read. - self.spend_elements(items.len() / 4 * 5)?; - let v: Option> = items.borrow().iter().map(Obj::as_num).collect(); - v.ok_or(PsError::Ps("typecheck", String::new()))? - } - _ => self.nums::<4>()?.to_vec(), - }; - if nums.len() / 4 * 5 >= MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - let mut bp = BezPath::new(); - for [x, y, w, h] in nums.as_chunks::<4>().0 { - bp.extend(self.rect_path(*x, *y, *w, *h)); - } - Ok(bp) - } - - // ---------- painting ---------- - - fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res { - if bp.elements().is_empty() || self.g.null { - return Ok(()); - } - // A mesh pattern paints its shading inside the path. - if let Some((sh, m)) = self.g.mesh.clone() { - let clips = self.g.clips.clone(); - self.clip_with(bp, rule); - let r = self.mesh_fill(&sh, m); - self.g.clips = clips; - return r; - } - if self.g.paint.is_none() { - return Ok(()); - } - let (paint, overprint) = (self.g.paint.clone(), self.g.overprint); - self.out.fill(bp, rule, paint, overprint, &self.g.clips); - Ok(()) - } - - fn stroke_path(&mut self, bp: BezPath) -> Res { - if bp.elements().is_empty() || self.g.paint.is_none() || self.g.null { - return Ok(()); - } - let st = self.stroke_layer()?; - self.out.stroke(bp, st, &self.g.clips); - Ok(()) - } - - /// The stroke the graphics state draws, in document space. Each entry of the dash pattern it - /// copies counts as an operation. - fn stroke_layer(&mut self) -> Res { - self.spend(self.g.dash.len() as u64)?; - let k = self.scale(); - let w = self.g.width * k; - let mut st = StrokeLayer::new(self.g.paint.clone(), if w > 0.0 && w.is_finite() { w } else { HAIRLINE }); - st.cap = self.g.cap; - st.join = self.g.join; - st.miter_limit = self.g.miter.clamp(1.0, 500.0); - st.overprint = self.g.overprint; - let pattern: Vec = self.g.dash.iter().map(|d| d * k).collect(); - let dash = Dash { pattern, offset: self.g.dash_offset * k, align_corners: false }; - st.dash = dash.is_dashed().then_some(dash); - Ok(st) - } - - /// The current path in user space, each element as its operator and points (quadratic - /// segments as cubic ones). - fn user_elements(&self) -> Res)>> { - let xf = self.xf(); - if xf.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - let inv = xf.inverse(); - let mut last = Point::ZERO; - let mut out = Vec::with_capacity(self.g.path.elements().len()); - for el in self.g.path.elements() { - let (op, pts) = match *el { - PathEl::MoveTo(p) => (Op::MoveTo, vec![p]), - PathEl::LineTo(p) => (Op::LineTo, vec![p]), - PathEl::QuadTo(a, p) => (Op::CurveTo, vec![last + (a - last) * (2.0 / 3.0), p + (a - p) * (2.0 / 3.0), p]), - PathEl::CurveTo(a, b, p) => (Op::CurveTo, vec![a, b, p]), - PathEl::ClosePath => (Op::ClosePath, vec![]), - }; - last = pts.last().copied().unwrap_or(last); - out.push((op, pts.into_iter().map(|p| inv * p).collect())); - } - Ok(out) - } - - /// `pathforall`: the current path in user space, each element through its procedure. - fn path_for_all(&mut self) -> Res { - let close = self.pop_proc()?; - let curve = self.pop_proc()?; - let line = self.pop_proc()?; - let mv = self.pop_proc()?; - self.spend_elements(self.g.path.elements().len())?; - for (op, pts) in self.user_elements()? { - for p in pts { - self.push_num(p.x)?; - self.push_num(p.y)?; - } - let proc = match op { - Op::MoveTo => &mv, - Op::LineTo => &line, - Op::CurveTo => &curve, - _ => &close, - }; - if !self.body(proc)? { - break; - } - } - Ok(()) - } - - /// Append user path `up` to the current path: a procedure of path operators, or the encoded - /// form (numbers and a string of operator codes). - fn user_path(&mut self, up: Obj) -> Res { - let items = up.items().cloned().ok_or(PsError::Ps("typecheck", "a user path".into()))?; - let encoded = match (items.get(0), items.get(1), items.len()) { - (Some(nums @ (Obj::Array { .. } | Obj::Str(_))), Some(Obj::Str(ops)), 2) => Some((nums, ops.to_vec())), - _ => None, - }; - let Some((nums, ops)) = encoded else { return self.call(Obj::Array { items, exec: true }) }; - // The operators by code, with how many operands each takes. - const OPS: [(Op, usize); 12] = [ - (Op::SetBBox, 4), - (Op::MoveTo, 2), - (Op::RMoveTo, 2), - (Op::LineTo, 2), - (Op::RLineTo, 2), - (Op::CurveTo, 6), - (Op::RCurveTo, 6), - (Op::Arc, 5), - (Op::Arcn, 5), - (Op::Arct, 5), - (Op::ClosePath, 0), - (Op::UCache, 0), - ]; - let nums: Vec = match nums { - Obj::Array { items, .. } => { - // Each entry of the data array and each byte of the operator string counts as an - // element read. - self.spend_elements(items.len().saturating_add(ops.len()))?; - items.borrow().iter().filter_map(Obj::as_num).collect() - } - _ => return ps_err("typecheck", "an encoded user path"), - }; - let mut next = nums.into_iter(); - let mut repeat = 1usize; - for code in ops { - if code >= 32 { - repeat = usize::from(code - 32); - continue; - } - let &(op, n) = OPS.get(usize::from(code)).ok_or(PsError::Ps("rangecheck", "a user path".into()))?; - for _ in 0..std::mem::replace(&mut repeat, 1) { - for _ in 0..n { - let v = next.next().ok_or(PsError::Ps("rangecheck", "a user path".into()))?; - self.push_num(v)?; - } - // Each operator run counts as an operation. - self.spend(1)?; - self.op(op)?; - } - } - Ok(()) - } - - /// `ufill`, `ueofill`, `ustroke` (with an optional matrix): paint a user path, keeping the - /// current path. - fn paint_user_path(&mut self, op: Op) -> Res { - let m = match (op, self.stack.last()) { - (Op::UStroke, Some(Obj::Array { items, exec: false })) if items.len() == 6 && self.stack.len() >= 2 => Some(self.pop_matrix()?), - _ => None, - }; - let up = self.pop()?; - self.gsave()?; - let depth = self.saved.len(); - self.take_path(); - let r = self.user_path(up).and_then(|()| { - if let Some(m) = m { - self.g.ctm *= m; - } - let bp = self.take_path(); - match op { - Op::UStroke => self.stroke_path(bp), - _ => self.fill_path(bp, if op == Op::UEoFill { FillRule::EvenOdd } else { FillRule::NonZero }), - } - }); - self.saved.truncate(depth); - self.grestore(); - r - } - - /// `upath`: the current path as a user path (a procedure, in user space). - fn upath(&mut self) -> Res { - self.pop_bool()?; - let elements = self.user_elements()?; - let b = self.xf().inverse().transform_rect_bbox(self.g.path.bounding_box()); - let mut items: Vec = [b.x0, b.y0, b.x1, b.y1].into_iter().map(Obj::Real).collect(); - items.push(Obj::Op(Op::SetBBox)); - for (op, pts) in elements { - items.extend(pts.iter().flat_map(|p| [Obj::Real(p.x), Obj::Real(p.y)])); - items.push(Obj::Op(op)); - } - self.alloc(items.len() * std::mem::size_of::())?; - self.push(Obj::proc(items)) - } - - /// `execform`: the form's `PaintProc` run with its matrix, clipped to its box. - fn exec_form(&mut self) -> Res { - let d = self.pop_dict()?; - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let m = get("Matrix").and_then(|o| o.items().and_then(|i| super::interp::matrix_of(&i.borrow()))).unwrap_or(Affine::IDENTITY); - let bbox: Vec = get("BBox").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); - let paint = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; - self.gsave()?; - let depth = self.saved.len(); - self.g.ctm *= m; - if let [x0, y0, x1, y1] = bbox[..] { - let bp = self.rect_path(x0, y0, x1 - x0, y1 - y0); - self.take_path(); - self.clip_with(bp, FillRule::NonZero); - } - self.push(Obj::Dict(d))?; - let r = self.call(paint); - self.saved.truncate(depth); - self.grestore(); - r - } - - pub(super) fn take_path(&mut self) -> BezPath { - self.g.cur = None; - self.g.start = None; - std::mem::take(&mut self.g.path) - } - - fn clip_with(&mut self, path: BezPath, rule: FillRule) { - let Some(c) = self.out.clip(path, rule) else { return }; - if self.g.clips.len() < MAX_NEST { - self.g.clips.push(c); - } else { - self.out.warn(&deep_clips()); - } - } - - /// The area the clip leaves, in document space (the page without a clip). The clip path read - /// counts against the operation budget. - fn clip_bounds(&mut self) -> Res { - let (bounds, len) = - self.g.clips.iter().rev().find_map(|c| c.region.as_ref()).map_or((self.out.frame, 0), |(p, _)| (p.bounding_box(), p.elements().len())); - self.spend_elements(len)?; - Ok(bounds) - } - - // ---------- colour ---------- - - /// A colour space operand. - pub(super) fn space_of(&mut self, o: &Obj, depth: u32) -> Res { - if depth > 4 { - return ps_err("limitcheck", "setcolorspace"); - } - let (family, items) = match o { - Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]), - Obj::Array { items, .. } => { - let v = items.to_vec(); - let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?; - (n, v) - } - _ => return ps_err("typecheck", "setcolorspace"), - }; - let arg = |i: usize| items.get(i).cloned().ok_or(PsError::Ps("rangecheck", "setcolorspace".into())); - Ok(match &*family { - "DeviceGray" | "CIEBasedA" | "CalGray" => Space::Gray, - "DeviceRGB" | "CIEBasedABC" | "CalRGB" | "Lab" | "CIEBasedDEF" => Space::Rgb, - "DeviceCMYK" | "CIEBasedDEFG" => Space::Cmyk, - "Pattern" => Space::Pattern(match items.get(1) { - Some(base) => Some(Rc::new(self.space_of(base, depth + 1)?)), - None => None, - }), - "ICCBased" => match arg(1)? { - Obj::Dict(d) => match d.borrow().get(&Key::name("N")).and_then(Obj::as_num) { - Some(1.0) => Space::Gray, - Some(4.0) => Space::Cmyk, - _ => Space::Rgb, - }, - _ => Space::Rgb, - }, - "Separation" | "DeviceN" => { - let names: Vec> = match arg(1)? { - Obj::Array { items, .. } => items.borrow().iter().filter_map(Obj::text).collect(), - o => o.text().into_iter().collect(), - }; - if names.len() > MAX_INKS { - return ps_err("limitcheck", "DeviceN"); - } - let alt = self.space_of(&arg(2)?, depth + 1)?; - Space::Inks { names, alt: Rc::new(alt), tint: arg(3)? } - } - "Indexed" => { - let base = self.space_of(&arg(1)?, depth + 1)?; - let hival = - arg(2)?.as_num().filter(|v| (0.0..=MAX_HIVAL as f64).contains(v)).ok_or(PsError::Ps("rangecheck", "Indexed".into()))? as usize; - let n = base.n(); - let table: Vec = match arg(3)? { - Obj::Str(s) => s.borrow().iter().map(|b| f64::from(*b) / 255.0).collect(), - p @ Obj::Array { .. } => { - let mut t = Vec::with_capacity((hival + 1) * n); - for i in 0..=hival { - self.push(Obj::Int(i as i64))?; - self.call(p.clone())?; - let mut c = vec![0.0; n]; - for slot in c.iter_mut().rev() { - *slot = self.pop_num()?; - } - t.extend(c); - } - t - } - _ => return ps_err("typecheck", "Indexed"), - }; - Space::Indexed { base: Rc::new(base), table: Rc::new(table) } - } - _ => return ps_err("undefined", &family), - }) - } - - fn set_space(&mut self, o: Obj) -> Res { - let space = self.space_of(&o, 0)?; - let comps = space.initial(); - self.g.space = Rc::new(space); - self.g.space_obj = o; - self.set_comps(comps) - } - - /// Set the colour to `comps` in the current space. - fn set_comps(&mut self, comps: Vec) -> Res { - let space = self.g.space.clone(); - self.g.mesh = None; - self.g.paint = self.paint_of(&space, &comps)?; - self.g.comps = comps; - Ok(()) - } - - /// Set a process colour, its space with it. - fn set_process(&mut self, space: Space, name: &str, comps: Vec) -> Res { - self.g.space = Rc::new(space); - self.g.space_obj = Obj::name(name); - self.set_comps(comps) - } - - /// The paint of colour `comps` in `space`: a spot ink links to its swatch at the tint. - pub fn paint_of(&mut self, space: &Space, comps: &[f64]) -> Res { - Ok(match space { - Space::Inks { names, alt, tint } => { - let t = comps.first().copied().unwrap_or(1.0).clamp(0.0, 1.0) as f32; - match names.as_slice() { - [one] if &**one == "None" => Paint::None, - [one] if &**one == "All" => { - Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(t), swatch: Some(REGISTRATION.into()), tint: t } - } - [one] => { - let full = match self.out.spot_colors.get(one) { - Some(c) => *c, - None => { - let c = self.tint_color(alt, tint, &[1.0])?; - self.out.spots.push((one.to_string(), c)); - // The tint transform may have listed this ink already: the color - // listed first stays. - self.out.spot_colors.entry(one.clone()).or_insert(c); - c - } - }; - Paint::Solid { color: full.tinted(t), swatch: Some(one.to_string()), tint: t } - } - _ => Paint::solid(self.tint_color(alt, tint, comps)?), - } - } - Space::Indexed { base, table } => { - let n = base.n(); - let i = comps.first().copied().unwrap_or(0.0).round().max(0.0) as usize; - let c = table.get(i * n..i * n + n).ok_or(PsError::Ps("rangecheck", "setcolor".into()))?.to_vec(); - Paint::solid(process(base, &c).unwrap_or(Color::BLACK)) - } - Space::Pattern(_) => Paint::None, - s => Paint::solid(process(s, comps).unwrap_or(Color::BLACK)), - }) - } - - /// The alternate colour of inks at `comps`, through the tint transform. - pub fn tint_color(&mut self, alt: &Space, tint: &Obj, comps: &[f64]) -> Res { - let base = self.stack.len(); - for c in comps { - self.push_num(*c)?; - } - self.call(tint.clone())?; - let n = alt.n(); - let at = self.stack.len().checked_sub(n).filter(|a| *a >= base).ok_or(PsError::Ps("stackunderflow", "tint transform".into()))?; - let v: Vec = self.stack.split_off(at).iter().map(|o| o.as_num().unwrap_or(0.0)).collect(); - self.stack.truncate(base); - Ok(process(alt, &v).unwrap_or(Color::BLACK)) - } - - /// The current colour as grey, RGB or CMYK components (`currentgray` …). - fn current_rgb(&self) -> [f32; 3] { - self.g.paint.color().map_or([0.0; 3], |c| c.to_rgb_uncalibrated()) - } - - /// `setcolor` in a pattern space: the pattern operand, and before it an uncoloured - /// pattern's colour in the underlying space. - fn set_pattern(&mut self, base: Option>) -> Res { - let Obj::Dict(d) = self.pop()? else { return ps_err("typecheck", "") }; - let uncoloured = d.borrow().get(&Key::name("PaintType")).and_then(Obj::as_num) == Some(2.0); - let under = match base.filter(|_| uncoloured) { - Some(base) => { - let mut c = vec![0.0; base.n()]; - for slot in c.iter_mut().rev() { - *slot = self.pop_num()?; - } - Some(self.paint_of(&base, &c)?) - } - None => None, - }; - self.g.mesh = None; - self.g.paint = self.pattern_paint(&d, under)?; - Ok(()) - } - - /// A pattern colour: a shading pattern's gradient, a tiling pattern's swatch (`under`: the - /// colour an uncoloured one paints in). - fn pattern_paint(&mut self, d: &DictRef, under: Option) -> Res { - let kind = d.borrow().get(&Key::name("PatternType")).and_then(Obj::as_num); - let sh = d.borrow().get(&Key::name("Shading")).cloned(); - let m = d.borrow().get(&Key::name("VCMatrix")).and_then(|o| o.items().and_then(|i| super::interp::matrix_of(&i.borrow()))); - // Pattern space → the document. - let m = self.out.page * m.unwrap_or(self.g.ctm); - match (kind, sh) { - (Some(2.0), Some(Obj::Dict(sh))) if is_mesh(&sh) => { - self.g.mesh = Some((sh, m)); - Ok(Paint::None) - } - (Some(2.0), Some(Obj::Dict(sh))) => Ok(self.gradient(&sh, m)?.map_or(Paint::None, |g| Paint::Gradient(Box::new(g)))), - (Some(2.0), _) => ps_err("typecheck", "Shading"), - _ => self.tiling(d, under, m), - } - } - - /// A tiling pattern as a pattern swatch (made the first time): its `PaintProc` drawn in - /// pattern space, one cell of `XStep` × `YStep` from the corner of its `BBox`; `m` maps pattern - /// space onto the document. - fn tiling(&mut self, d: &DictRef, under: Option, m: Affine) -> Res { - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let bbox: Vec = get("BBox").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); - let [x0, y0, x1, y1] = bbox[..] else { return ps_err("rangecheck", "BBox") }; - let step = |k: &str| get(k).and_then(|o| o.as_num()).map(f64::abs).filter(|v| v.is_finite() && *v > 1e-9 && *v < LIMIT); - let (Some(xs), Some(ys)) = (step("XStep"), step("YStep")) else { return ps_err("rangecheck", "XStep") }; - let tile = Rect::from_origin_size((x0.min(x1), y0.min(y1)), (xs, ys)); - let xf = m * Affine::translate(tile.origin().to_vec2()); - let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; - let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; - let hash = tiling_hash(&key, tile, under.as_ref()); - let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); - if let Some((.., name)) = made { - return Ok(Paint::Pattern { pattern: name.clone(), xf }); - } - if self.out.apart >= MAX_APART { - self.out.warn(NESTED_PATTERNS); - return Ok(Paint::solid(Color::gray(0.5))); - } - let paint = under.clone(); - let art = self.draw_apart(Rect::new(x0, y0, x1, y1).abs(), |it| { - if let Some(p) = paint { - it.g.paint = p; - } - it.push(Obj::Dict(d.clone()))?; - it.call(proc) - })?; - if art.is_empty() { - return Ok(Paint::None); - } - // The document gets its patterns only here, so the n made so far are Pattern 1 to - // Pattern n. - let name = format!("Pattern {}", self.out.doc.patterns.len() + 1); - let mut def = PatternDef::new(&name, art); - def.tile = tile; - self.out.doc.patterns.push(def); - // A cell with a NaN corner equals no cell, so the lookup above could never find it. - if !tile.is_nan() { - self.out.tilings.entry(hash).or_insert_with(|| Vec::with_capacity(1)).push((key, tile, under, name.clone())); - } - Ok(Paint::Pattern { pattern: name, xf }) - } - - /// The art `draw` draws on its own, in its own space (user space is the document's, the page - /// `frame`), from a fresh graphics state with the current font: a pattern's cell, a glyph. - pub fn draw_apart(&mut self, frame: Rect, draw: impl FnOnce(&mut Self) -> Res) -> Res>> { - let fresh = GState { font: self.g.font.clone(), ..GState::default() }; - let g = std::mem::replace(&mut self.g, fresh); - let saved = std::mem::take(&mut self.saved); - let drawn = std::mem::take(&mut self.out.drawn); - let page = std::mem::replace(&mut self.out.page, Affine::IDENTITY); - let frame = std::mem::replace(&mut self.out.frame, frame); - let merge = self.out.merge.take(); - // The art isn't in the groups open around it. - let groups = std::mem::take(&mut self.out.groups); - let too_deep = std::mem::take(&mut self.out.too_deep); - self.out.apart += 1; - let r = draw(self); - self.out.apart -= 1; - let art = std::mem::replace(&mut self.out.drawn, drawn); - (self.out.page, self.out.frame, self.out.merge) = (page, frame, merge); - (self.out.groups, self.out.too_deep) = (groups, too_deep); - (self.g, self.saved) = (g, saved); - r?; - let mut nodes = vectorcraft_doc::clipnest::nest(&mut self.out.doc, art); - if !self.out.shadings.is_empty() { - collapse(&mut nodes, &self.out.shadings); - } - Ok(nodes) - } - - /// An axial or radial shading as a gradient, `m` mapping its space onto the document. - fn gradient(&mut self, sh: &DictRef, m: Affine) -> Res> { - let get = |k: &str| sh.borrow().get(&Key::name(k)).cloned(); - let kind = get("ShadingType").and_then(|o| o.as_num()).unwrap_or(0.0); - if !is_gradient(sh) { - self.out.warn(UNKNOWN_SHADINGS); - return Ok(None); - } - let space = self.space_of(&get("ColorSpace").ok_or(PsError::Ps("undefined", "ColorSpace".into()))?, 0)?; - let nums = |o: Option| -> Vec { - o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() - }; - let coords = nums(get("Coords")); - let domain = nums(get("Domain")); - let (d0, d1) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); - let f = get("Function").ok_or(PsError::Ps("undefined", "Function".into()))?; - // Where the colour changes: the ends, and the bounds of stitched linear functions (unless - // there are too many). - let mut visits = 0; - let points = linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS); - let stitched = points.is_some(); - let ts: Vec = match points { - Some(mut pts) => { - pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t)); - pts.iter().map(|t| if d1 != d0 { (t - d0) / (d1 - d0) } else { 0.0 }).chain([0.0, 1.0]).collect() - } - None => (0..=SHADING_SAMPLES).map(|i| i as f64 / SHADING_SAMPLES as f64).collect(), - }; - let mut ts = ts; - ts.sort_by(f64::total_cmp); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); - let radial = kind == 3.0; - let c = |i: usize| coords.get(i).copied().unwrap_or(0.0); - let (r0, r1) = (c(2).max(0.0), c(5).max(1e-6)); - let mut stops = Vec::with_capacity(ts.len()); - let stop = |paint: Paint, offset: f64| match paint { - Paint::Solid { color, swatch, tint } => GradientStop { swatch, tint, ..GradientStop::new(offset as f32, color) }, - _ => GradientStop::new(offset as f32, Color::BLACK), - }; - for t in ts { - let comps = self.eval(&f, &[d0 + (d1 - d0) * t], 0)?; - let paint = self.paint_of(&space, &comps)?; - let offset = if radial { (r0 + (r1 - r0) * t) / r1 } else { t }; - // At a bound of a stitched function the pieces need not meet (a hard colour change): - // the colour just before the bound is a stop at the same offset, ahead of the one - // after it. - if stitched && t > 0.0 && t < 1.0 { - let comps = self.eval(&f, &[d0 + (d1 - d0) * (t - 1e-9)], 0)?; - let before = stop(self.paint_of(&space, &comps)?, offset); - if before.color != stop(paint.clone(), offset).color { - stops.push(before); - } - } - stops.push(stop(paint, offset)); - } - let (gk, geom) = if radial { - let centre = Point::new(c(3), c(4)); - let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1, 0.0), aspect: 1.0, focal: None }; - geom.set_focal(Some(Point::new(c(0), c(1)))); - geom.transform(m, GradientKind::Radial); - (GradientKind::Radial, geom) - } else { - (GradientKind::Linear, GradientGeom { start: m * Point::new(c(0), c(1)), end: m * Point::new(c(2), c(3)), aspect: 1.0, focal: None }) - }; - let mut g = GradientPaint::new(Gradient::new(gk, stops)); - g.geom = Some(geom); - Ok(Some(g)) - } - - /// A function (types 0, 2 and 3; an array of them, one per component) at `x` (types 2 and 3 - /// take one input). - pub fn eval(&mut self, f: &Obj, x: &[f64], depth: u32) -> Res> { - self.eval_inner(f, x, depth, &mut 0) - } - - /// [`Self::eval`], with `visits` (shared by the whole recursion, incremented on every call) - /// counting the functions visited against [`MAX_FUNCTION_VISITS`], as [`linear_points`] counts - /// its nodes. The depth limit alone stops a function that refers to itself, but not one that - /// fans out through shared arrays: when four levels of arrays each have 100 entries that all - /// refer to the same array of the level below (at the last level, the same function), one - /// evaluation would visit 101,010,101 functions. Each function visited also counts - /// [`FUNCTION_OPS`] against the operation budget. - fn eval_inner(&mut self, f: &Obj, x: &[f64], depth: u32, visits: &mut usize) -> Res> { - *visits += 1; - if depth > 8 || *visits > MAX_FUNCTION_VISITS { - return ps_err("limitcheck", "Function"); - } - self.spend(FUNCTION_OPS)?; - let d = match f { - Obj::Dict(d) => d.clone(), - Obj::Array { items, .. } => { - // Each entry is at least one more function: an array with more entries than the - // visits left raises `limitcheck` before it is copied. - if items.len() > MAX_FUNCTION_VISITS - *visits { - return ps_err("limitcheck", "Function"); - } - let fs = items.to_vec(); - let mut out = vec![]; - for g in &fs { - out.extend(self.eval_inner(g, x, depth + 1, visits)?.first().copied()); - } - return Ok(out); - } - _ => return ps_err("typecheck", "Function"), - }; - let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); - let nums = |o: Option| -> Vec { - o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() - }; - let domain = nums(get("Domain")); - let t = x.first().copied().unwrap_or(0.0); - let t = match domain.as_slice() { - [a, b, ..] => clamp_between(t, *a, *b), - _ => t, - }; - match get("FunctionType").and_then(|o| o.as_num()) { - Some(0.0) => self.sampled(&d, x), - Some(2.0) => { - let c0 = nums(get("C0")); - let c1 = nums(get("C1")); - let (c0, c1) = (if c0.is_empty() { vec![0.0] } else { c0 }, if c1.is_empty() { vec![1.0] } else { c1 }); - let n = get("N").and_then(|o| o.as_num()).unwrap_or(1.0); - let x = t.max(0.0).powf(n); - Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect()) - } - Some(3.0) => { - // Read the pieces through the borrow and clone only the one we recurse into: a - // `Functions` array of millions (shared, or reached again through a cycle) must - // not be copied on every call and at every live recursion level. - let functions = get("Functions"); - let fs = functions.as_ref().and_then(Obj::items); - let n = fs.map_or(0, Shared::len); - let bounds = nums(get("Bounds")); - let encode = nums(get("Encode")); - let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); - let k = bounds.iter().take_while(|b| t >= **b).count().min(n.saturating_sub(1)); - let a = if k == 0 { lo } else { bounds.get(k - 1).copied().unwrap_or(lo) }; - let b = bounds.get(k).copied().unwrap_or(hi); - let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); - let u = if b != a { e0 + (t - a) / (b - a) * (e1 - e0) } else { e0 }; - let g = fs.and_then(|fs| fs.get(k)).ok_or(PsError::Ps("rangecheck", "Functions".into()))?; - self.eval_inner(&g, &[u], depth + 1, visits) - } - _ => { - self.out.warn(UNKNOWN_FUNCTIONS); - let r = nums(get("Range")); - Ok(r.chunks(2).map(|p| p.iter().sum::() / 2.0).collect()) - } - } - } - - // ---------- the operators ---------- - - pub fn graphics_op(&mut self, op: Op) -> Res { - use Op::*; - match op { - Gsave => self.gsave()?, - Grestore => self.grestore(), - GrestoreAll => { - if let Some(g) = self.saved.first().cloned() { - self.saved.clear(); - self.g = g; - } - } - InitGraphics => { - let font = self.g.font.take(); - self.g = GState { font, null: self.g.null, ..GState::default() }; - } - NullDevice => { - self.g.null = true; - self.g.ctm = Affine::IDENTITY; - self.g.clips.clear(); - } - ExecForm => self.exec_form()?, - StrokePath => { - let bp = self.take_path(); - let mut st = self.stroke_layer()?; - // A dash pattern far finer than the path would make countless pieces. - let period: f64 = st.dash.as_ref().map_or(0.0, |d| d.pattern.iter().sum()); - let periods = if st.dash.is_some() && period > 0.0 { bp.perimeter(1e-3) / period } else { 0.0 }; - if st.dash.is_some() && !(period > 0.0 && periods < MAX_DASHES) { - st.dash = None; - } - let tol = (0.01 / self.scale().max(1e-9)).clamp(1e-4, 1.0); - let outline = vectorcraft_effects::stroke::line_outline(&bp, &st, st.width, tol); - // The path, each entry of the dash pattern in each period, and the outline count - // as elements. - let dashes = st.dash.as_ref().map_or(0.0, |d| periods * d.pattern.len() as f64) as usize; - self.spend_elements(bp.elements().len().saturating_add(dashes).saturating_add(outline.elements().len()))?; - if outline.elements().len() >= MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - self.g.cur = outline.elements().iter().rev().find_map(|e| e.end_point()); - self.g.start = self.g.cur; - self.g.path = outline; - } - PathForAll => self.path_for_all()?, - UFill | UEoFill | UStroke => self.paint_user_path(op)?, - UAppend => { - let up = self.pop()?; - self.user_path(up)?; - } - UPath => self.upath()?, - SetBBox => { - self.nums::<4>()?; - } - CurrentHsbColor => { - let [r, g, b] = self.current_rgb(); - let (max, min) = (r.max(g).max(b), r.min(g).min(b)); - let d = max - min; - let h = if d <= 0.0 { - 0.0 - } else if max == r { - ((g - b) / d).rem_euclid(6.0) / 6.0 - } else if max == g { - ((b - r) / d + 2.0) / 6.0 - } else { - ((r - g) / d + 4.0) / 6.0 - }; - let s = if max > 0.0 { d / max } else { 0.0 }; - for v in [h, s, max] { - self.push_num(f64::from(v))?; - } - } - CurrentColorRendering => { - let d = super::obj::Dict::from([(Key::name("ColorRenderingType"), Obj::Int(1))]); - self.push(Obj::dict(d))?; - } - FindColorRendering => { - self.pop()?; - self.push(Obj::name("DefaultColorRendering"))?; - self.push(Obj::Bool(false))?; - } - CurrentColorScreen => { - for _ in 0..4 { - self.push(Obj::Real(60.0))?; - self.push(Obj::Real(45.0))?; - self.push(Obj::proc(vec![]))?; - } - } - CurrentSmoothness => self.push(Obj::Real(0.02))?, - SetHalftonePhase => { - self.nums::<2>()?; - } - CurrentHalftonePhase => { - self.push(Obj::Int(0))?; - self.push(Obj::Int(0))?; - } - GStateNew => { - self.spend_elements(self.g.path.elements().len())?; - self.alloc(std::mem::size_of_val(self.g.path.elements()))?; - self.push(Obj::GState(Rc::new(self.g.clone())))?; - } - CurrentGState => { - self.pop()?; - self.spend_elements(self.g.path.elements().len())?; - self.alloc(std::mem::size_of_val(self.g.path.elements()))?; - self.push(Obj::GState(Rc::new(self.g.clone())))?; - } - SetGState => match self.pop()? { - Obj::GState(g) => { - self.spend_elements(g.path.elements().len())?; - self.g = (*g).clone(); - } - _ => return ps_err("typecheck", ""), - }, - SetLineWidth => self.g.width = self.pop_num()?.abs(), - CurrentLineWidth => self.push_num(self.g.width)?, - SetLineCap => { - self.g.cap = match self.pop_int()? { - 1 => LineCap::Round, - 2 => LineCap::Square, - _ => LineCap::Butt, - } - } - CurrentLineCap => self.push(Obj::Int(self.g.cap as i64))?, - SetLineJoin => { - self.g.join = match self.pop_int()? { - 1 => LineJoin::Round, - 2 => LineJoin::Bevel, - _ => LineJoin::Miter, - } - } - CurrentLineJoin => self.push(Obj::Int(self.g.join as i64))?, - SetMiterLimit => self.g.miter = self.pop_num()?, - CurrentMiterLimit => self.push_num(self.g.miter)?, - SetDash => { - let offset = self.pop_num()?; - let items = self.pop_array()?; - if items.len() > MAX_DASH_ENTRIES { - return ps_err("limitcheck", ""); - } - // Each entry read counts as an operation. - self.spend(items.len() as u64)?; - let v: Option> = items.borrow().iter().map(|o| o.as_num().filter(|v| v.is_finite() && *v >= 0.0)).collect(); - self.g.dash = v.ok_or(PsError::Ps("typecheck", String::new()))?.into(); - self.g.dash_offset = offset; - } - CurrentDash => { - // The array made counts against the memory budget. - self.alloc(self.g.dash.len() * std::mem::size_of::())?; - let d = self.g.dash.iter().map(|v| Obj::Real(*v)).collect(); - self.push(Obj::array(d))?; - self.push_num(self.g.dash_offset)?; - } - SetGray => { - let [g] = self.nums()?; - self.set_process(Space::Gray, "DeviceGray", vec![g])?; - } - SetRgbColor => { - let c = self.nums::<3>()?; - self.set_process(Space::Rgb, "DeviceRGB", c.to_vec())?; - } - SetHsbColor => { - let [h, s, b] = self.nums()?; - let [r, g, bl] = - Color::from_hsb((h.rem_euclid(1.0) * 360.0) as f32, s.clamp(0.0, 1.0) as f32, b.clamp(0.0, 1.0) as f32).to_rgb_uncalibrated(); - self.set_process(Space::Rgb, "DeviceRGB", vec![f64::from(r), f64::from(g), f64::from(bl)])?; - } - SetCmykColor => { - let c = self.nums::<4>()?; - self.set_process(Space::Cmyk, "DeviceCMYK", c.to_vec())?; - } - CurrentGray => { - let [r, g, b] = self.current_rgb(); - self.push_num(f64::from(0.3 * r + 0.59 * g + 0.11 * b))?; - } - CurrentRgbColor => { - for v in self.current_rgb() { - self.push_num(f64::from(v))?; - } - } - CurrentCmykColor => { - let c = self.g.paint.color().map_or([0.0, 0.0, 0.0, 1.0], |c| c.to_cmyk()); - for v in c { - self.push_num(f64::from(v))?; - } - } - SetColorSpace => { - let o = self.pop()?; - self.set_space(o)?; - } - CurrentColorSpace => { - let o = match &self.g.space_obj { - o @ Obj::Array { .. } => o.clone(), - o => Obj::array(vec![o.clone()]), - }; - self.push(o)?; - } - SetColor => { - if let Space::Pattern(base) = &*self.g.space { - self.set_pattern(base.clone())?; - } else { - let n = self.g.space.n(); - let mut c = vec![0.0; n]; - for slot in c.iter_mut().rev() { - *slot = self.pop_num()?; - } - self.set_comps(c)?; - } - } - CurrentColor => { - for v in self.g.comps.clone() { - self.push_num(v)?; - } - } - // `[/Pattern ] setcolorspace setcolor`, unless in a pattern space already. - SetPattern => { - if !matches!(*self.g.space, Space::Pattern(_)) { - self.g.space = Rc::new(Space::Pattern(Some(self.g.space.clone()))); - self.g.space_obj = Obj::array(vec![Obj::name("Pattern"), self.g.space_obj.clone()]); - } - let Space::Pattern(base) = &*self.g.space else { return ps_err("typecheck", "") }; - self.set_pattern(base.clone())?; - } - MakePattern => { - let m = self.pop_matrix()?; - let d = self.pop_dict()?; - let mut copy = d.borrow().clone(); - copy.insert(Key::name("VCMatrix"), matrix_obj(self.g.ctm * m)); - self.push(Obj::dict(copy))?; - } - SetOverprint => self.g.overprint = self.pop_bool()?, - CurrentOverprint => self.push(Obj::Bool(self.g.overprint))?, - SetFlat | SetHalftone | SetTransfer | SetBlackGeneration | SetUnderColorRemoval | SetColorRendering | SetSmoothness | SetStrokeAdjust - | SetPageDevice | SetUserParams | SetSystemParams => { - self.pop()?; - } - SetScreen => { - for _ in 0..3 { - self.pop()?; - } - } - SetColorTransfer => { - for _ in 0..4 { - self.pop()?; - } - } - SetColorScreen => { - for _ in 0..12 { - self.pop()?; - } - } - SetCacheLimit => { - self.pop()?; - } - SetUCacheParams => { - let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; - self.stack.truncate(at); - } - UCache => {} - CurrentScreen => { - self.push(Obj::Real(60.0))?; - self.push(Obj::Real(45.0))?; - self.push(Obj::proc(vec![]))?; - } - CurrentTransfer | CurrentBlackGeneration | CurrentUnderColorRemoval => self.push(Obj::proc(vec![]))?, - CurrentColorTransfer => { - for _ in 0..4 { - self.push(Obj::proc(vec![]))?; - } - } - CurrentHalftone => { - let mut d = super::obj::Dict::new(); - d.insert(Key::name("HalftoneType"), Obj::Int(1)); - d.insert(Key::name("Frequency"), Obj::Real(60.0)); - d.insert(Key::name("Angle"), Obj::Real(45.0)); - d.insert(Key::name("SpotFunction"), Obj::proc(vec![])); - self.push(Obj::dict(d))?; - } - FindEncoding => { - self.pop()?; - self.push(Obj::array(vec![Obj::name(".notdef"); 256]))?; - } - CurrentFlat => self.push(Obj::Real(1.0))?, - CurrentStrokeAdjust => self.push(Obj::Bool(false))?, - CurrentPageDevice => { - let mut d = super::obj::Dict::new(); - let f = self.out.frame; - d.insert(Key::name("PageSize"), Obj::array(vec![Obj::Real(f.width()), Obj::Real(f.height())])); - self.push(Obj::dict(d))?; - } - // The cache and stack sizes programs read (they `get` them without asking first). - CurrentUserParams | CurrentSystemParams => { - let keys: &[&str] = if op == CurrentUserParams { - &["MaxFontItem", "MaxFormItem", "MaxPatternItem", "MaxUPathItem", "MaxOpStack", "MaxDictStack", "MaxExecStack"] - } else { - &["MaxFontCache", "MaxFormCache", "MaxPatternCache", "MaxUPathCache", "MaxScreenStorage", "MaxDisplayList"] - }; - let d: super::obj::Dict = keys.iter().map(|k| (Key::name(k), Obj::Int(1 << 20))).collect(); - self.push(Obj::dict(d))?; - } - ShowPage => return Err(PsError::Quit), - CopyPage | ErasePage => {} - Matrix => self.push(matrix_obj(Affine::IDENTITY))?, - IdentMatrix | DefaultMatrix => { - let items = self.pop_array()?; - self.put_matrix(items, Affine::IDENTITY)?; - } - CurrentMatrix => { - let items = self.pop_array()?; - self.put_matrix(items, self.g.ctm)?; - } - SetMatrix => self.g.ctm = self.pop_matrix()?, - InitMatrix => self.g.ctm = Affine::IDENTITY, - Concat => { - let m = self.pop_matrix()?; - self.g.ctm *= m; - } - ConcatMatrix => { - let out = self.pop_array()?; - let (m2, m1) = (self.pop_matrix()?, self.pop_matrix()?); - self.put_matrix(out, m2 * m1)?; - } - Translate | Scale | Rotate => { - let into = match self.stack.last() { - Some(Obj::Array { .. }) => Some(self.pop_array()?), - _ => None, - }; - let m = match op { - Rotate => Affine::rotate(self.pop_num()?.to_radians()), - Translate => { - let [x, y] = self.nums()?; - Affine::translate((x, y)) - } - _ => { - let [x, y] = self.nums()?; - Affine::scale_non_uniform(x, y) - } - }; - match into { - Some(items) => self.put_matrix(items, m)?, - None => self.g.ctm *= m, - } - } - Transform | ITransform | DTransform | IDTransform => { - let m = match self.stack.last() { - Some(Obj::Array { .. }) => self.pop_matrix()?, - _ => self.g.ctm, - }; - let [x, y] = self.nums()?; - let inverse = matches!(op, ITransform | IDTransform); - if inverse && m.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - let m = if inverse { m.inverse() } else { m }; - let p = if matches!(op, DTransform | IDTransform) { - let v = m * Point::new(x, y) - m * Point::ZERO; - Point::new(v.x, v.y) - } else { - m * Point::new(x, y) - }; - self.push_num(p.x)?; - self.push_num(p.y)?; - } - InvertMatrix => { - let out = self.pop_array()?; - let m = self.pop_matrix()?; - if m.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - self.put_matrix(out, m.inverse())?; - } - NewPath => { - self.take_path(); - } - CurrentPoint => { - let p = self.user_point()?; - self.push_num(p.x)?; - self.push_num(p.y)?; - } - MoveTo => { - let [x, y] = self.nums()?; - let p = self.xf() * Point::new(x, y); - self.move_to(p)?; - } - RMoveTo => { - let [dx, dy] = self.nums()?; - let p = self.user_point()? + Vec2::new(dx, dy); - let p = self.xf() * p; - self.move_to(p)?; - } - LineTo => { - let [x, y] = self.nums()?; - self.line_to(self.xf() * Point::new(x, y))?; - } - RLineTo => { - let [dx, dy] = self.nums()?; - let p = self.user_point()? + Vec2::new(dx, dy); - self.line_to(self.xf() * p)?; - } - CurveTo | RCurveTo => { - let v = self.nums::<6>()?; - let o = if op == RCurveTo { self.user_point()?.to_vec2() } else { Vec2::ZERO }; - if self.g.cur.is_none() { - return ps_err("nocurrentpoint", ""); - } - let xf = self.xf(); - let [a, b, p] = [0, 2, 4].map(|i| xf * (Point::new(v[i], v[i + 1]) + o)); - self.grow()?; - self.g.path.curve_to(a, b, p); - self.g.cur = Some(p); - } - Arc | Arcn => { - let [x, y, r, a1, a2] = self.nums()?; - self.arc(Point::new(x, y), r, a1, a2, op == Arc)?; - } - Arct | Arcto => { - let [x1, y1, x2, y2, r] = self.nums()?; - let [t1, t2] = self.arct(Point::new(x1, y1), Point::new(x2, y2), r)?; - if op == Arcto { - for v in [t1.x, t1.y, t2.x, t2.y] { - self.push_num(v)?; - } - } - } - ClosePath => { - if self.g.cur.is_some() { - self.grow()?; - self.g.path.close_path(); - self.g.cur = self.g.start; - } - } - FlattenPath | ReversePath => {} - PathBBox => { - if self.g.path.elements().is_empty() { - return ps_err("nocurrentpoint", ""); - } - let xf = self.xf(); - if xf.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - self.spend_elements(self.g.path.elements().len())?; - let b = xf.inverse().transform_rect_bbox(self.g.path.bounding_box()); - for v in [b.x0, b.y0, b.x1, b.y1] { - self.push_num(v)?; - } - } - ClipPath => { - let b = self.clip_bounds()?; - self.take_path(); - self.g.path = b.to_path(0.1); - self.g.cur = Some(b.origin()); - self.g.start = self.g.cur; - } - InitClip => self.g.clips.clear(), - ClipSave => { - if self.g.saved_clips.len() >= MAX_GSAVE { - return Err(PsError::Limit("too many saved clips")); - } - self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?; - let clips = self.g.clips.as_slice().into(); - self.g.saved_clips.push(clips); - } - ClipRestore => { - if let Some(clips) = self.g.saved_clips.pop() { - self.g.clips = clips.to_vec(); - } - } - Clip | EoClip => { - self.spend_elements(self.g.path.elements().len())?; - let bp = self.g.path.clone(); - self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero }); - } - RectClip => { - let bp = self.rects()?; - self.take_path(); - self.clip_with(bp, FillRule::NonZero); - } - Fill | EoFill => { - let bp = self.take_path(); - self.fill_path(bp, if op == EoFill { FillRule::EvenOdd } else { FillRule::NonZero })?; - } - Stroke => { - let bp = self.take_path(); - self.stroke_path(bp)?; - } - RectFill => { - let bp = self.rects()?; - self.fill_path(bp, FillRule::NonZero)?; - } - RectStroke => { - let m = match self.stack.last() { - Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?), - _ => None, - }; - let mut bp = self.rects()?; - if let Some(m) = m { - let xf = self.xf(); - bp.apply_affine(xf * m * xf.inverse()); - } - self.stroke_path(bp)?; - } - ShFill => { - let d = self.pop_dict()?; - if self.g.null { - return Ok(()); - } - if is_mesh(&d) { - let m = self.xf(); - return self.mesh_fill(&d, m); - } - let m = self.xf(); - if let Some(g) = self.gradient(&d, m)? { - let area = self.clip_bounds()?; - let paint = Paint::Gradient(Box::new(g)); - let clips = self.g.clips.clone(); - self.out.fill(area.to_path(0.1), FillRule::NonZero, paint, self.g.overprint, &clips); - if let Some((_, n)) = self.out.drawn.last() { - let id = n.id; - self.out.shadings.insert(id); - } - } - } - _ => self.data_op(op)?, - } - Ok(()) - } -} - -/// Is shading `sh` axial or radial (a gradient)? -fn is_gradient(sh: &DictRef) -> bool { - matches!(sh.borrow().get(&Key::name("ShadingType")).and_then(Obj::as_num), Some(2.0 | 3.0)) -} - -/// Is shading `sh` one [`Interp::mesh_fill`] paints? -fn is_mesh(sh: &DictRef) -> bool { - matches!(sh.borrow().get(&Key::name("ShadingType")).and_then(Obj::as_num), Some(1.0 | 4.0 | 5.0 | 6.0 | 7.0)) -} - -/// Where a stitched function of linear interpolations changes slope (its bounds), when it is -/// one; `None` for functions that must be sampled. `depth` bounds the nesting the same way -/// [`Interp::eval`] does (`> 8`) and `visits` (shared by the whole recursion, incremented on -/// every call) bounds the total nodes looked at. Both are needed: the depth limit alone stops a -/// function that refers to itself in a cycle, but not one that fans out through shared children -/// (1025 references to a child a few levels deep is 1025^levels nodes without the visit cap). A -/// well-formed stitch of linear pieces is one level, at most `MAX_STITCH_STOPS + 1` pieces, so it -/// costs at most `MAX_STITCH_STOPS + 2` visits and is never cut off. -fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option> { - *visits += 1; - if depth > 8 || *visits > 2 * (MAX_STITCH_STOPS + 1) { - return None; - } - let Obj::Dict(d) = f else { return None }; - let d = d.borrow(); - let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num); - match num("FunctionType")? { - 2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]), - 3.0 => { - let items = d.get(&Key::name("Functions"))?.items()?; - // More pieces than the stop cap allows: sample the function instead (tested before - // `to_vec`, so a huge `Functions` array is not copied only to be rejected). Each stop - // runs [`Interp::eval`], which reads one piece, so a short `Bounds` with a huge - // `Functions` would still be O(stops * Functions) to find each piece without this. - if items.len() > MAX_STITCH_STOPS + 1 { - return None; - } - let fs = items.to_vec(); - if !fs.iter().all(|g| linear_points(g, depth + 1, visits).is_some_and(|p| p.is_empty())) { - return None; - } - d.get(&Key::name("Bounds"))?.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect()) - } - _ => None, - } -} - -/// The clip path a clipping group of one shading rectangle (`shfill` through a clip) can be -/// filled with instead. -pub(crate) fn collapse(nodes: &mut [Arc], shadings: &HashSet) { - for n in nodes.iter_mut() { - let NodeKind::Group { children, clip: true } = &n.kind else { - if let Some(c) = Arc::make_mut(n).children_mut() { - collapse(c, shadings); - } - continue; - }; - if let [clip, fill] = children.as_slice() - && shadings.contains(&fill.id) - && let (NodeKind::Path { path, rule, clipping: true, .. }, NodeKind::Path { .. }) = (&clip.kind, &fill.kind) - { - let mut p = Node::path(fill.id, path.clone(), (*fill.appearance).clone()); - if let NodeKind::Path { rule: r, .. } = &mut p.kind { - *r = *rule; - } - *n = Arc::new(p); - continue; - } - if let Some(c) = Arc::make_mut(n).children_mut() { - collapse(c, shadings); - } - } -} diff --git a/crates/eps/src/import/graphics/ops.rs b/crates/eps/src/import/graphics/ops.rs new file mode 100644 index 000000000..608a62413 --- /dev/null +++ b/crates/eps/src/import/graphics/ops.rs @@ -0,0 +1,533 @@ +use super::paint::is_mesh; +use super::state::{GState, Space}; +use super::*; + +impl Interp<'_> { + // ---------- the operators ---------- + + pub fn graphics_op(&mut self, op: Op) -> Res { + use Op::*; + match op { + Gsave => self.gsave()?, + Grestore => self.grestore(), + GrestoreAll => { + if let Some(g) = self.saved.first().cloned() { + self.saved.clear(); + self.g = g; + } + } + InitGraphics => { + let font = self.g.font.take(); + self.g = GState { font, null: self.g.null, ..GState::default() }; + } + NullDevice => { + self.g.null = true; + self.g.ctm = Affine::IDENTITY; + self.g.clips.clear(); + } + ExecForm => self.exec_form()?, + StrokePath => { + let bp = self.take_path(); + let mut st = self.stroke_layer()?; + // A dash pattern far finer than the path would make countless pieces. + let period: f64 = st.dash.as_ref().map_or(0.0, |d| d.pattern.iter().sum()); + let periods = if st.dash.is_some() && period > 0.0 { bp.perimeter(1e-3) / period } else { 0.0 }; + if st.dash.is_some() && !(period > 0.0 && periods < MAX_DASHES) { + st.dash = None; + } + let tol = (0.01 / self.scale().max(1e-9)).clamp(1e-4, 1.0); + let outline = vectorcraft_effects::stroke::line_outline(&bp, &st, st.width, tol); + // The path, each entry of the dash pattern in each period, and the outline count + // as elements. + let dashes = st.dash.as_ref().map_or(0.0, |d| periods * d.pattern.len() as f64) as usize; + self.spend_elements(bp.elements().len().saturating_add(dashes).saturating_add(outline.elements().len()))?; + if outline.elements().len() >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + self.g.cur = outline.elements().iter().rev().find_map(|e| e.end_point()); + self.g.start = self.g.cur; + self.g.path = outline; + } + PathForAll => self.path_for_all()?, + UFill | UEoFill | UStroke => self.paint_user_path(op)?, + UAppend => { + let up = self.pop()?; + self.user_path(up)?; + } + UPath => self.upath()?, + SetBBox => { + self.nums::<4>()?; + } + CurrentHsbColor => { + let [r, g, b] = self.current_rgb(); + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let d = max - min; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + ((g - b) / d).rem_euclid(6.0) / 6.0 + } else if max == g { + ((b - r) / d + 2.0) / 6.0 + } else { + ((r - g) / d + 4.0) / 6.0 + }; + let s = if max > 0.0 { d / max } else { 0.0 }; + for v in [h, s, max] { + self.push_num(f64::from(v))?; + } + } + CurrentColorRendering => { + let d = crate::import::obj::Dict::from([(Key::name("ColorRenderingType"), Obj::Int(1))]); + self.push(Obj::dict(d))?; + } + FindColorRendering => { + self.pop()?; + self.push(Obj::name("DefaultColorRendering"))?; + self.push(Obj::Bool(false))?; + } + CurrentColorScreen => { + for _ in 0..4 { + self.push(Obj::Real(60.0))?; + self.push(Obj::Real(45.0))?; + self.push(Obj::proc(vec![]))?; + } + } + CurrentSmoothness => self.push(Obj::Real(0.02))?, + SetHalftonePhase => { + self.nums::<2>()?; + } + CurrentHalftonePhase => { + self.push(Obj::Int(0))?; + self.push(Obj::Int(0))?; + } + GStateNew => { + self.spend_elements(self.g.path.elements().len())?; + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; + self.push(Obj::GState(Rc::new(self.g.clone())))?; + } + CurrentGState => { + self.pop()?; + self.spend_elements(self.g.path.elements().len())?; + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; + self.push(Obj::GState(Rc::new(self.g.clone())))?; + } + SetGState => match self.pop()? { + Obj::GState(g) => { + self.spend_elements(g.path.elements().len())?; + self.g = (*g).clone(); + } + _ => return ps_err("typecheck", ""), + }, + SetLineWidth => self.g.width = self.pop_num()?.abs(), + CurrentLineWidth => self.push_num(self.g.width)?, + SetLineCap => { + self.g.cap = match self.pop_int()? { + 1 => LineCap::Round, + 2 => LineCap::Square, + _ => LineCap::Butt, + } + } + CurrentLineCap => self.push(Obj::Int(self.g.cap as i64))?, + SetLineJoin => { + self.g.join = match self.pop_int()? { + 1 => LineJoin::Round, + 2 => LineJoin::Bevel, + _ => LineJoin::Miter, + } + } + CurrentLineJoin => self.push(Obj::Int(self.g.join as i64))?, + SetMiterLimit => self.g.miter = self.pop_num()?, + CurrentMiterLimit => self.push_num(self.g.miter)?, + SetDash => { + let offset = self.pop_num()?; + let items = self.pop_array()?; + if items.len() > MAX_DASH_ENTRIES { + return ps_err("limitcheck", ""); + } + // Each entry read counts as an operation. + self.spend(items.len() as u64)?; + let v: Option> = items.borrow().iter().map(|o| o.as_num().filter(|v| v.is_finite() && *v >= 0.0)).collect(); + self.g.dash = v.ok_or(PsError::Ps("typecheck", String::new()))?.into(); + self.g.dash_offset = offset; + } + CurrentDash => { + // The array made counts against the memory budget. + self.alloc(self.g.dash.len() * std::mem::size_of::())?; + let d = self.g.dash.iter().map(|v| Obj::Real(*v)).collect(); + self.push(Obj::array(d))?; + self.push_num(self.g.dash_offset)?; + } + SetGray => { + let [g] = self.nums()?; + self.set_process(Space::Gray, "DeviceGray", vec![g])?; + } + SetRgbColor => { + let c = self.nums::<3>()?; + self.set_process(Space::Rgb, "DeviceRGB", c.to_vec())?; + } + SetHsbColor => { + let [h, s, b] = self.nums()?; + let [r, g, bl] = + Color::from_hsb((h.rem_euclid(1.0) * 360.0) as f32, s.clamp(0.0, 1.0) as f32, b.clamp(0.0, 1.0) as f32).to_rgb_uncalibrated(); + self.set_process(Space::Rgb, "DeviceRGB", vec![f64::from(r), f64::from(g), f64::from(bl)])?; + } + SetCmykColor => { + let c = self.nums::<4>()?; + self.set_process(Space::Cmyk, "DeviceCMYK", c.to_vec())?; + } + CurrentGray => { + let [r, g, b] = self.current_rgb(); + self.push_num(f64::from(0.3 * r + 0.59 * g + 0.11 * b))?; + } + CurrentRgbColor => { + for v in self.current_rgb() { + self.push_num(f64::from(v))?; + } + } + CurrentCmykColor => { + let c = self.g.paint.color().map_or([0.0, 0.0, 0.0, 1.0], |c| c.to_cmyk()); + for v in c { + self.push_num(f64::from(v))?; + } + } + SetColorSpace => { + let o = self.pop()?; + self.set_space(o)?; + } + CurrentColorSpace => { + let o = match &self.g.space_obj { + o @ Obj::Array { .. } => o.clone(), + o => Obj::array(vec![o.clone()]), + }; + self.push(o)?; + } + SetColor => { + if let Space::Pattern(base) = &*self.g.space { + self.set_pattern(base.clone())?; + } else { + let n = self.g.space.n(); + let mut c = vec![0.0; n]; + for slot in c.iter_mut().rev() { + *slot = self.pop_num()?; + } + self.set_comps(c)?; + } + } + CurrentColor => { + for v in self.g.comps.clone() { + self.push_num(v)?; + } + } + // `[/Pattern ] setcolorspace setcolor`, unless in a pattern space already. + SetPattern => { + if !matches!(*self.g.space, Space::Pattern(_)) { + self.g.space = Rc::new(Space::Pattern(Some(self.g.space.clone()))); + self.g.space_obj = Obj::array(vec![Obj::name("Pattern"), self.g.space_obj.clone()]); + } + let Space::Pattern(base) = &*self.g.space else { return ps_err("typecheck", "") }; + self.set_pattern(base.clone())?; + } + MakePattern => { + let m = self.pop_matrix()?; + let d = self.pop_dict()?; + let mut copy = d.borrow().clone(); + copy.insert(Key::name("VCMatrix"), matrix_obj(self.g.ctm * m)); + self.push(Obj::dict(copy))?; + } + SetOverprint => self.g.overprint = self.pop_bool()?, + CurrentOverprint => self.push(Obj::Bool(self.g.overprint))?, + SetFlat | SetHalftone | SetTransfer | SetBlackGeneration | SetUnderColorRemoval | SetColorRendering | SetSmoothness | SetStrokeAdjust + | SetPageDevice | SetUserParams | SetSystemParams => { + self.pop()?; + } + SetScreen => { + for _ in 0..3 { + self.pop()?; + } + } + SetColorTransfer => { + for _ in 0..4 { + self.pop()?; + } + } + SetColorScreen => { + for _ in 0..12 { + self.pop()?; + } + } + SetCacheLimit => { + self.pop()?; + } + SetUCacheParams => { + let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; + self.stack.truncate(at); + } + UCache => {} + CurrentScreen => { + self.push(Obj::Real(60.0))?; + self.push(Obj::Real(45.0))?; + self.push(Obj::proc(vec![]))?; + } + CurrentTransfer | CurrentBlackGeneration | CurrentUnderColorRemoval => self.push(Obj::proc(vec![]))?, + CurrentColorTransfer => { + for _ in 0..4 { + self.push(Obj::proc(vec![]))?; + } + } + CurrentHalftone => { + let mut d = crate::import::obj::Dict::new(); + d.insert(Key::name("HalftoneType"), Obj::Int(1)); + d.insert(Key::name("Frequency"), Obj::Real(60.0)); + d.insert(Key::name("Angle"), Obj::Real(45.0)); + d.insert(Key::name("SpotFunction"), Obj::proc(vec![])); + self.push(Obj::dict(d))?; + } + FindEncoding => { + self.pop()?; + self.push(Obj::array(vec![Obj::name(".notdef"); 256]))?; + } + CurrentFlat => self.push(Obj::Real(1.0))?, + CurrentStrokeAdjust => self.push(Obj::Bool(false))?, + CurrentPageDevice => { + let mut d = crate::import::obj::Dict::new(); + let f = self.out.frame; + d.insert(Key::name("PageSize"), Obj::array(vec![Obj::Real(f.width()), Obj::Real(f.height())])); + self.push(Obj::dict(d))?; + } + // The cache and stack sizes programs read (they `get` them without asking first). + CurrentUserParams | CurrentSystemParams => { + let keys: &[&str] = if op == CurrentUserParams { + &["MaxFontItem", "MaxFormItem", "MaxPatternItem", "MaxUPathItem", "MaxOpStack", "MaxDictStack", "MaxExecStack"] + } else { + &["MaxFontCache", "MaxFormCache", "MaxPatternCache", "MaxUPathCache", "MaxScreenStorage", "MaxDisplayList"] + }; + let d: crate::import::obj::Dict = keys.iter().map(|k| (Key::name(k), Obj::Int(1 << 20))).collect(); + self.push(Obj::dict(d))?; + } + ShowPage => return Err(PsError::Quit), + CopyPage | ErasePage => {} + Matrix => self.push(matrix_obj(Affine::IDENTITY))?, + IdentMatrix | DefaultMatrix => { + let items = self.pop_array()?; + self.put_matrix(items, Affine::IDENTITY)?; + } + CurrentMatrix => { + let items = self.pop_array()?; + self.put_matrix(items, self.g.ctm)?; + } + SetMatrix => self.g.ctm = self.pop_matrix()?, + InitMatrix => self.g.ctm = Affine::IDENTITY, + Concat => { + let m = self.pop_matrix()?; + self.g.ctm *= m; + } + ConcatMatrix => { + let out = self.pop_array()?; + let (m2, m1) = (self.pop_matrix()?, self.pop_matrix()?); + self.put_matrix(out, m2 * m1)?; + } + Translate | Scale | Rotate => { + let into = match self.stack.last() { + Some(Obj::Array { .. }) => Some(self.pop_array()?), + _ => None, + }; + let m = match op { + Rotate => Affine::rotate(self.pop_num()?.to_radians()), + Translate => { + let [x, y] = self.nums()?; + Affine::translate((x, y)) + } + _ => { + let [x, y] = self.nums()?; + Affine::scale_non_uniform(x, y) + } + }; + match into { + Some(items) => self.put_matrix(items, m)?, + None => self.g.ctm *= m, + } + } + Transform | ITransform | DTransform | IDTransform => { + let m = match self.stack.last() { + Some(Obj::Array { .. }) => self.pop_matrix()?, + _ => self.g.ctm, + }; + let [x, y] = self.nums()?; + let inverse = matches!(op, ITransform | IDTransform); + if inverse && m.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + let m = if inverse { m.inverse() } else { m }; + let p = if matches!(op, DTransform | IDTransform) { + let v = m * Point::new(x, y) - m * Point::ZERO; + Point::new(v.x, v.y) + } else { + m * Point::new(x, y) + }; + self.push_num(p.x)?; + self.push_num(p.y)?; + } + InvertMatrix => { + let out = self.pop_array()?; + let m = self.pop_matrix()?; + if m.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + self.put_matrix(out, m.inverse())?; + } + NewPath => { + self.take_path(); + } + CurrentPoint => { + let p = self.user_point()?; + self.push_num(p.x)?; + self.push_num(p.y)?; + } + MoveTo => { + let [x, y] = self.nums()?; + let p = self.xf() * Point::new(x, y); + self.move_to(p)?; + } + RMoveTo => { + let [dx, dy] = self.nums()?; + let p = self.user_point()? + Vec2::new(dx, dy); + let p = self.xf() * p; + self.move_to(p)?; + } + LineTo => { + let [x, y] = self.nums()?; + self.line_to(self.xf() * Point::new(x, y))?; + } + RLineTo => { + let [dx, dy] = self.nums()?; + let p = self.user_point()? + Vec2::new(dx, dy); + self.line_to(self.xf() * p)?; + } + CurveTo | RCurveTo => { + let v = self.nums::<6>()?; + let o = if op == RCurveTo { self.user_point()?.to_vec2() } else { Vec2::ZERO }; + if self.g.cur.is_none() { + return ps_err("nocurrentpoint", ""); + } + let xf = self.xf(); + let [a, b, p] = [0, 2, 4].map(|i| xf * (Point::new(v[i], v[i + 1]) + o)); + self.grow()?; + self.g.path.curve_to(a, b, p); + self.g.cur = Some(p); + } + Arc | Arcn => { + let [x, y, r, a1, a2] = self.nums()?; + self.arc(Point::new(x, y), r, a1, a2, op == Arc)?; + } + Arct | Arcto => { + let [x1, y1, x2, y2, r] = self.nums()?; + let [t1, t2] = self.arct(Point::new(x1, y1), Point::new(x2, y2), r)?; + if op == Arcto { + for v in [t1.x, t1.y, t2.x, t2.y] { + self.push_num(v)?; + } + } + } + ClosePath => { + if self.g.cur.is_some() { + self.grow()?; + self.g.path.close_path(); + self.g.cur = self.g.start; + } + } + FlattenPath | ReversePath => {} + PathBBox => { + if self.g.path.elements().is_empty() { + return ps_err("nocurrentpoint", ""); + } + let xf = self.xf(); + if xf.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + self.spend_elements(self.g.path.elements().len())?; + let b = xf.inverse().transform_rect_bbox(self.g.path.bounding_box()); + for v in [b.x0, b.y0, b.x1, b.y1] { + self.push_num(v)?; + } + } + ClipPath => { + let b = self.clip_bounds()?; + self.take_path(); + self.g.path = b.to_path(0.1); + self.g.cur = Some(b.origin()); + self.g.start = self.g.cur; + } + InitClip => self.g.clips.clear(), + ClipSave => { + if self.g.saved_clips.len() >= MAX_GSAVE { + return Err(PsError::Limit("too many saved clips")); + } + self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?; + let clips = self.g.clips.as_slice().into(); + self.g.saved_clips.push(clips); + } + ClipRestore => { + if let Some(clips) = self.g.saved_clips.pop() { + self.g.clips = clips.to_vec(); + } + } + Clip | EoClip => { + self.spend_elements(self.g.path.elements().len())?; + let bp = self.g.path.clone(); + self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero }); + } + RectClip => { + let bp = self.rects()?; + self.take_path(); + self.clip_with(bp, FillRule::NonZero); + } + Fill | EoFill => { + let bp = self.take_path(); + self.fill_path(bp, if op == EoFill { FillRule::EvenOdd } else { FillRule::NonZero })?; + } + Stroke => { + let bp = self.take_path(); + self.stroke_path(bp)?; + } + RectFill => { + let bp = self.rects()?; + self.fill_path(bp, FillRule::NonZero)?; + } + RectStroke => { + let m = match self.stack.last() { + Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?), + _ => None, + }; + let mut bp = self.rects()?; + if let Some(m) = m { + let xf = self.xf(); + bp.apply_affine(xf * m * xf.inverse()); + } + self.stroke_path(bp)?; + } + ShFill => { + let d = self.pop_dict()?; + if self.g.null { + return Ok(()); + } + if is_mesh(&d) { + let m = self.xf(); + return self.mesh_fill(&d, m); + } + let m = self.xf(); + if let Some(g) = self.gradient(&d, m)? { + let area = self.clip_bounds()?; + let paint = Paint::Gradient(Box::new(g)); + let clips = self.g.clips.clone(); + self.out.fill(area.to_path(0.1), FillRule::NonZero, paint, self.g.overprint, &clips); + if let Some((_, n)) = self.out.drawn.last() { + let id = n.id; + self.out.shadings.insert(id); + } + } + } + _ => self.data_op(op)?, + } + Ok(()) + } +} diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs new file mode 100644 index 000000000..f2e343858 --- /dev/null +++ b/crates/eps/src/import/graphics/paint.rs @@ -0,0 +1,495 @@ +use super::state::{GState, Space, process, tiling_hash}; +use super::*; + +impl Interp<'_> { + // ---------- colour ---------- + + /// A colour space operand. + pub(in super::super) fn space_of(&mut self, o: &Obj, depth: u32) -> Res { + if depth > 4 { + return ps_err("limitcheck", "setcolorspace"); + } + let (family, items) = match o { + Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]), + Obj::Array { items, .. } => { + let v = items.to_vec(); + let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?; + (n, v) + } + _ => return ps_err("typecheck", "setcolorspace"), + }; + let arg = |i: usize| items.get(i).cloned().ok_or(PsError::Ps("rangecheck", "setcolorspace".into())); + Ok(match &*family { + "DeviceGray" | "CIEBasedA" | "CalGray" => Space::Gray, + "DeviceRGB" | "CIEBasedABC" | "CalRGB" | "Lab" | "CIEBasedDEF" => Space::Rgb, + "DeviceCMYK" | "CIEBasedDEFG" => Space::Cmyk, + "Pattern" => Space::Pattern(match items.get(1) { + Some(base) => Some(Rc::new(self.space_of(base, depth + 1)?)), + None => None, + }), + "ICCBased" => match arg(1)? { + Obj::Dict(d) => match d.borrow().get(&Key::name("N")).and_then(Obj::as_num) { + Some(1.0) => Space::Gray, + Some(4.0) => Space::Cmyk, + _ => Space::Rgb, + }, + _ => Space::Rgb, + }, + "Separation" | "DeviceN" => { + let names: Vec> = match arg(1)? { + Obj::Array { items, .. } => items.borrow().iter().filter_map(Obj::text).collect(), + o => o.text().into_iter().collect(), + }; + if names.len() > MAX_INKS { + return ps_err("limitcheck", "DeviceN"); + } + let alt = self.space_of(&arg(2)?, depth + 1)?; + Space::Inks { names, alt: Rc::new(alt), tint: arg(3)? } + } + "Indexed" => { + let base = self.space_of(&arg(1)?, depth + 1)?; + let hival = + arg(2)?.as_num().filter(|v| (0.0..=MAX_HIVAL as f64).contains(v)).ok_or(PsError::Ps("rangecheck", "Indexed".into()))? as usize; + let n = base.n(); + let table: Vec = match arg(3)? { + Obj::Str(s) => s.borrow().iter().map(|b| f64::from(*b) / 255.0).collect(), + p @ Obj::Array { .. } => { + let mut t = Vec::with_capacity((hival + 1) * n); + for i in 0..=hival { + self.push(Obj::Int(i as i64))?; + self.call(p.clone())?; + let mut c = vec![0.0; n]; + for slot in c.iter_mut().rev() { + *slot = self.pop_num()?; + } + t.extend(c); + } + t + } + _ => return ps_err("typecheck", "Indexed"), + }; + Space::Indexed { base: Rc::new(base), table: Rc::new(table) } + } + _ => return ps_err("undefined", &family), + }) + } + + pub(super) fn set_space(&mut self, o: Obj) -> Res { + let space = self.space_of(&o, 0)?; + let comps = space.initial(); + self.g.space = Rc::new(space); + self.g.space_obj = o; + self.set_comps(comps) + } + + /// Set the colour to `comps` in the current space. + pub(super) fn set_comps(&mut self, comps: Vec) -> Res { + let space = self.g.space.clone(); + self.g.mesh = None; + self.g.paint = self.paint_of(&space, &comps)?; + self.g.comps = comps; + Ok(()) + } + + /// Set a process colour, its space with it. + pub(super) fn set_process(&mut self, space: Space, name: &str, comps: Vec) -> Res { + self.g.space = Rc::new(space); + self.g.space_obj = Obj::name(name); + self.set_comps(comps) + } + + /// The paint of colour `comps` in `space`: a spot ink links to its swatch at the tint. + pub fn paint_of(&mut self, space: &Space, comps: &[f64]) -> Res { + Ok(match space { + Space::Inks { names, alt, tint } => { + let t = comps.first().copied().unwrap_or(1.0).clamp(0.0, 1.0) as f32; + match names.as_slice() { + [one] if &**one == "None" => Paint::None, + [one] if &**one == "All" => { + Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(t), swatch: Some(REGISTRATION.into()), tint: t } + } + [one] => { + let full = match self.out.spot_colors.get(one) { + Some(c) => *c, + None => { + let c = self.tint_color(alt, tint, &[1.0])?; + self.out.spots.push((one.to_string(), c)); + // The tint transform may have listed this ink already: the color + // listed first stays. + self.out.spot_colors.entry(one.clone()).or_insert(c); + c + } + }; + Paint::Solid { color: full.tinted(t), swatch: Some(one.to_string()), tint: t } + } + _ => Paint::solid(self.tint_color(alt, tint, comps)?), + } + } + Space::Indexed { base, table } => { + let n = base.n(); + let i = comps.first().copied().unwrap_or(0.0).round().max(0.0) as usize; + let c = table.get(i * n..i * n + n).ok_or(PsError::Ps("rangecheck", "setcolor".into()))?.to_vec(); + Paint::solid(process(base, &c).unwrap_or(Color::BLACK)) + } + Space::Pattern(_) => Paint::None, + s => Paint::solid(process(s, comps).unwrap_or(Color::BLACK)), + }) + } + + /// The alternate colour of inks at `comps`, through the tint transform. + pub fn tint_color(&mut self, alt: &Space, tint: &Obj, comps: &[f64]) -> Res { + let base = self.stack.len(); + for c in comps { + self.push_num(*c)?; + } + self.call(tint.clone())?; + let n = alt.n(); + let at = self.stack.len().checked_sub(n).filter(|a| *a >= base).ok_or(PsError::Ps("stackunderflow", "tint transform".into()))?; + let v: Vec = self.stack.split_off(at).iter().map(|o| o.as_num().unwrap_or(0.0)).collect(); + self.stack.truncate(base); + Ok(process(alt, &v).unwrap_or(Color::BLACK)) + } + + /// The current colour as grey, RGB or CMYK components (`currentgray` …). + pub(super) fn current_rgb(&self) -> [f32; 3] { + self.g.paint.color().map_or([0.0; 3], |c| c.to_rgb_uncalibrated()) + } + + /// `setcolor` in a pattern space: the pattern operand, and before it an uncoloured + /// pattern's colour in the underlying space. + pub(super) fn set_pattern(&mut self, base: Option>) -> Res { + let Obj::Dict(d) = self.pop()? else { return ps_err("typecheck", "") }; + let uncoloured = d.borrow().get(&Key::name("PaintType")).and_then(Obj::as_num) == Some(2.0); + let under = match base.filter(|_| uncoloured) { + Some(base) => { + let mut c = vec![0.0; base.n()]; + for slot in c.iter_mut().rev() { + *slot = self.pop_num()?; + } + Some(self.paint_of(&base, &c)?) + } + None => None, + }; + self.g.mesh = None; + self.g.paint = self.pattern_paint(&d, under)?; + Ok(()) + } + + /// A pattern colour: a shading pattern's gradient, a tiling pattern's swatch (`under`: the + /// colour an uncoloured one paints in). + fn pattern_paint(&mut self, d: &DictRef, under: Option) -> Res { + let kind = d.borrow().get(&Key::name("PatternType")).and_then(Obj::as_num); + let sh = d.borrow().get(&Key::name("Shading")).cloned(); + let m = d.borrow().get(&Key::name("VCMatrix")).and_then(|o| o.items().and_then(|i| crate::import::interp::matrix_of(&i.borrow()))); + // Pattern space → the document. + let m = self.out.page * m.unwrap_or(self.g.ctm); + match (kind, sh) { + (Some(2.0), Some(Obj::Dict(sh))) if is_mesh(&sh) => { + self.g.mesh = Some((sh, m)); + Ok(Paint::None) + } + (Some(2.0), Some(Obj::Dict(sh))) => Ok(self.gradient(&sh, m)?.map_or(Paint::None, |g| Paint::Gradient(Box::new(g)))), + (Some(2.0), _) => ps_err("typecheck", "Shading"), + _ => self.tiling(d, under, m), + } + } + + /// A tiling pattern as a pattern swatch (made the first time): its `PaintProc` drawn in + /// pattern space, one cell of `XStep` × `YStep` from the corner of its `BBox`; `m` maps pattern + /// space onto the document. + fn tiling(&mut self, d: &DictRef, under: Option, m: Affine) -> Res { + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let bbox: Vec = get("BBox").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); + let [x0, y0, x1, y1] = bbox[..] else { return ps_err("rangecheck", "BBox") }; + let step = |k: &str| get(k).and_then(|o| o.as_num()).map(f64::abs).filter(|v| v.is_finite() && *v > 1e-9 && *v < LIMIT); + let (Some(xs), Some(ys)) = (step("XStep"), step("YStep")) else { return ps_err("rangecheck", "XStep") }; + let tile = Rect::from_origin_size((x0.min(x1), y0.min(y1)), (xs, ys)); + let xf = m * Affine::translate(tile.origin().to_vec2()); + let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; + let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; + let hash = tiling_hash(&key, tile, under.as_ref()); + let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); + if let Some((.., name)) = made { + return Ok(Paint::Pattern { pattern: name.clone(), xf }); + } + if self.out.apart >= MAX_APART { + self.out.warn(NESTED_PATTERNS); + return Ok(Paint::solid(Color::gray(0.5))); + } + let paint = under.clone(); + let art = self.draw_apart(Rect::new(x0, y0, x1, y1).abs(), |it| { + if let Some(p) = paint { + it.g.paint = p; + } + it.push(Obj::Dict(d.clone()))?; + it.call(proc) + })?; + if art.is_empty() { + return Ok(Paint::None); + } + // The document gets its patterns only here, so the n made so far are Pattern 1 to + // Pattern n. + let name = format!("Pattern {}", self.out.doc.patterns.len() + 1); + let mut def = PatternDef::new(&name, art); + def.tile = tile; + self.out.doc.patterns.push(def); + // A cell with a NaN corner equals no cell, so the lookup above could never find it. + if !tile.is_nan() { + self.out.tilings.entry(hash).or_insert_with(|| Vec::with_capacity(1)).push((key, tile, under, name.clone())); + } + Ok(Paint::Pattern { pattern: name, xf }) + } + + /// The art `draw` draws on its own, in its own space (user space is the document's, the page + /// `frame`), from a fresh graphics state with the current font: a pattern's cell, a glyph. + pub fn draw_apart(&mut self, frame: Rect, draw: impl FnOnce(&mut Self) -> Res) -> Res>> { + let fresh = GState { font: self.g.font.clone(), ..GState::default() }; + let g = std::mem::replace(&mut self.g, fresh); + let saved = std::mem::take(&mut self.saved); + let drawn = std::mem::take(&mut self.out.drawn); + let page = std::mem::replace(&mut self.out.page, Affine::IDENTITY); + let frame = std::mem::replace(&mut self.out.frame, frame); + let merge = self.out.merge.take(); + // The art isn't in the groups open around it. + let groups = std::mem::take(&mut self.out.groups); + let too_deep = std::mem::take(&mut self.out.too_deep); + self.out.apart += 1; + let r = draw(self); + self.out.apart -= 1; + let art = std::mem::replace(&mut self.out.drawn, drawn); + (self.out.page, self.out.frame, self.out.merge) = (page, frame, merge); + (self.out.groups, self.out.too_deep) = (groups, too_deep); + (self.g, self.saved) = (g, saved); + r?; + let mut nodes = vectorcraft_doc::clipnest::nest(&mut self.out.doc, art); + if !self.out.shadings.is_empty() { + collapse(&mut nodes, &self.out.shadings); + } + Ok(nodes) + } + + /// An axial or radial shading as a gradient, `m` mapping its space onto the document. + pub(super) fn gradient(&mut self, sh: &DictRef, m: Affine) -> Res> { + let get = |k: &str| sh.borrow().get(&Key::name(k)).cloned(); + let kind = get("ShadingType").and_then(|o| o.as_num()).unwrap_or(0.0); + if !is_gradient(sh) { + self.out.warn(UNKNOWN_SHADINGS); + return Ok(None); + } + let space = self.space_of(&get("ColorSpace").ok_or(PsError::Ps("undefined", "ColorSpace".into()))?, 0)?; + let nums = |o: Option| -> Vec { + o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() + }; + let coords = nums(get("Coords")); + let domain = nums(get("Domain")); + let (d0, d1) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); + let f = get("Function").ok_or(PsError::Ps("undefined", "Function".into()))?; + // Where the colour changes: the ends, and the bounds of stitched linear functions (unless + // there are too many). + let mut visits = 0; + let points = linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS); + let stitched = points.is_some(); + let ts: Vec = match points { + Some(mut pts) => { + pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t)); + pts.iter().map(|t| if d1 != d0 { (t - d0) / (d1 - d0) } else { 0.0 }).chain([0.0, 1.0]).collect() + } + None => (0..=SHADING_SAMPLES).map(|i| i as f64 / SHADING_SAMPLES as f64).collect(), + }; + let mut ts = ts; + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let radial = kind == 3.0; + let c = |i: usize| coords.get(i).copied().unwrap_or(0.0); + let (r0, r1) = (c(2).max(0.0), c(5).max(1e-6)); + let mut stops = Vec::with_capacity(ts.len()); + let stop = |paint: Paint, offset: f64| match paint { + Paint::Solid { color, swatch, tint } => GradientStop { swatch, tint, ..GradientStop::new(offset as f32, color) }, + _ => GradientStop::new(offset as f32, Color::BLACK), + }; + for t in ts { + let comps = self.eval(&f, &[d0 + (d1 - d0) * t], 0)?; + let paint = self.paint_of(&space, &comps)?; + let offset = if radial { (r0 + (r1 - r0) * t) / r1 } else { t }; + // At a bound of a stitched function the pieces need not meet (a hard colour change): + // the colour just before the bound is a stop at the same offset, ahead of the one + // after it. + if stitched && t > 0.0 && t < 1.0 { + let comps = self.eval(&f, &[d0 + (d1 - d0) * (t - 1e-9)], 0)?; + let before = stop(self.paint_of(&space, &comps)?, offset); + if before.color != stop(paint.clone(), offset).color { + stops.push(before); + } + } + stops.push(stop(paint, offset)); + } + let (gk, geom) = if radial { + let centre = Point::new(c(3), c(4)); + let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1, 0.0), aspect: 1.0, focal: None }; + geom.set_focal(Some(Point::new(c(0), c(1)))); + geom.transform(m, GradientKind::Radial); + (GradientKind::Radial, geom) + } else { + (GradientKind::Linear, GradientGeom { start: m * Point::new(c(0), c(1)), end: m * Point::new(c(2), c(3)), aspect: 1.0, focal: None }) + }; + let mut g = GradientPaint::new(Gradient::new(gk, stops)); + g.geom = Some(geom); + Ok(Some(g)) + } + + /// A function (types 0, 2 and 3; an array of them, one per component) at `x` (types 2 and 3 + /// take one input). + pub fn eval(&mut self, f: &Obj, x: &[f64], depth: u32) -> Res> { + self.eval_inner(f, x, depth, &mut 0) + } + + /// [`Self::eval`], with `visits` (shared by the whole recursion, incremented on every call) + /// counting the functions visited against [`MAX_FUNCTION_VISITS`], as [`linear_points`] counts + /// its nodes. The depth limit alone stops a function that refers to itself, but not one that + /// fans out through shared arrays: when four levels of arrays each have 100 entries that all + /// refer to the same array of the level below (at the last level, the same function), one + /// evaluation would visit 101,010,101 functions. Each function visited also counts + /// [`FUNCTION_OPS`] against the operation budget. + fn eval_inner(&mut self, f: &Obj, x: &[f64], depth: u32, visits: &mut usize) -> Res> { + *visits += 1; + if depth > 8 || *visits > MAX_FUNCTION_VISITS { + return ps_err("limitcheck", "Function"); + } + self.spend(FUNCTION_OPS)?; + let d = match f { + Obj::Dict(d) => d.clone(), + Obj::Array { items, .. } => { + // Each entry is at least one more function: an array with more entries than the + // visits left raises `limitcheck` before it is copied. + if items.len() > MAX_FUNCTION_VISITS - *visits { + return ps_err("limitcheck", "Function"); + } + let fs = items.to_vec(); + let mut out = vec![]; + for g in &fs { + out.extend(self.eval_inner(g, x, depth + 1, visits)?.first().copied()); + } + return Ok(out); + } + _ => return ps_err("typecheck", "Function"), + }; + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let nums = |o: Option| -> Vec { + o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() + }; + let domain = nums(get("Domain")); + let t = x.first().copied().unwrap_or(0.0); + let t = match domain.as_slice() { + [a, b, ..] => clamp_between(t, *a, *b), + _ => t, + }; + match get("FunctionType").and_then(|o| o.as_num()) { + Some(0.0) => self.sampled(&d, x), + Some(2.0) => { + let c0 = nums(get("C0")); + let c1 = nums(get("C1")); + let (c0, c1) = (if c0.is_empty() { vec![0.0] } else { c0 }, if c1.is_empty() { vec![1.0] } else { c1 }); + let n = get("N").and_then(|o| o.as_num()).unwrap_or(1.0); + let x = t.max(0.0).powf(n); + Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect()) + } + Some(3.0) => { + // Read the pieces through the borrow and clone only the one we recurse into: a + // `Functions` array of millions (shared, or reached again through a cycle) must + // not be copied on every call and at every live recursion level. + let functions = get("Functions"); + let fs = functions.as_ref().and_then(Obj::items); + let n = fs.map_or(0, Shared::len); + let bounds = nums(get("Bounds")); + let encode = nums(get("Encode")); + let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); + let k = bounds.iter().take_while(|b| t >= **b).count().min(n.saturating_sub(1)); + let a = if k == 0 { lo } else { bounds.get(k - 1).copied().unwrap_or(lo) }; + let b = bounds.get(k).copied().unwrap_or(hi); + let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); + let u = if b != a { e0 + (t - a) / (b - a) * (e1 - e0) } else { e0 }; + let g = fs.and_then(|fs| fs.get(k)).ok_or(PsError::Ps("rangecheck", "Functions".into()))?; + self.eval_inner(&g, &[u], depth + 1, visits) + } + _ => { + self.out.warn(UNKNOWN_FUNCTIONS); + let r = nums(get("Range")); + Ok(r.chunks(2).map(|p| p.iter().sum::() / 2.0).collect()) + } + } + } +} + +/// Is shading `sh` axial or radial (a gradient)? +fn is_gradient(sh: &DictRef) -> bool { + matches!(sh.borrow().get(&Key::name("ShadingType")).and_then(Obj::as_num), Some(2.0 | 3.0)) +} + +/// Is shading `sh` one [`Interp::mesh_fill`] paints? +pub(super) fn is_mesh(sh: &DictRef) -> bool { + matches!(sh.borrow().get(&Key::name("ShadingType")).and_then(Obj::as_num), Some(1.0 | 4.0 | 5.0 | 6.0 | 7.0)) +} + +/// Where a stitched function of linear interpolations changes slope (its bounds), when it is +/// one; `None` for functions that must be sampled. `depth` bounds the nesting the same way +/// [`Interp::eval`] does (`> 8`) and `visits` (shared by the whole recursion, incremented on +/// every call) bounds the total nodes looked at. Both are needed: the depth limit alone stops a +/// function that refers to itself in a cycle, but not one that fans out through shared children +/// (1025 references to a child a few levels deep is 1025^levels nodes without the visit cap). A +/// well-formed stitch of linear pieces is one level, at most `MAX_STITCH_STOPS + 1` pieces, so it +/// costs at most `MAX_STITCH_STOPS + 2` visits and is never cut off. +fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option> { + *visits += 1; + if depth > 8 || *visits > 2 * (MAX_STITCH_STOPS + 1) { + return None; + } + let Obj::Dict(d) = f else { return None }; + let d = d.borrow(); + let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num); + match num("FunctionType")? { + 2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]), + 3.0 => { + let items = d.get(&Key::name("Functions"))?.items()?; + // More pieces than the stop cap allows: sample the function instead (tested before + // `to_vec`, so a huge `Functions` array is not copied only to be rejected). Each stop + // runs [`Interp::eval`], which reads one piece, so a short `Bounds` with a huge + // `Functions` would still be O(stops * Functions) to find each piece without this. + if items.len() > MAX_STITCH_STOPS + 1 { + return None; + } + let fs = items.to_vec(); + if !fs.iter().all(|g| linear_points(g, depth + 1, visits).is_some_and(|p| p.is_empty())) { + return None; + } + d.get(&Key::name("Bounds"))?.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect()) + } + _ => None, + } +} + +/// The clip path a clipping group of one shading rectangle (`shfill` through a clip) can be +/// filled with instead. +pub(crate) fn collapse(nodes: &mut [Arc], shadings: &HashSet) { + for n in nodes.iter_mut() { + let NodeKind::Group { children, clip: true } = &n.kind else { + if let Some(c) = Arc::make_mut(n).children_mut() { + collapse(c, shadings); + } + continue; + }; + if let [clip, fill] = children.as_slice() + && shadings.contains(&fill.id) + && let (NodeKind::Path { path, rule, clipping: true, .. }, NodeKind::Path { .. }) = (&clip.kind, &fill.kind) + { + let mut p = Node::path(fill.id, path.clone(), (*fill.appearance).clone()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = *rule; + } + *n = Arc::new(p); + continue; + } + if let Some(c) = Arc::make_mut(n).children_mut() { + collapse(c, shadings); + } + } +} diff --git a/crates/eps/src/import/graphics/path.rs b/crates/eps/src/import/graphics/path.rs new file mode 100644 index 000000000..3403e9add --- /dev/null +++ b/crates/eps/src/import/graphics/path.rs @@ -0,0 +1,428 @@ +use super::state::{ccw_turn, turn}; +use super::*; + +impl Interp<'_> { + /// User space → document space. + pub fn xf(&self) -> Affine { + self.out.page * self.g.ctm + } + + /// How much user-space lengths grow in the document. + pub fn scale(&self) -> f64 { + let s = self.xf().determinant().abs().sqrt(); + if s.is_finite() { s } else { 0.0 } + } + + /// The current point in user space. + pub fn user_point(&self) -> Res { + let p = self.g.cur.ok_or(PsError::Ps("nocurrentpoint", String::new()))?; + let inv = self.xf(); + if inv.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + Ok(inv.inverse() * p) + } + + pub fn gsave(&mut self) -> Res { + if self.saved.len() >= MAX_GSAVE { + return Err(PsError::Limit("too many saved graphics states")); + } + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; + self.saved.push(self.g.clone()); + Ok(()) + } + + pub fn grestore(&mut self) { + if let Some(g) = self.saved.pop() { + self.g = g; + } + } + + // ---------- paths ---------- + + /// Room for one more element in the current path. + pub fn grow(&self) -> Res { + if self.g.path.elements().len() >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + Ok(()) + } + + /// Count `n` path elements an operator makes, reads or copies against the operation budget, + /// an operation each: making or reading an element takes up to about as long as an operation, + /// and copying one takes less (measured). + pub(super) fn spend_elements(&mut self, n: usize) -> Res { + self.spend(n as u64) + } + + pub(super) fn move_to(&mut self, p: Point) -> Res { + self.grow()?; + // Consecutive movetos: the last one counts. + if let Some(PathEl::MoveTo(_)) = self.g.path.elements().last() { + self.g.path.pop(); + } + self.g.path.move_to(p); + self.g.cur = Some(p); + self.g.start = Some(p); + Ok(()) + } + + pub(super) fn line_to(&mut self, p: Point) -> Res { + self.grow()?; + if self.g.cur.is_none() { + return ps_err("nocurrentpoint", ""); + } + self.g.path.line_to(p); + self.g.cur = Some(p); + Ok(()) + } + + /// Path elements in user space appended (the first move a line when there is a current point), + /// each counted against the operation budget. + fn append_user(&mut self, els: impl Iterator) -> Res { + let xf = self.xf(); + for el in els { + self.spend_elements(1)?; + match el { + PathEl::MoveTo(p) => { + let p = xf * p; + if self.g.cur.is_some() { self.line_to(p)? } else { self.move_to(p)? } + } + PathEl::LineTo(p) => self.line_to(xf * p)?, + PathEl::QuadTo(a, p) => { + self.grow()?; + self.g.path.quad_to(xf * a, xf * p); + self.g.cur = Some(xf * p); + } + PathEl::CurveTo(a, b, p) => { + self.grow()?; + self.g.path.curve_to(xf * a, xf * b, xf * p); + self.g.cur = Some(xf * p); + } + PathEl::ClosePath => { + self.grow()?; + self.g.path.close_path(); + } + } + } + Ok(()) + } + + /// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise). + pub(super) fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res { + // `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it + // until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps, + // and the first endpoint, are worked out from what is left of each angle after whole turns. + let sweep = if ccw && a2 < a1 { + ccw_turn(a1, a2) + } else if !ccw && a2 > a1 { + -ccw_turn(a2, a1) + } else { + a2 - a1 + }; + let tau = std::f64::consts::TAU; + let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0); + let a1 = (a1 % 360.0).to_radians(); + let start = c + Vec2::new(r * a1.cos(), r * a1.sin()); + self.append_user(std::iter::once(PathEl::MoveTo(start)))?; + let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0); + let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); + self.append_user(arc.append_iter(tol)) + } + + /// `arct` / `arcto`: a line towards `p1` rounded with radius `r` into the line to `p2`; the + /// tangent points (user space). + pub(super) fn arct(&mut self, p1: Point, p2: Point, r: f64) -> Res<[Point; 2]> { + let p0 = self.user_point()?; + let (u, v) = (p0 - p1, p2 - p1); + let (lu, lv) = (u.hypot(), v.hypot()); + let cross = u.cross(v); + if lu < 1e-12 || lv < 1e-12 || cross.abs() < 1e-12 || r <= 0.0 { + self.append_user(std::iter::once(PathEl::LineTo(p1)))?; + return Ok([p1, p1]); + } + let (u, v) = (u / lu, v / lv); + let half = u.dot(v).clamp(-1.0, 1.0).acos() / 2.0; + let dist = r / half.tan(); + let (t1, t2) = (p1 + u * dist, p1 + v * dist); + let bis = (u + v).normalize(); + let c = p1 + bis * (r / half.sin()); + let (a1, a2) = ((t1 - c).atan2(), (t2 - c).atan2()); + self.append_user(std::iter::once(PathEl::LineTo(t1)))?; + let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); + let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, turn(a1, a2), 0.0); + self.append_user(arc.append_iter(tol))?; + Ok([t1, t2]) + } + + fn rect_path(&self, x: f64, y: f64, w: f64, h: f64) -> BezPath { + let xf = self.xf(); + let mut bp = BezPath::new(); + bp.move_to(xf * Point::new(x, y)); + bp.line_to(xf * Point::new(x + w, y)); + bp.line_to(xf * Point::new(x + w, y + h)); + bp.line_to(xf * Point::new(x, y + h)); + bp.close_path(); + bp + } + + /// The rectangles of `rectfill`, `rectstroke` and `rectclip`: `x y w h` or an array of them. + pub(super) fn rects(&mut self) -> Res { + let nums: Vec = match self.stack.last() { + Some(Obj::Array { .. }) => { + let items = self.pop_array()?; + // Five elements a rectangle, counted before the array is read. + self.spend_elements(items.len() / 4 * 5)?; + let v: Option> = items.borrow().iter().map(Obj::as_num).collect(); + v.ok_or(PsError::Ps("typecheck", String::new()))? + } + _ => self.nums::<4>()?.to_vec(), + }; + if nums.len() / 4 * 5 >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + let mut bp = BezPath::new(); + for [x, y, w, h] in nums.as_chunks::<4>().0 { + bp.extend(self.rect_path(*x, *y, *w, *h)); + } + Ok(bp) + } + + // ---------- painting ---------- + + pub(super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res { + if bp.elements().is_empty() || self.g.null { + return Ok(()); + } + // A mesh pattern paints its shading inside the path. + if let Some((sh, m)) = self.g.mesh.clone() { + let clips = self.g.clips.clone(); + self.clip_with(bp, rule); + let r = self.mesh_fill(&sh, m); + self.g.clips = clips; + return r; + } + if self.g.paint.is_none() { + return Ok(()); + } + let (paint, overprint) = (self.g.paint.clone(), self.g.overprint); + self.out.fill(bp, rule, paint, overprint, &self.g.clips); + Ok(()) + } + + pub(super) fn stroke_path(&mut self, bp: BezPath) -> Res { + if bp.elements().is_empty() || self.g.paint.is_none() || self.g.null { + return Ok(()); + } + let st = self.stroke_layer()?; + self.out.stroke(bp, st, &self.g.clips); + Ok(()) + } + + /// The stroke the graphics state draws, in document space. Each entry of the dash pattern it + /// copies counts as an operation. + pub(super) fn stroke_layer(&mut self) -> Res { + self.spend(self.g.dash.len() as u64)?; + let k = self.scale(); + let w = self.g.width * k; + let mut st = StrokeLayer::new(self.g.paint.clone(), if w > 0.0 && w.is_finite() { w } else { HAIRLINE }); + st.cap = self.g.cap; + st.join = self.g.join; + st.miter_limit = self.g.miter.clamp(1.0, 500.0); + st.overprint = self.g.overprint; + let pattern: Vec = self.g.dash.iter().map(|d| d * k).collect(); + let dash = Dash { pattern, offset: self.g.dash_offset * k, align_corners: false }; + st.dash = dash.is_dashed().then_some(dash); + Ok(st) + } + + /// The current path in user space, each element as its operator and points (quadratic + /// segments as cubic ones). + fn user_elements(&self) -> Res)>> { + let xf = self.xf(); + if xf.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + let inv = xf.inverse(); + let mut last = Point::ZERO; + let mut out = Vec::with_capacity(self.g.path.elements().len()); + for el in self.g.path.elements() { + let (op, pts) = match *el { + PathEl::MoveTo(p) => (Op::MoveTo, vec![p]), + PathEl::LineTo(p) => (Op::LineTo, vec![p]), + PathEl::QuadTo(a, p) => (Op::CurveTo, vec![last + (a - last) * (2.0 / 3.0), p + (a - p) * (2.0 / 3.0), p]), + PathEl::CurveTo(a, b, p) => (Op::CurveTo, vec![a, b, p]), + PathEl::ClosePath => (Op::ClosePath, vec![]), + }; + last = pts.last().copied().unwrap_or(last); + out.push((op, pts.into_iter().map(|p| inv * p).collect())); + } + Ok(out) + } + + /// `pathforall`: the current path in user space, each element through its procedure. + pub(super) fn path_for_all(&mut self) -> Res { + let close = self.pop_proc()?; + let curve = self.pop_proc()?; + let line = self.pop_proc()?; + let mv = self.pop_proc()?; + self.spend_elements(self.g.path.elements().len())?; + for (op, pts) in self.user_elements()? { + for p in pts { + self.push_num(p.x)?; + self.push_num(p.y)?; + } + let proc = match op { + Op::MoveTo => &mv, + Op::LineTo => &line, + Op::CurveTo => &curve, + _ => &close, + }; + if !self.body(proc)? { + break; + } + } + Ok(()) + } + + /// Append user path `up` to the current path: a procedure of path operators, or the encoded + /// form (numbers and a string of operator codes). + pub(super) fn user_path(&mut self, up: Obj) -> Res { + let items = up.items().cloned().ok_or(PsError::Ps("typecheck", "a user path".into()))?; + let encoded = match (items.get(0), items.get(1), items.len()) { + (Some(nums @ (Obj::Array { .. } | Obj::Str(_))), Some(Obj::Str(ops)), 2) => Some((nums, ops.to_vec())), + _ => None, + }; + let Some((nums, ops)) = encoded else { return self.call(Obj::Array { items, exec: true }) }; + // The operators by code, with how many operands each takes. + const OPS: [(Op, usize); 12] = [ + (Op::SetBBox, 4), + (Op::MoveTo, 2), + (Op::RMoveTo, 2), + (Op::LineTo, 2), + (Op::RLineTo, 2), + (Op::CurveTo, 6), + (Op::RCurveTo, 6), + (Op::Arc, 5), + (Op::Arcn, 5), + (Op::Arct, 5), + (Op::ClosePath, 0), + (Op::UCache, 0), + ]; + let nums: Vec = match nums { + Obj::Array { items, .. } => { + // Each entry of the data array and each byte of the operator string counts as an + // element read. + self.spend_elements(items.len().saturating_add(ops.len()))?; + items.borrow().iter().filter_map(Obj::as_num).collect() + } + _ => return ps_err("typecheck", "an encoded user path"), + }; + let mut next = nums.into_iter(); + let mut repeat = 1usize; + for code in ops { + if code >= 32 { + repeat = usize::from(code - 32); + continue; + } + let &(op, n) = OPS.get(usize::from(code)).ok_or(PsError::Ps("rangecheck", "a user path".into()))?; + for _ in 0..std::mem::replace(&mut repeat, 1) { + for _ in 0..n { + let v = next.next().ok_or(PsError::Ps("rangecheck", "a user path".into()))?; + self.push_num(v)?; + } + // Each operator run counts as an operation. + self.spend(1)?; + self.op(op)?; + } + } + Ok(()) + } + + /// `ufill`, `ueofill`, `ustroke` (with an optional matrix): paint a user path, keeping the + /// current path. + pub(super) fn paint_user_path(&mut self, op: Op) -> Res { + let m = match (op, self.stack.last()) { + (Op::UStroke, Some(Obj::Array { items, exec: false })) if items.len() == 6 && self.stack.len() >= 2 => Some(self.pop_matrix()?), + _ => None, + }; + let up = self.pop()?; + self.gsave()?; + let depth = self.saved.len(); + self.take_path(); + let r = self.user_path(up).and_then(|()| { + if let Some(m) = m { + self.g.ctm *= m; + } + let bp = self.take_path(); + match op { + Op::UStroke => self.stroke_path(bp), + _ => self.fill_path(bp, if op == Op::UEoFill { FillRule::EvenOdd } else { FillRule::NonZero }), + } + }); + self.saved.truncate(depth); + self.grestore(); + r + } + + /// `upath`: the current path as a user path (a procedure, in user space). + pub(super) fn upath(&mut self) -> Res { + self.pop_bool()?; + let elements = self.user_elements()?; + let b = self.xf().inverse().transform_rect_bbox(self.g.path.bounding_box()); + let mut items: Vec = [b.x0, b.y0, b.x1, b.y1].into_iter().map(Obj::Real).collect(); + items.push(Obj::Op(Op::SetBBox)); + for (op, pts) in elements { + items.extend(pts.iter().flat_map(|p| [Obj::Real(p.x), Obj::Real(p.y)])); + items.push(Obj::Op(op)); + } + self.alloc(items.len() * std::mem::size_of::())?; + self.push(Obj::proc(items)) + } + + /// `execform`: the form's `PaintProc` run with its matrix, clipped to its box. + pub(super) fn exec_form(&mut self) -> Res { + let d = self.pop_dict()?; + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let m = get("Matrix").and_then(|o| o.items().and_then(|i| crate::import::interp::matrix_of(&i.borrow()))).unwrap_or(Affine::IDENTITY); + let bbox: Vec = get("BBox").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); + let paint = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; + self.gsave()?; + let depth = self.saved.len(); + self.g.ctm *= m; + if let [x0, y0, x1, y1] = bbox[..] { + let bp = self.rect_path(x0, y0, x1 - x0, y1 - y0); + self.take_path(); + self.clip_with(bp, FillRule::NonZero); + } + self.push(Obj::Dict(d))?; + let r = self.call(paint); + self.saved.truncate(depth); + self.grestore(); + r + } + + pub(in super::super) fn take_path(&mut self) -> BezPath { + self.g.cur = None; + self.g.start = None; + std::mem::take(&mut self.g.path) + } + + pub(super) fn clip_with(&mut self, path: BezPath, rule: FillRule) { + let Some(c) = self.out.clip(path, rule) else { return }; + if self.g.clips.len() < MAX_NEST { + self.g.clips.push(c); + } else { + self.out.warn(&deep_clips()); + } + } + + /// The area the clip leaves, in document space (the page without a clip). The clip path read + /// counts against the operation budget. + pub(super) fn clip_bounds(&mut self) -> Res { + let (bounds, len) = + self.g.clips.iter().rev().find_map(|c| c.region.as_ref()).map_or((self.out.frame, 0), |(p, _)| (p.bounding_box(), p.elements().len())); + self.spend_elements(len)?; + Ok(bounds) + } +} diff --git a/crates/eps/src/import/graphics/state.rs b/crates/eps/src/import/graphics/state.rs new file mode 100644 index 000000000..6aa25d5a9 --- /dev/null +++ b/crates/eps/src/import/graphics/state.rs @@ -0,0 +1,328 @@ +use super::*; + +/// A colour space. +#[derive(Clone, Debug)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + /// Separation and DeviceN: the ink names, the alternate space and the tint transform. + Inks { + names: Vec>, + alt: Rc, + tint: Obj, + }, + Indexed { + base: Rc, + table: Rc>, + }, + /// Patterns; an uncoloured one paints in a colour of the underlying space. + Pattern(Option>), +} + +impl Space { + /// Components a colour has in it. + pub fn n(&self) -> usize { + match self { + Self::Gray | Self::Indexed { .. } | Self::Pattern(_) => 1, + Self::Rgb => 3, + Self::Cmyk => 4, + Self::Inks { names, .. } => names.len().max(1), + } + } + + /// The colour `setcolorspace` starts with. + pub(super) fn initial(&self) -> Vec { + match self { + Self::Cmyk => vec![0.0, 0.0, 0.0, 1.0], + Self::Inks { names, .. } => vec![1.0; names.len().max(1)], + s => vec![0.0; s.n()], + } + } +} + +/// A process colour from components in `space` (grey, RGB or CMYK), each clamped to 0–1. +pub(crate) fn process(space: &Space, c: &[f64]) -> Option { + let v = |i: usize| c.get(i).map_or(0.0, |x| if x.is_finite() { x.clamp(0.0, 1.0) as f32 } else { 0.0 }); + Some(match space { + Space::Gray => Color::gray(1.0 - v(0)), + Space::Rgb => Color::rgb(v(0), v(1), v(2)), + Space::Cmyk => Color::cmyk(v(0), v(1), v(2), v(3)), + _ => return None, + }) +} + +#[derive(Clone, Debug)] +pub(crate) struct GState { + /// User space → PostScript's default space. + pub ctm: Affine, + pub space: Rc, + /// The `setcolorspace` operand, for `currentcolorspace`. + pub space_obj: Obj, + pub comps: Vec, + pub paint: Paint, + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter: f64, + /// The dash pattern, shared by the states saved or copied from this one. + pub dash: Rc<[f64]>, + pub dash_offset: f64, + /// The current path, in document space. + pub path: BezPath, + pub cur: Option, + pub start: Option, + pub clips: Vec>, + /// The clips `clipsave` saved, for `cliprestore`. + pub saved_clips: Vec]>>, + pub overprint: bool, + pub font: Option, + /// `nulldevice`: painting draws nothing. + pub null: bool, + /// The colour is a shading pattern whose shading is a mesh: a fill paints it (the shading + /// and pattern space → the document). + pub mesh: Option<(DictRef, Affine)>, +} + +impl Default for GState { + fn default() -> Self { + Self { + ctm: Affine::IDENTITY, + space: Rc::new(Space::Gray), + space_obj: Obj::name("DeviceGray"), + comps: vec![0.0], + paint: Paint::solid(Color::gray(1.0)), + width: 1.0, + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 10.0, + dash: Rc::from([]), + dash_offset: 0.0, + path: BezPath::new(), + cur: None, + start: None, + clips: vec![], + saved_clips: vec![], + overprint: false, + font: None, + null: false, + mesh: None, + } + } +} + +/// What the program draws. +pub(crate) struct Out { + /// The document being made: ids, images and swatches. + pub doc: Document, + pub drawn: Drawn, + pub warnings: Vec, + /// PostScript's default space → document space. + pub page: Affine, + /// The page (the bounding box) in document space. + pub frame: Rect, + /// Clip and group ids are given in the order they open, so a chain of both sorts by id. + next_clip: u32, + /// The groups open (outermost first), and how many more opened past [`MAX_NEST`] (their art + /// goes in the innermost group kept). + pub(super) groups: Vec>, + pub(super) too_deep: usize, + /// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]). + elements: usize, + /// The path the last object was filled with: a stroke of the same path right after joins it. + pub(super) merge: Option, + /// Process colours painted, by model (the document opens in CMYK when most are). + pub cmyk: usize, + pub rgb: usize, + /// Spot inks used: name and colour at full tint. + pub spots: Vec<(String, Color)>, + /// The color at full tint of each spot ink in [`Self::spots`], by name. + pub(super) spot_colors: HashMap, Color>, + /// Rectangles painted with a shading (`shfill`): a clipping group of one is the clip path + /// filled with it. + pub shadings: HashSet, + /// Tiling patterns made into pattern swatches: the pattern's `PaintProc` (a pattern made + /// again with `makepattern` shares it) and cell, the colour an uncoloured one painted in, and + /// the swatch's name. Each list holds the patterns of one [`tiling_hash`]; a pattern whose + /// cell has a NaN corner is left out. + pub(super) tilings: HashMap>, + /// How deep art drawn apart (a pattern's cell, a Type 3 glyph) is nested. + pub apart: u32, +} + +fn sane(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) +} + +/// A tiling pattern made into a pattern swatch, as [`Out::tilings`] lists it. +pub(super) type Tiling = (Shared, Rect, Option, String); + +/// A hash of what tells tiling patterns apart in [`Out::tilings`]. Patterns that compare equal +/// hash alike: each number goes in by its bits, and −0 goes in as 0. +pub(super) fn tiling_hash(proc: &Shared, tile: Rect, under: Option<&Paint>) -> u64 { + let mut h = DefaultHasher::new(); + proc.hash(&mut h); + [tile.x0, tile.y0, tile.x1, tile.y1].map(|v| (v + 0.0).to_bits()).hash(&mut h); + if let Some(Paint::Solid { color, swatch, tint }) = under { + let c = match *color { + Color::Rgb { r, g, b } => [r, g, b, 0.0], + Color::Cmyk { c, m, y, k } => [c, m, y, k], + Color::Gray { k } => [k, 0.0, 0.0, 0.0], + Color::Lab { l, a, b } => [l, a, b, 0.0], + }; + c.map(|v| (v + 0.0).to_bits()).hash(&mut h); + swatch.hash(&mut h); + (tint + 0.0).to_bits().hash(&mut h); + } + h.finish() +} + +impl Out { + pub fn new(doc: Document, page: Affine, frame: Rect) -> Self { + Self { + doc, + drawn: vec![], + warnings: vec![], + page, + frame, + next_clip: 0, + groups: vec![], + too_deep: 0, + elements: 0, + merge: None, + cmyk: 0, + rgb: 0, + spots: vec![], + spot_colors: HashMap::new(), + shadings: HashSet::new(), + tilings: HashMap::new(), + apart: 0, + } + } + + pub fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + /// Open a group: what is drawn until it ends ([`Self::end_group`]) goes in it. + pub fn begin_group(&mut self) { + if self.groups.len() >= MAX_NEST { + self.too_deep += 1; + self.warn(&format!("groups nested more than {MAX_NEST} deep were read as part of the group around them")); + return; + } + self.next_clip += 1; + self.groups.push(Arc::new(Clip { id: self.next_clip, region: None })); + } + + /// End the innermost group open (an end without a beginning ends nothing). + pub fn end_group(&mut self) { + if self.too_deep > 0 { + self.too_deep -= 1; + } else { + self.groups.pop(); + } + } + + /// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the + /// order they opened. + pub fn chain(&self, clips: &[Arc]) -> Vec> { + if self.groups.is_empty() { + return clips.to_vec(); + } + let mut chain = Vec::with_capacity(clips.len() + self.groups.len()); + let (mut a, mut b) = (clips.iter().peekable(), self.groups.iter().peekable()); + while let (Some(x), Some(y)) = (a.peek(), b.peek()) { + let next = if x.id < y.id { a.next() } else { b.next() }; + chain.extend(next.cloned()); + } + chain.extend(a.chain(b).cloned()); + chain + } + + /// Add `node` under `clips` (in the groups open); `None` when it was left out. + pub fn push(&mut self, mut node: Node, clips: &[Arc]) -> Option { + self.merge = None; + let chain = self.chain(clips); + let size = node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum()) + chain.len(); + self.elements = self.elements.saturating_add(size); + if self.drawn.len() >= MAX_NODES || self.elements > MAX_DRAWN { + self.warn(TOO_MUCH); + return None; + } + if !node.visual_bounds().is_none_or(sane) { + self.warn(FAR_AWAY); + return None; + } + node.id = self.doc.alloc_id(); + let id = node.id; + self.drawn.push((chain, node)); + Some(id) + } + + /// Count a paint's colour model. + fn count(&mut self, p: &Paint) { + match p { + Paint::Solid { color: Color::Cmyk { .. }, swatch: None, .. } => self.cmyk += 1, + Paint::Solid { color: Color::Rgb { .. }, swatch: None, .. } => self.rgb += 1, + _ => {} + } + } + + /// Fill `bp` with `paint`. + pub(super) fn fill(&mut self, bp: BezPath, rule: FillRule, paint: Paint, overprint: bool, clips: &[Arc]) { + self.count(&paint); + let item = AppearanceItem::Fill(FillLayer { overprint, ..FillLayer::new(paint) }); + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + if self.push(n, clips).is_some() { + self.merge = Some(bp); + } + } + + /// Stroke `bp` with `st`: joins the object just filled with the same path, under the same + /// clips (`gsave fill grestore stroke`). + pub(super) fn stroke(&mut self, bp: BezPath, st: StrokeLayer, clips: &[Arc]) { + self.count(&st.paint); + if self.merge.as_ref() == Some(&bp) { + let here = self.chain(clips); + if let Some((chain, last)) = self.drawn.last_mut() + && chain.iter().map(|c| c.id).eq(here.iter().map(|c| c.id)) + { + last.appearance.items.push(AppearanceItem::Stroke(st)); + self.merge = None; + return; + } + } + let n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Appearance::default() }); + self.push(n, clips); + } + + /// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which + /// clips nothing. + pub(super) fn clip(&mut self, path: BezPath, rule: FillRule) -> Option> { + // Only a polygon that really covers the page: a diamond touching its edges has the page as + // its bounding box but cuts its corners away (#1542). + if path.elements().len() <= 6 && vectorcraft_geom::hit::polygon_covers_rect(&path, self.frame, 0.5) { + return None; + } + self.next_clip += 1; + Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })) + } +} + +/// The smallest angle from `a` to `b` (radians, −π to π). +pub(super) fn turn(a: f64, b: f64) -> f64 { + let d = (b - a).rem_euclid(std::f64::consts::TAU); + if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d } +} + +/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left +/// of each after whole turns. +pub(super) fn ccw_turn(a: f64, b: f64) -> f64 { + (b % 360.0 - a % 360.0).rem_euclid(360.0) +} From c15d70859b7a5561b3980768e3d225585144777b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:30:38 +0000 Subject: [PATCH 128/199] refactor(eps): move import/native.rs items to import/native/{build,compare,layers,plan,slots}.rs --- crates/eps/src/import/native.rs | 1565 +---------------------- crates/eps/src/import/native/build.rs | 333 +++++ crates/eps/src/import/native/compare.rs | 322 +++++ crates/eps/src/import/native/layers.rs | 242 ++++ crates/eps/src/import/native/plan.rs | 222 ++++ crates/eps/src/import/native/slots.rs | 470 +++++++ 6 files changed, 1604 insertions(+), 1550 deletions(-) create mode 100644 crates/eps/src/import/native/build.rs create mode 100644 crates/eps/src/import/native/compare.rs create mode 100644 crates/eps/src/import/native/layers.rs create mode 100644 crates/eps/src/import/native/plan.rs create mode 100644 crates/eps/src/import/native/slots.rs diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 99164aaa8..42f968faa 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -37,1511 +37,21 @@ use super::Imported; use super::ai::{self, Structure, slot_of}; use super::ate::{Story, Texts}; -/// The notes that say the import left out something the file has (see [`is_loss`]). -const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out"; -/// The same for a file without a page to take shown type from. -const TYPE_LEFT_OUT: &str = "text objects of kinds VectorCraft doesn't read from the file's text document yet are left out"; -const ART_LEFT_OUT: &str = "hidden layers have art this can't read"; -const LAYERS_UNREAD: &str = "the file's layers weren't read"; - -/// Does this import note say that the file has something the document doesn't (hidden text, art or -/// layers that could not be read)? Writing the document over the file would lose it for good. -pub fn is_loss(note: &str) -> bool { - note.ends_with(TEXT_LEFT_OUT) - || note.ends_with(TYPE_LEFT_OUT) - || [ART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n)) -} - -/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched -/// to each other (more than this and the layers aren't used). -const MAX_SLOTS: usize = 5000; -const MAX_PIECES: usize = 2000; -/// Most bytes of text in the text objects of the layers (a story named twice counts twice). -const MAX_TEXT: usize = 16 << 20; -/// Most pairs of a text object and a piece of the page's type compared, and of a glyph the page -/// kept as outlines and a node of the page's type. -const MAX_PAIRS: usize = 4_000_000; - -/// `names` for a note: the first few, and how many more. -fn few(names: BTreeSet) -> String { - const SHOWN: usize = 5; - let more = names.len().saturating_sub(SHOWN); - let mut list: Vec = names.into_iter().take(SHOWN).collect(); - if more > 0 { - list.push(format!("{more} more")); - } - list.join(", ") -} - -/// A text slot of the layers. -struct SlotInfo { - story: Option, - /// Which of the story's frames it is. - frame: usize, - /// It, its layer and the objects round it show. - shown: bool, - /// Its own frame or path, where the file places it ([`ai::text_frames`]). - art_path: Option, -} - -/// The text slots of the layers and objects, in order. -fn collect_slots(nodes: &[Arc], shown: bool, out: &mut Vec) { - for n in nodes { - let shown = shown && n.visible; - if let Some(story) = slot_of(n.name.as_deref()) { - out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None }); - } else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { - collect_slots(c, shown, out); - } - } -} - -/// How far from a text object's own extent the strokes drawn around it reach (points). -const OUTLINE_REACH: f64 = 6.0; - -/// Does `n` look like part of a text object's outline: a path with a stroke, inside `around`? -fn outlines(n: &Node, around: Rect) -> bool { - matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) - && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.width > 0.0)) - && n.visual_bounds().is_some_and(|b| { - let a = around.inflate(OUTLINE_REACH, OUTLINE_REACH); - b.x0 >= a.x0 && b.y0 >= a.y0 && b.x1 <= a.x1 && b.y1 <= a.y1 - }) -} - -/// How far apart (points) the baselines of two text nodes may be for them to be on one line. -const SAME_LINE: f64 = 0.5; - -/// Where `n`'s baseline lies across the direction it runs in (points), if it is text. -fn baseline(n: &Node) -> Option { - let NodeKind::Text(t) = &n.kind else { return None }; - let [a, b, _, _, e, f] = t.xf.as_coeffs(); - let len = a.hypot(b); - (len > 1e-9).then(|| (a * f - b * e) / len) -} - -/// The text objects the page paints, each as the run of nodes that make it up (its text, once for -/// each fill and stroke it has, with the paths of its strokes), in painting order: those that -/// show in `shown`, those of hidden layers and objects (a PDF has them) in `hidden`. -fn text_objects(nodes: &[Arc], on: bool, shown: &mut Vec>>, hidden: &mut Vec>>) { - let is_text = |n: &Node| matches!(n.kind, NodeKind::Text(_)); - let (mut i, mut free) = (0, 0); - while let Some(n) = nodes.get(i) { - if !is_text(n) { - if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { - text_objects(c, on && n.visible, shown, hidden); - free = i + 1; - } - i += 1; - continue; - } - let mut bounds = n.visual_bounds().unwrap_or_default(); - let (mut start, mut end) = (i, i); - while start > free - && let Some(p) = nodes.get(start - 1) - && outlines(p, bounds) - { - start -= 1; - bounds = bounds.union(p.visual_bounds().unwrap_or_default()); - } - while let Some(next) = nodes.get(end + 1) - && let Some(b) = next.visual_bounds() - && (is_text(next) - && !b.intersect(bounds.inflate(OUTLINE_REACH, OUTLINE_REACH)).is_zero_area() - && baseline(next) - .zip(nodes.get(start..=end).unwrap_or_default().iter().find_map(|m| baseline(m))) - .is_some_and(|(x, y)| (x - y).abs() <= SAME_LINE) - || outlines(next, bounds)) - { - end += 1; - bounds = bounds.union(b); - } - let object = nodes.get(start..=end).unwrap_or_default().to_vec(); - let into = if on && n.visible { &mut *shown } else { &mut *hidden }; - into.push(object); - i = end + 1; - free = i; - } -} - -/// `n` and what it holds with ids of `doc`. -fn reid(doc: &mut Document, n: &Arc) -> Arc { - let mut n = n.as_ref().clone(); - n.id = doc.alloc_id(); - if let Some(children) = n.children_mut() { - for c in children.iter_mut() { - *c = reid(doc, c); - } - } - Arc::new(n) -} - -/// The page's text objects given to each of `slots` text slots: in painting order, and when there -/// are more of one than of the other (a label may be a few text objects, or one with several -/// strokes), each goes where the share of the order it is at falls. -fn assign(objects: &[Vec>], slots: usize) -> Vec]>> { - let mut given: Vec]>> = vec![vec![]; slots]; - for (j, o) in objects.iter().enumerate() { - if let Some(at) = given.get_mut(((2 * j + 1) * slots / (2 * objects.len())).min(slots.saturating_sub(1))) { - at.push(o); - } - } - given -} - -/// What goes into a text slot. -enum Content { - /// Text objects (with the strokes round them) of the page. - Page(Vec>), - /// A text object made from the file's text document, where it lies on the document, whether - /// the page has type where it is, and the page's type of no text object that is nearest it - /// (kept after it). - Made(Box, Option, bool, Vec>), - /// Nothing the file lets this read. - Empty, - /// Its type is on the page with another text object's; there is nothing to put here. - Merged, -} - -/// Does `n` have transparency of its own: an opacity below 100%, a blend mode or isolation? -fn see_through(n: &Node) -> bool { - n.opacity < 1.0 || n.blend != BlendMode::Normal || n.isolate -} - -/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is -/// left out): the page's type takes its place as it is, made type keeps the slot's name. Made type -/// takes the slot's opacity, blend mode, isolation and knockout. The page's type takes them too when -/// the page draws it and the strokes around it with no transparency, the slot's, or the slot's -/// combined with that of the objects around `nodes`; otherwise they keep their own. Where the slot has -/// transparency and the type comes with other art that has none of its own, a group of them takes the -/// slot's instead. `round` has the opacity and blend mode of each object around `nodes`, which the page -/// can have folded into its type. `empty` counts those left out. -fn fill_slots( - doc: &mut Document, - nodes: &mut Vec>, - content: &mut std::vec::IntoIter, - names: &std::collections::HashMap, - empty: &mut usize, - made: &mut Vec<(u32, usize, NodeId)>, - round: &mut Vec<(f32, BlendMode)>, -) { - let mut i = 0; - while i < nodes.len() { - let Some(n) = nodes.get(i) else { break }; - if let Some(story) = slot_of(n.name.as_deref()) { - let visible = n.visible; - let name = names.get(&n.id); - let frame = ai::slot_frame(n.name.as_deref()); - let state = (n.opacity, n.blend, n.isolate, n.knockout); - let transparent = see_through(n); - // An art style read onto the slot (fills, strokes and effects round the characters) is the type's. - let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone()); - let replacement = match content.next() { - Some(Content::Page(parts)) if !parts.is_empty() => { - // The page's type (and the strokes round it) take the slot's place as they are. - let mut parts: Vec> = parts - .iter() - .map(|p| { - let mut p = reid(doc, p); - if !visible { - Arc::make_mut(&mut p).visible = false; - } - if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) { - Arc::make_mut(&mut p).name = Some(name.clone()); - } - p - }) - .collect(); - // When the page draws each of them with no transparency, the slot's, or the slot's - // combined with that of the objects around it, the slot's takes its place: on the one - // part, or on a group of several when the slot has transparency. Otherwise every part - // keeps the transparency the page draws it with (a fill's or a stroke's). - let above: f32 = round.iter().map(|r| r.0).product(); - let objects = |p: &Node| { - !see_through(p) - || (!p.isolate - && (p.blend == BlendMode::Normal || p.blend == state.1 || round.iter().any(|r| r.1 == p.blend)) - && [state.0, state.0 * above].iter().any(|o| (p.opacity - o).abs() < 1e-3)) - }; - if parts.iter().all(|p| objects(p)) { - for p in &mut parts { - let p = Arc::make_mut(p); - (p.opacity, p.blend, p.isolate) = (1.0, BlendMode::Normal, false); - } - if let [p] = parts.as_mut_slice() { - let p = Arc::make_mut(p); - (p.opacity, p.blend, p.isolate, p.knockout) = state; - } else if transparent { - let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts)); - (group.opacity, group.blend, group.isolate, group.knockout) = state; - parts.push(Arc::new(group)); - } - } - let n = parts.len(); - nodes.splice(i..=i, parts); - i += n; - continue; - } - Some(Content::Made(mut text, _, seen, extra)) => { - text.id = doc.alloc_id(); - if let Some(story) = story { - made.push((story, frame, text.id)); - } - text.visible = visible; - // Type with an art style is what the page draws as the style's look: it is seen. - let seen = seen || styled.is_some(); - if let Some(appearance) = styled { - text.appearance = appearance; - } - // Type the page shows is kept; other made type is weighed against the page. - text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) }; - let extra: Vec> = extra.iter().map(|p| reid(doc, p)).collect(); - // The type takes the slot's transparency; the page's type kept after it, where it has - // none of its own, goes into a group with it that has the transparency instead. - let parts: Vec> = if transparent && !extra.is_empty() && !extra.iter().any(|p| see_through(p)) { - let mut group = Node::group(doc.alloc_id(), std::iter::once(Arc::new(*text)).chain(extra).collect()); - (group.opacity, group.blend, group.isolate, group.knockout) = state; - vec![Arc::new(group)] - } else { - (text.opacity, text.blend, text.isolate, text.knockout) = state; - std::iter::once(Arc::new(*text)).chain(extra).collect() - }; - let n = parts.len(); - nodes.splice(i..=i, parts); - i += n; - continue; - } - Some(Content::Merged) => { - nodes.remove(i); - continue; - } - _ => None, - }; - match replacement { - Some(node) => { - if let Some(slot) = nodes.get_mut(i) { - *slot = Arc::new(node); - } - i += 1; - } - None => { - *empty += 1; - nodes.remove(i); - } - } - } else { - let state = (n.opacity, n.blend); - if n.children().is_some() - && !matches!(n.kind, NodeKind::Compound { .. }) - && let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut) - { - round.push(state); - fill_slots(doc, c, content, names, empty, made, round); - round.pop(); - } - i += 1; - } - } -} - -/// How far, in points, the page's type may be from where the file's text document puts it for the -/// two to be the same type. -const SAME_PLACE: f64 = 0.75; - -/// Its letters and digits, which is what is compared of type (the page may spell a mark another way). -fn letters(s: &str) -> String { - s.chars().filter(char::is_ascii_alphanumeric).collect() -} - -/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it -/// (the page splits a line where its kerning or its styles change), on that line's baseline and -/// after its start? How far from where the line starts it is, if so. -fn same_type(n: &Node, lines: &[(String, Point)]) -> Option { - let NodeKind::Text(t) = &n.kind else { return None }; - let have = letters(&t.plain_text()); - let [a, b, _, _, e, f] = t.xf.as_coeffs(); - let len = a.hypot(b); - if have.is_empty() || len < 1e-9 { - return None; - } - let (ux, uy) = (a / len, b / len); - // About how far a character of it advances, to say how far along a line a piece may be. - let advance = n.visual_bounds().map_or(0.0, |r| r.width().max(r.height())) / have.len() as f64; - lines - .iter() - .filter_map(|(text, at)| { - let line = letters(text); - let skipped = line.find(&have)?; - if skipped == 0 { - return Some(((at.x - e).powi(2) + (at.y - f).powi(2)).sqrt()).filter(|d| *d <= SAME_PLACE); - } - let (dx, dy) = (e - at.x, f - at.y); - let (along, across) = (dx * ux + dy * uy, (dx * uy - dy * ux).abs()); - ((have.len() > 1 || skipped + 1 == line.len()) - && across <= SAME_PLACE - && along >= -SAME_PLACE - && along <= line.len() as f64 * advance * 2.0 + 4.0) - .then_some(across + 0.001 * along) - }) - .min_by(f64::total_cmp) -} - -/// The page draws a line in pieces where its kerning or styles change ("swe", "ep"), and the file -/// has it as one line of one text object: the pieces that on one baseline add up to a line of -/// `lines` become one text (each keeping its own style, the line's spaces between them). -fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { - let text_of = |n: &Arc| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }; - let texts: Vec = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); - // A page with this many pieces of type in one place isn't one of lines of a story. - if texts.len() > MAX_PIECES { - return; - } - let mut taken = vec![false; parts.len()]; - // The first piece of each line to join, the others, and the spaces to put after each piece. - let mut joins: Vec<(usize, Vec, Vec)> = vec![]; - for &i in &texts { - if taken.get(i).copied().unwrap_or(true) { - continue; - } - let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue }; - let group: Vec = texts - .iter() - .copied() - .filter(|j| { - !taken.get(*j).copied().unwrap_or(true) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE) - }) - .collect(); - for j in &group { - if let Some(t) = taken.get_mut(*j) { - *t = true; - } - } - let pieces: Vec = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect(); - let places: Vec<(f64, f64)> = group - .iter() - .filter_map(|j| parts.get(*j)) - .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((t.xf.as_coeffs()[4], t.xf.as_coeffs()[5])) } else { None }) - .collect(); - let distinct = places.iter().enumerate().all(|(a, p)| places.iter().skip(a + 1).all(|q| (p.0 - q.0).hypot(p.1 - q.1) > 0.01)); - let joined: String = pieces.iter().map(|p| letters(p)).collect(); - if group.len() < 2 || !distinct || joined.is_empty() { - continue; - } - let Some((whole, _)) = lines.iter().find(|(text, _)| letters(text) == joined) else { continue }; - // Where the line has spaces after a piece's last character that the page doesn't draw. - let line_chars: Vec = whole.chars().collect(); - let mut at = 0; - let mut gaps = vec![]; - for (k, piece) in pieces.iter().enumerate() { - for _ in piece.chars().filter(|c| !c.is_whitespace()) { - while line_chars.get(at).is_some_and(|c| c.is_whitespace()) { - at += 1; - } - at += 1; - } - let space: String = line_chars.iter().skip(at).take_while(|c| c.is_whitespace()).collect(); - let next = pieces.get(k + 1); - let drawn = piece.ends_with(char::is_whitespace) || next.is_none_or(|n| n.starts_with(char::is_whitespace)); - gaps.push(if drawn { String::new() } else { space }); - } - if at <= line_chars.len() { - joins.push((group[0], group[1..].to_vec(), gaps)); - } - } - let mut gone: BTreeSet = BTreeSet::new(); - for (first, rest, gaps) in &joins { - let mut runs: Vec> = [first] - .into_iter() - .chain(rest) - .filter_map(|j| parts.get(*j)) - .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.clone()) } else { None }) - .collect(); - for (r, gap) in runs.iter_mut().zip(gaps) { - if let Some(last) = r.last_mut() { - last.text.push_str(gap); - } - } - if let Some(node) = parts.get_mut(*first).map(Arc::make_mut) - && let NodeKind::Text(t) = &mut node.kind - { - t.runs = runs.into_iter().flatten().collect(); - // The layout cached for the first piece is not this text's. - t.cached_bounds = None; - t.cached_baselines.clear(); - } - gone.extend(rest); - } - // The pieces that went into a text, removed once the joins are done: removing as each is made - // would move the places of the pieces that the others still name. - let mut keep = 0; - parts.retain(|_| { - keep += 1; - !gone.contains(&(keep - 1)) - }); -} - -/// Do the regions of made type in the slots that `keep` takes cover most of `object`? -fn covered(object: &[Arc], content: &[Content], keep: impl Fn(usize) -> bool) -> bool { - let Some(r) = extent(object) else { return false }; - let inside: f64 = content - .iter() - .enumerate() - .filter_map(|(k, c)| match c { - Content::Made(_, Some(region), ..) => Some(region.intersect(r).area()).filter(|a| *a > 0.0 && keep(k)), - _ => None, - }) - .sum(); - inside >= 0.5 * r.area().max(1e-9) -} - -/// The box round `parts`. -fn extent(parts: &[Arc]) -> Option { - parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) -} - -/// What goes into each of `infos`' slots: the page's type that the file's text document puts where the -/// page has it, else type made from the text document, else (for a file whose text document can't say) -/// the page's type in painting order. -fn plan( - infos: &[SlotInfo], - stories: &[Option>], - template: Option<(f64, f64)>, - to_doc: Affine, - pages: [&[Vec>]; 2], - warn: &mut Vec, -) -> Vec { - let [shown, hidden] = pages; - let lines: Vec>> = stories - .iter() - .map(|s| { - let (s, t) = (s.as_ref()?, template?); - Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect()) - }) - .collect(); - // The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show - // has them too: a file's page may draw what its layers have hidden since. - let mut pairs: Vec<(f64, usize, usize)> = vec![]; - for (k, _) in infos.iter().enumerate() { - let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue }; - for (j, object) in shown.iter().enumerate() { - if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { - pairs.push((d, k, j)); - } - } - } - pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut owner: Vec> = vec![None; shown.len()]; - for (_, k, j) in pairs { - if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) { - *o = Some(k); - } - } - // Type the text document makes, shown or not: the page's pieces of it aren't needed. - let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); - // Where the made type that doesn't show lies, by slot. - let mut hidden_at: Vec<(usize, Rect)> = vec![]; - let mut taller = 0; - if let Some(t) = template { - for (k, info) in infos.iter().enumerate() { - let Some(Some(story)) = stories.get(k) else { continue }; - let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue }; - let mut region = story.region(t, to_doc); - // Area type and type on a path in the object's own frame: the text document's frame - // of a copied object keeps its original's path. - if let (Some(path), NodeKind::Text(text)) = (&info.art_path, &mut node.kind) { - let size = text.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); - match &mut text.kind { - TextKind::Area { frame } => { - *frame = path.clone(); - region = path.bounds(); - } - TextKind::OnPath { path: on, .. } => { - *on = closed_where_ends_meet(path.clone()); - region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5)); - } - TextKind::Point => {} - } - } - if let (false, Some(r)) = (info.shown, region) { - hidden_at.push((k, r)); - } - if let NodeKind::Text(text) = &mut node.kind - && story.fit_first_line(text) - { - taller += 1; - } - if let Some(c) = content.get_mut(k) { - *c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]); - } - } - } - if taller > 0 { - warn.push(format!( - "area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise" - )); - } - // The file's text document is trusted where it agrees with the page about something (its lines - // start where the page's type does, or the page has type where it lays type out), or where the - // page has no type to disagree with. - let in_region = |object: &[Arc]| covered(object, &content, |_| true); - let trusted = owner.iter().any(Option::is_some) || shown.is_empty() || shown.iter().any(|o| in_region(o)); - if !trusted { - content.iter_mut().for_each(|c| *c = Content::Empty); - } - // Is `object` the type of a text object that doesn't show, as the page draws it: in its frame, - // with its words? (A copied object's lines are where its original's are, so they don't say.) - let hidden_type = |object: &[Arc]| { - let Some(r) = extent(object) else { return false }; - let words: Vec = - object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect(); - hidden_at.iter().any(|(k, place)| { - place.intersect(r).area() >= 0.5 * r.area().max(1e-9) - && lines - .get(*k) - .and_then(Option::as_ref) - .is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) - }) - }; - // The letters of each slot's story, in lower case. - let spelled: Vec = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect(); - // For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in - // its story? All are false unless such slots have the letters of every text between them. - let spelling = |object: &[Arc], content: &[Content]| -> Vec { - let r = extent(object); - let words: Vec = object - .iter() - .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None }) - .filter(|w| !w.is_empty()) - .collect(); - let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str()))); - let near: Vec = content - .iter() - .enumerate() - .map(|(k, c)| match (c, r) { - (Content::Made(_, Some(region), ..), Some(r)) => region.intersect(r).area() > 0.0 && has(k), - _ => false, - }) - .collect(); - let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str()))); - if every { near } else { vec![] } - }; - let mut spare: Vec>> = vec![]; - for (j, object) in shown.iter().enumerate() { - let spells = spelling(object, &content); - let spells = |k: usize| spells.get(k).copied().unwrap_or(false); - match owner.get(j).copied().flatten().filter(|_| trusted) { - // The type of a text object the layers hide: the layers have it, hidden, so the page's - // isn't another text object's. - Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {} - Some(k) => match content.get_mut(k) { - Some(Content::Made(_, _, seen, _)) => *seen = true, - Some(Content::Page(p)) => p.extend(object.iter().cloned()), - Some(c) => *c = Content::Page(object.clone()), - None => {} - }, - // What lies where made type is and has that type's letters is that type as the page draws it. - None if covered(object, &content, spells) => { - let r = extent(object).unwrap_or(Rect::ZERO); - for (k, c) in content.iter_mut().enumerate() { - if let Content::Made(_, Some(region), seen, _) = c - && !region.intersect(r).is_zero_area() - && spells(k) - { - *seen = true; - } - } - } - None if trusted && hidden_type(object) => {} - None => spare.push(object.clone()), - } - } - for (k, c) in content.iter_mut().enumerate() { - if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) { - join_pieces(parts, l); - } - } - // What is left of the page goes by painting order into the shown slots the file can't say. - let loose: Vec = - infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); - if loose.is_empty() { - // Type of the page that no text object says it owns goes with the nearest text object that has - // type of the page; with none, it is left out. - let mut left_out = false; - for object in &spare { - let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c { - Content::Page(parts) => extent(parts).map(|r| r.center()), - Content::Made(_, region, ..) => region.map(|r| r.center()), - _ => None, - }); - let nearest = content - .iter_mut() - .filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c))) - .min_by(|a, b| a.0.total_cmp(&b.0)); - match nearest { - Some((_, Content::Page(parts) | Content::Made(_, _, _, parts))) => parts.extend(object.iter().cloned()), - _ => left_out = true, - } - } - if !spare.is_empty() { - warn.push( - if left_out { - "some of the page's type has no text object in the layers, so it is left out" - } else { - "some of the page's type has no text object of its own in the layers, so it is with another's" - } - .into(), - ); - } - } else { - for (slot, parts) in loose.iter().zip(assign(&spare, loose.len())) { - if let Some(c) = content.get_mut(*slot) { - // A text object the page shows as part of another's has nothing to hold of its own. - *c = if parts.is_empty() && !spare.is_empty() { - Content::Merged - } else { - Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()) - }; - } - } - if !spare.is_empty() && spare.len() != loose.len() { - warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into()); - } - } - // The same for the slots that don't show. - let unread: Vec = - infos.iter().enumerate().filter(|(k, i)| !i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); - for (slot, parts) in unread.iter().zip(assign(hidden, unread.len())) { - if let Some(c) = content.get_mut(*slot) { - *c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()); - } - } - content -} - -/// `doc` with its layers that don't print hidden, when it has any. -fn as_printed(doc: &Document) -> Option { - fn hide(nodes: &mut [Arc]) -> bool { - let mut any = false; - for n in nodes { - if matches!(n.kind, NodeKind::Layer { printable: false, .. }) && n.visible { - Arc::make_mut(n).visible = false; - any = true; - } else if matches!(n.kind, NodeKind::Layer { .. }) - && let Some(c) = Arc::make_mut(n).children_mut() - { - any |= hide(c); - } - } - any - } - let mut d = doc.clone(); - hide(&mut d.layers).then_some(d) -} - -/// A premultiplied pixel seen on white. -fn on_white(p: &[u8; 4]) -> [u32; 3] { - let paper = 255 - u32::from(p[3]).min(255); - [0, 1, 2].map(|i| (u32::from(p[i]) + paper).min(255)) -} - -/// The lightness (0 to 255, Rec. 601 weights) of a pixel seen on white. -fn lightness([r, g, b]: [u32; 3]) -> u32 { - (r * 299 + g * 587 + b * 114) / 1000 -} - -/// How far (0 to 255) a pixel seen on white is from white: how much ink it has. -fn ink([r, g, b]: [u32; 3]) -> u32 { - 255 - r.min(g).min(b) -} - -/// How far apart two pixels' lightness, or their ink where one is white, may be for them to look -/// alike. -const SAME_PIXEL: u32 = 48; -/// The most ink a pixel seen as white has. -const WHITE: u32 = 8; - -/// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other -/// is white? -fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { - let (p, q) = (on_white(p), on_white(q)); - let (a, b) = (ink(p), ink(q)); - lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) -} - -/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour -/// and its CMYK equivalent stay within it; a red and a dark red don't). -const SAME_COLOUR: u32 = 64; - -/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same -/// lightness to be alike)? -fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { - let (p, q) = (on_white(p), on_white(q)); - p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR) -} - -/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, of those -/// that differ away from type, and of those whose neighbors all differ too, away from type (where -/// one draws an object that the other doesn't, not the edges of one drawn a little differently, or -/// type laid out a little differently: a line the page sets in pieces is laid out afresh as one). -#[derive(Clone, Copy)] -struct Difference { - any: f64, - art: f64, - solid: f64, -} - -/// The scale the comparisons draw a region at: about 400 pixels along its longer side. -fn compare_scale(r: Rect) -> f64 { - (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) -} - -/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases -/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher -/// resolution and apply the same reconstruction filter; keep the existing difference limits. -fn comparison_samples(doc: &Document) -> u32 { - let (mut images, mut vector) = (false, false); - doc.walk(|n| match n.kind { - NodeKind::Image(_) => images = true, - NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} - _ => vector = true, - }); - if images && !vector { 4 } else { 1 } -} - -/// Render for the page check. The comparison scale bounds the long side to 400 px, so the -/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha. -fn comparison_render( - renderer: &mut vectorcraft_render::Renderer, - doc: &Document, - rect: Rect, - scale: f64, - samples: u32, -) -> vectorcraft_render::Rendered { - let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false); - if samples == 1 { - return rendered; - } - let (width, height) = match vectorcraft_render::raster_size(rect, scale) { - Ok(size) => size, - // A malformed region cannot provide a comparable raster. - Err(_) => return rendered, - }; - let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else { - return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() }; - }; - let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw(); - vectorcraft_render::Rendered { width, height, pixels } -} - -/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers -/// with a renderer of their own, which keeps their decoded images between draws (the page's images -/// can share their keys and not their pixels, so the two don't share one). -struct PageView { - page: vectorcraft_render::Rendered, - /// Where the page's own type is, in its pixels. - page_type: Vec, - /// Where objects read with their art style lie, in its pixels: the page has Illustrator's - /// rendering of the style there, the layers VectorCraft's live effects, and the two aren't - /// held to look alike. - restyled: Vec, - layers: vectorcraft_render::Renderer, - scale: f64, - samples: u32, -} - -impl PageView { - fn new(frame: &Frame<'_>) -> Self { - let ra = frame.page_rect; - let scale = compare_scale(ra); - // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in - // the app that wrote the file. - let samples = comparison_samples(frame.page); - let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); - let mut page_type = vec![]; - type_areas(&frame.page.layers, ra, scale, &mut page_type); - Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples } - } - - /// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out - /// of the comparisons ([`Structure::restyled`](ai::Structure)). - fn restyled(&mut self, looks: &[Rect], region: Rect) { - let s = self.scale; - self.restyled = looks - .iter() - .map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH)) - .map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s)) - .collect(); - } - - /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). - fn difference(&mut self, b: &Document, rb: Rect) -> Difference { - // The page doesn't print what isn't printed. - let printed = as_printed(b); - let b = printed.as_ref().unwrap_or(b); - let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); - let mut areas = self.page_type.clone(); - type_areas(&b.layers, rb, self.scale, &mut areas); - compared(&self.page, &y, &areas, &self.restyled) - } - - /// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page - /// ([`differ_in_colour`]), type included: what taking an object out of the layers changes - /// there, when it is covered by one of about the same lightness (#1102). - fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 { - let printed = as_printed(b); - let b = printed.as_ref().unwrap_or(b); - let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); - if (y.width, y.height) != (self.page.width, self.page.height) { - return 1.0; - } - let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count(); - differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0) - } -} - -/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]), -/// for a region other than the page's ([`PageView`] draws that one once). -fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { - let printed = as_printed(b); - let b = printed.as_ref().unwrap_or(b); - let scale = compare_scale(ra); - // A renderer each: the two documents' images can share their keys and not their pixels. - let samples = comparison_samples(a); - let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples); - let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples); - let mut areas = vec![]; - type_areas(&a.layers, ra, scale, &mut areas); - type_areas(&b.layers, rb, scale, &mut areas); - compared(&x, &y, &areas, &[]) -} - -/// How two drawings of the same size differ, each drawn on nothing and seen on white, away from -/// the type in `areas` (pixels), pixels in `skip` counting as alike. Pixels are compared by lightness, and by whether they are inked -/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, -/// and what the comparison looks for is art that is missing or out of place. -fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> Difference { - if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { - return Difference { any: 1.0, art: 1.0, solid: 1.0 }; - } - let (w, h) = (x.width as usize, x.height as usize); - // Pixels in `skip` count as alike. - let skipped = in_areas(skip, w, h); - let differs: Vec = - x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); - let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false); - let typed = in_areas(areas, w, h); - let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); - // A pixel on the region's edge has neighbours outside it, which don't count as differing. - let solid = (1..h.saturating_sub(1)) - .flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) - .filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n)))) - .count(); - let area = (w as f64 * h as f64).max(1.0); - Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, art: art as f64 / area, solid: solid as f64 / area } -} - -/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin -/// for the strokes round it). -fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec) { - for n in nodes.iter().filter(|n| n.visible) { - if let NodeKind::Text(_) = n.kind - && let Some(b) = n.visual_bounds() - { - let px = - |r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale); - out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH))); - } else if let Some(c) = n.children() { - type_areas(c, region, scale, out); - } - } -} - -/// Which of `w` × `h` pixels (row by row) lie in one of `areas`. -fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec { - // Each area adds one inside it: corners marked, then summed along the rows and down the columns. - let stride = w + 1; - let mut count = vec![0i64; stride * (h + 1)]; - let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; - for r in areas { - let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)); - for (x, y, d) in [(x0, y0, 1), (x1, y0, -1), (x0, y1, -1), (x1, y1, 1)] { - if let Some(c) = count.get_mut(y * stride + x) { - *c += d; - } - } - } - for row in count.chunks_mut(stride) { - let mut run = 0; - for c in row { - run += *c; - *c = run; - } - } - let mut column = vec![0i64; stride]; - for row in count.chunks_mut(stride) { - for (c, sum) in row.iter_mut().zip(column.iter_mut()) { - *sum += *c; - *c = *sum; - } - } - (0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect() -} - -/// More of the page than this differing between the layers' art and the page's own, and the -/// layers aren't used. -const MAX_DIFFERENCE: f64 = 0.05; -/// More than this and the import says so. -const NOTABLE_DIFFERENCE: f64 = 0.002; -/// More of the page than this where one draws an object that the other doesn't (see -/// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page. -const MAX_SOLID_DIFFERENCE: f64 = 0.001; - -/// Which box of the editing copy is the page. -#[derive(Clone, Copy)] -enum Page { - /// An EPS's page is its art's bounding box. - Art, - /// A `.ai` is read on its (first and only) artboard. - Artboard, -} - -/// The page (`visible`, the file read as its page) and the place of its area in the layers' -/// document. -struct Frame<'a> { - page: &'a Document, - /// The page's area in `page`. - page_rect: Rect, - /// The same area in the layers' document. - rect: Rect, - /// The page holds all the art that prints (an EPS's page is its art's box, unless it was - /// saved as its artboard): the layers print nothing outside it. - whole: bool, -} - -/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the -/// file read as its page, put in its text objects → the document and its notes. Without a page -/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and -/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in -/// the text objects. `unit`: the size in points of a unit of the page's user space (see -/// [`layered_ai`]). -fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool, unit: f64) -> Result<(Document, Vec), String> { - // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art - // space, as its PDF part may (#2031). - let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } = - ai::read(data, matches!(page, Page::Art))?; - // Editing data in the units of the page's user space ([`in_units`]) is compared with the page - // scaled down to those units, and the document read from it is scaled up by `unit`. - let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); - let small = visible.filter(|v| matches!(page, Page::Artboard) && in_units(board, v, unit)).map(|v| { - let mut v = v.clone(); - scale_document(&mut v, 1.0 / unit); - v - }); - let visible = small.as_ref().or(visible); - let (mut shown, mut hidden) = (vec![], vec![]); - // The glyphs the page kept as outlines that no text object of it takes in, one by one. - let mut lone: Vec>> = vec![]; - let mut frame = None; - if let Some(visible) = visible { - let art_box = match page { - Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, - Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?, - }; - let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?; - // The layers' art in the document's terms: a Large Canvas editing copy is written at 1/N - // scale, which `to_doc` carries, so its art and the page reach the same size (#2031). - let rect = to_doc.transform_rect_bbox(art_box); - if (rect.width() - page_rect.width()).abs() > 0.5 || (rect.height() - page_rect.height()).abs() > 0.5 { - // Both sizes in points as the fields show them, and the editing data's page to the - // file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and - // the scale doesn't round to 1:1. - let n = |v: f64| Unit::Points.number(v); - let (w, h, pw, ph) = (n(rect.width()), n(rect.height()), n(page_rect.width()), n(page_rect.height())); - let k = page_rect.width() / rect.width(); - let factor = n(k.max(1.0 / k)); - if k.is_finite() && k > 0.0 && (rect.height() * k - page_rect.height()).abs() <= 0.5 && factor != "1" { - let scale = if k >= 1.0 { format!("1:{factor}") } else { format!("{factor}:1") }; - return Err(format!("its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale}")); - } - return Err(format!("its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page")); - } - // The page's area in the layers' document, and the page's type moved there. - let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0)); - for l in &visible.layers { - if let Some(c) = l.children() { - text_objects(c, l.visible, &mut shown, &mut hidden); - } - } - let taken: BTreeSet = shown.iter().chain(&hidden).flatten().map(|n| n.id).collect(); - visible.walk(|n| { - if vectorcraft_pdf::is_glyph_outlines(n) && !taken.contains(&n.id) { - lone.push(vec![Arc::new(n.clone())]); - } - }); - // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each - // goes where the layers have that artboard. - let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); - let same = - |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; - let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards); - for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) { - // The page with most of it, the first for what is on none. - let on = extent(object).and_then(|r| { - let inside = |p: &Rect| p.intersect(r).area().max(0.0); - pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1))) - }); - let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { - Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), - None => shift, - }; - for n in object.iter_mut() { - Arc::make_mut(n).transform(shift, false); - } - } - // An EPS saved as its artboard has the art outside it left out of its page. - let artboard_page = - artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); - let whole = matches!(page, Page::Art) && !artboard_page; - if whole { - // Its page is the file's own (its bounding box), as when it comes in as its page: art - // reaching past the editing data's artboard stays on it (#1102). - doc.artboards.truncate(1); - if let Some(a) = doc.artboards.first_mut() { - a.rect = rect; - } - } - frame = Some(Frame { page: visible, page_rect, rect, whole }); - } - let mut infos = vec![]; - collect_slots(&doc.layers, true, &mut infos); - // Each text object's own frame or path, by its story and frame. - let frames = ai::text_frames(data); - for info in &mut infos { - info.art_path = info.story.and_then(|s| frames.get(&(s, info.frame))).map(|p| PathData::from_bezpath(&(to_doc * p.clone()))); - } - // Type asked for as outlines is the page's: the text objects that show can't be filled. - if outlined && infos.iter().any(|i| i.shown) { - return Err("its type is to come in as outlines, which only its page has".into()); - } - if infos.iter().all(|i| !i.shown) && !shown.is_empty() { - return Err("its page has text, and its layers have no text objects to put it in".into()); - } - // The page's type is compared with every text object of the layers: that has its limits. - if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { - return Err("it has too many text objects".into()); - } - let texts = Texts::read(data); - // Each story is read once, however many text objects name it. - let mut read: std::collections::BTreeMap>> = Default::default(); - let stories: Vec>> = infos - .iter() - .map(|i| { - let n = i.story?; - read.entry(n).or_insert_with(|| texts.as_ref().and_then(|t| t.story(n as usize)).map(Arc::new)).clone() - }) - .collect(); - if stories.iter().flatten().map(|s| s.len()).sum::() > MAX_TEXT { - return Err("it has too much text".into()); - } - let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); - let mut notes = warnings; - let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes); - // The glyphs left out with the rest of the page's art, and how many text objects take type of - // the page right next to them (not counted past `MAX_PAIRS` comparisons). - let compared = lone.len().saturating_mul(shown.iter().chain(&hidden).map(Vec::len).sum()); - let missing: Vec = if compared > MAX_PAIRS { vec![] } else { lone.iter().filter_map(|g| extent(g)).collect() }; - let next_to_missing = |n: &Arc| { - matches!(n.kind, NodeKind::Text(_)) - && n.visual_bounds().is_some_and(|b| missing.iter().any(|r| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(*r).is_zero_area())) - }; - let short = content.iter().filter(|c| matches!(c, Content::Page(p) | Content::Made(_, _, _, p) if p.iter().any(next_to_missing))).count(); - let mut empty = 0; - let mut layers = std::mem::take(&mut doc.layers); - let mut made = vec![]; - fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]); - doc.layers = layers; - // A story in several frames is threaded type: its frames in order (the first holds the story). - made.sort(); - for thread in made.chunk_by(|a, b| a.0 == b.0) { - if thread.len() > 1 { - doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect()); - } - } - if empty > 0 { - notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); - } - if short > 0 { - notes.push(format!("{short} {PAGE_TYPE_WITHOUT_OUTLINES}")); - } - if !hidden_unread.is_empty() { - notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread))); - } - match frame { - Some(frame) => { - let mut view = PageView::new(&frame); - view.restyled(&restyled, frame.rect); - let unseen = prune_unseen(&frame, &mut view, &mut doc); - if unseen > 0 { - notes.push(format!("{unseen} {UNSEEN_TEXT}")); - } - match compare(&frame, &mut view, &doc) { - Ok(note) => notes.extend(note), - Err(why) if trust => notes.push(format!("{why}: they come from its editing data all the same")), - Err(why) => return Err(why), - } - } - None => unmark(&mut doc.layers, true, true, &mut vec![]), - } - doc.limit_depth_noting(&mut notes); - if small.is_some() { - scale_document(&mut doc, unit); - } - Ok((doc, notes)) -} - -/// Does the editing data's first artboard `board` (in the document read from it: art space through -/// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above -/// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a -/// unit (their `UserUnit`). -fn in_units(board: Option, page: &Document, unit: f64) -> bool { - let (Some(board), Some(p)) = (board, page.artboards.first().map(|a| a.rect)) else { return false }; - let fits = |k: f64| (board.width() - p.width() / k).abs() <= 0.5 && (board.height() - p.height() / k).abs() <= 0.5; - unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0) -} - -/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes, -/// effects and pattern tiles. -fn scale_document(doc: &mut Document, k: f64) { - let a = Affine::scale(k); - let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; - for layer in &mut doc.layers { - Arc::make_mut(layer).transform(a, scaling); - } - for board in &mut doc.artboards { - board.rect = a.transform_rect_bbox(board.rect); - } -} - -/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's -/// text document. -const MADE: &str = "\u{1}made"; -/// How many such objects are weighed against the page (each takes a render of the page's size). -const MAX_WEIGHED: usize = 12; -/// The text objects the file keeps that the page doesn't draw. -const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't draw (what is left of type turned to outlines) are left out"; -/// The text objects that hold the page's type next to glyphs the page kept as outlines, which are -/// left out. -const PAGE_TYPE_WITHOUT_OUTLINES: &str = "text objects hold their type as the page draws it, without the characters the page has only as outlines"; -/// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. -const UNSEEN_MARGIN: f64 = 5e-5; - -/// Clear the marks in `nodes` and say which marked objects show and print, by id. -fn unmark(nodes: &mut [Arc], shown: bool, prints: bool, found: &mut Vec<(NodeId, Option)>) { - for n in nodes { - let shown = shown && n.visible; - let prints = prints && !matches!(n.kind, NodeKind::Layer { printable: false, .. }); - if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) { - if shown && prints { - found.push((n.id, n.visual_bounds())); - } - let name = (!name.is_empty()).then(|| name.to_string()); - Arc::make_mut(n).name = name; - } else if !matches!(n.kind, NodeKind::Compound { .. }) - && let Some(c) = Arc::make_mut(n).children_mut() - { - unmark(c, shown, prints, found); - } - } -} - -/// Take object `id` out of `nodes` (`hide`: leave it, hidden); whether it was there. -fn take_out(nodes: &mut Vec>, id: NodeId, hide: bool) -> bool { - if let Some(i) = nodes.iter().position(|n| n.id == id) { - if hide { - if let Some(n) = nodes.get_mut(i) { - Arc::make_mut(n).visible = false; - } - } else { - nodes.remove(i); - } - return true; - } - nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide))) -} - -/// The text objects made from the file's text document that show and print on the page's area, -/// where the page has no type to match them, and that the layers look no worse without, are not -/// drawn by the app that wrote the file (which paints every shown text object; it keeps the type -/// of what was turned to outlines): they are taken out. How many. -fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize { - let mut found = vec![]; - unmark(&mut doc.layers, true, true, &mut found); - let rect = frame.rect; - if found.is_empty() || found.len() > MAX_WEIGHED { - return 0; - } - let with = view.difference(doc, rect).any; - // Taking a text object out changes only what is drawn in its box. When the art would still - // differ from the page by more than `compare` allows with every candidate gone, the layers - // aren't used whatever is pruned: don't draw them again for each one (#758). - let margin = OUTLINE_REACH + 1.0; - let boxes: f64 = found.iter().filter_map(|(_, b)| *b).map(|b| b.inflate(margin, margin).intersect(rect).area().max(0.0)).sum(); - if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE { - return 0; - } - // Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no - // less there for being about as light (#1102). - let mut with = view.colour_difference(doc, rect); - let mut removed = 0; - for (id, bounds) in found { - if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { - continue; - } - let mut without = doc.clone(); - if !take_out(&mut without.layers, id, true) { - continue; - } - let d = view.colour_difference(&without, rect); - if d - with <= UNSEEN_MARGIN { - take_out(&mut doc.layers, id, false); - with = d; - removed += 1; - } - } - removed -} - -/// How far (points) the art the layers print may reach past a page that holds it all. -const PAST_THE_PAGE: f64 = 1.0; - -/// Do the layers' art and the page's own look alike (where the page holds all the art that prints, -/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little: -/// with the share of the pixels that differ away from type when that is notable, otherwise with the -/// share of those that differ in the type's areas. -fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result, String> { - let mut diff = view.difference(layered, frame.rect); - if frame.whole - && let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered)) - .filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a)) - { - // The page and the art the layers print beyond it, each in its own document's place. - let all = frame.rect.union(art); - let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0); - let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all); - diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) }; - } - if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE { - return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0)); - } - if diff.any <= NOTABLE_DIFFERENCE { - return Ok(None); - } - Ok(Some(if diff.art > NOTABLE_DIFFERENCE { - format!( - "the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)", - diff.art * 100.0 - ) - } else { - format!( - "the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)", - (diff.any - diff.art) * 100.0 - ) - })) -} - -/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or -/// its program in the legacy format, see [`editing_copy`]): its layers (the -/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the -/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it -/// has one that can't be used, `visible` with a warning that says why. -pub(super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { - let Some(data) = editing_copy(ps) else { return visible }; - let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0)); - match result { - Ok((document, mut warnings)) => { - for w in &visible.warnings { - if !warnings.contains(w) { - warnings.push(w.clone()); - } - } - Imported { document, warnings, preview: false } - } - Err(why) => { - let mut v = visible; - v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); - v - } - } -} - -/// The `.ai` `visible` (read as its PDF part), or a PDF saved with the editing data, read through -/// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead, -/// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the -/// PDF part's bleed), and the art that lies outside them, which its PDF part doesn't have. -/// `warnings` are the notes of reading the PDF part; the notes returned are those, then the layers' -/// notes, or a note saying why the layers weren't read when the editing data can't be decompressed -/// or its layers can't be read. The flag returned is `true` when the document is the one read from -/// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing -/// data was decompressed but its layers can't be read. `outlined`: the PDF part's type was read as -/// outlines (a file whose type shows then comes in as its PDF part). -/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. -/// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, -/// by which its art came in scaled. Editing data whose artboard matches the page only in those -/// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from -/// it. -pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool, trust: bool, unit: f64) -> (Document, Vec, bool) { - let unread = |mut all: Vec, why: &str| { - all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); - all - }; - let data = match ai::decode_private(private) { - Ok(data) => data, - Err(why) => return (visible, unread(warnings, &why), false), - }; - if !has_layers(&data) { - return (visible, warnings, false); - } - // The PDF part, its pages placed and named as the editing data's artboards are, when the - // layers can't be used (`why`). - let from_pdf_part = |mut visible: Document, warnings: Vec, why: &str| { - place_pages(&ai::artboards(&data), &mut visible, unit); - let mut all = unread(warnings, why); - all.extend(ai::variables(&data).note()); - (visible, all, false) - }; - match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { - // An image too large for the editing data's reader would be missing from the layers, while - // the PDF part has it: the file comes in as its PDF part instead (#1479). - Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { - from_pdf_part(visible, warnings, "an image is too large to read from them") - } - Ok((mut done, notes)) => { - done.setup.bleed = visible.setup.bleed; - let mut all = warnings; - all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::>()); - (done, all, true) - } - Err(why) => from_pdf_part(visible, warnings, &why), - } -} - -/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as -/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on -/// each, to where they are, so the artboards keep their names and layout when the layers can't be -/// read (#1068). Left as they are unless there are as many artboards as pages, each the size of its -/// page in points, or, when `unit` is above 1, each the size of its page in units of `unit` points: -/// then the layout is drawn `unit` times as large. -fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { - if boards.is_empty() || boards.len() != doc.artboards.len() { - return; - } - let differ = |k: f64| { - boards - .iter() - .zip(&doc.artboards) - .any(|((_, b), a)| (b.width() - a.rect.width() / k).abs() > 0.5 || (b.height() - a.rect.height() / k).abs() > 0.5) - }; - let k = if !differ(1.0) { - 1.0 - } else if unit.is_finite() && unit > 1.0 && !differ(unit) { - unit - } else { - return; - }; - for ((name, _), a) in boards.iter().zip(&mut doc.artboards) { - a.name.clone_from(name); - } - let [(_, first), _, ..] = boards else { return }; - // Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it, - // `k` points a unit. - let to_doc = Affine::new([k, 0.0, 0.0, -k, -first.x0 * k, first.y1 * k]); - let pages: Vec = doc.artboards.iter().map(|a| a.rect).collect(); - let moves: Vec = boards.iter().zip(&pages).map(|((_, b), page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect(); - for layer in &mut doc.layers { - place_art(Arc::make_mut(layer), &pages, &moves); - } - for (a, v) in doc.artboards.iter_mut().zip(&moves) { - a.rect = a.rect + *v; - } -} - -/// The objects in `layer` (and its sublayers) moved by the move of the page they are on. -fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { - let Some(children) = layer.children_mut() else { return }; - for child in children { - if matches!(child.kind, NodeKind::Layer { .. }) { - place_art(Arc::make_mut(child), pages, moves); - continue; - } - let Some(r) = child.visual_bounds() else { continue }; - // The page it is on: the one with most of it (pages keep only the art that reaches into - // them), else the nearest. - let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot()); - let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue }; - if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) { - Arc::make_mut(child).transform(Affine::translate(*v), false); - } - } -} - -/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy -/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. -pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { - let data = ai::decode_private(private)?; - build(&data, None, Page::Artboard, false, false, 1.0) -} - -/// Does the editing data (or a program in the legacy format) have layers? -fn has_layers(data: &[u8]) -> bool { - data.windows(15).any(|w| w == b"%AI5_BeginLayer") -} - -/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program -/// itself when it has layers (the legacy format). `None` without one. -fn editing_copy(ps: &[u8]) -> Option, String>> { - match ai::eps_data(ps) { - Some(data) => Some(data.map(Cow::Owned)), - None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))), - } -} - -/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its -/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the -/// document and the import's notes. A file whose header says it is in the format (`illustrator`) -/// but whose program has no layers (Illustrator 3 had none; image editors still export their -/// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program -/// has no layers and isn't in the format: other PostScript isn't read as the format's operators. -pub(super) fn program_alone(ps: &[u8], illustrator: bool) -> Option), String>> { - if ai::has_eps_data(ps) { - return None; - } - if has_layers(ps) { - return Some(build(ps, None, Page::Art, false, false, 1.0)); - } - let program = one_layer(ps).filter(|_| illustrator)?; - Some(build(&program, None, Page::Art, false, false, 1.0)) -} - -/// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its -/// end) on a layer that shows, prints and isn't locked, as the version 5 format writes one. `None` -/// without a `%%EndSetup`: where the art starts isn't known. -fn one_layer(ps: &[u8]) -> Option> { - let setup = line_with(ps, 0, b"%%EndSetup")?; - let art = line_with(ps, setup + 1, b"").unwrap_or(ps.len()); - let end = line_with(ps, art, b"%%PageTrailer").or_else(|| line_with(ps, art, b"%%Trailer")).unwrap_or(ps.len()); - let mut out = Vec::with_capacity(ps.len() + 80); - out.extend_from_slice(ps.get(..art)?); - out.extend_from_slice(b"\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Layer 1) Ln\n"); - out.extend_from_slice(ps.get(art..end)?); - out.extend_from_slice(b"\nLB\n%AI5_EndLayer--\n"); - out.extend_from_slice(ps.get(end..)?); - Some(out) -} - -/// Where the first line of `ps` from `from` on that starts with `key` starts. -fn line_with(ps: &[u8], from: usize, key: &[u8]) -> Option { - (from..ps.len()).find(|&i| (i == 0 || matches!(ps.get(i - 1), Some(b'\n' | b'\r'))) && ps.get(i..).is_some_and(|rest| rest.starts_with(key))) -} +mod build; +mod compare; +mod layers; +mod plan; +mod slots; + +#[cfg(test)] +use build::{Frame, MADE, prune_unseen}; +#[cfg(test)] +use compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour}; +pub use layers::{ai_alone, layered_ai}; +pub(super) use layers::{layered, program_alone}; +pub use slots::is_loss; +#[cfg(test)] +use slots::join_pieces; #[cfg(test)] mod tests { @@ -1659,48 +169,3 @@ mod tests { assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error"); } } - -/// `path` with each open subpath whose last anchor lies on its first (within a thousandth of a -/// point) closed there: the object's own path of type round a closed path is written open, ending -/// on its start, and type on an open path stops at its end, so it had no room to run past it. -fn closed_where_ends_meet(mut path: PathData) -> PathData { - for sp in &mut path.subpaths { - let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; - if sp.closed || sp.anchors.len() < 3 || first.p.distance(last.p) >= 1e-3 { - continue; - } - if let Some(l) = sp.anchors.pop() - && let Some(a0) = sp.anchors.first_mut() - { - a0.h_in = l.h_in; - } - sp.closed = true; - } - path -} - -#[cfg(test)] -mod closed_ends_tests { - use super::*; - - /// A path drawn round to its start (the last anchor on the first) is closed there, its first - /// anchor taking the last one's incoming handle; one whose ends are apart stays open. - #[test] - fn a_path_ending_on_its_start_is_closed() { - let mut bp = kurbo::BezPath::new(); - bp.move_to((100.0, 0.0)); - bp.curve_to((100.0, 55.0), (55.0, 100.0), (0.0, 100.0)); - bp.curve_to((-55.0, 100.0), (-100.0, 55.0), (-100.0, 0.0)); - bp.curve_to((-100.0, -55.0), (-55.0, -100.0), (0.0, -100.0)); - bp.curve_to((55.0, -100.0), (100.0, -55.0), (100.0, 0.0001)); - let closed = closed_where_ends_meet(PathData::from_bezpath(&bp)); - let sp = &closed.subpaths[0]; - assert!(sp.closed && sp.anchors.len() == 4, "{sp:?}"); - assert_eq!(sp.anchors[0].h_in, Point::new(100.0, -55.0)); - let mut open = kurbo::BezPath::new(); - open.move_to((0.0, 0.0)); - open.line_to((50.0, 0.0)); - open.line_to((50.0, 50.0)); - assert!(!closed_where_ends_meet(PathData::from_bezpath(&open)).subpaths[0].closed); - } -} diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs new file mode 100644 index 000000000..9441da025 --- /dev/null +++ b/crates/eps/src/import/native/build.rs @@ -0,0 +1,333 @@ +use super::compare::{MAX_DIFFERENCE, PageView, compare}; +use super::plan::plan; +use super::slots::{ + ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, fill_slots, + text_objects, +}; +use super::*; + +/// Which box of the editing copy is the page. +#[derive(Clone, Copy)] +pub(super) enum Page { + /// An EPS's page is its art's bounding box. + Art, + /// A `.ai` is read on its (first and only) artboard. + Artboard, +} + +/// The page (`visible`, the file read as its page) and the place of its area in the layers' +/// document. +pub(super) struct Frame<'a> { + pub(super) page: &'a Document, + /// The page's area in `page`. + pub(super) page_rect: Rect, + /// The same area in the layers' document. + pub(super) rect: Rect, + /// The page holds all the art that prints (an EPS's page is its art's box, unless it was + /// saved as its artboard): the layers print nothing outside it. + pub(super) whole: bool, +} + +/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the +/// file read as its page, put in its text objects → the document and its notes. Without a page +/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and +/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in +/// the text objects. `unit`: the size in points of a unit of the page's user space (see +/// [`layered_ai`]). +pub(super) fn build( + data: &[u8], + visible: Option<&Document>, + page: Page, + outlined: bool, + trust: bool, + unit: f64, +) -> Result<(Document, Vec), String> { + // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art + // space, as its PDF part may (#2031). + let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } = + ai::read(data, matches!(page, Page::Art))?; + // Editing data in the units of the page's user space ([`in_units`]) is compared with the page + // scaled down to those units, and the document read from it is scaled up by `unit`. + let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); + let small = visible.filter(|v| matches!(page, Page::Artboard) && in_units(board, v, unit)).map(|v| { + let mut v = v.clone(); + scale_document(&mut v, 1.0 / unit); + v + }); + let visible = small.as_ref().or(visible); + let (mut shown, mut hidden) = (vec![], vec![]); + // The glyphs the page kept as outlines that no text object of it takes in, one by one. + let mut lone: Vec>> = vec![]; + let mut frame = None; + if let Some(visible) = visible { + let art_box = match page { + Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, + Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?, + }; + let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?; + // The layers' art in the document's terms: a Large Canvas editing copy is written at 1/N + // scale, which `to_doc` carries, so its art and the page reach the same size (#2031). + let rect = to_doc.transform_rect_bbox(art_box); + if (rect.width() - page_rect.width()).abs() > 0.5 || (rect.height() - page_rect.height()).abs() > 0.5 { + // Both sizes in points as the fields show them, and the editing data's page to the + // file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and + // the scale doesn't round to 1:1. + let n = |v: f64| Unit::Points.number(v); + let (w, h, pw, ph) = (n(rect.width()), n(rect.height()), n(page_rect.width()), n(page_rect.height())); + let k = page_rect.width() / rect.width(); + let factor = n(k.max(1.0 / k)); + if k.is_finite() && k > 0.0 && (rect.height() * k - page_rect.height()).abs() <= 0.5 && factor != "1" { + let scale = if k >= 1.0 { format!("1:{factor}") } else { format!("{factor}:1") }; + return Err(format!("its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale}")); + } + return Err(format!("its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page")); + } + // The page's area in the layers' document, and the page's type moved there. + let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0)); + for l in &visible.layers { + if let Some(c) = l.children() { + text_objects(c, l.visible, &mut shown, &mut hidden); + } + } + let taken: BTreeSet = shown.iter().chain(&hidden).flatten().map(|n| n.id).collect(); + visible.walk(|n| { + if vectorcraft_pdf::is_glyph_outlines(n) && !taken.contains(&n.id) { + lone.push(vec![Arc::new(n.clone())]); + } + }); + // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each + // goes where the layers have that artboard. + let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); + let same = + |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; + let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards); + for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) { + // The page with most of it, the first for what is on none. + let on = extent(object).and_then(|r| { + let inside = |p: &Rect| p.intersect(r).area().max(0.0); + pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1))) + }); + let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { + Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), + None => shift, + }; + for n in object.iter_mut() { + Arc::make_mut(n).transform(shift, false); + } + } + // An EPS saved as its artboard has the art outside it left out of its page. + let artboard_page = + artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); + let whole = matches!(page, Page::Art) && !artboard_page; + if whole { + // Its page is the file's own (its bounding box), as when it comes in as its page: art + // reaching past the editing data's artboard stays on it (#1102). + doc.artboards.truncate(1); + if let Some(a) = doc.artboards.first_mut() { + a.rect = rect; + } + } + frame = Some(Frame { page: visible, page_rect, rect, whole }); + } + let mut infos = vec![]; + collect_slots(&doc.layers, true, &mut infos); + // Each text object's own frame or path, by its story and frame. + let frames = ai::text_frames(data); + for info in &mut infos { + info.art_path = info.story.and_then(|s| frames.get(&(s, info.frame))).map(|p| PathData::from_bezpath(&(to_doc * p.clone()))); + } + // Type asked for as outlines is the page's: the text objects that show can't be filled. + if outlined && infos.iter().any(|i| i.shown) { + return Err("its type is to come in as outlines, which only its page has".into()); + } + if infos.iter().all(|i| !i.shown) && !shown.is_empty() { + return Err("its page has text, and its layers have no text objects to put it in".into()); + } + // The page's type is compared with every text object of the layers: that has its limits. + if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { + return Err("it has too many text objects".into()); + } + let texts = Texts::read(data); + // Each story is read once, however many text objects name it. + let mut read: std::collections::BTreeMap>> = Default::default(); + let stories: Vec>> = infos + .iter() + .map(|i| { + let n = i.story?; + read.entry(n).or_insert_with(|| texts.as_ref().and_then(|t| t.story(n as usize)).map(Arc::new)).clone() + }) + .collect(); + if stories.iter().flatten().map(|s| s.len()).sum::() > MAX_TEXT { + return Err("it has too much text".into()); + } + let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); + let mut notes = warnings; + let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes); + // The glyphs left out with the rest of the page's art, and how many text objects take type of + // the page right next to them (not counted past `MAX_PAIRS` comparisons). + let compared = lone.len().saturating_mul(shown.iter().chain(&hidden).map(Vec::len).sum()); + let missing: Vec = if compared > MAX_PAIRS { vec![] } else { lone.iter().filter_map(|g| extent(g)).collect() }; + let next_to_missing = |n: &Arc| { + matches!(n.kind, NodeKind::Text(_)) + && n.visual_bounds().is_some_and(|b| missing.iter().any(|r| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(*r).is_zero_area())) + }; + let short = content.iter().filter(|c| matches!(c, Content::Page(p) | Content::Made(_, _, _, p) if p.iter().any(next_to_missing))).count(); + let mut empty = 0; + let mut layers = std::mem::take(&mut doc.layers); + let mut made = vec![]; + fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]); + doc.layers = layers; + // A story in several frames is threaded type: its frames in order (the first holds the story). + made.sort(); + for thread in made.chunk_by(|a, b| a.0 == b.0) { + if thread.len() > 1 { + doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect()); + } + } + if empty > 0 { + notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); + } + if short > 0 { + notes.push(format!("{short} {PAGE_TYPE_WITHOUT_OUTLINES}")); + } + if !hidden_unread.is_empty() { + notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread))); + } + match frame { + Some(frame) => { + let mut view = PageView::new(&frame); + view.restyled(&restyled, frame.rect); + let unseen = prune_unseen(&frame, &mut view, &mut doc); + if unseen > 0 { + notes.push(format!("{unseen} {UNSEEN_TEXT}")); + } + match compare(&frame, &mut view, &doc) { + Ok(note) => notes.extend(note), + Err(why) if trust => notes.push(format!("{why}: they come from its editing data all the same")), + Err(why) => return Err(why), + } + } + None => unmark(&mut doc.layers, true, true, &mut vec![]), + } + doc.limit_depth_noting(&mut notes); + if small.is_some() { + scale_document(&mut doc, unit); + } + Ok((doc, notes)) +} + +/// Does the editing data's first artboard `board` (in the document read from it: art space through +/// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above +/// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a +/// unit (their `UserUnit`). +fn in_units(board: Option, page: &Document, unit: f64) -> bool { + let (Some(board), Some(p)) = (board, page.artboards.first().map(|a| a.rect)) else { return false }; + let fits = |k: f64| (board.width() - p.width() / k).abs() <= 0.5 && (board.height() - p.height() / k).abs() <= 0.5; + unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0) +} + +/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes, +/// effects and pattern tiles. +fn scale_document(doc: &mut Document, k: f64) { + let a = Affine::scale(k); + let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; + for layer in &mut doc.layers { + Arc::make_mut(layer).transform(a, scaling); + } + for board in &mut doc.artboards { + board.rect = a.transform_rect_bbox(board.rect); + } +} + +/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's +/// text document. +pub(super) const MADE: &str = "\u{1}made"; + +/// How many such objects are weighed against the page (each takes a render of the page's size). +const MAX_WEIGHED: usize = 12; + +/// The text objects the file keeps that the page doesn't draw. +const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't draw (what is left of type turned to outlines) are left out"; + +/// The text objects that hold the page's type next to glyphs the page kept as outlines, which are +/// left out. +const PAGE_TYPE_WITHOUT_OUTLINES: &str = "text objects hold their type as the page draws it, without the characters the page has only as outlines"; + +/// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. +const UNSEEN_MARGIN: f64 = 5e-5; + +/// Clear the marks in `nodes` and say which marked objects show and print, by id. +fn unmark(nodes: &mut [Arc], shown: bool, prints: bool, found: &mut Vec<(NodeId, Option)>) { + for n in nodes { + let shown = shown && n.visible; + let prints = prints && !matches!(n.kind, NodeKind::Layer { printable: false, .. }); + if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) { + if shown && prints { + found.push((n.id, n.visual_bounds())); + } + let name = (!name.is_empty()).then(|| name.to_string()); + Arc::make_mut(n).name = name; + } else if !matches!(n.kind, NodeKind::Compound { .. }) + && let Some(c) = Arc::make_mut(n).children_mut() + { + unmark(c, shown, prints, found); + } + } +} + +/// Take object `id` out of `nodes` (`hide`: leave it, hidden); whether it was there. +fn take_out(nodes: &mut Vec>, id: NodeId, hide: bool) -> bool { + if let Some(i) = nodes.iter().position(|n| n.id == id) { + if hide { + if let Some(n) = nodes.get_mut(i) { + Arc::make_mut(n).visible = false; + } + } else { + nodes.remove(i); + } + return true; + } + nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide))) +} + +/// The text objects made from the file's text document that show and print on the page's area, +/// where the page has no type to match them, and that the layers look no worse without, are not +/// drawn by the app that wrote the file (which paints every shown text object; it keeps the type +/// of what was turned to outlines): they are taken out. How many. +pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize { + let mut found = vec![]; + unmark(&mut doc.layers, true, true, &mut found); + let rect = frame.rect; + if found.is_empty() || found.len() > MAX_WEIGHED { + return 0; + } + let with = view.difference(doc, rect).any; + // Taking a text object out changes only what is drawn in its box. When the art would still + // differ from the page by more than `compare` allows with every candidate gone, the layers + // aren't used whatever is pruned: don't draw them again for each one (#758). + let margin = OUTLINE_REACH + 1.0; + let boxes: f64 = found.iter().filter_map(|(_, b)| *b).map(|b| b.inflate(margin, margin).intersect(rect).area().max(0.0)).sum(); + if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE { + return 0; + } + // Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no + // less there for being about as light (#1102). + let mut with = view.colour_difference(doc, rect); + let mut removed = 0; + for (id, bounds) in found { + if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { + continue; + } + let mut without = doc.clone(); + if !take_out(&mut without.layers, id, true) { + continue; + } + let d = view.colour_difference(&without, rect); + if d - with <= UNSEEN_MARGIN { + take_out(&mut doc.layers, id, false); + with = d; + removed += 1; + } + } + removed +} diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs new file mode 100644 index 000000000..ceaa584e2 --- /dev/null +++ b/crates/eps/src/import/native/compare.rs @@ -0,0 +1,322 @@ +use super::build::Frame; +use super::slots::OUTLINE_REACH; +use super::*; + +/// `doc` with its layers that don't print hidden, when it has any. +fn as_printed(doc: &Document) -> Option { + fn hide(nodes: &mut [Arc]) -> bool { + let mut any = false; + for n in nodes { + if matches!(n.kind, NodeKind::Layer { printable: false, .. }) && n.visible { + Arc::make_mut(n).visible = false; + any = true; + } else if matches!(n.kind, NodeKind::Layer { .. }) + && let Some(c) = Arc::make_mut(n).children_mut() + { + any |= hide(c); + } + } + any + } + let mut d = doc.clone(); + hide(&mut d.layers).then_some(d) +} + +/// A premultiplied pixel seen on white. +fn on_white(p: &[u8; 4]) -> [u32; 3] { + let paper = 255 - u32::from(p[3]).min(255); + [0, 1, 2].map(|i| (u32::from(p[i]) + paper).min(255)) +} + +/// The lightness (0 to 255, Rec. 601 weights) of a pixel seen on white. +fn lightness([r, g, b]: [u32; 3]) -> u32 { + (r * 299 + g * 587 + b * 114) / 1000 +} + +/// How far (0 to 255) a pixel seen on white is from white: how much ink it has. +fn ink([r, g, b]: [u32; 3]) -> u32 { + 255 - r.min(g).min(b) +} + +/// How far apart two pixels' lightness, or their ink where one is white, may be for them to look +/// alike. +const SAME_PIXEL: u32 = 48; + +/// The most ink a pixel seen as white has. +const WHITE: u32 = 8; + +/// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other +/// is white? +pub(super) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { + let (p, q) = (on_white(p), on_white(q)); + let (a, b) = (ink(p), ink(q)); + lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) +} + +/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour +/// and its CMYK equivalent stay within it; a red and a dark red don't). +const SAME_COLOUR: u32 = 64; + +/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same +/// lightness to be alike)? +pub(super) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { + let (p, q) = (on_white(p), on_white(q)); + p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR) +} + +/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, of those +/// that differ away from type, and of those whose neighbors all differ too, away from type (where +/// one draws an object that the other doesn't, not the edges of one drawn a little differently, or +/// type laid out a little differently: a line the page sets in pieces is laid out afresh as one). +#[derive(Clone, Copy)] +pub(super) struct Difference { + pub(super) any: f64, + art: f64, + pub(super) solid: f64, +} + +/// The scale the comparisons draw a region at: about 400 pixels along its longer side. +fn compare_scale(r: Rect) -> f64 { + (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) +} + +/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases +/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher +/// resolution and apply the same reconstruction filter; keep the existing difference limits. +pub(super) fn comparison_samples(doc: &Document) -> u32 { + let (mut images, mut vector) = (false, false); + doc.walk(|n| match n.kind { + NodeKind::Image(_) => images = true, + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} + _ => vector = true, + }); + if images && !vector { 4 } else { 1 } +} + +/// Render for the page check. The comparison scale bounds the long side to 400 px, so the +/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha. +pub(super) fn comparison_render( + renderer: &mut vectorcraft_render::Renderer, + doc: &Document, + rect: Rect, + scale: f64, + samples: u32, +) -> vectorcraft_render::Rendered { + let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false); + if samples == 1 { + return rendered; + } + let (width, height) = match vectorcraft_render::raster_size(rect, scale) { + Ok(size) => size, + // A malformed region cannot provide a comparable raster. + Err(_) => return rendered, + }; + let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else { + return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() }; + }; + let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw(); + vectorcraft_render::Rendered { width, height, pixels } +} + +/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers +/// with a renderer of their own, which keeps their decoded images between draws (the page's images +/// can share their keys and not their pixels, so the two don't share one). +pub(super) struct PageView { + page: vectorcraft_render::Rendered, + /// Where the page's own type is, in its pixels. + page_type: Vec, + /// Where objects read with their art style lie, in its pixels: the page has Illustrator's + /// rendering of the style there, the layers VectorCraft's live effects, and the two aren't + /// held to look alike. + restyled: Vec, + layers: vectorcraft_render::Renderer, + scale: f64, + samples: u32, +} + +impl PageView { + pub(super) fn new(frame: &Frame<'_>) -> Self { + let ra = frame.page_rect; + let scale = compare_scale(ra); + // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in + // the app that wrote the file. + let samples = comparison_samples(frame.page); + let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); + let mut page_type = vec![]; + type_areas(&frame.page.layers, ra, scale, &mut page_type); + Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples } + } + + /// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out + /// of the comparisons ([`Structure::restyled`](ai::Structure)). + pub(super) fn restyled(&mut self, looks: &[Rect], region: Rect) { + let s = self.scale; + self.restyled = looks + .iter() + .map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH)) + .map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s)) + .collect(); + } + + /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). + pub(super) fn difference(&mut self, b: &Document, rb: Rect) -> Difference { + // The page doesn't print what isn't printed. + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); + let mut areas = self.page_type.clone(); + type_areas(&b.layers, rb, self.scale, &mut areas); + compared(&self.page, &y, &areas, &self.restyled) + } + + /// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page + /// ([`differ_in_colour`]), type included: what taking an object out of the layers changes + /// there, when it is covered by one of about the same lightness (#1102). + pub(super) fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 { + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); + if (y.width, y.height) != (self.page.width, self.page.height) { + return 1.0; + } + let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count(); + differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0) + } +} + +/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]), +/// for a region other than the page's ([`PageView`] draws that one once). +pub(super) fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let scale = compare_scale(ra); + // A renderer each: the two documents' images can share their keys and not their pixels. + let samples = comparison_samples(a); + let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples); + let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples); + let mut areas = vec![]; + type_areas(&a.layers, ra, scale, &mut areas); + type_areas(&b.layers, rb, scale, &mut areas); + compared(&x, &y, &areas, &[]) +} + +/// How two drawings of the same size differ, each drawn on nothing and seen on white, away from +/// the type in `areas` (pixels), pixels in `skip` counting as alike. Pixels are compared by lightness, and by whether they are inked +/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, +/// and what the comparison looks for is art that is missing or out of place. +pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> Difference { + if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { + return Difference { any: 1.0, art: 1.0, solid: 1.0 }; + } + let (w, h) = (x.width as usize, x.height as usize); + // Pixels in `skip` count as alike. + let skipped = in_areas(skip, w, h); + let differs: Vec = + x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); + let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false); + let typed = in_areas(areas, w, h); + let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); + // A pixel on the region's edge has neighbours outside it, which don't count as differing. + let solid = (1..h.saturating_sub(1)) + .flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) + .filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n)))) + .count(); + let area = (w as f64 * h as f64).max(1.0); + Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, art: art as f64 / area, solid: solid as f64 / area } +} + +/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin +/// for the strokes round it). +fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec) { + for n in nodes.iter().filter(|n| n.visible) { + if let NodeKind::Text(_) = n.kind + && let Some(b) = n.visual_bounds() + { + let px = + |r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale); + out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH))); + } else if let Some(c) = n.children() { + type_areas(c, region, scale, out); + } + } +} + +/// Which of `w` × `h` pixels (row by row) lie in one of `areas`. +fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec { + // Each area adds one inside it: corners marked, then summed along the rows and down the columns. + let stride = w + 1; + let mut count = vec![0i64; stride * (h + 1)]; + let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; + for r in areas { + let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)); + for (x, y, d) in [(x0, y0, 1), (x1, y0, -1), (x0, y1, -1), (x1, y1, 1)] { + if let Some(c) = count.get_mut(y * stride + x) { + *c += d; + } + } + } + for row in count.chunks_mut(stride) { + let mut run = 0; + for c in row { + run += *c; + *c = run; + } + } + let mut column = vec![0i64; stride]; + for row in count.chunks_mut(stride) { + for (c, sum) in row.iter_mut().zip(column.iter_mut()) { + *sum += *c; + *c = *sum; + } + } + (0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect() +} + +/// More of the page than this differing between the layers' art and the page's own, and the +/// layers aren't used. +pub(super) const MAX_DIFFERENCE: f64 = 0.05; + +/// More than this and the import says so. +const NOTABLE_DIFFERENCE: f64 = 0.002; + +/// More of the page than this where one draws an object that the other doesn't (see +/// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page. +pub(super) const MAX_SOLID_DIFFERENCE: f64 = 0.001; + +/// How far (points) the art the layers print may reach past a page that holds it all. +const PAST_THE_PAGE: f64 = 1.0; + +/// Do the layers' art and the page's own look alike (where the page holds all the art that prints, +/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little: +/// with the share of the pixels that differ away from type when that is notable, otherwise with the +/// share of those that differ in the type's areas. +pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result, String> { + let mut diff = view.difference(layered, frame.rect); + if frame.whole + && let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered)) + .filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a)) + { + // The page and the art the layers print beyond it, each in its own document's place. + let all = frame.rect.union(art); + let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0); + let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all); + diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) }; + } + if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE { + return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0)); + } + if diff.any <= NOTABLE_DIFFERENCE { + return Ok(None); + } + Ok(Some(if diff.art > NOTABLE_DIFFERENCE { + format!( + "the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)", + diff.art * 100.0 + ) + } else { + format!( + "the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)", + (diff.any - diff.art) * 100.0 + ) + })) +} diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs new file mode 100644 index 000000000..e0e7eacc0 --- /dev/null +++ b/crates/eps/src/import/native/layers.rs @@ -0,0 +1,242 @@ +use super::build::{Page, build}; +use super::slots::LAYERS_UNREAD; +use super::*; + +/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or +/// its program in the legacy format, see [`editing_copy`]): its layers (the +/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the +/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it +/// has one that can't be used, `visible` with a warning that says why. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { + let Some(data) = editing_copy(ps) else { return visible }; + let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0)); + match result { + Ok((document, mut warnings)) => { + for w in &visible.warnings { + if !warnings.contains(w) { + warnings.push(w.clone()); + } + } + Imported { document, warnings, preview: false } + } + Err(why) => { + let mut v = visible; + v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); + v + } + } +} + +/// The `.ai` `visible` (read as its PDF part), or a PDF saved with the editing data, read through +/// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead, +/// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the +/// PDF part's bleed), and the art that lies outside them, which its PDF part doesn't have. +/// `warnings` are the notes of reading the PDF part; the notes returned are those, then the layers' +/// notes, or a note saying why the layers weren't read when the editing data can't be decompressed +/// or its layers can't be read. The flag returned is `true` when the document is the one read from +/// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing +/// data was decompressed but its layers can't be read. `outlined`: the PDF part's type was read as +/// outlines (a file whose type shows then comes in as its PDF part). +/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. +/// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, +/// by which its art came in scaled. Editing data whose artboard matches the page only in those +/// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from +/// it. +pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool, trust: bool, unit: f64) -> (Document, Vec, bool) { + let unread = |mut all: Vec, why: &str| { + all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); + all + }; + let data = match ai::decode_private(private) { + Ok(data) => data, + Err(why) => return (visible, unread(warnings, &why), false), + }; + if !has_layers(&data) { + return (visible, warnings, false); + } + // The PDF part, its pages placed and named as the editing data's artboards are, when the + // layers can't be used (`why`). + let from_pdf_part = |mut visible: Document, warnings: Vec, why: &str| { + place_pages(&ai::artboards(&data), &mut visible, unit); + let mut all = unread(warnings, why); + all.extend(ai::variables(&data).note()); + (visible, all, false) + }; + match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { + // An image too large for the editing data's reader would be missing from the layers, while + // the PDF part has it: the file comes in as its PDF part instead (#1479). + Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { + from_pdf_part(visible, warnings, "an image is too large to read from them") + } + Ok((mut done, notes)) => { + done.setup.bleed = visible.setup.bleed; + let mut all = warnings; + all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::>()); + (done, all, true) + } + Err(why) => from_pdf_part(visible, warnings, &why), + } +} + +/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as +/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on +/// each, to where they are, so the artboards keep their names and layout when the layers can't be +/// read (#1068). Left as they are unless there are as many artboards as pages, each the size of its +/// page in points, or, when `unit` is above 1, each the size of its page in units of `unit` points: +/// then the layout is drawn `unit` times as large. +fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { + if boards.is_empty() || boards.len() != doc.artboards.len() { + return; + } + let differ = |k: f64| { + boards + .iter() + .zip(&doc.artboards) + .any(|((_, b), a)| (b.width() - a.rect.width() / k).abs() > 0.5 || (b.height() - a.rect.height() / k).abs() > 0.5) + }; + let k = if !differ(1.0) { + 1.0 + } else if unit.is_finite() && unit > 1.0 && !differ(unit) { + unit + } else { + return; + }; + for ((name, _), a) in boards.iter().zip(&mut doc.artboards) { + a.name.clone_from(name); + } + let [(_, first), _, ..] = boards else { return }; + // Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it, + // `k` points a unit. + let to_doc = Affine::new([k, 0.0, 0.0, -k, -first.x0 * k, first.y1 * k]); + let pages: Vec = doc.artboards.iter().map(|a| a.rect).collect(); + let moves: Vec = boards.iter().zip(&pages).map(|((_, b), page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect(); + for layer in &mut doc.layers { + place_art(Arc::make_mut(layer), &pages, &moves); + } + for (a, v) in doc.artboards.iter_mut().zip(&moves) { + a.rect = a.rect + *v; + } +} + +/// The objects in `layer` (and its sublayers) moved by the move of the page they are on. +fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { + let Some(children) = layer.children_mut() else { return }; + for child in children { + if matches!(child.kind, NodeKind::Layer { .. }) { + place_art(Arc::make_mut(child), pages, moves); + continue; + } + let Some(r) = child.visual_bounds() else { continue }; + // The page it is on: the one with most of it (pages keep only the art that reaches into + // them), else the nearest. + let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot()); + let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue }; + if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) { + Arc::make_mut(child).transform(Affine::translate(*v), false); + } + } +} + +/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy +/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. +pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { + let data = ai::decode_private(private)?; + build(&data, None, Page::Artboard, false, false, 1.0) +} + +/// Does the editing data (or a program in the legacy format) have layers? +fn has_layers(data: &[u8]) -> bool { + data.windows(15).any(|w| w == b"%AI5_BeginLayer") +} + +/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program +/// itself when it has layers (the legacy format). `None` without one. +fn editing_copy(ps: &[u8]) -> Option, String>> { + match ai::eps_data(ps) { + Some(data) => Some(data.map(Cow::Owned)), + None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))), + } +} + +/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its +/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the +/// document and the import's notes. A file whose header says it is in the format (`illustrator`) +/// but whose program has no layers (Illustrator 3 had none; image editors still export their +/// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program +/// has no layers and isn't in the format: other PostScript isn't read as the format's operators. +pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool) -> Option), String>> { + if ai::has_eps_data(ps) { + return None; + } + if has_layers(ps) { + return Some(build(ps, None, Page::Art, false, false, 1.0)); + } + let program = one_layer(ps).filter(|_| illustrator)?; + Some(build(&program, None, Page::Art, false, false, 1.0)) +} + +/// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its +/// end) on a layer that shows, prints and isn't locked, as the version 5 format writes one. `None` +/// without a `%%EndSetup`: where the art starts isn't known. +fn one_layer(ps: &[u8]) -> Option> { + let setup = line_with(ps, 0, b"%%EndSetup")?; + let art = line_with(ps, setup + 1, b"").unwrap_or(ps.len()); + let end = line_with(ps, art, b"%%PageTrailer").or_else(|| line_with(ps, art, b"%%Trailer")).unwrap_or(ps.len()); + let mut out = Vec::with_capacity(ps.len() + 80); + out.extend_from_slice(ps.get(..art)?); + out.extend_from_slice(b"\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Layer 1) Ln\n"); + out.extend_from_slice(ps.get(art..end)?); + out.extend_from_slice(b"\nLB\n%AI5_EndLayer--\n"); + out.extend_from_slice(ps.get(end..)?); + Some(out) +} + +/// Where the first line of `ps` from `from` on that starts with `key` starts. +fn line_with(ps: &[u8], from: usize, key: &[u8]) -> Option { + (from..ps.len()).find(|&i| (i == 0 || matches!(ps.get(i - 1), Some(b'\n' | b'\r'))) && ps.get(i..).is_some_and(|rest| rest.starts_with(key))) +} + +/// `path` with each open subpath whose last anchor lies on its first (within a thousandth of a +/// point) closed there: the object's own path of type round a closed path is written open, ending +/// on its start, and type on an open path stops at its end, so it had no room to run past it. +pub(super) fn closed_where_ends_meet(mut path: PathData) -> PathData { + for sp in &mut path.subpaths { + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; + if sp.closed || sp.anchors.len() < 3 || first.p.distance(last.p) >= 1e-3 { + continue; + } + if let Some(l) = sp.anchors.pop() + && let Some(a0) = sp.anchors.first_mut() + { + a0.h_in = l.h_in; + } + sp.closed = true; + } + path +} + +#[cfg(test)] +mod closed_ends_tests { + use super::*; + + /// A path drawn round to its start (the last anchor on the first) is closed there, its first + /// anchor taking the last one's incoming handle; one whose ends are apart stays open. + #[test] + fn a_path_ending_on_its_start_is_closed() { + let mut bp = kurbo::BezPath::new(); + bp.move_to((100.0, 0.0)); + bp.curve_to((100.0, 55.0), (55.0, 100.0), (0.0, 100.0)); + bp.curve_to((-55.0, 100.0), (-100.0, 55.0), (-100.0, 0.0)); + bp.curve_to((-100.0, -55.0), (-55.0, -100.0), (0.0, -100.0)); + bp.curve_to((55.0, -100.0), (100.0, -55.0), (100.0, 0.0001)); + let closed = closed_where_ends_meet(PathData::from_bezpath(&bp)); + let sp = &closed.subpaths[0]; + assert!(sp.closed && sp.anchors.len() == 4, "{sp:?}"); + assert_eq!(sp.anchors[0].h_in, Point::new(100.0, -55.0)); + let mut open = kurbo::BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((50.0, 0.0)); + open.line_to((50.0, 50.0)); + assert!(!closed_where_ends_meet(PathData::from_bezpath(&open)).subpaths[0].closed); + } +} diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs new file mode 100644 index 000000000..aa86d57b6 --- /dev/null +++ b/crates/eps/src/import/native/plan.rs @@ -0,0 +1,222 @@ +use super::layers::closed_where_ends_meet; +use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type}; +use super::*; + +/// What goes into each of `infos`' slots: the page's type that the file's text document puts where the +/// page has it, else type made from the text document, else (for a file whose text document can't say) +/// the page's type in painting order. +pub(super) fn plan( + infos: &[SlotInfo], + stories: &[Option>], + template: Option<(f64, f64)>, + to_doc: Affine, + pages: [&[Vec>]; 2], + warn: &mut Vec, +) -> Vec { + let [shown, hidden] = pages; + let lines: Vec>> = stories + .iter() + .map(|s| { + let (s, t) = (s.as_ref()?, template?); + Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect()) + }) + .collect(); + // The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show + // has them too: a file's page may draw what its layers have hidden since. + let mut pairs: Vec<(f64, usize, usize)> = vec![]; + for (k, _) in infos.iter().enumerate() { + let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue }; + for (j, object) in shown.iter().enumerate() { + if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { + pairs.push((d, k, j)); + } + } + } + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut owner: Vec> = vec![None; shown.len()]; + for (_, k, j) in pairs { + if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) { + *o = Some(k); + } + } + // Type the text document makes, shown or not: the page's pieces of it aren't needed. + let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); + // Where the made type that doesn't show lies, by slot. + let mut hidden_at: Vec<(usize, Rect)> = vec![]; + let mut taller = 0; + if let Some(t) = template { + for (k, info) in infos.iter().enumerate() { + let Some(Some(story)) = stories.get(k) else { continue }; + let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue }; + let mut region = story.region(t, to_doc); + // Area type and type on a path in the object's own frame: the text document's frame + // of a copied object keeps its original's path. + if let (Some(path), NodeKind::Text(text)) = (&info.art_path, &mut node.kind) { + let size = text.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); + match &mut text.kind { + TextKind::Area { frame } => { + *frame = path.clone(); + region = path.bounds(); + } + TextKind::OnPath { path: on, .. } => { + *on = closed_where_ends_meet(path.clone()); + region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5)); + } + TextKind::Point => {} + } + } + if let (false, Some(r)) = (info.shown, region) { + hidden_at.push((k, r)); + } + if let NodeKind::Text(text) = &mut node.kind + && story.fit_first_line(text) + { + taller += 1; + } + if let Some(c) = content.get_mut(k) { + *c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]); + } + } + } + if taller > 0 { + warn.push(format!( + "area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise" + )); + } + // The file's text document is trusted where it agrees with the page about something (its lines + // start where the page's type does, or the page has type where it lays type out), or where the + // page has no type to disagree with. + let in_region = |object: &[Arc]| covered(object, &content, |_| true); + let trusted = owner.iter().any(Option::is_some) || shown.is_empty() || shown.iter().any(|o| in_region(o)); + if !trusted { + content.iter_mut().for_each(|c| *c = Content::Empty); + } + // Is `object` the type of a text object that doesn't show, as the page draws it: in its frame, + // with its words? (A copied object's lines are where its original's are, so they don't say.) + let hidden_type = |object: &[Arc]| { + let Some(r) = extent(object) else { return false }; + let words: Vec = + object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect(); + hidden_at.iter().any(|(k, place)| { + place.intersect(r).area() >= 0.5 * r.area().max(1e-9) + && lines + .get(*k) + .and_then(Option::as_ref) + .is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) + }) + }; + // The letters of each slot's story, in lower case. + let spelled: Vec = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect(); + // For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in + // its story? All are false unless such slots have the letters of every text between them. + let spelling = |object: &[Arc], content: &[Content]| -> Vec { + let r = extent(object); + let words: Vec = object + .iter() + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None }) + .filter(|w| !w.is_empty()) + .collect(); + let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str()))); + let near: Vec = content + .iter() + .enumerate() + .map(|(k, c)| match (c, r) { + (Content::Made(_, Some(region), ..), Some(r)) => region.intersect(r).area() > 0.0 && has(k), + _ => false, + }) + .collect(); + let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str()))); + if every { near } else { vec![] } + }; + let mut spare: Vec>> = vec![]; + for (j, object) in shown.iter().enumerate() { + let spells = spelling(object, &content); + let spells = |k: usize| spells.get(k).copied().unwrap_or(false); + match owner.get(j).copied().flatten().filter(|_| trusted) { + // The type of a text object the layers hide: the layers have it, hidden, so the page's + // isn't another text object's. + Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {} + Some(k) => match content.get_mut(k) { + Some(Content::Made(_, _, seen, _)) => *seen = true, + Some(Content::Page(p)) => p.extend(object.iter().cloned()), + Some(c) => *c = Content::Page(object.clone()), + None => {} + }, + // What lies where made type is and has that type's letters is that type as the page draws it. + None if covered(object, &content, spells) => { + let r = extent(object).unwrap_or(Rect::ZERO); + for (k, c) in content.iter_mut().enumerate() { + if let Content::Made(_, Some(region), seen, _) = c + && !region.intersect(r).is_zero_area() + && spells(k) + { + *seen = true; + } + } + } + None if trusted && hidden_type(object) => {} + None => spare.push(object.clone()), + } + } + for (k, c) in content.iter_mut().enumerate() { + if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) { + join_pieces(parts, l); + } + } + // What is left of the page goes by painting order into the shown slots the file can't say. + let loose: Vec = + infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); + if loose.is_empty() { + // Type of the page that no text object says it owns goes with the nearest text object that has + // type of the page; with none, it is left out. + let mut left_out = false; + for object in &spare { + let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c { + Content::Page(parts) => extent(parts).map(|r| r.center()), + Content::Made(_, region, ..) => region.map(|r| r.center()), + _ => None, + }); + let nearest = content + .iter_mut() + .filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c))) + .min_by(|a, b| a.0.total_cmp(&b.0)); + match nearest { + Some((_, Content::Page(parts) | Content::Made(_, _, _, parts))) => parts.extend(object.iter().cloned()), + _ => left_out = true, + } + } + if !spare.is_empty() { + warn.push( + if left_out { + "some of the page's type has no text object in the layers, so it is left out" + } else { + "some of the page's type has no text object of its own in the layers, so it is with another's" + } + .into(), + ); + } + } else { + for (slot, parts) in loose.iter().zip(assign(&spare, loose.len())) { + if let Some(c) = content.get_mut(*slot) { + // A text object the page shows as part of another's has nothing to hold of its own. + *c = if parts.is_empty() && !spare.is_empty() { + Content::Merged + } else { + Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()) + }; + } + } + if !spare.is_empty() && spare.len() != loose.len() { + warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into()); + } + } + // The same for the slots that don't show. + let unread: Vec = + infos.iter().enumerate().filter(|(k, i)| !i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); + for (slot, parts) in unread.iter().zip(assign(hidden, unread.len())) { + if let Some(c) = content.get_mut(*slot) { + *c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()); + } + } + content +} diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs new file mode 100644 index 000000000..07f7ef13d --- /dev/null +++ b/crates/eps/src/import/native/slots.rs @@ -0,0 +1,470 @@ +use super::build::MADE; +use super::*; + +/// The notes that say the import left out something the file has (see [`is_loss`]). +pub(super) const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out"; + +/// The same for a file without a page to take shown type from. +pub(super) const TYPE_LEFT_OUT: &str = "text objects of kinds VectorCraft doesn't read from the file's text document yet are left out"; + +pub(super) const ART_LEFT_OUT: &str = "hidden layers have art this can't read"; + +pub(super) const LAYERS_UNREAD: &str = "the file's layers weren't read"; + +/// Does this import note say that the file has something the document doesn't (hidden text, art or +/// layers that could not be read)? Writing the document over the file would lose it for good. +pub fn is_loss(note: &str) -> bool { + note.ends_with(TEXT_LEFT_OUT) + || note.ends_with(TYPE_LEFT_OUT) + || [ART_LEFT_OUT, LAYERS_UNREAD, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n)) +} + +/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched +/// to each other (more than this and the layers aren't used). +pub(super) const MAX_SLOTS: usize = 5000; + +const MAX_PIECES: usize = 2000; + +/// Most bytes of text in the text objects of the layers (a story named twice counts twice). +pub(super) const MAX_TEXT: usize = 16 << 20; + +/// Most pairs of a text object and a piece of the page's type compared, and of a glyph the page +/// kept as outlines and a node of the page's type. +pub(super) const MAX_PAIRS: usize = 4_000_000; + +/// `names` for a note: the first few, and how many more. +pub(super) fn few(names: BTreeSet) -> String { + const SHOWN: usize = 5; + let more = names.len().saturating_sub(SHOWN); + let mut list: Vec = names.into_iter().take(SHOWN).collect(); + if more > 0 { + list.push(format!("{more} more")); + } + list.join(", ") +} + +/// A text slot of the layers. +pub(super) struct SlotInfo { + pub(super) story: Option, + /// Which of the story's frames it is. + pub(super) frame: usize, + /// It, its layer and the objects round it show. + pub(super) shown: bool, + /// Its own frame or path, where the file places it ([`ai::text_frames`]). + pub(super) art_path: Option, +} + +/// The text slots of the layers and objects, in order. +pub(super) fn collect_slots(nodes: &[Arc], shown: bool, out: &mut Vec) { + for n in nodes { + let shown = shown && n.visible; + if let Some(story) = slot_of(n.name.as_deref()) { + out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None }); + } else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { + collect_slots(c, shown, out); + } + } +} + +/// How far from a text object's own extent the strokes drawn around it reach (points). +pub(super) const OUTLINE_REACH: f64 = 6.0; + +/// Does `n` look like part of a text object's outline: a path with a stroke, inside `around`? +fn outlines(n: &Node, around: Rect) -> bool { + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.width > 0.0)) + && n.visual_bounds().is_some_and(|b| { + let a = around.inflate(OUTLINE_REACH, OUTLINE_REACH); + b.x0 >= a.x0 && b.y0 >= a.y0 && b.x1 <= a.x1 && b.y1 <= a.y1 + }) +} + +/// How far apart (points) the baselines of two text nodes may be for them to be on one line. +const SAME_LINE: f64 = 0.5; + +/// Where `n`'s baseline lies across the direction it runs in (points), if it is text. +fn baseline(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let [a, b, _, _, e, f] = t.xf.as_coeffs(); + let len = a.hypot(b); + (len > 1e-9).then(|| (a * f - b * e) / len) +} + +/// The text objects the page paints, each as the run of nodes that make it up (its text, once for +/// each fill and stroke it has, with the paths of its strokes), in painting order: those that +/// show in `shown`, those of hidden layers and objects (a PDF has them) in `hidden`. +pub(super) fn text_objects(nodes: &[Arc], on: bool, shown: &mut Vec>>, hidden: &mut Vec>>) { + let is_text = |n: &Node| matches!(n.kind, NodeKind::Text(_)); + let (mut i, mut free) = (0, 0); + while let Some(n) = nodes.get(i) { + if !is_text(n) { + if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { + text_objects(c, on && n.visible, shown, hidden); + free = i + 1; + } + i += 1; + continue; + } + let mut bounds = n.visual_bounds().unwrap_or_default(); + let (mut start, mut end) = (i, i); + while start > free + && let Some(p) = nodes.get(start - 1) + && outlines(p, bounds) + { + start -= 1; + bounds = bounds.union(p.visual_bounds().unwrap_or_default()); + } + while let Some(next) = nodes.get(end + 1) + && let Some(b) = next.visual_bounds() + && (is_text(next) + && !b.intersect(bounds.inflate(OUTLINE_REACH, OUTLINE_REACH)).is_zero_area() + && baseline(next) + .zip(nodes.get(start..=end).unwrap_or_default().iter().find_map(|m| baseline(m))) + .is_some_and(|(x, y)| (x - y).abs() <= SAME_LINE) + || outlines(next, bounds)) + { + end += 1; + bounds = bounds.union(b); + } + let object = nodes.get(start..=end).unwrap_or_default().to_vec(); + let into = if on && n.visible { &mut *shown } else { &mut *hidden }; + into.push(object); + i = end + 1; + free = i; + } +} + +/// `n` and what it holds with ids of `doc`. +fn reid(doc: &mut Document, n: &Arc) -> Arc { + let mut n = n.as_ref().clone(); + n.id = doc.alloc_id(); + if let Some(children) = n.children_mut() { + for c in children.iter_mut() { + *c = reid(doc, c); + } + } + Arc::new(n) +} + +/// The page's text objects given to each of `slots` text slots: in painting order, and when there +/// are more of one than of the other (a label may be a few text objects, or one with several +/// strokes), each goes where the share of the order it is at falls. +pub(super) fn assign(objects: &[Vec>], slots: usize) -> Vec]>> { + let mut given: Vec]>> = vec![vec![]; slots]; + for (j, o) in objects.iter().enumerate() { + if let Some(at) = given.get_mut(((2 * j + 1) * slots / (2 * objects.len())).min(slots.saturating_sub(1))) { + at.push(o); + } + } + given +} + +/// What goes into a text slot. +pub(super) enum Content { + /// Text objects (with the strokes round them) of the page. + Page(Vec>), + /// A text object made from the file's text document, where it lies on the document, whether + /// the page has type where it is, and the page's type of no text object that is nearest it + /// (kept after it). + Made(Box, Option, bool, Vec>), + /// Nothing the file lets this read. + Empty, + /// Its type is on the page with another text object's; there is nothing to put here. + Merged, +} + +/// Does `n` have transparency of its own: an opacity below 100%, a blend mode or isolation? +fn see_through(n: &Node) -> bool { + n.opacity < 1.0 || n.blend != BlendMode::Normal || n.isolate +} + +/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is +/// left out): the page's type takes its place as it is, made type keeps the slot's name. Made type +/// takes the slot's opacity, blend mode, isolation and knockout. The page's type takes them too when +/// the page draws it and the strokes around it with no transparency, the slot's, or the slot's +/// combined with that of the objects around `nodes`; otherwise they keep their own. Where the slot has +/// transparency and the type comes with other art that has none of its own, a group of them takes the +/// slot's instead. `round` has the opacity and blend mode of each object around `nodes`, which the page +/// can have folded into its type. `empty` counts those left out. +pub(super) fn fill_slots( + doc: &mut Document, + nodes: &mut Vec>, + content: &mut std::vec::IntoIter, + names: &std::collections::HashMap, + empty: &mut usize, + made: &mut Vec<(u32, usize, NodeId)>, + round: &mut Vec<(f32, BlendMode)>, +) { + let mut i = 0; + while i < nodes.len() { + let Some(n) = nodes.get(i) else { break }; + if let Some(story) = slot_of(n.name.as_deref()) { + let visible = n.visible; + let name = names.get(&n.id); + let frame = ai::slot_frame(n.name.as_deref()); + let state = (n.opacity, n.blend, n.isolate, n.knockout); + let transparent = see_through(n); + // An art style read onto the slot (fills, strokes and effects round the characters) is the type's. + let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone()); + let replacement = match content.next() { + Some(Content::Page(parts)) if !parts.is_empty() => { + // The page's type (and the strokes round it) take the slot's place as they are. + let mut parts: Vec> = parts + .iter() + .map(|p| { + let mut p = reid(doc, p); + if !visible { + Arc::make_mut(&mut p).visible = false; + } + if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) { + Arc::make_mut(&mut p).name = Some(name.clone()); + } + p + }) + .collect(); + // When the page draws each of them with no transparency, the slot's, or the slot's + // combined with that of the objects around it, the slot's takes its place: on the one + // part, or on a group of several when the slot has transparency. Otherwise every part + // keeps the transparency the page draws it with (a fill's or a stroke's). + let above: f32 = round.iter().map(|r| r.0).product(); + let objects = |p: &Node| { + !see_through(p) + || (!p.isolate + && (p.blend == BlendMode::Normal || p.blend == state.1 || round.iter().any(|r| r.1 == p.blend)) + && [state.0, state.0 * above].iter().any(|o| (p.opacity - o).abs() < 1e-3)) + }; + if parts.iter().all(|p| objects(p)) { + for p in &mut parts { + let p = Arc::make_mut(p); + (p.opacity, p.blend, p.isolate) = (1.0, BlendMode::Normal, false); + } + if let [p] = parts.as_mut_slice() { + let p = Arc::make_mut(p); + (p.opacity, p.blend, p.isolate, p.knockout) = state; + } else if transparent { + let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts)); + (group.opacity, group.blend, group.isolate, group.knockout) = state; + parts.push(Arc::new(group)); + } + } + let n = parts.len(); + nodes.splice(i..=i, parts); + i += n; + continue; + } + Some(Content::Made(mut text, _, seen, extra)) => { + text.id = doc.alloc_id(); + if let Some(story) = story { + made.push((story, frame, text.id)); + } + text.visible = visible; + // Type with an art style is what the page draws as the style's look: it is seen. + let seen = seen || styled.is_some(); + if let Some(appearance) = styled { + text.appearance = appearance; + } + // Type the page shows is kept; other made type is weighed against the page. + text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) }; + let extra: Vec> = extra.iter().map(|p| reid(doc, p)).collect(); + // The type takes the slot's transparency; the page's type kept after it, where it has + // none of its own, goes into a group with it that has the transparency instead. + let parts: Vec> = if transparent && !extra.is_empty() && !extra.iter().any(|p| see_through(p)) { + let mut group = Node::group(doc.alloc_id(), std::iter::once(Arc::new(*text)).chain(extra).collect()); + (group.opacity, group.blend, group.isolate, group.knockout) = state; + vec![Arc::new(group)] + } else { + (text.opacity, text.blend, text.isolate, text.knockout) = state; + std::iter::once(Arc::new(*text)).chain(extra).collect() + }; + let n = parts.len(); + nodes.splice(i..=i, parts); + i += n; + continue; + } + Some(Content::Merged) => { + nodes.remove(i); + continue; + } + _ => None, + }; + match replacement { + Some(node) => { + if let Some(slot) = nodes.get_mut(i) { + *slot = Arc::new(node); + } + i += 1; + } + None => { + *empty += 1; + nodes.remove(i); + } + } + } else { + let state = (n.opacity, n.blend); + if n.children().is_some() + && !matches!(n.kind, NodeKind::Compound { .. }) + && let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut) + { + round.push(state); + fill_slots(doc, c, content, names, empty, made, round); + round.pop(); + } + i += 1; + } + } +} + +/// How far, in points, the page's type may be from where the file's text document puts it for the +/// two to be the same type. +const SAME_PLACE: f64 = 0.75; + +/// Its letters and digits, which is what is compared of type (the page may spell a mark another way). +pub(super) fn letters(s: &str) -> String { + s.chars().filter(char::is_ascii_alphanumeric).collect() +} + +/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it +/// (the page splits a line where its kerning or its styles change), on that line's baseline and +/// after its start? How far from where the line starts it is, if so. +pub(super) fn same_type(n: &Node, lines: &[(String, Point)]) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let have = letters(&t.plain_text()); + let [a, b, _, _, e, f] = t.xf.as_coeffs(); + let len = a.hypot(b); + if have.is_empty() || len < 1e-9 { + return None; + } + let (ux, uy) = (a / len, b / len); + // About how far a character of it advances, to say how far along a line a piece may be. + let advance = n.visual_bounds().map_or(0.0, |r| r.width().max(r.height())) / have.len() as f64; + lines + .iter() + .filter_map(|(text, at)| { + let line = letters(text); + let skipped = line.find(&have)?; + if skipped == 0 { + return Some(((at.x - e).powi(2) + (at.y - f).powi(2)).sqrt()).filter(|d| *d <= SAME_PLACE); + } + let (dx, dy) = (e - at.x, f - at.y); + let (along, across) = (dx * ux + dy * uy, (dx * uy - dy * ux).abs()); + ((have.len() > 1 || skipped + 1 == line.len()) + && across <= SAME_PLACE + && along >= -SAME_PLACE + && along <= line.len() as f64 * advance * 2.0 + 4.0) + .then_some(across + 0.001 * along) + }) + .min_by(f64::total_cmp) +} + +/// The page draws a line in pieces where its kerning or styles change ("swe", "ep"), and the file +/// has it as one line of one text object: the pieces that on one baseline add up to a line of +/// `lines` become one text (each keeping its own style, the line's spaces between them). +pub(super) fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { + let text_of = |n: &Arc| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }; + let texts: Vec = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); + // A page with this many pieces of type in one place isn't one of lines of a story. + if texts.len() > MAX_PIECES { + return; + } + let mut taken = vec![false; parts.len()]; + // The first piece of each line to join, the others, and the spaces to put after each piece. + let mut joins: Vec<(usize, Vec, Vec)> = vec![]; + for &i in &texts { + if taken.get(i).copied().unwrap_or(true) { + continue; + } + let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue }; + let group: Vec = texts + .iter() + .copied() + .filter(|j| { + !taken.get(*j).copied().unwrap_or(true) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE) + }) + .collect(); + for j in &group { + if let Some(t) = taken.get_mut(*j) { + *t = true; + } + } + let pieces: Vec = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect(); + let places: Vec<(f64, f64)> = group + .iter() + .filter_map(|j| parts.get(*j)) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((t.xf.as_coeffs()[4], t.xf.as_coeffs()[5])) } else { None }) + .collect(); + let distinct = places.iter().enumerate().all(|(a, p)| places.iter().skip(a + 1).all(|q| (p.0 - q.0).hypot(p.1 - q.1) > 0.01)); + let joined: String = pieces.iter().map(|p| letters(p)).collect(); + if group.len() < 2 || !distinct || joined.is_empty() { + continue; + } + let Some((whole, _)) = lines.iter().find(|(text, _)| letters(text) == joined) else { continue }; + // Where the line has spaces after a piece's last character that the page doesn't draw. + let line_chars: Vec = whole.chars().collect(); + let mut at = 0; + let mut gaps = vec![]; + for (k, piece) in pieces.iter().enumerate() { + for _ in piece.chars().filter(|c| !c.is_whitespace()) { + while line_chars.get(at).is_some_and(|c| c.is_whitespace()) { + at += 1; + } + at += 1; + } + let space: String = line_chars.iter().skip(at).take_while(|c| c.is_whitespace()).collect(); + let next = pieces.get(k + 1); + let drawn = piece.ends_with(char::is_whitespace) || next.is_none_or(|n| n.starts_with(char::is_whitespace)); + gaps.push(if drawn { String::new() } else { space }); + } + if at <= line_chars.len() { + joins.push((group[0], group[1..].to_vec(), gaps)); + } + } + let mut gone: BTreeSet = BTreeSet::new(); + for (first, rest, gaps) in &joins { + let mut runs: Vec> = [first] + .into_iter() + .chain(rest) + .filter_map(|j| parts.get(*j)) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.clone()) } else { None }) + .collect(); + for (r, gap) in runs.iter_mut().zip(gaps) { + if let Some(last) = r.last_mut() { + last.text.push_str(gap); + } + } + if let Some(node) = parts.get_mut(*first).map(Arc::make_mut) + && let NodeKind::Text(t) = &mut node.kind + { + t.runs = runs.into_iter().flatten().collect(); + // The layout cached for the first piece is not this text's. + t.cached_bounds = None; + t.cached_baselines.clear(); + } + gone.extend(rest); + } + // The pieces that went into a text, removed once the joins are done: removing as each is made + // would move the places of the pieces that the others still name. + let mut keep = 0; + parts.retain(|_| { + keep += 1; + !gone.contains(&(keep - 1)) + }); +} + +/// Do the regions of made type in the slots that `keep` takes cover most of `object`? +pub(super) fn covered(object: &[Arc], content: &[Content], keep: impl Fn(usize) -> bool) -> bool { + let Some(r) = extent(object) else { return false }; + let inside: f64 = content + .iter() + .enumerate() + .filter_map(|(k, c)| match c { + Content::Made(_, Some(region), ..) => Some(region.intersect(r).area()).filter(|a| *a > 0.0 && keep(k)), + _ => None, + }) + .sum(); + inside >= 0.5 * r.area().max(1e-9) +} + +/// The box round `parts`. +pub(super) fn extent(parts: &[Arc]) -> Option { + parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) +} From d459e0d63b924322b5009b6373bd7975051cac4c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:33:03 +0000 Subject: [PATCH 129/199] refactor(pdf): move encrypt.rs items to encrypt/{lexer}.rs --- crates/pdf/src/encrypt.rs | 341 +------------------------------- crates/pdf/src/encrypt/lexer.rs | 336 +++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 337 deletions(-) create mode 100644 crates/pdf/src/encrypt/lexer.rs diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs index 705ba0293..48941a4ed 100644 --- a/crates/pdf/src/encrypt.rs +++ b/crates/pdf/src/encrypt.rs @@ -21,6 +21,10 @@ use crate::lab_spot::{find, rfind, xref_offset}; use crate::settings::choice; use crate::{Changes, Compatibility, PdfError, PdfSettings, Printing, SecuritySettings, Standard}; +mod lexer; + +pub(crate) use lexer::{Lexer, Obj, Spanned, Tok, int, is_white}; + choice! { /// The encryption algorithm, which follows the PDF version (Compatibility). Encryption { @@ -448,343 +452,6 @@ fn owner_key(owner: &[u8], r: u8, n: usize) -> Vec { h.get(..n).unwrap_or(&h).to_vec() } -// ---------- PDF syntax ---------- - -pub(crate) fn is_white(b: u8) -> bool { - matches!(b, 0 | 9 | 10 | 12 | 13 | 32) -} - -fn is_regular(b: u8) -> bool { - !is_white(b) && !b"()<>[]{}/%".contains(&b) -} - -#[derive(Debug)] -pub(crate) enum Tok<'a> { - DictOpen, - DictClose, - ArrayOpen, - ArrayClose, - Name(&'a [u8]), - Str(Vec), - /// Numbers and keywords (`true`, `R`, `obj`, `stream`…). - Word(&'a [u8]), -} - -/// A token with its byte span. -pub(crate) type Spanned<'a> = (Tok<'a>, usize, usize); - -/// Deepest nesting of arrays and dictionaries read. -const MAX_DEPTH: usize = 64; - -/// A parsed object, with what encryption needs: the strings and dictionaries with their spans. -#[derive(Debug)] -pub(crate) enum Obj<'a> { - /// `start` is where `<<` is, `end` just after `>>`. - Dict { - entries: Vec<(&'a [u8], Obj<'a>)>, - start: usize, - end: usize, - }, - Array(Vec>), - Str { - bytes: Vec, - start: usize, - end: usize, - }, - Name(&'a [u8]), - Int { - value: i64, - start: usize, - end: usize, - }, - Ref(u32, u16), - /// Reals, booleans, null. - Other, -} - -impl<'a> Obj<'a> { - pub(crate) fn get(&self, key: &[u8]) -> Option<&Obj<'a>> { - match self { - Self::Dict { entries, .. } => entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v), - _ => None, - } - } - - pub(crate) fn name(&self, key: &[u8]) -> Option<&'a [u8]> { - match self.get(key)? { - Self::Name(n) => Some(n), - _ => None, - } - } - - pub(crate) fn int(&self, key: &[u8]) -> Option { - match self.get(key)? { - Self::Int { value, .. } => Some(*value), - _ => None, - } - } - - fn string(&self, key: &[u8]) -> Option<&[u8]> { - match self.get(key)? { - Self::Str { bytes, .. } => Some(bytes), - _ => None, - } - } - - /// The first string of the array at `key` (a file's ID). - fn first_string(&self, key: &[u8]) -> Option<&[u8]> { - match self.get(key)? { - Self::Array(items) => items.iter().find_map(|i| match i { - Self::Str { bytes, .. } => Some(bytes.as_slice()), - _ => None, - }), - _ => None, - } - } - - /// Every string in this object, in file order. - fn strings<'s>(&'s self, out: &mut Vec<&'s Obj<'a>>) { - match self { - Self::Str { .. } => out.push(self), - Self::Dict { entries, .. } => entries.iter().for_each(|(_, v)| v.strings(out)), - Self::Array(items) => items.iter().for_each(|v| v.strings(out)), - _ => {} - } - } -} - -pub(crate) fn int(w: &[u8]) -> Option { - std::str::from_utf8(w).ok()?.parse().ok() -} - -#[derive(Clone)] -pub(crate) struct Lexer<'a> { - buf: &'a [u8], - pos: usize, -} - -impl<'a> Lexer<'a> { - pub(crate) fn at(buf: &'a [u8], pos: usize) -> Self { - Self { buf, pos } - } - - fn peek_byte(&self, ahead: usize) -> Option { - self.buf.get(self.pos + ahead).copied() - } - - fn skip_space(&mut self) { - while let Some(b) = self.peek_byte(0) { - if is_white(b) { - self.pos += 1; - } else if b == b'%' { - while self.peek_byte(0).is_some_and(|b| b != b'\n' && b != b'\r') { - self.pos += 1; - } - } else { - break; - } - } - } - - pub(crate) fn next(&mut self) -> Option> { - self.skip_space(); - let start = self.pos; - let b = self.peek_byte(0)?; - let tok = match (b, self.peek_byte(1)) { - (b'<', Some(b'<')) => { - self.pos += 2; - Tok::DictOpen - } - (b'>', Some(b'>')) => { - self.pos += 2; - Tok::DictClose - } - (b'<', _) => Tok::Str(self.hex()?), - (b'(', _) => Tok::Str(self.literal()?), - (b'[', _) => { - self.pos += 1; - Tok::ArrayOpen - } - (b']', _) => { - self.pos += 1; - Tok::ArrayClose - } - (b'/', _) => { - self.pos += 1; - self.word(); - Tok::Name(self.buf.get(start + 1..self.pos)?) - } - _ if !is_regular(b) => { - // A stray delimiter. - self.pos += 1; - Tok::Word(self.buf.get(start..self.pos)?) - } - _ => { - self.word(); - Tok::Word(self.buf.get(start..self.pos)?) - } - }; - Some((tok, start, self.pos)) - } - - fn word(&mut self) { - while self.peek_byte(0).is_some_and(is_regular) { - self.pos += 1; - } - } - - fn hex(&mut self) -> Option> { - self.pos += 1; - let (mut out, mut high) = (vec![], None); - loop { - let b = self.peek_byte(0)?; - self.pos += 1; - if b == b'>' { - out.extend(high.map(|h: u8| h << 4)); - return Some(out); - } - if is_white(b) { - continue; - } - let v = char::from(b).to_digit(16)? as u8; - match high.take() { - Some(h) => out.push(h << 4 | v), - None => high = Some(v), - } - } - } - - fn literal(&mut self) -> Option> { - self.pos += 1; - let (mut out, mut depth) = (vec![], 1u32); - loop { - let b = self.peek_byte(0)?; - self.pos += 1; - match b { - b'\\' => { - let e = self.peek_byte(0)?; - self.pos += 1; - match e { - b'n' => out.push(b'\n'), - b'r' => out.push(b'\r'), - b't' => out.push(b'\t'), - b'b' => out.push(8), - b'f' => out.push(12), - // A backslash before an end of line continues the string. - b'\r' => { - if self.peek_byte(0) == Some(b'\n') { - self.pos += 1; - } - } - b'\n' => {} - b'0'..=b'7' => { - let mut v = u32::from(e - b'0'); - for _ in 0..2 { - match self.peek_byte(0) { - Some(d @ b'0'..=b'7') => { - v = v * 8 + u32::from(d - b'0'); - self.pos += 1; - } - _ => break, - } - } - out.push((v & 0xFF) as u8); - } - // `\(`, `\)`, `\\`; other escapes stand for the character. - _ => out.push(e), - } - } - b'(' => { - depth += 1; - out.push(b); - } - b')' => { - depth -= 1; - if depth == 0 { - return Some(out); - } - out.push(b); - } - // Ends of lines read as `\n`. - b'\r' => { - if self.peek_byte(0) == Some(b'\n') { - self.pos += 1; - } - out.push(b'\n'); - } - _ => out.push(b), - } - } - } - - /// One object (a reference `n g R` reads as one). - pub(crate) fn object(&mut self, depth: usize) -> Option> { - if depth > MAX_DEPTH { - return None; - } - let (tok, start, end) = self.next()?; - Some(match tok { - Tok::DictOpen => { - let mut entries = vec![]; - loop { - let mut look = self.clone(); - match look.next()? { - (Tok::DictClose, _, end) => { - *self = look; - return Some(Obj::Dict { entries, start, end }); - } - (Tok::Name(k), ..) => { - *self = look; - entries.push((k, self.object(depth + 1)?)); - } - _ => return None, - } - } - } - Tok::ArrayOpen => { - let mut items = vec![]; - loop { - let mut look = self.clone(); - if matches!(look.next()?, (Tok::ArrayClose, ..)) { - *self = look; - return Some(Obj::Array(items)); - } - items.push(self.object(depth + 1)?); - } - } - Tok::Str(bytes) => Obj::Str { bytes, start, end }, - Tok::Name(n) => Obj::Name(n), - Tok::Word(w) => match int(w) { - Some(value) => { - let mut look = self.clone(); - match (look.next(), look.next()) { - (Some((Tok::Word(g), ..)), Some((Tok::Word(b"R"), ..))) => { - match (u32::try_from(value), int(g).and_then(|g| u16::try_from(g).ok())) { - (Ok(n), Some(g)) => { - *self = look; - Obj::Ref(n, g) - } - _ => Obj::Int { value, start, end }, - } - } - _ => Obj::Int { value, start, end }, - } - } - None => Obj::Other, - }, - Tok::DictClose | Tok::ArrayClose => return None, - }) - } - - /// The header `num generation obj` of an indirect object. - pub(crate) fn header(&mut self) -> Option<(u32, u16)> { - let (Some((Tok::Word(n), ..)), Some((Tok::Word(g), ..)), Some((Tok::Word(b"obj"), ..))) = (self.next(), self.next(), self.next()) else { - return None; - }; - Some((u32::try_from(int(n)?).ok()?, u16::try_from(int(g)?).ok()?)) - } -} - // ---------- encrypting a written file ---------- fn failed(why: &str) -> PdfError { diff --git a/crates/pdf/src/encrypt/lexer.rs b/crates/pdf/src/encrypt/lexer.rs new file mode 100644 index 000000000..55a67296f --- /dev/null +++ b/crates/pdf/src/encrypt/lexer.rs @@ -0,0 +1,336 @@ +// ---------- PDF syntax ---------- + +pub(crate) fn is_white(b: u8) -> bool { + matches!(b, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn is_regular(b: u8) -> bool { + !is_white(b) && !b"()<>[]{}/%".contains(&b) +} + +#[derive(Debug)] +pub(crate) enum Tok<'a> { + DictOpen, + DictClose, + ArrayOpen, + ArrayClose, + Name(&'a [u8]), + Str(Vec), + /// Numbers and keywords (`true`, `R`, `obj`, `stream`…). + Word(&'a [u8]), +} + +/// A token with its byte span. +pub(crate) type Spanned<'a> = (Tok<'a>, usize, usize); + +/// Deepest nesting of arrays and dictionaries read. +const MAX_DEPTH: usize = 64; + +/// A parsed object, with what encryption needs: the strings and dictionaries with their spans. +#[derive(Debug)] +pub(crate) enum Obj<'a> { + /// `start` is where `<<` is, `end` just after `>>`. + Dict { + entries: Vec<(&'a [u8], Obj<'a>)>, + start: usize, + end: usize, + }, + Array(Vec>), + Str { + bytes: Vec, + start: usize, + end: usize, + }, + Name(&'a [u8]), + Int { + value: i64, + start: usize, + end: usize, + }, + Ref(u32, u16), + /// Reals, booleans, null. + Other, +} + +impl<'a> Obj<'a> { + pub(crate) fn get(&self, key: &[u8]) -> Option<&Obj<'a>> { + match self { + Self::Dict { entries, .. } => entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v), + _ => None, + } + } + + pub(crate) fn name(&self, key: &[u8]) -> Option<&'a [u8]> { + match self.get(key)? { + Self::Name(n) => Some(n), + _ => None, + } + } + + pub(crate) fn int(&self, key: &[u8]) -> Option { + match self.get(key)? { + Self::Int { value, .. } => Some(*value), + _ => None, + } + } + + pub(super) fn string(&self, key: &[u8]) -> Option<&[u8]> { + match self.get(key)? { + Self::Str { bytes, .. } => Some(bytes), + _ => None, + } + } + + /// The first string of the array at `key` (a file's ID). + pub(super) fn first_string(&self, key: &[u8]) -> Option<&[u8]> { + match self.get(key)? { + Self::Array(items) => items.iter().find_map(|i| match i { + Self::Str { bytes, .. } => Some(bytes.as_slice()), + _ => None, + }), + _ => None, + } + } + + /// Every string in this object, in file order. + pub(super) fn strings<'s>(&'s self, out: &mut Vec<&'s Obj<'a>>) { + match self { + Self::Str { .. } => out.push(self), + Self::Dict { entries, .. } => entries.iter().for_each(|(_, v)| v.strings(out)), + Self::Array(items) => items.iter().for_each(|v| v.strings(out)), + _ => {} + } + } +} + +pub(crate) fn int(w: &[u8]) -> Option { + std::str::from_utf8(w).ok()?.parse().ok() +} + +#[derive(Clone)] +pub(crate) struct Lexer<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Lexer<'a> { + pub(crate) fn at(buf: &'a [u8], pos: usize) -> Self { + Self { buf, pos } + } + + fn peek_byte(&self, ahead: usize) -> Option { + self.buf.get(self.pos + ahead).copied() + } + + fn skip_space(&mut self) { + while let Some(b) = self.peek_byte(0) { + if is_white(b) { + self.pos += 1; + } else if b == b'%' { + while self.peek_byte(0).is_some_and(|b| b != b'\n' && b != b'\r') { + self.pos += 1; + } + } else { + break; + } + } + } + + pub(crate) fn next(&mut self) -> Option> { + self.skip_space(); + let start = self.pos; + let b = self.peek_byte(0)?; + let tok = match (b, self.peek_byte(1)) { + (b'<', Some(b'<')) => { + self.pos += 2; + Tok::DictOpen + } + (b'>', Some(b'>')) => { + self.pos += 2; + Tok::DictClose + } + (b'<', _) => Tok::Str(self.hex()?), + (b'(', _) => Tok::Str(self.literal()?), + (b'[', _) => { + self.pos += 1; + Tok::ArrayOpen + } + (b']', _) => { + self.pos += 1; + Tok::ArrayClose + } + (b'/', _) => { + self.pos += 1; + self.word(); + Tok::Name(self.buf.get(start + 1..self.pos)?) + } + _ if !is_regular(b) => { + // A stray delimiter. + self.pos += 1; + Tok::Word(self.buf.get(start..self.pos)?) + } + _ => { + self.word(); + Tok::Word(self.buf.get(start..self.pos)?) + } + }; + Some((tok, start, self.pos)) + } + + fn word(&mut self) { + while self.peek_byte(0).is_some_and(is_regular) { + self.pos += 1; + } + } + + fn hex(&mut self) -> Option> { + self.pos += 1; + let (mut out, mut high) = (vec![], None); + loop { + let b = self.peek_byte(0)?; + self.pos += 1; + if b == b'>' { + out.extend(high.map(|h: u8| h << 4)); + return Some(out); + } + if is_white(b) { + continue; + } + let v = char::from(b).to_digit(16)? as u8; + match high.take() { + Some(h) => out.push(h << 4 | v), + None => high = Some(v), + } + } + } + + fn literal(&mut self) -> Option> { + self.pos += 1; + let (mut out, mut depth) = (vec![], 1u32); + loop { + let b = self.peek_byte(0)?; + self.pos += 1; + match b { + b'\\' => { + let e = self.peek_byte(0)?; + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(8), + b'f' => out.push(12), + // A backslash before an end of line continues the string. + b'\r' => { + if self.peek_byte(0) == Some(b'\n') { + self.pos += 1; + } + } + b'\n' => {} + b'0'..=b'7' => { + let mut v = u32::from(e - b'0'); + for _ in 0..2 { + match self.peek_byte(0) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + u32::from(d - b'0'); + self.pos += 1; + } + _ => break, + } + } + out.push((v & 0xFF) as u8); + } + // `\(`, `\)`, `\\`; other escapes stand for the character. + _ => out.push(e), + } + } + b'(' => { + depth += 1; + out.push(b); + } + b')' => { + depth -= 1; + if depth == 0 { + return Some(out); + } + out.push(b); + } + // Ends of lines read as `\n`. + b'\r' => { + if self.peek_byte(0) == Some(b'\n') { + self.pos += 1; + } + out.push(b'\n'); + } + _ => out.push(b), + } + } + } + + /// One object (a reference `n g R` reads as one). + pub(crate) fn object(&mut self, depth: usize) -> Option> { + if depth > MAX_DEPTH { + return None; + } + let (tok, start, end) = self.next()?; + Some(match tok { + Tok::DictOpen => { + let mut entries = vec![]; + loop { + let mut look = self.clone(); + match look.next()? { + (Tok::DictClose, _, end) => { + *self = look; + return Some(Obj::Dict { entries, start, end }); + } + (Tok::Name(k), ..) => { + *self = look; + entries.push((k, self.object(depth + 1)?)); + } + _ => return None, + } + } + } + Tok::ArrayOpen => { + let mut items = vec![]; + loop { + let mut look = self.clone(); + if matches!(look.next()?, (Tok::ArrayClose, ..)) { + *self = look; + return Some(Obj::Array(items)); + } + items.push(self.object(depth + 1)?); + } + } + Tok::Str(bytes) => Obj::Str { bytes, start, end }, + Tok::Name(n) => Obj::Name(n), + Tok::Word(w) => match int(w) { + Some(value) => { + let mut look = self.clone(); + match (look.next(), look.next()) { + (Some((Tok::Word(g), ..)), Some((Tok::Word(b"R"), ..))) => { + match (u32::try_from(value), int(g).and_then(|g| u16::try_from(g).ok())) { + (Ok(n), Some(g)) => { + *self = look; + Obj::Ref(n, g) + } + _ => Obj::Int { value, start, end }, + } + } + _ => Obj::Int { value, start, end }, + } + } + None => Obj::Other, + }, + Tok::DictClose | Tok::ArrayClose => return None, + }) + } + + /// The header `num generation obj` of an indirect object. + pub(crate) fn header(&mut self) -> Option<(u32, u16)> { + let (Some((Tok::Word(n), ..)), Some((Tok::Word(g), ..)), Some((Tok::Word(b"obj"), ..))) = (self.next(), self.next(), self.next()) else { + return None; + }; + Some((u32::try_from(int(n)?).ok()?, u16::try_from(int(g)?).ok()?)) + } +} From 882cb848b3bc301322772a700f2cdd9703314fb3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:33:03 +0000 Subject: [PATCH 130/199] refactor(pdf): move export.rs items to export/{entry,exporter,images,nodes,paint,text,writer}.rs --- crates/pdf/src/export.rs | 2105 +---------------------------- crates/pdf/src/export/entry.rs | 199 +++ crates/pdf/src/export/exporter.rs | 206 +++ crates/pdf/src/export/images.rs | 139 ++ crates/pdf/src/export/nodes.rs | 491 +++++++ crates/pdf/src/export/paint.rs | 293 ++++ crates/pdf/src/export/text.rs | 446 ++++++ crates/pdf/src/export/writer.rs | 342 +++++ 8 files changed, 2130 insertions(+), 2091 deletions(-) create mode 100644 crates/pdf/src/export/entry.rs create mode 100644 crates/pdf/src/export/exporter.rs create mode 100644 crates/pdf/src/export/images.rs create mode 100644 crates/pdf/src/export/nodes.rs create mode 100644 crates/pdf/src/export/paint.rs create mode 100644 crates/pdf/src/export/text.rs create mode 100644 crates/pdf/src/export/writer.rs diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 26a790bf2..d94ade86b 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -30,541 +30,21 @@ use crate::marks::PageBoxes; use crate::output::ColorOut; use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard}; -/// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). -pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { - export_with_report(doc, opts).map(|r| r.bytes) -} +mod entry; +mod exporter; +mod images; +mod nodes; +mod paint; +mod text; +mod writer; -/// Like [`export`], also returning warnings: options accepted but not applied yet (see -/// [`crate::PdfSettings::warnings`]) and features approximated or dropped. -pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { - write::guard(|| leaving_out_undecodable(doc, |left_out| export_with_report_inner(doc, opts, left_out))) -} - -/// Run `write` (a whole PDF written by a [`Writer`]), and run it again each time it fails on an -/// image whose pixel data the writer couldn't decode, with that image left out: [`Writer::finish`] -/// adds the image's key to the set `write` is given, and the exporters leave out the images it -/// holds. The writer reports such an image (a PNG, GIF or WebP kept as it is, whose header reads) -/// only when it finishes the file. Each repeated run leaves out at least one more image. -pub(crate) fn leaving_out_undecodable(doc: &Document, mut write: impl FnMut(&mut HashSet) -> Result) -> Result { - // The most runs repeated: each is a whole export, and a document of many images that don't - // decode would make them quadratic. Past it, one last run leaves out every image of `doc`, so - // the export still finishes (the images left out are reported). - const MAX_RETRIES: usize = 16; - let mut left_out = HashSet::new(); - let mut retries = 0; - loop { - let known = left_out.len(); - match write(&mut left_out) { - Err(_) if left_out.len() > known && retries < MAX_RETRIES => retries += 1, - Err(_) if left_out.len() > known && retries == MAX_RETRIES => { - left_out.extend(doc.images.keys().cloned()); - retries += 1; - } - done => return done, - } - } -} - -fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut HashSet) -> Result { - let set = &opts.settings; - set.check()?; - vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; - // Live geometry effects export as their result; raster effects are reported below. - let baked = vectorcraft_effects::bake_document(doc); - // Baking stops early when cancelled, leaving a half-baked copy that must not be written. - vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; - let doc = baked.as_ref().unwrap_or(doc); - let pages = pages(doc, opts)?; - - let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); - // The time of writing (none on the web) dates the editing data and the page information. - let now = vectorcraft_doc::metadata::output_time(); - let created_at = opts.created.or(now); - crate::pdfx::require_date(set.standard, created_at)?; - let display_doc_title = opts.display_doc_title || opts.title.as_deref().is_some_and(|title| !title.trim().is_empty()); - let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?; - // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). - let mut warnings = set.warnings(); - let native = opts.native.as_deref().filter(|_| set.preserve_editing); - if let Some(native) = native { - w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time))); - } else if set.preserve_editing { - warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); - } - - let mut ex = Exporter::new(doc, set); - ex.non_printing = set.include_non_printing || set.create_layers; - ex.out = w.out.clone(); - ex.left_out = left_out.clone(); - // A knockout page group draws the layers' objects one by one, not layer by layer. - ex.layers = set.writes_layers() && !doc.page_knockout; - if set.writes_layers() && doc.page_knockout { - warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); - } - ex.overprint = set.advanced.overprint == crate::Overprint::Preserve; - let bleed = set.bleed_of(doc); - let marks = set.marks.printer_marks(); - // In PDF 1.6 and later files that aren't PDF/X files, a page with a side longer than - // [`MAX_PAGE_SIDE`] is written in larger units; other files have every page in points. - let scales = set.compatibility.has_user_unit() && !set.standard.is_pdfx(); - let mut units = Vec::with_capacity(pages.len()); - let total = pages.len() as u64; - for (written, (i, boxes)) in pages.into_iter().enumerate() { - // The page count and the page being written, for a client that asked for progress. - vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; - vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1)); - let (m, r) = (boxes.media, boxes.bleed); - let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() }; - let art = crate::marks::art(doc, &marks, &boxes, bleed, &info); - // The page is the media box, y down from its top-left corner (like ours), in units of - // `unit` points. - let unit = if scales { user_unit(m.width(), m.height()) } else { 1.0 }; - units.push(unit); - let shift = Affine::translate((-m.x0, -m.y0)); - let view = if unit > 1.0 { Affine::scale(1.0 / unit) * shift } else { shift }; - let sheet = Sheet { - size: (m.width() / unit, m.height() / unit), - unit, - trim: view.transform_rect_bbox(boxes.trim), - bleed: view.transform_rect_bbox(r), - view, - window: None, - place: Affine::IDENTITY, - area: r, - // Art reaches as far as the bleed: the marks lie outside it. - clip: m != r, - marks: art.as_ref(), - negative: false, - }; - w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?; - } - vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; - let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible); - w.absorb(ex); - let (bytes, more) = w.finish(left_out, display_doc_title)?; - let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted, invisible)?; - // The writer gives the file its creation date as its modification date too. - let bytes = match opts.modified.filter(|m| Some(*m) != created_at) { - Some(modified) => crate::post::modification_date(bytes, modified)?, - None => bytes, - }; - // The writer's metadata has no copyright: it is added to the XMP packet. - let bytes = crate::post::rights(bytes, &doc.metadata)?; - warnings.extend(more); - let bytes = crate::post::user_units(bytes, &units)?; - let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; - warnings.dedup(); - Ok(ExportReport { bytes, warnings }) -} - -/// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds -/// make each page its artboard (the trim box) grown by the bleed, and by the printer's marks -/// around that. -fn pages(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { - let indices: Vec = match &opts.artboards { - Some(v) => v.clone(), - None => (0..doc.artboards.len()).collect(), - }; - if doc.artboards.is_empty() || indices.is_empty() { - return Err(PdfError::NoArtboards); - } - let (bleed, marks) = (opts.settings.bleed_of(doc), opts.settings.marks.printer_marks()); - indices.into_iter().map(|i| Ok((i, PageBoxes::new(doc.artboards.get(i).ok_or(PdfError::BadArtboard(i))?.rect, bleed, &marks)))).collect() -} - -/// What each page of `doc` exported with `opts` shows, in document space (its media box: the -/// artboard with the bleed and the printer's marks), in page order. -pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { - Ok(pages(doc, opts)?.into_iter().map(|(_, b)| b.media).collect()) -} - -/// The largest side of a page in units of its default user space that ISO 32000-1 (Annex C.2) -/// gives: 14,400 (200 inches in units of 1/72 inch). -pub(crate) const MAX_PAGE_SIDE: f64 = 14_400.0; - -/// The `UserUnit` (PDF 1.6) of a page `w` × `h` points large: 1 when both sides are at most -/// [`MAX_PAGE_SIDE`], else the longer side divided by it, rounded up to the next thousandth, so -/// that both sides are at most [`MAX_PAGE_SIDE`] units. -fn user_unit(w: f64, h: f64) -> f64 { - let side = w.abs().max(h.abs()); - if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 } -} - -/// The RGB colours and non-CMYK images a CMYK document carries, which the export separates into -/// CMYK. Their darkest values come out as rich blacks, so the export says how many there are. -#[derive(Default)] -pub(crate) struct RgbInCmyk { - /// Distinct RGB colours, by the bits of their channels. - colours: HashSet<[u32; 3]>, - /// Keys of the raster images that aren't CMYK. - images: HashSet, -} - -impl RgbInCmyk { - fn merge(&mut self, other: RgbInCmyk) { - self.colours.extend(other.colours); - self.images.extend(other.images); - } - - /// The warning for the export report; `None` when the document has nothing of the sort. - fn warning(&self) -> Option { - let (c, i) = (self.colours.len(), self.images.len()); - if c == 0 && i == 0 { - return None; - } - let mut parts = vec![]; - if c > 0 { - parts.push(format!("{c} RGB colour{}", if c == 1 { "" } else { "s" })); - } - if i > 0 { - parts.push(format!("{i} RGB or grey image{}", if i == 1 { "" } else { "s" })); - } - Some(format!( - "this CMYK document has {}: they are converted to CMYK through the colour settings, so dark ones can come out as rich blacks on several plates; paint them with CMYK colours (Edit › Edit Colors › Convert to CMYK) for pure black ink", - parts.join(" and ") - )) - } -} - -/// A PDF being written, shared by export and print: the file's configuration and metadata, then -/// its pages ([`Self::page`]), each drawn by an [`Exporter`]. -pub(crate) struct Writer { - pub pdf: write::Writer, - /// How colours are written (the Output settings). - pub out: Arc, - /// Groups blend in CMYK (a CMYK document's composite, or converting to CMYK; see - /// `cmyk_blending`). - cmyk: bool, - /// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]). - lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, - warnings: Vec, - /// RGB content of a CMYK document, gathered from the exporters ([`Self::absorb`]). - rgb_in_cmyk: RgbInCmyk, - /// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups. - pdf13: bool, - /// The document's images the exporters wrote, by key, gathered from the exporters - /// ([`Self::absorb`]): [`Self::finish`] finds the key of an image the writer couldn't decode. - images: Vec<(String, Image)>, -} - -/// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), -/// which [`Self::view`] maps onto the page. -pub(crate) struct Sheet<'a> { - /// The page's size in its units. - pub size: (f64, f64), - /// The size of the page's unit in points (its `UserUnit`): a page in points has 1. A side is - /// written at least 1 pt long. - pub unit: f64, - /// The TrimBox and BleedBox, in page space (y down from the top-left corner). - pub trim: Rect, - pub bleed: Rect, - /// Drawing space → page space. - pub view: Affine, - /// The part of the drawing space that shows (a tile): art and marks are clipped to it. - pub window: Option, - /// Document space → drawing space. - pub place: Affine, - /// The art drawn, in document space (the bleed box): what lies outside it is left out. - pub area: Rect, - /// Clip the art to [`Self::area`]. - pub clip: bool, - /// Printer's marks, in drawing space. - pub marks: Option<&'a Node>, - /// Invert the page: paper black, ink clear (a film negative). - pub negative: bool, -} - -impl Writer { - /// A PDF with `set`'s version, standard, compression and colour output, and `doc`'s metadata - /// under `title`, created at `created` (Unix seconds). With `cmyk`, transparency blends in - /// CMYK (unless the colours are converted to RGB). - pub(crate) fn new(doc: &Document, set: &crate::PdfSettings, title: &str, created: Option, cmyk: bool) -> Result { - let out = ColorOut::new(doc, set)?; - let mut warnings = out.profile_notes.clone(); - // Tagged CMYK colours need the profile (PDF/A tags every colour). - let cmyk_profile = if out.tagged { out.cmyk_icc() } else { None }; - if out.tagged && cmyk_profile.is_none() { - warnings.push(format!("the CMYK profile {} can't be embedded: CMYK colours are written untagged", out.cmyk_profile())); - } - let version = match set.compatibility { - // PDF 1.3 files are written with the PDF 1.4 settings: flat, they use nothing newer. - Compatibility::Pdf13 | Compatibility::Pdf14 => PdfVersion::Pdf14, - Compatibility::Pdf15 => PdfVersion::Pdf15, - Compatibility::Pdf16 => PdfVersion::Pdf16, - Compatibility::Pdf17 => PdfVersion::Pdf17, - Compatibility::Pdf20 => PdfVersion::Pdf20, - }; - // `check` has refused the standards (and standard/version pairs) the writer can't produce. - let archival = (set.standard == Standard::PdfA2b).then_some(Archival::A2_B); - let configuration = write::configuration(version, archival)?; - let settings = krilla::SerializeSettings { - compress_content_streams: set.compression.compress_text, - no_device_cs: out.tagged, - cmyk_profile, - configuration, - // SVG glyphs, which krilla can't draw, are drawn as art where they sit (`glyph_text`): - // as text they draw nothing, rather than their fonts' plain outlines over the art. - render_svg_glyph_fn: |_, _, _, _, _| Some(()), - ..Default::default() - }; - - // File Info normalization stays with the application. - let info = &doc.metadata; - let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string); - let metadata = Metadata { - creator: "VectorCraft".into(), - producer: "VectorCraft".into(), - title: Some(title.to_owned()), - authors: given(&info.author).into_iter().collect(), - description: given(&info.description), - keywords: info.keywords.clone(), - created: created.map(date_time), - modified: None, - }; - let pdf = write::Writer::new(settings, metadata); - Ok(Self { - pdf, - cmyk: out.blends_cmyk(cmyk), - out: Arc::new(out), - lab_spots: vec![], - warnings, - rgb_in_cmyk: RgbInCmyk::default(), - pdf13: set.pdf13(), - images: vec![], - }) - } - - /// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks. - pub(crate) fn page(&mut self, ex: &mut Exporter, sheet: &Sheet) -> Result<(), PdfError> { - let (w, h) = sheet.size; - let least = 1.0 / sheet.unit; - let size = Size::from_wh(w.max(least) as f32, h.max(least) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?; - let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32); - let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed))); - let mut s = page.surface(); - let paper = Rect::new(0.0, 0.0, w, h); - if sheet.negative { - cover(&mut s, paper, 255, 1.0); - } - // A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank. - let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO); - s.push_transform(&xf(sheet.view)); - let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1))); - if let Some(clip) = &window { - s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); - } - let placed = sheet.place != Affine::IDENTITY; - if placed { - s.push_transform(&xf(sheet.place)); - } - let r = sheet.area; - let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); - if let Some(clip) = &clip { - s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); - } - // Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF - // writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency - // at the top of a page is put in a group of its own, whose blending space `cmyk_blending` - // rewrites: non-isolated only when blending on the page reaches the paper, since some - // readers (CoreGraphics) drop most of a page in a non-isolated group (#2156). - let doc = ex.doc; - let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout); - let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency(); - if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) { - s.push_isolated(); - } else if cmyk_page_group { - let all = constant_mask(&mut s, r, 1.0); - s.push_mask(all); - } - if fits { - ex.children(&mut s, &doc.layers, r); - } - if page_group || cmyk_page_group { - s.pop(); - } - if clip.is_some() { - s.pop(); - } - if placed { - s.pop(); - } - if fits && let Some(art) = sheet.marks { - // The drawing space the page shows (the marks' area). - let shown = sheet.view.inverse().transform_rect_bbox(paper); - let knockout = std::mem::replace(&mut ex.knockout, false); - // Marks keep their inks: they aren't converted to the destination. The page - // information is drawn as glyph shapes, like the other marks, not as text. - let convert = std::mem::replace(&mut ex.convert, false); - let outline = std::mem::replace(&mut ex.outline_text, true); - ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true); - (ex.knockout, ex.convert, ex.outline_text) = (knockout, convert, outline); - } - if window.is_some() { - s.pop(); - } - s.pop(); - if sheet.negative { - s.push_blend_mode(krilla::blend::BlendMode::Difference); - cover(&mut s, paper, 255, 1.0); - s.pop(); - } - s.finish(); - page.finish(); - Ok(()) - } - - /// Take an exporter's warnings, Lab spot colours and images once its pages are drawn. - pub(crate) fn absorb(&mut self, ex: Exporter) { - for w in ex.warnings { - if !self.warnings.contains(&w) { - self.warnings.push(w); - } - } - self.rgb_in_cmyk.merge(ex.rgb_in_cmyk); - for spot in ex.lab_spots { - if !self.lab_spots.iter().any(|(n, _)| *n == spot.0) { - self.lab_spots.push(spot); - } - } - // The exporter caches images by their key, followed by their size and mask after a NUL. - for (cache, image) in ex.images { - if let (Some(key), Some(image)) = (cache.split('\0').next(), image) { - self.images.push((key.to_string(), image)); - } - } - } - - /// The finished file (with the output intent and Trapped entries, and checked against its - /// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK - /// alternates. When the writer couldn't decode one of the images, its key is added to - /// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned. - pub(crate) fn finish(mut self, left_out: &mut HashSet, display_doc_title: bool) -> Result<(Vec, Vec), PdfError> { - let images = std::mem::take(&mut self.images); - let mut bytes = self.pdf.finish().map_err(|e| { - if let write::WriteError::Serialization(k) = &e - && let krilla::error::KrillaError::Image(failed, ..) = &**k - { - left_out.extend(images.iter().filter(|(_, image)| image == failed).map(|(key, _)| key.clone())); - } - PdfError::from(e) - })?; - if self.cmyk { - cmyk_blending(&mut bytes, self.out.tagged); - } - let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only(); - let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; - let (bytes, more) = self.out.write_catalog(bytes, display_doc_title)?; - self.warnings.extend(more); - self.warnings.extend(self.rgb_in_cmyk.warning()); - Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) - } -} - -/// Make the transparency groups of `pdf` blend in CMYK. The PDF writer gives every group RGB as -/// its blending space (DeviceRGB, or sRGB when colours are `tagged`), so the group dictionaries -/// are rewritten in place to DeviceCMYK (or the CMYK profile's space), keeping their length so the -/// cross-reference offsets stay valid. Luminosity masks' groups keep RGB: mask luminance is that -/// of screen colours, as on screen. -fn cmyk_blending(pdf: &mut [u8], tagged: bool) { - let masks = luminosity_mask_groups(pdf); - let mut spaces = vec![("/CS/DeviceRGB".to_string(), "/CS/DeviceCMYK".to_string())]; - if tagged { - spaces.extend(icc_spaces(pdf)); - } - let mut from = 0; - while let Some(at) = find(pdf, b"/Group<<", from).map(|i| i + b"/Group".len()) { - let end = find(pdf, b">>", at).map_or(pdf.len(), |i| i + 2); - let mask = pdf.get(..at).and_then(object_number).is_some_and(|n| masks.contains(&n)); - if let (false, Some(dict)) = (mask, pdf.get_mut(at..end)) { - cmyk_group(dict, &spaces); - } - from = end; - } -} - -/// The ICC-based RGB colour spaces of `pdf`, each with the ICC-based CMYK one to blend in instead, -/// as group `/CS` entries (`/CS 3 0 R`); none without a CMYK one. -fn icc_spaces(pdf: &[u8]) -> Vec<(String, String)> { - const ICC: &[u8] = b"[/ICCBased "; - let (mut rgb, mut cmyk) = (vec![], None); - let mut from = 0; - while let Some(at) = find(pdf, ICC, from) { - from = at + ICC.len(); - let digits = pdf.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count()); - let (Some(array), Some(stream)) = (pdf.get(..at).and_then(object_number), pdf.get(from..from + digits).and_then(number)) else { - continue; - }; - // The profile stream's dictionary says how many components it has. - let Some(head) = find(pdf, format!("\n{stream} 0 obj").as_bytes(), 0) else { continue }; - let dict = pdf.get(head..find(pdf, b"stream", head).unwrap_or(pdf.len())).unwrap_or_default(); - if find(dict, b"/N 4", 0).is_some() { - cmyk.get_or_insert(format!("/CS {array} 0 R")); - } else if find(dict, b"/N 3", 0).is_some() { - rgb.push(format!("/CS {array} 0 R")); - } - } - let Some(cmyk) = cmyk else { return vec![] }; - rgb.into_iter().map(|r| (r, cmyk.clone())).collect() -} - -/// Rewrite transparency group dictionary `dict` (`<<…>>`) to blend in the CMYK space of the first -/// of `spaces` (RGB `/CS` entry, CMYK one) it has, at the same length: leaving out the optional -/// `/Type/Group` makes room for the longer entry, spaces pad the rest. -fn cmyk_group(dict: &mut [u8], spaces: &[(String, String)]) { - let Ok(text) = std::str::from_utf8(dict) else { return }; - if !(text.contains("/S/Transparency") && text.contains("/Type/Group")) { - return; - } - let Some((rgb, cmyk)) = spaces.iter().find(|(rgb, _)| text.contains(rgb.as_str())) else { return }; - let body = text.replacen("/Type/Group", "", 1).replacen(rgb.as_str(), cmyk, 1); - let body = body.strip_suffix(">>").unwrap_or(&body); - let Some(pad) = dict.len().checked_sub(body.len() + 2) else { return }; - let new = format!("{body}{}>>", " ".repeat(pad)); - dict.copy_from_slice(new.as_bytes()); -} - -/// Object numbers of the groups luminosity soft masks draw (`/G n 0 R` in their dictionaries). -fn luminosity_mask_groups(pdf: &[u8]) -> Vec { - let mut out = vec![]; - let mut from = 0; - while let Some(at) = find(pdf, b"/Type/Mask", from) { - let end = find(pdf, b">>", at).unwrap_or(pdf.len()); - let dict = &pdf[at..end]; - if find(dict, b"/S/Luminosity", 0).is_some() - && let Some(g) = find(dict, b"/G ", 0) - { - let rest = &dict[g + 3..]; - out.extend(number(&rest[..rest.iter().take_while(|b| b.is_ascii_digit()).count()])); - } - from = end; - } - out -} - -/// The number of the object whose dictionary `before` ends in (its last `n 0 obj` header). -fn object_number(before: &[u8]) -> Option { - let at = rfind(before, b" 0 obj")?; - let digits = before[..at].iter().rev().take_while(|b| b.is_ascii_digit()).count(); - number(&before[at - digits..at]) -} - -fn number(digits: &[u8]) -> Option { - std::str::from_utf8(digits).ok()?.parse().ok() -} - -/// Unix seconds (UTC) → krilla date. -fn date_time(t: i64) -> krilla::metadata::DateTime { - let [year, month, day, hour, minute, second] = vectorcraft_doc::metadata::civil(t); - krilla::metadata::DateTime::new(year as u16) - .month(month as u8) - .day(day as u8) - .hour(hour as u8) - .minute(minute as u8) - .second(second as u8) - .utc_offset_hour(0) - .utc_offset_minute(0) -} +pub(crate) use entry::{RgbInCmyk, leaving_out_undecodable}; +pub use entry::{export, export_with_report, page_areas}; +#[cfg(test)] +use exporter::{MAX_COORD, writable, xf}; +#[cfg(test)] +use images::{png_8_bit, png_16_bit}; +pub(crate) use writer::{Sheet, Writer}; pub(crate) struct Exporter<'a> { doc: &'a Document, @@ -632,1563 +112,6 @@ pub(crate) struct Exporter<'a> { symbol_nodes: u64, } -/// How deep inline graphics nest before they are left out. -const MAX_INLINE_DEPTH: u32 = 4; - -/// How deep tiling patterns nest: deeper pattern fills are written as their tiles. -const MAX_PATTERN_DEPTH: u32 = 4; - -/// The warning for an image left out because it can't be decoded: the note raster exports give -/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). -const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; - -impl<'a> Exporter<'a> { - /// An exporter of `doc` writing images as `set` says; layers whose Print option is off are - /// left out. - pub(crate) fn new(doc: &'a Document, set: &'a crate::PdfSettings) -> Self { - Self { - doc, - symbols: doc.symbol_boxes().quick().with_brush_art(), - warnings: vec![], - images: HashMap::new(), - brushes: None, - knockout: doc.page_knockout, - lab_spots: vec![], - interpolate: set.standard != Standard::PdfA2b, - // PDF 1.3 and 1.4 files are written with the PDF 1.4 settings ([`Writer::new`]). - sixteen_bit: !matches!(set.compatibility, Compatibility::Pdf13 | Compatibility::Pdf14), - compression: &set.compression, - non_printing: false, - intent: vectorcraft_color::cms::active().settings().intent, - transparent: None, - out: Arc::default(), - convert: true, - rgb_in_cmyk: RgbInCmyk::default(), - outline_text: set.advanced.outline_text, - fonts: HashMap::new(), - layers: false, - layer_index: crate::forms::pdf_layers(doc).iter().enumerate().map(|(i, l)| (address(l.node), i)).collect(), - in_layer: 0, - symbol_nodes: 0, - overprint: false, - overprinted: false, - invisible: false, - inline_depth: 0, - pattern_depth: 0, - left_out: HashSet::new(), - } - } - - /// Whether a layer shows transparency, which CMYK pages put in a group of its own. - fn shows_transparency(&mut self) -> bool { - let doc = self.doc; - *self.transparent.get_or_insert_with(|| doc.layers.iter().any(|l| l.shows_transparency())) - } -} - -/// Glyphs written together as real text: the same font at the same size along one baseline. -struct GlyphLine { - face: u32, - font: krilla::text::Font, - size: f64, - /// The glyph space (krilla's, y down, at `size`) → text space, at the line's start. - frame: Affine, - /// Glyph id, pen position and advance along the line (in glyph space), and the characters it - /// stands for (bytes of the plain text, or of the list marker's text). - glyphs: Vec<(u32, f64, f64, std::ops::Range)>, - /// The list marker the glyphs belong to, by its first glyph's index in the layout - /// ([`TextLayout::marker_of`]); `None` for glyphs of the plain text. - marker: Option, -} - -/// Most pixels along a side of a freeform gradient's image (its colour field is smooth). -const MAX_FIELD_PX: f64 = 512.0; -/// Most pixels along a side of a linear or radial gradient's image. -const MAX_GRADIENT_PX: f64 = 4096.0; - -/// The largest magnitude a coordinate, length or transform entry may have in the content stream it -/// is drawn into (for glyph spacing, in thousandths of an em). Art that would go beyond it is left -/// out. A PDF/A-2 file may hold only integers from -2,147,483,648 to 2,147,483,647 (ISO 19005-2, -/// 6.1.13). The PDF writer writes a whole number in the `i32` range as an integer, and in builds -/// with overflow checks it panics on -2,147,483,648. -const MAX_COORD: f64 = 1e9; - -fn xf(a: Affine) -> Transform { - let c = a.as_coeffs(); - Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) -} - -/// The current transform of surface `s` in the content stream it draws into, without that stream's -/// root transform (a page's y flip). -fn ctm(s: &Surface) -> Affine { - let t = s.ctm(); - Affine::new([t.sx(), t.ky(), t.kx(), t.sy(), t.tx(), t.ty()].map(f64::from)) -} - -/// Are `values` finite and within [`MAX_COORD`]? -fn in_range(values: &[f64]) -> bool { - values.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) -} - -/// Can art spanning `b`, in the space `space` maps into the one `s` draws in, be written: `b`, the -/// transform of `s` followed by `space`, and `b` through that transform all within [`MAX_COORD`]? -fn writable(s: &Surface, space: Affine, b: Rect) -> bool { - let t = ctm(s) * space; - let to = t.transform_rect_bbox(b); - in_range(&t.as_coeffs()) && in_range(&[b.x0, b.y0, b.x1, b.y1, to.x0, to.y0, to.x1, to.y1]) -} - -/// `bp` as a path to draw on `s`; `None` when it is empty or reaches beyond [`MAX_COORD`] there. -fn to_path(s: &Surface, bp: &BezPath) -> Option { - if !writable(s, Affine::IDENTITY, bp.control_box()) { - return None; - } - let mut pb = PathBuilder::new(); - for el in bp.elements() { - match *el { - PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32), - PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32), - PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32), - PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32), - PathEl::ClosePath => pb.close(), - } - } - pb.finish() -} - -fn norm(v: f32) -> NormalizedF32 { - NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE) -} - -fn rule(r: FillRule) -> krilla::paint::FillRule { - match r { - FillRule::NonZero => krilla::paint::FillRule::NonZero, - FillRule::EvenOdd => krilla::paint::FillRule::EvenOdd, - } -} - -fn blend(b: BlendMode) -> krilla::blend::BlendMode { - use krilla::blend::BlendMode as K; - match b { - BlendMode::Normal => K::Normal, - BlendMode::Darken => K::Darken, - BlendMode::Multiply => K::Multiply, - BlendMode::ColorBurn => K::ColorBurn, - BlendMode::Lighten => K::Lighten, - BlendMode::Screen => K::Screen, - BlendMode::ColorDodge => K::ColorDodge, - BlendMode::Overlay => K::Overlay, - BlendMode::SoftLight => K::SoftLight, - BlendMode::HardLight => K::HardLight, - BlendMode::Difference => K::Difference, - BlendMode::Exclusion => K::Exclusion, - BlendMode::Hue => K::Hue, - BlendMode::Saturation => K::Saturation, - BlendMode::Color => K::Color, - BlendMode::Luminosity => K::Luminosity, - } -} - -fn q(v: f32) -> u8 { - (v.clamp(0.0, 1.0) * 255.0).round() as u8 -} - -fn color(c: &Color) -> krilla::color::Color { - match *c { - Color::Rgb { r, g, b } => rgb::Color::new(q(r), q(g), q(b)).into(), - Color::Cmyk { c, m, y, k } => cmyk::Color::new(q(c), q(m), q(y), q(k)).into(), - // VectorCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness. - Color::Gray { k } => luma::Color::new(q(1.0 - k)).into(), - Color::Lab { .. } => { - let [r, g, b] = c.to_rgb(); - rgb::Color::new(q(r), q(g), q(b)).into() - } - } -} - -/// Fill the page (with a margin) with a grey level (0 = black, 255 = white) at `opacity`: mask -/// backdrops. -fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) { - let Some(p) = to_path(s, &page.inflate(1.0, 1.0).to_path(0.1)) else { return }; - s.set_stroke(None); - s.set_fill(Some(Fill { paint: rgb::Color::new(level, level, level).into(), opacity: norm(opacity), rule: krilla::paint::FillRule::NonZero })); - s.draw_path(&p); -} - -/// An alpha mask of constant `alpha` over the page. A group drawn through it is a transparency -/// group of that opacity that, unlike the writer's opacity groups, is not isolated. -/// The cover is white, so a reader that takes every soft mask as a luminosity mask shows the same. -fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask { - let mut sb = s.stream_builder(); - let mut ms = sb.surface(); - cover(&mut ms, page, 255, alpha); - ms.finish(); - krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha) -} - -/// A node's address, to tell the very node apart from equal ones. -fn address(n: &Node) -> usize { - std::ptr::from_ref(n).addr() -} - -fn rects_overlap(a: Rect, b: Rect) -> bool { - a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 -} - -impl Exporter<'_> { - /// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without - /// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the - /// document's colour spaces, so the file carries press values. - fn col(&mut self, c: &Color) -> krilla::color::Color { - let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; - if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) { - self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]); - } - let c = self.out.color(c, self.intent, cmyk_doc, self.convert); - if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) { - let note = note.clone(); - self.warn(note); - } - color(&c) - } - - /// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate - /// space; a Lab spot colour also gets a Lab one, [`crate::lab_spot`], unless the Spot Colors - /// options use CMYK values); `None` when `name` isn't a spot colour. - fn separation(&mut self, name: &str) -> Option { - let doc = self.doc; - let sw = doc.swatch(name).filter(|s| s.spot)?; - let color = doc.linked_color(sw.paint.color()?, true); - if let Color::Lab { l, a, b } = color - && !self.lab_spots.iter().any(|(n, _)| n == name) - { - self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b))); - } - let full = self.out.cmyk(&color, self.intent); - let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3]))); - Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt)) - } - - /// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch - /// or to Registration (`/All`: every plate). - fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> krilla::color::Color { - if link == Some(vectorcraft_color::swatch::REGISTRATION) { - let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(255, 255, 255, 255)); - return SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::AllColorants, alt)).into(); - } - match link.and_then(|n| self.separation(n)) { - Some(space) => SepColor::new(q(tint), space).into(), - None => self.col(c), - } - } - - /// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of - /// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the - /// tint and opacity there are halfway). `None` for other gradients; one that mixes a spot ink - /// with other colours is written in process colours (the PDF writer has no DeviceN). - fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { - let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; - let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; - let tints: Option> = g - .stops - .iter() - .map(|s| match s.swatch.as_deref() { - Some(n) if n == ink => Some(s.tint), - None if paper(&s.color) => Some(0.0), - _ => None, - }) - .collect(); - let Some(tints) = tints else { - self.warn("gradients mixing a spot color with other colors are exported in process colors"); - return None; - }; - let space = self.separation(ink)?; - let tint = |t: f32| -> krilla::color::Color { SepColor::new(q(t), space.clone()).into() }; - let mut out = vec![]; - for (i, s) in g.stops.iter().enumerate() { - out.push((s.offset, tint(tints[i]), s.opacity)); - if let Some(n) = g.stops.get(i + 1) - && (s.midpoint - 0.5).abs() > 1e-3 - { - let at = s.offset + (n.offset - s.offset) * s.midpoint; - out.push((at, tint((tints[i] + tints[i + 1]) / 2.0), (s.opacity + n.opacity) / 2.0)); - } - } - Some(out) - } - - /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect: the - /// app renders them to images before export (`vectorcraft_engine::export_pdf`), so the ones - /// still here (on `what`) can't be written. - fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect], what: impl FnOnce() -> String) { - if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { - self.warn(format!("raster effects (shadows, glows, blur, feather) on {} are left out of the PDF", what())); - } - } - - fn warn(&mut self, w: impl Into) { - let w = w.into(); - if !self.warnings.contains(&w) { - self.warnings.push(w); - } - } - - /// Convert a paint to draw on `s`. `bounds` resolves unset gradient geometry (like the - /// renderer). A gradient placed beyond [`MAX_COORD`] there gives no paint. - fn paint(&mut self, s: &Surface, p: &Paint, bounds: Rect) -> Option { - self.paint_in(s, p, bounds, Affine::IDENTITY) - } - - /// [`Self::paint`] drawn in another space: `space` maps the paint's space (where `bounds` - /// is) to the one drawn in. - fn paint_in(&mut self, s: &Surface, p: &Paint, bounds: Rect, space: Affine) -> Option { - match p { - Paint::None => None, - Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), - Paint::Gradient(g) => { - let geom = g.resolve(bounds); - let spot = (g.gradient.kind != GradientKind::Freeform).then(|| self.spot_stops(&g.gradient)).flatten(); - let colored = match spot { - Some(v) => v, - None => { - let stops = g.gradient.expanded_stops(); - // The PDF writer needs every stop in one colour space: stops of mixed - // models (midpoints sample in RGB) go through RGB, or stay CMYK in a CMYK - // document, where `col` separates the rest. - let mixed = stops.windows(2).any(|w| w[0].1.model() != w[1].1.model()); - let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; - let one = |c: Color| match c { - Color::Cmyk { .. } if cmyk_doc => c, - _ if mixed => c.in_model(vectorcraft_color::cms::Model::Rgb), - _ => c, - }; - stops.into_iter().map(|(o, c, a)| (o, self.col(&one(c)), a)).collect() - } - }; - let mut stops: Vec = Vec::new(); - let mut last = 0.0f32; - for (o, color, a) in colored { - let o = o.clamp(last, 1.0); - last = o; - stops.push(Stop { offset: norm(o), color, opacity: norm(a) }); - } - if stops.is_empty() { - return None; - } - match g.gradient.kind { - GradientKind::Linear => { - if !writable(s, space, Rect::from_points(geom.start, geom.end)) { - return None; - } - let (s, mut e) = (geom.start, geom.end); - if s.distance(e) < 1e-9 { - e = s + Vec2::new(1.0, 0.0); - } - Some( - LinearGradient { - x1: s.x as f32, - y1: s.y as f32, - x2: e.x as f32, - y2: e.y as f32, - transform: xf(space), - spread_method: SpreadMethod::Pad, - stops, - anti_alias: false, - } - .into(), - ) - } - GradientKind::Radial => { - let r = geom.length().max(1e-6) as f32; - let t = geom.radial_squash(); - let (cx, cy) = (geom.start.x as f32, geom.start.y as f32); - // An off-centre focal point: a two-point radial shading from it. - let f = t.inverse() * geom.focal_point(); - let circles = Rect::from_center_size(geom.start, (2.0 * f64::from(r), 2.0 * f64::from(r))).union_pt(f); - if !writable(s, space * t, circles) { - return None; - } - Some( - RadialGradient { - fx: f.x as f32, - fy: f.y as f32, - fr: 0.0, - cx, - cy, - cr: r, - transform: xf(space * t), - spread_method: SpreadMethod::Pad, - stops, - anti_alias: false, - } - .into(), - ) - } - // Painted areas get an image of the field ([`Self::area`]); elsewhere (gradient - // slices) the average colour stands in. - GradientKind::Freeform => { - self.warn("freeform gradients are exported as their average colour"); - let n = g.gradient.stops.len().max(1) as f32; - let mut acc = [0.0f32; 3]; - for s in &g.gradient.stops { - let c = s.color.to_rgb(); - for i in 0..3 { - acc[i] += c[i] / n; - } - } - Some(self.col(&Color::rgb(acc[0], acc[1], acc[2])).into()) - } - } - } - // Patterns paint through [`Self::area`]: only a missing one gets here. - Paint::Pattern { .. } => { - self.warn("missing patterns are exported as mid-grey"); - Some(rgb::Color::new(128, 128, 128).into()) - } - } - } - - /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; - /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). - fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask { - let mut sb = s.stream_builder(); - let mut ms = sb.surface(); - // An opaque backdrop when it matters: white outside the art (no clip), or black for the - // inverting Difference pass below to turn white. - if !m.clip || m.invert { - cover(&mut ms, page, if m.clip { 0 } else { 255 }, 1.0); - } - // Mask art is a picture of its own: it takes no part in a knockout group around the object, - // and has no inks to overprint. - let knockout = std::mem::take(&mut self.knockout); - let overprint = std::mem::take(&mut self.overprint); - self.node(&mut ms, &m.art, page, true); - (self.knockout, self.overprint) = (knockout, overprint); - if m.invert { - ms.push_blend_mode(krilla::blend::BlendMode::Difference); - cover(&mut ms, page, 255, 1.0); - ms.pop(); - } - ms.finish(); - krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) - } - - /// The children of a group, as the elements of a knockout group when it is one. - fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect) { - if !self.knockout { - for c in children { - self.node(s, c, page, false); - } - return; - } - // The PDF writer has no knockout groups: each element is drawn through a soft mask of - // where the elements above it don't paint (the same look for Normal blending). The masks - // nest, so element i sits inside the masks of elements i+1…n: open them outermost first. - self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); - let elements: Vec<_> = Node::knockout_elements(children) - .into_iter() - .filter(|c| c.visual_bounds_with(&self.symbols).is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) - .collect(); - let Some((first, rest)) = elements.split_first() else { return }; - for c in rest.iter().rev() { - let mask = self.knockout_mask(s, c, page); - s.push_mask(mask); - } - self.node(s, first, page, false); - for c in rest { - s.pop(); - self.node(s, c, page, false); - } - } - - /// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha - /// mask of `c` (at full object opacity without its own mask, unless those define its shape). - fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { - let shape = if c.knockout_shape { - c.clone() - } else { - let mut s = c.clone().with_mask(None); - s.opacity = 1.0; - s - }; - let mut sb = s.stream_builder(); - let mut ms = sb.surface(); - cover(&mut ms, page, 255, 1.0); - let alpha = { - let mut ab = ms.stream_builder(); - let mut als = ab.surface(); - let overprint = std::mem::take(&mut self.overprint); - self.node(&mut als, &shape, page, false); - self.overprint = overprint; - als.finish(); - krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) - }; - ms.push_mask(alpha); - cover(&mut ms, page, 0, 1.0); - ms.pop(); - ms.finish(); - krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) - } - - fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { - if self.layers - && self.in_layer != address(n) - && let NodeKind::Layer { printable, .. } = n.kind - && (printable || self.non_printing) - && let Some(&i) = self.layer_index.get(&address(n)) - { - // A PDF layer: hidden layers and sublayers are written too (their group is off). - let enclosing = std::mem::replace(&mut self.in_layer, address(n)); - crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true)); - self.in_layer = enclosing; - return; - } - if !force && !n.visible { - return; - } - if let NodeKind::Layer { template, printable, .. } = n.kind - && (template || !(printable || self.non_printing)) - { - return; - } - match n.visual_bounds_with(&self.symbols) { - Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return, - None if !n.is_container() => return, - _ => {} - } - self.warn_raster(&n.appearance.effects, || format!("{} objects", n.kind_label().to_lowercase())); - let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); - // Whether this container's children knock each other out (a knockout group is written as a group). - let knockout = n.knocks_out(self.knockout); - let enclosing = std::mem::replace(&mut self.knockout, knockout); - let mut pushes = 0; - if n.blend != BlendMode::Normal { - s.push_blend_mode(blend(n.blend)); - pushes += 1; - } - if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) { - if !n.isolate && n.blends_through() { - // Not isolated: blending inside reaches the art below the group. - let alpha = constant_mask(s, page, n.opacity); - s.push_mask(alpha); - } else if n.opacity < 1.0 { - s.push_opacity(norm(n.opacity)); - } else { - s.push_isolated(); - } - pushes += 1; - } - let mask = n.extras().mask.as_deref().filter(|m| !m.disabled); - let alpha = match (&n.kind, mask) { - (NodeKind::Image(im), Some(m)) => self.masked_image(im, m), - _ => None, - }; - if let Some(m) = mask - && alpha.is_none() - { - let mask = self.soft_mask(s, m, page); - s.push_mask(mask); - pushes += 1; - } - match &n.kind { - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - // The region every output clips to; nothing to clip by hides the clipped art. The - // clipping path's fill paints behind the clipped art and its stroke over it, unclipped. - if let Some((clip, rest)) = children.split_first() - && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(s, &bp).map(|p| (p, r))) - { - let paint = clip.clip_paint(); - s.push_clip_path(&p, &rule(r)); - if let Some(fill) = &paint.fill { - self.node(s, fill, page, false); - } - self.children(s, rest, page); - s.pop(); - if let Some(stroke) = &paint.stroke { - self.node(s, stroke, page, false); - } - } - } - NodeKind::Path { path, rule, guide, .. } => { - if !*guide { - self.shape(s, n, &path.to_bezpath(), *rule, page); - } - } - NodeKind::Compound { children, rule } => { - let mut bp = BezPath::new(); - for c in children { - if let Some(p) = c.path_data() { - bp.extend(p.to_bezpath()); - } - } - self.shape(s, n, &bp, *rule, page); - } - NodeKind::Text(t) => self.text(s, n, t, page), - NodeKind::Image(im) => self.image(s, im, alpha), - NodeKind::SymbolInstance { symbol, xf } => { - // Within the objects a frame may draw of symbol instances, as on screen: nested - // instances multiply. - let sym = self.doc.symbols.iter().find(|x| &x.name == symbol); - let total = sym.map_or(0, |sym| self.symbol_nodes.saturating_add(sym.art.count() as u64)); - if let Some(sym) = sym.filter(|_| total <= vectorcraft_doc::MAX_SYMBOL_NODES) { - self.symbol_nodes = total; - let mut art = (*sym.art).clone(); - art.transform(*xf, false); - self.node(s, &art, page, true); - } - } - // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => { - let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); - for c in g.children().into_iter().flatten() { - self.node(s, c, page, false); - } - } - } - self.knockout = enclosing; - for _ in 0..pushes { - s.pop(); - } - } - - fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) { - let Some(path) = to_path(s, bp) else { return }; - let bounds = bp.bounding_box(); - for item in &n.appearance.items { - match item { - AppearanceItem::Fill(fl) => { - if !fl.visible || fl.paint.is_none() { - continue; - } - self.warn_raster(&fl.effects, || "fills".into()); - self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { - if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds, true) { - return; - } - let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return }; - let bl = fl.blend != BlendMode::Normal; - if bl { - s.push_blend_mode(blend(fl.blend)); - } - s.set_stroke(None); - s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) })); - s.draw_path(&path); - if bl { - s.pop(); - } - }); - } - AppearanceItem::Stroke(st) => { - if !st.visible || st.paint.is_none() || st.width <= 0.0 { - continue; - } - self.warn_raster(&st.effects, || "strokes".into()); - self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, &path, r, st, page, bounds)); - } - } - } - s.set_fill(None); - s.set_stroke(None); - } - - /// Draw a fill or stroke of `paint` with `draw`: as a form marked to overprint when it - /// `overprints`, paints and overprints are kept (but not a white one with Discard White - /// Overprint). - fn overprinting(&mut self, s: &mut Surface, overprints: bool, paint: &Paint, draw: impl FnOnce(&mut Self, &mut Surface)) { - let white = || self.doc.setup.discard_white_overprint && vectorcraft_doc::overprint::is_white(paint); - if overprints && self.overprint && !paint.is_none() && !white() { - self.overprinted = true; - crate::forms::form(s, Mark::Overprint, |s| draw(self, s)); - } else { - draw(self, s); - } - } - - /// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which - /// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels - /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`])? - fn area_paint(&self, p: &Paint, fill: bool) -> bool { - match p { - Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), - Paint::Gradient(g) => g.draws_as_pixels(fill), - _ => false, - } - } - - /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, - /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a - /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), or an image - /// of a gradient drawn as pixels placed on `bounds` (the painted object's box, as on the - /// canvas), clipped to it. False (nothing drawn) for other paints. - #[allow(clippy::too_many_arguments)] - fn area( - &mut self, - s: &mut Surface, - clip: &Path, - rule: krilla::paint::FillRule, - p: &Paint, - transparency: (f32, BlendMode), - bounds: Rect, - area: Rect, - fill: bool, - ) -> bool { - if !self.area_paint(p, fill) { - return false; - } - let (opacity, mode) = transparency; - let tiling = match p { - Paint::Pattern { pattern, xf } => self.tiling(s, pattern, *xf), - _ => None, - }; - let mut pushes = 0; - if mode != BlendMode::Normal { - s.push_blend_mode(blend(mode)); - pushes += 1; - } - if let Some(paint) = tiling { - s.set_stroke(None); - s.set_fill(Some(Fill { paint, opacity: norm(opacity), rule })); - s.draw_path(clip); - s.set_fill(None); - } else { - s.push_clip_path(clip, &rule); - pushes += 1; - if opacity < 1.0 { - s.push_opacity(norm(opacity)); - pushes += 1; - } - let doc = self.doc; - match p { - Paint::Pattern { pattern, xf } => { - if let Some(def) = doc.pattern(pattern) { - self.warn("pattern fills nested more than 4 patterns deep, or with tiles too large for PDF, are written as their tiles"); - for inst in def.instances_in(*xf, area) { - self.node(s, &inst, area, true); - } - } - } - Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill), - _ => {} - } - } - for _ in 0..pushes { - s.pop(); - } - true - } - - /// Pattern swatch `name` placed by `at` (pattern space → the current space) as a PDF tiling - /// pattern (ISO 32000-1, 8.7.3.1): one period of the tiling ([`vectorcraft_doc::PatternDef::period`], - /// what the canvas repeats too) with every tile instance reaching into it, in paint order, so - /// brick and hex tilings and overlapping tiles repeat as they are; clipped to the period when - /// art reaches out of it. `None` for a pattern nested deeper than [`MAX_PATTERN_DEPTH`], a period - /// or placement beyond [`MAX_COORD`] or a placement that flattens the tiles. - fn tiling(&mut self, s: &mut Surface, name: &str, at: Affine) -> Option { - let doc = self.doc; - let def = doc.pattern(name)?; - let (pw, ph) = def.period(); - if self.pattern_depth >= MAX_PATTERN_DEPTH || !in_range(&[pw, ph]) || !in_range(&at.as_coeffs()) || at.determinant().abs() <= 1e-12 { - return None; - } - let cell = Rect::new(0.0, 0.0, pw, ph); - let tiles = def.instances_in(Affine::IDENTITY, cell); - let mut sb = s.stream_builder(); - let mut cs = sb.surface(); - let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b)); - let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten(); - if let Some(c) = &clip { - cs.push_clip_path(c, &krilla::paint::FillRule::NonZero); - } - // Pattern art is a picture of its own: it takes no part in a knockout group around the object. - let knockout = std::mem::take(&mut self.knockout); - self.pattern_depth += 1; - for t in &tiles { - self.node(&mut cs, t, cell, true); - } - self.pattern_depth -= 1; - self.knockout = knockout; - if clip.is_some() { - cs.pop(); - } - cs.finish(); - Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) - } - - /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's - /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so - /// scaled type gets its pixels too): a freeform gradient's colour field (at most - /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side), - /// with dither's noise when it paints a fill (`fill`). - fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { - use vectorcraft_color::freeform::{painted_box, spread_scale}; - let Some(b) = painted_box(bounds) else { return }; - let area = area.abs(); - // The area's sides in points on the page. - let [m0, m1, m2, m3, _, _] = ctm(s).as_coeffs(); - let (w, h) = (area.width() * m0.hypot(m1), area.height() * m2.hypot(m3)); - let long = w.max(h); - if !(long > 1e-9 && long.is_finite()) { - return; - } - let freeform = g.gradient.kind == GradientKind::Freeform; - let max = if freeform { MAX_FIELD_PX } else { MAX_GRADIENT_PX }; - let k = (self.doc.raster_effects_ppi / 72.0).min(max / long); - let along = |len: f64| (len * k).ceil().clamp(1.0, max) as u16; - let (cols, rows) = (along(w), along(h)); - let rgba: Vec = if freeform { - let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); - field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() - } else { - self.warn("linear and radial gradients drawn as pixels are exported as images"); - g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect() - }; - let pixels = if self.convert { self.out.pixels() } else { None }; - let (Some(img), Some(size)) = ( - crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels), - Size::from_wh(area.width() as f32, area.height() as f32), - ) else { - return; - }; - s.push_transform(&xf(Affine::translate(area.origin().to_vec2()))); - s.draw_image(img, size); - s.pop(); - } - - /// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its - /// unplaced gradients. - #[allow(clippy::too_many_arguments)] - fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect, bounds: Rect) { - if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { - return; - } - if self.area_paint(&st.paint, false) && st.path_gradient().is_none() { - // The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as - // Outline Stroke makes it, painted with the pattern's tiles or the gradient's image (a - // gradient along or across the stroke is written as slices below). - let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath(); - if let Some(clip) = to_path(s, ®ion) { - let area = region.bounding_box(); - self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area, false); - } - return; - } - let Some(paint) = self.paint(s, &st.paint, st.paint_bounds(bounds)) else { return }; - let w = stroke::for_writer(bp, st); - // A stroke whose width, caps or miter spikes reach beyond [`MAX_COORD`] is left out. - if !writable(s, Affine::IDENTITY, w.reach(st, bp.control_box()).inflate(1.0, 1.0)) { - return; - } - let mut pushes = 0; - if st.blend != BlendMode::Normal { - s.push_blend_mode(blend(st.blend)); - pushes += 1; - } - match w.side { - Some(StrokeAlign::Inside) => { - s.push_clip_path(path, &rule(r)); - pushes += 1; - } - Some(StrokeAlign::Outside) => { - // Clip to everything outside the path: a frame around all the stroke reaches - // (miter spikes included) plus the path, even-odd. - let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1); - outside.extend(bp.iter()); - if let Some(p) = to_path(s, &outside) { - s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); - pushes += 1; - } - } - _ => {} - } - match &w.shape { - WrittenShape::Stroke { width } => { - let dash = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| { - let mut pat: Vec = d.pattern.iter().map(|v| *v as f32).collect(); - if pat.len() % 2 == 1 { - pat.extend(pat.clone()); - } - // An offset beyond [`MAX_COORD`] is written as its remainder after whole - // periods of the pattern. - let period: f64 = pat.iter().copied().map(f64::from).sum(); - let offset = if in_range(&[d.offset]) { d.offset } else { d.offset.rem_euclid(period) }; - StrokeDash { array: pat, offset: if offset.is_finite() { offset as f32 } else { 0.0 } } - }); - s.set_fill(None); - s.set_stroke(Some(Stroke { - paint, - width: *width as f32, - miter_limit: st.miter_limit.max(1.0) as f32, - line_cap: match st.cap { - LineCap::Butt => krilla::paint::LineCap::Butt, - LineCap::Round => krilla::paint::LineCap::Round, - LineCap::Square => krilla::paint::LineCap::Square, - }, - line_join: match st.join { - LineJoin::Miter => krilla::paint::LineJoin::Miter, - LineJoin::Round => krilla::paint::LineJoin::Round, - LineJoin::Bevel => krilla::paint::LineJoin::Bevel, - }, - opacity: norm(st.opacity), - dash, - })); - s.draw_path(path); - s.set_stroke(None); - } - WrittenShape::Fill(outlines) if st.path_gradient().is_some() => { - // A gradient along or across the stroke: slices clipped to its outlines, under - // the stroke's opacity as a group. - if let Some(ws) = stroke::written_slices(bp, r, st, outlines) - && let Some(clip) = to_path(s, &ws.clip) - { - self.warn("gradients along or across strokes are exported as slices of linear gradients"); - if st.opacity < 1.0 { - s.push_opacity(norm(st.opacity)); - pushes += 1; - } - s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); - s.set_stroke(None); - for (shape, paint) in &ws.slices { - let b = shape.bounding_box(); - if let (Some(paint), Some(p)) = (self.paint(s, paint, b), to_path(s, shape)) { - s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); - s.draw_path(&p); - } - } - s.set_fill(None); - s.pop(); - } - } - WrittenShape::Fill(outlines) => { - // The line and its arrowheads overlap: they take the opacity once, as a group. - let grouped = outlines.len() > 1 && st.opacity < 1.0; - if grouped { - s.push_opacity(norm(st.opacity)); - pushes += 1; - } - let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) }; - s.set_stroke(None); - s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero })); - for outline in outlines { - if let Some(p) = to_path(s, outline) { - s.draw_path(&p); - } - } - s.set_fill(None); - } - } - for _ in 0..pushes { - s.pop(); - } - } - - /// Paint stroke `st` of the shape `bp` with its brush art (the stroke's opacity and blend mode - /// over all of it). False when it has no known brush. - fn brush_art(&mut self, s: &mut Surface, bp: &BezPath, st: &StrokeLayer, page: Rect) -> bool { - let doc = self.doc; - let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); - let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; - let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st); - let mut pushes = 0; - if st.blend != BlendMode::Normal { - s.push_blend_mode(blend(st.blend)); - pushes += 1; - } - if st.opacity < 1.0 { - s.push_opacity(norm(st.opacity)); - pushes += 1; - } - for piece in &art { - self.node(s, piece, page, false); - } - for _ in 0..pushes { - s.pop(); - } - true - } - - /// - /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are drawn as their symbols' art - /// (vector paths, like symbol instances) where the layout placed them; their characters - /// write no glyph. - fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject, page: Rect) { - let doc = self.doc; - let resolved = doc.inline_resolved(t); - let t = &*resolved; - let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let tb = t.xf.transform_rect_bbox(layout.bounds); - // The object's own fills and strokes paint the whole outline: those below the Characters - // row under the characters, the others over them. - let (below, above) = n.appearance.split_contents(); - let all = (!n.appearance.items.is_empty()).then(|| { - let mut all = layout.to_bezpath(); - for (_, bar) in vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t) { - all.extend(bar.iter()); - } - all.apply_affine(t.xf); - to_path(s, &all).map(|p| (all, p)) - }); - let all = all.flatten(); - if let Some((bp, path)) = &all { - self.text_items(s, below, bp, path, page, tb); - } - // Real text: the characters' fills as text in embedded fonts. The type's transform is - // pushed for each fill and stroke of the characters, inside its form when the fill or - // stroke overprints. - let text_space = xf(t.xf); - // Ruby can't be real text in one run (it isn't characters of the base text): the type's - // characters go out as outlines where ruby is present, ruby included. - let plain = (!self.outline_text && t.ruby.is_empty()).then(|| t.plain_text()); - let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t); - for (i, run) in t.runs.iter().enumerate() { - let mut bp = BezPath::new(); - for g in layout.glyphs.iter().filter(|g| g.run == i).chain(layout.ruby.iter().filter(|g| g.run == i)) { - bp.extend(g.outline.iter()); - } - // Underline and strikethrough bars are paths, with real text too (#847). - let mut run_bars = BezPath::new(); - for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { - run_bars.extend(bar.iter()); - } - bp.extend(run_bars.iter()); - let fill = &run.style.fill; - // Colour bitmap glyphs (emoji) real text doesn't write: all of them, or those it leaves. - let mut images: Option> = None; - self.overprinting(s, run.style.overprint_fill, fill, |ex, s| { - s.push_transform(&text_space); - // The outlines left to fill as paths: all of them, or those real text leaves. - let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill, true)) { - Some(text) => { - let (mut left, unwritten) = ex.glyph_text(s, &layout, i, text, fill, true, Some(false)); - images = Some(unwritten); - left.extend(run_bars.iter()); - Cow::Owned(left) - } - None => Cow::Borrowed(&bp), - }; - let opaque = (1.0, BlendMode::Normal); - if let Some(path) = to_path(s, &outlines) - && !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box(), true) - && let Some(paint) = ex.paint(s, fill, layout.bounds) - { - s.set_stroke(None); - s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); - s.draw_path(&path); - } - s.pop(); - }); - // They paint themselves, whatever the fill. - let images = images.unwrap_or_else(|| layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == i).map(|(gi, _)| gi).collect()); - s.push_transform(&text_space); - for g in images.iter().filter_map(|&gi| layout.glyphs.get(gi)) { - self.glyph_image(s, g); - // COLR glyphs: their layers, as art. - if let Some(art) = g.placed_colour(Affine::IDENTITY) { - self.node(s, &art, page, true); - } - } - s.pop(); - // Characters with neither fill nor stroke (a scanned page's recognized text), or whose - // fill is drawn as art (a dithered gradient's image, a pattern: outlines, not text), - // are still text: written invisible (rendering mode 3), so they can still be selected - // and searched. - let unpainted = fill.is_none() && !run.style.has_stroke(); - if let Some(text) = plain.as_deref().filter(|_| unpainted || self.area_paint(fill, true)) { - self.invisible = true; - crate::forms::form(s, Mark::Invisible, |s| { - s.push_transform(&text_space); - // The paint only lets the writer write the text; the glyphs it can't write - // as text draw nothing, and colour bitmap glyphs are drawn already. - self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false, None); - s.pop(); - }); - } else if let Some(text) = plain.as_deref().filter(|_| layout.glyphs.iter().any(|g| g.run == i && g.bend.is_some())) { - // Glyphs bent along a path were drawn as their outlines (text is set straight): - // they are written again as invisible text, straight where they stand, so they can - // still be selected, searched and copied. - self.invisible = true; - crate::forms::form(s, Mark::Invisible, |s| { - s.push_transform(&text_space); - self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false, Some(true)); - s.pop(); - }); - } - s.push_transform(&text_space); - let path = run.style.has_stroke().then(|| to_path(s, &bp)).flatten(); - s.pop(); - if let Some(path) = path { - // Character strokes are drawn in text space, with their cap, join and dashes. - let st = run.style.stroke_layer(); - self.overprinting(s, st.overprint, &st.paint, |ex, s| { - s.push_transform(&text_space); - ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds); - s.pop(); - }); - } - } - s.set_fill(None); - s.set_stroke(None); - if self.inline_depth < MAX_INLINE_DEPTH { - self.inline_depth += 1; - for ig in &layout.inlines { - let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; - let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue }; - let mut node = (*sym.art).clone(); - // Strokes scale with the art, as on the canvas. - node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true); - self.node(s, &node, page, true); - } - self.inline_depth -= 1; - } - if let Some((bp, path)) = &all { - self.text_items(s, above, bp, path, page, tb); - } - } - - /// Some of a type object's own fills and strokes (`items`) on its glyph outlines `bp` (`path`; - /// `tb`: their bounds). Strokes take every stroke option, as on paths. - #[allow(clippy::too_many_arguments)] - fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], bp: &BezPath, path: &Path, page: Rect, tb: Rect) { - for item in items { - match item { - AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { - if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box(), true) - && let Some(paint) = ex.paint(s, &fl.paint, tb) - { - s.set_stroke(None); - s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero })); - s.draw_path(path); - } - }), - AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => { - self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, path, FillRule::NonZero, st, page, tb)) - } - _ => {} - } - } - s.set_fill(None); - s.set_stroke(None); - } - - /// Fill the glyphs of run `run` of `layout` with `fill` as text in their fonts, embedded (in - /// text space; `text` is the plain text, whose characters the glyphs stand for, which makes - /// the text selectable and searchable) → the outlines of the glyphs left to draw as paths: - /// those of fonts that can't be embedded, and missing glyphs. Glyphs sharing a font, a frame - /// (size, scale, rotation) and a baseline are written together. Colour glyphs (bitmap emoji, - /// COLR, SVG), with `images`, are written as text too, which draws their images or layers; the - /// indices of those it can't write or draw (SVG glyphs) are returned with the outlines. The - /// hyphen the layout adds where it breaks a line inside a word ([`break_hyphen`]) is written - /// as the character its glyph stands for, on its own after the glyphs before it. The glyphs of - /// a list marker are written as the marker's characters ([`marker_chars`]), apart from the - /// plain text's glyphs; a marker glyph that stands for none of them is drawn as a path. - /// - /// `bent`: `Some(false)` writes the glyphs bent along a path ([`vectorcraft_text::GlyphBend`]) - /// as outlines, not text (text is set straight); `Some(true)` writes only those, as text where - /// they stand straight; `None` writes all of them as text. - #[allow(clippy::too_many_arguments)] - fn glyph_text( - &mut self, - s: &mut Surface, - layout: &TextLayout, - run: usize, - text: &str, - fill: &Paint, - images: bool, - bent: Option, - ) -> (BezPath, Vec) { - let (mut rest, mut unwritten) = (BezPath::new(), vec![]); - let mut line: Option = None; - // The characters the glyphs stand for: the plain text, then, when the run has glyphs that - // stand for no character of it, the characters a line-break hyphen can stand for. - let written: Cow = - if layout.glyphs.iter().any(|g| g.run == run && g.len == 0) { Cow::Owned(format!("{text}{BREAK_HYPHENS}")) } else { Cow::Borrowed(text) }; - let written_text: &str = &written; - // The text a line's glyphs stand for: `written`, or its list marker's characters. - let text_of = |l: &GlyphLine| l.marker.and_then(|m| layout.marker_of(m)).map_or(written_text, |m| m.text.as_str()); - // The characters of the list marker being written that each of its glyphs stands for. - let mut marker: Option<(usize, Vec>>)> = None; - for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) { - let coloured = g.image.is_some() || g.colour.is_some(); - if coloured && !images { - continue; - } - match (bent, g.bend.is_some()) { - (Some(false), true) => { - rest.extend(g.outline.iter()); - continue; - } - (Some(true), false) => continue, - _ => {} - } - // Of the glyphs with no length, only a list marker's and line-break hyphens are written - // as text; the others, such as kashidas, are drawn as paths. A list marker's glyphs are - // not line-break hyphens, whatever the marker. - let in_marker = (g.len == 0).then(|| layout.marker_of(gi)).flatten(); - let hyphen = if in_marker.is_some() { None } else { break_hyphen(g) }; - let (range, ch) = match in_marker { - Some(m) => { - if marker.as_ref().is_none_or(|(start, _)| *start != m.glyphs.start) { - marker = Some((m.glyphs.start, marker_chars(layout, m))); - } - let range = marker.as_ref().and_then(|(start, chars)| chars.get(gi.checked_sub(*start)?).cloned().flatten()); - let ch = range.clone().and_then(|r| m.text.get(r)).and_then(|t| t.chars().next()); - (range.unwrap_or(0..0), ch) - } - None => { - let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end); - let ch = written.get(range.clone()).and_then(|t| t.chars().next()); - (range, ch) - } - }; - let marker_at = in_marker.map(|m| m.glyphs.start); - // Spaces are written too (text extraction needs them); control characters and soft - // hyphens draw nothing. - let blank = g.outline.elements().is_empty() && !coloured; - if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) { - continue; - } - // The glyph at the font size of its vertical scale: `frame` maps that size's glyph - // space (y down, krilla's) to text space. - let [a, b, c, d, ..] = g.xf.as_coeffs(); - let (sx, sy) = (a.hypot(b), c.hypot(d)); - let font = if g.gid != 0 && ch.is_some() { self.font(g.font_id) } else { None }; - let det = g.xf.determinant(); - let Some((font, upem)) = font.filter(|(_, upem)| det.is_finite() && det.abs() > 1e-9 && (1e-3..=1e5).contains(&(upem * sy))) else { - rest.extend(g.outline.iter()); - if coloured { - unwritten.push(gi); - } - continue; - }; - // SVG glyphs are written as text that draws nothing (see the serialize settings): their - // art is drawn, or else their outline. - if is_svg_glyph(g) { - if coloured { - unwritten.push(gi); - } else { - rest.extend(g.outline.iter()); - } - } - let size = upem * sy; - let frame = g.xf * Affine::scale(1.0 / sy); - // Its pen position along the frame's baseline, when it continues the line (a - // line-break hyphen never does). - let x = line - .as_ref() - .filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6 && l.marker == marker_at) - .and_then(|l| { - let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs(); - let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4; - same.then_some(e) - }); - let x = match x { - Some(x) => x, - None => { - if let Some(l) = line.take() { - let text = text_of(&l); - self.draw_glyph_line(s, l, text, fill, layout.bounds); - } - line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![], marker: marker_at }); - 0.0 - } - }; - // The glyph's advance in frame units (the last one's ends the line). - let advance = g.advance * sy / sx; - if let Some(l) = line.as_mut() { - l.glyphs.push((g.gid, x, advance, range)); - } - // A line-break hyphen is written on its own. - if hyphen.is_some() - && let Some(l) = line.take() - { - self.draw_glyph_line(s, l, &written, fill, layout.bounds); - } - } - if let Some(l) = line { - let text = text_of(&l); - self.draw_glyph_line(s, l, text, fill, layout.bounds); - } - (rest, unwritten) - } - - /// Draw colour bitmap glyph `g`'s image (on `s` in text space), its PNG written once. - fn glyph_image(&mut self, s: &mut Surface, g: &vectorcraft_text::PositionedGlyph) { - let Some((image, to_text)) = g.placed_image() else { return }; - if !writable(s, to_text, image.rect()) { - return; - } - let cache = format!("\u{0}glyph\u{0}{}", image.blob.content_key()); - let img = match self.images.get(&cache) { - Some(img) => img.clone(), - None => { - let img = embed(&image.blob, self.interpolate, self.sixteen_bit); - self.images.insert(cache, img.clone()); - img - } - }; - let (Some(img), Some(size)) = (img, Size::from_wh(image.width as f32, image.height as f32)) else { return }; - s.push_transform(&xf(to_text)); - s.draw_image(img, size); - s.pop(); - } - - /// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where - /// `bounds` is). A line that would be written with a pen position, size or spacing (in - /// thousandths of an em) beyond [`MAX_COORD`] is left out. - fn draw_glyph_line(&mut self, s: &mut Surface, line: GlyphLine, text: &str, fill: &Paint, bounds: Rect) { - let size = line.size; - let glyphs: Vec = line - .glyphs - .iter() - .enumerate() - .map(|(i, (gid, x, advance, range))| { - let next = line.glyphs.get(i + 1).map_or(x + advance, |n| n.1); - KrillaGlyph::new(GlyphId::new(*gid), ((next - x) / size) as f32, 0.0, 0.0, 0.0, range.clone(), None) - }) - .collect(); - let pens = - line.glyphs.iter().fold(Rect::new(0.0, -size, 0.0, size), |r, (_, x, advance, _)| r.union_pt((*x, 0.0)).union_pt((x + advance, 0.0))); - let spacing: Vec = glyphs.iter().map(|g| f64::from(g.x_advance(1.0)) * 1000.0).collect(); - if !(writable(s, line.frame, pens) && in_range(&spacing)) { - return; - } - s.push_transform(&xf(line.frame)); - if let Some(paint) = self.paint_in(s, fill, bounds, line.frame.inverse()) { - s.set_stroke(None); - s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); - s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false); - } - s.pop(); - } - - /// The font of face `id` for real text and its units per em; `None` (with a warning) when its - /// licence doesn't allow embedding it as a subset, or it can't be read. - fn font(&mut self, id: u32) -> Option<(krilla::text::Font, f64)> { - if let Some(f) = self.fonts.get(&id) { - return f.clone(); - } - let Some(face) = vectorcraft_text::FontDb::global().face_by_id(id) else { - self.fonts.insert(id, None); - return None; - }; - let name = format!("{} {}", face.family, face.style); - let font = match face.embedding() { - Embedding::Subset => { - // A named instance of a variable font is embedded instanced (its own outlines and - // widths, not the default instance's). - match write::font(face.file_data(), face.face_index(), Some(face.variations())) { - Ok(font) => Some((font, face.units_per_em())), - Err(error) => { - self.warn(format!("the font “{name}” can't be embedded ({error}): its text is exported as outlines")); - None - } - } - } - Embedding::Whole => { - self.warn(format!( - "the font “{name}” may only be embedded whole, and fonts are embedded as subsets: its text is exported as outlines" - )); - None - } - Embedding::Forbidden => { - self.warn(format!("the licence of the font “{name}” doesn't allow embedding: its text is exported as outlines")); - None - } - }; - self.fonts.insert(id, font.clone()); - font - } - - /// Image `im` placed `size` points wide and high, resampled and compressed as the Compression - /// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images); - /// cached by key (and size when images are resampled). `None`, with [`UNDECODED`] among the - /// warnings, for an image that is missing, can't be decoded or is in [`Self::left_out`]. - fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64)) -> Option { - let key = im.key.as_str(); - let c = self.compression; - let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None); - let cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; - if let Some(i) = self.images.get(&cache) { - return i.clone(); - } - let interpolate = self.interpolate; - let doc = self.doc; - let out = self.out.clone(); - let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { - let cmyk_image = blob.is_cmyk(); - if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { - self.rgb_in_cmyk.images.insert(key.to_string()); - } - let r = if cmyk_image { - crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None) - } else { - crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) - }; - if let Some(w) = r.warning { - self.warn(w); - } - r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit)) - }); - if img.is_none() { - self.warn(UNDECODED); - } - self.images.insert(cache, img.clone()); - img - } - - /// CMYK image `im` with the alpha of its opacity mask `m`, when the mask is a greyscale image - /// on the image's own pixel grid (an image's soft mask as it opens): the alpha is written as the - /// image's soft mask, as an RGBA image's is, rather than as a luminosity soft mask over it, - /// through which CoreGraphics draws CMYK images on black boxes (#2159). `None` for other - /// images and masks; cached like [`Self::load_image`]. - fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask) -> Option { - let art = &m.art; - let NodeKind::Image(mi) = &art.kind else { return None }; - let plain = - art.opacity >= 1.0 && art.blend == BlendMode::Normal && art.extras().mask.is_none() && !art.appearance.effects.iter().any(|e| e.visible); - if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) { - return None; - } - let size = placed_size(im); - let cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1); - if let Some(i) = self.images.get(&cache) { - return i.clone(); - } - let doc = self.doc; - let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { - let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; - let r = crate::images::recode_cmyk(blob, size, self.compression, self.interpolate, self.out.cmyk_pixels(), Some(alpha)); - if let (Some(w), Some(_)) = (r.warning, &r.image) { - self.warn(w); - } - r.image - }); - self.images.insert(cache, img.clone()); - img - } - - /// Draw image `im`: `img` when given (its pixels already written), else as it is loaded. - fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option) { - // An image placed or sized beyond [`MAX_COORD`] is left out. - if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { - return; - } - let Some(img) = img.or_else(|| self.load_image(im, placed_size(im))) else { return }; - let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; - s.push_transform(&xf(im.xf)); - s.draw_image(img, size); - s.pop(); - } -} - -/// The size image `im` is placed at: its pixel grid's sides through its transform. -fn placed_size(im: &vectorcraft_doc::ImageObject) -> (f64, f64) { - let [a, b, c, d, ..] = im.xf.as_coeffs(); - (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)) -} - -/// Does PNG `bytes` have 16 bits a sample: the bit depth of its IHDR chunk, which follows the -/// signature? -fn png_16_bit(bytes: &[u8]) -> bool { - bytes.get(12..16) == Some(b"IHDR".as_slice()) && bytes.get(24) == Some(&16) -} - -/// PNG `bytes` at 8 bits a sample: decoded, and encoded again as gray or RGB, with alpha when the -/// source has it, and with the source's ICC profile. `None` when it can't be decoded. -fn png_8_bit(bytes: &[u8]) -> Option> { - use image::{ColorType, DynamicImage, ImageDecoder, ImageEncoder}; - let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(bytes)).ok()?; - let icc = decoder.icc_profile().ok().flatten(); - let img = DynamicImage::from_decoder(decoder).ok()?; - let img = match img.color() { - ColorType::L8 | ColorType::L16 => DynamicImage::ImageLuma8(img.into_luma8()), - ColorType::La8 | ColorType::La16 => DynamicImage::ImageLumaA8(img.into_luma_alpha8()), - ColorType::Rgb8 | ColorType::Rgb16 => DynamicImage::ImageRgb8(img.into_rgb8()), - _ => DynamicImage::ImageRgba8(img.into_rgba8()), - }; - let mut png = vec![]; - let mut encoder = image::codecs::png::PngEncoder::new(&mut png); - if let Some(icc) = icc { - encoder.set_icc_profile(icc).ok()?; - } - img.write_with_encoder(encoder).ok()?; - Some(png) -} - -/// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a -/// sample is written at 8 bits ([`png_8_bit`]). -fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { - let bytes = blob.bytes.as_ref().clone(); - let direct = match blob.mime.as_str() { - "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), - "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), - "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), - "image/gif" => Image::from_gif(bytes.clone().into(), interpolate).ok(), - "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), - _ => None, - }; - direct.or_else(|| { - let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); - let (w, h) = rgba.dimensions(); - Some(Image::from_rgba8(rgba.into_raw(), w, h)) - }) -} - -/// Is `g` drawn from its font's SVG document, which the PDF writer can't draw as a font's glyph? -fn is_svg_glyph(g: &vectorcraft_text::PositionedGlyph) -> bool { - vectorcraft_text::FontDb::global().is_svg_glyph(g.font_id, g.gid) -} - -/// The characters a line-break hyphen can stand for, as real text: '-' and U+2010 (HYPHEN). -const BREAK_HYPHENS: &str = "-\u{2010}"; - -/// The bytes of [`BREAK_HYPHENS`] that glyph `g` stands for when it is a hyphen the layout adds -/// where it breaks a line inside a word: a glyph that stands for no character of the text (length -/// 0) and that its face's character map gives to '-' or, failing that, to U+2010. `None` for any -/// other glyph, kashida tatweels included. -fn break_hyphen(g: &vectorcraft_text::PositionedGlyph) -> Option> { - if g.len != 0 || g.gid == 0 { - return None; - } - let face = vectorcraft_text::FontDb::global().face_by_id(g.font_id)?; - BREAK_HYPHENS.char_indices().find(|&(_, c)| face.glyph_for(c) == g.gid).map(|(i, c)| i..i + c.len_utf8()) -} - -/// The characters of list marker `m` of `layout` that each of its glyphs stands for, as bytes of -/// the marker's text, in the order of [`ListMarker::glyphs`]: `None` for its tab and for a glyph -/// that stands for none of them. A glyph stands for the first character left that the glyph's -/// font maps to that glyph in its character map. When the glyphs that no character maps to (an -/// OpenType feature such as oldstyle figures replaced them) are as many as the characters left, -/// they stand for those characters, in order. -fn marker_chars(layout: &TextLayout, m: &ListMarker) -> Vec>> { - let db = vectorcraft_text::FontDb::global(); - let mut left: Vec<(std::ops::Range, char)> = m.text.char_indices().map(|(i, c)| (i..i + c.len_utf8(), c)).collect(); - let mut chars = vec![None; m.glyphs.len()]; - let mut unmapped = vec![]; - for (k, gi) in m.glyphs.clone().enumerate() { - let Some(g) = layout.glyphs.get(gi).filter(|g| gi != m.tab && g.gid != 0) else { continue }; - let face = db.face_by_id(g.font_id); - match (left.iter().position(|(_, c)| face.as_ref().is_some_and(|f| f.glyph_for(*c) == g.gid)), chars.get_mut(k)) { - (Some(at), Some(slot)) => *slot = Some(left.remove(at).0), - _ => unmapped.push(k), - } - } - if unmapped.len() == left.len() { - for (k, (range, _)) in unmapped.into_iter().zip(left) { - if let Some(slot) = chars.get_mut(k) { - *slot = Some(range); - } - } - } - chars -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs new file mode 100644 index 000000000..46fb58d58 --- /dev/null +++ b/crates/pdf/src/export/entry.rs @@ -0,0 +1,199 @@ +use super::writer::{Sheet, Writer, date_time}; +use super::*; + +/// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). +pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { + export_with_report(doc, opts).map(|r| r.bytes) +} + +/// Like [`export`], also returning warnings: options accepted but not applied yet (see +/// [`crate::PdfSettings::warnings`]) and features approximated or dropped. +pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { + write::guard(|| leaving_out_undecodable(doc, |left_out| export_with_report_inner(doc, opts, left_out))) +} + +/// Run `write` (a whole PDF written by a [`Writer`]), and run it again each time it fails on an +/// image whose pixel data the writer couldn't decode, with that image left out: [`Writer::finish`] +/// adds the image's key to the set `write` is given, and the exporters leave out the images it +/// holds. The writer reports such an image (a PNG, GIF or WebP kept as it is, whose header reads) +/// only when it finishes the file. Each repeated run leaves out at least one more image. +pub(crate) fn leaving_out_undecodable(doc: &Document, mut write: impl FnMut(&mut HashSet) -> Result) -> Result { + // The most runs repeated: each is a whole export, and a document of many images that don't + // decode would make them quadratic. Past it, one last run leaves out every image of `doc`, so + // the export still finishes (the images left out are reported). + const MAX_RETRIES: usize = 16; + let mut left_out = HashSet::new(); + let mut retries = 0; + loop { + let known = left_out.len(); + match write(&mut left_out) { + Err(_) if left_out.len() > known && retries < MAX_RETRIES => retries += 1, + Err(_) if left_out.len() > known && retries == MAX_RETRIES => { + left_out.extend(doc.images.keys().cloned()); + retries += 1; + } + done => return done, + } + } +} + +fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut HashSet) -> Result { + let set = &opts.settings; + set.check()?; + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; + // Live geometry effects export as their result; raster effects are reported below. + let baked = vectorcraft_effects::bake_document(doc); + // Baking stops early when cancelled, leaving a half-baked copy that must not be written. + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; + let doc = baked.as_ref().unwrap_or(doc); + let pages = pages(doc, opts)?; + + let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); + // The time of writing (none on the web) dates the editing data and the page information. + let now = vectorcraft_doc::metadata::output_time(); + let created_at = opts.created.or(now); + crate::pdfx::require_date(set.standard, created_at)?; + let display_doc_title = opts.display_doc_title || opts.title.as_deref().is_some_and(|title| !title.trim().is_empty()); + let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?; + // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). + let mut warnings = set.warnings(); + let native = opts.native.as_deref().filter(|_| set.preserve_editing); + if let Some(native) = native { + w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time))); + } else if set.preserve_editing { + warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); + } + + let mut ex = Exporter::new(doc, set); + ex.non_printing = set.include_non_printing || set.create_layers; + ex.out = w.out.clone(); + ex.left_out = left_out.clone(); + // A knockout page group draws the layers' objects one by one, not layer by layer. + ex.layers = set.writes_layers() && !doc.page_knockout; + if set.writes_layers() && doc.page_knockout { + warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); + } + ex.overprint = set.advanced.overprint == crate::Overprint::Preserve; + let bleed = set.bleed_of(doc); + let marks = set.marks.printer_marks(); + // In PDF 1.6 and later files that aren't PDF/X files, a page with a side longer than + // [`MAX_PAGE_SIDE`] is written in larger units; other files have every page in points. + let scales = set.compatibility.has_user_unit() && !set.standard.is_pdfx(); + let mut units = Vec::with_capacity(pages.len()); + let total = pages.len() as u64; + for (written, (i, boxes)) in pages.into_iter().enumerate() { + // The page count and the page being written, for a client that asked for progress. + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; + vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1)); + let (m, r) = (boxes.media, boxes.bleed); + let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() }; + let art = crate::marks::art(doc, &marks, &boxes, bleed, &info); + // The page is the media box, y down from its top-left corner (like ours), in units of + // `unit` points. + let unit = if scales { user_unit(m.width(), m.height()) } else { 1.0 }; + units.push(unit); + let shift = Affine::translate((-m.x0, -m.y0)); + let view = if unit > 1.0 { Affine::scale(1.0 / unit) * shift } else { shift }; + let sheet = Sheet { + size: (m.width() / unit, m.height() / unit), + unit, + trim: view.transform_rect_bbox(boxes.trim), + bleed: view.transform_rect_bbox(r), + view, + window: None, + place: Affine::IDENTITY, + area: r, + // Art reaches as far as the bleed: the marks lie outside it. + clip: m != r, + marks: art.as_ref(), + negative: false, + }; + w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?; + } + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; + let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible); + w.absorb(ex); + let (bytes, more) = w.finish(left_out, display_doc_title)?; + let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted, invisible)?; + // The writer gives the file its creation date as its modification date too. + let bytes = match opts.modified.filter(|m| Some(*m) != created_at) { + Some(modified) => crate::post::modification_date(bytes, modified)?, + None => bytes, + }; + // The writer's metadata has no copyright: it is added to the XMP packet. + let bytes = crate::post::rights(bytes, &doc.metadata)?; + warnings.extend(more); + let bytes = crate::post::user_units(bytes, &units)?; + let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; + warnings.dedup(); + Ok(ExportReport { bytes, warnings }) +} + +/// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds +/// make each page its artboard (the trim box) grown by the bleed, and by the printer's marks +/// around that. +fn pages(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { + let indices: Vec = match &opts.artboards { + Some(v) => v.clone(), + None => (0..doc.artboards.len()).collect(), + }; + if doc.artboards.is_empty() || indices.is_empty() { + return Err(PdfError::NoArtboards); + } + let (bleed, marks) = (opts.settings.bleed_of(doc), opts.settings.marks.printer_marks()); + indices.into_iter().map(|i| Ok((i, PageBoxes::new(doc.artboards.get(i).ok_or(PdfError::BadArtboard(i))?.rect, bleed, &marks)))).collect() +} + +/// What each page of `doc` exported with `opts` shows, in document space (its media box: the +/// artboard with the bleed and the printer's marks), in page order. +pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { + Ok(pages(doc, opts)?.into_iter().map(|(_, b)| b.media).collect()) +} + +/// The largest side of a page in units of its default user space that ISO 32000-1 (Annex C.2) +/// gives: 14,400 (200 inches in units of 1/72 inch). +pub(crate) const MAX_PAGE_SIDE: f64 = 14_400.0; + +/// The `UserUnit` (PDF 1.6) of a page `w` × `h` points large: 1 when both sides are at most +/// [`MAX_PAGE_SIDE`], else the longer side divided by it, rounded up to the next thousandth, so +/// that both sides are at most [`MAX_PAGE_SIDE`] units. +fn user_unit(w: f64, h: f64) -> f64 { + let side = w.abs().max(h.abs()); + if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 } +} + +/// The RGB colours and non-CMYK images a CMYK document carries, which the export separates into +/// CMYK. Their darkest values come out as rich blacks, so the export says how many there are. +#[derive(Default)] +pub(crate) struct RgbInCmyk { + /// Distinct RGB colours, by the bits of their channels. + pub(super) colours: HashSet<[u32; 3]>, + /// Keys of the raster images that aren't CMYK. + pub(super) images: HashSet, +} + +impl RgbInCmyk { + pub(super) fn merge(&mut self, other: RgbInCmyk) { + self.colours.extend(other.colours); + self.images.extend(other.images); + } + + /// The warning for the export report; `None` when the document has nothing of the sort. + pub(super) fn warning(&self) -> Option { + let (c, i) = (self.colours.len(), self.images.len()); + if c == 0 && i == 0 { + return None; + } + let mut parts = vec![]; + if c > 0 { + parts.push(format!("{c} RGB colour{}", if c == 1 { "" } else { "s" })); + } + if i > 0 { + parts.push(format!("{i} RGB or grey image{}", if i == 1 { "" } else { "s" })); + } + Some(format!( + "this CMYK document has {}: they are converted to CMYK through the colour settings, so dark ones can come out as rich blacks on several plates; paint them with CMYK colours (Edit › Edit Colors › Convert to CMYK) for pure black ink", + parts.join(" and ") + )) + } +} diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs new file mode 100644 index 000000000..156ad49e4 --- /dev/null +++ b/crates/pdf/src/export/exporter.rs @@ -0,0 +1,206 @@ +use super::entry::RgbInCmyk; +use super::*; + +/// How deep inline graphics nest before they are left out. +pub(super) const MAX_INLINE_DEPTH: u32 = 4; + +/// How deep tiling patterns nest: deeper pattern fills are written as their tiles. +pub(super) const MAX_PATTERN_DEPTH: u32 = 4; + +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; + +impl<'a> Exporter<'a> { + /// An exporter of `doc` writing images as `set` says; layers whose Print option is off are + /// left out. + pub(crate) fn new(doc: &'a Document, set: &'a crate::PdfSettings) -> Self { + Self { + doc, + symbols: doc.symbol_boxes().quick().with_brush_art(), + warnings: vec![], + images: HashMap::new(), + brushes: None, + knockout: doc.page_knockout, + lab_spots: vec![], + interpolate: set.standard != Standard::PdfA2b, + // PDF 1.3 and 1.4 files are written with the PDF 1.4 settings ([`Writer::new`]). + sixteen_bit: !matches!(set.compatibility, Compatibility::Pdf13 | Compatibility::Pdf14), + compression: &set.compression, + non_printing: false, + intent: vectorcraft_color::cms::active().settings().intent, + transparent: None, + out: Arc::default(), + convert: true, + rgb_in_cmyk: RgbInCmyk::default(), + outline_text: set.advanced.outline_text, + fonts: HashMap::new(), + layers: false, + layer_index: crate::forms::pdf_layers(doc).iter().enumerate().map(|(i, l)| (address(l.node), i)).collect(), + in_layer: 0, + symbol_nodes: 0, + overprint: false, + overprinted: false, + invisible: false, + inline_depth: 0, + pattern_depth: 0, + left_out: HashSet::new(), + } + } + + /// Whether a layer shows transparency, which CMYK pages put in a group of its own. + pub(super) fn shows_transparency(&mut self) -> bool { + let doc = self.doc; + *self.transparent.get_or_insert_with(|| doc.layers.iter().any(|l| l.shows_transparency())) + } +} + +/// Glyphs written together as real text: the same font at the same size along one baseline. +pub(super) struct GlyphLine { + pub(super) face: u32, + pub(super) font: krilla::text::Font, + pub(super) size: f64, + /// The glyph space (krilla's, y down, at `size`) → text space, at the line's start. + pub(super) frame: Affine, + /// Glyph id, pen position and advance along the line (in glyph space), and the characters it + /// stands for (bytes of the plain text, or of the list marker's text). + pub(super) glyphs: Vec<(u32, f64, f64, std::ops::Range)>, + /// The list marker the glyphs belong to, by its first glyph's index in the layout + /// ([`TextLayout::marker_of`]); `None` for glyphs of the plain text. + pub(super) marker: Option, +} + +/// Most pixels along a side of a freeform gradient's image (its colour field is smooth). +pub(super) const MAX_FIELD_PX: f64 = 512.0; + +/// Most pixels along a side of a linear or radial gradient's image. +pub(super) const MAX_GRADIENT_PX: f64 = 4096.0; + +/// The largest magnitude a coordinate, length or transform entry may have in the content stream it +/// is drawn into (for glyph spacing, in thousandths of an em). Art that would go beyond it is left +/// out. A PDF/A-2 file may hold only integers from -2,147,483,648 to 2,147,483,647 (ISO 19005-2, +/// 6.1.13). The PDF writer writes a whole number in the `i32` range as an integer, and in builds +/// with overflow checks it panics on -2,147,483,648. +pub(super) const MAX_COORD: f64 = 1e9; + +pub(super) fn xf(a: Affine) -> Transform { + let c = a.as_coeffs(); + Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) +} + +/// The current transform of surface `s` in the content stream it draws into, without that stream's +/// root transform (a page's y flip). +pub(super) fn ctm(s: &Surface) -> Affine { + let t = s.ctm(); + Affine::new([t.sx(), t.ky(), t.kx(), t.sy(), t.tx(), t.ty()].map(f64::from)) +} + +/// Are `values` finite and within [`MAX_COORD`]? +pub(super) fn in_range(values: &[f64]) -> bool { + values.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) +} + +/// Can art spanning `b`, in the space `space` maps into the one `s` draws in, be written: `b`, the +/// transform of `s` followed by `space`, and `b` through that transform all within [`MAX_COORD`]? +pub(super) fn writable(s: &Surface, space: Affine, b: Rect) -> bool { + let t = ctm(s) * space; + let to = t.transform_rect_bbox(b); + in_range(&t.as_coeffs()) && in_range(&[b.x0, b.y0, b.x1, b.y1, to.x0, to.y0, to.x1, to.y1]) +} + +/// `bp` as a path to draw on `s`; `None` when it is empty or reaches beyond [`MAX_COORD`] there. +pub(super) fn to_path(s: &Surface, bp: &BezPath) -> Option { + if !writable(s, Affine::IDENTITY, bp.control_box()) { + return None; + } + let mut pb = PathBuilder::new(); + for el in bp.elements() { + match *el { + PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32), + PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32), + PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32), + PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32), + PathEl::ClosePath => pb.close(), + } + } + pb.finish() +} + +pub(super) fn norm(v: f32) -> NormalizedF32 { + NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE) +} + +pub(super) fn rule(r: FillRule) -> krilla::paint::FillRule { + match r { + FillRule::NonZero => krilla::paint::FillRule::NonZero, + FillRule::EvenOdd => krilla::paint::FillRule::EvenOdd, + } +} + +pub(super) fn blend(b: BlendMode) -> krilla::blend::BlendMode { + use krilla::blend::BlendMode as K; + match b { + BlendMode::Normal => K::Normal, + BlendMode::Darken => K::Darken, + BlendMode::Multiply => K::Multiply, + BlendMode::ColorBurn => K::ColorBurn, + BlendMode::Lighten => K::Lighten, + BlendMode::Screen => K::Screen, + BlendMode::ColorDodge => K::ColorDodge, + BlendMode::Overlay => K::Overlay, + BlendMode::SoftLight => K::SoftLight, + BlendMode::HardLight => K::HardLight, + BlendMode::Difference => K::Difference, + BlendMode::Exclusion => K::Exclusion, + BlendMode::Hue => K::Hue, + BlendMode::Saturation => K::Saturation, + BlendMode::Color => K::Color, + BlendMode::Luminosity => K::Luminosity, + } +} + +pub(super) fn q(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +pub(super) fn color(c: &Color) -> krilla::color::Color { + match *c { + Color::Rgb { r, g, b } => rgb::Color::new(q(r), q(g), q(b)).into(), + Color::Cmyk { c, m, y, k } => cmyk::Color::new(q(c), q(m), q(y), q(k)).into(), + // VectorCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness. + Color::Gray { k } => luma::Color::new(q(1.0 - k)).into(), + Color::Lab { .. } => { + let [r, g, b] = c.to_rgb(); + rgb::Color::new(q(r), q(g), q(b)).into() + } + } +} + +/// Fill the page (with a margin) with a grey level (0 = black, 255 = white) at `opacity`: mask +/// backdrops. +pub(super) fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) { + let Some(p) = to_path(s, &page.inflate(1.0, 1.0).to_path(0.1)) else { return }; + s.set_stroke(None); + s.set_fill(Some(Fill { paint: rgb::Color::new(level, level, level).into(), opacity: norm(opacity), rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&p); +} + +/// An alpha mask of constant `alpha` over the page. A group drawn through it is a transparency +/// group of that opacity that, unlike the writer's opacity groups, is not isolated. +/// The cover is white, so a reader that takes every soft mask as a luminosity mask shows the same. +pub(super) fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask { + let mut sb = s.stream_builder(); + let mut ms = sb.surface(); + cover(&mut ms, page, 255, alpha); + ms.finish(); + krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha) +} + +/// A node's address, to tell the very node apart from equal ones. +pub(super) fn address(n: &Node) -> usize { + std::ptr::from_ref(n).addr() +} + +pub(super) fn rects_overlap(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs new file mode 100644 index 000000000..946cb686e --- /dev/null +++ b/crates/pdf/src/export/images.rs @@ -0,0 +1,139 @@ +use super::exporter::{UNDECODED, writable, xf}; +use super::*; + +impl Exporter<'_> { + /// Image `im` placed `size` points wide and high, resampled and compressed as the Compression + /// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images); + /// cached by key (and size when images are resampled). `None`, with [`UNDECODED`] among the + /// warnings, for an image that is missing, can't be decoded or is in [`Self::left_out`]. + fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64)) -> Option { + let key = im.key.as_str(); + let c = self.compression; + let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None); + let cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; + if let Some(i) = self.images.get(&cache) { + return i.clone(); + } + let interpolate = self.interpolate; + let doc = self.doc; + let out = self.out.clone(); + let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { + let cmyk_image = blob.is_cmyk(); + if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { + self.rgb_in_cmyk.images.insert(key.to_string()); + } + let r = if cmyk_image { + crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None) + } else { + crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) + }; + if let Some(w) = r.warning { + self.warn(w); + } + r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit)) + }); + if img.is_none() { + self.warn(UNDECODED); + } + self.images.insert(cache, img.clone()); + img + } + + /// CMYK image `im` with the alpha of its opacity mask `m`, when the mask is a greyscale image + /// on the image's own pixel grid (an image's soft mask as it opens): the alpha is written as the + /// image's soft mask, as an RGBA image's is, rather than as a luminosity soft mask over it, + /// through which CoreGraphics draws CMYK images on black boxes (#2159). `None` for other + /// images and masks; cached like [`Self::load_image`]. + pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask) -> Option { + let art = &m.art; + let NodeKind::Image(mi) = &art.kind else { return None }; + let plain = + art.opacity >= 1.0 && art.blend == BlendMode::Normal && art.extras().mask.is_none() && !art.appearance.effects.iter().any(|e| e.visible); + if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) { + return None; + } + let size = placed_size(im); + let cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1); + if let Some(i) = self.images.get(&cache) { + return i.clone(); + } + let doc = self.doc; + let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { + let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; + let r = crate::images::recode_cmyk(blob, size, self.compression, self.interpolate, self.out.cmyk_pixels(), Some(alpha)); + if let (Some(w), Some(_)) = (r.warning, &r.image) { + self.warn(w); + } + r.image + }); + self.images.insert(cache, img.clone()); + img + } + + /// Draw image `im`: `img` when given (its pixels already written), else as it is loaded. + pub(super) fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option) { + // An image placed or sized beyond [`MAX_COORD`] is left out. + if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { + return; + } + let Some(img) = img.or_else(|| self.load_image(im, placed_size(im))) else { return }; + let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; + s.push_transform(&xf(im.xf)); + s.draw_image(img, size); + s.pop(); + } +} + +/// The size image `im` is placed at: its pixel grid's sides through its transform. +fn placed_size(im: &vectorcraft_doc::ImageObject) -> (f64, f64) { + let [a, b, c, d, ..] = im.xf.as_coeffs(); + (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)) +} + +/// Does PNG `bytes` have 16 bits a sample: the bit depth of its IHDR chunk, which follows the +/// signature? +pub(super) fn png_16_bit(bytes: &[u8]) -> bool { + bytes.get(12..16) == Some(b"IHDR".as_slice()) && bytes.get(24) == Some(&16) +} + +/// PNG `bytes` at 8 bits a sample: decoded, and encoded again as gray or RGB, with alpha when the +/// source has it, and with the source's ICC profile. `None` when it can't be decoded. +pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { + use image::{ColorType, DynamicImage, ImageDecoder, ImageEncoder}; + let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(bytes)).ok()?; + let icc = decoder.icc_profile().ok().flatten(); + let img = DynamicImage::from_decoder(decoder).ok()?; + let img = match img.color() { + ColorType::L8 | ColorType::L16 => DynamicImage::ImageLuma8(img.into_luma8()), + ColorType::La8 | ColorType::La16 => DynamicImage::ImageLumaA8(img.into_luma_alpha8()), + ColorType::Rgb8 | ColorType::Rgb16 => DynamicImage::ImageRgb8(img.into_rgb8()), + _ => DynamicImage::ImageRgba8(img.into_rgba8()), + }; + let mut png = vec![]; + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + encoder.set_icc_profile(icc).ok()?; + } + img.write_with_encoder(encoder).ok()?; + Some(png) +} + +/// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), +/// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a +/// sample is written at 8 bits ([`png_8_bit`]). +pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { + let bytes = blob.bytes.as_ref().clone(); + let direct = match blob.mime.as_str() { + "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), + "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), + "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), + "image/gif" => Image::from_gif(bytes.clone().into(), interpolate).ok(), + "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), + _ => None, + }; + direct.or_else(|| { + let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); + let (w, h) = rgba.dimensions(); + Some(Image::from_rgba8(rgba.into_raw(), w, h)) + }) +} diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs new file mode 100644 index 000000000..80b1dab7c --- /dev/null +++ b/crates/pdf/src/export/nodes.rs @@ -0,0 +1,491 @@ +use super::exporter::{address, blend, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable}; +use super::*; + +impl Exporter<'_> { + /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; + /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). + fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask { + let mut sb = s.stream_builder(); + let mut ms = sb.surface(); + // An opaque backdrop when it matters: white outside the art (no clip), or black for the + // inverting Difference pass below to turn white. + if !m.clip || m.invert { + cover(&mut ms, page, if m.clip { 0 } else { 255 }, 1.0); + } + // Mask art is a picture of its own: it takes no part in a knockout group around the object, + // and has no inks to overprint. + let knockout = std::mem::take(&mut self.knockout); + let overprint = std::mem::take(&mut self.overprint); + self.node(&mut ms, &m.art, page, true); + (self.knockout, self.overprint) = (knockout, overprint); + if m.invert { + ms.push_blend_mode(krilla::blend::BlendMode::Difference); + cover(&mut ms, page, 255, 1.0); + ms.pop(); + } + ms.finish(); + krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) + } + + /// The children of a group, as the elements of a knockout group when it is one. + pub(super) fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect) { + if !self.knockout { + for c in children { + self.node(s, c, page, false); + } + return; + } + // The PDF writer has no knockout groups: each element is drawn through a soft mask of + // where the elements above it don't paint (the same look for Normal blending). The masks + // nest, so element i sits inside the masks of elements i+1…n: open them outermost first. + self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); + let elements: Vec<_> = Node::knockout_elements(children) + .into_iter() + .filter(|c| c.visual_bounds_with(&self.symbols).is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) + .collect(); + let Some((first, rest)) = elements.split_first() else { return }; + for c in rest.iter().rev() { + let mask = self.knockout_mask(s, c, page); + s.push_mask(mask); + } + self.node(s, first, page, false); + for c in rest { + s.pop(); + self.node(s, c, page, false); + } + } + + /// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha + /// mask of `c` (at full object opacity without its own mask, unless those define its shape). + fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; + let mut sb = s.stream_builder(); + let mut ms = sb.surface(); + cover(&mut ms, page, 255, 1.0); + let alpha = { + let mut ab = ms.stream_builder(); + let mut als = ab.surface(); + let overprint = std::mem::take(&mut self.overprint); + self.node(&mut als, &shape, page, false); + self.overprint = overprint; + als.finish(); + krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) + }; + ms.push_mask(alpha); + cover(&mut ms, page, 0, 1.0); + ms.pop(); + ms.finish(); + krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) + } + + pub(super) fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { + if self.layers + && self.in_layer != address(n) + && let NodeKind::Layer { printable, .. } = n.kind + && (printable || self.non_printing) + && let Some(&i) = self.layer_index.get(&address(n)) + { + // A PDF layer: hidden layers and sublayers are written too (their group is off). + let enclosing = std::mem::replace(&mut self.in_layer, address(n)); + crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true)); + self.in_layer = enclosing; + return; + } + if !force && !n.visible { + return; + } + if let NodeKind::Layer { template, printable, .. } = n.kind + && (template || !(printable || self.non_printing)) + { + return; + } + match n.visual_bounds_with(&self.symbols) { + Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return, + None if !n.is_container() => return, + _ => {} + } + self.warn_raster(&n.appearance.effects, || format!("{} objects", n.kind_label().to_lowercase())); + let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); + // Whether this container's children knock each other out (a knockout group is written as a group). + let knockout = n.knocks_out(self.knockout); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + let mut pushes = 0; + if n.blend != BlendMode::Normal { + s.push_blend_mode(blend(n.blend)); + pushes += 1; + } + if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) { + if !n.isolate && n.blends_through() { + // Not isolated: blending inside reaches the art below the group. + let alpha = constant_mask(s, page, n.opacity); + s.push_mask(alpha); + } else if n.opacity < 1.0 { + s.push_opacity(norm(n.opacity)); + } else { + s.push_isolated(); + } + pushes += 1; + } + let mask = n.extras().mask.as_deref().filter(|m| !m.disabled); + let alpha = match (&n.kind, mask) { + (NodeKind::Image(im), Some(m)) => self.masked_image(im, m), + _ => None, + }; + if let Some(m) = mask + && alpha.is_none() + { + let mask = self.soft_mask(s, m, page); + s.push_mask(mask); + pushes += 1; + } + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + // The region every output clips to; nothing to clip by hides the clipped art. The + // clipping path's fill paints behind the clipped art and its stroke over it, unclipped. + if let Some((clip, rest)) = children.split_first() + && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(s, &bp).map(|p| (p, r))) + { + let paint = clip.clip_paint(); + s.push_clip_path(&p, &rule(r)); + if let Some(fill) = &paint.fill { + self.node(s, fill, page, false); + } + self.children(s, rest, page); + s.pop(); + if let Some(stroke) = &paint.stroke { + self.node(s, stroke, page, false); + } + } + } + NodeKind::Path { path, rule, guide, .. } => { + if !*guide { + self.shape(s, n, &path.to_bezpath(), *rule, page); + } + } + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for c in children { + if let Some(p) = c.path_data() { + bp.extend(p.to_bezpath()); + } + } + self.shape(s, n, &bp, *rule, page); + } + NodeKind::Text(t) => self.text(s, n, t, page), + NodeKind::Image(im) => self.image(s, im, alpha), + NodeKind::SymbolInstance { symbol, xf } => { + // Within the objects a frame may draw of symbol instances, as on screen: nested + // instances multiply. + let sym = self.doc.symbols.iter().find(|x| &x.name == symbol); + let total = sym.map_or(0, |sym| self.symbol_nodes.saturating_add(sym.art.count() as u64)); + if let Some(sym) = sym.filter(|_| total <= vectorcraft_doc::MAX_SYMBOL_NODES) { + self.symbol_nodes = total; + let mut art = (*sym.art).clone(); + art.transform(*xf, false); + self.node(s, &art, page, true); + } + } + // Live blends/envelopes/meshes export their evaluated (expanded) form. + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + for c in g.children().into_iter().flatten() { + self.node(s, c, page, false); + } + } + } + self.knockout = enclosing; + for _ in 0..pushes { + s.pop(); + } + } + + fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) { + let Some(path) = to_path(s, bp) else { return }; + let bounds = bp.bounding_box(); + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(fl) => { + if !fl.visible || fl.paint.is_none() { + continue; + } + self.warn_raster(&fl.effects, || "fills".into()); + self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { + if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds, true) { + return; + } + let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return }; + let bl = fl.blend != BlendMode::Normal; + if bl { + s.push_blend_mode(blend(fl.blend)); + } + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) })); + s.draw_path(&path); + if bl { + s.pop(); + } + }); + } + AppearanceItem::Stroke(st) => { + if !st.visible || st.paint.is_none() || st.width <= 0.0 { + continue; + } + self.warn_raster(&st.effects, || "strokes".into()); + self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, &path, r, st, page, bounds)); + } + } + } + s.set_fill(None); + s.set_stroke(None); + } + + /// Draw a fill or stroke of `paint` with `draw`: as a form marked to overprint when it + /// `overprints`, paints and overprints are kept (but not a white one with Discard White + /// Overprint). + pub(super) fn overprinting(&mut self, s: &mut Surface, overprints: bool, paint: &Paint, draw: impl FnOnce(&mut Self, &mut Surface)) { + let white = || self.doc.setup.discard_white_overprint && vectorcraft_doc::overprint::is_white(paint); + if overprints && self.overprint && !paint.is_none() && !white() { + self.overprinted = true; + crate::forms::form(s, Mark::Overprint, |s| draw(self, s)); + } else { + draw(self, s); + } + } + + /// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which + /// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels + /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`])? + pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool { + match p { + Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), + Paint::Gradient(g) => g.draws_as_pixels(fill), + _ => false, + } + } + + /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, + /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a + /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), or an image + /// of a gradient drawn as pixels placed on `bounds` (the painted object's box, as on the + /// canvas), clipped to it. False (nothing drawn) for other paints. + #[allow(clippy::too_many_arguments)] + pub(super) fn area( + &mut self, + s: &mut Surface, + clip: &Path, + rule: krilla::paint::FillRule, + p: &Paint, + transparency: (f32, BlendMode), + bounds: Rect, + area: Rect, + fill: bool, + ) -> bool { + if !self.area_paint(p, fill) { + return false; + } + let (opacity, mode) = transparency; + let tiling = match p { + Paint::Pattern { pattern, xf } => self.tiling(s, pattern, *xf), + _ => None, + }; + let mut pushes = 0; + if mode != BlendMode::Normal { + s.push_blend_mode(blend(mode)); + pushes += 1; + } + if let Some(paint) = tiling { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(opacity), rule })); + s.draw_path(clip); + s.set_fill(None); + } else { + s.push_clip_path(clip, &rule); + pushes += 1; + if opacity < 1.0 { + s.push_opacity(norm(opacity)); + pushes += 1; + } + let doc = self.doc; + match p { + Paint::Pattern { pattern, xf } => { + if let Some(def) = doc.pattern(pattern) { + self.warn("pattern fills nested more than 4 patterns deep, or with tiles too large for PDF, are written as their tiles"); + for inst in def.instances_in(*xf, area) { + self.node(s, &inst, area, true); + } + } + } + Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill), + _ => {} + } + } + for _ in 0..pushes { + s.pop(); + } + true + } + + /// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its + /// unplaced gradients. + #[allow(clippy::too_many_arguments)] + pub(super) fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect, bounds: Rect) { + if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { + return; + } + if self.area_paint(&st.paint, false) && st.path_gradient().is_none() { + // The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as + // Outline Stroke makes it, painted with the pattern's tiles or the gradient's image (a + // gradient along or across the stroke is written as slices below). + let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath(); + if let Some(clip) = to_path(s, ®ion) { + let area = region.bounding_box(); + self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area, false); + } + return; + } + let Some(paint) = self.paint(s, &st.paint, st.paint_bounds(bounds)) else { return }; + let w = stroke::for_writer(bp, st); + // A stroke whose width, caps or miter spikes reach beyond [`MAX_COORD`] is left out. + if !writable(s, Affine::IDENTITY, w.reach(st, bp.control_box()).inflate(1.0, 1.0)) { + return; + } + let mut pushes = 0; + if st.blend != BlendMode::Normal { + s.push_blend_mode(blend(st.blend)); + pushes += 1; + } + match w.side { + Some(StrokeAlign::Inside) => { + s.push_clip_path(path, &rule(r)); + pushes += 1; + } + Some(StrokeAlign::Outside) => { + // Clip to everything outside the path: a frame around all the stroke reaches + // (miter spikes included) plus the path, even-odd. + let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1); + outside.extend(bp.iter()); + if let Some(p) = to_path(s, &outside) { + s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); + pushes += 1; + } + } + _ => {} + } + match &w.shape { + WrittenShape::Stroke { width } => { + let dash = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| { + let mut pat: Vec = d.pattern.iter().map(|v| *v as f32).collect(); + if pat.len() % 2 == 1 { + pat.extend(pat.clone()); + } + // An offset beyond [`MAX_COORD`] is written as its remainder after whole + // periods of the pattern. + let period: f64 = pat.iter().copied().map(f64::from).sum(); + let offset = if in_range(&[d.offset]) { d.offset } else { d.offset.rem_euclid(period) }; + StrokeDash { array: pat, offset: if offset.is_finite() { offset as f32 } else { 0.0 } } + }); + s.set_fill(None); + s.set_stroke(Some(Stroke { + paint, + width: *width as f32, + miter_limit: st.miter_limit.max(1.0) as f32, + line_cap: match st.cap { + LineCap::Butt => krilla::paint::LineCap::Butt, + LineCap::Round => krilla::paint::LineCap::Round, + LineCap::Square => krilla::paint::LineCap::Square, + }, + line_join: match st.join { + LineJoin::Miter => krilla::paint::LineJoin::Miter, + LineJoin::Round => krilla::paint::LineJoin::Round, + LineJoin::Bevel => krilla::paint::LineJoin::Bevel, + }, + opacity: norm(st.opacity), + dash, + })); + s.draw_path(path); + s.set_stroke(None); + } + WrittenShape::Fill(outlines) if st.path_gradient().is_some() => { + // A gradient along or across the stroke: slices clipped to its outlines, under + // the stroke's opacity as a group. + if let Some(ws) = stroke::written_slices(bp, r, st, outlines) + && let Some(clip) = to_path(s, &ws.clip) + { + self.warn("gradients along or across strokes are exported as slices of linear gradients"); + if st.opacity < 1.0 { + s.push_opacity(norm(st.opacity)); + pushes += 1; + } + s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); + s.set_stroke(None); + for (shape, paint) in &ws.slices { + let b = shape.bounding_box(); + if let (Some(paint), Some(p)) = (self.paint(s, paint, b), to_path(s, shape)) { + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&p); + } + } + s.set_fill(None); + s.pop(); + } + } + WrittenShape::Fill(outlines) => { + // The line and its arrowheads overlap: they take the opacity once, as a group. + let grouped = outlines.len() > 1 && st.opacity < 1.0; + if grouped { + s.push_opacity(norm(st.opacity)); + pushes += 1; + } + let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) }; + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero })); + for outline in outlines { + if let Some(p) = to_path(s, outline) { + s.draw_path(&p); + } + } + s.set_fill(None); + } + } + for _ in 0..pushes { + s.pop(); + } + } + + /// Paint stroke `st` of the shape `bp` with its brush art (the stroke's opacity and blend mode + /// over all of it). False when it has no known brush. + fn brush_art(&mut self, s: &mut Surface, bp: &BezPath, st: &StrokeLayer, page: Rect) -> bool { + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; + let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st); + let mut pushes = 0; + if st.blend != BlendMode::Normal { + s.push_blend_mode(blend(st.blend)); + pushes += 1; + } + if st.opacity < 1.0 { + s.push_opacity(norm(st.opacity)); + pushes += 1; + } + for piece in &art { + self.node(s, piece, page, false); + } + for _ in 0..pushes { + s.pop(); + } + true + } +} diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs new file mode 100644 index 000000000..2c0316086 --- /dev/null +++ b/crates/pdf/src/export/paint.rs @@ -0,0 +1,293 @@ +use super::exporter::{MAX_FIELD_PX, MAX_GRADIENT_PX, MAX_PATTERN_DEPTH, color, ctm, in_range, norm, q, to_path, writable, xf}; +use super::*; + +impl Exporter<'_> { + /// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without + /// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the + /// document's colour spaces, so the file carries press values. + fn col(&mut self, c: &Color) -> krilla::color::Color { + let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; + if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) { + self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]); + } + let c = self.out.color(c, self.intent, cmyk_doc, self.convert); + if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) { + let note = note.clone(); + self.warn(note); + } + color(&c) + } + + /// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate + /// space; a Lab spot colour also gets a Lab one, [`crate::lab_spot`], unless the Spot Colors + /// options use CMYK values); `None` when `name` isn't a spot colour. + fn separation(&mut self, name: &str) -> Option { + let doc = self.doc; + let sw = doc.swatch(name).filter(|s| s.spot)?; + let color = doc.linked_color(sw.paint.color()?, true); + if let Color::Lab { l, a, b } = color + && !self.lab_spots.iter().any(|(n, _)| n == name) + { + self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b))); + } + let full = self.out.cmyk(&color, self.intent); + let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3]))); + Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt)) + } + + /// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch + /// or to Registration (`/All`: every plate). + fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> krilla::color::Color { + if link == Some(vectorcraft_color::swatch::REGISTRATION) { + let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(255, 255, 255, 255)); + return SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::AllColorants, alt)).into(); + } + match link.and_then(|n| self.separation(n)) { + Some(space) => SepColor::new(q(tint), space).into(), + None => self.col(c), + } + } + + /// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of + /// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the + /// tint and opacity there are halfway). `None` for other gradients; one that mixes a spot ink + /// with other colours is written in process colours (the PDF writer has no DeviceN). + fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { + let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; + let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; + let tints: Option> = g + .stops + .iter() + .map(|s| match s.swatch.as_deref() { + Some(n) if n == ink => Some(s.tint), + None if paper(&s.color) => Some(0.0), + _ => None, + }) + .collect(); + let Some(tints) = tints else { + self.warn("gradients mixing a spot color with other colors are exported in process colors"); + return None; + }; + let space = self.separation(ink)?; + let tint = |t: f32| -> krilla::color::Color { SepColor::new(q(t), space.clone()).into() }; + let mut out = vec![]; + for (i, s) in g.stops.iter().enumerate() { + out.push((s.offset, tint(tints[i]), s.opacity)); + if let Some(n) = g.stops.get(i + 1) + && (s.midpoint - 0.5).abs() > 1e-3 + { + let at = s.offset + (n.offset - s.offset) * s.midpoint; + out.push((at, tint((tints[i] + tints[i + 1]) / 2.0), (s.opacity + n.opacity) / 2.0)); + } + } + Some(out) + } + + /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect: the + /// app renders them to images before export (`vectorcraft_engine::export_pdf`), so the ones + /// still here (on `what`) can't be written. + pub(super) fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect], what: impl FnOnce() -> String) { + if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { + self.warn(format!("raster effects (shadows, glows, blur, feather) on {} are left out of the PDF", what())); + } + } + + pub(super) fn warn(&mut self, w: impl Into) { + let w = w.into(); + if !self.warnings.contains(&w) { + self.warnings.push(w); + } + } + + /// Convert a paint to draw on `s`. `bounds` resolves unset gradient geometry (like the + /// renderer). A gradient placed beyond [`MAX_COORD`] there gives no paint. + pub(super) fn paint(&mut self, s: &Surface, p: &Paint, bounds: Rect) -> Option { + self.paint_in(s, p, bounds, Affine::IDENTITY) + } + + /// [`Self::paint`] drawn in another space: `space` maps the paint's space (where `bounds` + /// is) to the one drawn in. + pub(super) fn paint_in(&mut self, s: &Surface, p: &Paint, bounds: Rect, space: Affine) -> Option { + match p { + Paint::None => None, + Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), + Paint::Gradient(g) => { + let geom = g.resolve(bounds); + let spot = (g.gradient.kind != GradientKind::Freeform).then(|| self.spot_stops(&g.gradient)).flatten(); + let colored = match spot { + Some(v) => v, + None => { + let stops = g.gradient.expanded_stops(); + // The PDF writer needs every stop in one colour space: stops of mixed + // models (midpoints sample in RGB) go through RGB, or stay CMYK in a CMYK + // document, where `col` separates the rest. + let mixed = stops.windows(2).any(|w| w[0].1.model() != w[1].1.model()); + let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; + let one = |c: Color| match c { + Color::Cmyk { .. } if cmyk_doc => c, + _ if mixed => c.in_model(vectorcraft_color::cms::Model::Rgb), + _ => c, + }; + stops.into_iter().map(|(o, c, a)| (o, self.col(&one(c)), a)).collect() + } + }; + let mut stops: Vec = Vec::new(); + let mut last = 0.0f32; + for (o, color, a) in colored { + let o = o.clamp(last, 1.0); + last = o; + stops.push(Stop { offset: norm(o), color, opacity: norm(a) }); + } + if stops.is_empty() { + return None; + } + match g.gradient.kind { + GradientKind::Linear => { + if !writable(s, space, Rect::from_points(geom.start, geom.end)) { + return None; + } + let (s, mut e) = (geom.start, geom.end); + if s.distance(e) < 1e-9 { + e = s + Vec2::new(1.0, 0.0); + } + Some( + LinearGradient { + x1: s.x as f32, + y1: s.y as f32, + x2: e.x as f32, + y2: e.y as f32, + transform: xf(space), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: false, + } + .into(), + ) + } + GradientKind::Radial => { + let r = geom.length().max(1e-6) as f32; + let t = geom.radial_squash(); + let (cx, cy) = (geom.start.x as f32, geom.start.y as f32); + // An off-centre focal point: a two-point radial shading from it. + let f = t.inverse() * geom.focal_point(); + let circles = Rect::from_center_size(geom.start, (2.0 * f64::from(r), 2.0 * f64::from(r))).union_pt(f); + if !writable(s, space * t, circles) { + return None; + } + Some( + RadialGradient { + fx: f.x as f32, + fy: f.y as f32, + fr: 0.0, + cx, + cy, + cr: r, + transform: xf(space * t), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: false, + } + .into(), + ) + } + // Painted areas get an image of the field ([`Self::area`]); elsewhere (gradient + // slices) the average colour stands in. + GradientKind::Freeform => { + self.warn("freeform gradients are exported as their average colour"); + let n = g.gradient.stops.len().max(1) as f32; + let mut acc = [0.0f32; 3]; + for s in &g.gradient.stops { + let c = s.color.to_rgb(); + for i in 0..3 { + acc[i] += c[i] / n; + } + } + Some(self.col(&Color::rgb(acc[0], acc[1], acc[2])).into()) + } + } + } + // Patterns paint through [`Self::area`]: only a missing one gets here. + Paint::Pattern { .. } => { + self.warn("missing patterns are exported as mid-grey"); + Some(rgb::Color::new(128, 128, 128).into()) + } + } + } + + /// Pattern swatch `name` placed by `at` (pattern space → the current space) as a PDF tiling + /// pattern (ISO 32000-1, 8.7.3.1): one period of the tiling ([`vectorcraft_doc::PatternDef::period`], + /// what the canvas repeats too) with every tile instance reaching into it, in paint order, so + /// brick and hex tilings and overlapping tiles repeat as they are; clipped to the period when + /// art reaches out of it. `None` for a pattern nested deeper than [`MAX_PATTERN_DEPTH`], a period + /// or placement beyond [`MAX_COORD`] or a placement that flattens the tiles. + pub(super) fn tiling(&mut self, s: &mut Surface, name: &str, at: Affine) -> Option { + let doc = self.doc; + let def = doc.pattern(name)?; + let (pw, ph) = def.period(); + if self.pattern_depth >= MAX_PATTERN_DEPTH || !in_range(&[pw, ph]) || !in_range(&at.as_coeffs()) || at.determinant().abs() <= 1e-12 { + return None; + } + let cell = Rect::new(0.0, 0.0, pw, ph); + let tiles = def.instances_in(Affine::IDENTITY, cell); + let mut sb = s.stream_builder(); + let mut cs = sb.surface(); + let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b)); + let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten(); + if let Some(c) = &clip { + cs.push_clip_path(c, &krilla::paint::FillRule::NonZero); + } + // Pattern art is a picture of its own: it takes no part in a knockout group around the object. + let knockout = std::mem::take(&mut self.knockout); + self.pattern_depth += 1; + for t in &tiles { + self.node(&mut cs, t, cell, true); + } + self.pattern_depth -= 1; + self.knockout = knockout; + if clip.is_some() { + cs.pop(); + } + cs.finish(); + Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) + } + + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's + /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so + /// scaled type gets its pixels too): a freeform gradient's colour field (at most + /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side), + /// with dither's noise when it paints a fill (`fill`). + pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { + use vectorcraft_color::freeform::{painted_box, spread_scale}; + let Some(b) = painted_box(bounds) else { return }; + let area = area.abs(); + // The area's sides in points on the page. + let [m0, m1, m2, m3, _, _] = ctm(s).as_coeffs(); + let (w, h) = (area.width() * m0.hypot(m1), area.height() * m2.hypot(m3)); + let long = w.max(h); + if !(long > 1e-9 && long.is_finite()) { + return; + } + let freeform = g.gradient.kind == GradientKind::Freeform; + let max = if freeform { MAX_FIELD_PX } else { MAX_GRADIENT_PX }; + let k = (self.doc.raster_effects_ppi / 72.0).min(max / long); + let along = |len: f64| (len * k).ceil().clamp(1.0, max) as u16; + let (cols, rows) = (along(w), along(h)); + let rgba: Vec = if freeform { + let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); + field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() + } else { + self.warn("linear and radial gradients drawn as pixels are exported as images"); + g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect() + }; + let pixels = if self.convert { self.out.pixels() } else { None }; + let (Some(img), Some(size)) = ( + crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels), + Size::from_wh(area.width() as f32, area.height() as f32), + ) else { + return; + }; + s.push_transform(&xf(Affine::translate(area.origin().to_vec2()))); + s.draw_image(img, size); + s.pop(); + } +} diff --git a/crates/pdf/src/export/text.rs b/crates/pdf/src/export/text.rs new file mode 100644 index 000000000..c8214127a --- /dev/null +++ b/crates/pdf/src/export/text.rs @@ -0,0 +1,446 @@ +use super::exporter::{GlyphLine, MAX_INLINE_DEPTH, in_range, norm, to_path, writable, xf}; +use super::images::embed; +use super::*; + +impl Exporter<'_> { + /// + /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are drawn as their symbols' art + /// (vector paths, like symbol instances) where the layout placed them; their characters + /// write no glyph. + pub(super) fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject, page: Rect) { + let doc = self.doc; + let resolved = doc.inline_resolved(t); + let t = &*resolved; + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let tb = t.xf.transform_rect_bbox(layout.bounds); + // The object's own fills and strokes paint the whole outline: those below the Characters + // row under the characters, the others over them. + let (below, above) = n.appearance.split_contents(); + let all = (!n.appearance.items.is_empty()).then(|| { + let mut all = layout.to_bezpath(); + for (_, bar) in vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t) { + all.extend(bar.iter()); + } + all.apply_affine(t.xf); + to_path(s, &all).map(|p| (all, p)) + }); + let all = all.flatten(); + if let Some((bp, path)) = &all { + self.text_items(s, below, bp, path, page, tb); + } + // Real text: the characters' fills as text in embedded fonts. The type's transform is + // pushed for each fill and stroke of the characters, inside its form when the fill or + // stroke overprints. + let text_space = xf(t.xf); + // Ruby can't be real text in one run (it isn't characters of the base text): the type's + // characters go out as outlines where ruby is present, ruby included. + let plain = (!self.outline_text && t.ruby.is_empty()).then(|| t.plain_text()); + let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t); + for (i, run) in t.runs.iter().enumerate() { + let mut bp = BezPath::new(); + for g in layout.glyphs.iter().filter(|g| g.run == i).chain(layout.ruby.iter().filter(|g| g.run == i)) { + bp.extend(g.outline.iter()); + } + // Underline and strikethrough bars are paths, with real text too (#847). + let mut run_bars = BezPath::new(); + for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { + run_bars.extend(bar.iter()); + } + bp.extend(run_bars.iter()); + let fill = &run.style.fill; + // Colour bitmap glyphs (emoji) real text doesn't write: all of them, or those it leaves. + let mut images: Option> = None; + self.overprinting(s, run.style.overprint_fill, fill, |ex, s| { + s.push_transform(&text_space); + // The outlines left to fill as paths: all of them, or those real text leaves. + let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill, true)) { + Some(text) => { + let (mut left, unwritten) = ex.glyph_text(s, &layout, i, text, fill, true, Some(false)); + images = Some(unwritten); + left.extend(run_bars.iter()); + Cow::Owned(left) + } + None => Cow::Borrowed(&bp), + }; + let opaque = (1.0, BlendMode::Normal); + if let Some(path) = to_path(s, &outlines) + && !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box(), true) + && let Some(paint) = ex.paint(s, fill, layout.bounds) + { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + } + s.pop(); + }); + // They paint themselves, whatever the fill. + let images = images.unwrap_or_else(|| layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == i).map(|(gi, _)| gi).collect()); + s.push_transform(&text_space); + for g in images.iter().filter_map(|&gi| layout.glyphs.get(gi)) { + self.glyph_image(s, g); + // COLR glyphs: their layers, as art. + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(s, &art, page, true); + } + } + s.pop(); + // Characters with neither fill nor stroke (a scanned page's recognized text), or whose + // fill is drawn as art (a dithered gradient's image, a pattern: outlines, not text), + // are still text: written invisible (rendering mode 3), so they can still be selected + // and searched. + let unpainted = fill.is_none() && !run.style.has_stroke(); + if let Some(text) = plain.as_deref().filter(|_| unpainted || self.area_paint(fill, true)) { + self.invisible = true; + crate::forms::form(s, Mark::Invisible, |s| { + s.push_transform(&text_space); + // The paint only lets the writer write the text; the glyphs it can't write + // as text draw nothing, and colour bitmap glyphs are drawn already. + self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false, None); + s.pop(); + }); + } else if let Some(text) = plain.as_deref().filter(|_| layout.glyphs.iter().any(|g| g.run == i && g.bend.is_some())) { + // Glyphs bent along a path were drawn as their outlines (text is set straight): + // they are written again as invisible text, straight where they stand, so they can + // still be selected, searched and copied. + self.invisible = true; + crate::forms::form(s, Mark::Invisible, |s| { + s.push_transform(&text_space); + self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false, Some(true)); + s.pop(); + }); + } + s.push_transform(&text_space); + let path = run.style.has_stroke().then(|| to_path(s, &bp)).flatten(); + s.pop(); + if let Some(path) = path { + // Character strokes are drawn in text space, with their cap, join and dashes. + let st = run.style.stroke_layer(); + self.overprinting(s, st.overprint, &st.paint, |ex, s| { + s.push_transform(&text_space); + ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds); + s.pop(); + }); + } + } + s.set_fill(None); + s.set_stroke(None); + if self.inline_depth < MAX_INLINE_DEPTH { + self.inline_depth += 1; + for ig in &layout.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue }; + let mut node = (*sym.art).clone(); + // Strokes scale with the art, as on the canvas. + node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true); + self.node(s, &node, page, true); + } + self.inline_depth -= 1; + } + if let Some((bp, path)) = &all { + self.text_items(s, above, bp, path, page, tb); + } + } + + /// Some of a type object's own fills and strokes (`items`) on its glyph outlines `bp` (`path`; + /// `tb`: their bounds). Strokes take every stroke option, as on paths. + #[allow(clippy::too_many_arguments)] + fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], bp: &BezPath, path: &Path, page: Rect, tb: Rect) { + for item in items { + match item { + AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { + if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box(), true) + && let Some(paint) = ex.paint(s, &fl.paint, tb) + { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero })); + s.draw_path(path); + } + }), + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => { + self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, path, FillRule::NonZero, st, page, tb)) + } + _ => {} + } + } + s.set_fill(None); + s.set_stroke(None); + } + + /// Fill the glyphs of run `run` of `layout` with `fill` as text in their fonts, embedded (in + /// text space; `text` is the plain text, whose characters the glyphs stand for, which makes + /// the text selectable and searchable) → the outlines of the glyphs left to draw as paths: + /// those of fonts that can't be embedded, and missing glyphs. Glyphs sharing a font, a frame + /// (size, scale, rotation) and a baseline are written together. Colour glyphs (bitmap emoji, + /// COLR, SVG), with `images`, are written as text too, which draws their images or layers; the + /// indices of those it can't write or draw (SVG glyphs) are returned with the outlines. The + /// hyphen the layout adds where it breaks a line inside a word ([`break_hyphen`]) is written + /// as the character its glyph stands for, on its own after the glyphs before it. The glyphs of + /// a list marker are written as the marker's characters ([`marker_chars`]), apart from the + /// plain text's glyphs; a marker glyph that stands for none of them is drawn as a path. + /// + /// `bent`: `Some(false)` writes the glyphs bent along a path ([`vectorcraft_text::GlyphBend`]) + /// as outlines, not text (text is set straight); `Some(true)` writes only those, as text where + /// they stand straight; `None` writes all of them as text. + #[allow(clippy::too_many_arguments)] + fn glyph_text( + &mut self, + s: &mut Surface, + layout: &TextLayout, + run: usize, + text: &str, + fill: &Paint, + images: bool, + bent: Option, + ) -> (BezPath, Vec) { + let (mut rest, mut unwritten) = (BezPath::new(), vec![]); + let mut line: Option = None; + // The characters the glyphs stand for: the plain text, then, when the run has glyphs that + // stand for no character of it, the characters a line-break hyphen can stand for. + let written: Cow = + if layout.glyphs.iter().any(|g| g.run == run && g.len == 0) { Cow::Owned(format!("{text}{BREAK_HYPHENS}")) } else { Cow::Borrowed(text) }; + let written_text: &str = &written; + // The text a line's glyphs stand for: `written`, or its list marker's characters. + let text_of = |l: &GlyphLine| l.marker.and_then(|m| layout.marker_of(m)).map_or(written_text, |m| m.text.as_str()); + // The characters of the list marker being written that each of its glyphs stands for. + let mut marker: Option<(usize, Vec>>)> = None; + for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) { + let coloured = g.image.is_some() || g.colour.is_some(); + if coloured && !images { + continue; + } + match (bent, g.bend.is_some()) { + (Some(false), true) => { + rest.extend(g.outline.iter()); + continue; + } + (Some(true), false) => continue, + _ => {} + } + // Of the glyphs with no length, only a list marker's and line-break hyphens are written + // as text; the others, such as kashidas, are drawn as paths. A list marker's glyphs are + // not line-break hyphens, whatever the marker. + let in_marker = (g.len == 0).then(|| layout.marker_of(gi)).flatten(); + let hyphen = if in_marker.is_some() { None } else { break_hyphen(g) }; + let (range, ch) = match in_marker { + Some(m) => { + if marker.as_ref().is_none_or(|(start, _)| *start != m.glyphs.start) { + marker = Some((m.glyphs.start, marker_chars(layout, m))); + } + let range = marker.as_ref().and_then(|(start, chars)| chars.get(gi.checked_sub(*start)?).cloned().flatten()); + let ch = range.clone().and_then(|r| m.text.get(r)).and_then(|t| t.chars().next()); + (range.unwrap_or(0..0), ch) + } + None => { + let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end); + let ch = written.get(range.clone()).and_then(|t| t.chars().next()); + (range, ch) + } + }; + let marker_at = in_marker.map(|m| m.glyphs.start); + // Spaces are written too (text extraction needs them); control characters and soft + // hyphens draw nothing. + let blank = g.outline.elements().is_empty() && !coloured; + if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) { + continue; + } + // The glyph at the font size of its vertical scale: `frame` maps that size's glyph + // space (y down, krilla's) to text space. + let [a, b, c, d, ..] = g.xf.as_coeffs(); + let (sx, sy) = (a.hypot(b), c.hypot(d)); + let font = if g.gid != 0 && ch.is_some() { self.font(g.font_id) } else { None }; + let det = g.xf.determinant(); + let Some((font, upem)) = font.filter(|(_, upem)| det.is_finite() && det.abs() > 1e-9 && (1e-3..=1e5).contains(&(upem * sy))) else { + rest.extend(g.outline.iter()); + if coloured { + unwritten.push(gi); + } + continue; + }; + // SVG glyphs are written as text that draws nothing (see the serialize settings): their + // art is drawn, or else their outline. + if is_svg_glyph(g) { + if coloured { + unwritten.push(gi); + } else { + rest.extend(g.outline.iter()); + } + } + let size = upem * sy; + let frame = g.xf * Affine::scale(1.0 / sy); + // Its pen position along the frame's baseline, when it continues the line (a + // line-break hyphen never does). + let x = line + .as_ref() + .filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6 && l.marker == marker_at) + .and_then(|l| { + let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs(); + let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4; + same.then_some(e) + }); + let x = match x { + Some(x) => x, + None => { + if let Some(l) = line.take() { + let text = text_of(&l); + self.draw_glyph_line(s, l, text, fill, layout.bounds); + } + line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![], marker: marker_at }); + 0.0 + } + }; + // The glyph's advance in frame units (the last one's ends the line). + let advance = g.advance * sy / sx; + if let Some(l) = line.as_mut() { + l.glyphs.push((g.gid, x, advance, range)); + } + // A line-break hyphen is written on its own. + if hyphen.is_some() + && let Some(l) = line.take() + { + self.draw_glyph_line(s, l, &written, fill, layout.bounds); + } + } + if let Some(l) = line { + let text = text_of(&l); + self.draw_glyph_line(s, l, text, fill, layout.bounds); + } + (rest, unwritten) + } + + /// Draw colour bitmap glyph `g`'s image (on `s` in text space), its PNG written once. + fn glyph_image(&mut self, s: &mut Surface, g: &vectorcraft_text::PositionedGlyph) { + let Some((image, to_text)) = g.placed_image() else { return }; + if !writable(s, to_text, image.rect()) { + return; + } + let cache = format!("\u{0}glyph\u{0}{}", image.blob.content_key()); + let img = match self.images.get(&cache) { + Some(img) => img.clone(), + None => { + let img = embed(&image.blob, self.interpolate, self.sixteen_bit); + self.images.insert(cache, img.clone()); + img + } + }; + let (Some(img), Some(size)) = (img, Size::from_wh(image.width as f32, image.height as f32)) else { return }; + s.push_transform(&xf(to_text)); + s.draw_image(img, size); + s.pop(); + } + + /// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where + /// `bounds` is). A line that would be written with a pen position, size or spacing (in + /// thousandths of an em) beyond [`MAX_COORD`] is left out. + fn draw_glyph_line(&mut self, s: &mut Surface, line: GlyphLine, text: &str, fill: &Paint, bounds: Rect) { + let size = line.size; + let glyphs: Vec = line + .glyphs + .iter() + .enumerate() + .map(|(i, (gid, x, advance, range))| { + let next = line.glyphs.get(i + 1).map_or(x + advance, |n| n.1); + KrillaGlyph::new(GlyphId::new(*gid), ((next - x) / size) as f32, 0.0, 0.0, 0.0, range.clone(), None) + }) + .collect(); + let pens = + line.glyphs.iter().fold(Rect::new(0.0, -size, 0.0, size), |r, (_, x, advance, _)| r.union_pt((*x, 0.0)).union_pt((x + advance, 0.0))); + let spacing: Vec = glyphs.iter().map(|g| f64::from(g.x_advance(1.0)) * 1000.0).collect(); + if !(writable(s, line.frame, pens) && in_range(&spacing)) { + return; + } + s.push_transform(&xf(line.frame)); + if let Some(paint) = self.paint_in(s, fill, bounds, line.frame.inverse()) { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false); + } + s.pop(); + } + + /// The font of face `id` for real text and its units per em; `None` (with a warning) when its + /// licence doesn't allow embedding it as a subset, or it can't be read. + fn font(&mut self, id: u32) -> Option<(krilla::text::Font, f64)> { + if let Some(f) = self.fonts.get(&id) { + return f.clone(); + } + let Some(face) = vectorcraft_text::FontDb::global().face_by_id(id) else { + self.fonts.insert(id, None); + return None; + }; + let name = format!("{} {}", face.family, face.style); + let font = match face.embedding() { + Embedding::Subset => { + // A named instance of a variable font is embedded instanced (its own outlines and + // widths, not the default instance's). + match write::font(face.file_data(), face.face_index(), Some(face.variations())) { + Ok(font) => Some((font, face.units_per_em())), + Err(error) => { + self.warn(format!("the font “{name}” can't be embedded ({error}): its text is exported as outlines")); + None + } + } + } + Embedding::Whole => { + self.warn(format!( + "the font “{name}” may only be embedded whole, and fonts are embedded as subsets: its text is exported as outlines" + )); + None + } + Embedding::Forbidden => { + self.warn(format!("the licence of the font “{name}” doesn't allow embedding: its text is exported as outlines")); + None + } + }; + self.fonts.insert(id, font.clone()); + font + } +} + +/// Is `g` drawn from its font's SVG document, which the PDF writer can't draw as a font's glyph? +fn is_svg_glyph(g: &vectorcraft_text::PositionedGlyph) -> bool { + vectorcraft_text::FontDb::global().is_svg_glyph(g.font_id, g.gid) +} + +/// The characters a line-break hyphen can stand for, as real text: '-' and U+2010 (HYPHEN). +const BREAK_HYPHENS: &str = "-\u{2010}"; + +/// The bytes of [`BREAK_HYPHENS`] that glyph `g` stands for when it is a hyphen the layout adds +/// where it breaks a line inside a word: a glyph that stands for no character of the text (length +/// 0) and that its face's character map gives to '-' or, failing that, to U+2010. `None` for any +/// other glyph, kashida tatweels included. +fn break_hyphen(g: &vectorcraft_text::PositionedGlyph) -> Option> { + if g.len != 0 || g.gid == 0 { + return None; + } + let face = vectorcraft_text::FontDb::global().face_by_id(g.font_id)?; + BREAK_HYPHENS.char_indices().find(|&(_, c)| face.glyph_for(c) == g.gid).map(|(i, c)| i..i + c.len_utf8()) +} + +/// The characters of list marker `m` of `layout` that each of its glyphs stands for, as bytes of +/// the marker's text, in the order of [`ListMarker::glyphs`]: `None` for its tab and for a glyph +/// that stands for none of them. A glyph stands for the first character left that the glyph's +/// font maps to that glyph in its character map. When the glyphs that no character maps to (an +/// OpenType feature such as oldstyle figures replaced them) are as many as the characters left, +/// they stand for those characters, in order. +fn marker_chars(layout: &TextLayout, m: &ListMarker) -> Vec>> { + let db = vectorcraft_text::FontDb::global(); + let mut left: Vec<(std::ops::Range, char)> = m.text.char_indices().map(|(i, c)| (i..i + c.len_utf8(), c)).collect(); + let mut chars = vec![None; m.glyphs.len()]; + let mut unmapped = vec![]; + for (k, gi) in m.glyphs.clone().enumerate() { + let Some(g) = layout.glyphs.get(gi).filter(|g| gi != m.tab && g.gid != 0) else { continue }; + let face = db.face_by_id(g.font_id); + match (left.iter().position(|(_, c)| face.as_ref().is_some_and(|f| f.glyph_for(*c) == g.gid)), chars.get_mut(k)) { + (Some(at), Some(slot)) => *slot = Some(left.remove(at).0), + _ => unmapped.push(k), + } + } + if unmapped.len() == left.len() { + for (k, (range, _)) in unmapped.into_iter().zip(left) { + if let Some(slot) = chars.get_mut(k) { + *slot = Some(range); + } + } + } + chars +} diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs new file mode 100644 index 000000000..372e1f927 --- /dev/null +++ b/crates/pdf/src/export/writer.rs @@ -0,0 +1,342 @@ +use super::entry::RgbInCmyk; +use super::exporter::{constant_mask, cover, to_path, writable, xf}; +use super::*; + +/// A PDF being written, shared by export and print: the file's configuration and metadata, then +/// its pages ([`Self::page`]), each drawn by an [`Exporter`]. +pub(crate) struct Writer { + pub pdf: write::Writer, + /// How colours are written (the Output settings). + pub out: Arc, + /// Groups blend in CMYK (a CMYK document's composite, or converting to CMYK; see + /// `cmyk_blending`). + cmyk: bool, + /// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]). + lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, + warnings: Vec, + /// RGB content of a CMYK document, gathered from the exporters ([`Self::absorb`]). + rgb_in_cmyk: RgbInCmyk, + /// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups. + pdf13: bool, + /// The document's images the exporters wrote, by key, gathered from the exporters + /// ([`Self::absorb`]): [`Self::finish`] finds the key of an image the writer couldn't decode. + images: Vec<(String, Image)>, +} + +/// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), +/// which [`Self::view`] maps onto the page. +pub(crate) struct Sheet<'a> { + /// The page's size in its units. + pub size: (f64, f64), + /// The size of the page's unit in points (its `UserUnit`): a page in points has 1. A side is + /// written at least 1 pt long. + pub unit: f64, + /// The TrimBox and BleedBox, in page space (y down from the top-left corner). + pub trim: Rect, + pub bleed: Rect, + /// Drawing space → page space. + pub view: Affine, + /// The part of the drawing space that shows (a tile): art and marks are clipped to it. + pub window: Option, + /// Document space → drawing space. + pub place: Affine, + /// The art drawn, in document space (the bleed box): what lies outside it is left out. + pub area: Rect, + /// Clip the art to [`Self::area`]. + pub clip: bool, + /// Printer's marks, in drawing space. + pub marks: Option<&'a Node>, + /// Invert the page: paper black, ink clear (a film negative). + pub negative: bool, +} + +impl Writer { + /// A PDF with `set`'s version, standard, compression and colour output, and `doc`'s metadata + /// under `title`, created at `created` (Unix seconds). With `cmyk`, transparency blends in + /// CMYK (unless the colours are converted to RGB). + pub(crate) fn new(doc: &Document, set: &crate::PdfSettings, title: &str, created: Option, cmyk: bool) -> Result { + let out = ColorOut::new(doc, set)?; + let mut warnings = out.profile_notes.clone(); + // Tagged CMYK colours need the profile (PDF/A tags every colour). + let cmyk_profile = if out.tagged { out.cmyk_icc() } else { None }; + if out.tagged && cmyk_profile.is_none() { + warnings.push(format!("the CMYK profile {} can't be embedded: CMYK colours are written untagged", out.cmyk_profile())); + } + let version = match set.compatibility { + // PDF 1.3 files are written with the PDF 1.4 settings: flat, they use nothing newer. + Compatibility::Pdf13 | Compatibility::Pdf14 => PdfVersion::Pdf14, + Compatibility::Pdf15 => PdfVersion::Pdf15, + Compatibility::Pdf16 => PdfVersion::Pdf16, + Compatibility::Pdf17 => PdfVersion::Pdf17, + Compatibility::Pdf20 => PdfVersion::Pdf20, + }; + // `check` has refused the standards (and standard/version pairs) the writer can't produce. + let archival = (set.standard == Standard::PdfA2b).then_some(Archival::A2_B); + let configuration = write::configuration(version, archival)?; + let settings = krilla::SerializeSettings { + compress_content_streams: set.compression.compress_text, + no_device_cs: out.tagged, + cmyk_profile, + configuration, + // SVG glyphs, which krilla can't draw, are drawn as art where they sit (`glyph_text`): + // as text they draw nothing, rather than their fonts' plain outlines over the art. + render_svg_glyph_fn: |_, _, _, _, _| Some(()), + ..Default::default() + }; + + // File Info normalization stays with the application. + let info = &doc.metadata; + let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string); + let metadata = Metadata { + creator: "VectorCraft".into(), + producer: "VectorCraft".into(), + title: Some(title.to_owned()), + authors: given(&info.author).into_iter().collect(), + description: given(&info.description), + keywords: info.keywords.clone(), + created: created.map(date_time), + modified: None, + }; + let pdf = write::Writer::new(settings, metadata); + Ok(Self { + pdf, + cmyk: out.blends_cmyk(cmyk), + out: Arc::new(out), + lab_spots: vec![], + warnings, + rgb_in_cmyk: RgbInCmyk::default(), + pdf13: set.pdf13(), + images: vec![], + }) + } + + /// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks. + pub(crate) fn page(&mut self, ex: &mut Exporter, sheet: &Sheet) -> Result<(), PdfError> { + let (w, h) = sheet.size; + let least = 1.0 / sheet.unit; + let size = Size::from_wh(w.max(least) as f32, h.max(least) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?; + let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32); + let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed))); + let mut s = page.surface(); + let paper = Rect::new(0.0, 0.0, w, h); + if sheet.negative { + cover(&mut s, paper, 255, 1.0); + } + // A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank. + let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO); + s.push_transform(&xf(sheet.view)); + let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1))); + if let Some(clip) = &window { + s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); + } + let placed = sheet.place != Affine::IDENTITY; + if placed { + s.push_transform(&xf(sheet.place)); + } + let r = sheet.area; + let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); + if let Some(clip) = &clip { + s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); + } + // Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF + // writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency + // at the top of a page is put in a group of its own, whose blending space `cmyk_blending` + // rewrites: non-isolated only when blending on the page reaches the paper, since some + // readers (CoreGraphics) drop most of a page in a non-isolated group (#2156). + let doc = ex.doc; + let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout); + let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency(); + if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) { + s.push_isolated(); + } else if cmyk_page_group { + let all = constant_mask(&mut s, r, 1.0); + s.push_mask(all); + } + if fits { + ex.children(&mut s, &doc.layers, r); + } + if page_group || cmyk_page_group { + s.pop(); + } + if clip.is_some() { + s.pop(); + } + if placed { + s.pop(); + } + if fits && let Some(art) = sheet.marks { + // The drawing space the page shows (the marks' area). + let shown = sheet.view.inverse().transform_rect_bbox(paper); + let knockout = std::mem::replace(&mut ex.knockout, false); + // Marks keep their inks: they aren't converted to the destination. The page + // information is drawn as glyph shapes, like the other marks, not as text. + let convert = std::mem::replace(&mut ex.convert, false); + let outline = std::mem::replace(&mut ex.outline_text, true); + ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true); + (ex.knockout, ex.convert, ex.outline_text) = (knockout, convert, outline); + } + if window.is_some() { + s.pop(); + } + s.pop(); + if sheet.negative { + s.push_blend_mode(krilla::blend::BlendMode::Difference); + cover(&mut s, paper, 255, 1.0); + s.pop(); + } + s.finish(); + page.finish(); + Ok(()) + } + + /// Take an exporter's warnings, Lab spot colours and images once its pages are drawn. + pub(crate) fn absorb(&mut self, ex: Exporter) { + for w in ex.warnings { + if !self.warnings.contains(&w) { + self.warnings.push(w); + } + } + self.rgb_in_cmyk.merge(ex.rgb_in_cmyk); + for spot in ex.lab_spots { + if !self.lab_spots.iter().any(|(n, _)| *n == spot.0) { + self.lab_spots.push(spot); + } + } + // The exporter caches images by their key, followed by their size and mask after a NUL. + for (cache, image) in ex.images { + if let (Some(key), Some(image)) = (cache.split('\0').next(), image) { + self.images.push((key.to_string(), image)); + } + } + } + + /// The finished file (with the output intent and Trapped entries, and checked against its + /// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK + /// alternates. When the writer couldn't decode one of the images, its key is added to + /// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned. + pub(crate) fn finish(mut self, left_out: &mut HashSet, display_doc_title: bool) -> Result<(Vec, Vec), PdfError> { + let images = std::mem::take(&mut self.images); + let mut bytes = self.pdf.finish().map_err(|e| { + if let write::WriteError::Serialization(k) = &e + && let krilla::error::KrillaError::Image(failed, ..) = &**k + { + left_out.extend(images.iter().filter(|(_, image)| image == failed).map(|(key, _)| key.clone())); + } + PdfError::from(e) + })?; + if self.cmyk { + cmyk_blending(&mut bytes, self.out.tagged); + } + let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only(); + let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; + let (bytes, more) = self.out.write_catalog(bytes, display_doc_title)?; + self.warnings.extend(more); + self.warnings.extend(self.rgb_in_cmyk.warning()); + Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) + } +} + +/// Make the transparency groups of `pdf` blend in CMYK. The PDF writer gives every group RGB as +/// its blending space (DeviceRGB, or sRGB when colours are `tagged`), so the group dictionaries +/// are rewritten in place to DeviceCMYK (or the CMYK profile's space), keeping their length so the +/// cross-reference offsets stay valid. Luminosity masks' groups keep RGB: mask luminance is that +/// of screen colours, as on screen. +fn cmyk_blending(pdf: &mut [u8], tagged: bool) { + let masks = luminosity_mask_groups(pdf); + let mut spaces = vec![("/CS/DeviceRGB".to_string(), "/CS/DeviceCMYK".to_string())]; + if tagged { + spaces.extend(icc_spaces(pdf)); + } + let mut from = 0; + while let Some(at) = find(pdf, b"/Group<<", from).map(|i| i + b"/Group".len()) { + let end = find(pdf, b">>", at).map_or(pdf.len(), |i| i + 2); + let mask = pdf.get(..at).and_then(object_number).is_some_and(|n| masks.contains(&n)); + if let (false, Some(dict)) = (mask, pdf.get_mut(at..end)) { + cmyk_group(dict, &spaces); + } + from = end; + } +} + +/// The ICC-based RGB colour spaces of `pdf`, each with the ICC-based CMYK one to blend in instead, +/// as group `/CS` entries (`/CS 3 0 R`); none without a CMYK one. +fn icc_spaces(pdf: &[u8]) -> Vec<(String, String)> { + const ICC: &[u8] = b"[/ICCBased "; + let (mut rgb, mut cmyk) = (vec![], None); + let mut from = 0; + while let Some(at) = find(pdf, ICC, from) { + from = at + ICC.len(); + let digits = pdf.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count()); + let (Some(array), Some(stream)) = (pdf.get(..at).and_then(object_number), pdf.get(from..from + digits).and_then(number)) else { + continue; + }; + // The profile stream's dictionary says how many components it has. + let Some(head) = find(pdf, format!("\n{stream} 0 obj").as_bytes(), 0) else { continue }; + let dict = pdf.get(head..find(pdf, b"stream", head).unwrap_or(pdf.len())).unwrap_or_default(); + if find(dict, b"/N 4", 0).is_some() { + cmyk.get_or_insert(format!("/CS {array} 0 R")); + } else if find(dict, b"/N 3", 0).is_some() { + rgb.push(format!("/CS {array} 0 R")); + } + } + let Some(cmyk) = cmyk else { return vec![] }; + rgb.into_iter().map(|r| (r, cmyk.clone())).collect() +} + +/// Rewrite transparency group dictionary `dict` (`<<…>>`) to blend in the CMYK space of the first +/// of `spaces` (RGB `/CS` entry, CMYK one) it has, at the same length: leaving out the optional +/// `/Type/Group` makes room for the longer entry, spaces pad the rest. +fn cmyk_group(dict: &mut [u8], spaces: &[(String, String)]) { + let Ok(text) = std::str::from_utf8(dict) else { return }; + if !(text.contains("/S/Transparency") && text.contains("/Type/Group")) { + return; + } + let Some((rgb, cmyk)) = spaces.iter().find(|(rgb, _)| text.contains(rgb.as_str())) else { return }; + let body = text.replacen("/Type/Group", "", 1).replacen(rgb.as_str(), cmyk, 1); + let body = body.strip_suffix(">>").unwrap_or(&body); + let Some(pad) = dict.len().checked_sub(body.len() + 2) else { return }; + let new = format!("{body}{}>>", " ".repeat(pad)); + dict.copy_from_slice(new.as_bytes()); +} + +/// Object numbers of the groups luminosity soft masks draw (`/G n 0 R` in their dictionaries). +fn luminosity_mask_groups(pdf: &[u8]) -> Vec { + let mut out = vec![]; + let mut from = 0; + while let Some(at) = find(pdf, b"/Type/Mask", from) { + let end = find(pdf, b">>", at).unwrap_or(pdf.len()); + let dict = &pdf[at..end]; + if find(dict, b"/S/Luminosity", 0).is_some() + && let Some(g) = find(dict, b"/G ", 0) + { + let rest = &dict[g + 3..]; + out.extend(number(&rest[..rest.iter().take_while(|b| b.is_ascii_digit()).count()])); + } + from = end; + } + out +} + +/// The number of the object whose dictionary `before` ends in (its last `n 0 obj` header). +fn object_number(before: &[u8]) -> Option { + let at = rfind(before, b" 0 obj")?; + let digits = before[..at].iter().rev().take_while(|b| b.is_ascii_digit()).count(); + number(&before[at - digits..at]) +} + +fn number(digits: &[u8]) -> Option { + std::str::from_utf8(digits).ok()?.parse().ok() +} + +/// Unix seconds (UTC) → krilla date. +pub(super) fn date_time(t: i64) -> krilla::metadata::DateTime { + let [year, month, day, hour, minute, second] = vectorcraft_doc::metadata::civil(t); + krilla::metadata::DateTime::new(year as u16) + .month(month as u8) + .day(day as u8) + .hour(hour as u8) + .minute(minute as u8) + .second(second as u8) + .utc_offset_hour(0) + .utc_offset_minute(0) +} From 64fef38a50dc223d1ab981cf4c59d527d6242a83 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:33:03 +0000 Subject: [PATCH 131/199] refactor(pdf): move import.rs items to import/{builder,cid,device,entry,state}.rs --- crates/pdf/src/import.rs | 2224 +----------------------------- crates/pdf/src/import/builder.rs | 857 ++++++++++++ crates/pdf/src/import/cid.rs | 223 +++ crates/pdf/src/import/device.rs | 368 +++++ crates/pdf/src/import/entry.rs | 530 +++++++ crates/pdf/src/import/state.rs | 277 ++++ 6 files changed, 2268 insertions(+), 2211 deletions(-) create mode 100644 crates/pdf/src/import/builder.rs create mode 100644 crates/pdf/src/import/cid.rs create mode 100644 crates/pdf/src/import/device.rs create mode 100644 crates/pdf/src/import/entry.rs create mode 100644 crates/pdf/src/import/state.rs diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index f888f3dbd..cafe35119 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -30,2216 +30,18 @@ use crate::import_shading::{ use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; -/// Mixed into an image's cache key when it is read white inside an alpha soft mask. -const WHITE_KEY: u128 = 0x7717_e000_a1fa; -/// The name of the paths text imports as. -const TEXT_OUTLINES: &str = ""; -/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects. -const OVERPRINT_UNREAD: &str = "the overprint of a page's objects couldn't be read: they knock out"; -/// The document overprints as overprint mode 1 does. -const OVERPRINT_ZERO_MODE: &str = - "overprint in the mode where a process colour's zero tints knock out (/OPM 0) is imported as overprint, where they don't"; -/// The deepest soft masks, patterns and type 3 glyphs are read within each other. -const MAX_NESTED: u32 = 8; -/// A tiling pattern read this many times with the same art is taken to always draw it. -const PATTERN_REUSE: u32 = 16; -/// Stands for the group of art whose group couldn't be told (see [`Builder::unsure`]): it goes -/// to a hidden, non-printing layer of its page. -const UNSORTED: usize = usize::MAX; - -/// Import a PDF (or PDF-compatible `.ai`) with default options. -pub fn import(bytes: &[u8]) -> Result { - import_with_report(bytes, &ImportOptions::default()).map(|r| r.document) -} - -/// A layer of the imported document, in paint order. -enum Slot { - /// A page's art outside optional content groups. - Page(Box), - /// An optional content group (index into [`Ocgs::list`]). - Group(usize), -} - -/// What each group's layer holds, in paint order: its sublayers (their groups) and, where its -/// own group is listed, its art. -type Nesting = HashMap>; - -/// Group `g` paints (its first art): note where its art goes in its layer, and where that layer -/// goes when it is new: in its parent's layer (placed the same way) or, for a top-level group, in -/// `slots`. -fn place_group(g: usize, ocgs: &Ocgs, slots: &mut Vec, nesting: &mut Nesting) { - let placed = nesting.contains_key(&g); - nesting.entry(g).or_default().push(g); - if placed { - return; - } - let mut child = g; - // Each parent is placed before its sublayers, so the chain ends (within the nesting depth). - for _ in 0..=MAX_NESTING { - let Some(parent) = ocgs.list.get(child).and_then(|o| o.parent) else { break }; - let placed = nesting.contains_key(&parent); - nesting.entry(parent).or_default().push(child); - if placed { - return; - } - child = parent; - } - slots.push(Slot::Group(child)); -} - -/// The top-level group that group `g` is listed under (`g` itself when it isn't a sublayer). -fn top_group(g: usize, ocgs: &Ocgs) -> usize { - let mut top = g; - for _ in 0..=MAX_NESTING { - match ocgs.list.get(top).and_then(|o| o.parent) { - Some(parent) => top = parent, - None => break, - } - } - top -} - -/// Merge the top-level groups a page marks (`page`, in the order it first marks each, its empty -/// ones too) into the stacking order of the pages before (`order`, bottom first; `known`: its -/// groups). A group new to it goes right below the first group the page marks after it that it -/// has: a layer whose art starts on a later page keeps its place among the others (#508). -fn merge_order(order: &mut Vec, known: &mut HashSet, page: &[usize]) { - let mut above = None; - for &g in page.iter().rev() { - if known.insert(g) { - let at = above.and_then(|a| order.iter().position(|&x| x == a)).unwrap_or(order.len()); - order.insert(at, g); - } - above = Some(g); - } -} - -/// The layer of group `g`, in `color`: its art and sublayers. `None` for a group that isn't read. -fn group_layer( - b: &mut Builder<'_>, - g: usize, - ocgs: &Ocgs, - nesting: &Nesting, - art: &mut HashMap>>, - color: LayerColor, -) -> Option { - let ocg = ocgs.list.get(g)?; - let mut l = Node::layer(b.id(), &ocg.name, color); - (l.visible, l.locked) = (ocg.on, ocg.locked); - let mut children = vec![]; - for &c in nesting.get(&g).into_iter().flatten() { - if c == g { - children.extend(art.remove(&g).unwrap_or_default()); - } else if let Some(sub) = group_layer(b, c, ocgs, nesting, art, color) { - children.push(Arc::new(sub)); - } - } - if let NodeKind::Layer { children: c, printable, .. } = &mut l.kind { - *c = children; - *printable = ocg.print; - } - Some(l) -} - -/// The note on a file carrying an editor's private data (#472): its PDF part, the one read, holds -/// only the art on its artboards (art on the pasteboard is in the private data alone). -pub const OFF_ARTBOARD_NOTE: &str = - "only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open"; - -/// Warning: art reaching past the page box was clipped to it. -pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it"; - -/// Warning: glyphs were kept as outlines rather than read as type. -pub const OUTLINED_TEXT_NOTE: &str = "text was converted to outlines"; -/// Warning: text drawn invisibly (text rendering mode 3, as a scanned page's OCR layer is) that -/// can't come in as type (text read as outlines, or a Type 3 font) was left out: as outlines it -/// would draw nothing (#2675). -pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's searchable OCR layer) that couldn't be read as type was left out"; -/// Warning: glyphs that differ from the installed font of their name were kept as outlines. -pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines"; -/// The end of the warning that glyphs whose characters the file doesn't name were kept as -/// outlines, after "text in" and the font's name. -pub const UNNAMED_CHARACTERS_NOTE: &str = "whose characters the file doesn't name (U+FFFD) was kept as outlines"; - -/// Is `note` one of the warnings that glyphs were kept as outlines ([`OUTLINED_TEXT_NOTE`], -/// [`UNMATCHED_GLYPHS_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? -pub fn is_outlines_note(note: &str) -> bool { - note == OUTLINED_TEXT_NOTE || note == UNMATCHED_GLYPHS_NOTE || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) -} - -/// Is `n` glyphs kept as outlines (a path the import names ``)? -pub fn is_glyph_outlines(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { .. }) && n.name.as_deref() == Some(TEXT_OUTLINES) -} - -/// Does `doc` hold glyphs kept as outlines ([`is_glyph_outlines`])? -pub fn holds_outlines(doc: &Document) -> bool { - let mut any = false; - doc.walk(|n| any |= is_glyph_outlines(n)); - any -} - -/// Import a PDF, returning the document plus warnings about content that was approximated or skipped. -pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { - // The interpreter draws forms, patterns, glyphs and masks inside one another by recursion, in - // large stack frames: it caps that (50 deep), and gets room for the cap whatever thread asks. - vectorcraft_doc::depth::with_deep_stack(|| read(bytes, opts)) -} - -/// [`import_with_report`] on the thread that asks. -pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result { - read_with_reader_options(bytes, opts, &crate::pages::open_options()) -} +mod builder; +mod cid; +mod device; +mod entry; +mod state; #[cfg(test)] -pub(crate) fn import_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_pdf::OpenOptions) -> Result { - vectorcraft_doc::depth::with_deep_stack(|| read_with_reader_options(bytes, opts, reader)) -} - -fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_pdf::OpenOptions) -> Result { - let original = crate::pages::open_with_options(bytes, opts.password.as_deref(), reader)?; - let picked = crate::pages::picked(opts, original.pages().len())?; - let mut ocgs = Ocgs::read(&original); - let route = opts.layers && !ocgs.list.is_empty(); - // Content that is off imports as hidden layers: an update of the file turns every group on - // (unless a form is hidden by its own group: its art couldn't be told apart). - let hide = route && ocgs.skips_any(); - let mut hidden_form = false; - if hide { - for page in picked.iter().filter_map(|&n| original.pages().get(n)) { - hidden_form |= hides_forms(page, &mut ocgs, original.decode_limits().max_decoded_stream())?; - if hidden_form { - break; - } - } - } - let turned_on = (hide && !hidden_form) - .then(|| all_on(bytes, &original)) - .flatten() - .and_then(|b| crate::pages::open_with_options(&b, opts.password.as_deref(), reader).ok()) - .filter(|p| Ocgs::read(p).list.is_empty()); - let mut notes = vec![]; - if hide && turned_on.is_none() { - notes.push("the art of hidden layers couldn't be read and was left out".to_string()); - } - let all_on = turned_on.is_some(); - let pdf = turned_on.unwrap_or(original); - let pages = pdf.pages(); - - let sink: Arc>> = Arc::default(); - let sink2 = sink.clone(); - let incomplete = Arc::new(AtomicBool::new(false)); - let incomplete_sink = incomplete.clone(); - let settings = InterpreterSettings { - warning_sink: Arc::new(move |w| { - let msg = match w { - InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped", - InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped", - InterpreterWarning::ContentTruncated => { - incomplete_sink.store(true, Ordering::Relaxed); - return; - } - }; - if let Ok(mut v) = sink2.lock() - && !v.iter().any(|x| x == msg) - { - v.push(msg.to_string()); - } - }), - render_annotations: false, - ..Default::default() - }; - - let mut doc = Document::new(1.0, 1.0); - doc.title = "Imported PDF".into(); - doc.artboards.clear(); - doc.layers.clear(); - let taken: Vec = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect(); - let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as, route); - // The art of a group that is off (drawn as `all_on` turned it on) or doesn't print mustn't - // show or print on its page's layer. - b.unsure = route && ocgs.list.iter().any(|g| (all_on && !g.on) || !g.print); - b.taken = taken; - b.warnings = notes; - b.colors.uncalibrated_rgb = opts.uncalibrated_rgb && picked.iter().filter_map(|&n| pages.get(n)).any(crate::pages::has_private_data); - let cache = InterpreterCache::new(); - let mut x = 0.0; - // Where the next page's art may begin (a `.ai` page's art reaches past its artboard). - let mut clear = f64::NEG_INFINITY; - // Every page only text over private data: a file saved without its PDF part shows a notice - // there, not its art. A page that states where its art is isn't that notice. - let (mut only_text, mut art_boxes) = (true, false); - let mut slots: Vec = vec![]; - let mut group_art: HashMap>> = HashMap::new(); - let mut nesting = Nesting::new(); - // The top-level groups in the order the pages mark them, bottom first. - let (mut order, mut known) = (vec![], HashSet::new()); - for (i, &number) in picked.iter().enumerate() { - let Some(page) = pages.get(number) else { continue }; - // Artboards on the trim boxes have the bleed boxes round them as the document's bleed - // (the widest of the pages' on each side), so the art there shows and prints as bleed. - if opts.crop == CropTo::Trim { - for (side, b) in doc.setup.bleed.iter_mut().zip(crate::pages::bleed(page)) { - *side = side.max(b); - } - } - // The chosen box sits at (x, 0); the page draws round it. - let (init, frame) = crate::pages::frame(page, opts.crop); - let mut ab = Rect::new(x, 0.0, x + frame.width(), frame.height()); - let xf = Affine::translate((x - frame.x0, -frame.y0)) * init; - let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone()); - b.page = ab; - // The first id of this page's art. - let first_id = b.next; - let mut scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts, pdf.decode_limits().max_decoded_stream())?; - // The walk's ink names pick between spaces of inks that look the same to the interpreter. A - // page keeps them only when it uses such an ink and the interpreter draws as many paths - // there as the walk counts. - let tied = scan.inks.iter().any(|n| b.colors.tied(n)); - if !(tied && drawn_paths(page, Context::new(xf, ab, &cache, pdf.xref(), settings.clone())) == Some(scan.paints.len())) { - scan.inks.clear(); - } - if route { - let mut seen = HashSet::new(); - let marked: Vec = scan.tags.iter().filter_map(|(_, g)| g.map(|g| top_group(g, &ocgs))).filter(|g| seen.insert(*g)).collect(); - merge_order(&mut order, &mut known, &marked); - } - b.begin_page(scan); - craft_pdf::interpret_page(page, &mut ctx, &mut b).map_err(|e| PdfError::Parse(e.to_string()))?; - if incomplete.load(Ordering::Relaxed) { - return Err(crate::import_scan::incomplete_content()); - } - let mut parts = b.end_page(); - // A PDF-compatible `.ai` (the editor's private data next to the PDF art). - let ai = crate::pages::has_private_data(page); - if ai { - drop_page_fill(&mut parts, xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop))); - b.warn(OFF_ARTBOARD_NOTE); - } - // A file with several artboards writes, on each page, the art of its neighbours that - // reaches into the page's box: art lying wholly outside this page is theirs (each page - // draws it shifted by its own artboard spacing), so keep it only where it belongs. - if picked.len() > 1 { - for (_, art) in &mut parts { - art.retain(|n| n.visual_bounds().is_none_or(|r| r.x0 <= ab.x1 && r.x1 >= ab.x0 && r.y0 <= ab.y1 && r.y1 >= ab.y0)); - } - parts.retain(|(_, art)| !art.is_empty()); - } - // Viewers show a page through its crop box, so art reaching past it (a mock-up backdrop - // around the page, say) never showed. A clip to the page box that the file states is - // dropped as redundant (by hayro and by `push_clip_path`), so restore the box here: - // each object reaching past it keeps its own clip, which Fit to Artwork Bounds and - // `CropTo::Bounding` then respect. - // The crop box is what viewers show, whichever box the artboard takes. Not in a - // PDF-compatible `.ai`: its PDF part stands in for the editable document, whose art - // crossing the artboard edge isn't clipped, so it opens whole (#646). - let shown = xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop)); - let page_box = shown.inflate(0.01, 0.01); - let mut clipped = false; - for (_, art) in parts.iter_mut().filter(|_| !ai) { - for n in art.iter_mut() { - if n.visual_bounds().is_some_and(|r| !contains(page_box, r)) { - let clip = Arc::new(b.clip_node(&shown.to_path(0.1), FillRule::NonZero)); - *n = Arc::new(Node::new(b.id(), NodeKind::Group { children: vec![clip, n.clone()], clip: true })); - clipped = true; - } - } - } - if clipped { - b.warn(PAST_PAGE_NOTE); - } - // A `.ai` page keeps its art past the artboard edge, unclipped (#646), so a page whose art - // reaches far to the left would lay it over the page before it in the row: there it would - // be taken for that page's art (by the check against the editing data, which then finds - // art its layers don't have there, and by `place_pages`). Such a page moves right until its - // art clears the art before it. - let mut art_right = f64::NEG_INFINITY; - if ai && let Some(art) = parts.iter().flat_map(|(_, v)| v.iter()).filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) { - // Art at a coordinate that isn't a number (a damaged file) moves nothing. - let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0); - if dx > 0.0 { - let shift = Affine::translate((dx, 0.0)); - for (_, v) in &mut parts { - for n in v.iter_mut() { - Arc::make_mut(n).transform(shift, false); - } - } - // What the page's lines of type were is told where their glyphs are (the outlines - // stroked on them are looked for there), so that moves with them. - for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) { - for ink in &mut facts.ink { - *ink = *ink + kurbo::Vec2::new(dx, 0.0); - } - } - ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1); - } - art_right = art.x1 + dx; - } - let children: Vec> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); - only_text &= ai && is_only_text(&children); - art_boxes |= crate::pages::states_art(page); - let mut right = ab.x1; - if opts.crop == CropTo::Bounding - && let Some(art) = vectorcraft_doc::live::nodes_bounds(&children) - { - right = right.max(art.x1); - ab = art; - } - x = right + opts.artboard_gap; - clear = right.max(art_right) + opts.artboard_gap; - doc.artboards.push(Artboard { - id: i as u32 + 1, - name: format!("Artboard {}", i + 1), - rect: ab, - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }); - let page_layer = |b: &mut Builder<'_>, art: Vec>, shown: bool| { - let name = if shown { format!("Page {}", number + 1) } else { format!("Page {} (unsorted)", number + 1) }; - let mut layer = Node::layer(b.id(), &name, LayerColor::Preset((i % 27) as u8)); - layer.visible = shown; - if let NodeKind::Layer { children: c, printable, .. } = &mut layer.kind { - (*c, *printable) = (art, shown); - } - Slot::Page(Box::new(layer)) - }; - if parts.is_empty() { - slots.push(page_layer(&mut b, vec![], true)); - } - for (key, art) in parts { - match key { - None => slots.push(page_layer(&mut b, art, true)), - Some(UNSORTED) => slots.push(page_layer(&mut b, art, false)), - Some(g) => { - let into = group_art.entry(g).or_insert_with(|| { - place_group(g, &ocgs, &mut slots, &mut nesting); - vec![] - }); - into.extend(art); - } - } - } - } - if only_text && !art_boxes { - return Err(PdfError::PlaceholderOnly); - } - // A group the pages mark but draw nothing in is an empty layer, as the editor had it. - for &g in &order { - if !nesting.contains_key(&g) { - slots.push(Slot::Group(g)); - } - } - // The groups' layers stack as the pages mark them (the art outside them stays where it was - // drawn): a group that first paints on a later page doesn't go on top of the others. - let rank: HashMap = order.iter().enumerate().map(|(i, &g)| (g, i)).collect(); - let at: Vec = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect(); - let mut stacked: Vec = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect(); - stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX)); - for (i, g) in at.into_iter().zip(stacked) { - if let Some(s) = slots.get_mut(i) { - *s = Slot::Group(g); - } - } - for (n, slot) in slots.into_iter().enumerate() { - let mut layer = match slot { - Slot::Page(l) => *l, - // Sublayers take their top-level layer's colour. - Slot::Group(g) => match group_layer(&mut b, g, &ocgs, &nesting, &mut group_art, LayerColor::Preset((n % 27) as u8)) { - Some(l) => l, - None => continue, - }, - }; - if let Some(art) = layer.children_mut() { - crate::import_lines::rebuild(art, &b.lines); - } - doc.layers.push(Arc::new(layer)); - } - for (k, blob) in b.images.drain() { - doc.images.insert(k, blob); - } - // A file painted mostly in CMYK opens as a CMYK document (with CMYK default swatches). - if b.colors.cmyk_document() { - doc.color_mode = ColorMode::Cmyk; - let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); - (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); - } - doc.swatches.extend(b.colors.swatches()); - // Tiling patterns are pattern swatches. - for p in b.patterns.drain(..) { - doc.swatches.push(Swatch { - name: p.name.clone(), - paint: Paint::Pattern { pattern: p.name.clone(), xf: Affine::IDENTITY }, - global: false, - spot: false, - }); - doc.patterns.push(p); - } - if b.colors.mixed { - b.warn("colours mixing several inks (DeviceN) were imported as RGB"); - } - if !b.missing_fonts.is_empty() { - let list = b.missing_fonts.join(", "); - b.warn(&format!("fonts that aren't available show in the fallback font until they are: {list}")); - } - // Before anything walks the tree by recursion (`fix_next_id` does): nested clips can be deeper - // than the stack goes. - let mut depth_notes = vec![]; - doc.limit_depth_noting(&mut depth_notes); - doc.fix_next_id(); - doc.reserve_ids(b.next); - - let mut warnings = b.warnings; - if let Ok(v) = sink.lock() { - for w in v.iter() { - if !warnings.contains(w) { - warnings.push(w.clone()); - } - } - } - warnings.extend(depth_notes); - let user_unit = picked.first().and_then(|&n| pages.get(n)).map_or(1.0, |page| f64::from(page.user_unit())); - Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text, user_unit }) -} - -/// How many paths the interpreter draws on `page` (each fill and each stroke), leaving out those -/// of soft masks, pattern cells and type 3 glyphs, which [`Builder`] reads nested. `None` when the -/// interpreter fails. -fn drawn_paths<'a>(page: &'a Page<'a>, mut ctx: Context<'a>) -> Option { - struct Count(usize); - impl<'a> Device<'a> for Count { - fn set_soft_mask(&mut self, _: Option>) {} - fn set_blend_mode(&mut self, _: craft_pdf::interpret::BlendMode) {} - // As `Builder::draw_path` takes them: each fill and each stroke. - fn draw_path(&mut self, _: &BezPath, _: Affine, _: &craft_pdf::interpret::Paint<'a>, _: &PathDrawMode) { - self.0 += 1; - } - fn push_clip_path(&mut self, _: &ClipPath) {} - fn push_transparency_group(&mut self, _: f32, _: Option>, _: craft_pdf::interpret::BlendMode) {} - fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &craft_pdf::interpret::Paint<'a>, _: &GlyphDrawMode) {} - fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} - fn pop_clip_path(&mut self) {} - fn pop_transparency_group(&mut self) {} - } - let mut count = Count(0); - craft_pdf::interpret_page(page, &mut ctx, &mut count).ok()?; - Some(count.0) -} - -/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes -/// Hiragino): each glyph's CID from the font's charset, then Adobe's CID → Unicode table of its -/// character collection (Japan1, GB1, CNS1, Korea1). -pub(crate) struct CidText { - /// CID of each glyph. - cids: Vec, - table: craft_pdf::interpret::hayro_cmap::CMap, -} - -impl CidText { - pub(crate) fn of(data: &[u8]) -> Option { - // An embedded CFF of a few CJK glyphs is kilobytes; whole fonts are some megabytes. - if data.len() > 64 << 20 { - return None; - } - use craft_pdf::interpret::hayro_cmap::{CMap, CMapName, load_embedded}; - use skrifa::raw::ps::cff::{CffFontRef, dict, v1::Cff}; - use skrifa::raw::{FontData, FontRead}; - let cff = Cff::read(FontData::new(data)).ok()?; - let top = cff.top_dicts().get(0)?; - let ordering = dict::entries(top, None).filter_map(Result::ok).find_map(|e| match e { - dict::Entry::Ros { ordering, .. } => Some(ordering), - _ => None, - })?; - let name = match cff.string(ordering)? { - b"Japan1" => CMapName::AdobeJapan1Ucs2, - b"GB1" => CMapName::AdobeGb1Ucs2, - b"CNS1" => CMapName::AdobeCns1Ucs2, - b"Korea1" => CMapName::AdobeKorea1Ucs2, - _ => return None, - }; - let table = CMap::parse(load_embedded(name)?, load_embedded)?; - let charset = CffFontRef::new(data, 0, None).ok()?.charset()?; - let cids = (0..charset.num_glyphs().min(65_536)).map(|g| charset.string_id(skrifa::GlyphId::new(g)).map_or(0, |s| s.to_u16())).collect(); - Some(Self { cids, table }) - } - - fn unicode(&self, glyph: u32) -> Option { - let cid = *self.cids.get(glyph as usize)?; - self.table.lookup_bf_string(u32::from(cid)) - } -} - -/// A ToUnicode value that names no character: U+FFFD alone (#708). -fn replacement(u: &craft_pdf::interpret::hayro_cmap::BfString) -> bool { - use craft_pdf::interpret::hayro_cmap::BfString; - match u { - BfString::Char(c) => *c == char::REPLACEMENT_CHARACTER, - BfString::String(s) => !s.is_empty() && s.chars().all(|c| c == char::REPLACEMENT_CHARACTER), - } -} - -/// The character of each glyph of an embedded TrueType or OpenType font program, from its own -/// `cmap` (the first character mapped to each glyph); `None` for a bare CFF or a font without one. -pub(crate) fn font_chars(data: &[u8]) -> Option> { - use skrifa::MetadataProvider; - // As for CidText: whole fonts are some megabytes. - if data.len() > 64 << 20 { - return None; - } - let font = skrifa::FontRef::new(data).ok()?; - let mut chars = HashMap::new(); - for (c, g) in font.charmap().mappings() { - if let Some(c) = char::from_u32(c).filter(|c| !c.is_control()) { - chars.entry(g.to_u32()).or_insert(c); - } - } - (!chars.is_empty()).then_some(chars) -} - -/// The ideographs of the Kangxi radicals U+2F00–U+2FD5 (their NFKC forms): the CID → Unicode -/// tables of some PDFs map 龍 to the radical ⿓, which looks the same but isn't the character. -const KANGXI: &str = "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角言谷豆豕豸貝赤走足身車辛辰辵邑酉釆里金長門阜隶隹雨靑非面革韋韭音頁風飛食首香馬骨高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠"; - -/// How far glyph `o` (the PDF's, for `text`) is from the installed `face`'s glyph for it: the -/// largest difference between their boxes, in thousandths of an em. `None` when they are clearly -/// different glyphs (or the face lacks the character); `Some(0)` when there is nothing to compare -/// (several characters, a vertical form, no ink in either). -fn glyph_deviation(face: &vectorcraft_text::FontFace, text: &str, o: &craft_pdf::interpret::font::OutlineGlyph, vertical: bool) -> Option { - let Some(c) = comparable(text, vertical) else { return Some(0.0) }; - let gid = face.glyph_for(c); - if gid == 0 { - return None; - } - let embedded = o.outline(); - let installed = vectorcraft_text::FontDb::global().outline(face, gid); - match (embedded.elements().is_empty(), installed.elements().is_empty()) { - (true, true) => Some(0.0), - (false, false) => { - let e = embedded.bounding_box(); - let k = 1000.0 / face.units_per_em(); - // Installed outlines are y-down; the PDF's glyph space is y-up. - let i = installed.bounding_box(); - let i = Rect::new(i.x0 * k, -i.y1 * k, i.x1 * k, -i.y0 * k); - let off = [(e.x0, i.x0), (e.x1, i.x1), (e.y0, i.y0), (e.y1, i.y1)].iter().fold(0.0f64, |m, (a, b)| m.max((a - b).abs())); - (off <= 100.0).then_some(off) - } - _ => None, - } -} - -/// The face of `face`'s family that draws glyph `o` (for `text`): `face` itself, or the style whose -/// glyph is closest (a variable font's named instances, or a family whose PostScript names don't -/// tell its styles apart, draw a character differently in each style). `None`: no style of the -/// family has that glyph, so the font isn't the installed one. -fn matching_face( - face: &Arc, - text: &str, - o: &craft_pdf::interpret::font::OutlineGlyph, - vertical: bool, -) -> Option> { - let own = glyph_deviation(face, text, o, vertical); - if own == Some(0.0) { - return Some(face.clone()); - } - let db = vectorcraft_text::FontDb::global(); - // Other installed versions of its family and style (a font library keeps old ones), then the - // family's other styles. - let versions = db.versions(face).into_iter().skip(1).take(16); - let styles = - db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s)); - let others = versions.chain(styles); - let mut best = own.map(|d| (d, face.clone())); - for f in others { - if let Some(d) = glyph_deviation(&f, text, o, vertical) - && best.as_ref().is_none_or(|(b, _)| d < *b) - { - best = Some((d, f)); - } - } - best.map(|(_, f)| f) -} - -/// The characters a glyph no mapping names is looked for among (#811): those single-byte fonts -/// encode (ASCII, Latin-1, Latin Extended-A, general punctuation, the euro and trade mark signs, -/// the fi and fl ligatures). -const IDENTIFIABLE: [std::ops::RangeInclusive; 6] = - [0x21..=0x7E, 0xA1..=0x17F, 0x2010..=0x2044, 0x20AC..=0x20AC, 0x2122..=0x2122, 0xFB01..=0xFB02]; - -/// The character whose glyph in installed `face` draws `embedded` (a glyph outline in thousandths -/// of an em, y up): the first of [`IDENTIFIABLE`] with the same outline, point for point. A subset -/// font embedded without a character map still carries the font's own outlines, so a glyph whose -/// subset code names nothing is found this way. `None` when no glyph matches (another version of -/// the font, or a glyph that is no character), and for empty glyphs, which every space draws. -pub(crate) fn identify_glyph(face: &vectorcraft_text::FontFace, embedded: &BezPath) -> Option { - if embedded.elements().is_empty() { - return None; - } - let k = 1000.0 / face.units_per_em(); - // Installed outlines are y-down in font units. - let to_pdf = Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]); - let db = vectorcraft_text::FontDb::global(); - IDENTIFIABLE.iter().cloned().flatten().filter_map(char::from_u32).find(|c| { - let gid = face.glyph_for(*c); - gid != 0 && same_shape(embedded, &(to_pdf * db.outline(face, gid).as_ref().clone())) - }) -} - -/// Do outlines `a` and `b` draw the same segments, every point within 2/1000 em (closing -/// commands aside)? -fn same_shape(a: &BezPath, b: &BezPath) -> bool { - let drawn = |p: &BezPath| p.elements().iter().filter(|e| !matches!(e, PathEl::ClosePath)).copied().collect::>(); - let (a, b) = (drawn(a), drawn(b)); - let near = |p: kurbo::Point, q: kurbo::Point| (p - q).hypot() <= 2.0; - a.len() == b.len() - && a.iter().zip(&b).all(|(x, y)| match (x, y) { - (PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(*p, *q), - (PathEl::QuadTo(p1, p2), PathEl::QuadTo(q1, q2)) => near(*p1, *q1) && near(*p2, *q2), - (PathEl::CurveTo(p1, p2, p3), PathEl::CurveTo(q1, q2, q3)) => near(*p1, *q1) && near(*p2, *q2) && near(*p3, *q3), - _ => false, - }) -} - -/// The character of glyph `text` when its outline can be compared with an installed font's: a -/// single character, and not CJK punctuation set vertically (its vertical form differs). -fn comparable(text: &str, vertical: bool) -> Option { - let mut chars = text.chars(); - let (Some(c), None) = (chars.next(), chars.next()) else { return None }; - if vertical && matches!(c as u32, 0x2014 | 0x2015 | 0x2025 | 0x2026 | 0x3000..=0x303F | 0x30FC | 0xFE30..=0xFE4F | 0xFF00..=0xFF65) { - return None; - } - Some(c) -} - -pub(crate) fn unify_radical(c: char) -> char { - let i = (c as u32).wrapping_sub(0x2F00); - if i < 214 { KANGXI.chars().nth(i as usize).unwrap_or(c) } else { c } -} - -/// The font a PDF font draws with ([`Builder::font_name`]). -#[derive(Clone)] -struct FontInfo { - family: String, - style: String, - /// The installed face of its PostScript name. - face: Option>, -} - -/// The page a PDF-compatible `.ai` paints under its layers is the editor's page, not art: an opaque -/// white rectangle the size of the page (`page`, document space), outside every layer and before -/// any of them. Drop it from the page's art (`parts`, as [`Builder::end_page`] gives them). -fn drop_page_fill(parts: &mut Vec<(Option, Vec>)>, page: Rect) { - let layered = parts.iter().any(|(g, _)| g.is_some()); - let Some((None, art)) = parts.first_mut() else { return }; - // Not a spot colour: a white ink is art. - let fill = art.first().is_some_and(|n| { - layered - && n.blend == BlendMode::Normal - && white_cover(n, page) - && n.appearance.fill().is_some_and(|f| matches!(f.paint, Paint::Solid { swatch: None, .. })) - && n.geometric_bounds().is_some_and(|b| contains(page.inflate(0.5, 0.5), b)) - }); - if fill { - art.remove(0); - if art.is_empty() { - parts.remove(0); - } - } -} - -/// Is this art text and nothing else (in groups and clips), with some text? -fn is_only_text(nodes: &[Arc]) -> bool { - fn walk(nodes: &[Arc], text: &mut bool) -> bool { - nodes.iter().all(|n| match &n.kind { - NodeKind::Path { clipping: true, .. } => true, - NodeKind::Path { .. } if n.name.as_deref() == Some(TEXT_OUTLINES) => { - *text = true; - true - } - NodeKind::Text(_) => { - *text = true; - true - } - NodeKind::Group { children, .. } => walk(children, text), - _ => false, - }) - } - let mut text = false; - walk(nodes, &mut text) && text -} - -/// Decoded RGBA pixels, width, height and hayro's scale factors. -type Decoded = (Vec, u32, u32, (f32, f32)); - -enum FrameKind { - Root, - /// A clip group whose clip path node is stored here. - Clip(Box), - /// A clip that was redundant (covers the page); children go straight to the parent. - Skip, - Group { - opacity: f32, - blend: BlendMode, - mask: Option>, - isolate: bool, - knockout: bool, - }, -} - -struct Frame { - kind: FrameKind, - children: Vec>, - /// The first optional content group entered inside (where the frame's art goes). - group: Option, - /// The group each child was drawn in, where the frame's art goes when the frame dissolves - /// into its parent (a layer's form drawing its sublayers' forms). - child_groups: Vec>, -} - -impl Frame { - fn new(kind: FrameKind) -> Self { - Self { kind, children: vec![], group: None, child_groups: vec![] } - } -} - -/// A dissolving frame's `children`, each with the group it was drawn in (`groups`, else the -/// frame's `group`). -fn grouped(children: Vec>, groups: Vec>, group: Option) -> impl Iterator, Option)> { - children.into_iter().zip(groups.into_iter().map(move |g| g.or(group)).chain(std::iter::repeat(group))) -} - -/// Glyphs drawn consecutively with the same paint, merged into one path. -struct GlyphRun { - path: BezPath, - paint: Paint, - opacity: f32, - stroke: Option, - scale: f64, - overprint: bool, -} - -/// How often a tiling pattern was read, and the art (as its paints) each reading drew with the -/// swatch made for it. -type PatternReadings = (u32, Vec<(Vec, String)>); - -/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back. -struct Saved { - stack: Vec, - root_groups: Vec>, - blend: BlendMode, - mask: Option>, - page: Rect, - pending: bool, -} - -struct Builder<'p> { - colors: Colors<'p>, - next: u64, - stack: Vec, - /// The optional content group of each child of the root frame. - root_groups: Vec>, - blend: BlendMode, - page: Rect, - glyphs: Option, - text: Option, - text_as: TextAs, - images: HashMap, - image_keys: HashMap, - /// The images kept with their CMYK samples ([`crate::import_image`]). - cmyk_keys: HashSet, - /// Mask keys of CMYK images whose mask turned out to hide nothing. - opaque: HashSet, - warnings: Vec, - /// Fonts by cache key → base font name and whether the file carries its glyphs (from - /// [`scan_page`]). - fonts: FontNames, - font_names: HashMap, - families: Option, - missing_fonts: Vec, - /// Font (cache key) → its characters by glyph, for CID-keyed fonts embedded without a - /// ToUnicode map. - cid_text: HashMap>>, - /// Font (cache key) → the character of each glyph its embedded font program maps - /// ([`font_chars`]), for glyphs the file's ToUnicode map doesn't name (#708). - font_chars: HashMap>>>, - /// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph - /// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines. - matched: HashMap>>, - /// (Font, glyph) → the character its outline matches in the installed face, for glyphs no - /// mapping names ([`Builder::identified_char`]). - identified: HashMap<(u128, u32), Option>, - /// Per font: the installed version its type names, when it isn't the one its family and style - /// resolve to (see `CharStyle::font_version`). - versions: HashMap>, - /// The soft mask of the graphics state, the art drawn through it so far, and the masks read. - mask: Option>, - masked: Vec>, - masks: HashMap>, - /// Pattern swatches made, and per (pattern, stroke) the art signatures read and their names. - patterns: Vec, - pattern_keys: HashMap<(u128, bool), PatternReadings>, - /// Per function-based shading (and the inks it paints with): what it comes in as, read once. - function_paints: HashMap<(u128, Option>), Paint>, - /// What is left of the import's function evaluations for those shadings. - function_budget: FunctionBudget, - /// Swatch names in use. - taken: Vec, - /// The page as walked by [`scan_page`], and how far the interpreter is through it. - scan: Scan, - tag_at: usize, - group_at: usize, - /// The fills and strokes of paths drawn so far ([`Scan::paints`]). - paint_at: usize, - aligned: bool, - /// Whether optional content groups become layers. - route: bool, - /// Some groups are hidden or don't print: art in marked content whose group can't be told - /// (once the scan and the interpreter part) is [`UNSORTED`] rather than the page's. - unsure: bool, - /// The marked-content sequences open: the group each marks. - marked: Vec>, - /// A transparency group was just pushed: a form's (whose flags come next) or an image's. - pending: bool, - nested: u32, - /// Reading an alpha soft mask's art: images are read white with their own alpha, so the - /// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape, - /// rasterizes masks into such images). - white_images: bool, - /// The last filled path: its node and its outline (a stroke of the same outline joins it). - last_fill: Option<(NodeId, BezPath)>, - /// What each line of type made tells beyond its text object ([`crate::import_lines`]). - lines: HashMap, -} - -fn blend(b: craft_pdf::interpret::BlendMode) -> BlendMode { - use craft_pdf::interpret::BlendMode as H; - match b { - H::Normal => BlendMode::Normal, - H::Multiply => BlendMode::Multiply, - H::Screen => BlendMode::Screen, - H::Overlay => BlendMode::Overlay, - H::Darken => BlendMode::Darken, - H::Lighten => BlendMode::Lighten, - H::ColorDodge => BlendMode::ColorDodge, - H::ColorBurn => BlendMode::ColorBurn, - H::HardLight => BlendMode::HardLight, - H::SoftLight => BlendMode::SoftLight, - H::Difference => BlendMode::Difference, - H::Exclusion => BlendMode::Exclusion, - H::Hue => BlendMode::Hue, - H::Saturation => BlendMode::Saturation, - H::Color => BlendMode::Color, - H::Luminosity => BlendMode::Luminosity, - } -} - -fn fill_rule(r: craft_pdf::interpret::FillRule) -> FillRule { - match r { - craft_pdf::interpret::FillRule::NonZero => FillRule::NonZero, - craft_pdf::interpret::FillRule::EvenOdd => FillRule::EvenOdd, - } -} - -/// `p` with every subpath closed: how a fill reads it. -pub(crate) fn closed_subpaths(p: &BezPath) -> BezPath { - let mut out = BezPath::new(); - let mut open = false; - for el in p.elements() { - if open && matches!(el, PathEl::MoveTo(_)) { - out.push(PathEl::ClosePath); - } - out.push(*el); - open = !matches!(el, PathEl::ClosePath); - } - if open { - out.push(PathEl::ClosePath); - } - out -} - -/// Do a fill of `fill` and a stroke of `stroke` paint one object's outline? Applications write the -/// fill without closing the path (filling closes it) and the stroke closed. -fn same_outline(fill: &BezPath, stroke: &BezPath) -> bool { - fill.elements() == stroke.elements() || closed_subpaths(fill).elements() == closed_subpaths(stroke).elements() -} - -fn mean_scale(a: Affine) -> f64 { - a.determinant().abs().sqrt() -} - -/// Width a PDF line width of 0 imports as, points: a print hairline. In PDF `0 w` is the thinnest -/// line the device can draw (ISO 32000-1, 8.4.3.2), not no line. -const HAIRLINE: f64 = 0.25; - -/// Document width of the stroke `p` drawn at `scale`: `0 w` becomes a [`HAIRLINE`]. -fn line_width(p: &StrokeProps, scale: f64) -> f64 { - if p.line_width == 0.0 { HAIRLINE } else { p.line_width as f64 * scale } -} - -fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer { - let mut st = StrokeLayer::new(paint, line_width(p, scale)); - st.opacity = opacity; - st.cap = match p.line_cap { - kurbo::Cap::Butt => LineCap::Butt, - kurbo::Cap::Round => LineCap::Round, - kurbo::Cap::Square => LineCap::Square, - }; - st.join = match p.line_join { - kurbo::Join::Miter => LineJoin::Miter, - kurbo::Join::Round => LineJoin::Round, - kurbo::Join::Bevel => LineJoin::Bevel, - }; - st.miter_limit = p.miter_limit as f64; - // (Every stroke keeps its own copy: a dash array has a few entries, and one with thousands is cut.) - let dash = Dash { - pattern: p.dash_array.iter().take(MAX_DASH_ENTRIES).map(|v| *v as f64 * scale).collect(), - offset: p.dash_offset as f64 * scale, - align_corners: false, - }; - // An invalid dash array (a negative value, or all zeros) strokes solid. - st.dash = dash.is_dashed().then_some(dash); - st -} - -/// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). -const MAX_DASH_ENTRIES: usize = 64; - -pub(crate) fn round3(v: f32) -> f32 { - (v * 1000.0).round() / 1000.0 -} - -/// The paints of `nodes`' leaves, in order: what tells two readings of a pattern cell apart. -fn paints(nodes: &[Arc]) -> Vec { - let mut out = vec![]; - for n in nodes { - n.walk(&mut |c| out.extend(c.appearance.items.iter().map(|i| i.paint().clone()))); - } - out -} - -/// Visual bounds of `nodes`; `None` when one has none. -fn bounds(nodes: &[Arc]) -> Option { - nodes.iter().try_fold(None, |acc, n| n.visual_bounds().map(|b| Some(acc.map_or(b, |a: Rect| a.union(b)))))? -} - -impl<'p> Builder<'p> { - fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self { - Self { - colors, - next, - stack: vec![], - root_groups: vec![], - blend: BlendMode::Normal, - page: Rect::ZERO, - glyphs: None, - text: None, - text_as, - images: HashMap::new(), - image_keys: HashMap::new(), - cmyk_keys: HashSet::new(), - opaque: HashSet::new(), - warnings: vec![], - fonts: HashMap::new(), - font_names: HashMap::new(), - families: None, - missing_fonts: vec![], - cid_text: HashMap::new(), - font_chars: HashMap::new(), - matched: HashMap::new(), - identified: HashMap::new(), - versions: HashMap::new(), - mask: None, - masked: vec![], - masks: HashMap::new(), - patterns: vec![], - pattern_keys: HashMap::new(), - function_paints: HashMap::new(), - function_budget: FunctionBudget::new(), - taken: vec![], - scan: Scan::default(), - tag_at: 0, - group_at: 0, - paint_at: 0, - aligned: true, - route, - unsure: false, - marked: vec![], - pending: false, - nested: 0, - white_images: false, - last_fill: None, - lines: HashMap::new(), - } - } - - fn id(&mut self) -> NodeId { - let id = NodeId(self.next); - self.next += 1; - id - } - - /// A deep copy of `node` with fresh ids. - fn reid(&mut self, node: &Node) -> Node { - let mut n = node.clone(); - n.id = self.id(); - if let Some(ch) = n.children_mut() { - let old = std::mem::take(ch); - *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); - } - n - } - - /// `m` with fresh ids for its art: a mask read once can mask several objects. - fn fresh_mask(&mut self, mut m: OpacityMask) -> OpacityMask { - m.art = Arc::new(self.reid(&m.art)); - m - } - - fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) { - self.warnings.push(w.to_string()); - } - } - - fn begin_page(&mut self, scan: Scan) { - self.stack = vec![Frame::new(FrameKind::Root)]; - self.root_groups.clear(); - self.blend = BlendMode::Normal; - self.glyphs = None; - self.text = None; - self.mask = None; - self.masked.clear(); - self.marked.clear(); - self.pending = false; - (self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0); - if self.scan.overprints && self.scan.zero_mode { - self.warn(OVERPRINT_ZERO_MODE); - } - } - - /// Whether the path the interpreter draws now (a fill or a stroke, each counted) overprints. It - /// takes the path's turn in the page's walk. - fn path_overprint(&mut self) -> bool { - if self.nested > 0 { - return false; - } - self.paint_at += 1; - self.scan.overprints && self.scan.paints.get(self.paint_at - 1).copied().unwrap_or(false) - } - - /// What the interpreter draws now, as the page's walk counts it: the path whose turn comes - /// next, or the `text` before it. `None` in a nested reading, which the walk doesn't follow. - fn walked(&self, text: bool) -> Option { - (self.nested == 0).then_some(if text { Walked::Text(self.paint_at) } else { Walked::Path(self.paint_at) }) - } - - /// Whether the glyph the interpreter draws now (its fill, or its `stroke`) overprints. - fn glyph_overprint(&mut self, stroke: bool) -> bool { - if self.nested > 0 || !self.scan.overprints { - return false; - } - match self.scan.texts.get(self.paint_at) { - Some(TextOverprint::All { fill, stroke: s }) => *if stroke { s } else { fill }, - Some(TextOverprint::Mixed) => { - self.warn("some text is set to overprint in a way that can't be read: it knocks out"); - false - } - _ => false, - } - } - - /// The page's art, split by optional content group (in the order they first paint). - fn end_page(&mut self) -> Vec<(Option, Vec>)> { - let mut children = self.close_frames(); - // The interpreter drew another number of paths than the file was read to paint: which - // of them overprint can't be trusted. - if self.scan.overprints && self.paint_at != self.scan.paints.len() { - children.iter_mut().for_each(vectorcraft_doc::overprint::clear_overprints); - self.warn(OVERPRINT_UNREAD); - } - let groups = std::mem::take(&mut self.root_groups); - let mut parts: Vec<(Option, Vec>)> = vec![]; - // Thousands of groups: find each one's part by index. - let mut at: HashMap, usize> = HashMap::new(); - for (n, g) in children.into_iter().zip(groups.into_iter().chain(std::iter::repeat(None))) { - let i = *at.entry(g).or_insert_with(|| { - parts.push((g, vec![])); - parts.len() - 1 - }); - if let Some((_, v)) = parts.get_mut(i) { - v.push(n); - } - } - parts - } - - /// Finish the open runs and frames → the root frame's children. - fn close_frames(&mut self) -> Vec> { - self.flush(); - while self.stack.len() > 1 { - self.pop_frame(); - } - self.stack.pop().map(|f| f.children).unwrap_or_default() - } - - /// The group art drawn now belongs to (the innermost marked as one). - fn current_group(&self) -> Option { - self.marked.iter().rev().find_map(|g| *g) - } - - /// Add `n` to the open frame; `group`: the optional content group it was drawn in, if it - /// knows one (else the current one). - fn emit(&mut self, n: Arc, group: Option) { - let root = self.stack.len() == 1; - let current = self.current_group(); - let Some(f) = self.stack.last_mut() else { return }; - f.children.push(n); - // A frame's art goes where its first art was drawn (a clip can end in a later group). - if root { - self.root_groups.push(group.or(current)); - } else { - f.child_groups.push(group.or(current)); - if f.group.is_none() { - f.group = group.or(current); - } - } - } - - fn push_node(&mut self, mut n: Node) { - if self.blend != BlendMode::Normal && !n.is_container() { - n.blend = self.blend; - } - if self.mask.is_some() { - self.masked.push(Arc::new(n)); - } else { - self.emit(Arc::new(n), None); - } - } - - /// Finish the open glyph runs and the art drawn through the current soft mask. - fn flush(&mut self) { - self.flush_glyphs(); - self.flush_text(); - self.flush_masked(); - } - - /// The art drawn through the graphics state's soft mask so far, as one masked object. - fn flush_masked(&mut self) { - if self.masked.is_empty() { - return; - } - let art = std::mem::take(&mut self.masked); - let Some(spec) = self.mask.clone() else { - art.into_iter().for_each(|n| self.emit(n, None)); - return; - }; - match spec.as_ref() { - // Each object is drawn through the mask on its own. - MaskSpec::Opacity(a) => { - for mut n in art { - Arc::make_mut(&mut n).opacity *= a; - self.emit(n, None); - } - } - MaskSpec::Mask(m) => { - let m = self.fresh_mask(m.clone()); - let node = match <[Arc; 1]>::try_from(art) { - Ok([one]) if one.extras().mask.is_none() => { - fold_stop_opacity(&one, &m).unwrap_or_else(|| Arc::unwrap_or_clone(one).with_mask(Some(Box::new(m)))) - } - Ok([one]) => { - let mut g = Node::group(self.id(), vec![one]); - g.extras_mut().mask = Some(Box::new(m)); - g - } - Err(all) => { - let mut g = Node::group(self.id(), all); - g.extras_mut().mask = Some(Box::new(m)); - g - } - }; - self.emit(Arc::new(node), None); - } - } - } - - /// A clip path node. - fn clip_node(&mut self, path: &BezPath, rule: FillRule) -> Node { - let mut clip = Node::new(self.id(), NodeKind::Path { path: PathData::from_bezpath(path), rule, live: None, clipping: true, guide: false }); - clip.name = Some("".into()); - clip - } - - fn pop_frame(&mut self) { - if self.stack.len() <= 1 { - return; - } - let Some(f) = self.stack.pop() else { return }; - let group = f.group; - let node = match f.kind { - FrameKind::Root => None, - FrameKind::Skip => { - grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); - None - } - FrameKind::Clip(clip) => { - let noop = clip.path_data().map(|p| p.to_bezpath()).filter(is_rectangle).map(|p| p.bounding_box().inflate(0.01, 0.01)); - if f.children.is_empty() { - None - } else if noop.is_some_and(|r| bounds(&f.children).is_some_and(|b| contains(r, b))) { - // A rectangle around all of its art (a form's box) clips nothing. - grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); - None - } else if let Some(shape) = gradient_shape(&clip, &f.children) { - // One path, so tools such as the Shape Builder see the shape, not its box (#1304). - Some(shape) - } else { - let mut ch = vec![Arc::new(*clip)]; - ch.extend(f.children); - Some(Node::new(self.id(), NodeKind::Group { children: ch, clip: true })) - } - } - FrameKind::Group { opacity, blend, mask, isolate, knockout } => { - let children = f.children; - // A constant alpha mask is opacity (of a group that isn't isolated). - let (opacity, mask) = match mask.as_deref() { - Some(MaskSpec::Opacity(a)) => (opacity * a, None), - Some(MaskSpec::Mask(m)) => (opacity, Some(m.clone())), - None => (opacity, None), - }; - // Isolation only shows with blending inside; knockout with several objects. - let isolate = isolate && Node::children_blend(&children, &mut |c| c.blends_through()); - let knockout = knockout && children.len() > 1; - let plain = opacity >= 0.999 && blend == BlendMode::Normal && mask.is_none() && !isolate && !knockout; - if children.is_empty() { - None - } else if plain { - grouped(children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); - None - } else if let [only] = children.as_slice() - && !only.is_container() - && !isolate - && !knockout - && (mask.is_none() || only.extras().mask.is_none()) - { - // A single object in a group: fold opacity, blend and mask into the object. - let mut only = (**only).clone(); - only.opacity *= opacity; - if blend != BlendMode::Normal { - only.blend = blend; - } - if let Some(m) = mask { - let m = self.fresh_mask(m); - only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m)))); - } - Some(only) - } else { - let mut g = Node::group(self.id(), children); - (g.opacity, g.blend, g.isolate) = (opacity, blend, isolate); - if knockout { - g.knockout = Knockout::On; - } - g.extras_mut().mask = mask.map(|m| Box::new(self.fresh_mask(m))); - Some(g) - } - } - }; - if let Some(n) = node { - self.emit(Arc::new(n), group); - } - } - - fn flush_glyphs(&mut self) { - let Some(run) = self.glyphs.take() else { return }; - if run.path.elements().is_empty() { - return; - } - let mut ap = Appearance::default(); - match &run.stroke { - None => { - let mut f = FillLayer::new(run.paint); - (f.opacity, f.overprint) = (run.opacity, run.overprint); - ap.items.push(AppearanceItem::Fill(f)); - } - Some(p) => { - let mut st = stroke_layer(run.paint, run.opacity, p, run.scale); - st.overprint = run.overprint; - ap.items.push(AppearanceItem::Stroke(st)); - } - } - let id = self.id(); - let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap); - n.name = Some(TEXT_OUTLINES.into()); - self.push_node(n); - } - - fn flush_text(&mut self) { - let Some(mut line) = self.text.take() else { return }; - let held = std::mem::take(&mut line.held); - if let Some((t, opacity, facts)) = line.finish() { - let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); - n.opacity = opacity; - self.lines.insert(n.id, facts); - self.push_node(n); - } - // The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke). - for n in held { - self.push_node(n); - } - } - - /// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph - /// before it on the open line was stroked so too, is held with the line instead of ending it - /// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the - /// type). `false`: it isn't one. `walked`: the path in the page's walk. - fn hold_glyph_stroke( - &mut self, - path: &BezPath, - transform: Affine, - paint: &craft_pdf::interpret::Paint<'_>, - draw_mode: &PathDrawMode, - walked: Option, - ) -> bool { - let PathDrawMode::Stroke(props) = draw_mode else { return false }; - if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, craft_pdf::interpret::Paint::Pattern(_)) { - return false; - } - let (p, opacity) = self.paint(paint, true, walked); - let Some(line) = &self.text else { return false }; - let Some(ink) = line.ink.last() else { return false }; - if line.held.len() + 1 != line.ink.len() || opacity < 0.999 || !line.last_fill().is_some_and(|f| *f == p) { - return false; - } - let mut bp = path.clone(); - bp.apply_affine(transform); - let b = bp.bounding_box(); - let tol = 0.01 * (ink.width() + ink.height()) + 0.01; - if ![(b.x0, ink.x0), (b.y0, ink.y0), (b.x1, ink.x1), (b.y1, ink.y1)].iter().all(|(a, c)| (a - c).abs() <= tol) { - return false; - } - let mut st = stroke_layer(p, opacity, props, mean_scale(transform)); - // Every painted path takes its turn in the scan's overprint list, a held one too. - st.overprint = self.path_overprint(); - let n = Node::path(self.id(), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); - if let Some(line) = &mut self.text { - line.held.push(n); - } - true - } - - /// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke; `walked`: - /// what it paints in the page's walk, which names its ink). - fn paint(&mut self, p: &craft_pdf::interpret::Paint<'_>, stroke: bool, walked: Option) -> (Paint, f32) { - let pattern = matches!(p, craft_pdf::interpret::Paint::Pattern(_)); - let inks = walked.and_then(|w| self.scan.ink(w, stroke, pattern)); - match p { - craft_pdf::interpret::Paint::Color(c) => { - let [r, g, b, a] = c.to_rgba().components(); - let paint = self.colors.solid(c, inks).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint); - (paint, a) - } - craft_pdf::interpret::Paint::Pattern(pat) => match pat.as_ref() { - Pattern::Shading(sp) => { - // The ink names outlive the walk's borrow of `self`. - let inks = inks.map(<[String]>::to_vec); - match self.function_shading(sp, inks.as_deref()) { - Some(paint) => (paint, sp.opacity), - None => match shading_gradient(sp, &mut self.colors, inks.as_deref()) { - Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity), - None => { - self.warn("shadings that can't be read as a gradient or gradient mesh (such as very large meshes) are imported as a flat grey"); - (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity) - } - }, - } - } - Pattern::Tiling(t) => match self.tiling(t, stroke) { - Some(paint) => (paint, 1.0), - None => { - self.warn("tiling patterns nested too deeply are imported as a flat grey"); - (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0) - } - }, - }, - } - } - - /// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the - /// coordinates `page` has. `None` when nested too deeply. - fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option>> { - if self.nested >= MAX_NESTED { - return None; - } - self.flush(); - let saved = Saved { - stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]), - root_groups: std::mem::take(&mut self.root_groups), - blend: std::mem::replace(&mut self.blend, BlendMode::Normal), - mask: self.mask.take(), - page: std::mem::replace(&mut self.page, page), - pending: std::mem::take(&mut self.pending), - }; - self.nested += 1; - read(self); - let art = self.close_frames(); - self.nested -= 1; - (self.stack, self.root_groups, self.blend, self.mask, self.page, self.pending) = - (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.pending); - Some(art) - } - - /// What soft mask `m` does (read once per mask and placement). - fn soft_mask(&mut self, m: &SoftMask<'_>) -> Arc { - let key = m.cache_key(); - if let Some(s) = self.masks.get(&key) { - return s.clone(); - } - let page = self.page; - let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha); - let art = self.nested(page, |b| m.interpret(b)); - self.white_images = saved; - let spec = match art { - Some(art) => { - let [r, g, b, _] = m.background_color().to_rgba().components(); - let backdrop = luminance(&Color::rgb(r, g, b)); - let inverted = m.transfer_function().is_some_and(|tf| { - let mut ends = [0, 255]; - tf.apply_to(&mut ends); - ends[0] > ends[1] - }); - let id = self.id(); - mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all)) - } - None => { - self.warn("soft masks nested too deeply were left out (content drawn unmasked)"); - MaskSpec::Opacity(1.0) - } - }; - let spec = Arc::new(spec); - self.masks.insert(key, spec.clone()); - spec - } - - /// Function-based shading `sp` (painting with `inks`) as a paint, read the first time it is - /// used and within the import's budget of function evaluations; `None` for other shadings. - fn function_shading(&mut self, sp: &craft_pdf::interpret::pattern::ShadingPattern, inks: Option<&[String]>) -> Option { - let key = (sp.cache_key(), inks.map(<[String]>::to_vec)); - if let Some(paint) = self.function_paints.get(&key) { - return Some(paint.clone()); - } - let paint = match function_paint(sp, &mut self.colors, inks, &self.function_budget)? { - FunctionPaint::Gradient(g) => Paint::Gradient(g), - FunctionPaint::Average(c) => { - self.warn( - "function-based shadings that aren't read as a gradient (their colour changes in two directions, they need too many stops, or the file has too many to read) are imported as their average colour", - ); - Paint::solid(c) - } - FunctionPaint::Unread => { - self.warn("function-based shadings that can't be read (their function or domain is invalid, or the file has too many to read) are imported as a flat grey"); - Paint::solid(Color::rgb(0.5, 0.5, 0.5)) - } - }; - self.function_paints.insert(key, paint.clone()); - Some(paint) - } - - /// Tiling pattern `t` as a pattern swatch's paint (the swatch made the first time). - fn tiling(&mut self, t: &TilingPattern<'_>, stroke: bool) -> Option { - let key = (t.cache_key(), stroke); - let tile = Rect::from_origin_size(t.bbox.origin(), (t.x_step.abs().max(1e-3) as f64, t.y_step.abs().max(1e-3) as f64)); - let xf = t.matrix * Affine::translate(tile.origin().to_vec2()); - // An uncoloured pattern draws in the current colour: read it again unless it always - // drew the same. - if let Some((n, seen)) = self.pattern_keys.get(&key) - && *n >= PATTERN_REUSE - && let [(_, name)] = seen.as_slice() - { - return Some(Paint::Pattern { pattern: name.clone(), xf }); - } - // A swatch is shared with uses outside an alpha mask, and pattern paints keep their - // colours there: its images are read as they are. - let white = std::mem::take(&mut self.white_images); - let art = self.nested(Rect::ZERO, |b| { - // A cell whose content can't be decoded is empty. - let _ = t.interpret(b, Affine::IDENTITY, stroke); - }); - self.white_images = white; - let art = art?; - let sig = paints(&art); - let entry = self.pattern_keys.entry(key).or_default(); - entry.0 += 1; - let name = match entry.1.iter().find(|(s, _)| *s == sig) { - Some((_, name)) => name.clone(), - None => { - let name = (self.patterns.len() + 1..).map(|i| format!("Pattern {i}")).find(|n| !self.taken.contains(n)).unwrap_or_default(); - self.taken.push(name.clone()); - let mut def = PatternDef::new(&name, art); - def.tile = tile; - self.patterns.push(def); - if let Some(e) = self.pattern_keys.get_mut(&key) { - e.1.push((sig, name.clone())); - } - name - } - }; - Some(Paint::Pattern { pattern: name, xf }) - } - - fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) { - self.add_image_masked(key, make, xf, None); - } - - /// An image's alpha (its soft mask, stencil mask or colour key, at its own resolution) as a - /// greyscale image over the image's `w` × `h` pixels (`transform`): its opacity mask. `None` - /// when it masks nothing. - fn alpha_mask(&mut self, key: u128, r: &craft_pdf::interpret::RasterImage<'_>, w: u32, h: u32, transform: Affine) -> Option { - let mkey = key ^ 0x5_3a5c; - if self.opaque.contains(&mkey) { - return None; - } - let (k, mw, mh) = if let Some(v) = self.image_keys.get(&mkey).cloned() { - v - } else { - let mut alpha = None; - r.with_rgba(|_, a| alpha = a, None); - let Some(a) = alpha.filter(|a| a.data.iter().any(|v| *v < 255)) else { - self.opaque.insert(mkey); - return None; - }; - let mut png = Vec::new(); - image::GrayImage::from_raw(a.width, a.height, a.data)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - let blob = ImageBlob::new("image/png", png); - let k = blob.content_key(); - self.images.insert(k.clone(), blob); - self.image_keys.insert(mkey, (k.clone(), a.width, a.height)); - (k, a.width, a.height) - }; - let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1))); - let art = Node::new( - self.id(), - NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default(), pixels: None })), - ); - Some(OpacityMask::new(art, true)) - } - - fn add_image_masked(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine, mask: Option) { - let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() { - v - } else { - let Some((blob, w, h)) = make() else { - self.warn("an image could not be decoded and was skipped"); - return; - }; - // Content keys: placing this page into another document merges its images safely. - let k = blob.content_key(); - self.images.insert(k.clone(), blob); - self.image_keys.insert(key, (k.clone(), w, h)); - (k, w, h) - }; - let id = self.id(); - let mut n = Node::new( - id, - NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), - ); - n.extras_mut().mask = mask.map(Box::new); - self.push_node(n); - } - - /// Mesh shadings filling `region` (document space): gradient meshes, clipped to the region - /// when they reach past it. - fn draw_meshes(&mut self, meshes: Vec, region: &BezPath, opacity: f32) { - let nodes: Vec> = meshes.into_iter().map(|m| Arc::new(Node::new(self.id(), NodeKind::Mesh(m)))).collect(); - let Some(art) = bounds(&nodes) else { return }; - let mut content = match <[Arc; 1]>::try_from(nodes) { - Ok([one]) => Arc::unwrap_or_clone(one), - Err(all) => Node::group(self.id(), all), - }; - content.opacity *= opacity; - if !contains(region.bounding_box().inflate(0.5, 0.5), art) { - let clip = self.clip_node(region, FillRule::NonZero); - content = clipped(clip, content, self.id()); - } - self.push_node(content); - } - - /// The family and style of font `key` (drawing glyph `o`): the installed face of its PostScript - /// name, else the available family its name reads as; whether it is available. - /// The character of glyph `glyph` of font `key` that no mapping names, found by its outline in - /// the installed face of the font's name ([`identify_glyph`]); cached per glyph. - fn identified_char(&mut self, key: u128, glyph: u32, o: &craft_pdf::interpret::font::OutlineGlyph) -> Option { - if let Some(c) = self.identified.get(&(key, glyph)) { - return *c; - } - let c = self.font_name(key, o).face.and_then(|f| identify_glyph(&f, &o.outline())); - self.identified.insert((key, glyph), c); - c - } - - fn font_name(&mut self, key: u128, o: &craft_pdf::interpret::font::OutlineGlyph) -> FontInfo { - if let Some(n) = self.font_names.get(&key) { - return n.clone(); - } - let data = o.font_data(); - let name = self.fonts.get(&key).map(|f| f.name.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); - let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic)); - // A subset's six-letter tag. - let ps = name.as_deref().map(|n| match n.split_once('+') { - Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest.to_string(), - _ => n.to_string(), - }); - let installed = ps.as_deref().and_then(|ps| vectorcraft_text::FontDb::global().find_postscript(ps)); - let n = match (installed, name) { - (Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), face: Some(face) }, - (None, Some(name)) => { - let families = self.families.get_or_insert_with(Families::available); - let (family, style, found) = families.resolve(&name, weight, italic); - if !found && !family.is_empty() && !self.missing_fonts.contains(&family) { - self.missing_fonts.push(family.clone()); - } - FontInfo { family, style, face: None } - } - (None, None) => { - let d = vectorcraft_doc::CharStyle::default(); - FontInfo { family: d.font_family, style: d.font_style, face: None } - } - }; - self.font_names.insert(key, n.clone()); - n - } - - /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps - /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, - /// type with neither fill nor stroke. - fn type_glyph( - &mut self, - o: &craft_pdf::interpret::font::OutlineGlyph, - m: Affine, - scale: f64, - paint: Option<&craft_pdf::interpret::Paint<'_>>, - stroke: Option<&StrokeProps>, - overprint: bool, - ) -> bool { - if self.text_as != TextAs::Text || self.nested > 0 { - return false; - } - let key = o.font_cache_key(); - let glyph = o.glyph_id().to_u32(); - let (unicode, from_cid) = match o.as_unicode().filter(|u| !replacement(u)) { - Some(u) => (Some(u), false), - // No mapping, or one to U+FFFD (#708): the font program may still name the character. - None => { - let cid = self.cid_text.entry(key).or_insert_with(|| o.font_data().and_then(|d| CidText::of(d.data.as_ref().as_ref())).map(Arc::new)); - match cid.as_ref().and_then(|c| c.unicode(glyph)) { - Some(u) => (Some(u), true), - None => { - let chars = self - .font_chars - .entry(key) - .or_insert_with(|| o.font_data().and_then(|d| font_chars(d.data.as_ref().as_ref())).map(Arc::new)); - let named = chars.as_ref().and_then(|m| m.get(&glyph)).copied(); - // Still unnamed: the installed font may draw the same outline (#811). - (named.or_else(|| self.identified_char(key, glyph, o)).map(BfString::Char), false) - } - } - } - }; - let text: String = match unicode { - Some(BfString::Char(c)) => c.to_string(), - Some(BfString::String(s)) => s, - None => { - // Invisible text isn't kept as outlines: the caller says it was left out. - if paint.is_some() && o.as_unicode().is_some_and(|u| replacement(&u)) { - let name = self.font_name(key, o).family; - self.warn(&format!("text in {name} {UNNAMED_CHARACTERS_NOTE}")); - } - return false; - } - }; - // Adobe's CID tables map some ideographs to their look-alike Kangxi radicals. - let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text }; - let Some(at) = Placement::of(m) else { return false }; - if text.is_empty() || text.chars().any(|c| c.is_control()) { - return false; - } - let width = o.advance_width().filter(|w| w.is_finite()); - // Set vertically (WMode 1): no horizontal advance. - let upright = width.is_some_and(|w| w.abs() <= 1.0); - let mut info = self.font_name(key, o); - // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which - // style of the installed family it is, so the name decides. - let embedded = self.fonts.get(&key).is_none_or(|f| f.embedded); - if let Some(face) = &info.face - && embedded - { - let decided = match self.matched.get(&key) { - Some(m) => m.clone(), - // A glyph that can't be compared (several characters, a vertical form) or that - // shows nothing (invisible text) decides nothing: it takes the face of its name - // until one that can be compared does. - None if paint.is_none() || comparable(&text, upright).is_none() => Some(face.clone()), - None => { - let m = matching_face(face, &text, o, upright); - self.matched.insert(key, m.clone()); - m - } - }; - // Invisible text shows no glyphs that could differ: it keeps the face of its name. - let Some(f) = decided.or_else(|| paint.is_none().then(|| face.clone())) else { - self.warn(UNMATCHED_GLYPHS_NOTE); - return false; - }; - info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) }; - } - // A version of the family and style that the two alone don't resolve to: the type names it. - // Kept per font once its face is decided (until then each glyph asks). - let named = |f: &Option>| { - let f = f.as_ref()?; - let default = vectorcraft_text::FontDb::global().face(&f.family, &f.style)?; - (default.version != f.version).then(|| f.version.clone()) - }; - let version = match self.versions.get(&key) { - Some(v) => v.clone(), - None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(), - None => named(&info.face), - }; - let (paint, opacity) = match paint { - Some(p) => { - let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true)); - (Some(paint), opacity) - } - None => (None, 1.0), - }; - let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); - let look = Look { - font: key, - family: info.family, - style: info.style, - version, - size: at.size, - h_scale: at.h_scale, - overprint_fill: stroke.is_none() && overprint, - overprint_stroke: stroke.is_some() && overprint, - fill: if stroke.is_none() { paint } else { None }, - stroke, - }; - let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); - // A glyph set vertically is drawn from its origin, which the file puts the position - // vector's vx (from the font's `W2`, else half its width from `W` or `DW`) to the left of - // the column's centre (ISO 32000-1 §9.7.4.3); vertical type centres each glyph on the - // column. Half an em only for a full-width glyph (#722). - let top = upright.then(|| { - let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(glyph)); - m * kurbo::Point::new(vx, 880.0) - }); - let place = |line: &mut TextLine| match top { - Some(top) => line.push_upright(&look, at, opacity, Upright { top }, &text), - None => line.push(&look, at, opacity, advance, &text), - }; - self.flush_glyphs(); - // A stroke over the glyph just filled (fill and stroke rendering). - if let (Some(s), Some(line)) = (&look.stroke, &mut self.text) - && look.fill.is_none() - && line.stroke_last(key, at, s.clone(), overprint) - { - return true; - } - if !self.text.as_mut().is_some_and(place) { - self.flush_text(); - let mut line = TextLine::new(at, opacity); - place(&mut line); - self.text = Some(line); - } - // Its ink: a stroke the file draws as outlines of the glyphs is told by it. - let mut ink = o.outline(); - if let Some(line) = &mut self.text - && !ink.elements().is_empty() - { - ink.apply_affine(m); - line.ink.push(ink.bounding_box()); - } - true - } -} - -fn rgba_png(rgba: Vec, w: u32, h: u32) -> Option> { - let img = image::RgbaImage::from_raw(w, h, rgba)?; - let mut out = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some(out) -} - -fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { - if a.width == w && a.height == h { - return a.data.clone(); - } - match image::GrayImage::from_raw(a.width, a.height, a.data.clone()) { - Some(g) => image::imageops::resize(&g, w, h, image::imageops::FilterType::Triangle).into_raw(), - None => vec![255; (w * h) as usize], - } -} - -impl<'a> Device<'a> for Builder<'_> { - fn set_soft_mask(&mut self, mask: Option>) { - let spec = mask.map(|m| self.soft_mask(&m)); - let same = match (&spec, &self.mask) { - (Some(a), Some(b)) => Arc::ptr_eq(a, b), - (None, None) => true, - _ => false, - }; - if !same { - self.flush(); - self.mask = spec; - } - } - - fn set_blend_mode(&mut self, blend_mode: craft_pdf::interpret::BlendMode) { - let b = blend(blend_mode); - if b != self.blend { - self.flush_glyphs(); - self.flush_text(); - self.blend = b; - } - } - - fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) { - self.flush_glyphs(); - let walked = self.walked(false); - if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) { - self.pending = false; - return; - } - self.flush_text(); - self.pending = false; - let overprint = self.path_overprint(); - let mut bp = path.clone(); - bp.apply_affine(transform); - if bp.elements().is_empty() { - return; - } - let shading = match paint { - craft_pdf::interpret::Paint::Pattern(p) => match p.as_ref() { - Pattern::Shading(sp) => Some(sp), - Pattern::Tiling(_) => None, - }, - craft_pdf::interpret::Paint::Color(_) => None, - }; - // Patch and triangle meshes are gradient meshes. - if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading) - && let Some(meshes) = mesh_shading(sp, &mut self.colors, walked.and_then(|w| self.scan.ink(w, false, true))) - { - self.draw_meshes(meshes, &bp, sp.opacity); - return; - } - let pd = PathData::from_bezpath(&bp); - let stroke = matches!(draw_mode, PathDrawMode::Stroke(_)); - let (paint, opacity) = self.paint(paint, stroke, walked); - // A gradient that doesn't extend past its ends paints only between them. - let width = match draw_mode { - PathDrawMode::Stroke(p) => line_width(p, mean_scale(transform)), - PathDrawMode::Fill(_) => 0.0, - }; - let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width))); - let wrap = |b: &mut Self, n: Node| match &band { - Some(band) => { - let clip = b.clip_node(band, FillRule::EvenOdd); - let id = b.id(); - clipped(clip, n, id) - } - None => n, - }; - match draw_mode { - PathDrawMode::Fill(rule) => { - let mut f = FillLayer::new(paint); - (f.opacity, f.overprint) = (opacity, overprint); - let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() }); - if let NodeKind::Path { rule: r, .. } = &mut n.kind { - *r = fill_rule(*rule); - } - self.last_fill = Some((n.id, bp)); - let n = wrap(self, n); - self.push_node(n); - } - PathDrawMode::Stroke(props) => { - let mut st = stroke_layer(paint, opacity, props, mean_scale(transform)); - st.overprint = overprint; - // Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke - // of the same outline) becomes one object: a path with a fill and a live stroke. - let blend = self.blend; - let fill = self.last_fill.take(); - let last = match self.mask { - Some(_) => self.masked.last_mut(), - None => self.stack.last_mut().and_then(|f| f.children.last_mut()), - }; - if band.is_none() - && let Some(last) = last - && let Some((id, outline)) = fill - && last.id == id - && last.blend == blend - // Opacity or a mask of its own came from a transparency group the fill was - // drawn in and this stroke wasn't. - && last.opacity == 1.0 - && last.extras().mask.is_none() - && last.appearance.stroke().is_none() - && same_outline(&outline, &bp) - { - let last = Arc::make_mut(last); - // The stroke's outline: closing it changes the stroke, not the fill. - if let Some(p) = last.path_data_mut() { - *p = pd; - } - last.appearance.items.push(AppearanceItem::Stroke(st)); - return; - } - let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); - let n = wrap(self, n); - self.push_node(n); - } - } - } - - fn push_clip_path(&mut self, clip_path: &ClipPath) { - self.flush(); - // A form's box right after its transparency group: the group's isolate and knockout - // flags are the next ones read from the file. - if std::mem::take(&mut self.pending) { - let flags = self.scan.groups.get(self.group_at).copied(); - self.group_at += 1; - if let Some((i, k)) = flags - && let Some(Frame { kind: FrameKind::Group { isolate, knockout, .. }, .. }) = self.stack.last_mut() - { - (*isolate, *knockout) = (i, k); - } - } - // Clips that contain the whole page do nothing visible; don't create groups for them. Only - // a polygon that really covers it counts: a diamond touching the page edges has the page - // as its bounding box but cuts its corners away (#1542). - let redundant = clip_path.path.elements().len() <= 6 - && self.page.area() > 0.0 - && vectorcraft_geom::hit::polygon_covers_rect(&clip_path.path, self.page, 0.01); - let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) }; - self.stack.push(Frame::new(kind)); - } - - fn push_transparency_group(&mut self, opacity: f32, mask: Option>, blend_mode: craft_pdf::interpret::BlendMode) { - self.flush(); - let mask = mask.map(|m| self.soft_mask(&m)); - self.stack.push(Frame::new(FrameKind::Group { opacity, blend: blend(blend_mode), mask, isolate: false, knockout: false })); - self.pending = self.nested == 0; - // Blend mode applies to the group as a whole, not to its children. - self.blend = BlendMode::Normal; - } - - fn draw_glyph( - &mut self, - glyph: &Glyph<'a>, - transform: Affine, - glyph_transform: Affine, - paint: &craft_pdf::interpret::Paint<'a>, - draw_mode: &GlyphDrawMode, - ) { - // Clipping text paints nothing; its outlines arrive separately as a clip path. - if matches!(draw_mode, GlyphDrawMode::Clip) { - return; - } - self.pending = false; - let stroke = match draw_mode { - // Invisible text (a scanned page's recognized text, over the scan) draws nothing but - // is still text: type with neither fill nor stroke, so it stays searchable. - GlyphDrawMode::Invisible => { - let typed = match glyph { - Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false), - Glyph::Type3(_) => false, - }; - if !typed { - self.warn(INVISIBLE_TEXT_NOTE); - } - return; - } - GlyphDrawMode::Fill | GlyphDrawMode::Clip => None, - GlyphDrawMode::Stroke(p) => Some(p.clone()), - }; - match glyph { - Glyph::Outline(o) => { - let scale = mean_scale(transform); - let overprint = self.glyph_overprint(stroke.is_some()); - if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { - return; - } - self.flush_text(); - let mut bp = o.outline(); - bp.apply_affine(transform * glyph_transform); - let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true)); - let same = self.glyphs.as_ref().is_some_and(|r| { - r.paint == paint - && r.opacity == opacity - && r.overprint == overprint - && r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width) - && (r.scale - scale).abs() < 1e-9 - }); - if !same { - self.flush_glyphs(); - self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint }); - } - if let Some(r) = &mut self.glyphs { - r.path.extend(bp.iter()); - } - self.warn(OUTLINED_TEXT_NOTE); - } - Glyph::Type3(t3) => { - if self.nested >= MAX_NESTED { - return; - } - self.flush(); - self.nested += 1; - t3.interpret(self, transform, glyph_transform, paint); - self.nested -= 1; - } - } - } - - fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { - self.flush_glyphs(); - self.flush_text(); - self.pending = false; - match image { - Image::Raster(r) => { - let white = self.white_images; - // Inside an alpha mask the same image is read again, white (its own cache key). - let key = craft_pdf::interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 }; - let st = r.stream(); - let (w, h) = (r.width(), r.height()); - // CMYK images keep their samples (read once per image). - let known = !white && self.cmyk_keys.contains(&key); - let cmyk = if known || white { - None - } else { - crate::import_image::cmyk(st, w, h).unwrap_or_else(|why| { - self.warn(why); - None - }) - }; - if known || cmyk.is_some() { - self.cmyk_keys.insert(key); - // Its mask (read by the interpreter, whatever its kind) becomes an opacity - // mask, so the inks stay as they are. - let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None }; - return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask); - } - // JPEG passthrough for plain DeviceRGB/DeviceGray DCT images. - let dict = st.dict(); - let filters = st.filters(); - let cs_ok = dict.get::>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray")); - let jpeg = !white - && filters.len() == 1 - && matches!(filters[0], craft_pdf::syntax::Filter::DctDecode) - && cs_ok - && !dict.contains_key(b"SMask") - && !dict.contains_key(b"Mask") - && !dict.contains_key(b"Decode"); - if jpeg { - let bytes = st.raw_data().to_vec(); - self.add_image(key, || Some((ImageBlob::new("image/jpeg", bytes), w, h)), transform); - return; - } - let mut decoded: Option = None; - r.with_rgba( - |img, alpha| { - let (w, h, sf) = (img.width(), img.height(), img.scale_factors()); - let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]); - let rgba: Vec = match img { - _ if white => a.into_iter().flat_map(|a| [255, 255, 255, a]).collect(), - ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(), - ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(), - }; - decoded = Some((rgba, w, h, sf)); - }, - None, - ); - let Some((rgba, w, h, sf)) = decoded else { - self.warn("an image could not be decoded and was skipped"); - return; - }; - let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); - self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); - } - Image::Stencil(s) => { - let white = self.white_images; - let key = craft_pdf::interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 }; - let mut decoded: Option = None; - s.with_stencil( - |luma, paint| { - let rgb = match paint { - _ if white => [255, 255, 255, 255], - craft_pdf::interpret::Paint::Color(c) => c.to_rgba().to_rgba8(), - _ => [0, 0, 0, 255], - }; - let rgba = luma.data.iter().flat_map(|a| [rgb[0], rgb[1], rgb[2], *a]).collect(); - decoded = Some((rgba, luma.width, luma.height, luma.scale_factors)); - }, - None, - ); - let Some((rgba, w, h, sf)) = decoded else { return }; - let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); - self.add_image(key ^ 0x5_7e9c, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); - } - } - } - - fn pop_clip_path(&mut self) { - self.flush(); - self.pending = false; - self.pop_frame(); - } - - fn pop_transparency_group(&mut self) { - self.flush(); - self.pending = false; - self.pop_frame(); - } - - fn begin_marked_content(&mut self, tag: &[u8], _mcid: Option) { - if self.nested > 0 { - return; - } - let at = self.tag_at; - self.tag_at += 1; - let group = match self.scan.tags.get(at) { - Some((t, g)) if self.aligned && *t == tag_key(tag) => *g, - _ => { - if self.route && self.aligned { - self.aligned = false; - let went = if self.unsure { "a hidden, non-printing layer of its page" } else { "its page's layer" }; - self.warn(&format!("some art couldn't be told apart by layer and went to {went}")); - } - self.unsure.then_some(UNSORTED) - } - } - .filter(|_| self.route); - // Only a layer's marked content ends the runs gathered so far: other tags (a span with its - // actual text, an artifact) leave a line of type whole. - if group.is_some() { - self.flush(); - } - if let Some(g) = group - && self.stack.len() > 1 - && let Some(f) = self.stack.last_mut() - { - f.group.get_or_insert(g); - } - self.marked.push(group); - } - - fn end_marked_content(&mut self) { - if self.nested > 0 { - return; - } - // The runs gathered within a layer's sequence are its art: finish them before it ends. - if self.marked.last().copied().flatten().is_some() { - self.flush(); - } - self.marked.pop(); - } -} +pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical}; +pub use entry::{ + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, + import, import_with_report, is_glyph_outlines, is_outlines_note, +}; +#[cfg(test)] +pub(crate) use entry::{import_with_reader_options, read}; +pub(crate) use state::{closed_subpaths, round3}; diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs new file mode 100644 index 000000000..4c9428dd9 --- /dev/null +++ b/crates/pdf/src/import/builder.rs @@ -0,0 +1,857 @@ +use super::cid::{CidText, FontInfo, comparable, font_chars, identify_glyph, matching_face, replacement, unify_radical}; +use super::entry::{MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE}; +use super::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; +use super::*; + +impl<'p> Builder<'p> { + pub(super) fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self { + Self { + colors, + next, + stack: vec![], + root_groups: vec![], + blend: BlendMode::Normal, + page: Rect::ZERO, + glyphs: None, + text: None, + text_as, + images: HashMap::new(), + image_keys: HashMap::new(), + cmyk_keys: HashSet::new(), + opaque: HashSet::new(), + warnings: vec![], + fonts: HashMap::new(), + font_names: HashMap::new(), + families: None, + missing_fonts: vec![], + cid_text: HashMap::new(), + font_chars: HashMap::new(), + matched: HashMap::new(), + identified: HashMap::new(), + versions: HashMap::new(), + mask: None, + masked: vec![], + masks: HashMap::new(), + patterns: vec![], + pattern_keys: HashMap::new(), + function_paints: HashMap::new(), + function_budget: FunctionBudget::new(), + taken: vec![], + scan: Scan::default(), + tag_at: 0, + group_at: 0, + paint_at: 0, + aligned: true, + route, + unsure: false, + marked: vec![], + pending: false, + nested: 0, + white_images: false, + last_fill: None, + lines: HashMap::new(), + } + } + + pub(super) fn id(&mut self) -> NodeId { + let id = NodeId(self.next); + self.next += 1; + id + } + + /// A deep copy of `node` with fresh ids. + fn reid(&mut self, node: &Node) -> Node { + let mut n = node.clone(); + n.id = self.id(); + if let Some(ch) = n.children_mut() { + let old = std::mem::take(ch); + *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); + } + n + } + + /// `m` with fresh ids for its art: a mask read once can mask several objects. + fn fresh_mask(&mut self, mut m: OpacityMask) -> OpacityMask { + m.art = Arc::new(self.reid(&m.art)); + m + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + pub(super) fn begin_page(&mut self, scan: Scan) { + self.stack = vec![Frame::new(FrameKind::Root)]; + self.root_groups.clear(); + self.blend = BlendMode::Normal; + self.glyphs = None; + self.text = None; + self.mask = None; + self.masked.clear(); + self.marked.clear(); + self.pending = false; + (self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0); + if self.scan.overprints && self.scan.zero_mode { + self.warn(OVERPRINT_ZERO_MODE); + } + } + + /// Whether the path the interpreter draws now (a fill or a stroke, each counted) overprints. It + /// takes the path's turn in the page's walk. + pub(super) fn path_overprint(&mut self) -> bool { + if self.nested > 0 { + return false; + } + self.paint_at += 1; + self.scan.overprints && self.scan.paints.get(self.paint_at - 1).copied().unwrap_or(false) + } + + /// What the interpreter draws now, as the page's walk counts it: the path whose turn comes + /// next, or the `text` before it. `None` in a nested reading, which the walk doesn't follow. + pub(super) fn walked(&self, text: bool) -> Option { + (self.nested == 0).then_some(if text { Walked::Text(self.paint_at) } else { Walked::Path(self.paint_at) }) + } + + /// Whether the glyph the interpreter draws now (its fill, or its `stroke`) overprints. + pub(super) fn glyph_overprint(&mut self, stroke: bool) -> bool { + if self.nested > 0 || !self.scan.overprints { + return false; + } + match self.scan.texts.get(self.paint_at) { + Some(TextOverprint::All { fill, stroke: s }) => *if stroke { s } else { fill }, + Some(TextOverprint::Mixed) => { + self.warn("some text is set to overprint in a way that can't be read: it knocks out"); + false + } + _ => false, + } + } + + /// The page's art, split by optional content group (in the order they first paint). + pub(super) fn end_page(&mut self) -> Vec<(Option, Vec>)> { + let mut children = self.close_frames(); + // The interpreter drew another number of paths than the file was read to paint: which + // of them overprint can't be trusted. + if self.scan.overprints && self.paint_at != self.scan.paints.len() { + children.iter_mut().for_each(vectorcraft_doc::overprint::clear_overprints); + self.warn(OVERPRINT_UNREAD); + } + let groups = std::mem::take(&mut self.root_groups); + let mut parts: Vec<(Option, Vec>)> = vec![]; + // Thousands of groups: find each one's part by index. + let mut at: HashMap, usize> = HashMap::new(); + for (n, g) in children.into_iter().zip(groups.into_iter().chain(std::iter::repeat(None))) { + let i = *at.entry(g).or_insert_with(|| { + parts.push((g, vec![])); + parts.len() - 1 + }); + if let Some((_, v)) = parts.get_mut(i) { + v.push(n); + } + } + parts + } + + /// Finish the open runs and frames → the root frame's children. + fn close_frames(&mut self) -> Vec> { + self.flush(); + while self.stack.len() > 1 { + self.pop_frame(); + } + self.stack.pop().map(|f| f.children).unwrap_or_default() + } + + /// The group art drawn now belongs to (the innermost marked as one). + fn current_group(&self) -> Option { + self.marked.iter().rev().find_map(|g| *g) + } + + /// Add `n` to the open frame; `group`: the optional content group it was drawn in, if it + /// knows one (else the current one). + fn emit(&mut self, n: Arc, group: Option) { + let root = self.stack.len() == 1; + let current = self.current_group(); + let Some(f) = self.stack.last_mut() else { return }; + f.children.push(n); + // A frame's art goes where its first art was drawn (a clip can end in a later group). + if root { + self.root_groups.push(group.or(current)); + } else { + f.child_groups.push(group.or(current)); + if f.group.is_none() { + f.group = group.or(current); + } + } + } + + pub(super) fn push_node(&mut self, mut n: Node) { + if self.blend != BlendMode::Normal && !n.is_container() { + n.blend = self.blend; + } + if self.mask.is_some() { + self.masked.push(Arc::new(n)); + } else { + self.emit(Arc::new(n), None); + } + } + + /// Finish the open glyph runs and the art drawn through the current soft mask. + pub(super) fn flush(&mut self) { + self.flush_glyphs(); + self.flush_text(); + self.flush_masked(); + } + + /// The art drawn through the graphics state's soft mask so far, as one masked object. + fn flush_masked(&mut self) { + if self.masked.is_empty() { + return; + } + let art = std::mem::take(&mut self.masked); + let Some(spec) = self.mask.clone() else { + art.into_iter().for_each(|n| self.emit(n, None)); + return; + }; + match spec.as_ref() { + // Each object is drawn through the mask on its own. + MaskSpec::Opacity(a) => { + for mut n in art { + Arc::make_mut(&mut n).opacity *= a; + self.emit(n, None); + } + } + MaskSpec::Mask(m) => { + let m = self.fresh_mask(m.clone()); + let node = match <[Arc; 1]>::try_from(art) { + Ok([one]) if one.extras().mask.is_none() => { + fold_stop_opacity(&one, &m).unwrap_or_else(|| Arc::unwrap_or_clone(one).with_mask(Some(Box::new(m)))) + } + Ok([one]) => { + let mut g = Node::group(self.id(), vec![one]); + g.extras_mut().mask = Some(Box::new(m)); + g + } + Err(all) => { + let mut g = Node::group(self.id(), all); + g.extras_mut().mask = Some(Box::new(m)); + g + } + }; + self.emit(Arc::new(node), None); + } + } + } + + /// A clip path node. + pub(super) fn clip_node(&mut self, path: &BezPath, rule: FillRule) -> Node { + let mut clip = Node::new(self.id(), NodeKind::Path { path: PathData::from_bezpath(path), rule, live: None, clipping: true, guide: false }); + clip.name = Some("".into()); + clip + } + + pub(super) fn pop_frame(&mut self) { + if self.stack.len() <= 1 { + return; + } + let Some(f) = self.stack.pop() else { return }; + let group = f.group; + let node = match f.kind { + FrameKind::Root => None, + FrameKind::Skip => { + grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); + None + } + FrameKind::Clip(clip) => { + let noop = clip.path_data().map(|p| p.to_bezpath()).filter(is_rectangle).map(|p| p.bounding_box().inflate(0.01, 0.01)); + if f.children.is_empty() { + None + } else if noop.is_some_and(|r| bounds(&f.children).is_some_and(|b| contains(r, b))) { + // A rectangle around all of its art (a form's box) clips nothing. + grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); + None + } else if let Some(shape) = gradient_shape(&clip, &f.children) { + // One path, so tools such as the Shape Builder see the shape, not its box (#1304). + Some(shape) + } else { + let mut ch = vec![Arc::new(*clip)]; + ch.extend(f.children); + Some(Node::new(self.id(), NodeKind::Group { children: ch, clip: true })) + } + } + FrameKind::Group { opacity, blend, mask, isolate, knockout } => { + let children = f.children; + // A constant alpha mask is opacity (of a group that isn't isolated). + let (opacity, mask) = match mask.as_deref() { + Some(MaskSpec::Opacity(a)) => (opacity * a, None), + Some(MaskSpec::Mask(m)) => (opacity, Some(m.clone())), + None => (opacity, None), + }; + // Isolation only shows with blending inside; knockout with several objects. + let isolate = isolate && Node::children_blend(&children, &mut |c| c.blends_through()); + let knockout = knockout && children.len() > 1; + let plain = opacity >= 0.999 && blend == BlendMode::Normal && mask.is_none() && !isolate && !knockout; + if children.is_empty() { + None + } else if plain { + grouped(children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); + None + } else if let [only] = children.as_slice() + && !only.is_container() + && !isolate + && !knockout + && (mask.is_none() || only.extras().mask.is_none()) + { + // A single object in a group: fold opacity, blend and mask into the object. + let mut only = (**only).clone(); + only.opacity *= opacity; + if blend != BlendMode::Normal { + only.blend = blend; + } + if let Some(m) = mask { + let m = self.fresh_mask(m); + only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m)))); + } + Some(only) + } else { + let mut g = Node::group(self.id(), children); + (g.opacity, g.blend, g.isolate) = (opacity, blend, isolate); + if knockout { + g.knockout = Knockout::On; + } + g.extras_mut().mask = mask.map(|m| Box::new(self.fresh_mask(m))); + Some(g) + } + } + }; + if let Some(n) = node { + self.emit(Arc::new(n), group); + } + } + + pub(super) fn flush_glyphs(&mut self) { + let Some(run) = self.glyphs.take() else { return }; + if run.path.elements().is_empty() { + return; + } + let mut ap = Appearance::default(); + match &run.stroke { + None => { + let mut f = FillLayer::new(run.paint); + (f.opacity, f.overprint) = (run.opacity, run.overprint); + ap.items.push(AppearanceItem::Fill(f)); + } + Some(p) => { + let mut st = stroke_layer(run.paint, run.opacity, p, run.scale); + st.overprint = run.overprint; + ap.items.push(AppearanceItem::Stroke(st)); + } + } + let id = self.id(); + let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap); + n.name = Some(TEXT_OUTLINES.into()); + self.push_node(n); + } + + pub(super) fn flush_text(&mut self) { + let Some(mut line) = self.text.take() else { return }; + let held = std::mem::take(&mut line.held); + if let Some((t, opacity, facts)) = line.finish() { + let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); + n.opacity = opacity; + self.lines.insert(n.id, facts); + self.push_node(n); + } + // The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke). + for n in held { + self.push_node(n); + } + } + + /// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph + /// before it on the open line was stroked so too, is held with the line instead of ending it + /// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the + /// type). `false`: it isn't one. `walked`: the path in the page's walk. + pub(super) fn hold_glyph_stroke( + &mut self, + path: &BezPath, + transform: Affine, + paint: &craft_pdf::interpret::Paint<'_>, + draw_mode: &PathDrawMode, + walked: Option, + ) -> bool { + let PathDrawMode::Stroke(props) = draw_mode else { return false }; + if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, craft_pdf::interpret::Paint::Pattern(_)) { + return false; + } + let (p, opacity) = self.paint(paint, true, walked); + let Some(line) = &self.text else { return false }; + let Some(ink) = line.ink.last() else { return false }; + if line.held.len() + 1 != line.ink.len() || opacity < 0.999 || !line.last_fill().is_some_and(|f| *f == p) { + return false; + } + let mut bp = path.clone(); + bp.apply_affine(transform); + let b = bp.bounding_box(); + let tol = 0.01 * (ink.width() + ink.height()) + 0.01; + if ![(b.x0, ink.x0), (b.y0, ink.y0), (b.x1, ink.x1), (b.y1, ink.y1)].iter().all(|(a, c)| (a - c).abs() <= tol) { + return false; + } + let mut st = stroke_layer(p, opacity, props, mean_scale(transform)); + // Every painted path takes its turn in the scan's overprint list, a held one too. + st.overprint = self.path_overprint(); + let n = Node::path(self.id(), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); + if let Some(line) = &mut self.text { + line.held.push(n); + } + true + } + + /// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke; `walked`: + /// what it paints in the page's walk, which names its ink). + pub(super) fn paint(&mut self, p: &craft_pdf::interpret::Paint<'_>, stroke: bool, walked: Option) -> (Paint, f32) { + let pattern = matches!(p, craft_pdf::interpret::Paint::Pattern(_)); + let inks = walked.and_then(|w| self.scan.ink(w, stroke, pattern)); + match p { + craft_pdf::interpret::Paint::Color(c) => { + let [r, g, b, a] = c.to_rgba().components(); + let paint = self.colors.solid(c, inks).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint); + (paint, a) + } + craft_pdf::interpret::Paint::Pattern(pat) => match pat.as_ref() { + Pattern::Shading(sp) => { + // The ink names outlive the walk's borrow of `self`. + let inks = inks.map(<[String]>::to_vec); + match self.function_shading(sp, inks.as_deref()) { + Some(paint) => (paint, sp.opacity), + None => match shading_gradient(sp, &mut self.colors, inks.as_deref()) { + Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity), + None => { + self.warn("shadings that can't be read as a gradient or gradient mesh (such as very large meshes) are imported as a flat grey"); + (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity) + } + }, + } + } + Pattern::Tiling(t) => match self.tiling(t, stroke) { + Some(paint) => (paint, 1.0), + None => { + self.warn("tiling patterns nested too deeply are imported as a flat grey"); + (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0) + } + }, + }, + } + } + + /// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the + /// coordinates `page` has. `None` when nested too deeply. + fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option>> { + if self.nested >= MAX_NESTED { + return None; + } + self.flush(); + let saved = Saved { + stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]), + root_groups: std::mem::take(&mut self.root_groups), + blend: std::mem::replace(&mut self.blend, BlendMode::Normal), + mask: self.mask.take(), + page: std::mem::replace(&mut self.page, page), + pending: std::mem::take(&mut self.pending), + }; + self.nested += 1; + read(self); + let art = self.close_frames(); + self.nested -= 1; + (self.stack, self.root_groups, self.blend, self.mask, self.page, self.pending) = + (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.pending); + Some(art) + } + + /// What soft mask `m` does (read once per mask and placement). + pub(super) fn soft_mask(&mut self, m: &SoftMask<'_>) -> Arc { + let key = m.cache_key(); + if let Some(s) = self.masks.get(&key) { + return s.clone(); + } + let page = self.page; + let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha); + let art = self.nested(page, |b| m.interpret(b)); + self.white_images = saved; + let spec = match art { + Some(art) => { + let [r, g, b, _] = m.background_color().to_rgba().components(); + let backdrop = luminance(&Color::rgb(r, g, b)); + let inverted = m.transfer_function().is_some_and(|tf| { + let mut ends = [0, 255]; + tf.apply_to(&mut ends); + ends[0] > ends[1] + }); + let id = self.id(); + mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all)) + } + None => { + self.warn("soft masks nested too deeply were left out (content drawn unmasked)"); + MaskSpec::Opacity(1.0) + } + }; + let spec = Arc::new(spec); + self.masks.insert(key, spec.clone()); + spec + } + + /// Function-based shading `sp` (painting with `inks`) as a paint, read the first time it is + /// used and within the import's budget of function evaluations; `None` for other shadings. + fn function_shading(&mut self, sp: &craft_pdf::interpret::pattern::ShadingPattern, inks: Option<&[String]>) -> Option { + let key = (sp.cache_key(), inks.map(<[String]>::to_vec)); + if let Some(paint) = self.function_paints.get(&key) { + return Some(paint.clone()); + } + let paint = match function_paint(sp, &mut self.colors, inks, &self.function_budget)? { + FunctionPaint::Gradient(g) => Paint::Gradient(g), + FunctionPaint::Average(c) => { + self.warn( + "function-based shadings that aren't read as a gradient (their colour changes in two directions, they need too many stops, or the file has too many to read) are imported as their average colour", + ); + Paint::solid(c) + } + FunctionPaint::Unread => { + self.warn("function-based shadings that can't be read (their function or domain is invalid, or the file has too many to read) are imported as a flat grey"); + Paint::solid(Color::rgb(0.5, 0.5, 0.5)) + } + }; + self.function_paints.insert(key, paint.clone()); + Some(paint) + } + + /// Tiling pattern `t` as a pattern swatch's paint (the swatch made the first time). + fn tiling(&mut self, t: &TilingPattern<'_>, stroke: bool) -> Option { + let key = (t.cache_key(), stroke); + let tile = Rect::from_origin_size(t.bbox.origin(), (t.x_step.abs().max(1e-3) as f64, t.y_step.abs().max(1e-3) as f64)); + let xf = t.matrix * Affine::translate(tile.origin().to_vec2()); + // An uncoloured pattern draws in the current colour: read it again unless it always + // drew the same. + if let Some((n, seen)) = self.pattern_keys.get(&key) + && *n >= PATTERN_REUSE + && let [(_, name)] = seen.as_slice() + { + return Some(Paint::Pattern { pattern: name.clone(), xf }); + } + // A swatch is shared with uses outside an alpha mask, and pattern paints keep their + // colours there: its images are read as they are. + let white = std::mem::take(&mut self.white_images); + let art = self.nested(Rect::ZERO, |b| { + // A cell whose content can't be decoded is empty. + let _ = t.interpret(b, Affine::IDENTITY, stroke); + }); + self.white_images = white; + let art = art?; + let sig = paints(&art); + let entry = self.pattern_keys.entry(key).or_default(); + entry.0 += 1; + let name = match entry.1.iter().find(|(s, _)| *s == sig) { + Some((_, name)) => name.clone(), + None => { + let name = (self.patterns.len() + 1..).map(|i| format!("Pattern {i}")).find(|n| !self.taken.contains(n)).unwrap_or_default(); + self.taken.push(name.clone()); + let mut def = PatternDef::new(&name, art); + def.tile = tile; + self.patterns.push(def); + if let Some(e) = self.pattern_keys.get_mut(&key) { + e.1.push((sig, name.clone())); + } + name + } + }; + Some(Paint::Pattern { pattern: name, xf }) + } + + pub(super) fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) { + self.add_image_masked(key, make, xf, None); + } + + /// An image's alpha (its soft mask, stencil mask or colour key, at its own resolution) as a + /// greyscale image over the image's `w` × `h` pixels (`transform`): its opacity mask. `None` + /// when it masks nothing. + pub(super) fn alpha_mask( + &mut self, + key: u128, + r: &craft_pdf::interpret::RasterImage<'_>, + w: u32, + h: u32, + transform: Affine, + ) -> Option { + let mkey = key ^ 0x5_3a5c; + if self.opaque.contains(&mkey) { + return None; + } + let (k, mw, mh) = if let Some(v) = self.image_keys.get(&mkey).cloned() { + v + } else { + let mut alpha = None; + r.with_rgba(|_, a| alpha = a, None); + let Some(a) = alpha.filter(|a| a.data.iter().any(|v| *v < 255)) else { + self.opaque.insert(mkey); + return None; + }; + let mut png = Vec::new(); + image::GrayImage::from_raw(a.width, a.height, a.data)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + let blob = ImageBlob::new("image/png", png); + let k = blob.content_key(); + self.images.insert(k.clone(), blob); + self.image_keys.insert(mkey, (k.clone(), a.width, a.height)); + (k, a.width, a.height) + }; + let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1))); + let art = Node::new( + self.id(), + NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default(), pixels: None })), + ); + Some(OpacityMask::new(art, true)) + } + + pub(super) fn add_image_masked( + &mut self, + key: u128, + make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, + xf: Affine, + mask: Option, + ) { + let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() { + v + } else { + let Some((blob, w, h)) = make() else { + self.warn("an image could not be decoded and was skipped"); + return; + }; + // Content keys: placing this page into another document merges its images safely. + let k = blob.content_key(); + self.images.insert(k.clone(), blob); + self.image_keys.insert(key, (k.clone(), w, h)); + (k, w, h) + }; + let id = self.id(); + let mut n = Node::new( + id, + NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ); + n.extras_mut().mask = mask.map(Box::new); + self.push_node(n); + } + + /// Mesh shadings filling `region` (document space): gradient meshes, clipped to the region + /// when they reach past it. + pub(super) fn draw_meshes(&mut self, meshes: Vec, region: &BezPath, opacity: f32) { + let nodes: Vec> = meshes.into_iter().map(|m| Arc::new(Node::new(self.id(), NodeKind::Mesh(m)))).collect(); + let Some(art) = bounds(&nodes) else { return }; + let mut content = match <[Arc; 1]>::try_from(nodes) { + Ok([one]) => Arc::unwrap_or_clone(one), + Err(all) => Node::group(self.id(), all), + }; + content.opacity *= opacity; + if !contains(region.bounding_box().inflate(0.5, 0.5), art) { + let clip = self.clip_node(region, FillRule::NonZero); + content = clipped(clip, content, self.id()); + } + self.push_node(content); + } + + /// The family and style of font `key` (drawing glyph `o`): the installed face of its PostScript + /// name, else the available family its name reads as; whether it is available. + /// The character of glyph `glyph` of font `key` that no mapping names, found by its outline in + /// the installed face of the font's name ([`identify_glyph`]); cached per glyph. + fn identified_char(&mut self, key: u128, glyph: u32, o: &craft_pdf::interpret::font::OutlineGlyph) -> Option { + if let Some(c) = self.identified.get(&(key, glyph)) { + return *c; + } + let c = self.font_name(key, o).face.and_then(|f| identify_glyph(&f, &o.outline())); + self.identified.insert((key, glyph), c); + c + } + + fn font_name(&mut self, key: u128, o: &craft_pdf::interpret::font::OutlineGlyph) -> FontInfo { + if let Some(n) = self.font_names.get(&key) { + return n.clone(); + } + let data = o.font_data(); + let name = self.fonts.get(&key).map(|f| f.name.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); + let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic)); + // A subset's six-letter tag. + let ps = name.as_deref().map(|n| match n.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest.to_string(), + _ => n.to_string(), + }); + let installed = ps.as_deref().and_then(|ps| vectorcraft_text::FontDb::global().find_postscript(ps)); + let n = match (installed, name) { + (Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), face: Some(face) }, + (None, Some(name)) => { + let families = self.families.get_or_insert_with(Families::available); + let (family, style, found) = families.resolve(&name, weight, italic); + if !found && !family.is_empty() && !self.missing_fonts.contains(&family) { + self.missing_fonts.push(family.clone()); + } + FontInfo { family, style, face: None } + } + (None, None) => { + let d = vectorcraft_doc::CharStyle::default(); + FontInfo { family: d.font_family, style: d.font_style, face: None } + } + }; + self.font_names.insert(key, n.clone()); + n + } + + /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps + /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, + /// type with neither fill nor stroke. + pub(super) fn type_glyph( + &mut self, + o: &craft_pdf::interpret::font::OutlineGlyph, + m: Affine, + scale: f64, + paint: Option<&craft_pdf::interpret::Paint<'_>>, + stroke: Option<&StrokeProps>, + overprint: bool, + ) -> bool { + if self.text_as != TextAs::Text || self.nested > 0 { + return false; + } + let key = o.font_cache_key(); + let glyph = o.glyph_id().to_u32(); + let (unicode, from_cid) = match o.as_unicode().filter(|u| !replacement(u)) { + Some(u) => (Some(u), false), + // No mapping, or one to U+FFFD (#708): the font program may still name the character. + None => { + let cid = self.cid_text.entry(key).or_insert_with(|| o.font_data().and_then(|d| CidText::of(d.data.as_ref().as_ref())).map(Arc::new)); + match cid.as_ref().and_then(|c| c.unicode(glyph)) { + Some(u) => (Some(u), true), + None => { + let chars = self + .font_chars + .entry(key) + .or_insert_with(|| o.font_data().and_then(|d| font_chars(d.data.as_ref().as_ref())).map(Arc::new)); + let named = chars.as_ref().and_then(|m| m.get(&glyph)).copied(); + // Still unnamed: the installed font may draw the same outline (#811). + (named.or_else(|| self.identified_char(key, glyph, o)).map(BfString::Char), false) + } + } + } + }; + let text: String = match unicode { + Some(BfString::Char(c)) => c.to_string(), + Some(BfString::String(s)) => s, + None => { + // Invisible text isn't kept as outlines: the caller says it was left out. + if paint.is_some() && o.as_unicode().is_some_and(|u| replacement(&u)) { + let name = self.font_name(key, o).family; + self.warn(&format!("text in {name} {UNNAMED_CHARACTERS_NOTE}")); + } + return false; + } + }; + // Adobe's CID tables map some ideographs to their look-alike Kangxi radicals. + let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text }; + let Some(at) = Placement::of(m) else { return false }; + if text.is_empty() || text.chars().any(|c| c.is_control()) { + return false; + } + let width = o.advance_width().filter(|w| w.is_finite()); + // Set vertically (WMode 1): no horizontal advance. + let upright = width.is_some_and(|w| w.abs() <= 1.0); + let mut info = self.font_name(key, o); + // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which + // style of the installed family it is, so the name decides. + let embedded = self.fonts.get(&key).is_none_or(|f| f.embedded); + if let Some(face) = &info.face + && embedded + { + let decided = match self.matched.get(&key) { + Some(m) => m.clone(), + // A glyph that can't be compared (several characters, a vertical form) or that + // shows nothing (invisible text) decides nothing: it takes the face of its name + // until one that can be compared does. + None if paint.is_none() || comparable(&text, upright).is_none() => Some(face.clone()), + None => { + let m = matching_face(face, &text, o, upright); + self.matched.insert(key, m.clone()); + m + } + }; + // Invisible text shows no glyphs that could differ: it keeps the face of its name. + let Some(f) = decided.or_else(|| paint.is_none().then(|| face.clone())) else { + self.warn(UNMATCHED_GLYPHS_NOTE); + return false; + }; + info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) }; + } + // A version of the family and style that the two alone don't resolve to: the type names it. + // Kept per font once its face is decided (until then each glyph asks). + let named = |f: &Option>| { + let f = f.as_ref()?; + let default = vectorcraft_text::FontDb::global().face(&f.family, &f.style)?; + (default.version != f.version).then(|| f.version.clone()) + }; + let version = match self.versions.get(&key) { + Some(v) => v.clone(), + None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(), + None => named(&info.face), + }; + let (paint, opacity) = match paint { + Some(p) => { + let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true)); + (Some(paint), opacity) + } + None => (None, 1.0), + }; + let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); + let look = Look { + font: key, + family: info.family, + style: info.style, + version, + size: at.size, + h_scale: at.h_scale, + overprint_fill: stroke.is_none() && overprint, + overprint_stroke: stroke.is_some() && overprint, + fill: if stroke.is_none() { paint } else { None }, + stroke, + }; + let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); + // A glyph set vertically is drawn from its origin, which the file puts the position + // vector's vx (from the font's `W2`, else half its width from `W` or `DW`) to the left of + // the column's centre (ISO 32000-1 §9.7.4.3); vertical type centres each glyph on the + // column. Half an em only for a full-width glyph (#722). + let top = upright.then(|| { + let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(glyph)); + m * kurbo::Point::new(vx, 880.0) + }); + let place = |line: &mut TextLine| match top { + Some(top) => line.push_upright(&look, at, opacity, Upright { top }, &text), + None => line.push(&look, at, opacity, advance, &text), + }; + self.flush_glyphs(); + // A stroke over the glyph just filled (fill and stroke rendering). + if let (Some(s), Some(line)) = (&look.stroke, &mut self.text) + && look.fill.is_none() + && line.stroke_last(key, at, s.clone(), overprint) + { + return true; + } + if !self.text.as_mut().is_some_and(place) { + self.flush_text(); + let mut line = TextLine::new(at, opacity); + place(&mut line); + self.text = Some(line); + } + // Its ink: a stroke the file draws as outlines of the glyphs is told by it. + let mut ink = o.outline(); + if let Some(line) = &mut self.text + && !ink.elements().is_empty() + { + ink.apply_affine(m); + line.ink.push(ink.bounding_box()); + } + true + } +} diff --git a/crates/pdf/src/import/cid.rs b/crates/pdf/src/import/cid.rs new file mode 100644 index 000000000..ee6684d22 --- /dev/null +++ b/crates/pdf/src/import/cid.rs @@ -0,0 +1,223 @@ +use super::*; + +/// How many paths the interpreter draws on `page` (each fill and each stroke), leaving out those +/// of soft masks, pattern cells and type 3 glyphs, which [`Builder`] reads nested. `None` when the +/// interpreter fails. +pub(super) fn drawn_paths<'a>(page: &'a Page<'a>, mut ctx: Context<'a>) -> Option { + struct Count(usize); + impl<'a> Device<'a> for Count { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: craft_pdf::interpret::BlendMode) {} + // As `Builder::draw_path` takes them: each fill and each stroke. + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &craft_pdf::interpret::Paint<'a>, _: &PathDrawMode) { + self.0 += 1; + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: craft_pdf::interpret::BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &craft_pdf::interpret::Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let mut count = Count(0); + craft_pdf::interpret_page(page, &mut ctx, &mut count).ok()?; + Some(count.0) +} + +/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes +/// Hiragino): each glyph's CID from the font's charset, then Adobe's CID → Unicode table of its +/// character collection (Japan1, GB1, CNS1, Korea1). +pub(crate) struct CidText { + /// CID of each glyph. + cids: Vec, + table: craft_pdf::interpret::hayro_cmap::CMap, +} + +impl CidText { + pub(crate) fn of(data: &[u8]) -> Option { + // An embedded CFF of a few CJK glyphs is kilobytes; whole fonts are some megabytes. + if data.len() > 64 << 20 { + return None; + } + use craft_pdf::interpret::hayro_cmap::{CMap, CMapName, load_embedded}; + use skrifa::raw::ps::cff::{CffFontRef, dict, v1::Cff}; + use skrifa::raw::{FontData, FontRead}; + let cff = Cff::read(FontData::new(data)).ok()?; + let top = cff.top_dicts().get(0)?; + let ordering = dict::entries(top, None).filter_map(Result::ok).find_map(|e| match e { + dict::Entry::Ros { ordering, .. } => Some(ordering), + _ => None, + })?; + let name = match cff.string(ordering)? { + b"Japan1" => CMapName::AdobeJapan1Ucs2, + b"GB1" => CMapName::AdobeGb1Ucs2, + b"CNS1" => CMapName::AdobeCns1Ucs2, + b"Korea1" => CMapName::AdobeKorea1Ucs2, + _ => return None, + }; + let table = CMap::parse(load_embedded(name)?, load_embedded)?; + let charset = CffFontRef::new(data, 0, None).ok()?.charset()?; + let cids = (0..charset.num_glyphs().min(65_536)).map(|g| charset.string_id(skrifa::GlyphId::new(g)).map_or(0, |s| s.to_u16())).collect(); + Some(Self { cids, table }) + } + + pub(super) fn unicode(&self, glyph: u32) -> Option { + let cid = *self.cids.get(glyph as usize)?; + self.table.lookup_bf_string(u32::from(cid)) + } +} + +/// A ToUnicode value that names no character: U+FFFD alone (#708). +pub(super) fn replacement(u: &craft_pdf::interpret::hayro_cmap::BfString) -> bool { + use craft_pdf::interpret::hayro_cmap::BfString; + match u { + BfString::Char(c) => *c == char::REPLACEMENT_CHARACTER, + BfString::String(s) => !s.is_empty() && s.chars().all(|c| c == char::REPLACEMENT_CHARACTER), + } +} + +/// The character of each glyph of an embedded TrueType or OpenType font program, from its own +/// `cmap` (the first character mapped to each glyph); `None` for a bare CFF or a font without one. +pub(crate) fn font_chars(data: &[u8]) -> Option> { + use skrifa::MetadataProvider; + // As for CidText: whole fonts are some megabytes. + if data.len() > 64 << 20 { + return None; + } + let font = skrifa::FontRef::new(data).ok()?; + let mut chars = HashMap::new(); + for (c, g) in font.charmap().mappings() { + if let Some(c) = char::from_u32(c).filter(|c| !c.is_control()) { + chars.entry(g.to_u32()).or_insert(c); + } + } + (!chars.is_empty()).then_some(chars) +} + +/// The ideographs of the Kangxi radicals U+2F00–U+2FD5 (their NFKC forms): the CID → Unicode +/// tables of some PDFs map 龍 to the radical ⿓, which looks the same but isn't the character. +const KANGXI: &str = "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角言谷豆豕豸貝赤走足身車辛辰辵邑酉釆里金長門阜隶隹雨靑非面革韋韭音頁風飛食首香馬骨高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠"; + +/// How far glyph `o` (the PDF's, for `text`) is from the installed `face`'s glyph for it: the +/// largest difference between their boxes, in thousandths of an em. `None` when they are clearly +/// different glyphs (or the face lacks the character); `Some(0)` when there is nothing to compare +/// (several characters, a vertical form, no ink in either). +fn glyph_deviation(face: &vectorcraft_text::FontFace, text: &str, o: &craft_pdf::interpret::font::OutlineGlyph, vertical: bool) -> Option { + let Some(c) = comparable(text, vertical) else { return Some(0.0) }; + let gid = face.glyph_for(c); + if gid == 0 { + return None; + } + let embedded = o.outline(); + let installed = vectorcraft_text::FontDb::global().outline(face, gid); + match (embedded.elements().is_empty(), installed.elements().is_empty()) { + (true, true) => Some(0.0), + (false, false) => { + let e = embedded.bounding_box(); + let k = 1000.0 / face.units_per_em(); + // Installed outlines are y-down; the PDF's glyph space is y-up. + let i = installed.bounding_box(); + let i = Rect::new(i.x0 * k, -i.y1 * k, i.x1 * k, -i.y0 * k); + let off = [(e.x0, i.x0), (e.x1, i.x1), (e.y0, i.y0), (e.y1, i.y1)].iter().fold(0.0f64, |m, (a, b)| m.max((a - b).abs())); + (off <= 100.0).then_some(off) + } + _ => None, + } +} + +/// The face of `face`'s family that draws glyph `o` (for `text`): `face` itself, or the style whose +/// glyph is closest (a variable font's named instances, or a family whose PostScript names don't +/// tell its styles apart, draw a character differently in each style). `None`: no style of the +/// family has that glyph, so the font isn't the installed one. +pub(super) fn matching_face( + face: &Arc, + text: &str, + o: &craft_pdf::interpret::font::OutlineGlyph, + vertical: bool, +) -> Option> { + let own = glyph_deviation(face, text, o, vertical); + if own == Some(0.0) { + return Some(face.clone()); + } + let db = vectorcraft_text::FontDb::global(); + // Other installed versions of its family and style (a font library keeps old ones), then the + // family's other styles. + let versions = db.versions(face).into_iter().skip(1).take(16); + let styles = + db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s)); + let others = versions.chain(styles); + let mut best = own.map(|d| (d, face.clone())); + for f in others { + if let Some(d) = glyph_deviation(&f, text, o, vertical) + && best.as_ref().is_none_or(|(b, _)| d < *b) + { + best = Some((d, f)); + } + } + best.map(|(_, f)| f) +} + +/// The characters a glyph no mapping names is looked for among (#811): those single-byte fonts +/// encode (ASCII, Latin-1, Latin Extended-A, general punctuation, the euro and trade mark signs, +/// the fi and fl ligatures). +const IDENTIFIABLE: [std::ops::RangeInclusive; 6] = + [0x21..=0x7E, 0xA1..=0x17F, 0x2010..=0x2044, 0x20AC..=0x20AC, 0x2122..=0x2122, 0xFB01..=0xFB02]; + +/// The character whose glyph in installed `face` draws `embedded` (a glyph outline in thousandths +/// of an em, y up): the first of [`IDENTIFIABLE`] with the same outline, point for point. A subset +/// font embedded without a character map still carries the font's own outlines, so a glyph whose +/// subset code names nothing is found this way. `None` when no glyph matches (another version of +/// the font, or a glyph that is no character), and for empty glyphs, which every space draws. +pub(crate) fn identify_glyph(face: &vectorcraft_text::FontFace, embedded: &BezPath) -> Option { + if embedded.elements().is_empty() { + return None; + } + let k = 1000.0 / face.units_per_em(); + // Installed outlines are y-down in font units. + let to_pdf = Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]); + let db = vectorcraft_text::FontDb::global(); + IDENTIFIABLE.iter().cloned().flatten().filter_map(char::from_u32).find(|c| { + let gid = face.glyph_for(*c); + gid != 0 && same_shape(embedded, &(to_pdf * db.outline(face, gid).as_ref().clone())) + }) +} + +/// Do outlines `a` and `b` draw the same segments, every point within 2/1000 em (closing +/// commands aside)? +fn same_shape(a: &BezPath, b: &BezPath) -> bool { + let drawn = |p: &BezPath| p.elements().iter().filter(|e| !matches!(e, PathEl::ClosePath)).copied().collect::>(); + let (a, b) = (drawn(a), drawn(b)); + let near = |p: kurbo::Point, q: kurbo::Point| (p - q).hypot() <= 2.0; + a.len() == b.len() + && a.iter().zip(&b).all(|(x, y)| match (x, y) { + (PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(*p, *q), + (PathEl::QuadTo(p1, p2), PathEl::QuadTo(q1, q2)) => near(*p1, *q1) && near(*p2, *q2), + (PathEl::CurveTo(p1, p2, p3), PathEl::CurveTo(q1, q2, q3)) => near(*p1, *q1) && near(*p2, *q2) && near(*p3, *q3), + _ => false, + }) +} + +/// The character of glyph `text` when its outline can be compared with an installed font's: a +/// single character, and not CJK punctuation set vertically (its vertical form differs). +pub(super) fn comparable(text: &str, vertical: bool) -> Option { + let mut chars = text.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return None }; + if vertical && matches!(c as u32, 0x2014 | 0x2015 | 0x2025 | 0x2026 | 0x3000..=0x303F | 0x30FC | 0xFE30..=0xFE4F | 0xFF00..=0xFF65) { + return None; + } + Some(c) +} + +pub(crate) fn unify_radical(c: char) -> char { + let i = (c as u32).wrapping_sub(0x2F00); + if i < 214 { KANGXI.chars().nth(i as usize).unwrap_or(c) } else { c } +} + +/// The font a PDF font draws with ([`Builder::font_name`]). +#[derive(Clone)] +pub(super) struct FontInfo { + pub(super) family: String, + pub(super) style: String, + /// The installed face of its PostScript name. + pub(super) face: Option>, +} diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs new file mode 100644 index 000000000..d9721f01f --- /dev/null +++ b/crates/pdf/src/import/device.rs @@ -0,0 +1,368 @@ +use super::entry::{INVISIBLE_TEXT_NOTE, MAX_NESTED, OUTLINED_TEXT_NOTE, UNSORTED, WHITE_KEY}; +use super::state::{ + Builder, Decoded, Frame, FrameKind, GlyphRun, blend, fill_rule, line_width, mean_scale, resize_alpha, rgba_png, same_outline, stroke_layer, +}; +use super::*; + +impl<'a> Device<'a> for Builder<'_> { + fn set_soft_mask(&mut self, mask: Option>) { + let spec = mask.map(|m| self.soft_mask(&m)); + let same = match (&spec, &self.mask) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + }; + if !same { + self.flush(); + self.mask = spec; + } + } + + fn set_blend_mode(&mut self, blend_mode: craft_pdf::interpret::BlendMode) { + let b = blend(blend_mode); + if b != self.blend { + self.flush_glyphs(); + self.flush_text(); + self.blend = b; + } + } + + fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) { + self.flush_glyphs(); + let walked = self.walked(false); + if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) { + self.pending = false; + return; + } + self.flush_text(); + self.pending = false; + let overprint = self.path_overprint(); + let mut bp = path.clone(); + bp.apply_affine(transform); + if bp.elements().is_empty() { + return; + } + let shading = match paint { + craft_pdf::interpret::Paint::Pattern(p) => match p.as_ref() { + Pattern::Shading(sp) => Some(sp), + Pattern::Tiling(_) => None, + }, + craft_pdf::interpret::Paint::Color(_) => None, + }; + // Patch and triangle meshes are gradient meshes. + if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading) + && let Some(meshes) = mesh_shading(sp, &mut self.colors, walked.and_then(|w| self.scan.ink(w, false, true))) + { + self.draw_meshes(meshes, &bp, sp.opacity); + return; + } + let pd = PathData::from_bezpath(&bp); + let stroke = matches!(draw_mode, PathDrawMode::Stroke(_)); + let (paint, opacity) = self.paint(paint, stroke, walked); + // A gradient that doesn't extend past its ends paints only between them. + let width = match draw_mode { + PathDrawMode::Stroke(p) => line_width(p, mean_scale(transform)), + PathDrawMode::Fill(_) => 0.0, + }; + let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width))); + let wrap = |b: &mut Self, n: Node| match &band { + Some(band) => { + let clip = b.clip_node(band, FillRule::EvenOdd); + let id = b.id(); + clipped(clip, n, id) + } + None => n, + }; + match draw_mode { + PathDrawMode::Fill(rule) => { + let mut f = FillLayer::new(paint); + (f.opacity, f.overprint) = (opacity, overprint); + let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() }); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = fill_rule(*rule); + } + self.last_fill = Some((n.id, bp)); + let n = wrap(self, n); + self.push_node(n); + } + PathDrawMode::Stroke(props) => { + let mut st = stroke_layer(paint, opacity, props, mean_scale(transform)); + st.overprint = overprint; + // Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke + // of the same outline) becomes one object: a path with a fill and a live stroke. + let blend = self.blend; + let fill = self.last_fill.take(); + let last = match self.mask { + Some(_) => self.masked.last_mut(), + None => self.stack.last_mut().and_then(|f| f.children.last_mut()), + }; + if band.is_none() + && let Some(last) = last + && let Some((id, outline)) = fill + && last.id == id + && last.blend == blend + // Opacity or a mask of its own came from a transparency group the fill was + // drawn in and this stroke wasn't. + && last.opacity == 1.0 + && last.extras().mask.is_none() + && last.appearance.stroke().is_none() + && same_outline(&outline, &bp) + { + let last = Arc::make_mut(last); + // The stroke's outline: closing it changes the stroke, not the fill. + if let Some(p) = last.path_data_mut() { + *p = pd; + } + last.appearance.items.push(AppearanceItem::Stroke(st)); + return; + } + let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); + let n = wrap(self, n); + self.push_node(n); + } + } + } + + fn push_clip_path(&mut self, clip_path: &ClipPath) { + self.flush(); + // A form's box right after its transparency group: the group's isolate and knockout + // flags are the next ones read from the file. + if std::mem::take(&mut self.pending) { + let flags = self.scan.groups.get(self.group_at).copied(); + self.group_at += 1; + if let Some((i, k)) = flags + && let Some(Frame { kind: FrameKind::Group { isolate, knockout, .. }, .. }) = self.stack.last_mut() + { + (*isolate, *knockout) = (i, k); + } + } + // Clips that contain the whole page do nothing visible; don't create groups for them. Only + // a polygon that really covers it counts: a diamond touching the page edges has the page + // as its bounding box but cuts its corners away (#1542). + let redundant = clip_path.path.elements().len() <= 6 + && self.page.area() > 0.0 + && vectorcraft_geom::hit::polygon_covers_rect(&clip_path.path, self.page, 0.01); + let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) }; + self.stack.push(Frame::new(kind)); + } + + fn push_transparency_group(&mut self, opacity: f32, mask: Option>, blend_mode: craft_pdf::interpret::BlendMode) { + self.flush(); + let mask = mask.map(|m| self.soft_mask(&m)); + self.stack.push(Frame::new(FrameKind::Group { opacity, blend: blend(blend_mode), mask, isolate: false, knockout: false })); + self.pending = self.nested == 0; + // Blend mode applies to the group as a whole, not to its children. + self.blend = BlendMode::Normal; + } + + fn draw_glyph( + &mut self, + glyph: &Glyph<'a>, + transform: Affine, + glyph_transform: Affine, + paint: &craft_pdf::interpret::Paint<'a>, + draw_mode: &GlyphDrawMode, + ) { + // Clipping text paints nothing; its outlines arrive separately as a clip path. + if matches!(draw_mode, GlyphDrawMode::Clip) { + return; + } + self.pending = false; + let stroke = match draw_mode { + // Invisible text (a scanned page's recognized text, over the scan) draws nothing but + // is still text: type with neither fill nor stroke, so it stays searchable. + GlyphDrawMode::Invisible => { + let typed = match glyph { + Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false), + Glyph::Type3(_) => false, + }; + if !typed { + self.warn(INVISIBLE_TEXT_NOTE); + } + return; + } + GlyphDrawMode::Fill | GlyphDrawMode::Clip => None, + GlyphDrawMode::Stroke(p) => Some(p.clone()), + }; + match glyph { + Glyph::Outline(o) => { + let scale = mean_scale(transform); + let overprint = self.glyph_overprint(stroke.is_some()); + if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { + return; + } + self.flush_text(); + let mut bp = o.outline(); + bp.apply_affine(transform * glyph_transform); + let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true)); + let same = self.glyphs.as_ref().is_some_and(|r| { + r.paint == paint + && r.opacity == opacity + && r.overprint == overprint + && r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width) + && (r.scale - scale).abs() < 1e-9 + }); + if !same { + self.flush_glyphs(); + self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint }); + } + if let Some(r) = &mut self.glyphs { + r.path.extend(bp.iter()); + } + self.warn(OUTLINED_TEXT_NOTE); + } + Glyph::Type3(t3) => { + if self.nested >= MAX_NESTED { + return; + } + self.flush(); + self.nested += 1; + t3.interpret(self, transform, glyph_transform, paint); + self.nested -= 1; + } + } + } + + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + self.flush_glyphs(); + self.flush_text(); + self.pending = false; + match image { + Image::Raster(r) => { + let white = self.white_images; + // Inside an alpha mask the same image is read again, white (its own cache key). + let key = craft_pdf::interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 }; + let st = r.stream(); + let (w, h) = (r.width(), r.height()); + // CMYK images keep their samples (read once per image). + let known = !white && self.cmyk_keys.contains(&key); + let cmyk = if known || white { + None + } else { + crate::import_image::cmyk(st, w, h).unwrap_or_else(|why| { + self.warn(why); + None + }) + }; + if known || cmyk.is_some() { + self.cmyk_keys.insert(key); + // Its mask (read by the interpreter, whatever its kind) becomes an opacity + // mask, so the inks stay as they are. + let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None }; + return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask); + } + // JPEG passthrough for plain DeviceRGB/DeviceGray DCT images. + let dict = st.dict(); + let filters = st.filters(); + let cs_ok = dict.get::>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray")); + let jpeg = !white + && filters.len() == 1 + && matches!(filters[0], craft_pdf::syntax::Filter::DctDecode) + && cs_ok + && !dict.contains_key(b"SMask") + && !dict.contains_key(b"Mask") + && !dict.contains_key(b"Decode"); + if jpeg { + let bytes = st.raw_data().to_vec(); + self.add_image(key, || Some((ImageBlob::new("image/jpeg", bytes), w, h)), transform); + return; + } + let mut decoded: Option = None; + r.with_rgba( + |img, alpha| { + let (w, h, sf) = (img.width(), img.height(), img.scale_factors()); + let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]); + let rgba: Vec = match img { + _ if white => a.into_iter().flat_map(|a| [255, 255, 255, a]).collect(), + ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(), + ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(), + }; + decoded = Some((rgba, w, h, sf)); + }, + None, + ); + let Some((rgba, w, h, sf)) = decoded else { + self.warn("an image could not be decoded and was skipped"); + return; + }; + let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); + self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); + } + Image::Stencil(s) => { + let white = self.white_images; + let key = craft_pdf::interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 }; + let mut decoded: Option = None; + s.with_stencil( + |luma, paint| { + let rgb = match paint { + _ if white => [255, 255, 255, 255], + craft_pdf::interpret::Paint::Color(c) => c.to_rgba().to_rgba8(), + _ => [0, 0, 0, 255], + }; + let rgba = luma.data.iter().flat_map(|a| [rgb[0], rgb[1], rgb[2], *a]).collect(); + decoded = Some((rgba, luma.width, luma.height, luma.scale_factors)); + }, + None, + ); + let Some((rgba, w, h, sf)) = decoded else { return }; + let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); + self.add_image(key ^ 0x5_7e9c, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); + } + } + } + + fn pop_clip_path(&mut self) { + self.flush(); + self.pending = false; + self.pop_frame(); + } + + fn pop_transparency_group(&mut self) { + self.flush(); + self.pending = false; + self.pop_frame(); + } + + fn begin_marked_content(&mut self, tag: &[u8], _mcid: Option) { + if self.nested > 0 { + return; + } + let at = self.tag_at; + self.tag_at += 1; + let group = match self.scan.tags.get(at) { + Some((t, g)) if self.aligned && *t == tag_key(tag) => *g, + _ => { + if self.route && self.aligned { + self.aligned = false; + let went = if self.unsure { "a hidden, non-printing layer of its page" } else { "its page's layer" }; + self.warn(&format!("some art couldn't be told apart by layer and went to {went}")); + } + self.unsure.then_some(UNSORTED) + } + } + .filter(|_| self.route); + // Only a layer's marked content ends the runs gathered so far: other tags (a span with its + // actual text, an artifact) leave a line of type whole. + if group.is_some() { + self.flush(); + } + if let Some(g) = group + && self.stack.len() > 1 + && let Some(f) = self.stack.last_mut() + { + f.group.get_or_insert(g); + } + self.marked.push(group); + } + + fn end_marked_content(&mut self) { + if self.nested > 0 { + return; + } + // The runs gathered within a layer's sequence are its art: finish them before it ends. + if self.marked.last().copied().flatten().is_some() { + self.flush(); + } + self.marked.pop(); + } +} diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs new file mode 100644 index 000000000..d52cd576e --- /dev/null +++ b/crates/pdf/src/import/entry.rs @@ -0,0 +1,530 @@ +use super::cid::drawn_paths; +use super::state::Builder; +use super::*; + +/// Mixed into an image's cache key when it is read white inside an alpha soft mask. +pub(super) const WHITE_KEY: u128 = 0x7717_e000_a1fa; + +/// The name of the paths text imports as. +pub(super) const TEXT_OUTLINES: &str = ""; + +/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects. +pub(super) const OVERPRINT_UNREAD: &str = "the overprint of a page's objects couldn't be read: they knock out"; + +/// The document overprints as overprint mode 1 does. +pub(super) const OVERPRINT_ZERO_MODE: &str = + "overprint in the mode where a process colour's zero tints knock out (/OPM 0) is imported as overprint, where they don't"; + +/// The deepest soft masks, patterns and type 3 glyphs are read within each other. +pub(super) const MAX_NESTED: u32 = 8; + +/// A tiling pattern read this many times with the same art is taken to always draw it. +pub(super) const PATTERN_REUSE: u32 = 16; + +/// Stands for the group of art whose group couldn't be told (see [`Builder::unsure`]): it goes +/// to a hidden, non-printing layer of its page. +pub(super) const UNSORTED: usize = usize::MAX; + +/// Import a PDF (or PDF-compatible `.ai`) with default options. +pub fn import(bytes: &[u8]) -> Result { + import_with_report(bytes, &ImportOptions::default()).map(|r| r.document) +} + +/// A layer of the imported document, in paint order. +enum Slot { + /// A page's art outside optional content groups. + Page(Box), + /// An optional content group (index into [`Ocgs::list`]). + Group(usize), +} + +/// What each group's layer holds, in paint order: its sublayers (their groups) and, where its +/// own group is listed, its art. +type Nesting = HashMap>; + +/// Group `g` paints (its first art): note where its art goes in its layer, and where that layer +/// goes when it is new: in its parent's layer (placed the same way) or, for a top-level group, in +/// `slots`. +fn place_group(g: usize, ocgs: &Ocgs, slots: &mut Vec, nesting: &mut Nesting) { + let placed = nesting.contains_key(&g); + nesting.entry(g).or_default().push(g); + if placed { + return; + } + let mut child = g; + // Each parent is placed before its sublayers, so the chain ends (within the nesting depth). + for _ in 0..=MAX_NESTING { + let Some(parent) = ocgs.list.get(child).and_then(|o| o.parent) else { break }; + let placed = nesting.contains_key(&parent); + nesting.entry(parent).or_default().push(child); + if placed { + return; + } + child = parent; + } + slots.push(Slot::Group(child)); +} + +/// The top-level group that group `g` is listed under (`g` itself when it isn't a sublayer). +fn top_group(g: usize, ocgs: &Ocgs) -> usize { + let mut top = g; + for _ in 0..=MAX_NESTING { + match ocgs.list.get(top).and_then(|o| o.parent) { + Some(parent) => top = parent, + None => break, + } + } + top +} + +/// Merge the top-level groups a page marks (`page`, in the order it first marks each, its empty +/// ones too) into the stacking order of the pages before (`order`, bottom first; `known`: its +/// groups). A group new to it goes right below the first group the page marks after it that it +/// has: a layer whose art starts on a later page keeps its place among the others (#508). +fn merge_order(order: &mut Vec, known: &mut HashSet, page: &[usize]) { + let mut above = None; + for &g in page.iter().rev() { + if known.insert(g) { + let at = above.and_then(|a| order.iter().position(|&x| x == a)).unwrap_or(order.len()); + order.insert(at, g); + } + above = Some(g); + } +} + +/// The layer of group `g`, in `color`: its art and sublayers. `None` for a group that isn't read. +fn group_layer( + b: &mut Builder<'_>, + g: usize, + ocgs: &Ocgs, + nesting: &Nesting, + art: &mut HashMap>>, + color: LayerColor, +) -> Option { + let ocg = ocgs.list.get(g)?; + let mut l = Node::layer(b.id(), &ocg.name, color); + (l.visible, l.locked) = (ocg.on, ocg.locked); + let mut children = vec![]; + for &c in nesting.get(&g).into_iter().flatten() { + if c == g { + children.extend(art.remove(&g).unwrap_or_default()); + } else if let Some(sub) = group_layer(b, c, ocgs, nesting, art, color) { + children.push(Arc::new(sub)); + } + } + if let NodeKind::Layer { children: c, printable, .. } = &mut l.kind { + *c = children; + *printable = ocg.print; + } + Some(l) +} + +/// The note on a file carrying an editor's private data (#472): its PDF part, the one read, holds +/// only the art on its artboards (art on the pasteboard is in the private data alone). +pub const OFF_ARTBOARD_NOTE: &str = + "only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open"; + +/// Warning: art reaching past the page box was clipped to it. +pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it"; + +/// Warning: glyphs were kept as outlines rather than read as type. +pub const OUTLINED_TEXT_NOTE: &str = "text was converted to outlines"; + +/// Warning: text drawn invisibly (text rendering mode 3, as a scanned page's OCR layer is) that +/// can't come in as type (text read as outlines, or a Type 3 font) was left out: as outlines it +/// would draw nothing (#2675). +pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's searchable OCR layer) that couldn't be read as type was left out"; + +/// Warning: glyphs that differ from the installed font of their name were kept as outlines. +pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines"; + +/// The end of the warning that glyphs whose characters the file doesn't name were kept as +/// outlines, after "text in" and the font's name. +pub const UNNAMED_CHARACTERS_NOTE: &str = "whose characters the file doesn't name (U+FFFD) was kept as outlines"; + +/// Is `note` one of the warnings that glyphs were kept as outlines ([`OUTLINED_TEXT_NOTE`], +/// [`UNMATCHED_GLYPHS_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? +pub fn is_outlines_note(note: &str) -> bool { + note == OUTLINED_TEXT_NOTE || note == UNMATCHED_GLYPHS_NOTE || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) +} + +/// Is `n` glyphs kept as outlines (a path the import names ``)? +pub fn is_glyph_outlines(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { .. }) && n.name.as_deref() == Some(TEXT_OUTLINES) +} + +/// Does `doc` hold glyphs kept as outlines ([`is_glyph_outlines`])? +pub fn holds_outlines(doc: &Document) -> bool { + let mut any = false; + doc.walk(|n| any |= is_glyph_outlines(n)); + any +} + +/// Import a PDF, returning the document plus warnings about content that was approximated or skipped. +pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { + // The interpreter draws forms, patterns, glyphs and masks inside one another by recursion, in + // large stack frames: it caps that (50 deep), and gets room for the cap whatever thread asks. + vectorcraft_doc::depth::with_deep_stack(|| read(bytes, opts)) +} + +/// [`import_with_report`] on the thread that asks. +pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result { + read_with_reader_options(bytes, opts, &crate::pages::open_options()) +} + +#[cfg(test)] +pub(crate) fn import_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_pdf::OpenOptions) -> Result { + vectorcraft_doc::depth::with_deep_stack(|| read_with_reader_options(bytes, opts, reader)) +} + +fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_pdf::OpenOptions) -> Result { + let original = crate::pages::open_with_options(bytes, opts.password.as_deref(), reader)?; + let picked = crate::pages::picked(opts, original.pages().len())?; + let mut ocgs = Ocgs::read(&original); + let route = opts.layers && !ocgs.list.is_empty(); + // Content that is off imports as hidden layers: an update of the file turns every group on + // (unless a form is hidden by its own group: its art couldn't be told apart). + let hide = route && ocgs.skips_any(); + let mut hidden_form = false; + if hide { + for page in picked.iter().filter_map(|&n| original.pages().get(n)) { + hidden_form |= hides_forms(page, &mut ocgs, original.decode_limits().max_decoded_stream())?; + if hidden_form { + break; + } + } + } + let turned_on = (hide && !hidden_form) + .then(|| all_on(bytes, &original)) + .flatten() + .and_then(|b| crate::pages::open_with_options(&b, opts.password.as_deref(), reader).ok()) + .filter(|p| Ocgs::read(p).list.is_empty()); + let mut notes = vec![]; + if hide && turned_on.is_none() { + notes.push("the art of hidden layers couldn't be read and was left out".to_string()); + } + let all_on = turned_on.is_some(); + let pdf = turned_on.unwrap_or(original); + let pages = pdf.pages(); + + let sink: Arc>> = Arc::default(); + let sink2 = sink.clone(); + let incomplete = Arc::new(AtomicBool::new(false)); + let incomplete_sink = incomplete.clone(); + let settings = InterpreterSettings { + warning_sink: Arc::new(move |w| { + let msg = match w { + InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped", + InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped", + InterpreterWarning::ContentTruncated => { + incomplete_sink.store(true, Ordering::Relaxed); + return; + } + }; + if let Ok(mut v) = sink2.lock() + && !v.iter().any(|x| x == msg) + { + v.push(msg.to_string()); + } + }), + render_annotations: false, + ..Default::default() + }; + + let mut doc = Document::new(1.0, 1.0); + doc.title = "Imported PDF".into(); + doc.artboards.clear(); + doc.layers.clear(); + let taken: Vec = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect(); + let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as, route); + // The art of a group that is off (drawn as `all_on` turned it on) or doesn't print mustn't + // show or print on its page's layer. + b.unsure = route && ocgs.list.iter().any(|g| (all_on && !g.on) || !g.print); + b.taken = taken; + b.warnings = notes; + b.colors.uncalibrated_rgb = opts.uncalibrated_rgb && picked.iter().filter_map(|&n| pages.get(n)).any(crate::pages::has_private_data); + let cache = InterpreterCache::new(); + let mut x = 0.0; + // Where the next page's art may begin (a `.ai` page's art reaches past its artboard). + let mut clear = f64::NEG_INFINITY; + // Every page only text over private data: a file saved without its PDF part shows a notice + // there, not its art. A page that states where its art is isn't that notice. + let (mut only_text, mut art_boxes) = (true, false); + let mut slots: Vec = vec![]; + let mut group_art: HashMap>> = HashMap::new(); + let mut nesting = Nesting::new(); + // The top-level groups in the order the pages mark them, bottom first. + let (mut order, mut known) = (vec![], HashSet::new()); + for (i, &number) in picked.iter().enumerate() { + let Some(page) = pages.get(number) else { continue }; + // Artboards on the trim boxes have the bleed boxes round them as the document's bleed + // (the widest of the pages' on each side), so the art there shows and prints as bleed. + if opts.crop == CropTo::Trim { + for (side, b) in doc.setup.bleed.iter_mut().zip(crate::pages::bleed(page)) { + *side = side.max(b); + } + } + // The chosen box sits at (x, 0); the page draws round it. + let (init, frame) = crate::pages::frame(page, opts.crop); + let mut ab = Rect::new(x, 0.0, x + frame.width(), frame.height()); + let xf = Affine::translate((x - frame.x0, -frame.y0)) * init; + let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone()); + b.page = ab; + // The first id of this page's art. + let first_id = b.next; + let mut scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts, pdf.decode_limits().max_decoded_stream())?; + // The walk's ink names pick between spaces of inks that look the same to the interpreter. A + // page keeps them only when it uses such an ink and the interpreter draws as many paths + // there as the walk counts. + let tied = scan.inks.iter().any(|n| b.colors.tied(n)); + if !(tied && drawn_paths(page, Context::new(xf, ab, &cache, pdf.xref(), settings.clone())) == Some(scan.paints.len())) { + scan.inks.clear(); + } + if route { + let mut seen = HashSet::new(); + let marked: Vec = scan.tags.iter().filter_map(|(_, g)| g.map(|g| top_group(g, &ocgs))).filter(|g| seen.insert(*g)).collect(); + merge_order(&mut order, &mut known, &marked); + } + b.begin_page(scan); + craft_pdf::interpret_page(page, &mut ctx, &mut b).map_err(|e| PdfError::Parse(e.to_string()))?; + if incomplete.load(Ordering::Relaxed) { + return Err(crate::import_scan::incomplete_content()); + } + let mut parts = b.end_page(); + // A PDF-compatible `.ai` (the editor's private data next to the PDF art). + let ai = crate::pages::has_private_data(page); + if ai { + drop_page_fill(&mut parts, xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop))); + b.warn(OFF_ARTBOARD_NOTE); + } + // A file with several artboards writes, on each page, the art of its neighbours that + // reaches into the page's box: art lying wholly outside this page is theirs (each page + // draws it shifted by its own artboard spacing), so keep it only where it belongs. + if picked.len() > 1 { + for (_, art) in &mut parts { + art.retain(|n| n.visual_bounds().is_none_or(|r| r.x0 <= ab.x1 && r.x1 >= ab.x0 && r.y0 <= ab.y1 && r.y1 >= ab.y0)); + } + parts.retain(|(_, art)| !art.is_empty()); + } + // Viewers show a page through its crop box, so art reaching past it (a mock-up backdrop + // around the page, say) never showed. A clip to the page box that the file states is + // dropped as redundant (by hayro and by `push_clip_path`), so restore the box here: + // each object reaching past it keeps its own clip, which Fit to Artwork Bounds and + // `CropTo::Bounding` then respect. + // The crop box is what viewers show, whichever box the artboard takes. Not in a + // PDF-compatible `.ai`: its PDF part stands in for the editable document, whose art + // crossing the artboard edge isn't clipped, so it opens whole (#646). + let shown = xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop)); + let page_box = shown.inflate(0.01, 0.01); + let mut clipped = false; + for (_, art) in parts.iter_mut().filter(|_| !ai) { + for n in art.iter_mut() { + if n.visual_bounds().is_some_and(|r| !contains(page_box, r)) { + let clip = Arc::new(b.clip_node(&shown.to_path(0.1), FillRule::NonZero)); + *n = Arc::new(Node::new(b.id(), NodeKind::Group { children: vec![clip, n.clone()], clip: true })); + clipped = true; + } + } + } + if clipped { + b.warn(PAST_PAGE_NOTE); + } + // A `.ai` page keeps its art past the artboard edge, unclipped (#646), so a page whose art + // reaches far to the left would lay it over the page before it in the row: there it would + // be taken for that page's art (by the check against the editing data, which then finds + // art its layers don't have there, and by `place_pages`). Such a page moves right until its + // art clears the art before it. + let mut art_right = f64::NEG_INFINITY; + if ai && let Some(art) = parts.iter().flat_map(|(_, v)| v.iter()).filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) { + // Art at a coordinate that isn't a number (a damaged file) moves nothing. + let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0); + if dx > 0.0 { + let shift = Affine::translate((dx, 0.0)); + for (_, v) in &mut parts { + for n in v.iter_mut() { + Arc::make_mut(n).transform(shift, false); + } + } + // What the page's lines of type were is told where their glyphs are (the outlines + // stroked on them are looked for there), so that moves with them. + for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) { + for ink in &mut facts.ink { + *ink = *ink + kurbo::Vec2::new(dx, 0.0); + } + } + ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1); + } + art_right = art.x1 + dx; + } + let children: Vec> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); + only_text &= ai && is_only_text(&children); + art_boxes |= crate::pages::states_art(page); + let mut right = ab.x1; + if opts.crop == CropTo::Bounding + && let Some(art) = vectorcraft_doc::live::nodes_bounds(&children) + { + right = right.max(art.x1); + ab = art; + } + x = right + opts.artboard_gap; + clear = right.max(art_right) + opts.artboard_gap; + doc.artboards.push(Artboard { + id: i as u32 + 1, + name: format!("Artboard {}", i + 1), + rect: ab, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }); + let page_layer = |b: &mut Builder<'_>, art: Vec>, shown: bool| { + let name = if shown { format!("Page {}", number + 1) } else { format!("Page {} (unsorted)", number + 1) }; + let mut layer = Node::layer(b.id(), &name, LayerColor::Preset((i % 27) as u8)); + layer.visible = shown; + if let NodeKind::Layer { children: c, printable, .. } = &mut layer.kind { + (*c, *printable) = (art, shown); + } + Slot::Page(Box::new(layer)) + }; + if parts.is_empty() { + slots.push(page_layer(&mut b, vec![], true)); + } + for (key, art) in parts { + match key { + None => slots.push(page_layer(&mut b, art, true)), + Some(UNSORTED) => slots.push(page_layer(&mut b, art, false)), + Some(g) => { + let into = group_art.entry(g).or_insert_with(|| { + place_group(g, &ocgs, &mut slots, &mut nesting); + vec![] + }); + into.extend(art); + } + } + } + } + if only_text && !art_boxes { + return Err(PdfError::PlaceholderOnly); + } + // A group the pages mark but draw nothing in is an empty layer, as the editor had it. + for &g in &order { + if !nesting.contains_key(&g) { + slots.push(Slot::Group(g)); + } + } + // The groups' layers stack as the pages mark them (the art outside them stays where it was + // drawn): a group that first paints on a later page doesn't go on top of the others. + let rank: HashMap = order.iter().enumerate().map(|(i, &g)| (g, i)).collect(); + let at: Vec = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect(); + let mut stacked: Vec = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect(); + stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX)); + for (i, g) in at.into_iter().zip(stacked) { + if let Some(s) = slots.get_mut(i) { + *s = Slot::Group(g); + } + } + for (n, slot) in slots.into_iter().enumerate() { + let mut layer = match slot { + Slot::Page(l) => *l, + // Sublayers take their top-level layer's colour. + Slot::Group(g) => match group_layer(&mut b, g, &ocgs, &nesting, &mut group_art, LayerColor::Preset((n % 27) as u8)) { + Some(l) => l, + None => continue, + }, + }; + if let Some(art) = layer.children_mut() { + crate::import_lines::rebuild(art, &b.lines); + } + doc.layers.push(Arc::new(layer)); + } + for (k, blob) in b.images.drain() { + doc.images.insert(k, blob); + } + // A file painted mostly in CMYK opens as a CMYK document (with CMYK default swatches). + if b.colors.cmyk_document() { + doc.color_mode = ColorMode::Cmyk; + let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + } + doc.swatches.extend(b.colors.swatches()); + // Tiling patterns are pattern swatches. + for p in b.patterns.drain(..) { + doc.swatches.push(Swatch { + name: p.name.clone(), + paint: Paint::Pattern { pattern: p.name.clone(), xf: Affine::IDENTITY }, + global: false, + spot: false, + }); + doc.patterns.push(p); + } + if b.colors.mixed { + b.warn("colours mixing several inks (DeviceN) were imported as RGB"); + } + if !b.missing_fonts.is_empty() { + let list = b.missing_fonts.join(", "); + b.warn(&format!("fonts that aren't available show in the fallback font until they are: {list}")); + } + // Before anything walks the tree by recursion (`fix_next_id` does): nested clips can be deeper + // than the stack goes. + let mut depth_notes = vec![]; + doc.limit_depth_noting(&mut depth_notes); + doc.fix_next_id(); + doc.reserve_ids(b.next); + + let mut warnings = b.warnings; + if let Ok(v) = sink.lock() { + for w in v.iter() { + if !warnings.contains(w) { + warnings.push(w.clone()); + } + } + } + warnings.extend(depth_notes); + let user_unit = picked.first().and_then(|&n| pages.get(n)).map_or(1.0, |page| f64::from(page.user_unit())); + Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text, user_unit }) +} + +/// The page a PDF-compatible `.ai` paints under its layers is the editor's page, not art: an opaque +/// white rectangle the size of the page (`page`, document space), outside every layer and before +/// any of them. Drop it from the page's art (`parts`, as [`Builder::end_page`] gives them). +fn drop_page_fill(parts: &mut Vec<(Option, Vec>)>, page: Rect) { + let layered = parts.iter().any(|(g, _)| g.is_some()); + let Some((None, art)) = parts.first_mut() else { return }; + // Not a spot colour: a white ink is art. + let fill = art.first().is_some_and(|n| { + layered + && n.blend == BlendMode::Normal + && white_cover(n, page) + && n.appearance.fill().is_some_and(|f| matches!(f.paint, Paint::Solid { swatch: None, .. })) + && n.geometric_bounds().is_some_and(|b| contains(page.inflate(0.5, 0.5), b)) + }); + if fill { + art.remove(0); + if art.is_empty() { + parts.remove(0); + } + } +} + +/// Is this art text and nothing else (in groups and clips), with some text? +fn is_only_text(nodes: &[Arc]) -> bool { + fn walk(nodes: &[Arc], text: &mut bool) -> bool { + nodes.iter().all(|n| match &n.kind { + NodeKind::Path { clipping: true, .. } => true, + NodeKind::Path { .. } if n.name.as_deref() == Some(TEXT_OUTLINES) => { + *text = true; + true + } + NodeKind::Text(_) => { + *text = true; + true + } + NodeKind::Group { children, .. } => walk(children, text), + _ => false, + }) + } + let mut text = false; + walk(nodes, &mut text) && text +} diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs new file mode 100644 index 000000000..cbe9f7a02 --- /dev/null +++ b/crates/pdf/src/import/state.rs @@ -0,0 +1,277 @@ +use super::cid::{CidText, FontInfo}; +use super::*; + +/// Decoded RGBA pixels, width, height and hayro's scale factors. +pub(super) type Decoded = (Vec, u32, u32, (f32, f32)); + +pub(super) enum FrameKind { + Root, + /// A clip group whose clip path node is stored here. + Clip(Box), + /// A clip that was redundant (covers the page); children go straight to the parent. + Skip, + Group { + opacity: f32, + blend: BlendMode, + mask: Option>, + isolate: bool, + knockout: bool, + }, +} + +pub(super) struct Frame { + pub(super) kind: FrameKind, + pub(super) children: Vec>, + /// The first optional content group entered inside (where the frame's art goes). + pub(super) group: Option, + /// The group each child was drawn in, where the frame's art goes when the frame dissolves + /// into its parent (a layer's form drawing its sublayers' forms). + pub(super) child_groups: Vec>, +} + +impl Frame { + pub(super) fn new(kind: FrameKind) -> Self { + Self { kind, children: vec![], group: None, child_groups: vec![] } + } +} + +/// A dissolving frame's `children`, each with the group it was drawn in (`groups`, else the +/// frame's `group`). +pub(super) fn grouped( + children: Vec>, + groups: Vec>, + group: Option, +) -> impl Iterator, Option)> { + children.into_iter().zip(groups.into_iter().map(move |g| g.or(group)).chain(std::iter::repeat(group))) +} + +/// Glyphs drawn consecutively with the same paint, merged into one path. +pub(super) struct GlyphRun { + pub(super) path: BezPath, + pub(super) paint: Paint, + pub(super) opacity: f32, + pub(super) stroke: Option, + pub(super) scale: f64, + pub(super) overprint: bool, +} + +/// How often a tiling pattern was read, and the art (as its paints) each reading drew with the +/// swatch made for it. +pub(super) type PatternReadings = (u32, Vec<(Vec, String)>); + +/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back. +pub(super) struct Saved { + pub(super) stack: Vec, + pub(super) root_groups: Vec>, + pub(super) blend: BlendMode, + pub(super) mask: Option>, + pub(super) page: Rect, + pub(super) pending: bool, +} + +pub(super) struct Builder<'p> { + pub(super) colors: Colors<'p>, + pub(super) next: u64, + pub(super) stack: Vec, + /// The optional content group of each child of the root frame. + pub(super) root_groups: Vec>, + pub(super) blend: BlendMode, + pub(super) page: Rect, + pub(super) glyphs: Option, + pub(super) text: Option, + pub(super) text_as: TextAs, + pub(super) images: HashMap, + pub(super) image_keys: HashMap, + /// The images kept with their CMYK samples ([`crate::import_image`]). + pub(super) cmyk_keys: HashSet, + /// Mask keys of CMYK images whose mask turned out to hide nothing. + pub(super) opaque: HashSet, + pub(super) warnings: Vec, + /// Fonts by cache key → base font name and whether the file carries its glyphs (from + /// [`scan_page`]). + pub(super) fonts: FontNames, + pub(super) font_names: HashMap, + pub(super) families: Option, + pub(super) missing_fonts: Vec, + /// Font (cache key) → its characters by glyph, for CID-keyed fonts embedded without a + /// ToUnicode map. + pub(super) cid_text: HashMap>>, + /// Font (cache key) → the character of each glyph its embedded font program maps + /// ([`font_chars`]), for glyphs the file's ToUnicode map doesn't name (#708). + pub(super) font_chars: HashMap>>>, + /// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph + /// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines. + pub(super) matched: HashMap>>, + /// (Font, glyph) → the character its outline matches in the installed face, for glyphs no + /// mapping names ([`Builder::identified_char`]). + pub(super) identified: HashMap<(u128, u32), Option>, + /// Per font: the installed version its type names, when it isn't the one its family and style + /// resolve to (see `CharStyle::font_version`). + pub(super) versions: HashMap>, + /// The soft mask of the graphics state, the art drawn through it so far, and the masks read. + pub(super) mask: Option>, + pub(super) masked: Vec>, + pub(super) masks: HashMap>, + /// Pattern swatches made, and per (pattern, stroke) the art signatures read and their names. + pub(super) patterns: Vec, + pub(super) pattern_keys: HashMap<(u128, bool), PatternReadings>, + /// Per function-based shading (and the inks it paints with): what it comes in as, read once. + pub(super) function_paints: HashMap<(u128, Option>), Paint>, + /// What is left of the import's function evaluations for those shadings. + pub(super) function_budget: FunctionBudget, + /// Swatch names in use. + pub(super) taken: Vec, + /// The page as walked by [`scan_page`], and how far the interpreter is through it. + pub(super) scan: Scan, + pub(super) tag_at: usize, + pub(super) group_at: usize, + /// The fills and strokes of paths drawn so far ([`Scan::paints`]). + pub(super) paint_at: usize, + pub(super) aligned: bool, + /// Whether optional content groups become layers. + pub(super) route: bool, + /// Some groups are hidden or don't print: art in marked content whose group can't be told + /// (once the scan and the interpreter part) is [`UNSORTED`] rather than the page's. + pub(super) unsure: bool, + /// The marked-content sequences open: the group each marks. + pub(super) marked: Vec>, + /// A transparency group was just pushed: a form's (whose flags come next) or an image's. + pub(super) pending: bool, + pub(super) nested: u32, + /// Reading an alpha soft mask's art: images are read white with their own alpha, so the + /// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape, + /// rasterizes masks into such images). + pub(super) white_images: bool, + /// The last filled path: its node and its outline (a stroke of the same outline joins it). + pub(super) last_fill: Option<(NodeId, BezPath)>, + /// What each line of type made tells beyond its text object ([`crate::import_lines`]). + pub(super) lines: HashMap, +} + +pub(super) fn blend(b: craft_pdf::interpret::BlendMode) -> BlendMode { + use craft_pdf::interpret::BlendMode as H; + match b { + H::Normal => BlendMode::Normal, + H::Multiply => BlendMode::Multiply, + H::Screen => BlendMode::Screen, + H::Overlay => BlendMode::Overlay, + H::Darken => BlendMode::Darken, + H::Lighten => BlendMode::Lighten, + H::ColorDodge => BlendMode::ColorDodge, + H::ColorBurn => BlendMode::ColorBurn, + H::HardLight => BlendMode::HardLight, + H::SoftLight => BlendMode::SoftLight, + H::Difference => BlendMode::Difference, + H::Exclusion => BlendMode::Exclusion, + H::Hue => BlendMode::Hue, + H::Saturation => BlendMode::Saturation, + H::Color => BlendMode::Color, + H::Luminosity => BlendMode::Luminosity, + } +} + +pub(super) fn fill_rule(r: craft_pdf::interpret::FillRule) -> FillRule { + match r { + craft_pdf::interpret::FillRule::NonZero => FillRule::NonZero, + craft_pdf::interpret::FillRule::EvenOdd => FillRule::EvenOdd, + } +} + +/// `p` with every subpath closed: how a fill reads it. +pub(crate) fn closed_subpaths(p: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let mut open = false; + for el in p.elements() { + if open && matches!(el, PathEl::MoveTo(_)) { + out.push(PathEl::ClosePath); + } + out.push(*el); + open = !matches!(el, PathEl::ClosePath); + } + if open { + out.push(PathEl::ClosePath); + } + out +} + +/// Do a fill of `fill` and a stroke of `stroke` paint one object's outline? Applications write the +/// fill without closing the path (filling closes it) and the stroke closed. +pub(super) fn same_outline(fill: &BezPath, stroke: &BezPath) -> bool { + fill.elements() == stroke.elements() || closed_subpaths(fill).elements() == closed_subpaths(stroke).elements() +} + +pub(super) fn mean_scale(a: Affine) -> f64 { + a.determinant().abs().sqrt() +} + +/// Width a PDF line width of 0 imports as, points: a print hairline. In PDF `0 w` is the thinnest +/// line the device can draw (ISO 32000-1, 8.4.3.2), not no line. +const HAIRLINE: f64 = 0.25; + +/// Document width of the stroke `p` drawn at `scale`: `0 w` becomes a [`HAIRLINE`]. +pub(super) fn line_width(p: &StrokeProps, scale: f64) -> f64 { + if p.line_width == 0.0 { HAIRLINE } else { p.line_width as f64 * scale } +} + +pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer { + let mut st = StrokeLayer::new(paint, line_width(p, scale)); + st.opacity = opacity; + st.cap = match p.line_cap { + kurbo::Cap::Butt => LineCap::Butt, + kurbo::Cap::Round => LineCap::Round, + kurbo::Cap::Square => LineCap::Square, + }; + st.join = match p.line_join { + kurbo::Join::Miter => LineJoin::Miter, + kurbo::Join::Round => LineJoin::Round, + kurbo::Join::Bevel => LineJoin::Bevel, + }; + st.miter_limit = p.miter_limit as f64; + // (Every stroke keeps its own copy: a dash array has a few entries, and one with thousands is cut.) + let dash = Dash { + pattern: p.dash_array.iter().take(MAX_DASH_ENTRIES).map(|v| *v as f64 * scale).collect(), + offset: p.dash_offset as f64 * scale, + align_corners: false, + }; + // An invalid dash array (a negative value, or all zeros) strokes solid. + st.dash = dash.is_dashed().then_some(dash); + st +} + +/// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). +const MAX_DASH_ENTRIES: usize = 64; + +pub(crate) fn round3(v: f32) -> f32 { + (v * 1000.0).round() / 1000.0 +} + +/// The paints of `nodes`' leaves, in order: what tells two readings of a pattern cell apart. +pub(super) fn paints(nodes: &[Arc]) -> Vec { + let mut out = vec![]; + for n in nodes { + n.walk(&mut |c| out.extend(c.appearance.items.iter().map(|i| i.paint().clone()))); + } + out +} + +/// Visual bounds of `nodes`; `None` when one has none. +pub(super) fn bounds(nodes: &[Arc]) -> Option { + nodes.iter().try_fold(None, |acc, n| n.visual_bounds().map(|b| Some(acc.map_or(b, |a: Rect| a.union(b)))))? +} + +pub(super) fn rgba_png(rgba: Vec, w: u32, h: u32) -> Option> { + let img = image::RgbaImage::from_raw(w, h, rgba)?; + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + +pub(super) fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { + if a.width == w && a.height == h { + return a.data.clone(); + } + match image::GrayImage::from_raw(a.width, a.height, a.data.clone()) { + Some(g) => image::imageops::resize(&g, w, h, image::imageops::FilterType::Triangle).into_raw(), + None => vec![255; (w * h) as usize], + } +} From c84a60350f10e8a0b517b82f60158fa8aca0e3a0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:35:02 +0000 Subject: [PATCH 132/199] refactor(render): move fx.rs items to fx/{bounds,entries,pixels,predicates,raster,renderer}.rs --- crates/render/src/fx.rs | 2413 +--------------------------- crates/render/src/fx/bounds.rs | 374 +++++ crates/render/src/fx/entries.rs | 355 ++++ crates/render/src/fx/pixels.rs | 668 ++++++++ crates/render/src/fx/predicates.rs | 78 + crates/render/src/fx/raster.rs | 257 +++ crates/render/src/fx/renderer.rs | 695 ++++++++ 7 files changed, 2444 insertions(+), 2396 deletions(-) create mode 100644 crates/render/src/fx/bounds.rs create mode 100644 crates/render/src/fx/entries.rs create mode 100644 crates/render/src/fx/pixels.rs create mode 100644 crates/render/src/fx/predicates.rs create mode 100644 crates/render/src/fx/raster.rs create mode 100644 crates/render/src/fx/renderer.rs diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 9de83c3d2..7c53e841c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -61,2404 +61,25 @@ use vello_cpu::peniko; use crate::ink::Ink; use crate::{Frame, Renderer, blend_mode, fill_rule, paint}; -fn visible(effects: &[Effect]) -> bool { - effects.iter().any(|e| e.visible) -} - -/// Does this node need the effects path (a path or compound path with visible effects)? -pub(crate) fn has_fx(n: &Node) -> bool { - if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) { - return false; - } - visible(&n.appearance.effects) - || n.appearance.items.iter().any(|i| match i { - AppearanceItem::Fill(f) => visible(&f.effects), - AppearanceItem::Stroke(s) => visible(&s.effects), - }) -} - -/// Does `n` carry visible object-level effects that only apply through its art (type, images, -/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with -/// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own -/// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies -/// it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]), or is it a -/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? -pub(crate) fn has_object_fx(n: &Node) -> bool { - (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) - || effects::has_item_geometry(n) - || effects::has_transform_art(n) - || effects::has_container_appearance(n) - || effects::has_crop_marks(n) - || effects::has_adjustment(n) - || effects::has_revolve(n) - || effects::has_solid(n) -} - -/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? -fn is_container(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) -} - -/// Can the art [`Renderer::fx_art`] makes for `n` be made from live objects it evaluates? It can -/// when `n` is a live object, and when `n` is a group or layer without a Pathfinder effect that -/// holds live objects and whose geometry effects reshape them or whose own fills and strokes paint -/// their evaluated outlines ([`effects::evaluate_container`]). -fn evaluates_live(n: &Node) -> bool { - use vectorcraft_doc::live::{is_live, subtree_has_live}; - let to_members = effects::has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(effects::paints); - is_live(n) || (is_container(n) && to_members && !effects::has_pathfinder(n) && subtree_has_live(n)) -} - -/// Can the art [`Renderer::fx_art`] makes for `n` change while `n` stays the same, other than -/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed), -/// a symbol instance (made from its symbol's art), or an object with a 3D effect or a color -/// adjustment (shaded or recolored through the color settings), in it or in the opacity masks of -/// the objects in it. -fn reads_outside(n: &Node) -> bool { - let mut reads = false; - n.walk(&mut |c| { - reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) - || effects::has_revolve(c) - || effects::has_solid(c) - || effects::has_adjustment(c) - || c.extras().mask.as_ref().is_some_and(|m| reads_outside(&m.art)) - }); - reads -} - -/// What [`Renderer::raster_fx`] paints effects around. -struct Content<'a> { - /// The object the effects belong to (cached shadows are checked against it). - node: &'a Node, - /// The `Arc` holding `node`, when known: cached rasters keep it and match it by identity. - owner: Option<&'a Arc>, - /// First shadow-cache slot of these effects (`None`: don't cache). - cache: Option, - /// Document-space bounds of the painted content. - reach: Rect, - /// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and - /// Inner Glow is skipped). - outline: Option<(&'a BezPath, FillRule)>, - /// What Rasterize makes its image over: the object's [`drawn_bounds`] as far as the effects - /// before it spread them (`None`: `reach`). - bounds: Option, - /// Where the image of the Rasterize first in these effects is kept for the effects after a - /// split ([`Renderer::raster_fx`]), which draw this content more than once. - kept: Option<&'a Kept>, -} - -/// The image of the last Rasterize before a split in an effect stack ([`Renderer::raster_fx`]). It -/// is made the first time the effects after the split draw the content and drawn from here the -/// other times, so the content before the split is drawn once. -struct Kept { - /// Where the effects after the split take the content from: the visible area grown by how - /// far they paint. - window: Rect, - /// The view and pixel size of the split's frame, when the image is over - /// [`Renderer::rasterize_image`]'s limits: the content is then drawn in that frame. - frame: Option<(Affine, f64)>, - image: std::cell::RefCell>, -} - -#[derive(Clone)] -enum KeptImage { - /// The image on its own grid, as [`Renderer::rasterize_image`] makes it, and how many images - /// in the content failed to decode while it was made ([`crate::paint::decode_failures`]): - /// each draw of the image from here counts them again ([`crate::paint::add_decode_failures`]). - Grid(std::rc::Rc<(Vec, usize, usize)>, u64), - /// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the - /// Rasterize applied, as the first draw made it, and the transform from its pixels to - /// document space. - Raster(Arc, Affine), -} - -/// The [`Content::bounds`] of `n` under raster effects `rfx`: its [`drawn_bounds`] when `rfx` -/// holds a Rasterize. -fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]) -> Option { - rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| drawn_bounds(symbols, n)).flatten() -} - -/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). -fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> { - Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f } -} - -fn node_bezpath(n: &Node) -> Option { - match &n.kind { - NodeKind::Path { path, .. } => Some(path.to_bezpath()), - NodeKind::Compound { children, .. } => { - let mut bp = BezPath::new(); - for c in children { - if let Some(p) = c.path_data() { - bp.extend(p.to_bezpath()); - } - } - Some(bp) - } - _ => None, - } -} - -fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath { - if !effects::has_geometry(effects) || bp.elements().is_empty() { - return bp.clone(); - } - effects::apply_geometry_bez(effects, bp, bp.bounding_box(), ctx) -} - -/// The object-level effected geometry of `n` (base path `bp`). -pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath { - apply(&n.appearance.effects, bp, &GeomContext::of(n)) -} - -fn item_effects(item: &AppearanceItem) -> &[Effect] { - match item { - AppearanceItem::Fill(f) => &f.effects, - AppearanceItem::Stroke(s) => &s.effects, - } -} - -/// Visual bounds including geometry effects, stroke outsets and shadows/glows (symbol instances -/// measured by their art from `symbols`). -pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { - return cull_bounds(symbols, &art).map(|b| { - let o = outset(symbols, &n.appearance.effects, b); - b.inflate(o, o) - }); - } - let bp = node_bezpath(n)?; - let ctx = GeomContext::of(n); - let g = effected_path(n, &bp); - let mut r: Option = (!g.elements().is_empty()).then(|| g.bounding_box()); - for item in &n.appearance.items { - let fx = item_effects(item); - if effects::has_geometry(fx) { - let ig = apply(fx, &g, &ctx.item(item)); - if !ig.elements().is_empty() { - let b = ig.bounding_box(); - r = Some(r.map_or(b, |r| r.union(b))); - } - } - } - let s = n.appearance.outset(); - r.map(|r| { - let r = r.inflate(s, s); - // The fills' and strokes' own raster effects, then the object's. - let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b)); - let o = outset(symbols, &n.appearance.effects, r); - r.inflate(o, o) - }) -} - -/// Does `item` paint (as [`Renderer::paint_items`] draws it)? -fn paints(item: &AppearanceItem) -> bool { - item.visible() && !item.paint().is_none() -} - -/// The bounds of what `n` draws, its own and its members' geometry effects applied and the raster -/// effects of its members and of its fills and strokes included (each fill's and stroke's -/// spreading what it paints, [`item_bounds`]), with strokes measured as -/// [`Node::visual_bounds_with`] measures them, without the reach of `n`'s own raster effects -/// (symbol instances measured by their art from `symbols`): what Rasterize makes its image over. -pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - let b = art_bounds(symbols, n)?; - let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty()); - Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r))) -} - -/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path -/// with `n`'s and `item`'s geometry effects, and for a stroke its width. `None` for other objects, -/// 3D art, a brushed stroke and an item that draws no path. -fn item_bounds(n: &Node, item: &AppearanceItem) -> Option { - let stroke = matches!(item, AppearanceItem::Stroke(_)); - if !has_fx(n) || effects::has_revolve(n) || effects::has_solid(n) || (stroke && vectorcraft_brush::has_brush(n)) { - return None; - } - let g = effected_path(n, &node_bezpath(n)?); - let fx = item_effects(item); - if effects::has_geometry(fx) { - let ig = apply(fx, &g, &GeomContext::of(n).item(item)); - let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; - return (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o)); - } - let one = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() }; - (!g.elements().is_empty()).then(|| one.stroked_bounds(g.bounding_box(), || g.clone())) -} - -/// `b` grown by how far raster effects `fx` paint past it, each spreading what the ones before it -/// paint (Rasterize at the document's raster effects resolution where it takes it). -fn spread(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> Rect { - let ppi = doc_raster_ppi(symbols.document()); - effects::raster_effects(fx).iter().fold(b, |r, e| e.at_document_ppi(ppi).spread(r, 72.0 / ppi)) -} - -/// [`drawn_bounds`] before the raster effects of `n`'s fills and strokes: its art, and the art of -/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches). -fn art_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - let b = shape_bounds(symbols, n); - let brushed = vectorcraft_brush::has_brush(n) - .then(|| vectorcraft_brush::expand(symbols.document(), n)) - .flatten() - .and_then(|art| art.visual_bounds_with(symbols)); - let b = vectorcraft_geom::union_opt(b, brushed)?; - let pad = if matches!(n.kind, NodeKind::SymbolInstance { .. }) { crate::brush_fx::pad(symbols, n) } else { 0.0 }; - Some(b.inflate(pad, pad)) -} - -/// [`art_bounds`] without brush art. -fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - // The visible members of `g` (a Shaper group's first member is its source art, which paints - // nothing). - let union = |g: &Node| { - let members = g.children()?.get(usize::from(g.extras().shaper.is_some())..).unwrap_or_default(); - members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) - }; - match &n.kind { - _ if effects::has_container_appearance(n) => { - let art = effects::evaluate_container(Some(symbols.document()), n); - let art = art.as_ref().unwrap_or(n); - if art.clips() { art.visual_bounds_with(symbols) } else { union(art) } - } - NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n), - _ if has_fx(n) => { - // A 3D effect's art. - if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { - return drawn_bounds(symbols, &art); - } - let g = effected_path(n, &node_bezpath(n)?); - // The path as the fills and strokes without geometry effects of their own paint it - // (as every fill and stroke would when none paints). - let mut plain = n.appearance.clone(); - plain.items.retain(|i| !(paints(i) && effects::has_geometry(item_effects(i)))); - let on_path = !n.appearance.items.iter().any(paints) || plain.items.iter().any(paints); - let mut r = (on_path && !g.elements().is_empty()).then(|| plain.stroked_bounds(g.bounding_box(), || g.clone())); - let ctx = GeomContext::of(n); - for item in n.appearance.items.iter().filter(|i| paints(i) && effects::has_geometry(item_effects(i))) { - let ig = apply(item_effects(item), &g, &ctx.item(item)); - let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; - r = vectorcraft_geom::union_opt(r, (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o))); - } - r - } - // As [`cull_bounds_in`] measures them, without the object's own raster effects: the - // reshaped art (type's outlines, and an image and the paths of its own fills and strokes, - // measured one by one with the effects of those fills and strokes), grown by how far the - // effects in a symbol's art and a live object's content paint past it. - _ if has_object_fx(n) => { - let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), - _ => None, - }; - let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); - let b = match &reshaped { - Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { - r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) - } - Some(r) => r.visual_bounds_with(symbols), - None => None, - } - .or_else(|| n.visual_bounds_with(symbols))?; - let a = match &reshaped { - Some(r) => reshaped_art_effects_reach(symbols, n, r), - None => art_effects_reach(symbols, n), - }; - let a = a.max(live_content_reach(symbols, n)); - Some(b.inflate(a, a)) - } - _ => { - let b = n.visual_bounds_with(symbols)?; - let a = art_effects_reach(symbols, n).max(live_content_reach(symbols, n)); - Some(b.inflate(a, a)) - } - } -} - -/// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop -/// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster -/// effects inside the art. -pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option { - if let Some(art) = effects::crop_marks_art(n) { - return drawn_bounds(symbols, &art); - } - drawn_bounds(symbols, n).map(|b| spread(symbols, &n.appearance.effects, b)) -} - -/// The outline the renderer clips `n`'s Feather, Inner Glow and Rasterize with Create Clipping -/// Mask to: a path's or compound path's geometry with its object-level geometry effects applied, -/// the outline of the art [`Renderer::fx_art`] makes for an object with object effects, and with -/// Crop Marks the object's own. For other objects it is [`effects::clip_outline`]. -pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { - if let Some(art) = effects::crop_marks_art(n) { - return drawn_outline(doc, art.children()?.first()?); - } - if has_object_fx(n) { - let art = Renderer::new().fx_art(doc, &Arc::new(n.clone()), false); - return effects::clip_outline(&art); - } - match &n.kind { - NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } if effects::has_geometry(&n.appearance.effects) => { - Some((effected_path(n, &node_bezpath(n)?), *rule)) - } - _ => effects::clip_outline(n), - } -} - -/// How far raster effects `fx` paint beyond `b`, each spreading what the ones before it paint -/// ([`spread`]). -fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 { - let s = spread(symbols, fx, b); - [b.x0 - s.x0, b.y0 - s.y0, s.x1 - b.x1, s.y1 - b.y1].into_iter().fold(0.0, f64::max) -} - -/// Bounds used for culling: like `Node::visual_bounds_with`, but aware of live effects anywhere in -/// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are -/// measured by their art from `symbols`. -pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - cull_bounds_in(symbols, n, true) -} - -/// [`cull_bounds`] of `n`. With `after_reshape` true, the effects in the art of a symbol instance -/// with geometry effects of its own are measured on the art as those effects reshape it -/// ([`reshaped_art_effects_reach`]). With `after_reshape` false, they are measured on the art -/// before those effects run ([`art_effects_reach`]), which `symbols` keeps for each symbol and -/// linear part. -fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Option { - let inner = |c: &Node| cull_bounds_in(symbols, c, after_reshape); - match &n.kind { - // The object and its crop marks. - _ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)), - // The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach. - _ if effects::has_container_appearance(n) => { - let art = effects::evaluate_container(Some(symbols.document()), n); - let art = art.as_ref().unwrap_or(n); - let b = if art.clips() { - art.visual_bounds_with(symbols) - } else { - art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) - }?; - let o = outset(symbols, &n.appearance.effects, b); - Some(b.inflate(o, o)) - } - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) - } - _ if has_fx(n) => visual_bounds(symbols, n), - // The reshaped art (a symbol instance's reshaped art is made from its symbol's art; type's - // outlines, and an image and the paths of its own fills and strokes, measured one by one - // with the geometry effects of those fills and strokes), grown by how far the effects in a - // symbol instance's art paint past that art. - _ if has_object_fx(n) => { - let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), - _ => None, - }; - let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); - let b = match &reshaped { - Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { - r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) - } - Some(r) => r.visual_bounds_with(symbols), - None => None, - } - .or_else(|| n.visual_bounds_with(symbols))?; - let a = match &reshaped { - Some(r) if after_reshape => reshaped_art_effects_reach(symbols, n, r), - _ => art_effects_reach(symbols, n), - }; - let a = a.max(live_content_reach(symbols, n)); - let b = b.inflate(a, a); - let o = outset(symbols, &n.appearance.effects, b); - Some(b.inflate(o, o)) - } - NodeKind::SymbolInstance { .. } => { - let b = n.visual_bounds_with(symbols)?; - let a = art_effects_reach(symbols, n); - Some(b.inflate(a, a)) - } - _ => { - let b = n.visual_bounds_with(symbols)?; - let b = match &n.kind { - NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), - _ => b, - }; - let a = live_content_reach(symbols, n); - Some(b.inflate(a, a)) - } - } -} - -/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its -/// characters' strokes, miter spikes included, and by `outset`: on a path they stand above it and -/// run past its ends, in an area they can hang past its frame, where its own box -/// ([`Node::visual_bounds_with`]) doesn't. -fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { - let b = t.cached_bounds.unwrap_or_else(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds); - let o = outset.max(t.char_stroke_reach()); - (b.width() > 0.0 || b.height() > 0.0).then(|| t.xf.transform_rect_bbox(b).inflate(o, o)) -} - -/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual -/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that -/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the -/// farthest side. 0 for any other kind of object. -fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { - if !vectorcraft_doc::live::is_live(n) { - return 0.0; - } - let mut reach = 0.0f64; - n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c))); - reach -} - -/// How far the effects in the art of symbol instance `n` (and in the art of the symbols the -/// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the -/// art as the instance places it before its effects run: 0 when the art has no visible effects, and -/// for any other kind of object. The reach is the same wherever the art is moved to, so the art is -/// placed by the linear part of the instance's transform alone, and `symbols` keeps the reach for -/// each symbol and linear part, up to a limit ([`SymbolBoxes::effects_reach`]). -fn art_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { - let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return 0.0 }; - if !symbols.has_effects(symbol) { - return 0.0; - } - let measure = |[m0, m1, m2, m3]: [f64; 4]| { - let past = |art: &Arc| { - let mut placed = (**art).clone(); - placed.transform(Affine::new([m0, m1, m2, m3, 0.0, 0.0]), false); - match (cull_bounds(symbols, &placed), placed.visual_bounds_with(symbols)) { - (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), - _ => 0.0, - } - }; - symbols.measure_art(symbol, past).unwrap_or(0.0) - }; - let [m0, m1, m2, m3, _, _] = xf.as_coeffs(); - symbols.effects_reach(symbol, [m0, m1, m2, m3], &measure) -} - -/// How far the effects in the art of symbol instance `n` paint past `r`, that art as `n`'s own -/// geometry effects reshape it ([`effects::reshape`]), on its farthest side: 0 when the art has no -/// visible effects, and for any other kind of object. The art in `r` is measured without the raster -/// effects of `n` that `r` carries, by [`cull_bounds_in`] with `after_reshape` false: the effects in -/// the art of an instance in it are measured before that instance's own geometry effects run. -fn reshaped_art_effects_reach(symbols: &SymbolBoxes, n: &Node, r: &Node) -> f64 { - let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return 0.0 }; - if !symbols.has_effects(symbol) { - return 0.0; - } - let art = r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds_in(symbols, c, false))); - match (art, r.visual_bounds_with(symbols)) { - (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), - _ => 0.0, - } -} - -fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter { - Filter::from_primitive(FilterPrimitive::DropShadowOnly { - dx: dx as f32, - dy: dy as f32, - std_deviation: (blur / 2.0).max(0.0) as f32, - color, - edge_mode: EdgeMode::None, - }) -} - -/// The layer filter of a drop shadow with Darkness: the object's own pixels blurred. -fn shade_filter(blur: f64) -> Filter { - Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: (blur / 2.0).max(0.0) as f32, edge_mode: EdgeMode::None }) -} - -/// How much darker (0..1) a drop shadow with Darkness `darkness` makes the values `ink` paints: -/// black added leaves the cyan, magenta and yellow plane of a CMYK document as it is and scales -/// the complemented black plane, as it scales screen colors, by 1 − `darkness`. -fn shade(ink: Ink, darkness: f32) -> f32 { - match ink { - Ink::Cmy => 0.0, - Ink::K | Ink::Display | Ink::RichBlack => darkness, - } -} - -/// What a shadow or glow raster is made from ([`Made::pixels`]): the silhouette's alpha, or its -/// premultiplied RGBA when the shadow takes the object's own colors (Darkness). -fn silhouette(pm: &vello_cpu::Pixmap, colored: bool) -> Vec { - if colored { pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect() } else { pm.data().iter().map(|p| p.a).collect() } -} - -fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { - let [r, g, b] = ink.rgb(c); - peniko::Color::new([r, g, b, 1.0]) -} - -/// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is -/// cropped to the view when its effects only look nearby, else rendered at a lower resolution. -const PIXEL_BUDGET: f64 = 8.4e6; - -/// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each -/// draws the content again inside the one after it. -const MAX_RASTERIZE: usize = 16; - -/// The columns and rows of the image Rasterize `r` makes over `rect` (whole pixels of the image, -/// `line` points wide at the document's raster effects resolution). `None` when the image would -/// hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than [`crate::MAX_RASTER_SIDE`]. -fn image_size(r: &effects::Rasterize, rect: Rect, line: f64) -> Option<(u16, u16)> { - let cell = r.cell(line); - let (cols, rows) = ((rect.width() / cell).round(), (rect.height() / cell).round()); - let side = f64::from(crate::MAX_RASTER_SIDE); - (cols >= 1.0 && rows >= 1.0 && cols <= side && rows <= side && cols * rows <= PIXEL_BUDGET).then_some((cols as u16, rows as u16)) -} +mod bounds; +mod entries; +mod pixels; +mod predicates; +mod raster; +mod renderer; #[cfg(test)] -thread_local! { - /// How many times a Rasterize has drawn the content before it on this thread. - pub(crate) static RASTERIZED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a -/// number). -fn doc_raster_ppi(doc: &Document) -> f64 { - doc.raster_ppi() -} - -/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the -/// content itself), in stack order. -fn content_changing(fx: &&RasterFx) -> bool { - matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_) | RasterFx::Rasterize(_)) -} - -/// Is the content drawn with hard edges for `chain` into a raster of pixels `px` wide (document -/// units): does it go first through a Rasterize with anti-aliasing off whose pixels are no -/// smaller? (Smaller ones show as the share of each raster pixel they cover, as smooth edges do.) -fn hard_edged(chain: &[&RasterFx], px: f64) -> bool { - matches!(chain.first(), Some(RasterFx::Rasterize(r)) if !r.anti_alias && r.cell(72.0) >= px) -} - -/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when -/// anywhere in the object. -fn looks_around(fx: &RasterFx) -> Option { - match fx { - RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius), - RasterFx::Pixel(p) => p.reach(), - RasterFx::Rasterize(r) => Some(r.reach()), - _ => Some(0.0), - } -} - -/// The content of an object with Photoshop-style effects as filtered pixels, drawn at -/// (`anchor`'s view position + `rel`) scaled by 1 / `k` (see [`Renderer::pixel_content`]). -pub(crate) struct PixelEntry { - node: Drawn, - linear: [u64; 4], - anchor: vectorcraft_geom::Point, - rel: (f64, f64), - k: f64, - image: std::sync::Arc, - /// For a raster of the content's whole reach at full size, `None`: it draws after a pan too. - /// For one cropped to the view, or made at a lower resolution, the view and the rect it was - /// made for: it draws only there, so dragging other art over the object keeps it (a wide blur, - /// or any blur zoomed in, reaches past the pixel budget). - view: Option, - made: Made, - /// What the content read when the raster was made, with the channels the effects ran on and, - /// under De-Interlace, the raster resolution. - inputs: Inputs, - /// Made for the object before it moved ([`Renderer::move_pixel_content`]), or made over - /// rasters made before their objects moved: only a frame rendered while interacting draws - /// it. - moved: bool, - /// How many images in the content failed to decode while the raster was made - /// ([`crate::paint::decode_failures`]): each frame that draws the raster counts them - /// again ([`crate::paint::add_decode_failures`]). - failed: u64, - pub(crate) stamp: u64, -} - -impl PixelEntry { - /// The bytes it holds, for [`FX_RASTER_BUDGET`]. - pub(crate) fn bytes(&self) -> usize { - raster_bytes(&self.image) + self.made.bytes() - } -} - -/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys. -fn linear_key(view: Affine) -> [u64; 4] { - let c = view.as_coeffs(); - [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] -} - -/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and -/// the device-pixel rect it was made for in that view: the frame's, or around it the margin of -/// [`pan_margin`] ([`PixelEntry::view`], [`ShadowEntry::view`]). -#[derive(Clone, Copy, Debug)] -struct ViewCrop { - pan: Vec2, - screen: Rect, -} - -impl ViewCrop { - fn of(view: Affine, screen: Rect) -> Self { - Self { pan: view.translation(), screen } - } - - /// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto - /// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies - /// within the one the raster was made for: the same pixels in the same places, as when a frame - /// is drawn again only in a part of it. - fn serves(&self, view: Affine, screen: Rect) -> bool { - let d = view.translation() - self.pan; - let whole = Vec2::new(d.x.round(), d.y.round()); - let s = screen - whole; - (d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1 - } - - /// The rect the raster was made for, in the device pixels of a frame through `view` that it - /// serves ([`Self::serves`]). - fn served(&self, view: Affine) -> Rect { - let d = view.translation() - self.pan; - self.screen + Vec2::new(d.x.round(), d.y.round()) - } -} - -/// How far (device pixels, on each side) past a `w` × `h` view a raster cropped to it, and `m` -/// pixels around that, is made when it is made again at the same zoom and rotation, so that later -/// pans of up to that many pixels draw it: the most that keeps the raster within `budget` pixels and -/// `side` pixels a side, with two pixels to spare for rounding, and at most half the view's longer -/// side. 0 when the view and the `m` pixels around it alone leave no room. -fn pan_margin((w, h): (f64, f64), m: f64, budget: f64, side: f64) -> f64 { - let (a, b) = (w + 2.0 * m, h + 2.0 * m); - // The largest `e` with (a + 2e) × (b + 2e) within the budget, less two pixels for the rounding - // of the raster's edges to whole pixels. - let e = (((a - b).powi(2) + 4.0 * budget).sqrt() - (a + b)) / 4.0 - 2.0; - // (Also for a view or spread that isn't a number.) - if !e.is_finite() { - return 0.0; - } - e.min((side - a.max(b)) / 2.0 - 2.0).min(w.max(h) / 2.0).floor().max(0.0) -} - -/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while -/// [`crate::RenderOptions::dragging`] (a 1000 × 1000 raster's), when the frame does not draw a raster -/// kept from an earlier frame instead (#2558). -const DRAG_PIXEL_BUDGET: f64 = 1.0e6; - -/// The scale (raster pixels a device pixel) for content of `rw` × `rh` device pixels that would -/// render at scale `k`, when a drag lowers it to stay within [`DRAG_PIXEL_BUDGET`]; `None` when it -/// does not (`dragging` off, or content within the budget at `k`). -fn drag_scale(dragging: bool, (rw, rh): (f64, f64), k: f64) -> Option { - let d = (DRAG_PIXEL_BUDGET / (rw * rh)).sqrt(); - (dragging && d < k).then_some(d) -} - -/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content -/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height, -/// scale). It covers what of the reach can show in the view, the blur's spread around it -/// included, at `scale` raster pixels a device pixel: 1, or less to stay within -/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a -/// side, whatever the size of the view (#1706). `None`: nothing to draw. -fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> { - filter_region_within(reach, screen, blur, PIXEL_BUDGET) -} - -/// The region [`filter_region`] returns, within `budget` pixels instead of [`PIXEL_BUDGET`]. -fn filter_region_within(reach: Rect, screen: (u16, u16), blur: f64, budget: f64) -> Option<(f64, f64, u16, u16, f64)> { - // 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing. - let spread = blur.max(0.0) * 1.5 + 2.0; - let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread); - let r = reach.inflate(spread, spread).intersect(screen); - let (x0, y0) = (r.x0.floor(), r.y0.floor()); - let (rw, rh) = (r.x1.ceil() - x0, r.y1.ceil() - y0); - // (Also false for a reach or a blur that isn't a number.) - if !(rw >= 1.0 && rh >= 1.0 && rw.is_finite() && rh.is_finite()) { - return None; - } - let side = f64::from(u16::MAX); - let k = (budget / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0); - let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side)); - Some((x0, y0, w as u16, h as u16, k)) -} - -/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel. -fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc, (x, y): (f64, f64), k: f64) { - let (w, h) = (image.width() as f64, image.height() as f64); - ctx.set_transform(Affine::translate((x, y)) * Affine::scale(1.0 / k)); - // On whole pixels at full size the cheapest sampler is exact. - let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; - let sampler = peniko::ImageSampler { quality, ..Default::default() }; - // The raster maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern, - // radial or freeform gradient) would resample it (#1536). - ctx.reset_paint_transform(); - ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); - ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); - ctx.set_transform(Affine::IDENTITY); -} - -/// Draw `image` with its pixels placed by `xf` (pixels to device pixels). On whole pixels at full -/// size it is drawn as [`draw_scaled`] draws it. -fn draw_placed(ctx: &mut RenderContext, image: std::sync::Arc, xf: Affine) { - let [a, b, c, d, e, f] = xf.as_coeffs(); - let near = |v: f64, to: f64| (v - to).abs() < 1e-6; - if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) { - return draw_scaled(ctx, image, (e.round(), f.round()), 1.0); - } - let (w, h) = (image.width() as f64, image.height() as f64); - ctx.set_transform(xf); - let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() }; - ctx.reset_paint_transform(); - ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); - ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); - ctx.set_transform(Affine::IDENTITY); -} - -/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to -/// the view-space position of `anchor` (a document point). -pub(crate) struct ShadowEntry { - node: Drawn, - linear: [u64; 4], - anchor: vectorcraft_geom::Point, - /// Pixel offset of the raster's top-left from the anchor's view position. - rel: (f64, f64), - image: std::sync::Arc, - /// View pixels per raster pixel: more than 1 for a glow made coarser to fit the budget. - scale: f64, - /// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one - /// cropped to the view, or made over such a raster, the view and the rect it was made for: it - /// draws only there ([`PixelEntry::view`]). - view: Option, - made: Made, - /// What the silhouette read when the raster was made. - inputs: Inputs, - /// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters - /// made before their objects moved: only a frame rendered while interacting draws it. - moved: bool, - pub(crate) stamp: u64, -} - -/// A shadow or glow raster ready to draw ([`Renderer::draw_shadow`]). -struct Shadow { - image: std::sync::Arc, - /// Raster to view pixels. - at: Affine, - mode: vectorcraft_doc::color::BlendMode, - /// Made before its object moved ([`ShadowEntry::moved`]). - moved: bool, - /// For a raster cropped to the view, or made over a raster that is ([`ShadowEntry::view`]), the - /// rect it was made for, in this frame's device pixels ([`ViewCrop::served`]). - cropped: Option, -} - -impl ShadowEntry { - /// The bytes it holds, for [`FX_RASTER_BUDGET`]. - pub(crate) fn bytes(&self) -> usize { - raster_bytes(&self.image) + self.made.bytes() - } -} - -/// What a cached shadow, glow or filtered raster was made from. While -/// [`crate::RenderOptions::interacting`], an object whose content only moved after the raster -/// was made draws from that raster ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]). -pub(crate) struct Made { - /// The content's pixels as drawn into the raster before the effects ran: premultiplied RGBA, - /// or their alpha alone under a shadow or glow that is one color ([`silhouette`]). - pixels: Vec, - /// The effects run on them. - fx: Vec, - /// The size of the content's document-space bounds. - size: Size, - /// The color channels the Photoshop-style filters were told of (none for shadows and glows). - channels: Option, - /// Feather's clip outline, moved to put the raster's anchor at the origin. - outline: Option<(BezPath, FillRule)>, -} - -/// How far (document units) the size of a moved object's content and the points of its Feather -/// outline may lie from those of the content a raster was made from. -const MOVED_SLACK: f64 = 1e-6; - -impl Made { - /// The bytes it holds beyond the raster. - fn bytes(&self) -> usize { - self.pixels.len() + self.outline.as_ref().map_or(0, |(g, _)| crate::lru::path_bytes(g)) - } - - /// Whether content of size `size` under the effects `fx`, told of `channels`, with Feather's - /// `outline` (moved to put its anchor at the origin) may be this content moved: the same - /// effects, channels and outline rule, and the size and outline within [`MOVED_SLACK`]. - fn fits(&self, fx: &[&RasterFx], size: Size, channels: Option, outline: Option<&(BezPath, FillRule)>) -> bool { - let near = |a: f64, b: f64| (a - b).abs() <= MOVED_SLACK; - self.fx.len() == fx.len() - && self.fx.iter().zip(fx).all(|(a, b)| a == *b) - && near(self.size.width, size.width) - && near(self.size.height, size.height) - && self.channels == channels - && match (&self.outline, outline) { - (None, None) => true, - (Some((a, ra)), Some((b, rb))) => ra == rb && same_path(a, b), - _ => false, - } - } -} - -/// Whether `a` and `b` have the same elements with points within [`MOVED_SLACK`] of each other. -fn same_path(a: &BezPath, b: &BezPath) -> bool { - let near = |p: &Point, q: &Point| (*p - *q).hypot() <= MOVED_SLACK; - a.elements().len() == b.elements().len() - && a.elements().iter().zip(b.elements()).all(|pair| match pair { - (PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(p, q), - (PathEl::QuadTo(p0, p1), PathEl::QuadTo(q0, q1)) => near(p0, q0) && near(p1, q1), - (PathEl::CurveTo(p0, p1, p2), PathEl::CurveTo(q0, q1, q2)) => near(p0, q0) && near(p1, q1) && near(p2, q2), - (PathEl::ClosePath, PathEl::ClosePath) => true, - _ => false, - }) -} - -/// Feather's clip outline of `content` when `chain` feathers it, moved to put `anchor` at the -/// origin ([`Made::outline`]). -fn feather_outline(chain: &[&RasterFx], content: &Content, anchor: Point) -> Option<(BezPath, FillRule)> { - let (g, rule) = content.outline.filter(|_| chain.iter().any(|fx| matches!(fx, RasterFx::Feather { .. })))?; - let mut g = g.clone(); - g.apply_affine(Affine::translate(-anchor.to_vec2())); - Some((g, rule)) -} - -/// The object a cached raster was made for: the drawn `Arc` itself when known (kept alive, so its -/// address, the cache key, names it alone and a key match is exact), else a copy compared by value. -pub(crate) enum Drawn { - Shared(Arc), - Copy(Box), -} - -impl Drawn { - fn of(n: &Node, owner: Option<&Arc>) -> Self { - match owner { - Some(a) => Self::Shared(a.clone()), - None => Self::Copy(Box::new(n.clone())), - } - } - - /// Is `n` the object drawn? - fn is(&self, n: &Node) -> bool { - match self { - Self::Shared(a) => std::ptr::eq(&**a, n), - Self::Copy(c) => **c == *n, - } - } - - /// The id of the object drawn. - fn id(&self) -> NodeId { - match self { - Self::Shared(a) => a.id, - Self::Copy(c) => c.id, - } - } -} - -/// What an object's content reads from outside its own nodes, in it or in the opacity masks of the -/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) struct Reads { - /// Pattern paints: the pattern definitions. Their art can hold anything, so content painted - /// with a pattern reads everything else too. - patterns: bool, - /// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold - /// anything, so content that shows a symbol reads everything else too. - symbols: bool, - /// Images and placed documents: their pixels and files, and the options and the layer's Dim - /// Images they are drawn with. - images: bool, - /// Brushed strokes: the brush library. - brushes: bool, - /// Type: the fonts, whether overprinting shows ([`crate::proof::overprints`]) and the - /// substitution highlights. - text: bool, - /// Plug-in effects on its objects: the plug-ins installed. Those in the symbol and pattern art - /// it shows are found with the document's inputs ([`DocInputs`]). - plugins: bool, -} - -impl Reads { - /// What the content of `n` reads. - pub(crate) fn of(n: &Node) -> Self { - Self::of_all(std::iter::once(n)) - } - - /// What the content of `nodes` reads together. A pattern's art can hold anything, as a - /// symbol's can, so content painted with a pattern reads everything else too (#1723). - pub(crate) fn of_all<'a>(nodes: impl Iterator) -> Self { - let mut reads = Self::default(); - nodes.for_each(|n| reads.add(n)); - if reads.symbols || reads.patterns { - return Self { patterns: true, symbols: true, images: true, brushes: true, text: true, plugins: reads.plugins }; - } - reads - } - - /// Does the content read anything outside its own nodes? - pub(crate) fn any(self) -> bool { - self != Self::default() - } - - /// Can installing or removing a plug-in change what the content draws? It can when an object in - /// it has a plug-in effect, and when it shows symbol or pattern art. - pub(crate) fn plugins_can_change(self) -> bool { - self.plugins || self.symbols || self.patterns - } - - /// Does the content show symbol or pattern art? - pub(crate) fn shows_definitions(self) -> bool { - self.symbols || self.patterns - } - - fn add(&mut self, n: &Node) { - let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. }); - n.walk(&mut |c| { - self.plugins |= effects::has_plugin_effect(c); - for item in &c.appearance.items { - self.patterns |= pattern(item.paint()); - self.brushes |= matches!(item, AppearanceItem::Stroke(s) if s.brush.is_some()); - } - match &c.kind { - NodeKind::SymbolInstance { .. } => self.symbols = true, - NodeKind::Image(_) | NodeKind::PlacedDocument(_) => self.images = true, - NodeKind::Text(t) => { - self.text = true; - self.symbols |= t.runs.iter().any(|r| r.inline.is_some()); - self.patterns |= t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke)); - } - _ => {} - } - if let Some(m) = c.extras().mask.as_deref() { - self.add(&m.art); - } - }); - } -} - -/// What a cached shadow, glow or filtered raster was drawn from besides its object, its effects -/// and the view's zoom and rotation ([`Renderer::raster_inputs`]). A raster is drawn from only -/// while its inputs equal the frame's. -#[derive(Clone)] -pub(crate) struct Inputs { - /// What the content reads. - reads: Reads, - /// A hash of the inputs as found when the raster was made: those every raster reads, those - /// `reads` names, and what the effects read themselves ([`Inputs::with`]). - key: u64, - /// The document's resources and the color settings `key` was found with: holding them keeps - /// the addresses they hashed for their values ([`DocInputs`]). - _doc: Arc, -} - -impl PartialEq for Inputs { - fn eq(&self, other: &Self) -> bool { - (self.reads, self.key) == (other.reads, other.key) - } -} - -impl Inputs { - /// These inputs with `extra`, something the effects read themselves. - fn with(mut self, extra: impl Hash) -> Self { - let mut h = DefaultHasher::new(); - (self.key, extra).hash(&mut h); - self.key = h.finish(); - self - } -} - -/// What raster contents read from the document by name or key, and the color settings, as a frame -/// finds them ([`Renderer::doc_inputs`]): hashes of the patterns' tilings -/// ([`crate::pattern::hash_tiling`]), of the symbols' names and art and of the images' keys and -/// pixels, the address of the color settings, and whether the patterns' and symbols' art has a -/// plug-in effect. Art, pixels and previews are hashed by the addresses of their shared values, -/// and names, keys, tiles, tile types and overlaps are hashed by value. The live cache keeps these -/// inputs with each evaluated live object, with each mesh's quads and with what each walk of a -/// live object or of effect art found when it found no placed file (`crate::live`), and -/// [`Renderer::fx_art`] keeps them with art that can be made from evaluated live objects. -pub(crate) struct DocInputs { - patterns: u64, - symbols: u64, - images: u64, - pub(crate) cms: usize, - /// Whether an object in a pattern's or a symbol's art, or in an opacity mask there, has a - /// visible plug-in effect. - plugins: bool, - /// Weak references to the values whose addresses the hashes hold: a weak reference keeps the - /// allocation, not the value, so no other value gets one of those addresses while these - /// inputs are kept. - _held: Held, -} - -impl DocInputs { - /// Whether `self` and `other` were made from the same patterns and symbols: their names, - /// tilings and art. - pub(crate) fn same_art(&self, other: &DocInputs) -> bool { - (self.patterns, self.symbols) == (other.patterns, other.symbols) - } -} - -/// The pattern and symbol art, the image pixels and the color settings, held weakly. -type Held = (Vec>, Vec>>, Weak); - -/// Most bytes the shadow and filtered-content rasters of one renderer hold together, with what each -/// was made from ([`Made`]): the least recently drawn go beyond it. -pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20; - -fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize { - pm.width() as usize * pm.height() as usize * 4 -} - -/// Live-effect geometry of a node, kept across frames: the object-level result and each -/// appearance item's own result on it (filled in as items are drawn). The node is kept alive, so -/// its address names it alone and a hit is exact. -pub(crate) struct FxGeom { - pub(crate) node: Arc, - g: Arc, - items: Vec>>, - pub(crate) stamp: u64, -} - -impl Renderer { - /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) as a cached raster: the - /// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU, - /// and reused while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are - /// unchanged (pans only move it), and while interacting after the object only moved - /// ([`Self::move_shadow`]). A raster of the whole reach too large for the budget is cropped to - /// what can show in the `screen` (device pixels): the view, and around it the blur's spread and - /// `around` (document units: how far the shadows and glows stacked over this one look), made - /// coarser to fit the budget where the blur hides the coarser grid. Such a raster, and one - /// made over it, is reused while a pan keeps the view inside the rect it was made for: the - /// view, and when the raster kept for the object was made at the same zoom and rotation and no - /// drag is on, the view with [`pan_margin`] around it, within `over` (the rect, in device - /// pixels, that the cropped rasters of the shadows and glows it is made over were made for; - /// `None` when it is made over none). `None` when the effect isn't a shadow/glow or the raster - /// would still be too large (caller falls back to [`Self::draw_shadow_filter`]). - /// While dragging on a multithreaded context, a raster that has to be made again counts as too - /// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels. - #[allow(clippy::too_many_arguments)] - fn shadow_raster( - &mut self, - f: &Frame, - n: &Node, - i: usize, - fx: &RasterFx, - reach: Rect, - owner: Option<&Arc>, - (screen, around, over): (Rect, f64, Option), - paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), - ) -> Option { - let (mode, opacity, dx, dy, blur, color, darkness) = match fx { - RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness), - RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None), - _ => return None, - }; - let linear = linear_key(f.view); - let key = (n as *const Node as usize, i, f.ink); - let stamp = self.stamp; - let interacting = f.opts.interacting; - // What the silhouette reads: as the raster kept for `n` found it, else found now. - let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads); - let inputs = self.raster_inputs(f, n, reads); - let exact = |e: &ShadowEntry| { - e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen)) - }; - if interacting && !self.shadows.get(&key).is_some_and(exact) { - self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint); - } - let hit = self.shadows.get_mut(&key).filter(|e| exact(e)); - let (image, x, y, scale, moved, cropped) = match hit { - Some(e) => { - e.stamp = stamp; - self.stats.moved += usize::from(e.moved); - let p = f.view * e.anchor; - (e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.map(|v| v.served(f.view))) - } - None => { - // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. - let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 }; - let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0; - let reach = reach + Vec2::new(dx, dy); - let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread); - // The raster's region and scale within `budget` pixels: `None` when over the limits. - let region = |r: Rect, coarser: bool, budget: f64| { - let (x0, y0) = (r.x0.floor(), r.y0.floor()); - let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); - let scale = if coarser { (w * h / budget).sqrt().ceil().max(1.0) } else { 1.0 }; - if !scale.is_finite() || scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 { - return None; - } - let (w, h) = ((w / scale).ceil(), (h / scale).ceil()); - ((1.0..=8192.0).contains(&w) && (1.0..=8192.0).contains(&h) && w * h <= budget).then_some((x0, y0, w, h, scale)) - }; - // A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter - // layer the caller falls back to can't make its silhouette solid. - let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| over.is_none()); - // Else it is cropped to what can show in the view, within the budget of the filter - // layer it would otherwise be drawn through, and made coarser in the same way. Made - // again at this zoom and rotation (after a pan), it covers a margin around the view - // too, so that later pans within the margin draw it. - let ((x0, y0, w, h, scale), cropped) = match full { - Some(r) => (r, None), - None => { - let m = spread + around / f.px; - let again = self.shadows.get(&key).is_some_and(|e| e.linear == linear) && !f.opts.dragging; - let e = if again { pan_margin((screen.width(), screen.height()), m, PIXEL_BUDGET, 8192.0) } else { 0.0 }; - let room = over.map_or(screen.inflate(e, e), |o| screen.inflate(e, e).intersect(o)); - let r = whole.intersect(room.inflate(m, m)); - if r.width() <= 0.0 || r.height() <= 0.0 { - return None; - } - (region(r, true, PIXEL_BUDGET)?, Some(room)) - } - }; - // During a drag, a larger shadow on a multithreaded context is filtered within the - // drag's budget (`with_filters`), and no raster is kept. - if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET { - return None; - } - let (w, h) = (w as u16, h as u16); - // Silhouette (the object's painted alpha, and its colors under Darkness), offset by - // the shadow distance. - let mut off = f.offscreen_context(w, h); - let view = Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view; - // Art is culled against the raster's own area: members of a group outside the view cast their shadows in it. - let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); - let crop = Frame { mt: false, view, visible, ..*f }; - let before = self.stats.moved; - self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); - // A silhouette drawn over moved rasters makes a moved shadow. - let moved = self.stats.moved > before; - off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); - off.render(&mut pm, &mut self.resources); - // What the raster is made from, for its object once it moves (`move_shadow`). - let made = - Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None }; - let (wu, hu) = (w as usize, h as usize); - let sigma = blur / 2.0 / f.px / scale; - if let Some(darkness) = darkness { - // The object's own colors, blurred, with the black Darkness adds (premultiplied). - let keep = 1.0 - shade(f.ink, darkness); - let mut planes: [Vec; 4] = [ - pm.data().iter().map(|p| p.r as f32).collect(), - pm.data().iter().map(|p| p.g as f32).collect(), - pm.data().iter().map(|p| p.b as f32).collect(), - pm.data().iter().map(|p| p.a as f32).collect(), - ]; - for plane in &mut planes { - effects::pixel::blur_plane(plane, wu, hu, sigma); - } - let [r, g, b, a] = &planes; - for (k, px) in pm.data_mut().iter_mut().enumerate() { - let al = (a[k] * opacity).clamp(0.0, 255.0).round(); - let value = |plane: &Vec| (plane[k] * opacity * keep).round().clamp(0.0, al) as u8; - *px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 }; - } - } else { - // An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow. - let solid = matches!(fx, RasterFx::OuterGlow { .. }); - let mut a: Vec = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect(); - effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale); - effects::pixel::blur_plane(&mut a, wu, hu, sigma); - let [cr, cg, cb] = f.ink.rgb(&color); - let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8; - for (px, av) in pm.data_mut().iter_mut().zip(&a) { - let al = (av * opacity).clamp(0.0, 255.0); - *px = vello_cpu::color::PremulRgba8 { r: tint(al, cr), g: tint(al, cg), b: tint(al, cb), a: al.round() as u8 }; - } - } - let image = std::sync::Arc::new(pm); - let anchor = reach.origin(); - let p = f.view * anchor; - let node = Drawn::of(n, owner); - let rel = (x0 - p.x, y0 - p.y); - let view = cropped.map(|room| ViewCrop::of(f.view, room)); - self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp }); - self.trim_fx_rasters(FX_RASTER_BUDGET); - (image, x0, y0, scale, moved, cropped) - } - }; - Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped }) - } - - /// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx` - /// draws in. The blend mode applies per draw: a blend layer would be a full-viewport compositing - /// pass per shadow. A full-size raster drawn as made sits on whole pixels, so the cheapest - /// sampler is exact. - fn draw_shadow(&mut self, ctx: &mut RenderContext, s: &Shadow, to: Affine) { - let (w, h) = (s.image.width() as f64, s.image.height() as f64); - let at = to * s.at; - ctx.set_transform(at); - ctx.set_blend_mode(blend_mode(s.mode)); - let [a, b, c, d, _, _] = at.as_coeffs(); - let exact = (a, b, c, d) == (1.0, 0.0, 0.0, 1.0) && at.translation().x.fract() == 0.0 && at.translation().y.fract() == 0.0; - let quality = if exact { peniko::ImageQuality::Low } else { peniko::ImageQuality::Medium }; - let sampler = peniko::ImageSampler { quality, ..Default::default() }; - ctx.reset_paint_transform(); - ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(s.image.clone()), sampler }); - ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); - ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal)); - ctx.set_transform(Affine::IDENTITY); - } - - /// Drop shadow / outer glow `fx` of what `paint` draws (bounded by `reach`) through a vello - /// filter layer, when there is no raster for it ([`Self::shadow_raster`]). vello's shadow filter - /// blurs the soft alpha, so an Outer Glow drawn here lacks the raster's solid silhouette. - fn draw_shadow_filter( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - fx: &RasterFx, - reach: Rect, - paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), - ) { - let (mode, opacity, dx, dy, blur, color, darkness) = match fx { - RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness), - RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None), - _ => return, - }; - let filter = match darkness { - Some(_) => shade_filter(blur), - None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)), - }; - // The offset moves the content rather than the filter: vello_cpu drops layer content - // that lies entirely outside the viewport before filtering. - let reach = reach + Vec2::new(dx, dy); - self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { - c.set_transform(fr.view); - c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); - paint(r, c, &shifted(fr, dx, dy)); - // Darkness: black over the object's own colors, keeping their alpha. - if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) { - c.set_transform(fr.view); - c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop)); - c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black])); - c.fill_rect(&reach); - c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal)); - } - c.pop_layer(); - }); - } - - /// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier - /// frame made for its object (the same id, effect slot and ink, at this zoom and rotation, with - /// the same `inputs`) when only the silhouette's place changed after that frame: made by the - /// same effect `fx` from a silhouette whose bounds have the size of `reach` (the content's - /// bounds moved by the shadow's offset), and whose alpha (and colors, under Darkness) are those - /// of `n`'s silhouette drawn into a raster placed as that one was against its anchor - /// ([`silhouette`]). The raster is kept as `n`'s, marked - /// moved, and is drawn at the nearest whole pixel. - #[allow(clippy::too_many_arguments)] - fn move_shadow( - &mut self, - f: &Frame, - n: &Node, - key: (usize, usize, Ink), - fx: &RasterFx, - reach: Rect, - owner: Option<&Arc>, - inputs: &Inputs, - paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), - ) { - let (dx, dy, colored) = match fx { - RasterFx::DropShadow { dx, dy, darkness, .. } => (*dx, *dy, darkness.is_some()), - _ => (0.0, 0.0, false), - }; - let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size()); - let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| { - (k.1, k.2) == (key.1, key.2) - && e.stamp < stamp - && e.linear == linear - && e.view.is_none() - && e.node.id() == n.id - && e.inputs == *inputs - && e.made.fits(&[fx], size, None, None) - }; - let Some((old, rel, w, h, scale)) = - self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height(), e.scale)) - else { - return; - }; - // The silhouette drawn where the old one lies in its raster. - let anchor = reach.origin(); - let p = f.view * anchor; - let mut off = f.offscreen_context(w, h); - let view = Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view; - // Culled against the raster's area, as `shadow_raster` draws the silhouette. - let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); - let crop = Frame { mt: false, view, visible, ..*f }; - self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); - off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); - off.render(&mut pm, &mut self.resources); - if self.shadows.get(&old).is_none_or(|e| silhouette(&pm, colored) != e.made.pixels) { - return; - } - if let Some(mut e) = self.shadows.remove(&old) { - (e.node, e.anchor, e.moved) = (Drawn::of(n, owner), anchor, true); - self.shadows.insert(key, e); - } - } - - /// `draw_shape` for nodes with live effects. - pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { - // The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path, - // made from the node alone, so the effects give the same result every frame while the - // plug-ins installed stay the same (`check_plugins`). - let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned(); - let mut cached = owner.map(|o| { - let key = Arc::as_ptr(&o) as usize; - let stamp = self.stamp; - let e = match self.fx_geom.remove(&key) { - Some(e) if Arc::ptr_eq(&e.node, &o) => e, - _ => FxGeom { g: Arc::new(effected_path(n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp }, - }; - (key, FxGeom { stamp, ..e }) - }); - let g = match &cached { - Some((_, e)) => e.g.clone(), - None => Arc::new(effected_path(n, bp)), - }; - self.draw_shape_fx_geom(ctx, f, n, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items)); - if let Some((key, e)) = cached { - self.fx_geom.insert(key, e); - } - } - - /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry. - fn draw_shape_fx_geom( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - n: &Node, - g: &BezPath, - rule: FillRule, - items: Option<&mut Vec>>>, - ) { - if g.elements().is_empty() { - return; - } - if f.opts.outline { - self.hairline(ctx, f, g, [0, 0, 0, 255]); - return; - } - let gctx = GeomContext::of(n); - let rfx = effects::raster_effects(&n.appearance.effects); - // Document-space reach of the painted geometry (strokes, arrowheads…). - let outset = n.appearance.outset(); - let reach = g.bounding_box().inflate(outset, outset); - let bounds = rasterize_bounds(f.symbols, n, &rfx); - // Under Rasterize the content reaches as far as its fills' and strokes' raster effects paint. - let reach = bounds.map_or(reach, |b| reach.union(b)); - let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None }; - let mut items = items; - self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut())); - ctx.set_transform(Affine::IDENTITY); - } - - /// Draw type, an image, a symbol instance or a live object with object-level effects - /// ([`has_object_fx`]) inside its transparency group: its art (reshaped by the geometry - /// effects, see [`effects::reshape`]) with the raster effects painted around it as for paths, - /// the art standing for the object's silhouette. - pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { - let opacity = self.opacity_of(a); - if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate) { - let blends = self.blends_through(a); - let bounds = if blends { self.bounds_of(f.symbols, a) } else { None }; - let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() }; - self.group(ctx, f, comp, &mut |r, c, fr| r.draw_object_fx_art(c, fr, a, cache)); - } else { - self.draw_object_fx_art(ctx, f, a, cache); - } - self.stats.drawn += 1; - } - - /// What [`Self::draw_object_fx`] draws inside the object's transparency group. - fn draw_object_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { - let art = self.fx_art(f.doc, a, cache); - // Art the cache did not keep is made again in the next frame. - let kept = cache && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &art)); - let outer = self.made_again; - self.made_again = outer || !kept; - // With Crop Marks, the object inside the art paints its own raster effects. - let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) }; - match cull_bounds(f.symbols, &art) { - Some(reach) if !f.opts.outline && !rfx.is_empty() => { - // Feather, Inner Glow and Rasterize with Create Clipping Mask clip to the art's outline. - let clipped = rfx.iter().any(|x| { - matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }) || matches!(x, RasterFx::Rasterize(r) if r.clipping_mask) - }); - let region = if clipped { self.clip_of(&art) } else { None }; - // A symbol's art can change while the instance doesn't: no cached shadows. - let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); - let bounds = rasterize_bounds(f.symbols, a, &rfx); - let reach = bounds.map_or(reach, |b| reach.union(b)); - let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None }; - self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art)); - ctx.set_transform(Affine::IDENTITY); - } - _ => self.draw_fx_art(ctx, f, a, &art), - } - self.made_again = outer; - } - - /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): - /// reshaped by its geometry effects, else type, images and paths as themselves and the others - /// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their - /// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]); - /// without the object's transparency and effects (a path keeps its geometry effects). Art that - /// is made from live objects it evaluated ([`evaluates_live`]), or from type, symbol art or - /// colors shaded through the color settings ([`reads_outside`]), is kept only while what it - /// reads from outside `a` stays the same ([`Self::live_found`]). - fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { - let key = Arc::as_ptr(a) as usize; - let mut kept = None; - if cache - && let Some((node, art, found)) = self.fx_arts.get(&key, self.stamp) - && Arc::ptr_eq(node, a) - { - match found { - None => return art.clone(), - Some(was) => kept = Some((art.clone(), was.0)), - } - } - let found = (kept.is_some() || (cache && (evaluates_live(a) || reads_outside(a)))).then(|| self.live_found(doc, a)); - if let Some((art, was)) = kept - && found.as_ref().is_some_and(|f| f.0 == was) - { - return art; - } - let symbol = match &a.kind { - NodeKind::SymbolInstance { symbol, .. } => { - doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a)) - } - _ => None, - }; - let container = is_container(a); - let art = match effects::crop_marks_art(a) - .or_else(|| effects::revolve_art(a)) - .or_else(|| effects::solid_art(a)) - .or_else(|| effects::reshape(Some(doc), a, symbol.as_ref())) - { - Some(r) => r, - None if container => effects::evaluate_container(Some(doc), a).unwrap_or_else(|| (**a).clone()), - None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), - None => effects::outline_art(Some(doc), a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), - }; - let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art }; - let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref()); - // A path's geometry effects still apply when it is drawn. - let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); - art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); - let mut art = art.with_mask(None); - art.opacity = 1.0; - art.blend = Default::default(); - art.isolate = false; - // One element: its pieces never knock each other out (a group's members still do as its - // knockout setting says). - art.knockout = if container { a.knockout } else { vectorcraft_doc::Knockout::Off }; - let art = Arc::new(art); - if cache { - let bytes = crate::lru::node_bytes(&art); - self.fx_arts.insert(key, (a.clone(), art.clone(), found), bytes, self.stamp); - } - art - } - - /// Draw `art`, the art [`Self::fx_art`] made for `a`. Type art that `fx_arts` keeps for `a` is - /// drawn as `fx_owner`, so [`Self::draw_text`] takes its glyph geometry from `texts`. - fn draw_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, art: &Arc) { - let kept = - matches!(art.kind, NodeKind::Text(_)) && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, art)); - if !kept { - return self.draw_node(ctx, f, art, true); - } - let outer = self.fx_owner.replace(art.clone()); - self.draw_node(ctx, f, art, true); - self.fx_owner = outer; - } - - /// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on - /// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the - /// decoded images under a key naming the adjustment. - fn adjusted_art(&mut self, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { - art.appearance.effects.retain(|e| !effects::is_adjustment(&e.id)); - let mut wanted: Vec<(String, String, u64, effects::ColorMap)> = vec![]; - let adjusted = effects::adjust(&art, outer, &mut |key, map| { - let mut h = std::hash::DefaultHasher::new(); - std::hash::Hash::hash(&format!("{map:?}"), &mut h); - let hash = std::hash::Hasher::finish(&h); - let derived = format!("{key}\u{0}adjust\u{0}{hash:016x}"); - wanted.push((key.to_string(), derived.clone(), hash, map.clone())); - Some(derived) - }); - for (key, derived, hash, map) in wanted { - self.adjusted_image(&key, derived, hash, &map); - } - adjusted.unwrap_or(art) - } - - /// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows - /// below it, the content blurred or feathered, inner glows above it (clipped to its outline). - /// `paint` draws the content in the frame it is given. - fn raster_fx( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - content: &Content, - rfx: &[RasterFx], - paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), - ) { - // Only the last MAX_RASTERIZE Rasterize effects are drawn. - let count = rfx.iter().filter(|fx| matches!(fx, RasterFx::Rasterize(_))).count(); - let capped: Vec; - let rfx = if count > MAX_RASTERIZE { - let mut left_out = count - MAX_RASTERIZE; - capped = rfx - .iter() - .filter(|fx| { - let out = left_out > 0 && matches!(fx, RasterFx::Rasterize(_)); - left_out -= usize::from(out); - !out - }) - .cloned() - .collect(); - &capped[..] - } else { - rfx - }; - // Rasterize at the document's raster effects resolution takes it here. - let resolved: Vec; - let rfx = if rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(r) if r.ppi.is_none())) { - let ppi = doc_raster_ppi(f.doc); - resolved = rfx.iter().map(|fx| fx.at_document_ppi(ppi)).collect(); - &resolved[..] - } else { - rfx - }; - // Rasterize makes an image of everything the effects before it paint, shadows and glows - // included, and the shadows and glows after it paint around that image. When some come - // after the last Rasterize, the stack splits after it, else when any effect comes before - // it, the stack splits before it; the effects after the split paint around the content - // drawn with those before it. Rasterize then comes first in its part. - if let Some(at) = rfx.iter().rposition(|fx| matches!(fx, RasterFx::Rasterize(_))) { - let paints_around = |fx: &[RasterFx]| fx.iter().any(|fx| !content_changing(&fx)); - let (_, rest) = rfx.split_at(at + 1); - let split = if paints_around(rest) { Some(at + 1) } else { (at > 0).then_some(at) }; - if let Some(split) = split { - let (before, after) = rfx.split_at(split); - let line = 72.0 / doc_raster_ppi(f.doc); - let (mut reach, mut spread) = (content.reach, content.bounds); - // After a Rasterize on white without a clipping mask, the silhouette is its image, - // and it stays the silhouette through the effects after it (as in `pixel_content`). - let mut image = None; - // Whether the image of a Rasterize before the split is over `rasterize_image`'s - // limits. Such an image is kept only as far as the view needs it, so the effects - // after the split are not cached. - let (mut over, mut last_over) = (false, false); - for fx in before { - if let RasterFx::Rasterize(r) = fx { - last_over = image_size(r, r.image(spread.unwrap_or(reach), line), line).is_none(); - over |= last_over; - } - let o = fx.outset(reach); - reach = reach.inflate(o, o); - spread = spread.map(|b| fx.spread(b, line)); - if let RasterFx::Rasterize(r) = fx - && r.white - && !r.clipping_mask - { - image = Some(spread.unwrap_or(reach).to_path(0.1)); - } - } - // When the split comes after the Rasterize, the shadows and glows after it and the - // content each draw the content before the split. The first draw makes the image, - // and the others draw the one it keeps. - let window = after.iter().fold(f.visible, |w, fx| { - let o = fx.outset(w); - w.inflate(o, o) - }); - let frame = last_over.then_some((f.view, f.px)); - let kept = (split > at).then(|| Kept { window, frame, image: std::cell::RefCell::new(None) }); - let inner = Content { - node: content.node, - owner: content.owner, - cache: None, - reach: content.reach, - outline: content.outline, - bounds: content.bounds, - kept: kept.as_ref(), - }; - let outline = image.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); - // When the split comes before the Rasterize, the part from it on draws the content - // once, and its image is the one kept for a split above. - let outer = Content { - node: content.node, - owner: content.owner, - cache: content.cache.filter(|_| !over), - reach, - outline, - bounds: spread, - kept: content.kept.filter(|_| split == at), - }; - self.raster_fx(ctx, f, &outer, after, &mut |r, c, fr| r.raster_fx(c, fr, &inner, before, paint)); - return; - } - } - let reach = content.reach; - // The `Arc` drawn: the caller's, or the node `draw_arc` is drawing. - let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); - // Below the content: shadows and outer glows, each of the content with the ones before it in the stack, as AI - // files draw a glow round a glow and the SVG export nests them. Their rasters are made first to last, each once - // and drawn into the later ones' silhouettes as an image (so n of them cost n rasters, not 2^n draws), then - // drawn with the last lowest. One without a raster (an object drawn uncached, a glow past the budget) is drawn - // through a filter layer from the content alone, and goes into the later silhouettes that way. - let mut made: Vec<(&RasterFx, Option)> = vec![]; - let mut stacked = reach; - let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())); - // How far (document units) a shadow or glow looks from a pixel to the silhouette under it. - let looks = |fx: &RasterFx| match fx { - RasterFx::DropShadow { dx, dy, blur, .. } => blur.max(0.0) * 1.5 + dx.abs() + dy.abs(), - RasterFx::OuterGlow { blur, .. } => blur.max(0.0) * 1.5, - _ => 0.0, - }; - for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { - // A raster cropped to the view covers what the shadows and glows stacked over it read. - let around: f64 = rfx.iter().skip(i + 1).filter(|x| x.is_below()).map(looks).sum(); - // The rect that the cropped rasters of the earlier ones (drawn into this one's silhouette) were all made for. - let over = made.iter().filter_map(|(_, s)| s.as_ref()?.cropped).reduce(|a, b| a.intersect(b)); - let shadow = { - let earlier = &made; - let view = f.view; - let mut art = |r: &mut Self, c: &mut RenderContext, fr: &Frame| { - let to = fr.view * view.inverse(); - for (fx, s) in earlier.iter().rev() { - match s { - Some(s) => { - r.stats.moved += usize::from(s.moved); - r.draw_shadow(c, s, to); - } - None => r.draw_shadow_filter(c, fr, fx, reach, paint), - } - } - paint(r, c, fr); - }; - let view = (screen, around, over); - content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), view, &mut art)) - }; - stacked = match fx { - RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)), - _ => stacked.inflate(fx.outset(stacked), fx.outset(stacked)), - }; - made.push((fx, shadow)); - } - for (fx, s) in made.iter().rev() { - match s { - Some(s) => self.draw_shadow(ctx, s, Affine::IDENTITY), - None => self.draw_shadow_filter(ctx, f, fx, reach, paint), - } - } - // The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur - // works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel - // and looked blocky as the view moved by parts of a pixel (#755). - let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0)); - if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) { - self.pixel_content(ctx, f, content, rfx, paint); - } else { - paint(self, ctx, f); - } - // Above the content: inner glows, clipped to its outline. - let Some((g, rule)) = content.outline else { return }; - for fx in rfx { - let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue }; - let filter = shadow_filter(0.0, 0.0, *blur, pcolor(f.ink, color)); - self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| { - // The blend layer goes outside the clip: a clip layer is isolated, so a blend - // inside it would mix with nothing instead of the content below. - if let Some((m, o)) = comp { - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(blend_mode(m)), Some(o), None, None); - } - c.set_transform(fr.view); - c.set_fill_rule(fill_rule(rule)); - c.push_clip_layer(g); - c.push_layer(None, None, None, None, Some(filter)); - c.set_paint(peniko::Color::BLACK); - if *center { - c.set_fill_rule(fill_rule(rule)); - c.fill_path(g); - } else { - // Everything outside the shape, so the glow bleeds in from the edges. - let pad = blur * 2.0 + 4.0 * fr.px; - let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1); - inv.extend(g.iter()); - c.set_fill_rule(peniko::Fill::EvenOdd); - c.fill_path(&inv); - } - c.pop_layer(); - c.pop_layer(); - if comp.is_some() { - c.pop_layer(); - } - }); - } - } - - /// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the - /// Photoshop-style filters and Rasterize) in stack order on its pixels: rendered offscreen over - /// all it can reach, filtered on the CPU and drawn back. A Rasterize first in the chain draws the - /// content on its image's own grid instead ([`Self::rasterize_image`]) when that image is within - /// the limits below; content with a [`Kept`] image draws that image after its first draw. The - /// raster holds at most [`PIXEL_BUDGET`] pixels and [`crate::MAX_RASTER_SIDE`] a side: content - /// over the budget is cropped to the view (content with a [`Kept`] image to its window) when - /// every effect only looks nearby, and content still over either limit is rendered at a lower - /// resolution. A whole raster at full resolution is cached while the object, the - /// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and - /// while interacting after the content only moved ([`Self::move_pixel_content`]). A cropped or - /// lower-resolution raster is cached while the object, the zoom and what the content reads stay - /// the same and a pan keeps the view inside the rect it was made for ([`PixelEntry::view`]): the - /// view, and for a raster cropped to it that is made again at the same zoom and rotation, outside - /// a drag and for content without a [`Kept`] image, the view with [`pan_margin`] around it. A - /// lower-resolution one is kept resampled to the view's pixels ([`Self::resampled`]). - /// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`] - /// pixels and is not cached. - fn pixel_content( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - content: &Content, - rfx: &[RasterFx], - paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), - ) { - let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect(); - // When the first draw of this content kept its raster, that raster is drawn. - if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) { - draw_placed(ctx, image, f.view * place); - return; - } - // Content kept with an image over the limits is drawn in the split's frame, over where the - // effects after the split take it from, then placed in this one. - let call = f.view; - let split_frame; - let (f, screen) = match content.kept.and_then(|k| k.frame.map(|frame| (k.window, frame))) { - Some((window, (view, px))) => { - split_frame = Frame { view, px, ..*f }; - (&split_frame, view.transform_rect_bbox(window)) - } - None => (f, Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64)), - }; - // Where the result can paint: each effect spreads what the ones before it painted. - let reach = chain.iter().fold(content.reach, |r, fx| { - let o = fx.outset(r); - r.inflate(o, o) - }); - let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0); - if !(full.area().is_finite() && full.x1 > screen.x0 && full.y1 > screen.y0 && full.x0 < screen.x1 && full.y0 < screen.y1) { - return; - } - let fits = full.area() <= PIXEL_BUDGET; - // Device pixels from a pixel to the content deciding it (`None`: anywhere). - let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px)); - let linear = linear_key(f.view); - let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink)); - // Made again at this zoom and rotation (after a pan), a raster cropped to the view covers a - // margin around the view too, so that later pans within the margin draw it. - let again = key.and_then(|key| self.pixel_fx.get(&key)).is_some_and(|e| e.linear == linear) && content.kept.is_none() && !f.opts.dragging; - let room = match margin { - Some(m) if !fits && again => { - let e = pan_margin((screen.width(), screen.height()), m, PIXEL_BUDGET, f64::from(crate::MAX_RASTER_SIDE)); - screen.inflate(e, e) - } - _ => screen, - }; - let region = match margin { - Some(m) if !fits => full.intersect(room.inflate(m, m)), - _ => full, - }; - let (x0, y0) = (region.x0.floor(), region.y0.floor()); - let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0)); - // (vello_cpu panics drawing near `u16::MAX` pixels a side.) - let side = f64::from(crate::MAX_RASTER_SIDE); - let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0); - let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side)); - // The cached raster: of the whole content at full size at this zoom, else of this view (and - // the margin around it). - let whole = fits && k >= 1.0; - let in_view = (!whole).then(|| ViewCrop::of(f.view, room)); - let stamp = self.stamp; - let channels = match f.ink { - Ink::Cmy => effects::pixel::Channels::CmyPlane, - Ink::K => effects::pixel::Channels::KPlane, - Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk, - Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, - }; - // De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi). - let ppi = doc_raster_ppi(f.doc); - // What the content reads (as the raster kept for it found it, else found now), with the - // channels the effects run on and, under De-Interlace or Rasterize, the resolution. - let inputs = key.map(|key| { - let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads); - let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }) | RasterFx::Rasterize(_))); - self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) - }); - let interacting = f.opts.interacting; - let in_place = |e: &PixelEntry| match e.view { - None => whole, - Some(v) => !whole && v.serves(f.view, screen), - }; - let exact = |e: &PixelEntry| { - e.linear == linear && in_place(e) && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) - }; - if interacting - && whole - && let Some(key) = key - && let Some(inputs) = &inputs - && !self.pixel_fx.get(&key).is_some_and(exact) - { - self.move_pixel_content(f, content, key, &chain, channels, inputs, paint); - } - if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| exact(e)) { - e.stamp = stamp; - self.stats.moved += usize::from(e.moved); - let p = f.view * e.anchor; - let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k); - crate::paint::add_decode_failures(e.failed); - draw_scaled(ctx, image, at, k); - return; - } - // During a drag, content without a raster to draw renders within the drag's budget and keeps - // no raster (#2558). - let (k, w, h, key) = match drag_scale(f.opts.dragging, (rw, rh), k) { - Some(d) => { - self.stats.moved += 1; - self.stats.previewed += 1; - (d, (rw * d).ceil().clamp(1.0, side), (rh * d).ceil().clamp(1.0, side), None) - } - None => (k, w, h, key), - }; - let (wu, hu) = (w as u16, h as u16); - let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view; - let to_doc = view.inverse(); - let px = f.px / k; - let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; - let line = 72.0 / ppi; - let (before, decoded) = (self.stats.moved, crate::paint::decode_failures()); - // A Rasterize first in the chain draws the content on its image's own grid instead, unless - // that image is too large: the content is drawn once. - let mut rasterized = match chain.first() { - Some(RasterFx::Rasterize(r)) => { - let rect = r.image(content.bounds.unwrap_or(content.reach), line); - let kept = content.kept.and_then(|k| match &*k.image.borrow() { - Some(KeptImage::Grid(image, failed)) => { - crate::paint::add_decode_failures(*failed); - Some(image.clone()) - } - _ => None, - }); - let image = kept.or_else(|| { - let failures = crate::paint::decode_failures(); - let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new); - if let (Some(k), Some(image)) = (content.kept, &image) { - *k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures)); - } - image - }); - image.map(|image| (image, rect)) - } - _ => None, - }; - let mut pm = vello_cpu::Pixmap::new(wu, hu); - if rasterized.is_none() { - #[cfg(test)] - if matches!(chain.first(), Some(RasterFx::Rasterize(_))) { - RASTERIZED.with(|n| n.set(n.get() + 1)); - } - let mut off = f.offscreen_context(wu, hu); - if hard_edged(&chain, px) { - off.set_aliasing_threshold(crate::AntiAlias::None.threshold()); - } - self.inside_layer(|r| paint(r, &mut off, &frame)); - off.flush(); - off.render(&mut pm, &mut self.resources); - } - let failed = crate::paint::decode_failures() - decoded; - // Content drawn over moved rasters makes a moved raster. - let moved = self.stats.moved > before; - // What the raster is made from, for its object once it moves (`move_pixel_content`): a - // cropped or coarser raster never moves. - let made = key.map(|_| Made { - pixels: if whole { pm.data_as_u8_slice().to_vec() } else { Vec::new() }, - fx: chain.iter().map(|fx| (*fx).clone()).collect(), - size: content.reach.size(), - channels: Some(channels), - outline: feather_outline(&chain, content, content.reach.origin()), - }); - let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height())); - let space = effects::PixelSpace { to_doc, px, center, channels, line, extent }; - let (wz, hz) = (wu as usize, hu as usize); - // What the content covers after the effects run so far: Rasterize makes its image over it. - let mut bounds = content.bounds.unwrap_or(content.reach); - // The silhouette Feather and Create Clipping Mask clip to: the content's, or after a Rasterize - // on white without a clipping mask, its image. - let mut image_outline: Option = None; - for (i, fx) in chain.into_iter().enumerate() { - let before = bounds; - bounds = fx.spread(before, space.line); - let outline = image_outline.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); - match fx { - RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px), - RasterFx::Feather { radius } => { - effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px); - if let Some((g, rule)) = outline { - self.clip_pixels(&mut pm, &frame, g, rule); - } - } - RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space), - RasterFx::Rasterize(r) => { - match rasterized.take().filter(|_| i == 0) { - Some((image, rect)) => { - let (pixels, cols, rows) = &*image; - r.compose(pm.data_as_u8_slice_mut(), wz, hz, &space, pixels, *cols, *rows, rect) - } - None => r.apply(pm.data_as_u8_slice_mut(), wz, hz, &space, before), - } - if let Some((g, rule)) = outline.filter(|_| r.clipping_mask) { - self.clip_pixels(&mut pm, &frame, g, rule); - } - if r.white && !r.clipping_mask { - image_outline = Some(bounds.to_path(0.1)); - } - } - _ => {} - } - } - let image = std::sync::Arc::new(pm); - // A raster kept for this view alone at a lower resolution than the view's is resampled to the - // view's pixels once: each frame that draws it then copies it pixel for pixel, where sampling - // it again across the whole view cost several times as much as a plain fill. - let view_raster = key.is_some() && in_view.is_some() && k < 1.0 && content.kept.is_none(); - let (image, x0, y0, k) = match view_raster.then(|| self.resampled(f, image.clone(), (x0, y0), k, screen)).flatten() { - Some((image, x, y)) => (image, x, y, 1.0), - None => (image, x0, y0, k), - }; - // A cancelled export drew part of the content: that raster isn't kept for later frames. - if !vectorcraft_doc::cancel::is_cancelled() - && let Some(((key, made), inputs)) = key.zip(made).zip(inputs) - { - let anchor = content.reach.origin(); - let p = f.view * anchor; - let rel = (x0 - p.x, y0 - p.y); - let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); - let node = Drawn::of(content.node, owner.as_ref()); - let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp }; - self.pixel_fx.insert(key, entry); - self.trim_fx_rasters(FX_RASTER_BUDGET); - } - // An image over the limits was drawn at a frame's resolution: its raster is kept. - match content.kept.filter(|kept| kept.image.borrow().is_none()) { - Some(kept) => { - let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k); - *kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place)); - if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) } - } - None => draw_scaled(ctx, image, (x0, y0), k), - } - } - - /// `image` as [`draw_scaled`] draws it with its top-left at (`x`, `y`) device pixels, `1 / k` - /// device pixels a pixel, resampled to the device pixels it covers of `screen`, with their - /// top-left. `None` when it covers none of them. - fn resampled( - &mut self, - f: &Frame, - image: std::sync::Arc, - (x, y): (f64, f64), - k: f64, - screen: Rect, - ) -> Option<(std::sync::Arc, f64, f64)> { - let covered = Rect::new(x, y, x + f64::from(image.width()) / k, y + f64::from(image.height()) / k).intersect(screen); - let (x0, y0) = (covered.x0.floor(), covered.y0.floor()); - let (w, h) = (covered.x1.ceil() - x0, covered.y1.ceil() - y0); - let side = f64::from(crate::MAX_RASTER_SIDE); - if !((1.0..=side).contains(&w) && (1.0..=side).contains(&h)) { - return None; - } - let (w, h) = (w as u16, h as u16); - let mut off = f.offscreen_context(w, h); - draw_scaled(&mut off, image, (x - x0, y - y0), k); - off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); - off.render(&mut pm, &mut self.resources); - Some((std::sync::Arc::new(pm), x0, y0)) - } - - /// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame - /// made at full size for its object (the same id, slot and ink, at this zoom and rotation, with - /// the same `inputs`) when only the content's place changed after that frame: made by the same - /// effects `chain`, told of the same `channels`, from content whose bounds have the same size - /// (and the same Feather outline against its anchor), and whose pixels are the pixels of - /// `content` drawn into a raster placed as that one was against its anchor. The raster is kept - /// as `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under - /// De-Interlace, or under a Rasterize first in the chain, takes none. - #[allow(clippy::too_many_arguments)] - fn move_pixel_content( - &mut self, - f: &Frame, - content: &Content, - key: (usize, usize, Ink), - chain: &[&RasterFx], - channels: Channels, - inputs: &Inputs, - paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), - ) { - if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) || matches!(chain.first(), Some(RasterFx::Rasterize(_))) - { - return; - } - let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin()); - let outline = feather_outline(chain, content, anchor); - let fits = |k: &(usize, usize, Ink), e: &PixelEntry| { - (k.1, k.2) == (key.1, key.2) - && e.stamp < stamp - && e.linear == linear - && e.k >= 1.0 - && e.view.is_none() - && e.node.id() == content.node.id - && e.inputs == *inputs - && e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref()) - }; - let Some((old, rel, w, h)) = self.pixel_fx.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height())) - else { - return; - }; - // The content drawn where the old content lies in its raster. - let p = f.view * anchor; - let view = Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view; - let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); - let frame = Frame { mt: false, view, visible, ..*f }; - let mut off = f.offscreen_context(w, h); - if hard_edged(chain, f.px) { - off.set_aliasing_threshold(crate::AntiAlias::None.threshold()); - } - self.inside_layer(|r| paint(r, &mut off, &frame)); - off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); - off.render(&mut pm, &mut self.resources); - if self.pixel_fx.get(&old).is_none_or(|e| e.made.pixels.as_slice() != pm.data_as_u8_slice()) { - return; - } - let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); - if let Some(mut e) = self.pixel_fx.remove(&old) { - (e.node, e.anchor, e.moved) = (Drawn::of(content.node, owner.as_ref()), anchor, true); - self.pixel_fx.insert(key, e); - } - } - - /// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the - /// color settings, the edge smoothing, the document's raster effects resolution (which a - /// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and, - /// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and - /// brush library it reads; for images and placed documents their pixels and files, the - /// layer's Dim Images and the options they are drawn with; for type the fonts, whether - /// overprinting shows ([`crate::proof::overprints`]) and Document Setup's substitution - /// highlights; and the plug-ins installed, when an object in it has a plug-in effect, and - /// when it shows symbol or pattern art and an object in the document's symbol or pattern art - /// has one. - pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs { - let doc = self.doc_inputs(f.doc); - let mut h = DefaultHasher::new(); - (doc.cms, f.opts.anti_alias.threshold(), self.knockout, f.doc.raster_effects_ppi.to_bits()).hash(&mut h); - // Cached effects on containers can include paths made into guides. - f.opts.hide_guides.hash(&mut h); - if let Some((isolated, path)) = self.isolation_path(f) - && n.id != isolated - && path.contains(&n.id) - { - isolated.hash(&mut h); - } - if reads.patterns { - doc.patterns.hash(&mut h); - } - if reads.symbols { - doc.symbols.hash(&mut h); - } - if reads.images { - let o = f.opts; - (doc.images, self.dim_images.map(f32::to_bits), o.smooth_images, o.image_mips, o.trace_views, o.progressive_placed).hash(&mut h); - o.trace_source.as_deref().hash(&mut h); - o.mark_undecoded_images.hash(&mut h); - } - if reads.brushes { - self.brushes.library_key(f.doc, self.stamp).hash(&mut h); - } - if reads.text { - let o = f.opts; - let highlights = o.highlight_substitutions.then_some(( - o.highlight_substituted_fonts, - f.doc.setup.highlight_substituted_glyphs, - o.highlight_alternate_glyphs, - )); - (vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h); - } - if reads.plugins || (doc.plugins && (reads.symbols || reads.patterns)) { - effects::plugin_revision().hash(&mut h); - } - Inputs { reads, key: h.finish(), _doc: doc } - } - - /// [`DocInputs`] for document `doc`: found once a frame (and again for another document drawn - /// in it, such as a placed document's own), and kept while they stay the same. - pub(crate) fn doc_inputs(&mut self, doc: &Document) -> Arc { - let at = (self.stamp, std::ptr::from_ref(doc) as usize); - if let Some((seen, d)) = &self.doc_inputs - && *seen == at - { - return d.clone(); - } - let cms = vectorcraft_color::cms::active(); - let hash = |each: &mut dyn FnMut(&mut DefaultHasher)| { - let mut h = DefaultHasher::new(); - each(&mut h); - h.finish() - }; - let address = |a: &Arc| Arc::as_ptr(a) as usize; - let patterns = hash(&mut |h| doc.patterns.iter().for_each(|p| crate::pattern::hash_tiling(p, h))); - let symbols = hash(&mut |h| doc.symbols.iter().for_each(|s| (&s.name, address(&s.art)).hash(h))); - let images = hash(&mut |h| { - for (key, blob) in &doc.images { - (key, Arc::as_ptr(&blob.bytes) as usize, blob.proxy.as_ref().map(|p| Arc::as_ptr(p) as usize)).hash(h); - } - }); - let found = (patterns, symbols, images, Arc::as_ptr(&cms) as usize); - let d = match &self.doc_inputs { - Some((_, d)) if (d.patterns, d.symbols, d.images, d.cms) == found => d.clone(), - _ => { - let doc_art = || doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art)); - let art = doc_art().map(Arc::downgrade).collect(); - let pixels = doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy)).map(Arc::downgrade).collect(); - let plugins = doc_art().any(|a| crate::holds_plugin_effect(a)); - Arc::new(DocInputs { patterns, symbols, images, cms: found.3, plugins, _held: (art, pixels, Arc::downgrade(&cms)) }) - } - }; - self.doc_inputs = Some((at, d.clone())); - d - } - - /// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes - /// ([`FX_RASTER_BUDGET`]). - fn trim_fx_rasters(&mut self, budget: usize) { - let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, e.bytes())); - let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, e.bytes())); - let mut all: Vec<_> = shadows.chain(pixels).collect(); - let mut total: usize = all.iter().map(|e| e.3).sum(); - if total <= budget { - return; - } - all.sort_unstable_by_key(|e| e.0); - for (_, pixel, key, bytes) in all { - if total <= budget { - break; - } - if pixel { - self.pixel_fx.remove(&key); - } else { - self.shadows.remove(&key); - } - total -= bytes; - } - } - - /// The image Effect › Rasterize `r` makes of the content `paint` draws over `rect` (whole - /// pixels of the image): drawn on the image's own grid as Object › Rasterize draws it (with - /// hard edges when `r` doesn't anti-alias), then given `r`'s background and color model. `None` - /// when it would hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than - /// [`crate::MAX_RASTER_SIDE`]. → (pixels, columns, rows) - fn rasterize_image( - &mut self, - f: &Frame, - r: &effects::Rasterize, - rect: Rect, - paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), - ) -> Option<(Vec, usize, usize)> { - let line = 72.0 / doc_raster_ppi(f.doc); - let (w, h) = image_size(r, rect, line)?; - #[cfg(test)] - RASTERIZED.with(|n| n.set(n.get() + 1)); - let cell = r.cell(line); - let view = Affine::scale(1.0 / cell) * Affine::translate(-rect.origin().to_vec2()); - // The effect's own anti-aliasing, whatever the view or the export draws with. - let anti_alias = match (r.anti_alias, r.hinted) { - (false, _) => crate::AntiAlias::None, - (true, true) => crate::AntiAlias::Type, - (true, false) => crate::AntiAlias::Art, - }; - let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() }; - let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f }; - let mut off = frame.offscreen_context(w, h); - self.inside_layer(|s| paint(s, &mut off, &frame)); - off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); - off.render(&mut pm, &mut self.resources); - let mut pixels = pm.data_as_u8_slice().to_vec(); - r.finish(&mut pixels); - Some((pixels, usize::from(w), usize::from(h))) - } - - /// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip). - fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) { - let mut m = frame.offscreen_context(pm.width(), pm.height()); - m.set_transform(frame.view); - m.set_fill_rule(fill_rule(rule)); - m.set_paint(peniko::Color::BLACK); - m.fill_path(g); - m.flush(); - let mut mask = vello_cpu::Pixmap::new(pm.width(), pm.height()); - m.render(&mut mask, &mut self.resources); - for (p, c) in pm.data_mut().iter_mut().zip(mask.data()) { - let keep = |v: u8| ((v as u32 * c.a as u32 + 127) / 255) as u8; - *p = vello_cpu::color::PremulRgba8 { r: keep(p.r), g: keep(p.g), b: keep(p.b), a: keep(p.a) }; - } - } - - /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw` - /// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context - /// the layer is drawn into an offscreen single-threaded context covering `reach` (document - /// space) plus the blur's spread, then composited back with `composite`. - /// While dragging, that context holds at most [`DRAG_PIXEL_BUDGET`] pixels. - fn with_filters( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - reach: Rect, - blur: f64, - composite: Option<(vectorcraft_doc::color::BlendMode, f32)>, - draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(vectorcraft_doc::color::BlendMode, f32)>), - ) { - if !f.mt { - return self.inside_layer(|r| draw(r, ctx, f, composite)); - } - let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return }; - // During a drag, the region holds at most `DRAG_PIXEL_BUDGET` pixels (#2558). - let drag = || filter_region_within(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px, DRAG_PIXEL_BUDGET); - let (x0, y0, w, h, k) = match f.opts.dragging.then(drag).flatten().filter(|r| r.4 < k) { - Some(r) => { - self.stats.moved += 1; - self.stats.previewed += 1; - r - } - None => (x0, y0, w, h, k), - }; - let mut off = f.offscreen_context(w, h); - let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f }; - self.inside_layer(|r| draw(r, &mut off, &shifted, None)); - off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); - off.render(&mut pm, &mut self.resources); - let layered = composite.is_some_and(|(m, o)| m != vectorcraft_doc::color::BlendMode::Normal || o < 1.0); - if let Some((m, o)) = composite.filter(|_| layered) { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None); - } - if k < 1.0 { - draw_scaled(ctx, std::sync::Arc::new(pm), (x0, y0), k); - ctx.set_transform(Affine::IDENTITY); - } else { - crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0)); - } - if layered { - ctx.pop_layer(); - } - } - - /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and - /// painting per-item raster effects around that item alone. - #[allow(clippy::too_many_arguments)] - fn paint_items( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - n: &Node, - g: &BezPath, - rule: FillRule, - gctx: &GeomContext, - mut cache: Option<&mut Vec>>>, - ) { - let bounds = g.bounding_box(); - for (j, item) in n.appearance.items.iter().enumerate() { - if !item.visible() || item.paint().is_none() { - continue; - } - // The item's own geometry effects on `g`, from the cache when there is one. - let slot = cache.as_deref_mut().and_then(|c| c.get_mut(j)); - let ig: Arc = match slot { - Some(Some(ig)) => ig.clone(), - Some(s) => s.insert(Arc::new(apply(item_effects(item), g, &gctx.item(item)))).clone(), - None => Arc::new(apply(item_effects(item), g, &gctx.item(item))), - }; - let ig = &*ig; - let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; - let rfx = effects::raster_effects(item_effects(item)); - if rfx.is_empty() { - self.paint_item(ctx, f, n, item, ig, rule, ib); - continue; - } - // The item's silhouette: its fill area, or the outline of its stroke. - let outline = match item { - AppearanceItem::Fill(_) => ig.clone(), - AppearanceItem::Stroke(st) => { - let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(ig)); - effects::stroke::line_outline(&effects::stroke::stroke_pieces(ig, st).line, st, w, f.px * 0.25) - } - }; - let outset = n.appearance.outset(); - let orule = if item.is_fill() { rule } else { FillRule::NonZero }; - // Each item's shadows get their own cache slots. - // Rasterize makes its image over the item's silhouette. - let bounds = rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| outline.bounding_box()); - let content = Content { - node: n, - owner: None, - cache: Some((j + 1) << 8), - reach: ib.inflate(outset, outset), - outline: Some((&outline, orule)), - bounds, - kept: None, - }; - self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib)); - ctx.set_transform(Affine::IDENTITY); - } - } - - /// Paint one fill or stroke of `n` on its geometry `ig` (bounds `ib`). - #[allow(clippy::too_many_arguments)] - fn paint_item(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, item: &AppearanceItem, ig: &BezPath, rule: FillRule, ib: Rect) { - match item { - AppearanceItem::Fill(fl) => { - let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); - } - ctx.set_transform(f.view); - if paint::set_paint(ctx, &fl.paint, ib, f) { - ctx.set_fill_rule(fill_rule(rule)); - ctx.fill_path(ig); - } - if layered { - ctx.pop_layer(); - } - } - AppearanceItem::Stroke(st) => { - if st.width <= 0.0 { - return; - } - // Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush). - if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) { - return; - } - self.draw_stroke(ctx, f, Affine::IDENTITY, ig, rule, st, None, ib); - } - } - } -} +pub(crate) use bounds::visual_bounds; +pub(crate) use bounds::{cull_bounds, drawn_outline, painted_reach}; +#[cfg(test)] +pub(crate) use entries::Drawn; +pub(crate) use entries::{DocInputs, FxGeom, Reads, ShadowEntry}; +pub(crate) use predicates::{has_fx, has_object_fx}; +pub(crate) use raster::PixelEntry; +#[cfg(test)] +pub(crate) use raster::RASTERIZED; +#[cfg(test)] +use raster::{DRAG_PIXEL_BUDGET, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin}; #[cfg(test)] mod tests; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs new file mode 100644 index 000000000..f19270140 --- /dev/null +++ b/crates/render/src/fx/bounds.rs @@ -0,0 +1,374 @@ +use super::predicates::{has_fx, has_object_fx}; +use super::raster::doc_raster_ppi; +use super::*; + +fn node_bezpath(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.to_bezpath()), + NodeKind::Compound { children, .. } => { + let mut bp = BezPath::new(); + for c in children { + if let Some(p) = c.path_data() { + bp.extend(p.to_bezpath()); + } + } + Some(bp) + } + _ => None, + } +} + +pub(super) fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath { + if !effects::has_geometry(effects) || bp.elements().is_empty() { + return bp.clone(); + } + effects::apply_geometry_bez(effects, bp, bp.bounding_box(), ctx) +} + +/// The object-level effected geometry of `n` (base path `bp`). +pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath { + apply(&n.appearance.effects, bp, &GeomContext::of(n)) +} + +pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] { + match item { + AppearanceItem::Fill(f) => &f.effects, + AppearanceItem::Stroke(s) => &s.effects, + } +} + +/// Visual bounds including geometry effects, stroke outsets and shadows/glows (symbol instances +/// measured by their art from `symbols`). +pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { + return cull_bounds(symbols, &art).map(|b| { + let o = outset(symbols, &n.appearance.effects, b); + b.inflate(o, o) + }); + } + let bp = node_bezpath(n)?; + let ctx = GeomContext::of(n); + let g = effected_path(n, &bp); + let mut r: Option = (!g.elements().is_empty()).then(|| g.bounding_box()); + for item in &n.appearance.items { + let fx = item_effects(item); + if effects::has_geometry(fx) { + let ig = apply(fx, &g, &ctx.item(item)); + if !ig.elements().is_empty() { + let b = ig.bounding_box(); + r = Some(r.map_or(b, |r| r.union(b))); + } + } + } + let s = n.appearance.outset(); + r.map(|r| { + let r = r.inflate(s, s); + // The fills' and strokes' own raster effects, then the object's. + let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b)); + let o = outset(symbols, &n.appearance.effects, r); + r.inflate(o, o) + }) +} + +/// Does `item` paint (as [`Renderer::paint_items`] draws it)? +fn paints(item: &AppearanceItem) -> bool { + item.visible() && !item.paint().is_none() +} + +/// The bounds of what `n` draws, its own and its members' geometry effects applied and the raster +/// effects of its members and of its fills and strokes included (each fill's and stroke's +/// spreading what it paints, [`item_bounds`]), with strokes measured as +/// [`Node::visual_bounds_with`] measures them, without the reach of `n`'s own raster effects +/// (symbol instances measured by their art from `symbols`): what Rasterize makes its image over. +pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + let b = art_bounds(symbols, n)?; + let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty()); + Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r))) +} + +/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path +/// with `n`'s and `item`'s geometry effects, and for a stroke its width. `None` for other objects, +/// 3D art, a brushed stroke and an item that draws no path. +fn item_bounds(n: &Node, item: &AppearanceItem) -> Option { + let stroke = matches!(item, AppearanceItem::Stroke(_)); + if !has_fx(n) || effects::has_revolve(n) || effects::has_solid(n) || (stroke && vectorcraft_brush::has_brush(n)) { + return None; + } + let g = effected_path(n, &node_bezpath(n)?); + let fx = item_effects(item); + if effects::has_geometry(fx) { + let ig = apply(fx, &g, &GeomContext::of(n).item(item)); + let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; + return (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o)); + } + let one = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() }; + (!g.elements().is_empty()).then(|| one.stroked_bounds(g.bounding_box(), || g.clone())) +} + +/// `b` grown by how far raster effects `fx` paint past it, each spreading what the ones before it +/// paint (Rasterize at the document's raster effects resolution where it takes it). +fn spread(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> Rect { + let ppi = doc_raster_ppi(symbols.document()); + effects::raster_effects(fx).iter().fold(b, |r, e| e.at_document_ppi(ppi).spread(r, 72.0 / ppi)) +} + +/// [`drawn_bounds`] before the raster effects of `n`'s fills and strokes: its art, and the art of +/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches). +fn art_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + let b = shape_bounds(symbols, n); + let brushed = vectorcraft_brush::has_brush(n) + .then(|| vectorcraft_brush::expand(symbols.document(), n)) + .flatten() + .and_then(|art| art.visual_bounds_with(symbols)); + let b = vectorcraft_geom::union_opt(b, brushed)?; + let pad = if matches!(n.kind, NodeKind::SymbolInstance { .. }) { crate::brush_fx::pad(symbols, n) } else { 0.0 }; + Some(b.inflate(pad, pad)) +} + +/// [`art_bounds`] without brush art. +fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + // The visible members of `g` (a Shaper group's first member is its source art, which paints + // nothing). + let union = |g: &Node| { + let members = g.children()?.get(usize::from(g.extras().shaper.is_some())..).unwrap_or_default(); + members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) + }; + match &n.kind { + _ if effects::has_container_appearance(n) => { + let art = effects::evaluate_container(Some(symbols.document()), n); + let art = art.as_ref().unwrap_or(n); + if art.clips() { art.visual_bounds_with(symbols) } else { union(art) } + } + NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n), + _ if has_fx(n) => { + // A 3D effect's art. + if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { + return drawn_bounds(symbols, &art); + } + let g = effected_path(n, &node_bezpath(n)?); + // The path as the fills and strokes without geometry effects of their own paint it + // (as every fill and stroke would when none paints). + let mut plain = n.appearance.clone(); + plain.items.retain(|i| !(paints(i) && effects::has_geometry(item_effects(i)))); + let on_path = !n.appearance.items.iter().any(paints) || plain.items.iter().any(paints); + let mut r = (on_path && !g.elements().is_empty()).then(|| plain.stroked_bounds(g.bounding_box(), || g.clone())); + let ctx = GeomContext::of(n); + for item in n.appearance.items.iter().filter(|i| paints(i) && effects::has_geometry(item_effects(i))) { + let ig = apply(item_effects(item), &g, &ctx.item(item)); + let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; + r = vectorcraft_geom::union_opt(r, (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o))); + } + r + } + // As [`cull_bounds_in`] measures them, without the object's own raster effects: the + // reshaped art (type's outlines, and an image and the paths of its own fills and strokes, + // measured one by one with the effects of those fills and strokes), grown by how far the + // effects in a symbol's art and a live object's content paint past it. + _ if has_object_fx(n) => { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + _ => None, + }; + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); + let b = match &reshaped { + Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { + r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) + } + Some(r) => r.visual_bounds_with(symbols), + None => None, + } + .or_else(|| n.visual_bounds_with(symbols))?; + let a = match &reshaped { + Some(r) => reshaped_art_effects_reach(symbols, n, r), + None => art_effects_reach(symbols, n), + }; + let a = a.max(live_content_reach(symbols, n)); + Some(b.inflate(a, a)) + } + _ => { + let b = n.visual_bounds_with(symbols)?; + let a = art_effects_reach(symbols, n).max(live_content_reach(symbols, n)); + Some(b.inflate(a, a)) + } + } +} + +/// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop +/// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster +/// effects inside the art. +pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option { + if let Some(art) = effects::crop_marks_art(n) { + return drawn_bounds(symbols, &art); + } + drawn_bounds(symbols, n).map(|b| spread(symbols, &n.appearance.effects, b)) +} + +/// The outline the renderer clips `n`'s Feather, Inner Glow and Rasterize with Create Clipping +/// Mask to: a path's or compound path's geometry with its object-level geometry effects applied, +/// the outline of the art [`Renderer::fx_art`] makes for an object with object effects, and with +/// Crop Marks the object's own. For other objects it is [`effects::clip_outline`]. +pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { + if let Some(art) = effects::crop_marks_art(n) { + return drawn_outline(doc, art.children()?.first()?); + } + if has_object_fx(n) { + let art = Renderer::new().fx_art(doc, &Arc::new(n.clone()), false); + return effects::clip_outline(&art); + } + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } if effects::has_geometry(&n.appearance.effects) => { + Some((effected_path(n, &node_bezpath(n)?), *rule)) + } + _ => effects::clip_outline(n), + } +} + +/// How far raster effects `fx` paint beyond `b`, each spreading what the ones before it paint +/// ([`spread`]). +fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 { + let s = spread(symbols, fx, b); + [b.x0 - s.x0, b.y0 - s.y0, s.x1 - b.x1, s.y1 - b.y1].into_iter().fold(0.0, f64::max) +} + +/// Bounds used for culling: like `Node::visual_bounds_with`, but aware of live effects anywhere in +/// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are +/// measured by their art from `symbols`. +pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + cull_bounds_in(symbols, n, true) +} + +/// [`cull_bounds`] of `n`. With `after_reshape` true, the effects in the art of a symbol instance +/// with geometry effects of its own are measured on the art as those effects reshape it +/// ([`reshaped_art_effects_reach`]). With `after_reshape` false, they are measured on the art +/// before those effects run ([`art_effects_reach`]), which `symbols` keeps for each symbol and +/// linear part. +fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Option { + let inner = |c: &Node| cull_bounds_in(symbols, c, after_reshape); + match &n.kind { + // The object and its crop marks. + _ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)), + // The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach. + _ if effects::has_container_appearance(n) => { + let art = effects::evaluate_container(Some(symbols.document()), n); + let art = art.as_ref().unwrap_or(n); + let b = if art.clips() { + art.visual_bounds_with(symbols) + } else { + art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) + }?; + let o = outset(symbols, &n.appearance.effects, b); + Some(b.inflate(o, o)) + } + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) + } + _ if has_fx(n) => visual_bounds(symbols, n), + // The reshaped art (a symbol instance's reshaped art is made from its symbol's art; type's + // outlines, and an image and the paths of its own fills and strokes, measured one by one + // with the geometry effects of those fills and strokes), grown by how far the effects in a + // symbol instance's art paint past that art. + _ if has_object_fx(n) => { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + _ => None, + }; + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); + let b = match &reshaped { + Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { + r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) + } + Some(r) => r.visual_bounds_with(symbols), + None => None, + } + .or_else(|| n.visual_bounds_with(symbols))?; + let a = match &reshaped { + Some(r) if after_reshape => reshaped_art_effects_reach(symbols, n, r), + _ => art_effects_reach(symbols, n), + }; + let a = a.max(live_content_reach(symbols, n)); + let b = b.inflate(a, a); + let o = outset(symbols, &n.appearance.effects, b); + Some(b.inflate(o, o)) + } + NodeKind::SymbolInstance { .. } => { + let b = n.visual_bounds_with(symbols)?; + let a = art_effects_reach(symbols, n); + Some(b.inflate(a, a)) + } + _ => { + let b = n.visual_bounds_with(symbols)?; + let b = match &n.kind { + NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), + _ => b, + }; + let a = live_content_reach(symbols, n); + Some(b.inflate(a, a)) + } + } +} + +/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its +/// characters' strokes, miter spikes included, and by `outset`: on a path they stand above it and +/// run past its ends, in an area they can hang past its frame, where its own box +/// ([`Node::visual_bounds_with`]) doesn't. +fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { + let b = t.cached_bounds.unwrap_or_else(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds); + let o = outset.max(t.char_stroke_reach()); + (b.width() > 0.0 || b.height() > 0.0).then(|| t.xf.transform_rect_bbox(b).inflate(o, o)) +} + +/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual +/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that +/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the +/// farthest side. 0 for any other kind of object. +fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { + if !vectorcraft_doc::live::is_live(n) { + return 0.0; + } + let mut reach = 0.0f64; + n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c))); + reach +} + +/// How far the effects in the art of symbol instance `n` (and in the art of the symbols the +/// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the +/// art as the instance places it before its effects run: 0 when the art has no visible effects, and +/// for any other kind of object. The reach is the same wherever the art is moved to, so the art is +/// placed by the linear part of the instance's transform alone, and `symbols` keeps the reach for +/// each symbol and linear part, up to a limit ([`SymbolBoxes::effects_reach`]). +fn art_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return 0.0 }; + if !symbols.has_effects(symbol) { + return 0.0; + } + let measure = |[m0, m1, m2, m3]: [f64; 4]| { + let past = |art: &Arc| { + let mut placed = (**art).clone(); + placed.transform(Affine::new([m0, m1, m2, m3, 0.0, 0.0]), false); + match (cull_bounds(symbols, &placed), placed.visual_bounds_with(symbols)) { + (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), + _ => 0.0, + } + }; + symbols.measure_art(symbol, past).unwrap_or(0.0) + }; + let [m0, m1, m2, m3, _, _] = xf.as_coeffs(); + symbols.effects_reach(symbol, [m0, m1, m2, m3], &measure) +} + +/// How far the effects in the art of symbol instance `n` paint past `r`, that art as `n`'s own +/// geometry effects reshape it ([`effects::reshape`]), on its farthest side: 0 when the art has no +/// visible effects, and for any other kind of object. The art in `r` is measured without the raster +/// effects of `n` that `r` carries, by [`cull_bounds_in`] with `after_reshape` false: the effects in +/// the art of an instance in it are measured before that instance's own geometry effects run. +fn reshaped_art_effects_reach(symbols: &SymbolBoxes, n: &Node, r: &Node) -> f64 { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return 0.0 }; + if !symbols.has_effects(symbol) { + return 0.0; + } + let art = r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds_in(symbols, c, false))); + match (art, r.visual_bounds_with(symbols)) { + (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), + _ => 0.0, + } +} diff --git a/crates/render/src/fx/entries.rs b/crates/render/src/fx/entries.rs new file mode 100644 index 000000000..f933f2307 --- /dev/null +++ b/crates/render/src/fx/entries.rs @@ -0,0 +1,355 @@ +use super::raster::ViewCrop; +use super::*; + +/// What [`Renderer::raster_fx`] paints effects around. +pub(super) struct Content<'a> { + /// The object the effects belong to (cached shadows are checked against it). + pub(super) node: &'a Node, + /// The `Arc` holding `node`, when known: cached rasters keep it and match it by identity. + pub(super) owner: Option<&'a Arc>, + /// First shadow-cache slot of these effects (`None`: don't cache). + pub(super) cache: Option, + /// Document-space bounds of the painted content. + pub(super) reach: Rect, + /// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and + /// Inner Glow is skipped). + pub(super) outline: Option<(&'a BezPath, FillRule)>, + /// What Rasterize makes its image over: the object's [`drawn_bounds`] as far as the effects + /// before it spread them (`None`: `reach`). + pub(super) bounds: Option, + /// Where the image of the Rasterize first in these effects is kept for the effects after a + /// split ([`Renderer::raster_fx`]), which draw this content more than once. + pub(super) kept: Option<&'a Kept>, +} + +/// The image of the last Rasterize before a split in an effect stack ([`Renderer::raster_fx`]). It +/// is made the first time the effects after the split draw the content and drawn from here the +/// other times, so the content before the split is drawn once. +pub(super) struct Kept { + /// Where the effects after the split take the content from: the visible area grown by how + /// far they paint. + pub(super) window: Rect, + /// The view and pixel size of the split's frame, when the image is over + /// [`Renderer::rasterize_image`]'s limits: the content is then drawn in that frame. + pub(super) frame: Option<(Affine, f64)>, + pub(super) image: std::cell::RefCell>, +} + +#[derive(Clone)] +pub(super) enum KeptImage { + /// The image on its own grid, as [`Renderer::rasterize_image`] makes it, and how many images + /// in the content failed to decode while it was made ([`crate::paint::decode_failures`]): + /// each draw of the image from here counts them again ([`crate::paint::add_decode_failures`]). + Grid(std::rc::Rc<(Vec, usize, usize)>, u64), + /// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the + /// Rasterize applied, as the first draw made it, and the transform from its pixels to + /// document space. + Raster(Arc, Affine), +} + +/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to +/// the view-space position of `anchor` (a document point). +pub(crate) struct ShadowEntry { + pub(super) node: Drawn, + pub(super) linear: [u64; 4], + pub(super) anchor: vectorcraft_geom::Point, + /// Pixel offset of the raster's top-left from the anchor's view position. + pub(super) rel: (f64, f64), + pub(super) image: std::sync::Arc, + /// View pixels per raster pixel: more than 1 for a glow made coarser to fit the budget. + pub(super) scale: f64, + /// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one + /// cropped to the view, or made over such a raster, the view and the rect it was made for: it + /// draws only there ([`PixelEntry::view`]). + pub(super) view: Option, + pub(super) made: Made, + /// What the silhouette read when the raster was made. + pub(super) inputs: Inputs, + /// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters + /// made before their objects moved: only a frame rendered while interacting draws it. + pub(super) moved: bool, + pub(crate) stamp: u64, +} + +/// A shadow or glow raster ready to draw ([`Renderer::draw_shadow`]). +pub(super) struct Shadow { + pub(super) image: std::sync::Arc, + /// Raster to view pixels. + pub(super) at: Affine, + pub(super) mode: vectorcraft_doc::color::BlendMode, + /// Made before its object moved ([`ShadowEntry::moved`]). + pub(super) moved: bool, + /// For a raster cropped to the view, or made over a raster that is ([`ShadowEntry::view`]), the + /// rect it was made for, in this frame's device pixels ([`ViewCrop::served`]). + pub(super) cropped: Option, +} + +impl ShadowEntry { + /// The bytes it holds, for [`FX_RASTER_BUDGET`]. + pub(crate) fn bytes(&self) -> usize { + raster_bytes(&self.image) + self.made.bytes() + } +} + +/// What a cached shadow, glow or filtered raster was made from. While +/// [`crate::RenderOptions::interacting`], an object whose content only moved after the raster +/// was made draws from that raster ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]). +pub(crate) struct Made { + /// The content's pixels as drawn into the raster before the effects ran: premultiplied RGBA, + /// or their alpha alone under a shadow or glow that is one color ([`silhouette`]). + pub(super) pixels: Vec, + /// The effects run on them. + pub(super) fx: Vec, + /// The size of the content's document-space bounds. + pub(super) size: Size, + /// The color channels the Photoshop-style filters were told of (none for shadows and glows). + pub(super) channels: Option, + /// Feather's clip outline, moved to put the raster's anchor at the origin. + pub(super) outline: Option<(BezPath, FillRule)>, +} + +/// How far (document units) the size of a moved object's content and the points of its Feather +/// outline may lie from those of the content a raster was made from. +const MOVED_SLACK: f64 = 1e-6; + +impl Made { + /// The bytes it holds beyond the raster. + pub(super) fn bytes(&self) -> usize { + self.pixels.len() + self.outline.as_ref().map_or(0, |(g, _)| crate::lru::path_bytes(g)) + } + + /// Whether content of size `size` under the effects `fx`, told of `channels`, with Feather's + /// `outline` (moved to put its anchor at the origin) may be this content moved: the same + /// effects, channels and outline rule, and the size and outline within [`MOVED_SLACK`]. + pub(super) fn fits(&self, fx: &[&RasterFx], size: Size, channels: Option, outline: Option<&(BezPath, FillRule)>) -> bool { + let near = |a: f64, b: f64| (a - b).abs() <= MOVED_SLACK; + self.fx.len() == fx.len() + && self.fx.iter().zip(fx).all(|(a, b)| a == *b) + && near(self.size.width, size.width) + && near(self.size.height, size.height) + && self.channels == channels + && match (&self.outline, outline) { + (None, None) => true, + (Some((a, ra)), Some((b, rb))) => ra == rb && same_path(a, b), + _ => false, + } + } +} + +/// Whether `a` and `b` have the same elements with points within [`MOVED_SLACK`] of each other. +fn same_path(a: &BezPath, b: &BezPath) -> bool { + let near = |p: &Point, q: &Point| (*p - *q).hypot() <= MOVED_SLACK; + a.elements().len() == b.elements().len() + && a.elements().iter().zip(b.elements()).all(|pair| match pair { + (PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(p, q), + (PathEl::QuadTo(p0, p1), PathEl::QuadTo(q0, q1)) => near(p0, q0) && near(p1, q1), + (PathEl::CurveTo(p0, p1, p2), PathEl::CurveTo(q0, q1, q2)) => near(p0, q0) && near(p1, q1) && near(p2, q2), + (PathEl::ClosePath, PathEl::ClosePath) => true, + _ => false, + }) +} + +/// Feather's clip outline of `content` when `chain` feathers it, moved to put `anchor` at the +/// origin ([`Made::outline`]). +pub(super) fn feather_outline(chain: &[&RasterFx], content: &Content, anchor: Point) -> Option<(BezPath, FillRule)> { + let (g, rule) = content.outline.filter(|_| chain.iter().any(|fx| matches!(fx, RasterFx::Feather { .. })))?; + let mut g = g.clone(); + g.apply_affine(Affine::translate(-anchor.to_vec2())); + Some((g, rule)) +} + +/// The object a cached raster was made for: the drawn `Arc` itself when known (kept alive, so its +/// address, the cache key, names it alone and a key match is exact), else a copy compared by value. +pub(crate) enum Drawn { + Shared(Arc), + Copy(Box), +} + +impl Drawn { + pub(super) fn of(n: &Node, owner: Option<&Arc>) -> Self { + match owner { + Some(a) => Self::Shared(a.clone()), + None => Self::Copy(Box::new(n.clone())), + } + } + + /// Is `n` the object drawn? + pub(super) fn is(&self, n: &Node) -> bool { + match self { + Self::Shared(a) => std::ptr::eq(&**a, n), + Self::Copy(c) => **c == *n, + } + } + + /// The id of the object drawn. + pub(super) fn id(&self) -> NodeId { + match self { + Self::Shared(a) => a.id, + Self::Copy(c) => c.id, + } + } +} + +/// What an object's content reads from outside its own nodes, in it or in the opacity masks of the +/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Reads { + /// Pattern paints: the pattern definitions. Their art can hold anything, so content painted + /// with a pattern reads everything else too. + pub(super) patterns: bool, + /// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold + /// anything, so content that shows a symbol reads everything else too. + pub(super) symbols: bool, + /// Images and placed documents: their pixels and files, and the options and the layer's Dim + /// Images they are drawn with. + pub(super) images: bool, + /// Brushed strokes: the brush library. + pub(super) brushes: bool, + /// Type: the fonts, whether overprinting shows ([`crate::proof::overprints`]) and the + /// substitution highlights. + pub(super) text: bool, + /// Plug-in effects on its objects: the plug-ins installed. Those in the symbol and pattern art + /// it shows are found with the document's inputs ([`DocInputs`]). + pub(super) plugins: bool, +} + +impl Reads { + /// What the content of `n` reads. + pub(crate) fn of(n: &Node) -> Self { + Self::of_all(std::iter::once(n)) + } + + /// What the content of `nodes` reads together. A pattern's art can hold anything, as a + /// symbol's can, so content painted with a pattern reads everything else too (#1723). + pub(crate) fn of_all<'a>(nodes: impl Iterator) -> Self { + let mut reads = Self::default(); + nodes.for_each(|n| reads.add(n)); + if reads.symbols || reads.patterns { + return Self { patterns: true, symbols: true, images: true, brushes: true, text: true, plugins: reads.plugins }; + } + reads + } + + /// Does the content read anything outside its own nodes? + pub(crate) fn any(self) -> bool { + self != Self::default() + } + + /// Can installing or removing a plug-in change what the content draws? It can when an object in + /// it has a plug-in effect, and when it shows symbol or pattern art. + pub(crate) fn plugins_can_change(self) -> bool { + self.plugins || self.symbols || self.patterns + } + + /// Does the content show symbol or pattern art? + pub(crate) fn shows_definitions(self) -> bool { + self.symbols || self.patterns + } + + fn add(&mut self, n: &Node) { + let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. }); + n.walk(&mut |c| { + self.plugins |= effects::has_plugin_effect(c); + for item in &c.appearance.items { + self.patterns |= pattern(item.paint()); + self.brushes |= matches!(item, AppearanceItem::Stroke(s) if s.brush.is_some()); + } + match &c.kind { + NodeKind::SymbolInstance { .. } => self.symbols = true, + NodeKind::Image(_) | NodeKind::PlacedDocument(_) => self.images = true, + NodeKind::Text(t) => { + self.text = true; + self.symbols |= t.runs.iter().any(|r| r.inline.is_some()); + self.patterns |= t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke)); + } + _ => {} + } + if let Some(m) = c.extras().mask.as_deref() { + self.add(&m.art); + } + }); + } +} + +/// What a cached shadow, glow or filtered raster was drawn from besides its object, its effects +/// and the view's zoom and rotation ([`Renderer::raster_inputs`]). A raster is drawn from only +/// while its inputs equal the frame's. +#[derive(Clone)] +pub(crate) struct Inputs { + /// What the content reads. + pub(super) reads: Reads, + /// A hash of the inputs as found when the raster was made: those every raster reads, those + /// `reads` names, and what the effects read themselves ([`Inputs::with`]). + pub(super) key: u64, + /// The document's resources and the color settings `key` was found with: holding them keeps + /// the addresses they hashed for their values ([`DocInputs`]). + pub(super) _doc: Arc, +} + +impl PartialEq for Inputs { + fn eq(&self, other: &Self) -> bool { + (self.reads, self.key) == (other.reads, other.key) + } +} + +impl Inputs { + /// These inputs with `extra`, something the effects read themselves. + pub(super) fn with(mut self, extra: impl Hash) -> Self { + let mut h = DefaultHasher::new(); + (self.key, extra).hash(&mut h); + self.key = h.finish(); + self + } +} + +/// What raster contents read from the document by name or key, and the color settings, as a frame +/// finds them ([`Renderer::doc_inputs`]): hashes of the patterns' tilings +/// ([`crate::pattern::hash_tiling`]), of the symbols' names and art and of the images' keys and +/// pixels, the address of the color settings, and whether the patterns' and symbols' art has a +/// plug-in effect. Art, pixels and previews are hashed by the addresses of their shared values, +/// and names, keys, tiles, tile types and overlaps are hashed by value. The live cache keeps these +/// inputs with each evaluated live object, with each mesh's quads and with what each walk of a +/// live object or of effect art found when it found no placed file (`crate::live`), and +/// [`Renderer::fx_art`] keeps them with art that can be made from evaluated live objects. +pub(crate) struct DocInputs { + pub(super) patterns: u64, + pub(super) symbols: u64, + pub(super) images: u64, + pub(crate) cms: usize, + /// Whether an object in a pattern's or a symbol's art, or in an opacity mask there, has a + /// visible plug-in effect. + pub(super) plugins: bool, + /// Weak references to the values whose addresses the hashes hold: a weak reference keeps the + /// allocation, not the value, so no other value gets one of those addresses while these + /// inputs are kept. + pub(super) _held: Held, +} + +impl DocInputs { + /// Whether `self` and `other` were made from the same patterns and symbols: their names, + /// tilings and art. + pub(crate) fn same_art(&self, other: &DocInputs) -> bool { + (self.patterns, self.symbols) == (other.patterns, other.symbols) + } +} + +/// The pattern and symbol art, the image pixels and the color settings, held weakly. +pub(super) type Held = (Vec>, Vec>>, Weak); + +/// Most bytes the shadow and filtered-content rasters of one renderer hold together, with what each +/// was made from ([`Made`]): the least recently drawn go beyond it. +pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20; + +pub(super) fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize { + pm.width() as usize * pm.height() as usize * 4 +} + +/// Live-effect geometry of a node, kept across frames: the object-level result and each +/// appearance item's own result on it (filled in as items are drawn). The node is kept alive, so +/// its address names it alone and a hit is exact. +pub(crate) struct FxGeom { + pub(crate) node: Arc, + pub(super) g: Arc, + pub(super) items: Vec>>, + pub(crate) stamp: u64, +} diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs new file mode 100644 index 000000000..27fb7f588 --- /dev/null +++ b/crates/render/src/fx/pixels.rs @@ -0,0 +1,668 @@ +use super::bounds::{apply, item_effects}; +use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, KeptImage, Made, Reads, feather_outline}; +#[cfg(test)] +use super::raster::RASTERIZED; +use super::raster::{ + DRAG_PIXEL_BUDGET, PIXEL_BUDGET, PixelEntry, ViewCrop, content_changing, doc_raster_ppi, drag_scale, draw_placed, draw_scaled, filter_region, + filter_region_within, hard_edged, image_size, linear_key, looks_around, pan_margin, +}; +use super::*; + +impl Renderer { + /// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the + /// Photoshop-style filters and Rasterize) in stack order on its pixels: rendered offscreen over + /// all it can reach, filtered on the CPU and drawn back. A Rasterize first in the chain draws the + /// content on its image's own grid instead ([`Self::rasterize_image`]) when that image is within + /// the limits below; content with a [`Kept`] image draws that image after its first draw. The + /// raster holds at most [`PIXEL_BUDGET`] pixels and [`crate::MAX_RASTER_SIDE`] a side: content + /// over the budget is cropped to the view (content with a [`Kept`] image to its window) when + /// every effect only looks nearby, and content still over either limit is rendered at a lower + /// resolution. A whole raster at full resolution is cached while the object, the + /// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and + /// while interacting after the content only moved ([`Self::move_pixel_content`]). A cropped or + /// lower-resolution raster is cached while the object, the zoom and what the content reads stay + /// the same and a pan keeps the view inside the rect it was made for ([`PixelEntry::view`]): the + /// view, and for a raster cropped to it that is made again at the same zoom and rotation, outside + /// a drag and for content without a [`Kept`] image, the view with [`pan_margin`] around it. A + /// lower-resolution one is kept resampled to the view's pixels ([`Self::resampled`]). + /// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`] + /// pixels and is not cached. + pub(super) fn pixel_content( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + content: &Content, + rfx: &[RasterFx], + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect(); + // When the first draw of this content kept its raster, that raster is drawn. + if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) { + draw_placed(ctx, image, f.view * place); + return; + } + // Content kept with an image over the limits is drawn in the split's frame, over where the + // effects after the split take it from, then placed in this one. + let call = f.view; + let split_frame; + let (f, screen) = match content.kept.and_then(|k| k.frame.map(|frame| (k.window, frame))) { + Some((window, (view, px))) => { + split_frame = Frame { view, px, ..*f }; + (&split_frame, view.transform_rect_bbox(window)) + } + None => (f, Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64)), + }; + // Where the result can paint: each effect spreads what the ones before it painted. + let reach = chain.iter().fold(content.reach, |r, fx| { + let o = fx.outset(r); + r.inflate(o, o) + }); + let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0); + if !(full.area().is_finite() && full.x1 > screen.x0 && full.y1 > screen.y0 && full.x0 < screen.x1 && full.y0 < screen.y1) { + return; + } + let fits = full.area() <= PIXEL_BUDGET; + // Device pixels from a pixel to the content deciding it (`None`: anywhere). + let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px)); + let linear = linear_key(f.view); + let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink)); + // Made again at this zoom and rotation (after a pan), a raster cropped to the view covers a + // margin around the view too, so that later pans within the margin draw it. + let again = key.and_then(|key| self.pixel_fx.get(&key)).is_some_and(|e| e.linear == linear) && content.kept.is_none() && !f.opts.dragging; + let room = match margin { + Some(m) if !fits && again => { + let e = pan_margin((screen.width(), screen.height()), m, PIXEL_BUDGET, f64::from(crate::MAX_RASTER_SIDE)); + screen.inflate(e, e) + } + _ => screen, + }; + let region = match margin { + Some(m) if !fits => full.intersect(room.inflate(m, m)), + _ => full, + }; + let (x0, y0) = (region.x0.floor(), region.y0.floor()); + let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0)); + // (vello_cpu panics drawing near `u16::MAX` pixels a side.) + let side = f64::from(crate::MAX_RASTER_SIDE); + let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0); + let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side)); + // The cached raster: of the whole content at full size at this zoom, else of this view (and + // the margin around it). + let whole = fits && k >= 1.0; + let in_view = (!whole).then(|| ViewCrop::of(f.view, room)); + let stamp = self.stamp; + let channels = match f.ink { + Ink::Cmy => effects::pixel::Channels::CmyPlane, + Ink::K => effects::pixel::Channels::KPlane, + Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk, + Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, + }; + // De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi). + let ppi = doc_raster_ppi(f.doc); + // What the content reads (as the raster kept for it found it, else found now), with the + // channels the effects run on and, under De-Interlace or Rasterize, the resolution. + let inputs = key.map(|key| { + let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads); + let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }) | RasterFx::Rasterize(_))); + self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) + }); + let interacting = f.opts.interacting; + let in_place = |e: &PixelEntry| match e.view { + None => whole, + Some(v) => !whole && v.serves(f.view, screen), + }; + let exact = |e: &PixelEntry| { + e.linear == linear && in_place(e) && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) + }; + if interacting + && whole + && let Some(key) = key + && let Some(inputs) = &inputs + && !self.pixel_fx.get(&key).is_some_and(exact) + { + self.move_pixel_content(f, content, key, &chain, channels, inputs, paint); + } + if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| exact(e)) { + e.stamp = stamp; + self.stats.moved += usize::from(e.moved); + let p = f.view * e.anchor; + let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k); + crate::paint::add_decode_failures(e.failed); + draw_scaled(ctx, image, at, k); + return; + } + // During a drag, content without a raster to draw renders within the drag's budget and keeps + // no raster (#2558). + let (k, w, h, key) = match drag_scale(f.opts.dragging, (rw, rh), k) { + Some(d) => { + self.stats.moved += 1; + self.stats.previewed += 1; + (d, (rw * d).ceil().clamp(1.0, side), (rh * d).ceil().clamp(1.0, side), None) + } + None => (k, w, h, key), + }; + let (wu, hu) = (w as u16, h as u16); + let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view; + let to_doc = view.inverse(); + let px = f.px / k; + let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; + let line = 72.0 / ppi; + let (before, decoded) = (self.stats.moved, crate::paint::decode_failures()); + // A Rasterize first in the chain draws the content on its image's own grid instead, unless + // that image is too large: the content is drawn once. + let mut rasterized = match chain.first() { + Some(RasterFx::Rasterize(r)) => { + let rect = r.image(content.bounds.unwrap_or(content.reach), line); + let kept = content.kept.and_then(|k| match &*k.image.borrow() { + Some(KeptImage::Grid(image, failed)) => { + crate::paint::add_decode_failures(*failed); + Some(image.clone()) + } + _ => None, + }); + let image = kept.or_else(|| { + let failures = crate::paint::decode_failures(); + let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new); + if let (Some(k), Some(image)) = (content.kept, &image) { + *k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures)); + } + image + }); + image.map(|image| (image, rect)) + } + _ => None, + }; + let mut pm = vello_cpu::Pixmap::new(wu, hu); + if rasterized.is_none() { + #[cfg(test)] + if matches!(chain.first(), Some(RasterFx::Rasterize(_))) { + RASTERIZED.with(|n| n.set(n.get() + 1)); + } + let mut off = f.offscreen_context(wu, hu); + if hard_edged(&chain, px) { + off.set_aliasing_threshold(crate::AntiAlias::None.threshold()); + } + self.inside_layer(|r| paint(r, &mut off, &frame)); + off.flush(); + off.render(&mut pm, &mut self.resources); + } + let failed = crate::paint::decode_failures() - decoded; + // Content drawn over moved rasters makes a moved raster. + let moved = self.stats.moved > before; + // What the raster is made from, for its object once it moves (`move_pixel_content`): a + // cropped or coarser raster never moves. + let made = key.map(|_| Made { + pixels: if whole { pm.data_as_u8_slice().to_vec() } else { Vec::new() }, + fx: chain.iter().map(|fx| (*fx).clone()).collect(), + size: content.reach.size(), + channels: Some(channels), + outline: feather_outline(&chain, content, content.reach.origin()), + }); + let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height())); + let space = effects::PixelSpace { to_doc, px, center, channels, line, extent }; + let (wz, hz) = (wu as usize, hu as usize); + // What the content covers after the effects run so far: Rasterize makes its image over it. + let mut bounds = content.bounds.unwrap_or(content.reach); + // The silhouette Feather and Create Clipping Mask clip to: the content's, or after a Rasterize + // on white without a clipping mask, its image. + let mut image_outline: Option = None; + for (i, fx) in chain.into_iter().enumerate() { + let before = bounds; + bounds = fx.spread(before, space.line); + let outline = image_outline.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); + match fx { + RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px), + RasterFx::Feather { radius } => { + effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px); + if let Some((g, rule)) = outline { + self.clip_pixels(&mut pm, &frame, g, rule); + } + } + RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space), + RasterFx::Rasterize(r) => { + match rasterized.take().filter(|_| i == 0) { + Some((image, rect)) => { + let (pixels, cols, rows) = &*image; + r.compose(pm.data_as_u8_slice_mut(), wz, hz, &space, pixels, *cols, *rows, rect) + } + None => r.apply(pm.data_as_u8_slice_mut(), wz, hz, &space, before), + } + if let Some((g, rule)) = outline.filter(|_| r.clipping_mask) { + self.clip_pixels(&mut pm, &frame, g, rule); + } + if r.white && !r.clipping_mask { + image_outline = Some(bounds.to_path(0.1)); + } + } + _ => {} + } + } + let image = std::sync::Arc::new(pm); + // A raster kept for this view alone at a lower resolution than the view's is resampled to the + // view's pixels once: each frame that draws it then copies it pixel for pixel, where sampling + // it again across the whole view cost several times as much as a plain fill. + let view_raster = key.is_some() && in_view.is_some() && k < 1.0 && content.kept.is_none(); + let (image, x0, y0, k) = match view_raster.then(|| self.resampled(f, image.clone(), (x0, y0), k, screen)).flatten() { + Some((image, x, y)) => (image, x, y, 1.0), + None => (image, x0, y0, k), + }; + // A cancelled export drew part of the content: that raster isn't kept for later frames. + if !vectorcraft_doc::cancel::is_cancelled() + && let Some(((key, made), inputs)) = key.zip(made).zip(inputs) + { + let anchor = content.reach.origin(); + let p = f.view * anchor; + let rel = (x0 - p.x, y0 - p.y); + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); + let node = Drawn::of(content.node, owner.as_ref()); + let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp }; + self.pixel_fx.insert(key, entry); + self.trim_fx_rasters(FX_RASTER_BUDGET); + } + // An image over the limits was drawn at a frame's resolution: its raster is kept. + match content.kept.filter(|kept| kept.image.borrow().is_none()) { + Some(kept) => { + let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k); + *kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place)); + if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) } + } + None => draw_scaled(ctx, image, (x0, y0), k), + } + } + + /// `image` as [`draw_scaled`] draws it with its top-left at (`x`, `y`) device pixels, `1 / k` + /// device pixels a pixel, resampled to the device pixels it covers of `screen`, with their + /// top-left. `None` when it covers none of them. + fn resampled( + &mut self, + f: &Frame, + image: std::sync::Arc, + (x, y): (f64, f64), + k: f64, + screen: Rect, + ) -> Option<(std::sync::Arc, f64, f64)> { + let covered = Rect::new(x, y, x + f64::from(image.width()) / k, y + f64::from(image.height()) / k).intersect(screen); + let (x0, y0) = (covered.x0.floor(), covered.y0.floor()); + let (w, h) = (covered.x1.ceil() - x0, covered.y1.ceil() - y0); + let side = f64::from(crate::MAX_RASTER_SIDE); + if !((1.0..=side).contains(&w) && (1.0..=side).contains(&h)) { + return None; + } + let (w, h) = (w as u16, h as u16); + let mut off = f.offscreen_context(w, h); + draw_scaled(&mut off, image, (x - x0, y - y0), k); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + Some((std::sync::Arc::new(pm), x0, y0)) + } + + /// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame + /// made at full size for its object (the same id, slot and ink, at this zoom and rotation, with + /// the same `inputs`) when only the content's place changed after that frame: made by the same + /// effects `chain`, told of the same `channels`, from content whose bounds have the same size + /// (and the same Feather outline against its anchor), and whose pixels are the pixels of + /// `content` drawn into a raster placed as that one was against its anchor. The raster is kept + /// as `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under + /// De-Interlace, or under a Rasterize first in the chain, takes none. + #[allow(clippy::too_many_arguments)] + fn move_pixel_content( + &mut self, + f: &Frame, + content: &Content, + key: (usize, usize, Ink), + chain: &[&RasterFx], + channels: Channels, + inputs: &Inputs, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) || matches!(chain.first(), Some(RasterFx::Rasterize(_))) + { + return; + } + let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin()); + let outline = feather_outline(chain, content, anchor); + let fits = |k: &(usize, usize, Ink), e: &PixelEntry| { + (k.1, k.2) == (key.1, key.2) + && e.stamp < stamp + && e.linear == linear + && e.k >= 1.0 + && e.view.is_none() + && e.node.id() == content.node.id + && e.inputs == *inputs + && e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref()) + }; + let Some((old, rel, w, h)) = self.pixel_fx.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height())) + else { + return; + }; + // The content drawn where the old content lies in its raster. + let p = f.view * anchor; + let view = Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let frame = Frame { mt: false, view, visible, ..*f }; + let mut off = f.offscreen_context(w, h); + if hard_edged(chain, f.px) { + off.set_aliasing_threshold(crate::AntiAlias::None.threshold()); + } + self.inside_layer(|r| paint(r, &mut off, &frame)); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + if self.pixel_fx.get(&old).is_none_or(|e| e.made.pixels.as_slice() != pm.data_as_u8_slice()) { + return; + } + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); + if let Some(mut e) = self.pixel_fx.remove(&old) { + (e.node, e.anchor, e.moved) = (Drawn::of(content.node, owner.as_ref()), anchor, true); + self.pixel_fx.insert(key, e); + } + } + + /// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the + /// color settings, the edge smoothing, the document's raster effects resolution (which a + /// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and, + /// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and + /// brush library it reads; for images and placed documents their pixels and files, the + /// layer's Dim Images and the options they are drawn with; for type the fonts, whether + /// overprinting shows ([`crate::proof::overprints`]) and Document Setup's substitution + /// highlights; and the plug-ins installed, when an object in it has a plug-in effect, and + /// when it shows symbol or pattern art and an object in the document's symbol or pattern art + /// has one. + pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs { + let doc = self.doc_inputs(f.doc); + let mut h = DefaultHasher::new(); + (doc.cms, f.opts.anti_alias.threshold(), self.knockout, f.doc.raster_effects_ppi.to_bits()).hash(&mut h); + // Cached effects on containers can include paths made into guides. + f.opts.hide_guides.hash(&mut h); + if let Some((isolated, path)) = self.isolation_path(f) + && n.id != isolated + && path.contains(&n.id) + { + isolated.hash(&mut h); + } + if reads.patterns { + doc.patterns.hash(&mut h); + } + if reads.symbols { + doc.symbols.hash(&mut h); + } + if reads.images { + let o = f.opts; + (doc.images, self.dim_images.map(f32::to_bits), o.smooth_images, o.image_mips, o.trace_views, o.progressive_placed).hash(&mut h); + o.trace_source.as_deref().hash(&mut h); + o.mark_undecoded_images.hash(&mut h); + } + if reads.brushes { + self.brushes.library_key(f.doc, self.stamp).hash(&mut h); + } + if reads.text { + let o = f.opts; + let highlights = o.highlight_substitutions.then_some(( + o.highlight_substituted_fonts, + f.doc.setup.highlight_substituted_glyphs, + o.highlight_alternate_glyphs, + )); + (vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h); + } + if reads.plugins || (doc.plugins && (reads.symbols || reads.patterns)) { + effects::plugin_revision().hash(&mut h); + } + Inputs { reads, key: h.finish(), _doc: doc } + } + + /// [`DocInputs`] for document `doc`: found once a frame (and again for another document drawn + /// in it, such as a placed document's own), and kept while they stay the same. + pub(crate) fn doc_inputs(&mut self, doc: &Document) -> Arc { + let at = (self.stamp, std::ptr::from_ref(doc) as usize); + if let Some((seen, d)) = &self.doc_inputs + && *seen == at + { + return d.clone(); + } + let cms = vectorcraft_color::cms::active(); + let hash = |each: &mut dyn FnMut(&mut DefaultHasher)| { + let mut h = DefaultHasher::new(); + each(&mut h); + h.finish() + }; + let address = |a: &Arc| Arc::as_ptr(a) as usize; + let patterns = hash(&mut |h| doc.patterns.iter().for_each(|p| crate::pattern::hash_tiling(p, h))); + let symbols = hash(&mut |h| doc.symbols.iter().for_each(|s| (&s.name, address(&s.art)).hash(h))); + let images = hash(&mut |h| { + for (key, blob) in &doc.images { + (key, Arc::as_ptr(&blob.bytes) as usize, blob.proxy.as_ref().map(|p| Arc::as_ptr(p) as usize)).hash(h); + } + }); + let found = (patterns, symbols, images, Arc::as_ptr(&cms) as usize); + let d = match &self.doc_inputs { + Some((_, d)) if (d.patterns, d.symbols, d.images, d.cms) == found => d.clone(), + _ => { + let doc_art = || doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art)); + let art = doc_art().map(Arc::downgrade).collect(); + let pixels = doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy)).map(Arc::downgrade).collect(); + let plugins = doc_art().any(|a| crate::holds_plugin_effect(a)); + Arc::new(DocInputs { patterns, symbols, images, cms: found.3, plugins, _held: (art, pixels, Arc::downgrade(&cms)) }) + } + }; + self.doc_inputs = Some((at, d.clone())); + d + } + + /// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes + /// ([`FX_RASTER_BUDGET`]). + pub(super) fn trim_fx_rasters(&mut self, budget: usize) { + let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, e.bytes())); + let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, e.bytes())); + let mut all: Vec<_> = shadows.chain(pixels).collect(); + let mut total: usize = all.iter().map(|e| e.3).sum(); + if total <= budget { + return; + } + all.sort_unstable_by_key(|e| e.0); + for (_, pixel, key, bytes) in all { + if total <= budget { + break; + } + if pixel { + self.pixel_fx.remove(&key); + } else { + self.shadows.remove(&key); + } + total -= bytes; + } + } + + /// The image Effect › Rasterize `r` makes of the content `paint` draws over `rect` (whole + /// pixels of the image): drawn on the image's own grid as Object › Rasterize draws it (with + /// hard edges when `r` doesn't anti-alias), then given `r`'s background and color model. `None` + /// when it would hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than + /// [`crate::MAX_RASTER_SIDE`]. → (pixels, columns, rows) + fn rasterize_image( + &mut self, + f: &Frame, + r: &effects::Rasterize, + rect: Rect, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) -> Option<(Vec, usize, usize)> { + let line = 72.0 / doc_raster_ppi(f.doc); + let (w, h) = image_size(r, rect, line)?; + #[cfg(test)] + RASTERIZED.with(|n| n.set(n.get() + 1)); + let cell = r.cell(line); + let view = Affine::scale(1.0 / cell) * Affine::translate(-rect.origin().to_vec2()); + // The effect's own anti-aliasing, whatever the view or the export draws with. + let anti_alias = match (r.anti_alias, r.hinted) { + (false, _) => crate::AntiAlias::None, + (true, true) => crate::AntiAlias::Type, + (true, false) => crate::AntiAlias::Art, + }; + let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() }; + let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f }; + let mut off = frame.offscreen_context(w, h); + self.inside_layer(|s| paint(s, &mut off, &frame)); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + let mut pixels = pm.data_as_u8_slice().to_vec(); + r.finish(&mut pixels); + Some((pixels, usize::from(w), usize::from(h))) + } + + /// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip). + fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) { + let mut m = frame.offscreen_context(pm.width(), pm.height()); + m.set_transform(frame.view); + m.set_fill_rule(fill_rule(rule)); + m.set_paint(peniko::Color::BLACK); + m.fill_path(g); + m.flush(); + let mut mask = vello_cpu::Pixmap::new(pm.width(), pm.height()); + m.render(&mut mask, &mut self.resources); + for (p, c) in pm.data_mut().iter_mut().zip(mask.data()) { + let keep = |v: u8| ((v as u32 * c.a as u32 + 127) / 255) as u8; + *p = vello_cpu::color::PremulRgba8 { r: keep(p.r), g: keep(p.g), b: keep(p.b), a: keep(p.a) }; + } + } + + /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw` + /// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context + /// the layer is drawn into an offscreen single-threaded context covering `reach` (document + /// space) plus the blur's spread, then composited back with `composite`. + /// While dragging, that context holds at most [`DRAG_PIXEL_BUDGET`] pixels. + pub(super) fn with_filters( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + blur: f64, + composite: Option<(vectorcraft_doc::color::BlendMode, f32)>, + draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(vectorcraft_doc::color::BlendMode, f32)>), + ) { + if !f.mt { + return self.inside_layer(|r| draw(r, ctx, f, composite)); + } + let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return }; + // During a drag, the region holds at most `DRAG_PIXEL_BUDGET` pixels (#2558). + let drag = || filter_region_within(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px, DRAG_PIXEL_BUDGET); + let (x0, y0, w, h, k) = match f.opts.dragging.then(drag).flatten().filter(|r| r.4 < k) { + Some(r) => { + self.stats.moved += 1; + self.stats.previewed += 1; + r + } + None => (x0, y0, w, h, k), + }; + let mut off = f.offscreen_context(w, h); + let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f }; + self.inside_layer(|r| draw(r, &mut off, &shifted, None)); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + let layered = composite.is_some_and(|(m, o)| m != vectorcraft_doc::color::BlendMode::Normal || o < 1.0); + if let Some((m, o)) = composite.filter(|_| layered) { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None); + } + if k < 1.0 { + draw_scaled(ctx, std::sync::Arc::new(pm), (x0, y0), k); + ctx.set_transform(Affine::IDENTITY); + } else { + crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0)); + } + if layered { + ctx.pop_layer(); + } + } + + /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and + /// painting per-item raster effects around that item alone. + #[allow(clippy::too_many_arguments)] + pub(super) fn paint_items( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + n: &Node, + g: &BezPath, + rule: FillRule, + gctx: &GeomContext, + mut cache: Option<&mut Vec>>>, + ) { + let bounds = g.bounding_box(); + for (j, item) in n.appearance.items.iter().enumerate() { + if !item.visible() || item.paint().is_none() { + continue; + } + // The item's own geometry effects on `g`, from the cache when there is one. + let slot = cache.as_deref_mut().and_then(|c| c.get_mut(j)); + let ig: Arc = match slot { + Some(Some(ig)) => ig.clone(), + Some(s) => s.insert(Arc::new(apply(item_effects(item), g, &gctx.item(item)))).clone(), + None => Arc::new(apply(item_effects(item), g, &gctx.item(item))), + }; + let ig = &*ig; + let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; + let rfx = effects::raster_effects(item_effects(item)); + if rfx.is_empty() { + self.paint_item(ctx, f, n, item, ig, rule, ib); + continue; + } + // The item's silhouette: its fill area, or the outline of its stroke. + let outline = match item { + AppearanceItem::Fill(_) => ig.clone(), + AppearanceItem::Stroke(st) => { + let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(ig)); + effects::stroke::line_outline(&effects::stroke::stroke_pieces(ig, st).line, st, w, f.px * 0.25) + } + }; + let outset = n.appearance.outset(); + let orule = if item.is_fill() { rule } else { FillRule::NonZero }; + // Each item's shadows get their own cache slots. + // Rasterize makes its image over the item's silhouette. + let bounds = rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| outline.bounding_box()); + let content = Content { + node: n, + owner: None, + cache: Some((j + 1) << 8), + reach: ib.inflate(outset, outset), + outline: Some((&outline, orule)), + bounds, + kept: None, + }; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib)); + ctx.set_transform(Affine::IDENTITY); + } + } + + /// Paint one fill or stroke of `n` on its geometry `ig` (bounds `ib`). + #[allow(clippy::too_many_arguments)] + fn paint_item(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, item: &AppearanceItem, ig: &BezPath, rule: FillRule, ib: Rect) { + match item { + AppearanceItem::Fill(fl) => { + let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); + } + ctx.set_transform(f.view); + if paint::set_paint(ctx, &fl.paint, ib, f) { + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(ig); + } + if layered { + ctx.pop_layer(); + } + } + AppearanceItem::Stroke(st) => { + if st.width <= 0.0 { + return; + } + // Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush). + if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) { + return; + } + self.draw_stroke(ctx, f, Affine::IDENTITY, ig, rule, st, None, ib); + } + } + } +} diff --git a/crates/render/src/fx/predicates.rs b/crates/render/src/fx/predicates.rs new file mode 100644 index 000000000..2f4d66adb --- /dev/null +++ b/crates/render/src/fx/predicates.rs @@ -0,0 +1,78 @@ +use super::bounds::drawn_bounds; +use super::*; + +fn visible(effects: &[Effect]) -> bool { + effects.iter().any(|e| e.visible) +} + +/// Does this node need the effects path (a path or compound path with visible effects)? +pub(crate) fn has_fx(n: &Node) -> bool { + if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) { + return false; + } + visible(&n.appearance.effects) + || n.appearance.items.iter().any(|i| match i { + AppearanceItem::Fill(f) => visible(&f.effects), + AppearanceItem::Stroke(s) => visible(&s.effects), + }) +} + +/// Does `n` carry visible object-level effects that only apply through its art (type, images, +/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with +/// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own +/// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies +/// it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]), or is it a +/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? +pub(crate) fn has_object_fx(n: &Node) -> bool { + (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) + || effects::has_item_geometry(n) + || effects::has_transform_art(n) + || effects::has_container_appearance(n) + || effects::has_crop_marks(n) + || effects::has_adjustment(n) + || effects::has_revolve(n) + || effects::has_solid(n) +} + +/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? +pub(super) fn is_container(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) +} + +/// Can the art [`Renderer::fx_art`] makes for `n` be made from live objects it evaluates? It can +/// when `n` is a live object, and when `n` is a group or layer without a Pathfinder effect that +/// holds live objects and whose geometry effects reshape them or whose own fills and strokes paint +/// their evaluated outlines ([`effects::evaluate_container`]). +pub(super) fn evaluates_live(n: &Node) -> bool { + use vectorcraft_doc::live::{is_live, subtree_has_live}; + let to_members = effects::has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(effects::paints); + is_live(n) || (is_container(n) && to_members && !effects::has_pathfinder(n) && subtree_has_live(n)) +} + +/// Can the art [`Renderer::fx_art`] makes for `n` change while `n` stays the same, other than +/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed), +/// a symbol instance (made from its symbol's art), or an object with a 3D effect or a color +/// adjustment (shaded or recolored through the color settings), in it or in the opacity masks of +/// the objects in it. +pub(super) fn reads_outside(n: &Node) -> bool { + let mut reads = false; + n.walk(&mut |c| { + reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) + || effects::has_revolve(c) + || effects::has_solid(c) + || effects::has_adjustment(c) + || c.extras().mask.as_ref().is_some_and(|m| reads_outside(&m.art)) + }); + reads +} + +/// The [`Content::bounds`] of `n` under raster effects `rfx`: its [`drawn_bounds`] when `rfx` +/// holds a Rasterize. +pub(super) fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]) -> Option { + rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| drawn_bounds(symbols, n)).flatten() +} + +/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). +pub(super) fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> { + Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f } +} diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs new file mode 100644 index 000000000..20d0ddcdd --- /dev/null +++ b/crates/render/src/fx/raster.rs @@ -0,0 +1,257 @@ +use super::entries::{Drawn, Inputs, Made, raster_bytes}; +use super::*; + +pub(super) fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter { + Filter::from_primitive(FilterPrimitive::DropShadowOnly { + dx: dx as f32, + dy: dy as f32, + std_deviation: (blur / 2.0).max(0.0) as f32, + color, + edge_mode: EdgeMode::None, + }) +} + +/// The layer filter of a drop shadow with Darkness: the object's own pixels blurred. +pub(super) fn shade_filter(blur: f64) -> Filter { + Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: (blur / 2.0).max(0.0) as f32, edge_mode: EdgeMode::None }) +} + +/// How much darker (0..1) a drop shadow with Darkness `darkness` makes the values `ink` paints: +/// black added leaves the cyan, magenta and yellow plane of a CMYK document as it is and scales +/// the complemented black plane, as it scales screen colors, by 1 − `darkness`. +pub(super) fn shade(ink: Ink, darkness: f32) -> f32 { + match ink { + Ink::Cmy => 0.0, + Ink::K | Ink::Display | Ink::RichBlack => darkness, + } +} + +/// What a shadow or glow raster is made from ([`Made::pixels`]): the silhouette's alpha, or its +/// premultiplied RGBA when the shadow takes the object's own colors (Darkness). +pub(super) fn silhouette(pm: &vello_cpu::Pixmap, colored: bool) -> Vec { + if colored { pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect() } else { pm.data().iter().map(|p| p.a).collect() } +} + +pub(super) fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { + let [r, g, b] = ink.rgb(c); + peniko::Color::new([r, g, b, 1.0]) +} + +/// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is +/// cropped to the view when its effects only look nearby, else rendered at a lower resolution. +pub(super) const PIXEL_BUDGET: f64 = 8.4e6; + +/// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each +/// draws the content again inside the one after it. +pub(super) const MAX_RASTERIZE: usize = 16; + +/// The columns and rows of the image Rasterize `r` makes over `rect` (whole pixels of the image, +/// `line` points wide at the document's raster effects resolution). `None` when the image would +/// hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than [`crate::MAX_RASTER_SIDE`]. +pub(super) fn image_size(r: &effects::Rasterize, rect: Rect, line: f64) -> Option<(u16, u16)> { + let cell = r.cell(line); + let (cols, rows) = ((rect.width() / cell).round(), (rect.height() / cell).round()); + let side = f64::from(crate::MAX_RASTER_SIDE); + (cols >= 1.0 && rows >= 1.0 && cols <= side && rows <= side && cols * rows <= PIXEL_BUDGET).then_some((cols as u16, rows as u16)) +} + +#[cfg(test)] +thread_local! { + /// How many times a Rasterize has drawn the content before it on this thread. + pub(crate) static RASTERIZED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a +/// number). +pub(super) fn doc_raster_ppi(doc: &Document) -> f64 { + doc.raster_ppi() +} + +/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the +/// content itself), in stack order. +pub(super) fn content_changing(fx: &&RasterFx) -> bool { + matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_) | RasterFx::Rasterize(_)) +} + +/// Is the content drawn with hard edges for `chain` into a raster of pixels `px` wide (document +/// units): does it go first through a Rasterize with anti-aliasing off whose pixels are no +/// smaller? (Smaller ones show as the share of each raster pixel they cover, as smooth edges do.) +pub(super) fn hard_edged(chain: &[&RasterFx], px: f64) -> bool { + matches!(chain.first(), Some(RasterFx::Rasterize(r)) if !r.anti_alias && r.cell(72.0) >= px) +} + +/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when +/// anywhere in the object. +pub(super) fn looks_around(fx: &RasterFx) -> Option { + match fx { + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius), + RasterFx::Pixel(p) => p.reach(), + RasterFx::Rasterize(r) => Some(r.reach()), + _ => Some(0.0), + } +} + +/// The content of an object with Photoshop-style effects as filtered pixels, drawn at +/// (`anchor`'s view position + `rel`) scaled by 1 / `k` (see [`Renderer::pixel_content`]). +pub(crate) struct PixelEntry { + pub(super) node: Drawn, + pub(super) linear: [u64; 4], + pub(super) anchor: vectorcraft_geom::Point, + pub(super) rel: (f64, f64), + pub(super) k: f64, + pub(super) image: std::sync::Arc, + /// For a raster of the content's whole reach at full size, `None`: it draws after a pan too. + /// For one cropped to the view, or made at a lower resolution, the view and the rect it was + /// made for: it draws only there, so dragging other art over the object keeps it (a wide blur, + /// or any blur zoomed in, reaches past the pixel budget). + pub(super) view: Option, + pub(super) made: Made, + /// What the content read when the raster was made, with the channels the effects ran on and, + /// under De-Interlace, the raster resolution. + pub(super) inputs: Inputs, + /// Made for the object before it moved ([`Renderer::move_pixel_content`]), or made over + /// rasters made before their objects moved: only a frame rendered while interacting draws + /// it. + pub(super) moved: bool, + /// How many images in the content failed to decode while the raster was made + /// ([`crate::paint::decode_failures`]): each frame that draws the raster counts them + /// again ([`crate::paint::add_decode_failures`]). + pub(super) failed: u64, + pub(crate) stamp: u64, +} + +impl PixelEntry { + /// The bytes it holds, for [`FX_RASTER_BUDGET`]. + pub(crate) fn bytes(&self) -> usize { + raster_bytes(&self.image) + self.made.bytes() + } +} + +/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys. +pub(super) fn linear_key(view: Affine) -> [u64; 4] { + let c = view.as_coeffs(); + [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] +} + +/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and +/// the device-pixel rect it was made for in that view: the frame's, or around it the margin of +/// [`pan_margin`] ([`PixelEntry::view`], [`ShadowEntry::view`]). +#[derive(Clone, Copy, Debug)] +pub(super) struct ViewCrop { + pan: Vec2, + screen: Rect, +} + +impl ViewCrop { + pub(super) fn of(view: Affine, screen: Rect) -> Self { + Self { pan: view.translation(), screen } + } + + /// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto + /// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies + /// within the one the raster was made for: the same pixels in the same places, as when a frame + /// is drawn again only in a part of it. + pub(super) fn serves(&self, view: Affine, screen: Rect) -> bool { + let d = view.translation() - self.pan; + let whole = Vec2::new(d.x.round(), d.y.round()); + let s = screen - whole; + (d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1 + } + + /// The rect the raster was made for, in the device pixels of a frame through `view` that it + /// serves ([`Self::serves`]). + pub(super) fn served(&self, view: Affine) -> Rect { + let d = view.translation() - self.pan; + self.screen + Vec2::new(d.x.round(), d.y.round()) + } +} + +/// How far (device pixels, on each side) past a `w` × `h` view a raster cropped to it, and `m` +/// pixels around that, is made when it is made again at the same zoom and rotation, so that later +/// pans of up to that many pixels draw it: the most that keeps the raster within `budget` pixels and +/// `side` pixels a side, with two pixels to spare for rounding, and at most half the view's longer +/// side. 0 when the view and the `m` pixels around it alone leave no room. +pub(super) fn pan_margin((w, h): (f64, f64), m: f64, budget: f64, side: f64) -> f64 { + let (a, b) = (w + 2.0 * m, h + 2.0 * m); + // The largest `e` with (a + 2e) × (b + 2e) within the budget, less two pixels for the rounding + // of the raster's edges to whole pixels. + let e = (((a - b).powi(2) + 4.0 * budget).sqrt() - (a + b)) / 4.0 - 2.0; + // (Also for a view or spread that isn't a number.) + if !e.is_finite() { + return 0.0; + } + e.min((side - a.max(b)) / 2.0 - 2.0).min(w.max(h) / 2.0).floor().max(0.0) +} + +/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while +/// [`crate::RenderOptions::dragging`] (a 1000 × 1000 raster's), when the frame does not draw a raster +/// kept from an earlier frame instead (#2558). +pub(super) const DRAG_PIXEL_BUDGET: f64 = 1.0e6; + +/// The scale (raster pixels a device pixel) for content of `rw` × `rh` device pixels that would +/// render at scale `k`, when a drag lowers it to stay within [`DRAG_PIXEL_BUDGET`]; `None` when it +/// does not (`dragging` off, or content within the budget at `k`). +pub(super) fn drag_scale(dragging: bool, (rw, rh): (f64, f64), k: f64) -> Option { + let d = (DRAG_PIXEL_BUDGET / (rw * rh)).sqrt(); + (dragging && d < k).then_some(d) +} + +/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content +/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height, +/// scale). It covers what of the reach can show in the view, the blur's spread around it +/// included, at `scale` raster pixels a device pixel: 1, or less to stay within +/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a +/// side, whatever the size of the view (#1706). `None`: nothing to draw. +pub(super) fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> { + filter_region_within(reach, screen, blur, PIXEL_BUDGET) +} + +/// The region [`filter_region`] returns, within `budget` pixels instead of [`PIXEL_BUDGET`]. +pub(super) fn filter_region_within(reach: Rect, screen: (u16, u16), blur: f64, budget: f64) -> Option<(f64, f64, u16, u16, f64)> { + // 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing. + let spread = blur.max(0.0) * 1.5 + 2.0; + let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread); + let r = reach.inflate(spread, spread).intersect(screen); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (rw, rh) = (r.x1.ceil() - x0, r.y1.ceil() - y0); + // (Also false for a reach or a blur that isn't a number.) + if !(rw >= 1.0 && rh >= 1.0 && rw.is_finite() && rh.is_finite()) { + return None; + } + let side = f64::from(u16::MAX); + let k = (budget / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0); + let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side)); + Some((x0, y0, w as u16, h as u16, k)) +} + +/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel. +pub(super) fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc, (x, y): (f64, f64), k: f64) { + let (w, h) = (image.width() as f64, image.height() as f64); + ctx.set_transform(Affine::translate((x, y)) * Affine::scale(1.0 / k)); + // On whole pixels at full size the cheapest sampler is exact. + let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; + let sampler = peniko::ImageSampler { quality, ..Default::default() }; + // The raster maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern, + // radial or freeform gradient) would resample it (#1536). + ctx.reset_paint_transform(); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_transform(Affine::IDENTITY); +} + +/// Draw `image` with its pixels placed by `xf` (pixels to device pixels). On whole pixels at full +/// size it is drawn as [`draw_scaled`] draws it. +pub(super) fn draw_placed(ctx: &mut RenderContext, image: std::sync::Arc, xf: Affine) { + let [a, b, c, d, e, f] = xf.as_coeffs(); + let near = |v: f64, to: f64| (v - to).abs() < 1e-6; + if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) { + return draw_scaled(ctx, image, (e.round(), f.round()), 1.0); + } + let (w, h) = (image.width() as f64, image.height() as f64); + ctx.set_transform(xf); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() }; + ctx.reset_paint_transform(); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_transform(Affine::IDENTITY); +} diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs new file mode 100644 index 000000000..ccd8d9ad7 --- /dev/null +++ b/crates/render/src/fx/renderer.rs @@ -0,0 +1,695 @@ +use super::bounds::{cull_bounds, effected_path}; +use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, Kept, Made, Reads, Shadow, ShadowEntry}; +use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted}; +use super::raster::{ + DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, shade, + shade_filter, shadow_filter, silhouette, +}; +use super::*; + +impl Renderer { + /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) as a cached raster: the + /// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU, + /// and reused while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are + /// unchanged (pans only move it), and while interacting after the object only moved + /// ([`Self::move_shadow`]). A raster of the whole reach too large for the budget is cropped to + /// what can show in the `screen` (device pixels): the view, and around it the blur's spread and + /// `around` (document units: how far the shadows and glows stacked over this one look), made + /// coarser to fit the budget where the blur hides the coarser grid. Such a raster, and one + /// made over it, is reused while a pan keeps the view inside the rect it was made for: the + /// view, and when the raster kept for the object was made at the same zoom and rotation and no + /// drag is on, the view with [`pan_margin`] around it, within `over` (the rect, in device + /// pixels, that the cropped rasters of the shadows and glows it is made over were made for; + /// `None` when it is made over none). `None` when the effect isn't a shadow/glow or the raster + /// would still be too large (caller falls back to [`Self::draw_shadow_filter`]). + /// While dragging on a multithreaded context, a raster that has to be made again counts as too + /// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels. + #[allow(clippy::too_many_arguments)] + fn shadow_raster( + &mut self, + f: &Frame, + n: &Node, + i: usize, + fx: &RasterFx, + reach: Rect, + owner: Option<&Arc>, + (screen, around, over): (Rect, f64, Option), + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) -> Option { + let (mode, opacity, dx, dy, blur, color, darkness) = match fx { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness), + RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None), + _ => return None, + }; + let linear = linear_key(f.view); + let key = (n as *const Node as usize, i, f.ink); + let stamp = self.stamp; + let interacting = f.opts.interacting; + // What the silhouette reads: as the raster kept for `n` found it, else found now. + let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads); + let inputs = self.raster_inputs(f, n, reads); + let exact = |e: &ShadowEntry| { + e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen)) + }; + if interacting && !self.shadows.get(&key).is_some_and(exact) { + self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint); + } + let hit = self.shadows.get_mut(&key).filter(|e| exact(e)); + let (image, x, y, scale, moved, cropped) = match hit { + Some(e) => { + e.stamp = stamp; + self.stats.moved += usize::from(e.moved); + let p = f.view * e.anchor; + (e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.map(|v| v.served(f.view))) + } + None => { + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 }; + let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0; + let reach = reach + Vec2::new(dx, dy); + let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread); + // The raster's region and scale within `budget` pixels: `None` when over the limits. + let region = |r: Rect, coarser: bool, budget: f64| { + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); + let scale = if coarser { (w * h / budget).sqrt().ceil().max(1.0) } else { 1.0 }; + if !scale.is_finite() || scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 { + return None; + } + let (w, h) = ((w / scale).ceil(), (h / scale).ceil()); + ((1.0..=8192.0).contains(&w) && (1.0..=8192.0).contains(&h) && w * h <= budget).then_some((x0, y0, w, h, scale)) + }; + // A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter + // layer the caller falls back to can't make its silhouette solid. + let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| over.is_none()); + // Else it is cropped to what can show in the view, within the budget of the filter + // layer it would otherwise be drawn through, and made coarser in the same way. Made + // again at this zoom and rotation (after a pan), it covers a margin around the view + // too, so that later pans within the margin draw it. + let ((x0, y0, w, h, scale), cropped) = match full { + Some(r) => (r, None), + None => { + let m = spread + around / f.px; + let again = self.shadows.get(&key).is_some_and(|e| e.linear == linear) && !f.opts.dragging; + let e = if again { pan_margin((screen.width(), screen.height()), m, PIXEL_BUDGET, 8192.0) } else { 0.0 }; + let room = over.map_or(screen.inflate(e, e), |o| screen.inflate(e, e).intersect(o)); + let r = whole.intersect(room.inflate(m, m)); + if r.width() <= 0.0 || r.height() <= 0.0 { + return None; + } + (region(r, true, PIXEL_BUDGET)?, Some(room)) + } + }; + // During a drag, a larger shadow on a multithreaded context is filtered within the + // drag's budget (`with_filters`), and no raster is kept. + if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET { + return None; + } + let (w, h) = (w as u16, h as u16); + // Silhouette (the object's painted alpha, and its colors under Darkness), offset by + // the shadow distance. + let mut off = f.offscreen_context(w, h); + let view = Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view; + // Art is culled against the raster's own area: members of a group outside the view cast their shadows in it. + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let crop = Frame { mt: false, view, visible, ..*f }; + let before = self.stats.moved; + self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); + // A silhouette drawn over moved rasters makes a moved shadow. + let moved = self.stats.moved > before; + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + // What the raster is made from, for its object once it moves (`move_shadow`). + let made = + Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None }; + let (wu, hu) = (w as usize, h as usize); + let sigma = blur / 2.0 / f.px / scale; + if let Some(darkness) = darkness { + // The object's own colors, blurred, with the black Darkness adds (premultiplied). + let keep = 1.0 - shade(f.ink, darkness); + let mut planes: [Vec; 4] = [ + pm.data().iter().map(|p| p.r as f32).collect(), + pm.data().iter().map(|p| p.g as f32).collect(), + pm.data().iter().map(|p| p.b as f32).collect(), + pm.data().iter().map(|p| p.a as f32).collect(), + ]; + for plane in &mut planes { + effects::pixel::blur_plane(plane, wu, hu, sigma); + } + let [r, g, b, a] = &planes; + for (k, px) in pm.data_mut().iter_mut().enumerate() { + let al = (a[k] * opacity).clamp(0.0, 255.0).round(); + let value = |plane: &Vec| (plane[k] * opacity * keep).round().clamp(0.0, al) as u8; + *px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 }; + } + } else { + // An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow. + let solid = matches!(fx, RasterFx::OuterGlow { .. }); + let mut a: Vec = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect(); + effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale); + effects::pixel::blur_plane(&mut a, wu, hu, sigma); + let [cr, cg, cb] = f.ink.rgb(&color); + let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8; + for (px, av) in pm.data_mut().iter_mut().zip(&a) { + let al = (av * opacity).clamp(0.0, 255.0); + *px = vello_cpu::color::PremulRgba8 { r: tint(al, cr), g: tint(al, cg), b: tint(al, cb), a: al.round() as u8 }; + } + } + let image = std::sync::Arc::new(pm); + let anchor = reach.origin(); + let p = f.view * anchor; + let node = Drawn::of(n, owner); + let rel = (x0 - p.x, y0 - p.y); + let view = cropped.map(|room| ViewCrop::of(f.view, room)); + self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp }); + self.trim_fx_rasters(FX_RASTER_BUDGET); + (image, x0, y0, scale, moved, cropped) + } + }; + Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped }) + } + + /// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx` + /// draws in. The blend mode applies per draw: a blend layer would be a full-viewport compositing + /// pass per shadow. A full-size raster drawn as made sits on whole pixels, so the cheapest + /// sampler is exact. + fn draw_shadow(&mut self, ctx: &mut RenderContext, s: &Shadow, to: Affine) { + let (w, h) = (s.image.width() as f64, s.image.height() as f64); + let at = to * s.at; + ctx.set_transform(at); + ctx.set_blend_mode(blend_mode(s.mode)); + let [a, b, c, d, _, _] = at.as_coeffs(); + let exact = (a, b, c, d) == (1.0, 0.0, 0.0, 1.0) && at.translation().x.fract() == 0.0 && at.translation().y.fract() == 0.0; + let quality = if exact { peniko::ImageQuality::Low } else { peniko::ImageQuality::Medium }; + let sampler = peniko::ImageSampler { quality, ..Default::default() }; + ctx.reset_paint_transform(); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(s.image.clone()), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal)); + ctx.set_transform(Affine::IDENTITY); + } + + /// Drop shadow / outer glow `fx` of what `paint` draws (bounded by `reach`) through a vello + /// filter layer, when there is no raster for it ([`Self::shadow_raster`]). vello's shadow filter + /// blurs the soft alpha, so an Outer Glow drawn here lacks the raster's solid silhouette. + fn draw_shadow_filter( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + fx: &RasterFx, + reach: Rect, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + let (mode, opacity, dx, dy, blur, color, darkness) = match fx { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness), + RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None), + _ => return, + }; + let filter = match darkness { + Some(_) => shade_filter(blur), + None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)), + }; + // The offset moves the content rather than the filter: vello_cpu drops layer content + // that lies entirely outside the viewport before filtering. + let reach = reach + Vec2::new(dx, dy); + self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { + c.set_transform(fr.view); + c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); + paint(r, c, &shifted(fr, dx, dy)); + // Darkness: black over the object's own colors, keeping their alpha. + if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) { + c.set_transform(fr.view); + c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop)); + c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black])); + c.fill_rect(&reach); + c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal)); + } + c.pop_layer(); + }); + } + + /// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier + /// frame made for its object (the same id, effect slot and ink, at this zoom and rotation, with + /// the same `inputs`) when only the silhouette's place changed after that frame: made by the + /// same effect `fx` from a silhouette whose bounds have the size of `reach` (the content's + /// bounds moved by the shadow's offset), and whose alpha (and colors, under Darkness) are those + /// of `n`'s silhouette drawn into a raster placed as that one was against its anchor + /// ([`silhouette`]). The raster is kept as `n`'s, marked + /// moved, and is drawn at the nearest whole pixel. + #[allow(clippy::too_many_arguments)] + fn move_shadow( + &mut self, + f: &Frame, + n: &Node, + key: (usize, usize, Ink), + fx: &RasterFx, + reach: Rect, + owner: Option<&Arc>, + inputs: &Inputs, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + let (dx, dy, colored) = match fx { + RasterFx::DropShadow { dx, dy, darkness, .. } => (*dx, *dy, darkness.is_some()), + _ => (0.0, 0.0, false), + }; + let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size()); + let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| { + (k.1, k.2) == (key.1, key.2) + && e.stamp < stamp + && e.linear == linear + && e.view.is_none() + && e.node.id() == n.id + && e.inputs == *inputs + && e.made.fits(&[fx], size, None, None) + }; + let Some((old, rel, w, h, scale)) = + self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height(), e.scale)) + else { + return; + }; + // The silhouette drawn where the old one lies in its raster. + let anchor = reach.origin(); + let p = f.view * anchor; + let mut off = f.offscreen_context(w, h); + let view = Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view; + // Culled against the raster's area, as `shadow_raster` draws the silhouette. + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let crop = Frame { mt: false, view, visible, ..*f }; + self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + if self.shadows.get(&old).is_none_or(|e| silhouette(&pm, colored) != e.made.pixels) { + return; + } + if let Some(mut e) = self.shadows.remove(&old) { + (e.node, e.anchor, e.moved) = (Drawn::of(n, owner), anchor, true); + self.shadows.insert(key, e); + } + } + + /// `draw_shape` for nodes with live effects. + pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { + // The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path, + // made from the node alone, so the effects give the same result every frame while the + // plug-ins installed stay the same (`check_plugins`). + let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned(); + let mut cached = owner.map(|o| { + let key = Arc::as_ptr(&o) as usize; + let stamp = self.stamp; + let e = match self.fx_geom.remove(&key) { + Some(e) if Arc::ptr_eq(&e.node, &o) => e, + _ => FxGeom { g: Arc::new(effected_path(n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp }, + }; + (key, FxGeom { stamp, ..e }) + }); + let g = match &cached { + Some((_, e)) => e.g.clone(), + None => Arc::new(effected_path(n, bp)), + }; + self.draw_shape_fx_geom(ctx, f, n, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items)); + if let Some((key, e)) = cached { + self.fx_geom.insert(key, e); + } + } + + /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry. + fn draw_shape_fx_geom( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + n: &Node, + g: &BezPath, + rule: FillRule, + items: Option<&mut Vec>>>, + ) { + if g.elements().is_empty() { + return; + } + if f.opts.outline { + self.hairline(ctx, f, g, [0, 0, 0, 255]); + return; + } + let gctx = GeomContext::of(n); + let rfx = effects::raster_effects(&n.appearance.effects); + // Document-space reach of the painted geometry (strokes, arrowheads…). + let outset = n.appearance.outset(); + let reach = g.bounding_box().inflate(outset, outset); + let bounds = rasterize_bounds(f.symbols, n, &rfx); + // Under Rasterize the content reaches as far as its fills' and strokes' raster effects paint. + let reach = bounds.map_or(reach, |b| reach.union(b)); + let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None }; + let mut items = items; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut())); + ctx.set_transform(Affine::IDENTITY); + } + + /// Draw type, an image, a symbol instance or a live object with object-level effects + /// ([`has_object_fx`]) inside its transparency group: its art (reshaped by the geometry + /// effects, see [`effects::reshape`]) with the raster effects painted around it as for paths, + /// the art standing for the object's silhouette. + pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { + let opacity = self.opacity_of(a); + if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate) { + let blends = self.blends_through(a); + let bounds = if blends { self.bounds_of(f.symbols, a) } else { None }; + let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| r.draw_object_fx_art(c, fr, a, cache)); + } else { + self.draw_object_fx_art(ctx, f, a, cache); + } + self.stats.drawn += 1; + } + + /// What [`Self::draw_object_fx`] draws inside the object's transparency group. + fn draw_object_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { + let art = self.fx_art(f.doc, a, cache); + // Art the cache did not keep is made again in the next frame. + let kept = cache && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &art)); + let outer = self.made_again; + self.made_again = outer || !kept; + // With Crop Marks, the object inside the art paints its own raster effects. + let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) }; + match cull_bounds(f.symbols, &art) { + Some(reach) if !f.opts.outline && !rfx.is_empty() => { + // Feather, Inner Glow and Rasterize with Create Clipping Mask clip to the art's outline. + let clipped = rfx.iter().any(|x| { + matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }) || matches!(x, RasterFx::Rasterize(r) if r.clipping_mask) + }); + let region = if clipped { self.clip_of(&art) } else { None }; + // A symbol's art can change while the instance doesn't: no cached shadows. + let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); + let bounds = rasterize_bounds(f.symbols, a, &rfx); + let reach = bounds.map_or(reach, |b| reach.union(b)); + let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None }; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art)); + ctx.set_transform(Affine::IDENTITY); + } + _ => self.draw_fx_art(ctx, f, a, &art), + } + self.made_again = outer; + } + + /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): + /// reshaped by its geometry effects, else type, images and paths as themselves and the others + /// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their + /// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]); + /// without the object's transparency and effects (a path keeps its geometry effects). Art that + /// is made from live objects it evaluated ([`evaluates_live`]), or from type, symbol art or + /// colors shaded through the color settings ([`reads_outside`]), is kept only while what it + /// reads from outside `a` stays the same ([`Self::live_found`]). + pub(super) fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { + let key = Arc::as_ptr(a) as usize; + let mut kept = None; + if cache + && let Some((node, art, found)) = self.fx_arts.get(&key, self.stamp) + && Arc::ptr_eq(node, a) + { + match found { + None => return art.clone(), + Some(was) => kept = Some((art.clone(), was.0)), + } + } + let found = (kept.is_some() || (cache && (evaluates_live(a) || reads_outside(a)))).then(|| self.live_found(doc, a)); + if let Some((art, was)) = kept + && found.as_ref().is_some_and(|f| f.0 == was) + { + return art; + } + let symbol = match &a.kind { + NodeKind::SymbolInstance { symbol, .. } => { + doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a)) + } + _ => None, + }; + let container = is_container(a); + let art = match effects::crop_marks_art(a) + .or_else(|| effects::revolve_art(a)) + .or_else(|| effects::solid_art(a)) + .or_else(|| effects::reshape(Some(doc), a, symbol.as_ref())) + { + Some(r) => r, + None if container => effects::evaluate_container(Some(doc), a).unwrap_or_else(|| (**a).clone()), + None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), + None => effects::outline_art(Some(doc), a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), + }; + let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art }; + let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref()); + // A path's geometry effects still apply when it is drawn. + let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); + let mut art = art.with_mask(None); + art.opacity = 1.0; + art.blend = Default::default(); + art.isolate = false; + // One element: its pieces never knock each other out (a group's members still do as its + // knockout setting says). + art.knockout = if container { a.knockout } else { vectorcraft_doc::Knockout::Off }; + let art = Arc::new(art); + if cache { + let bytes = crate::lru::node_bytes(&art); + self.fx_arts.insert(key, (a.clone(), art.clone(), found), bytes, self.stamp); + } + art + } + + /// Draw `art`, the art [`Self::fx_art`] made for `a`. Type art that `fx_arts` keeps for `a` is + /// drawn as `fx_owner`, so [`Self::draw_text`] takes its glyph geometry from `texts`. + fn draw_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, art: &Arc) { + let kept = + matches!(art.kind, NodeKind::Text(_)) && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, art)); + if !kept { + return self.draw_node(ctx, f, art, true); + } + let outer = self.fx_owner.replace(art.clone()); + self.draw_node(ctx, f, art, true); + self.fx_owner = outer; + } + + /// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on + /// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the + /// decoded images under a key naming the adjustment. + fn adjusted_art(&mut self, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { + art.appearance.effects.retain(|e| !effects::is_adjustment(&e.id)); + let mut wanted: Vec<(String, String, u64, effects::ColorMap)> = vec![]; + let adjusted = effects::adjust(&art, outer, &mut |key, map| { + let mut h = std::hash::DefaultHasher::new(); + std::hash::Hash::hash(&format!("{map:?}"), &mut h); + let hash = std::hash::Hasher::finish(&h); + let derived = format!("{key}\u{0}adjust\u{0}{hash:016x}"); + wanted.push((key.to_string(), derived.clone(), hash, map.clone())); + Some(derived) + }); + for (key, derived, hash, map) in wanted { + self.adjusted_image(&key, derived, hash, &map); + } + adjusted.unwrap_or(art) + } + + /// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows + /// below it, the content blurred or feathered, inner glows above it (clipped to its outline). + /// `paint` draws the content in the frame it is given. + pub(super) fn raster_fx( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + content: &Content, + rfx: &[RasterFx], + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + // Only the last MAX_RASTERIZE Rasterize effects are drawn. + let count = rfx.iter().filter(|fx| matches!(fx, RasterFx::Rasterize(_))).count(); + let capped: Vec; + let rfx = if count > MAX_RASTERIZE { + let mut left_out = count - MAX_RASTERIZE; + capped = rfx + .iter() + .filter(|fx| { + let out = left_out > 0 && matches!(fx, RasterFx::Rasterize(_)); + left_out -= usize::from(out); + !out + }) + .cloned() + .collect(); + &capped[..] + } else { + rfx + }; + // Rasterize at the document's raster effects resolution takes it here. + let resolved: Vec; + let rfx = if rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(r) if r.ppi.is_none())) { + let ppi = doc_raster_ppi(f.doc); + resolved = rfx.iter().map(|fx| fx.at_document_ppi(ppi)).collect(); + &resolved[..] + } else { + rfx + }; + // Rasterize makes an image of everything the effects before it paint, shadows and glows + // included, and the shadows and glows after it paint around that image. When some come + // after the last Rasterize, the stack splits after it, else when any effect comes before + // it, the stack splits before it; the effects after the split paint around the content + // drawn with those before it. Rasterize then comes first in its part. + if let Some(at) = rfx.iter().rposition(|fx| matches!(fx, RasterFx::Rasterize(_))) { + let paints_around = |fx: &[RasterFx]| fx.iter().any(|fx| !content_changing(&fx)); + let (_, rest) = rfx.split_at(at + 1); + let split = if paints_around(rest) { Some(at + 1) } else { (at > 0).then_some(at) }; + if let Some(split) = split { + let (before, after) = rfx.split_at(split); + let line = 72.0 / doc_raster_ppi(f.doc); + let (mut reach, mut spread) = (content.reach, content.bounds); + // After a Rasterize on white without a clipping mask, the silhouette is its image, + // and it stays the silhouette through the effects after it (as in `pixel_content`). + let mut image = None; + // Whether the image of a Rasterize before the split is over `rasterize_image`'s + // limits. Such an image is kept only as far as the view needs it, so the effects + // after the split are not cached. + let (mut over, mut last_over) = (false, false); + for fx in before { + if let RasterFx::Rasterize(r) = fx { + last_over = image_size(r, r.image(spread.unwrap_or(reach), line), line).is_none(); + over |= last_over; + } + let o = fx.outset(reach); + reach = reach.inflate(o, o); + spread = spread.map(|b| fx.spread(b, line)); + if let RasterFx::Rasterize(r) = fx + && r.white + && !r.clipping_mask + { + image = Some(spread.unwrap_or(reach).to_path(0.1)); + } + } + // When the split comes after the Rasterize, the shadows and glows after it and the + // content each draw the content before the split. The first draw makes the image, + // and the others draw the one it keeps. + let window = after.iter().fold(f.visible, |w, fx| { + let o = fx.outset(w); + w.inflate(o, o) + }); + let frame = last_over.then_some((f.view, f.px)); + let kept = (split > at).then(|| Kept { window, frame, image: std::cell::RefCell::new(None) }); + let inner = Content { + node: content.node, + owner: content.owner, + cache: None, + reach: content.reach, + outline: content.outline, + bounds: content.bounds, + kept: kept.as_ref(), + }; + let outline = image.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); + // When the split comes before the Rasterize, the part from it on draws the content + // once, and its image is the one kept for a split above. + let outer = Content { + node: content.node, + owner: content.owner, + cache: content.cache.filter(|_| !over), + reach, + outline, + bounds: spread, + kept: content.kept.filter(|_| split == at), + }; + self.raster_fx(ctx, f, &outer, after, &mut |r, c, fr| r.raster_fx(c, fr, &inner, before, paint)); + return; + } + } + let reach = content.reach; + // The `Arc` drawn: the caller's, or the node `draw_arc` is drawing. + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); + // Below the content: shadows and outer glows, each of the content with the ones before it in the stack, as AI + // files draw a glow round a glow and the SVG export nests them. Their rasters are made first to last, each once + // and drawn into the later ones' silhouettes as an image (so n of them cost n rasters, not 2^n draws), then + // drawn with the last lowest. One without a raster (an object drawn uncached, a glow past the budget) is drawn + // through a filter layer from the content alone, and goes into the later silhouettes that way. + let mut made: Vec<(&RasterFx, Option)> = vec![]; + let mut stacked = reach; + let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())); + // How far (document units) a shadow or glow looks from a pixel to the silhouette under it. + let looks = |fx: &RasterFx| match fx { + RasterFx::DropShadow { dx, dy, blur, .. } => blur.max(0.0) * 1.5 + dx.abs() + dy.abs(), + RasterFx::OuterGlow { blur, .. } => blur.max(0.0) * 1.5, + _ => 0.0, + }; + for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { + // A raster cropped to the view covers what the shadows and glows stacked over it read. + let around: f64 = rfx.iter().skip(i + 1).filter(|x| x.is_below()).map(looks).sum(); + // The rect that the cropped rasters of the earlier ones (drawn into this one's silhouette) were all made for. + let over = made.iter().filter_map(|(_, s)| s.as_ref()?.cropped).reduce(|a, b| a.intersect(b)); + let shadow = { + let earlier = &made; + let view = f.view; + let mut art = |r: &mut Self, c: &mut RenderContext, fr: &Frame| { + let to = fr.view * view.inverse(); + for (fx, s) in earlier.iter().rev() { + match s { + Some(s) => { + r.stats.moved += usize::from(s.moved); + r.draw_shadow(c, s, to); + } + None => r.draw_shadow_filter(c, fr, fx, reach, paint), + } + } + paint(r, c, fr); + }; + let view = (screen, around, over); + content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), view, &mut art)) + }; + stacked = match fx { + RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)), + _ => stacked.inflate(fx.outset(stacked), fx.outset(stacked)), + }; + made.push((fx, shadow)); + } + for (fx, s) in made.iter().rev() { + match s { + Some(s) => self.draw_shadow(ctx, s, Affine::IDENTITY), + None => self.draw_shadow_filter(ctx, f, fx, reach, paint), + } + } + // The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur + // works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel + // and looked blocky as the view moved by parts of a pixel (#755). + let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0)); + if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) { + self.pixel_content(ctx, f, content, rfx, paint); + } else { + paint(self, ctx, f); + } + // Above the content: inner glows, clipped to its outline. + let Some((g, rule)) = content.outline else { return }; + for fx in rfx { + let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue }; + let filter = shadow_filter(0.0, 0.0, *blur, pcolor(f.ink, color)); + self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| { + // The blend layer goes outside the clip: a clip layer is isolated, so a blend + // inside it would mix with nothing instead of the content below. + if let Some((m, o)) = comp { + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(blend_mode(m)), Some(o), None, None); + } + c.set_transform(fr.view); + c.set_fill_rule(fill_rule(rule)); + c.push_clip_layer(g); + c.push_layer(None, None, None, None, Some(filter)); + c.set_paint(peniko::Color::BLACK); + if *center { + c.set_fill_rule(fill_rule(rule)); + c.fill_path(g); + } else { + // Everything outside the shape, so the glow bleeds in from the edges. + let pad = blur * 2.0 + 4.0 * fr.px; + let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1); + inv.extend(g.iter()); + c.set_fill_rule(peniko::Fill::EvenOdd); + c.fill_path(&inv); + } + c.pop_layer(); + c.pop_layer(); + if comp.is_some() { + c.pop_layer(); + } + }); + } + } +} From e987e6dc584e814027549d1ebd80f0b3b4d5a3f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:39:00 +0000 Subject: [PATCH 133/199] refactor(tools): move builder.rs items to builder/{cache,live_paint,lookup,map}.rs --- crates/tools/src/builder.rs | 1182 +----------------------- crates/tools/src/builder/cache.rs | 195 ++++ crates/tools/src/builder/live_paint.rs | 265 ++++++ crates/tools/src/builder/lookup.rs | 259 ++++++ crates/tools/src/builder/map.rs | 462 +++++++++ 5 files changed, 1198 insertions(+), 1165 deletions(-) create mode 100644 crates/tools/src/builder/cache.rs create mode 100644 crates/tools/src/builder/live_paint.rs create mode 100644 crates/tools/src/builder/lookup.rs create mode 100644 crates/tools/src/builder/map.rs diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index d5d665e75..2ed8ff6d6 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -23,914 +23,26 @@ use vectorcraft_pathops as po; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; -/// Names starting with this mark a Live Paint group, together with its sources group ([`SOURCES_NAME`]). -pub const LIVE_PAINT_PREFIX: &str = "Live Paint"; -/// Default name of a new Live Paint group. -pub const LIVE_PAINT_NAME: &str = "Live Paint Group"; -/// The hidden child group holding the source paths. -pub const SOURCES_NAME: &str = "Live Paint Sources"; -pub const FACE_NAME: &str = "Face"; -pub const EDGE_NAME: &str = "Edge"; - -/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red. -pub const HATCH: [u8; 3] = [0x80, 0x80, 0x80]; -pub const HIGHLIGHT_RED: [u8; 3] = [0xff, 0x2a, 0x2a]; - -/// Is `n` a Live Paint group: an unclipped group whose name starts with [`LIVE_PAINT_PREFIX`] and -/// that holds a group named [`SOURCES_NAME`]? A plain group with such a name is not one. -pub fn is_live_paint(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) - && n.name.as_deref().is_some_and(|s| s.starts_with(LIVE_PAINT_PREFIX)) - && sources(n).is_some_and(|s| matches!(s.kind, NodeKind::Group { .. })) -} - -/// Whether Live Paint can take `n` as it is: a Live Paint group, a path or compound path, or a -/// group of those. Type, images, symbols, blends, meshes and the rest have to be converted to -/// paths first (); -/// Make and Merge refuse them rather than hide them among the group's sources. -pub fn live_paint_takes(n: &Node) -> bool { - is_live_paint(n) - || match &n.kind { - NodeKind::Path { .. } | NodeKind::Compound { .. } => true, - NodeKind::Group { children, .. } => children.iter().all(|c| live_paint_takes(c)), - _ => false, - } -} - -fn named(n: &Node, name: &str) -> bool { - n.name.as_deref() == Some(name) -} - -/// Face children of a Live Paint group (paint order). -pub fn faces(group: &Node) -> Vec<&Arc> { - group.children().map(|c| c.iter().filter(|n| named(n, FACE_NAME)).collect()).unwrap_or_default() -} - -/// Edge children of a Live Paint group. -pub fn edges(group: &Node) -> Vec<&Arc> { - group.children().map(|c| c.iter().filter(|n| named(n, EDGE_NAME)).collect()).unwrap_or_default() -} - -/// The hidden sources group of a Live Paint group. -pub fn sources(group: &Node) -> Option<&Arc> { - group.children()?.iter().find(|n| named(n, SOURCES_NAME)) -} - -/// Does the path (filled) contain `p`? -pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool { - if path.bounds().is_none_or(|b| !b.inflate(1e-6, 1e-6).contains(p)) { - return false; - } - let w = path.to_bezpath().winding(p); - match rule { - FillRule::EvenOdd => w % 2 != 0, - _ => w != 0, - } -} - -/// The face of `group` under `p` (front-most). -pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { - front_face(faces(group).into_iter(), p) -} - -/// The face of `group` under `p` (front-most) among its faces that are not hidden. -pub fn visible_face_at(group: &Node, p: Point) -> Option<&Arc> { - front_face(faces(group).into_iter().filter(|f| f.visible), p) -} - -/// The face of `faces` (paint order) under `p` (front-most). -fn front_face<'a>(faces: impl DoubleEndedIterator>, p: Point) -> Option<&'a Arc> { - faces.rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) -} - -/// The edge of `group` nearest to `p` within `tol`. -pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { - nearest_edge(edges(group), p, tol) -} - -/// The edge of `group` nearest to `p` within `tol` among its edges that are not hidden. -pub fn visible_edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { - nearest_edge(edges(group).into_iter().filter(|e| e.visible), p, tol) -} - -/// The edge of `edges` nearest to `p` within `tol`. -fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, tol: f64) -> Option<&'a Arc> { - let mut best: Option<(f64, &Arc)> = None; - for e in edges { - if let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) - && d <= tol - && best.is_none_or(|b| d < b.0) - { - best = Some((d, e)); - } - } - best.map(|b| b.1) -} - -/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). -pub fn live_paint_at(doc: &Document, p: Point) -> Option { - let mut hit = None; - doc.walk(|n| { - if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { - hit = Some(n.id); - } - }); - hit -} - -/// Paths, compound paths (as one shape) of a subtree in paint order; guides, clipping paths and -/// hidden objects are skipped. Live Paint groups contribute their faces and edges. -pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { - collect_leaves(n, false, out); -} - -/// [`leaf_shapes`]; with `filled_faces`, a Live Paint group contributes only its filled faces and -/// its stroked edges. -fn collect_leaves<'a>(n: &'a Node, filled_faces: bool, out: &mut Vec<&'a Node>) { - if !n.visible { - return; - } - if n.extras().shaper.is_some() { - for c in n.children().into_iter().flatten().skip(1) { - collect_leaves(c, filled_faces, out); - } - return; - } - match &n.kind { - NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} - NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), - NodeKind::Group { .. } if filled_faces && is_live_paint(n) => { - out.extend(faces(n).into_iter().filter(|f| f.visible && !f.appearance.fill_paint().is_none()).map(|f| &**f)); - out.extend(edges(n).into_iter().filter(|e| e.visible && !e.appearance.stroke_paint().is_none()).map(|e| &**e)); - } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children { - collect_leaves(c, filled_faces, out); - } - } - _ => {} - } -} - -/// Filled outline of a path or compound path node. -pub fn node_outline(n: &Node) -> Option<(PathData, FillRule)> { - match &n.kind { - NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), - NodeKind::Compound { children, rule } => { - let subs = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect(); - Some((PathData::new(subs), *rule)) - } - _ => None, - } -} - -/// How far (pt) a piece of the planar map may lie from `outline`, the outline it follows, to be -/// matched to it or cut out of it: a ten-thousandth of the outline's larger side, at least 0.05. -/// The map's pieces follow the outlines to within a few hundredths of a point. -pub fn outline_snap(outline: &PathData) -> f64 { - outline.bounds().map_or(0.05, |b| (b.width().max(b.height()) / 2000.0).max(0.25) * 0.2) -} - -/// The outlines of `leaves` as they are drawn ([`node_outline`]), in their order, for -/// [`BuilderMap::stroke_at`]. A leaf without an outline, and one inside a Live Paint group or a -/// Shaper Group (which make their own paths), gets an empty outline, so no stroke of it splits. -pub fn drawn_outlines(doc: &Document, leaves: &[&Node]) -> Vec { - let in_live_group = |id: NodeId| { - let mut up = doc.parent_of(id); - while let Some(p) = up { - if doc.node(p).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some()) { - return true; - } - up = doc.parent_of(p); - } - false - }; - leaves.iter().map(|l| if in_live_group(l.id) { None } else { node_outline(l) }.map_or_else(PathData::default, |o| o.0)).collect() -} - -/// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index, -/// with the leaves they came from. -pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, Vec<&'a Node>) { - shapes_of(doc, roots, false) -} - -/// [`shapes_for`]; with `filled_faces`, a Live Paint group contributes only its filled faces and -/// its stroked edges. -fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Vec, Vec<&'a Node>) { - let mut lv = vec![]; - for id in roots { - if let Some(n) = doc.node(*id) { - collect_leaves(n, filled_faces, &mut lv); - } - } - let mut shapes = vec![]; - let mut used = vec![]; - for l in lv { - if let Some((p, r)) = node_outline(l) - && !p.is_empty() - && p.subpaths.iter().all(|s| s.anchors.iter().all(|a| a.p.x.is_finite() && a.p.y.is_finite())) - { - shapes.push(po::Shape::new(p, r, used.len() as u64)); - used.push(l); - } - } - (shapes, used) -} - -/// Shape Builder inputs: [`shapes_for`], with the open subpaths of filled paths closed where that -/// encloses an area (the fill bounds a region, as if an invisible edge closed it) when -/// `open_filled_as_closed`. Unfilled open paths and straight lines stay open and cut the regions -/// they cross, and so do filled open paths without `open_filled_as_closed`. A Live Paint group -/// contributes its filled faces as shapes and its stroked edges as open lines; its unfilled faces -/// and its edges without a stroke are left out. -pub fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { - let (mut shapes, leaves) = shapes_of(doc, roots, true); - for (s, l) in shapes.iter_mut().zip(&leaves) { - if named(l, EDGE_NAME) && doc.parent_of(l.id).and_then(|p| doc.node(p)).is_some_and(is_live_paint) { - // A closed edge opens at its first anchor and keeps its closing segment. - let start = BTreeSet::from([0]); - s.path.subpaths = s.path.subpaths.iter().flat_map(|sp| sp.cut_at(&start)).collect(); - continue; - } - if !open_filled_as_closed || l.appearance.fill_paint().is_none() { - continue; - } - for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) { - sp.closed = po::encloses_area(sp); - } - } - (shapes, leaves) -} - -/// A planar-map face with its outline and bounds, ready for hit tests. -pub type Face = (po::Region, BezPath, Rect); - -fn faces_of(regions: Vec) -> Vec { - regions - .into_iter() - .filter_map(|r| { - let bp = r.path.to_bezpath(); - let b = r.path.bounds()?; - Some((r, bp, b)) - }) - .collect() -} - -thread_local! { - /// The Shape Builder maps built on this thread ([`maps_built`]). - static MAPS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// How many Shape Builder maps ([`BuilderMap::of_shapes`]) this thread has built, for tests that -/// count them. -pub fn maps_built() -> usize { - MAPS_BUILT.with(std::cell::Cell::get) -} - -/// The Shape Builder's planar map of a selection. -#[derive(Default)] -pub struct BuilderMap { - /// The inputs, back → front (see [`po::shape_builder`]). - pub shapes: Vec, - pub regions: Vec, - /// Pieces of the open paths that cut regions (see [`BuilderMap::new`]), keyed by input index, - /// with their bounds. - pub lines: Vec<(po::Shape, Rect)>, - /// Pieces of the other inputs' outlines (cut wherever paths meet), keyed by input index, with - /// their bounds: the edges erasing deletes. Only in a map made `with_edges`. - pub edges: Vec<(po::Shape, Rect)>, -} - -/// What a Shape Builder gesture touches at a point. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Hit { - Region(usize), - Line(usize), - Edge(usize), -} - -/// The piece of `pieces` nearest `p` within `tol`, with its distance. -fn piece_near(pieces: &[(po::Shape, Rect)], p: Point, tol: f64) -> Option<(f64, usize)> { - let mut best: Option<(f64, usize)> = None; - for (i, (l, b)) in pieces.iter().enumerate() { - if b.inflate(tol, tol).contains(p) - && let Some((.., d)) = l.path.nearest(p) - && d <= tol - && best.is_none_or(|b| d < b.0) - { - best = Some((d, i)); - } - } - best -} - -impl BuilderMap { - /// The map of `roots` (sorted back → front), with the leaves the inputs came from; the outline - /// edges only `with_edges`. With `open_filled_as_closed` (Consider Open Filled Paths as - /// Closed), a filled open path bounds a region as if an invisible edge closed it; without it, - /// the path cuts like a line. - pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool) -> (Self, Vec<&'a Node>) { - Self::with_gap(doc, roots, with_edges, open_filled_as_closed, 0.0) - } - - /// The same arrangement with Gap Detection, using the shared Live Paint gap finder. - pub fn with_gap<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool, gap: f64) -> (Self, Vec<&'a Node>) { - let (shapes, leaves) = shape_builder_shapes(doc, roots, open_filled_as_closed); - (Self::of_shapes(shapes, with_edges, gap), leaves) - } - - /// The map of `shapes` (the inputs, back → front, as [`shape_builder_shapes`] gives them), with - /// the outline edges only `with_edges`, closing gaps up to `gap`. - pub fn of_shapes(shapes: Vec, with_edges: bool, gap: f64) -> Self { - if shapes.is_empty() { - return Self::default(); - } - MAPS_BUILT.with(|n| n.set(n.get() + 1)); - let arr = po::shape_builder_with_gap(&shapes, with_edges, gap); - let bounded = |pieces: Vec| { - pieces - .into_iter() - .filter_map(|l| { - let b = l.path.bounds()?; - Some((l, b)) - }) - .collect() - }; - Self { shapes, regions: faces_of(arr.regions), lines: bounded(arr.lines), edges: bounded(arr.edges) } - } - - /// The region containing `p`. - pub fn region_at(&self, p: Point) -> Option { - self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0) - } - - /// The regions a rectangular marquee from `from` (where the drag started) to `to` touches: each - /// region whose outline crosses the rectangle or has an anchor inside it, and the region the - /// whole rectangle lies in. Of those, the region under `from` comes first, then the one under - /// `to`, then the others in map order. A marquee of no size touches the region under `from`. - pub fn regions_in_marquee(&self, from: Point, to: Point) -> Vec { - let r = Rect::from_points(from, to); - if r.width() <= 0.0 && r.height() <= 0.0 { - return self.region_at(from).into_iter().collect(); - } - let c = r.center(); - let touches = |(reg, bp, b): &Face| (b.contains(c) && bp.winding(c) != 0) || vectorcraft_geom::hit::intersects_rect(®.path, r); - let mut out: Vec = self.regions.iter().enumerate().filter(|(_, f)| touches(f)).map(|(i, _)| i).collect(); - for p in [to, from] { - if let Some(k) = self.region_at(p).and_then(|i| out.iter().position(|&j| j == i)) { - let i = out.remove(k); - out.insert(0, i); - } - } - out - } - - /// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that - /// close, else the region under it. - pub fn hit(&self, p: Point, piece_tol: Option) -> Option { - self.piece_hit(p, piece_tol).or_else(|| self.region_at(p).map(Hit::Region)) - } - - /// The line piece or edge within `piece_tol` of `p` that [`Self::hit`] gives (None without - /// `piece_tol`). - fn piece_hit(&self, p: Point, piece_tol: Option) -> Option { - piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) { - (Some(l), Some(e)) if e.0 < l.0 => Some(Hit::Edge(e.1)), - (Some(l), _) => Some(Hit::Line(l.1)), - (None, e) => e.map(|e| Hit::Edge(e.1)), - }) - } - - /// The stroke a click at `p` splits with In Merge Mode, Clicking Stroke Splits the Path: the - /// line piece or edge nearest `p` within `tol` (as [`BuilderMap::hit`] finds it), from the - /// anchor or crossing point before `p` to the one after it on the path it lies on. `outlines` - /// are the inputs' outlines as they are drawn (a filled open path without the edge that closes - /// it in the map). Returns the input's index and the run, going the way the path goes; None - /// when no piece is that close, when the run is not on the outline as drawn, when it would be a - /// whole closed subpath, when it is too short to cut out (see [`po::cut_out`]), or when it is - /// all of the path (a lone line) or leaves no more of it than [`outline_snap`], which leaves - /// nothing to split off. - pub fn stroke_at(&self, outlines: &[PathData], p: Point, tol: f64) -> Option<(usize, SubPath)> { - use kurbo::ParamCurve as _; - let piece = match self.hit(p, Some(tol))? { - Hit::Line(i) => self.lines.get(i), - Hit::Edge(i) => self.edges.get(i), - Hit::Region(_) => None, - }?; - let i = piece.0.key as usize; - let drawn = outlines.get(i)?; - let stops: Vec = drawn.anchors().map(|(.., a)| a.p).collect(); - let snap = outline_snap(drawn); - let mut run = po::run_around(piece.0.path.subpaths.first()?, &stops, p, snap)?; - let n = run.segment_count(); - if n == 0 { - return None; - } - let mid = run.segment(n / 2).eval(0.5); - drawn.nearest(mid).filter(|n| n.4 <= snap)?; - // A run that cannot be cut out, or that is all of the path (such as a lone line) or all - // but a speck of it, leaves nothing to split off. - if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| rest.length() > snap) { - return None; - } - // Reverse the run when the path's parameter goes down along it. - let seg = run.segment(n / 2); - if let (Some(a), Some(b)) = (drawn.nearest(seg.eval(0.25)), drawn.nearest(seg.eval(0.75))) - && a.0 == b.0 - && (b.1 as f64 + b.2) < (a.1 as f64 + a.2) - { - run.reverse(); - } - Some((i, run)) - } - - /// What a Shape Builder gesture at `p` touches: [`Self::hit`], except that while erasing, a - /// point in a region that is no more than `2 * piece_tol` across there (see - /// [`Self::narrow_at`]) touches the region and not the line piece or edge beside it. - pub fn builder_hit(&self, p: Point, piece_tol: Option) -> Option { - match (piece_tol, self.piece_hit(p, piece_tol)) { - (Some(tol), Some(piece)) => Some(match self.region_at(p) { - Some(r) if self.narrow_at(r, p, tol) => Hit::Region(r), - _ => piece, - }), - (_, piece) => piece.or_else(|| self.region_at(p).map(Hit::Region)), - } - } - - /// Is region `r` no more than `2 * tol` across at `p`, measured from a point of its outline - /// within `tol` of `p`, through `p`, to its outline on the other side? Every point on that line - /// is then within `tol` of the outline. The points tried are the point of the outline nearest - /// `p` and each point of a segment where the segment is square to the line to `p`, so a - /// point near the end of a thin bar measures across the bar from its sides as well as along it - /// from the end, and splitting a segment in two does not change the answer. False when `p` is - /// on the outline or more than `tol` from it. - fn narrow_at(&self, r: usize, p: Point, tol: f64) -> bool { - use kurbo::{ParamCurve as _, ParamCurveNearest as _}; - let Some((_, bp, _)) = self.regions.get(r) else { return false }; - // Each segment with the parameter, point and distance of its point nearest `p`. - let near: Vec<(kurbo::PathSeg, f64, Point, f64)> = bp - .segments() - .map(|seg| { - let n = seg.nearest(p, 1e-6); - (seg, n.t, seg.eval(n.t), n.distance_sq.sqrt()) - }) - .collect(); - if !near.iter().all(|&(.., d)| d.is_finite() && d > 1e-9) { - return false; - } - let closest = near.iter().map(|&(.., d)| d).fold(f64::INFINITY, f64::min); - // A line from `p` no longer than `2 * tol` only meets segments that close to `p`. - let reach: Vec = near.iter().filter(|&&(.., d)| d <= 2.0 * tol).map(|&(seg, ..)| seg).collect(); - // A segment's end is its nearest point when the segment turns away from `p` or stops - // short of the point square to it; there, only the nearest point of all the outline and - // an end where the segment is square to the line to `p` count. - let square = |seg: kurbo::PathSeg, t: f64, n: Point| { - use kurbo::ParamCurveDeriv as _; - let dir = match seg { - kurbo::PathSeg::Line(l) => l.p1 - l.p0, - kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), - kurbo::PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), - }; - let v = p - n; - dir.hypot() > 1e-12 && dir.dot(v).abs() <= 1e-6 * dir.hypot() * v.hypot() - }; - let from = - |&&(seg, t, n, d): &&(kurbo::PathSeg, f64, Point, f64)| d <= tol && (d == closest || (t > 1e-9 && t < 1.0 - 1e-9) || square(seg, t, n)); - near.iter().filter(from).any(|&(_, _, n, d)| { - let line = kurbo::Line::new(p, p + (p - n) * ((2.0 * tol - d) / d)); - reach.iter().any(|seg| seg.intersect_line(line).iter().any(|x| x.line_t > 1e-9 && x.line_t <= 1.0)) - }) - } - - /// What a gesture along the polyline `pts` touches, in order: [`Self::hit`] at each vertex and - /// at the points [`Self::probes`] picks between them, so it touches every region it crosses, - /// however narrow. - pub fn hits_along(&self, pts: &[Point], piece_tol: Option) -> Touched { - self.along(pts, piece_tol, Self::hit) - } - - /// What a Shape Builder gesture along the polyline `pts` touches: [`Self::hits_along`] with - /// [`Self::builder_hit`], so an erasing gesture also touches each region it goes into where the - /// region is no more than `2 * piece_tol` across. - pub fn builder_hits_along(&self, pts: &[Point], piece_tol: Option) -> Touched { - self.along(pts, piece_tol, Self::builder_hit) - } - - fn along(&self, pts: &[Point], piece_tol: Option, hit: fn(&Self, Point, Option) -> Option) -> Touched { - let mut touched = Touched::default(); - let mut last: Option = None; - for &p in pts { - if let Some(a) = last { - for q in self.probes(a, p, piece_tol) { - touched.add(hit(self, q, piece_tol)); - } - } - touched.add(hit(self, p, piece_tol)); - last = Some(p); - } - touched - } - - /// The points strictly between `a` and `b`, in order, that [`Self::hits_along`] tests: the - /// middle of each stretch between two places where the segment crosses a region's outline, and - /// with `piece_tol` (erasing) those crossings, the places where it crosses a line piece or an - /// edge, and points `piece_tol / 2` apart (at most 4,000 per segment), so the line pieces and - /// edges the segment crosses or passes close to are touched too. - fn probes(&self, a: Point, b: Point, piece_tol: Option) -> Vec { - let len = a.distance(b); - if !(len.is_finite() && len > 0.0) { - return vec![]; - } - let line = kurbo::Line::new(a, b); - let lb = Rect::from_points(a, b); - let mut cross: Vec = vec![]; - for (_, bp, rb) in &self.regions { - if rb.x0 > lb.x1 || rb.x1 < lb.x0 || rb.y0 > lb.y1 || rb.y1 < lb.y0 { - continue; - } - for seg in bp.segments() { - cross.extend(seg.intersect_line(line).iter().map(|x| x.line_t)); - } - } - cross.retain(|t| t.is_finite() && *t > 0.0 && *t < 1.0); - cross.sort_by(f64::total_cmp); - // Crossings closer than this along the segment are one place. - let gap = 1e-9 / len; - let mut ts: Vec = vec![]; - let mut from = 0.0; - for &t in cross.iter().chain([1.0].iter()) { - if t - from > gap { - ts.push((from + t) / 2.0); - from = t; - } - } - if let Some(tol) = piece_tol { - ts.extend(cross.iter().copied()); - for (piece, pb) in self.lines.iter().chain(&self.edges) { - if pb.x0 > lb.x1 || pb.x1 < lb.x0 || pb.y0 > lb.y1 || pb.y1 < lb.y0 { - continue; - } - for seg in piece.path.to_bezpath().segments() { - ts.extend(seg.intersect_line(line).iter().map(|x| x.line_t).filter(|t| t.is_finite() && *t > 0.0 && *t < 1.0)); - } - } - let n = ((len / (tol / 2.0).max(1e-3)).ceil() as usize).min(4000); - ts.extend((1..n).map(|k| k as f64 / n as f64)); - ts.sort_by(f64::total_cmp); - } - ts.into_iter().map(|t| a.lerp(b, t)).collect() - } - - /// The regions just either side of the middle of `piece` (None: outside every region). - pub fn sides(&self, piece: &po::Shape) -> [Option; 2] { - let Some(sp) = piece.path.subpaths.first() else { return [None; 2] }; - let n = sp.segment_count(); - if n == 0 { - return [None; 2]; - } - let c = sp.segment(n / 2); - use kurbo::{ParamCurve as _, ParamCurveDeriv as _}; - let (m, d) = (c.eval(0.5), c.deriv().eval(0.5).to_vec2()); - let len = d.hypot(); - if !(len.is_finite() && len > 0.0) { - return [None; 2]; - } - // A little to each side: inside the faces next to it, which share it exactly. - let off = d.turn_90() * (0.05f64.min(0.05 * c.p0.distance(c.p3)).max(1e-6) / len); - [m + off, m - off].map(|q| self.region_at(q)) - } - - /// Does line piece `i` run between two different regions of `regions` (an edge that merging or - /// deleting them removes)? A piece with the same region on both sides, such as the end of a - /// line inside a shape, bounds nothing and stays. - pub fn line_between(&self, i: usize, regions: &[usize]) -> bool { - self.lines.get(i).is_some_and(|(l, _)| matches!(self.sides(l), [Some(a), Some(b)] if a != b && regions.contains(&a) && regions.contains(&b))) - } - - /// The edges `touched` deletes: those that bound none of its regions (deleting a region takes - /// its edges with it, so an erase drag that crosses an edge into a region deletes the region). - pub fn erased_edges(&self, touched: &Touched) -> Vec { - let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r))); - touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect() - } -} - -/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Touched { - pub regions: Vec, - pub lines: Vec, - pub edges: Vec, -} - -impl Touched { - pub fn add(&mut self, hit: Option) { - let (list, i) = match hit { - Some(Hit::Region(i)) => (&mut self.regions, i), - Some(Hit::Line(i)) => (&mut self.lines, i), - Some(Hit::Edge(i)) => (&mut self.edges, i), - None => return, - }; - if !list.contains(&i) { - list.push(i); - } - } - - /// Adds what `other` touched, in its order. - pub fn extend(&mut self, other: &Touched) { - let regions = other.regions.iter().map(|&i| Hit::Region(i)); - for hit in regions.chain(other.lines.iter().map(|&i| Hit::Line(i))).chain(other.edges.iter().map(|&i| Hit::Edge(i))) { - self.add(Some(hit)); - } - } - - pub fn is_empty(&self) -> bool { - self.regions.is_empty() && self.lines.is_empty() && self.edges.is_empty() - } -} - -/// Selected roots sorted back → front in document order. -pub fn sorted_roots(doc: &Document, ids: &[NodeId]) -> Vec { - let mut v: Vec<(Vec, NodeId)> = ids.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect(); - v.sort(); - v.dedup_by(|a, b| a.1 == b.1); - v.into_iter().map(|(_, id)| id).collect() -} - -/// Sample points along a polyline every `step` units (always including the vertices). -pub fn sample_polyline(pts: &[Point], step: f64) -> Vec { - let mut out = vec![]; - let step = step.max(1e-3); - for (i, &p) in pts.iter().enumerate() { - if i > 0 { - let a = pts[i - 1]; - let n = ((a.distance(p) / step).ceil() as usize).min(4000); - for k in 1..n { - out.push(a.lerp(p, k as f64 / n as f64)); - } - } - out.push(p); - } - out -} - -/// Diagonal hatch lines filling `path` (the Shape Builder's grey mesh highlight), `gap` apart. -pub fn hatch(path: &BezPath, gap: f64) -> BezPath { - let mut out = BezPath::new(); - let b = path.bounding_box(); - if b.width() <= 0.0 || b.height() <= 0.0 { - return out; - } - // Rotate by -45° so the hatch lines become horizontal scanlines. - let s = std::f64::consts::FRAC_1_SQRT_2; - let rot = |p: Point| Point::new((p.x + p.y) * s, (p.y - p.x) * s); - let unrot = |p: Point| Point::new((p.x - p.y) * s, (p.x + p.y) * s); - let mut edges: Vec<(Point, Point)> = vec![]; - let mut first = Point::ZERO; - let mut last = Point::ZERO; - kurbo::flatten(path.iter(), gap.max(1e-3) * 0.1, |el| match el { - kurbo::PathEl::MoveTo(p) => { - if last != first { - edges.push((rot(last), rot(first))); - } - first = p; - last = p; - } - kurbo::PathEl::LineTo(p) => { - edges.push((rot(last), rot(p))); - last = p; - } - kurbo::PathEl::ClosePath => { - if last != first { - edges.push((rot(last), rot(first))); - } - last = first; - } - _ => {} - }); - if last != first { - edges.push((rot(last), rot(first))); - } - let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); - for (a, c) in &edges { - y0 = y0.min(a.y.min(c.y)); - y1 = y1.max(a.y.max(c.y)); - } - let gap = gap.max((y1 - y0) / 400.0).max(1e-6); - let mut y = (y0 / gap).floor() * gap + gap * 0.5; - let mut xs: Vec = vec![]; - while y < y1 { - xs.clear(); - for (a, c) in &edges { - if (a.y <= y) != (c.y <= y) { - xs.push(a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)); - } - } - xs.sort_by(f64::total_cmp); - for pair in xs.as_chunks::<2>().0 { - out.move_to(unrot(Point::new(pair[0], y))); - out.line_to(unrot(Point::new(pair[1], y))); - } - y += gap; - } - out -} - -/// What a [`RegionCache`] map was built for: the document's address and revision, the selected ids -/// and whether filled open paths count as closed. -type CacheKey = (usize, (u64, u64), Vec, bool); - -/// Hovering builds the map of inputs with more segments than this on another thread -/// ([`RegionCache::ready`]); smaller maps are built on the calling thread. -const BACKGROUND_SEGMENTS: usize = 128; - -/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and -/// the channel it arrives on. -struct Pending { - key: CacheKey, - outlines: Vec, - done: Receiver, -} - -/// The planar map of the current selection, cached by document identity, revision, selection and -/// whether filled open paths count as closed. -#[derive(Default)] -struct RegionCache { - key: Option, - /// The map, which the Shape Builder's merge command takes too ([`Tool::shape_builder_map`]). - map: Arc, - /// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions. - live: bool, - /// Gap Detection distance; changing tool options invalidates the cache. - gap: f64, - /// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]). - outlines: Vec, - /// The map being built on another thread, if any. - pending: Option, - /// The key of the last map whose build on another thread failed. - failed: Option, - /// How many times the map has been built. - #[cfg(test)] - builds: usize, - /// How many of those builds ran on another thread. - #[cfg(test)] - background: usize, -} - -/// The map of `shapes`: Live Paint faces with `live`, else the Shape Builder map with its edges, -/// closing gaps up to `gap`. -fn build_map(shapes: Vec, live: bool, gap: f64) -> BuilderMap { - if live { - let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) }; - BuilderMap { regions, ..Default::default() } - } else { - BuilderMap::of_shapes(shapes, true, gap) - } -} - -/// Builds the map of `shapes` ([`build_map`]) on a new thread; None when no thread starts. -#[cfg(not(target_arch = "wasm32"))] -fn spawn_build(shapes: Vec, live: bool, gap: f64) -> Option> { - let (tx, done) = std::sync::mpsc::channel(); - let spawned = std::thread::Builder::new().name("vectorcraft-region-map".into()).spawn(move || { - // The tool may have dropped the receiver meanwhile (another tool, another selection). - let _ = tx.send(build_map(shapes, live, gap)); - }); - spawned.is_ok().then_some(done) -} - -/// The web build has no threads. -#[cfg(target_arch = "wasm32")] -fn spawn_build(_shapes: Vec, _live: bool, _gap: f64) -> Option> { - None -} - -impl RegionCache { - fn live_paint() -> Self { - Self { live: true, ..Default::default() } - } - - fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey { - (cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed) - } - - /// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder - /// only). - fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec, Vec) { - let roots = sorted_roots(cx.doc, &cx.selection.objects); - if self.live { - (shapes_for(cx.doc, &roots).0, vec![]) - } else { - let (shapes, leaves) = shape_builder_shapes(cx.doc, &roots, open_filled_as_closed); - let outlines = drawn_outlines(cx.doc, &leaves); - (shapes, outlines) - } - } - - fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec) { - self.map = Arc::new(map); - self.outlines = outlines; - self.key = Some(key); - #[cfg(test)] - { - self.builds += 1; - } - } - - /// Forgets the map and any map being built (the tool options changed). - fn clear(&mut self) { - self.key = None; - self.pending = None; - self.failed = None; - } - - /// Is a map being built on another thread? - fn waiting(&self) -> bool { - self.pending.is_some() - } - - /// The map, built again only when the key changes, with filled open paths closed when - /// `open_filled_as_closed` (Shape Builder only). A Shape Builder map has the outline edges too, - /// so hovering and erasing use the same map. Waits for a map being built on another thread for - /// the same key. - fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap { - let key = Self::key_for(cx, open_filled_as_closed); - if self.key.as_ref() != Some(&key) { - let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines))); - let (map, outlines) = match built { - Some(b) => { - #[cfg(test)] - { - self.background += 1; - } - b - } - None => { - let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); - (build_map(shapes, self.live, self.gap), outlines) - } - }; - self.install(key, map, outlines); - } - &self.map - } - - /// Takes the map being built on another thread once it has arrived, or with `wait` once it - /// arrives, as the map for the key it was built for. - fn settle(&mut self, wait: bool) { - let Some(p) = self.pending.take() else { return }; - let got = if wait { p.done.recv().map_err(|_| TryRecvError::Disconnected) } else { p.done.try_recv() }; - match got { - Ok(map) => { - #[cfg(test)] - { - self.background += 1; - } - self.install(p.key, map, p.outlines); - } - // The build failed (a bug in the planar map): hovering does not build it again, and a - // gesture builds it on the calling thread, which reports the failure. - Err(TryRecvError::Disconnected) => self.failed = Some(p.key), - Err(TryRecvError::Empty) => self.pending = Some(p), - } - } - - /// The map as [`Self::get`] gives it, without waiting: when the key changes and the inputs have - /// more than [`BACKGROUND_SEGMENTS`] segments, the map is built on another thread, and this is - /// None until it arrives ([`Self::waiting`] holds meanwhile). A map being built for an earlier - /// key is let finish before the next one starts. - fn ready(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<&BuilderMap> { - self.settle(false); - let key = Self::key_for(cx, open_filled_as_closed); - if self.key.as_ref() == Some(&key) { - return Some(&self.map); - } - if self.pending.is_some() || self.failed.as_ref() == Some(&key) { - return None; - } - let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); - let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::()).sum(); - if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) { - if let Some(done) = spawn_build(shapes, self.live, self.gap) { - self.pending = Some(Pending { key, outlines, done }); - return None; - } - // No thread started: build here. - return Some(self.get(cx, open_filled_as_closed)); - } - let map = build_map(shapes, self.live, self.gap); - self.install(key, map, outlines); - Some(&self.map) - } - - fn at(&mut self, cx: &ToolContext, p: Point) -> Option { - self.get(cx, true).region_at(p) - } -} - // ---------- Shape Builder ---------- pub use po::SHAPE_BUILDER_MAX_GAP; + +mod cache; +mod live_paint; +mod lookup; +mod map; + +use cache::RegionCache; +#[cfg(test)] +use live_paint::LpHover; +use live_paint::lp_color; +pub use live_paint::{LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, LivePaintSelectionTool}; +pub use lookup::{ + EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, drawn_outlines, edge_near, edges, face_at, faces, is_live_paint, + leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, sources, + visible_edge_near, visible_face_at, +}; +pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots}; /// Small, Medium and Large gap lengths in points. pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0]; @@ -1409,266 +521,6 @@ impl Tool for ShapeBuilderTool { } } -// ---------- Live Paint Bucket ---------- - -/// What the Live Paint tools highlight under the cursor. -#[derive(Clone, Debug, Default, PartialEq)] -enum LpHover { - #[default] - None, - /// A face (or edge) of an existing Live Paint group. - Part(BezPath), - /// A region of the (not yet live) selection. - Region(usize), -} - -/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a visible face -/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under -/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside -/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the -/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. -fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { - if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { - return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); - } - if !edge { - return None; - } - let mut best: Option<(f64, NodeId, &Arc)> = None; - doc.walk(|n| { - if is_live_paint(n) - && let Some(e) = visible_edge_near(n, p, tol) - && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) - && best.is_none_or(|b| d <= b.0) - && doc.is_visible(n.id) - { - best = Some((d, n.id, e)); - } - }); - best.map(|(_, g, e)| (g, Some(e))) -} - -/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`]), else -/// with `cache` the region of the selection under `p`. A large selection's regions are found on -/// another thread ([`RegionCache::ready`]), and until they are, no region is highlighted. -fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool) -> LpHover { - if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) { - return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); - } - let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); - if !sel_is_live && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) { - return LpHover::Region(i); - } - LpHover::None -} - -/// The highlight of `h`, `width` screen pixels wide in `color`. -fn lp_overlays(h: &LpHover, cache: &RegionCache, color: [u8; 3], width: f32) -> Vec { - match h { - LpHover::None => vec![], - LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }], - LpHover::Region(i) => { - cache.map.regions.get(*i).map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }]).unwrap_or_default() - } - } -} - -/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click -/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a -/// Live Paint group first. -pub struct LivePaintBucketTool { - cache: RegionCache, - hover: LpHover, - /// Where the pointer last moved, and whether Shift was held. - at: Option<(Point, bool)>, -} - -impl Default for LivePaintBucketTool { - fn default() -> Self { - Self { cache: RegionCache::live_paint(), hover: LpHover::None, at: None } - } -} - -impl Tool for LivePaintBucketTool { - fn id(&self) -> &'static str { - "livePaintBucket" - } - - fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let p = ev.pos; - match ev.kind { - PointerKind::Move => { - self.at = Some((p, ev.mods.shift)); - self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift); - vec![] - } - PointerKind::Down => { - self.hover = LpHover::None; - let point = json!([p.x, p.y]); - if ev.mods.shift { - return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { - Some((g, Some(_))) => { - vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] - } - _ => vec![], - }; - } - if let Some(g) = live_paint_at(cx.doc, p) { - return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))]; - } - if self.cache.at(cx, p).is_some() { - let roots = sorted_roots(cx.doc, &cx.selection.objects); - return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))]; - } - vec![] - } - _ => vec![], - } - } - - fn overlays(&self, _cx: &ToolContext) -> Vec { - lp_overlays(&self.hover, &self.cache, HIGHLIGHT_RED, 4.0) - } - - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::LivePaintBucket - } - - fn waiting(&self) -> bool { - self.cache.waiting() - } - - fn poll(&mut self, cx: &ToolContext, wait: bool) { - if !self.cache.waiting() { - return; - } - // As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part - // of a Live Paint group the hover takes no map. - loop { - self.cache.settle(wait); - if let Some((p, shift)) = self.at { - self.hover = lp_hover(cx, Some(&mut self.cache), p, shift); - } - if !wait || !self.cache.waiting() { - break; - } - } - } -} - -// ---------- Live Paint Selection ---------- - -/// The Live Paint Selection tool's highlight width (screen pixels) by default. -pub const LP_SELECT_WIDTH: f64 = 4.0; -/// The least and greatest highlight width (screen pixels) the Live Paint Selection tool takes. -pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); - -/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. Its Tool -/// Options (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight -/// `color` (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With Select -/// Strokes off, Alt-click selects the face under the pointer as a click does; with Select Fills -/// off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click -/// selects no face or edge. -pub struct LivePaintSelectionTool { - cache: RegionCache, - hover: LpHover, - fills: bool, - strokes: bool, - color: [u8; 3], - width: f64, -} - -impl Default for LivePaintSelectionTool { - fn default() -> Self { - Self { cache: RegionCache::live_paint(), hover: LpHover::None, fills: true, strokes: true, color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH } - } -} - -impl LivePaintSelectionTool { - /// What a click picks, Alt held or not: Some(true) an edge, Some(false) a face, None nothing. - fn picks_edge(&self, alt: bool) -> Option { - match (self.fills, self.strokes) { - (true, true) => Some(alt), - (true, false) => Some(false), - (false, true) => Some(true), - (false, false) => None, - } - } -} - -/// A highlight color option: `#rrggbb` or a layer color's name (any case). -fn lp_color(v: &Value) -> Option<[u8; 3]> { - let s = v.as_str()?.trim(); - if let Some((_, c)) = vectorcraft_doc::LAYER_COLORS.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) { - return Some(*c); - } - let [r, g, b, _] = vectorcraft_color::Color::from_hex(s)?.to_rgba8(1.0); - Some([r, g, b]) -} - -impl Tool for LivePaintSelectionTool { - fn id(&self) -> &'static str { - "livePaintSelection" - } - - fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let p = ev.pos; - let edge = self.picks_edge(ev.mods.alt); - match ev.kind { - PointerKind::Move => { - // Only the parts of Live Paint groups highlight, so no map of the selection is built. - self.hover = match edge.map(|e| lp_hover(cx, None, p, e)) { - Some(h @ LpHover::Part(_)) => h, - _ => LpHover::None, - }; - vec![] - } - PointerKind::Down => { - let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id); - match (hit, ev.mods.shift) { - (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], - (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], - (None, true) => vec![], - (None, false) => vec![Action::Exec("select.none".into(), json!({}))], - } - } - _ => vec![], - } - } - - fn overlays(&self, _cx: &ToolContext) -> Vec { - lp_overlays(&self.hover, &self.cache, self.color, self.width as f32) - } - - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Arrow - } - - fn options(&self) -> Value { - let [r, g, b] = self.color; - json!({ - "selectFills": self.fills, - "selectStrokes": self.strokes, - "color": format!("#{r:02x}{g:02x}{b:02x}"), - "width": self.width, - }) - } - - fn set_option(&mut self, key: &str, v: &Value) { - match key { - "selectFills" => self.fills = v.as_bool().unwrap_or(self.fills), - "selectStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes), - "color" => self.color = lp_color(v).unwrap_or(self.color), - "width" => { - let (min, max) = LP_SELECT_WIDTH_RANGE; - self.width = v.as_f64().filter(|w| w.is_finite()).map_or(self.width, |w| w.clamp(min, max)); - } - _ => return, - } - self.hover = LpHover::None; - } -} - /// Create a builder-family tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { Some(match id { diff --git a/crates/tools/src/builder/cache.rs b/crates/tools/src/builder/cache.rs new file mode 100644 index 000000000..27487e4c3 --- /dev/null +++ b/crates/tools/src/builder/cache.rs @@ -0,0 +1,195 @@ +use super::lookup::{drawn_outlines, faces_of, shape_builder_shapes, shapes_for}; +use super::map::{BuilderMap, sorted_roots}; +use super::*; + +/// What a [`RegionCache`] map was built for: the document's address and revision, the selected ids +/// and whether filled open paths count as closed. +pub(super) type CacheKey = (usize, (u64, u64), Vec, bool); + +/// Hovering builds the map of inputs with more segments than this on another thread +/// ([`RegionCache::ready`]); smaller maps are built on the calling thread. +const BACKGROUND_SEGMENTS: usize = 128; + +/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and +/// the channel it arrives on. +struct Pending { + key: CacheKey, + outlines: Vec, + done: Receiver, +} + +/// The planar map of the current selection, cached by document identity, revision, selection and +/// whether filled open paths count as closed. +#[derive(Default)] +pub(super) struct RegionCache { + pub(super) key: Option, + /// The map, which the Shape Builder's merge command takes too ([`Tool::shape_builder_map`]). + pub(super) map: Arc, + /// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions. + live: bool, + /// Gap Detection distance; changing tool options invalidates the cache. + pub(super) gap: f64, + /// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]). + pub(super) outlines: Vec, + /// The map being built on another thread, if any. + pending: Option, + /// The key of the last map whose build on another thread failed. + failed: Option, + /// How many times the map has been built. + #[cfg(test)] + pub(super) builds: usize, + /// How many of those builds ran on another thread. + #[cfg(test)] + pub(super) background: usize, +} + +/// The map of `shapes`: Live Paint faces with `live`, else the Shape Builder map with its edges, +/// closing gaps up to `gap`. +fn build_map(shapes: Vec, live: bool, gap: f64) -> BuilderMap { + if live { + let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) }; + BuilderMap { regions, ..Default::default() } + } else { + BuilderMap::of_shapes(shapes, true, gap) + } +} + +/// Builds the map of `shapes` ([`build_map`]) on a new thread; None when no thread starts. +#[cfg(not(target_arch = "wasm32"))] +fn spawn_build(shapes: Vec, live: bool, gap: f64) -> Option> { + let (tx, done) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("vectorcraft-region-map".into()).spawn(move || { + // The tool may have dropped the receiver meanwhile (another tool, another selection). + let _ = tx.send(build_map(shapes, live, gap)); + }); + spawned.is_ok().then_some(done) +} + +/// The web build has no threads. +#[cfg(target_arch = "wasm32")] +fn spawn_build(_shapes: Vec, _live: bool, _gap: f64) -> Option> { + None +} + +impl RegionCache { + pub(super) fn live_paint() -> Self { + Self { live: true, ..Default::default() } + } + + fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey { + (cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed) + } + + /// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder + /// only). + fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec, Vec) { + let roots = sorted_roots(cx.doc, &cx.selection.objects); + if self.live { + (shapes_for(cx.doc, &roots).0, vec![]) + } else { + let (shapes, leaves) = shape_builder_shapes(cx.doc, &roots, open_filled_as_closed); + let outlines = drawn_outlines(cx.doc, &leaves); + (shapes, outlines) + } + } + + fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec) { + self.map = Arc::new(map); + self.outlines = outlines; + self.key = Some(key); + #[cfg(test)] + { + self.builds += 1; + } + } + + /// Forgets the map and any map being built (the tool options changed). + pub(super) fn clear(&mut self) { + self.key = None; + self.pending = None; + self.failed = None; + } + + /// Is a map being built on another thread? + pub(super) fn waiting(&self) -> bool { + self.pending.is_some() + } + + /// The map, built again only when the key changes, with filled open paths closed when + /// `open_filled_as_closed` (Shape Builder only). A Shape Builder map has the outline edges too, + /// so hovering and erasing use the same map. Waits for a map being built on another thread for + /// the same key. + pub(super) fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap { + let key = Self::key_for(cx, open_filled_as_closed); + if self.key.as_ref() != Some(&key) { + let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines))); + let (map, outlines) = match built { + Some(b) => { + #[cfg(test)] + { + self.background += 1; + } + b + } + None => { + let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); + (build_map(shapes, self.live, self.gap), outlines) + } + }; + self.install(key, map, outlines); + } + &self.map + } + + /// Takes the map being built on another thread once it has arrived, or with `wait` once it + /// arrives, as the map for the key it was built for. + pub(super) fn settle(&mut self, wait: bool) { + let Some(p) = self.pending.take() else { return }; + let got = if wait { p.done.recv().map_err(|_| TryRecvError::Disconnected) } else { p.done.try_recv() }; + match got { + Ok(map) => { + #[cfg(test)] + { + self.background += 1; + } + self.install(p.key, map, p.outlines); + } + // The build failed (a bug in the planar map): hovering does not build it again, and a + // gesture builds it on the calling thread, which reports the failure. + Err(TryRecvError::Disconnected) => self.failed = Some(p.key), + Err(TryRecvError::Empty) => self.pending = Some(p), + } + } + + /// The map as [`Self::get`] gives it, without waiting: when the key changes and the inputs have + /// more than [`BACKGROUND_SEGMENTS`] segments, the map is built on another thread, and this is + /// None until it arrives ([`Self::waiting`] holds meanwhile). A map being built for an earlier + /// key is let finish before the next one starts. + pub(super) fn ready(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<&BuilderMap> { + self.settle(false); + let key = Self::key_for(cx, open_filled_as_closed); + if self.key.as_ref() == Some(&key) { + return Some(&self.map); + } + if self.pending.is_some() || self.failed.as_ref() == Some(&key) { + return None; + } + let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); + let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::()).sum(); + if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) { + if let Some(done) = spawn_build(shapes, self.live, self.gap) { + self.pending = Some(Pending { key, outlines, done }); + return None; + } + // No thread started: build here. + return Some(self.get(cx, open_filled_as_closed)); + } + let map = build_map(shapes, self.live, self.gap); + self.install(key, map, outlines); + Some(&self.map) + } + + pub(super) fn at(&mut self, cx: &ToolContext, p: Point) -> Option { + self.get(cx, true).region_at(p) + } +} diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs new file mode 100644 index 000000000..667a6e2e3 --- /dev/null +++ b/crates/tools/src/builder/live_paint.rs @@ -0,0 +1,265 @@ +use super::cache::RegionCache; +use super::lookup::{is_live_paint, live_paint_at, visible_edge_near, visible_face_at}; +use super::map::{HIGHLIGHT_RED, sorted_roots}; +use super::*; + +// ---------- Live Paint Bucket ---------- + +/// What the Live Paint tools highlight under the cursor. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) enum LpHover { + #[default] + None, + /// A face (or edge) of an existing Live Paint group. + Part(BezPath), + /// A region of the (not yet live) selection. + Region(usize), +} + +/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a visible face +/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under +/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside +/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the +/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. +fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { + if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { + return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); + } + if !edge { + return None; + } + let mut best: Option<(f64, NodeId, &Arc)> = None; + doc.walk(|n| { + if is_live_paint(n) + && let Some(e) = visible_edge_near(n, p, tol) + && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && best.is_none_or(|b| d <= b.0) + && doc.is_visible(n.id) + { + best = Some((d, n.id, e)); + } + }); + best.map(|(_, g, e)| (g, Some(e))) +} + +/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`]), else +/// with `cache` the region of the selection under `p`. A large selection's regions are found on +/// another thread ([`RegionCache::ready`]), and until they are, no region is highlighted. +fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool) -> LpHover { + if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) { + return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); + } + let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); + if !sel_is_live && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) { + return LpHover::Region(i); + } + LpHover::None +} + +/// The highlight of `h`, `width` screen pixels wide in `color`. +fn lp_overlays(h: &LpHover, cache: &RegionCache, color: [u8; 3], width: f32) -> Vec { + match h { + LpHover::None => vec![], + LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }], + LpHover::Region(i) => { + cache.map.regions.get(*i).map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }]).unwrap_or_default() + } + } +} + +/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click +/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a +/// Live Paint group first. +pub struct LivePaintBucketTool { + pub(super) cache: RegionCache, + pub(super) hover: LpHover, + /// Where the pointer last moved, and whether Shift was held. + at: Option<(Point, bool)>, +} + +impl Default for LivePaintBucketTool { + fn default() -> Self { + Self { cache: RegionCache::live_paint(), hover: LpHover::None, at: None } + } +} + +impl Tool for LivePaintBucketTool { + fn id(&self) -> &'static str { + "livePaintBucket" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Move => { + self.at = Some((p, ev.mods.shift)); + self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift); + vec![] + } + PointerKind::Down => { + self.hover = LpHover::None; + let point = json!([p.x, p.y]); + if ev.mods.shift { + return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { + Some((g, Some(_))) => { + vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] + } + _ => vec![], + }; + } + if let Some(g) = live_paint_at(cx.doc, p) { + return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))]; + } + if self.cache.at(cx, p).is_some() { + let roots = sorted_roots(cx.doc, &cx.selection.objects); + return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))]; + } + vec![] + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + lp_overlays(&self.hover, &self.cache, HIGHLIGHT_RED, 4.0) + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::LivePaintBucket + } + + fn waiting(&self) -> bool { + self.cache.waiting() + } + + fn poll(&mut self, cx: &ToolContext, wait: bool) { + if !self.cache.waiting() { + return; + } + // As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part + // of a Live Paint group the hover takes no map. + loop { + self.cache.settle(wait); + if let Some((p, shift)) = self.at { + self.hover = lp_hover(cx, Some(&mut self.cache), p, shift); + } + if !wait || !self.cache.waiting() { + break; + } + } + } +} + +// ---------- Live Paint Selection ---------- + +/// The Live Paint Selection tool's highlight width (screen pixels) by default. +pub const LP_SELECT_WIDTH: f64 = 4.0; + +/// The least and greatest highlight width (screen pixels) the Live Paint Selection tool takes. +pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); + +/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. Its Tool +/// Options (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight +/// `color` (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With Select +/// Strokes off, Alt-click selects the face under the pointer as a click does; with Select Fills +/// off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click +/// selects no face or edge. +pub struct LivePaintSelectionTool { + pub(super) cache: RegionCache, + pub(super) hover: LpHover, + fills: bool, + strokes: bool, + color: [u8; 3], + width: f64, +} + +impl Default for LivePaintSelectionTool { + fn default() -> Self { + Self { cache: RegionCache::live_paint(), hover: LpHover::None, fills: true, strokes: true, color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH } + } +} + +impl LivePaintSelectionTool { + /// What a click picks, Alt held or not: Some(true) an edge, Some(false) a face, None nothing. + fn picks_edge(&self, alt: bool) -> Option { + match (self.fills, self.strokes) { + (true, true) => Some(alt), + (true, false) => Some(false), + (false, true) => Some(true), + (false, false) => None, + } + } +} + +/// A highlight color option: `#rrggbb` or a layer color's name (any case). +pub(super) fn lp_color(v: &Value) -> Option<[u8; 3]> { + let s = v.as_str()?.trim(); + if let Some((_, c)) = vectorcraft_doc::LAYER_COLORS.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) { + return Some(*c); + } + let [r, g, b, _] = vectorcraft_color::Color::from_hex(s)?.to_rgba8(1.0); + Some([r, g, b]) +} + +impl Tool for LivePaintSelectionTool { + fn id(&self) -> &'static str { + "livePaintSelection" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + let edge = self.picks_edge(ev.mods.alt); + match ev.kind { + PointerKind::Move => { + // Only the parts of Live Paint groups highlight, so no map of the selection is built. + self.hover = match edge.map(|e| lp_hover(cx, None, p, e)) { + Some(h @ LpHover::Part(_)) => h, + _ => LpHover::None, + }; + vec![] + } + PointerKind::Down => { + let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id); + match (hit, ev.mods.shift) { + (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], + (None, true) => vec![], + (None, false) => vec![Action::Exec("select.none".into(), json!({}))], + } + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + lp_overlays(&self.hover, &self.cache, self.color, self.width as f32) + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Arrow + } + + fn options(&self) -> Value { + let [r, g, b] = self.color; + json!({ + "selectFills": self.fills, + "selectStrokes": self.strokes, + "color": format!("#{r:02x}{g:02x}{b:02x}"), + "width": self.width, + }) + } + + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "selectFills" => self.fills = v.as_bool().unwrap_or(self.fills), + "selectStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes), + "color" => self.color = lp_color(v).unwrap_or(self.color), + "width" => { + let (min, max) = LP_SELECT_WIDTH_RANGE; + self.width = v.as_f64().filter(|w| w.is_finite()).map_or(self.width, |w| w.clamp(min, max)); + } + _ => return, + } + self.hover = LpHover::None; + } +} diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs new file mode 100644 index 000000000..30475be34 --- /dev/null +++ b/crates/tools/src/builder/lookup.rs @@ -0,0 +1,259 @@ +use super::*; + +/// Names starting with this mark a Live Paint group, together with its sources group ([`SOURCES_NAME`]). +pub const LIVE_PAINT_PREFIX: &str = "Live Paint"; + +/// Default name of a new Live Paint group. +pub const LIVE_PAINT_NAME: &str = "Live Paint Group"; + +/// The hidden child group holding the source paths. +pub const SOURCES_NAME: &str = "Live Paint Sources"; + +pub const FACE_NAME: &str = "Face"; + +pub const EDGE_NAME: &str = "Edge"; + +/// Is `n` a Live Paint group: an unclipped group whose name starts with [`LIVE_PAINT_PREFIX`] and +/// that holds a group named [`SOURCES_NAME`]? A plain group with such a name is not one. +pub fn is_live_paint(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && n.name.as_deref().is_some_and(|s| s.starts_with(LIVE_PAINT_PREFIX)) + && sources(n).is_some_and(|s| matches!(s.kind, NodeKind::Group { .. })) +} + +/// Whether Live Paint can take `n` as it is: a Live Paint group, a path or compound path, or a +/// group of those. Type, images, symbols, blends, meshes and the rest have to be converted to +/// paths first (); +/// Make and Merge refuse them rather than hide them among the group's sources. +pub fn live_paint_takes(n: &Node) -> bool { + is_live_paint(n) + || match &n.kind { + NodeKind::Path { .. } | NodeKind::Compound { .. } => true, + NodeKind::Group { children, .. } => children.iter().all(|c| live_paint_takes(c)), + _ => false, + } +} + +fn named(n: &Node, name: &str) -> bool { + n.name.as_deref() == Some(name) +} + +/// Face children of a Live Paint group (paint order). +pub fn faces(group: &Node) -> Vec<&Arc> { + group.children().map(|c| c.iter().filter(|n| named(n, FACE_NAME)).collect()).unwrap_or_default() +} + +/// Edge children of a Live Paint group. +pub fn edges(group: &Node) -> Vec<&Arc> { + group.children().map(|c| c.iter().filter(|n| named(n, EDGE_NAME)).collect()).unwrap_or_default() +} + +/// The hidden sources group of a Live Paint group. +pub fn sources(group: &Node) -> Option<&Arc> { + group.children()?.iter().find(|n| named(n, SOURCES_NAME)) +} + +/// Does the path (filled) contain `p`? +pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool { + if path.bounds().is_none_or(|b| !b.inflate(1e-6, 1e-6).contains(p)) { + return false; + } + let w = path.to_bezpath().winding(p); + match rule { + FillRule::EvenOdd => w % 2 != 0, + _ => w != 0, + } +} + +/// The face of `group` under `p` (front-most). +pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { + front_face(faces(group).into_iter(), p) +} + +/// The face of `group` under `p` (front-most) among its faces that are not hidden. +pub fn visible_face_at(group: &Node, p: Point) -> Option<&Arc> { + front_face(faces(group).into_iter().filter(|f| f.visible), p) +} + +/// The face of `faces` (paint order) under `p` (front-most). +fn front_face<'a>(faces: impl DoubleEndedIterator>, p: Point) -> Option<&'a Arc> { + faces.rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) +} + +/// The edge of `group` nearest to `p` within `tol`. +pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + nearest_edge(edges(group), p, tol) +} + +/// The edge of `group` nearest to `p` within `tol` among its edges that are not hidden. +pub fn visible_edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + nearest_edge(edges(group).into_iter().filter(|e| e.visible), p, tol) +} + +/// The edge of `edges` nearest to `p` within `tol`. +fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, tol: f64) -> Option<&'a Arc> { + let mut best: Option<(f64, &Arc)> = None; + for e in edges { + if let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && d <= tol + && best.is_none_or(|b| d < b.0) + { + best = Some((d, e)); + } + } + best.map(|b| b.1) +} + +/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). +pub fn live_paint_at(doc: &Document, p: Point) -> Option { + let mut hit = None; + doc.walk(|n| { + if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { + hit = Some(n.id); + } + }); + hit +} + +/// Paths, compound paths (as one shape) of a subtree in paint order; guides, clipping paths and +/// hidden objects are skipped. Live Paint groups contribute their faces and edges. +pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { + collect_leaves(n, false, out); +} + +/// [`leaf_shapes`]; with `filled_faces`, a Live Paint group contributes only its filled faces and +/// its stroked edges. +fn collect_leaves<'a>(n: &'a Node, filled_faces: bool, out: &mut Vec<&'a Node>) { + if !n.visible { + return; + } + if n.extras().shaper.is_some() { + for c in n.children().into_iter().flatten().skip(1) { + collect_leaves(c, filled_faces, out); + } + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), + NodeKind::Group { .. } if filled_faces && is_live_paint(n) => { + out.extend(faces(n).into_iter().filter(|f| f.visible && !f.appearance.fill_paint().is_none()).map(|f| &**f)); + out.extend(edges(n).into_iter().filter(|e| e.visible && !e.appearance.stroke_paint().is_none()).map(|e| &**e)); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children { + collect_leaves(c, filled_faces, out); + } + } + _ => {} + } +} + +/// Filled outline of a path or compound path node. +pub fn node_outline(n: &Node) -> Option<(PathData, FillRule)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + let subs = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect(); + Some((PathData::new(subs), *rule)) + } + _ => None, + } +} + +/// How far (pt) a piece of the planar map may lie from `outline`, the outline it follows, to be +/// matched to it or cut out of it: a ten-thousandth of the outline's larger side, at least 0.05. +/// The map's pieces follow the outlines to within a few hundredths of a point. +pub fn outline_snap(outline: &PathData) -> f64 { + outline.bounds().map_or(0.05, |b| (b.width().max(b.height()) / 2000.0).max(0.25) * 0.2) +} + +/// The outlines of `leaves` as they are drawn ([`node_outline`]), in their order, for +/// [`BuilderMap::stroke_at`]. A leaf without an outline, and one inside a Live Paint group or a +/// Shaper Group (which make their own paths), gets an empty outline, so no stroke of it splits. +pub fn drawn_outlines(doc: &Document, leaves: &[&Node]) -> Vec { + let in_live_group = |id: NodeId| { + let mut up = doc.parent_of(id); + while let Some(p) = up { + if doc.node(p).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some()) { + return true; + } + up = doc.parent_of(p); + } + false + }; + leaves.iter().map(|l| if in_live_group(l.id) { None } else { node_outline(l) }.map_or_else(PathData::default, |o| o.0)).collect() +} + +/// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index, +/// with the leaves they came from. +pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, Vec<&'a Node>) { + shapes_of(doc, roots, false) +} + +/// [`shapes_for`]; with `filled_faces`, a Live Paint group contributes only its filled faces and +/// its stroked edges. +fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Vec, Vec<&'a Node>) { + let mut lv = vec![]; + for id in roots { + if let Some(n) = doc.node(*id) { + collect_leaves(n, filled_faces, &mut lv); + } + } + let mut shapes = vec![]; + let mut used = vec![]; + for l in lv { + if let Some((p, r)) = node_outline(l) + && !p.is_empty() + && p.subpaths.iter().all(|s| s.anchors.iter().all(|a| a.p.x.is_finite() && a.p.y.is_finite())) + { + shapes.push(po::Shape::new(p, r, used.len() as u64)); + used.push(l); + } + } + (shapes, used) +} + +/// Shape Builder inputs: [`shapes_for`], with the open subpaths of filled paths closed where that +/// encloses an area (the fill bounds a region, as if an invisible edge closed it) when +/// `open_filled_as_closed`. Unfilled open paths and straight lines stay open and cut the regions +/// they cross, and so do filled open paths without `open_filled_as_closed`. A Live Paint group +/// contributes its filled faces as shapes and its stroked edges as open lines; its unfilled faces +/// and its edges without a stroke are left out. +pub fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { + let (mut shapes, leaves) = shapes_of(doc, roots, true); + for (s, l) in shapes.iter_mut().zip(&leaves) { + if named(l, EDGE_NAME) && doc.parent_of(l.id).and_then(|p| doc.node(p)).is_some_and(is_live_paint) { + // A closed edge opens at its first anchor and keeps its closing segment. + let start = BTreeSet::from([0]); + s.path.subpaths = s.path.subpaths.iter().flat_map(|sp| sp.cut_at(&start)).collect(); + continue; + } + if !open_filled_as_closed || l.appearance.fill_paint().is_none() { + continue; + } + for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) { + sp.closed = po::encloses_area(sp); + } + } + (shapes, leaves) +} + +/// A planar-map face with its outline and bounds, ready for hit tests. +pub type Face = (po::Region, BezPath, Rect); + +pub(super) fn faces_of(regions: Vec) -> Vec { + regions + .into_iter() + .filter_map(|r| { + let bp = r.path.to_bezpath(); + let b = r.path.bounds()?; + Some((r, bp, b)) + }) + .collect() +} + +thread_local! { + /// The Shape Builder maps built on this thread ([`maps_built`]). + pub(super) static MAPS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} diff --git a/crates/tools/src/builder/map.rs b/crates/tools/src/builder/map.rs new file mode 100644 index 000000000..cf9327dd4 --- /dev/null +++ b/crates/tools/src/builder/map.rs @@ -0,0 +1,462 @@ +use super::lookup::{Face, MAPS_BUILT, faces_of, outline_snap, shape_builder_shapes}; +use super::*; + +/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red. +pub const HATCH: [u8; 3] = [0x80, 0x80, 0x80]; + +pub const HIGHLIGHT_RED: [u8; 3] = [0xff, 0x2a, 0x2a]; + +/// How many Shape Builder maps ([`BuilderMap::of_shapes`]) this thread has built, for tests that +/// count them. +pub fn maps_built() -> usize { + MAPS_BUILT.with(std::cell::Cell::get) +} + +/// The Shape Builder's planar map of a selection. +#[derive(Default)] +pub struct BuilderMap { + /// The inputs, back → front (see [`po::shape_builder`]). + pub shapes: Vec, + pub regions: Vec, + /// Pieces of the open paths that cut regions (see [`BuilderMap::new`]), keyed by input index, + /// with their bounds. + pub lines: Vec<(po::Shape, Rect)>, + /// Pieces of the other inputs' outlines (cut wherever paths meet), keyed by input index, with + /// their bounds: the edges erasing deletes. Only in a map made `with_edges`. + pub edges: Vec<(po::Shape, Rect)>, +} + +/// What a Shape Builder gesture touches at a point. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Hit { + Region(usize), + Line(usize), + Edge(usize), +} + +/// The piece of `pieces` nearest `p` within `tol`, with its distance. +fn piece_near(pieces: &[(po::Shape, Rect)], p: Point, tol: f64) -> Option<(f64, usize)> { + let mut best: Option<(f64, usize)> = None; + for (i, (l, b)) in pieces.iter().enumerate() { + if b.inflate(tol, tol).contains(p) + && let Some((.., d)) = l.path.nearest(p) + && d <= tol + && best.is_none_or(|b| d < b.0) + { + best = Some((d, i)); + } + } + best +} + +impl BuilderMap { + /// The map of `roots` (sorted back → front), with the leaves the inputs came from; the outline + /// edges only `with_edges`. With `open_filled_as_closed` (Consider Open Filled Paths as + /// Closed), a filled open path bounds a region as if an invisible edge closed it; without it, + /// the path cuts like a line. + pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool) -> (Self, Vec<&'a Node>) { + Self::with_gap(doc, roots, with_edges, open_filled_as_closed, 0.0) + } + + /// The same arrangement with Gap Detection, using the shared Live Paint gap finder. + pub fn with_gap<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool, gap: f64) -> (Self, Vec<&'a Node>) { + let (shapes, leaves) = shape_builder_shapes(doc, roots, open_filled_as_closed); + (Self::of_shapes(shapes, with_edges, gap), leaves) + } + + /// The map of `shapes` (the inputs, back → front, as [`shape_builder_shapes`] gives them), with + /// the outline edges only `with_edges`, closing gaps up to `gap`. + pub fn of_shapes(shapes: Vec, with_edges: bool, gap: f64) -> Self { + if shapes.is_empty() { + return Self::default(); + } + MAPS_BUILT.with(|n| n.set(n.get() + 1)); + let arr = po::shape_builder_with_gap(&shapes, with_edges, gap); + let bounded = |pieces: Vec| { + pieces + .into_iter() + .filter_map(|l| { + let b = l.path.bounds()?; + Some((l, b)) + }) + .collect() + }; + Self { shapes, regions: faces_of(arr.regions), lines: bounded(arr.lines), edges: bounded(arr.edges) } + } + + /// The region containing `p`. + pub fn region_at(&self, p: Point) -> Option { + self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0) + } + + /// The regions a rectangular marquee from `from` (where the drag started) to `to` touches: each + /// region whose outline crosses the rectangle or has an anchor inside it, and the region the + /// whole rectangle lies in. Of those, the region under `from` comes first, then the one under + /// `to`, then the others in map order. A marquee of no size touches the region under `from`. + pub fn regions_in_marquee(&self, from: Point, to: Point) -> Vec { + let r = Rect::from_points(from, to); + if r.width() <= 0.0 && r.height() <= 0.0 { + return self.region_at(from).into_iter().collect(); + } + let c = r.center(); + let touches = |(reg, bp, b): &Face| (b.contains(c) && bp.winding(c) != 0) || vectorcraft_geom::hit::intersects_rect(®.path, r); + let mut out: Vec = self.regions.iter().enumerate().filter(|(_, f)| touches(f)).map(|(i, _)| i).collect(); + for p in [to, from] { + if let Some(k) = self.region_at(p).and_then(|i| out.iter().position(|&j| j == i)) { + let i = out.remove(k); + out.insert(0, i); + } + } + out + } + + /// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that + /// close, else the region under it. + pub fn hit(&self, p: Point, piece_tol: Option) -> Option { + self.piece_hit(p, piece_tol).or_else(|| self.region_at(p).map(Hit::Region)) + } + + /// The line piece or edge within `piece_tol` of `p` that [`Self::hit`] gives (None without + /// `piece_tol`). + fn piece_hit(&self, p: Point, piece_tol: Option) -> Option { + piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) { + (Some(l), Some(e)) if e.0 < l.0 => Some(Hit::Edge(e.1)), + (Some(l), _) => Some(Hit::Line(l.1)), + (None, e) => e.map(|e| Hit::Edge(e.1)), + }) + } + + /// The stroke a click at `p` splits with In Merge Mode, Clicking Stroke Splits the Path: the + /// line piece or edge nearest `p` within `tol` (as [`BuilderMap::hit`] finds it), from the + /// anchor or crossing point before `p` to the one after it on the path it lies on. `outlines` + /// are the inputs' outlines as they are drawn (a filled open path without the edge that closes + /// it in the map). Returns the input's index and the run, going the way the path goes; None + /// when no piece is that close, when the run is not on the outline as drawn, when it would be a + /// whole closed subpath, when it is too short to cut out (see [`po::cut_out`]), or when it is + /// all of the path (a lone line) or leaves no more of it than [`outline_snap`], which leaves + /// nothing to split off. + pub fn stroke_at(&self, outlines: &[PathData], p: Point, tol: f64) -> Option<(usize, SubPath)> { + use kurbo::ParamCurve as _; + let piece = match self.hit(p, Some(tol))? { + Hit::Line(i) => self.lines.get(i), + Hit::Edge(i) => self.edges.get(i), + Hit::Region(_) => None, + }?; + let i = piece.0.key as usize; + let drawn = outlines.get(i)?; + let stops: Vec = drawn.anchors().map(|(.., a)| a.p).collect(); + let snap = outline_snap(drawn); + let mut run = po::run_around(piece.0.path.subpaths.first()?, &stops, p, snap)?; + let n = run.segment_count(); + if n == 0 { + return None; + } + let mid = run.segment(n / 2).eval(0.5); + drawn.nearest(mid).filter(|n| n.4 <= snap)?; + // A run that cannot be cut out, or that is all of the path (such as a lone line) or all + // but a speck of it, leaves nothing to split off. + if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| rest.length() > snap) { + return None; + } + // Reverse the run when the path's parameter goes down along it. + let seg = run.segment(n / 2); + if let (Some(a), Some(b)) = (drawn.nearest(seg.eval(0.25)), drawn.nearest(seg.eval(0.75))) + && a.0 == b.0 + && (b.1 as f64 + b.2) < (a.1 as f64 + a.2) + { + run.reverse(); + } + Some((i, run)) + } + + /// What a Shape Builder gesture at `p` touches: [`Self::hit`], except that while erasing, a + /// point in a region that is no more than `2 * piece_tol` across there (see + /// [`Self::narrow_at`]) touches the region and not the line piece or edge beside it. + pub fn builder_hit(&self, p: Point, piece_tol: Option) -> Option { + match (piece_tol, self.piece_hit(p, piece_tol)) { + (Some(tol), Some(piece)) => Some(match self.region_at(p) { + Some(r) if self.narrow_at(r, p, tol) => Hit::Region(r), + _ => piece, + }), + (_, piece) => piece.or_else(|| self.region_at(p).map(Hit::Region)), + } + } + + /// Is region `r` no more than `2 * tol` across at `p`, measured from a point of its outline + /// within `tol` of `p`, through `p`, to its outline on the other side? Every point on that line + /// is then within `tol` of the outline. The points tried are the point of the outline nearest + /// `p` and each point of a segment where the segment is square to the line to `p`, so a + /// point near the end of a thin bar measures across the bar from its sides as well as along it + /// from the end, and splitting a segment in two does not change the answer. False when `p` is + /// on the outline or more than `tol` from it. + fn narrow_at(&self, r: usize, p: Point, tol: f64) -> bool { + use kurbo::{ParamCurve as _, ParamCurveNearest as _}; + let Some((_, bp, _)) = self.regions.get(r) else { return false }; + // Each segment with the parameter, point and distance of its point nearest `p`. + let near: Vec<(kurbo::PathSeg, f64, Point, f64)> = bp + .segments() + .map(|seg| { + let n = seg.nearest(p, 1e-6); + (seg, n.t, seg.eval(n.t), n.distance_sq.sqrt()) + }) + .collect(); + if !near.iter().all(|&(.., d)| d.is_finite() && d > 1e-9) { + return false; + } + let closest = near.iter().map(|&(.., d)| d).fold(f64::INFINITY, f64::min); + // A line from `p` no longer than `2 * tol` only meets segments that close to `p`. + let reach: Vec = near.iter().filter(|&&(.., d)| d <= 2.0 * tol).map(|&(seg, ..)| seg).collect(); + // A segment's end is its nearest point when the segment turns away from `p` or stops + // short of the point square to it; there, only the nearest point of all the outline and + // an end where the segment is square to the line to `p` count. + let square = |seg: kurbo::PathSeg, t: f64, n: Point| { + use kurbo::ParamCurveDeriv as _; + let dir = match seg { + kurbo::PathSeg::Line(l) => l.p1 - l.p0, + kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), + kurbo::PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + }; + let v = p - n; + dir.hypot() > 1e-12 && dir.dot(v).abs() <= 1e-6 * dir.hypot() * v.hypot() + }; + let from = + |&&(seg, t, n, d): &&(kurbo::PathSeg, f64, Point, f64)| d <= tol && (d == closest || (t > 1e-9 && t < 1.0 - 1e-9) || square(seg, t, n)); + near.iter().filter(from).any(|&(_, _, n, d)| { + let line = kurbo::Line::new(p, p + (p - n) * ((2.0 * tol - d) / d)); + reach.iter().any(|seg| seg.intersect_line(line).iter().any(|x| x.line_t > 1e-9 && x.line_t <= 1.0)) + }) + } + + /// What a gesture along the polyline `pts` touches, in order: [`Self::hit`] at each vertex and + /// at the points [`Self::probes`] picks between them, so it touches every region it crosses, + /// however narrow. + pub fn hits_along(&self, pts: &[Point], piece_tol: Option) -> Touched { + self.along(pts, piece_tol, Self::hit) + } + + /// What a Shape Builder gesture along the polyline `pts` touches: [`Self::hits_along`] with + /// [`Self::builder_hit`], so an erasing gesture also touches each region it goes into where the + /// region is no more than `2 * piece_tol` across. + pub fn builder_hits_along(&self, pts: &[Point], piece_tol: Option) -> Touched { + self.along(pts, piece_tol, Self::builder_hit) + } + + fn along(&self, pts: &[Point], piece_tol: Option, hit: fn(&Self, Point, Option) -> Option) -> Touched { + let mut touched = Touched::default(); + let mut last: Option = None; + for &p in pts { + if let Some(a) = last { + for q in self.probes(a, p, piece_tol) { + touched.add(hit(self, q, piece_tol)); + } + } + touched.add(hit(self, p, piece_tol)); + last = Some(p); + } + touched + } + + /// The points strictly between `a` and `b`, in order, that [`Self::hits_along`] tests: the + /// middle of each stretch between two places where the segment crosses a region's outline, and + /// with `piece_tol` (erasing) those crossings, the places where it crosses a line piece or an + /// edge, and points `piece_tol / 2` apart (at most 4,000 per segment), so the line pieces and + /// edges the segment crosses or passes close to are touched too. + fn probes(&self, a: Point, b: Point, piece_tol: Option) -> Vec { + let len = a.distance(b); + if !(len.is_finite() && len > 0.0) { + return vec![]; + } + let line = kurbo::Line::new(a, b); + let lb = Rect::from_points(a, b); + let mut cross: Vec = vec![]; + for (_, bp, rb) in &self.regions { + if rb.x0 > lb.x1 || rb.x1 < lb.x0 || rb.y0 > lb.y1 || rb.y1 < lb.y0 { + continue; + } + for seg in bp.segments() { + cross.extend(seg.intersect_line(line).iter().map(|x| x.line_t)); + } + } + cross.retain(|t| t.is_finite() && *t > 0.0 && *t < 1.0); + cross.sort_by(f64::total_cmp); + // Crossings closer than this along the segment are one place. + let gap = 1e-9 / len; + let mut ts: Vec = vec![]; + let mut from = 0.0; + for &t in cross.iter().chain([1.0].iter()) { + if t - from > gap { + ts.push((from + t) / 2.0); + from = t; + } + } + if let Some(tol) = piece_tol { + ts.extend(cross.iter().copied()); + for (piece, pb) in self.lines.iter().chain(&self.edges) { + if pb.x0 > lb.x1 || pb.x1 < lb.x0 || pb.y0 > lb.y1 || pb.y1 < lb.y0 { + continue; + } + for seg in piece.path.to_bezpath().segments() { + ts.extend(seg.intersect_line(line).iter().map(|x| x.line_t).filter(|t| t.is_finite() && *t > 0.0 && *t < 1.0)); + } + } + let n = ((len / (tol / 2.0).max(1e-3)).ceil() as usize).min(4000); + ts.extend((1..n).map(|k| k as f64 / n as f64)); + ts.sort_by(f64::total_cmp); + } + ts.into_iter().map(|t| a.lerp(b, t)).collect() + } + + /// The regions just either side of the middle of `piece` (None: outside every region). + pub fn sides(&self, piece: &po::Shape) -> [Option; 2] { + let Some(sp) = piece.path.subpaths.first() else { return [None; 2] }; + let n = sp.segment_count(); + if n == 0 { + return [None; 2]; + } + let c = sp.segment(n / 2); + use kurbo::{ParamCurve as _, ParamCurveDeriv as _}; + let (m, d) = (c.eval(0.5), c.deriv().eval(0.5).to_vec2()); + let len = d.hypot(); + if !(len.is_finite() && len > 0.0) { + return [None; 2]; + } + // A little to each side: inside the faces next to it, which share it exactly. + let off = d.turn_90() * (0.05f64.min(0.05 * c.p0.distance(c.p3)).max(1e-6) / len); + [m + off, m - off].map(|q| self.region_at(q)) + } + + /// Does line piece `i` run between two different regions of `regions` (an edge that merging or + /// deleting them removes)? A piece with the same region on both sides, such as the end of a + /// line inside a shape, bounds nothing and stays. + pub fn line_between(&self, i: usize, regions: &[usize]) -> bool { + self.lines.get(i).is_some_and(|(l, _)| matches!(self.sides(l), [Some(a), Some(b)] if a != b && regions.contains(&a) && regions.contains(&b))) + } + + /// The edges `touched` deletes: those that bound none of its regions (deleting a region takes + /// its edges with it, so an erase drag that crosses an edge into a region deletes the region). + pub fn erased_edges(&self, touched: &Touched) -> Vec { + let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r))); + touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect() + } +} + +/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Touched { + pub regions: Vec, + pub lines: Vec, + pub edges: Vec, +} + +impl Touched { + pub fn add(&mut self, hit: Option) { + let (list, i) = match hit { + Some(Hit::Region(i)) => (&mut self.regions, i), + Some(Hit::Line(i)) => (&mut self.lines, i), + Some(Hit::Edge(i)) => (&mut self.edges, i), + None => return, + }; + if !list.contains(&i) { + list.push(i); + } + } + + /// Adds what `other` touched, in its order. + pub fn extend(&mut self, other: &Touched) { + let regions = other.regions.iter().map(|&i| Hit::Region(i)); + for hit in regions.chain(other.lines.iter().map(|&i| Hit::Line(i))).chain(other.edges.iter().map(|&i| Hit::Edge(i))) { + self.add(Some(hit)); + } + } + + pub fn is_empty(&self) -> bool { + self.regions.is_empty() && self.lines.is_empty() && self.edges.is_empty() + } +} + +/// Selected roots sorted back → front in document order. +pub fn sorted_roots(doc: &Document, ids: &[NodeId]) -> Vec { + let mut v: Vec<(Vec, NodeId)> = ids.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect(); + v.sort(); + v.dedup_by(|a, b| a.1 == b.1); + v.into_iter().map(|(_, id)| id).collect() +} + +/// Sample points along a polyline every `step` units (always including the vertices). +pub fn sample_polyline(pts: &[Point], step: f64) -> Vec { + let mut out = vec![]; + let step = step.max(1e-3); + for (i, &p) in pts.iter().enumerate() { + if i > 0 { + let a = pts[i - 1]; + let n = ((a.distance(p) / step).ceil() as usize).min(4000); + for k in 1..n { + out.push(a.lerp(p, k as f64 / n as f64)); + } + } + out.push(p); + } + out +} + +/// Diagonal hatch lines filling `path` (the Shape Builder's grey mesh highlight), `gap` apart. +pub fn hatch(path: &BezPath, gap: f64) -> BezPath { + let mut out = BezPath::new(); + let b = path.bounding_box(); + if b.width() <= 0.0 || b.height() <= 0.0 { + return out; + } + // Rotate by -45° so the hatch lines become horizontal scanlines. + let s = std::f64::consts::FRAC_1_SQRT_2; + let rot = |p: Point| Point::new((p.x + p.y) * s, (p.y - p.x) * s); + let unrot = |p: Point| Point::new((p.x - p.y) * s, (p.x + p.y) * s); + let mut edges: Vec<(Point, Point)> = vec![]; + let mut first = Point::ZERO; + let mut last = Point::ZERO; + kurbo::flatten(path.iter(), gap.max(1e-3) * 0.1, |el| match el { + kurbo::PathEl::MoveTo(p) => { + if last != first { + edges.push((rot(last), rot(first))); + } + first = p; + last = p; + } + kurbo::PathEl::LineTo(p) => { + edges.push((rot(last), rot(p))); + last = p; + } + kurbo::PathEl::ClosePath => { + if last != first { + edges.push((rot(last), rot(first))); + } + last = first; + } + _ => {} + }); + if last != first { + edges.push((rot(last), rot(first))); + } + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for (a, c) in &edges { + y0 = y0.min(a.y.min(c.y)); + y1 = y1.max(a.y.max(c.y)); + } + let gap = gap.max((y1 - y0) / 400.0).max(1e-6); + let mut y = (y0 / gap).floor() * gap + gap * 0.5; + let mut xs: Vec = vec![]; + while y < y1 { + xs.clear(); + for (a, c) in &edges { + if (a.y <= y) != (c.y <= y) { + xs.push(a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)); + } + } + xs.sort_by(f64::total_cmp); + for pair in xs.as_chunks::<2>().0 { + out.move_to(unrot(Point::new(pair[0], y))); + out.line_to(unrot(Point::new(pair[1], y))); + } + y += gap; + } + out +} From 443990eb62f08199c5090b27eef06d973f4e0c4d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:39:00 +0000 Subject: [PATCH 134/199] refactor(tools): move distort/perspective.rs items to distort/perspective/{tools}.rs --- crates/tools/src/distort/perspective.rs | 353 +----------------- crates/tools/src/distort/perspective/tools.rs | 348 +++++++++++++++++ 2 files changed, 354 insertions(+), 347 deletions(-) create mode 100644 crates/tools/src/distort/perspective/tools.rs diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index f7bcacf43..aeaef8461 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -79,6 +79,12 @@ pub type WidgetGeom = (Vec<(Plane, [Point; 4])>, (Point, f64)); pub use vectorcraft_geom::Homography; +mod tools; + +#[cfg(test)] +use tools::resize_cursor; +pub use tools::{PerspectiveGridTool, PerspectiveSelectionTool}; + fn two() -> u8 { 2 } @@ -960,353 +966,6 @@ fn drag_handle(press: &Press, p: Point, mods: Mods) -> PerspectiveGrid { if n.validate().is_ok() { n } else { g.clone() } } -#[derive(Default)] -pub struct PerspectiveGridTool { - drag: Option, - /// Dragging a plane widget. - plane: Option, -} - -impl Tool for PerspectiveGridTool { - fn id(&self) -> &'static str { - "perspectiveGrid" - } - fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let p = ev.pos; - let g = PerspectiveGrid::current(cx.doc); - match ev.kind { - PointerKind::Down => { - let mut pre = vec![]; - if !g.visible { - pre.push(Action::Exec("perspective.grid.show".into(), json!({"visible": true}))); - } - if let Some(pl) = g.widget_hit_cx(cx, p) { - pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); - return pre; - } - if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) { - self.plane = Some(d); - pre.push(Action::Begin("Move Plane".into())); - return pre; - } - let tol = cx.tol(6.0); - if !g.locked - && let Some((h, _)) = handles(&g, cx.tol(1.0)) - .into_iter() - .filter(|(_, q)| q.distance(p) <= tol) - .min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))) - { - let steps = cell_steps(&g, cx.tol(1.0)); - self.drag = Some(Press { handle: h, grid: g, at: p, steps }); - pre.push(Action::Begin("Edit Perspective Grid".into())); - } - pre - } - PointerKind::Drag if self.plane.is_some() => self.plane.and_then(|d| d.preview(&g, p, ev.mods)).into_iter().collect(), - PointerKind::Drag => match &self.drag { - Some(press) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(press, p, ev.mods).definition_json())], - None => vec![], - }, - PointerKind::Up => match (self.drag.take(), self.plane.take()) { - (None, None) => vec![], - _ => vec![Action::Commit], - }, - PointerKind::DoubleClick => PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect(), - _ => vec![], - } - } - fn overlays(&self, cx: &ToolContext) -> Vec { - let g = PerspectiveGrid::current(cx.doc); - // The grid's handles hide with it. - if !g.visible { - return vec![]; - } - let ink = [0x20, 0x20, 0x20]; - handles(&g, cx.tol(1.0)) - .into_iter() - .map(|(h, p)| match h { - Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: ink, filled: true, size: 7.0 }, - Handle::GroundLeft | Handle::GroundRight => Overlay::Anchor { p, color: ink, filled: true, size: 5.0 }, - Handle::Cell => Overlay::Handle { p, color: ink }, - _ => Overlay::Anchor { p, color: ink, filled: false, size: 6.0 }, - }) - .collect() - } - fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { - let g = PerspectiveGrid::current(cx.doc); - let tol = cx.tol(6.0); - if self.plane.is_some() || PlaneDrag::hit(&g, tol, p).is_some() { - return Cursor::Move; - } - if g.locked { - return Cursor::Arrow; - } - match handles(&g, cx.tol(1.0)).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) { - Some(Handle::Horizon | Handle::Height | Handle::Cell) => Cursor::ResizeV, - Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH, - Some(_) => Cursor::Move, - None => Cursor::Arrow, - } - .turned(cx.view_angle()) - } - fn busy(&self) -> bool { - self.drag.is_some() || self.plane.is_some() - } - fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - match (self.drag.take(), self.plane.take()) { - (None, None) => vec![], - _ => vec![Action::Commit], - } - } -} - -// ---------- Perspective Selection tool ---------- - -#[derive(Clone, Debug, Default)] -enum SelDrag { - #[default] - Idle, - /// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along - /// the plane's normal (5 pressed while dragging); `copy`: Alt at the last event. - Move { ids: Vec, from: Point, last: Point, began: bool, perp: bool, copy: bool }, - /// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`). - Scale { ids: Vec, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle }, - /// Dragging a plane widget. - Plane(PlaneDrag), -} - -/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move -/// perpendicular to the plane) and scales (bounding-box handles; Shift proportional, Alt from the -/// centre) objects within their perspective planes; the arrow keys nudge them in perspective. -#[derive(Default)] -pub struct PerspectiveSelectionTool { - drag: SelDrag, - measure: Option<(Point, String)>, -} - -/// The selection's perspective bounding box: plane, depth, plane-space rect and the homography -/// that draws it. -fn selection_persp_box(cx: &ToolContext, g: &PerspectiveGrid) -> Option<(Plane, f64, Rect, Homography)> { - let (plane, depth, rect) = g.plane_bounds(cx.doc, &cx.selection.objects)?; - Some((plane, depth, rect, g.homography_at(plane, depth)?)) -} - -/// The handle of the perspective box (`rect` drawn by `h`) within `tol` of `p`. -fn persp_handle_at(rect: Rect, h: &Homography, p: Point, tol: f64) -> Option { - BoxHandle::ALL.into_iter().find(|k| h.apply(k.pos(rect)).is_some_and(|q| q.distance(p) <= tol)) -} - -/// The resize cursor that fits a handle seen in direction `d` from the box's centre. -fn resize_cursor(d: Vec2) -> Cursor { - // Counter-clockwise degrees on screen (y down), folded to 0..180. - let a = (-d.y).atan2(d.x).to_degrees().rem_euclid(180.0); - match a { - a if !(22.5..157.5).contains(&a) => Cursor::ResizeH, - a if a < 67.5 => Cursor::ResizeNeSw, - a if a < 112.5 => Cursor::ResizeV, - _ => Cursor::ResizeNwSe, - } -} - -impl PerspectiveSelectionTool { - /// The `perspective.move` preview for the move drag (and its measurement label). - fn move_preview(&mut self, cx: &ToolContext) -> Option { - let SelDrag::Move { ids, from, last, perp, copy, .. } = &self.drag else { return None }; - let g = PerspectiveGrid::current(cx.doc); - let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp}); - if g.plane != Plane::None { - v["plane"] = json!(g.plane.id()); - } - let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); - self.measure = at.and_then(|(pl, depth)| { - let text = if *perp { - format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth)) - } else { - let d = g.plane_delta(pl, depth, *from, *last)?; - cx.offset_label(d.x, d.y) - }; - Some((*last, text)) - }); - Some(Action::Preview("perspective.move".into(), v)) - } -} - -impl Tool for PerspectiveSelectionTool { - fn id(&self) -> &'static str { - "perspectiveSelection" - } - fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let (p, m) = (ev.pos, ev.mods); - let g = PerspectiveGrid::current(cx.doc); - match (ev.kind, std::mem::take(&mut self.drag)) { - (PointerKind::Down, _) => { - self.measure = None; - if let Some(pl) = g.widget_hit_cx(cx, p) { - return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))]; - } - if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) { - self.drag = SelDrag::Plane(d); - return vec![Action::Begin("Move Plane".into())]; - } - if let Some((plane, depth, rect, h)) = selection_persp_box(cx, &g) - && let Some(handle) = persp_handle_at(rect, &h, p, cx.tol(5.0)) - { - self.drag = SelDrag::Scale { ids: cx.selection.objects.clone(), plane, depth, rect, handle }; - return vec![Action::Begin("Scale in Perspective".into())]; - } - let Some(hit) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else { - return vec![Action::Exec("select.set".into(), json!({"ids": []}))]; - }; - let top = hit.top_object(cx.isolation); - let mut acts = vec![]; - let ids = if cx.selection.objects.contains(&top) { - cx.selection.objects.clone() - } else { - acts.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); - vec![top] - }; - // Selecting an object in perspective makes its plane the active one. - if let Some((pl, _)) = g.attachment_of(cx.doc, top) - && pl != g.plane - { - acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); - } - self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt }; - acts - } - (PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => { - if !began && p.distance(from) < cx.tol(3.0) { - self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt }; - return vec![]; - } - let mut out = vec![]; - if !began { - out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into())); - } - self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt }; - out.extend(self.move_preview(cx)); - out - } - (PointerKind::Drag, SelDrag::Scale { ids, plane, depth, rect, handle }) => { - let q = g.homography_at(plane, depth).and_then(|h| h.inverse()).and_then(|hi| hi.apply(p)); - self.drag = SelDrag::Scale { ids: ids.clone(), plane, depth, rect, handle }; - let Some(q) = q else { return vec![] }; - let a = scale_for_drag(rect, handle, q, m.shift, m.alt); - let nr = a.transform_rect_bbox(rect); - self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); - vec![Action::Preview("perspective.transform".into(), json!({"ids": crate::json_ids(&ids), "matrix": crate::select::matrix_json(a)}))] - } - (PointerKind::Up, SelDrag::Move { began, .. }) => { - self.measure = None; - if began { vec![Action::Commit] } else { vec![] } - } - (PointerKind::Drag, SelDrag::Plane(d)) => { - self.drag = SelDrag::Plane(d); - d.preview(&g, p, m).into_iter().collect() - } - (PointerKind::Up, SelDrag::Scale { .. } | SelDrag::Plane(_)) => { - self.measure = None; - vec![Action::Commit] - } - (PointerKind::DoubleClick, d) => { - self.drag = d; - if let Some(w) = PlaneDrag::hit(&g, cx.tol(6.0), p) { - return vec![w.options()]; - } - // Type in perspective: Edit Text, then type. - match vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation)) { - Some(id) if cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) => { - vec![Action::Exec("perspective.editText".into(), json!({"id": id.0})), Action::SwitchTool("type".into())] - } - _ => vec![], - } - } - (_, d) => { - self.drag = d; - vec![] - } - } - } - fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { - if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) { - *perp = !*perp; - return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; - } - let (dx, dy) = match key { - ToolKey::Left => (-1, 0), - ToolKey::Right => (1, 0), - ToolKey::Up => (0, -1), - ToolKey::Down => (0, 1), - _ => return vec![], - }; - if !self.claims_key(cx, key) { - return vec![]; - } - vec![Action::Exec("perspective.nudge".into(), json!({"dx": dx, "dy": dy, "big": mods.shift, "copy": mods.alt}))] - } - fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { - // The arrows nudge a selection in perspective. - matches!(key, ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down) - && matches!(self.drag, SelDrag::Idle) - && cx.selection.anchors.is_empty() - && { - let g = PerspectiveGrid::current(cx.doc); - cx.selection.objects.iter().any(|id| g.attachment_of(cx.doc, *id).is_some()) - } - } - fn overlays(&self, cx: &ToolContext) -> Vec { - // The selection's bounding box in perspective, in its plane's colour, with its handles. - let g = PerspectiveGrid::current(cx.doc); - let mut out = vec![]; - if let Some((plane, _, rect, h)) = selection_persp_box(cx, &g) { - let corners: Vec = crate::xform::rect_corners(rect).iter().filter_map(|q| h.apply(*q)).collect(); - if corners.len() == 4 { - out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color: plane.color(), width: 1.0, dashed: false }); - } - for k in BoxHandle::ALL { - if let Some(q) = h.apply(k.pos(rect)) { - out.push(Overlay::Anchor { p: q, color: plane.color(), filled: false, size: 6.0 }); - } - } - } - if cx.measurement_labels - && let Some((p, t)) = &self.measure - { - out.push(Overlay::Measure { p: *p, text: t.clone() }); - } - out - } - fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { - let g = PerspectiveGrid::current(cx.doc); - let handle = match &self.drag { - SelDrag::Scale { handle, .. } => Some(*handle), - SelDrag::Move { began: true, .. } | SelDrag::Plane(_) => return Cursor::Move, - _ => None, - }; - if PlaneDrag::hit(&g, cx.tol(6.0), p).is_some() { - return Cursor::Move; - } - if let Some((_, _, rect, h)) = selection_persp_box(cx, &g) - && let Some(k) = handle.or_else(|| persp_handle_at(rect, &h, p, cx.tol(5.0))) - && let (Some(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center())) - { - // The handle's direction on screen: the view turns it clockwise. - return resize_cursor((Affine::rotate(cx.view_angle().to_radians()) * (a - c).to_point()).to_vec2()); - } - Cursor::Arrow - } - fn busy(&self) -> bool { - !matches!(self.drag, SelDrag::Idle) - } - fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - self.measure = None; - match std::mem::take(&mut self.drag) { - SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } | SelDrag::Plane(_) => vec![Action::Commit], - _ => vec![], - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs new file mode 100644 index 000000000..9e563f00c --- /dev/null +++ b/crates/tools/src/distort/perspective/tools.rs @@ -0,0 +1,348 @@ +use super::*; + +#[derive(Default)] +pub struct PerspectiveGridTool { + drag: Option, + /// Dragging a plane widget. + plane: Option, +} + +impl Tool for PerspectiveGridTool { + fn id(&self) -> &'static str { + "perspectiveGrid" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + let g = PerspectiveGrid::current(cx.doc); + match ev.kind { + PointerKind::Down => { + let mut pre = vec![]; + if !g.visible { + pre.push(Action::Exec("perspective.grid.show".into(), json!({"visible": true}))); + } + if let Some(pl) = g.widget_hit_cx(cx, p) { + pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); + return pre; + } + if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + self.plane = Some(d); + pre.push(Action::Begin("Move Plane".into())); + return pre; + } + let tol = cx.tol(6.0); + if !g.locked + && let Some((h, _)) = handles(&g, cx.tol(1.0)) + .into_iter() + .filter(|(_, q)| q.distance(p) <= tol) + .min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))) + { + let steps = cell_steps(&g, cx.tol(1.0)); + self.drag = Some(Press { handle: h, grid: g, at: p, steps }); + pre.push(Action::Begin("Edit Perspective Grid".into())); + } + pre + } + PointerKind::Drag if self.plane.is_some() => self.plane.and_then(|d| d.preview(&g, p, ev.mods)).into_iter().collect(), + PointerKind::Drag => match &self.drag { + Some(press) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(press, p, ev.mods).definition_json())], + None => vec![], + }, + PointerKind::Up => match (self.drag.take(), self.plane.take()) { + (None, None) => vec![], + _ => vec![Action::Commit], + }, + PointerKind::DoubleClick => PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect(), + _ => vec![], + } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + let g = PerspectiveGrid::current(cx.doc); + // The grid's handles hide with it. + if !g.visible { + return vec![]; + } + let ink = [0x20, 0x20, 0x20]; + handles(&g, cx.tol(1.0)) + .into_iter() + .map(|(h, p)| match h { + Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: ink, filled: true, size: 7.0 }, + Handle::GroundLeft | Handle::GroundRight => Overlay::Anchor { p, color: ink, filled: true, size: 5.0 }, + Handle::Cell => Overlay::Handle { p, color: ink }, + _ => Overlay::Anchor { p, color: ink, filled: false, size: 6.0 }, + }) + .collect() + } + fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + let g = PerspectiveGrid::current(cx.doc); + let tol = cx.tol(6.0); + if self.plane.is_some() || PlaneDrag::hit(&g, tol, p).is_some() { + return Cursor::Move; + } + if g.locked { + return Cursor::Arrow; + } + match handles(&g, cx.tol(1.0)).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) { + Some(Handle::Horizon | Handle::Height | Handle::Cell) => Cursor::ResizeV, + Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH, + Some(_) => Cursor::Move, + None => Cursor::Arrow, + } + .turned(cx.view_angle()) + } + fn busy(&self) -> bool { + self.drag.is_some() || self.plane.is_some() + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + match (self.drag.take(), self.plane.take()) { + (None, None) => vec![], + _ => vec![Action::Commit], + } + } +} + +// ---------- Perspective Selection tool ---------- + +#[derive(Clone, Debug, Default)] +enum SelDrag { + #[default] + Idle, + /// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along + /// the plane's normal (5 pressed while dragging); `copy`: Alt at the last event. + Move { ids: Vec, from: Point, last: Point, began: bool, perp: bool, copy: bool }, + /// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`). + Scale { ids: Vec, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle }, + /// Dragging a plane widget. + Plane(PlaneDrag), +} + +/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move +/// perpendicular to the plane) and scales (bounding-box handles; Shift proportional, Alt from the +/// centre) objects within their perspective planes; the arrow keys nudge them in perspective. +#[derive(Default)] +pub struct PerspectiveSelectionTool { + drag: SelDrag, + measure: Option<(Point, String)>, +} + +/// The selection's perspective bounding box: plane, depth, plane-space rect and the homography +/// that draws it. +fn selection_persp_box(cx: &ToolContext, g: &PerspectiveGrid) -> Option<(Plane, f64, Rect, Homography)> { + let (plane, depth, rect) = g.plane_bounds(cx.doc, &cx.selection.objects)?; + Some((plane, depth, rect, g.homography_at(plane, depth)?)) +} + +/// The handle of the perspective box (`rect` drawn by `h`) within `tol` of `p`. +fn persp_handle_at(rect: Rect, h: &Homography, p: Point, tol: f64) -> Option { + BoxHandle::ALL.into_iter().find(|k| h.apply(k.pos(rect)).is_some_and(|q| q.distance(p) <= tol)) +} + +/// The resize cursor that fits a handle seen in direction `d` from the box's centre. +pub(super) fn resize_cursor(d: Vec2) -> Cursor { + // Counter-clockwise degrees on screen (y down), folded to 0..180. + let a = (-d.y).atan2(d.x).to_degrees().rem_euclid(180.0); + match a { + a if !(22.5..157.5).contains(&a) => Cursor::ResizeH, + a if a < 67.5 => Cursor::ResizeNeSw, + a if a < 112.5 => Cursor::ResizeV, + _ => Cursor::ResizeNwSe, + } +} + +impl PerspectiveSelectionTool { + /// The `perspective.move` preview for the move drag (and its measurement label). + fn move_preview(&mut self, cx: &ToolContext) -> Option { + let SelDrag::Move { ids, from, last, perp, copy, .. } = &self.drag else { return None }; + let g = PerspectiveGrid::current(cx.doc); + let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp}); + if g.plane != Plane::None { + v["plane"] = json!(g.plane.id()); + } + let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); + self.measure = at.and_then(|(pl, depth)| { + let text = if *perp { + format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth)) + } else { + let d = g.plane_delta(pl, depth, *from, *last)?; + cx.offset_label(d.x, d.y) + }; + Some((*last, text)) + }); + Some(Action::Preview("perspective.move".into(), v)) + } +} + +impl Tool for PerspectiveSelectionTool { + fn id(&self) -> &'static str { + "perspectiveSelection" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let (p, m) = (ev.pos, ev.mods); + let g = PerspectiveGrid::current(cx.doc); + match (ev.kind, std::mem::take(&mut self.drag)) { + (PointerKind::Down, _) => { + self.measure = None; + if let Some(pl) = g.widget_hit_cx(cx, p) { + return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))]; + } + if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + self.drag = SelDrag::Plane(d); + return vec![Action::Begin("Move Plane".into())]; + } + if let Some((plane, depth, rect, h)) = selection_persp_box(cx, &g) + && let Some(handle) = persp_handle_at(rect, &h, p, cx.tol(5.0)) + { + self.drag = SelDrag::Scale { ids: cx.selection.objects.clone(), plane, depth, rect, handle }; + return vec![Action::Begin("Scale in Perspective".into())]; + } + let Some(hit) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else { + return vec![Action::Exec("select.set".into(), json!({"ids": []}))]; + }; + let top = hit.top_object(cx.isolation); + let mut acts = vec![]; + let ids = if cx.selection.objects.contains(&top) { + cx.selection.objects.clone() + } else { + acts.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); + vec![top] + }; + // Selecting an object in perspective makes its plane the active one. + if let Some((pl, _)) = g.attachment_of(cx.doc, top) + && pl != g.plane + { + acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); + } + self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt }; + acts + } + (PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => { + if !began && p.distance(from) < cx.tol(3.0) { + self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt }; + return vec![]; + } + let mut out = vec![]; + if !began { + out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into())); + } + self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt }; + out.extend(self.move_preview(cx)); + out + } + (PointerKind::Drag, SelDrag::Scale { ids, plane, depth, rect, handle }) => { + let q = g.homography_at(plane, depth).and_then(|h| h.inverse()).and_then(|hi| hi.apply(p)); + self.drag = SelDrag::Scale { ids: ids.clone(), plane, depth, rect, handle }; + let Some(q) = q else { return vec![] }; + let a = scale_for_drag(rect, handle, q, m.shift, m.alt); + let nr = a.transform_rect_bbox(rect); + self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); + vec![Action::Preview("perspective.transform".into(), json!({"ids": crate::json_ids(&ids), "matrix": crate::select::matrix_json(a)}))] + } + (PointerKind::Up, SelDrag::Move { began, .. }) => { + self.measure = None; + if began { vec![Action::Commit] } else { vec![] } + } + (PointerKind::Drag, SelDrag::Plane(d)) => { + self.drag = SelDrag::Plane(d); + d.preview(&g, p, m).into_iter().collect() + } + (PointerKind::Up, SelDrag::Scale { .. } | SelDrag::Plane(_)) => { + self.measure = None; + vec![Action::Commit] + } + (PointerKind::DoubleClick, d) => { + self.drag = d; + if let Some(w) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + return vec![w.options()]; + } + // Type in perspective: Edit Text, then type. + match vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation)) { + Some(id) if cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) => { + vec![Action::Exec("perspective.editText".into(), json!({"id": id.0})), Action::SwitchTool("type".into())] + } + _ => vec![], + } + } + (_, d) => { + self.drag = d; + vec![] + } + } + } + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) { + *perp = !*perp; + return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; + } + let (dx, dy) = match key { + ToolKey::Left => (-1, 0), + ToolKey::Right => (1, 0), + ToolKey::Up => (0, -1), + ToolKey::Down => (0, 1), + _ => return vec![], + }; + if !self.claims_key(cx, key) { + return vec![]; + } + vec![Action::Exec("perspective.nudge".into(), json!({"dx": dx, "dy": dy, "big": mods.shift, "copy": mods.alt}))] + } + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + // The arrows nudge a selection in perspective. + matches!(key, ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down) + && matches!(self.drag, SelDrag::Idle) + && cx.selection.anchors.is_empty() + && { + let g = PerspectiveGrid::current(cx.doc); + cx.selection.objects.iter().any(|id| g.attachment_of(cx.doc, *id).is_some()) + } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + // The selection's bounding box in perspective, in its plane's colour, with its handles. + let g = PerspectiveGrid::current(cx.doc); + let mut out = vec![]; + if let Some((plane, _, rect, h)) = selection_persp_box(cx, &g) { + let corners: Vec = crate::xform::rect_corners(rect).iter().filter_map(|q| h.apply(*q)).collect(); + if corners.len() == 4 { + out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color: plane.color(), width: 1.0, dashed: false }); + } + for k in BoxHandle::ALL { + if let Some(q) = h.apply(k.pos(rect)) { + out.push(Overlay::Anchor { p: q, color: plane.color(), filled: false, size: 6.0 }); + } + } + } + if cx.measurement_labels + && let Some((p, t)) = &self.measure + { + out.push(Overlay::Measure { p: *p, text: t.clone() }); + } + out + } + fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + let g = PerspectiveGrid::current(cx.doc); + let handle = match &self.drag { + SelDrag::Scale { handle, .. } => Some(*handle), + SelDrag::Move { began: true, .. } | SelDrag::Plane(_) => return Cursor::Move, + _ => None, + }; + if PlaneDrag::hit(&g, cx.tol(6.0), p).is_some() { + return Cursor::Move; + } + if let Some((_, _, rect, h)) = selection_persp_box(cx, &g) + && let Some(k) = handle.or_else(|| persp_handle_at(rect, &h, p, cx.tol(5.0))) + && let (Some(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center())) + { + // The handle's direction on screen: the view turns it clockwise. + return resize_cursor((Affine::rotate(cx.view_angle().to_radians()) * (a - c).to_point()).to_vec2()); + } + Cursor::Arrow + } + fn busy(&self) -> bool { + !matches!(self.drag, SelDrag::Idle) + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.measure = None; + match std::mem::take(&mut self.drag) { + SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } | SelDrag::Plane(_) => vec![Action::Commit], + _ => vec![], + } + } +} From 9eafb73e3ff1d7617cc252d7844ed1fdf0b31c57 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:39:00 +0000 Subject: [PATCH 135/199] refactor(tools): move guides.rs items to guides/{snap,targets}.rs --- crates/tools/src/guides.rs | 1559 +--------------------------- crates/tools/src/guides/snap.rs | 455 ++++++++ crates/tools/src/guides/targets.rs | 1096 +++++++++++++++++++ 3 files changed, 1563 insertions(+), 1547 deletions(-) create mode 100644 crates/tools/src/guides/snap.rs create mode 100644 crates/tools/src/guides/targets.rs diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index e05f7dceb..e793f0424 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -20,6 +20,18 @@ use vectorcraft_text::{FontDb, layout as layout_text}; use crate::bbox::Handle; use crate::{Overlay, ScreenFrame, ToolContext}; +mod snap; +mod targets; + +pub(crate) use snap::hold_on_path; +pub use snap::{ + DEFAULT_CONSTRUCTION_ANGLES, DrawSnap, HandleSnap, Leave, PointSnap, construction_angles, distance_guides, snap_draw, snap_pick, square, +}; +use targets::Kind; +pub use targets::Targets; +#[cfg(test)] +use targets::{lines_near, object_boxes}; + pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc]; /// How Smart Guides look (Preferences › Smart Guides › Display Options): their colour, and whether @@ -46,57 +58,6 @@ impl GuideStyle { } } -#[derive(Clone, Copy, Debug, PartialEq)] -enum Kind { - Anchor, - Center, - Edge, - Artboard, - Bleed, - Guide, - Glyph, -} - -impl Kind { - fn label(self) -> &'static str { - match self { - Kind::Anchor => "anchor", - Kind::Center => "center", - Kind::Edge => "path", - Kind::Artboard => "artboard", - Kind::Bleed => "bleed", - Kind::Guide => "guide", - Kind::Glyph => "glyph", - } - } -} - -/// Snap targets gathered from the document (excluding the objects being edited). -#[derive(Default)] -pub struct Targets { - points: Vec<(Point, Kind)>, - xs: Vec<(f64, Point, Kind)>, - ys: Vec<(f64, Point, Kind)>, - /// Path segments and their control bounds, which a point lands on ("path"): gathered only for - /// dragged anchors ([`Self::for_anchor_drag`]). - segments: Vec<(Rect, PathSeg)>, - /// Ruler guides, which pull a point into line where they run (Snap to Point). - rulers: Vec, - /// The bounds of the objects a moved selection spaces itself evenly with (Spacing Guides): - /// gathered only for moves ([`Self::for_move`]). - boxes: Vec, - /// Line Extension: the lines a single dragged anchor keeps to, along the straight segments - /// it ends, as the end that stays put and the unit direction towards the dragged one - /// ([`Self::for_anchor_drag`]). - extensions: Vec<(Point, Vec2)>, - /// Snap to Tangent and Snap to Perpendicular: a drawn line from an anchor lands where it - /// grazes a curve, or meets a segment square ([`Self::touching`]). - tangent: bool, - perpendicular: bool, - /// How the guides these targets produce look. - style: GuideStyle, -} - /// A ruler guide as a snap target. struct Ruler { vertical: bool, @@ -121,1501 +82,5 @@ impl Ruler { } } -/// How many anchors, and how many segments, the targets gather at most. -const BUDGET: usize = 20_000; - -/// What [`Targets::collect_inner`] gathers from each path besides its anchors and bounds. -#[derive(Clone, Copy, PartialEq)] -enum Gather { - /// Nothing more: a moved or resized selection lines up with bounds. - Bounds, - /// Its segments, which a dragged anchor lands on. - Segments, - /// Its segments, and its anchors as lines too: a drawn point lines up with them. - Drawing, -} - -/// The entries of `sorted` (ascending by `key`) whose key is within `tol` of `v`, found by -/// bisection. -fn within(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] { - let lo = sorted.partition_point(|e| key(e) < v - tol); - let hi = sorted.partition_point(|e| key(e) <= v + tol); - sorted.get(lo..hi).unwrap_or_default() -} - -/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those: -/// one pass, however many are skipped. A Shaper Group's original shapes, its first child, are -/// not shown and are skipped too. -fn walk_but<'a>(nodes: &'a [Arc], skip: &HashSet, f: &mut impl FnMut(&'a Node)) { - for n in nodes.iter().filter(|n| !skip.contains(&n.id)) { - f(n); - if let Some(ch) = n.children() { - walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), skip, f); - } - } -} - -/// The lines of `lines` (sorted by where they run) within `tol` of `v`. -fn lines_near(lines: &[(f64, Point, Kind)], v: f64, tol: f64) -> &[(f64, Point, Kind)] { - within(lines, |l| l.0, v, tol) -} - -/// `r`'s extent along the x axis (`x`) or the y axis. -fn span(r: &Rect, x: bool) -> (f64, f64) { - if x { (r.x0, r.x1) } else { (r.y0, r.y1) } -} - -/// Along axis `x`, does dragging `handle` move the box's minimum edge (true) or maximum (false)? -fn resize_moves_min_edge(handle: Handle, x: bool) -> Option { - let (ax, ay) = handle.axes(); - if x && !ax || !x && !ay { - return None; - } - if x { - Some(matches!(handle, Handle::Left | Handle::TopLeft | Handle::BottomLeft)) - } else { - Some(matches!(handle, Handle::Top | Handle::TopLeft | Handle::TopRight)) - } -} - -/// The objects a moved selection (`moving`) spaces itself evenly with, by their bounds: each -/// visible object on a layer as a whole (a group too), and inside a group holding some of the -/// moving objects its other objects, but a Shaper Group's original shapes. At most [`BUDGET`]. -fn object_boxes(doc: &Document, moving: &[NodeId]) -> Vec { - fn push(nodes: &[Arc], moving: &HashSet, holders: &HashSet, out: &mut Vec) { - for n in nodes.iter().filter(|n| n.visible && !moving.contains(&n.id)) { - if out.len() >= BUDGET { - return; - } - match n.children() { - Some(ch) if n.is_layer() || holders.contains(&n.id) => { - push(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), moving, holders, out) - } - _ => out.extend(n.geometric_bounds()), - } - } - } - let holders: HashSet = moving.iter().filter_map(|id| doc.ancestry(*id)).flatten().collect(); - let mut out = vec![]; - push(&doc.layers, &moving.iter().copied().collect(), &holders, &mut out); - out -} - -impl Targets { - pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option) -> Self { - Self::collect_inner(doc, exclude, None, visible, Gather::Bounds) - } - - /// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection, - /// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides › - /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). - pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { - let mut t = Self::collect(cx.doc, exclude, None).styled(cx); - if cx.spacing_guides { - t.boxes = object_boxes(cx.doc, &cx.selection.objects); - } - t - } - - /// Targets for scaling the selection: the other art, styled as `cx` asks, and with Smart Guides › - /// Spacing Guides the neighbours whose gaps the moving edge should match ([`Self::snap_scale`]). - pub fn for_scale(cx: &ToolContext) -> Self { - Self::for_move(cx, &cx.selection.objects) - } - - /// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its - /// anchors, centres, edges and path segments, its anchors in line too. The anchors and - /// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets. - pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option, glyphs: bool) -> Self { - let mut t = Self::collect_inner(doc, exclude, None, visible, Gather::Drawing); - t.push_paths(doc, exclude, true); - if glyphs { - t.push_glyphs(doc, exclude, visible); - } - t.index() - } - - /// Targets for a dragged direction handle of path `id`: the other art, and the anchors of `id` - /// (its bounds and the segments through the handle move with it, so they would chase it). - pub fn for_handle(doc: &Document, id: NodeId) -> Self { - let mut t = Self::collect_inner(doc, &[id], None, None, Gather::Bounds); - t.push_paths(doc, &[id], false); - t.index() - } - - /// The anchors of the paths `ids` as targets, on them and in line, and their segments too - /// when `segments`: at most [`BUDGET`] of each. - fn push_paths(&mut self, doc: &Document, ids: &[NodeId], segments: bool) { - let (mut anchors, mut segment_budget) = (BUDGET, if segments { BUDGET } else { 0 }); - for path in ids.iter().filter_map(|id| doc.node(*id)?.path_data()) { - for sp in &path.subpaths { - for a in sp.anchors.iter().take(anchors) { - self.push_anchor(a.p); - anchors -= 1; - } - self.push_segments(sp, |_| false, &mut segment_budget); - } - } - } - - /// Sort the targets so that [`within`] finds those near a point by bisection: the anchors - /// and centres by x, the lines by where they run. Every constructor ends with it (the sort - /// is stable: of targets alike, the first gathered still wins). - fn index(mut self) -> Self { - self.points.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); - self.xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - self.ys.sort_by(|a, b| a.0.total_cmp(&b.0)); - self - } - - /// Anchor `a` as a target: a point lands on it and lines up with it. - fn push_anchor(&mut self, a: Point) { - self.points.push((a, Kind::Anchor)); - self.xs.push((a.x, a, Kind::Anchor)); - self.ys.push((a.y, a, Kind::Anchor)); - } - - /// Live-text baselines and glyph bounds (View › Snap to Glyph). - fn push_glyphs(&mut self, doc: &Document, exclude: &[NodeId], visible: Option) { - let skip: HashSet = exclude.iter().copied().collect(); - let db = FontDb::global(); - let kind = Kind::Glyph; - let mut budget = BUDGET / 4; - walk_but(&doc.layers, &skip, &mut |n| { - if budget == 0 || !n.visible { - return; - } - let NodeKind::Text(t) = &n.kind else { return }; - let lay = layout_text(db, t); - let ink = t.xf.transform_rect_bbox(lay.bounds); - if !ink.is_finite() || ink.width() <= 0.0 || ink.height() <= 0.0 { - return; - } - if let Some(v) = visible - && ink.intersect(v).area() <= 0.0 - && !v.contains(ink.center()) - { - return; - } - let to_doc = |p: Point| t.xf * p; - for line in &lay.lines { - let at = to_doc(Point::new(line.x0, line.baseline)); - self.ys.push((at.y, at, kind)); - } - for g in &lay.glyphs { - if budget == 0 { - break; - } - let bb = g.outline.bounding_box(); - if !(bb.width() > 0.0 && bb.height() > 0.0) { - continue; - } - let r = t.xf.transform_rect_bbox(bb); - for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { - self.xs.push((p.x, p, kind)); - self.ys.push((p.y, p, kind)); - } - budget -= 1; - } - }); - } - - /// The same targets drawing their guides as the Smart Guides preferences in `cx` ask - /// ([`GuideStyle::of`]). - pub fn styled(mut self, cx: &ToolContext) -> Self { - self.style = GuideStyle::of(cx); - self - } - - /// An alignment line from `a` to `b`, unless Alignment Guides are off. - fn line(&self, a: Point, b: Point) -> Option { - self.style.lines.then_some(Overlay::Line { a, b, color: self.style.color, dashed: false }) - } - - /// A label `text` at `p`, unless Anchor/Path Labels are off. - fn label(&self, p: Point, text: &str) -> Option { - self.style.labels.then(|| Overlay::Label { p, text: text.into(), color: self.style.color }) - } - - /// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the - /// art that moves along with it). - pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self { - Self::collect_inner(doc, exclude, Some(index), None, Gather::Bounds) - } - - /// Targets for dragging the direct-selected anchors of `sel` (and its objects selected as a - /// whole): the other art, its path segments too, and the anchors and segments that stay put on - /// the paths being reshaped (their bounds move along, so they are no targets). A single anchor - /// also keeps to the extensions of the straight segments it ends (#2120). - pub fn for_anchor_drag(doc: &Document, sel: &Selection) -> Self { - let mut t = Self::collect_inner(doc, &sel.objects, None, None, Gather::Segments); - let (mut anchors, mut segments) = (BUDGET, BUDGET); - // With several anchors moving, the one under the pointer is not known here, and the - // others' extensions don't run through it. - let single = sel.objects.len() <= 1 && sel.anchors.values().map(|s| s.len()).sum::() == 1; - for (id, set) in &sel.anchors { - let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue }; - for (si, sp) in path.subpaths.iter().enumerate() { - let moving = |ai: usize| set.contains(&(si, ai)); - for a in sp.anchors.iter().enumerate().filter(|(ai, _)| !moving(*ai)).map(|(_, a)| a.p).take(anchors) { - t.push_anchor(a); - anchors -= 1; - } - t.push_segments(sp, moving, &mut segments); - if single { - t.push_extensions(sp, moving); - } - } - } - t.index() - } - - /// The extensions of the straight segments of `sp` with one end among the anchors `moving` - /// accepts and the other staying put. Horizontal and vertical ones are left out: lining up - /// with the other end already keeps the point on them, and says "align". - fn push_extensions(&mut self, sp: &SubPath, moving: impl Fn(usize) -> bool) { - let n = sp.anchors.len(); - for seg in (0..sp.segment_count()).filter(|&seg| moving(seg) != moving((seg + 1) % n) && sp.segment_is_line(seg)) { - let c = sp.segment(seg); - let (fixed, dragged) = if moving(seg) { (c.p3, c.p0) } else { (c.p0, c.p3) }; - let d = dragged - fixed; - let len = d.hypot(); - if len.is_finite() && d.x.abs() > 1e-9 * len && d.y.abs() > 1e-9 * len { - self.extensions.push((fixed, d / len)); - } - } - } - - /// The segments of `sp` that stay put while the anchors `moving` accepts move, as targets, at - /// most `budget` of them (counted down). - fn push_segments(&mut self, sp: &SubPath, moving: impl Fn(usize) -> bool, budget: &mut usize) { - let n = sp.anchors.len(); - for seg in 0..sp.segment_count() { - if *budget == 0 { - return; - } - if moving(seg) || moving((seg + 1) % n) { - continue; - } - let c = sp.segment(seg); - let bounds = Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3); - // A straight one as a line, so the point lands on it exactly. - self.segments.push((bounds, if sp.segment_is_line(seg) { PathSeg::Line(Line::new(c.p0, c.p3)) } else { PathSeg::Cubic(c) })); - *budget -= 1; - } - } - - fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option, visible: Option, gather: Gather) -> Self { - let mut t = Targets::default(); - let add_rect = |t: &mut Targets, r: Rect, kind: Kind| { - let c = r.center(); - for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { - t.xs.push((p.x, p, kind)); - t.ys.push((p.y, p, kind)); - } - // A bleed shares its artboard's centre. - if kind != Kind::Bleed { - t.points.push((c, Kind::Center)); - t.xs.push((c.x, c, Kind::Center)); - t.ys.push((c.y, c, Kind::Center)); - } - }; - for (_, ab) in doc.artboards.iter().enumerate().filter(|(i, _)| Some(*i) != skip_artboard) { - add_rect(&mut t, ab.rect, Kind::Artboard); - if doc.setup.has_bleed() { - add_rect(&mut t, doc.setup.bleed_rect(ab.rect), Kind::Bleed); - } - } - let (mut budget, mut segment_budget) = (BUDGET, BUDGET); - walk_but(&doc.layers, &exclude.iter().copied().collect(), &mut |n| { - if budget == 0 || n.is_container() || !n.visible { - return; - } - let Some(b) = n.geometric_bounds() else { return }; - if let Some(v) = visible - && b.intersect(v).area() <= 0.0 - && !v.contains(b.center()) - { - return; - } - if let NodeKind::Path { path, .. } = &n.kind { - for (_, _, a) in path.anchors() { - if gather == Gather::Drawing { - t.push_anchor(a.p); - } else { - t.points.push((a.p, Kind::Anchor)); - } - budget = budget.saturating_sub(1); - } - if gather != Gather::Bounds { - for sp in &path.subpaths { - t.push_segments(sp, |_| false, &mut segment_budget); - } - } - } - add_rect(&mut t, b, Kind::Edge); - }); - t.index() - } - - /// Only these targets' anchors pull (View → Snap to Point). - pub(crate) fn anchors_only(mut self) -> Self { - self.points.retain(|(_, k)| *k == Kind::Anchor); - self.xs.clear(); - self.ys.clear(); - self.segments.clear(); - self.extensions.clear(); - self - } - - /// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and - /// unlocked, as the selection tools pick them) pull the pointer into line where they run. - fn for_snap_to_point(self, cx: &ToolContext) -> Self { - self.anchors_only().with_rulers(cx) - } - - /// These targets and the ruler guides, when they are shown (and so unlocked to the selection - /// tools): they pull a point into line where they run. - fn with_rulers(mut self, cx: &ToolContext) -> Self { - if cx.guides { - self.rulers = cx - .doc - .guides - .iter() - .filter(|g| cx.doc.guide_shown(g)) - .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) - .collect(); - } - self - } - - /// The ruler guides alone ([`Self::with_rulers`]): what a dragged selection's grabbed point - /// still snaps to with View → Snap to Point when Smart Guides line the selection up. - pub(crate) fn rulers(cx: &ToolContext) -> Self { - Self::default().styled(cx).with_rulers(cx) - } - - /// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart - /// Guides off: the anchors of the art but `exclude` and the ruler guides. - pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option { - (cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx)) - } - - /// The gridlines (one every `step` from the page's origin, as - /// [`vectorcraft_geom::snap::snap_to_grid`] places them) at and either side of the nearest one - /// to each of `points`, as lines to line up with. - fn grid_lines(points: impl IntoIterator, step: f64) -> Self { - let mut t = Targets::default(); - let line = |v: f64, k: i32| vectorcraft_geom::snap::snap_to_grid(v, step, 0.0) + f64::from(k) * step; - for p in points { - for k in -1..=1 { - let (x, y) = (line(p.x, k), line(p.y, k)); - t.xs.push((x, Point::new(x, p.y), Kind::Guide)); - t.ys.push((y, Point::new(p.x, y), Kind::Guide)); - } - } - t.index() - } - - /// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds - /// reach into `near`. - fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self { - let mut t = Targets::default(); - for n in roots.iter().filter_map(|id| doc.node(*id)) { - crate::draw2::walk_shown(n, &mut |c| { - if c.is_container() || !c.visible { - return; - } - let Some(b) = c.geometric_bounds() else { return }; - if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 { - return; - } - if let NodeKind::Path { path, .. } = &c.kind { - t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor))); - } - t.points.push((b.center(), Kind::Center)); - }); - } - t.index() - } - - /// Snap a single point: onto the nearest anchor or centre, else onto the nearest segment, else - /// into line with targets. Returns the snapped point and guide overlays. - pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec) { - self.snap_from(p, None, tol) - } - - /// Snap a drawn point `p` as [`Self::snap_point`] does, and when it leaves `from` (the anchor - /// before it, a shape's start): with Shift held it keeps to its angles round it, sliding along - /// that way into line with the nearest target; else, before lining up, it lands on a - /// construction guide through it, where a target's line crosses the guide if one does nearby. - pub fn snap_from(&self, p: Point, from: Option<&Leave>, tol: f64) -> (Point, Vec) { - if let Some(f) = from - && let Some(q) = f.constrained(p) - { - return match self.slide(f.at, q - f.at, q, tol, true) { - Some((q, target)) => (q, self.lined_up(q, target)), - None => (q, vec![]), - }; - } - if let Some((q, k)) = self.nearest_point(p, tol) { - return (q, self.label(q, k.label()).into_iter().collect()); - } - if let Some(q) = self.crossing(p, tol) { - return (q, self.label(q, "intersect").into_iter().collect()); - } - if let Some(f) = from.filter(|f| f.contact) - && let Some((q, perpendicular, dir)) = self.touching(f.at, p, tol) - { - return (q, self.touch_guides(f.at, q, perpendicular, dir, tol)); - } - if let Some(q) = self.on_segment(p, tol) { - return (q, self.label(q, Kind::Edge.label()).into_iter().collect()); - } - // A construction guide through the point it leaves, else a dragged segment's extension: - // it lands on the guide, sliding into line with a target crossing it nearby. - if let Some((at, dir, q)) = from.and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))).or_else(|| self.extension(p, tol)) { - let construction = |q: Point| Overlay::Line { a: at, b: q, color: self.style.color, dashed: false }; - return match self.slide(at, dir, q, tol, false) { - Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()), - None => (q, vec![construction(q)]), - }; - } - let mut out = p; - let mut ov = vec![]; - // The targets' lines along one axis and the ruler guides running past `p`, the nearest - // within reach of `v`. - let nearest = |lines: &[(f64, Point, Kind)], vertical: bool, v: f64| { - let rulers = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(p, tol)); - lines_near(lines, v, tol) - .iter() - .copied() - .chain(rulers) - .filter(|(t, ..)| (t - v).abs() <= tol) - .min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs())) - }; - let mut aligned = false; - if let Some((x, from, _)) = nearest(&self.xs, true, p.x) { - out.x = x; - aligned = true; - ov.extend(self.line(from, Point::new(x, p.y))); - } - if let Some((y, from, _)) = nearest(&self.ys, false, p.y) { - out.y = y; - aligned = true; - ov.extend(self.line(from, Point::new(p.x, y))); - } - if aligned { - ov.extend(self.label(out, "align")); - } - (out, ov) - } - - /// Where a line from `at` through a point near `p` touches a target segment: square to it - /// (`perpendicular`, the foot of the perpendicular from `at`) or just grazing a curve - /// (`tangent`), within `tol` of `p`. The point, whether it is the perpendicular one, and the - /// segment's direction there. Targets gather segments for drawn points and dragged anchors only. - fn touching(&self, at: Point, p: Point, tol: f64) -> Option<(Point, bool, Vec2)> { - if !(self.tangent || self.perpendicular) || ![at.x, at.y, p.x, p.y, tol].iter().all(|v| v.is_finite()) { - return None; - } - let mut best: Option<(f64, Point, bool, Vec2)> = None; - // Every segment in reach is tried: their order is the document's, not the distance. - for (_, seg) in self.segments.iter().filter(|(b, _)| b.inflate(tol, tol).contains(p)) { - let curved = !matches!(seg, PathSeg::Line(_)); - for (perpendicular, wanted) in [(true, self.perpendicular && !curved), (false, self.tangent && curved)] { - if !wanted { - continue; - } - for t in touch_params(seg, at, perpendicular) { - let (q, d) = (seg.eval(t), seg_direction(seg, t)); - let dist = q.distance(p); - if dist <= tol && q.distance(at) > tol && best.is_none_or(|b| dist < b.0) { - best = Some((dist, q, perpendicular, d)); - } - } - } - } - best.map(|(_, q, perpendicular, d)| (q, perpendicular, d)) - } - - /// The guides of a point landed where the line from `at` touches a segment at `q` running - /// along `dir`: the line itself, a square corner mark for the perpendicular, and the label. - fn touch_guides(&self, at: Point, q: Point, perpendicular: bool, dir: Vec2, tol: f64) -> Vec { - let mut out: Vec = self.line(at, q).into_iter().collect(); - let (u, v) = (at - q, dir); - if perpendicular && self.style.lines && u.hypot2() > 1e-18 && v.hypot2() > 1e-18 { - let (u, v) = (u / u.hypot(), v / v.hypot()); - let s = tol * 1.5; - let mut mark = BezPath::new(); - mark.move_to(q + u * s); - mark.line_to(q + u * s + v * s); - mark.line_to(q + v * s); - out.push(Overlay::Path { path: mark, color: self.style.color, width: 1.0, dashed: false }); - } - out.extend(self.label(q, if perpendicular { "perpendicular" } else { "tangent" })); - out - } - - /// The segment extension nearest `p`, within `tol` of it (and `p` farther than that from the - /// end it starts at): that end, its direction and where `p` lands on it. Where `p` is nearer - /// the horizontal or the vertical through that end there is none: lining up with it ("align") - /// is what straightens a segment that is nearly level or upright. - fn extension(&self, p: Point, tol: f64) -> Option<(Point, Vec2, Point)> { - self.extensions - .iter() - .filter(|(at, _)| at.distance(p) > tol) - .map(|&(at, d)| (at, d, at + d * (p - at).dot(d))) - .map(|(at, d, q)| (at, d, q, q.distance(p))) - .filter(|(at, .., dist)| *dist <= tol && (p.x - at.x).abs().min((p.y - at.y).abs()) > *dist) - .min_by(|a, b| a.3.total_cmp(&b.3)) - .map(|(at, d, q, _)| (at, d, q)) - } - - /// The anchor or centre nearest `p`, within `tol`. - /// Is a target point (an anchor, a centre) within `tol` of `p`? - pub(crate) fn has_point_near(&self, p: Point, tol: f64) -> bool { - self.nearest_point(p, tol).is_some() - } - - fn nearest_point(&self, p: Point, tol: f64) -> Option<(Point, Kind)> { - within(&self.points, |q| q.0.x, p.x, tol) - .iter() - .copied() - .filter(|(q, _)| q.distance(p) <= tol) - .min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) - } - - /// Slide `q`, on the line through `at` along `dir` (beyond `at` only, for a `ray`), to where - /// the nearest target line crosses it, at most twice `tol` away: where it lands and the - /// target's own point (where its line starts). - fn slide(&self, at: Point, dir: Vec2, q: Point, tol: f64, ray: bool) -> Option<(Point, Point)> { - if !dir.hypot2().is_finite() || dir.hypot2() <= 1e-18 { - return None; - } - let reach = 2.0 * tol; - let mut best: Option<(f64, Point, Point)> = None; - for (vertical, lines, d, a, v) in [(true, &self.xs, dir.x, at.x, q.x), (false, &self.ys, dir.y, at.y, q.y)] { - if d.abs() <= 1e-9 { - continue; - } - // The ruler guides that way count as lines too (#812). - let rulers: Vec<(f64, Point, Kind)> = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(q, reach)).collect(); - // A line that far across the way crosses it at least that far along. - for (t, from, _) in lines_near(lines, v, reach).iter().chain(&rulers) { - let s = (t - a) / d; - // Exactly on the line, whatever the rounding along the way. - let hit = if vertical { Point::new(*t, at.y + dir.y * s) } else { Point::new(at.x + dir.x * s, *t) }; - let moved = hit.distance(q); - if moved <= reach && (!ray || s > 0.0) && best.is_none_or(|b| moved < b.0) { - best = Some((moved, hit, *from)); - } - } - } - best.map(|(_, hit, from)| (hit, from)) - } - - /// The guides of a point `q` slid into line with the target at `from`: the line from it and - /// "align", or the target's own label when `q` sits right on it. - fn lined_up(&self, q: Point, from: Point) -> Vec { - if let Some((on, k)) = self.nearest_point(q, 1e-9) { - return self.label(on, k.label()).into_iter().collect(); - } - self.line(from, q).into_iter().chain(self.label(q, "align")).collect() - } - - /// The nearest point within `tol` of `p` on a target segment. - fn on_segment(&self, p: Point, tol: f64) -> Option { - self.segments - .iter() - .filter(|(b, _)| b.inflate(tol, tol).contains(p)) - .map(|(_, c)| (c, c.nearest(p, 1e-6))) - .filter(|(_, n)| n.distance_sq <= tol * tol) - .min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq)) - .map(|(c, n)| c.eval(n.t)) - } - - /// Where two target paths, a path and a ruler guide, or two ruler guides cross, the crossing - /// nearest `p` within `tol` ("intersect", #1602). - fn crossing(&self, p: Point, tol: f64) -> Option { - // A ruler guide running past, as a short line through the reach of `p`. - let ruler = |r: &Ruler| { - let (pos, ..) = r.line_at(p, tol)?; - let (across, along) = if r.vertical { (p.x, p.y) } else { (p.y, p.x) }; - let (a, b) = (along - tol, along + tol); - ((pos - across).abs() <= tol) - .then(|| PathSeg::Line(if r.vertical { Line::new((pos, a), (pos, b)) } else { Line::new((a, pos), (b, pos)) })) - }; - let near: Vec = self - .segments - .iter() - .filter(|(b, c)| b.inflate(tol, tol).contains(p) && c.nearest(p, 1e-6).distance_sq <= tol * tol) - .map(|(_, c)| *c) - .chain(self.rulers.iter().filter_map(ruler)) - // shortcut: the first 32 lines in reach are paired, raise it if busier crossings matter - .take(32) - .collect(); - let mut best: Option = None; - for (i, a) in near.iter().enumerate() { - for b in near.iter().skip(i + 1) { - let hits: Vec = match (a, b) { - (PathSeg::Line(l), c) | (c, PathSeg::Line(l)) => c.intersect_line(*l).iter().map(|x| l.eval(x.line_t)).collect(), - _ => meeting(a, b, p).into_iter().collect(), - }; - for q in hits { - if q.distance(p) <= tol && best.is_none_or(|b| q.distance(p) < b.distance(p)) { - best = Some(q); - } - } - } - } - best - } - - /// Snap a ruler guide at `v` (the x of a vertical one, the y of a horizontal one) into line - /// with the nearest edge, centre or anchor within `tol`: where it goes, and a label there. - pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec) { - let along = |q: &Point| if vertical { q.x } else { q.y }; - let lines = if vertical { &self.xs } else { &self.ys }; - let best = lines_near(lines, v, tol) - .iter() - .map(|(t, from, k)| (*t, *from, *k)) - .chain(self.points.iter().map(|(q, k)| (along(q), *q, *k))) - .filter(|(t, ..)| (t - v).abs() <= tol) - .min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs())); - match best { - Some((t, from, k)) => (t, self.label(from, k.label()).into_iter().collect()), - None => (v, vec![]), - } - } - - /// Snap a dragged point that carries others along at `offsets` (a resize handle and the - /// bleed edge beyond it): onto an anchor or centre near the point itself, otherwise into line - /// with targets, each axis on whichever of them comes nearest. - pub fn snap_point_with(&self, p: Point, offsets: &[Vec2], tol: f64) -> (Point, Vec) { - if self.nearest_point(p, tol).is_some() { - return self.snap_point(p, tol); - } - let rects: Vec = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect(); - let (adj, mut ov, aligned) = self.align_rects(&rects, tol); - let out = p + adj; - if aligned.contains(&true) { - ov.extend(self.label(out, "align")); - } - (out, ov) - } - - /// Snap a moving rectangle (selection bounds after a move by `d`): tries its corners/edges/centre. - pub fn snap_rect(&self, r: Rect, tol: f64) -> (Vec2, Vec) { - self.snap_rects(&[r], tol) - } - - /// Snap a point that may only move along `dir` (the grabbed point of a move Shift keeps to its - /// line): onto the point of that line nearest an anchor or center within `tol` of `p`, labeled - /// at the anchor or center, else to where the nearest target line or ruler guide crosses the - /// line, at most twice `tol` away ([`Self::slide`]). Returns where it goes and the guides. - pub(crate) fn snap_point_along(&self, p: Point, dir: Vec2, tol: f64) -> (Point, Vec) { - let l2 = dir.hypot2(); - if !l2.is_finite() || l2 <= 1e-18 { - return (p, vec![]); - } - if let Some((q, k)) = self.nearest_point(p, tol) { - return (p + dir * ((q - p).dot(dir) / l2), self.label(q, k.label()).into_iter().collect()); - } - match self.slide(p, dir, p, tol, false) { - Some((q, from)) => (q, self.lined_up(q, from)), - None => (p, vec![]), - } - } - - /// [`Self::snap_rects`] for rectangles that may only move along `dir` (a move Shift keeps to - /// its line): they slide along it until the edge or center of one of them is in line with a - /// target, within `tol` of it on that axis, the target that needs the shortest way along the - /// line winning. Lining up with nothing, a single rectangle with Spacing Guides slides to where - /// it is spaced evenly along an axis the line runs along. Returns the shift (along `dir`) and - /// the guides. - pub(crate) fn snap_rects_along(&self, rects: &[Rect], dir: Vec2, tol: f64) -> (Vec2, Vec) { - let len = dir.hypot(); - if !len.is_finite() || len <= 1e-9 { - return (Vec2::ZERO, vec![]); - } - let u = dir / len; - // The axes the line runs along: the x axis (`true`) and the y axis, and how far a step of - // one along the line goes on that axis. - let axes = [(true, u.x), (false, u.y)].into_iter().filter(|(_, c)| c.abs() > 1e-9); - // The way along the line, the target line it lines up with, where that line starts, and - // its axis. - let mut best: Option<(f64, f64, Point, bool)> = None; - for (x, c) in axes.clone() { - let lines = if x { &self.xs } else { &self.ys }; - for r in rects { - let vs = if x { [r.x0, r.center().x, r.x1] } else { [r.y0, r.center().y, r.y1] }; - for v in vs { - for (t, from, _) in lines_near(lines, v, tol).iter().filter(|(t, ..)| (t - v).abs() <= tol) { - let s = (t - v) / c; - if best.is_none_or(|b| s.abs() < b.0.abs()) { - best = Some((s, *t, *from, x)); - } - } - } - } - } - if let Some((s, t, from, x)) = best { - let ov = rects.iter().map(|r| *r + u * s).reduce(|a, b| a.union(b)).and_then(|m| { - if x { - self.line(Point::new(t, from.y.min(m.y0)), Point::new(t, from.y.max(m.y1))) - } else { - self.line(Point::new(from.x.min(m.x0), t), Point::new(from.x.max(m.x1), t)) - } - }); - return (u * s, ov.into_iter().collect()); - } - let [r] = rects else { return (Vec2::ZERO, vec![]) }; - if self.boxes.is_empty() { - return (Vec2::ZERO, vec![]); - } - let spaced = - axes.filter_map(|(x, c)| self.even_spacing(*r, x, tol).map(|(shift, _)| (shift / c, x))).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())); - let Some((s, x)) = spaced else { return (Vec2::ZERO, vec![]) }; - let gaps = self.even_spacing(*r + u * s, x, tol).map(|(_, gaps)| gaps).unwrap_or_default(); - (u * s, gaps.into_iter().flat_map(|g| self.gap_marks(g, x, tol)).collect()) - } - - /// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any - /// of them nearest a target, per axis. A single moved selection with Spacing Guides - /// ([`Self::for_move`]) is spaced evenly along an axis that lines up with nothing - /// ([`Self::even_spacing`]). Returns the shift and the guides. - pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec) { - let (mut d, mut ov, aligned) = self.align_rects(rects, tol); - if let [r] = rects - && !self.boxes.is_empty() - { - let moved = *r + d; - let mut spaced = false; - for (x, lined_up) in [true, false].into_iter().zip(aligned) { - if let Some((shift, _)) = self.even_spacing(moved, x, tol).filter(|_| !lined_up) { - *(if x { &mut d.x } else { &mut d.y }) += shift; - spaced = true; - } - } - // The equal gaps, marked where the rectangle ends up. - if spaced { - let done = *r + d; - for (x, lined_up) in [true, false].into_iter().zip(aligned) { - let gaps = if lined_up { None } else { self.even_spacing(done, x, tol) }; - ov.extend(gaps.into_iter().flat_map(|(_, gaps)| gaps).flat_map(|g| self.gap_marks(g, x, tol))); - } - } - } - (d, ov) - } - - /// Spacing Guides along the x axis (`x`) or the y axis for the moved rectangle `r`, among the - /// objects of its row (column): the shift (within `tol`) that leaves it as far from its - /// neighbour before or after it as two other objects of the row are apart, or as far from both - /// neighbours; and the equal gaps then, as (start, end, where across) along that axis. - fn even_spacing(&self, r: Rect, x: bool, tol: f64) -> Option<(f64, Vec<[f64; 3]>)> { - let (r0, r1) = span(&r, x); - let len = r1 - r0; - let (c0, c1) = span(&r, !x); - // The objects beside it, before or after, overlapping it across, by where they start. - let mut row: Vec<(Rect, f64, f64)> = self - .boxes - .iter() - .map(|b| (*b, span(b, x).0, span(b, x).1)) - .filter(|(b, a0, a1)| span(b, !x).0 < c1 && span(b, !x).1 > c0 && (*a1 <= r0 + tol || *a0 >= r1 - tol)) - .collect(); - row.sort_by(|a, b| a.1.total_cmp(&b.1)); - // Where two boxes overlap across: the middle of that (or of the space between them). - let mid = |a: &Rect, b: &Rect| (span(a, !x).0.max(span(b, !x).0) + span(a, !x).1.min(span(b, !x).1)) / 2.0; - // Each object and the nearest one after it, unless the moved rectangle sits between them. - let pairs: Vec<(f64, [f64; 3])> = row - .iter() - .filter_map(|(p, _, p1)| { - let next = row.partition_point(|(_, a0, _)| *a0 < *p1); - let (q, q0, _) = row.get(next)?; - let straddles = *p1 <= r0 + tol && *q0 >= r1 - tol; - (*q0 > *p1 && !straddles).then(|| (q0 - p1, [*p1, *q0, mid(p, q)])) - }) - .collect(); - let before = row.iter().filter(|(_, _, a1)| *a1 <= r0 + tol).max_by(|a, b| a.2.total_cmp(&b.2)); - let after = row.iter().filter(|(_, a0, _)| *a0 >= r1 - tol).min_by(|a, b| a.1.total_cmp(&b.1)); - // Where it could start, and the gap it repeats (None: halfway between its neighbours). - let mut starts: Vec<(f64, Option)> = vec![]; - if let (Some(a), Some(b)) = (before, after) - && b.1 - a.2 > len - { - starts.push(((a.2 + b.1 - len) / 2.0, None)); - } - for (g, _) in &pairs { - starts.extend(before.map(|a| (a.2 + g, Some(*g)))); - starts.extend(after.map(|b| (b.1 - g - len, Some(*g)))); - } - let (start, repeats) = - starts.into_iter().filter(|(t, _)| (t - r0).abs() <= tol).min_by(|a, b| (a.0 - r0).abs().total_cmp(&(b.0 - r0).abs()))?; - let at = r + if x { Vec2::new(start - r0, 0.0) } else { Vec2::new(0.0, start - r0) }; - let (end, mut gaps) = (start + len, vec![]); - if let Some((a, _, a1)) = before - && repeats.is_none_or(|g| (start - a1 - g).abs() <= 1e-9) - { - gaps.push([*a1, start, mid(a, &at)]); - } - if let Some((b, b0, _)) = after - && repeats.is_none_or(|g| (b0 - end - g).abs() <= 1e-9) - { - gaps.push([end, *b0, mid(b, &at)]); - } - if let Some(g) = repeats { - gaps.extend(pairs.iter().filter(|(pg, _)| (pg - g).abs() <= 1e-9).map(|(_, gap)| *gap)); - } - Some((start - r0, gaps)) - } - - /// A Spacing Guides mark: a line across the gap (start, end, where across) along the x axis - /// (`x`) or the y axis, with a tick `tick` long either side of it at each end. - fn gap_marks(&self, [u0, u1, c]: [f64; 3], x: bool, tick: f64) -> [Overlay; 3] { - let pt = |u: f64, c: f64| if x { Point::new(u, c) } else { Point::new(c, u) }; - let line = |a: Point, b: Point| Overlay::Line { a, b, color: self.style.color, dashed: false }; - [line(pt(u0, c), pt(u1, c)), line(pt(u0, c - tick), pt(u0, c + tick)), line(pt(u1, c - tick), pt(u1, c + tick))] - } - - /// [`Self::snap_rects`] before Spacing Guides, also saying whether each axis (x, y) lined up - /// (even with Alignment Guides off, or already in line, when there is no line or shift to - /// tell). - fn align_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec, [bool; 2]) { - let nearest = |targets: &[(f64, Point, Kind)], along: fn(&Rect) -> [f64; 3]| { - rects - .iter() - .flat_map(|r| along(r).into_iter().map(move |v| (v, *r))) - .flat_map(|(v, r)| lines_near(targets, v, tol).iter().map(move |(t, from, _)| (t - v, *from, v, r))) - .filter(|(d, ..)| d.abs() <= tol) - .min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())) - }; - let best_x = nearest(&self.xs, |r| [r.x0, r.center().x, r.x1]); - let best_y = nearest(&self.ys, |r| [r.y0, r.center().y, r.y1]); - let mut d = Vec2::ZERO; - let mut ov = vec![]; - if let Some((dx, from, x, r)) = best_x { - d.x = dx; - let x = x + dx; - let (y0, y1) = (from.y.min(r.y0), from.y.max(r.y1)); - ov.extend(self.line(Point::new(x, y0), Point::new(x, y1))); - } - if let Some((dy, from, y, r)) = best_y { - d.y = dy; - let y = y + dy; - let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1)); - ov.extend(self.line(Point::new(x0, y), Point::new(x1, y))); - } - (d, ov, [best_x.is_some(), best_y.is_some()]) - } - - /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for - /// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own - /// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page - /// (labelled as [`Self::snap_point`] does), else the corners and the side it moves line up with - /// the nearest target or ruler guide ("align"), whatever the box's angle. A handle with one way - /// to go (a side, or a proportional corner) slides along it until one of them is on a target. - pub fn snap_scale(&self, bx: &OrientedBox, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec) { - type Hit = Option<(f64, Point)>; - // Where a snap lands: in line with a target's x and/or y, or on an anchor or centre. - type Snap = (Hit, Hit, Option<(Point, Kind)>); - let r = bx.rect; - let origin = if from_center { r.center() } else { handle.opposite().pos(r) }; - let (d0, [sx, _, _, sy, _, _]) = (handle.pos(r) - origin, a.as_coeffs()); - let (to_doc, to_local) = (bx.to_doc(), bx.to_doc().inverse()); - // A scale that collapses or flips the box is no snap. - let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0; - // The target line along the x axis (`x`: a vertical line) or the y axis nearest `p`, the - // ruler guides running past it included. - let nearest = |p: Point, x: bool| -> Hit { - let (v, ts) = if x { (p.x, &self.xs) } else { (p.y, &self.ys) }; - let rulers = self.rulers.iter().filter(|r| r.vertical == x).filter_map(|r| r.line_at(p, tol)); - lines_near(ts, v, tol) - .iter() - .copied() - .chain(rulers) - .map(|(t, from, _)| (t, from)) - .filter(|(t, _)| (t - v).abs() <= tol) - .min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs())) - }; - let (mut nx, mut ny) = (sx, sy); - let mut snap: Snap = (None, None, None); - if handle.is_corner() && !proportional { - // The corner goes anywhere: onto an anchor or centre, else it takes the target's x, its - // y, or both. - let p = to_doc * (a * handle.pos(r)); - let (hx, hy) = (nearest(p, true), nearest(p, false)); - let on = self.nearest_point(p, tol).map(|(q, k)| (Some((q.x, q)), Some((q.y, q)), Some((q, k)))); - for (tx, ty, at) in on.into_iter().chain([(hx, hy, None), (hx, None, None), (None, hy, None)]) { - if tx.is_none() && ty.is_none() { - continue; - } - let q = to_local * Point::new(tx.map_or(p.x, |h| h.0), ty.map_or(p.y, |h| h.0)); - let (mut cx, mut cy) = ((q.x - origin.x) / d0.x, (q.y - origin.y) / d0.y); - // Square to the page, the axis without a target stays exactly where it was. - if bx.angle == 0.0 { - (cx, cy) = (if tx.is_some() { cx } else { sx }, if ty.is_some() { cy } else { sy }); - } - if valid(cx, sx) && valid(cy, sy) { - (nx, ny, snap) = (cx, cy, (tx, ty, at)); - break; - } - } - } else { - // One parameter `s`: the scale is `c + s·e` on each axis. - let (ax, _) = handle.axes(); - let (e, c, s0) = match (handle.is_corner(), ax, proportional) { - (true, _, _) => ((sx.signum(), sy.signum()), (0.0, 0.0), sx.abs()), - (false, true, true) => ((1.0, sx.signum()), (0.0, 0.0), sx), - (false, true, false) => ((1.0, 0.0), (0.0, 1.0), sx), - (false, false, true) => ((sy.signum(), 1.0), (0.0, 0.0), sy), - (false, false, false) => ((0.0, 1.0), (1.0, 0.0), sy), - }; - // The points that may land on a target: the handle, and the two ends of its side. - let i = handle as usize; - let ends = if handle.is_corner() { [None, None] } else { [Handle::ALL.get((i + 1) % 8), Handle::ALL.get((i + 7) % 8)] }; - // How far the handle travels per unit of `s`: a snap may not drag it far from the - // pointer, as it would for a target the side runs almost along. - let travel = (e.0 * d0.x).hypot(e.1 * d0.y); - // The snap so far: its `s`, whether it only lines up (an anchor or centre comes first), - // how far it moves the handle, and where it lands. - let mut best: Option<(f64, bool, f64, Snap)> = None; - let mut consider = |s: f64, in_line: bool, found: Snap| { - let moved = (s - s0).abs() * travel; - if valid(s, s0) && moved <= 2.0 * tol && best.as_ref().is_none_or(|k| (in_line, moved) < (k.1, k.2)) { - best = Some((s, in_line, moved, found)); - } - }; - for h in std::iter::once(handle).chain(ends.into_iter().flatten().copied()) { - let v = h.pos(r) - origin; - let base = to_doc * (origin + Vec2::new(c.0 * v.x, c.1 * v.y)); - let dir = (to_doc * Point::new(e.0 * v.x, e.1 * v.y)).to_vec2(); - let p = base + dir * s0; - // An anchor or centre beside its way: it stops at the nearest point of the way. - let l2 = dir.hypot2(); - if l2 > 1e-18 - && let Some((q, k)) = self.nearest_point(p, tol) - { - consider((q - base).dot(dir) / l2, false, (None, None, Some((q, k)))); - } - for (is_x, b, d) in [(true, base.x, dir.x), (false, base.y, dir.y)] { - if d.abs() <= 1e-9 { - continue; - } - let Some(hit) = nearest(p, is_x) else { continue }; - consider((hit.0 - b) / d, true, if is_x { (Some(hit), None, None) } else { (None, Some(hit), None) }); - } - } - if let Some((s, _, _, found)) = best { - (nx, ny, snap) = (c.0 + s * e.0, c.1 + s * e.1, found); - } - } - let mut out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2()); - let (hit_x, hit_y, on) = snap; - if let Some((q, k)) = on { - return (out, self.label(q, k.label()).into_iter().collect()); - } - // The lines run from the target's own point across the resized box, on the page. - let mut nr = (to_doc * out).transform_rect_bbox(r); - let mut ov = vec![]; - if let Some((x, from)) = hit_x { - ov.extend(self.line(Point::new(x, from.y.min(nr.y0)), Point::new(x, from.y.max(nr.y1)))); - } - if let Some((y, from)) = hit_y { - ov.extend(self.line(Point::new(from.x.min(nr.x0), y), Point::new(from.x.max(nr.x1), y))); - } - if hit_x.is_some() || hit_y.is_some() { - ov.extend(self.label(to_doc * (out * handle.pos(r)), "align")); - } - if !self.boxes.is_empty() && !from_center { - let aligned = [hit_x.is_some(), hit_y.is_some()]; - for (is_x, lined_up) in [(true, aligned[0]), (false, aligned[1])] { - if lined_up { - continue; - } - let Some(moves_min) = resize_moves_min_edge(handle, is_x) else { continue }; - if let Some((shift, gaps)) = self.even_spacing(nr, is_x, tol) { - let (lo, hi) = span(&nr, is_x); - let len = hi - lo; - let new_len = if moves_min { hi - (lo + shift) } else { hi + shift - lo }; - if len > 1e-9 && new_len > 1e-9 { - let f = new_len / len; - let (ax, ay) = handle.axes(); - if is_x { - nx *= f; - if proportional && ay { - ny *= f; - } - } else { - ny *= f; - if proportional && ax { - nx *= f; - } - } - out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2()); - nr = (to_doc * out).transform_rect_bbox(r); - ov.extend(gaps.into_iter().flat_map(|g| self.gap_marks(g, is_x, tol))); - } - } - } - } - (out, ov) - } -} - -/// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back -/// settles where they meet (none when they don't nearby). -fn meeting(a: &PathSeg, b: &PathSeg, p: Point) -> Option { - let mut q = p; - for _ in 0..32 { - let on_a = a.eval(a.nearest(q, 1e-9).t); - q = b.eval(b.nearest(on_a, 1e-9).t); - if on_a.distance(q) <= 1e-6 { - return Some(q); - } - } - None -} - -/// The direction of `seg` at `t` (not normalised; zero in a cusp). -fn seg_direction(seg: &PathSeg, t: f64) -> Vec2 { - match seg { - PathSeg::Line(l) => l.p1 - l.p0, - PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), - PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), - } -} - -/// The parameters in (0, 1) where the line from `at` to `seg` is square to it (`perpendicular`) -/// or grazes it, found by sampling the curve for a change of sign and bisecting. -fn touch_params(seg: &PathSeg, at: Point, perpendicular: bool) -> Vec { - const STEPS: usize = 24; - let f = |t: f64| { - let (v, d) = (seg.eval(t) - at, seg_direction(seg, t)); - if perpendicular { v.dot(d) } else { v.cross(d) } - }; - let mut out = vec![]; - let mut prev = (0.0, f(0.0)); - for i in 1..=STEPS { - let t = i as f64 / STEPS as f64; - let cur = (t, f(t)); - if prev.1.is_finite() && cur.1.is_finite() && prev.1 * cur.1 < 0.0 { - let (mut lo, mut hi) = (prev, cur); - for _ in 0..40 { - let mid = 0.5 * (lo.0 + hi.0); - let m = (mid, f(mid)); - if lo.1 * m.1 <= 0.0 { - hi = m; - } else { - lo = m; - } - } - let t = 0.5 * (lo.0 + hi.0); - // A jump through a cusp changes sign without crossing zero: the line must really - // run along (or square to) the curve there. - let (v, d) = (seg.eval(t) - at, seg_direction(seg, t)); - let scale = v.hypot() * d.hypot(); - if scale > 1e-12 && f(t).abs() <= 1e-3 * scale { - out.push(t); - } - } - prev = cur; - } - out -} - -/// Where a press at `p` takes hold of `path` by its outline: the point of the path nearest `p`, -/// when it is within `reach`, and on a straight segment exactly on its line. A path dragged by a -/// press on its stroke or beside its edge snaps by that point of the path, so it is the path that -/// lands on a ruler guide or lines up, not the pressed spot a few pixels beside it (#3701). -pub(crate) fn hold_on_path(path: &vectorcraft_geom::PathData, p: Point, reach: f64) -> Option { - let (si, seg, _, q, d) = path.nearest(p)?; - if d > reach { - return None; - } - let sp = path.subpaths.get(si)?; - if !sp.segment_is_line(seg) { - return Some(q); - } - let c = sp.segment(seg); - let line = Line::new(c.p0, c.p3); - Some(line.eval(line.nearest(p, 0.0).t)) -} - -/// Snap `p` for a drawing tool when smart guides (or grid snapping) are on. -pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec) { - snap_with(cx, p, || Targets::collect(cx.doc, exclude, None)) -} - -/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`, else (Snap -/// to Point) to their anchors and the ruler guides within the Snap to Point distance. -fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (Point, Vec) { - PointSnap::new(cx, targets).snap(cx, p) -} - -/// Snapping for a dragged point, its targets gathered once (when the drag begins): what -/// [`snap_draw`] does for a point at a time. -pub struct PointSnap { - /// The smart guide targets, or with Smart Guides off those of Snap to Point, and how near (in - /// screen pixels) they pull; none with pixels or the grid snapping instead, or nothing on. - targets: Option<(Targets, f64)>, -} - -impl PointSnap { - pub fn new(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self { - let targets = if cx.snap_to_pixel || cx.snap_to_grid { - None - } else if cx.smart_guides { - // Snap to Point keeps pulling points onto the ruler guides with Smart Guides on (#812). - let mut t = targets().styled(cx); - (t.tangent, t.perpendicular) = (cx.snap_tangent, cx.snap_perpendicular); - Some((if cx.snap_to_point { t.with_rulers(cx) } else { t }, cx.snapping_tolerance)) - } else if cx.snap_to_point { - Some((targets().for_snap_to_point(cx), cx.snap_tolerance)) - } else { - None - }; - Self { targets } - } - - /// Where `p` goes, and the guides showing why. - pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec) { - self.snap_from(cx, p, None) - } - - /// Where a drawn point `p` leaving `from` goes ([`Targets::snap_from`]), and the guides - /// showing why. Pixels and the grid round it before Shift turns it onto its angle. - pub fn snap_from(&self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> (Point, Vec) { - let keep = |q: Point| from.and_then(|f| f.constrained(q)).unwrap_or(q); - if cx.snap_to_pixel { - return (keep(Point::new(p.x.round(), p.y.round())), vec![]); - } - if cx.snap_to_grid { - return (keep(vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step())), vec![]); - } - match &self.targets { - Some((t, tol)) => t.snap_from(p, from, cx.tol(*tol)), - None => (keep(p), vec![]), - } - } - - /// Snap a bounding-box resize ([`Targets::snap_scale`]) as [`Self::snap`] snaps a point. With - /// Snap to Pixel on, the corner or side the handle moves lines up with whole pixels, and with - /// Snap to Grid on it lines up with the nearest gridline, however far that is; neither shows - /// guides. Otherwise the handle snaps to the targets [`Self::new`] gathered. - pub fn snap_scale( - &self, - cx: &ToolContext, - bx: &OrientedBox, - handle: Handle, - a: Affine, - proportional: bool, - from_center: bool, - ) -> (Affine, Vec) { - let step = if cx.snap_to_pixel { - Some(1.0) - } else if cx.snap_to_grid { - Some(cx.grid_step()).filter(|s| s.is_finite() && *s > 0.0) - } else { - None - }; - if let Some(step) = step { - let moved = Handle::ALL.map(|h| bx.to_doc() * (a * h.pos(bx.rect))); - // Every point the handle moves is within half a step of a gridline, so a tolerance of - // one step always finds one. - return (Targets::grid_lines(moved, step).snap_scale(bx, handle, a, proportional, from_center, step).0, vec![]); - } - match &self.targets { - Some((t, tol)) => t.snap_scale(bx, handle, a, proportional, from_center, cx.tol(*tol)), - None => (a, vec![]), - } - } -} - -/// The Construction Guides angle set new preferences start with. -pub const DEFAULT_CONSTRUCTION_ANGLES: &str = "90° & 45° Angles"; - -/// The angles (degrees) of the construction guides of a Smart Guides › Angles set: "90°" gives the -/// horizontal and the vertical, "45°" the diagonals, "60°" and "30°" their multiples between those. -pub fn construction_angles(set: &str) -> &'static [f64] { - match set { - "30° Angles" => &[30.0, 60.0, 120.0, 150.0], - "45° Angles" => &[45.0, 135.0], - "60° Angles" => &[60.0, 120.0], - "90° Angles" => &[0.0, 90.0], - "90° & 45° & 30° Angles" => &[0.0, 30.0, 45.0, 60.0, 90.0, 120.0, 135.0, 150.0], - _ => &[0.0, 45.0, 90.0, 135.0], - } -} - -/// How Shift keeps a drawn point to the point it leaves from. -#[derive(Clone, Copy, Debug, PartialEq)] -enum Keep { - /// At steps of `step` degrees counted from `base` (a segment). - Angle { step: f64, base: f64 }, - /// On a diagonal, as far along as the pointer's farther axis (a square's corner). - Square, -} - -/// The point a drawn point leaves from (the anchor before it, a shape's start) and how it keeps -/// to it: with Shift held at fixed angles round it, and onto the construction guides through it -/// (Smart Guides › Construction Guides). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Leave { - pub at: Point, - /// Shift held: how the point keeps to `at`. - shift: Option, - /// The angles of the construction guides through `at` (none with them off). - angles: &'static [f64], - /// A drawn segment: it may land square to a segment or grazing a curve (a box does not). - contact: bool, -} - -impl Leave { - /// A segment from `at` (Pen, Curvature, Line Segment): Shift keeps it at 45° steps from - /// General › Constrain Angle; with Smart Guides on it lands on the construction guides. - pub fn segment(cx: &ToolContext, at: Point, shift: bool) -> Self { - Self { - at, - shift: shift.then_some(Keep::Angle { step: 45.0, base: cx.constrain_angle }), - angles: if cx.smart_guides { cx.construction_angles } else { &[] }, - contact: true, - } - } - - /// A curved source cannot make a line-to-line perpendicular or line-to-curve tangent contact. - pub fn without_contact(mut self) -> Self { - self.contact = false; - self - } - - /// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids, and - /// Artboard, which passes no Shift): Shift keeps the dragged corner on a diagonal, the box a - /// square. - pub fn diagonal(at: Point, shift: bool) -> Self { - Self { at, shift: shift.then_some(Keep::Square), angles: &[], contact: false } - } - - /// Where Shift puts `p`: none without it. - fn constrained(&self, p: Point) -> Option { - let v = p - self.at; - Some(match self.shift? { - Keep::Angle { step, base } => self.at + constrain_angle_from(v, step, base), - Keep::Square => self.at + square(v), - }) - } - - /// The construction guide through `at` nearest `p`, within `tol` of it (and `p` farther than - /// that from `at`, where every guide meets): its direction and where `p` lands on it. - fn construction(&self, p: Point, tol: f64) -> Option<(Vec2, Point)> { - let v = p - self.at; - if v.hypot() <= tol { - return None; - } - self.angles - .iter() - // Counter-clockwise as seen on the y-down page. - .map(|a| Vec2::new(a.to_radians().cos(), -a.to_radians().sin())) - .map(|d| (d, self.at + d * v.dot(d))) - .map(|(d, q)| (d, q, q.distance(p))) - .filter(|(.., dist)| *dist <= tol) - .min_by(|a, b| a.2.total_cmp(&b.2)) - .map(|(d, q, _)| (d, q)) - } -} - -/// `d` made square: as long on both axes as on its longer one, each keeping its sign. -pub fn square(d: Vec2) -> Vec2 { - let s = d.x.abs().max(d.y.abs()); - Vec2::new(s * d.x.signum(), s * d.y.signum()) -} - -/// Smart Guides for the points a drawing tool places: the Pen's and Curvature tool's anchors, -/// the Pencil's ends, the shape tools' start and dragged corner, the Type tool's click and the -/// Artboard tool's new artboard. Each such tool keeps one and asks it for every point, hovering -/// too, so they all snap alike, and shows its [`Self::guides`]. -/// -/// The targets ([`Targets::for_drawing`], the art in the window) are gathered once per document -/// state: hovering gathers them again only after the document, the selection, what is left out, -/// the window or the snapping options changed; a press always gathers them afresh, and the drag -/// it starts keeps them, the document then showing the drag's own previews. -#[derive(Default)] -pub struct DrawSnap { - /// The targets, and what they were gathered for (none: for one drag only). - targets: Option<(Option, PointSnap)>, - guides: Vec, -} - -/// What a [`DrawSnap`]'s targets were gathered for: the document state, the art left out, the -/// window and every option [`PointSnap::new`] reads. -#[derive(PartialEq)] -struct SnapKey { - revision: (u64, u64), - exclude: Vec, - screen: Option, - view: [bool; 7], - snap_to_glyph: bool, - style: GuideStyle, - tolerances: [f64; 2], -} - -impl SnapKey { - fn of(cx: &ToolContext, exclude: &[NodeId]) -> Self { - Self { - revision: cx.revision, - exclude: exclude.to_vec(), - screen: cx.screen, - view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides, cx.snap_tangent, cx.snap_perpendicular], - snap_to_glyph: cx.snap_to_glyph && cx.alignment_guides, - style: GuideStyle::of(cx), - tolerances: [cx.snapping_tolerance, cx.snap_tolerance], - } - } -} - -/// The part of the document the window shows (none headless: all of it). -fn visible(cx: &ToolContext) -> Option { - let s = cx.screen?; - let (w, h) = s.size; - Some(Rect::from_points(s.at(0.0, 0.0), s.at(w, h)).union_pt(s.at(w, 0.0)).union_pt(s.at(0.0, h))) -} - -impl DrawSnap { - /// Snap the pointer hovering at `p` (the art but `exclude` as targets, the anchors of - /// `exclude` still pulling), reusing the targets while nothing they depend on changed. - pub fn hover(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point { - let key = SnapKey::of(cx, exclude); - if self.targets.as_ref().is_none_or(|(k, _)| k.as_ref() != Some(&key)) { - self.gather(cx, key); - } - self.snap(cx, p, from) - } - - /// Snap the point pressed at `p`, the targets gathered afresh: the drag that follows keeps - /// them ([`Self::drag`]). - pub fn press(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point { - self.gather(cx, SnapKey::of(cx, exclude)); - self.snap(cx, p, from) - } - - /// Gather `targets` other than the art for the drag a press starts (a point being dragged - /// leaves itself out); the next hover gathers its own again. - pub fn hold(&mut self, cx: &ToolContext, targets: impl FnOnce() -> Targets) { - self.targets = Some((None, PointSnap::new(cx, targets))); - self.guides.clear(); - } - - /// Snap `p`, dragged since the press, with the targets the press gathered. - pub fn drag(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point { - if self.targets.is_none() { - self.gather(cx, SnapKey::of(cx, &[])); - } - self.snap(cx, p, from) - } - - /// The guides of the last snap. - pub fn guides(&self) -> &[Overlay] { - &self.guides - } - - /// Hide the guides (the gesture ended). - pub fn clear(&mut self) { - self.guides.clear(); - } - - fn gather(&mut self, cx: &ToolContext, key: SnapKey) { - let snap = PointSnap::new(cx, || Targets::for_drawing(cx.doc, &key.exclude, visible(cx), key.snap_to_glyph)); - self.targets = Some((Some(key), snap)); - } - - fn snap(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point { - let (q, guides) = match &self.targets { - Some((_, t)) => t.snap_from(cx, p, from), - None => (p, vec![]), - }; - self.guides = guides; - q - } -} - -/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it -/// snaps as a drawn point does ([`DrawSnap`]). The targets ([`Targets::for_handle`]) are gathered -/// on the first move that needs them and kept for the rest of the drag. -#[derive(Default)] -pub struct HandleSnap(DrawSnap); - -impl HandleSnap { - /// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`, - /// and the guides showing why. - pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec) { - let path = cx.doc.node(id).and_then(|n| n.path_data()); - if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) { - return (a.p + constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]); - } - if self.0.targets.is_none() { - self.0.hold(cx, || Targets::for_handle(cx.doc, id)); - } - let q = self.0.drag(cx, p, None); - (q, self.0.guides().to_vec()) - } -} - -/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the -/// selection or of the object under the pointer, when Snap to Point or Smart Guides is on. -pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec) { - if !(cx.snap_to_point || cx.smart_guides) { - return (p, vec![]); - } - let tol = if cx.smart_guides { cx.snap_tol() } else { cx.tol(cx.snap_tolerance) }; - let mut roots = cx.selection.objects.clone(); - roots.extend(hit_test(cx.doc, p, HitOptions { tol, skip_guides: false, ..cx.hit_options() }).map(|h| h.leaf)); - let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol); - Targets::points_near(cx.doc, &roots, near).styled(cx).snap_point(p, tol) -} - -/// Distance Guides (Smart Guides → Invoke Distance Guides): how far the selection's bounds `from` -/// lie from `to` along each axis, as measured lines ([`Overlay::Distance`]) labelled with their -/// lengths in the General unit. Along an axis on which the two lie apart, one line spans the gap -/// between their facing edges; along one on which they overlap, or one holds the other, one line -/// runs between their left (top) edges and one between their right (bottom) edges. An artboard -/// (`artboard`) is always measured edge to edge: the selection's distance from its left, right, -/// top and bottom edges. Each line runs across the middle of where the two overlap on the other -/// axis, else across the selection's middle, with a dashed line from `to`'s edge out to it when it -/// passes beside `to`. Lines of no length are left out. -pub fn distance_guides(cx: &ToolContext, from: Rect, to: Rect, artboard: bool) -> Vec { - let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()); - if !finite(&from) || !finite(&to) { - return vec![]; - } - let color = cx.smart_guide_color; - let mut out = Vec::new(); - for x in [true, false] { - let ((f0, f1), (t0, t1)) = (span(&from, x), span(&to, x)); - let ((g0, g1), (u0, u1)) = (span(&from, !x), span(&to, !x)); - let (lo, hi) = (g0.max(u0), g1.min(u1)); - let across = if lo <= hi { (lo + hi) / 2.0 } else { (g0 + g1) / 2.0 }; - let pt = |u: f64, c: f64| if x { Point::new(u, c) } else { Point::new(c, u) }; - // Each measured line: from the selection's edge to `to`'s. - let lines: Vec<(f64, f64)> = match (artboard, f1 <= t0, t1 <= f0) { - (false, true, _) => vec![(f1, t0)], - (false, _, true) => vec![(f0, t1)], - _ => vec![(f0, t0), (f1, t1)], - }; - for (fu, tu) in lines { - let length = (tu - fu).abs(); - if length <= 1e-9 { - continue; - } - out.push(Overlay::Distance { a: pt(fu, across), b: pt(tu, across), text: cx.len(length), color }); - let on_edge = across.clamp(u0, u1); - if on_edge != across { - out.push(Overlay::Line { a: pt(tu, on_edge), b: pt(tu, across), color, dashed: true }); - } - } - } - out -} - #[cfg(test)] mod tests; diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs new file mode 100644 index 000000000..673e9f078 --- /dev/null +++ b/crates/tools/src/guides/snap.rs @@ -0,0 +1,455 @@ +use super::targets::{Targets, span}; +use super::*; + +/// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back +/// settles where they meet (none when they don't nearby). +pub(super) fn meeting(a: &PathSeg, b: &PathSeg, p: Point) -> Option { + let mut q = p; + for _ in 0..32 { + let on_a = a.eval(a.nearest(q, 1e-9).t); + q = b.eval(b.nearest(on_a, 1e-9).t); + if on_a.distance(q) <= 1e-6 { + return Some(q); + } + } + None +} + +/// The direction of `seg` at `t` (not normalised; zero in a cusp). +pub(super) fn seg_direction(seg: &PathSeg, t: f64) -> Vec2 { + match seg { + PathSeg::Line(l) => l.p1 - l.p0, + PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), + PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + } +} + +/// The parameters in (0, 1) where the line from `at` to `seg` is square to it (`perpendicular`) +/// or grazes it, found by sampling the curve for a change of sign and bisecting. +pub(super) fn touch_params(seg: &PathSeg, at: Point, perpendicular: bool) -> Vec { + const STEPS: usize = 24; + let f = |t: f64| { + let (v, d) = (seg.eval(t) - at, seg_direction(seg, t)); + if perpendicular { v.dot(d) } else { v.cross(d) } + }; + let mut out = vec![]; + let mut prev = (0.0, f(0.0)); + for i in 1..=STEPS { + let t = i as f64 / STEPS as f64; + let cur = (t, f(t)); + if prev.1.is_finite() && cur.1.is_finite() && prev.1 * cur.1 < 0.0 { + let (mut lo, mut hi) = (prev, cur); + for _ in 0..40 { + let mid = 0.5 * (lo.0 + hi.0); + let m = (mid, f(mid)); + if lo.1 * m.1 <= 0.0 { + hi = m; + } else { + lo = m; + } + } + let t = 0.5 * (lo.0 + hi.0); + // A jump through a cusp changes sign without crossing zero: the line must really + // run along (or square to) the curve there. + let (v, d) = (seg.eval(t) - at, seg_direction(seg, t)); + let scale = v.hypot() * d.hypot(); + if scale > 1e-12 && f(t).abs() <= 1e-3 * scale { + out.push(t); + } + } + prev = cur; + } + out +} + +/// Where a press at `p` takes hold of `path` by its outline: the point of the path nearest `p`, +/// when it is within `reach`, and on a straight segment exactly on its line. A path dragged by a +/// press on its stroke or beside its edge snaps by that point of the path, so it is the path that +/// lands on a ruler guide or lines up, not the pressed spot a few pixels beside it (#3701). +pub(crate) fn hold_on_path(path: &vectorcraft_geom::PathData, p: Point, reach: f64) -> Option { + let (si, seg, _, q, d) = path.nearest(p)?; + if d > reach { + return None; + } + let sp = path.subpaths.get(si)?; + if !sp.segment_is_line(seg) { + return Some(q); + } + let c = sp.segment(seg); + let line = Line::new(c.p0, c.p3); + Some(line.eval(line.nearest(p, 0.0).t)) +} + +/// Snap `p` for a drawing tool when smart guides (or grid snapping) are on. +pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec) { + snap_with(cx, p, || Targets::collect(cx.doc, exclude, None)) +} + +/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`, else (Snap +/// to Point) to their anchors and the ruler guides within the Snap to Point distance. +fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (Point, Vec) { + PointSnap::new(cx, targets).snap(cx, p) +} + +/// Snapping for a dragged point, its targets gathered once (when the drag begins): what +/// [`snap_draw`] does for a point at a time. +pub struct PointSnap { + /// The smart guide targets, or with Smart Guides off those of Snap to Point, and how near (in + /// screen pixels) they pull; none with pixels or the grid snapping instead, or nothing on. + targets: Option<(Targets, f64)>, +} + +impl PointSnap { + pub fn new(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self { + let targets = if cx.snap_to_pixel || cx.snap_to_grid { + None + } else if cx.smart_guides { + // Snap to Point keeps pulling points onto the ruler guides with Smart Guides on (#812). + let mut t = targets().styled(cx); + (t.tangent, t.perpendicular) = (cx.snap_tangent, cx.snap_perpendicular); + Some((if cx.snap_to_point { t.with_rulers(cx) } else { t }, cx.snapping_tolerance)) + } else if cx.snap_to_point { + Some((targets().for_snap_to_point(cx), cx.snap_tolerance)) + } else { + None + }; + Self { targets } + } + + /// Where `p` goes, and the guides showing why. + pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec) { + self.snap_from(cx, p, None) + } + + /// Where a drawn point `p` leaving `from` goes ([`Targets::snap_from`]), and the guides + /// showing why. Pixels and the grid round it before Shift turns it onto its angle. + pub fn snap_from(&self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> (Point, Vec) { + let keep = |q: Point| from.and_then(|f| f.constrained(q)).unwrap_or(q); + if cx.snap_to_pixel { + return (keep(Point::new(p.x.round(), p.y.round())), vec![]); + } + if cx.snap_to_grid { + return (keep(vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step())), vec![]); + } + match &self.targets { + Some((t, tol)) => t.snap_from(p, from, cx.tol(*tol)), + None => (keep(p), vec![]), + } + } + + /// Snap a bounding-box resize ([`Targets::snap_scale`]) as [`Self::snap`] snaps a point. With + /// Snap to Pixel on, the corner or side the handle moves lines up with whole pixels, and with + /// Snap to Grid on it lines up with the nearest gridline, however far that is; neither shows + /// guides. Otherwise the handle snaps to the targets [`Self::new`] gathered. + pub fn snap_scale( + &self, + cx: &ToolContext, + bx: &OrientedBox, + handle: Handle, + a: Affine, + proportional: bool, + from_center: bool, + ) -> (Affine, Vec) { + let step = if cx.snap_to_pixel { + Some(1.0) + } else if cx.snap_to_grid { + Some(cx.grid_step()).filter(|s| s.is_finite() && *s > 0.0) + } else { + None + }; + if let Some(step) = step { + let moved = Handle::ALL.map(|h| bx.to_doc() * (a * h.pos(bx.rect))); + // Every point the handle moves is within half a step of a gridline, so a tolerance of + // one step always finds one. + return (Targets::grid_lines(moved, step).snap_scale(bx, handle, a, proportional, from_center, step).0, vec![]); + } + match &self.targets { + Some((t, tol)) => t.snap_scale(bx, handle, a, proportional, from_center, cx.tol(*tol)), + None => (a, vec![]), + } + } +} + +/// The Construction Guides angle set new preferences start with. +pub const DEFAULT_CONSTRUCTION_ANGLES: &str = "90° & 45° Angles"; + +/// The angles (degrees) of the construction guides of a Smart Guides › Angles set: "90°" gives the +/// horizontal and the vertical, "45°" the diagonals, "60°" and "30°" their multiples between those. +pub fn construction_angles(set: &str) -> &'static [f64] { + match set { + "30° Angles" => &[30.0, 60.0, 120.0, 150.0], + "45° Angles" => &[45.0, 135.0], + "60° Angles" => &[60.0, 120.0], + "90° Angles" => &[0.0, 90.0], + "90° & 45° & 30° Angles" => &[0.0, 30.0, 45.0, 60.0, 90.0, 120.0, 135.0, 150.0], + _ => &[0.0, 45.0, 90.0, 135.0], + } +} + +/// How Shift keeps a drawn point to the point it leaves from. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Keep { + /// At steps of `step` degrees counted from `base` (a segment). + Angle { step: f64, base: f64 }, + /// On a diagonal, as far along as the pointer's farther axis (a square's corner). + Square, +} + +/// The point a drawn point leaves from (the anchor before it, a shape's start) and how it keeps +/// to it: with Shift held at fixed angles round it, and onto the construction guides through it +/// (Smart Guides › Construction Guides). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Leave { + pub at: Point, + /// Shift held: how the point keeps to `at`. + shift: Option, + /// The angles of the construction guides through `at` (none with them off). + angles: &'static [f64], + /// A drawn segment: it may land square to a segment or grazing a curve (a box does not). + pub(super) contact: bool, +} + +impl Leave { + /// A segment from `at` (Pen, Curvature, Line Segment): Shift keeps it at 45° steps from + /// General › Constrain Angle; with Smart Guides on it lands on the construction guides. + pub fn segment(cx: &ToolContext, at: Point, shift: bool) -> Self { + Self { + at, + shift: shift.then_some(Keep::Angle { step: 45.0, base: cx.constrain_angle }), + angles: if cx.smart_guides { cx.construction_angles } else { &[] }, + contact: true, + } + } + + /// A curved source cannot make a line-to-line perpendicular or line-to-curve tangent contact. + pub fn without_contact(mut self) -> Self { + self.contact = false; + self + } + + /// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids, and + /// Artboard, which passes no Shift): Shift keeps the dragged corner on a diagonal, the box a + /// square. + pub fn diagonal(at: Point, shift: bool) -> Self { + Self { at, shift: shift.then_some(Keep::Square), angles: &[], contact: false } + } + + /// Where Shift puts `p`: none without it. + pub(super) fn constrained(&self, p: Point) -> Option { + let v = p - self.at; + Some(match self.shift? { + Keep::Angle { step, base } => self.at + constrain_angle_from(v, step, base), + Keep::Square => self.at + square(v), + }) + } + + /// The construction guide through `at` nearest `p`, within `tol` of it (and `p` farther than + /// that from `at`, where every guide meets): its direction and where `p` lands on it. + pub(super) fn construction(&self, p: Point, tol: f64) -> Option<(Vec2, Point)> { + let v = p - self.at; + if v.hypot() <= tol { + return None; + } + self.angles + .iter() + // Counter-clockwise as seen on the y-down page. + .map(|a| Vec2::new(a.to_radians().cos(), -a.to_radians().sin())) + .map(|d| (d, self.at + d * v.dot(d))) + .map(|(d, q)| (d, q, q.distance(p))) + .filter(|(.., dist)| *dist <= tol) + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map(|(d, q, _)| (d, q)) + } +} + +/// `d` made square: as long on both axes as on its longer one, each keeping its sign. +pub fn square(d: Vec2) -> Vec2 { + let s = d.x.abs().max(d.y.abs()); + Vec2::new(s * d.x.signum(), s * d.y.signum()) +} + +/// Smart Guides for the points a drawing tool places: the Pen's and Curvature tool's anchors, +/// the Pencil's ends, the shape tools' start and dragged corner, the Type tool's click and the +/// Artboard tool's new artboard. Each such tool keeps one and asks it for every point, hovering +/// too, so they all snap alike, and shows its [`Self::guides`]. +/// +/// The targets ([`Targets::for_drawing`], the art in the window) are gathered once per document +/// state: hovering gathers them again only after the document, the selection, what is left out, +/// the window or the snapping options changed; a press always gathers them afresh, and the drag +/// it starts keeps them, the document then showing the drag's own previews. +#[derive(Default)] +pub struct DrawSnap { + /// The targets, and what they were gathered for (none: for one drag only). + targets: Option<(Option, PointSnap)>, + guides: Vec, +} + +/// What a [`DrawSnap`]'s targets were gathered for: the document state, the art left out, the +/// window and every option [`PointSnap::new`] reads. +#[derive(PartialEq)] +struct SnapKey { + revision: (u64, u64), + exclude: Vec, + screen: Option, + view: [bool; 7], + snap_to_glyph: bool, + style: GuideStyle, + tolerances: [f64; 2], +} + +impl SnapKey { + fn of(cx: &ToolContext, exclude: &[NodeId]) -> Self { + Self { + revision: cx.revision, + exclude: exclude.to_vec(), + screen: cx.screen, + view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides, cx.snap_tangent, cx.snap_perpendicular], + snap_to_glyph: cx.snap_to_glyph && cx.alignment_guides, + style: GuideStyle::of(cx), + tolerances: [cx.snapping_tolerance, cx.snap_tolerance], + } + } +} + +/// The part of the document the window shows (none headless: all of it). +fn visible(cx: &ToolContext) -> Option { + let s = cx.screen?; + let (w, h) = s.size; + Some(Rect::from_points(s.at(0.0, 0.0), s.at(w, h)).union_pt(s.at(w, 0.0)).union_pt(s.at(0.0, h))) +} + +impl DrawSnap { + /// Snap the pointer hovering at `p` (the art but `exclude` as targets, the anchors of + /// `exclude` still pulling), reusing the targets while nothing they depend on changed. + pub fn hover(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point { + let key = SnapKey::of(cx, exclude); + if self.targets.as_ref().is_none_or(|(k, _)| k.as_ref() != Some(&key)) { + self.gather(cx, key); + } + self.snap(cx, p, from) + } + + /// Snap the point pressed at `p`, the targets gathered afresh: the drag that follows keeps + /// them ([`Self::drag`]). + pub fn press(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point { + self.gather(cx, SnapKey::of(cx, exclude)); + self.snap(cx, p, from) + } + + /// Gather `targets` other than the art for the drag a press starts (a point being dragged + /// leaves itself out); the next hover gathers its own again. + pub fn hold(&mut self, cx: &ToolContext, targets: impl FnOnce() -> Targets) { + self.targets = Some((None, PointSnap::new(cx, targets))); + self.guides.clear(); + } + + /// Snap `p`, dragged since the press, with the targets the press gathered. + pub fn drag(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point { + if self.targets.is_none() { + self.gather(cx, SnapKey::of(cx, &[])); + } + self.snap(cx, p, from) + } + + /// The guides of the last snap. + pub fn guides(&self) -> &[Overlay] { + &self.guides + } + + /// Hide the guides (the gesture ended). + pub fn clear(&mut self) { + self.guides.clear(); + } + + fn gather(&mut self, cx: &ToolContext, key: SnapKey) { + let snap = PointSnap::new(cx, || Targets::for_drawing(cx.doc, &key.exclude, visible(cx), key.snap_to_glyph)); + self.targets = Some((Some(key), snap)); + } + + fn snap(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point { + let (q, guides) = match &self.targets { + Some((_, t)) => t.snap_from(cx, p, from), + None => (p, vec![]), + }; + self.guides = guides; + q + } +} + +/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it +/// snaps as a drawn point does ([`DrawSnap`]). The targets ([`Targets::for_handle`]) are gathered +/// on the first move that needs them and kept for the rest of the drag. +#[derive(Default)] +pub struct HandleSnap(pub(crate) DrawSnap); + +impl HandleSnap { + /// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`, + /// and the guides showing why. + pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec) { + let path = cx.doc.node(id).and_then(|n| n.path_data()); + if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) { + return (a.p + constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]); + } + if self.0.targets.is_none() { + self.0.hold(cx, || Targets::for_handle(cx.doc, id)); + } + let q = self.0.drag(cx, p, None); + (q, self.0.guides().to_vec()) + } +} + +/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the +/// selection or of the object under the pointer, when Snap to Point or Smart Guides is on. +pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec) { + if !(cx.snap_to_point || cx.smart_guides) { + return (p, vec![]); + } + let tol = if cx.smart_guides { cx.snap_tol() } else { cx.tol(cx.snap_tolerance) }; + let mut roots = cx.selection.objects.clone(); + roots.extend(hit_test(cx.doc, p, HitOptions { tol, skip_guides: false, ..cx.hit_options() }).map(|h| h.leaf)); + let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol); + Targets::points_near(cx.doc, &roots, near).styled(cx).snap_point(p, tol) +} + +/// Distance Guides (Smart Guides → Invoke Distance Guides): how far the selection's bounds `from` +/// lie from `to` along each axis, as measured lines ([`Overlay::Distance`]) labelled with their +/// lengths in the General unit. Along an axis on which the two lie apart, one line spans the gap +/// between their facing edges; along one on which they overlap, or one holds the other, one line +/// runs between their left (top) edges and one between their right (bottom) edges. An artboard +/// (`artboard`) is always measured edge to edge: the selection's distance from its left, right, +/// top and bottom edges. Each line runs across the middle of where the two overlap on the other +/// axis, else across the selection's middle, with a dashed line from `to`'s edge out to it when it +/// passes beside `to`. Lines of no length are left out. +pub fn distance_guides(cx: &ToolContext, from: Rect, to: Rect, artboard: bool) -> Vec { + let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()); + if !finite(&from) || !finite(&to) { + return vec![]; + } + let color = cx.smart_guide_color; + let mut out = Vec::new(); + for x in [true, false] { + let ((f0, f1), (t0, t1)) = (span(&from, x), span(&to, x)); + let ((g0, g1), (u0, u1)) = (span(&from, !x), span(&to, !x)); + let (lo, hi) = (g0.max(u0), g1.min(u1)); + let across = if lo <= hi { (lo + hi) / 2.0 } else { (g0 + g1) / 2.0 }; + let pt = |u: f64, c: f64| if x { Point::new(u, c) } else { Point::new(c, u) }; + // Each measured line: from the selection's edge to `to`'s. + let lines: Vec<(f64, f64)> = match (artboard, f1 <= t0, t1 <= f0) { + (false, true, _) => vec![(f1, t0)], + (false, _, true) => vec![(f0, t1)], + _ => vec![(f0, t0), (f1, t1)], + }; + for (fu, tu) in lines { + let length = (tu - fu).abs(); + if length <= 1e-9 { + continue; + } + out.push(Overlay::Distance { a: pt(fu, across), b: pt(tu, across), text: cx.len(length), color }); + let on_edge = across.clamp(u0, u1); + if on_edge != across { + out.push(Overlay::Line { a: pt(tu, on_edge), b: pt(tu, across), color, dashed: true }); + } + } + } + out +} diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs new file mode 100644 index 000000000..4d77ac652 --- /dev/null +++ b/crates/tools/src/guides/targets.rs @@ -0,0 +1,1096 @@ +use super::snap::{Leave, meeting, seg_direction, touch_params}; +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Kind { + Anchor, + Center, + Edge, + Artboard, + Bleed, + Guide, + Glyph, +} + +impl Kind { + fn label(self) -> &'static str { + match self { + Kind::Anchor => "anchor", + Kind::Center => "center", + Kind::Edge => "path", + Kind::Artboard => "artboard", + Kind::Bleed => "bleed", + Kind::Guide => "guide", + Kind::Glyph => "glyph", + } + } +} + +/// Snap targets gathered from the document (excluding the objects being edited). +#[derive(Default)] +pub struct Targets { + pub(super) points: Vec<(Point, Kind)>, + pub(super) xs: Vec<(f64, Point, Kind)>, + pub(super) ys: Vec<(f64, Point, Kind)>, + /// Path segments and their control bounds, which a point lands on ("path"): gathered only for + /// dragged anchors ([`Self::for_anchor_drag`]). + pub(super) segments: Vec<(Rect, PathSeg)>, + /// Ruler guides, which pull a point into line where they run (Snap to Point). + pub(super) rulers: Vec, + /// The bounds of the objects a moved selection spaces itself evenly with (Spacing Guides): + /// gathered only for moves ([`Self::for_move`]). + pub(super) boxes: Vec, + /// Line Extension: the lines a single dragged anchor keeps to, along the straight segments + /// it ends, as the end that stays put and the unit direction towards the dragged one + /// ([`Self::for_anchor_drag`]). + pub(super) extensions: Vec<(Point, Vec2)>, + /// Snap to Tangent and Snap to Perpendicular: a drawn line from an anchor lands where it + /// grazes a curve, or meets a segment square ([`Self::touching`]). + pub(super) tangent: bool, + pub(super) perpendicular: bool, + /// How the guides these targets produce look. + pub(super) style: GuideStyle, +} + +/// How many anchors, and how many segments, the targets gather at most. +const BUDGET: usize = 20_000; + +/// What [`Targets::collect_inner`] gathers from each path besides its anchors and bounds. +#[derive(Clone, Copy, PartialEq)] +enum Gather { + /// Nothing more: a moved or resized selection lines up with bounds. + Bounds, + /// Its segments, which a dragged anchor lands on. + Segments, + /// Its segments, and its anchors as lines too: a drawn point lines up with them. + Drawing, +} + +/// The entries of `sorted` (ascending by `key`) whose key is within `tol` of `v`, found by +/// bisection. +fn within(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] { + let lo = sorted.partition_point(|e| key(e) < v - tol); + let hi = sorted.partition_point(|e| key(e) <= v + tol); + sorted.get(lo..hi).unwrap_or_default() +} + +/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those: +/// one pass, however many are skipped. A Shaper Group's original shapes, its first child, are +/// not shown and are skipped too. +fn walk_but<'a>(nodes: &'a [Arc], skip: &HashSet, f: &mut impl FnMut(&'a Node)) { + for n in nodes.iter().filter(|n| !skip.contains(&n.id)) { + f(n); + if let Some(ch) = n.children() { + walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), skip, f); + } + } +} + +/// The lines of `lines` (sorted by where they run) within `tol` of `v`. +pub(super) fn lines_near(lines: &[(f64, Point, Kind)], v: f64, tol: f64) -> &[(f64, Point, Kind)] { + within(lines, |l| l.0, v, tol) +} + +/// `r`'s extent along the x axis (`x`) or the y axis. +pub(super) fn span(r: &Rect, x: bool) -> (f64, f64) { + if x { (r.x0, r.x1) } else { (r.y0, r.y1) } +} + +/// Along axis `x`, does dragging `handle` move the box's minimum edge (true) or maximum (false)? +fn resize_moves_min_edge(handle: Handle, x: bool) -> Option { + let (ax, ay) = handle.axes(); + if x && !ax || !x && !ay { + return None; + } + if x { + Some(matches!(handle, Handle::Left | Handle::TopLeft | Handle::BottomLeft)) + } else { + Some(matches!(handle, Handle::Top | Handle::TopLeft | Handle::TopRight)) + } +} + +/// The objects a moved selection (`moving`) spaces itself evenly with, by their bounds: each +/// visible object on a layer as a whole (a group too), and inside a group holding some of the +/// moving objects its other objects, but a Shaper Group's original shapes. At most [`BUDGET`]. +pub(super) fn object_boxes(doc: &Document, moving: &[NodeId]) -> Vec { + fn push(nodes: &[Arc], moving: &HashSet, holders: &HashSet, out: &mut Vec) { + for n in nodes.iter().filter(|n| n.visible && !moving.contains(&n.id)) { + if out.len() >= BUDGET { + return; + } + match n.children() { + Some(ch) if n.is_layer() || holders.contains(&n.id) => { + push(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), moving, holders, out) + } + _ => out.extend(n.geometric_bounds()), + } + } + } + let holders: HashSet = moving.iter().filter_map(|id| doc.ancestry(*id)).flatten().collect(); + let mut out = vec![]; + push(&doc.layers, &moving.iter().copied().collect(), &holders, &mut out); + out +} + +impl Targets { + pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option) -> Self { + Self::collect_inner(doc, exclude, None, visible, Gather::Bounds) + } + + /// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection, + /// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides › + /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). + pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { + let mut t = Self::collect(cx.doc, exclude, None).styled(cx); + if cx.spacing_guides { + t.boxes = object_boxes(cx.doc, &cx.selection.objects); + } + t + } + + /// Targets for scaling the selection: the other art, styled as `cx` asks, and with Smart Guides › + /// Spacing Guides the neighbours whose gaps the moving edge should match ([`Self::snap_scale`]). + pub fn for_scale(cx: &ToolContext) -> Self { + Self::for_move(cx, &cx.selection.objects) + } + + /// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its + /// anchors, centres, edges and path segments, its anchors in line too. The anchors and + /// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets. + pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option, glyphs: bool) -> Self { + let mut t = Self::collect_inner(doc, exclude, None, visible, Gather::Drawing); + t.push_paths(doc, exclude, true); + if glyphs { + t.push_glyphs(doc, exclude, visible); + } + t.index() + } + + /// Targets for a dragged direction handle of path `id`: the other art, and the anchors of `id` + /// (its bounds and the segments through the handle move with it, so they would chase it). + pub fn for_handle(doc: &Document, id: NodeId) -> Self { + let mut t = Self::collect_inner(doc, &[id], None, None, Gather::Bounds); + t.push_paths(doc, &[id], false); + t.index() + } + + /// The anchors of the paths `ids` as targets, on them and in line, and their segments too + /// when `segments`: at most [`BUDGET`] of each. + fn push_paths(&mut self, doc: &Document, ids: &[NodeId], segments: bool) { + let (mut anchors, mut segment_budget) = (BUDGET, if segments { BUDGET } else { 0 }); + for path in ids.iter().filter_map(|id| doc.node(*id)?.path_data()) { + for sp in &path.subpaths { + for a in sp.anchors.iter().take(anchors) { + self.push_anchor(a.p); + anchors -= 1; + } + self.push_segments(sp, |_| false, &mut segment_budget); + } + } + } + + /// Sort the targets so that [`within`] finds those near a point by bisection: the anchors + /// and centres by x, the lines by where they run. Every constructor ends with it (the sort + /// is stable: of targets alike, the first gathered still wins). + fn index(mut self) -> Self { + self.points.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); + self.xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + self.ys.sort_by(|a, b| a.0.total_cmp(&b.0)); + self + } + + /// Anchor `a` as a target: a point lands on it and lines up with it. + fn push_anchor(&mut self, a: Point) { + self.points.push((a, Kind::Anchor)); + self.xs.push((a.x, a, Kind::Anchor)); + self.ys.push((a.y, a, Kind::Anchor)); + } + + /// Live-text baselines and glyph bounds (View › Snap to Glyph). + fn push_glyphs(&mut self, doc: &Document, exclude: &[NodeId], visible: Option) { + let skip: HashSet = exclude.iter().copied().collect(); + let db = FontDb::global(); + let kind = Kind::Glyph; + let mut budget = BUDGET / 4; + walk_but(&doc.layers, &skip, &mut |n| { + if budget == 0 || !n.visible { + return; + } + let NodeKind::Text(t) = &n.kind else { return }; + let lay = layout_text(db, t); + let ink = t.xf.transform_rect_bbox(lay.bounds); + if !ink.is_finite() || ink.width() <= 0.0 || ink.height() <= 0.0 { + return; + } + if let Some(v) = visible + && ink.intersect(v).area() <= 0.0 + && !v.contains(ink.center()) + { + return; + } + let to_doc = |p: Point| t.xf * p; + for line in &lay.lines { + let at = to_doc(Point::new(line.x0, line.baseline)); + self.ys.push((at.y, at, kind)); + } + for g in &lay.glyphs { + if budget == 0 { + break; + } + let bb = g.outline.bounding_box(); + if !(bb.width() > 0.0 && bb.height() > 0.0) { + continue; + } + let r = t.xf.transform_rect_bbox(bb); + for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { + self.xs.push((p.x, p, kind)); + self.ys.push((p.y, p, kind)); + } + budget -= 1; + } + }); + } + + /// The same targets drawing their guides as the Smart Guides preferences in `cx` ask + /// ([`GuideStyle::of`]). + pub fn styled(mut self, cx: &ToolContext) -> Self { + self.style = GuideStyle::of(cx); + self + } + + /// An alignment line from `a` to `b`, unless Alignment Guides are off. + fn line(&self, a: Point, b: Point) -> Option { + self.style.lines.then_some(Overlay::Line { a, b, color: self.style.color, dashed: false }) + } + + /// A label `text` at `p`, unless Anchor/Path Labels are off. + fn label(&self, p: Point, text: &str) -> Option { + self.style.labels.then(|| Overlay::Label { p, text: text.into(), color: self.style.color }) + } + + /// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the + /// art that moves along with it). + pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self { + Self::collect_inner(doc, exclude, Some(index), None, Gather::Bounds) + } + + /// Targets for dragging the direct-selected anchors of `sel` (and its objects selected as a + /// whole): the other art, its path segments too, and the anchors and segments that stay put on + /// the paths being reshaped (their bounds move along, so they are no targets). A single anchor + /// also keeps to the extensions of the straight segments it ends (#2120). + pub fn for_anchor_drag(doc: &Document, sel: &Selection) -> Self { + let mut t = Self::collect_inner(doc, &sel.objects, None, None, Gather::Segments); + let (mut anchors, mut segments) = (BUDGET, BUDGET); + // With several anchors moving, the one under the pointer is not known here, and the + // others' extensions don't run through it. + let single = sel.objects.len() <= 1 && sel.anchors.values().map(|s| s.len()).sum::() == 1; + for (id, set) in &sel.anchors { + let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue }; + for (si, sp) in path.subpaths.iter().enumerate() { + let moving = |ai: usize| set.contains(&(si, ai)); + for a in sp.anchors.iter().enumerate().filter(|(ai, _)| !moving(*ai)).map(|(_, a)| a.p).take(anchors) { + t.push_anchor(a); + anchors -= 1; + } + t.push_segments(sp, moving, &mut segments); + if single { + t.push_extensions(sp, moving); + } + } + } + t.index() + } + + /// The extensions of the straight segments of `sp` with one end among the anchors `moving` + /// accepts and the other staying put. Horizontal and vertical ones are left out: lining up + /// with the other end already keeps the point on them, and says "align". + fn push_extensions(&mut self, sp: &SubPath, moving: impl Fn(usize) -> bool) { + let n = sp.anchors.len(); + for seg in (0..sp.segment_count()).filter(|&seg| moving(seg) != moving((seg + 1) % n) && sp.segment_is_line(seg)) { + let c = sp.segment(seg); + let (fixed, dragged) = if moving(seg) { (c.p3, c.p0) } else { (c.p0, c.p3) }; + let d = dragged - fixed; + let len = d.hypot(); + if len.is_finite() && d.x.abs() > 1e-9 * len && d.y.abs() > 1e-9 * len { + self.extensions.push((fixed, d / len)); + } + } + } + + /// The segments of `sp` that stay put while the anchors `moving` accepts move, as targets, at + /// most `budget` of them (counted down). + fn push_segments(&mut self, sp: &SubPath, moving: impl Fn(usize) -> bool, budget: &mut usize) { + let n = sp.anchors.len(); + for seg in 0..sp.segment_count() { + if *budget == 0 { + return; + } + if moving(seg) || moving((seg + 1) % n) { + continue; + } + let c = sp.segment(seg); + let bounds = Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3); + // A straight one as a line, so the point lands on it exactly. + self.segments.push((bounds, if sp.segment_is_line(seg) { PathSeg::Line(Line::new(c.p0, c.p3)) } else { PathSeg::Cubic(c) })); + *budget -= 1; + } + } + + fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option, visible: Option, gather: Gather) -> Self { + let mut t = Targets::default(); + let add_rect = |t: &mut Targets, r: Rect, kind: Kind| { + let c = r.center(); + for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { + t.xs.push((p.x, p, kind)); + t.ys.push((p.y, p, kind)); + } + // A bleed shares its artboard's centre. + if kind != Kind::Bleed { + t.points.push((c, Kind::Center)); + t.xs.push((c.x, c, Kind::Center)); + t.ys.push((c.y, c, Kind::Center)); + } + }; + for (_, ab) in doc.artboards.iter().enumerate().filter(|(i, _)| Some(*i) != skip_artboard) { + add_rect(&mut t, ab.rect, Kind::Artboard); + if doc.setup.has_bleed() { + add_rect(&mut t, doc.setup.bleed_rect(ab.rect), Kind::Bleed); + } + } + let (mut budget, mut segment_budget) = (BUDGET, BUDGET); + walk_but(&doc.layers, &exclude.iter().copied().collect(), &mut |n| { + if budget == 0 || n.is_container() || !n.visible { + return; + } + let Some(b) = n.geometric_bounds() else { return }; + if let Some(v) = visible + && b.intersect(v).area() <= 0.0 + && !v.contains(b.center()) + { + return; + } + if let NodeKind::Path { path, .. } = &n.kind { + for (_, _, a) in path.anchors() { + if gather == Gather::Drawing { + t.push_anchor(a.p); + } else { + t.points.push((a.p, Kind::Anchor)); + } + budget = budget.saturating_sub(1); + } + if gather != Gather::Bounds { + for sp in &path.subpaths { + t.push_segments(sp, |_| false, &mut segment_budget); + } + } + } + add_rect(&mut t, b, Kind::Edge); + }); + t.index() + } + + /// Only these targets' anchors pull (View → Snap to Point). + pub(crate) fn anchors_only(mut self) -> Self { + self.points.retain(|(_, k)| *k == Kind::Anchor); + self.xs.clear(); + self.ys.clear(); + self.segments.clear(); + self.extensions.clear(); + self + } + + /// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and + /// unlocked, as the selection tools pick them) pull the pointer into line where they run. + pub(super) fn for_snap_to_point(self, cx: &ToolContext) -> Self { + self.anchors_only().with_rulers(cx) + } + + /// These targets and the ruler guides, when they are shown (and so unlocked to the selection + /// tools): they pull a point into line where they run. + pub(super) fn with_rulers(mut self, cx: &ToolContext) -> Self { + if cx.guides { + self.rulers = cx + .doc + .guides + .iter() + .filter(|g| cx.doc.guide_shown(g)) + .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) + .collect(); + } + self + } + + /// The ruler guides alone ([`Self::with_rulers`]): what a dragged selection's grabbed point + /// still snaps to with View → Snap to Point when Smart Guides line the selection up. + pub(crate) fn rulers(cx: &ToolContext) -> Self { + Self::default().styled(cx).with_rulers(cx) + } + + /// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart + /// Guides off: the anchors of the art but `exclude` and the ruler guides. + pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option { + (cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx)) + } + + /// The gridlines (one every `step` from the page's origin, as + /// [`vectorcraft_geom::snap::snap_to_grid`] places them) at and either side of the nearest one + /// to each of `points`, as lines to line up with. + pub(super) fn grid_lines(points: impl IntoIterator, step: f64) -> Self { + let mut t = Targets::default(); + let line = |v: f64, k: i32| vectorcraft_geom::snap::snap_to_grid(v, step, 0.0) + f64::from(k) * step; + for p in points { + for k in -1..=1 { + let (x, y) = (line(p.x, k), line(p.y, k)); + t.xs.push((x, Point::new(x, p.y), Kind::Guide)); + t.ys.push((y, Point::new(p.x, y), Kind::Guide)); + } + } + t.index() + } + + /// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds + /// reach into `near`. + pub(super) fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self { + let mut t = Targets::default(); + for n in roots.iter().filter_map(|id| doc.node(*id)) { + crate::draw2::walk_shown(n, &mut |c| { + if c.is_container() || !c.visible { + return; + } + let Some(b) = c.geometric_bounds() else { return }; + if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 { + return; + } + if let NodeKind::Path { path, .. } = &c.kind { + t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor))); + } + t.points.push((b.center(), Kind::Center)); + }); + } + t.index() + } + + /// Snap a single point: onto the nearest anchor or centre, else onto the nearest segment, else + /// into line with targets. Returns the snapped point and guide overlays. + pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec) { + self.snap_from(p, None, tol) + } + + /// Snap a drawn point `p` as [`Self::snap_point`] does, and when it leaves `from` (the anchor + /// before it, a shape's start): with Shift held it keeps to its angles round it, sliding along + /// that way into line with the nearest target; else, before lining up, it lands on a + /// construction guide through it, where a target's line crosses the guide if one does nearby. + pub fn snap_from(&self, p: Point, from: Option<&Leave>, tol: f64) -> (Point, Vec) { + if let Some(f) = from + && let Some(q) = f.constrained(p) + { + return match self.slide(f.at, q - f.at, q, tol, true) { + Some((q, target)) => (q, self.lined_up(q, target)), + None => (q, vec![]), + }; + } + if let Some((q, k)) = self.nearest_point(p, tol) { + return (q, self.label(q, k.label()).into_iter().collect()); + } + if let Some(q) = self.crossing(p, tol) { + return (q, self.label(q, "intersect").into_iter().collect()); + } + if let Some(f) = from.filter(|f| f.contact) + && let Some((q, perpendicular, dir)) = self.touching(f.at, p, tol) + { + return (q, self.touch_guides(f.at, q, perpendicular, dir, tol)); + } + if let Some(q) = self.on_segment(p, tol) { + return (q, self.label(q, Kind::Edge.label()).into_iter().collect()); + } + // A construction guide through the point it leaves, else a dragged segment's extension: + // it lands on the guide, sliding into line with a target crossing it nearby. + if let Some((at, dir, q)) = from.and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))).or_else(|| self.extension(p, tol)) { + let construction = |q: Point| Overlay::Line { a: at, b: q, color: self.style.color, dashed: false }; + return match self.slide(at, dir, q, tol, false) { + Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()), + None => (q, vec![construction(q)]), + }; + } + let mut out = p; + let mut ov = vec![]; + // The targets' lines along one axis and the ruler guides running past `p`, the nearest + // within reach of `v`. + let nearest = |lines: &[(f64, Point, Kind)], vertical: bool, v: f64| { + let rulers = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(p, tol)); + lines_near(lines, v, tol) + .iter() + .copied() + .chain(rulers) + .filter(|(t, ..)| (t - v).abs() <= tol) + .min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs())) + }; + let mut aligned = false; + if let Some((x, from, _)) = nearest(&self.xs, true, p.x) { + out.x = x; + aligned = true; + ov.extend(self.line(from, Point::new(x, p.y))); + } + if let Some((y, from, _)) = nearest(&self.ys, false, p.y) { + out.y = y; + aligned = true; + ov.extend(self.line(from, Point::new(p.x, y))); + } + if aligned { + ov.extend(self.label(out, "align")); + } + (out, ov) + } + + /// Where a line from `at` through a point near `p` touches a target segment: square to it + /// (`perpendicular`, the foot of the perpendicular from `at`) or just grazing a curve + /// (`tangent`), within `tol` of `p`. The point, whether it is the perpendicular one, and the + /// segment's direction there. Targets gather segments for drawn points and dragged anchors only. + pub(super) fn touching(&self, at: Point, p: Point, tol: f64) -> Option<(Point, bool, Vec2)> { + if !(self.tangent || self.perpendicular) || ![at.x, at.y, p.x, p.y, tol].iter().all(|v| v.is_finite()) { + return None; + } + let mut best: Option<(f64, Point, bool, Vec2)> = None; + // Every segment in reach is tried: their order is the document's, not the distance. + for (_, seg) in self.segments.iter().filter(|(b, _)| b.inflate(tol, tol).contains(p)) { + let curved = !matches!(seg, PathSeg::Line(_)); + for (perpendicular, wanted) in [(true, self.perpendicular && !curved), (false, self.tangent && curved)] { + if !wanted { + continue; + } + for t in touch_params(seg, at, perpendicular) { + let (q, d) = (seg.eval(t), seg_direction(seg, t)); + let dist = q.distance(p); + if dist <= tol && q.distance(at) > tol && best.is_none_or(|b| dist < b.0) { + best = Some((dist, q, perpendicular, d)); + } + } + } + } + best.map(|(_, q, perpendicular, d)| (q, perpendicular, d)) + } + + /// The guides of a point landed where the line from `at` touches a segment at `q` running + /// along `dir`: the line itself, a square corner mark for the perpendicular, and the label. + fn touch_guides(&self, at: Point, q: Point, perpendicular: bool, dir: Vec2, tol: f64) -> Vec { + let mut out: Vec = self.line(at, q).into_iter().collect(); + let (u, v) = (at - q, dir); + if perpendicular && self.style.lines && u.hypot2() > 1e-18 && v.hypot2() > 1e-18 { + let (u, v) = (u / u.hypot(), v / v.hypot()); + let s = tol * 1.5; + let mut mark = BezPath::new(); + mark.move_to(q + u * s); + mark.line_to(q + u * s + v * s); + mark.line_to(q + v * s); + out.push(Overlay::Path { path: mark, color: self.style.color, width: 1.0, dashed: false }); + } + out.extend(self.label(q, if perpendicular { "perpendicular" } else { "tangent" })); + out + } + + /// The segment extension nearest `p`, within `tol` of it (and `p` farther than that from the + /// end it starts at): that end, its direction and where `p` lands on it. Where `p` is nearer + /// the horizontal or the vertical through that end there is none: lining up with it ("align") + /// is what straightens a segment that is nearly level or upright. + fn extension(&self, p: Point, tol: f64) -> Option<(Point, Vec2, Point)> { + self.extensions + .iter() + .filter(|(at, _)| at.distance(p) > tol) + .map(|&(at, d)| (at, d, at + d * (p - at).dot(d))) + .map(|(at, d, q)| (at, d, q, q.distance(p))) + .filter(|(at, .., dist)| *dist <= tol && (p.x - at.x).abs().min((p.y - at.y).abs()) > *dist) + .min_by(|a, b| a.3.total_cmp(&b.3)) + .map(|(at, d, q, _)| (at, d, q)) + } + + /// The anchor or centre nearest `p`, within `tol`. + /// Is a target point (an anchor, a centre) within `tol` of `p`? + pub(crate) fn has_point_near(&self, p: Point, tol: f64) -> bool { + self.nearest_point(p, tol).is_some() + } + + pub(super) fn nearest_point(&self, p: Point, tol: f64) -> Option<(Point, Kind)> { + within(&self.points, |q| q.0.x, p.x, tol) + .iter() + .copied() + .filter(|(q, _)| q.distance(p) <= tol) + .min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) + } + + /// Slide `q`, on the line through `at` along `dir` (beyond `at` only, for a `ray`), to where + /// the nearest target line crosses it, at most twice `tol` away: where it lands and the + /// target's own point (where its line starts). + fn slide(&self, at: Point, dir: Vec2, q: Point, tol: f64, ray: bool) -> Option<(Point, Point)> { + if !dir.hypot2().is_finite() || dir.hypot2() <= 1e-18 { + return None; + } + let reach = 2.0 * tol; + let mut best: Option<(f64, Point, Point)> = None; + for (vertical, lines, d, a, v) in [(true, &self.xs, dir.x, at.x, q.x), (false, &self.ys, dir.y, at.y, q.y)] { + if d.abs() <= 1e-9 { + continue; + } + // The ruler guides that way count as lines too (#812). + let rulers: Vec<(f64, Point, Kind)> = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(q, reach)).collect(); + // A line that far across the way crosses it at least that far along. + for (t, from, _) in lines_near(lines, v, reach).iter().chain(&rulers) { + let s = (t - a) / d; + // Exactly on the line, whatever the rounding along the way. + let hit = if vertical { Point::new(*t, at.y + dir.y * s) } else { Point::new(at.x + dir.x * s, *t) }; + let moved = hit.distance(q); + if moved <= reach && (!ray || s > 0.0) && best.is_none_or(|b| moved < b.0) { + best = Some((moved, hit, *from)); + } + } + } + best.map(|(_, hit, from)| (hit, from)) + } + + /// The guides of a point `q` slid into line with the target at `from`: the line from it and + /// "align", or the target's own label when `q` sits right on it. + fn lined_up(&self, q: Point, from: Point) -> Vec { + if let Some((on, k)) = self.nearest_point(q, 1e-9) { + return self.label(on, k.label()).into_iter().collect(); + } + self.line(from, q).into_iter().chain(self.label(q, "align")).collect() + } + + /// The nearest point within `tol` of `p` on a target segment. + fn on_segment(&self, p: Point, tol: f64) -> Option { + self.segments + .iter() + .filter(|(b, _)| b.inflate(tol, tol).contains(p)) + .map(|(_, c)| (c, c.nearest(p, 1e-6))) + .filter(|(_, n)| n.distance_sq <= tol * tol) + .min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq)) + .map(|(c, n)| c.eval(n.t)) + } + + /// Where two target paths, a path and a ruler guide, or two ruler guides cross, the crossing + /// nearest `p` within `tol` ("intersect", #1602). + fn crossing(&self, p: Point, tol: f64) -> Option { + // A ruler guide running past, as a short line through the reach of `p`. + let ruler = |r: &Ruler| { + let (pos, ..) = r.line_at(p, tol)?; + let (across, along) = if r.vertical { (p.x, p.y) } else { (p.y, p.x) }; + let (a, b) = (along - tol, along + tol); + ((pos - across).abs() <= tol) + .then(|| PathSeg::Line(if r.vertical { Line::new((pos, a), (pos, b)) } else { Line::new((a, pos), (b, pos)) })) + }; + let near: Vec = self + .segments + .iter() + .filter(|(b, c)| b.inflate(tol, tol).contains(p) && c.nearest(p, 1e-6).distance_sq <= tol * tol) + .map(|(_, c)| *c) + .chain(self.rulers.iter().filter_map(ruler)) + // shortcut: the first 32 lines in reach are paired, raise it if busier crossings matter + .take(32) + .collect(); + let mut best: Option = None; + for (i, a) in near.iter().enumerate() { + for b in near.iter().skip(i + 1) { + let hits: Vec = match (a, b) { + (PathSeg::Line(l), c) | (c, PathSeg::Line(l)) => c.intersect_line(*l).iter().map(|x| l.eval(x.line_t)).collect(), + _ => meeting(a, b, p).into_iter().collect(), + }; + for q in hits { + if q.distance(p) <= tol && best.is_none_or(|b| q.distance(p) < b.distance(p)) { + best = Some(q); + } + } + } + } + best + } + + /// Snap a ruler guide at `v` (the x of a vertical one, the y of a horizontal one) into line + /// with the nearest edge, centre or anchor within `tol`: where it goes, and a label there. + pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec) { + let along = |q: &Point| if vertical { q.x } else { q.y }; + let lines = if vertical { &self.xs } else { &self.ys }; + let best = lines_near(lines, v, tol) + .iter() + .map(|(t, from, k)| (*t, *from, *k)) + .chain(self.points.iter().map(|(q, k)| (along(q), *q, *k))) + .filter(|(t, ..)| (t - v).abs() <= tol) + .min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs())); + match best { + Some((t, from, k)) => (t, self.label(from, k.label()).into_iter().collect()), + None => (v, vec![]), + } + } + + /// Snap a dragged point that carries others along at `offsets` (a resize handle and the + /// bleed edge beyond it): onto an anchor or centre near the point itself, otherwise into line + /// with targets, each axis on whichever of them comes nearest. + pub fn snap_point_with(&self, p: Point, offsets: &[Vec2], tol: f64) -> (Point, Vec) { + if self.nearest_point(p, tol).is_some() { + return self.snap_point(p, tol); + } + let rects: Vec = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect(); + let (adj, mut ov, aligned) = self.align_rects(&rects, tol); + let out = p + adj; + if aligned.contains(&true) { + ov.extend(self.label(out, "align")); + } + (out, ov) + } + + /// Snap a moving rectangle (selection bounds after a move by `d`): tries its corners/edges/centre. + pub fn snap_rect(&self, r: Rect, tol: f64) -> (Vec2, Vec) { + self.snap_rects(&[r], tol) + } + + /// Snap a point that may only move along `dir` (the grabbed point of a move Shift keeps to its + /// line): onto the point of that line nearest an anchor or center within `tol` of `p`, labeled + /// at the anchor or center, else to where the nearest target line or ruler guide crosses the + /// line, at most twice `tol` away ([`Self::slide`]). Returns where it goes and the guides. + pub(crate) fn snap_point_along(&self, p: Point, dir: Vec2, tol: f64) -> (Point, Vec) { + let l2 = dir.hypot2(); + if !l2.is_finite() || l2 <= 1e-18 { + return (p, vec![]); + } + if let Some((q, k)) = self.nearest_point(p, tol) { + return (p + dir * ((q - p).dot(dir) / l2), self.label(q, k.label()).into_iter().collect()); + } + match self.slide(p, dir, p, tol, false) { + Some((q, from)) => (q, self.lined_up(q, from)), + None => (p, vec![]), + } + } + + /// [`Self::snap_rects`] for rectangles that may only move along `dir` (a move Shift keeps to + /// its line): they slide along it until the edge or center of one of them is in line with a + /// target, within `tol` of it on that axis, the target that needs the shortest way along the + /// line winning. Lining up with nothing, a single rectangle with Spacing Guides slides to where + /// it is spaced evenly along an axis the line runs along. Returns the shift (along `dir`) and + /// the guides. + pub(crate) fn snap_rects_along(&self, rects: &[Rect], dir: Vec2, tol: f64) -> (Vec2, Vec) { + let len = dir.hypot(); + if !len.is_finite() || len <= 1e-9 { + return (Vec2::ZERO, vec![]); + } + let u = dir / len; + // The axes the line runs along: the x axis (`true`) and the y axis, and how far a step of + // one along the line goes on that axis. + let axes = [(true, u.x), (false, u.y)].into_iter().filter(|(_, c)| c.abs() > 1e-9); + // The way along the line, the target line it lines up with, where that line starts, and + // its axis. + let mut best: Option<(f64, f64, Point, bool)> = None; + for (x, c) in axes.clone() { + let lines = if x { &self.xs } else { &self.ys }; + for r in rects { + let vs = if x { [r.x0, r.center().x, r.x1] } else { [r.y0, r.center().y, r.y1] }; + for v in vs { + for (t, from, _) in lines_near(lines, v, tol).iter().filter(|(t, ..)| (t - v).abs() <= tol) { + let s = (t - v) / c; + if best.is_none_or(|b| s.abs() < b.0.abs()) { + best = Some((s, *t, *from, x)); + } + } + } + } + } + if let Some((s, t, from, x)) = best { + let ov = rects.iter().map(|r| *r + u * s).reduce(|a, b| a.union(b)).and_then(|m| { + if x { + self.line(Point::new(t, from.y.min(m.y0)), Point::new(t, from.y.max(m.y1))) + } else { + self.line(Point::new(from.x.min(m.x0), t), Point::new(from.x.max(m.x1), t)) + } + }); + return (u * s, ov.into_iter().collect()); + } + let [r] = rects else { return (Vec2::ZERO, vec![]) }; + if self.boxes.is_empty() { + return (Vec2::ZERO, vec![]); + } + let spaced = + axes.filter_map(|(x, c)| self.even_spacing(*r, x, tol).map(|(shift, _)| (shift / c, x))).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())); + let Some((s, x)) = spaced else { return (Vec2::ZERO, vec![]) }; + let gaps = self.even_spacing(*r + u * s, x, tol).map(|(_, gaps)| gaps).unwrap_or_default(); + (u * s, gaps.into_iter().flat_map(|g| self.gap_marks(g, x, tol)).collect()) + } + + /// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any + /// of them nearest a target, per axis. A single moved selection with Spacing Guides + /// ([`Self::for_move`]) is spaced evenly along an axis that lines up with nothing + /// ([`Self::even_spacing`]). Returns the shift and the guides. + pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec) { + let (mut d, mut ov, aligned) = self.align_rects(rects, tol); + if let [r] = rects + && !self.boxes.is_empty() + { + let moved = *r + d; + let mut spaced = false; + for (x, lined_up) in [true, false].into_iter().zip(aligned) { + if let Some((shift, _)) = self.even_spacing(moved, x, tol).filter(|_| !lined_up) { + *(if x { &mut d.x } else { &mut d.y }) += shift; + spaced = true; + } + } + // The equal gaps, marked where the rectangle ends up. + if spaced { + let done = *r + d; + for (x, lined_up) in [true, false].into_iter().zip(aligned) { + let gaps = if lined_up { None } else { self.even_spacing(done, x, tol) }; + ov.extend(gaps.into_iter().flat_map(|(_, gaps)| gaps).flat_map(|g| self.gap_marks(g, x, tol))); + } + } + } + (d, ov) + } + + /// Spacing Guides along the x axis (`x`) or the y axis for the moved rectangle `r`, among the + /// objects of its row (column): the shift (within `tol`) that leaves it as far from its + /// neighbour before or after it as two other objects of the row are apart, or as far from both + /// neighbours; and the equal gaps then, as (start, end, where across) along that axis. + fn even_spacing(&self, r: Rect, x: bool, tol: f64) -> Option<(f64, Vec<[f64; 3]>)> { + let (r0, r1) = span(&r, x); + let len = r1 - r0; + let (c0, c1) = span(&r, !x); + // The objects beside it, before or after, overlapping it across, by where they start. + let mut row: Vec<(Rect, f64, f64)> = self + .boxes + .iter() + .map(|b| (*b, span(b, x).0, span(b, x).1)) + .filter(|(b, a0, a1)| span(b, !x).0 < c1 && span(b, !x).1 > c0 && (*a1 <= r0 + tol || *a0 >= r1 - tol)) + .collect(); + row.sort_by(|a, b| a.1.total_cmp(&b.1)); + // Where two boxes overlap across: the middle of that (or of the space between them). + let mid = |a: &Rect, b: &Rect| (span(a, !x).0.max(span(b, !x).0) + span(a, !x).1.min(span(b, !x).1)) / 2.0; + // Each object and the nearest one after it, unless the moved rectangle sits between them. + let pairs: Vec<(f64, [f64; 3])> = row + .iter() + .filter_map(|(p, _, p1)| { + let next = row.partition_point(|(_, a0, _)| *a0 < *p1); + let (q, q0, _) = row.get(next)?; + let straddles = *p1 <= r0 + tol && *q0 >= r1 - tol; + (*q0 > *p1 && !straddles).then(|| (q0 - p1, [*p1, *q0, mid(p, q)])) + }) + .collect(); + let before = row.iter().filter(|(_, _, a1)| *a1 <= r0 + tol).max_by(|a, b| a.2.total_cmp(&b.2)); + let after = row.iter().filter(|(_, a0, _)| *a0 >= r1 - tol).min_by(|a, b| a.1.total_cmp(&b.1)); + // Where it could start, and the gap it repeats (None: halfway between its neighbours). + let mut starts: Vec<(f64, Option)> = vec![]; + if let (Some(a), Some(b)) = (before, after) + && b.1 - a.2 > len + { + starts.push(((a.2 + b.1 - len) / 2.0, None)); + } + for (g, _) in &pairs { + starts.extend(before.map(|a| (a.2 + g, Some(*g)))); + starts.extend(after.map(|b| (b.1 - g - len, Some(*g)))); + } + let (start, repeats) = + starts.into_iter().filter(|(t, _)| (t - r0).abs() <= tol).min_by(|a, b| (a.0 - r0).abs().total_cmp(&(b.0 - r0).abs()))?; + let at = r + if x { Vec2::new(start - r0, 0.0) } else { Vec2::new(0.0, start - r0) }; + let (end, mut gaps) = (start + len, vec![]); + if let Some((a, _, a1)) = before + && repeats.is_none_or(|g| (start - a1 - g).abs() <= 1e-9) + { + gaps.push([*a1, start, mid(a, &at)]); + } + if let Some((b, b0, _)) = after + && repeats.is_none_or(|g| (b0 - end - g).abs() <= 1e-9) + { + gaps.push([end, *b0, mid(b, &at)]); + } + if let Some(g) = repeats { + gaps.extend(pairs.iter().filter(|(pg, _)| (pg - g).abs() <= 1e-9).map(|(_, gap)| *gap)); + } + Some((start - r0, gaps)) + } + + /// A Spacing Guides mark: a line across the gap (start, end, where across) along the x axis + /// (`x`) or the y axis, with a tick `tick` long either side of it at each end. + fn gap_marks(&self, [u0, u1, c]: [f64; 3], x: bool, tick: f64) -> [Overlay; 3] { + let pt = |u: f64, c: f64| if x { Point::new(u, c) } else { Point::new(c, u) }; + let line = |a: Point, b: Point| Overlay::Line { a, b, color: self.style.color, dashed: false }; + [line(pt(u0, c), pt(u1, c)), line(pt(u0, c - tick), pt(u0, c + tick)), line(pt(u1, c - tick), pt(u1, c + tick))] + } + + /// [`Self::snap_rects`] before Spacing Guides, also saying whether each axis (x, y) lined up + /// (even with Alignment Guides off, or already in line, when there is no line or shift to + /// tell). + fn align_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec, [bool; 2]) { + let nearest = |targets: &[(f64, Point, Kind)], along: fn(&Rect) -> [f64; 3]| { + rects + .iter() + .flat_map(|r| along(r).into_iter().map(move |v| (v, *r))) + .flat_map(|(v, r)| lines_near(targets, v, tol).iter().map(move |(t, from, _)| (t - v, *from, v, r))) + .filter(|(d, ..)| d.abs() <= tol) + .min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())) + }; + let best_x = nearest(&self.xs, |r| [r.x0, r.center().x, r.x1]); + let best_y = nearest(&self.ys, |r| [r.y0, r.center().y, r.y1]); + let mut d = Vec2::ZERO; + let mut ov = vec![]; + if let Some((dx, from, x, r)) = best_x { + d.x = dx; + let x = x + dx; + let (y0, y1) = (from.y.min(r.y0), from.y.max(r.y1)); + ov.extend(self.line(Point::new(x, y0), Point::new(x, y1))); + } + if let Some((dy, from, y, r)) = best_y { + d.y = dy; + let y = y + dy; + let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1)); + ov.extend(self.line(Point::new(x0, y), Point::new(x1, y))); + } + (d, ov, [best_x.is_some(), best_y.is_some()]) + } + + /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for + /// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own + /// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page + /// (labelled as [`Self::snap_point`] does), else the corners and the side it moves line up with + /// the nearest target or ruler guide ("align"), whatever the box's angle. A handle with one way + /// to go (a side, or a proportional corner) slides along it until one of them is on a target. + pub fn snap_scale(&self, bx: &OrientedBox, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec) { + type Hit = Option<(f64, Point)>; + // Where a snap lands: in line with a target's x and/or y, or on an anchor or centre. + type Snap = (Hit, Hit, Option<(Point, Kind)>); + let r = bx.rect; + let origin = if from_center { r.center() } else { handle.opposite().pos(r) }; + let (d0, [sx, _, _, sy, _, _]) = (handle.pos(r) - origin, a.as_coeffs()); + let (to_doc, to_local) = (bx.to_doc(), bx.to_doc().inverse()); + // A scale that collapses or flips the box is no snap. + let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0; + // The target line along the x axis (`x`: a vertical line) or the y axis nearest `p`, the + // ruler guides running past it included. + let nearest = |p: Point, x: bool| -> Hit { + let (v, ts) = if x { (p.x, &self.xs) } else { (p.y, &self.ys) }; + let rulers = self.rulers.iter().filter(|r| r.vertical == x).filter_map(|r| r.line_at(p, tol)); + lines_near(ts, v, tol) + .iter() + .copied() + .chain(rulers) + .map(|(t, from, _)| (t, from)) + .filter(|(t, _)| (t - v).abs() <= tol) + .min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs())) + }; + let (mut nx, mut ny) = (sx, sy); + let mut snap: Snap = (None, None, None); + if handle.is_corner() && !proportional { + // The corner goes anywhere: onto an anchor or centre, else it takes the target's x, its + // y, or both. + let p = to_doc * (a * handle.pos(r)); + let (hx, hy) = (nearest(p, true), nearest(p, false)); + let on = self.nearest_point(p, tol).map(|(q, k)| (Some((q.x, q)), Some((q.y, q)), Some((q, k)))); + for (tx, ty, at) in on.into_iter().chain([(hx, hy, None), (hx, None, None), (None, hy, None)]) { + if tx.is_none() && ty.is_none() { + continue; + } + let q = to_local * Point::new(tx.map_or(p.x, |h| h.0), ty.map_or(p.y, |h| h.0)); + let (mut cx, mut cy) = ((q.x - origin.x) / d0.x, (q.y - origin.y) / d0.y); + // Square to the page, the axis without a target stays exactly where it was. + if bx.angle == 0.0 { + (cx, cy) = (if tx.is_some() { cx } else { sx }, if ty.is_some() { cy } else { sy }); + } + if valid(cx, sx) && valid(cy, sy) { + (nx, ny, snap) = (cx, cy, (tx, ty, at)); + break; + } + } + } else { + // One parameter `s`: the scale is `c + s·e` on each axis. + let (ax, _) = handle.axes(); + let (e, c, s0) = match (handle.is_corner(), ax, proportional) { + (true, _, _) => ((sx.signum(), sy.signum()), (0.0, 0.0), sx.abs()), + (false, true, true) => ((1.0, sx.signum()), (0.0, 0.0), sx), + (false, true, false) => ((1.0, 0.0), (0.0, 1.0), sx), + (false, false, true) => ((sy.signum(), 1.0), (0.0, 0.0), sy), + (false, false, false) => ((0.0, 1.0), (1.0, 0.0), sy), + }; + // The points that may land on a target: the handle, and the two ends of its side. + let i = handle as usize; + let ends = if handle.is_corner() { [None, None] } else { [Handle::ALL.get((i + 1) % 8), Handle::ALL.get((i + 7) % 8)] }; + // How far the handle travels per unit of `s`: a snap may not drag it far from the + // pointer, as it would for a target the side runs almost along. + let travel = (e.0 * d0.x).hypot(e.1 * d0.y); + // The snap so far: its `s`, whether it only lines up (an anchor or centre comes first), + // how far it moves the handle, and where it lands. + let mut best: Option<(f64, bool, f64, Snap)> = None; + let mut consider = |s: f64, in_line: bool, found: Snap| { + let moved = (s - s0).abs() * travel; + if valid(s, s0) && moved <= 2.0 * tol && best.as_ref().is_none_or(|k| (in_line, moved) < (k.1, k.2)) { + best = Some((s, in_line, moved, found)); + } + }; + for h in std::iter::once(handle).chain(ends.into_iter().flatten().copied()) { + let v = h.pos(r) - origin; + let base = to_doc * (origin + Vec2::new(c.0 * v.x, c.1 * v.y)); + let dir = (to_doc * Point::new(e.0 * v.x, e.1 * v.y)).to_vec2(); + let p = base + dir * s0; + // An anchor or centre beside its way: it stops at the nearest point of the way. + let l2 = dir.hypot2(); + if l2 > 1e-18 + && let Some((q, k)) = self.nearest_point(p, tol) + { + consider((q - base).dot(dir) / l2, false, (None, None, Some((q, k)))); + } + for (is_x, b, d) in [(true, base.x, dir.x), (false, base.y, dir.y)] { + if d.abs() <= 1e-9 { + continue; + } + let Some(hit) = nearest(p, is_x) else { continue }; + consider((hit.0 - b) / d, true, if is_x { (Some(hit), None, None) } else { (None, Some(hit), None) }); + } + } + if let Some((s, _, _, found)) = best { + (nx, ny, snap) = (c.0 + s * e.0, c.1 + s * e.1, found); + } + } + let mut out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2()); + let (hit_x, hit_y, on) = snap; + if let Some((q, k)) = on { + return (out, self.label(q, k.label()).into_iter().collect()); + } + // The lines run from the target's own point across the resized box, on the page. + let mut nr = (to_doc * out).transform_rect_bbox(r); + let mut ov = vec![]; + if let Some((x, from)) = hit_x { + ov.extend(self.line(Point::new(x, from.y.min(nr.y0)), Point::new(x, from.y.max(nr.y1)))); + } + if let Some((y, from)) = hit_y { + ov.extend(self.line(Point::new(from.x.min(nr.x0), y), Point::new(from.x.max(nr.x1), y))); + } + if hit_x.is_some() || hit_y.is_some() { + ov.extend(self.label(to_doc * (out * handle.pos(r)), "align")); + } + if !self.boxes.is_empty() && !from_center { + let aligned = [hit_x.is_some(), hit_y.is_some()]; + for (is_x, lined_up) in [(true, aligned[0]), (false, aligned[1])] { + if lined_up { + continue; + } + let Some(moves_min) = resize_moves_min_edge(handle, is_x) else { continue }; + if let Some((shift, gaps)) = self.even_spacing(nr, is_x, tol) { + let (lo, hi) = span(&nr, is_x); + let len = hi - lo; + let new_len = if moves_min { hi - (lo + shift) } else { hi + shift - lo }; + if len > 1e-9 && new_len > 1e-9 { + let f = new_len / len; + let (ax, ay) = handle.axes(); + if is_x { + nx *= f; + if proportional && ay { + ny *= f; + } + } else { + ny *= f; + if proportional && ax { + nx *= f; + } + } + out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2()); + nr = (to_doc * out).transform_rect_bbox(r); + ov.extend(gaps.into_iter().flat_map(|g| self.gap_marks(g, is_x, tol))); + } + } + } + } + (out, ov) + } +} From 1b9881ce1e9bbbb2a971950164592e39cc074e40 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:39:00 +0000 Subject: [PATCH 136/199] refactor(tools): move lib.rs items to {context,cursor,input}.rs --- crates/tools/src/context.rs | 284 +++++++++++++++ crates/tools/src/cursor.rs | 305 ++++++++++++++++ crates/tools/src/input.rs | 112 ++++++ crates/tools/src/lib.rs | 702 +----------------------------------- 4 files changed, 707 insertions(+), 696 deletions(-) create mode 100644 crates/tools/src/context.rs create mode 100644 crates/tools/src/cursor.rs create mode 100644 crates/tools/src/input.rs diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs new file mode 100644 index 000000000..298714b50 --- /dev/null +++ b/crates/tools/src/context.rs @@ -0,0 +1,284 @@ +use super::*; + +/// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template. +#[derive(Clone, Debug, PartialEq)] +pub struct PaintDefaults { + pub fill: Paint, + pub stroke: Paint, + pub stroke_width: f64, + /// The appearance new art takes (stroke options, more fills and strokes, effects), its top + /// fill and stroke repainted with `fill`, `stroke` and `stroke_width`; `None`: one plain fill + /// and stroke. Its placed gradients are relative to the unit box. + pub appearance: Option, + /// New art's opacity and blend mode. + pub opacity: f32, + pub blend: vectorcraft_color::BlendMode, + /// The graphic style the template came from (by name): new art is linked to it. + pub style: Option, + /// The template is the last selection's appearance (New Art Has Basic Appearance off): with + /// the option on again, new art takes only its basic fill and stroke. + pub inherited: bool, +} + +impl Default for PaintDefaults { + /// White fill, 1 pt black stroke, nothing else. + fn default() -> Self { + Self { + fill: Paint::solid(vectorcraft_color::Color::WHITE), + stroke: Paint::solid(vectorcraft_color::Color::BLACK), + stroke_width: 1.0, + appearance: None, + opacity: 1.0, + blend: Default::default(), + style: None, + inherited: false, + } + } +} + +/// The document window on screen, in document coordinates: widgets that stay put on screen (the +/// Plane Switching Widget) are placed with it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScreenFrame { + /// The window's top-left corner. + pub origin: Point, + /// One screen pixel to the right and one down. + pub right: Vec2, + pub down: Vec2, + /// The window's size in screen pixels. + pub size: (f64, f64), +} + +impl ScreenFrame { + /// The document point `x`, `y` screen pixels from the window's top-left corner. + pub fn at(&self, x: f64, y: f64) -> Point { + self.origin + self.right * x + self.down * y + } + /// Where the document point `p` is, in screen pixels from the window's top-left corner (the + /// inverse of [`Self::at`]); none for a degenerate frame. + pub fn to_screen(&self, p: Point) -> Option<(f64, f64)> { + let (r, d, v) = (self.right, self.down, p - self.origin); + let det = r.x * d.y - d.x * r.y; + (det.is_finite() && det.abs() > 1e-12).then(|| ((v.x * d.y - d.x * v.y) / det, (r.x * v.y - v.x * r.y) / det)) + } +} + +/// Read-only context a tool sees. +pub struct ToolContext<'a> { + pub doc: &'a Document, + /// The rulers' zero point with the view's active artboard (see + /// [`Document::ruler_origin_for`]): where ruler ticks and measured positions count from. + pub ruler_origin: Point, + /// Which open document `doc` is (its process-unique id) and its revision, which moves with + /// every change of the document or the selection: what a tool keeps between pointer events + /// for the document as it is (the snap targets of hovering) is keyed on it. + pub revision: (u64, u64), + pub selection: &'a Selection, + /// Screen pixels per document point. + pub zoom: f64, + pub isolation: Option, + pub paint: &'a PaintDefaults, + pub outline: bool, + pub smart_guides: bool, + /// View → Show Guides with Lock Guides off: the selection tools pick and drag ruler guides. + pub guides: bool, + pub snap_to_grid: bool, + /// Bounding box shown (View → Show/Hide Bounding Box). + pub show_bbox: bool, + /// View → Snap to Pixel. + pub snap_to_pixel: bool, + /// View → Snap to Point: with Smart Guides off, dragged selections, drawn points and picked + /// points (a transform's reference point) land on anchors and ruler guides within + /// [`Self::snap_tolerance`]. + pub snap_to_point: bool, + /// View → Snapping → Snap to Tangent: with Smart Guides on, a line drawn + /// from an anchor lands where it touches a nearby curve. + pub snap_tangent: bool, + /// View → Snapping → Snap to Perpendicular: likewise where it meets a nearby segment square. + pub snap_perpendicular: bool, + /// View → Snap to Glyph: drawn points snap to live-text baselines and glyph bounds (with Smart Guides). + pub snap_to_glyph: bool, + /// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners. + pub corner_widgets: bool, + /// Which paint proxy is in front (true = Fill): the one the Gradient tool edits. + pub fill_active: bool, + /// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator. + pub gradient_stop: Option, + /// The Appearance panel's active fill/stroke (paint-order index into the first selected + /// object's stack); paint tools show and edit that item. + pub appearance_item: Option, + /// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it. + pub constrain_angle: f64, + /// General → Keyboard Increment: arrow-key nudges in document units (Shift multiplies by 10). + pub keyboard_increment: f64, + /// The selected freeform gradient point (`paint.freeform.selectPoint`), marked on the + /// freeform annotator. + pub freeform_point: Option, + /// Eyedropper Options' raster sample size: the pixels square averaged when the Eyedropper + /// samples an image (1, 3 or 5). + pub raster_sample: u32, + /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). + pub preview_bounds: bool, + /// The session's memo of the selection tools' measurements for the document as it is + /// ([`select::PreviewMemo`]); without one they measure on every call. + pub preview_memo: Option<&'a select::PreviewMemo>, + /// Units ▸ General: the unit measurement labels show lengths in. + pub unit: Unit, + /// Units ▸ Stroke: the unit measurement labels show stroke widths in. + pub stroke_unit: Unit, + /// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes + /// the style at the caret, even the text the Type tool copied with its formatting. + pub paste_plain_text: bool, + /// View → Hide Slices: the Slice Selection tool can't pick hidden slices. + pub slices_hidden: bool, + /// View → Lock Slices: the Slice Selection tool leaves locked slices alone. + pub slices_locked: bool, + /// General → Disable Auto Add/Delete is off: the Pen adds an anchor on a selected path's + /// segment and deletes one of its anchors. + pub auto_add_delete: bool, + /// Selection & Anchor Display → Tolerance: how near (screen pixels) a click must be to a path + /// or an anchor to pick it. + pub selection_tolerance: f64, + /// Selection & Anchor Display → Size (1–7, default 3): how big anchors and handles are drawn. + pub anchor_size: u32, + /// Selection & Anchor Display → Object Selection by Path Only: a click inside a filled path + /// doesn't pick it, only one on its path does. + pub path_only: bool, + /// Type → Type Object Selection by Path Only: type is picked on its type path only (point + /// type's baseline, area type's frame, type on a path's path), not anywhere in its bounds. + pub type_path_only: bool, + /// General → Double Click To Isolate: a double-click on a group with the Selection tool + /// isolates it. + pub double_click_isolate: bool, + /// Selection & Anchor Display → Command Click to Select Objects Behind: Cmd/Ctrl-click with the + /// Selection tool selects the object under the selected one, the next click the one under + /// that. + pub select_behind: bool, + /// Selection & Anchor Display → Highlight anchors on mouse over: Direct Selection marks the + /// anchor under the pointer. + pub highlight_anchors: bool, + /// Selection & Anchor Display → Snap to Point (screen pixels): how near an anchor or a ruler + /// guide pulls the pointer while View → Snap to Point is on. + pub snap_tolerance: f64, + /// Selection & Anchor Display → Show handles when multiple anchors are selected: off, Direct + /// Selection shows and drags handles only while a single anchor is selected. + pub handles_multiple: bool, + /// Selection & Anchor Display → Hide Corner Widget for angles greater than (degrees): corners + /// wider than this show no Live Corners widget. + pub corner_widget_max_angle: f64, + /// Selection & Anchor Display → Move Locked and Hidden Artwork with Artboard. + pub move_locked_with_artboard: bool, + /// Selection & Anchor Display → Enable Rubber Band for Pen Tool: the Pen previews the next + /// segment to the pointer. + pub pen_rubber_band: bool, + /// Selection & Anchor Display → Enable Rubber Band for Curvature Tool. + pub curvature_rubber_band: bool, + /// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with + /// placeholder text, selected. + pub placeholder_text: bool, + /// Smart Guides → Color: the smart guides' lines and labels (RGB). + pub smart_guide_color: [u8; 3], + /// Smart Guides → Alignment Guides: the lines along the edges and centres the art lines up + /// with show. Off, the art still snaps into line. + pub alignment_guides: bool, + /// Smart Guides → Anchor/Path Labels: the "anchor", "center", "path"… labels show. + pub anchor_path_labels: bool, + /// Smart Guides → Measurement Labels: the size and offset readouts while drawing and moving. + pub measurement_labels: bool, + /// Smart Guides → Transform Tools: the readouts while scaling, rotating and shearing. + pub transform_tools_guides: bool, + /// Smart Guides → Spacing Guides: a moved selection snaps to spacing equal to the gap between + /// two other objects in its row or column, or evenly between its two neighbours, and the + /// equal gaps show. + pub spacing_guides: bool, + /// Smart Guides → Invoke Distance Guides: with the Selection tool, Alt/Option held over another + /// object shows how far the selection is from it, and over an empty part of an artboard how + /// far it is from the artboard's edges ([`guides::distance_guides`]). + pub distance_guides: bool, + /// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the + /// pointer, a dragged edge or a drawn point. + pub snapping_tolerance: f64, + /// Smart Guides → Construction Guides: the angles (degrees, counter-clockwise from the x axis + /// as seen on the page) of the lines through the point a drawn point leaves from, onto which a + /// point near one lands ([`guides::construction_angles`]); none with the option off. + pub construction_angles: &'static [f64], + /// The document window (none headless): screen-fixed widgets sit in it. + pub screen: Option, + /// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden. + pub plane_widget: Option, +} + +impl ToolContext<'_> { + /// Tolerance in document units for `px` screen pixels. + pub fn tol(&self, px: f64) -> f64 { + px / self.zoom.max(1e-9) + } + /// How far the view is turned (Rotate View), in degrees clockwise on screen; 0 without a + /// window. + pub fn view_angle(&self) -> f64 { + // `right` is one screen pixel to the right, in document units. + self.screen.map(|s| (-s.right.y).atan2(s.right.x).to_degrees()).filter(|a| a.is_finite()).unwrap_or(0.0) + } + /// How near (document units) a smart guide target pulls: Smart Guides → Snapping Tolerance. + pub fn snap_tol(&self) -> f64 { + self.tol(self.snapping_tolerance) + } + /// A length as measurement labels show it, in the General unit (`12.50 mm`). + pub fn len(&self, v: f64) -> String { + self.unit.readout(v) + } + /// A size label: `W: …` over `H: …`. + pub fn size_label(&self, w: f64, h: f64) -> String { + format!("W: {}\nH: {}", self.len(w), self.len(h)) + } + /// Star tool drag: outer (R1) and inner (R2) radii from the centre to the pointer. + pub fn star_radii_label(&self, outer: f64, inner: f64) -> String { + format!("R1: {}\nR2: {}", self.len(outer), self.len(inner)) + } + /// A move label: `dX: …` over `dY: …`. + pub fn offset_label(&self, dx: f64, dy: f64) -> String { + format!("dX: {}\ndY: {}", self.len(dx), self.len(dy)) + } + /// A segment label while drawing: distance, angle and offsets between two points. + pub fn segment_measure_label(&self, a: Point, b: Point) -> String { + let (dist, ang, dx, dy) = xform::measure(a, b); + format!("D: {}\nAngle: {ang:.1}°\n{}", self.len(dist), self.offset_label(dx, dy)) + } + /// What Snap to Grid snaps to: the grid's subdivisions (or its lines without any). + pub fn grid_step(&self) -> f64 { + self.doc.grid.spacing / self.doc.grid.subdivisions.max(1) as f64 + } + /// The selection tolerance in document units ([`Self::selection_tolerance`]). + pub fn pick_tol(&self) -> f64 { + self.tol(self.selection_tolerance) + } + /// How near (document units) a press picks an anchor or a direction handle's end: the + /// selection tolerance, but at least 2 px past the drawn point, so a point is easy to catch + /// and wins over the segments through it, which are tested after it (#593). + pub fn point_tol(&self) -> f64 { + // Anchors are drawn 5 px wide at the default Size 3, a pixel more or less per step. + let half = (5.0 + f64::from(self.anchor_size.clamp(1, 7)) - 3.0) / 2.0; + self.tol(self.selection_tolerance.max(half + 2.0)) + } + pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions { + vectorcraft_doc::hit::HitOptions { + tol: self.pick_tol(), + outline: self.outline, + path_only: self.path_only, + type_path_only: self.type_path_only, + scope: self.isolation, + skip_guides: !self.guides, + clip_outlines: false, + } + } + + /// [`Self::hit_options`] for the selection tools (Selection, Direct Selection, Group + /// Selection): a clipping path is also hit on its outline ([`HitOptions::clip_outlines`]). + /// Only for what a press or a hover selects: a double-click, loaded text or a guide's release + /// acts on what is drawn and takes [`Self::hit_options`]. + /// + /// [`HitOptions::clip_outlines`]: vectorcraft_doc::hit::HitOptions::clip_outlines + pub fn selection_hit_options(&self) -> vectorcraft_doc::hit::HitOptions { + vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..self.hit_options() } + } +} diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs new file mode 100644 index 000000000..7661f3d72 --- /dev/null +++ b/crates/tools/src/cursor.rs @@ -0,0 +1,305 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Cursor { + #[default] + Arrow, + ArrowHollow, + /// Direct Selection over an anchor of a path that isn't selected: the white arrow with a + /// hollow square. + DirectAnchor, + /// Direct Selection over an anchor of a selected path: the white arrow with a filled square. + DirectAnchorSelected, + /// Over a segment a drag reshapes (Direct Selection, the Anchor Point tool, the Pen with Alt): + /// an arrowhead with a small curve between two anchor marks. + ReshapeSegment, + /// The Group Selection tool, and Direct Selection with Alt held: the white arrow with a plus. + GroupSelection, + /// Alt-dragging a copy with the Selection or Direct Selection tool: a double arrowhead. + SelectionCopy, + Move, + Crosshair, + ResizeH, + ResizeV, + ResizeNwSe, + ResizeNeSw, + /// Free Transform with Cmd/Ctrl over the top or bottom side, where a drag shears the object + /// sideways: two arrows side by side, pointing opposite ways along that side. + ShearH, + /// Free Transform with Cmd/Ctrl over the left or right side, where a drag shears the object up + /// or down: the ShearH arrows turned upright. + ShearV, + Rotate, + /// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off + /// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps + /// the corner it is at, so at the top right it opens toward the bottom left. Make it with + /// [`Cursor::rotate_about`]. + RotateToward(u8), + /// Over a Live Corners widget (drag to round the corners). + CornerRadius, + /// The Pen with the new-path asterisk: a click starts a new path. + Pen, + /// The Pen without a badge, while a path is being drawn: a click adds its next anchor. + PenDrawing, + PenAdd, + PenDelete, + PenClose, + PenContinue, + /// The Anchor Point tool, and the Pen with Alt held (which lends its gestures): a caret, its tip + /// on the anchor it converts. + AnchorPoint, + /// The Curvature tool: the pen nib with a curve badge. + Curvature, + /// While drawing, over an end of another open path (a click joins the two). + PenJoin, + /// Over the last anchor of the path being drawn: a click retracts its outgoing handle. + PenConvert, + Text, + Hand, + HandGrab, + ZoomIn, + ZoomOut, + Eyedropper, + NotAllowed, + /// A long operation (opening a file, a background save or export): the OS wait cursor. + Busy, + /// Over the gradient annotator's bar: a click adds a stop. + AddStop, + /// A gradient stop dragged off the bar: releasing deletes it. + RemoveStop, + /// The Slice tool: a crosshair with a blade. + Slice, + /// The Slice Selection tool: the arrow with a slice badge. + SliceSelect, + /// The Width tool away from strokes. + Width, + /// The Width tool over a stroke: a drag adds a width point. + WidthAdd, + /// The Width tool over a width point or a handle end: a drag moves or widens it. + WidthPoint, + /// The Blend tool away from art: a crosshair with a hollow square. + Blend, + /// The Blend tool over an object it can blend: a crosshair with a filled square. + BlendObject, + /// The Blend tool over an anchor point (the blend starts there): a crosshair with a target. + BlendAnchor, + /// The Objects on Path tool over a path the selected objects can attach to (a click attaches + /// them). + ObjectsOnPath, + /// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket. + PathBracket, + /// Over the type widget of selected type (a double-click converts point type to area type and + /// back): the arrow with a type badge. + TypeWidget, + /// The Selection tool loaded with the text of area type or type on a path by a click on its out + /// or in port (a click threads it into a path or a new frame, a drag draws the new frame). + LoadedText, + /// The Shape Builder: a crosshair with a plus (merge mode)... + ShapeBuilder, + /// ...or, with Alt held, a minus (erase mode). + ShapeBuilderErase, + /// The Lasso tool. + Lasso, + /// The Magic Wand tool. + MagicWand, + /// The Pencil tool. + Pencil, + /// The Pencil tool before a press that draws a new freehand path: the pointer is not over a + /// selected path that the press would continue or redraw. A crosshair with a small star. + PencilNew, + /// The Pencil tool with Alt pressed after the drag starts and Alt key toggles to Smooth Tool off: + /// releasing the mouse button with Alt still held closes the path. A drag started with Shift or + /// Alt held draws an open straight line instead. Also while a freehand stroke is drawn back near + /// its start, where releasing the mouse button closes the path. A crosshair with a ring. + PencilClose, + /// The Pencil tool before a press that draws a straight line (Shift or Alt held). A crosshair with a + /// short straight line. + PencilStraight, + /// The Pencil tool before a press that draws a straight line (Shift or Alt held), over an end of + /// a selected open path: the line continues the path from that end. A crosshair with a short + /// straight line leaving a square end point. + PencilContinue, + /// The Paintbrush tool. + Paintbrush, + /// The Paintbrush tool with Alt held: releasing the mouse button with Alt still held closes + /// the path. Also while a stroke is drawn back near its start, where releasing the mouse button + /// closes the path. A crosshair with a ring. + PaintbrushClose, + /// The Blob Brush tool. + BlobBrush, + /// The Eraser tool. + Eraser, + /// The Smooth tool. + Smooth, + /// The Path Eraser tool. + PathEraser, + /// The Join tool. + Join, + /// The Knife tool. + Knife, + /// The Shaper tool. + Shaper, + /// The Live Paint Bucket. + LivePaintBucket, + /// The Measure tool. + Measure, + /// The Print Tiling tool. + PrintTiling, + /// The Symbol Sprayer tool. + SymbolSprayer, + /// The Symbol Shifter tool. + SymbolShifter, + /// The Symbol Scruncher tool. + SymbolScruncher, + /// The Symbol Sizer tool. + SymbolSizer, + /// The Symbol Spinner tool. + SymbolSpinner, + /// The Symbol Stainer tool. + SymbolStainer, + /// The Symbol Screener tool. + SymbolScreener, + /// The Symbol Styler tool. + SymbolStyler, + /// The vertical type tools: a turned I-beam. + TypeVertical, + /// The Scissors over a path segment (a click splits the path there)... + Scissors, + /// ...or away from every segment (a click does nothing). + ScissorsNotAllowed, + /// The Mesh tool off a mesh point: a click adds one. + MeshAdd, + /// Puppet Warp over the selection's mesh where a pin can go: a click adds one. + PuppetPin, +} + +impl Cursor { + /// The rotate cursor for the pointer at `p` turning art about `center`: [`Cursor::RotateToward`] + /// the pointer's side of it, or the plain [`Cursor::Rotate`] on the centre itself. + pub fn rotate_about(center: Point, p: Point) -> Self { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return Cursor::Rotate; + } + // y points down the page: atan2(x, -y) is 0 straight up and grows clockwise. + let octant = (d.x.atan2(-d.y) / std::f64::consts::FRAC_PI_4).round() as i64; + Cursor::RotateToward(octant.rem_euclid(8) as u8) + } + + /// [`Cursor::rotate_about`] in a view turned `view` degrees clockwise + /// ([`ToolContext::view_angle`]): the pointer's side of `center` as it shows on screen. The + /// pointer's direction turns with the view before it rounds to the nearest 45°. + pub fn rotate_about_in_view(center: Point, p: Point, view: f64) -> Self { + let (sn, cs) = if view.is_finite() { view.to_radians().sin_cos() } else { (0.0, 1.0) }; + let d = p - center; + // y points down the page, so this turns `d` clockwise on the page. + Cursor::rotate_about(center, center + Vec2::new(d.x * cs - d.y * sn, d.x * sn + d.y * cs)) + } + + /// The cursor as it shows in a view turned `deg` degrees clockwise (Rotate View): the resize + /// cursors and [`Cursor::RotateToward`] turn with the view to the nearest 45°, and every other + /// cursor stays as it is. + pub fn turned(self, deg: f64) -> Self { + const RESIZE: [Cursor; 4] = [Cursor::ResizeH, Cursor::ResizeNeSw, Cursor::ResizeV, Cursor::ResizeNwSe]; + let k = if deg.is_finite() { (deg / 45.0).round() as i64 } else { 0 }; + match self { + Cursor::RotateToward(o) => Cursor::RotateToward((i64::from(o) + k).rem_euclid(8) as u8), + c => match RESIZE.iter().position(|r| *r == c) { + Some(i) => RESIZE.get((i as i64 - k).rem_euclid(4) as usize).copied().unwrap_or(c), + None => c, + }, + } + } + + /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the + /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. + pub fn precise(self) -> Self { + match self { + Cursor::Pen + | Cursor::PenDrawing + | Cursor::PenAdd + | Cursor::PenDelete + | Cursor::PenClose + | Cursor::PenContinue + | Cursor::PenJoin + | Cursor::PenConvert + | Cursor::AnchorPoint + | Cursor::Curvature + | Cursor::Eyedropper + | Cursor::Slice + | Cursor::Blend + | Cursor::BlendObject + | Cursor::BlendAnchor + | Cursor::ShapeBuilder + | Cursor::ShapeBuilderErase + | Cursor::Lasso + | Cursor::MagicWand + | Cursor::Pencil + | Cursor::PencilNew + | Cursor::PencilClose + | Cursor::PencilStraight + | Cursor::PencilContinue + | Cursor::Paintbrush + | Cursor::PaintbrushClose + | Cursor::BlobBrush + | Cursor::Eraser + | Cursor::Smooth + | Cursor::PathEraser + | Cursor::Join + | Cursor::Knife + | Cursor::Shaper + | Cursor::LivePaintBucket + | Cursor::Measure + | Cursor::PrintTiling + | Cursor::SymbolSprayer + | Cursor::SymbolShifter + | Cursor::SymbolScruncher + | Cursor::SymbolSizer + | Cursor::SymbolSpinner + | Cursor::SymbolStainer + | Cursor::SymbolScreener + | Cursor::SymbolStyler + | Cursor::Scissors + | Cursor::MeshAdd + | Cursor::PuppetPin => Cursor::Crosshair, + c => c, + } + } + + /// The cursor this one borrows until it's drawn: the generic crosshair for the + /// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet + /// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of + /// its own. + pub fn stand_in(self) -> Option { + match self { + Cursor::Lasso + | Cursor::MagicWand + | Cursor::Pencil + | Cursor::Paintbrush + | Cursor::BlobBrush + | Cursor::Eraser + | Cursor::Smooth + | Cursor::PathEraser + | Cursor::Join + | Cursor::Knife + | Cursor::Shaper + | Cursor::LivePaintBucket + | Cursor::Measure + | Cursor::ObjectsOnPath + | Cursor::PrintTiling + | Cursor::SymbolSprayer + | Cursor::SymbolShifter + | Cursor::SymbolScruncher + | Cursor::SymbolSizer + | Cursor::SymbolSpinner + | Cursor::SymbolStainer + | Cursor::SymbolScreener + | Cursor::SymbolStyler + | Cursor::Scissors => Some(Cursor::Crosshair), + Cursor::TypeVertical => Some(Cursor::Text), + Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd), + Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed), + _ => None, + } + } +} diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs new file mode 100644 index 000000000..804939840 --- /dev/null +++ b/crates/tools/src/input.rs @@ -0,0 +1,112 @@ +use super::*; + +/// Modifier keys. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Mods { + #[serde(default)] + pub shift: bool, + /// Option on macOS. + #[serde(default)] + pub alt: bool, + /// Command on macOS, Ctrl elsewhere. + #[serde(default)] + pub cmd: bool, + #[serde(default)] + pub ctrl: bool, + #[serde(default)] + pub space: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PointerKind { + /// Button pressed. + Down, + /// Moved with the button held. + Drag, + /// Button released. + Up, + /// Moved with no button (hover). + Move, + DoubleClick, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PointerEvent { + pub kind: PointerKind, + /// Document coordinates. + pub pos: Point, + #[serde(default)] + pub mods: Mods, + #[serde(default = "one")] + pub pressure: f32, +} + +fn one() -> f32 { + 1.0 +} + +impl PointerEvent { + pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { + Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0 } + } + pub fn with_mods(mut self, m: Mods) -> Self { + self.mods = m; + self + } + /// The pen `pressure` of a pointer event given as JSON (control channel, MCP): 0..1, default 1. + pub fn json_pressure(e: &Value) -> f32 { + e.get("pressure").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(1.0, |f| f.clamp(0.0, 1.0) as f32) + } + /// How long the pointer then holds still, in seconds, from a JSON pointer event's `holdMs` + /// (0..60000; default 0): the time [`Tool::tick`] gets. + pub fn json_hold(e: &Value) -> f64 { + e.get("holdMs").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(0.0, |ms| ms.clamp(0.0, 60_000.0) / 1000.0) + } +} + +/// Keys tools care about. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ToolKey { + Enter, + Escape, + Backspace, + Delete, + Up, + Down, + Left, + Right, + /// Illustrator: ↑/↓ while drawing a polygon/star change sides/points; `[`/`]` change sizes. + BracketLeft, + BracketRight, + Tab, + Home, + End, + /// A digit key 0–9 (5 while dragging with the Perspective Selection tool moves perpendicular + /// to the plane). + Digit(u8), + /// A letter key a–z, lowercase (E while dragging a Selection or Group Selection marquee makes it + /// select only the objects entirely inside it, and E again switches it back). + Letter(char), +} + +/// What a tool asks the engine to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + /// Start an interaction (snapshot the document). The label becomes the undo name. + Begin(String), + /// Replace the current preview with this command, applied to the snapshot. + Preview(String, Value), + /// Finish the interaction: keep the last preview as one undo step. + Commit, + /// Abort the interaction: restore the snapshot. + Cancel, + /// Execute a command immediately (its own undo step, if it edits). + Exec(String, Value), + /// Ask the UI to open a dialog (e.g. click with the Rectangle tool → size dialog). + Dialog(String, Value), + /// Switch to another tool (e.g. after placing a text frame). + SwitchTool(String), + /// Call the tool's `notify` after the preceding actions ran (e.g. to pick up a created object). + Notify(String), +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index b2b2c97c8..e55bb4069 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -51,399 +51,13 @@ use vectorcraft_geom::{BezPath, Point, Rect, Vec2}; pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info}; -/// Modifier keys. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct Mods { - #[serde(default)] - pub shift: bool, - /// Option on macOS. - #[serde(default)] - pub alt: bool, - /// Command on macOS, Ctrl elsewhere. - #[serde(default)] - pub cmd: bool, - #[serde(default)] - pub ctrl: bool, - #[serde(default)] - pub space: bool, -} +mod context; +mod cursor; +mod input; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum PointerKind { - /// Button pressed. - Down, - /// Moved with the button held. - Drag, - /// Button released. - Up, - /// Moved with no button (hover). - Move, - DoubleClick, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct PointerEvent { - pub kind: PointerKind, - /// Document coordinates. - pub pos: Point, - #[serde(default)] - pub mods: Mods, - #[serde(default = "one")] - pub pressure: f32, -} - -fn one() -> f32 { - 1.0 -} - -impl PointerEvent { - pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { - Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0 } - } - pub fn with_mods(mut self, m: Mods) -> Self { - self.mods = m; - self - } - /// The pen `pressure` of a pointer event given as JSON (control channel, MCP): 0..1, default 1. - pub fn json_pressure(e: &Value) -> f32 { - e.get("pressure").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(1.0, |f| f.clamp(0.0, 1.0) as f32) - } - /// How long the pointer then holds still, in seconds, from a JSON pointer event's `holdMs` - /// (0..60000; default 0): the time [`Tool::tick`] gets. - pub fn json_hold(e: &Value) -> f64 { - e.get("holdMs").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(0.0, |ms| ms.clamp(0.0, 60_000.0) / 1000.0) - } -} - -/// Keys tools care about. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum ToolKey { - Enter, - Escape, - Backspace, - Delete, - Up, - Down, - Left, - Right, - /// Illustrator: ↑/↓ while drawing a polygon/star change sides/points; `[`/`]` change sizes. - BracketLeft, - BracketRight, - Tab, - Home, - End, - /// A digit key 0–9 (5 while dragging with the Perspective Selection tool moves perpendicular - /// to the plane). - Digit(u8), - /// A letter key a–z, lowercase (E while dragging a Selection or Group Selection marquee makes it - /// select only the objects entirely inside it, and E again switches it back). - Letter(char), -} - -/// What a tool asks the engine to do. -#[derive(Clone, Debug, PartialEq)] -pub enum Action { - /// Start an interaction (snapshot the document). The label becomes the undo name. - Begin(String), - /// Replace the current preview with this command, applied to the snapshot. - Preview(String, Value), - /// Finish the interaction: keep the last preview as one undo step. - Commit, - /// Abort the interaction: restore the snapshot. - Cancel, - /// Execute a command immediately (its own undo step, if it edits). - Exec(String, Value), - /// Ask the UI to open a dialog (e.g. click with the Rectangle tool → size dialog). - Dialog(String, Value), - /// Switch to another tool (e.g. after placing a text frame). - SwitchTool(String), - /// Call the tool's `notify` after the preceding actions ran (e.g. to pick up a created object). - Notify(String), -} - -/// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template. -#[derive(Clone, Debug, PartialEq)] -pub struct PaintDefaults { - pub fill: Paint, - pub stroke: Paint, - pub stroke_width: f64, - /// The appearance new art takes (stroke options, more fills and strokes, effects), its top - /// fill and stroke repainted with `fill`, `stroke` and `stroke_width`; `None`: one plain fill - /// and stroke. Its placed gradients are relative to the unit box. - pub appearance: Option, - /// New art's opacity and blend mode. - pub opacity: f32, - pub blend: vectorcraft_color::BlendMode, - /// The graphic style the template came from (by name): new art is linked to it. - pub style: Option, - /// The template is the last selection's appearance (New Art Has Basic Appearance off): with - /// the option on again, new art takes only its basic fill and stroke. - pub inherited: bool, -} - -impl Default for PaintDefaults { - /// White fill, 1 pt black stroke, nothing else. - fn default() -> Self { - Self { - fill: Paint::solid(vectorcraft_color::Color::WHITE), - stroke: Paint::solid(vectorcraft_color::Color::BLACK), - stroke_width: 1.0, - appearance: None, - opacity: 1.0, - blend: Default::default(), - style: None, - inherited: false, - } - } -} - -/// The document window on screen, in document coordinates: widgets that stay put on screen (the -/// Plane Switching Widget) are placed with it. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ScreenFrame { - /// The window's top-left corner. - pub origin: Point, - /// One screen pixel to the right and one down. - pub right: Vec2, - pub down: Vec2, - /// The window's size in screen pixels. - pub size: (f64, f64), -} - -impl ScreenFrame { - /// The document point `x`, `y` screen pixels from the window's top-left corner. - pub fn at(&self, x: f64, y: f64) -> Point { - self.origin + self.right * x + self.down * y - } - /// Where the document point `p` is, in screen pixels from the window's top-left corner (the - /// inverse of [`Self::at`]); none for a degenerate frame. - pub fn to_screen(&self, p: Point) -> Option<(f64, f64)> { - let (r, d, v) = (self.right, self.down, p - self.origin); - let det = r.x * d.y - d.x * r.y; - (det.is_finite() && det.abs() > 1e-12).then(|| ((v.x * d.y - d.x * v.y) / det, (r.x * v.y - v.x * r.y) / det)) - } -} - -/// Read-only context a tool sees. -pub struct ToolContext<'a> { - pub doc: &'a Document, - /// The rulers' zero point with the view's active artboard (see - /// [`Document::ruler_origin_for`]): where ruler ticks and measured positions count from. - pub ruler_origin: Point, - /// Which open document `doc` is (its process-unique id) and its revision, which moves with - /// every change of the document or the selection: what a tool keeps between pointer events - /// for the document as it is (the snap targets of hovering) is keyed on it. - pub revision: (u64, u64), - pub selection: &'a Selection, - /// Screen pixels per document point. - pub zoom: f64, - pub isolation: Option, - pub paint: &'a PaintDefaults, - pub outline: bool, - pub smart_guides: bool, - /// View → Show Guides with Lock Guides off: the selection tools pick and drag ruler guides. - pub guides: bool, - pub snap_to_grid: bool, - /// Bounding box shown (View → Show/Hide Bounding Box). - pub show_bbox: bool, - /// View → Snap to Pixel. - pub snap_to_pixel: bool, - /// View → Snap to Point: with Smart Guides off, dragged selections, drawn points and picked - /// points (a transform's reference point) land on anchors and ruler guides within - /// [`Self::snap_tolerance`]. - pub snap_to_point: bool, - /// View → Snapping → Snap to Tangent: with Smart Guides on, a line drawn - /// from an anchor lands where it touches a nearby curve. - pub snap_tangent: bool, - /// View → Snapping → Snap to Perpendicular: likewise where it meets a nearby segment square. - pub snap_perpendicular: bool, - /// View → Snap to Glyph: drawn points snap to live-text baselines and glyph bounds (with Smart Guides). - pub snap_to_glyph: bool, - /// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners. - pub corner_widgets: bool, - /// Which paint proxy is in front (true = Fill): the one the Gradient tool edits. - pub fill_active: bool, - /// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator. - pub gradient_stop: Option, - /// The Appearance panel's active fill/stroke (paint-order index into the first selected - /// object's stack); paint tools show and edit that item. - pub appearance_item: Option, - /// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it. - pub constrain_angle: f64, - /// General → Keyboard Increment: arrow-key nudges in document units (Shift multiplies by 10). - pub keyboard_increment: f64, - /// The selected freeform gradient point (`paint.freeform.selectPoint`), marked on the - /// freeform annotator. - pub freeform_point: Option, - /// Eyedropper Options' raster sample size: the pixels square averaged when the Eyedropper - /// samples an image (1, 3 or 5). - pub raster_sample: u32, - /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). - pub preview_bounds: bool, - /// The session's memo of the selection tools' measurements for the document as it is - /// ([`select::PreviewMemo`]); without one they measure on every call. - pub preview_memo: Option<&'a select::PreviewMemo>, - /// Units ▸ General: the unit measurement labels show lengths in. - pub unit: Unit, - /// Units ▸ Stroke: the unit measurement labels show stroke widths in. - pub stroke_unit: Unit, - /// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes - /// the style at the caret, even the text the Type tool copied with its formatting. - pub paste_plain_text: bool, - /// View → Hide Slices: the Slice Selection tool can't pick hidden slices. - pub slices_hidden: bool, - /// View → Lock Slices: the Slice Selection tool leaves locked slices alone. - pub slices_locked: bool, - /// General → Disable Auto Add/Delete is off: the Pen adds an anchor on a selected path's - /// segment and deletes one of its anchors. - pub auto_add_delete: bool, - /// Selection & Anchor Display → Tolerance: how near (screen pixels) a click must be to a path - /// or an anchor to pick it. - pub selection_tolerance: f64, - /// Selection & Anchor Display → Size (1–7, default 3): how big anchors and handles are drawn. - pub anchor_size: u32, - /// Selection & Anchor Display → Object Selection by Path Only: a click inside a filled path - /// doesn't pick it, only one on its path does. - pub path_only: bool, - /// Type → Type Object Selection by Path Only: type is picked on its type path only (point - /// type's baseline, area type's frame, type on a path's path), not anywhere in its bounds. - pub type_path_only: bool, - /// General → Double Click To Isolate: a double-click on a group with the Selection tool - /// isolates it. - pub double_click_isolate: bool, - /// Selection & Anchor Display → Command Click to Select Objects Behind: Cmd/Ctrl-click with the - /// Selection tool selects the object under the selected one, the next click the one under - /// that. - pub select_behind: bool, - /// Selection & Anchor Display → Highlight anchors on mouse over: Direct Selection marks the - /// anchor under the pointer. - pub highlight_anchors: bool, - /// Selection & Anchor Display → Snap to Point (screen pixels): how near an anchor or a ruler - /// guide pulls the pointer while View → Snap to Point is on. - pub snap_tolerance: f64, - /// Selection & Anchor Display → Show handles when multiple anchors are selected: off, Direct - /// Selection shows and drags handles only while a single anchor is selected. - pub handles_multiple: bool, - /// Selection & Anchor Display → Hide Corner Widget for angles greater than (degrees): corners - /// wider than this show no Live Corners widget. - pub corner_widget_max_angle: f64, - /// Selection & Anchor Display → Move Locked and Hidden Artwork with Artboard. - pub move_locked_with_artboard: bool, - /// Selection & Anchor Display → Enable Rubber Band for Pen Tool: the Pen previews the next - /// segment to the pointer. - pub pen_rubber_band: bool, - /// Selection & Anchor Display → Enable Rubber Band for Curvature Tool. - pub curvature_rubber_band: bool, - /// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with - /// placeholder text, selected. - pub placeholder_text: bool, - /// Smart Guides → Color: the smart guides' lines and labels (RGB). - pub smart_guide_color: [u8; 3], - /// Smart Guides → Alignment Guides: the lines along the edges and centres the art lines up - /// with show. Off, the art still snaps into line. - pub alignment_guides: bool, - /// Smart Guides → Anchor/Path Labels: the "anchor", "center", "path"… labels show. - pub anchor_path_labels: bool, - /// Smart Guides → Measurement Labels: the size and offset readouts while drawing and moving. - pub measurement_labels: bool, - /// Smart Guides → Transform Tools: the readouts while scaling, rotating and shearing. - pub transform_tools_guides: bool, - /// Smart Guides → Spacing Guides: a moved selection snaps to spacing equal to the gap between - /// two other objects in its row or column, or evenly between its two neighbours, and the - /// equal gaps show. - pub spacing_guides: bool, - /// Smart Guides → Invoke Distance Guides: with the Selection tool, Alt/Option held over another - /// object shows how far the selection is from it, and over an empty part of an artboard how - /// far it is from the artboard's edges ([`guides::distance_guides`]). - pub distance_guides: bool, - /// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the - /// pointer, a dragged edge or a drawn point. - pub snapping_tolerance: f64, - /// Smart Guides → Construction Guides: the angles (degrees, counter-clockwise from the x axis - /// as seen on the page) of the lines through the point a drawn point leaves from, onto which a - /// point near one lands ([`guides::construction_angles`]); none with the option off. - pub construction_angles: &'static [f64], - /// The document window (none headless): screen-fixed widgets sit in it. - pub screen: Option, - /// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden. - pub plane_widget: Option, -} - -impl ToolContext<'_> { - /// Tolerance in document units for `px` screen pixels. - pub fn tol(&self, px: f64) -> f64 { - px / self.zoom.max(1e-9) - } - /// How far the view is turned (Rotate View), in degrees clockwise on screen; 0 without a - /// window. - pub fn view_angle(&self) -> f64 { - // `right` is one screen pixel to the right, in document units. - self.screen.map(|s| (-s.right.y).atan2(s.right.x).to_degrees()).filter(|a| a.is_finite()).unwrap_or(0.0) - } - /// How near (document units) a smart guide target pulls: Smart Guides → Snapping Tolerance. - pub fn snap_tol(&self) -> f64 { - self.tol(self.snapping_tolerance) - } - /// A length as measurement labels show it, in the General unit (`12.50 mm`). - pub fn len(&self, v: f64) -> String { - self.unit.readout(v) - } - /// A size label: `W: …` over `H: …`. - pub fn size_label(&self, w: f64, h: f64) -> String { - format!("W: {}\nH: {}", self.len(w), self.len(h)) - } - /// Star tool drag: outer (R1) and inner (R2) radii from the centre to the pointer. - pub fn star_radii_label(&self, outer: f64, inner: f64) -> String { - format!("R1: {}\nR2: {}", self.len(outer), self.len(inner)) - } - /// A move label: `dX: …` over `dY: …`. - pub fn offset_label(&self, dx: f64, dy: f64) -> String { - format!("dX: {}\ndY: {}", self.len(dx), self.len(dy)) - } - /// A segment label while drawing: distance, angle and offsets between two points. - pub fn segment_measure_label(&self, a: Point, b: Point) -> String { - let (dist, ang, dx, dy) = xform::measure(a, b); - format!("D: {}\nAngle: {ang:.1}°\n{}", self.len(dist), self.offset_label(dx, dy)) - } - /// What Snap to Grid snaps to: the grid's subdivisions (or its lines without any). - pub fn grid_step(&self) -> f64 { - self.doc.grid.spacing / self.doc.grid.subdivisions.max(1) as f64 - } - /// The selection tolerance in document units ([`Self::selection_tolerance`]). - pub fn pick_tol(&self) -> f64 { - self.tol(self.selection_tolerance) - } - /// How near (document units) a press picks an anchor or a direction handle's end: the - /// selection tolerance, but at least 2 px past the drawn point, so a point is easy to catch - /// and wins over the segments through it, which are tested after it (#593). - pub fn point_tol(&self) -> f64 { - // Anchors are drawn 5 px wide at the default Size 3, a pixel more or less per step. - let half = (5.0 + f64::from(self.anchor_size.clamp(1, 7)) - 3.0) / 2.0; - self.tol(self.selection_tolerance.max(half + 2.0)) - } - pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions { - vectorcraft_doc::hit::HitOptions { - tol: self.pick_tol(), - outline: self.outline, - path_only: self.path_only, - type_path_only: self.type_path_only, - scope: self.isolation, - skip_guides: !self.guides, - clip_outlines: false, - } - } - - /// [`Self::hit_options`] for the selection tools (Selection, Direct Selection, Group - /// Selection): a clipping path is also hit on its outline ([`HitOptions::clip_outlines`]). - /// Only for what a press or a hover selects: a double-click, loaded text or a guide's release - /// acts on what is drawn and takes [`Self::hit_options`]. - /// - /// [`HitOptions::clip_outlines`]: vectorcraft_doc::hit::HitOptions::clip_outlines - pub fn selection_hit_options(&self) -> vectorcraft_doc::hit::HitOptions { - vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..self.hit_options() } - } -} +pub use context::{PaintDefaults, ScreenFrame, ToolContext}; +pub use cursor::Cursor; +pub use input::{Action, Mods, PointerEvent, PointerKind, ToolKey}; /// Visual feedback drawn by the UI in screen space (coordinates here are document space). #[derive(Clone, Debug, PartialEq)] @@ -475,310 +89,6 @@ pub enum Overlay { GridLine { a: Point, b: Point, color: [u8; 4] }, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Cursor { - #[default] - Arrow, - ArrowHollow, - /// Direct Selection over an anchor of a path that isn't selected: the white arrow with a - /// hollow square. - DirectAnchor, - /// Direct Selection over an anchor of a selected path: the white arrow with a filled square. - DirectAnchorSelected, - /// Over a segment a drag reshapes (Direct Selection, the Anchor Point tool, the Pen with Alt): - /// an arrowhead with a small curve between two anchor marks. - ReshapeSegment, - /// The Group Selection tool, and Direct Selection with Alt held: the white arrow with a plus. - GroupSelection, - /// Alt-dragging a copy with the Selection or Direct Selection tool: a double arrowhead. - SelectionCopy, - Move, - Crosshair, - ResizeH, - ResizeV, - ResizeNwSe, - ResizeNeSw, - /// Free Transform with Cmd/Ctrl over the top or bottom side, where a drag shears the object - /// sideways: two arrows side by side, pointing opposite ways along that side. - ShearH, - /// Free Transform with Cmd/Ctrl over the left or right side, where a drag shears the object up - /// or down: the ShearH arrows turned upright. - ShearV, - Rotate, - /// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off - /// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps - /// the corner it is at, so at the top right it opens toward the bottom left. Make it with - /// [`Cursor::rotate_about`]. - RotateToward(u8), - /// Over a Live Corners widget (drag to round the corners). - CornerRadius, - /// The Pen with the new-path asterisk: a click starts a new path. - Pen, - /// The Pen without a badge, while a path is being drawn: a click adds its next anchor. - PenDrawing, - PenAdd, - PenDelete, - PenClose, - PenContinue, - /// The Anchor Point tool, and the Pen with Alt held (which lends its gestures): a caret, its tip - /// on the anchor it converts. - AnchorPoint, - /// The Curvature tool: the pen nib with a curve badge. - Curvature, - /// While drawing, over an end of another open path (a click joins the two). - PenJoin, - /// Over the last anchor of the path being drawn: a click retracts its outgoing handle. - PenConvert, - Text, - Hand, - HandGrab, - ZoomIn, - ZoomOut, - Eyedropper, - NotAllowed, - /// A long operation (opening a file, a background save or export): the OS wait cursor. - Busy, - /// Over the gradient annotator's bar: a click adds a stop. - AddStop, - /// A gradient stop dragged off the bar: releasing deletes it. - RemoveStop, - /// The Slice tool: a crosshair with a blade. - Slice, - /// The Slice Selection tool: the arrow with a slice badge. - SliceSelect, - /// The Width tool away from strokes. - Width, - /// The Width tool over a stroke: a drag adds a width point. - WidthAdd, - /// The Width tool over a width point or a handle end: a drag moves or widens it. - WidthPoint, - /// The Blend tool away from art: a crosshair with a hollow square. - Blend, - /// The Blend tool over an object it can blend: a crosshair with a filled square. - BlendObject, - /// The Blend tool over an anchor point (the blend starts there): a crosshair with a target. - BlendAnchor, - /// The Objects on Path tool over a path the selected objects can attach to (a click attaches - /// them). - ObjectsOnPath, - /// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket. - PathBracket, - /// Over the type widget of selected type (a double-click converts point type to area type and - /// back): the arrow with a type badge. - TypeWidget, - /// The Selection tool loaded with the text of area type or type on a path by a click on its out - /// or in port (a click threads it into a path or a new frame, a drag draws the new frame). - LoadedText, - /// The Shape Builder: a crosshair with a plus (merge mode)... - ShapeBuilder, - /// ...or, with Alt held, a minus (erase mode). - ShapeBuilderErase, - /// The Lasso tool. - Lasso, - /// The Magic Wand tool. - MagicWand, - /// The Pencil tool. - Pencil, - /// The Pencil tool before a press that draws a new freehand path: the pointer is not over a - /// selected path that the press would continue or redraw. A crosshair with a small star. - PencilNew, - /// The Pencil tool with Alt pressed after the drag starts and Alt key toggles to Smooth Tool off: - /// releasing the mouse button with Alt still held closes the path. A drag started with Shift or - /// Alt held draws an open straight line instead. Also while a freehand stroke is drawn back near - /// its start, where releasing the mouse button closes the path. A crosshair with a ring. - PencilClose, - /// The Pencil tool before a press that draws a straight line (Shift or Alt held). A crosshair with a - /// short straight line. - PencilStraight, - /// The Pencil tool before a press that draws a straight line (Shift or Alt held), over an end of - /// a selected open path: the line continues the path from that end. A crosshair with a short - /// straight line leaving a square end point. - PencilContinue, - /// The Paintbrush tool. - Paintbrush, - /// The Paintbrush tool with Alt held: releasing the mouse button with Alt still held closes - /// the path. Also while a stroke is drawn back near its start, where releasing the mouse button - /// closes the path. A crosshair with a ring. - PaintbrushClose, - /// The Blob Brush tool. - BlobBrush, - /// The Eraser tool. - Eraser, - /// The Smooth tool. - Smooth, - /// The Path Eraser tool. - PathEraser, - /// The Join tool. - Join, - /// The Knife tool. - Knife, - /// The Shaper tool. - Shaper, - /// The Live Paint Bucket. - LivePaintBucket, - /// The Measure tool. - Measure, - /// The Print Tiling tool. - PrintTiling, - /// The Symbol Sprayer tool. - SymbolSprayer, - /// The Symbol Shifter tool. - SymbolShifter, - /// The Symbol Scruncher tool. - SymbolScruncher, - /// The Symbol Sizer tool. - SymbolSizer, - /// The Symbol Spinner tool. - SymbolSpinner, - /// The Symbol Stainer tool. - SymbolStainer, - /// The Symbol Screener tool. - SymbolScreener, - /// The Symbol Styler tool. - SymbolStyler, - /// The vertical type tools: a turned I-beam. - TypeVertical, - /// The Scissors over a path segment (a click splits the path there)... - Scissors, - /// ...or away from every segment (a click does nothing). - ScissorsNotAllowed, - /// The Mesh tool off a mesh point: a click adds one. - MeshAdd, - /// Puppet Warp over the selection's mesh where a pin can go: a click adds one. - PuppetPin, -} - -impl Cursor { - /// The rotate cursor for the pointer at `p` turning art about `center`: [`Cursor::RotateToward`] - /// the pointer's side of it, or the plain [`Cursor::Rotate`] on the centre itself. - pub fn rotate_about(center: Point, p: Point) -> Self { - let d = p - center; - if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { - return Cursor::Rotate; - } - // y points down the page: atan2(x, -y) is 0 straight up and grows clockwise. - let octant = (d.x.atan2(-d.y) / std::f64::consts::FRAC_PI_4).round() as i64; - Cursor::RotateToward(octant.rem_euclid(8) as u8) - } - - /// [`Cursor::rotate_about`] in a view turned `view` degrees clockwise - /// ([`ToolContext::view_angle`]): the pointer's side of `center` as it shows on screen. The - /// pointer's direction turns with the view before it rounds to the nearest 45°. - pub fn rotate_about_in_view(center: Point, p: Point, view: f64) -> Self { - let (sn, cs) = if view.is_finite() { view.to_radians().sin_cos() } else { (0.0, 1.0) }; - let d = p - center; - // y points down the page, so this turns `d` clockwise on the page. - Cursor::rotate_about(center, center + Vec2::new(d.x * cs - d.y * sn, d.x * sn + d.y * cs)) - } - - /// The cursor as it shows in a view turned `deg` degrees clockwise (Rotate View): the resize - /// cursors and [`Cursor::RotateToward`] turn with the view to the nearest 45°, and every other - /// cursor stays as it is. - pub fn turned(self, deg: f64) -> Self { - const RESIZE: [Cursor; 4] = [Cursor::ResizeH, Cursor::ResizeNeSw, Cursor::ResizeV, Cursor::ResizeNwSe]; - let k = if deg.is_finite() { (deg / 45.0).round() as i64 } else { 0 }; - match self { - Cursor::RotateToward(o) => Cursor::RotateToward((i64::from(o) + k).rem_euclid(8) as u8), - c => match RESIZE.iter().position(|r| *r == c) { - Some(i) => RESIZE.get((i as i64 - k).rem_euclid(4) as usize).copied().unwrap_or(c), - None => c, - }, - } - } - - /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the - /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. - pub fn precise(self) -> Self { - match self { - Cursor::Pen - | Cursor::PenDrawing - | Cursor::PenAdd - | Cursor::PenDelete - | Cursor::PenClose - | Cursor::PenContinue - | Cursor::PenJoin - | Cursor::PenConvert - | Cursor::AnchorPoint - | Cursor::Curvature - | Cursor::Eyedropper - | Cursor::Slice - | Cursor::Blend - | Cursor::BlendObject - | Cursor::BlendAnchor - | Cursor::ShapeBuilder - | Cursor::ShapeBuilderErase - | Cursor::Lasso - | Cursor::MagicWand - | Cursor::Pencil - | Cursor::PencilNew - | Cursor::PencilClose - | Cursor::PencilStraight - | Cursor::PencilContinue - | Cursor::Paintbrush - | Cursor::PaintbrushClose - | Cursor::BlobBrush - | Cursor::Eraser - | Cursor::Smooth - | Cursor::PathEraser - | Cursor::Join - | Cursor::Knife - | Cursor::Shaper - | Cursor::LivePaintBucket - | Cursor::Measure - | Cursor::PrintTiling - | Cursor::SymbolSprayer - | Cursor::SymbolShifter - | Cursor::SymbolScruncher - | Cursor::SymbolSizer - | Cursor::SymbolSpinner - | Cursor::SymbolStainer - | Cursor::SymbolScreener - | Cursor::SymbolStyler - | Cursor::Scissors - | Cursor::MeshAdd - | Cursor::PuppetPin => Cursor::Crosshair, - c => c, - } - } - - /// The cursor this one borrows until it's drawn: the generic crosshair for the - /// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet - /// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of - /// its own. - pub fn stand_in(self) -> Option { - match self { - Cursor::Lasso - | Cursor::MagicWand - | Cursor::Pencil - | Cursor::Paintbrush - | Cursor::BlobBrush - | Cursor::Eraser - | Cursor::Smooth - | Cursor::PathEraser - | Cursor::Join - | Cursor::Knife - | Cursor::Shaper - | Cursor::LivePaintBucket - | Cursor::Measure - | Cursor::ObjectsOnPath - | Cursor::PrintTiling - | Cursor::SymbolSprayer - | Cursor::SymbolShifter - | Cursor::SymbolScruncher - | Cursor::SymbolSizer - | Cursor::SymbolSpinner - | Cursor::SymbolStainer - | Cursor::SymbolScreener - | Cursor::SymbolStyler - | Cursor::Scissors => Some(Cursor::Crosshair), - Cursor::TypeVertical => Some(Cursor::Text), - Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd), - Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed), - _ => None, - } - } -} - /// What an IME's "delete the text around the cursor" counts: UTF-8 bytes (winit's raw /// `DeleteSurroundingBytes`) or Unicode scalar values (egui's `DeleteSurrounding`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] From 714aecc7b413e3aa9e50a0f8e6dfa11ee8bc7fe0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:39:00 +0000 Subject: [PATCH 137/199] refactor(tools): move text.rs items to text/{plain,ports,tool}.rs --- crates/tools/src/text.rs | 1057 +------------------------------- crates/tools/src/text/plain.rs | 235 +++++++ crates/tools/src/text/ports.rs | 189 ++++++ crates/tools/src/text/tool.rs | 632 +++++++++++++++++++ 4 files changed, 1067 insertions(+), 1046 deletions(-) create mode 100644 crates/tools/src/text/plain.rs create mode 100644 crates/tools/src/text/ports.rs create mode 100644 crates/tools/src/text/tool.rs diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 12e9e80bf..af7370e96 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -33,6 +33,17 @@ use vectorcraft_text::{FontDb, TextLayout, edit}; use crate::guides::DrawSnap; use crate::{Action, Cursor, ImeSurrounding, Mods, Overlay, PointerEvent, PointerKind, SurroundingUnit, Tool, ToolContext, ToolKey}; +mod plain; +mod ports; +mod tool; + +#[cfg(test)] +use plain::char_range_to_bytes; +use plain::common_affixes; +pub use plain::step_key; +pub(crate) use ports::port_at; +pub use ports::{PORT_RISE_PX, PORTS_MAX, Port, PortMarks, PortState, port_centre, port_states, ported, ports_shown, push_port, thread_ports}; + /// Which of the Type tools this is. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum Mode { @@ -540,1051 +551,5 @@ impl TypeTool { } } -/// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the -/// out port above the bottom-right corner on the right side, the in port below the top-left corner -/// on the left side, clear of the bounding box's corner handles, so both can be grabbed. -pub const PORT_RISE_PX: f64 = 12.0; -/// Half a port's side (screen px). -const PORT_HALF_PX: f64 = 4.5; -/// The red of the out port of text that doesn't fit. -const OVERFLOW_RED: [u8; 3] = [230, 30, 30]; - -/// One of the two ports of area type or type on a path: its story flows in through the in port -/// and on through the out port. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Port { - In, - Out, -} - -/// What a port shows: empty when the frame is not threaded on that side (and, for the out port, -/// all its text is seen), an arrow when it is threaded to another frame there, a red "+" in the out -/// port when its text doesn't fit. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PortState { - Empty, - Threaded, - Overflow, -} - -/// Centre of `port` of `t`, `px` document units to the screen pixel: area type's on its frame's -/// sides by [`PORT_RISE_PX`] from the corners; type on a path's along its path, as far past its end -/// bracket (out) or before its start bracket (in), clear of the brackets, which drag. -pub fn port_centre(t: &TextObject, port: Port, px: f64) -> Option { - let rise = PORT_RISE_PX * px; - match &t.kind { - TextKind::Area { frame } => { - let b = frame.bounds()?; - // The corner the port sits by, and the far end of the side it sits on. - let (corner, far) = match port { - Port::Out => (Point::new(b.x1, b.y1), Point::new(b.x1, b.y0)), - Port::In => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)), - }; - let (corner, side) = (t.xf * corner, t.xf * far - t.xf * corner); - let len = side.hypot(); - Some(if len > 1e-9 && len.is_finite() { corner + side * (rise.min(len / 2.0) / len) } else { corner }) - } - TextKind::OnPath { path, .. } => { - let arc = vectorcraft_geom::ArcPath::new(path); - let (start, end) = t.kind.path_span()?; - if arc.is_empty() { - return None; - } - let (at, away) = match port { - Port::Out => (end, rise), - Port::In => (start, -rise), - }; - let (p, dir) = arc.at(at * arc.len()); - let (at, ahead) = (t.xf * p, t.xf * (p + dir) - t.xf * p); - let len = ahead.hypot(); - Some(if len > 1e-9 && len.is_finite() { at + ahead * (away / len) } else { at }) - } - TextKind::Point => None, - } -} - -/// What the in and out ports of frame `id` of `d` show, its text `overflow`ing or not: an arrow -/// where a frame of its thread comes before (in) or after (out) it, else a red "+" in the out port -/// of text that doesn't fit, else nothing. -pub fn port_states(d: &vectorcraft_doc::Document, id: NodeId, overflow: bool) -> (PortState, PortState) { - let thread = d.text_threads.iter().find(|t| t.contains(&id)); - let at = thread.and_then(|t| t.iter().position(|x| *x == id)); - let input = if at.is_some_and(|i| i > 0) { PortState::Threaded } else { PortState::Empty }; - let output = match (thread, at) { - (Some(t), Some(i)) if i + 1 < t.len() => PortState::Threaded, - _ if overflow => PortState::Overflow, - _ => PortState::Empty, - }; - (input, output) -} - -/// A port at `c` showing `state`, `px` document units to the screen pixel: a white box outlined in -/// `color` (the frame's layer colour; red for text that doesn't fit), with a red "+" or an arrow -/// pointing the way the story flows. -pub fn push_port(o: &mut Vec, c: Point, state: PortState, color: [u8; 3], px: f64) { - let h = PORT_HALF_PX * px; - let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); - let color = if state == PortState::Overflow { OVERFLOW_RED } else { color }; - let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; - o.push(Overlay::Highlight { quad: corners, color: [255, 255, 255, 255] }); - o.push(Overlay::Path { path: r.to_path(0.1), color, width: 1.0, dashed: false }); - let k = 2.5 * px; - let line = |a: Point, b: Point| Overlay::Line { a, b, color, dashed: false }; - match state { - PortState::Empty => {} - PortState::Overflow => { - o.push(line(Point::new(c.x - k, c.y), Point::new(c.x + k, c.y))); - o.push(line(Point::new(c.x, c.y - k), Point::new(c.x, c.y + k))); - } - PortState::Threaded => { - let tip = Point::new(c.x + k, c.y); - o.push(line(Point::new(c.x - k, c.y), tip)); - o.push(line(Point::new(c.x, c.y - k), tip)); - o.push(line(Point::new(c.x, c.y + k), tip)); - } - } -} - -/// The in and out ports of area type or type on a path `t`, frame `id`, its text `overflow`ing or -/// not ([`port_states`]). -fn ports(cx: &ToolContext, id: NodeId, t: &TextObject, overflow: bool, o: &mut Vec) { - let (input, output) = port_states(cx.doc, id, overflow); - let (color, px) = (cx.doc.layer_color(id), cx.tol(1.0)); - for (port, state) in [(Port::In, input), (Port::Out, output)] { - if let Some(c) = port_centre(t, port, px) { - push_port(o, c, state, color, px); - } - } -} - -/// The most selected area type and type on a path whose ports are drawn and take clicks: selecting -/// all of an opened PDF can select tens of thousands of type objects, whose ports would only bury -/// the canvas (and the GPU's buffers) in boxes. -pub const PORTS_MAX: usize = 1_000; - -/// The text of `id` in `d` when it has ports: area type or type on a path a thread can hold, not in -/// perspective ([`crate::select::is_threadable`]). The one test for drawing ports, clicking them and -/// counting them toward [`PORTS_MAX`]. -pub fn ported(d: &vectorcraft_doc::Document, id: NodeId) -> Option<&TextObject> { - match d.node(id).filter(|n| crate::select::is_threadable(n)).map(|n| &n.kind) { - Some(NodeKind::Text(t)) => Some(t), - _ => None, - } -} - -/// Are at most [`PORTS_MAX`] of `selected` [`ported`] (counting stops past it), so that their -/// ports show? -pub fn ports_shown(d: &vectorcraft_doc::Document, selected: &[NodeId]) -> bool { - selected.iter().filter(|id| ported(d, **id).is_some()).take(PORTS_MAX + 1).count() <= PORTS_MAX -} - -/// The selected area type or type on a path with a port under `p`, and which port: a click on the -/// out port loads the Selection tool with its text to thread into another frame after it, a click -/// on the in port to thread into one before it. Every such frame has both ports, threaded, -/// overflowing or not, while their ports show ([`PORTS_MAX`]). -pub(crate) fn port_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Port)> { - if !ports_shown(cx.doc, &cx.selection.objects) { - return None; - } - let h = cx.tol(PORT_HALF_PX + 1.0); - let near = |c: Point| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h; - cx.selection.objects.iter().find_map(|id| { - let t = ported(cx.doc, *id)?; - [Port::Out, Port::In].into_iter().find(|port| port_centre(t, *port, cx.tol(1.0)).is_some_and(near)).map(|port| (*id, port)) - }) -} - -/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept -/// with the object and the fonts it was made from, as overlays run every frame. -#[derive(Default)] -pub struct PortMarks { - seen: std::collections::HashMap, -} - -/// The in and out ports of the selected area type and type on a path, for every tool but the Type -/// tools (they draw their own): each tells whether the frame is threaded on that side and the out -/// port whether text is hidden by the size of its frame, whatever tool is active. None when more -/// than [`PORTS_MAX`] of them are selected. -pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec { - let fonts = FontDb::global().generation(); - let mut o = Vec::new(); - let mut kept = std::collections::HashMap::new(); - if !ports_shown(cx.doc, &cx.selection.objects) { - marks.seen = kept; - return o; - } - for id in &cx.selection.objects { - let Some(t) = ported(cx.doc, *id) else { continue }; - let over = match marks.seen.remove(id) { - Some(((g, was), over)) if g == fonts && &was == t => over, - _ => vectorcraft_text::layout(FontDb::global(), t).overflow, - }; - ports(cx, *id, t, over, &mut o); - kept.insert(*id, ((fonts, t.clone()), over)); - } - marks.seen = kept; - o -} - -/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). -/// `None` when `r` runs past the end of `s` or backwards. -/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking; -/// ↑/↓ leading (down opens it up); Shift+↑/↓ baseline shift. -pub fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, f64)> { - let sign = |up: bool| if up { 1.0 } else { -1.0 }; - Some(match (key, shift) { - (ToolKey::Left | ToolKey::Right, false) => (if caret { "kerning" } else { "tracking" }, sign(key == ToolKey::Right)), - (ToolKey::Up | ToolKey::Down, false) => ("leading", sign(key == ToolKey::Down)), - (ToolKey::Up | ToolKey::Down, true) => ("baselineShift", sign(key == ToolKey::Up)), - _ => return None, - }) -} - -fn char_range_to_bytes(s: &str, r: Range) -> Option> { - let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) }; - let (a, b) = (byte(r.start)?, byte(r.end)?); - (a <= b).then_some(a..b) -} - -/// Lengths of the common prefix (at most `max_prefix`) and suffix of `a` and `b` (on char -/// boundaries, not overlapping). -fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) { - let mut p = 0; - for ((i, x), y) in a.char_indices().zip(b.chars()) { - if x != y || i + x.len_utf8() > max_prefix { - break; - } - p = i + x.len_utf8(); - } - let max_s = (a.len() - p).min(b.len() - p); - let mut s = 0; - for (x, y) in a[p..].chars().rev().zip(b[p..].chars().rev()) { - if x != y || s + x.len_utf8() > max_s { - break; - } - s += x.len_utf8(); - } - (p, s) -} - -/// Byte length of the borrowed plain-text segments. -fn plain_len(segments: &[&str]) -> usize { - segments.iter().map(|s| s.len()).sum() -} - -/// Is `byte` a UTF-8 boundary in the concatenation of `segments`? Run edges are boundaries. -fn plain_is_boundary(segments: &[&str], byte: usize) -> bool { - let mut at = 0usize; - for segment in segments { - if byte == at { - return true; - } - if byte < at + segment.len() { - return segment.is_char_boundary(byte - at); - } - at += segment.len(); - } - byte == at -} - -/// Append the bytes `start..end` of the concatenation of `segments` to `out`. -fn copy_plain_range(segments: &[&str], start: usize, end: usize, out: &mut String) { - let mut at = 0usize; - for segment in segments { - let segment_end = at + segment.len(); - let from = start.max(at); - let to = end.min(segment_end); - if from < to { - let (a, b) = (from - at, to - at); - if segment.is_char_boundary(a) && segment.is_char_boundary(b) { - out.push_str(&segment[a..b]); - } - } - if segment_end >= end { - break; - } - at = segment_end; - } -} - -/// Move `i` back to a character boundary in the concatenation of `segments` (deleting fewer -/// bytes rather than splitting UTF-8). -fn plain_floor_boundary(segments: &[&str], mut i: usize) -> usize { - i = i.min(plain_len(segments)); - while i > 0 && !plain_is_boundary(segments, i) { - i -= 1; - } - i -} - -/// Move `i` forward to a character boundary in the concatenation of `segments`. -fn plain_ceil_boundary(segments: &[&str], mut i: usize) -> usize { - let len = plain_len(segments); - i = i.min(len); - while i < len && !plain_is_boundary(segments, i) { - i += 1; - } - i -} - -/// The byte offset `n` characters before `at` (a boundary) in `segments`, stopping at the start of -/// the text. Walks the runs, so a large IME deletion count never copies the whole text. -fn plain_chars_before(segments: &[&str], at: usize, n: usize) -> usize { - if n == 0 { - return at; - } - let mut remaining = n; - let mut end = plain_len(segments); - for segment in segments.iter().rev() { - let start = end - segment.len(); - let local_end = at.min(end).saturating_sub(start); - if local_end > 0 && segment.is_char_boundary(local_end) { - let before = &segment[..local_end]; - let count = before.chars().count(); - if count >= remaining { - return start + before.char_indices().rev().nth(remaining.saturating_sub(1)).map_or(0, |(i, _)| i); - } - remaining -= count; - } - end = start; - } - 0 -} - -/// The byte offset `n` characters after `at` (a boundary) in `segments`, stopping at the end of -/// the text. -fn plain_chars_after(segments: &[&str], at: usize, n: usize) -> usize { - if n == 0 { - return at; - } - let len = plain_len(segments); - let mut remaining = n; - let mut acc = 0usize; - for segment in segments { - let end = acc + segment.len(); - let local_start = at.saturating_sub(acc); - if end > at && segment.is_char_boundary(local_start) { - let after = &segment[local_start..]; - let count = after.chars().count(); - if count >= remaining { - return acc + local_start + after.char_indices().nth(remaining).map_or(after.len(), |(i, _)| i); - } - remaining -= count; - } - acc = end; - } - len -} - -impl Tool for TypeTool { - fn id(&self) -> &'static str { - match (self.mode, self.vertical) { - (Mode::Type, false) => "type", - (Mode::Type, true) => "verticalType", - (Mode::Area, false) => "areaType", - (Mode::Area, true) => "verticalAreaType", - (Mode::OnPath, false) => "typeOnPath", - (Mode::OnPath, true) => "verticalTypeOnPath", - } - } - fn busy(&self) -> bool { - self.press.is_some() || self.selecting - } - fn wants_text(&self) -> bool { - self.editing.is_some() - } - /// Escape while editing is the tool's: it goes on to the Selection tool (#1686). - fn claims_key(&self, _: &ToolContext, key: ToolKey) -> bool { - key == ToolKey::Escape && self.editing.is_some() - } - fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - match ev.kind { - PointerKind::Down => { - if let Some(byte) = self.hit_edited(cx, ev.pos) { - let same = self.clicks.0.is_some_and(|q| (q - ev.pos).hypot() <= cx.tol(4.0)); - let n = if same { self.clicks.1 + 1 } else { 1 }; - self.clicks = (Some(ev.pos), n); - let out = self.move_to(byte, ev.mods.shift); - self.goal_x = None; - if n >= 3 { - self.select_word_or_para(cx, true); - self.clicks.1 = 0; - } else { - self.selecting = true; - } - return out; - } - self.clicks = (None, 0); - // A press on other type edits it from there, so a drag selects its text at once, - // as it does in the type being edited. - if let Some(out) = self.edit_at(cx, ev.pos) { - self.selecting = true; - return out; - } - self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None))); - self.drag = None; - vec![] - } - PointerKind::Drag => { - if self.selecting { - if let Some(t) = self.current(cx) { - let lay = self.layout(&t); - self.caret = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * ev.pos); - self.clicks = (None, 0); - } - } else if self.press.is_some() { - self.drag = Some(self.snap.drag(cx, ev.pos, None)); - } - vec![] - } - PointerKind::Up => { - if self.selecting { - self.selecting = false; - return vec![]; - } - self.snap.clear(); - let Some((start, at)) = self.press.take() else { return vec![] }; - self.on_up(cx, start, at) - } - PointerKind::DoubleClick => { - if self.editing.is_some() && self.hit_edited(cx, ev.pos).is_some() { - self.select_word_or_para(cx, false); - self.clicks = (Some(ev.pos), 2); - } - vec![] - } - PointerKind::Move => { - // The type being edited is no target: its guides would only point at itself. - self.snap.hover(cx, ev.pos, self.editing.as_slice(), None); - vec![] - } - } - } - fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec { - if self.editing.is_none() { - return vec![]; - } - // An IME commit replaces its marked text (an empty commit just removes it). - let committing = self.preedit.is_some(); - // A space typed on its own at a caret may start a list. - let list_space = !committing && s == " " && self.caret == self.anchor; - if committing && s.is_empty() { - return self.ime_preedit(cx, "", None); - } - if let Some(p) = self.preedit.take() { - self.anchor = p.range.start; - self.caret = p.range.end; - } - let Some(s) = self.input_text(cx, s) else { return vec![] }; - self.clicks = (None, 0); - // Pasting what we copied keeps its formatting (unless pasted text is kept plain). - let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); - if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text { - let runs = self.clipboard.clone(); - return self.replace(cx, "", Some(runs)); - } - let mut out = self.replace(cx, &s, None); - if list_space { - out.extend(self.start_typed_list(cx)); - } - out - } - /// Would [`Tool::text_input`] change `s` before inserting it? Control characters are dropped, - /// `\r` becomes `\n`, Typographer's Quotes rewrites a straight quote, and copied text keeps - /// its run styles. An IME commit the application rewrites is an application edit, not an - /// accepted IME insertion (its receipt must stay `Other`). - fn text_input_is_verbatim(&self, cx: &ToolContext, s: &str) -> bool { - if self.editing.is_none() { - return true; - } - if s.is_empty() { - return true; - } - // A straight quote's direction depends on the character before the insertion. During a - // commit this check still sees the pre-edit at the caret, while `text_input` replaces it - // from its start; with a custom pair whose opening quote is straight, that can look - // verbatim here and then be rewritten. Never claim verbatim for one. - if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") { - return false; - } - let Some(normalized) = self.input_text(cx, s) else { return false }; - if normalized != s { - return false; - } - let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); - !(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text) - } - /// Whether the typing session still describes the edited document; a session ended by an outside - /// command reports false until a replacement commit starts a new one. - fn ime_session_matches_document(&self, cx: &ToolContext) -> bool { - match (self.editing, &self.typing) { - (Some(id), Some(ty)) => Self::text(cx, id).is_none_or(|t| ty.cur == t.runs), - // An outside command settled the session: the document may have changed under it, and - // the commit that starts a new session cannot claim the IME made that change. - (Some(_), None) => !self.typing_ended, - _ => true, - } - } - /// Handle an editing key: ignore keys while an IME composes, remap arrows in vertical text, step - /// type attributes with Alt, and otherwise edit, navigate or end the session. - fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { - // The IME owns the keys while it composes (the UI doesn't send them; this is a guard). - if self.preedit.is_some() { - return vec![]; - } - let Some(t) = self.current(cx) else { - self.editing = None; - return vec![]; - }; - let text = t.plain_text(); - let len = text.len(); - self.caret = self.caret.min(len); - self.anchor = self.anchor.min(len); - let (a, b) = self.sel(); - // Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps). - if mods.alt - && let Some(id) = self.editing - && let Some((attribute, by)) = step_key(key, mods.shift, a == b) - { - let mut out = self.commit(); - let by = if mods.cmd { by * 5.0 } else { by }; - out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by}))); - return out; - } - let word = mods.cmd || mods.alt; - let lay = self.layout(&t); - let key = if lay.vertical { - match key { - ToolKey::Up => ToolKey::Left, - ToolKey::Down => ToolKey::Right, - ToolKey::Right => ToolKey::Up, - ToolKey::Left => ToolKey::Down, - other => other, - } - } else { - key - }; - let vertical = matches!(key, ToolKey::Up | ToolKey::Down); - if !vertical { - self.goal_x = None; - } - match key { - // Escape ends editing and goes back to the tool used before double-clicking into type - // (or to the Selection tool by default), the type selected: the keyboard is free for - // tool shortcuts again (#1686). - ToolKey::Escape => { - let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); - let mut out = self.finish(cx); - out.push(Action::SwitchTool(target)); - out - } - ToolKey::Enter => self.replace(cx, "\n", None), - ToolKey::Tab => self.replace(cx, "\t", None), - ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None), - ToolKey::Backspace if self.caret > 0 => { - self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) }; - self.replace(cx, "", None) - } - // Backspace takes back one character (a mistyped Thai tone mark, say); Delete, the - // Left and Right arrows a whole cluster. - ToolKey::Delete if self.caret < len => { - self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_cluster(&text, self.caret) }; - self.replace(cx, "", None) - } - ToolKey::Left => { - let to = if a != b && !mods.shift { - if lay.glyphs.iter().any(|g| g.rtl) - && vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x - { - b - } else { - a - } - } else if word { - edit::prev_word(&text, self.caret) - } else { - if lay.glyphs.iter().any(|g| g.rtl) { - vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, false) - } else { - edit::prev_cluster(&text, self.caret) - } - }; - self.move_to(to, mods.shift) - } - ToolKey::Right => { - let to = if a != b && !mods.shift { - if lay.glyphs.iter().any(|g| g.rtl) - && vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x - { - a - } else { - b - } - } else if word { - edit::next_word(&text, self.caret) - } else { - if lay.glyphs.iter().any(|g| g.rtl) { - vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, true) - } else { - edit::next_cluster(&text, self.caret) - } - }; - self.move_to(to, mods.shift) - } - ToolKey::Up | ToolKey::Down => { - let d = if key == ToolKey::Up { -1 } else { 1 }; - let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret }; - let x = self.goal_x.unwrap_or_else(|| lay.logical_point(vectorcraft_text::caret_position(&lay, from).0).x); - let to = if mods.cmd { - // Cmd+Up/Down: paragraph start / end. - let p = edit::paragraph_at(&text, from); - if d < 0 { - if from == p.start { edit::paragraph_at(&text, p.start.saturating_sub(1)).start } else { p.start } - } else if from == p.end { - edit::paragraph_at(&text, (p.end + 1).min(len)).end - } else { - p.end - } - } else { - vectorcraft_text::caret_vertical(&lay, from, d, x) - }; - let out = self.move_to(to, mods.shift); - self.goal_x = Some(x); - out - } - ToolKey::Home => { - let to = if mods.cmd { 0 } else { vectorcraft_text::line_home(&lay, self.caret) }; - self.move_to(to, mods.shift) - } - ToolKey::End => { - let to = if mods.cmd { len } else { vectorcraft_text::line_end_of(&lay, self.caret) }; - self.move_to(to, mods.shift) - } - _ => vec![], - } - } - fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option>) -> Vec { - if self.editing.is_none() { - return vec![]; - } - // A clear with nothing marked (some integrations send one when the IME is enabled) must - // not delete the selection. - if text.is_empty() && self.preedit.is_none() { - return vec![]; - } - let text: String = text.chars().filter(|c| !c.is_control()).collect(); - // The new marked text replaces the previous one, or the selection when it starts. - if let Some(p) = self.preedit.take() { - self.anchor = p.range.start; - self.caret = p.range.end; - } - self.clicks = (None, 0); - let mut out = self.replace(cx, &text, None); - if text.is_empty() { - // Cancelled before anything else was typed: no empty undo step. - if self.typing.as_ref().is_some_and(|ty| ty.cur == ty.base) { - self.typing = None; - out.push(Action::Cancel); - } - return out; - } - let end = self.caret; - let start = end.saturating_sub(text.len()); - self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) }); - out - } - /// Delete the requested bytes or characters before and after the selection or marked text, - /// preserving the selection direction and composition and refusing a stale session; byte counts - /// round inward to UTF-8 boundaries. - fn ime_delete_surrounding(&mut self, cx: &ToolContext, before: usize, after: usize, unit: SurroundingUnit) -> Vec { - let Some(id) = self.editing else { return vec![] }; - // The document changed under the session (undo, a panel edit): the ranges below would index - // text that is no longer there. The commit that follows resets the session; do not delete - // from the wrong text in the meantime. - if let Some(t) = Self::text(cx, id) - && self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) - { - return vec![]; - } - // Walk the runs in place: joining the whole edited text here would copy a large document - // for every IME deletion event, though only the characters next to the selection matter. - let Some(segments) = self.plain_segments(cx) else { return vec![] }; - let len = plain_len(&segments); - // The text the IME's following commit replaces (the selection, or the marked text while - // composing). The surrounding deletion never removes it: it deletes only the bytes/characters - // before and after it, preserving the selection direction and the composition. - let (raw_start, raw_end) = match &self.preedit { - Some(p) => (p.range.start.min(len), p.range.end.min(len)), - None => (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)), - }; - let (before_start, start, end, after_end) = match unit { - SurroundingUnit::Bytes => { - // Inward rounding: a count ending inside a scalar leaves that scalar whole. The - // preceding side moves its start forward (ceil), the following side moves its end - // backward (floor). Do not reverse these: that would delete part of a character. - let start = plain_floor_boundary(&segments, raw_start); - let end = plain_ceil_boundary(&segments, raw_end); - let before_start = plain_ceil_boundary(&segments, start.saturating_sub(before)); - let after_end = plain_floor_boundary(&segments, end.saturating_add(after)); - (before_start, start, end, after_end) - } - SurroundingUnit::Chars => { - let start = plain_floor_boundary(&segments, raw_start); - let end = plain_ceil_boundary(&segments, raw_end); - let before_start = plain_chars_before(&segments, start, before); - let after_end = plain_chars_after(&segments, end, after); - (before_start, start, end, after_end) - } - }; - let mut ranges = Vec::with_capacity(2); - if before_start < start { - ranges.push((before_start, start)); - } - if end < after_end { - ranges.push((end, after_end)); - } - if ranges.is_empty() { - return vec![]; - } - self.delete_ranges(cx, &ranges) - } - /// Return the active Type tool's stable IME owner (document uid, target id, replacement epoch), - /// or `None` when nothing is being edited. - fn ime_target(&self, cx: &ToolContext) -> Option<(u64, u64, u64)> { - let id = self.editing?; - Self::text(cx, id)?; - Some((cx.revision.0, id.0, self.edit_session)) - } - /// Return a bounded logical text window around the caret, anchor and optional pre-edit; a stale - /// typing session publishes the document's current text, and unusable or non-boundary offsets - /// return `Unavailable` rather than stale data. - fn ime_surrounding(&self, cx: &ToolContext, max_bytes: usize) -> ImeSurrounding { - let Some(id) = self.editing else { - return ImeSurrounding::Unavailable("no text object is being edited"); - }; - let Some(current) = Self::text(cx, id) else { - return ImeSurrounding::Unavailable("the edited text object is gone"); - }; - // The document changed under the typing session (undo, a panel or control edit): the - // session's runs, marked-text range and caret offsets no longer describe the document. - // Publish the document's own text (never the stale session's, which the IME cannot see); - // the marked text has no range in it, so it is excluded from the logical view. The - // commit-time check (`ime_session_matches_document`) reports the reset as application - // origin instead of letting a receipt describe the wrong text. - let stale = self.typing.as_ref().is_some_and(|ty| ty.cur != current.runs); - if self.preedit.is_some() && self.typing.is_none() { - return ImeSurrounding::Unavailable("the IME pre-edit has no typing session"); - } - let Some(segments) = self.plain_segments(cx) else { - return ImeSurrounding::Unavailable("the edited text object is gone"); - }; - let len = plain_len(&segments); - let caret = self.caret.min(len); - let anchor = self.anchor.min(len); - if !plain_is_boundary(&segments, caret) || !plain_is_boundary(&segments, anchor) { - return ImeSurrounding::Unavailable("the caret or selection anchor is not a UTF-8 boundary"); - } - let preedit = match self.preedit.as_ref().filter(|_| !stale) { - Some(p) => { - let start = p.range.start.min(len); - let end = p.range.end.min(len); - if start > end || !plain_is_boundary(&segments, start) || !plain_is_boundary(&segments, end) { - return ImeSurrounding::Unavailable("the IME pre-edit is not on UTF-8 boundaries"); - } - Some(start..end) - } - None => None, - }; - ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes) - } - /// Whether an IME pre-edit is currently shown for this Type-tool session. - fn composing(&self) -> bool { - self.preedit.is_some() - } - fn ime_caret(&self, cx: &ToolContext) -> Option<(Point, Point)> { - let t = self.current(cx)?; - let lay = self.layout(&t); - let len = t.plain_text().len(); - // The candidate window follows the clause being converted, else the caret. - let at = match &self.preedit { - Some(p) => p.range.start + p.active.as_ref().map_or(0, |r| r.start), - None => self.caret.min(self.anchor), - }; - let (a, b) = vectorcraft_text::caret_position(&lay, at.min(len)); - Some((t.xf * a, t.xf * b)) - } - fn deactivate(&mut self, cx: &ToolContext) -> Vec { - self.prev_tool = None; - self.finish(cx) - } - /// Editing ends when a command takes the text out of the selection (Deselect, another object - /// picked in the Layers panel) or removes it. After an Undo that takes back a list typed - /// characters started, the caret goes on after the characters, and after its Redo the caret - /// goes before the paragraph's text. - fn after_command(&mut self, cx: &ToolContext) -> Vec { - self.after_typed_list(cx); - let Some(id) = self.editing else { return vec![] }; - let mut cur = Some(id).filter(|i| cx.doc.node(*i).is_some()); - while let Some(c) = cur { - if cx.selection.contains(c) { - return vec![]; - } - cur = cx.doc.parent_of(c); - } - self.finish(cx) - } - /// After `text.create` / `text.createInPath` the engine selects the new object; edit it. - fn notify(&mut self, cx: &ToolContext, what: &str) { - if what == "text.editNew" - && let Some(id) = cx.selection.objects.first().copied() - { - self.start_editing(id, 0); - let t = Self::text(cx, id); - self.fresh = t.filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id); - // Placeholder text comes selected: typing replaces it. - self.caret = t.map_or(0, |t| edit::runs_len(&t.runs)); - } - } - /// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles - /// `start..end`. - fn options(&self) -> Value { - match self.editing { - Some(id) => { - // Offsets may exceed the text after Select All; callers clamp to the text length. - let (a, b) = self.sel(); - json!({ - "editing": id.0, - "start": a, - "end": b, - "caret": self.caret, - "anchor": self.anchor, - "typing": self.typing.is_some(), - "composing": self.preedit.is_some(), - "prevTool": self.prev_tool, - }) - } - None => json!({"editing": null, "prevTool": self.prev_tool}), - } - } - /// `select {start, end}` / `selectAll` / `copy` (with `runs` of the selection) / - /// `commitTyping` (the caller commits the engine interaction). - fn set_option(&mut self, key: &str, v: &Value) { - match key { - // Transient selection transferred between Type and Eyedropper; never a saved setting. - "textSelection" => { - if let (Some(id), Some(a), Some(b)) = ( - v.get("editing").and_then(Value::as_u64), - v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64), - v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64), - ) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b)) - { - self.start_editing(NodeId(id), b); - self.anchor = a; - } - } - "prevTool" | "previousTool" => { - self.prev_tool = v.as_str().map(String::from); - } - "commitTyping" | "endTyping" => { - self.typing = None; - self.preedit = None; - self.typing_ended = true; - } - "selectAll" if self.editing.is_some() => { - self.anchor = 0; - self.caret = usize::MAX / 4; - } - "select" if self.editing.is_some() => { - let g = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize); - if let (Some(a), Some(b)) = (g("start"), g("end")) { - self.anchor = a; - self.caret = b; - } - } - "copy" => { - if let Ok(runs) = serde_json::from_value::>(v.clone()) { - self.clipboard = runs; - } - } - "stopEditing" => { - self.typing = None; - self.preedit = None; - self.editing = None; - self.prev_tool = None; - } - _ => {} - } - } - fn overlays(&self, cx: &ToolContext) -> Vec { - let mut o = self.snap.guides().to_vec(); - if let (Some((_, s)), Some(d)) = (self.press, self.drag) { - // The area being drawn (along the Constrain Angle's axes when one is set, as `on_up` - // makes it) and its size. - let size = match vectorcraft_geom::constrain_axes(cx.constrain_angle) { - None => { - let r = Rect::from_points(s, d); - o.push(Overlay::Marquee(r)); - r.size() - } - Some(axes) => { - let back = axes.inverse(); - let r = Rect::from_points(back * s, back * d); - let mut path = r.to_path(0.1); - path.apply_affine(axes); - o.push(Overlay::Path { path, color: [79, 128, 255], width: 1.0, dashed: true }); - r.size() - } - }; - if cx.measurement_labels { - o.push(Overlay::Measure { p: d, text: cx.size_label(size.width, size.height) }); - } - } - // The ports of selected area type and type on a path, as many as show ([`PORTS_MAX`]), the - // edited one's included. - let shown = ports_shown(cx.doc, &cx.selection.objects); - if shown { - for id in cx.selection.objects.iter().filter(|id| Some(**id) != self.editing) { - if let Some(t) = ported(cx.doc, *id) { - let lay = self.layout(t); - ports(cx, *id, t, lay.overflow, &mut o); - } - } - } - let Some(t) = self.current(cx) else { return o }; - let lay = self.layout(&t); - let len = t.plain_text().len(); - // Frame / baseline path of the edited object. - let frame: Option = match &t.kind { - TextKind::Area { frame } => Some(frame.to_bezpath()), - TextKind::OnPath { path, .. } => Some(path.to_bezpath()), - TextKind::Point => None, - }; - if let Some(mut f) = frame { - f.apply_affine(t.xf); - o.push(Overlay::Path { path: f, color: [79, 128, 255], width: 1.0, dashed: false }); - } - let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)); - if let Some(pe) = &self.preedit { - // Marked text: a thin underline, a thick one under the clause being converted. - let under = |o: &mut Vec, r: Range, width: f32| { - for q in vectorcraft_text::selection_quads(&lay, r.start.min(len), r.end.min(len)) { - let (l, rr) = (t.xf * q[3], t.xf * q[2]); - o.push(Overlay::Path { path: vectorcraft_geom::Line::new(l, rr).to_path(0.1), color: [0, 0, 0], width, dashed: false }); - } - }; - under(&mut o, pe.range.clone(), 1.0); - match &pe.active { - Some(r) if !r.is_empty() => under(&mut o, pe.range.start + r.start..pe.range.start + r.end, 2.5), - _ => { - let at = pe.range.start + pe.active.as_ref().map_or(pe.range.len(), |r| r.start); - let (p, q) = vectorcraft_text::caret_position(&lay, at.min(len)); - o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false }); - } - } - } else if a == b { - let (p, q) = vectorcraft_text::caret_position(&lay, self.caret.min(len)); - o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false }); - } else { - for q in vectorcraft_text::selection_quads(&lay, a, b) { - o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] }); - } - } - if let Some(id) = self.editing.filter(|id| shown && ported(cx.doc, *id).is_some()) { - ports(cx, id, &t, lay.overflow, &mut o); - } - o - } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { - if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() { - return Cursor::Crosshair; - } - if self.vertical { Cursor::TypeVertical } else { Cursor::Text } - } -} - -/// The logical IME surrounding window: the pre-edit is removed and the caret/anchor offsets -/// are remapped around it. `preedit` has already been validated against `segments`. -fn ime_surrounding_window( - segments: &[&str], - preedit: Option>, - len: usize, - caret: usize, - anchor: usize, - max_bytes: usize, -) -> ImeSurrounding { - let removed = preedit.as_ref().map_or(0, |r| r.end - r.start); - let logical_len = len - removed; - let to_logical = |byte: usize| -> usize { - match &preedit { - Some(r) if byte > r.start && byte < r.end => r.start, - Some(r) if byte >= r.end => byte - removed, - _ => byte, - } - }; - let cursor = to_logical(caret); - let anchor = to_logical(anchor); - let selection_start = cursor.min(anchor); - let selection_end = cursor.max(anchor); - let selection_len = selection_end - selection_start; - if selection_len > max_bytes { - return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit"); - } - let budget = max_bytes - selection_len; - let before_available = selection_start; - let after_available = logical_len - selection_end; - let mut before = (budget / 2).min(before_available); - let mut after = (budget - before).min(after_available); - before = (budget - after).min(before_available); - after = (budget - before).min(after_available); - let mut window_start = selection_start - before; - let mut window_end = selection_end + after; - let logical_boundary = |byte: usize| -> bool { - match &preedit { - Some(r) if byte == r.start => true, - Some(r) if byte < r.start => plain_is_boundary(segments, byte), - Some(_) => plain_is_boundary(segments, byte + removed), - None => plain_is_boundary(segments, byte), - } - }; - while window_start < selection_start && !logical_boundary(window_start) { - window_start += 1; - } - while selection_end < window_end && !logical_boundary(window_end) { - window_end -= 1; - } - let to_original = |byte: usize| -> usize { - match &preedit { - Some(r) if byte > r.start => byte + removed, - _ => byte, - } - }; - let original_start = to_original(window_start); - let original_end = to_original(window_end); - let mut text = String::with_capacity(window_end - window_start); - match &preedit { - Some(r) => { - if window_start < r.start { - let first_end = original_end.min(r.start); - if original_start < first_end { - copy_plain_range(segments, original_start, first_end, &mut text); - } - } - if r.start < window_end { - let second_start = r.end.max(original_start); - if second_start < original_end { - copy_plain_range(segments, second_start, original_end, &mut text); - } - } - } - None => { - if original_start < original_end { - copy_plain_range(segments, original_start, original_end, &mut text); - } - } - } - debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length"); - ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start } -} - #[cfg(test)] mod tests; diff --git a/crates/tools/src/text/plain.rs b/crates/tools/src/text/plain.rs new file mode 100644 index 000000000..1b058a4b6 --- /dev/null +++ b/crates/tools/src/text/plain.rs @@ -0,0 +1,235 @@ +use super::*; + +/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). +/// `None` when `r` runs past the end of `s` or backwards. +/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking; +/// ↑/↓ leading (down opens it up); Shift+↑/↓ baseline shift. +pub fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, f64)> { + let sign = |up: bool| if up { 1.0 } else { -1.0 }; + Some(match (key, shift) { + (ToolKey::Left | ToolKey::Right, false) => (if caret { "kerning" } else { "tracking" }, sign(key == ToolKey::Right)), + (ToolKey::Up | ToolKey::Down, false) => ("leading", sign(key == ToolKey::Down)), + (ToolKey::Up | ToolKey::Down, true) => ("baselineShift", sign(key == ToolKey::Up)), + _ => return None, + }) +} + +pub(super) fn char_range_to_bytes(s: &str, r: Range) -> Option> { + let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) }; + let (a, b) = (byte(r.start)?, byte(r.end)?); + (a <= b).then_some(a..b) +} + +/// Lengths of the common prefix (at most `max_prefix`) and suffix of `a` and `b` (on char +/// boundaries, not overlapping). +pub(super) fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) { + let mut p = 0; + for ((i, x), y) in a.char_indices().zip(b.chars()) { + if x != y || i + x.len_utf8() > max_prefix { + break; + } + p = i + x.len_utf8(); + } + let max_s = (a.len() - p).min(b.len() - p); + let mut s = 0; + for (x, y) in a[p..].chars().rev().zip(b[p..].chars().rev()) { + if x != y || s + x.len_utf8() > max_s { + break; + } + s += x.len_utf8(); + } + (p, s) +} + +/// Byte length of the borrowed plain-text segments. +pub(super) fn plain_len(segments: &[&str]) -> usize { + segments.iter().map(|s| s.len()).sum() +} + +/// Is `byte` a UTF-8 boundary in the concatenation of `segments`? Run edges are boundaries. +pub(super) fn plain_is_boundary(segments: &[&str], byte: usize) -> bool { + let mut at = 0usize; + for segment in segments { + if byte == at { + return true; + } + if byte < at + segment.len() { + return segment.is_char_boundary(byte - at); + } + at += segment.len(); + } + byte == at +} + +/// Append the bytes `start..end` of the concatenation of `segments` to `out`. +fn copy_plain_range(segments: &[&str], start: usize, end: usize, out: &mut String) { + let mut at = 0usize; + for segment in segments { + let segment_end = at + segment.len(); + let from = start.max(at); + let to = end.min(segment_end); + if from < to { + let (a, b) = (from - at, to - at); + if segment.is_char_boundary(a) && segment.is_char_boundary(b) { + out.push_str(&segment[a..b]); + } + } + if segment_end >= end { + break; + } + at = segment_end; + } +} + +/// Move `i` back to a character boundary in the concatenation of `segments` (deleting fewer +/// bytes rather than splitting UTF-8). +pub(super) fn plain_floor_boundary(segments: &[&str], mut i: usize) -> usize { + i = i.min(plain_len(segments)); + while i > 0 && !plain_is_boundary(segments, i) { + i -= 1; + } + i +} + +/// Move `i` forward to a character boundary in the concatenation of `segments`. +pub(super) fn plain_ceil_boundary(segments: &[&str], mut i: usize) -> usize { + let len = plain_len(segments); + i = i.min(len); + while i < len && !plain_is_boundary(segments, i) { + i += 1; + } + i +} + +/// The byte offset `n` characters before `at` (a boundary) in `segments`, stopping at the start of +/// the text. Walks the runs, so a large IME deletion count never copies the whole text. +pub(super) fn plain_chars_before(segments: &[&str], at: usize, n: usize) -> usize { + if n == 0 { + return at; + } + let mut remaining = n; + let mut end = plain_len(segments); + for segment in segments.iter().rev() { + let start = end - segment.len(); + let local_end = at.min(end).saturating_sub(start); + if local_end > 0 && segment.is_char_boundary(local_end) { + let before = &segment[..local_end]; + let count = before.chars().count(); + if count >= remaining { + return start + before.char_indices().rev().nth(remaining.saturating_sub(1)).map_or(0, |(i, _)| i); + } + remaining -= count; + } + end = start; + } + 0 +} + +/// The byte offset `n` characters after `at` (a boundary) in `segments`, stopping at the end of +/// the text. +pub(super) fn plain_chars_after(segments: &[&str], at: usize, n: usize) -> usize { + if n == 0 { + return at; + } + let len = plain_len(segments); + let mut remaining = n; + let mut acc = 0usize; + for segment in segments { + let end = acc + segment.len(); + let local_start = at.saturating_sub(acc); + if end > at && segment.is_char_boundary(local_start) { + let after = &segment[local_start..]; + let count = after.chars().count(); + if count >= remaining { + return acc + local_start + after.char_indices().nth(remaining).map_or(after.len(), |(i, _)| i); + } + remaining -= count; + } + acc = end; + } + len +} + +/// The logical IME surrounding window: the pre-edit is removed and the caret/anchor offsets +/// are remapped around it. `preedit` has already been validated against `segments`. +pub(super) fn ime_surrounding_window( + segments: &[&str], + preedit: Option>, + len: usize, + caret: usize, + anchor: usize, + max_bytes: usize, +) -> ImeSurrounding { + let removed = preedit.as_ref().map_or(0, |r| r.end - r.start); + let logical_len = len - removed; + let to_logical = |byte: usize| -> usize { + match &preedit { + Some(r) if byte > r.start && byte < r.end => r.start, + Some(r) if byte >= r.end => byte - removed, + _ => byte, + } + }; + let cursor = to_logical(caret); + let anchor = to_logical(anchor); + let selection_start = cursor.min(anchor); + let selection_end = cursor.max(anchor); + let selection_len = selection_end - selection_start; + if selection_len > max_bytes { + return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit"); + } + let budget = max_bytes - selection_len; + let before_available = selection_start; + let after_available = logical_len - selection_end; + let mut before = (budget / 2).min(before_available); + let mut after = (budget - before).min(after_available); + before = (budget - after).min(before_available); + after = (budget - before).min(after_available); + let mut window_start = selection_start - before; + let mut window_end = selection_end + after; + let logical_boundary = |byte: usize| -> bool { + match &preedit { + Some(r) if byte == r.start => true, + Some(r) if byte < r.start => plain_is_boundary(segments, byte), + Some(_) => plain_is_boundary(segments, byte + removed), + None => plain_is_boundary(segments, byte), + } + }; + while window_start < selection_start && !logical_boundary(window_start) { + window_start += 1; + } + while selection_end < window_end && !logical_boundary(window_end) { + window_end -= 1; + } + let to_original = |byte: usize| -> usize { + match &preedit { + Some(r) if byte > r.start => byte + removed, + _ => byte, + } + }; + let original_start = to_original(window_start); + let original_end = to_original(window_end); + let mut text = String::with_capacity(window_end - window_start); + match &preedit { + Some(r) => { + if window_start < r.start { + let first_end = original_end.min(r.start); + if original_start < first_end { + copy_plain_range(segments, original_start, first_end, &mut text); + } + } + if r.start < window_end { + let second_start = r.end.max(original_start); + if second_start < original_end { + copy_plain_range(segments, second_start, original_end, &mut text); + } + } + } + None => { + if original_start < original_end { + copy_plain_range(segments, original_start, original_end, &mut text); + } + } + } + debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length"); + ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start } +} diff --git a/crates/tools/src/text/ports.rs b/crates/tools/src/text/ports.rs new file mode 100644 index 000000000..ace85694c --- /dev/null +++ b/crates/tools/src/text/ports.rs @@ -0,0 +1,189 @@ +use super::*; + +/// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the +/// out port above the bottom-right corner on the right side, the in port below the top-left corner +/// on the left side, clear of the bounding box's corner handles, so both can be grabbed. +pub const PORT_RISE_PX: f64 = 12.0; + +/// Half a port's side (screen px). +const PORT_HALF_PX: f64 = 4.5; + +/// The red of the out port of text that doesn't fit. +const OVERFLOW_RED: [u8; 3] = [230, 30, 30]; + +/// One of the two ports of area type or type on a path: its story flows in through the in port +/// and on through the out port. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Port { + In, + Out, +} + +/// What a port shows: empty when the frame is not threaded on that side (and, for the out port, +/// all its text is seen), an arrow when it is threaded to another frame there, a red "+" in the out +/// port when its text doesn't fit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PortState { + Empty, + Threaded, + Overflow, +} + +/// Centre of `port` of `t`, `px` document units to the screen pixel: area type's on its frame's +/// sides by [`PORT_RISE_PX`] from the corners; type on a path's along its path, as far past its end +/// bracket (out) or before its start bracket (in), clear of the brackets, which drag. +pub fn port_centre(t: &TextObject, port: Port, px: f64) -> Option { + let rise = PORT_RISE_PX * px; + match &t.kind { + TextKind::Area { frame } => { + let b = frame.bounds()?; + // The corner the port sits by, and the far end of the side it sits on. + let (corner, far) = match port { + Port::Out => (Point::new(b.x1, b.y1), Point::new(b.x1, b.y0)), + Port::In => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)), + }; + let (corner, side) = (t.xf * corner, t.xf * far - t.xf * corner); + let len = side.hypot(); + Some(if len > 1e-9 && len.is_finite() { corner + side * (rise.min(len / 2.0) / len) } else { corner }) + } + TextKind::OnPath { path, .. } => { + let arc = vectorcraft_geom::ArcPath::new(path); + let (start, end) = t.kind.path_span()?; + if arc.is_empty() { + return None; + } + let (at, away) = match port { + Port::Out => (end, rise), + Port::In => (start, -rise), + }; + let (p, dir) = arc.at(at * arc.len()); + let (at, ahead) = (t.xf * p, t.xf * (p + dir) - t.xf * p); + let len = ahead.hypot(); + Some(if len > 1e-9 && len.is_finite() { at + ahead * (away / len) } else { at }) + } + TextKind::Point => None, + } +} + +/// What the in and out ports of frame `id` of `d` show, its text `overflow`ing or not: an arrow +/// where a frame of its thread comes before (in) or after (out) it, else a red "+" in the out port +/// of text that doesn't fit, else nothing. +pub fn port_states(d: &vectorcraft_doc::Document, id: NodeId, overflow: bool) -> (PortState, PortState) { + let thread = d.text_threads.iter().find(|t| t.contains(&id)); + let at = thread.and_then(|t| t.iter().position(|x| *x == id)); + let input = if at.is_some_and(|i| i > 0) { PortState::Threaded } else { PortState::Empty }; + let output = match (thread, at) { + (Some(t), Some(i)) if i + 1 < t.len() => PortState::Threaded, + _ if overflow => PortState::Overflow, + _ => PortState::Empty, + }; + (input, output) +} + +/// A port at `c` showing `state`, `px` document units to the screen pixel: a white box outlined in +/// `color` (the frame's layer colour; red for text that doesn't fit), with a red "+" or an arrow +/// pointing the way the story flows. +pub fn push_port(o: &mut Vec, c: Point, state: PortState, color: [u8; 3], px: f64) { + let h = PORT_HALF_PX * px; + let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); + let color = if state == PortState::Overflow { OVERFLOW_RED } else { color }; + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + o.push(Overlay::Highlight { quad: corners, color: [255, 255, 255, 255] }); + o.push(Overlay::Path { path: r.to_path(0.1), color, width: 1.0, dashed: false }); + let k = 2.5 * px; + let line = |a: Point, b: Point| Overlay::Line { a, b, color, dashed: false }; + match state { + PortState::Empty => {} + PortState::Overflow => { + o.push(line(Point::new(c.x - k, c.y), Point::new(c.x + k, c.y))); + o.push(line(Point::new(c.x, c.y - k), Point::new(c.x, c.y + k))); + } + PortState::Threaded => { + let tip = Point::new(c.x + k, c.y); + o.push(line(Point::new(c.x - k, c.y), tip)); + o.push(line(Point::new(c.x, c.y - k), tip)); + o.push(line(Point::new(c.x, c.y + k), tip)); + } + } +} + +/// The in and out ports of area type or type on a path `t`, frame `id`, its text `overflow`ing or +/// not ([`port_states`]). +pub(super) fn ports(cx: &ToolContext, id: NodeId, t: &TextObject, overflow: bool, o: &mut Vec) { + let (input, output) = port_states(cx.doc, id, overflow); + let (color, px) = (cx.doc.layer_color(id), cx.tol(1.0)); + for (port, state) in [(Port::In, input), (Port::Out, output)] { + if let Some(c) = port_centre(t, port, px) { + push_port(o, c, state, color, px); + } + } +} + +/// The most selected area type and type on a path whose ports are drawn and take clicks: selecting +/// all of an opened PDF can select tens of thousands of type objects, whose ports would only bury +/// the canvas (and the GPU's buffers) in boxes. +pub const PORTS_MAX: usize = 1_000; + +/// The text of `id` in `d` when it has ports: area type or type on a path a thread can hold, not in +/// perspective ([`crate::select::is_threadable`]). The one test for drawing ports, clicking them and +/// counting them toward [`PORTS_MAX`]. +pub fn ported(d: &vectorcraft_doc::Document, id: NodeId) -> Option<&TextObject> { + match d.node(id).filter(|n| crate::select::is_threadable(n)).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t), + _ => None, + } +} + +/// Are at most [`PORTS_MAX`] of `selected` [`ported`] (counting stops past it), so that their +/// ports show? +pub fn ports_shown(d: &vectorcraft_doc::Document, selected: &[NodeId]) -> bool { + selected.iter().filter(|id| ported(d, **id).is_some()).take(PORTS_MAX + 1).count() <= PORTS_MAX +} + +/// The selected area type or type on a path with a port under `p`, and which port: a click on the +/// out port loads the Selection tool with its text to thread into another frame after it, a click +/// on the in port to thread into one before it. Every such frame has both ports, threaded, +/// overflowing or not, while their ports show ([`PORTS_MAX`]). +pub(crate) fn port_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Port)> { + if !ports_shown(cx.doc, &cx.selection.objects) { + return None; + } + let h = cx.tol(PORT_HALF_PX + 1.0); + let near = |c: Point| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h; + cx.selection.objects.iter().find_map(|id| { + let t = ported(cx.doc, *id)?; + [Port::Out, Port::In].into_iter().find(|port| port_centre(t, *port, cx.tol(1.0)).is_some_and(near)).map(|port| (*id, port)) + }) +} + +/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept +/// with the object and the fonts it was made from, as overlays run every frame. +#[derive(Default)] +pub struct PortMarks { + seen: std::collections::HashMap, +} + +/// The in and out ports of the selected area type and type on a path, for every tool but the Type +/// tools (they draw their own): each tells whether the frame is threaded on that side and the out +/// port whether text is hidden by the size of its frame, whatever tool is active. None when more +/// than [`PORTS_MAX`] of them are selected. +pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec { + let fonts = FontDb::global().generation(); + let mut o = Vec::new(); + let mut kept = std::collections::HashMap::new(); + if !ports_shown(cx.doc, &cx.selection.objects) { + marks.seen = kept; + return o; + } + for id in &cx.selection.objects { + let Some(t) = ported(cx.doc, *id) else { continue }; + let over = match marks.seen.remove(id) { + Some(((g, was), over)) if g == fonts && &was == t => over, + _ => vectorcraft_text::layout(FontDb::global(), t).overflow, + }; + ports(cx, *id, t, over, &mut o); + kept.insert(*id, ((fonts, t.clone()), over)); + } + marks.seen = kept; + o +} diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs new file mode 100644 index 000000000..55f114a3d --- /dev/null +++ b/crates/tools/src/text/tool.rs @@ -0,0 +1,632 @@ +use super::plain::{ + char_range_to_bytes, ime_surrounding_window, plain_ceil_boundary, plain_chars_after, plain_chars_before, plain_floor_boundary, plain_is_boundary, + plain_len, step_key, +}; +use super::ports::{ported, ports, ports_shown}; +use super::*; + +impl Tool for TypeTool { + fn id(&self) -> &'static str { + match (self.mode, self.vertical) { + (Mode::Type, false) => "type", + (Mode::Type, true) => "verticalType", + (Mode::Area, false) => "areaType", + (Mode::Area, true) => "verticalAreaType", + (Mode::OnPath, false) => "typeOnPath", + (Mode::OnPath, true) => "verticalTypeOnPath", + } + } + fn busy(&self) -> bool { + self.press.is_some() || self.selecting + } + fn wants_text(&self) -> bool { + self.editing.is_some() + } + /// Escape while editing is the tool's: it goes on to the Selection tool (#1686). + fn claims_key(&self, _: &ToolContext, key: ToolKey) -> bool { + key == ToolKey::Escape && self.editing.is_some() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + if let Some(byte) = self.hit_edited(cx, ev.pos) { + let same = self.clicks.0.is_some_and(|q| (q - ev.pos).hypot() <= cx.tol(4.0)); + let n = if same { self.clicks.1 + 1 } else { 1 }; + self.clicks = (Some(ev.pos), n); + let out = self.move_to(byte, ev.mods.shift); + self.goal_x = None; + if n >= 3 { + self.select_word_or_para(cx, true); + self.clicks.1 = 0; + } else { + self.selecting = true; + } + return out; + } + self.clicks = (None, 0); + // A press on other type edits it from there, so a drag selects its text at once, + // as it does in the type being edited. + if let Some(out) = self.edit_at(cx, ev.pos) { + self.selecting = true; + return out; + } + self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None))); + self.drag = None; + vec![] + } + PointerKind::Drag => { + if self.selecting { + if let Some(t) = self.current(cx) { + let lay = self.layout(&t); + self.caret = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * ev.pos); + self.clicks = (None, 0); + } + } else if self.press.is_some() { + self.drag = Some(self.snap.drag(cx, ev.pos, None)); + } + vec![] + } + PointerKind::Up => { + if self.selecting { + self.selecting = false; + return vec![]; + } + self.snap.clear(); + let Some((start, at)) = self.press.take() else { return vec![] }; + self.on_up(cx, start, at) + } + PointerKind::DoubleClick => { + if self.editing.is_some() && self.hit_edited(cx, ev.pos).is_some() { + self.select_word_or_para(cx, false); + self.clicks = (Some(ev.pos), 2); + } + vec![] + } + PointerKind::Move => { + // The type being edited is no target: its guides would only point at itself. + self.snap.hover(cx, ev.pos, self.editing.as_slice(), None); + vec![] + } + } + } + fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec { + if self.editing.is_none() { + return vec![]; + } + // An IME commit replaces its marked text (an empty commit just removes it). + let committing = self.preedit.is_some(); + // A space typed on its own at a caret may start a list. + let list_space = !committing && s == " " && self.caret == self.anchor; + if committing && s.is_empty() { + return self.ime_preedit(cx, "", None); + } + if let Some(p) = self.preedit.take() { + self.anchor = p.range.start; + self.caret = p.range.end; + } + let Some(s) = self.input_text(cx, s) else { return vec![] }; + self.clicks = (None, 0); + // Pasting what we copied keeps its formatting (unless pasted text is kept plain). + let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); + if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text { + let runs = self.clipboard.clone(); + return self.replace(cx, "", Some(runs)); + } + let mut out = self.replace(cx, &s, None); + if list_space { + out.extend(self.start_typed_list(cx)); + } + out + } + /// Would [`Tool::text_input`] change `s` before inserting it? Control characters are dropped, + /// `\r` becomes `\n`, Typographer's Quotes rewrites a straight quote, and copied text keeps + /// its run styles. An IME commit the application rewrites is an application edit, not an + /// accepted IME insertion (its receipt must stay `Other`). + fn text_input_is_verbatim(&self, cx: &ToolContext, s: &str) -> bool { + if self.editing.is_none() { + return true; + } + if s.is_empty() { + return true; + } + // A straight quote's direction depends on the character before the insertion. During a + // commit this check still sees the pre-edit at the caret, while `text_input` replaces it + // from its start; with a custom pair whose opening quote is straight, that can look + // verbatim here and then be rewritten. Never claim verbatim for one. + if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") { + return false; + } + let Some(normalized) = self.input_text(cx, s) else { return false }; + if normalized != s { + return false; + } + let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); + !(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text) + } + /// Whether the typing session still describes the edited document; a session ended by an outside + /// command reports false until a replacement commit starts a new one. + fn ime_session_matches_document(&self, cx: &ToolContext) -> bool { + match (self.editing, &self.typing) { + (Some(id), Some(ty)) => Self::text(cx, id).is_none_or(|t| ty.cur == t.runs), + // An outside command settled the session: the document may have changed under it, and + // the commit that starts a new session cannot claim the IME made that change. + (Some(_), None) => !self.typing_ended, + _ => true, + } + } + /// Handle an editing key: ignore keys while an IME composes, remap arrows in vertical text, step + /// type attributes with Alt, and otherwise edit, navigate or end the session. + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + // The IME owns the keys while it composes (the UI doesn't send them; this is a guard). + if self.preedit.is_some() { + return vec![]; + } + let Some(t) = self.current(cx) else { + self.editing = None; + return vec![]; + }; + let text = t.plain_text(); + let len = text.len(); + self.caret = self.caret.min(len); + self.anchor = self.anchor.min(len); + let (a, b) = self.sel(); + // Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps). + if mods.alt + && let Some(id) = self.editing + && let Some((attribute, by)) = step_key(key, mods.shift, a == b) + { + let mut out = self.commit(); + let by = if mods.cmd { by * 5.0 } else { by }; + out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by}))); + return out; + } + let word = mods.cmd || mods.alt; + let lay = self.layout(&t); + let key = if lay.vertical { + match key { + ToolKey::Up => ToolKey::Left, + ToolKey::Down => ToolKey::Right, + ToolKey::Right => ToolKey::Up, + ToolKey::Left => ToolKey::Down, + other => other, + } + } else { + key + }; + let vertical = matches!(key, ToolKey::Up | ToolKey::Down); + if !vertical { + self.goal_x = None; + } + match key { + // Escape ends editing and goes back to the tool used before double-clicking into type + // (or to the Selection tool by default), the type selected: the keyboard is free for + // tool shortcuts again (#1686). + ToolKey::Escape => { + let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); + let mut out = self.finish(cx); + out.push(Action::SwitchTool(target)); + out + } + ToolKey::Enter => self.replace(cx, "\n", None), + ToolKey::Tab => self.replace(cx, "\t", None), + ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None), + ToolKey::Backspace if self.caret > 0 => { + self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) }; + self.replace(cx, "", None) + } + // Backspace takes back one character (a mistyped Thai tone mark, say); Delete, the + // Left and Right arrows a whole cluster. + ToolKey::Delete if self.caret < len => { + self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_cluster(&text, self.caret) }; + self.replace(cx, "", None) + } + ToolKey::Left => { + let to = if a != b && !mods.shift { + if lay.glyphs.iter().any(|g| g.rtl) + && vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x + { + b + } else { + a + } + } else if word { + edit::prev_word(&text, self.caret) + } else { + if lay.glyphs.iter().any(|g| g.rtl) { + vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, false) + } else { + edit::prev_cluster(&text, self.caret) + } + }; + self.move_to(to, mods.shift) + } + ToolKey::Right => { + let to = if a != b && !mods.shift { + if lay.glyphs.iter().any(|g| g.rtl) + && vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x + { + a + } else { + b + } + } else if word { + edit::next_word(&text, self.caret) + } else { + if lay.glyphs.iter().any(|g| g.rtl) { + vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, true) + } else { + edit::next_cluster(&text, self.caret) + } + }; + self.move_to(to, mods.shift) + } + ToolKey::Up | ToolKey::Down => { + let d = if key == ToolKey::Up { -1 } else { 1 }; + let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret }; + let x = self.goal_x.unwrap_or_else(|| lay.logical_point(vectorcraft_text::caret_position(&lay, from).0).x); + let to = if mods.cmd { + // Cmd+Up/Down: paragraph start / end. + let p = edit::paragraph_at(&text, from); + if d < 0 { + if from == p.start { edit::paragraph_at(&text, p.start.saturating_sub(1)).start } else { p.start } + } else if from == p.end { + edit::paragraph_at(&text, (p.end + 1).min(len)).end + } else { + p.end + } + } else { + vectorcraft_text::caret_vertical(&lay, from, d, x) + }; + let out = self.move_to(to, mods.shift); + self.goal_x = Some(x); + out + } + ToolKey::Home => { + let to = if mods.cmd { 0 } else { vectorcraft_text::line_home(&lay, self.caret) }; + self.move_to(to, mods.shift) + } + ToolKey::End => { + let to = if mods.cmd { len } else { vectorcraft_text::line_end_of(&lay, self.caret) }; + self.move_to(to, mods.shift) + } + _ => vec![], + } + } + fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option>) -> Vec { + if self.editing.is_none() { + return vec![]; + } + // A clear with nothing marked (some integrations send one when the IME is enabled) must + // not delete the selection. + if text.is_empty() && self.preedit.is_none() { + return vec![]; + } + let text: String = text.chars().filter(|c| !c.is_control()).collect(); + // The new marked text replaces the previous one, or the selection when it starts. + if let Some(p) = self.preedit.take() { + self.anchor = p.range.start; + self.caret = p.range.end; + } + self.clicks = (None, 0); + let mut out = self.replace(cx, &text, None); + if text.is_empty() { + // Cancelled before anything else was typed: no empty undo step. + if self.typing.as_ref().is_some_and(|ty| ty.cur == ty.base) { + self.typing = None; + out.push(Action::Cancel); + } + return out; + } + let end = self.caret; + let start = end.saturating_sub(text.len()); + self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) }); + out + } + /// Delete the requested bytes or characters before and after the selection or marked text, + /// preserving the selection direction and composition and refusing a stale session; byte counts + /// round inward to UTF-8 boundaries. + fn ime_delete_surrounding(&mut self, cx: &ToolContext, before: usize, after: usize, unit: SurroundingUnit) -> Vec { + let Some(id) = self.editing else { return vec![] }; + // The document changed under the session (undo, a panel edit): the ranges below would index + // text that is no longer there. The commit that follows resets the session; do not delete + // from the wrong text in the meantime. + if let Some(t) = Self::text(cx, id) + && self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) + { + return vec![]; + } + // Walk the runs in place: joining the whole edited text here would copy a large document + // for every IME deletion event, though only the characters next to the selection matter. + let Some(segments) = self.plain_segments(cx) else { return vec![] }; + let len = plain_len(&segments); + // The text the IME's following commit replaces (the selection, or the marked text while + // composing). The surrounding deletion never removes it: it deletes only the bytes/characters + // before and after it, preserving the selection direction and the composition. + let (raw_start, raw_end) = match &self.preedit { + Some(p) => (p.range.start.min(len), p.range.end.min(len)), + None => (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)), + }; + let (before_start, start, end, after_end) = match unit { + SurroundingUnit::Bytes => { + // Inward rounding: a count ending inside a scalar leaves that scalar whole. The + // preceding side moves its start forward (ceil), the following side moves its end + // backward (floor). Do not reverse these: that would delete part of a character. + let start = plain_floor_boundary(&segments, raw_start); + let end = plain_ceil_boundary(&segments, raw_end); + let before_start = plain_ceil_boundary(&segments, start.saturating_sub(before)); + let after_end = plain_floor_boundary(&segments, end.saturating_add(after)); + (before_start, start, end, after_end) + } + SurroundingUnit::Chars => { + let start = plain_floor_boundary(&segments, raw_start); + let end = plain_ceil_boundary(&segments, raw_end); + let before_start = plain_chars_before(&segments, start, before); + let after_end = plain_chars_after(&segments, end, after); + (before_start, start, end, after_end) + } + }; + let mut ranges = Vec::with_capacity(2); + if before_start < start { + ranges.push((before_start, start)); + } + if end < after_end { + ranges.push((end, after_end)); + } + if ranges.is_empty() { + return vec![]; + } + self.delete_ranges(cx, &ranges) + } + /// Return the active Type tool's stable IME owner (document uid, target id, replacement epoch), + /// or `None` when nothing is being edited. + fn ime_target(&self, cx: &ToolContext) -> Option<(u64, u64, u64)> { + let id = self.editing?; + Self::text(cx, id)?; + Some((cx.revision.0, id.0, self.edit_session)) + } + /// Return a bounded logical text window around the caret, anchor and optional pre-edit; a stale + /// typing session publishes the document's current text, and unusable or non-boundary offsets + /// return `Unavailable` rather than stale data. + fn ime_surrounding(&self, cx: &ToolContext, max_bytes: usize) -> ImeSurrounding { + let Some(id) = self.editing else { + return ImeSurrounding::Unavailable("no text object is being edited"); + }; + let Some(current) = Self::text(cx, id) else { + return ImeSurrounding::Unavailable("the edited text object is gone"); + }; + // The document changed under the typing session (undo, a panel or control edit): the + // session's runs, marked-text range and caret offsets no longer describe the document. + // Publish the document's own text (never the stale session's, which the IME cannot see); + // the marked text has no range in it, so it is excluded from the logical view. The + // commit-time check (`ime_session_matches_document`) reports the reset as application + // origin instead of letting a receipt describe the wrong text. + let stale = self.typing.as_ref().is_some_and(|ty| ty.cur != current.runs); + if self.preedit.is_some() && self.typing.is_none() { + return ImeSurrounding::Unavailable("the IME pre-edit has no typing session"); + } + let Some(segments) = self.plain_segments(cx) else { + return ImeSurrounding::Unavailable("the edited text object is gone"); + }; + let len = plain_len(&segments); + let caret = self.caret.min(len); + let anchor = self.anchor.min(len); + if !plain_is_boundary(&segments, caret) || !plain_is_boundary(&segments, anchor) { + return ImeSurrounding::Unavailable("the caret or selection anchor is not a UTF-8 boundary"); + } + let preedit = match self.preedit.as_ref().filter(|_| !stale) { + Some(p) => { + let start = p.range.start.min(len); + let end = p.range.end.min(len); + if start > end || !plain_is_boundary(&segments, start) || !plain_is_boundary(&segments, end) { + return ImeSurrounding::Unavailable("the IME pre-edit is not on UTF-8 boundaries"); + } + Some(start..end) + } + None => None, + }; + ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes) + } + /// Whether an IME pre-edit is currently shown for this Type-tool session. + fn composing(&self) -> bool { + self.preedit.is_some() + } + fn ime_caret(&self, cx: &ToolContext) -> Option<(Point, Point)> { + let t = self.current(cx)?; + let lay = self.layout(&t); + let len = t.plain_text().len(); + // The candidate window follows the clause being converted, else the caret. + let at = match &self.preedit { + Some(p) => p.range.start + p.active.as_ref().map_or(0, |r| r.start), + None => self.caret.min(self.anchor), + }; + let (a, b) = vectorcraft_text::caret_position(&lay, at.min(len)); + Some((t.xf * a, t.xf * b)) + } + fn deactivate(&mut self, cx: &ToolContext) -> Vec { + self.prev_tool = None; + self.finish(cx) + } + /// Editing ends when a command takes the text out of the selection (Deselect, another object + /// picked in the Layers panel) or removes it. After an Undo that takes back a list typed + /// characters started, the caret goes on after the characters, and after its Redo the caret + /// goes before the paragraph's text. + fn after_command(&mut self, cx: &ToolContext) -> Vec { + self.after_typed_list(cx); + let Some(id) = self.editing else { return vec![] }; + let mut cur = Some(id).filter(|i| cx.doc.node(*i).is_some()); + while let Some(c) = cur { + if cx.selection.contains(c) { + return vec![]; + } + cur = cx.doc.parent_of(c); + } + self.finish(cx) + } + /// After `text.create` / `text.createInPath` the engine selects the new object; edit it. + fn notify(&mut self, cx: &ToolContext, what: &str) { + if what == "text.editNew" + && let Some(id) = cx.selection.objects.first().copied() + { + self.start_editing(id, 0); + let t = Self::text(cx, id); + self.fresh = t.filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id); + // Placeholder text comes selected: typing replaces it. + self.caret = t.map_or(0, |t| edit::runs_len(&t.runs)); + } + } + /// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles + /// `start..end`. + fn options(&self) -> Value { + match self.editing { + Some(id) => { + // Offsets may exceed the text after Select All; callers clamp to the text length. + let (a, b) = self.sel(); + json!({ + "editing": id.0, + "start": a, + "end": b, + "caret": self.caret, + "anchor": self.anchor, + "typing": self.typing.is_some(), + "composing": self.preedit.is_some(), + "prevTool": self.prev_tool, + }) + } + None => json!({"editing": null, "prevTool": self.prev_tool}), + } + } + /// `select {start, end}` / `selectAll` / `copy` (with `runs` of the selection) / + /// `commitTyping` (the caller commits the engine interaction). + fn set_option(&mut self, key: &str, v: &Value) { + match key { + // Transient selection transferred between Type and Eyedropper; never a saved setting. + "textSelection" => { + if let (Some(id), Some(a), Some(b)) = ( + v.get("editing").and_then(Value::as_u64), + v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64), + v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64), + ) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b)) + { + self.start_editing(NodeId(id), b); + self.anchor = a; + } + } + "prevTool" | "previousTool" => { + self.prev_tool = v.as_str().map(String::from); + } + "commitTyping" | "endTyping" => { + self.typing = None; + self.preedit = None; + self.typing_ended = true; + } + "selectAll" if self.editing.is_some() => { + self.anchor = 0; + self.caret = usize::MAX / 4; + } + "select" if self.editing.is_some() => { + let g = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize); + if let (Some(a), Some(b)) = (g("start"), g("end")) { + self.anchor = a; + self.caret = b; + } + } + "copy" => { + if let Ok(runs) = serde_json::from_value::>(v.clone()) { + self.clipboard = runs; + } + } + "stopEditing" => { + self.typing = None; + self.preedit = None; + self.editing = None; + self.prev_tool = None; + } + _ => {} + } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut o = self.snap.guides().to_vec(); + if let (Some((_, s)), Some(d)) = (self.press, self.drag) { + // The area being drawn (along the Constrain Angle's axes when one is set, as `on_up` + // makes it) and its size. + let size = match vectorcraft_geom::constrain_axes(cx.constrain_angle) { + None => { + let r = Rect::from_points(s, d); + o.push(Overlay::Marquee(r)); + r.size() + } + Some(axes) => { + let back = axes.inverse(); + let r = Rect::from_points(back * s, back * d); + let mut path = r.to_path(0.1); + path.apply_affine(axes); + o.push(Overlay::Path { path, color: [79, 128, 255], width: 1.0, dashed: true }); + r.size() + } + }; + if cx.measurement_labels { + o.push(Overlay::Measure { p: d, text: cx.size_label(size.width, size.height) }); + } + } + // The ports of selected area type and type on a path, as many as show ([`PORTS_MAX`]), the + // edited one's included. + let shown = ports_shown(cx.doc, &cx.selection.objects); + if shown { + for id in cx.selection.objects.iter().filter(|id| Some(**id) != self.editing) { + if let Some(t) = ported(cx.doc, *id) { + let lay = self.layout(t); + ports(cx, *id, t, lay.overflow, &mut o); + } + } + } + let Some(t) = self.current(cx) else { return o }; + let lay = self.layout(&t); + let len = t.plain_text().len(); + // Frame / baseline path of the edited object. + let frame: Option = match &t.kind { + TextKind::Area { frame } => Some(frame.to_bezpath()), + TextKind::OnPath { path, .. } => Some(path.to_bezpath()), + TextKind::Point => None, + }; + if let Some(mut f) = frame { + f.apply_affine(t.xf); + o.push(Overlay::Path { path: f, color: [79, 128, 255], width: 1.0, dashed: false }); + } + let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)); + if let Some(pe) = &self.preedit { + // Marked text: a thin underline, a thick one under the clause being converted. + let under = |o: &mut Vec, r: Range, width: f32| { + for q in vectorcraft_text::selection_quads(&lay, r.start.min(len), r.end.min(len)) { + let (l, rr) = (t.xf * q[3], t.xf * q[2]); + o.push(Overlay::Path { path: vectorcraft_geom::Line::new(l, rr).to_path(0.1), color: [0, 0, 0], width, dashed: false }); + } + }; + under(&mut o, pe.range.clone(), 1.0); + match &pe.active { + Some(r) if !r.is_empty() => under(&mut o, pe.range.start + r.start..pe.range.start + r.end, 2.5), + _ => { + let at = pe.range.start + pe.active.as_ref().map_or(pe.range.len(), |r| r.start); + let (p, q) = vectorcraft_text::caret_position(&lay, at.min(len)); + o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false }); + } + } + } else if a == b { + let (p, q) = vectorcraft_text::caret_position(&lay, self.caret.min(len)); + o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false }); + } else { + for q in vectorcraft_text::selection_quads(&lay, a, b) { + o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] }); + } + } + if let Some(id) = self.editing.filter(|id| shown && ported(cx.doc, *id).is_some()) { + ports(cx, id, &t, lay.overflow, &mut o); + } + o + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() { + return Cursor::Crosshair; + } + if self.vertical { Cursor::TypeVertical } else { Cursor::Text } + } +} From dbce5020f2a479ff4c6679314cc43e90f66c8f27 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:50 +0000 Subject: [PATCH 138/199] refactor(ui-egui): move canvas.rs items to canvas/{artboards,context,cursor,drag,grid,home,input,outline,overlays,raster,task_bar,tool_overlays,view}.rs --- crates/ui-egui/src/canvas.rs | 3416 +------------------- crates/ui-egui/src/canvas/artboards.rs | 214 ++ crates/ui-egui/src/canvas/context.rs | 116 + crates/ui-egui/src/canvas/cursor.rs | 152 + crates/ui-egui/src/canvas/drag.rs | 282 ++ crates/ui-egui/src/canvas/grid.rs | 377 +++ crates/ui-egui/src/canvas/home.rs | 121 + crates/ui-egui/src/canvas/input.rs | 408 +++ crates/ui-egui/src/canvas/outline.rs | 201 ++ crates/ui-egui/src/canvas/overlays.rs | 377 +++ crates/ui-egui/src/canvas/raster.rs | 93 + crates/ui-egui/src/canvas/task_bar.rs | 294 ++ crates/ui-egui/src/canvas/tool_overlays.rs | 338 ++ crates/ui-egui/src/canvas/view.rs | 436 +++ 14 files changed, 3465 insertions(+), 3360 deletions(-) create mode 100644 crates/ui-egui/src/canvas/artboards.rs create mode 100644 crates/ui-egui/src/canvas/context.rs create mode 100644 crates/ui-egui/src/canvas/cursor.rs create mode 100644 crates/ui-egui/src/canvas/drag.rs create mode 100644 crates/ui-egui/src/canvas/grid.rs create mode 100644 crates/ui-egui/src/canvas/home.rs create mode 100644 crates/ui-egui/src/canvas/input.rs create mode 100644 crates/ui-egui/src/canvas/outline.rs create mode 100644 crates/ui-egui/src/canvas/overlays.rs create mode 100644 crates/ui-egui/src/canvas/raster.rs create mode 100644 crates/ui-egui/src/canvas/task_bar.rs create mode 100644 crates/ui-egui/src/canvas/tool_overlays.rs create mode 100644 crates/ui-egui/src/canvas/view.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ed1be0766..d590c01f6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -14,6 +14,62 @@ use crate::stylus::StylusFeed; use crate::theme::{self, Tokens}; use crate::{CacheKey, VectorcraftApp, now_ms, widgets}; +mod artboards; +mod context; +mod cursor; +mod drag; +mod grid; +mod home; +mod input; +mod outline; +mod overlays; +mod raster; +mod task_bar; +mod tool_overlays; +mod view; + +use artboards::{ArtboardNameEdit, artboard_label, artboard_marks, artboard_name_editor, name_edit_id}; +#[cfg(test)] +use artboards::{artboard_name_field_rect, begin_artboard_name_edit, commit_artboard_name, names_an_artboard, tool_name_rect}; +use context::{context_menu, panel_drop}; +pub use cursor::{HOVER_STILL_SECS, STILL_SECS, ViewKey}; +use cursor::{cursor_icon, hover_cursor, hover_wait, still_wait}; +use drag::drag_id; +pub use drag::{apply_requests, dispatch, mods}; +pub(crate) use drag::{end_document_drag, release_art_in_window, release_over_panel}; +#[cfg(test)] +use drag::{frame_moves, tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_press_doc_id}; +#[cfg(test)] +use grid::ruler_rects; +use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds}; +pub(crate) use grid::{RulerScale, checker_texture, ruler_label, ruler_origin_dragging}; +#[cfg(test)] +pub(crate) use grid::{TRANSPARENCY_GRID, vertical_label_height}; +use home::home; +use input::handle_input; +use outline::KEY_OUTLINE; +#[cfg(test)] +use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn}; +#[cfg(test)] +use overlays::selected_anchor; +use overlays::{HandleLook, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; +pub(crate) use raster::color_image; +use raster::{changed_pixels, pixel_region, upload}; +use task_bar::task_bar; +pub(crate) use task_bar::{has_colors, task_bar_rect}; +pub use tool_overlays::HiddenChars; +#[cfg(test)] +use tool_overlays::{DIAL_RADIUS, HiddenMarks}; +use tool_overlays::{ + grid_repeat_overlay, hidden_chars_overlay, live_paint_gap_overlays, mirror_axis_overlay, original_path_overlay, print_tiling_overlay, + rotate_view_dial, shaper_widget_overlay, slice_overlay, thread_overlays, +}; +pub(crate) use view::animated_zoom; +pub use view::fit; +use view::scroll_bars; +#[cfg(test)] +use view::{ScrollRest, Span, scroll_by, scroll_spans, scroll_tracks}; + const RULER: f32 = 16.0; /// The scroll bars' thickness along the window's right and bottom edges. const SCROLL_BAR: f32 = 12.0; @@ -102,184 +158,6 @@ enum Drag { Present, } -/// Whether drag `d` is a press the tool holds. Every other drag (panning, zooming, turning the -/// view, a touch gesture, Presentation Mode) never reaches the tool, so its hover ends with it. -fn tool_holds(d: Drag) -> bool { - matches!(d, Drag::Tool | Drag::Art) -} - -/// What a hover depends on besides the pointer's screen position. -#[derive(Clone, Copy, Debug, PartialEq)] -struct HoverContext { - /// The active tool: a tool switched to under a still pointer is hovered at once. - tool: &'static str, - /// The canvas, and the view's centre, zoom and rotation. - canvas: egui::Rect, - center: Point, - zoom: f64, - rotation: f64, - /// The active document's id and revision (which moves with every edit, undo and selection - /// change). - doc: Option<(u64, u64)>, -} - -/// Clear canvas interaction state before a tab transfer takes ownership of the artwork drag. -pub(crate) fn end_document_drag(ctx: &egui::Context) { - ctx.data_mut(|d| { - d.remove::(drag_id()); - d.remove::(drag_pos_id()); - }); -} - -/// Temp-memory key for the active canvas drag state. -fn drag_id() -> egui::Id { - egui::Id::unique("canvas-drag") -} -/// Where the pointer was last seen during a press on the canvas. -fn drag_pos_id() -> egui::Id { - egui::Id::unique("canvas-drag-pos") -} -/// The layer the canvas was last drawn on: a release over another layer is over a panel (#2826). -fn drag_layer_id() -> egui::Id { - egui::Id::unique("canvas-drag-layer") -} -/// Whether `p` is over the canvas itself, not a panel or a floating window over it. -fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool { - resp.rect.contains(p) && ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id) -} -/// The document point last sent to the tool as a drag during [`Drag::Tool`]; none until the press -/// has dragged. -fn tool_drag_doc_id() -> egui::Id { - egui::Id::unique("canvas-tool-drag-doc") -} -/// The document point of the press that began [`Drag::Tool`], sent to the tool as its Down. -fn tool_press_doc_id() -> egui::Id { - egui::Id::unique("canvas-tool-press-doc") -} -/// Whether the tool should get another drag event, and if so records `p` as the last position -/// sent: the pointer moved this frame, or its document position changed without a delta (#834). -/// egui's frame delta is zero when some Wayland compositors coalesce motion until release, and in -/// the frame the pointer comes back into the window after leaving it (`PointerGone` forgot where -/// it was), though it is somewhere new; judged on the delta alone, the preview stayed behind. -fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> bool { - let at = xf.to_doc(p); - let id = tool_drag_doc_id(); - // Before the press has dragged, the last position the tool got is the press itself: a press - // held still is not a drag. - let prev = ui.data(|d| d.get_temp::(id).or_else(|| d.get_temp::(tool_press_doc_id()))); - if pointer.delta() == egui::Vec2::ZERO && prev == Some(at) { - return false; - } - ui.data_mut(|d| d.insert_temp(id, at)); - true -} - -/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch -/// input, else the pressure in the host's feed while a pen is the pointer that moved last -/// ([`crate::Services::stylus`]: [`StylusFeed::press`] for a new press, [`StylusFeed::held`] -/// after it), else the last one seen since the press (`pressed`: a new press, whose mouse has none -/// until a pen reports it). A mouse presses fully (1). -fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 { - let stylus = app.services.stylus.as_ref(); - let id = egui::Id::unique("canvas-pressure"); - let force = ui.input(|i| { - i.events.iter().rev().find_map(|e| match e { - egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), - _ => None, - }) - }); - let force = force.or_else(|| stylus.and_then(if pressed { StylusFeed::press } else { StylusFeed::held })); - let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::(id)) }).unwrap_or(1.0); - ui.data_mut(|d| d.insert_temp(id, p)); - p -} - -/// The positions this frame's pointer moves passed through before its last one, each with the -/// pen force a touch event gave just before it, or the pressure the host's feed kept for that move -/// ([`StylusFeed::take_moves`], macOS), if any. The last move is the pointer's position. A mouse's -/// moves count too, with no pressure: a fast mouse stroke otherwise keeps one position a frame, a -/// sample every few pixels, too few for a low Fidelity to follow the hand (#3328). -fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Option)> { - let pressures = stylus.map(StylusFeed::take_moves).unwrap_or_default(); - ui.input(|i| { - let mut moves = vec![]; - if i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) { - let mut force = None; - for e in &i.events { - match e { - egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => force = Some(f.clamp(0.0, 1.0)), - egui::Event::PointerMoved(p) => moves.push((*p, force)), - _ => {} - } - } - } else if !pressures.is_empty() { - // The feed's last pressure is the last move's: pair them from the end. A lift's 0 is - // no pressure (the press keeps its last touch). - let points: Vec = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some(*p) } else { None }).collect(); - let forces = pressures.iter().rev().map(|f| (*f > 0.0).then_some(*f)).chain(std::iter::repeat(None)); - moves = points.into_iter().rev().zip(forces).collect(); - moves.reverse(); - } else { - moves = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some((*p, None)) } else { None }).collect(); - } - moves.pop(); - // A burst of events (a stalled frame) is thinned to a bounded count. - const MAX: usize = 64; - if moves.len() > MAX { - let step = moves.len().div_ceil(MAX); - moves = moves.into_iter().step_by(step).collect(); - } - moves - }) -} - -/// The moves of this frame ([`frame_moves`]) the active tool gets before the pointer's position: -/// all of them for a tool that builds a stroke from them ([`vectorcraft_engine::Session::tool_wants_samples`]), none -/// for the others, which get one drag a frame. The moves are read either way, so the host's feed -/// pairs the next frame's with its own. -fn tool_frame_moves(ui: &Ui, app: &VectorcraftApp) -> Vec<(Pos2, Option)> { - let moves = frame_moves(ui, app.services.stylus.as_ref()); - if app.session.tool_wants_samples() { moves } else { vec![] } -} - -pub fn mods(m: egui::Modifiers, space: bool) -> Mods { - Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space } -} - -/// Fit the view (View → Fit Artboard / Fit All / Actual Size). -pub fn fit(app: &mut VectorcraftApp, how: &str) { - let Some(rect) = app.canvas_rect else { - if let Some(v) = app.view_mut() { - v.fitted = false; - } - return; - }; - let current = app.view().map_or(0, |v| v.artboard); - let Some(st) = app.session.active() else { return }; - let target = match how { - "view.fitAll" => { - st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)) - } - // The navigator's artboard (the first if it has gone). - _ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect), - }; - let Some(target) = target else { return }; - let zoom = if how == "view.actualSize" { - actual_size_zoom(app.session.prefs.display_print_size) - } else { - // The width and height of the box round the target turned with the view, at zoom 1. - let (sn, cs) = app.view().map_or(0.0, |v| v.rotation.to_radians()).sin_cos(); - let (w, h) = (target.width(), target.height()); - let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0); - let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0); - zx.min(zy).clamp(0.0313, 640.0) - }; - let Some(v) = app.view_mut() else { return }; - v.center = target.center(); - v.fitted = true; - v.zoom = zoom; -} - /// Paint the document canvas, complete or cached artwork, navigation and tool overlays. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -817,3187 +695,5 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// The active tool's cursor at the pointer, as `mods` make it. While the view moves (`still` -/// false), the cursor the tool last gave for the same tool and keys stays: asking the tool again -/// would hit-test the art under the pointer at every frame of the move (#2452). -fn hover_cursor(app: &mut VectorcraftApp, mods: Mods, view: vectorcraft_engine::ViewInfo, still: bool) -> Option { - let id = app.session.tool_id(); - if !still - && let Some((was, with, c)) = app.canvas.hover_cursor - && (was, with) == (id, mods) - { - return Some(c); - } - let c = app.session.cursor(app.hover_doc?, mods, view); - app.canvas.hover_cursor = Some((id, mods, c)); - Some(c) -} - -/// How long (seconds) the view must stay still before a canvas with -/// [`crate::CanvasCache::waits_for_still`] set renders a heavy document again. -pub const STILL_SECS: f64 = 0.2; - -/// How long (seconds) the view must stay still before the canvas hit-tests the art under the -/// pointer again, for Object Highlighting and the tool's cursor. While the view pans, zooms or -/// turns under a resting pointer, the document point under it changes every frame, and hit-testing -/// detailed art each time slowed every frame of the move (#2452). -pub const HOVER_STILL_SECS: f64 = 0.15; - -/// The view [`hover_wait`] watches: the canvas's rectangle and the view's zoom, centre and rotation. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ViewKey { - rect: egui::Rect, - zoom: f64, - center: Point, - rotation: f64, -} - -/// Note the view `key` at time `now` (seconds, egui's input time) → how many more seconds the -/// canvas leaves the art under the pointer alone: the view changed less than [`HOVER_STILL_SECS`] -/// ago. The first view noted counts as still. -fn hover_wait(moved: &mut Option<(ViewKey, f64)>, key: ViewKey, now: f64) -> Option { - match moved { - Some((k, at)) if *k != key => (*k, *at) = (key, now), - Some(_) => {} - None => *moved = Some((key, f64::NEG_INFINITY)), - } - let waited = moved.map_or(f64::INFINITY, |(_, at)| now - at); - // The clock went back (a new egui context): don't wait for it to catch up. - (0.0..HOVER_STILL_SECS).contains(&waited).then_some(HOVER_STILL_SECS - waited) -} - -/// Note that the canvas asks for `key` at time `now` (seconds, egui's input time) → how many more -/// seconds it draws the frame `shown` reprojected instead of rendering `key`: while the last -/// render was `slow`, only the view differs between the two, and the canvas has asked for `key` -/// for less than [`STILL_SECS`]. `still` holds the key last asked for and when the canvas first -/// asked for it. None: the canvas renders `key` now, unless it is the frame shown. -fn still_wait(still: &mut Option<(CacheKey, f64)>, shown: Option<&CacheKey>, key: &CacheKey, slow: bool, now: f64) -> Option { - if still.as_ref().is_none_or(|(k, _)| k != key) { - *still = Some((key.clone(), now)); - } - let waited = still.as_ref().map_or(0.0, |(_, at)| now - at); - // The clock went back (a new egui context): don't wait for it to catch up. - let waiting = (0.0..STILL_SECS).contains(&waited); - (slow && waiting && shown.is_some_and(|s| s != key && only_view_differs(s, key))).then_some(STILL_SECS - waited) -} - -/// Whether two cache keys differ only in the view (zoom, center, rotation, canvas size, pixel -/// density and Pixel Preview's document pixels), with Pixel Preview on in both or off in both. -fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool { - a.pixel.is_some() == b.pixel.is_some() - && *a == CacheKey { zoom: a.zoom, cx: a.cx, cy: a.cy, w: a.w, h: a.h, ppp: a.ppp, rot: a.rot, pixel: a.pixel, ..b.clone() } -} - -fn cursor_icon(c: Cursor) -> egui::CursorIcon { - use egui::CursorIcon as C; - match c { - Cursor::Arrow - | Cursor::ArrowHollow - | Cursor::DirectAnchor - | Cursor::DirectAnchorSelected - | Cursor::ReshapeSegment - | Cursor::GroupSelection - | Cursor::CornerRadius - | Cursor::PathBracket - | Cursor::TypeWidget - | Cursor::LoadedText => C::Default, - Cursor::SelectionCopy => C::Copy, - Cursor::Move => C::Move, - Cursor::Crosshair => C::Crosshair, - Cursor::ResizeH | Cursor::ShearH => C::ResizeHorizontal, - Cursor::ResizeV | Cursor::ShearV => C::ResizeVertical, - Cursor::ResizeNwSe => C::ResizeNwSe, - Cursor::ResizeNeSw => C::ResizeNeSw, - Cursor::Rotate | Cursor::RotateToward(_) => C::Alias, - Cursor::Pen - | Cursor::PenDrawing - | Cursor::PenAdd - | Cursor::PenDelete - | Cursor::PenClose - | Cursor::PenContinue - | Cursor::PenJoin - | Cursor::PenConvert - | Cursor::AnchorPoint - | Cursor::Curvature => C::Crosshair, - Cursor::Text => C::Text, - Cursor::Hand => C::Grab, - Cursor::HandGrab => C::Grabbing, - Cursor::ZoomIn => C::ZoomIn, - Cursor::ZoomOut => C::ZoomOut, - Cursor::Eyedropper => C::Crosshair, - Cursor::NotAllowed => C::NotAllowed, - Cursor::Busy => C::Progress, - Cursor::AddStop => C::Copy, - Cursor::RemoveStop => C::NotAllowed, - Cursor::Slice => C::Crosshair, - Cursor::SliceSelect => C::Default, - Cursor::Width | Cursor::WidthAdd => C::Crosshair, - Cursor::WidthPoint => C::Move, - Cursor::PencilNew => C::Crosshair, - Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair, - Cursor::PencilClose | Cursor::PencilStraight | Cursor::PencilContinue | Cursor::PaintbrushClose => C::Crosshair, - Cursor::TypeVertical => C::VerticalText, - // Not drawn yet: the cursor each borrows. - Cursor::Lasso - | Cursor::MagicWand - | Cursor::Pencil - | Cursor::Paintbrush - | Cursor::BlobBrush - | Cursor::Eraser - | Cursor::Smooth - | Cursor::PathEraser - | Cursor::Join - | Cursor::Knife - | Cursor::Shaper - | Cursor::LivePaintBucket - | Cursor::Measure - | Cursor::PrintTiling - | Cursor::SymbolSprayer - | Cursor::SymbolShifter - | Cursor::SymbolScruncher - | Cursor::SymbolSizer - | Cursor::SymbolSpinner - | Cursor::SymbolStainer - | Cursor::SymbolScreener - | Cursor::SymbolStyler - | Cursor::ObjectsOnPath - | Cursor::Scissors - | Cursor::ScissorsNotAllowed - | Cursor::MeshAdd - | Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)), - } -} - -/// Dispatch canvas pointer, keyboard and drop events through the active document view. -fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { - let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); - let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; - let turn_id = egui::Id::unique("canvas-wheel-turn"); - let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); - let mut turn = turn_before; - let (pointer, m, space, (factor, scroll)) = - ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn))); - if turn != turn_before { - ui.data_mut(|d| d.insert_temp(turn_id, turn)); - } - if turn.glide != 0.0 { - ui.ctx().request_repaint(); - } - let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); - // Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also - // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The - // tool's events below take their document points from the view this moves to. - if resp.hovered() { - if (factor - 1.0).abs() > 1e-6 - && let (Some(p), Some(vm)) = (hover, app.view_mut()) - { - zoom_about(vm, rect, p, vm.zoom * factor); - } - if scroll != egui::Vec2::ZERO { - scroll_view(app, ui, scroll); - } - if let Some(p) = hover - && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) - && let Some(vm) = app.view_mut() - { - rotate_about(vm, rect, p, deg); - } - } - let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); - // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), - // so a tap whose press and lift come in one frame has no hover position: it presses (and - // double-taps) where it was seen last (#491). - let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); - let view = app.view_info(); - touch_gestures(app, ui, rect, view); - // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the - // tool's press is dropped, as a right-click presses nothing. - if resp.long_touched() { - drop_press(app, ui, view); - } - let drag: Option = ui.data(|d| d.get_temp(drag_id())); - // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows - // without moving the mouse (Alt switches the Shape Builder to erase mode). - let mods_changed = ui.data_mut(|d| { - let id = egui::Id::unique("canvas-mods"); - let prev = d.get_temp::(id); - d.insert_temp(id, m); - prev.is_some_and(|prev| prev != m) - }); - // Where the tool hovers: the pointer over the canvas itself, not over a floating panel, a menu, - // a popup or a dialog drawn on top of it ([`on_canvas`]). - let hover_tool = hover.filter(|p| on_canvas(ui, resp, *p)); - // Whether the tool hovered last frame: coming onto the canvas re-hovers it, even after a single - // jump egui doesn't count as moving, and leaving it ends the tool's hover (below). - let was_over = ui.data_mut(|d| { - let id = egui::Id::unique("canvas-hovered"); - let prev = d.get_temp::(id).unwrap_or(false); - d.insert_temp(id, hover_tool.is_some()); - prev - }); - // What a hover means besides where the pointer is on screen: the tool, the canvas, the view - // (scrolled, zoomed or turned by the wheel, a gesture, keys or a command) and the document and - // selection as they are (another tab, an edit, an undo). Under a still pointer, a tool switched - // to is hovered at once, and when the rest changes, a tool that asks for it - // ([`Session::tool_wants_rehover`]) is hovered again where the pointer is. - let context = HoverContext { - tool: app.session.tool_id(), - canvas: rect, - center: v.center, - zoom: v.zoom, - rotation: v.rotation, - doc: app.session.active().map(|st| (st.uid, st.revision)), - }; - let (tool_switched, context_changed) = ui.data_mut(|d| { - let id = egui::Id::unique("canvas-hover-context"); - let prev = d.get_temp::(id); - d.insert_temp(id, context); - prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context)) - }); - - // In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, - // drag or hover, and a drag does not pan the view. A drag that began before the mode was turned - // on ends as usual. - if app.ui.screen_mode == 3 && matches!(drag, None | Some(Drag::Present | Drag::Gesture)) { - let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some(); - // `press` is this frame's new press, or else the press held so far. A quick tap can be - // pressed and released in one frame. - let press = if pressed { Some(Drag::Present) } else { drag }; - if press.is_some() && !pointer.primary_down() { - ui.data_mut(|dd| dd.remove::(drag_id())); - // A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release - // does not move the view to the next artboard. - if press == Some(Drag::Present) && resp.clicked() { - crate::shortcuts::presentation_step(app, true); - } - } else if pressed { - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present)); - } - return; - } - - // A pen turned to its eraser end selects the Eraser, and a pen turned back to its tip selects - // the tool before it. The switch runs only while the pointer is in this window. It runs before - // this frame's press, which then uses the new tool; while a press is held, it waits for the - // release. A turn made over another window takes effect when the pointer comes in. - if drag.is_none() && ui.input(|i| i.pointer.has_pointer()) { - crate::stylus::sync_eraser_tool(app); - } - let tool = app.session.tool_id(); - // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and - // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. - let zoom_mode = if space { m.command } else { tool == "zoom" }; - let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; - let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; - let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. })); - // egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a - // press on the canvas itself reaches the tools, else it would land at the canvas's centre. - if pointer.primary_pressed() - && resp.hovered() - && !middle_pan - && let Some(p) = at - { - ui.ctx().memory_mut(|mem| mem.stop_text_input()); - app.ui.flyout = None; - // The artboard's name sits above its corner. A press there renames on a double-click - // and does not start a tool gesture (a fitted artboard, a new one, Artboard Options). - if let Some(i) = artboard_name_at(app, ui, &xf, p) { - ui.data_mut(|dd| dd.insert_temp(name_press_id(), i)); - } else { - let d = if crate::touch::finger_navigates_only() { - // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. - Drag::Gesture - } else if zoom_mode { - if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } - } else if pan_mode { - Drag::Pan { start: p, center: v.center, middle: false } - } else if rotate_mode { - let c = rect.center(); - Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } - } else { - // Cmd with another tool drags with the selection tool used last (the session lends it). - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; - dispatch(app, &ev, view); - crate::document_drag::begin(app, ui.ctx(), ev.pos); - Drag::Tool - }; - // Panning, zooming or turning the view takes the pointer from the tool's hover. - if !tool_holds(d) { - app.session.tool_hover_ended(view); - } - ui.data_mut(|dd| { - dd.insert_temp(drag_id(), d); - dd.insert_temp(drag_pos_id(), p); - dd.insert_temp(drag_layer_id(), resp.layer_id); - // A new press has not dragged yet, whatever an interrupted one left behind. - dd.remove::(tool_drag_doc_id()); - dd.insert_temp(tool_press_doc_id(), xf.to_doc(p)); - }); - } - } else if drag.is_none() - && resp.hovered() - && pointer.button_pressed(egui::PointerButton::Middle) - && let Some(start) = hover - { - // Middle-button drag pans the view whatever the tool. - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true })); - app.session.tool_hover_ended(view); - } else if let Some(d) = drag { - // The pointer gone (a pen lifted with its press), the drag ends where it was last seen. - let p = pointer.interact_pos().or_else(|| ui.data(|dd| dd.get_temp(drag_pos_id()))).unwrap_or(rect.center()); - let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() }; - if held { - ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); - match d { - Drag::Pan { start, center, .. } => { - let ppp = ui.ctx().pixels_per_point(); - if let Some(vm) = app.view_mut() { - vm.center = center; - pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); - } - } - Drag::RotateView { start_angle, start_rot, snaps } => { - let c = rect.center(); - let a = (p.y - c.y).atan2(p.x - c.x) as f64; - let mut deg = start_rot + (a - start_angle).to_degrees(); - if snaps && m.shift { - deg = (deg / 15.0).round() * 15.0; - } - if let Some(vm) = app.view_mut() { - vm.rotation = vectorcraft_geom::normalize_deg(deg); - } - } - Drag::ZoomBox { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)), - Drag::ZoomScrub { start, held, moved } => { - let dt = ui.input(|i| i.unstable_dt).min(0.25); - let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP); - // Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out). - let exponent = if moved { - f64::from(pointer.delta().x) * SCRUB_ZOOM - } else if held > HOLD_DELAY { - f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 } - } else { - 0.0 - }; - if exponent != 0.0 - && let Some(vm) = app.view_mut() - { - zoom_about(vm, rect, start, vm.zoom * exponent.exp()); - } - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); - } - // Without a pointer it is over a floating panel's own OS window (`drag_art_out`). - Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => { - // Back on the canvas: the move goes on from where it left, with the pointer. - egui::DragAndDrop::clear_payload(ui.ctx()); - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - ui.data_mut(|dd| { - dd.insert_temp(drag_id(), Drag::Tool); - dd.insert_temp(drag_pos_id(), p); - }); - } - Drag::Art | Drag::Gesture | Drag::Present => {} - Drag::Tool if drag_art_out(app, ui, resp, p) => { - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art)); - } - Drag::Tool => { - // Every position the pen or mouse passed this frame, not just the last, for a - // tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a - // mouse reports far more often than frames come). Touch events (a pen on Windows - // or the web, a finger) and a macOS pen's moves carry their own pressure; a - // mouse's, a pen's on X11 among them, take the host's latest. Other tools get - // the last position only, so a costly drag preview runs once a frame. - let moves = tool_frame_moves(ui, app); - // A frame whose pointer ends where the last one did still moved in between. - if tool_drag_moved(ui, &xf, p, &pointer) || !moves.is_empty() { - for (at, force) in moves { - let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); - dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); - } - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts - // a quarter second at most. - if app.session.tool_wants_ticks() { - let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25)); - let r = app.session.tool_tick(dt, view); - apply_requests(app, r); - } - } - } - } else { - // The last position the tool's drag got: none if the press never dragged. - let last_drag: Option = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); - ui.data_mut(|dd| { - dd.remove::(drag_id()); - dd.remove::(drag_pos_id()); - dd.remove::(tool_drag_doc_id()); - dd.remove::(tool_press_doc_id()); - }); - match d { - Drag::ZoomBox { start } => { - let r = egui::Rect::from_two_pos(start, p); - if let Some(vm) = app.view_mut() { - if r.width() > 8.0 && r.height() > 8.0 { - // The marquee's center and its size on screen set the view, at any rotation. - vm.center = xf.to_doc(r.center()); - let fill = (rect.width() / r.width()).min(rect.height() / r.height()); - vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); - } else { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); - } - } - } - // A click (not held, not dragged) steps the zoom as with the marquee. - Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { - if let Some(vm) = app.view_mut() { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); - } - } - Drag::ZoomScrub { .. } => {} - Drag::Tool => { - // The second click of a double-click: don't fit another artboard on this release. - // A double-click on an object does not open Artboard Options. Empty artboard - // space still does. - if resp.double_clicked() && app.session.tool_id() == "artboard" { - app.session.set_tool_option("suppressFitClick", &json!(true)); - } - // A move that came in the release's own frame shapes the preview first: the - // tools commit the last preview, which should be where the pointer let go - // (#834). Only once the press has dragged, so a click's jitter stays a click - // (the Pen's corner anchor, no nudge with Direct Selection). - if let Some(last) = last_drag - && last != xf.to_doc(p) - { - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - Drag::Art if on_canvas(ui, resp, p) => { - // Came back to the canvas and released in one frame: finish the move here. - egui::DragAndDrop::clear_payload(ui.ctx()); - let pressure = pen_pressure(ui, app, false); - let pos = xf.to_doc(p); - dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); - } - Drag::Art => { - // Released over a panel: the move goes back. (The app ends it before the panels - // see the release, so a panel can take the art; this is the headless path.) - // With no document open there is nothing to roll back; the tool still gets its up. - let _ = app.session.cancel_interaction(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); - } - Drag::Gesture | Drag::Present => {} - Drag::Pan { .. } | Drag::RotateView { .. } => {} - } - } - } else if let Some(p) = hover_tool - && (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover())) - { - let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; - dispatch(app, &ev, view); - } - // The pointer left the canvas (for a panel, or out of the window) or went under a floating - // panel, menu, popup or dialog, with no press the tool holds: the tool's hover ends, which no - // hover event would clear (Alt's Distance Guides). - if was_over && hover_tool.is_none() && !drag.is_some_and(tool_holds) { - app.session.tool_hover_ended(view); - ui.ctx().request_repaint(); - } - // The tool works on another thread (a large selection's planar map): it checks each frame - // whether the work is done, and then shows the highlight under the pointer. - if app.session.tool_waiting() { - if let Err(e) = app.session.tool_poll(view, false) { - app.status(e.to_string()); - } - ui.ctx().request_repaint_after(std::time::Duration::from_millis(16)); - } - if pointer.primary_released() - && let Some(i) = ui.data_mut(|d| d.remove_temp::(name_press_id())) - { - select_named_artboard(app, i); - } - if resp.double_clicked() - && let Some(p) = at - { - if let Some(i) = artboard_name_at(app, ui, &xf, p) { - begin_artboard_name_edit(ui, app, i); - } else { - let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; - let before = app.session.tool_id().to_string(); - dispatch(app, &ev, view); - // A double-click on type switched a selection tool to the Type tool: the caret goes where - // the type was clicked, so typing edits it at once. - if before != "type" && app.session.tool_id() == "type" { - app.session.set_tool_option("prevTool", &json!(before)); - for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(app, &PointerEvent { kind, ..ev }, view); - } - } - } - } - if drag.is_some() || pointer.is_moving() || mods_changed { - ui.ctx().request_repaint(); - } -} - -/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`). -const WHEEL_ZOOM: f64 = 0.01; -/// How fast a wheel notch's zoom glides in with Zoom with Mouse Wheel: the time constant (s) of -/// the part still to come. -const WHEEL_GLIDE: f64 = 0.05; - -/// What the wheel did across frames ([`wheel`]). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -struct WheelTurn { - /// The last wheel turn was an Alt-wheel one. - alt: bool, - /// The zoom (natural log) wheel notches still have to glide in. - glide: f64, -} -/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it). -const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0; -/// Animated Zoom: how far the pointer moves before a press is a drag, in points. -const SCRUB_SLOP: f32 = 3.0; -/// Animated Zoom: how long a press is held still before it zooms on, in seconds… -const HOLD_DELAY: f32 = 0.3; -/// …and how fast it zooms then (as `exp(seconds × HOLD_ZOOM)`: twice as close each second). -const HOLD_ZOOM: f64 = std::f64::consts::LN_2; - -/// Performance › Animated Zoom, which needs GPU Performance as in Illustrator: the Zoom tool zooms -/// as it is dragged sideways or held, instead of zooming to the area dragged across. -pub(crate) fn animated_zoom(p: &vectorcraft_engine::Prefs) -> bool { - p.animated_zoom && p.gpu_performance -} - -/// Zoom for View → Actual Size (`view.actualSize`). With Preferences › General › Display Print -/// Size at 100% Zoom off, one document point is one screen point. On, one document inch (72 pt) -/// fills an inch of the screen as the system counts it: 96 screen points, the reference density -/// that display scaling is set against (a CSS inch), whatever the scale factor. -pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 { - if display_print_size { 96.0 / 72.0 } else { 1.0 } -} - -/// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document -/// point under screen point `p` where it is. -fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) { - let before = Xf::new(rect, vm).to_doc(p); - vm.zoom = zoom.clamp(0.0313, 640.0); - vm.center += before - Xf::new(rect, vm).to_doc(p); -} - -/// Turn view `vm` of the canvas `rect` by `degrees` clockwise, keeping the document point under -/// screen point `p` where it is. -fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) { - let before = Xf::new(rect, vm).to_doc(p); - vm.rotation = vectorcraft_geom::normalize_deg(vm.rotation + degrees); - vm.center += before - Xf::new(rect, vm).to_doc(p); -} - -/// Move view `vm`'s content by `(dx, dy)` device pixels along the screen's axes, at `ppp` device -/// pixels a point: the canvas's frame moves by exactly those pixels. -fn pan_pixels(vm: &mut View, ppp: f32, (dx, dy): (f64, f64)) { - let (sn, cs) = vm.rotation.to_radians().sin_cos(); - let k = vm.zoom * f64::from(ppp); - vm.center -= vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / k, (-dx * sn + dy * cs) / k); -} - -/// Screen points `d` in the nearest whole device pixels, at `ppp` device pixels a point. -fn whole_pixels(d: egui::Vec2, ppp: f32) -> (f64, f64) { - ((f64::from(d.x) * f64::from(ppp)).round(), (f64::from(d.y) * f64::from(ppp)).round()) -} - -/// What the scrolls of the view have left to move ([`scroll_view`]). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -struct ScrollRest { - /// Device pixels along the screen's axes, at most half a pixel each. - x: f64, - y: f64, - /// The document, the view's center, zoom and rotation, and the device pixels a point that the - /// last scroll left. - after: Option<(u64, Point, f64, f64, f32)>, -} - -/// Scroll the active view so that its content moves `by` screen points (the wheel, a trackpad, a -/// touch screen, a scroll bar), in whole device pixels: the part of a pixel left over is added to -/// the next scroll. It is dropped when the view changed in between (a zoom, a turn, Fit, a pan -/// of another kind, another document) or the device pixels a point did. -fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) { - let ppp = ui.ctx().pixels_per_point(); - let Some(doc) = app.session.active().map(|st| st.uid) else { return }; - let Some(vm) = app.view_mut() else { return }; - let id = egui::Id::unique("canvas-scroll-rest"); - let rest = scroll_by(vm, doc, ppp, by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); - ui.data_mut(|d| d.insert_temp(id, rest)); -} - -/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point, after the -/// scrolls that left `rest`: what this scroll leaves. -fn scroll_by(vm: &mut View, doc: u64, ppp: f32, by: egui::Vec2, rest: ScrollRest) -> ScrollRest { - let (x, y) = if rest.after == Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) { (rest.x, rest.y) } else { (0.0, 0.0) }; - let (x, y) = (x + f64::from(by.x) * f64::from(ppp), y + f64::from(by.y) * f64::from(ppp)); - let (dx, dy) = (x.round(), y.round()); - pan_pixels(vm, ppp, (dx, dy)); - ScrollRest { x: x - dx, y: y - dy, after: Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) } -} - -/// How far a trackpad rotation turns before it turns the view, in degrees. -const ROTATE_DEAD_ZONE: f64 = 5.0; -/// How long after its last rotation event a trackpad rotation ends, in seconds. -const ROTATE_GESTURE_END: f64 = 0.25; - -/// A trackpad rotation under way ([`trackpad_turn`]). -#[derive(Clone, Copy, Debug)] -struct TrackpadTurn { - /// Degrees it has turned so far, clockwise. - total: f64, - /// When its last rotation event came (egui's input time, in seconds). - last: f64, - /// It has reached [`ROTATE_DEAD_ZONE`] and turns the view. - turning: bool, -} - -/// General › Trackpad Gesture to Rotate View (`on`): the degrees, clockwise, that a two-finger -/// rotation on a Mac trackpad turns the view this frame. egui sends the rotation as -/// [`egui::Event::Rotate`], from winit on macOS and from Safari's gesture events in the web build. -/// A rotation does not turn the view until it reaches [`ROTATE_DEAD_ZONE`]; it then turns the view -/// by all of it, and by each later event as it comes. It ends [`ROTATE_GESTURE_END`] after its last -/// event. Rotation events that come while fingers are on a touch screen do not turn the view. -fn trackpad_turn(ui: &Ui, on: bool) -> Option { - if !on { - return None; - } - let (deg, now) = ui.input(|i| { - let rad: f32 = - if i.multi_touch().is_some() { 0.0 } else { i.events.iter().map(|e| if let egui::Event::Rotate(r) = e { *r } else { 0.0 }).sum() }; - (f64::from(rad).to_degrees(), i.time) - }); - if deg == 0.0 || !deg.is_finite() { - return None; - } - let id = egui::Id::unique("canvas-trackpad-turn"); - let kept: Option = ui.data(|d| d.get_temp(id)); - let mut t = kept.filter(|t| now - t.last <= ROTATE_GESTURE_END).unwrap_or(TrackpadTurn { total: 0.0, last: now, turning: false }); - t.total += deg; - t.last = now; - let turn = if t.turning { - Some(deg) - } else if t.total.abs() >= ROTATE_DEAD_ZONE { - t.turning = true; - Some(t.total) - } else { - None - }; - ui.data_mut(|d| d.insert_temp(id, t)); - turn -} - -/// One scroll bar along its axis, in screen points: the span it scrolls over (`lo..hi`) and the -/// part of it the canvas shows (`v0..v1`). -#[derive(Clone, Copy, Debug, PartialEq)] -struct Span { - lo: f32, - hi: f32, - v0: f32, - v1: f32, -} - -impl Span { - /// The thumb on a track `len` long: where it starts and how long it is. - fn thumb(self, len: f32) -> (f32, f32) { - let total = (self.hi - self.lo).max(1.0); - let size = (len * (self.v1 - self.v0) / total).clamp(MIN_THUMB.min(len), len); - let room = total - (self.v1 - self.v0); - let at = if room > 0.0 { (self.v0 - self.lo) / room * (len - size) } else { 0.0 }; - (at.clamp(0.0, (len - size).max(0.0)), size) - } - /// Screen points the view moves for each point the thumb moves on a track `len` long. - fn ratio(self, len: f32) -> f32 { - let room = (self.hi - self.lo) - (self.v1 - self.v0); - let free = len - self.thumb(len).1; - if room > 0.0 && free > 0.0 { room / free } else { 0.0 } - } -} - -const MIN_THUMB: f32 = 20.0; - -/// The scroll bars' spans, horizontal then vertical: the art and the artboards (`content`) as the -/// view turns them on screen, half a window past each side, and the view itself, so the thumb is -/// always on its track. The canvas has no edge of its own (no drawing area), so the content sets it. -fn scroll_spans(xf: &Xf, content: Option) -> [Span; 2] { - let r = xf.rect; - let around = content.map(|c| egui::Rect::from_points(&xf.quad(c)).expand2(r.size() / 2.0)).filter(|b| b.is_finite()); - let b = around.map_or(r, |b| b.union(r)); - [Span { lo: b.left(), hi: b.right(), v0: r.left(), v1: r.right() }, Span { lo: b.top(), hi: b.bottom(), v0: r.top(), v1: r.bottom() }] -} - -/// The tracks of the horizontal and the vertical scroll bar, along the bottom and the right edge -/// of the window (`outer`), as long as the canvas (`rect`) beside them. -fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; 2] { - let corner = outer.max - vec2(SCROLL_BAR, SCROLL_BAR); - [ - egui::Rect::from_min_max(pos2(rect.left(), corner.y), pos2(corner.x, outer.bottom())), - egui::Rect::from_min_max(pos2(corner.x, rect.top()), pos2(outer.right(), corner.y)), - ] -} - -/// The window's scroll bars ([`scroll_spans`]): a thumb dragged scrolls the view along the -/// screen's axis (a turned view too), a click on the track pages towards it, and the wheel over a -/// bar scrolls along it. -fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) { - let content = app.content_bounds(); - let Some(v) = app.view().copied() else { return }; - let xf = Xf::new(rect, &v); - let painter = ui.painter(); - painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler); - let hover = ui.input(|i| i.pointer.hover_pos()); - // Screen points the view moves this frame, or the center a dragged thumb's drag began at and - // the screen points it has moved the view since. - let mut moved = egui::Vec2::ZERO; - let mut center = None; - for (k, (track, span)) in scroll_tracks(rect, outer).into_iter().zip(scroll_spans(&xf, content)).enumerate() { - let horizontal = k == 0; - let along = |p: Pos2| if horizontal { p.x - track.left() } else { p.y - track.top() }; - let axis = if horizontal { vec2(1.0, 0.0) } else { vec2(0.0, 1.0) }; - let len = if horizontal { track.width() } else { track.height() }; - let (at, size) = span.thumb(len); - let on_thumb = |p: Pos2| (at..=at + size).contains(&along(p)); - let id = egui::Id::unique(("canvas-scroll-bar", k)); - let resp = ui.interact(track, id, Sense::click_and_drag()); - if resp.drag_started() - && let Some(p) = ui.input(|i| i.pointer.press_origin()) - && on_thumb(p) - { - // The span moves with the view: the drag keeps the one it started with. - let d = ThumbDrag { from: along(p), center: v.center, ratio: span.ratio(len), min: span.lo - span.v0, max: span.hi - span.v1 }; - ui.data_mut(|dd| dd.insert_temp(id, d)); - } - let drag: Option = ui.data(|dd| dd.get_temp(id)); - if let Some(d) = drag { - if resp.dragged() - && let Some(p) = resp.interact_pointer_pos() - { - let by = ((along(p) - d.from) * d.ratio).clamp(d.min, d.max); - center = Some((d.center, axis * by)); - } - if !resp.dragged() || resp.drag_stopped() { - ui.data_mut(|dd| dd.remove::(id)); - } - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - // A page is most of the window, and never past the end of the span. - let page = 0.9 * (span.v1 - span.v0); - if along(p) < at { - moved -= axis * page.min(span.v0 - span.lo); - } else if along(p) > at + size { - moved += axis * page.min(span.hi - span.v1); - } - } - if resp.hovered() { - let d = ui.input(|i| i.smooth_scroll_delta); - moved -= axis * if horizontal { d.x + d.y } else { d.y }; - } - painter.rect_filled(track, 0.0, t.ruler); - let edge = if horizontal { [track.left_top(), track.right_top()] } else { [track.left_top(), track.left_bottom()] }; - painter.line_segment(edge, Stroke::new(1.0, t.border)); - let thumb = if horizontal { - egui::Rect::from_min_size(pos2(track.left() + at, track.top()), vec2(size, track.height())).shrink2(vec2(1.0, 3.0)) - } else { - egui::Rect::from_min_size(pos2(track.left(), track.top() + at), vec2(track.width(), size)).shrink2(vec2(3.0, 1.0)) - }; - let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p)); - painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled }); - } - let ppp = ui.ctx().pixels_per_point(); - if let Some((c, by)) = center - && let Some(vm) = app.view_mut() - { - vm.center = c; - pan_pixels(vm, ppp, whole_pixels(-by, ppp)); - } - if moved != egui::Vec2::ZERO { - scroll_view(app, ui, -moved); - } -} - -/// A scroll bar thumb being dragged: where along the track it was pressed, and the view's centre, -/// the span's travel ratio ([`Span::ratio`]) and how far the view could move either way then. -#[derive(Clone, Copy, Debug)] -struct ThumbDrag { - from: f32, - center: Point, - ratio: f32, - min: f32, - max: f32, -} - -/// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white -/// artboard and on the grey pasteboard alike (#725). -fn marquee(p: &egui::Painter, r: egui::Rect) { - marquee_through(p, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]); -} - -/// A [`marquee`] through the screen corners `q` in turn, back to the first: a document rectangle's -/// [`Xf::quad`] in a rotated view. -fn marquee_through(p: &egui::Painter, q: &[Pos2]) { - let pts: Vec = q.iter().chain(q.first()).copied().collect(); - p.add(Shape::line(pts.clone(), Stroke::new(1.0, Color32::from_white_alpha(230)))); - for w in pts.windows(2) { - p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(40)), 3.0, 3.0)); - } -} - -/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: -/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option -/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel -/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points -/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom -/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once. -fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) { - // Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead). - let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta); - if !wheel_zooms { - // egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few - // frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go. - if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) { - turn.alt = m.alt && !m.command; - } - let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); - return if turn.alt && scroll != egui::Vec2::ZERO { - (zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan) - } else { - (zoom, scroll + pan) - }; - } - // The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom. - let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta)); - let mut scroll = pan; - for e in &i.events { - match e { - egui::Event::MouseWheel { unit, delta, modifiers, .. } => { - let d = match unit { - egui::MouseWheelUnit::Point => *delta, - egui::MouseWheelUnit::Line => *delta * line, - egui::MouseWheelUnit::Page => *delta * page, - }; - if modifiers.command { - scroll.x += d.x + d.y; - } else if modifiers.shift { - scroll.y += d.x + d.y; - } else if *unit == egui::MouseWheelUnit::Point { - zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp(); - scroll.x += d.x; - } else { - turn.glide += f64::from(d.y) * WHEEL_ZOOM; - scroll.x += d.x; - } - } - egui::Event::Zoom(f) => zoom *= f64::from(*f), - _ => {} - } - } - // This frame's part of the glide: what is left shrinks by e every WHEEL_GLIDE seconds. - let part = if turn.glide.abs() < 1e-3 { 1.0 } else { 1.0 - (-f64::from(i.stable_dt.min(0.1)) / WHEEL_GLIDE).exp() }; - let step = turn.glide * part; - turn.glide -= step; - (zoom * step.exp(), scroll) -} - -/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second -/// finger drops the press the first one began (its tool interaction rolled back; the rest of the -/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes. -fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) { - if !app.session.prefs.touch_gestures { - return; - } - let (touches, now): (Vec<_>, f64) = ui.input(|i| { - let t = i.events.iter().filter_map(|e| match e { - egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)), - _ => None, - }); - (t.collect(), i.time) - }); - if touches.is_empty() { - return; - } - let key = egui::Id::unique("canvas-touch"); - let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default(); - let mut tap = None; - for (id, phase, pos) in touches { - // A gesture begins on the canvas. - if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) { - continue; - } - // A cancelled contact (a palm the shell drops when a pen lands) keeps nothing it began. - if phase == egui::TouchPhase::Cancel { - drop_press(app, ui, view); - } - match taps.feed(id, phase, pos, now) { - Some(crate::touch::Touch::Fingers) => { - drop_press(app, ui, view); - ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); - } - Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n), - None => {} - } - } - ui.data_mut(|d| d.insert_temp(key, taps)); - // In Presentation Mode a tap of two or three fingers undoes or redoes nothing. - if let Some(cmd) = tap - && app.ui.screen_mode < 3 - { - crate::menus::invoke(app, cmd, serde_json::json!({})); - } -} - -/// Drop the tool press in progress, if any: nothing it began is kept, and the rest of the press -/// does nothing ([`Drag::Gesture`]). -fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) { - match ui.data(|d| d.get_temp::(drag_id())) { - Some(Drag::Tool | Drag::Art) => { - // The tool's mouse-up returns it to rest. A move waiting over a panel is still live - // and would otherwise outlive the drag that owns it (#2826). - // Nothing to roll back without a document; the tool still gets its mouse-up. - let _ = app.session.cancel_interaction(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); - egui::DragAndDrop::clear_payload(ui.ctx()); - } - Some(_) => {} - None => return, - } - ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); -} - -/// A Selection tool move dragged off the canvas (to `p`, over a panel) becomes a panel drag of -/// the selected art ([`widgets::PanelDrag::Art`]): the art waits where it last moved on the -/// canvas, and a panel it is released on takes it (the Graphic Styles panel makes a style of it). -/// Back on the canvas, [`Drag::Art`] takes the move up again where the pointer is (#2826). -fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -> bool { - let Some(st) = app.session.active().filter(|_| app.session.tool_id() == "selection") else { return false }; - // The Selection tool's interaction while it moves (or Alt-copies) the selection. - if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") { - return false; - } - // A floating panel in an OS window of its own takes the art too: craft-ui hands that window - // the pointer while it is over it, and this window sees the pointer go. - craft_ui::docking::route_app_drag(ui.ctx(), crate::panel_docking::area_id()); - if on_canvas(ui, resp, p) && ui.input(|i| i.pointer.has_pointer()) { - return false; - } - let ids = st.selection.objects.clone(); - egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids)); - true -} - -/// A Selection tool move released over a panel: end it before the panels see the release, so a -/// panel that takes the art ([`widgets::PanelDrag::Art`]) takes it where it was, and no move is -/// left open (#2826). The art stays where it last moved while the pointer is over the panel. -pub(crate) fn release_over_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { - crate::document_drag::cleanup(app, ctx); - let art = matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Art)); - // Over a floating panel's own OS window the pointer is gone from this window, and the release - // comes here later, at a point of this window that may lie on the canvas: that panel takes it. - let gone_id = drag_id().with("art-gone"); - if !ctx.input(|i| i.pointer.primary_released()) { - if art { - let gone = ctx.input(|i| !i.pointer.has_pointer()); - ctx.data_mut(|d| d.insert_temp(gone_id, gone)); - } - return; - } - let gone = ctx.data_mut(|d| d.remove_temp::(gone_id)).unwrap_or(false); - let p = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())); - if !art { - return; - } - let layer = ctx.data(|d| d.get_temp::(drag_layer_id())); - let on = !gone && p.is_some_and(|p| app.canvas_rect.is_some_and(|r| r.contains(p)) && ctx.layer_id_at(p).is_none_or(|l| Some(l) == layer)); - if on { - // The canvas's release finishes the move. - return; - } - end_art_move(app, ctx); -} - -/// A release in a floating panel's own OS window, during an art drag from the canvas: the move -/// ends before that panel takes the art. The main window would only see it on its next pass. -pub(crate) fn release_art_in_window(app: &mut VectorcraftApp, ctx: &egui::Context) { - let art = matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Art)); - if art && ctx.input(|i| i.pointer.primary_released()) { - end_art_move(app, ctx); - } -} - -/// Roll back the art move dragged off the canvas; a panel takes the art instead. -fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) { - // With no document open there is nothing to roll back; the tool still gets its mouse-up. - let _ = app.session.cancel_interaction(); - let view = app.view_info(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); - ctx.data_mut(|d| { - d.remove::(drag_id()); - d.remove::(drag_id().with("art-gone")); - }); -} - -/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). -pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { - let r = app.session.pointer(ev, view); - // The artboard the Artboard tool makes active is the active one (the navigator's, the - // Artboards panel's). - if app.session.tool_id() == "artboard" - && let Some(i) = app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()) - && let Some(v) = app.view_mut() - { - v.artboard = i; - } - // A press with a selection tool inside an artboard makes it the active one (#693), as the - // Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to - // Artboard aligns to it, and All on Active Artboard selects on it. - if ev.kind == PointerKind::Down - && vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id()) - && let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos)) - && let Some(v) = app.view_mut() - { - v.artboard = i; - } - apply_requests(app, r); -} - -/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. -pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result>) { - match r { - Ok(reqs) => { - for r in reqs { - match r { - vectorcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p), - vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t), - vectorcraft_engine::UiRequest::Status(msg) => app.status(msg), - vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p), - } - } - } - Err(e) => app.status(e.to_string()), - } -} - -/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per -/// look, and its cell size in screen points. -pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) { - let colors = setup.grid_colors.map(|c| crate::panels::c32(&c)); - let id = egui::Id::unique("transparency-grid-tile"); - let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors); - let tex = cached.map(|(_, t)| t).unwrap_or_else(|| { - let [a, b] = colors; - let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]); - let opts = egui::TextureOptions { wrap_mode: egui::TextureWrapMode::Repeat, ..egui::TextureOptions::NEAREST }; - let t = ctx.load_texture(TRANSPARENCY_GRID, img, opts); - ctx.data_mut(|d| d.insert_temp(id, (colors, t.clone()))); - t - }); - (tex, setup.grid_size.cell()) -} - -/// Name of the transparency grid's texture. -pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid"; - -/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose -/// cells stay `cell` screen points at every zoom and turn with a rotated view. -fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) { - // Two cells per texture repeat. - let per = (2.0 * cell as f64 / zoom).max(1e-9); - textured_quad(p, tex, quad, ((ab.width() / per) as f32, (ab.height() / per) as f32)); -} - -/// One quad drawn with `tex`: its corners `quad` (from the top left, clockwise) show the texture -/// from (0, 0) to (`u`, `v`). -fn textured_quad(p: &egui::Painter, tex: egui::TextureId, quad: &[Pos2], (u, v): (f32, f32)) { - let mut mesh = egui::Mesh::with_texture(tex); - for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) { - mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE }); - } - mesh.indices.extend([0, 1, 2, 0, 2, 3]); - p.add(Shape::mesh(mesh)); -} - -/// The zoom the pixel grid shows from in Pixel Preview (600%), and its colour: a translucent grey, -/// so the art's pixels read through it. -const PIXEL_GRID_ZOOM: f64 = 6.0; -const PIXEL_GRID: Color32 = Color32::from_rgba_premultiplied(64, 64, 64, 96); - -/// The look of the grid or of the guides (Preferences › Guides & Grid › Color and Style). -#[derive(Clone, Copy)] -struct LineLook { - color: Color32, - dots: bool, -} - -impl LineLook { - fn grid(prefs: &vectorcraft_engine::Prefs) -> Self { - Self { color: pref_color(&prefs.grid_color, Color32::from_gray(200)), dots: prefs.grid_style == "dots" } - } - - fn guides(prefs: &vectorcraft_engine::Prefs, t: &Tokens) -> Self { - Self { color: pref_color(&prefs.guide_color, t.guide), dots: prefs.guide_style == "dots" } - } - - /// A guide from `a` to `b`: a line, or dots. - fn guide(self, p: &egui::Painter, a: Pos2, b: Pos2) { - if self.dots { - p.extend(Shape::dotted_line(&[a, b], self.color, 4.0, 0.75)); - } else { - p.line_segment([a, b], Stroke::new(1.0, self.color)); - } - } -} - -/// A `#rrggbb` colour preference, `fallback` when it doesn't parse. -fn pref_color(hex: &str, fallback: Color32) -> Color32 { - vectorcraft_color::Color::from_hex(hex).map_or(fallback, |c| crate::panels::c32(&c)) -} - -/// The view in document space: the box round the canvas's corners (the view may be rotated). -fn view_bounds(xf: &Xf) -> Rect { - let r = xf.rect; - let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|c| xf.to_doc(c)); - corners.iter().fold(Rect::from_points(corners[0], corners[0]), |b, c| b.union_pt(*c)) -} - -/// The part of the segment from `a` to `b` inside `r`, none when the segment misses it. -fn clip_segment(a: Pos2, b: Pos2, r: egui::Rect) -> Option<(Pos2, Pos2)> { - let d = b - a; - let (mut t0, mut t1) = (0.0_f32, 1.0_f32); - // Each edge as (the run toward it, the room before it). - for (run, room) in [(-d.x, a.x - r.left()), (d.x, r.right() - a.x), (-d.y, a.y - r.top()), (d.y, r.bottom() - a.y)] { - if run == 0.0 { - if room < 0.0 { - return None; - } - } else if run < 0.0 { - t0 = t0.max(room / run); - } else { - t1 = t1.min(room / run); - } - } - (t0 <= t1).then(|| (a + d * t0, a + d * t1)) -} - -/// The grid (View → Show Grid): a gridline every `spacing` and `subdiv` lighter lines between -/// (left out when they would crowd). In the Dots style, a dot where gridlines cross the -/// subdivisions instead. -fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32, look: LineLook) { - // The painter clips to the canvas. - let view = view_bounds(xf); - let sub = spacing / subdiv.max(1) as f64; - let step = if sub * xf.zoom >= 6.0 { sub } else { spacing }; - // Dots crowd sooner than lines. - if step * xf.zoom < 4.0 || (look.dots && spacing * xf.zoom < 8.0) { - return; - } - let major = |v: f64| (v / spacing - (v / spacing).round()).abs() < 1e-6; - // The lines across the view (where along their axis), at least 4 px apart (and capped). - let lines = |from: f64, to: f64| { - let first = (from / step).floor(); - (0..10_000u32).map(move |i| (first + f64::from(i)) * step).take_while(move |v| *v <= to) - }; - if look.dots { - let mut mesh = egui::Mesh::default(); - for x in lines(view.x0, view.x1) { - for y in lines(view.y0, view.y1).filter(|y| major(x) || major(*y)) { - mesh.add_colored_rect(egui::Rect::from_center_size(xf.to_screen(Point::new(x, y)), vec2(1.5, 1.5)), look.color); - } - } - p.add(Shape::mesh(mesh)); - return; - } - let minor = look.color.gamma_multiply(0.4); - let stroke = |major: bool| Stroke::new(1.0, if major { look.color } else { minor }); - for x in lines(view.x0, view.x1) { - p.line_segment([xf.to_screen(Point::new(x, view.y0)), xf.to_screen(Point::new(x, view.y1))], stroke(major(x))); - } - for y in lines(view.y0, view.y1) { - p.line_segment([xf.to_screen(Point::new(view.x0, y)), xf.to_screen(Point::new(view.x1, y))], stroke(major(y))); - } -} - -/// A ruler label for `v` (in the ruler's unit) on a ruler whose labels are `step` apart: whole -/// numbers, or the fewest decimals that write `step` exactly, so a binary step keeps its value -/// (an inch ruler's 1/16 reads `0.0625`, not the `0.06` that rounding the step's magnitude gave). -/// How far down the left ruler a label of `chars` characters reaches, counting from its top: it -/// is spelled one character per line, [`RULER_CHAR`] apart, and the last one is a line tall. -pub(crate) fn vertical_label_height(chars: usize) -> f32 { - chars.saturating_sub(1) as f32 * RULER_CHAR + RULER_LINE -} - -/// A ruler label's line spacing and line height on the left ruler. -const RULER_CHAR: f32 = 8.5; -const RULER_LINE: f32 = 9.5; - -pub(crate) fn ruler_label(v: f64, step: f64) -> String { - let exact = |d: u32| (step * 10f64.powi(d as i32)).fract().abs() < 1e-9; - let decimals = (0..=7).find(|d| exact(*d)).unwrap_or(7) as usize; - let s = format!("{v:.decimals$}"); - // Trailing zeros go, as `Unit::number` drops them: a sixteenth-inch ruler labels the inch `1`, - // not `1.0000`, which the vertical ruler would spell down six lines. - let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { &s }; - if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { s.into() } -} - -/// The rulers, numbered in `unit` (the General unit). -/// The top ruler, the left ruler and the box where they meet. -fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] { - let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER)); - let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom())); - [top, left, egui::Rect::from_min_size(full.min, vec2(RULER, RULER))] -} - -/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's -/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left -/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else, -/// it makes none. The corner drags the rulers' origin or resets it on a double-click. -fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option { - let [top, left, corner] = ruler_rects(full); - // Right-click on a ruler or the origin box: the document units, to swap between them as - // Preferences ▸ Units ▸ General does ([`crate::menus::ruler_menu_body`]). - let mut unit_clicked = None; - for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] { - // Click-and-drag so the same widget drags out guides (left) and opens the unit menu (right). - let resp = ui.interact(r, egui::Id::unique(id), Sense::click_and_drag()); - resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); - let kind = if resp.drag_stopped() { - PointerKind::Up - } else if resp.dragged() { - PointerKind::Drag - } else { - continue; - }; - let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); - let on_canvas = p.is_some_and(|p| canvas.contains(p)); - if on_canvas { - // The new guide shows even if guides were hidden. - app.ui.view.guides = true; - } - let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 }; - if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) { - app.status(e.to_string()); - } - } - let corner = ui.interact(corner, egui::Id::unique("ruler-corner"), Sense::click_and_drag()); - corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); - if let Some((id, p)) = unit_clicked { - crate::menus::invoke(app, &id, p); - } - if corner.double_clicked_by(egui::PointerButton::Primary) { - let artboard = app.view().map_or(0, |v| v.artboard); - if let Err(e) = app.run("document.resetRulerOrigin", json!({"artboard": artboard})) { - app.status(e.to_string()); - } - } - // Keep previews in the UI: only a release on the canvas edits the document, once. Cancelling - // never adds an undo step or interferes with the drawing tool's interaction. - let drag_id = ruler_origin_drag_id(); - let uid = app.session.active().map(|d| d.uid); - if corner.drag_started_by(egui::PointerButton::Primary) { - ui.data_mut(|d| d.insert_temp(drag_id, (uid, false))); - } - let mut preview = None; - if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option, bool)>(drag_id)) { - cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)); - let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p)); - if corner.drag_stopped_by(egui::PointerButton::Primary) { - ui.data_mut(|d| d.remove::<(Option, bool)>(drag_id)); - if !cancelled - && let Some(at) = at - && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)})) - { - app.status(e.to_string()); - } - } else if !ui.input(|i| i.pointer.primary_down()) { - ui.data_mut(|d| d.remove::<(Option, bool)>(drag_id)); - } else { - ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled))); - if !cancelled { - preview = at; - } - } - } - preview -} - -/// The key of the ruler origin drag's state in egui's temporary data. -fn ruler_origin_drag_id() -> egui::Id { - egui::Id::unique("ruler-origin-drag") -} - -/// Is a drag from the rulers' corner in progress? It stays true after Escape cancels the move, until -/// the button is released. -pub(crate) fn ruler_origin_dragging(ctx: &egui::Context) -> bool { - ctx.data(|d| d.get_temp::<(Option, bool)>(ruler_origin_drag_id()).is_some()) -} - -/// What the rulers count, and from where: `unit`, from `origin` (document points), with each -/// unit stretched `stretch` times along the document's x and y, which a video ruler's pixel aspect -/// ratio makes differ. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct RulerScale { - pub(crate) unit: Unit, - pub(crate) origin: Point, - pub(crate) stretch: [f64; 2], -} - -impl RulerScale { - /// The rulers in `unit` (the General unit), from `origin` (the ruler origin). - pub(crate) fn document(unit: Unit, origin: Point) -> Self { - Self { unit, origin, stretch: [1.0, 1.0] } - } - - /// View › Rulers › Show Video Rulers on artboard `ab`: pixels from its top-left corner, each - /// pixel as many points wide as its Video Ruler Pixel Aspect Ratio and one point tall, so the - /// rulers read the artboard's size in video pixels - /// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). - pub(crate) fn video(ab: &vectorcraft_doc::Artboard) -> Self { - Self { unit: Unit::Pixels, origin: Point::new(ab.rect.x0, ab.rect.y0), stretch: [ab.video_ruler_pixel_aspect_ratio, 1.0] } - } - - /// How far document point `p` lies from the origin along `axis` (a unit direction in the - /// document), in points of this scale: each document coordinate divided by its stretch. - /// Divided by the unit's points, it is the value the ruler reads there. - pub(crate) fn along(&self, p: Point, axis: vectorcraft_geom::Vec2) -> f64 { - let d = p - self.origin; - d.x / self.stretch[0] * axis.x + d.y / self.stretch[1] * axis.y - } - - /// The screen points one point of this scale spans along `axis` at `zoom` (screen points per - /// document point): `zoom` itself when nothing is stretched. - pub(crate) fn zoom_along(&self, zoom: f64, axis: vectorcraft_geom::Vec2) -> f64 { - zoom / (axis.x * axis.x / self.stretch[0] + axis.y * axis.y / self.stretch[1]) - } -} - -fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, scale: RulerScale, t: &Tokens) { - let p = ui.painter(); - let [top, left, corner] = ruler_rects(full); - for r in [top, left, corner] { - p.rect_filled(r, 0.0, t.ruler); - } - p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.border)); - p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border)); - // Crosshair in the origin box. - p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick)); - p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick)); - let font = egui::FontId::proportional(9.5); - // Each ruler measures along its own edge, in the canvas's coordinates turned with the view - // (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance - // (screen points) from `from` along the ruler, its length, and the label of a labelled one. - let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f32, Option)| { - // Pick a label step (in the unit) that gives ≥ 50 px between labels on this ruler, at its - // own scale; positions below are in the unit (`per` points of the scale each). - let zoom = scale.zoom_along(xf.zoom, axis); - let (per, step) = (scale.unit.points(), scale.unit.ruler_step(zoom)); - let minor = scale.unit.ruler_minor(step); - let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01; - let (a, b) = (scale.along(xf.to_doc(from), axis), scale.along(xf.to_doc(to), axis)); - let mut v = (a / per / minor).floor() * minor; - while v * per <= b { - // A tick's length: the labelled ones longest, the halves between them shorter. - let (len, label) = if near(v, step) { - (RULER, Some(ruler_label(v, step))) - } else if near(v, step / 2.0) { - (7.0, None) - } else { - (4.0, None) - }; - draw(((v * per - a) * zoom) as f32, len, label); - v += minor; - } - }; - // The canvas's directions along the screen's x and y. - let (sn, cs) = xf.rot.sin_cos(); - let clip_top = p.with_clip_rect(top); - ticks(top.left_top(), top.right_top(), vectorcraft_geom::Vec2::new(cs, -sn), &mut |d, len, label| { - let sx = top.left() + d; - clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); - if let Some(text) = label { - clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, text, font.clone(), t.ruler_tick); - } - }); - let clip_left = p.with_clip_rect(left); - // A label spelled down the ruler is tall, and a long one can reach the next label's place: - // "-0.0625" is seven characters, 60 pt of ruler, where a sixteenth-inch step leaves 54 pt at - // zoom 12. The ticks all stay; a label that would run into the one above it is left out. - let mut filled_to = f32::NEG_INFINITY; - ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, len, label| { - let sy = left.top() + d; - clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); - if let Some(text) = label { - // Vertical labels read top-to-bottom, one digit per line like Illustrator. - let top = sy + 2.0; - if top < filled_to { - return; - } - filled_to = top + vertical_label_height(text.chars().count()); - for (k, ch) in text.chars().enumerate() { - clip_left.text( - pos2(left.left() + 4.0, top + k as f32 * RULER_CHAR), - egui::Align2::LEFT_TOP, - ch.to_string(), - font.clone(), - t.ruler_tick, - ); - } - } - }); - if let Some(h) = hover { - let s = xf.to_screen(h); - clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text)); - clip_left.line_segment([pos2(left.left(), s.y), pos2(left.right(), s.y)], Stroke::new(1.0, t.text)); - } -} - -fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec> { - // Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px). - let mut out: Vec> = vec![]; - let a = xf.affine(); - let mut t = bp.clone(); - t.apply_affine(a); - let mut cur: Vec = vec![]; - kurbo_flatten(&t, 0.25, &mut |el| match el { - PathEl::MoveTo(p) => { - if cur.len() > 1 { - out.push(std::mem::take(&mut cur)); - } - cur.clear(); - cur.push(pos2(p.x as f32, p.y as f32)); - } - PathEl::LineTo(p) => cur.push(pos2(p.x as f32, p.y as f32)), - PathEl::ClosePath => { - if let Some(f) = cur.first().copied() { - cur.push(f); - } - } - _ => {} - }); - if cur.len() > 1 { - out.push(cur); - } - out -} - -fn stroke_path(p: &egui::Painter, bp: &BezPath, xf: &Xf, s: Stroke) { - for line in to_screen_path(bp, xf) { - p.add(Shape::line(line, s)); - } -} - -fn c32(rgb: [u8; 3]) -> Color32 { - Color32::from_rgb(rgb[0], rgb[1], rgb[2]) -} - -/// Outline of a node for highlighting (paths, compound children, area type frames, other -/// text/image bounds, symbol instances' bounds measured by their art from `symbols`; a clip -/// group's clipping path only, as [`walk_drawn`] visits it). -fn node_outline(n: &Node, symbols: &SymbolBoxes) -> BezPath { - let mut bp = BezPath::new(); - walk_drawn(n, &mut |c| match &c.kind { - NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), - // Area type shows its frame: the type area Direct Selection reshapes; type on a path its - // path, which the selection tools' brackets sit on; point type shows its baseline(s). - NodeKind::Text(t) => { - if c.extras().perspective.is_none() { - if let Some(path) = t.area_frame().or_else(|| t.type_path()) { - bp.extend(path.to_bezpath()); - } else if matches!(t.kind, vectorcraft_doc::TextKind::Point) { - for (a, b) in t.baselines() { - bp.move_to(t.xf * a); - bp.line_to(t.xf * b); - } - } - } else if let Some(b) = c.geometric_bounds_with(symbols) { - bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); - } - } - NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { - if let Some(b) = c.geometric_bounds_with(symbols) { - bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); - } - } - // A placed document shows its box, turned with it. - NodeKind::PlacedDocument(p) => { - let [a, b, c, d] = p.corners(); - bp.move_to(a); - [b, c, d].into_iter().for_each(|q| bp.line_to(q)); - bp.close_path(); - } - // A gradient mesh shows its mesh lines (#1111). - NodeKind::Mesh(m) => bp.extend(m.lines().to_bezpath()), - // An envelope shows its mesh (or top object), not its content. - NodeKind::Envelope { .. } => { - if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) { - bp.extend(lines.to_bezpath()); - } - } - _ => {} - }); - bp -} - -/// The width of the key object's outline (Align to Key Object), thicker than the selection's. -const KEY_OUTLINE: f32 = 2.5; - -/// The most points ([`overlay_points`]) the selection's outlines and anchors, and the hover -/// highlight, are drawn for: an Image Trace of a photo selects hundreds of thousands of paths at -/// once, and selecting all of an opened PDF can select as many type objects, images and gradient -/// mesh pieces. Their outlines would make a mesh larger than the GPU takes in one buffer (#525, -/// #2460). -const OVERLAY_MAX_ANCHORS: usize = 100_000; - -/// Whether `nodes` have more than [`OVERLAY_MAX_ANCHORS`] points to outline ([`overlay_points`]; -/// counting stops there). -fn too_many_anchors<'a>(nodes: impl IntoIterator) -> bool { - fn over(n: &Node, left: &mut usize) -> bool { - match left.checked_sub(overlay_points(n)) { - Some(l) => *left = l, - None => return true, - } - // A clip group counts only its clipping path, the one child `walk_drawn` visits. - if n.clips() { - return n.children().and_then(|children| children.first()).is_some_and(|clip| over(clip, left)); - } - !matches!(n.kind, NodeKind::Envelope { .. }) && n.editing_children().iter().any(|c| over(c, left)) - } - let mut left = OVERLAY_MAX_ANCHORS; - nodes.into_iter().any(|n| over(n, &mut left)) -} - -/// The points counted for `n` itself, without its children, toward [`OVERLAY_MAX_ANCHORS`]: a -/// path's anchors; the anchors of area type's frame or of type on a path's path; the four corners -/// of the box that area type or type on a path in perspective, images, symbol instances and -/// placed documents show; six for point type, its box's corners and the ends of the baseline -/// marker a selected one shows; and the points and the ends of the mesh lines of a gradient mesh -/// or an envelope. -fn overlay_points(n: &Node) -> usize { - match &n.kind { - NodeKind::Path { path, .. } => path.anchor_count(), - NodeKind::Text(t) => match &t.kind { - vectorcraft_doc::TextKind::Point => 6, - _ if n.extras().perspective.is_some() => 4, - vectorcraft_doc::TextKind::Area { frame } => frame.anchor_count(), - vectorcraft_doc::TextKind::OnPath { path, .. } => path.anchor_count(), - }, - NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::PlacedDocument(_) => 4, - NodeKind::Mesh(m) => m.lines().anchor_count() + m.points.len(), - NodeKind::Envelope { .. } => { - vectorcraft_doc::live::envelope_overlay(n).map_or(0, |(lines, grid)| lines.anchor_count() + grid.map_or(0, |g| g.points.len())) - } - _ => 0, - } -} - -/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the -/// envelope shows its mesh instead), and a clip group or clipping layer shows only its clipping -/// path, its first child, not the art the path clips (#2422). -fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { - f(n); - if matches!(n.kind, NodeKind::Envelope { .. }) { - return; - } - if n.clips() { - if let Some(clip) = n.children().and_then(|children| children.first()) { - walk_drawn(clip, f); - } - return; - } - for c in n.editing_children().iter() { - walk_drawn(c, f); - } -} - -/// The topmost editable object under document point `p` at `zoom`, as what is drawn there -/// ([`hit_with`]): a swatch or a symbol dropped on art. -fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { - hit_with(app, p, zoom, vectorcraft_doc::hit::hit_test) -} - -/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick -/// it: [`hit_at`], and a clipping path on its outline before the art it clips -/// ([`vectorcraft_doc::hit::HitOptions::clip_outlines`]). Object Highlighting and a right-click's -/// selection show and take what a click of those tools would. -fn selection_hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { - hit_with(app, p, zoom, |doc, p, opt| vectorcraft_doc::hit::hit_test(doc, p, vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..opt })) -} - -/// `test` on the active document at document point `p`, with the options the selection tools hit -/// objects with at `zoom` (Selection & Anchor Display › Tolerance and Object Selection by Path Only, -/// Type › Type Object Selection by Path Only, isolation mode). -fn hit_with( - app: &VectorcraftApp, - p: Point, - zoom: f64, - test: impl FnOnce(&vectorcraft_doc::Document, Point, vectorcraft_doc::hit::HitOptions) -> Option, -) -> Option { - let prefs = &app.session.prefs; - let opt = vectorcraft_doc::hit::HitOptions { - tol: prefs.selection_tolerance / zoom, - outline: app.ui.view.outline, - path_only: prefs.object_selection_by_path_only, - type_path_only: prefs.type_selection_by_path_only, - scope: app.session.active().and_then(|st| st.isolation), - skip_guides: !app.ui.view.guides, - ..Default::default() - }; - test(&app.session.active()?.doc, p, opt) -} - -/// Right-click: the object under the pointer is selected first unless it already is, then the -/// context menu lists what applies to the selection ([`crate::menus::context_items`]). The locked -/// objects under the pointer ([`vectorcraft_doc::hit::locked_objects_at`]) are kept in -/// `ui.context_locked` while the menu is open, for its Unlock submenu. -fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) { - if resp.secondary_clicked() - && let Some(p) = resp.interact_pointer_pos() - { - let p = xf.to_doc(p); - let locked = hit_with(app, p, xf.zoom, |doc, p, opt| Some(vectorcraft_doc::hit::locked_objects_at(doc, p, opt))); - app.ui.context_locked = app.session.active().map(|st| st.uid).zip(locked); - if let Some(st) = app.session.active() - && let Some(top) = selection_hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation)) - && !st.selection.contains(top) - { - // A locked or hidden object can't be selected; the menu is then for the selection as is. - let _ = app.run("select.set", json!({ "ids": [top.0] })); - } - } - let mut clicked = None; - if resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked)).is_none() { - app.ui.context_locked = None; - } - if let Some((id, p)) = clicked { - crate::menus::invoke(app, &id, p); - } -} - -/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer, -/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes -/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the -/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries -/// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on -/// top of its appearance). A chip follows the pointer meanwhile. -fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) { - crate::panels::swatches::drag_preview(app, ui.ctx()); - let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return }; - // Not through a floating panel over the canvas. - if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) { - return; - } - let Some(d) = resp.dnd_release_payload::() else { return }; - // A Libraries panel graphic: a copy centred where it is dropped. - if let widgets::PanelDrag::LibraryGraphic { library, item } = &*d { - let at = xf.to_doc(pos); - if let Err(e) = app.run("library.use", json!({"library": library, "kind": "graphic", "item": item, "center": [at.x, at.y]})) { - app.status(e); - } - return; - } - // A symbol from the Symbols panel: an instance centred where it is dropped, on art or not. - if let widgets::PanelDrag::Symbol(name) = &*d { - let at = xf.to_doc(pos); - if let Err(e) = app.run("symbol.place", json!({"name": name, "x": at.x, "y": at.y})) { - app.status(e); - } - return; - } - // A symbol from a symbol library panel: added to the document's Symbols and placed there. - if let widgets::PanelDrag::LibrarySymbol { library, name } = &*d { - let at = xf.to_doc(pos); - if let Err(e) = app.run("symbol.addFromLibrary", json!({"library": library, "name": name, "place": true, "x": at.x, "y": at.y})) { - app.status(e); - } - return; - } - let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; - let Some(st) = app.session.active() else { return }; - let (cmd, params) = match &*d { - widgets::PanelDrag::Paint { params, .. } => { - // Colour groups paint nothing. - if params.is_null() { - return; - } - let mut params = params.clone(); - params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]); - params["focus"] = json!(false); - (crate::panels::proxy_cmd(app, false), params) - } - widgets::PanelDrag::Appearance(source) => { - let target = hit.top_object(st.isolation); - if target == *source { - return; - } - ("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]})) - } - widgets::PanelDrag::GraphicStyle(name) => { - let add = ui.input(|i| i.modifiers.alt); - ("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add})) - } - // A brush from the Brushes panel: the path it lands on takes it (a compound path as a whole). - widgets::PanelDrag::Brush { name, .. } => { - ("brush.apply", json!({"name": name, "ids": [vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]})) - } - // A brush from a brush library panel: added to the document's Brushes and applied to the - // path it lands on, in one step. - widgets::PanelDrag::LibraryBrush { library, name } => ( - "brush.addFromLibrary", - json!({"library": library, "name": name, "apply": true, "ids": [vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]}), - ), - // Art dragged back onto the canvas: its move was already dropped. (A symbol was placed - // above.) - widgets::PanelDrag::Art(_) - | widgets::PanelDrag::Symbol(_) - | widgets::PanelDrag::LibrarySymbol { .. } - | widgets::PanelDrag::LibraryGraphic { .. } => return, - // A Libraries panel style or group only moves within that panel. - widgets::PanelDrag::LibraryItem { .. } | widgets::PanelDrag::LibraryGroup { .. } => return, - }; - if let Err(e) = app.run(cmd, params) { - app.status(e); - } -} - -fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { - let Some(h) = app.hover_doc else { return }; - // Smart Guides › Object Highlighting: a Smart Guides display option, so it needs them on and Snap to Grid and Pixel Preview off. - if !app.ui.view.smart_guides || !app.session.prefs.object_highlighting || app.ui.view.snap_to_grid || app.ui.view.pixel_preview { - return; - } - if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { - return; - } - let Some(st) = app.session.active() else { return }; - let Some(hit) = selection_hit_at(app, h, xf.zoom) else { return }; - let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf }; - if st.selection.contains(id) { - return; - } - if let Some(n) = st.doc.node(id).filter(|n| !too_many_anchors([*n])) { - let color = c32(st.doc.layer_color(id)); - stroke_path(p, &node_outline(n, &st.doc.symbol_boxes()), xf, Stroke::new(1.5, color)); - } -} - -/// Selected anchors are drawn slightly deeper than the layer colour (#4f80ff → #3d82ff for Layer 1). -fn selected_anchor(c: Color32) -> Color32 { - if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c } -} - -/// How anchors and handles look: Selection & Anchor Display › Size and Handles. -#[derive(Clone, Copy)] -struct HandleLook<'a> { - /// Points bigger (or smaller) than the default size 3, per step of Size (1–7). - grow: f32, - /// `solid` (the default), `hollow` or `large`. - style: &'a str, -} - -impl<'a> HandleLook<'a> { - fn of(prefs: &'a vectorcraft_engine::Prefs) -> Self { - Self { grow: prefs.anchor_size.clamp(1, 7) as f32 - 3.0, style: &prefs.handle_style } - } - - /// A direction handle's end at `c`: a solid dot, a hollow one or a larger solid one. - fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) { - let r = 2.75 + self.grow / 2.0; - let hollow = self.style == "hollow"; - p.circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color }); - if hollow { - p.circle_stroke(c, r, Stroke::new(1.0, color)); - } - } -} - -fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { - let r = egui::Rect::from_center_size(c, vec2(size, size)); - if filled { - p.rect_filled(r, 0.0, color); - } else { - p.rect_filled(r, 0.0, Color32::WHITE); - p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); - } -} - -fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { - p.circle(c, size / 2.0, if filled { color } else { Color32::WHITE }, Stroke::new(1.0, color)); -} - -/// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story. -/// The marks of each text object are kept from one frame to the next ([`HiddenChars`]). -fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { - let Some(st) = app.session.active() else { return }; - let clip = p.clip_rect(); - let color = Color32::from_rgb(0x4f, 0x9d, 0xff); - let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0)); - let kept = &mut app.canvas.hidden_chars; - // `last` holds the marks of the last frame. The marks of the objects this frame doesn't draw - // are dropped at the end. - let mut last = std::mem::take(&mut kept.marks); - // The inputs are read before any layout: when fonts load during this frame, the next frame - // lays all type out again. - let inputs = (vectorcraft_text::FontDb::global().generation(), vectorcraft_text::hyphenation_exceptions()); - if kept.inputs != inputs { - last.clear(); - kept.inputs = inputs; - } - // `walk` visits each node's `Arc` in the order `Document::walk` visits the nodes (depth first, - // in paint order). - fn walk(nodes: &[std::sync::Arc], f: &mut impl FnMut(&std::sync::Arc)) { - for n in nodes { - f(n); - if let Some(children) = n.children() { - walk(children, f); - } - } - } - walk(&st.doc.layers, &mut |n| { - let NodeKind::Text(tx) = &n.kind else { return }; - // The box round the object's corners on screen, turned with the view. - if !n.visible || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) { - return; - } - let marks = match last.remove(&n.id) { - Some((was, marks)) if std::sync::Arc::ptr_eq(&was, n) => marks, - _ => std::sync::Arc::new(HiddenMarks::of(tx)), - }; - for &at in &marks.spaces { - p.circle_filled(xf.to_screen(at), 1.2, color); - } - for &(at, glyph) in &marks.ends { - p.text(xf.to_screen(at), egui::Align2::LEFT_BOTTOM, glyph, font.clone(), color); - } - kept.marks.insert(n.id, (n.clone(), marks)); - }); -} - -/// The Show Hidden Characters marks of the text objects drawn in the last frame -/// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an -/// edited object is a new `Arc` and is laid out again, and all type is laid out again when the -/// fonts or the hyphenation exceptions change. -#[derive(Default)] -pub struct HiddenChars { - /// The fonts' generation ([`vectorcraft_text::FontDb::generation`]) and the hyphenation - /// exceptions the marks were laid out with. - inputs: (u64, String), - /// Each text object drawn, by id: the node it was laid out from and its marks. - marks: std::collections::HashMap, std::sync::Arc)>, -} - -/// One text object's marks in document space: a dot over each space, and ¶ or # after the lines -/// that end a paragraph or the story. Type on a path has none. -#[derive(Default)] -struct HiddenMarks { - spaces: Vec, - ends: Vec<(Point, &'static str)>, -} - -impl HiddenMarks { - /// Lay out `tx` and place its marks. - fn of(tx: &vectorcraft_doc::TextObject) -> Self { - let mut marks = Self::default(); - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); - if lay.on_path { - return marks; - } - let text = tx.plain_text(); - let to = |q: Point| tx.xf * q; - // A list marker's glyphs (length 0) stand for no character. - for g in lay.glyphs.iter().filter(|g| g.len > 0) { - if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) { - marks.spaces.push(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0))); - } - } - for (i, line) in lay.lines.iter().enumerate() { - let last = i + 1 == lay.lines.len(); - let breaks = text.get(line.end..).is_some_and(|s| s.starts_with('\n')); - let (glyph, at) = if last && line.end >= text.len() { - ("#", true) - } else if breaks { - ("¶", true) - } else { - ("", false) - }; - if at { - marks.ends.push((to(Point::new(line.x1 + 1.0, line.baseline)), glyph)); - } - } - marks - } -} - -/// View › Show Text Threads: for each thread with a selected frame, a line in its first frame's -/// layer colour from each frame's out port to the next frame's in port, both showing the arrow of a -/// threaded port, where the tools draw the ports of selected type ([`vectorcraft_tools::text`]). -/// Only frames with ports ([`vectorcraft_tools::text::ported`]) are joined: a link to or from a -/// frame in perspective, which has none, gets no line. With more than -/// [`vectorcraft_tools::text::PORTS_MAX`] such frames selected the tools draw no ports, and only -/// the lines are drawn here. `zoom`: screen pixels to the document unit. -fn thread_overlays(app: &VectorcraftApp, zoom: f64) -> Vec { - use vectorcraft_tools::text::{Port, PortState, port_centre, ported, ports_shown, push_port}; - let Some(st) = app.session.active() else { return vec![] }; - let px = 1.0 / zoom.max(1e-9); - let boxes = ports_shown(&st.doc, &st.selection.objects); - let mut o = vec![]; - for thread in &st.doc.text_threads { - if !thread.iter().any(|id| st.selection.objects.contains(id)) { - continue; - } - let color = st.doc.layer_color(thread[0]); - for w in thread.windows(2) { - let [a, b] = [w[0], w[1]]; - let (Some(from), Some(to)) = (ported(&st.doc, a), ported(&st.doc, b)) else { continue }; - let (Some(out), Some(input)) = (port_centre(from, Port::Out, px), port_centre(to, Port::In, px)) else { continue }; - o.push(Overlay::Line { a: out, b: input, color, dashed: false }); - if boxes { - push_port(&mut o, out, PortState::Threaded, st.doc.layer_color(a), px); - push_port(&mut o, input, PortState::Threaded, st.doc.layer_color(b), px); - } - } - } - o -} - -/// View → Show Live Paint Gaps: the gaps of the selected Live Paint groups in each group's Gap -/// Preview Color ([`crate::dialogs::live_paint_gaps::selected_gaps`]). Cached per revision. -fn live_paint_gap_overlays(app: &mut VectorcraftApp) -> Vec { - let Some(st) = app.session.active() else { return vec![] }; - let key = (st.uid, st.revision); - let gaps = match &app.canvas.live_paint_gaps { - Some((k, gaps)) if *k == key => gaps.clone(), - _ => { - let gaps = std::sync::Arc::new(crate::dialogs::live_paint_gaps::selected_gaps(app)); - app.canvas.live_paint_gaps = Some((key, gaps.clone())); - gaps - } - }; - gaps.to_vec() -} - -/// Simplify's Show Original Path: the paths its preview ran on, as they were before it, in a band -/// 3 screen points wide of their layer's color at 40%, under the selection's outlines. Nothing is drawn -/// when they have more than [`OVERLAY_MAX_ANCHORS`] anchor points. -fn original_path_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { - let paths = crate::dialogs::path_ops::original_paths(app); - let Some(st) = app.session.active().filter(|_| paths.iter().map(|(_, path)| path.anchor_count()).sum::() <= OVERLAY_MAX_ANCHORS) else { - return; - }; - for (id, path) in paths { - stroke_path(p, &path.to_bezpath(), xf, Stroke::new(3.0, c32(st.doc.layer_color(id)).gamma_multiply(0.4))); - } -} - -/// The slices (unless View → Hide Slices): user and object slices outlined in the Slices -/// preferences' line colour, auto slices dashed and dimmer, each numbered at its top left (Show -/// Slice Numbers); selected slices have a bolder outline. The layout is cached per revision. -fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { - let Some(st) = app.session.active() else { return }; - let key = (st.uid, st.revision); - let slices = match &app.canvas.slices { - Some((k, s)) if *k == key => s.clone(), - _ => { - let s = std::sync::Arc::new(st.doc.slice_layout()); - app.canvas.slices = Some((key, s.clone())); - s - } - }; - if slices.is_empty() { - return; - } - let prefs = &app.session.prefs; - let line = pref_color(&prefs.slice_line_color, Color32::from_rgb(0xff, 0x3f, 0x3f)); - let dim = line.gamma_multiply(0.55); - let selected = &st.selection.slices; - let objects = &st.selection.objects; - for a in slices.iter() { - let auto = a.source == vectorcraft_doc::SliceSource::Auto; - let on = a.id.is_some_and(|id| selected.contains(&id) || objects.contains(&id)); - let q = xf.quad(a.rect); - let ring: Vec = q.iter().chain(q.first()).copied().collect(); - if auto { - p.extend(Shape::dashed_line(&ring, Stroke::new(1.0, dim), 3.0, 3.0)); - } else { - p.add(Shape::line(ring, Stroke::new(if on { 2.0 } else { 1.0 }, line))); - } - if prefs.show_slice_numbers - && let Some(tl) = q.first() - { - let font = egui::FontId::proportional(9.0); - let galley = p.layout_no_wrap(format!("{:02}", a.number), font, Color32::WHITE); - let badge = egui::Rect::from_min_size(*tl + vec2(1.0, 1.0), galley.size() + vec2(6.0, 2.0)); - p.rect_filled(badge, CornerRadius::ZERO, if auto { dim } else { line }); - p.galley(badge.min + vec2(3.0, 1.0), galley, Color32::WHITE); - } - } -} - -/// The print tiling (View → Show Print Tiling, or the Print Tiling tool): each page of the -/// document's print settings as `print.preview` lays it out, its paper edge and its imageable -/// area dashed, numbered at its top left; tiles outside the tile range dimmer. Cached per revision. -fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { - let Some(st) = app.session.active() else { return }; - let key = (st.uid, st.revision); - let pages = match &app.canvas.print_tiling { - Some((k, pages)) if *k == key => pages.clone(), - _ => { - // Settings that can't print have no pages to show. - let pages = std::sync::Arc::new(vectorcraft_engine::cmd::printtiling::pages(&st.doc).unwrap_or_default()); - app.canvas.print_tiling = Some((key, pages.clone())); - pages - } - }; - let font = egui::FontId::proportional(10.0); - let ring = |r: [f64; 4]| { - let q = xf.quad(Rect::new(r[0], r[1], r[2], r[3])); - q.iter().chain(q.first()).copied().collect::>() - }; - for page in pages.iter() { - let color = if page.printed { PRINT_TILING } else { PRINT_TILING.gamma_multiply(0.45) }; - p.add(Shape::line(ring(page.page), Stroke::new(0.75, color))); - let inner = ring(page.imageable); - p.extend(Shape::dashed_line(&inner, Stroke::new(1.0, color), 4.0, 3.0)); - if let Some(corner) = inner.iter().copied().reduce(|a, b| pos2(a.x.min(b.x), a.y.min(b.y))) { - p.text(corner + vec2(3.0, 2.0), egui::Align2::LEFT_TOP, page.number.to_string(), font.clone(), color); - } - } -} - -/// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard. -const PRINT_TILING: Color32 = Color32::from_gray(96); - -/// A Shaper Group's mode widget (Shaper and Selection tools), drawn with selection edges shown or -/// hidden: an arrow pointing right in Face Selection mode and left in Construction mode. -fn shaper_widget_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { - let Some(st) = app.session.active() else { return }; - if matches!(app.session.tool_id(), "shaper" | "selection") - && let Some(w) = vectorcraft_tools::shaperwidget::ShaperWidget::of(&st.doc, &st.selection, st.isolation, xf.zoom) - { - let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::shaperwidget::RADIUS_PX); - let color = c32(st.doc.layer_color(w.group)); - p.circle_filled(c, r, Color32::WHITE); - p.circle_stroke(c, r, Stroke::new(1.0, color)); - let d = if w.construction { -1.0 } else { 1.0 }; - let arrow = vec![c + vec2(3.0 * d, 0.0), c + vec2(-2.0 * d, -3.0), c + vec2(-2.0 * d, 3.0)]; - p.add(Shape::convex_polygon(arrow, color, Stroke::NONE)); - } -} - -/// High-contrast live controls, using the same geometry as the Selection tool's hit testing. -fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { - if app.session.tool_id() != "selection" || !app.ui.view.bounding_box { - return; - } - let Some(st) = app.session.active() else { return }; - let Some(axis) = vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, xf.zoom) else { return }; - let t = Tokens::get(p.ctx()); - let ends = axis.ends.map(|q| xf.to_screen(q)); - let center = xf.to_screen(axis.center); - if !center.is_finite() || !ends.iter().all(|p| p.is_finite()) { - return; - } - if let Some((a, b)) = clip_segment(ends[0], ends[1], xf.rect) { - p.line_segment([a, b], Stroke::new(5.0, t.measure_bg)); - p.line_segment([a, b], Stroke::new(2.0, t.selection)); - } - for end in ends { - p.circle_filled(end, vectorcraft_tools::mirror::HANDLE_RADIUS, t.measure_bg); - p.circle_stroke(end, vectorcraft_tools::mirror::HANDLE_RADIUS, Stroke::new(2.0, t.selection)); - } - let handle = egui::Rect::from_center_size(center, vec2(12.0, 12.0)); - p.rect_filled(handle, 2.0, t.selection); - p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside); -} - -/// A selected grid repeat's row, column and corner handles and its spacing sliders (#2691), at -/// the places the Selection tool's hit testing finds them. -fn grid_repeat_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { - use vectorcraft_tools::repeat_grid::{GridHandles, GridPart, HANDLE_RADIUS, SLIDER_HALF}; - if app.session.tool_id() != "selection" || !app.ui.view.bounding_box { - return; - } - let frame = app.selection_box(); - let Some(st) = app.session.active() else { return }; - let Some(grid) = GridHandles::of(&st.doc, &st.selection, frame, xf.zoom) else { return }; - let t = Tokens::get(p.ctx()); - let half = f64::from(SLIDER_HALF) / xf.zoom; - for (part, at) in grid.parts() { - let c = xf.to_screen(at); - if !c.is_finite() { - continue; - } - if part.is_slider() { - // A bar across the gap's middle, square to the spacing it sets. - let along = if part == GridPart::HSpacing { vectorcraft_geom::Vec2::new(0.0, half) } else { vectorcraft_geom::Vec2::new(half, 0.0) }; - let ends = [xf.to_screen(at - along), xf.to_screen(at + along)]; - p.line_segment(ends, Stroke::new(6.0, t.measure_bg)); - p.line_segment(ends, Stroke::new(2.5, t.selection)); - } else { - p.circle_filled(c, HANDLE_RADIUS, t.measure_bg); - p.circle_stroke(c, HANDLE_RADIUS, Stroke::new(2.0, t.selection)); - } - } -} - -/// The radius of the Rotate View dial, in screen points. -const DIAL_RADIUS: f32 = 40.0; - -/// The dial a Rotate View drag shows, centered on `c`: a tick every 15° (longer every 90°), a -/// needle pointing where the document's top points in a view turned by `rotation` degrees, and the -/// angle under the center, rounded to whole degrees as in the status bar. -fn rotate_view_dial(p: &egui::Painter, c: Pos2, rotation: f64, t: &Tokens) { - // `dir(deg)` is the screen direction `deg` degrees clockwise from straight up. - let dir = |deg: f32| vec2(deg.to_radians().sin(), -deg.to_radians().cos()); - p.circle(c, DIAL_RADIUS, t.panel.gamma_multiply(0.85), Stroke::new(1.0, t.border)); - for i in 0..24u8 { - let len = if i % 6 == 0 { 8.0 } else { 4.0 }; - let d = dir(f32::from(i) * 15.0); - p.line_segment([c + d * (DIAL_RADIUS - len), c + d * DIAL_RADIUS], Stroke::new(1.0, t.text_dim)); - } - p.line_segment([c, c + dir(rotation as f32) * DIAL_RADIUS], Stroke::new(2.0, t.accent)); - p.circle_filled(c, 2.5, t.accent); - p.text(c + vec2(0.0, 8.0), egui::Align2::CENTER_TOP, format!("{rotation:.0}°"), egui::FontId::proportional(11.0), t.text); -} - -fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) { - // The Selection tool's bounding box (rotated with the objects after a rotation). It hides while - // a drag moves or resizes the selection, so only the art is seen going with the pointer (#712). - let show_box = app.session.tool_id() == "selection" - && !app.session.tool_transforming() - && app.ui.view.bounding_box - && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); - let bbox = if show_box { app.selection_box() } else { None }; - let st = app.session.active(); - let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id)))); - let big_bounds = if big { app.selection_bounds() } else { None }; - let app = &*app; - let Some(st) = app.session.active() else { return }; - let symbols = st.doc.symbol_boxes(); - let tool = app.session.tool_id(); - let direct = vectorcraft_tools::catalog::edits_anchors(tool); - let curvature = tool == "curvature"; - // Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding - // box's handles a point bigger or smaller per step. - let look = HandleLook::of(&app.session.prefs); - let grow = look.grow; - let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 }; - // The selected anchors' handles (drawn once they are counted: Show handles when multiple - // anchors are selected off shows them for a single one only). - let (mut handles, mut with_handles) = (vec![], 0); - // Too many paths to outline: the selection's bounds stand for them. - if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) { - stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id)))); - } - // The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has - // too many paths to outline. - if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) { - let outline = if too_many_anchors([n]) { - n.geometric_bounds_with(&symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()) - } else { - Some(node_outline(n, &symbols)) - }; - if let Some(bp) = outline { - stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); - } - } - for id in st.selection.objects.iter().filter(|_| !big) { - let Some(n) = st.doc.node(*id) else { continue }; - let color = c32(st.doc.layer_color(*id)); - let partial = st.selection.partial(*id); - // Path outlines (and area type / placed document / image / mesh / symbol outlines; - // point type baselines and origin are drawn below). - if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) { - stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color)); - } - // Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh - // envelope's points. - walk_drawn(n, &mut |c| { - let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid); - let grid = match &c.kind { - NodeKind::Mesh(m) => Some(m), - _ => envelope.as_ref(), - }; - if let Some(grid) = grid { - for q in &grid.points { - anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct)); - } - return; - } - let NodeKind::Path { path, .. } = &c.kind else { return }; - // The Curvature tool owns its points while drawing; existing paths use this overlay. - if curvature && curve_points_from_tool { - return; - } - let selected_segments = st.selection.segments.get(&c.id); - if let Some(segments) = selected_segments { - for &(si, seg) in segments { - let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue }; - let curve = sp.segment(seg); - let mut outline = vectorcraft_geom::BezPath::new(); - outline.move_to(curve.p0); - if sp.segment_is_line(seg) { - outline.line_to(curve.p3); - } else { - outline.curve_to(curve.p1, curve.p2, curve.p3); - } - stroke_path(p, &outline, xf, Stroke::new(2.0, color)); - } - } - for (si, ai, a) in path.anchors() { - // An object selected whole has all its anchors selected, and so do the paths inside a - // group, compound path or clip group selected whole. Direction handles show only on a - // path that is selected itself: those are the handles that `handle_anchors` in - // `vectorcraft-tools` lets the tools drag. - let (sel, handles_shown) = match partial { - Some(set) => { - let sel = set.contains(&(si, ai)); - (sel, sel) - } - None => (true, direct && c.id == *id), - }; - let sp = xf.to_screen(a.p); - if !curvature && handles_shown { - with_handles += 1; - let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true)); - handles.extend( - [(a.h_in, sides.0), (a.h_out, sides.1)] - .into_iter() - .filter(|(h, show)| *show && h.distance(a.p) > 1e-6) - .map(|(h, _)| (sp, xf.to_screen(h), color)), - ); - } - let anchor_selected = sel && selected_segments.is_none(); - let ink = if anchor_selected && partial.is_some() { selected_anchor(color) } else { color }; - if curvature { - curve_point(p, sp, ink, anchor_selected && partial.is_some(), grow + 6.0); - } else { - anchor_square(p, sp, ink, anchor_selected, anchor(partial.is_some() || direct)); - } - } - }); - // Centre point (Attributes panel → Show Center). - if n.shows_center() - && let Some(b) = n.geometric_bounds_with(&symbols) - { - anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false)); - } - // Point type: each line's baseline, where a click selects it (`hit`), whatever its - // alignment and rotation, and the anchor (area type shows its frame instead, which may - // not be a rectangle, and type on a path its path). - if let NodeKind::Text(tx) = &n.kind - && matches!(tx.kind, vectorcraft_doc::TextKind::Point) - { - for (a, b) in tx.baselines() { - p.line_segment([xf.to_screen(tx.xf * a), xf.to_screen(tx.xf * b)], Stroke::new(1.0, color)); - } - p.circle_filled(xf.to_screen(tx.xf * Point::ZERO), 2.5, color); - } - } - if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 { - for (sp, hp, color) in handles { - p.line_segment([sp, hp], Stroke::new(1.0, color)); - look.draw(p, hp, color); - } - } - // The spine of each selected blend (or of the blend a selected key object belongs to). - let mut spines = vec![]; - for id in &st.selection.objects { - let is_blend = |b: &vectorcraft_doc::NodeId| st.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. })); - let Some(b) = [Some(*id), st.doc.parent_of(*id)].into_iter().flatten().find(is_blend).filter(|b| !spines.contains(b)) else { continue }; - spines.push(b); - let Some(NodeKind::Blend { children, spec }) = st.doc.node(b).map(|n| &n.kind) else { continue }; - let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) else { continue }; - let color = c32(st.doc.layer_color(b)); - stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color)); - for (_, _, a) in path.anchors() { - anchor_square(p, xf.to_screen(a.p), color, false, anchor(direct)); - } - } - // Live Corners widgets (Selection on a live rectangle or polygon, Direct Selection on any path). - if matches!(tool, "selection" | "directSelection") - && let Some(w) = vectorcraft_tools::corners::CornerWidgets::showing( - &st.doc, - &st.selection, - xf.zoom, - tool == "directSelection", - app.ui.view.corner_widgets, - app.session.prefs.hide_corner_widget_above, - ) - { - for (id, q) in w.visible_on() { - let (sp, color) = (xf.to_screen(q), c32(st.doc.layer_color(id))); - p.circle_filled(sp, 3.0, Color32::WHITE); - p.circle_stroke(sp, 3.0, Stroke::new(1.0, color)); - p.circle_filled(sp, 1.0, color); - } - } - // Bounding box with handles (Selection tool). - if let Some(b) = bbox { - let color = c32(st.doc.layer_color(st.selection.objects[0])); - p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color))); - for h in vectorcraft_tools::bbox::Handle::ALL { - let c = xf.to_screen(b.to_doc() * h.pos(b.rect)); - let hr = egui::Rect::from_center_size(c, vec2(6.0 + grow, 6.0 + grow)); - p.rect_filled(hr, 0.0, Color32::WHITE); - p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); - } - // The type widget beside it: hollow on point type, filled on area type. - if let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) { - let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::typewidget::RADIUS_PX); - p.circle_filled(c, r, if w.area { color } else { Color32::WHITE }); - p.circle_stroke(c, r, Stroke::new(1.0, color)); - } - } - // Pie widgets (Selection tool, with View › Show Bounding Box, on live ellipses): each joined - // by a line to the outline's point at its angle. They stay while one is dragged. - if tool == "selection" && app.ui.view.bounding_box { - for w in vectorcraft_tools::pie::PieWidget::of(&st.doc, &st.selection, xf.zoom) { - let (on, c, r, color) = (xf.to_screen(w.on), xf.to_screen(w.at), vectorcraft_tools::pie::RADIUS_PX, c32(st.doc.layer_color(w.id))); - p.line_segment([on, c], Stroke::new(1.0, color)); - p.circle_filled(c, r, Color32::WHITE); - p.circle_stroke(c, r, Stroke::new(1.0, color)); - } - } -} - -/// Record where the Type tool's IME candidate window goes this pass, if it has an editor. -/// -/// The descriptor itself is observed once at the end of the pass (`crate::ime::end_of_pass`), -/// after dialogs, floating panels, progress windows and the palette draw: any of them can open -/// over the canvas, take the keyboard or run a command that changes the edited text, so a -/// descriptor written here would carry a stale owner, surrounding text or cause. -fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) { - app.ime_caret_rect = None; - // A hidden viewport cannot compose; the end-of-pass observation clears the owner. - if !app.session.tool_wants_text() || app.viewport_hidden(ctx) { - return; - } - let Some((a, b)) = app.session.tool_ime_caret(app.view_info()) else { return }; - let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0)); - app.ime_caret_rect = Some((r, r)); -} - -/// The width an artboard needs on screen for its name to show (px): narrower, the name is left -/// out rather than spilling over its neighbours. -const MIN_LABELLED_ARTBOARD: f32 = 24.0; - -/// Temp-memory key recording an artboard-name press before its edit field opens. -fn name_press_id() -> egui::Id { - egui::Id::unique("canvas-artboard-name-press") -} - -/// Temp-memory key for the artboard name field currently being edited. -fn name_edit_id() -> egui::Id { - egui::Id::unique("canvas-artboard-name-edit") -} - -/// A name being typed above an artboard. `fresh`: this frame opened it, so a focus loss from the -/// press that started it is not the user leaving the field. -#[derive(Clone)] -struct ArtboardNameEdit { - index: usize, - text: String, - fresh: bool, -} - -/// The canvas label for `name`, laid out as [`artboard_label`] draws it. -fn canvas_name_galley(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) -> Option<(std::sync::Arc, Pos2)> { - if !width.is_finite() || width < MIN_LABELLED_ARTBOARD { - return None; - } - let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color); - job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; - let galley = painter.layout_job(job); - let at = corner - vec2(0.0, 4.0 + galley.size().y); - Some((galley, at)) -} - -/// The length on screen, in points, of each arm of an artboard's center mark (a cross), at any zoom. -const CENTER_MARK_ARM: f32 = 6.0; - -/// Artboard `ab`'s display marks in `color`, each line 1 point wide on screen at any zoom and -/// turned with the view: with Show Cross Hairs, a line from the middle of each side through the -/// center to the middle of the opposite side; with Show Center Mark, a cross at the center whose -/// arms run along the artboard's sides, [`CENTER_MARK_ARM`] long on screen -/// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/modify-display-settings-of-artboards.html); -/// with Show Video Safe Areas, the outlines of its action-safe and title-safe areas -/// ([`vectorcraft_doc::Artboard::video_safe_areas`], -/// https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). -fn artboard_marks(painter: &egui::Painter, xf: &Xf, ab: &vectorcraft_doc::Artboard, color: Color32) { - let (r, stroke) = (ab.rect, Stroke::new(1.0, color)); - let c = r.center(); - let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); - if ab.show_cross_hairs { - painter.line_segment([at(r.x0, c.y), at(r.x1, c.y)], stroke); - painter.line_segment([at(c.x, r.y0), at(c.x, r.y1)], stroke); - } - if ab.show_center_mark { - let mid = at(c.x, c.y); - for side in [at(c.x + 1.0, c.y), at(c.x, c.y + 1.0)] { - let arm = (side - mid).normalized() * CENTER_MARK_ARM; - painter.line_segment([mid - arm, mid + arm], stroke); - } - } - if ab.show_video_safe_areas { - for area in ab.video_safe_areas() { - painter.add(Shape::closed_line(xf.quad(area), stroke)); - } - } -} - -/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on -/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949). -fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) { - if let Some((galley, at)) = canvas_name_galley(painter, corner, width, name, color) { - painter.galley(at, galley, color); - } -} - -fn canvas_name_rect(painter: &egui::Painter, corner: Pos2, width: f32, name: &str) -> Option { - canvas_name_galley(painter, corner, width, name, Color32::WHITE).map(|(galley, at)| egui::Rect::from_min_size(at, galley.size()).expand(2.0)) -} - -/// Where the Artboard tool draws "01 - name" for its active artboard (see its overlay label). -fn tool_name_rect(painter: &egui::Painter, xf: &Xf, rect: Rect, index: usize, name: &str) -> egui::Rect { - let z = xf.zoom.max(1e-9); - let anchor = Point::new(rect.x0 - 8.0 / z, rect.y0 - 4.0 / z); - let sp = xf.to_screen(anchor) + vec2(8.0, -14.0); - let galley = painter.layout(format!("{:02} - {name}", index + 1), egui::FontId::proportional(11.0), Color32::WHITE, f32::INFINITY); - egui::Rect::from_min_size(sp, galley.size()).expand(2.0) -} - -/// The artboard whose on-canvas name contains `screen`, if that name is being drawn. -fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: Pos2) -> Option { - let st = app.session.active()?; - let artboard_tool = app.session.tool_id() == "artboard"; - if !artboard_tool && st.doc.artboards.len() <= 1 { - return None; - } - let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); - let painter = ui.painter(); - let mut hit = None; - for (i, ab) in st.doc.artboards.iter().enumerate() { - if tool_labelled == u64::try_from(i).ok() { - continue; - } - let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); - let width = (ab.rect.width() * xf.zoom) as f32; - let text = format!("{:02} - {}", i + 1, ab.name); - if canvas_name_rect(painter, corner, width, &text).is_some_and(|r| r.contains(screen)) { - hit = Some(i); - } - } - if let Some(i) = tool_labelled.and_then(|i| usize::try_from(i).ok()) - && let Some(ab) = st.doc.artboards.get(i) - && tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen) - { - return Some(i); - } - hit -} - -fn select_named_artboard(app: &mut VectorcraftApp, index: usize) { - let n = app.session.active().map(|st| st.doc.artboards.len()).unwrap_or(0); - if index >= n { - return; - } - if let Some(v) = app.view_mut() { - v.artboard = index; - } - if app.session.tool_id() == "artboard" { - app.session.set_tool_option("active", &json!(index)); - } -} - -/// Start editing artboard `index`'s name on the canvas, unless the artboard is locked (the -/// Artboards panel still renames it). -fn begin_artboard_name_edit(ui: &Ui, app: &VectorcraftApp, index: usize) { - let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).filter(|a| !a.locked).map(|a| a.name.clone())) else { return }; - ui.data_mut(|d| d.insert_temp(name_edit_id(), ArtboardNameEdit { index, text: name, fresh: true })); -} - -fn commit_artboard_name(app: &mut VectorcraftApp, index: usize, text: &str) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - let current = app.session.active().and_then(|st| st.doc.artboards.get(index).map(|a| a.name.clone())); - if current.as_deref() == Some(trimmed) { - return; - } - // A refusal is reported by the command; the label just closes. - app.run("artboard.setProps", json!({ "index": index, "name": trimmed })).ok(); -} - -/// The text field over an artboard name a double-click opened. -fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { - let Some(mut edit) = ui.data(|d| d.get_temp::(name_edit_id())) else { return }; - let alive = app.session.active().is_some_and(|st| edit.index < st.doc.artboards.len()); - if !alive { - ui.data_mut(|d| d.remove::(name_edit_id())); - return; - } - let Some(rect) = artboard_name_field_rect(app, ui, xf, edit.index) else { - ui.data_mut(|d| d.remove::(name_edit_id())); - return; - }; - let fresh = edit.fresh; - edit.fresh = false; - let te = ui.put( - rect, - egui::TextEdit::singleline(&mut edit.text) - .id(name_edit_id().with("field")) - .font(egui::FontId::proportional(11.0)) - // Keep the original inset so theme button padding cannot move the canvas label. - .margin(egui::Margin::symmetric(4, 2)), - ); - if fresh || (!te.has_focus() && !te.lost_focus()) { - te.request_focus(); - } - let done = !fresh && te.lost_focus(); - let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); - let (index, text) = (edit.index, edit.text.clone()); - if done { - ui.data_mut(|d| d.remove::(name_edit_id())); - if !escape { - commit_artboard_name(app, index, &text); - } - } else { - ui.data_mut(|d| d.insert_temp(name_edit_id(), edit)); - } -} - -/// Where the rename field sits: the name label's rectangle, at least wide enough to type in. -fn artboard_name_field_rect(app: &VectorcraftApp, ui: &Ui, xf: &Xf, index: usize) -> Option { - let st = app.session.active()?; - let ab = st.doc.artboards.get(index)?; - let artboard_tool = app.session.tool_id() == "artboard"; - let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); - let painter = ui.painter(); - let rect = if tool_labelled == u64::try_from(index).ok() { - tool_name_rect(painter, xf, ab.rect, index, &ab.name) - } else { - let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); - let width = (ab.rect.width() * xf.zoom) as f32; - canvas_name_rect(painter, corner, width, &format!("{:02} - {}", index + 1, ab.name))? - }; - Some(egui::Rect::from_min_size(rect.min, vec2(rect.width().max(80.0), rect.height().max(16.0)))) -} - -/// Is overlay label `text` the Artboard tool's "01 - "? It holds a name, so it is -/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours. -fn names_an_artboard(text: &str) -> bool { - text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit())) -} - -fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) { - for o in overlays { - match o { - // Where the tools select: the document rectangle, turned with the view. - Overlay::Marquee(r) => marquee_through(p, &xf.quad(*r)), - Overlay::Path { path, color, width, dashed } => { - let s = Stroke::new(*width, c32(*color)); - if *dashed { - for line in to_screen_path(path, xf) { - p.extend(Shape::dashed_line(&line, s, 4.0, 3.0)); - } - } else { - stroke_path(p, path, xf, s); - } - } - Overlay::Line { a, b, color, dashed } => { - let s = Stroke::new(1.0, c32(*color)); - let (a, b) = (xf.to_screen(*a), xf.to_screen(*b)); - if *dashed { - p.extend(Shape::dashed_line(&[a, b], s, 4.0, 3.0)); - } else { - p.line_segment([a, b], s); - } - } - Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size), - Overlay::CurvePoint { p: pt, color, filled, size } => curve_point(p, xf.to_screen(*pt), c32(*color), *filled, *size + look.grow), - Overlay::Handle { p: pt, color } => look.draw(p, xf.to_screen(*pt), c32(*color)), - Overlay::Label { p: pt, text, color } => { - let sp = xf.to_screen(*pt) + vec2(8.0, -14.0); - p.text( - sp, - egui::Align2::LEFT_TOP, - crate::panels::label_or_name(text, !names_an_artboard(text)), - egui::FontId::proportional(11.0), - c32(*color), - ); - } - Overlay::Highlight { quad, color } => { - let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); - p.add(Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), c, Stroke::NONE)); - } - Overlay::Measure { p: pt, text } => { - let sp = xf.to_screen(*pt) + vec2(14.0, 14.0); - let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0); - let r = egui::Rect::from_min_size(sp, galley.size() + vec2(12.0, 8.0)); - p.rect_filled(r, CornerRadius::same(3), t.measure_bg); - p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE); - } - Overlay::Distance { a, b, text, color } => distance_guide(p, xf.to_screen(*a), xf.to_screen(*b), text, c32(*color)), - Overlay::GridLine { a, b, color } => { - let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); - p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c)); - } - Overlay::Swatch { p: pt, color, selected } => { - // A white disc under the colour shows its opacity; a dark rim keeps it readable on - // any art, and the accent ring marks the selected stop. - let c = xf.to_screen(*pt); - p.circle_filled(c, 6.0, Color32::WHITE); - p.circle_filled(c, 5.0, Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3])); - p.circle_stroke(c, 6.0, Stroke::new(1.0, Color32::from_gray(32))); - if *selected { - p.circle_stroke(c, 8.0, Stroke::new(2.0, t.accent)); - } - } - } - } -} - -/// A Distance Guides measurement on screen ([`Overlay::Distance`]): the line from `a` to `b` in the -/// smart guide colour `color` with a 4 px tick across each end, and `text` in a pill of that colour -/// on its middle, in white or black, whichever reads on it. -fn distance_guide(p: &egui::Painter, a: Pos2, b: Pos2, text: &str, color: Color32) { - let s = Stroke::new(1.0, color); - p.line_segment([a, b], s); - let along = b - a; - let len = along.length(); - if len > 0.0 && len.is_finite() { - let across = vec2(-along.y, along.x) / len * 4.0; - for end in [a, b] { - p.line_segment([end - across, end + across], s); - } - } - let luma = 0.299 * f32::from(color.r()) + 0.587 * f32::from(color.g()) + 0.114 * f32::from(color.b()); - let ink = if luma > 150.0 { Color32::BLACK } else { Color32::WHITE }; - let galley = p.layout_no_wrap(text.to_owned(), egui::FontId::proportional(11.0), ink); - let r = egui::Rect::from_center_size(a + along / 2.0, galley.size() + vec2(8.0, 4.0)); - p.rect_filled(r, CornerRadius::same(3), color); - p.galley(r.min + vec2(4.0, 2.0), galley, ink); -} - -/// The Home screen (`app.home`; with no document open, unless its preference is off). -fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_filled(rect, 0.0, t.panel_darker); - // Taller than the window (a small window, many recent files): it scrolls. Widgets scrolled out - // of view are clipped, so they take no pointer. - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); - egui::ScrollArea::vertical().id_salt("home-scroll").auto_shrink([false, false]).show(&mut child, |ui| { - let side = (ui.available_width() - 820.0).max(40.0) / 2.0; - let width = (ui.available_width() - 2.0 * side).max(0.0); - ui.add_space(60.0); - ui.horizontal_top(|ui| { - ui.add_space(side); - ui.allocate_ui_with_layout(vec2(width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| { - ui.set_width(width); - home_content(app, ui); - }); - }); - ui.add_space(60.0); - }); -} - -/// The Home screen's column: welcome, New / Open, Recent Files, presets and community links. -fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text)); - ui.add_space(4.0); - ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim)); - ui.add_space(22.0); - ui.horizontal(|ui| { - if widgets::primary_button(ui, tl!("New file")).clicked() { - app.run("file.newDialog", json!({})).ok(); - } - ui.add_space(8.0); - if widgets::secondary_button(ui, tl!("Open")).clicked() { - app.run("file.open", json!({})).ok(); - } - }); - recent(app, ui); - ui.add_space(28.0); - ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text)); - ui.add_space(10.0); - // A few of New Document's presets (`file.newPresets`), as its cards. - let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"]; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(14.0, 14.0); - let cards: Vec<_> = presets.iter().filter_map(|name| vectorcraft_engine::cmd::newdoc::find(&app.session, name)).collect(); - for s in cards { - if crate::dialogs::preset_card(ui, &s, false).clicked() { - app.run("file.new", json!({ "preset": s.name })).ok(); - } - } - }); - ui.add_space(28.0); - ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text)); - ui.add_space(10.0); - crate::community::links(app, ui); -} - -/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and -/// folder; a click opens it. Nothing when there are none. -fn recent(app: &mut VectorcraftApp, ui: &mut Ui) { - const SHOWN: usize = 6; - let files: Vec = crate::io::recent_files(app).iter().take(SHOWN).cloned().collect(); - if files.is_empty() { - return; - } - let t = Tokens::get(ui.ctx()); - ui.add_space(28.0); - ui.label(egui::RichText::new(tl!("Recent Files")).font(theme::semibold(14.0)).color(t.text)); - ui.add_space(8.0); - for path in files { - if recent_row(ui, &path).clicked() { - // A failure is reported to the user there. - let _ = crate::io::open_recent_reporting(app, &path); - } - } -} - -/// One Recent Files row across the Home screen: history icon, name (accent) and folder (dim, -/// truncated). The whole row is one button that lights up under the pointer; its tooltip is the -/// full path. -fn recent_row(ui: &mut Ui, path: &str) -> egui::Response { - const PAD: f32 = 6.0; - const ICON: f32 = 16.0; - let t = Tokens::get(ui.ctx()); - let p = std::path::Path::new(path); - let name = p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()); - let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(); - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), egui::Sense::click()); - if ui.is_rect_visible(rect) { - if resp.hovered() { - ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover); - } - let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + PAD + ICON / 2.0, rect.center().y), vec2(ICON, ICON)); - crate::icons::paint(ui, "history", icon, t.icon); - let x = icon.right() + 8.0; - let name_galley = egui::WidgetText::from(egui::RichText::new(&name).size(13.0).color(t.accent)).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - (rect.right() - PAD - x).max(0.0), - egui::TextStyle::Body, - ); - let folder_x = x + name_galley.size().x + 10.0; - ui.painter().galley(egui::pos2(x, rect.center().y - name_galley.size().y / 2.0), name_galley, t.accent); - let room = rect.right() - PAD - folder_x; - if room > 0.0 && !folder.is_empty() { - let folder_galley = egui::WidgetText::from(egui::RichText::new(&folder).size(12.0).color(t.text_dim)).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - room, - egui::TextStyle::Body, - ); - ui.painter().galley(egui::pos2(folder_x, rect.center().y - folder_galley.size().y / 2.0), folder_galley, t.text_dim); - } - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); - resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand) -} - -fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { - // Bounded per curve: a document's far-out handles, zoomed onto the screen, made kurbo - // reserve gigabytes for the selection outline. - vectorcraft_geom::flatten(p.elements().iter().copied(), tol, f); -} - -/// The rendered raster `img` as an egui image. Both are premultiplied RGBA8, so its bytes are copied -/// over whole instead of pixel by pixel, which costs milliseconds for a canvas on a large screen. -/// A raster whose bytes don't match its size gives an empty image of that size. -pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImage { - let size = [img.width as usize, img.height as usize]; - let mut pixels = vec![egui::Color32::TRANSPARENT; size[0] * size[1]]; - let bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut pixels); - if bytes.len() == img.pixels.len() { - bytes.copy_from_slice(&img.pixels); - } - egui::ColorImage::new(size, pixels) -} - -/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document -/// changed, or placed documents' bitmaps were made, since the frame shown (its key `key` but for -/// the revision and [`CacheKey::placed`], and its render options `opts`), which this thread -/// rendered, and no frame is on its way from the worker: those it changed in, and those the -/// objects showing those bitmaps' files cover (`Some(None)`: none), grown to whole eighths of the -/// frame, as long as they are at most half of it. None: render the whole frame. Frames the worker -/// renders stay whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. -fn changed_pixels( - app: &VectorcraftApp, - key: &CacheKey, - doc: &vectorcraft_doc::Document, - opts: &vectorcraft_render::RenderOptions, - view: Affine, - w: u32, - h: u32, -) -> Option> { - let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?; - // A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds. - if opts.mask_view.is_some() { - return None; - } - let shown_key = app.canvas.key.as_ref()?; - let same_view = *shown_key == CacheKey { revision: shown_key.revision, placed: shown_key.placed, ..key.clone() }; - if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) { - return None; - } - // The placed documents' files whose bitmaps were made since the frame shown. - let placed = if shown_key.placed == key.placed { - vectorcraft_render::Changed::Nothing - } else { - vectorcraft_render::placed_area(doc, &vectorcraft_render::placed_document::made_between(shown_key.placed, key.placed)?) - }; - match vectorcraft_render::changed(shown, doc).and(placed) { - vectorcraft_render::Changed::Nothing => Some(None), - vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { - None => Some(None), - Some([x0, y0, x1, y1]) => { - let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h)); - (u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1])) - } - }, - vectorcraft_render::Changed::Everything => None, - } -} - -/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a -/// frame rendered again come in few sizes, which keep the contexts the renderer keeps. -fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) { - let step = n.div_ceil(8).max(1); - let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n); - let a = a.min(n - len); - (a, a + len) -} - -/// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified. -fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) { - let color = color_image(img); - let options = if pixel { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; - match &mut app.canvas.texture { - Some(tex) => tex.set(color, options), - None => app.canvas.texture = Some(ctx.load_texture("canvas", color, options)), - } -} - -/// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view -/// included. None when the view is degenerate. -fn pixel_region(xf: &Xf) -> Option<[i64; 4]> { - let r = xf.rect; - let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| xf.to_doc(p)); - let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); - for p in corners { - (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); - } - let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil()); - // A document pixel is bigger than a screen pixel here, so the region is about canvas-sized. - let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= 16384.0 && y1 - y0 <= 16384.0; - ok.then_some([x0 as i64, y0 as i64, x1 as i64, y1 as i64]) -} - -/// The Contextual Task Bar's area. -fn task_bar_id() -> egui::Id { - egui::Id::unique("task-bar") -} - -/// Where the Contextual Task Bar was last drawn (none while it isn't shown). -pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option { - let id = task_bar_id(); - ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) -} - -/// Whether the objects `ids` (or anything inside them) are painted with a colour: a solid colour, -/// a gradient's stops or a mesh's points, which Recolor Artwork can change. -pub(crate) fn has_colors(doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]) -> bool { - ids.iter().filter_map(|id| doc.node(*id)).any(|n| { - let mut found = false; - vectorcraft_doc::swatches::node_colors(n, &mut |_, _| found = true); - found - }) -} - -/// The bounds of the artboards the Artboard tool has selected ([`crate::panels::artboards::picked`]). -fn picked_artboards_bounds(app: &VectorcraftApp) -> Option { - let st = app.session.active()?; - let picked = crate::panels::artboards::picked(app, st.doc.artboards.len()); - picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b)) -} - -/// The Contextual Task Bar: a floating pill under the selection with the most likely next actions. -/// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard -/// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens). -/// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the -/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). -fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { - let t = Tokens::get(ui.ctx()); - let on_artboards = app.session.tool_id() == "artboard"; - if !on_artboards - && (app.session.active().is_none_or(|st| st.selection.is_empty()) - || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")) - { - return; - } - let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return }; - let Some(st) = app.session.active() else { return }; - let n = st.selection.len(); - let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned(); - let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. })); - let is_image = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Image(_))); - let is_clip_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { clip: true, .. })); - let is_compound = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Compound { .. })); - let open_path = first.as_ref().is_some_and(|f| matches!(&f.kind, NodeKind::Path { path, .. } if !path.is_closed())); - let text = first.as_ref().and_then(|f| match &f.kind { - NodeKind::Text(t) => Some(&t.kind), - _ => None, - }); - // Images and vector art selected together: a clipping mask is the likely next step. - let mixed = n > 1 && { - let images = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).filter(|o| matches!(o.kind, NodeKind::Image(_))).count(); - images > 0 && images < n - }; - // An Image Trace object: the group the engine names "Image Trace", its source image first. - let is_trace = is_group - && first.as_ref().is_some_and(|f| { - f.name.as_deref() == Some("Image Trace") && f.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) - }); - let mut items: Vec<(&str, &str, &str, serde_json::Value)> = vec![]; // (label, icon, command, params) - let none = || json!({}); - if on_artboards { - // As in the reference app, the Artboard tool offers to resize, duplicate and export the - // artboards (), - // and to rearrange them (). - items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS, none())); - items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS, none())); - if st.doc.artboards.len() > 1 { - items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); - } - items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); - } else { - // Edit Path: the Direct Selection tool, to edit the path's anchors. - let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); - // As in the reference app, an image offers Image Trace and Crop Image, and traced art offers - // Expand (). - if n == 1 && is_image { - items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none())); - items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none())); - } - // Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar - // offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does - // (). - // These run the same commands as the Control bar's anchor buttons. - let anchors = !st.selection.anchors.is_empty(); - if anchors { - items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none())); - items.push((tl!("Connect"), "dc-join", "path.join", none())); - items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none())); - items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); - items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); - } - // As in the reference app, open paths offer Join and Edit Path, compound paths and clipping - // masks their Release, images selected with vector art Make Clipping Mask, and type converts - // between point and area type (). - if n > 1 { - items.push((tl!("Group"), "group", "object.group", none())); - if mixed { - items.push((tl!("Make Clipping Mask"), "dc-pf-crop", "object.clippingMask.make", none())); - } else { - items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite", none())); - } - } else if is_trace { - items.push((tl!("Expand"), "maximize-2", "imageTrace.expand", none())); - } else if is_clip_group { - items.push((tl!("Edit Clipping Path"), "dc-direct", "object.clippingMask.editMask", none())); - items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none())); - } else if is_group { - items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none())); - items.push((tl!("Isolate"), "square-dashed", "object.isolate", none())); - } else if is_compound { - items.push(edit_path()); - items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none())); - } else if let Some(kind) = text { - items.push((tl!("Create Outlines"), "type", "type.createOutlines", none())); - // Point type converts to area type and back; type on a path doesn't. - match kind { - TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), - TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), - TextKind::OnPath { .. } => {} - } - } else if is_image { - // Offset Path and Simplify don't apply to an image. - } else if !anchors { - if open_path { - items.push((tl!("Join"), "dc-join", "path.join", none())); - } - items.push(edit_path()); - items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath", none())); - items.push((tl!("Simplify"), "spline", "object.path.simplify", none())); - } - items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); - } - let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default(); - let colored = has_colors(&st.doc, &st.selection.objects); - let doc = st.uid; - // Under the selection's lowest corner on screen, centered on the selection (the view may be - // rotated). - let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); - let anchor = pos2(xf.to_screen(b.center()).x, lowest); - let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); - // Its place under the selection, then where the handle moved it, kept on the canvas (with the - // bar's size last frame, the estimate before it first shows). - let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); - let id = task_bar_id(); - let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(vec2(est_w, 44.0)); - let bounds = xf.rect.shrink(8.0); - let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min)); - let place = &mut app.ui.task_bar_place; - // Just unpinned: follow the selection from the pinned spot. - if !place.pinned - && let Some(at) = place.pin_at.take() - { - place.offset = Some((doc, xf.rect.min + at - under)); - } - let pos = keep_in(match place.pin_at { - Some(at) => xf.rect.min + at, - None => under + place.offset.filter(|(d, _)| *d == doc).map_or(Vec2::ZERO, |(_, o)| o), - }); - place.shown_at = Some(pos - xf.rect.min); - // Pinned before it ever showed: hold it here. - if place.pinned && place.pin_at.is_none() { - place.pin_at = place.shown_at; - } - let pinned = place.pinned; - let mut moved = Vec2::ZERO; - let mut run: Option<(&str, serde_json::Value)> = None; - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { - egui::Frame::NONE - .fill(t.panel) - .stroke(Stroke::new(1.0, t.tool_active)) - .corner_radius(CornerRadius::same(5)) - .inner_margin(egui::Margin::symmetric(8, 6)) - .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) }) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - let (g, grip) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::drag()); - let active = grip.hovered() || grip.dragged(); - ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), if active { t.text_dim } else { t.button_border }); - if grip.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - moved = grip.drag_delta(); - } else if grip.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - for (label, icon, cmd, params) in &items { - let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); - let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); - } - ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside); - crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon); - ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong); - if resp.clicked() { - run = Some((*cmd, params.clone())); - } - } - // The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool. - if !on_artboards { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill); - ui.painter().rect_stroke( - egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), - 0.0, - Stroke::new(1.0, t.button_border), - StrokeKind::Outside, - ); - if resp.on_hover_text(tl!("Fill")).clicked() { - app.session.fill_active = true; - app.ui.open_panel = Some("swatches".into()); - } - // Recolor Artwork, when the selection has colours to recolour. - if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() { - run = Some(("ui.recolorDialog", json!({}))); - } - if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() { - run = Some(("object.lock", json!({}))); - } - } - let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 30.0); - egui::Popup::menu(&more).show(|ui| { - ui.set_min_width(170.0); - let bar = [ - (tl!("Hide Bar"), "window.taskBar", false), - (tl!("Pin Bar Position"), "window.taskBar.pin", pinned), - (tl!("Reset Bar Position"), "window.taskBar.reset", false), - ]; - for (label, cmd, checked) in bar { - if widgets::menu_item(ui, label, true, checked) { - run = Some((cmd, json!({}))); - } - } - ui.separator(); - if widgets::menu_item(ui, tl!("Show Properties Panel"), true, false) { - run = Some(("window.panel", json!({"panel": "properties"}))); - } - }); - }); - }); - }); - if moved != Vec2::ZERO { - let to = keep_in(pos + moved); - let place = &mut app.ui.task_bar_place; - if place.pinned { - place.pin_at = Some(to - xf.rect.min); - } else { - place.offset = Some((doc, to - under)); - } - ui.ctx().request_repaint(); - } - match run { - // Image Trace traces the image at once with the Default preset. As a quick trace from - // Properties or the Control bar, it leaves the Image Trace panel as it was. - Some((TASK_BAR_IMAGE_TRACE, _)) => crate::menus::invoke(app, "imageTrace.make", json!({"preset": "Default"})), - // The Artboard tool's buttons: Artboard Options for the active artboard, as Properties' - // icon opens it; Duplicate for every selected artboard; Rearrange All Artboards. - Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})), - Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app), - Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app), - // Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do. - Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})), - Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})), - Some((c, p)) => crate::menus::invoke(app, c, p), - None => {} - } -} - -/// The Contextual Task Bar's Image Trace button: a trace with the Default preset. -const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace"; -/// The Contextual Task Bar's Resize button with the Artboard tool: Artboard Options. -const TASK_BAR_ARTBOARD_OPTIONS: &str = "taskBar.artboardOptions"; -/// The Contextual Task Bar's Duplicate button with the Artboard tool. -const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards"; -/// The Contextual Task Bar's Rearrange All button with the Artboard tool. -const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards"; -/// The Contextual Task Bar's Corner button for direct-selected anchors. -const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner"; -/// The Contextual Task Bar's Smooth button for direct-selected anchors. -const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth"; - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/canvas/artboards.rs b/crates/ui-egui/src/canvas/artboards.rs new file mode 100644 index 000000000..986be51aa --- /dev/null +++ b/crates/ui-egui/src/canvas/artboards.rs @@ -0,0 +1,214 @@ +use super::*; + +/// The width an artboard needs on screen for its name to show (px): narrower, the name is left +/// out rather than spilling over its neighbours. +const MIN_LABELLED_ARTBOARD: f32 = 24.0; + +/// Temp-memory key recording an artboard-name press before its edit field opens. +pub(super) fn name_press_id() -> egui::Id { + egui::Id::unique("canvas-artboard-name-press") +} + +/// Temp-memory key for the artboard name field currently being edited. +pub(super) fn name_edit_id() -> egui::Id { + egui::Id::unique("canvas-artboard-name-edit") +} + +/// A name being typed above an artboard. `fresh`: this frame opened it, so a focus loss from the +/// press that started it is not the user leaving the field. +#[derive(Clone)] +pub(super) struct ArtboardNameEdit { + pub(super) index: usize, + text: String, + fresh: bool, +} + +/// The canvas label for `name`, laid out as [`artboard_label`] draws it. +fn canvas_name_galley(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) -> Option<(std::sync::Arc, Pos2)> { + if !width.is_finite() || width < MIN_LABELLED_ARTBOARD { + return None; + } + let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color); + job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; + let galley = painter.layout_job(job); + let at = corner - vec2(0.0, 4.0 + galley.size().y); + Some((galley, at)) +} + +/// The length on screen, in points, of each arm of an artboard's center mark (a cross), at any zoom. +const CENTER_MARK_ARM: f32 = 6.0; + +/// Artboard `ab`'s display marks in `color`, each line 1 point wide on screen at any zoom and +/// turned with the view: with Show Cross Hairs, a line from the middle of each side through the +/// center to the middle of the opposite side; with Show Center Mark, a cross at the center whose +/// arms run along the artboard's sides, [`CENTER_MARK_ARM`] long on screen +/// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/modify-display-settings-of-artboards.html); +/// with Show Video Safe Areas, the outlines of its action-safe and title-safe areas +/// ([`vectorcraft_doc::Artboard::video_safe_areas`], +/// https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). +pub(super) fn artboard_marks(painter: &egui::Painter, xf: &Xf, ab: &vectorcraft_doc::Artboard, color: Color32) { + let (r, stroke) = (ab.rect, Stroke::new(1.0, color)); + let c = r.center(); + let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); + if ab.show_cross_hairs { + painter.line_segment([at(r.x0, c.y), at(r.x1, c.y)], stroke); + painter.line_segment([at(c.x, r.y0), at(c.x, r.y1)], stroke); + } + if ab.show_center_mark { + let mid = at(c.x, c.y); + for side in [at(c.x + 1.0, c.y), at(c.x, c.y + 1.0)] { + let arm = (side - mid).normalized() * CENTER_MARK_ARM; + painter.line_segment([mid - arm, mid + arm], stroke); + } + } + if ab.show_video_safe_areas { + for area in ab.video_safe_areas() { + painter.add(Shape::closed_line(xf.quad(area), stroke)); + } + } +} + +/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on +/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949). +pub(super) fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) { + if let Some((galley, at)) = canvas_name_galley(painter, corner, width, name, color) { + painter.galley(at, galley, color); + } +} + +fn canvas_name_rect(painter: &egui::Painter, corner: Pos2, width: f32, name: &str) -> Option { + canvas_name_galley(painter, corner, width, name, Color32::WHITE).map(|(galley, at)| egui::Rect::from_min_size(at, galley.size()).expand(2.0)) +} + +/// Where the Artboard tool draws "01 - name" for its active artboard (see its overlay label). +pub(super) fn tool_name_rect(painter: &egui::Painter, xf: &Xf, rect: Rect, index: usize, name: &str) -> egui::Rect { + let z = xf.zoom.max(1e-9); + let anchor = Point::new(rect.x0 - 8.0 / z, rect.y0 - 4.0 / z); + let sp = xf.to_screen(anchor) + vec2(8.0, -14.0); + let galley = painter.layout(format!("{:02} - {name}", index + 1), egui::FontId::proportional(11.0), Color32::WHITE, f32::INFINITY); + egui::Rect::from_min_size(sp, galley.size()).expand(2.0) +} + +/// The artboard whose on-canvas name contains `screen`, if that name is being drawn. +pub(super) fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: Pos2) -> Option { + let st = app.session.active()?; + let artboard_tool = app.session.tool_id() == "artboard"; + if !artboard_tool && st.doc.artboards.len() <= 1 { + return None; + } + let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); + let painter = ui.painter(); + let mut hit = None; + for (i, ab) in st.doc.artboards.iter().enumerate() { + if tool_labelled == u64::try_from(i).ok() { + continue; + } + let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); + let width = (ab.rect.width() * xf.zoom) as f32; + let text = format!("{:02} - {}", i + 1, ab.name); + if canvas_name_rect(painter, corner, width, &text).is_some_and(|r| r.contains(screen)) { + hit = Some(i); + } + } + if let Some(i) = tool_labelled.and_then(|i| usize::try_from(i).ok()) + && let Some(ab) = st.doc.artboards.get(i) + && tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen) + { + return Some(i); + } + hit +} + +pub(super) fn select_named_artboard(app: &mut VectorcraftApp, index: usize) { + let n = app.session.active().map(|st| st.doc.artboards.len()).unwrap_or(0); + if index >= n { + return; + } + if let Some(v) = app.view_mut() { + v.artboard = index; + } + if app.session.tool_id() == "artboard" { + app.session.set_tool_option("active", &json!(index)); + } +} + +/// Start editing artboard `index`'s name on the canvas, unless the artboard is locked (the +/// Artboards panel still renames it). +pub(super) fn begin_artboard_name_edit(ui: &Ui, app: &VectorcraftApp, index: usize) { + let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).filter(|a| !a.locked).map(|a| a.name.clone())) else { return }; + ui.data_mut(|d| d.insert_temp(name_edit_id(), ArtboardNameEdit { index, text: name, fresh: true })); +} + +pub(super) fn commit_artboard_name(app: &mut VectorcraftApp, index: usize, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let current = app.session.active().and_then(|st| st.doc.artboards.get(index).map(|a| a.name.clone())); + if current.as_deref() == Some(trimmed) { + return; + } + // A refusal is reported by the command; the label just closes. + app.run("artboard.setProps", json!({ "index": index, "name": trimmed })).ok(); +} + +/// The text field over an artboard name a double-click opened. +pub(super) fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { + let Some(mut edit) = ui.data(|d| d.get_temp::(name_edit_id())) else { return }; + let alive = app.session.active().is_some_and(|st| edit.index < st.doc.artboards.len()); + if !alive { + ui.data_mut(|d| d.remove::(name_edit_id())); + return; + } + let Some(rect) = artboard_name_field_rect(app, ui, xf, edit.index) else { + ui.data_mut(|d| d.remove::(name_edit_id())); + return; + }; + let fresh = edit.fresh; + edit.fresh = false; + let te = ui.put( + rect, + egui::TextEdit::singleline(&mut edit.text) + .id(name_edit_id().with("field")) + .font(egui::FontId::proportional(11.0)) + // Keep the original inset so theme button padding cannot move the canvas label. + .margin(egui::Margin::symmetric(4, 2)), + ); + if fresh || (!te.has_focus() && !te.lost_focus()) { + te.request_focus(); + } + let done = !fresh && te.lost_focus(); + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let (index, text) = (edit.index, edit.text.clone()); + if done { + ui.data_mut(|d| d.remove::(name_edit_id())); + if !escape { + commit_artboard_name(app, index, &text); + } + } else { + ui.data_mut(|d| d.insert_temp(name_edit_id(), edit)); + } +} + +/// Where the rename field sits: the name label's rectangle, at least wide enough to type in. +pub(super) fn artboard_name_field_rect(app: &VectorcraftApp, ui: &Ui, xf: &Xf, index: usize) -> Option { + let st = app.session.active()?; + let ab = st.doc.artboards.get(index)?; + let artboard_tool = app.session.tool_id() == "artboard"; + let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); + let painter = ui.painter(); + let rect = if tool_labelled == u64::try_from(index).ok() { + tool_name_rect(painter, xf, ab.rect, index, &ab.name) + } else { + let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); + let width = (ab.rect.width() * xf.zoom) as f32; + canvas_name_rect(painter, corner, width, &format!("{:02} - {}", index + 1, ab.name))? + }; + Some(egui::Rect::from_min_size(rect.min, vec2(rect.width().max(80.0), rect.height().max(16.0)))) +} + +/// Is overlay label `text` the Artboard tool's "01 - "? It holds a name, so it is +/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours. +pub(super) fn names_an_artboard(text: &str) -> bool { + text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit())) +} diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs new file mode 100644 index 000000000..534321242 --- /dev/null +++ b/crates/ui-egui/src/canvas/context.rs @@ -0,0 +1,116 @@ +use super::outline::{hit_at, hit_with, selection_hit_at}; +use super::*; + +/// Right-click: the object under the pointer is selected first unless it already is, then the +/// context menu lists what applies to the selection ([`crate::menus::context_items`]). The locked +/// objects under the pointer ([`vectorcraft_doc::hit::locked_objects_at`]) are kept in +/// `ui.context_locked` while the menu is open, for its Unlock submenu. +pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) { + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let p = xf.to_doc(p); + let locked = hit_with(app, p, xf.zoom, |doc, p, opt| Some(vectorcraft_doc::hit::locked_objects_at(doc, p, opt))); + app.ui.context_locked = app.session.active().map(|st| st.uid).zip(locked); + if let Some(st) = app.session.active() + && let Some(top) = selection_hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation)) + && !st.selection.contains(top) + { + // A locked or hidden object can't be selected; the menu is then for the selection as is. + let _ = app.run("select.set", json!({ "ids": [top.0] })); + } + } + let mut clicked = None; + if resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked)).is_none() { + app.ui.context_locked = None; + } + if let Some((id, p)) = clicked { + crate::menus::invoke(app, &id, p); + } +} + +/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer, +/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes +/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the +/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries +/// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on +/// top of its appearance). A chip follows the pointer meanwhile. +pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) { + crate::panels::swatches::drag_preview(app, ui.ctx()); + let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return }; + // Not through a floating panel over the canvas. + if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) { + return; + } + let Some(d) = resp.dnd_release_payload::() else { return }; + // A Libraries panel graphic: a copy centred where it is dropped. + if let widgets::PanelDrag::LibraryGraphic { library, item } = &*d { + let at = xf.to_doc(pos); + if let Err(e) = app.run("library.use", json!({"library": library, "kind": "graphic", "item": item, "center": [at.x, at.y]})) { + app.status(e); + } + return; + } + // A symbol from the Symbols panel: an instance centred where it is dropped, on art or not. + if let widgets::PanelDrag::Symbol(name) = &*d { + let at = xf.to_doc(pos); + if let Err(e) = app.run("symbol.place", json!({"name": name, "x": at.x, "y": at.y})) { + app.status(e); + } + return; + } + // A symbol from a symbol library panel: added to the document's Symbols and placed there. + if let widgets::PanelDrag::LibrarySymbol { library, name } = &*d { + let at = xf.to_doc(pos); + if let Err(e) = app.run("symbol.addFromLibrary", json!({"library": library, "name": name, "place": true, "x": at.x, "y": at.y})) { + app.status(e); + } + return; + } + let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; + let Some(st) = app.session.active() else { return }; + let (cmd, params) = match &*d { + widgets::PanelDrag::Paint { params, .. } => { + // Colour groups paint nothing. + if params.is_null() { + return; + } + let mut params = params.clone(); + params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]); + params["focus"] = json!(false); + (crate::panels::proxy_cmd(app, false), params) + } + widgets::PanelDrag::Appearance(source) => { + let target = hit.top_object(st.isolation); + if target == *source { + return; + } + ("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]})) + } + widgets::PanelDrag::GraphicStyle(name) => { + let add = ui.input(|i| i.modifiers.alt); + ("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add})) + } + // A brush from the Brushes panel: the path it lands on takes it (a compound path as a whole). + widgets::PanelDrag::Brush { name, .. } => { + ("brush.apply", json!({"name": name, "ids": [vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]})) + } + // A brush from a brush library panel: added to the document's Brushes and applied to the + // path it lands on, in one step. + widgets::PanelDrag::LibraryBrush { library, name } => ( + "brush.addFromLibrary", + json!({"library": library, "name": name, "apply": true, "ids": [vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]}), + ), + // Art dragged back onto the canvas: its move was already dropped. (A symbol was placed + // above.) + widgets::PanelDrag::Art(_) + | widgets::PanelDrag::Symbol(_) + | widgets::PanelDrag::LibrarySymbol { .. } + | widgets::PanelDrag::LibraryGraphic { .. } => return, + // A Libraries panel style or group only moves within that panel. + widgets::PanelDrag::LibraryItem { .. } | widgets::PanelDrag::LibraryGroup { .. } => return, + }; + if let Err(e) = app.run(cmd, params) { + app.status(e); + } +} diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs new file mode 100644 index 000000000..b67276df4 --- /dev/null +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -0,0 +1,152 @@ +use super::*; + +/// The active tool's cursor at the pointer, as `mods` make it. While the view moves (`still` +/// false), the cursor the tool last gave for the same tool and keys stays: asking the tool again +/// would hit-test the art under the pointer at every frame of the move (#2452). +pub(super) fn hover_cursor(app: &mut VectorcraftApp, mods: Mods, view: vectorcraft_engine::ViewInfo, still: bool) -> Option { + let id = app.session.tool_id(); + if !still + && let Some((was, with, c)) = app.canvas.hover_cursor + && (was, with) == (id, mods) + { + return Some(c); + } + let c = app.session.cursor(app.hover_doc?, mods, view); + app.canvas.hover_cursor = Some((id, mods, c)); + Some(c) +} + +/// How long (seconds) the view must stay still before a canvas with +/// [`crate::CanvasCache::waits_for_still`] set renders a heavy document again. +pub const STILL_SECS: f64 = 0.2; + +/// How long (seconds) the view must stay still before the canvas hit-tests the art under the +/// pointer again, for Object Highlighting and the tool's cursor. While the view pans, zooms or +/// turns under a resting pointer, the document point under it changes every frame, and hit-testing +/// detailed art each time slowed every frame of the move (#2452). +pub const HOVER_STILL_SECS: f64 = 0.15; + +/// The view [`hover_wait`] watches: the canvas's rectangle and the view's zoom, centre and rotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewKey { + pub(super) rect: egui::Rect, + pub(super) zoom: f64, + pub(super) center: Point, + pub(super) rotation: f64, +} + +/// Note the view `key` at time `now` (seconds, egui's input time) → how many more seconds the +/// canvas leaves the art under the pointer alone: the view changed less than [`HOVER_STILL_SECS`] +/// ago. The first view noted counts as still. +pub(super) fn hover_wait(moved: &mut Option<(ViewKey, f64)>, key: ViewKey, now: f64) -> Option { + match moved { + Some((k, at)) if *k != key => (*k, *at) = (key, now), + Some(_) => {} + None => *moved = Some((key, f64::NEG_INFINITY)), + } + let waited = moved.map_or(f64::INFINITY, |(_, at)| now - at); + // The clock went back (a new egui context): don't wait for it to catch up. + (0.0..HOVER_STILL_SECS).contains(&waited).then_some(HOVER_STILL_SECS - waited) +} + +/// Note that the canvas asks for `key` at time `now` (seconds, egui's input time) → how many more +/// seconds it draws the frame `shown` reprojected instead of rendering `key`: while the last +/// render was `slow`, only the view differs between the two, and the canvas has asked for `key` +/// for less than [`STILL_SECS`]. `still` holds the key last asked for and when the canvas first +/// asked for it. None: the canvas renders `key` now, unless it is the frame shown. +pub(super) fn still_wait(still: &mut Option<(CacheKey, f64)>, shown: Option<&CacheKey>, key: &CacheKey, slow: bool, now: f64) -> Option { + if still.as_ref().is_none_or(|(k, _)| k != key) { + *still = Some((key.clone(), now)); + } + let waited = still.as_ref().map_or(0.0, |(_, at)| now - at); + // The clock went back (a new egui context): don't wait for it to catch up. + let waiting = (0.0..STILL_SECS).contains(&waited); + (slow && waiting && shown.is_some_and(|s| s != key && only_view_differs(s, key))).then_some(STILL_SECS - waited) +} + +/// Whether two cache keys differ only in the view (zoom, center, rotation, canvas size, pixel +/// density and Pixel Preview's document pixels), with Pixel Preview on in both or off in both. +fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool { + a.pixel.is_some() == b.pixel.is_some() + && *a == CacheKey { zoom: a.zoom, cx: a.cx, cy: a.cy, w: a.w, h: a.h, ppp: a.ppp, rot: a.rot, pixel: a.pixel, ..b.clone() } +} + +pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { + use egui::CursorIcon as C; + match c { + Cursor::Arrow + | Cursor::ArrowHollow + | Cursor::DirectAnchor + | Cursor::DirectAnchorSelected + | Cursor::ReshapeSegment + | Cursor::GroupSelection + | Cursor::CornerRadius + | Cursor::PathBracket + | Cursor::TypeWidget + | Cursor::LoadedText => C::Default, + Cursor::SelectionCopy => C::Copy, + Cursor::Move => C::Move, + Cursor::Crosshair => C::Crosshair, + Cursor::ResizeH | Cursor::ShearH => C::ResizeHorizontal, + Cursor::ResizeV | Cursor::ShearV => C::ResizeVertical, + Cursor::ResizeNwSe => C::ResizeNwSe, + Cursor::ResizeNeSw => C::ResizeNeSw, + Cursor::Rotate | Cursor::RotateToward(_) => C::Alias, + Cursor::Pen + | Cursor::PenDrawing + | Cursor::PenAdd + | Cursor::PenDelete + | Cursor::PenClose + | Cursor::PenContinue + | Cursor::PenJoin + | Cursor::PenConvert + | Cursor::AnchorPoint + | Cursor::Curvature => C::Crosshair, + Cursor::Text => C::Text, + Cursor::Hand => C::Grab, + Cursor::HandGrab => C::Grabbing, + Cursor::ZoomIn => C::ZoomIn, + Cursor::ZoomOut => C::ZoomOut, + Cursor::Eyedropper => C::Crosshair, + Cursor::NotAllowed => C::NotAllowed, + Cursor::Busy => C::Progress, + Cursor::AddStop => C::Copy, + Cursor::RemoveStop => C::NotAllowed, + Cursor::Slice => C::Crosshair, + Cursor::SliceSelect => C::Default, + Cursor::Width | Cursor::WidthAdd => C::Crosshair, + Cursor::WidthPoint => C::Move, + Cursor::PencilNew => C::Crosshair, + Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair, + Cursor::PencilClose | Cursor::PencilStraight | Cursor::PencilContinue | Cursor::PaintbrushClose => C::Crosshair, + Cursor::TypeVertical => C::VerticalText, + // Not drawn yet: the cursor each borrows. + Cursor::Lasso + | Cursor::MagicWand + | Cursor::Pencil + | Cursor::Paintbrush + | Cursor::BlobBrush + | Cursor::Eraser + | Cursor::Smooth + | Cursor::PathEraser + | Cursor::Join + | Cursor::Knife + | Cursor::Shaper + | Cursor::LivePaintBucket + | Cursor::Measure + | Cursor::PrintTiling + | Cursor::SymbolSprayer + | Cursor::SymbolShifter + | Cursor::SymbolScruncher + | Cursor::SymbolSizer + | Cursor::SymbolSpinner + | Cursor::SymbolStainer + | Cursor::SymbolScreener + | Cursor::SymbolStyler + | Cursor::ObjectsOnPath + | Cursor::Scissors + | Cursor::ScissorsNotAllowed + | Cursor::MeshAdd + | Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)), + } +} diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs new file mode 100644 index 000000000..e17690dd3 --- /dev/null +++ b/crates/ui-egui/src/canvas/drag.rs @@ -0,0 +1,282 @@ +use super::*; + +/// Whether drag `d` is a press the tool holds. Every other drag (panning, zooming, turning the +/// view, a touch gesture, Presentation Mode) never reaches the tool, so its hover ends with it. +pub(super) fn tool_holds(d: Drag) -> bool { + matches!(d, Drag::Tool | Drag::Art) +} + +/// What a hover depends on besides the pointer's screen position. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct HoverContext { + /// The active tool: a tool switched to under a still pointer is hovered at once. + pub(super) tool: &'static str, + /// The canvas, and the view's centre, zoom and rotation. + pub(super) canvas: egui::Rect, + pub(super) center: Point, + pub(super) zoom: f64, + pub(super) rotation: f64, + /// The active document's id and revision (which moves with every edit, undo and selection + /// change). + pub(super) doc: Option<(u64, u64)>, +} + +/// Clear canvas interaction state before a tab transfer takes ownership of the artwork drag. +pub(crate) fn end_document_drag(ctx: &egui::Context) { + ctx.data_mut(|d| { + d.remove::(drag_id()); + d.remove::(drag_pos_id()); + }); +} + +/// Temp-memory key for the active canvas drag state. +pub(super) fn drag_id() -> egui::Id { + egui::Id::unique("canvas-drag") +} + +/// Where the pointer was last seen during a press on the canvas. +pub(super) fn drag_pos_id() -> egui::Id { + egui::Id::unique("canvas-drag-pos") +} + +/// The layer the canvas was last drawn on: a release over another layer is over a panel (#2826). +pub(super) fn drag_layer_id() -> egui::Id { + egui::Id::unique("canvas-drag-layer") +} + +/// Whether `p` is over the canvas itself, not a panel or a floating window over it. +pub(super) fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool { + resp.rect.contains(p) && ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id) +} + +/// The document point last sent to the tool as a drag during [`Drag::Tool`]; none until the press +/// has dragged. +pub(super) fn tool_drag_doc_id() -> egui::Id { + egui::Id::unique("canvas-tool-drag-doc") +} + +/// The document point of the press that began [`Drag::Tool`], sent to the tool as its Down. +pub(super) fn tool_press_doc_id() -> egui::Id { + egui::Id::unique("canvas-tool-press-doc") +} + +/// Whether the tool should get another drag event, and if so records `p` as the last position +/// sent: the pointer moved this frame, or its document position changed without a delta (#834). +/// egui's frame delta is zero when some Wayland compositors coalesce motion until release, and in +/// the frame the pointer comes back into the window after leaving it (`PointerGone` forgot where +/// it was), though it is somewhere new; judged on the delta alone, the preview stayed behind. +pub(super) fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> bool { + let at = xf.to_doc(p); + let id = tool_drag_doc_id(); + // Before the press has dragged, the last position the tool got is the press itself: a press + // held still is not a drag. + let prev = ui.data(|d| d.get_temp::(id).or_else(|| d.get_temp::(tool_press_doc_id()))); + if pointer.delta() == egui::Vec2::ZERO && prev == Some(at) { + return false; + } + ui.data_mut(|d| d.insert_temp(id, at)); + true +} + +/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch +/// input, else the pressure in the host's feed while a pen is the pointer that moved last +/// ([`crate::Services::stylus`]: [`StylusFeed::press`] for a new press, [`StylusFeed::held`] +/// after it), else the last one seen since the press (`pressed`: a new press, whose mouse has none +/// until a pen reports it). A mouse presses fully (1). +pub(super) fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 { + let stylus = app.services.stylus.as_ref(); + let id = egui::Id::unique("canvas-pressure"); + let force = ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), + _ => None, + }) + }); + let force = force.or_else(|| stylus.and_then(if pressed { StylusFeed::press } else { StylusFeed::held })); + let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::(id)) }).unwrap_or(1.0); + ui.data_mut(|d| d.insert_temp(id, p)); + p +} + +/// The positions this frame's pointer moves passed through before its last one, each with the +/// pen force a touch event gave just before it, or the pressure the host's feed kept for that move +/// ([`StylusFeed::take_moves`], macOS), if any. The last move is the pointer's position. A mouse's +/// moves count too, with no pressure: a fast mouse stroke otherwise keeps one position a frame, a +/// sample every few pixels, too few for a low Fidelity to follow the hand (#3328). +pub(super) fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Option)> { + let pressures = stylus.map(StylusFeed::take_moves).unwrap_or_default(); + ui.input(|i| { + let mut moves = vec![]; + if i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) { + let mut force = None; + for e in &i.events { + match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => force = Some(f.clamp(0.0, 1.0)), + egui::Event::PointerMoved(p) => moves.push((*p, force)), + _ => {} + } + } + } else if !pressures.is_empty() { + // The feed's last pressure is the last move's: pair them from the end. A lift's 0 is + // no pressure (the press keeps its last touch). + let points: Vec = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some(*p) } else { None }).collect(); + let forces = pressures.iter().rev().map(|f| (*f > 0.0).then_some(*f)).chain(std::iter::repeat(None)); + moves = points.into_iter().rev().zip(forces).collect(); + moves.reverse(); + } else { + moves = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some((*p, None)) } else { None }).collect(); + } + moves.pop(); + // A burst of events (a stalled frame) is thinned to a bounded count. + const MAX: usize = 64; + if moves.len() > MAX { + let step = moves.len().div_ceil(MAX); + moves = moves.into_iter().step_by(step).collect(); + } + moves + }) +} + +/// The moves of this frame ([`frame_moves`]) the active tool gets before the pointer's position: +/// all of them for a tool that builds a stroke from them ([`vectorcraft_engine::Session::tool_wants_samples`]), none +/// for the others, which get one drag a frame. The moves are read either way, so the host's feed +/// pairs the next frame's with its own. +pub(super) fn tool_frame_moves(ui: &Ui, app: &VectorcraftApp) -> Vec<(Pos2, Option)> { + let moves = frame_moves(ui, app.services.stylus.as_ref()); + if app.session.tool_wants_samples() { moves } else { vec![] } +} + +pub fn mods(m: egui::Modifiers, space: bool) -> Mods { + Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space } +} + +/// Drop the tool press in progress, if any: nothing it began is kept, and the rest of the press +/// does nothing ([`Drag::Gesture`]). +pub(super) fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) { + match ui.data(|d| d.get_temp::(drag_id())) { + Some(Drag::Tool | Drag::Art) => { + // The tool's mouse-up returns it to rest. A move waiting over a panel is still live + // and would otherwise outlive the drag that owns it (#2826). + // Nothing to roll back without a document; the tool still gets its mouse-up. + let _ = app.session.cancel_interaction(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + egui::DragAndDrop::clear_payload(ui.ctx()); + } + Some(_) => {} + None => return, + } + ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); +} + +/// A Selection tool move dragged off the canvas (to `p`, over a panel) becomes a panel drag of +/// the selected art ([`widgets::PanelDrag::Art`]): the art waits where it last moved on the +/// canvas, and a panel it is released on takes it (the Graphic Styles panel makes a style of it). +/// Back on the canvas, [`Drag::Art`] takes the move up again where the pointer is (#2826). +pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -> bool { + let Some(st) = app.session.active().filter(|_| app.session.tool_id() == "selection") else { return false }; + // The Selection tool's interaction while it moves (or Alt-copies) the selection. + if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") { + return false; + } + // A floating panel in an OS window of its own takes the art too: craft-ui hands that window + // the pointer while it is over it, and this window sees the pointer go. + craft_ui::docking::route_app_drag(ui.ctx(), crate::panel_docking::area_id()); + if on_canvas(ui, resp, p) && ui.input(|i| i.pointer.has_pointer()) { + return false; + } + let ids = st.selection.objects.clone(); + egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids)); + true +} + +/// A Selection tool move released over a panel: end it before the panels see the release, so a +/// panel that takes the art ([`widgets::PanelDrag::Art`]) takes it where it was, and no move is +/// left open (#2826). The art stays where it last moved while the pointer is over the panel. +pub(crate) fn release_over_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { + crate::document_drag::cleanup(app, ctx); + let art = matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Art)); + // Over a floating panel's own OS window the pointer is gone from this window, and the release + // comes here later, at a point of this window that may lie on the canvas: that panel takes it. + let gone_id = drag_id().with("art-gone"); + if !ctx.input(|i| i.pointer.primary_released()) { + if art { + let gone = ctx.input(|i| !i.pointer.has_pointer()); + ctx.data_mut(|d| d.insert_temp(gone_id, gone)); + } + return; + } + let gone = ctx.data_mut(|d| d.remove_temp::(gone_id)).unwrap_or(false); + let p = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())); + if !art { + return; + } + let layer = ctx.data(|d| d.get_temp::(drag_layer_id())); + let on = !gone && p.is_some_and(|p| app.canvas_rect.is_some_and(|r| r.contains(p)) && ctx.layer_id_at(p).is_none_or(|l| Some(l) == layer)); + if on { + // The canvas's release finishes the move. + return; + } + end_art_move(app, ctx); +} + +/// A release in a floating panel's own OS window, during an art drag from the canvas: the move +/// ends before that panel takes the art. The main window would only see it on its next pass. +pub(crate) fn release_art_in_window(app: &mut VectorcraftApp, ctx: &egui::Context) { + let art = matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Art)); + if art && ctx.input(|i| i.pointer.primary_released()) { + end_art_move(app, ctx); + } +} + +/// Roll back the art move dragged off the canvas; a panel takes the art instead. +fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) { + // With no document open there is nothing to roll back; the tool still gets its mouse-up. + let _ = app.session.cancel_interaction(); + let view = app.view_info(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + ctx.data_mut(|d| { + d.remove::(drag_id()); + d.remove::(drag_id().with("art-gone")); + }); +} + +/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). +pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { + let r = app.session.pointer(ev, view); + // The artboard the Artboard tool makes active is the active one (the navigator's, the + // Artboards panel's). + if app.session.tool_id() == "artboard" + && let Some(i) = app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()) + && let Some(v) = app.view_mut() + { + v.artboard = i; + } + // A press with a selection tool inside an artboard makes it the active one (#693), as the + // Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to + // Artboard aligns to it, and All on Active Artboard selects on it. + if ev.kind == PointerKind::Down + && vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id()) + && let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos)) + && let Some(v) = app.view_mut() + { + v.artboard = i; + } + apply_requests(app, r); +} + +/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. +pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result>) { + match r { + Ok(reqs) => { + for r in reqs { + match r { + vectorcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p), + vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t), + vectorcraft_engine::UiRequest::Status(msg) => app.status(msg), + vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p), + } + } + } + Err(e) => app.status(e.to_string()), + } +} diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs new file mode 100644 index 000000000..a36380df0 --- /dev/null +++ b/crates/ui-egui/src/canvas/grid.rs @@ -0,0 +1,377 @@ +use super::drag::mods; +use super::*; + +/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per +/// look, and its cell size in screen points. +pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) { + let colors = setup.grid_colors.map(|c| crate::panels::c32(&c)); + let id = egui::Id::unique("transparency-grid-tile"); + let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors); + let tex = cached.map(|(_, t)| t).unwrap_or_else(|| { + let [a, b] = colors; + let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]); + let opts = egui::TextureOptions { wrap_mode: egui::TextureWrapMode::Repeat, ..egui::TextureOptions::NEAREST }; + let t = ctx.load_texture(TRANSPARENCY_GRID, img, opts); + ctx.data_mut(|d| d.insert_temp(id, (colors, t.clone()))); + t + }); + (tex, setup.grid_size.cell()) +} + +/// Name of the transparency grid's texture. +pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid"; + +/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose +/// cells stay `cell` screen points at every zoom and turn with a rotated view. +pub(super) fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) { + // Two cells per texture repeat. + let per = (2.0 * cell as f64 / zoom).max(1e-9); + textured_quad(p, tex, quad, ((ab.width() / per) as f32, (ab.height() / per) as f32)); +} + +/// One quad drawn with `tex`: its corners `quad` (from the top left, clockwise) show the texture +/// from (0, 0) to (`u`, `v`). +pub(super) fn textured_quad(p: &egui::Painter, tex: egui::TextureId, quad: &[Pos2], (u, v): (f32, f32)) { + let mut mesh = egui::Mesh::with_texture(tex); + for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) { + mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + p.add(Shape::mesh(mesh)); +} + +/// The zoom the pixel grid shows from in Pixel Preview (600%), and its colour: a translucent grey, +/// so the art's pixels read through it. +pub(super) const PIXEL_GRID_ZOOM: f64 = 6.0; + +pub(super) const PIXEL_GRID: Color32 = Color32::from_rgba_premultiplied(64, 64, 64, 96); + +/// The look of the grid or of the guides (Preferences › Guides & Grid › Color and Style). +#[derive(Clone, Copy)] +pub(super) struct LineLook { + pub(super) color: Color32, + pub(super) dots: bool, +} + +impl LineLook { + pub(super) fn grid(prefs: &vectorcraft_engine::Prefs) -> Self { + Self { color: pref_color(&prefs.grid_color, Color32::from_gray(200)), dots: prefs.grid_style == "dots" } + } + + pub(super) fn guides(prefs: &vectorcraft_engine::Prefs, t: &Tokens) -> Self { + Self { color: pref_color(&prefs.guide_color, t.guide), dots: prefs.guide_style == "dots" } + } + + /// A guide from `a` to `b`: a line, or dots. + pub(super) fn guide(self, p: &egui::Painter, a: Pos2, b: Pos2) { + if self.dots { + p.extend(Shape::dotted_line(&[a, b], self.color, 4.0, 0.75)); + } else { + p.line_segment([a, b], Stroke::new(1.0, self.color)); + } + } +} + +/// A `#rrggbb` colour preference, `fallback` when it doesn't parse. +pub(super) fn pref_color(hex: &str, fallback: Color32) -> Color32 { + vectorcraft_color::Color::from_hex(hex).map_or(fallback, |c| crate::panels::c32(&c)) +} + +/// The view in document space: the box round the canvas's corners (the view may be rotated). +pub(super) fn view_bounds(xf: &Xf) -> Rect { + let r = xf.rect; + let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|c| xf.to_doc(c)); + corners.iter().fold(Rect::from_points(corners[0], corners[0]), |b, c| b.union_pt(*c)) +} + +/// The part of the segment from `a` to `b` inside `r`, none when the segment misses it. +pub(super) fn clip_segment(a: Pos2, b: Pos2, r: egui::Rect) -> Option<(Pos2, Pos2)> { + let d = b - a; + let (mut t0, mut t1) = (0.0_f32, 1.0_f32); + // Each edge as (the run toward it, the room before it). + for (run, room) in [(-d.x, a.x - r.left()), (d.x, r.right() - a.x), (-d.y, a.y - r.top()), (d.y, r.bottom() - a.y)] { + if run == 0.0 { + if room < 0.0 { + return None; + } + } else if run < 0.0 { + t0 = t0.max(room / run); + } else { + t1 = t1.min(room / run); + } + } + (t0 <= t1).then(|| (a + d * t0, a + d * t1)) +} + +/// The grid (View → Show Grid): a gridline every `spacing` and `subdiv` lighter lines between +/// (left out when they would crowd). In the Dots style, a dot where gridlines cross the +/// subdivisions instead. +pub(super) fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32, look: LineLook) { + // The painter clips to the canvas. + let view = view_bounds(xf); + let sub = spacing / subdiv.max(1) as f64; + let step = if sub * xf.zoom >= 6.0 { sub } else { spacing }; + // Dots crowd sooner than lines. + if step * xf.zoom < 4.0 || (look.dots && spacing * xf.zoom < 8.0) { + return; + } + let major = |v: f64| (v / spacing - (v / spacing).round()).abs() < 1e-6; + // The lines across the view (where along their axis), at least 4 px apart (and capped). + let lines = |from: f64, to: f64| { + let first = (from / step).floor(); + (0..10_000u32).map(move |i| (first + f64::from(i)) * step).take_while(move |v| *v <= to) + }; + if look.dots { + let mut mesh = egui::Mesh::default(); + for x in lines(view.x0, view.x1) { + for y in lines(view.y0, view.y1).filter(|y| major(x) || major(*y)) { + mesh.add_colored_rect(egui::Rect::from_center_size(xf.to_screen(Point::new(x, y)), vec2(1.5, 1.5)), look.color); + } + } + p.add(Shape::mesh(mesh)); + return; + } + let minor = look.color.gamma_multiply(0.4); + let stroke = |major: bool| Stroke::new(1.0, if major { look.color } else { minor }); + for x in lines(view.x0, view.x1) { + p.line_segment([xf.to_screen(Point::new(x, view.y0)), xf.to_screen(Point::new(x, view.y1))], stroke(major(x))); + } + for y in lines(view.y0, view.y1) { + p.line_segment([xf.to_screen(Point::new(view.x0, y)), xf.to_screen(Point::new(view.x1, y))], stroke(major(y))); + } +} + +/// A ruler label for `v` (in the ruler's unit) on a ruler whose labels are `step` apart: whole +/// numbers, or the fewest decimals that write `step` exactly, so a binary step keeps its value +/// (an inch ruler's 1/16 reads `0.0625`, not the `0.06` that rounding the step's magnitude gave). +/// How far down the left ruler a label of `chars` characters reaches, counting from its top: it +/// is spelled one character per line, [`RULER_CHAR`] apart, and the last one is a line tall. +pub(crate) fn vertical_label_height(chars: usize) -> f32 { + chars.saturating_sub(1) as f32 * RULER_CHAR + RULER_LINE +} + +/// A ruler label's line spacing and line height on the left ruler. +const RULER_CHAR: f32 = 8.5; + +const RULER_LINE: f32 = 9.5; + +pub(crate) fn ruler_label(v: f64, step: f64) -> String { + let exact = |d: u32| (step * 10f64.powi(d as i32)).fract().abs() < 1e-9; + let decimals = (0..=7).find(|d| exact(*d)).unwrap_or(7) as usize; + let s = format!("{v:.decimals$}"); + // Trailing zeros go, as `Unit::number` drops them: a sixteenth-inch ruler labels the inch `1`, + // not `1.0000`, which the vertical ruler would spell down six lines. + let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { &s }; + if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { s.into() } +} + +/// The rulers, numbered in `unit` (the General unit). +/// The top ruler, the left ruler and the box where they meet. +pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] { + let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER)); + let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom())); + [top, left, egui::Rect::from_min_size(full.min, vec2(RULER, RULER))] +} + +/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's +/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left +/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else, +/// it makes none. The corner drags the rulers' origin or resets it on a double-click. +pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option { + let [top, left, corner] = ruler_rects(full); + // Right-click on a ruler or the origin box: the document units, to swap between them as + // Preferences ▸ Units ▸ General does ([`crate::menus::ruler_menu_body`]). + let mut unit_clicked = None; + for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] { + // Click-and-drag so the same widget drags out guides (left) and opens the unit menu (right). + let resp = ui.interact(r, egui::Id::unique(id), Sense::click_and_drag()); + resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); + let kind = if resp.drag_stopped() { + PointerKind::Up + } else if resp.dragged() { + PointerKind::Drag + } else { + continue; + }; + let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); + let on_canvas = p.is_some_and(|p| canvas.contains(p)); + if on_canvas { + // The new guide shows even if guides were hidden. + app.ui.view.guides = true; + } + let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 }; + if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) { + app.status(e.to_string()); + } + } + let corner = ui.interact(corner, egui::Id::unique("ruler-corner"), Sense::click_and_drag()); + corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); + if let Some((id, p)) = unit_clicked { + crate::menus::invoke(app, &id, p); + } + if corner.double_clicked_by(egui::PointerButton::Primary) { + let artboard = app.view().map_or(0, |v| v.artboard); + if let Err(e) = app.run("document.resetRulerOrigin", json!({"artboard": artboard})) { + app.status(e.to_string()); + } + } + // Keep previews in the UI: only a release on the canvas edits the document, once. Cancelling + // never adds an undo step or interferes with the drawing tool's interaction. + let drag_id = ruler_origin_drag_id(); + let uid = app.session.active().map(|d| d.uid); + if corner.drag_started_by(egui::PointerButton::Primary) { + ui.data_mut(|d| d.insert_temp(drag_id, (uid, false))); + } + let mut preview = None; + if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option, bool)>(drag_id)) { + cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)); + let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p)); + if corner.drag_stopped_by(egui::PointerButton::Primary) { + ui.data_mut(|d| d.remove::<(Option, bool)>(drag_id)); + if !cancelled + && let Some(at) = at + && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)})) + { + app.status(e.to_string()); + } + } else if !ui.input(|i| i.pointer.primary_down()) { + ui.data_mut(|d| d.remove::<(Option, bool)>(drag_id)); + } else { + ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled))); + if !cancelled { + preview = at; + } + } + } + preview +} + +/// The key of the ruler origin drag's state in egui's temporary data. +fn ruler_origin_drag_id() -> egui::Id { + egui::Id::unique("ruler-origin-drag") +} + +/// Is a drag from the rulers' corner in progress? It stays true after Escape cancels the move, until +/// the button is released. +pub(crate) fn ruler_origin_dragging(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::<(Option, bool)>(ruler_origin_drag_id()).is_some()) +} + +/// What the rulers count, and from where: `unit`, from `origin` (document points), with each +/// unit stretched `stretch` times along the document's x and y, which a video ruler's pixel aspect +/// ratio makes differ. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RulerScale { + pub(crate) unit: Unit, + pub(crate) origin: Point, + pub(crate) stretch: [f64; 2], +} + +impl RulerScale { + /// The rulers in `unit` (the General unit), from `origin` (the ruler origin). + pub(crate) fn document(unit: Unit, origin: Point) -> Self { + Self { unit, origin, stretch: [1.0, 1.0] } + } + + /// View › Rulers › Show Video Rulers on artboard `ab`: pixels from its top-left corner, each + /// pixel as many points wide as its Video Ruler Pixel Aspect Ratio and one point tall, so the + /// rulers read the artboard's size in video pixels + /// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). + pub(crate) fn video(ab: &vectorcraft_doc::Artboard) -> Self { + Self { unit: Unit::Pixels, origin: Point::new(ab.rect.x0, ab.rect.y0), stretch: [ab.video_ruler_pixel_aspect_ratio, 1.0] } + } + + /// How far document point `p` lies from the origin along `axis` (a unit direction in the + /// document), in points of this scale: each document coordinate divided by its stretch. + /// Divided by the unit's points, it is the value the ruler reads there. + pub(crate) fn along(&self, p: Point, axis: vectorcraft_geom::Vec2) -> f64 { + let d = p - self.origin; + d.x / self.stretch[0] * axis.x + d.y / self.stretch[1] * axis.y + } + + /// The screen points one point of this scale spans along `axis` at `zoom` (screen points per + /// document point): `zoom` itself when nothing is stretched. + pub(crate) fn zoom_along(&self, zoom: f64, axis: vectorcraft_geom::Vec2) -> f64 { + zoom / (axis.x * axis.x / self.stretch[0] + axis.y * axis.y / self.stretch[1]) + } +} + +pub(super) fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, scale: RulerScale, t: &Tokens) { + let p = ui.painter(); + let [top, left, corner] = ruler_rects(full); + for r in [top, left, corner] { + p.rect_filled(r, 0.0, t.ruler); + } + p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.border)); + p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border)); + // Crosshair in the origin box. + p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick)); + p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick)); + let font = egui::FontId::proportional(9.5); + // Each ruler measures along its own edge, in the canvas's coordinates turned with the view + // (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance + // (screen points) from `from` along the ruler, its length, and the label of a labelled one. + let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f32, Option)| { + // Pick a label step (in the unit) that gives ≥ 50 px between labels on this ruler, at its + // own scale; positions below are in the unit (`per` points of the scale each). + let zoom = scale.zoom_along(xf.zoom, axis); + let (per, step) = (scale.unit.points(), scale.unit.ruler_step(zoom)); + let minor = scale.unit.ruler_minor(step); + let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01; + let (a, b) = (scale.along(xf.to_doc(from), axis), scale.along(xf.to_doc(to), axis)); + let mut v = (a / per / minor).floor() * minor; + while v * per <= b { + // A tick's length: the labelled ones longest, the halves between them shorter. + let (len, label) = if near(v, step) { + (RULER, Some(ruler_label(v, step))) + } else if near(v, step / 2.0) { + (7.0, None) + } else { + (4.0, None) + }; + draw(((v * per - a) * zoom) as f32, len, label); + v += minor; + } + }; + // The canvas's directions along the screen's x and y. + let (sn, cs) = xf.rot.sin_cos(); + let clip_top = p.with_clip_rect(top); + ticks(top.left_top(), top.right_top(), vectorcraft_geom::Vec2::new(cs, -sn), &mut |d, len, label| { + let sx = top.left() + d; + clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); + if let Some(text) = label { + clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, text, font.clone(), t.ruler_tick); + } + }); + let clip_left = p.with_clip_rect(left); + // A label spelled down the ruler is tall, and a long one can reach the next label's place: + // "-0.0625" is seven characters, 60 pt of ruler, where a sixteenth-inch step leaves 54 pt at + // zoom 12. The ticks all stay; a label that would run into the one above it is left out. + let mut filled_to = f32::NEG_INFINITY; + ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, len, label| { + let sy = left.top() + d; + clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); + if let Some(text) = label { + // Vertical labels read top-to-bottom, one digit per line like Illustrator. + let top = sy + 2.0; + if top < filled_to { + return; + } + filled_to = top + vertical_label_height(text.chars().count()); + for (k, ch) in text.chars().enumerate() { + clip_left.text( + pos2(left.left() + 4.0, top + k as f32 * RULER_CHAR), + egui::Align2::LEFT_TOP, + ch.to_string(), + font.clone(), + t.ruler_tick, + ); + } + } + }); + if let Some(h) = hover { + let s = xf.to_screen(h); + clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text)); + clip_left.line_segment([pos2(left.left(), s.y), pos2(left.right(), s.y)], Stroke::new(1.0, t.text)); + } +} diff --git a/crates/ui-egui/src/canvas/home.rs b/crates/ui-egui/src/canvas/home.rs new file mode 100644 index 000000000..1d23d9882 --- /dev/null +++ b/crates/ui-egui/src/canvas/home.rs @@ -0,0 +1,121 @@ +use super::*; + +/// The Home screen (`app.home`; with no document open, unless its preference is off). +pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(rect, 0.0, t.panel_darker); + // Taller than the window (a small window, many recent files): it scrolls. Widgets scrolled out + // of view are clipped, so they take no pointer. + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); + egui::ScrollArea::vertical().id_salt("home-scroll").auto_shrink([false, false]).show(&mut child, |ui| { + let side = (ui.available_width() - 820.0).max(40.0) / 2.0; + let width = (ui.available_width() - 2.0 * side).max(0.0); + ui.add_space(60.0); + ui.horizontal_top(|ui| { + ui.add_space(side); + ui.allocate_ui_with_layout(vec2(width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_width(width); + home_content(app, ui); + }); + }); + ui.add_space(60.0); + }); +} + +/// The Home screen's column: welcome, New / Open, Recent Files, presets and community links. +fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text)); + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim)); + ui.add_space(22.0); + ui.horizontal(|ui| { + if widgets::primary_button(ui, tl!("New file")).clicked() { + app.run("file.newDialog", json!({})).ok(); + } + ui.add_space(8.0); + if widgets::secondary_button(ui, tl!("Open")).clicked() { + app.run("file.open", json!({})).ok(); + } + }); + recent(app, ui); + ui.add_space(28.0); + ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text)); + ui.add_space(10.0); + // A few of New Document's presets (`file.newPresets`), as its cards. + let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"]; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(14.0, 14.0); + let cards: Vec<_> = presets.iter().filter_map(|name| vectorcraft_engine::cmd::newdoc::find(&app.session, name)).collect(); + for s in cards { + if crate::dialogs::preset_card(ui, &s, false).clicked() { + app.run("file.new", json!({ "preset": s.name })).ok(); + } + } + }); + ui.add_space(28.0); + ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text)); + ui.add_space(10.0); + crate::community::links(app, ui); +} + +/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and +/// folder; a click opens it. Nothing when there are none. +fn recent(app: &mut VectorcraftApp, ui: &mut Ui) { + const SHOWN: usize = 6; + let files: Vec = crate::io::recent_files(app).iter().take(SHOWN).cloned().collect(); + if files.is_empty() { + return; + } + let t = Tokens::get(ui.ctx()); + ui.add_space(28.0); + ui.label(egui::RichText::new(tl!("Recent Files")).font(theme::semibold(14.0)).color(t.text)); + ui.add_space(8.0); + for path in files { + if recent_row(ui, &path).clicked() { + // A failure is reported to the user there. + let _ = crate::io::open_recent_reporting(app, &path); + } + } +} + +/// One Recent Files row across the Home screen: history icon, name (accent) and folder (dim, +/// truncated). The whole row is one button that lights up under the pointer; its tooltip is the +/// full path. +fn recent_row(ui: &mut Ui, path: &str) -> egui::Response { + const PAD: f32 = 6.0; + const ICON: f32 = 16.0; + let t = Tokens::get(ui.ctx()); + let p = std::path::Path::new(path); + let name = p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()); + let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), egui::Sense::click()); + if ui.is_rect_visible(rect) { + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover); + } + let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + PAD + ICON / 2.0, rect.center().y), vec2(ICON, ICON)); + crate::icons::paint(ui, "history", icon, t.icon); + let x = icon.right() + 8.0; + let name_galley = egui::WidgetText::from(egui::RichText::new(&name).size(13.0).color(t.accent)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + (rect.right() - PAD - x).max(0.0), + egui::TextStyle::Body, + ); + let folder_x = x + name_galley.size().x + 10.0; + ui.painter().galley(egui::pos2(x, rect.center().y - name_galley.size().y / 2.0), name_galley, t.accent); + let room = rect.right() - PAD - folder_x; + if room > 0.0 && !folder.is_empty() { + let folder_galley = egui::WidgetText::from(egui::RichText::new(&folder).size(12.0).color(t.text_dim)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + room, + egui::TextStyle::Body, + ); + ui.painter().galley(egui::pos2(folder_x, rect.center().y - folder_galley.size().y / 2.0), folder_galley, t.text_dim); + } + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); + resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand) +} diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs new file mode 100644 index 000000000..319e9c84b --- /dev/null +++ b/crates/ui-egui/src/canvas/input.rs @@ -0,0 +1,408 @@ +use super::artboards::{artboard_name_at, begin_artboard_name_edit, name_press_id, select_named_artboard}; +use super::drag::{ + HoverContext, apply_requests, dispatch, drag_art_out, drag_id, drag_layer_id, drag_pos_id, drop_press, mods, on_canvas, pen_pressure, + tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id, +}; +use super::view::{ + HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn, + wheel, whole_pixels, zoom_about, +}; +use super::*; + +/// Dispatch canvas pointer, keyboard and drop events through the active document view. +pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { + let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; + let turn_id = egui::Id::unique("canvas-wheel-turn"); + let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); + let mut turn = turn_before; + let (pointer, m, space, (factor, scroll)) = + ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn))); + if turn != turn_before { + ui.data_mut(|d| d.insert_temp(turn_id, turn)); + } + if turn.glide != 0.0 { + ui.ctx().request_repaint(); + } + let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); + // Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also + // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The + // tool's events below take their document points from the view this moves to. + if resp.hovered() { + if (factor - 1.0).abs() > 1e-6 + && let (Some(p), Some(vm)) = (hover, app.view_mut()) + { + zoom_about(vm, rect, p, vm.zoom * factor); + } + if scroll != egui::Vec2::ZERO { + scroll_view(app, ui, scroll); + } + if let Some(p) = hover + && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) + && let Some(vm) = app.view_mut() + { + rotate_about(vm, rect, p, deg); + } + } + let v = *app.view().unwrap_or(&View::default()); + let xf = Xf::new(rect, &v); + // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), + // so a tap whose press and lift come in one frame has no hover position: it presses (and + // double-taps) where it was seen last (#491). + let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); + let view = app.view_info(); + touch_gestures(app, ui, rect, view); + // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the + // tool's press is dropped, as a right-click presses nothing. + if resp.long_touched() { + drop_press(app, ui, view); + } + let drag: Option = ui.data(|d| d.get_temp(drag_id())); + // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows + // without moving the mouse (Alt switches the Shape Builder to erase mode). + let mods_changed = ui.data_mut(|d| { + let id = egui::Id::unique("canvas-mods"); + let prev = d.get_temp::(id); + d.insert_temp(id, m); + prev.is_some_and(|prev| prev != m) + }); + // Where the tool hovers: the pointer over the canvas itself, not over a floating panel, a menu, + // a popup or a dialog drawn on top of it ([`on_canvas`]). + let hover_tool = hover.filter(|p| on_canvas(ui, resp, *p)); + // Whether the tool hovered last frame: coming onto the canvas re-hovers it, even after a single + // jump egui doesn't count as moving, and leaving it ends the tool's hover (below). + let was_over = ui.data_mut(|d| { + let id = egui::Id::unique("canvas-hovered"); + let prev = d.get_temp::(id).unwrap_or(false); + d.insert_temp(id, hover_tool.is_some()); + prev + }); + // What a hover means besides where the pointer is on screen: the tool, the canvas, the view + // (scrolled, zoomed or turned by the wheel, a gesture, keys or a command) and the document and + // selection as they are (another tab, an edit, an undo). Under a still pointer, a tool switched + // to is hovered at once, and when the rest changes, a tool that asks for it + // ([`Session::tool_wants_rehover`]) is hovered again where the pointer is. + let context = HoverContext { + tool: app.session.tool_id(), + canvas: rect, + center: v.center, + zoom: v.zoom, + rotation: v.rotation, + doc: app.session.active().map(|st| (st.uid, st.revision)), + }; + let (tool_switched, context_changed) = ui.data_mut(|d| { + let id = egui::Id::unique("canvas-hover-context"); + let prev = d.get_temp::(id); + d.insert_temp(id, context); + prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context)) + }); + + // In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, + // drag or hover, and a drag does not pan the view. A drag that began before the mode was turned + // on ends as usual. + if app.ui.screen_mode == 3 && matches!(drag, None | Some(Drag::Present | Drag::Gesture)) { + let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some(); + // `press` is this frame's new press, or else the press held so far. A quick tap can be + // pressed and released in one frame. + let press = if pressed { Some(Drag::Present) } else { drag }; + if press.is_some() && !pointer.primary_down() { + ui.data_mut(|dd| dd.remove::(drag_id())); + // A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release + // does not move the view to the next artboard. + if press == Some(Drag::Present) && resp.clicked() { + crate::shortcuts::presentation_step(app, true); + } + } else if pressed { + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present)); + } + return; + } + + // A pen turned to its eraser end selects the Eraser, and a pen turned back to its tip selects + // the tool before it. The switch runs only while the pointer is in this window. It runs before + // this frame's press, which then uses the new tool; while a press is held, it waits for the + // release. A turn made over another window takes effect when the pointer comes in. + if drag.is_none() && ui.input(|i| i.pointer.has_pointer()) { + crate::stylus::sync_eraser_tool(app); + } + let tool = app.session.tool_id(); + // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and + // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. + let zoom_mode = if space { m.command } else { tool == "zoom" }; + let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; + let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; + let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. })); + // egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a + // press on the canvas itself reaches the tools, else it would land at the canvas's centre. + if pointer.primary_pressed() + && resp.hovered() + && !middle_pan + && let Some(p) = at + { + ui.ctx().memory_mut(|mem| mem.stop_text_input()); + app.ui.flyout = None; + // The artboard's name sits above its corner. A press there renames on a double-click + // and does not start a tool gesture (a fitted artboard, a new one, Artboard Options). + if let Some(i) = artboard_name_at(app, ui, &xf, p) { + ui.data_mut(|dd| dd.insert_temp(name_press_id(), i)); + } else { + let d = if crate::touch::finger_navigates_only() { + // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. + Drag::Gesture + } else if zoom_mode { + if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } + } else if pan_mode { + Drag::Pan { start: p, center: v.center, middle: false } + } else if rotate_mode { + let c = rect.center(); + Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } + } else { + // Cmd with another tool drags with the selection tool used last (the session lends it). + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; + dispatch(app, &ev, view); + crate::document_drag::begin(app, ui.ctx(), ev.pos); + Drag::Tool + }; + // Panning, zooming or turning the view takes the pointer from the tool's hover. + if !tool_holds(d) { + app.session.tool_hover_ended(view); + } + ui.data_mut(|dd| { + dd.insert_temp(drag_id(), d); + dd.insert_temp(drag_pos_id(), p); + dd.insert_temp(drag_layer_id(), resp.layer_id); + // A new press has not dragged yet, whatever an interrupted one left behind. + dd.remove::(tool_drag_doc_id()); + dd.insert_temp(tool_press_doc_id(), xf.to_doc(p)); + }); + } + } else if drag.is_none() + && resp.hovered() + && pointer.button_pressed(egui::PointerButton::Middle) + && let Some(start) = hover + { + // Middle-button drag pans the view whatever the tool. + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true })); + app.session.tool_hover_ended(view); + } else if let Some(d) = drag { + // The pointer gone (a pen lifted with its press), the drag ends where it was last seen. + let p = pointer.interact_pos().or_else(|| ui.data(|dd| dd.get_temp(drag_pos_id()))).unwrap_or(rect.center()); + let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() }; + if held { + ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); + match d { + Drag::Pan { start, center, .. } => { + let ppp = ui.ctx().pixels_per_point(); + if let Some(vm) = app.view_mut() { + vm.center = center; + pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); + } + } + Drag::RotateView { start_angle, start_rot, snaps } => { + let c = rect.center(); + let a = (p.y - c.y).atan2(p.x - c.x) as f64; + let mut deg = start_rot + (a - start_angle).to_degrees(); + if snaps && m.shift { + deg = (deg / 15.0).round() * 15.0; + } + if let Some(vm) = app.view_mut() { + vm.rotation = vectorcraft_geom::normalize_deg(deg); + } + } + Drag::ZoomBox { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)), + Drag::ZoomScrub { start, held, moved } => { + let dt = ui.input(|i| i.unstable_dt).min(0.25); + let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP); + // Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out). + let exponent = if moved { + f64::from(pointer.delta().x) * SCRUB_ZOOM + } else if held > HOLD_DELAY { + f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 } + } else { + 0.0 + }; + if exponent != 0.0 + && let Some(vm) = app.view_mut() + { + zoom_about(vm, rect, start, vm.zoom * exponent.exp()); + } + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); + } + // Without a pointer it is over a floating panel's own OS window (`drag_art_out`). + Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => { + // Back on the canvas: the move goes on from where it left, with the pointer. + egui::DragAndDrop::clear_payload(ui.ctx()); + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + ui.data_mut(|dd| { + dd.insert_temp(drag_id(), Drag::Tool); + dd.insert_temp(drag_pos_id(), p); + }); + } + Drag::Art | Drag::Gesture | Drag::Present => {} + Drag::Tool if drag_art_out(app, ui, resp, p) => { + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art)); + } + Drag::Tool => { + // Every position the pen or mouse passed this frame, not just the last, for a + // tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a + // mouse reports far more often than frames come). Touch events (a pen on Windows + // or the web, a finger) and a macOS pen's moves carry their own pressure; a + // mouse's, a pen's on X11 among them, take the host's latest. Other tools get + // the last position only, so a costly drag preview runs once a frame. + let moves = tool_frame_moves(ui, app); + // A frame whose pointer ends where the last one did still moved in between. + if tool_drag_moved(ui, &xf, p, &pointer) || !moves.is_empty() { + for (at, force) in moves { + let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); + } + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts + // a quarter second at most. + if app.session.tool_wants_ticks() { + let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25)); + let r = app.session.tool_tick(dt, view); + apply_requests(app, r); + } + } + } + } else { + // The last position the tool's drag got: none if the press never dragged. + let last_drag: Option = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); + ui.data_mut(|dd| { + dd.remove::(drag_id()); + dd.remove::(drag_pos_id()); + dd.remove::(tool_drag_doc_id()); + dd.remove::(tool_press_doc_id()); + }); + match d { + Drag::ZoomBox { start } => { + let r = egui::Rect::from_two_pos(start, p); + if let Some(vm) = app.view_mut() { + if r.width() > 8.0 && r.height() > 8.0 { + // The marquee's center and its size on screen set the view, at any rotation. + vm.center = xf.to_doc(r.center()); + let fill = (rect.width() / r.width()).min(rect.height() / r.height()); + vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); + } else { + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + } + } + } + // A click (not held, not dragged) steps the zoom as with the marquee. + Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { + if let Some(vm) = app.view_mut() { + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + } + } + Drag::ZoomScrub { .. } => {} + Drag::Tool => { + // The second click of a double-click: don't fit another artboard on this release. + // A double-click on an object does not open Artboard Options. Empty artboard + // space still does. + if resp.double_clicked() && app.session.tool_id() == "artboard" { + app.session.set_tool_option("suppressFitClick", &json!(true)); + } + // A move that came in the release's own frame shapes the preview first: the + // tools commit the last preview, which should be where the pointer let go + // (#834). Only once the press has dragged, so a click's jitter stays a click + // (the Pen's corner anchor, no nudge with Direct Selection). + if let Some(last) = last_drag + && last != xf.to_doc(p) + { + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + Drag::Art if on_canvas(ui, resp, p) => { + // Came back to the canvas and released in one frame: finish the move here. + egui::DragAndDrop::clear_payload(ui.ctx()); + let pressure = pen_pressure(ui, app, false); + let pos = xf.to_doc(p); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); + } + Drag::Art => { + // Released over a panel: the move goes back. (The app ends it before the panels + // see the release, so a panel can take the art; this is the headless path.) + // With no document open there is nothing to roll back; the tool still gets its up. + let _ = app.session.cancel_interaction(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + } + Drag::Gesture | Drag::Present => {} + Drag::Pan { .. } | Drag::RotateView { .. } => {} + } + } + } else if let Some(p) = hover_tool + && (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover())) + { + let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + dispatch(app, &ev, view); + } + // The pointer left the canvas (for a panel, or out of the window) or went under a floating + // panel, menu, popup or dialog, with no press the tool holds: the tool's hover ends, which no + // hover event would clear (Alt's Distance Guides). + if was_over && hover_tool.is_none() && !drag.is_some_and(tool_holds) { + app.session.tool_hover_ended(view); + ui.ctx().request_repaint(); + } + // The tool works on another thread (a large selection's planar map): it checks each frame + // whether the work is done, and then shows the highlight under the pointer. + if app.session.tool_waiting() { + if let Err(e) = app.session.tool_poll(view, false) { + app.status(e.to_string()); + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(16)); + } + if pointer.primary_released() + && let Some(i) = ui.data_mut(|d| d.remove_temp::(name_press_id())) + { + select_named_artboard(app, i); + } + if resp.double_clicked() + && let Some(p) = at + { + if let Some(i) = artboard_name_at(app, ui, &xf, p) { + begin_artboard_name_edit(ui, app, i); + } else { + let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let before = app.session.tool_id().to_string(); + dispatch(app, &ev, view); + // A double-click on type switched a selection tool to the Type tool: the caret goes where + // the type was clicked, so typing edits it at once. + if before != "type" && app.session.tool_id() == "type" { + app.session.set_tool_option("prevTool", &json!(before)); + for kind in [PointerKind::Down, PointerKind::Up] { + dispatch(app, &PointerEvent { kind, ..ev }, view); + } + } + } + } + if drag.is_some() || pointer.is_moving() || mods_changed { + ui.ctx().request_repaint(); + } +} + +/// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white +/// artboard and on the grey pasteboard alike (#725). +fn marquee(p: &egui::Painter, r: egui::Rect) { + marquee_through(p, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]); +} + +/// A [`marquee`] through the screen corners `q` in turn, back to the first: a document rectangle's +/// [`Xf::quad`] in a rotated view. +pub(super) fn marquee_through(p: &egui::Painter, q: &[Pos2]) { + let pts: Vec = q.iter().chain(q.first()).copied().collect(); + p.add(Shape::line(pts.clone(), Stroke::new(1.0, Color32::from_white_alpha(230)))); + for w in pts.windows(2) { + p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(40)), 3.0, 3.0)); + } +} diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs new file mode 100644 index 000000000..907f33612 --- /dev/null +++ b/crates/ui-egui/src/canvas/outline.rs @@ -0,0 +1,201 @@ +use super::*; + +pub(super) fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec> { + // Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px). + let mut out: Vec> = vec![]; + let a = xf.affine(); + let mut t = bp.clone(); + t.apply_affine(a); + let mut cur: Vec = vec![]; + kurbo_flatten(&t, 0.25, &mut |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur.clear(); + cur.push(pos2(p.x as f32, p.y as f32)); + } + PathEl::LineTo(p) => cur.push(pos2(p.x as f32, p.y as f32)), + PathEl::ClosePath => { + if let Some(f) = cur.first().copied() { + cur.push(f); + } + } + _ => {} + }); + if cur.len() > 1 { + out.push(cur); + } + out +} + +pub(super) fn stroke_path(p: &egui::Painter, bp: &BezPath, xf: &Xf, s: Stroke) { + for line in to_screen_path(bp, xf) { + p.add(Shape::line(line, s)); + } +} + +pub(super) fn c32(rgb: [u8; 3]) -> Color32 { + Color32::from_rgb(rgb[0], rgb[1], rgb[2]) +} + +/// Outline of a node for highlighting (paths, compound children, area type frames, other +/// text/image bounds, symbol instances' bounds measured by their art from `symbols`; a clip +/// group's clipping path only, as [`walk_drawn`] visits it). +pub(super) fn node_outline(n: &Node, symbols: &SymbolBoxes) -> BezPath { + let mut bp = BezPath::new(); + walk_drawn(n, &mut |c| match &c.kind { + NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), + // Area type shows its frame: the type area Direct Selection reshapes; type on a path its + // path, which the selection tools' brackets sit on; point type shows its baseline(s). + NodeKind::Text(t) => { + if c.extras().perspective.is_none() { + if let Some(path) = t.area_frame().or_else(|| t.type_path()) { + bp.extend(path.to_bezpath()); + } else if matches!(t.kind, vectorcraft_doc::TextKind::Point) { + for (a, b) in t.baselines() { + bp.move_to(t.xf * a); + bp.line_to(t.xf * b); + } + } + } else if let Some(b) = c.geometric_bounds_with(symbols) { + bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); + } + } + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + if let Some(b) = c.geometric_bounds_with(symbols) { + bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); + } + } + // A placed document shows its box, turned with it. + NodeKind::PlacedDocument(p) => { + let [a, b, c, d] = p.corners(); + bp.move_to(a); + [b, c, d].into_iter().for_each(|q| bp.line_to(q)); + bp.close_path(); + } + // A gradient mesh shows its mesh lines (#1111). + NodeKind::Mesh(m) => bp.extend(m.lines().to_bezpath()), + // An envelope shows its mesh (or top object), not its content. + NodeKind::Envelope { .. } => { + if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) { + bp.extend(lines.to_bezpath()); + } + } + _ => {} + }); + bp +} + +/// The width of the key object's outline (Align to Key Object), thicker than the selection's. +pub(super) const KEY_OUTLINE: f32 = 2.5; + +/// The most points ([`overlay_points`]) the selection's outlines and anchors, and the hover +/// highlight, are drawn for: an Image Trace of a photo selects hundreds of thousands of paths at +/// once, and selecting all of an opened PDF can select as many type objects, images and gradient +/// mesh pieces. Their outlines would make a mesh larger than the GPU takes in one buffer (#525, +/// #2460). +pub(super) const OVERLAY_MAX_ANCHORS: usize = 100_000; + +/// Whether `nodes` have more than [`OVERLAY_MAX_ANCHORS`] points to outline ([`overlay_points`]; +/// counting stops there). +pub(super) fn too_many_anchors<'a>(nodes: impl IntoIterator) -> bool { + fn over(n: &Node, left: &mut usize) -> bool { + match left.checked_sub(overlay_points(n)) { + Some(l) => *left = l, + None => return true, + } + // A clip group counts only its clipping path, the one child `walk_drawn` visits. + if n.clips() { + return n.children().and_then(|children| children.first()).is_some_and(|clip| over(clip, left)); + } + !matches!(n.kind, NodeKind::Envelope { .. }) && n.editing_children().iter().any(|c| over(c, left)) + } + let mut left = OVERLAY_MAX_ANCHORS; + nodes.into_iter().any(|n| over(n, &mut left)) +} + +/// The points counted for `n` itself, without its children, toward [`OVERLAY_MAX_ANCHORS`]: a +/// path's anchors; the anchors of area type's frame or of type on a path's path; the four corners +/// of the box that area type or type on a path in perspective, images, symbol instances and +/// placed documents show; six for point type, its box's corners and the ends of the baseline +/// marker a selected one shows; and the points and the ends of the mesh lines of a gradient mesh +/// or an envelope. +fn overlay_points(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { path, .. } => path.anchor_count(), + NodeKind::Text(t) => match &t.kind { + vectorcraft_doc::TextKind::Point => 6, + _ if n.extras().perspective.is_some() => 4, + vectorcraft_doc::TextKind::Area { frame } => frame.anchor_count(), + vectorcraft_doc::TextKind::OnPath { path, .. } => path.anchor_count(), + }, + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::PlacedDocument(_) => 4, + NodeKind::Mesh(m) => m.lines().anchor_count() + m.points.len(), + NodeKind::Envelope { .. } => { + vectorcraft_doc::live::envelope_overlay(n).map_or(0, |(lines, grid)| lines.anchor_count() + grid.map_or(0, |g| g.points.len())) + } + _ => 0, + } +} + +/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the +/// envelope shows its mesh instead), and a clip group or clipping layer shows only its clipping +/// path, its first child, not the art the path clips (#2422). +pub(super) fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { + f(n); + if matches!(n.kind, NodeKind::Envelope { .. }) { + return; + } + if n.clips() { + if let Some(clip) = n.children().and_then(|children| children.first()) { + walk_drawn(clip, f); + } + return; + } + for c in n.editing_children().iter() { + walk_drawn(c, f); + } +} + +/// The topmost editable object under document point `p` at `zoom`, as what is drawn there +/// ([`hit_with`]): a swatch or a symbol dropped on art. +pub(super) fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { + hit_with(app, p, zoom, vectorcraft_doc::hit::hit_test) +} + +/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick +/// it: [`hit_at`], and a clipping path on its outline before the art it clips +/// ([`vectorcraft_doc::hit::HitOptions::clip_outlines`]). Object Highlighting and a right-click's +/// selection show and take what a click of those tools would. +pub(super) fn selection_hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { + hit_with(app, p, zoom, |doc, p, opt| vectorcraft_doc::hit::hit_test(doc, p, vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..opt })) +} + +/// `test` on the active document at document point `p`, with the options the selection tools hit +/// objects with at `zoom` (Selection & Anchor Display › Tolerance and Object Selection by Path Only, +/// Type › Type Object Selection by Path Only, isolation mode). +pub(super) fn hit_with( + app: &VectorcraftApp, + p: Point, + zoom: f64, + test: impl FnOnce(&vectorcraft_doc::Document, Point, vectorcraft_doc::hit::HitOptions) -> Option, +) -> Option { + let prefs = &app.session.prefs; + let opt = vectorcraft_doc::hit::HitOptions { + tol: prefs.selection_tolerance / zoom, + outline: app.ui.view.outline, + path_only: prefs.object_selection_by_path_only, + type_path_only: prefs.type_selection_by_path_only, + scope: app.session.active().and_then(|st| st.isolation), + skip_guides: !app.ui.view.guides, + ..Default::default() + }; + test(&app.session.active()?.doc, p, opt) +} + +fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { + // Bounded per curve: a document's far-out handles, zoomed onto the screen, made kurbo + // reserve gigabytes for the selection outline. + vectorcraft_geom::flatten(p.elements().iter().copied(), tol, f); +} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs new file mode 100644 index 000000000..6722c4761 --- /dev/null +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -0,0 +1,377 @@ +use super::artboards::names_an_artboard; +use super::input::marquee_through; +use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors, walk_drawn}; +use super::*; + +pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(h) = app.hover_doc else { return }; + // Smart Guides › Object Highlighting: a Smart Guides display option, so it needs them on and Snap to Grid and Pixel Preview off. + if !app.ui.view.smart_guides || !app.session.prefs.object_highlighting || app.ui.view.snap_to_grid || app.ui.view.pixel_preview { + return; + } + if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { + return; + } + let Some(st) = app.session.active() else { return }; + let Some(hit) = selection_hit_at(app, h, xf.zoom) else { return }; + let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf }; + if st.selection.contains(id) { + return; + } + if let Some(n) = st.doc.node(id).filter(|n| !too_many_anchors([*n])) { + let color = c32(st.doc.layer_color(id)); + stroke_path(p, &node_outline(n, &st.doc.symbol_boxes()), xf, Stroke::new(1.5, color)); + } +} + +/// Selected anchors are drawn slightly deeper than the layer colour (#4f80ff → #3d82ff for Layer 1). +pub(super) fn selected_anchor(c: Color32) -> Color32 { + if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c } +} + +/// How anchors and handles look: Selection & Anchor Display › Size and Handles. +#[derive(Clone, Copy)] +pub(super) struct HandleLook<'a> { + /// Points bigger (or smaller) than the default size 3, per step of Size (1–7). + grow: f32, + /// `solid` (the default), `hollow` or `large`. + style: &'a str, +} + +impl<'a> HandleLook<'a> { + pub(super) fn of(prefs: &'a vectorcraft_engine::Prefs) -> Self { + Self { grow: prefs.anchor_size.clamp(1, 7) as f32 - 3.0, style: &prefs.handle_style } + } + + /// A direction handle's end at `c`: a solid dot, a hollow one or a larger solid one. + fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) { + let r = 2.75 + self.grow / 2.0; + let hollow = self.style == "hollow"; + p.circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color }); + if hollow { + p.circle_stroke(c, r, Stroke::new(1.0, color)); + } + } +} + +fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { + let r = egui::Rect::from_center_size(c, vec2(size, size)); + if filled { + p.rect_filled(r, 0.0, color); + } else { + p.rect_filled(r, 0.0, Color32::WHITE); + p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); + } +} + +fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { + p.circle(c, size / 2.0, if filled { color } else { Color32::WHITE }, Stroke::new(1.0, color)); +} + +pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) { + // The Selection tool's bounding box (rotated with the objects after a rotation). It hides while + // a drag moves or resizes the selection, so only the art is seen going with the pointer (#712). + let show_box = app.session.tool_id() == "selection" + && !app.session.tool_transforming() + && app.ui.view.bounding_box + && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); + let bbox = if show_box { app.selection_box() } else { None }; + let st = app.session.active(); + let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id)))); + let big_bounds = if big { app.selection_bounds() } else { None }; + let app = &*app; + let Some(st) = app.session.active() else { return }; + let symbols = st.doc.symbol_boxes(); + let tool = app.session.tool_id(); + let direct = vectorcraft_tools::catalog::edits_anchors(tool); + let curvature = tool == "curvature"; + // Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding + // box's handles a point bigger or smaller per step. + let look = HandleLook::of(&app.session.prefs); + let grow = look.grow; + let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 }; + // The selected anchors' handles (drawn once they are counted: Show handles when multiple + // anchors are selected off shows them for a single one only). + let (mut handles, mut with_handles) = (vec![], 0); + // Too many paths to outline: the selection's bounds stand for them. + if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) { + stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id)))); + } + // The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has + // too many paths to outline. + if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) { + let outline = if too_many_anchors([n]) { + n.geometric_bounds_with(&symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()) + } else { + Some(node_outline(n, &symbols)) + }; + if let Some(bp) = outline { + stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); + } + } + for id in st.selection.objects.iter().filter(|_| !big) { + let Some(n) = st.doc.node(*id) else { continue }; + let color = c32(st.doc.layer_color(*id)); + let partial = st.selection.partial(*id); + // Path outlines (and area type / placed document / image / mesh / symbol outlines; + // point type baselines and origin are drawn below). + if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) { + stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color)); + } + // Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh + // envelope's points. + walk_drawn(n, &mut |c| { + let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid); + let grid = match &c.kind { + NodeKind::Mesh(m) => Some(m), + _ => envelope.as_ref(), + }; + if let Some(grid) = grid { + for q in &grid.points { + anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct)); + } + return; + } + let NodeKind::Path { path, .. } = &c.kind else { return }; + // The Curvature tool owns its points while drawing; existing paths use this overlay. + if curvature && curve_points_from_tool { + return; + } + let selected_segments = st.selection.segments.get(&c.id); + if let Some(segments) = selected_segments { + for &(si, seg) in segments { + let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue }; + let curve = sp.segment(seg); + let mut outline = vectorcraft_geom::BezPath::new(); + outline.move_to(curve.p0); + if sp.segment_is_line(seg) { + outline.line_to(curve.p3); + } else { + outline.curve_to(curve.p1, curve.p2, curve.p3); + } + stroke_path(p, &outline, xf, Stroke::new(2.0, color)); + } + } + for (si, ai, a) in path.anchors() { + // An object selected whole has all its anchors selected, and so do the paths inside a + // group, compound path or clip group selected whole. Direction handles show only on a + // path that is selected itself: those are the handles that `handle_anchors` in + // `vectorcraft-tools` lets the tools drag. + let (sel, handles_shown) = match partial { + Some(set) => { + let sel = set.contains(&(si, ai)); + (sel, sel) + } + None => (true, direct && c.id == *id), + }; + let sp = xf.to_screen(a.p); + if !curvature && handles_shown { + with_handles += 1; + let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true)); + handles.extend( + [(a.h_in, sides.0), (a.h_out, sides.1)] + .into_iter() + .filter(|(h, show)| *show && h.distance(a.p) > 1e-6) + .map(|(h, _)| (sp, xf.to_screen(h), color)), + ); + } + let anchor_selected = sel && selected_segments.is_none(); + let ink = if anchor_selected && partial.is_some() { selected_anchor(color) } else { color }; + if curvature { + curve_point(p, sp, ink, anchor_selected && partial.is_some(), grow + 6.0); + } else { + anchor_square(p, sp, ink, anchor_selected, anchor(partial.is_some() || direct)); + } + } + }); + // Centre point (Attributes panel → Show Center). + if n.shows_center() + && let Some(b) = n.geometric_bounds_with(&symbols) + { + anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false)); + } + // Point type: each line's baseline, where a click selects it (`hit`), whatever its + // alignment and rotation, and the anchor (area type shows its frame instead, which may + // not be a rectangle, and type on a path its path). + if let NodeKind::Text(tx) = &n.kind + && matches!(tx.kind, vectorcraft_doc::TextKind::Point) + { + for (a, b) in tx.baselines() { + p.line_segment([xf.to_screen(tx.xf * a), xf.to_screen(tx.xf * b)], Stroke::new(1.0, color)); + } + p.circle_filled(xf.to_screen(tx.xf * Point::ZERO), 2.5, color); + } + } + if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 { + for (sp, hp, color) in handles { + p.line_segment([sp, hp], Stroke::new(1.0, color)); + look.draw(p, hp, color); + } + } + // The spine of each selected blend (or of the blend a selected key object belongs to). + let mut spines = vec![]; + for id in &st.selection.objects { + let is_blend = |b: &vectorcraft_doc::NodeId| st.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. })); + let Some(b) = [Some(*id), st.doc.parent_of(*id)].into_iter().flatten().find(is_blend).filter(|b| !spines.contains(b)) else { continue }; + spines.push(b); + let Some(NodeKind::Blend { children, spec }) = st.doc.node(b).map(|n| &n.kind) else { continue }; + let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) else { continue }; + let color = c32(st.doc.layer_color(b)); + stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color)); + for (_, _, a) in path.anchors() { + anchor_square(p, xf.to_screen(a.p), color, false, anchor(direct)); + } + } + // Live Corners widgets (Selection on a live rectangle or polygon, Direct Selection on any path). + if matches!(tool, "selection" | "directSelection") + && let Some(w) = vectorcraft_tools::corners::CornerWidgets::showing( + &st.doc, + &st.selection, + xf.zoom, + tool == "directSelection", + app.ui.view.corner_widgets, + app.session.prefs.hide_corner_widget_above, + ) + { + for (id, q) in w.visible_on() { + let (sp, color) = (xf.to_screen(q), c32(st.doc.layer_color(id))); + p.circle_filled(sp, 3.0, Color32::WHITE); + p.circle_stroke(sp, 3.0, Stroke::new(1.0, color)); + p.circle_filled(sp, 1.0, color); + } + } + // Bounding box with handles (Selection tool). + if let Some(b) = bbox { + let color = c32(st.doc.layer_color(st.selection.objects[0])); + p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color))); + for h in vectorcraft_tools::bbox::Handle::ALL { + let c = xf.to_screen(b.to_doc() * h.pos(b.rect)); + let hr = egui::Rect::from_center_size(c, vec2(6.0 + grow, 6.0 + grow)); + p.rect_filled(hr, 0.0, Color32::WHITE); + p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); + } + // The type widget beside it: hollow on point type, filled on area type. + if let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) { + let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::typewidget::RADIUS_PX); + p.circle_filled(c, r, if w.area { color } else { Color32::WHITE }); + p.circle_stroke(c, r, Stroke::new(1.0, color)); + } + } + // Pie widgets (Selection tool, with View › Show Bounding Box, on live ellipses): each joined + // by a line to the outline's point at its angle. They stay while one is dragged. + if tool == "selection" && app.ui.view.bounding_box { + for w in vectorcraft_tools::pie::PieWidget::of(&st.doc, &st.selection, xf.zoom) { + let (on, c, r, color) = (xf.to_screen(w.on), xf.to_screen(w.at), vectorcraft_tools::pie::RADIUS_PX, c32(st.doc.layer_color(w.id))); + p.line_segment([on, c], Stroke::new(1.0, color)); + p.circle_filled(c, r, Color32::WHITE); + p.circle_stroke(c, r, Stroke::new(1.0, color)); + } + } +} + +/// Record where the Type tool's IME candidate window goes this pass, if it has an editor. +/// +/// The descriptor itself is observed once at the end of the pass (`crate::ime::end_of_pass`), +/// after dialogs, floating panels, progress windows and the palette draw: any of them can open +/// over the canvas, take the keyboard or run a command that changes the edited text, so a +/// descriptor written here would carry a stale owner, surrounding text or cause. +pub(super) fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) { + app.ime_caret_rect = None; + // A hidden viewport cannot compose; the end-of-pass observation clears the owner. + if !app.session.tool_wants_text() || app.viewport_hidden(ctx) { + return; + } + let Some((a, b)) = app.session.tool_ime_caret(app.view_info()) else { return }; + let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0)); + app.ime_caret_rect = Some((r, r)); +} + +pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) { + for o in overlays { + match o { + // Where the tools select: the document rectangle, turned with the view. + Overlay::Marquee(r) => marquee_through(p, &xf.quad(*r)), + Overlay::Path { path, color, width, dashed } => { + let s = Stroke::new(*width, c32(*color)); + if *dashed { + for line in to_screen_path(path, xf) { + p.extend(Shape::dashed_line(&line, s, 4.0, 3.0)); + } + } else { + stroke_path(p, path, xf, s); + } + } + Overlay::Line { a, b, color, dashed } => { + let s = Stroke::new(1.0, c32(*color)); + let (a, b) = (xf.to_screen(*a), xf.to_screen(*b)); + if *dashed { + p.extend(Shape::dashed_line(&[a, b], s, 4.0, 3.0)); + } else { + p.line_segment([a, b], s); + } + } + Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size), + Overlay::CurvePoint { p: pt, color, filled, size } => curve_point(p, xf.to_screen(*pt), c32(*color), *filled, *size + look.grow), + Overlay::Handle { p: pt, color } => look.draw(p, xf.to_screen(*pt), c32(*color)), + Overlay::Label { p: pt, text, color } => { + let sp = xf.to_screen(*pt) + vec2(8.0, -14.0); + p.text( + sp, + egui::Align2::LEFT_TOP, + crate::panels::label_or_name(text, !names_an_artboard(text)), + egui::FontId::proportional(11.0), + c32(*color), + ); + } + Overlay::Highlight { quad, color } => { + let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); + p.add(Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), c, Stroke::NONE)); + } + Overlay::Measure { p: pt, text } => { + let sp = xf.to_screen(*pt) + vec2(14.0, 14.0); + let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0); + let r = egui::Rect::from_min_size(sp, galley.size() + vec2(12.0, 8.0)); + p.rect_filled(r, CornerRadius::same(3), t.measure_bg); + p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE); + } + Overlay::Distance { a, b, text, color } => distance_guide(p, xf.to_screen(*a), xf.to_screen(*b), text, c32(*color)), + Overlay::GridLine { a, b, color } => { + let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); + p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c)); + } + Overlay::Swatch { p: pt, color, selected } => { + // A white disc under the colour shows its opacity; a dark rim keeps it readable on + // any art, and the accent ring marks the selected stop. + let c = xf.to_screen(*pt); + p.circle_filled(c, 6.0, Color32::WHITE); + p.circle_filled(c, 5.0, Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3])); + p.circle_stroke(c, 6.0, Stroke::new(1.0, Color32::from_gray(32))); + if *selected { + p.circle_stroke(c, 8.0, Stroke::new(2.0, t.accent)); + } + } + } + } +} + +/// A Distance Guides measurement on screen ([`Overlay::Distance`]): the line from `a` to `b` in the +/// smart guide colour `color` with a 4 px tick across each end, and `text` in a pill of that colour +/// on its middle, in white or black, whichever reads on it. +fn distance_guide(p: &egui::Painter, a: Pos2, b: Pos2, text: &str, color: Color32) { + let s = Stroke::new(1.0, color); + p.line_segment([a, b], s); + let along = b - a; + let len = along.length(); + if len > 0.0 && len.is_finite() { + let across = vec2(-along.y, along.x) / len * 4.0; + for end in [a, b] { + p.line_segment([end - across, end + across], s); + } + } + let luma = 0.299 * f32::from(color.r()) + 0.587 * f32::from(color.g()) + 0.114 * f32::from(color.b()); + let ink = if luma > 150.0 { Color32::BLACK } else { Color32::WHITE }; + let galley = p.layout_no_wrap(text.to_owned(), egui::FontId::proportional(11.0), ink); + let r = egui::Rect::from_center_size(a + along / 2.0, galley.size() + vec2(8.0, 4.0)); + p.rect_filled(r, CornerRadius::same(3), color); + p.galley(r.min + vec2(4.0, 2.0), galley, ink); +} diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs new file mode 100644 index 000000000..e2fc0f165 --- /dev/null +++ b/crates/ui-egui/src/canvas/raster.rs @@ -0,0 +1,93 @@ +use super::*; + +/// The rendered raster `img` as an egui image. Both are premultiplied RGBA8, so its bytes are copied +/// over whole instead of pixel by pixel, which costs milliseconds for a canvas on a large screen. +/// A raster whose bytes don't match its size gives an empty image of that size. +pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImage { + let size = [img.width as usize, img.height as usize]; + let mut pixels = vec![egui::Color32::TRANSPARENT; size[0] * size[1]]; + let bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut pixels); + if bytes.len() == img.pixels.len() { + bytes.copy_from_slice(&img.pixels); + } + egui::ColorImage::new(size, pixels) +} + +/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document +/// changed, or placed documents' bitmaps were made, since the frame shown (its key `key` but for +/// the revision and [`CacheKey::placed`], and its render options `opts`), which this thread +/// rendered, and no frame is on its way from the worker: those it changed in, and those the +/// objects showing those bitmaps' files cover (`Some(None)`: none), grown to whole eighths of the +/// frame, as long as they are at most half of it. None: render the whole frame. Frames the worker +/// renders stay whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. +pub(super) fn changed_pixels( + app: &VectorcraftApp, + key: &CacheKey, + doc: &vectorcraft_doc::Document, + opts: &vectorcraft_render::RenderOptions, + view: Affine, + w: u32, + h: u32, +) -> Option> { + let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?; + // A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds. + if opts.mask_view.is_some() { + return None; + } + let shown_key = app.canvas.key.as_ref()?; + let same_view = *shown_key == CacheKey { revision: shown_key.revision, placed: shown_key.placed, ..key.clone() }; + if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) { + return None; + } + // The placed documents' files whose bitmaps were made since the frame shown. + let placed = if shown_key.placed == key.placed { + vectorcraft_render::Changed::Nothing + } else { + vectorcraft_render::placed_area(doc, &vectorcraft_render::placed_document::made_between(shown_key.placed, key.placed)?) + }; + match vectorcraft_render::changed(shown, doc).and(placed) { + vectorcraft_render::Changed::Nothing => Some(None), + vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { + None => Some(None), + Some([x0, y0, x1, y1]) => { + let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h)); + (u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1])) + } + }, + vectorcraft_render::Changed::Everything => None, + } +} + +/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a +/// frame rendered again come in few sizes, which keep the contexts the renderer keeps. +fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) { + let step = n.div_ceil(8).max(1); + let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n); + let a = a.min(n - len); + (a, a + len) +} + +/// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified. +pub(super) fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) { + let color = color_image(img); + let options = if pixel { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; + match &mut app.canvas.texture { + Some(tex) => tex.set(color, options), + None => app.canvas.texture = Some(ctx.load_texture("canvas", color, options)), + } +} + +/// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view +/// included. None when the view is degenerate. +pub(super) fn pixel_region(xf: &Xf) -> Option<[i64; 4]> { + let r = xf.rect; + let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| xf.to_doc(p)); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in corners { + (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + } + let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil()); + // A document pixel is bigger than a screen pixel here, so the region is about canvas-sized. + let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= 16384.0 && y1 - y0 <= 16384.0; + ok.then_some([x0 as i64, y0 as i64, x1 as i64, y1 as i64]) +} diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs new file mode 100644 index 000000000..273fce5e6 --- /dev/null +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -0,0 +1,294 @@ +use super::*; + +/// The Contextual Task Bar's area. +fn task_bar_id() -> egui::Id { + egui::Id::unique("task-bar") +} + +/// Where the Contextual Task Bar was last drawn (none while it isn't shown). +pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option { + let id = task_bar_id(); + ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) +} + +/// Whether the objects `ids` (or anything inside them) are painted with a colour: a solid colour, +/// a gradient's stops or a mesh's points, which Recolor Artwork can change. +pub(crate) fn has_colors(doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]) -> bool { + ids.iter().filter_map(|id| doc.node(*id)).any(|n| { + let mut found = false; + vectorcraft_doc::swatches::node_colors(n, &mut |_, _| found = true); + found + }) +} + +/// The bounds of the artboards the Artboard tool has selected ([`crate::panels::artboards::picked`]). +fn picked_artboards_bounds(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let picked = crate::panels::artboards::picked(app, st.doc.artboards.len()); + picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b)) +} + +/// The Contextual Task Bar: a floating pill under the selection with the most likely next actions. +/// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard +/// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens). +/// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the +/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). +pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { + let t = Tokens::get(ui.ctx()); + let on_artboards = app.session.tool_id() == "artboard"; + if !on_artboards + && (app.session.active().is_none_or(|st| st.selection.is_empty()) + || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")) + { + return; + } + let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return }; + let Some(st) = app.session.active() else { return }; + let n = st.selection.len(); + let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned(); + let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. })); + let is_image = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Image(_))); + let is_clip_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { clip: true, .. })); + let is_compound = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Compound { .. })); + let open_path = first.as_ref().is_some_and(|f| matches!(&f.kind, NodeKind::Path { path, .. } if !path.is_closed())); + let text = first.as_ref().and_then(|f| match &f.kind { + NodeKind::Text(t) => Some(&t.kind), + _ => None, + }); + // Images and vector art selected together: a clipping mask is the likely next step. + let mixed = n > 1 && { + let images = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).filter(|o| matches!(o.kind, NodeKind::Image(_))).count(); + images > 0 && images < n + }; + // An Image Trace object: the group the engine names "Image Trace", its source image first. + let is_trace = is_group + && first.as_ref().is_some_and(|f| { + f.name.as_deref() == Some("Image Trace") && f.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) + }); + let mut items: Vec<(&str, &str, &str, serde_json::Value)> = vec![]; // (label, icon, command, params) + let none = || json!({}); + if on_artboards { + // As in the reference app, the Artboard tool offers to resize, duplicate and export the + // artboards (), + // and to rearrange them (). + items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS, none())); + items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS, none())); + if st.doc.artboards.len() > 1 { + items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); + } + items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); + } else { + // Edit Path: the Direct Selection tool, to edit the path's anchors. + let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); + // As in the reference app, an image offers Image Trace and Crop Image, and traced art offers + // Expand (). + if n == 1 && is_image { + items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none())); + items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none())); + } + // Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar + // offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does + // (). + // These run the same commands as the Control bar's anchor buttons. + let anchors = !st.selection.anchors.is_empty(); + if anchors { + items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none())); + items.push((tl!("Connect"), "dc-join", "path.join", none())); + items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none())); + items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); + items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); + } + // As in the reference app, open paths offer Join and Edit Path, compound paths and clipping + // masks their Release, images selected with vector art Make Clipping Mask, and type converts + // between point and area type (). + if n > 1 { + items.push((tl!("Group"), "group", "object.group", none())); + if mixed { + items.push((tl!("Make Clipping Mask"), "dc-pf-crop", "object.clippingMask.make", none())); + } else { + items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite", none())); + } + } else if is_trace { + items.push((tl!("Expand"), "maximize-2", "imageTrace.expand", none())); + } else if is_clip_group { + items.push((tl!("Edit Clipping Path"), "dc-direct", "object.clippingMask.editMask", none())); + items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none())); + } else if is_group { + items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none())); + items.push((tl!("Isolate"), "square-dashed", "object.isolate", none())); + } else if is_compound { + items.push(edit_path()); + items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none())); + } else if let Some(kind) = text { + items.push((tl!("Create Outlines"), "type", "type.createOutlines", none())); + // Point type converts to area type and back; type on a path doesn't. + match kind { + TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), + TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), + TextKind::OnPath { .. } => {} + } + } else if is_image { + // Offset Path and Simplify don't apply to an image. + } else if !anchors { + if open_path { + items.push((tl!("Join"), "dc-join", "path.join", none())); + } + items.push(edit_path()); + items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath", none())); + items.push((tl!("Simplify"), "spline", "object.path.simplify", none())); + } + items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); + } + let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default(); + let colored = has_colors(&st.doc, &st.selection.objects); + let doc = st.uid; + // Under the selection's lowest corner on screen, centered on the selection (the view may be + // rotated). + let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); + let anchor = pos2(xf.to_screen(b.center()).x, lowest); + let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + // Its place under the selection, then where the handle moved it, kept on the canvas (with the + // bar's size last frame, the estimate before it first shows). + let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); + let id = task_bar_id(); + let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(vec2(est_w, 44.0)); + let bounds = xf.rect.shrink(8.0); + let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min)); + let place = &mut app.ui.task_bar_place; + // Just unpinned: follow the selection from the pinned spot. + if !place.pinned + && let Some(at) = place.pin_at.take() + { + place.offset = Some((doc, xf.rect.min + at - under)); + } + let pos = keep_in(match place.pin_at { + Some(at) => xf.rect.min + at, + None => under + place.offset.filter(|(d, _)| *d == doc).map_or(Vec2::ZERO, |(_, o)| o), + }); + place.shown_at = Some(pos - xf.rect.min); + // Pinned before it ever showed: hold it here. + if place.pinned && place.pin_at.is_none() { + place.pin_at = place.shown_at; + } + let pinned = place.pinned; + let mut moved = Vec2::ZERO; + let mut run: Option<(&str, serde_json::Value)> = None; + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.tool_active)) + .corner_radius(CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (g, grip) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::drag()); + let active = grip.hovered() || grip.dragged(); + ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), if active { t.text_dim } else { t.button_border }); + if grip.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + moved = grip.drag_delta(); + } else if grip.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + for (label, icon, cmd, params) in &items { + let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); + let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside); + crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon); + ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong); + if resp.clicked() { + run = Some((*cmd, params.clone())); + } + } + // The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool. + if !on_artboards { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); + widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill); + ui.painter().rect_stroke( + egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), + 0.0, + Stroke::new(1.0, t.button_border), + StrokeKind::Outside, + ); + if resp.on_hover_text(tl!("Fill")).clicked() { + app.session.fill_active = true; + app.ui.open_panel = Some("swatches".into()); + } + // Recolor Artwork, when the selection has colours to recolour. + if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() { + run = Some(("ui.recolorDialog", json!({}))); + } + if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() { + run = Some(("object.lock", json!({}))); + } + } + let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 30.0); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(170.0); + let bar = [ + (tl!("Hide Bar"), "window.taskBar", false), + (tl!("Pin Bar Position"), "window.taskBar.pin", pinned), + (tl!("Reset Bar Position"), "window.taskBar.reset", false), + ]; + for (label, cmd, checked) in bar { + if widgets::menu_item(ui, label, true, checked) { + run = Some((cmd, json!({}))); + } + } + ui.separator(); + if widgets::menu_item(ui, tl!("Show Properties Panel"), true, false) { + run = Some(("window.panel", json!({"panel": "properties"}))); + } + }); + }); + }); + }); + if moved != Vec2::ZERO { + let to = keep_in(pos + moved); + let place = &mut app.ui.task_bar_place; + if place.pinned { + place.pin_at = Some(to - xf.rect.min); + } else { + place.offset = Some((doc, to - under)); + } + ui.ctx().request_repaint(); + } + match run { + // Image Trace traces the image at once with the Default preset. As a quick trace from + // Properties or the Control bar, it leaves the Image Trace panel as it was. + Some((TASK_BAR_IMAGE_TRACE, _)) => crate::menus::invoke(app, "imageTrace.make", json!({"preset": "Default"})), + // The Artboard tool's buttons: Artboard Options for the active artboard, as Properties' + // icon opens it; Duplicate for every selected artboard; Rearrange All Artboards. + Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})), + Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app), + Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app), + // Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do. + Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})), + Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})), + Some((c, p)) => crate::menus::invoke(app, c, p), + None => {} + } +} + +/// The Contextual Task Bar's Image Trace button: a trace with the Default preset. +const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace"; + +/// The Contextual Task Bar's Resize button with the Artboard tool: Artboard Options. +const TASK_BAR_ARTBOARD_OPTIONS: &str = "taskBar.artboardOptions"; + +/// The Contextual Task Bar's Duplicate button with the Artboard tool. +const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards"; + +/// The Contextual Task Bar's Rearrange All button with the Artboard tool. +const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards"; + +/// The Contextual Task Bar's Corner button for direct-selected anchors. +const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner"; + +/// The Contextual Task Bar's Smooth button for direct-selected anchors. +const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth"; diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs new file mode 100644 index 000000000..c3f1b8007 --- /dev/null +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -0,0 +1,338 @@ +use super::grid::{clip_segment, pref_color}; +use super::outline::{OVERLAY_MAX_ANCHORS, c32, stroke_path}; +use super::*; + +/// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story. +/// The marks of each text object are kept from one frame to the next ([`HiddenChars`]). +pub(super) fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(st) = app.session.active() else { return }; + let clip = p.clip_rect(); + let color = Color32::from_rgb(0x4f, 0x9d, 0xff); + let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0)); + let kept = &mut app.canvas.hidden_chars; + // `last` holds the marks of the last frame. The marks of the objects this frame doesn't draw + // are dropped at the end. + let mut last = std::mem::take(&mut kept.marks); + // The inputs are read before any layout: when fonts load during this frame, the next frame + // lays all type out again. + let inputs = (vectorcraft_text::FontDb::global().generation(), vectorcraft_text::hyphenation_exceptions()); + if kept.inputs != inputs { + last.clear(); + kept.inputs = inputs; + } + // `walk` visits each node's `Arc` in the order `Document::walk` visits the nodes (depth first, + // in paint order). + fn walk(nodes: &[std::sync::Arc], f: &mut impl FnMut(&std::sync::Arc)) { + for n in nodes { + f(n); + if let Some(children) = n.children() { + walk(children, f); + } + } + } + walk(&st.doc.layers, &mut |n| { + let NodeKind::Text(tx) = &n.kind else { return }; + // The box round the object's corners on screen, turned with the view. + if !n.visible || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) { + return; + } + let marks = match last.remove(&n.id) { + Some((was, marks)) if std::sync::Arc::ptr_eq(&was, n) => marks, + _ => std::sync::Arc::new(HiddenMarks::of(tx)), + }; + for &at in &marks.spaces { + p.circle_filled(xf.to_screen(at), 1.2, color); + } + for &(at, glyph) in &marks.ends { + p.text(xf.to_screen(at), egui::Align2::LEFT_BOTTOM, glyph, font.clone(), color); + } + kept.marks.insert(n.id, (n.clone(), marks)); + }); +} + +/// The Show Hidden Characters marks of the text objects drawn in the last frame +/// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an +/// edited object is a new `Arc` and is laid out again, and all type is laid out again when the +/// fonts or the hyphenation exceptions change. +#[derive(Default)] +pub struct HiddenChars { + /// The fonts' generation ([`vectorcraft_text::FontDb::generation`]) and the hyphenation + /// exceptions the marks were laid out with. + pub(super) inputs: (u64, String), + /// Each text object drawn, by id: the node it was laid out from and its marks. + pub(super) marks: std::collections::HashMap, std::sync::Arc)>, +} + +/// One text object's marks in document space: a dot over each space, and ¶ or # after the lines +/// that end a paragraph or the story. Type on a path has none. +#[derive(Default)] +pub(super) struct HiddenMarks { + pub(super) spaces: Vec, + ends: Vec<(Point, &'static str)>, +} + +impl HiddenMarks { + /// Lay out `tx` and place its marks. + pub(super) fn of(tx: &vectorcraft_doc::TextObject) -> Self { + let mut marks = Self::default(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); + if lay.on_path { + return marks; + } + let text = tx.plain_text(); + let to = |q: Point| tx.xf * q; + // A list marker's glyphs (length 0) stand for no character. + for g in lay.glyphs.iter().filter(|g| g.len > 0) { + if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) { + marks.spaces.push(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0))); + } + } + for (i, line) in lay.lines.iter().enumerate() { + let last = i + 1 == lay.lines.len(); + let breaks = text.get(line.end..).is_some_and(|s| s.starts_with('\n')); + let (glyph, at) = if last && line.end >= text.len() { + ("#", true) + } else if breaks { + ("¶", true) + } else { + ("", false) + }; + if at { + marks.ends.push((to(Point::new(line.x1 + 1.0, line.baseline)), glyph)); + } + } + marks + } +} + +/// View › Show Text Threads: for each thread with a selected frame, a line in its first frame's +/// layer colour from each frame's out port to the next frame's in port, both showing the arrow of a +/// threaded port, where the tools draw the ports of selected type ([`vectorcraft_tools::text`]). +/// Only frames with ports ([`vectorcraft_tools::text::ported`]) are joined: a link to or from a +/// frame in perspective, which has none, gets no line. With more than +/// [`vectorcraft_tools::text::PORTS_MAX`] such frames selected the tools draw no ports, and only +/// the lines are drawn here. `zoom`: screen pixels to the document unit. +pub(super) fn thread_overlays(app: &VectorcraftApp, zoom: f64) -> Vec { + use vectorcraft_tools::text::{Port, PortState, port_centre, ported, ports_shown, push_port}; + let Some(st) = app.session.active() else { return vec![] }; + let px = 1.0 / zoom.max(1e-9); + let boxes = ports_shown(&st.doc, &st.selection.objects); + let mut o = vec![]; + for thread in &st.doc.text_threads { + if !thread.iter().any(|id| st.selection.objects.contains(id)) { + continue; + } + let color = st.doc.layer_color(thread[0]); + for w in thread.windows(2) { + let [a, b] = [w[0], w[1]]; + let (Some(from), Some(to)) = (ported(&st.doc, a), ported(&st.doc, b)) else { continue }; + let (Some(out), Some(input)) = (port_centre(from, Port::Out, px), port_centre(to, Port::In, px)) else { continue }; + o.push(Overlay::Line { a: out, b: input, color, dashed: false }); + if boxes { + push_port(&mut o, out, PortState::Threaded, st.doc.layer_color(a), px); + push_port(&mut o, input, PortState::Threaded, st.doc.layer_color(b), px); + } + } + } + o +} + +/// View → Show Live Paint Gaps: the gaps of the selected Live Paint groups in each group's Gap +/// Preview Color ([`crate::dialogs::live_paint_gaps::selected_gaps`]). Cached per revision. +pub(super) fn live_paint_gap_overlays(app: &mut VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let key = (st.uid, st.revision); + let gaps = match &app.canvas.live_paint_gaps { + Some((k, gaps)) if *k == key => gaps.clone(), + _ => { + let gaps = std::sync::Arc::new(crate::dialogs::live_paint_gaps::selected_gaps(app)); + app.canvas.live_paint_gaps = Some((key, gaps.clone())); + gaps + } + }; + gaps.to_vec() +} + +/// Simplify's Show Original Path: the paths its preview ran on, as they were before it, in a band +/// 3 screen points wide of their layer's color at 40%, under the selection's outlines. Nothing is drawn +/// when they have more than [`OVERLAY_MAX_ANCHORS`] anchor points. +pub(super) fn original_path_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let paths = crate::dialogs::path_ops::original_paths(app); + let Some(st) = app.session.active().filter(|_| paths.iter().map(|(_, path)| path.anchor_count()).sum::() <= OVERLAY_MAX_ANCHORS) else { + return; + }; + for (id, path) in paths { + stroke_path(p, &path.to_bezpath(), xf, Stroke::new(3.0, c32(st.doc.layer_color(id)).gamma_multiply(0.4))); + } +} + +/// The slices (unless View → Hide Slices): user and object slices outlined in the Slices +/// preferences' line colour, auto slices dashed and dimmer, each numbered at its top left (Show +/// Slice Numbers); selected slices have a bolder outline. The layout is cached per revision. +pub(super) fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(st) = app.session.active() else { return }; + let key = (st.uid, st.revision); + let slices = match &app.canvas.slices { + Some((k, s)) if *k == key => s.clone(), + _ => { + let s = std::sync::Arc::new(st.doc.slice_layout()); + app.canvas.slices = Some((key, s.clone())); + s + } + }; + if slices.is_empty() { + return; + } + let prefs = &app.session.prefs; + let line = pref_color(&prefs.slice_line_color, Color32::from_rgb(0xff, 0x3f, 0x3f)); + let dim = line.gamma_multiply(0.55); + let selected = &st.selection.slices; + let objects = &st.selection.objects; + for a in slices.iter() { + let auto = a.source == vectorcraft_doc::SliceSource::Auto; + let on = a.id.is_some_and(|id| selected.contains(&id) || objects.contains(&id)); + let q = xf.quad(a.rect); + let ring: Vec = q.iter().chain(q.first()).copied().collect(); + if auto { + p.extend(Shape::dashed_line(&ring, Stroke::new(1.0, dim), 3.0, 3.0)); + } else { + p.add(Shape::line(ring, Stroke::new(if on { 2.0 } else { 1.0 }, line))); + } + if prefs.show_slice_numbers + && let Some(tl) = q.first() + { + let font = egui::FontId::proportional(9.0); + let galley = p.layout_no_wrap(format!("{:02}", a.number), font, Color32::WHITE); + let badge = egui::Rect::from_min_size(*tl + vec2(1.0, 1.0), galley.size() + vec2(6.0, 2.0)); + p.rect_filled(badge, CornerRadius::ZERO, if auto { dim } else { line }); + p.galley(badge.min + vec2(3.0, 1.0), galley, Color32::WHITE); + } + } +} + +/// The print tiling (View → Show Print Tiling, or the Print Tiling tool): each page of the +/// document's print settings as `print.preview` lays it out, its paper edge and its imageable +/// area dashed, numbered at its top left; tiles outside the tile range dimmer. Cached per revision. +pub(super) fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(st) = app.session.active() else { return }; + let key = (st.uid, st.revision); + let pages = match &app.canvas.print_tiling { + Some((k, pages)) if *k == key => pages.clone(), + _ => { + // Settings that can't print have no pages to show. + let pages = std::sync::Arc::new(vectorcraft_engine::cmd::printtiling::pages(&st.doc).unwrap_or_default()); + app.canvas.print_tiling = Some((key, pages.clone())); + pages + } + }; + let font = egui::FontId::proportional(10.0); + let ring = |r: [f64; 4]| { + let q = xf.quad(Rect::new(r[0], r[1], r[2], r[3])); + q.iter().chain(q.first()).copied().collect::>() + }; + for page in pages.iter() { + let color = if page.printed { PRINT_TILING } else { PRINT_TILING.gamma_multiply(0.45) }; + p.add(Shape::line(ring(page.page), Stroke::new(0.75, color))); + let inner = ring(page.imageable); + p.extend(Shape::dashed_line(&inner, Stroke::new(1.0, color), 4.0, 3.0)); + if let Some(corner) = inner.iter().copied().reduce(|a, b| pos2(a.x.min(b.x), a.y.min(b.y))) { + p.text(corner + vec2(3.0, 2.0), egui::Align2::LEFT_TOP, page.number.to_string(), font.clone(), color); + } + } +} + +/// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard. +const PRINT_TILING: Color32 = Color32::from_gray(96); + +/// A Shaper Group's mode widget (Shaper and Selection tools), drawn with selection edges shown or +/// hidden: an arrow pointing right in Face Selection mode and left in Construction mode. +pub(super) fn shaper_widget_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(st) = app.session.active() else { return }; + if matches!(app.session.tool_id(), "shaper" | "selection") + && let Some(w) = vectorcraft_tools::shaperwidget::ShaperWidget::of(&st.doc, &st.selection, st.isolation, xf.zoom) + { + let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::shaperwidget::RADIUS_PX); + let color = c32(st.doc.layer_color(w.group)); + p.circle_filled(c, r, Color32::WHITE); + p.circle_stroke(c, r, Stroke::new(1.0, color)); + let d = if w.construction { -1.0 } else { 1.0 }; + let arrow = vec![c + vec2(3.0 * d, 0.0), c + vec2(-2.0 * d, -3.0), c + vec2(-2.0 * d, 3.0)]; + p.add(Shape::convex_polygon(arrow, color, Stroke::NONE)); + } +} + +/// High-contrast live controls, using the same geometry as the Selection tool's hit testing. +pub(super) fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { + if app.session.tool_id() != "selection" || !app.ui.view.bounding_box { + return; + } + let Some(st) = app.session.active() else { return }; + let Some(axis) = vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, xf.zoom) else { return }; + let t = Tokens::get(p.ctx()); + let ends = axis.ends.map(|q| xf.to_screen(q)); + let center = xf.to_screen(axis.center); + if !center.is_finite() || !ends.iter().all(|p| p.is_finite()) { + return; + } + if let Some((a, b)) = clip_segment(ends[0], ends[1], xf.rect) { + p.line_segment([a, b], Stroke::new(5.0, t.measure_bg)); + p.line_segment([a, b], Stroke::new(2.0, t.selection)); + } + for end in ends { + p.circle_filled(end, vectorcraft_tools::mirror::HANDLE_RADIUS, t.measure_bg); + p.circle_stroke(end, vectorcraft_tools::mirror::HANDLE_RADIUS, Stroke::new(2.0, t.selection)); + } + let handle = egui::Rect::from_center_size(center, vec2(12.0, 12.0)); + p.rect_filled(handle, 2.0, t.selection); + p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside); +} + +/// A selected grid repeat's row, column and corner handles and its spacing sliders (#2691), at +/// the places the Selection tool's hit testing finds them. +pub(super) fn grid_repeat_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { + use vectorcraft_tools::repeat_grid::{GridHandles, GridPart, HANDLE_RADIUS, SLIDER_HALF}; + if app.session.tool_id() != "selection" || !app.ui.view.bounding_box { + return; + } + let frame = app.selection_box(); + let Some(st) = app.session.active() else { return }; + let Some(grid) = GridHandles::of(&st.doc, &st.selection, frame, xf.zoom) else { return }; + let t = Tokens::get(p.ctx()); + let half = f64::from(SLIDER_HALF) / xf.zoom; + for (part, at) in grid.parts() { + let c = xf.to_screen(at); + if !c.is_finite() { + continue; + } + if part.is_slider() { + // A bar across the gap's middle, square to the spacing it sets. + let along = if part == GridPart::HSpacing { vectorcraft_geom::Vec2::new(0.0, half) } else { vectorcraft_geom::Vec2::new(half, 0.0) }; + let ends = [xf.to_screen(at - along), xf.to_screen(at + along)]; + p.line_segment(ends, Stroke::new(6.0, t.measure_bg)); + p.line_segment(ends, Stroke::new(2.5, t.selection)); + } else { + p.circle_filled(c, HANDLE_RADIUS, t.measure_bg); + p.circle_stroke(c, HANDLE_RADIUS, Stroke::new(2.0, t.selection)); + } + } +} + +/// The radius of the Rotate View dial, in screen points. +pub(super) const DIAL_RADIUS: f32 = 40.0; + +/// The dial a Rotate View drag shows, centered on `c`: a tick every 15° (longer every 90°), a +/// needle pointing where the document's top points in a view turned by `rotation` degrees, and the +/// angle under the center, rounded to whole degrees as in the status bar. +pub(super) fn rotate_view_dial(p: &egui::Painter, c: Pos2, rotation: f64, t: &Tokens) { + // `dir(deg)` is the screen direction `deg` degrees clockwise from straight up. + let dir = |deg: f32| vec2(deg.to_radians().sin(), -deg.to_radians().cos()); + p.circle(c, DIAL_RADIUS, t.panel.gamma_multiply(0.85), Stroke::new(1.0, t.border)); + for i in 0..24u8 { + let len = if i % 6 == 0 { 8.0 } else { 4.0 }; + let d = dir(f32::from(i) * 15.0); + p.line_segment([c + d * (DIAL_RADIUS - len), c + d * DIAL_RADIUS], Stroke::new(1.0, t.text_dim)); + } + p.line_segment([c, c + dir(rotation as f32) * DIAL_RADIUS], Stroke::new(2.0, t.accent)); + p.circle_filled(c, 2.5, t.accent); + p.text(c + vec2(0.0, 8.0), egui::Align2::CENTER_TOP, format!("{rotation:.0}°"), egui::FontId::proportional(11.0), t.text); +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs new file mode 100644 index 000000000..f4ca00008 --- /dev/null +++ b/crates/ui-egui/src/canvas/view.rs @@ -0,0 +1,436 @@ +use super::drag::{drag_id, drop_press}; +use super::*; + +/// Fit the view (View → Fit Artboard / Fit All / Actual Size). +pub fn fit(app: &mut VectorcraftApp, how: &str) { + let Some(rect) = app.canvas_rect else { + if let Some(v) = app.view_mut() { + v.fitted = false; + } + return; + }; + let current = app.view().map_or(0, |v| v.artboard); + let Some(st) = app.session.active() else { return }; + let target = match how { + "view.fitAll" => { + st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)) + } + // The navigator's artboard (the first if it has gone). + _ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect), + }; + let Some(target) = target else { return }; + let zoom = if how == "view.actualSize" { + actual_size_zoom(app.session.prefs.display_print_size) + } else { + // The width and height of the box round the target turned with the view, at zoom 1. + let (sn, cs) = app.view().map_or(0.0, |v| v.rotation.to_radians()).sin_cos(); + let (w, h) = (target.width(), target.height()); + let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0); + let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0); + zx.min(zy).clamp(0.0313, 640.0) + }; + let Some(v) = app.view_mut() else { return }; + v.center = target.center(); + v.fitted = true; + v.zoom = zoom; +} + +/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`). +const WHEEL_ZOOM: f64 = 0.01; + +/// How fast a wheel notch's zoom glides in with Zoom with Mouse Wheel: the time constant (s) of +/// the part still to come. +const WHEEL_GLIDE: f64 = 0.05; + +/// What the wheel did across frames ([`wheel`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct WheelTurn { + /// The last wheel turn was an Alt-wheel one. + alt: bool, + /// The zoom (natural log) wheel notches still have to glide in. + pub(super) glide: f64, +} + +/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it). +pub(super) const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0; + +/// Animated Zoom: how far the pointer moves before a press is a drag, in points. +pub(super) const SCRUB_SLOP: f32 = 3.0; + +/// Animated Zoom: how long a press is held still before it zooms on, in seconds… +pub(super) const HOLD_DELAY: f32 = 0.3; + +/// …and how fast it zooms then (as `exp(seconds × HOLD_ZOOM)`: twice as close each second). +pub(super) const HOLD_ZOOM: f64 = std::f64::consts::LN_2; + +/// Performance › Animated Zoom, which needs GPU Performance as in Illustrator: the Zoom tool zooms +/// as it is dragged sideways or held, instead of zooming to the area dragged across. +pub(crate) fn animated_zoom(p: &vectorcraft_engine::Prefs) -> bool { + p.animated_zoom && p.gpu_performance +} + +/// Zoom for View → Actual Size (`view.actualSize`). With Preferences › General › Display Print +/// Size at 100% Zoom off, one document point is one screen point. On, one document inch (72 pt) +/// fills an inch of the screen as the system counts it: 96 screen points, the reference density +/// that display scaling is set against (a CSS inch), whatever the scale factor. +pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 { + if display_print_size { 96.0 / 72.0 } else { 1.0 } +} + +/// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document +/// point under screen point `p` where it is. +pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) { + let before = Xf::new(rect, vm).to_doc(p); + vm.zoom = zoom.clamp(0.0313, 640.0); + vm.center += before - Xf::new(rect, vm).to_doc(p); +} + +/// Turn view `vm` of the canvas `rect` by `degrees` clockwise, keeping the document point under +/// screen point `p` where it is. +pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) { + let before = Xf::new(rect, vm).to_doc(p); + vm.rotation = vectorcraft_geom::normalize_deg(vm.rotation + degrees); + vm.center += before - Xf::new(rect, vm).to_doc(p); +} + +/// Move view `vm`'s content by `(dx, dy)` device pixels along the screen's axes, at `ppp` device +/// pixels a point: the canvas's frame moves by exactly those pixels. +pub(super) fn pan_pixels(vm: &mut View, ppp: f32, (dx, dy): (f64, f64)) { + let (sn, cs) = vm.rotation.to_radians().sin_cos(); + let k = vm.zoom * f64::from(ppp); + vm.center -= vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / k, (-dx * sn + dy * cs) / k); +} + +/// Screen points `d` in the nearest whole device pixels, at `ppp` device pixels a point. +pub(super) fn whole_pixels(d: egui::Vec2, ppp: f32) -> (f64, f64) { + ((f64::from(d.x) * f64::from(ppp)).round(), (f64::from(d.y) * f64::from(ppp)).round()) +} + +/// What the scrolls of the view have left to move ([`scroll_view`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct ScrollRest { + /// Device pixels along the screen's axes, at most half a pixel each. + pub(super) x: f64, + pub(super) y: f64, + /// The document, the view's center, zoom and rotation, and the device pixels a point that the + /// last scroll left. + after: Option<(u64, Point, f64, f64, f32)>, +} + +/// Scroll the active view so that its content moves `by` screen points (the wheel, a trackpad, a +/// touch screen, a scroll bar), in whole device pixels: the part of a pixel left over is added to +/// the next scroll. It is dropped when the view changed in between (a zoom, a turn, Fit, a pan +/// of another kind, another document) or the device pixels a point did. +pub(super) fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) { + let ppp = ui.ctx().pixels_per_point(); + let Some(doc) = app.session.active().map(|st| st.uid) else { return }; + let Some(vm) = app.view_mut() else { return }; + let id = egui::Id::unique("canvas-scroll-rest"); + let rest = scroll_by(vm, doc, ppp, by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); + ui.data_mut(|d| d.insert_temp(id, rest)); +} + +/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point, after the +/// scrolls that left `rest`: what this scroll leaves. +pub(super) fn scroll_by(vm: &mut View, doc: u64, ppp: f32, by: egui::Vec2, rest: ScrollRest) -> ScrollRest { + let (x, y) = if rest.after == Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) { (rest.x, rest.y) } else { (0.0, 0.0) }; + let (x, y) = (x + f64::from(by.x) * f64::from(ppp), y + f64::from(by.y) * f64::from(ppp)); + let (dx, dy) = (x.round(), y.round()); + pan_pixels(vm, ppp, (dx, dy)); + ScrollRest { x: x - dx, y: y - dy, after: Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) } +} + +/// How far a trackpad rotation turns before it turns the view, in degrees. +const ROTATE_DEAD_ZONE: f64 = 5.0; + +/// How long after its last rotation event a trackpad rotation ends, in seconds. +const ROTATE_GESTURE_END: f64 = 0.25; + +/// A trackpad rotation under way ([`trackpad_turn`]). +#[derive(Clone, Copy, Debug)] +struct TrackpadTurn { + /// Degrees it has turned so far, clockwise. + total: f64, + /// When its last rotation event came (egui's input time, in seconds). + last: f64, + /// It has reached [`ROTATE_DEAD_ZONE`] and turns the view. + turning: bool, +} + +/// General › Trackpad Gesture to Rotate View (`on`): the degrees, clockwise, that a two-finger +/// rotation on a Mac trackpad turns the view this frame. egui sends the rotation as +/// [`egui::Event::Rotate`], from winit on macOS and from Safari's gesture events in the web build. +/// A rotation does not turn the view until it reaches [`ROTATE_DEAD_ZONE`]; it then turns the view +/// by all of it, and by each later event as it comes. It ends [`ROTATE_GESTURE_END`] after its last +/// event. Rotation events that come while fingers are on a touch screen do not turn the view. +pub(super) fn trackpad_turn(ui: &Ui, on: bool) -> Option { + if !on { + return None; + } + let (deg, now) = ui.input(|i| { + let rad: f32 = + if i.multi_touch().is_some() { 0.0 } else { i.events.iter().map(|e| if let egui::Event::Rotate(r) = e { *r } else { 0.0 }).sum() }; + (f64::from(rad).to_degrees(), i.time) + }); + if deg == 0.0 || !deg.is_finite() { + return None; + } + let id = egui::Id::unique("canvas-trackpad-turn"); + let kept: Option = ui.data(|d| d.get_temp(id)); + let mut t = kept.filter(|t| now - t.last <= ROTATE_GESTURE_END).unwrap_or(TrackpadTurn { total: 0.0, last: now, turning: false }); + t.total += deg; + t.last = now; + let turn = if t.turning { + Some(deg) + } else if t.total.abs() >= ROTATE_DEAD_ZONE { + t.turning = true; + Some(t.total) + } else { + None + }; + ui.data_mut(|d| d.insert_temp(id, t)); + turn +} + +/// One scroll bar along its axis, in screen points: the span it scrolls over (`lo..hi`) and the +/// part of it the canvas shows (`v0..v1`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Span { + pub(super) lo: f32, + pub(super) hi: f32, + pub(super) v0: f32, + pub(super) v1: f32, +} + +impl Span { + /// The thumb on a track `len` long: where it starts and how long it is. + pub(super) fn thumb(self, len: f32) -> (f32, f32) { + let total = (self.hi - self.lo).max(1.0); + let size = (len * (self.v1 - self.v0) / total).clamp(MIN_THUMB.min(len), len); + let room = total - (self.v1 - self.v0); + let at = if room > 0.0 { (self.v0 - self.lo) / room * (len - size) } else { 0.0 }; + (at.clamp(0.0, (len - size).max(0.0)), size) + } + /// Screen points the view moves for each point the thumb moves on a track `len` long. + pub(super) fn ratio(self, len: f32) -> f32 { + let room = (self.hi - self.lo) - (self.v1 - self.v0); + let free = len - self.thumb(len).1; + if room > 0.0 && free > 0.0 { room / free } else { 0.0 } + } +} + +const MIN_THUMB: f32 = 20.0; + +/// The scroll bars' spans, horizontal then vertical: the art and the artboards (`content`) as the +/// view turns them on screen, half a window past each side, and the view itself, so the thumb is +/// always on its track. The canvas has no edge of its own (no drawing area), so the content sets it. +pub(super) fn scroll_spans(xf: &Xf, content: Option) -> [Span; 2] { + let r = xf.rect; + let around = content.map(|c| egui::Rect::from_points(&xf.quad(c)).expand2(r.size() / 2.0)).filter(|b| b.is_finite()); + let b = around.map_or(r, |b| b.union(r)); + [Span { lo: b.left(), hi: b.right(), v0: r.left(), v1: r.right() }, Span { lo: b.top(), hi: b.bottom(), v0: r.top(), v1: r.bottom() }] +} + +/// The tracks of the horizontal and the vertical scroll bar, along the bottom and the right edge +/// of the window (`outer`), as long as the canvas (`rect`) beside them. +pub(super) fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; 2] { + let corner = outer.max - vec2(SCROLL_BAR, SCROLL_BAR); + [ + egui::Rect::from_min_max(pos2(rect.left(), corner.y), pos2(corner.x, outer.bottom())), + egui::Rect::from_min_max(pos2(corner.x, rect.top()), pos2(outer.right(), corner.y)), + ] +} + +/// The window's scroll bars ([`scroll_spans`]): a thumb dragged scrolls the view along the +/// screen's axis (a turned view too), a click on the track pages towards it, and the wheel over a +/// bar scrolls along it. +pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) { + let content = app.content_bounds(); + let Some(v) = app.view().copied() else { return }; + let xf = Xf::new(rect, &v); + let painter = ui.painter(); + painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler); + let hover = ui.input(|i| i.pointer.hover_pos()); + // Screen points the view moves this frame, or the center a dragged thumb's drag began at and + // the screen points it has moved the view since. + let mut moved = egui::Vec2::ZERO; + let mut center = None; + for (k, (track, span)) in scroll_tracks(rect, outer).into_iter().zip(scroll_spans(&xf, content)).enumerate() { + let horizontal = k == 0; + let along = |p: Pos2| if horizontal { p.x - track.left() } else { p.y - track.top() }; + let axis = if horizontal { vec2(1.0, 0.0) } else { vec2(0.0, 1.0) }; + let len = if horizontal { track.width() } else { track.height() }; + let (at, size) = span.thumb(len); + let on_thumb = |p: Pos2| (at..=at + size).contains(&along(p)); + let id = egui::Id::unique(("canvas-scroll-bar", k)); + let resp = ui.interact(track, id, Sense::click_and_drag()); + if resp.drag_started() + && let Some(p) = ui.input(|i| i.pointer.press_origin()) + && on_thumb(p) + { + // The span moves with the view: the drag keeps the one it started with. + let d = ThumbDrag { from: along(p), center: v.center, ratio: span.ratio(len), min: span.lo - span.v0, max: span.hi - span.v1 }; + ui.data_mut(|dd| dd.insert_temp(id, d)); + } + let drag: Option = ui.data(|dd| dd.get_temp(id)); + if let Some(d) = drag { + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + let by = ((along(p) - d.from) * d.ratio).clamp(d.min, d.max); + center = Some((d.center, axis * by)); + } + if !resp.dragged() || resp.drag_stopped() { + ui.data_mut(|dd| dd.remove::(id)); + } + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + // A page is most of the window, and never past the end of the span. + let page = 0.9 * (span.v1 - span.v0); + if along(p) < at { + moved -= axis * page.min(span.v0 - span.lo); + } else if along(p) > at + size { + moved += axis * page.min(span.hi - span.v1); + } + } + if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta); + moved -= axis * if horizontal { d.x + d.y } else { d.y }; + } + painter.rect_filled(track, 0.0, t.ruler); + let edge = if horizontal { [track.left_top(), track.right_top()] } else { [track.left_top(), track.left_bottom()] }; + painter.line_segment(edge, Stroke::new(1.0, t.border)); + let thumb = if horizontal { + egui::Rect::from_min_size(pos2(track.left() + at, track.top()), vec2(size, track.height())).shrink2(vec2(1.0, 3.0)) + } else { + egui::Rect::from_min_size(pos2(track.left(), track.top() + at), vec2(track.width(), size)).shrink2(vec2(3.0, 1.0)) + }; + let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p)); + painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled }); + } + let ppp = ui.ctx().pixels_per_point(); + if let Some((c, by)) = center + && let Some(vm) = app.view_mut() + { + vm.center = c; + pan_pixels(vm, ppp, whole_pixels(-by, ppp)); + } + if moved != egui::Vec2::ZERO { + scroll_view(app, ui, -moved); + } +} + +/// A scroll bar thumb being dragged: where along the track it was pressed, and the view's centre, +/// the span's travel ratio ([`Span::ratio`]) and how far the view could move either way then. +#[derive(Clone, Copy, Debug)] +struct ThumbDrag { + from: f32, + center: Point, + ratio: f32, + min: f32, + max: f32, +} + +/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: +/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option +/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel +/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points +/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom +/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once. +pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) { + // Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead). + let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta); + if !wheel_zooms { + // egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few + // frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go. + if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) { + turn.alt = m.alt && !m.command; + } + let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); + return if turn.alt && scroll != egui::Vec2::ZERO { + (zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan) + } else { + (zoom, scroll + pan) + }; + } + // The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom. + let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta)); + let mut scroll = pan; + for e in &i.events { + match e { + egui::Event::MouseWheel { unit, delta, modifiers, .. } => { + let d = match unit { + egui::MouseWheelUnit::Point => *delta, + egui::MouseWheelUnit::Line => *delta * line, + egui::MouseWheelUnit::Page => *delta * page, + }; + if modifiers.command { + scroll.x += d.x + d.y; + } else if modifiers.shift { + scroll.y += d.x + d.y; + } else if *unit == egui::MouseWheelUnit::Point { + zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp(); + scroll.x += d.x; + } else { + turn.glide += f64::from(d.y) * WHEEL_ZOOM; + scroll.x += d.x; + } + } + egui::Event::Zoom(f) => zoom *= f64::from(*f), + _ => {} + } + } + // This frame's part of the glide: what is left shrinks by e every WHEEL_GLIDE seconds. + let part = if turn.glide.abs() < 1e-3 { 1.0 } else { 1.0 - (-f64::from(i.stable_dt.min(0.1)) / WHEEL_GLIDE).exp() }; + let step = turn.glide * part; + turn.glide -= step; + (zoom * step.exp(), scroll) +} + +/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second +/// finger drops the press the first one began (its tool interaction rolled back; the rest of the +/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes. +pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) { + if !app.session.prefs.touch_gestures { + return; + } + let (touches, now): (Vec<_>, f64) = ui.input(|i| { + let t = i.events.iter().filter_map(|e| match e { + egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)), + _ => None, + }); + (t.collect(), i.time) + }); + if touches.is_empty() { + return; + } + let key = egui::Id::unique("canvas-touch"); + let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default(); + let mut tap = None; + for (id, phase, pos) in touches { + // A gesture begins on the canvas. + if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) { + continue; + } + // A cancelled contact (a palm the shell drops when a pen lands) keeps nothing it began. + if phase == egui::TouchPhase::Cancel { + drop_press(app, ui, view); + } + match taps.feed(id, phase, pos, now) { + Some(crate::touch::Touch::Fingers) => { + drop_press(app, ui, view); + ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); + } + Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n), + None => {} + } + } + ui.data_mut(|d| d.insert_temp(key, taps)); + // In Presentation Mode a tap of two or three fingers undoes or redoes nothing. + if let Some(cmd) = tap + && app.ui.screen_mode < 3 + { + crate::menus::invoke(app, cmd, serde_json::json!({})); + } +} From bd1559b8fea59464f6ecea25191af4ff9345cea6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:50 +0000 Subject: [PATCH 139/199] refactor(ui-egui): move chrome.rs items to chrome/{control_bar,hints,status_bar,tabs}.rs --- crates/ui-egui/src/chrome.rs | 1051 +--------------------- crates/ui-egui/src/chrome/control_bar.rs | 282 ++++++ crates/ui-egui/src/chrome/hints.rs | 254 ++++++ crates/ui-egui/src/chrome/status_bar.rs | 232 +++++ crates/ui-egui/src/chrome/tabs.rs | 271 ++++++ 5 files changed, 1057 insertions(+), 1033 deletions(-) create mode 100644 crates/ui-egui/src/chrome/control_bar.rs create mode 100644 crates/ui-egui/src/chrome/hints.rs create mode 100644 crates/ui-egui/src/chrome/status_bar.rs create mode 100644 crates/ui-egui/src/chrome/tabs.rs diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 3a51ee4f4..2000aaacb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -13,6 +13,24 @@ use crate::theme::{self, Tokens}; use crate::widgets; use crate::{VectorcraftApp, icons, menus, titlebar}; +mod control_bar; +mod hints; +mod status_bar; +mod tabs; + +#[cfg(test)] +use control_bar::ISOLATION_BUTTON; +pub use control_bar::{AnchorControls, anchor_buttons, anchor_controls, control_bar}; +#[cfg(test)] +use hints::hint_segments; +pub use hints::{hint_bar, hint_strings}; +pub use status_bar::status_bar; +#[cfg(test)] +use status_bar::{StatusReadoutWidths, status_readouts}; +#[cfg(test)] +use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_title}; +pub use tabs::{doc_tabs, isolation_bar}; + /// The application bar's height, in points (compact, as in PhotoCraft). pub const APP_BAR_HEIGHT: f32 = 32.0; /// The brand mark's side. @@ -142,1039 +160,6 @@ pub fn sync_window_title(app: &mut VectorcraftApp, ctx: &egui::Context) { } } -/// An anchor button: icon, tooltip, command and the convert command's `to`. -type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>); - -/// The anchor controls the Control bar and the Properties panel show. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum AnchorControls { - None, - /// Paths selected as a whole with a tool that edits anchors (Direct Selection, the Pen…), - /// which shows all their anchors selected: "Convert:" only, as removing or cutting at every - /// anchor isn't what they're for. - Convert, - /// Anchors direct-selected: "Convert:" and "Anchors:". - All, -} - -/// Which anchor controls show for the selection and the active tool. -pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls { - let Some(st) = app.session.active() else { - return AnchorControls::None; - }; - if !st.selection.anchors.is_empty() { - return AnchorControls::All; - } - let paths = st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }))); - if paths && vectorcraft_tools::catalog::edits_anchors(app.session.tool_id()) { AnchorControls::Convert } else { AnchorControls::None } -} - -/// What the Control bar and the Properties panel offer for selected anchors ([`anchor_controls`]): -/// "Convert:" corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a -/// row of its own: inline in the Control bar, one under the other in a panel. -pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorControls) { - let t = Tokens::get(ui.ctx()); - let mut run = None; - let groups: [(&str, &[AnchorButton]); 2] = [ - ( - tl!("Convert:"), - &[ - ("dc-anchor", tl!("Convert Selected Anchor Points to Corner"), "path.convertAnchors", Some("corner")), - ("dc-anchor-smooth", tl!("Convert Selected Anchor Points to Smooth"), "path.convertAnchors", Some("smooth")), - ], - ), - ( - tl!("Anchors:"), - &[ - ("pen-tool-delete", tl!("Remove Anchor Points"), "path.removeAnchors", None), - ("dc-join", tl!("Connect Selected End Points"), "path.join", None), - ("scissors", tl!("Cut Path at Selected Anchor Points"), "path.cutAtAnchors", None), - ], - ), - ]; - let shown = match controls { - AnchorControls::None => 0, - AnchorControls::Convert => 1, - AnchorControls::All => groups.len(), - }; - for (label, buttons) in groups.into_iter().take(shown) { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(label).size(12.0).color(t.text)); - for &(icon, tip, id, to) in buttons { - if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() { - run = Some((id, to.map_or_else(|| json!({}), |to| json!({ "to": to })))); - } - } - }); - } - if let Some((id, p)) = run { - crate::menus::invoke(app, id, p); - } -} - -/// The id of the Control bar's isolation button. -const ISOLATION_BUTTON: &str = "cb-isolation"; - -/// The room the Control bar needs for the Snapping menu next to the Artboard tool's controls; with -/// less, the menu is left out so those controls still fit the narrowest window. -const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0; - -/// The Control bar's snapping quick access; every change uses its existing command. -fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { - ui.menu_button(tl!("Snapping"), |ui| { - for (label, cmd, on) in [ - (tl!("Smart Guides"), "view.smartGuides", app.ui.view.smart_guides), - (tl!("Snap to Point"), "view.snapToPoint", app.ui.view.snap_to_point), - (tl!("Snap to Grid"), "view.snapToGrid", app.ui.view.snap_to_grid), - (tl!("Snap to Pixel"), "view.snapToPixel", app.ui.view.snap_to_pixel), - ] { - if widgets::check(ui, label, on, true) { - crate::menus::invoke(app, cmd, json!({})); - } - } - ui.separator(); - for (key, label, on) in [ - ("snapTangent", tl!("Snap to Tangent"), app.session.prefs.snap_tangent), - ("snapPerpendicular", tl!("Snap to Perpendicular"), app.session.prefs.snap_perpendicular), - ] { - if widgets::check(ui, label, on, true) { - crate::menus::invoke(app, "prefs.set", json!({"key": key, "value": !on})); - } - } - }); -} - -/// The Control bar's isolation button. In isolation mode it is Exit Isolation Mode, except while a -/// pattern is edited (the isolation bar's Done and Cancel end that). Otherwise, with one object -/// selected that [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which -/// also isolates a path inside a group that Direct Selection selected. An envelope's contents -/// get no button: the envelope's Edit Envelope and Edit Contents buttons show instead. -fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) { - let Some(st) = app.session.active() else { return }; - let (icon, tip, id) = if st.isolation.is_some() { - if st.doc.pattern_edit.is_some() { - return; - } - ("chevron-left", tl!("Exit Isolation Mode"), "object.exitIsolation") - } else if let [one] = st.selection.objects[..] - && st.doc.node(one).is_some_and(|n| vectorcraft_tools::select::isolates(&n.kind)) - && vectorcraft_doc::live::envelope_of(&st.doc, one).is_none() - { - ("square-dashed", tl!("Isolate Selected Object"), "object.isolate") - } else { - return; - }; - let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); - let resp = ui.interact(rect, egui::Id::unique(ISOLATION_BUTTON), Sense::click()); - if widgets::paint_icon_button(ui, resp, icon, tip, false).clicked() { - crate::menus::invoke(app, id, json!({})); - } -} - -/// The room the Control bar's inline X/Y/W/H fields need; with less, only the Transform link shows. -const INLINE_TRANSFORM_WIDTH: f32 = 440.0; - -/// The Control bar (Window → Control), context-sensitive like Illustrator's. -pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - egui::Panel::top("control_bar") - .exact_size(34.0) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - ui.horizontal_centered(|ui| { - // Gripper - let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover()); - for i in 0..5 { - ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); - } - if app.session.active().is_none() { - ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); - return; - } - // The Artboard tool's controls fill the narrowest window, so they keep it to themselves there. - if app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH { - snapping_menu(app, ui); - } - let Some(st) = app.session.active() else { return }; - let sel = st.selection.objects.clone(); - let units = app.session.general_unit(); - let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); - let anchors = anchor_controls(app); - let label = match &first { - _ if app.session.tool_id() == "artboard" => tl!("Artboard"), - Some(_) if sel.len() == 1 && anchors != AnchorControls::None => { - tl!("Anchor Point") - } - Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => { - if im.link.is_some() { - tl!("Linked File") - } else { - tl!("Embedded") - } - } - Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => { - tl!("Image Tracing") - } - _ => tl!(crate::panels::appearance::object_label(app)), - }; - ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); - // With the Artboard tool in use, the bar shows the active artboard's controls in place - // of the object controls. - if crate::panels::artboards::control_bar(app, ui) { - return; - } - if anchors != AnchorControls::None { - anchor_buttons(app, ui, anchors); - } - isolation_button(app, ui); - ui.add_space(6.0); - // An image or an Image Trace object shows its own controls in place of Fill and Stroke. - let image = crate::place::control_bar_details(app, ui) || crate::panels::image_trace::control_bar(app, ui); - crate::toolbar::control_bar_options(app, ui); - crate::dialogs::envelope::control_bar(app, ui); - let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; - if !image { - let shown_stroke = crate::panels::current_stroke(app); - let mixed = crate::panels::stroke_mixed(app, ui.ctx()); - let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); - crate::panels::paint_chip(app, ui, false, 22.0, true); - crate::panels::paint_chip(app, ui, true, 22.0, true); - // The link opens the Stroke panel as a popover under it; then the weight spinner - // (with presets) and the width profile. - stroke_panel::link(app, ui, tl!("Stroke:")); - stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0); - if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) { - app.run("stroke.set", json!({"profile": id})).ok(); - } - ui.add_space(4.0); - ui.separator(); - } - if ui.link(egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() { - app.ui.open_panel = Some("transparency".into()); - } - if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0) - && !sel.is_empty() - { - app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); - } - if !sel.is_empty() { - // Style picker: the selection's graphic style; its menu applies another. - ui.add_space(4.0); - if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() { - app.ui.open_panel = Some("graphicStyles".into()); - } - let resp = widgets::chip_button_named(ui, 22.0, true, tl!("Graphic Style"), |ui, r| { - crate::panels::graphic_styles::paint_linked(app, ui, r) - }); - egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); - // Recolor Artwork, when the selection has colours to recolour. - let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects)); - if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 24.0).clicked() { - crate::menus::invoke(app, "ui.recolorDialog", json!({})); - } - } - ui.separator(); - crate::panels::character::control_bar(app, ui); - if sel.is_empty() { - if widgets::flat_button(ui, tl!("Document Setup"), 112.0).clicked() { - app.run("file.documentSetup", json!({})).ok(); - } - if widgets::flat_button(ui, tl!("Preferences"), 90.0).clicked() { - app.run("edit.preferences", json!({})).ok(); - } - return; - } - crate::panels::align::align_buttons(app, ui, 24.0); - ui.separator(); - // The Transform link opens the whole Transform panel (reference point, rotate, - // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. - crate::panels::transform::link(app, ui); - // The bounding box, rotated with rotated objects: its centre and its own sides. - if let Some(b) = app.selection_box() - && ui.available_width() >= INLINE_TRANSFORM_WIDTH - { - let c = b.center(); - let origin = app.ruler_origin(); - let link = crate::panels::transform::proportional(app, ui); - let copy = crate::panels::transform::alt_enter_copies(app, ui); - for (k, lbl, v, offset) in [ - ("x", "X:", c.x, origin.x), - ("y", "Y:", c.y, origin.y), - ("width", "W:", b.rect.width(), 0.0), - ("height", "H:", b.rect.height(), 0.0), - ] { - // The W/H link sits between W and H. - if k == "height" { - crate::panels::transform::constrain_link(app, ui); - } - widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim)); - let (id, shown) = (("cb", k), Some(v - offset)); - let set = if matches!(k, "width" | "height") { - widgets::size_field(ui, id, shown, units, 80.0) - } else { - widgets::num_field(ui, id, shown, units, 80.0) - }; - if let Some(nv) = set { - app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link, "copy": copy})).ok(); - } - } - } - }); - }); -} - -/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited -/// "Name* @ 66.67 % (/Opacity Mask)". -fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool, display_print_size: bool) -> String { - let mode = if d.doc.mask_edit.is_some() { - format!("<{0}>/{0}", tl!("Opacity Mask")) - } else { - let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" }; - format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") }) - }; - format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %")) -} - -/// Whether a document tab's × comes before its title (macOS) rather than after it (Windows, -/// Linux and the web). -const CLOSE_BEFORE_TITLE: bool = cfg!(target_os = "macos"); - -/// The first of tabs `widths` wide that the strip shows in `room`: the first one, or a later one so -/// that the `active` tab fits. -fn first_tab_shown(widths: &[f32], active: Option, room: f32) -> usize { - let Some(a) = active else { return 0 }; - let mut first = 0; - while first < a && widths.get(first..=a).map_or(0.0, |w| w.iter().sum::()) > room { - first += 1; - } - first -} - -/// Document tab strip: "Name* @ 66.67% (RGB/Preview)". User Interface › Large Tabs makes the tabs -/// taller, with larger titles. Tabs that don't fit are reached from a » button at the strip's -/// right end, which lists every open document, and the active tab is always in view (#746). -pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { - /// The width of the » button. - const MORE: f32 = 30.0; - let t = Tokens::get(ui.ctx()); - let (height, title_size) = if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) }; - let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.tab_strip); - ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); - // On the Home screen no tab is the current one. - let active = app.session.active_index().filter(|_| app.ui.home.is_none()); - let tabs: Vec<(String, std::sync::Arc)> = app - .session - .documents() - .iter() - .enumerate() - .map(|(i, d)| { - let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0); - let title = tab_title(d, zoom, app.ui.view.outline, app.session.prefs.display_print_size); - let color = if Some(i) == active { t.text_strong } else { t.text_dim }; - (title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color)) - }) - .collect(); - let widths: Vec = tabs.iter().map(|(_, g)| g.size().x + 50.0).collect(); - let overflow = widths.iter().sum::() > strip.width(); - let room = strip.width() - if overflow { MORE } else { 0.0 }; - let first = if overflow { first_tab_shown(&widths, active, room) } else { 0 }; - let mut x = strip.left(); - let mut activate = None; - let mut close = None; - let mut hovering_art = false; - for (i, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) { - if x + w > strip.left() + room && Some(i) != active { - break; - } - let is_active = Some(i) == active; - let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(*w, strip.height() - 1.0)); - let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click()); - if is_active { - ui.painter().rect_filled(r, 0.0, t.panel); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4)); - } - ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.5, t.border)); - // The × where each platform puts a tab's: before the title on macOS, after it elsewhere - // (#673). - let (x_center, title_left) = if CLOSE_BEFORE_TITLE { (r.left() + 16.0, r.left() + 32.0) } else { (r.right() - 16.0, r.left() + 14.0) }; - let xr = egui::Rect::from_center_size(egui::pos2(x_center, r.center().y), vec2(12.0, 12.0)); - let xresp = ui.interact(xr.expand(3.0), ui.scope_id().with(("tabx", i)), Sense::click()); - icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text }); - ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text); - hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0)); - if xresp.clicked() { - close = Some(i); - } else if resp.clicked() { - activate = Some(i); - } - x += w; - } - crate::document_drag::finish_tabs(ui.ctx(), hovering_art); - // The » button: every open document, the active one checked. The list scrolls once it - // reaches the bottom of the window, so every document of a large session stays reachable - // (#4017). - if overflow { - let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0)); - let resp = ui.interact(more, ui.scope_id().with("tabs-more"), Sense::click()); - ui.painter().rect_filled(more, 0.0, if resp.hovered() { t.hover.gamma_multiply(0.4) } else { t.tab_strip }); - icons::paint(ui, "chevrons-right", egui::Rect::from_center_size(more.center(), vec2(14.0, 14.0)), t.text); - let resp = resp.on_hover_text(tl!("Show all open documents")); - egui::Popup::menu(&resp).show(|ui| { - widgets::menu_scroll(ui, |ui| { - for (i, (title, _)) in tabs.iter().enumerate() { - if widgets::menu_item_name(ui, title, true, Some(i) == active) { - activate = Some(i); - ui.close(); - } - } - }); - }); - } - if let Some(i) = close { - if let Err(e) = crate::unsaved::close(app, i) { - app.status(e); - } - } else if let Some(i) = activate { - app.ui.home = None; - let changed = app.session.active_index() != Some(i); - if app.session.set_active(i) - && changed - && app.session.tool_id() == "artboard" - && let Some(n) = app.session.active().map(|d| d.doc.artboards.len()) - { - // A document switch recreates the tool; restore the displayed board as its target. - // Clicking the current tab keeps its existing multiselection and tool options. - let selected = crate::panels::artboards::selected(app, n); - crate::panels::artboards::select(app, selected); - } - } -} - -/// The isolation mode breadcrumb bar. It is drawn over the top of `area` (the canvas), after the -/// canvas, so entering isolation mode does not change the canvas's size (#3126). -pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { - let t = Tokens::get(ui.ctx()); - if let Some(st) = app.session.active() - && let Some(iso) = st.isolation - { - let mut crumbs: Vec = - st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect(); - // Pattern editing mode: the pattern's name, and Save a Copy / Done / Cancel at the right. - let pattern_edit = st.doc.pattern_edit.as_ref().map(|e| e.pattern.clone()); - if let Some(pn) = &pattern_edit { - crumbs.push(pn.clone()); - } - let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0)); - ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::click_and_drag()); - ui.painter().rect_filled(bar, 0.0, t.panel); - let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0)); - let bresp = ui.interact(back, ui.scope_id().with("iso-back"), Sense::click()); - icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon }); - ui.painter().text( - bar.left_center() + vec2(30.0, 0.0), - egui::Align2::LEFT_CENTER, - crumbs.join(" › "), - egui::FontId::proportional(12.0), - t.text, - ); - let mut pattern_cmd = None; - if pattern_edit.is_some() { - let mut x = bar.right() - 6.0; - for (label, cmd) in [("Cancel", "object.pattern.cancel"), ("Done", "object.pattern.done"), ("Save a Copy", "object.pattern.saveCopy")] { - let label = tl!(label); - let w = 12.0 + 7.0 * label.len() as f32; - let r = egui::Rect::from_min_max(egui::pos2(x - w, bar.top() + 3.0), egui::pos2(x, bar.bottom() - 3.0)); - let resp = ui.interact(r, ui.scope_id().with(("pat-bar", cmd)), Sense::click()); - ui.painter().rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.panel }); - ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); - ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), t.text); - if resp.clicked() { - pattern_cmd = Some(cmd); - } - x -= w + 6.0; - } - } - if bresp.clicked() { - pattern_cmd = pattern_cmd.or(pattern_edit.as_ref().map(|_| "object.pattern.done")); - if pattern_cmd.is_none() { - app.run("object.exitIsolation", json!({})).ok(); - } - } - if let Some(cmd) = pattern_cmd { - app.run(cmd, json!({})).ok(); - } - } -} - -/// Draws the artboard navigator's field, which shows the active artboard (`cur`) by number and -/// name. The field's list shows every artboard by number and name, as the Artboards panel lists -/// them, and opens with the active artboard's row in view. The list lays out only the rows in view -/// and at most one more. Returns the artboard chosen in the list. -fn artboard_list(app: &VectorcraftApp, ui: &mut Ui, cur: usize) -> Option { - /// The width of the field. - const FIELD_W: f32 = 140.0; - /// The least width of the list. - const LIST_W: f32 = 220.0; - /// The height of a row, as in the Artboards panel. - const ROW_H: f32 = 24.0; - /// The rows the list shows at once. - const ROWS: f32 = 10.0; - let t = Tokens::get(ui.ctx()); - let boards = app.session.active().map(|d| d.doc.artboards.as_slice()).unwrap_or_default(); - let shown = boards.get(cur).map_or_else(|| "–".to_string(), |a| format!("{} {}", cur + 1, a.name)); - // Under `drawn`, egui's temp data stores the pass the list was last drawn in. - let drawn = ui.scope_id().with("artboard-list"); - let height = ROW_H * ROWS; - ui.add_enabled_ui(!boards.is_empty(), |ui| { - ui.set_max_width(FIELD_W); - ui.name_inputs(tl!("Artboard"), |ui| { - egui::ComboBox::from_id_salt("artboard-list") - .selected_text(egui::RichText::new(shown).size(11.5)) - .width(FIELD_W) - .wrap_mode(egui::TextWrapMode::Truncate) - .height(height) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show_ui(ui, |ui| { - let pass = ui.ctx().cumulative_pass_nr(); - let last = ui.data(|d| d.get_temp::(drawn)).unwrap_or_default(); - ui.data_mut(|d| d.insert_temp(drawn, pass)); - let mut area = egui::ScrollArea::vertical().max_height(height); - // As the list opens, it scrolls the active row as close to the middle as the list allows. - if last + 1 < pass || last == 0 { - area = area.vertical_scroll_offset((cur as f32 * ROW_H - (height - ROW_H) * 0.5).max(0.0)); - } - // Every number ends where the widest one ends. - let numbers = ui.painter().layout_no_wrap(boards.len().to_string(), egui::FontId::proportional(12.0), t.text_dim).size().x; - let width = ui.available_width().max(LIST_W); - let mut chosen = None; - ui.spacing_mut().item_spacing.y = 0.0; - area.show_rows(ui, ROW_H, boards.len(), |ui, range| { - for i in range { - let Some(board) = boards.get(i) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::click()); - if i == cur { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - let number_end = r.left() + 8.0 + numbers; - ui.painter().text( - egui::pos2(number_end, r.center().y), - egui::Align2::RIGHT_CENTER, - (i + 1).to_string(), - egui::FontId::proportional(12.0), - t.text_dim, - ); - let name_start = number_end + 10.0; - let name = egui::WidgetText::from(egui::RichText::new(&board.name).size(12.5)).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - r.right() - 8.0 - name_start, - egui::TextStyle::Body, - ); - ui.painter().galley(egui::pos2(name_start, r.center().y - name.size().y / 2.0), name, t.text); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == cur, format!("{} {}", i + 1, board.name))); - if resp.clicked() { - chosen = Some(i); - } - } - }); - if chosen.is_some() { - ui.close(); - } - chosen - }) - .inner - .flatten() - }) - }) - .inner -} - -/// Draw the status message, bounded readouts and artboard navigation controls. -pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - egui::Panel::bottom("status_bar") - .exact_size(24.0) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - ui.horizontal_centered(|ui| { - let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0); - let print = app.session.prefs.display_print_size; - let shown = crate::state::zoom_display_percent(zoom, print); - ui.name_inputs(tl!("Zoom"), |ui| { - egui::ComboBox::from_id_salt("zoom-combo") - .selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5)) - .width(76.0) - .show_ui(ui, |ui| { - for z in ZOOM_STOPS.iter().rev() { - if ui - .selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print)) - .clicked() - { - app.run("view.setZoom", json!({"zoom": z})).ok(); - } - } - ui.separator(); - if ui.selectable_label(false, tl!("Fit on Screen")).clicked() { - app.run("view.fitArtboard", json!({})).ok(); - } - if ui.selectable_label(false, tl!("Fit All")).clicked() { - app.run("view.fitAll", json!({})).ok(); - } - }); - }); - let rot = app.view().map(|v| v.rotation).unwrap_or(0.0); - ui.name_inputs(tl!("Rotation"), |ui| { - egui::ComboBox::from_id_salt("rot-combo") - .selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)) - .width(52.0) - .show_ui(ui, |ui| { - for (label, a) in ROTATE_VIEW_ANGLES { - if ui.selectable_label((rot - a).abs() < 1e-9, label).clicked() { - // It fails only without a document, when there is no view to turn. - app.run("view.rotate", json!({"angle": a})).ok(); - } - } - }); - }); - ui.separator(); - // The artboard navigator has First and Previous, a field with the current artboard's - // number and name and a list of every artboard, then Next and Last. - let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); - let cur = crate::panels::artboards::selected(app, nab); - let mut go = None; - for (icon, to, tip) in [("chevrons-left", "first", tl!("First Artboard")), ("chevron-left", "previous", tl!("Previous Artboard"))] { - if widgets::icon_button_enabled(ui, icon, tip, false, cur > 0, 18.0).clicked() { - go = Some(json!(to)); - } - } - if let Some(i) = artboard_list(app, ui, cur) { - go = Some(json!(i)); - } - for (icon, to, tip) in [("chevron-right", "next", tl!("Next Artboard")), ("chevrons-right", "last", tl!("Last Artboard"))] { - if widgets::icon_button_enabled(ui, icon, tip, false, cur + 1 < nab, 18.0).clicked() { - go = Some(json!(to)); - } - } - if let Some(to) = go - && let Err(e) = app.run("view.goToArtboard", json!({ "index": to })) - { - app.status(e); - } - ui.separator(); - let tool = vectorcraft_tools::tool_info(app.session.tool_id()) - .map(|t| { - // The catalog keys carry the full label ("Selection Tool"); only English drops the suffix. - let shown = tl!(t.label); - if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } - }) - .unwrap_or(""); - ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); - status_readouts(app, ui, &t); - }); - }); -} - -/// The most recently measured status widths, scoped to this UI and display settings. -struct StatusReadoutWidths { - key: (crate::i18n::Lang, vectorcraft_doc::Unit, f32), - values: [f32; 4], -} - -/// Reuse sample-string measurements until language, document units or display scale changes. -/// Keep a single entry per UI so repeated DPI/zoom changes cannot grow the cache indefinitely. -fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc::Unit, t: &Tokens) -> Arc { - let id = ui.scope_id().with("status-readout-widths"); - let key = (lang, unit, ui.ctx().pixels_per_point()); - if let Some(cached) = ui.data(|data| data.get_temp::>(id)) - && cached.key == key - { - return cached; - } - // Text layout also accesses egui state, so measure outside the ui.data lock. - let mono = theme::mono(10.5); - let proportional = egui::FontId::proportional(10.5); - let render = crate::i18n::tr_ctx(lang, "diagnostics", "render"); - let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui"); - let measure = |text, font: &egui::FontId| ui.painter().layout_no_wrap(text, font.clone(), t.text).size().x.ceil() + 4.0; - // Reserve a readable numeric range using every digit in the active font. These samples - // depend on the language/units, never the live values: timings cannot move field boundaries. - let timing_width = |label| (0..=9).map(|d| measure(format!("{label} {d}{d}{d}{d}.{d} ms"), &proportional)).fold(0.0_f32, f32::max); - let render_width = timing_width(render); - let ui_width = timing_width(ui_name); - let fps_width = (0..=9).map(|d| measure(format!("{d}{d}{d}{d} fps"), &proportional)).fold(0.0_f32, f32::max); - // Each axis shows at least -9999.99..9999.99 in the selected unit without elision. - let axis_width = (0..=9).map(|d| measure(format!("X: -{d}{d}{d}{d}.{d}{d} {}", unit.suffix()), &mono)).fold(100.0_f32, f32::max); - let widths = Arc::new(StatusReadoutWidths { key, values: [render_width, ui_width, fps_width, axis_width] }); - ui.data_mut(|data| data.insert_temp(id, Arc::clone(&widths))); - widths -} - -/// Allocate the remaining row once, then paint each field inside its own bounded rectangle. -/// Child UIs requested with a desired width can shrink to their contents; these rectangles cannot. -fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { - const MESSAGE_MIN: f32 = 180.0; - let mono = theme::mono(10.5); - let proportional = egui::FontId::proportional(10.5); - let unit = app.session.general_unit(); - let lang = crate::i18n::current(); - let render = crate::i18n::tr_ctx(lang, "diagnostics", "render"); - let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui"); - let widths = status_readout_widths(ui, lang, unit, t); - let [render_width, ui_width, fps_width, axis_width] = widths.values; - let coord_width = 2.0 * axis_width; - let perf_width = render_width + ui_width + fps_width + 12.0; - let point_id = ui.scope_id().with(("status-hover-point", app.session.active().map(|st| st.uid))); - if let Some(p) = app.hover_doc { - ui.data_mut(|data| data.insert_temp(point_id, p)); - } - let gap = ui.spacing().item_spacing.x; - let size = vec2(ui.available_width().max(0.0), ui.available_height().max(0.0)); - let (area, _) = ui.allocate_exact_size(size, Sense::hover()); - let coordinates = area.width() >= MESSAGE_MIN + coord_width + gap; - let diagnostics = area.width() >= MESSAGE_MIN + coord_width + perf_width + 2.0 * gap; - let mut right = area.right(); - if diagnostics { - let perf = egui::Rect::from_min_max(egui::pos2(right - perf_width, area.top()), area.right_bottom()); - let mut x = perf.left(); - for (id, width, text) in [ - ("render", render_width, format!("{render} {:.1} ms", app.perf.render_ms)), - ("ui", ui_width, format!("{ui_name} {:.1} ms", app.perf.frame_ms)), - // No rate until the window has drawn continuously for a moment (`frame_rate`). - ("fps", fps_width, if app.perf.fps > 0.0 { format!("{:.0} fps", app.perf.fps) } else { "– fps".into() }), - ] { - let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(width, area.height())); - status_field(ui, id, rect, text, proportional.clone(), t.text_disabled); - x += width + 6.0; - } - right = perf.left() - gap; - } - if coordinates { - let left = right - coord_width; - // Keep the coordinate slot even without a hover point, so entering/leaving the canvas - // cannot move or re-truncate the message. X and Y have separate, stable origins. - let coord_rect = egui::Rect::from_min_max(egui::pos2(left, area.top()), egui::pos2(right, area.bottom())); - // The canvas clears hover_doc when the pointer leaves. While inspecting a readout, - // retain its last value so its tooltip can show the complete coordinate. - let inspecting = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| coord_rect.contains(p)) && ui.rect_contains_pointer(coord_rect); - let point = app.hover_doc.or_else(|| inspecting.then(|| ui.data(|data| data.get_temp::(point_id))).flatten()); - if let Some(p) = point { - let origin = app.ruler_origin(); - for (id, x, value) in [("X", left, p.x - origin.x), ("Y", left + axis_width, p.y - origin.y)] { - let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(axis_width, area.height())); - status_field(ui, id, rect, format!("{id}: {}", unit.readout(value)), mono.clone(), t.text_dim); - } - } - right = left - gap; - } - let busy: Vec<&str> = app - .background - .jobs - .iter() - .filter(|j| !j.label.is_empty() && !j.cancelled()) - .map(|j| j.label.as_str()) - .chain(app.opening.pending.iter().map(|p| p.label.as_str())) - .chain(app.placing.pending.iter().map(|p| p.label.as_str())) - .collect(); - // Reserve the spinner's space while idle too: starting a background job changes the text, - // not its origin. Messages take priority over optional readouts in a narrow window. - let message = if let Some(job) = busy.first() { - let spinner = egui::Rect::from_center_size(egui::pos2(area.left() + 6.0, area.center().y), vec2(12.0, 12.0)); - ui.put(spinner, egui::Spinner::new().size(12.0).color(t.accent)); - let label = crate::i18n::msg(job); - let more = busy.len() - 1; - if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") } - } else { - crate::i18n::msg(&app.ui.status) - }; - if !message.is_empty() { - let rect = egui::Rect::from_min_max(egui::pos2((area.left() + 18.0).min(right), area.top()), egui::pos2(right, area.bottom())); - status_field(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text); - } -} - -/// A field never grows with its text. Clipping also bounds over-wide glyphs; hover and access -/// information retain the complete value when its on-screen label is truncated. -fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32) { - if rect.width() <= 0.0 || rect.height() <= 0.0 { - return; - } - let galley = egui::WidgetText::from(egui::RichText::new(&text).font(font)).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - rect.width(), - egui::TextStyle::Body, - ); - let at = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); - let elided = galley.elided; - ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)).galley(at, galley, color); - let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); - if elided { - response.on_hover_text(text); - } -} - -/// Contextual hint for the active tool: segments of (text, bold). Keys in bold segments are -/// written as shortcuts are ("Alt+Click"); [`hint_segments`] names them for the platform. -/// The hint of the Zoom tool with Performance › Animated Zoom on. -const ANIMATED_ZOOM_HINT: &str = "zoom.animated"; - -fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { - Some(match tool { - "dimension" => &[ - ("Drag", true), - (" out from a highlighted edge to place its dimension | ", false), - ("Esc", true), - (" cancels | Double-click the tool for options", false), - ], - "selection" => &[ - ("Click", true), - (" the object to select | ", false), - ("Shift+Click", true), - (" to select multiple objects | ", false), - ("Alt+Drag", true), - (" the object to duplicate", false), - ], - "directSelection" => &[ - ("Click", true), - (" an anchor or path segment to select it | ", false), - ("Drag", true), - (" to move | ", false), - ("Shift+Click", true), - (" to add", false), - ], - "pen" => &[ - ("Click", true), - (" to add a corner point | ", false), - ("Drag", true), - (" to add a smooth point | ", false), - ("Click the first point", true), - (" to close | ", false), - ("Enter", true), - (" to finish", false), - ], - "curvature" => &[ - ("Click", true), - (" to add smooth points | ", false), - ("Double-click", true), - (" to toggle corner | ", false), - ("Esc", true), - (" to finish", false), - ], - "type" => &[ - ("Click", true), - (" to create point type | ", false), - ("Drag", true), - (" to create area type | ", false), - ("Esc", true), - (" to exit editing", false), - ], - "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" => &[ - ("Drag", true), - (" to draw | ", false), - ("Shift+Drag", true), - (" to constrain proportions | ", false), - ("Alt+Drag", true), - (" from center | ", false), - ("Click", true), - (" for exact size", false), - ], - "rotate" | "reflect" | "scale" | "shear" => &[ - ("Click", true), - (" to set the reference point | ", false), - ("Drag", true), - (" to transform | ", false), - ("Alt+Click", true), - (" for exact values", false), - ], - "hand" => &[("Drag", true), (" to pan the view", false)], - "zoom" => &[ - ("Click", true), - (" to zoom in | ", false), - ("Alt+Click", true), - (" to zoom out | ", false), - ("Drag", true), - (" to zoom into an area", false), - ], - ANIMATED_ZOOM_HINT => &[ - ("Click", true), - (" to zoom in | ", false), - ("Alt+Click", true), - (" to zoom out | ", false), - ("Drag", true), - (" right or left to zoom in or out | ", false), - ("Hold", true), - (" to keep zooming", false), - ], - "eyedropper" => &[ - ("Click", true), - (" an object to copy its attributes | ", false), - ("Alt+Click", true), - (" to apply the selection's to it | ", false), - ("Shift+Click", true), - (" to sample a color", false), - ], - "gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)], - "artboard" => &[ - ("Click", true), - (" to select an artboard | ", false), - ("Drag", true), - (" on the canvas to create one (", false), - ("Shift", true), - (" selects several, or draws one inside an artboard)", false), - ], - "width" => &[ - ("Drag", true), - (" a stroke to add a width point | ", false), - ("Alt+Drag", true), - (" to change one side | ", false), - ("Shift+Click", true), - (" to select more points | ", false), - ("Double-click", true), - (" a point to edit it | ", false), - ("Delete", true), - (" to remove points", false), - ], - "puppetWarp" => &[ - ("Click", true), - (" the art to add a pin | ", false), - ("Shift+Click", true), - (" to select more pins | ", false), - ("Drag", true), - (" a pin to warp | ", false), - ("Alt+Drag", true), - (" near a selected pin to rotate | ", false), - ("Delete", true), - (" to remove pins", false), - ], - "warp" => &[ - ("Drag", true), - (" across paths to push them along | ", false), - ("Alt+Drag", true), - (" to size the brush (", false), - ("Shift", true), - (" keeps its proportions)", false), - ], - "twirl" | "pucker" | "bloat" => &[ - ("Click or drag", true), - (" over paths, and hold still to keep going | ", false), - ("Alt+Drag", true), - (" to size the brush (", false), - ("Shift", true), - (" keeps its proportions)", false), - ], - "scallop" | "crystallize" | "wrinkle" => &[ - ("Click or drag", true), - (" over paths to roughen their outlines | ", false), - ("Alt+Drag", true), - (" to size the brush (", false), - ("Shift", true), - (" keeps its proportions)", false), - ], - "perspectiveSelection" => &[ - ("Drag", true), - (" to move in perspective | ", false), - ("Alt+Drag", true), - (" to copy | ", false), - ("5", true), - (" while dragging to move perpendicular to the plane | ", false), - ("Drag a handle", true), - (" to scale in perspective", false), - ], - "blend" => &[ - ("Click", true), - (" an object, then another to blend them | ", false), - ("Click an anchor point", true), - (" to blend from it | ", false), - ("Alt+Click", true), - (" to set spacing and orientation", false), - ], - _ => return None, - }) -} - -/// The hint bar's segments for `tool`, with keys named as the menus name them (Alt and Ctrl on -/// Windows and Linux, ⌥ and ⌘ on macOS). Tools without a hint point to Search Commands. -/// Every hint-bar fragment (the action after the last `+` of a chord included), so the catalog tests -/// can insist a complete language covers them. -pub fn hint_strings() -> Vec<&'static str> { - let mut v = Vec::new(); - for tool in vectorcraft_tools::catalog::all_tools().map(|t| t.id).chain([ANIMATED_ZOOM_HINT]) { - for &(text, bold) in hint_for(tool).unwrap_or(&[]) { - let text = if bold { text.rsplit_once('+').map_or(text, |(_, k)| k) } else { text }; - if !text.is_empty() && !text.chars().all(|c| c.is_ascii_digit()) && !v.contains(&text) { - v.push(text); - } - } - } - v -} - -fn hint_segments(tool: &str) -> Vec<(Cow<'static, str>, bool)> { - let search; - let segments = match hint_for(tool) { - Some(s) => s, - None => { - search = match menus::shortcut_of("help.commandPalette") { - Some(sc) => [(tl!("Press "), false), (sc, true), (tl!(" to search every command"), false)], - None => [(tl!("Use "), false), (tl!("Help › Search Commands…"), true), (tl!(" to search every command"), false)], - }; - &search - } - }; - segments - .iter() - .map(|&(text, bold)| { - // Key names ("Click", "Esc") translate; in a chord ("Alt+Drag") the modifiers are named for - // the platform and only the action after the last `+` translates. - if !bold { - return (Cow::Borrowed(tl!(text)), bold); - } - match text.rsplit_once('+') { - Some((_, key)) if !key.is_empty() => { - let pretty = menus::pretty_shortcut(text); - let shown = pretty.strip_suffix(key).map_or(pretty.clone(), |m| format!("{m}{}", tl!(key))); - (Cow::Owned(shown), bold) - } - _ => (Cow::Owned(menus::pretty_shortcut(tl!(text))), bold), - } - }) - .collect() -} - -pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let panel = egui::Panel::bottom("hint_bar") - .exact_size(28.0) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - ui.horizontal_centered(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); - ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(1.2, t.text)); - ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text); - ui.add_space(6.0); - let mut job = egui::text::LayoutJob::default(); - let segments = if let Some(d) = app.ui.dialog.as_ref().filter(|d| crate::dialogs::revolve_gizmo::active(d)) { - vec![(crate::dialogs::revolve_gizmo::hint(d).into(), false)] - } else { - let tool = app.session.tool_id(); - let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool }; - hint_segments(hint) - }; - for (txt, bold) in segments { - let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; - job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); - } - ui.label(job); - }); - }); - widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs new file mode 100644 index 000000000..a0d063b07 --- /dev/null +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -0,0 +1,282 @@ +use super::*; + +/// An anchor button: icon, tooltip, command and the convert command's `to`. +type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>); + +/// The anchor controls the Control bar and the Properties panel show. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AnchorControls { + None, + /// Paths selected as a whole with a tool that edits anchors (Direct Selection, the Pen…), + /// which shows all their anchors selected: "Convert:" only, as removing or cutting at every + /// anchor isn't what they're for. + Convert, + /// Anchors direct-selected: "Convert:" and "Anchors:". + All, +} + +/// Which anchor controls show for the selection and the active tool. +pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls { + let Some(st) = app.session.active() else { + return AnchorControls::None; + }; + if !st.selection.anchors.is_empty() { + return AnchorControls::All; + } + let paths = st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }))); + if paths && vectorcraft_tools::catalog::edits_anchors(app.session.tool_id()) { AnchorControls::Convert } else { AnchorControls::None } +} + +/// What the Control bar and the Properties panel offer for selected anchors ([`anchor_controls`]): +/// "Convert:" corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a +/// row of its own: inline in the Control bar, one under the other in a panel. +pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorControls) { + let t = Tokens::get(ui.ctx()); + let mut run = None; + let groups: [(&str, &[AnchorButton]); 2] = [ + ( + tl!("Convert:"), + &[ + ("dc-anchor", tl!("Convert Selected Anchor Points to Corner"), "path.convertAnchors", Some("corner")), + ("dc-anchor-smooth", tl!("Convert Selected Anchor Points to Smooth"), "path.convertAnchors", Some("smooth")), + ], + ), + ( + tl!("Anchors:"), + &[ + ("pen-tool-delete", tl!("Remove Anchor Points"), "path.removeAnchors", None), + ("dc-join", tl!("Connect Selected End Points"), "path.join", None), + ("scissors", tl!("Cut Path at Selected Anchor Points"), "path.cutAtAnchors", None), + ], + ), + ]; + let shown = match controls { + AnchorControls::None => 0, + AnchorControls::Convert => 1, + AnchorControls::All => groups.len(), + }; + for (label, buttons) in groups.into_iter().take(shown) { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).size(12.0).color(t.text)); + for &(icon, tip, id, to) in buttons { + if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() { + run = Some((id, to.map_or_else(|| json!({}), |to| json!({ "to": to })))); + } + } + }); + } + if let Some((id, p)) = run { + crate::menus::invoke(app, id, p); + } +} + +/// The id of the Control bar's isolation button. +pub(super) const ISOLATION_BUTTON: &str = "cb-isolation"; + +/// The room the Control bar needs for the Snapping menu next to the Artboard tool's controls; with +/// less, the menu is left out so those controls still fit the narrowest window. +const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0; + +/// The Control bar's snapping quick access; every change uses its existing command. +fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.menu_button(tl!("Snapping"), |ui| { + for (label, cmd, on) in [ + (tl!("Smart Guides"), "view.smartGuides", app.ui.view.smart_guides), + (tl!("Snap to Point"), "view.snapToPoint", app.ui.view.snap_to_point), + (tl!("Snap to Grid"), "view.snapToGrid", app.ui.view.snap_to_grid), + (tl!("Snap to Pixel"), "view.snapToPixel", app.ui.view.snap_to_pixel), + ] { + if widgets::check(ui, label, on, true) { + crate::menus::invoke(app, cmd, json!({})); + } + } + ui.separator(); + for (key, label, on) in [ + ("snapTangent", tl!("Snap to Tangent"), app.session.prefs.snap_tangent), + ("snapPerpendicular", tl!("Snap to Perpendicular"), app.session.prefs.snap_perpendicular), + ] { + if widgets::check(ui, label, on, true) { + crate::menus::invoke(app, "prefs.set", json!({"key": key, "value": !on})); + } + } + }); +} + +/// The Control bar's isolation button. In isolation mode it is Exit Isolation Mode, except while a +/// pattern is edited (the isolation bar's Done and Cancel end that). Otherwise, with one object +/// selected that [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which +/// also isolates a path inside a group that Direct Selection selected. An envelope's contents +/// get no button: the envelope's Edit Envelope and Edit Contents buttons show instead. +fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { return }; + let (icon, tip, id) = if st.isolation.is_some() { + if st.doc.pattern_edit.is_some() { + return; + } + ("chevron-left", tl!("Exit Isolation Mode"), "object.exitIsolation") + } else if let [one] = st.selection.objects[..] + && st.doc.node(one).is_some_and(|n| vectorcraft_tools::select::isolates(&n.kind)) + && vectorcraft_doc::live::envelope_of(&st.doc, one).is_none() + { + ("square-dashed", tl!("Isolate Selected Object"), "object.isolate") + } else { + return; + }; + let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + let resp = ui.interact(rect, egui::Id::unique(ISOLATION_BUTTON), Sense::click()); + if widgets::paint_icon_button(ui, resp, icon, tip, false).clicked() { + crate::menus::invoke(app, id, json!({})); + } +} + +/// The room the Control bar's inline X/Y/W/H fields need; with less, only the Transform link shows. +const INLINE_TRANSFORM_WIDTH: f32 = 440.0; + +/// The Control bar (Window → Control), context-sensitive like Illustrator's. +pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::top("control_bar") + .exact_size(34.0) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + // Gripper + let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover()); + for i in 0..5 { + ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); + } + if app.session.active().is_none() { + ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); + return; + } + // The Artboard tool's controls fill the narrowest window, so they keep it to themselves there. + if app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH { + snapping_menu(app, ui); + } + let Some(st) = app.session.active() else { return }; + let sel = st.selection.objects.clone(); + let units = app.session.general_unit(); + let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); + let anchors = anchor_controls(app); + let label = match &first { + _ if app.session.tool_id() == "artboard" => tl!("Artboard"), + Some(_) if sel.len() == 1 && anchors != AnchorControls::None => { + tl!("Anchor Point") + } + Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => { + if im.link.is_some() { + tl!("Linked File") + } else { + tl!("Embedded") + } + } + Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => { + tl!("Image Tracing") + } + _ => tl!(crate::panels::appearance::object_label(app)), + }; + ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); + // With the Artboard tool in use, the bar shows the active artboard's controls in place + // of the object controls. + if crate::panels::artboards::control_bar(app, ui) { + return; + } + if anchors != AnchorControls::None { + anchor_buttons(app, ui, anchors); + } + isolation_button(app, ui); + ui.add_space(6.0); + // An image or an Image Trace object shows its own controls in place of Fill and Stroke. + let image = crate::place::control_bar_details(app, ui) || crate::panels::image_trace::control_bar(app, ui); + crate::toolbar::control_bar_options(app, ui); + crate::dialogs::envelope::control_bar(app, ui); + let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; + if !image { + let shown_stroke = crate::panels::current_stroke(app); + let mixed = crate::panels::stroke_mixed(app, ui.ctx()); + let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); + crate::panels::paint_chip(app, ui, false, 22.0, true); + crate::panels::paint_chip(app, ui, true, 22.0, true); + // The link opens the Stroke panel as a popover under it; then the weight spinner + // (with presets) and the width profile. + stroke_panel::link(app, ui, tl!("Stroke:")); + stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0); + if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) { + app.run("stroke.set", json!({"profile": id})).ok(); + } + ui.add_space(4.0); + ui.separator(); + } + if ui.link(egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() { + app.ui.open_panel = Some("transparency".into()); + } + if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0) + && !sel.is_empty() + { + app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); + } + if !sel.is_empty() { + // Style picker: the selection's graphic style; its menu applies another. + ui.add_space(4.0); + if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() { + app.ui.open_panel = Some("graphicStyles".into()); + } + let resp = widgets::chip_button_named(ui, 22.0, true, tl!("Graphic Style"), |ui, r| { + crate::panels::graphic_styles::paint_linked(app, ui, r) + }); + egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); + // Recolor Artwork, when the selection has colours to recolour. + let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects)); + if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 24.0).clicked() { + crate::menus::invoke(app, "ui.recolorDialog", json!({})); + } + } + ui.separator(); + crate::panels::character::control_bar(app, ui); + if sel.is_empty() { + if widgets::flat_button(ui, tl!("Document Setup"), 112.0).clicked() { + app.run("file.documentSetup", json!({})).ok(); + } + if widgets::flat_button(ui, tl!("Preferences"), 90.0).clicked() { + app.run("edit.preferences", json!({})).ok(); + } + return; + } + crate::panels::align::align_buttons(app, ui, 24.0); + ui.separator(); + // The Transform link opens the whole Transform panel (reference point, rotate, + // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. + crate::panels::transform::link(app, ui); + // The bounding box, rotated with rotated objects: its centre and its own sides. + if let Some(b) = app.selection_box() + && ui.available_width() >= INLINE_TRANSFORM_WIDTH + { + let c = b.center(); + let origin = app.ruler_origin(); + let link = crate::panels::transform::proportional(app, ui); + let copy = crate::panels::transform::alt_enter_copies(app, ui); + for (k, lbl, v, offset) in [ + ("x", "X:", c.x, origin.x), + ("y", "Y:", c.y, origin.y), + ("width", "W:", b.rect.width(), 0.0), + ("height", "H:", b.rect.height(), 0.0), + ] { + // The W/H link sits between W and H. + if k == "height" { + crate::panels::transform::constrain_link(app, ui); + } + widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim)); + let (id, shown) = (("cb", k), Some(v - offset)); + let set = if matches!(k, "width" | "height") { + widgets::size_field(ui, id, shown, units, 80.0) + } else { + widgets::num_field(ui, id, shown, units, 80.0) + }; + if let Some(nv) = set { + app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link, "copy": copy})).ok(); + } + } + } + }); + }); +} diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs new file mode 100644 index 000000000..947f0dcf2 --- /dev/null +++ b/crates/ui-egui/src/chrome/hints.rs @@ -0,0 +1,254 @@ +use super::status_bar::ANIMATED_ZOOM_HINT; +use super::*; + +fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { + Some(match tool { + "dimension" => &[ + ("Drag", true), + (" out from a highlighted edge to place its dimension | ", false), + ("Esc", true), + (" cancels | Double-click the tool for options", false), + ], + "selection" => &[ + ("Click", true), + (" the object to select | ", false), + ("Shift+Click", true), + (" to select multiple objects | ", false), + ("Alt+Drag", true), + (" the object to duplicate", false), + ], + "directSelection" => &[ + ("Click", true), + (" an anchor or path segment to select it | ", false), + ("Drag", true), + (" to move | ", false), + ("Shift+Click", true), + (" to add", false), + ], + "pen" => &[ + ("Click", true), + (" to add a corner point | ", false), + ("Drag", true), + (" to add a smooth point | ", false), + ("Click the first point", true), + (" to close | ", false), + ("Enter", true), + (" to finish", false), + ], + "curvature" => &[ + ("Click", true), + (" to add smooth points | ", false), + ("Double-click", true), + (" to toggle corner | ", false), + ("Esc", true), + (" to finish", false), + ], + "type" => &[ + ("Click", true), + (" to create point type | ", false), + ("Drag", true), + (" to create area type | ", false), + ("Esc", true), + (" to exit editing", false), + ], + "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" => &[ + ("Drag", true), + (" to draw | ", false), + ("Shift+Drag", true), + (" to constrain proportions | ", false), + ("Alt+Drag", true), + (" from center | ", false), + ("Click", true), + (" for exact size", false), + ], + "rotate" | "reflect" | "scale" | "shear" => &[ + ("Click", true), + (" to set the reference point | ", false), + ("Drag", true), + (" to transform | ", false), + ("Alt+Click", true), + (" for exact values", false), + ], + "hand" => &[("Drag", true), (" to pan the view", false)], + "zoom" => &[ + ("Click", true), + (" to zoom in | ", false), + ("Alt+Click", true), + (" to zoom out | ", false), + ("Drag", true), + (" to zoom into an area", false), + ], + ANIMATED_ZOOM_HINT => &[ + ("Click", true), + (" to zoom in | ", false), + ("Alt+Click", true), + (" to zoom out | ", false), + ("Drag", true), + (" right or left to zoom in or out | ", false), + ("Hold", true), + (" to keep zooming", false), + ], + "eyedropper" => &[ + ("Click", true), + (" an object to copy its attributes | ", false), + ("Alt+Click", true), + (" to apply the selection's to it | ", false), + ("Shift+Click", true), + (" to sample a color", false), + ], + "gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)], + "artboard" => &[ + ("Click", true), + (" to select an artboard | ", false), + ("Drag", true), + (" on the canvas to create one (", false), + ("Shift", true), + (" selects several, or draws one inside an artboard)", false), + ], + "width" => &[ + ("Drag", true), + (" a stroke to add a width point | ", false), + ("Alt+Drag", true), + (" to change one side | ", false), + ("Shift+Click", true), + (" to select more points | ", false), + ("Double-click", true), + (" a point to edit it | ", false), + ("Delete", true), + (" to remove points", false), + ], + "puppetWarp" => &[ + ("Click", true), + (" the art to add a pin | ", false), + ("Shift+Click", true), + (" to select more pins | ", false), + ("Drag", true), + (" a pin to warp | ", false), + ("Alt+Drag", true), + (" near a selected pin to rotate | ", false), + ("Delete", true), + (" to remove pins", false), + ], + "warp" => &[ + ("Drag", true), + (" across paths to push them along | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], + "twirl" | "pucker" | "bloat" => &[ + ("Click or drag", true), + (" over paths, and hold still to keep going | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], + "scallop" | "crystallize" | "wrinkle" => &[ + ("Click or drag", true), + (" over paths to roughen their outlines | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], + "perspectiveSelection" => &[ + ("Drag", true), + (" to move in perspective | ", false), + ("Alt+Drag", true), + (" to copy | ", false), + ("5", true), + (" while dragging to move perpendicular to the plane | ", false), + ("Drag a handle", true), + (" to scale in perspective", false), + ], + "blend" => &[ + ("Click", true), + (" an object, then another to blend them | ", false), + ("Click an anchor point", true), + (" to blend from it | ", false), + ("Alt+Click", true), + (" to set spacing and orientation", false), + ], + _ => return None, + }) +} + +/// The hint bar's segments for `tool`, with keys named as the menus name them (Alt and Ctrl on +/// Windows and Linux, ⌥ and ⌘ on macOS). Tools without a hint point to Search Commands. +/// Every hint-bar fragment (the action after the last `+` of a chord included), so the catalog tests +/// can insist a complete language covers them. +pub fn hint_strings() -> Vec<&'static str> { + let mut v = Vec::new(); + for tool in vectorcraft_tools::catalog::all_tools().map(|t| t.id).chain([ANIMATED_ZOOM_HINT]) { + for &(text, bold) in hint_for(tool).unwrap_or(&[]) { + let text = if bold { text.rsplit_once('+').map_or(text, |(_, k)| k) } else { text }; + if !text.is_empty() && !text.chars().all(|c| c.is_ascii_digit()) && !v.contains(&text) { + v.push(text); + } + } + } + v +} + +pub(super) fn hint_segments(tool: &str) -> Vec<(Cow<'static, str>, bool)> { + let search; + let segments = match hint_for(tool) { + Some(s) => s, + None => { + search = match menus::shortcut_of("help.commandPalette") { + Some(sc) => [(tl!("Press "), false), (sc, true), (tl!(" to search every command"), false)], + None => [(tl!("Use "), false), (tl!("Help › Search Commands…"), true), (tl!(" to search every command"), false)], + }; + &search + } + }; + segments + .iter() + .map(|&(text, bold)| { + // Key names ("Click", "Esc") translate; in a chord ("Alt+Drag") the modifiers are named for + // the platform and only the action after the last `+` translates. + if !bold { + return (Cow::Borrowed(tl!(text)), bold); + } + match text.rsplit_once('+') { + Some((_, key)) if !key.is_empty() => { + let pretty = menus::pretty_shortcut(text); + let shown = pretty.strip_suffix(key).map_or(pretty.clone(), |m| format!("{m}{}", tl!(key))); + (Cow::Owned(shown), bold) + } + _ => (Cow::Owned(menus::pretty_shortcut(tl!(text))), bold), + } + }) + .collect() +} + +pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let panel = egui::Panel::bottom("hint_bar") + .exact_size(28.0) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(1.2, t.text)); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text); + ui.add_space(6.0); + let mut job = egui::text::LayoutJob::default(); + let segments = if let Some(d) = app.ui.dialog.as_ref().filter(|d| crate::dialogs::revolve_gizmo::active(d)) { + vec![(crate::dialogs::revolve_gizmo::hint(d).into(), false)] + } else { + let tool = app.session.tool_id(); + let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool }; + hint_segments(hint) + }; + for (txt, bold) in segments { + let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; + job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); + } + ui.label(job); + }); + }); + widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); +} diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs new file mode 100644 index 000000000..28b57a654 --- /dev/null +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -0,0 +1,232 @@ +use super::tabs::artboard_list; +use super::*; + +/// Draw the status message, bounded readouts and artboard navigation controls. +pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::bottom("status_bar") + .exact_size(24.0) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0); + let print = app.session.prefs.display_print_size; + let shown = crate::state::zoom_display_percent(zoom, print); + ui.name_inputs(tl!("Zoom"), |ui| { + egui::ComboBox::from_id_salt("zoom-combo") + .selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5)) + .width(76.0) + .show_ui(ui, |ui| { + for z in ZOOM_STOPS.iter().rev() { + if ui + .selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print)) + .clicked() + { + app.run("view.setZoom", json!({"zoom": z})).ok(); + } + } + ui.separator(); + if ui.selectable_label(false, tl!("Fit on Screen")).clicked() { + app.run("view.fitArtboard", json!({})).ok(); + } + if ui.selectable_label(false, tl!("Fit All")).clicked() { + app.run("view.fitAll", json!({})).ok(); + } + }); + }); + let rot = app.view().map(|v| v.rotation).unwrap_or(0.0); + ui.name_inputs(tl!("Rotation"), |ui| { + egui::ComboBox::from_id_salt("rot-combo") + .selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)) + .width(52.0) + .show_ui(ui, |ui| { + for (label, a) in ROTATE_VIEW_ANGLES { + if ui.selectable_label((rot - a).abs() < 1e-9, label).clicked() { + // It fails only without a document, when there is no view to turn. + app.run("view.rotate", json!({"angle": a})).ok(); + } + } + }); + }); + ui.separator(); + // The artboard navigator has First and Previous, a field with the current artboard's + // number and name and a list of every artboard, then Next and Last. + let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); + let cur = crate::panels::artboards::selected(app, nab); + let mut go = None; + for (icon, to, tip) in [("chevrons-left", "first", tl!("First Artboard")), ("chevron-left", "previous", tl!("Previous Artboard"))] { + if widgets::icon_button_enabled(ui, icon, tip, false, cur > 0, 18.0).clicked() { + go = Some(json!(to)); + } + } + if let Some(i) = artboard_list(app, ui, cur) { + go = Some(json!(i)); + } + for (icon, to, tip) in [("chevron-right", "next", tl!("Next Artboard")), ("chevrons-right", "last", tl!("Last Artboard"))] { + if widgets::icon_button_enabled(ui, icon, tip, false, cur + 1 < nab, 18.0).clicked() { + go = Some(json!(to)); + } + } + if let Some(to) = go + && let Err(e) = app.run("view.goToArtboard", json!({ "index": to })) + { + app.status(e); + } + ui.separator(); + let tool = vectorcraft_tools::tool_info(app.session.tool_id()) + .map(|t| { + // The catalog keys carry the full label ("Selection Tool"); only English drops the suffix. + let shown = tl!(t.label); + if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } + }) + .unwrap_or(""); + ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); + status_readouts(app, ui, &t); + }); + }); +} + +/// The most recently measured status widths, scoped to this UI and display settings. +pub(super) struct StatusReadoutWidths { + pub(super) key: (crate::i18n::Lang, vectorcraft_doc::Unit, f32), + pub(super) values: [f32; 4], +} + +/// Reuse sample-string measurements until language, document units or display scale changes. +/// Keep a single entry per UI so repeated DPI/zoom changes cannot grow the cache indefinitely. +fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc::Unit, t: &Tokens) -> Arc { + let id = ui.scope_id().with("status-readout-widths"); + let key = (lang, unit, ui.ctx().pixels_per_point()); + if let Some(cached) = ui.data(|data| data.get_temp::>(id)) + && cached.key == key + { + return cached; + } + // Text layout also accesses egui state, so measure outside the ui.data lock. + let mono = theme::mono(10.5); + let proportional = egui::FontId::proportional(10.5); + let render = crate::i18n::tr_ctx(lang, "diagnostics", "render"); + let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui"); + let measure = |text, font: &egui::FontId| ui.painter().layout_no_wrap(text, font.clone(), t.text).size().x.ceil() + 4.0; + // Reserve a readable numeric range using every digit in the active font. These samples + // depend on the language/units, never the live values: timings cannot move field boundaries. + let timing_width = |label| (0..=9).map(|d| measure(format!("{label} {d}{d}{d}{d}.{d} ms"), &proportional)).fold(0.0_f32, f32::max); + let render_width = timing_width(render); + let ui_width = timing_width(ui_name); + let fps_width = (0..=9).map(|d| measure(format!("{d}{d}{d}{d} fps"), &proportional)).fold(0.0_f32, f32::max); + // Each axis shows at least -9999.99..9999.99 in the selected unit without elision. + let axis_width = (0..=9).map(|d| measure(format!("X: -{d}{d}{d}{d}.{d}{d} {}", unit.suffix()), &mono)).fold(100.0_f32, f32::max); + let widths = Arc::new(StatusReadoutWidths { key, values: [render_width, ui_width, fps_width, axis_width] }); + ui.data_mut(|data| data.insert_temp(id, Arc::clone(&widths))); + widths +} + +/// Allocate the remaining row once, then paint each field inside its own bounded rectangle. +/// Child UIs requested with a desired width can shrink to their contents; these rectangles cannot. +pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { + const MESSAGE_MIN: f32 = 180.0; + let mono = theme::mono(10.5); + let proportional = egui::FontId::proportional(10.5); + let unit = app.session.general_unit(); + let lang = crate::i18n::current(); + let render = crate::i18n::tr_ctx(lang, "diagnostics", "render"); + let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui"); + let widths = status_readout_widths(ui, lang, unit, t); + let [render_width, ui_width, fps_width, axis_width] = widths.values; + let coord_width = 2.0 * axis_width; + let perf_width = render_width + ui_width + fps_width + 12.0; + let point_id = ui.scope_id().with(("status-hover-point", app.session.active().map(|st| st.uid))); + if let Some(p) = app.hover_doc { + ui.data_mut(|data| data.insert_temp(point_id, p)); + } + let gap = ui.spacing().item_spacing.x; + let size = vec2(ui.available_width().max(0.0), ui.available_height().max(0.0)); + let (area, _) = ui.allocate_exact_size(size, Sense::hover()); + let coordinates = area.width() >= MESSAGE_MIN + coord_width + gap; + let diagnostics = area.width() >= MESSAGE_MIN + coord_width + perf_width + 2.0 * gap; + let mut right = area.right(); + if diagnostics { + let perf = egui::Rect::from_min_max(egui::pos2(right - perf_width, area.top()), area.right_bottom()); + let mut x = perf.left(); + for (id, width, text) in [ + ("render", render_width, format!("{render} {:.1} ms", app.perf.render_ms)), + ("ui", ui_width, format!("{ui_name} {:.1} ms", app.perf.frame_ms)), + // No rate until the window has drawn continuously for a moment (`frame_rate`). + ("fps", fps_width, if app.perf.fps > 0.0 { format!("{:.0} fps", app.perf.fps) } else { "– fps".into() }), + ] { + let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(width, area.height())); + status_field(ui, id, rect, text, proportional.clone(), t.text_disabled); + x += width + 6.0; + } + right = perf.left() - gap; + } + if coordinates { + let left = right - coord_width; + // Keep the coordinate slot even without a hover point, so entering/leaving the canvas + // cannot move or re-truncate the message. X and Y have separate, stable origins. + let coord_rect = egui::Rect::from_min_max(egui::pos2(left, area.top()), egui::pos2(right, area.bottom())); + // The canvas clears hover_doc when the pointer leaves. While inspecting a readout, + // retain its last value so its tooltip can show the complete coordinate. + let inspecting = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| coord_rect.contains(p)) && ui.rect_contains_pointer(coord_rect); + let point = app.hover_doc.or_else(|| inspecting.then(|| ui.data(|data| data.get_temp::(point_id))).flatten()); + if let Some(p) = point { + let origin = app.ruler_origin(); + for (id, x, value) in [("X", left, p.x - origin.x), ("Y", left + axis_width, p.y - origin.y)] { + let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(axis_width, area.height())); + status_field(ui, id, rect, format!("{id}: {}", unit.readout(value)), mono.clone(), t.text_dim); + } + } + right = left - gap; + } + let busy: Vec<&str> = app + .background + .jobs + .iter() + .filter(|j| !j.label.is_empty() && !j.cancelled()) + .map(|j| j.label.as_str()) + .chain(app.opening.pending.iter().map(|p| p.label.as_str())) + .chain(app.placing.pending.iter().map(|p| p.label.as_str())) + .collect(); + // Reserve the spinner's space while idle too: starting a background job changes the text, + // not its origin. Messages take priority over optional readouts in a narrow window. + let message = if let Some(job) = busy.first() { + let spinner = egui::Rect::from_center_size(egui::pos2(area.left() + 6.0, area.center().y), vec2(12.0, 12.0)); + ui.put(spinner, egui::Spinner::new().size(12.0).color(t.accent)); + let label = crate::i18n::msg(job); + let more = busy.len() - 1; + if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") } + } else { + crate::i18n::msg(&app.ui.status) + }; + if !message.is_empty() { + let rect = egui::Rect::from_min_max(egui::pos2((area.left() + 18.0).min(right), area.top()), egui::pos2(right, area.bottom())); + status_field(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text); + } +} + +/// A field never grows with its text. Clipping also bounds over-wide glyphs; hover and access +/// information retain the complete value when its on-screen label is truncated. +fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32) { + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return; + } + let galley = egui::WidgetText::from(egui::RichText::new(&text).font(font)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + rect.width(), + egui::TextStyle::Body, + ); + let at = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); + let elided = galley.elided; + ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)).galley(at, galley, color); + let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); + if elided { + response.on_hover_text(text); + } +} + +/// Contextual hint for the active tool: segments of (text, bold). Keys in bold segments are +/// written as shortcuts are ("Alt+Click"); [`hint_segments`] names them for the platform. +/// The hint of the Zoom tool with Performance › Animated Zoom on. +pub(super) const ANIMATED_ZOOM_HINT: &str = "zoom.animated"; diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs new file mode 100644 index 000000000..95847e27e --- /dev/null +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -0,0 +1,271 @@ +use super::*; + +/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited +/// "Name* @ 66.67 % (/Opacity Mask)". +pub(super) fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool, display_print_size: bool) -> String { + let mode = if d.doc.mask_edit.is_some() { + format!("<{0}>/{0}", tl!("Opacity Mask")) + } else { + let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" }; + format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") }) + }; + format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %")) +} + +/// Whether a document tab's × comes before its title (macOS) rather than after it (Windows, +/// Linux and the web). +pub(super) const CLOSE_BEFORE_TITLE: bool = cfg!(target_os = "macos"); + +/// The first of tabs `widths` wide that the strip shows in `room`: the first one, or a later one so +/// that the `active` tab fits. +pub(super) fn first_tab_shown(widths: &[f32], active: Option, room: f32) -> usize { + let Some(a) = active else { return 0 }; + let mut first = 0; + while first < a && widths.get(first..=a).map_or(0.0, |w| w.iter().sum::()) > room { + first += 1; + } + first +} + +/// Document tab strip: "Name* @ 66.67% (RGB/Preview)". User Interface › Large Tabs makes the tabs +/// taller, with larger titles. Tabs that don't fit are reached from a » button at the strip's +/// right end, which lists every open document, and the active tab is always in view (#746). +pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { + /// The width of the » button. + const MORE: f32 = 30.0; + let t = Tokens::get(ui.ctx()); + let (height, title_size) = if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) }; + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.tab_strip); + ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); + // On the Home screen no tab is the current one. + let active = app.session.active_index().filter(|_| app.ui.home.is_none()); + let tabs: Vec<(String, std::sync::Arc)> = app + .session + .documents() + .iter() + .enumerate() + .map(|(i, d)| { + let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0); + let title = tab_title(d, zoom, app.ui.view.outline, app.session.prefs.display_print_size); + let color = if Some(i) == active { t.text_strong } else { t.text_dim }; + (title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color)) + }) + .collect(); + let widths: Vec = tabs.iter().map(|(_, g)| g.size().x + 50.0).collect(); + let overflow = widths.iter().sum::() > strip.width(); + let room = strip.width() - if overflow { MORE } else { 0.0 }; + let first = if overflow { first_tab_shown(&widths, active, room) } else { 0 }; + let mut x = strip.left(); + let mut activate = None; + let mut close = None; + let mut hovering_art = false; + for (i, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) { + if x + w > strip.left() + room && Some(i) != active { + break; + } + let is_active = Some(i) == active; + let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(*w, strip.height() - 1.0)); + let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click()); + if is_active { + ui.painter().rect_filled(r, 0.0, t.panel); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4)); + } + ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.5, t.border)); + // The × where each platform puts a tab's: before the title on macOS, after it elsewhere + // (#673). + let (x_center, title_left) = if CLOSE_BEFORE_TITLE { (r.left() + 16.0, r.left() + 32.0) } else { (r.right() - 16.0, r.left() + 14.0) }; + let xr = egui::Rect::from_center_size(egui::pos2(x_center, r.center().y), vec2(12.0, 12.0)); + let xresp = ui.interact(xr.expand(3.0), ui.scope_id().with(("tabx", i)), Sense::click()); + icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text }); + ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text); + hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0)); + if xresp.clicked() { + close = Some(i); + } else if resp.clicked() { + activate = Some(i); + } + x += w; + } + crate::document_drag::finish_tabs(ui.ctx(), hovering_art); + // The » button: every open document, the active one checked. The list scrolls once it + // reaches the bottom of the window, so every document of a large session stays reachable + // (#4017). + if overflow { + let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0)); + let resp = ui.interact(more, ui.scope_id().with("tabs-more"), Sense::click()); + ui.painter().rect_filled(more, 0.0, if resp.hovered() { t.hover.gamma_multiply(0.4) } else { t.tab_strip }); + icons::paint(ui, "chevrons-right", egui::Rect::from_center_size(more.center(), vec2(14.0, 14.0)), t.text); + let resp = resp.on_hover_text(tl!("Show all open documents")); + egui::Popup::menu(&resp).show(|ui| { + widgets::menu_scroll(ui, |ui| { + for (i, (title, _)) in tabs.iter().enumerate() { + if widgets::menu_item_name(ui, title, true, Some(i) == active) { + activate = Some(i); + ui.close(); + } + } + }); + }); + } + if let Some(i) = close { + if let Err(e) = crate::unsaved::close(app, i) { + app.status(e); + } + } else if let Some(i) = activate { + app.ui.home = None; + let changed = app.session.active_index() != Some(i); + if app.session.set_active(i) + && changed + && app.session.tool_id() == "artboard" + && let Some(n) = app.session.active().map(|d| d.doc.artboards.len()) + { + // A document switch recreates the tool; restore the displayed board as its target. + // Clicking the current tab keeps its existing multiselection and tool options. + let selected = crate::panels::artboards::selected(app, n); + crate::panels::artboards::select(app, selected); + } + } +} + +/// The isolation mode breadcrumb bar. It is drawn over the top of `area` (the canvas), after the +/// canvas, so entering isolation mode does not change the canvas's size (#3126). +pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { + let t = Tokens::get(ui.ctx()); + if let Some(st) = app.session.active() + && let Some(iso) = st.isolation + { + let mut crumbs: Vec = + st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect(); + // Pattern editing mode: the pattern's name, and Save a Copy / Done / Cancel at the right. + let pattern_edit = st.doc.pattern_edit.as_ref().map(|e| e.pattern.clone()); + if let Some(pn) = &pattern_edit { + crumbs.push(pn.clone()); + } + let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0)); + ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::click_and_drag()); + ui.painter().rect_filled(bar, 0.0, t.panel); + let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0)); + let bresp = ui.interact(back, ui.scope_id().with("iso-back"), Sense::click()); + icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon }); + ui.painter().text( + bar.left_center() + vec2(30.0, 0.0), + egui::Align2::LEFT_CENTER, + crumbs.join(" › "), + egui::FontId::proportional(12.0), + t.text, + ); + let mut pattern_cmd = None; + if pattern_edit.is_some() { + let mut x = bar.right() - 6.0; + for (label, cmd) in [("Cancel", "object.pattern.cancel"), ("Done", "object.pattern.done"), ("Save a Copy", "object.pattern.saveCopy")] { + let label = tl!(label); + let w = 12.0 + 7.0 * label.len() as f32; + let r = egui::Rect::from_min_max(egui::pos2(x - w, bar.top() + 3.0), egui::pos2(x, bar.bottom() - 3.0)); + let resp = ui.interact(r, ui.scope_id().with(("pat-bar", cmd)), Sense::click()); + ui.painter().rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.panel }); + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), t.text); + if resp.clicked() { + pattern_cmd = Some(cmd); + } + x -= w + 6.0; + } + } + if bresp.clicked() { + pattern_cmd = pattern_cmd.or(pattern_edit.as_ref().map(|_| "object.pattern.done")); + if pattern_cmd.is_none() { + app.run("object.exitIsolation", json!({})).ok(); + } + } + if let Some(cmd) = pattern_cmd { + app.run(cmd, json!({})).ok(); + } + } +} + +/// Draws the artboard navigator's field, which shows the active artboard (`cur`) by number and +/// name. The field's list shows every artboard by number and name, as the Artboards panel lists +/// them, and opens with the active artboard's row in view. The list lays out only the rows in view +/// and at most one more. Returns the artboard chosen in the list. +pub(super) fn artboard_list(app: &VectorcraftApp, ui: &mut Ui, cur: usize) -> Option { + /// The width of the field. + const FIELD_W: f32 = 140.0; + /// The least width of the list. + const LIST_W: f32 = 220.0; + /// The height of a row, as in the Artboards panel. + const ROW_H: f32 = 24.0; + /// The rows the list shows at once. + const ROWS: f32 = 10.0; + let t = Tokens::get(ui.ctx()); + let boards = app.session.active().map(|d| d.doc.artboards.as_slice()).unwrap_or_default(); + let shown = boards.get(cur).map_or_else(|| "–".to_string(), |a| format!("{} {}", cur + 1, a.name)); + // Under `drawn`, egui's temp data stores the pass the list was last drawn in. + let drawn = ui.scope_id().with("artboard-list"); + let height = ROW_H * ROWS; + ui.add_enabled_ui(!boards.is_empty(), |ui| { + ui.set_max_width(FIELD_W); + ui.name_inputs(tl!("Artboard"), |ui| { + egui::ComboBox::from_id_salt("artboard-list") + .selected_text(egui::RichText::new(shown).size(11.5)) + .width(FIELD_W) + .wrap_mode(egui::TextWrapMode::Truncate) + .height(height) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui(ui, |ui| { + let pass = ui.ctx().cumulative_pass_nr(); + let last = ui.data(|d| d.get_temp::(drawn)).unwrap_or_default(); + ui.data_mut(|d| d.insert_temp(drawn, pass)); + let mut area = egui::ScrollArea::vertical().max_height(height); + // As the list opens, it scrolls the active row as close to the middle as the list allows. + if last + 1 < pass || last == 0 { + area = area.vertical_scroll_offset((cur as f32 * ROW_H - (height - ROW_H) * 0.5).max(0.0)); + } + // Every number ends where the widest one ends. + let numbers = ui.painter().layout_no_wrap(boards.len().to_string(), egui::FontId::proportional(12.0), t.text_dim).size().x; + let width = ui.available_width().max(LIST_W); + let mut chosen = None; + ui.spacing_mut().item_spacing.y = 0.0; + area.show_rows(ui, ROW_H, boards.len(), |ui, range| { + for i in range { + let Some(board) = boards.get(i) else { continue }; + let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::click()); + if i == cur { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let number_end = r.left() + 8.0 + numbers; + ui.painter().text( + egui::pos2(number_end, r.center().y), + egui::Align2::RIGHT_CENTER, + (i + 1).to_string(), + egui::FontId::proportional(12.0), + t.text_dim, + ); + let name_start = number_end + 10.0; + let name = egui::WidgetText::from(egui::RichText::new(&board.name).size(12.5)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + r.right() - 8.0 - name_start, + egui::TextStyle::Body, + ); + ui.painter().galley(egui::pos2(name_start, r.center().y - name.size().y / 2.0), name, t.text); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == cur, format!("{} {}", i + 1, board.name))); + if resp.clicked() { + chosen = Some(i); + } + } + }); + if chosen.is_some() { + ui.close(); + } + chosen + }) + .inner + .flatten() + }) + }) + .inner +} From 5dfd01e1f115d3ffea208b5b5c2ad27827bc655b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:50 +0000 Subject: [PATCH 140/199] refactor(ui-egui): move io.rs items to io/{export,open,save}.rs --- crates/ui-egui/src/io.rs | 1105 +------------------------------ crates/ui-egui/src/io/export.rs | 302 +++++++++ crates/ui-egui/src/io/open.rs | 362 ++++++++++ crates/ui-egui/src/io/save.rs | 440 ++++++++++++ 4 files changed, 1121 insertions(+), 1088 deletions(-) create mode 100644 crates/ui-egui/src/io/export.rs create mode 100644 crates/ui-egui/src/io/open.rs create mode 100644 crates/ui-egui/src/io/save.rs diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 43c5b1746..c6b9c2677 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -13,1094 +13,23 @@ use crate::dialogs::svg_options; use crate::state::Dialog; use crate::{FilePick, Services, VectorcraftApp, dialogs}; -/// Template extensions New from Template's open dialog lists first. -const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; - -/// Open bytes of any readable format as a new document (templates open untitled); swatch and -/// graphic style library files open in the library panel, Libraries panel files are added to it, -/// flattener, PDF, print and perspective grid presets files are imported, a shortcut set another -/// app saved (`.kys`) opens Import Shortcut Set, and a script written for the reference app -/// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for -/// a document opened now (its `warnings` say what didn't come in as it was), else null. -pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result { - let opens = opens_as(name); - // A WebAssembly plug-in is installed. - if opens == Some(OpensAs::Plugin) { - let module = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}); - return install_module(app, module).map(|_| Value::Null); - } - if opens == Some(OpensAs::ShortcutSet) { - return dialogs::shortcut_import::open(app, name, bytes).map(|_| Value::Null); - } - if opens == Some(OpensAs::Script) { - let p = match path { - Some(path) => json!({ "path": path }), - None => json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), - }; - return dialogs::script_check::command(app, &p).map(|_| Value::Null); - } - if let Some(OpensAs::Presets { import, what }) = opens { - let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; - let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - app.status(format!("Imported {what}: {}", names.join(", "))); - return Ok(Value::Null); - } - if opens == Some(OpensAs::Library) { - return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null); - } - let swatches = opens == Some(OpensAs::SwatchLibrary); - if swatches || opens == Some(OpensAs::StyleLibrary) { - let p = match path { - Some(path) => serde_json::json!({ "path": path }), - // `.ase` swatch libraries are binary. - None if swatches => serde_json::json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), - None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}), - }; - let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; - return load(app, p).map(|_| Value::Null); - } - // A PDF with several pages or a password asks first (the Import PDF dialog), and so does a - // DXF drawing (DXF Import Options). - if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) - || crate::dialogs::dxf_import::offer(app, name, bytes, path.clone(), None) - { - return Ok(Value::Null); - } - open_document(app, name, bytes, path, &Value::Null) -} - -/// The kinds of file File → Open installs, imports, checks, shows in the library panel or the -/// Libraries panel, or reviews in Import Shortcut Set ([`opens_as`]). -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum OpensAs { - /// A WebAssembly plug-in: installed. - Plugin, - /// A shortcut set another app saved (`.kys`): reviewed in Import Shortcut Set. - ShortcutSet, - /// A script written for the reference app: checked without running it (Check Script…). - Script, - /// Flattener, PDF, print or perspective grid presets: imported with command `import`, and - /// `what` names them in the status bar. - Presets { import: &'static str, what: &'static str }, - /// A Libraries panel file: added to the Libraries panel as a new library. - Library, - /// A swatch library: opened in the library panel. - SwatchLibrary, - /// A graphic style library: opened in the library panel. - StyleLibrary, -} - -/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks, -/// shows in the library panel or the Libraries panel, or reviews in Import Shortcut Set -/// ([`open_bytes`]), else `None`. A drop opens these files the same way, wherever they are dropped -/// ([`VectorcraftApp::drop_target`]). -pub fn opens_as(name: &str) -> Option { - let ext = fileio::extension(name); - let presets = [ - (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), - (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), - (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), - (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), - ]; - if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { - Some(OpensAs::Plugin) - } else if vectorcraft_engine::cmd::shortcutset::EXTS.contains(&ext.as_str()) { - Some(OpensAs::ShortcutSet) - } else if vectorcraft_engine::cmd::scripts::EXTS.contains(&ext.as_str()) { - Some(OpensAs::Script) - } else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { - Some(OpensAs::Presets { import, what }) - } else if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) { - Some(OpensAs::Library) - } else if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()) { - Some(OpensAs::SwatchLibrary) - } else if ext == vectorcraft_doc::style_libs::STYLES_EXT { - Some(OpensAs::StyleLibrary) - } else { - None - } -} - -/// Open a document through the engine loader with the `document.open` options in `p` → -/// `document.open`'s result. -pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { - let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?; - opened(app, &r); - Ok(r) -} - -/// A document just opened (`r`: `document.open`'s result): the views follow, the status bar says -/// what didn't come in as it was, and missing links are asked about. -pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { - app.sync_views(); - if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { - app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default())); - } - crate::dialogs::missing_links::after_open(app, r); -} - -/// A path from the open dialog, or "cancelled". -fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result { - crate::picks::open(app, pick).ok_or_else(|| "cancelled".into()) -} - -/// Object › Plug-ins › Install Plug-in…: installs the module `p` gives (`path`, or `dataBase64` -/// with `name`), else a picked `.wasm` (the web's file picker hands the file to [`open_bytes`], -/// which installs it) → [`install_module`]'s result. -pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result { - let given = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| !v.is_empty()); - if given("path") || given("dataBase64") { - return install_module(app, p.clone()); - } - if app.services.open_async.is_some() { - return open_dialog(app).map(|_| Value::Null); - } - let path = - pick_open(app, &FilePick { filters: vec![FilePick::filter("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?; - install_module(app, json!({ "path": path })) -} - -/// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` -/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't -/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. -pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { - if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { - let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); - let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); - let info = app.run("plugin.inspect", source.clone())?; - if info["budget"] == "heavy" { - let name = info["name"].as_str().unwrap_or_default(); - let (minutes, times) = vectorcraft_plugins::heavy_terms(); - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); - let detail = crate::i18n::fmt( - tl!( - "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." - ), - &[("minutes", &minutes.to_string()), ("times", ×.to_string())], - ); - let mut allowed = source; - allowed["allowHeavy"] = json!(true); - dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); - return Ok(json!({ "pending": dialogs::confirm::KIND })); - } - } - let r = app.run("plugin.install", module)?; - app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); - Ok(r) -} - -/// Where File → Open… starts: the folder of the most recently opened file, else the active -/// document's (#4122). -fn open_dialog_folder(app: &VectorcraftApp) -> Option { - app.ui.recent_files.iter().find_map(|p| fileio::dialog_folder(p)).or_else(|| suggested(app, "").1) -} - -/// File → Open… -pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { - if let Some(f) = app.services.open_async.as_mut() { - f(); - return Ok(()); - } - let path = - pick_open(app, &FilePick { filters: FilePick::filters(fileio::open_filters()), folder: open_dialog_folder(app), ..Default::default() })?; - open_reporting(app, &path).map(|_| ()) -} - -/// A file the user asked to open (File › Open, Open Recent, the Home screen, a drop, the Finder) -/// couldn't be: say so in a dialog as well as the status bar, which is easily missed (#861). -pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { - app.status(format!("Couldn't open {name}: {e}")); - let message = crate::i18n::fmt(tl!("Can't open “{name}”."), &[("name", name)]); - dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e)); -} - -/// A save or an export that didn't write `path`: say so in a dialog that names the file and the -/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which -/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved -/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request -/// an agent sent and waits on: the error is its reply. -pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) { - if app.ui.answering_control { - return; - } - let name = fileio::file_name(path); - let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") }; - dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e)); -} - -/// [`write_to`], a failure reported ([`report_write_error`]). -pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { - let r = write_to(&mut app.services, path, bytes); - if let Err(e) = &r { - report_write_error(app, false, path, e); - } - r -} - -/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On -/// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then -/// `{background: true, status}`, and the document opens in its tab (or the failure is reported) -/// once it is ready. -pub fn open_reporting(app: &mut VectorcraftApp, path: &str) -> Result { - let r = open_file(app, path, crate::opening::available()); - if let Err(e) = &r { - report_open_error(app, &fileio::file_name(path), e); - } - r -} - -/// Home › Recent Files and File › Open Recent: focus an already-open tab for that path instead of -/// calling `document.open` again (#3455). -pub fn open_recent_reporting(app: &mut VectorcraftApp, path: &str) -> Result { - if let Some(r) = try_activate_open_document(app, path)? { - return Ok(r); - } - open_reporting(app, path) -} - -fn try_activate_open_document(app: &mut VectorcraftApp, path: &str) -> Result, String> { - let Some(index) = document_index_for_path(app, path) else { - return Ok(None); - }; - let st = &app.session.documents()[index]; - let r = json!({ - "index": index, - "title": st.title(), - "format": st.format, - "warnings": [], - "restored": false, - "activated": true, - }); - app.run("document.activate", json!({ "index": index }))?; - opened(app, &r); - Ok(Some(r)) -} - -fn document_index_for_path(app: &VectorcraftApp, path: &str) -> Option { - app.session.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_open_path(p, path))) -} - -fn same_open_path(a: &str, b: &str) -> bool { - match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { - (Ok(a), Ok(b)) => a == b, - _ => a == b, - } -} - -fn read(app: &VectorcraftApp, path: &str) -> Result, String> { - app.services.read.as_ref().ok_or("no file reader")?(path) -} - -/// Open the file at `path` (see [`open_bytes`]) → `document.open`'s result, or null. -pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result { - open_file(app, path, false) -} - -/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks -/// first) is decoded off the UI thread ([`crate::opening::start`]). -pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { - // A shortcut set is read by Import Shortcut Set, up to its reader's cap. - if dialogs::shortcut_import::is_kys(path) { - dialogs::shortcut_import::command(app, &json!({ "path": path }))?; - note_recent(app, path); - return Ok(Value::Null); - } - // A script is read by `script.check`, up to its limit. - if opens_as(path) == Some(OpensAs::Script) { - dialogs::script_check::command(app, &json!({ "path": path }))?; - note_recent(app, path); - return Ok(Value::Null); - } - let mut bytes = read(app, path)?; - if background && opens_as(path).is_none() { - // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. - if crate::dialogs::import_pdf::offer(app, path, &bytes, Some(path.to_string()), None) - || crate::dialogs::dxf_import::offer(app, path, &bytes, Some(path.to_string()), None) - { - note_recent(app, path); - return Ok(Value::Null); - } - match crate::opening::start(app, path, bytes) { - Ok(r) => return Ok(r), - // No thread: at once. - Err(back) => bytes = back, - } - } - let r = open_bytes(app, path, &bytes, Some(path.to_string()))?; - note_recent(app, path); - Ok(r) -} - -/// File → New from Template…: a template (or any readable file) as a new untitled document. Without -/// a path the open dialog starts in the Templates folder. On the web the browser's file picker -/// opens it (`.vctemplate`, `.ait` and template files open untitled there too). -pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Result { - let path = match path { - Some(p) => p, - None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), - None => { - let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); - pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? - } - }; - let bytes = read(app, &path)?; - let r = fileio::open_template(&mut app.session, &path, &bytes, Some(&path)).map_err(|e| e.to_string())?; - app.sync_views(); - crate::dialogs::missing_links::after_open(app, &r); - Ok(r) -} - -/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. -pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> { - if let Some(dl) = services.download.as_mut() { - return dl(&fileio::file_name(path), bytes); - } - services.write.as_mut().ok_or("no file writer")?(path, bytes) -} - -/// The web saves by downloading: no save panel, no folders. -pub(crate) fn is_web(app: &VectorcraftApp) -> bool { - app.services.download.is_some() -} - -/// Write `bytes` to `path`, else to a file picked in a save panel that suggests `name` next to the -/// document and shows its file type, [`file_types`] (the web downloads them as `name`) → where -/// they went. -pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: &str, bytes: &[u8]) -> Result { - let path = match path { - Some(p) => p, - None => { - let (_, folder) = suggested(app, ""); - let filters = file_types(&fileio::extension(name)); - pick_path(app, &FilePick { name: name.to_string(), folder, filters }, |_| false)? - } - }; - write_reporting(app, &path, bytes)?; - Ok(path) -} - -/// Where a file goes when no path was given: the suggested name on the web (a download), else the -/// save panel's choice with the suggested name's extension, [`with_written_extension`]: a name -/// typed with the extension of a format `keeps` takes stays as typed too. -fn pick_path(app: &mut VectorcraftApp, pick: &FilePick, keeps: impl Fn(&Format) -> bool) -> Result { - let ext = fileio::extension(&pick.name); - pick_finished(app, pick, |picked, _| with_written_extension(picked, &ext, &keeps)) -} - -/// `path` as typed in a save panel for a file with extension `ext`: kept when its extension is -/// `ext` in any letter case, or names the format `ext` names (`photo.jpeg` for a `.jpg` file) or -/// a format `keeps` takes. An extension that names another format (`card.svg` for a `.png` file) -/// is replaced with `ext`, and `.ext` is added to any other path (`v1.2` becomes `v1.2.png`). -fn with_written_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { - let written = fileio::format(ext); - let keeps = |g: &Format| written.is_some_and(|w| w.id == g.id) || keeps(g); - if !ext.is_empty() && !fileio::extension(path).eq_ignore_ascii_case(ext) && fileio::format_for_name(path).is_some_and(|g| !keeps(g)) { - return std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(); - } - with_extension(path, ext, keeps) -} - -/// The suggested name made into a path by `finish` on the web (a download), else the save panel's -/// choice made into one, with the extension of the file type picked in the panel when it reports -/// one ([`crate::picks::PickAnswer`]). When `finish` changed the choice (an extension added or -/// replaced) and a file is there, which the panel did not ask about, a question asks before -/// replacing it ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK -/// runs what asked again. -fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str, Option<&str>) -> String) -> Result { - if is_web(app) { - return Ok(finish(&pick.name, None)); - } - let (picked, file_type) = crate::picks::save_with_type(app, pick).ok_or("cancelled")?; - let path = finish(&picked, file_type.as_deref()); - if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, file_type.as_deref(), &path) { - return Err("cancelled".into()); - } - Ok(path) -} - -/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format -/// `keeps` takes, else with `.ext` added. -fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { - let has = fileio::extension(path); - if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) { - path.to_string() - } else { - format!("{path}.{ext}") - } -} - -/// The active document's file name with extension `ext`, and its folder. -fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option) { - let st = app.session.active(); - let t = st.map(|d| d.path.clone().unwrap_or_else(|| d.doc.title.clone())).unwrap_or_else(|| "Untitled".into()); - let folder = st.and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(|p| p.to_string_lossy().to_string()); - (format!("{}.{ext}", fileio::file_stem(&t)), folder.filter(|f| !f.is_empty())) -} - -/// The save panel's file type, `(label, extensions)`, for the files `write_named` and -/// `target_path` write that are not a document format (`fileio::format` has those). -const FILE_TYPES: &[(&str, &[&str])] = &[ - ("Swatch libraries", &["vcswatches"]), - ("Swatch libraries", &["gpl"]), - ("Swatch libraries", &["ase"]), - ("Graphic Style Libraries", &[vectorcraft_doc::style_libs::STYLES_EXT]), - ("Libraries", vectorcraft_engine::cmd::library::LIBRARY_EXTS), - ("Flattener presets", vectorcraft_engine::cmd::flatten::PRESET_EXTS), - ("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS), - ("Perspective grid presets", vectorcraft_engine::cmd::perspgrid::PRESET_EXTS), - ("Print presets", vectorcraft_engine::cmd::printpresets::PRESET_EXTS), - ("CSS", &["css"]), - ("PostScript", &["ps"]), -]; - -/// The file type a save panel shows for a file with extension `ext`: its format's, else its -/// [`FILE_TYPES`] row; none for any other extension. -fn file_types(ext: &str) -> Vec { - if let Some(f) = fileio::format(ext) { - return vec![FilePick::filter(f.label, f.extensions)]; - } - let ext = ext.trim_start_matches('.').to_ascii_lowercase(); - FilePick::filters(FILE_TYPES.iter().find(|(_, exts)| exts.contains(&ext.as_str())).copied()) -} - -/// `path`, else one picked in a save panel (the web: the suggested name) for the active document -/// with extension `ext`, filtered to its file type when it has one ([`file_types`]). A name typed -/// with another format's extension gets `ext` instead ([`with_written_extension`]). -pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { - target_path_keeping(app, path, ext, |_| false) -} - -/// [`target_path`] for an export in format `f` (Export As's OK and [`export`]): a name typed with -/// `.svgz` for an SVG stays as typed, and [`export`] writes it compressed. -pub(crate) fn export_path(app: &mut VectorcraftApp, path: Option, f: &Format) -> Result { - target_path_keeping(app, path, f.extensions[0], |g| f.id == "svg" && g.id == "svgz") -} - -/// [`target_path`], keeping a typed extension of a format `keeps` takes. -fn target_path_keeping(app: &mut VectorcraftApp, path: Option, ext: &str, keeps: impl Fn(&Format) -> bool) -> Result { - if let Some(p) = path { - return Ok(p); - } - let (name, folder) = suggested(app, ext); - let filters = file_types(ext); - pick_path(app, &FilePick { name, folder, filters }, keeps) -} - -/// Keep the active document's saved view current before a save (native files reopen at it). -pub fn remember_view(app: &mut VectorcraftApp) { - let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; - if let Some(st) = app.session.active_mut() { - st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation }); - } -} - -/// A path typed in a save panel: kept when its extension names one of the panel's file types (a -/// save format, or a template format for Save as Template, `mode`). An extension that names -/// another format (`card.png`, or `card.ait` in Save As) is replaced with `f`'s, and `f`'s is -/// added to any other path (`notes.v2` becomes `notes.v2.vectorcraft`). -fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String { - let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; - if fileio::format_for_name(path).is_some_and(|g| !formats.contains(&g.id)) { - return std::path::Path::new(path).with_extension(f.extensions[0]).to_string_lossy().into_owned(); - } - with_extension(path, f.extensions[0], |g| formats.contains(&g.id)) -} - -/// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`, -/// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s -/// place. A path that still ends in the extension of the name the panel suggested (`suggested`), -/// in any letter case, gets the picked type's extension instead, unless that extension is one of -/// the picked type's; such a path stays as typed. Without a picked type, or with one the panel did not -/// offer, [`with_save_extension`] with `f`. -fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String { - let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; - let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else { - return with_save_extension(path, f, mode); - }; - let has = fileio::extension(path); - if !has.is_empty() && has == fileio::extension(suggested) && !g.extensions.contains(&has.as_str()) { - return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned(); - } - with_save_extension(path, g, mode) -} - -/// Write every file of an export (one per artboard, linked images) for the destination `path`. -/// → the export's own files (without the linked images). -fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, String> { - let named = enc.named(doc, path); - for (p, bytes) in &named { - write(p, bytes)?; - } - Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect()) -} - -fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result { - fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string()) -} - -/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the -/// engine command's `{path?, format?, options?, svg?}`. -/// -/// When no path is known, desktop asks with a save panel (one file type per save format, or one -/// per template format for Save as Template) and the web opens the Save As dialog (file name and -/// format). With `ask_options` and no options in `p`, -/// a format picked that way that has options asks for them before anything is written (SVG -/// Options, the Save PDF dialog, or the save options dialog: native files on Save As only, `.ai` -/// files on Save As and Save a Copy), and so does Save As or Save a Copy to a given SVG path. -/// → `{path, format, warnings…}` once written, or `{pending: , -/// path?}` while a dialog is open. -pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { - remember_view(app); - // Writing over the file the document was read from is asked about here, not refused. - let first = plan(app, mode, &acknowledged(p))?; - let ask = ask_options && !has_options(p); - if let Some(path) = first.path.clone() { - if let Some(r) = ask_before_losing(app, &path, save_command(mode), p) { - return Ok(r); - } - if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { - return ask_format_options(app, mode, first.format, &path); - } - return write_plan(app, first); - } - if is_web(app) && ask_options { - return Ok(open_options(app, mode.command(), first.format, &first.name, true)); - } - let filters = FilePick::filters(match mode { - SaveMode::Template => fileio::template_filters(first.format.id), - _ => fileio::save_filters(first.format.id), - }); - let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; - let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; - // The picked file type wins over a `format` param. - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - if let Some(o) = q.as_object_mut() { - o.remove("format"); - o.insert("path".into(), json!(picked)); - } - let chosen = plan(app, mode, &acknowledged(&q))?; - if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { - return Ok(r); - } - if ask && asks_options(mode, chosen.format) { - let path = chosen.path.clone().unwrap_or_default(); - let r = ask_format_options(app, mode, chosen.format, &path); - crate::dialogs::opened_by_panel(app); - return r; - } - write_plan(app, chosen) -} - -/// The UI command that runs a save in `mode`. -fn save_command(mode: SaveMode) -> &'static str { - match mode { - SaveMode::Save => "file.save", - SaveMode::SaveAs => "file.saveAs", - SaveMode::Copy => "file.saveCopy", - SaveMode::Template => "file.saveAsTemplate", - } -} - -/// `p` with `acknowledgeLoss: true`. -fn acknowledged(p: &Value) -> Value { - let mut p = if p.is_object() { p.clone() } else { json!({}) }; - p["acknowledgeLoss"] = json!(true); - p -} - -/// Writing `path` over the file the active document was read from, when reading it left things -/// out (hidden text, art or layers VectorCraft can't read yet): asks first, and OK runs `command` -/// with `params` and `acknowledgeLoss` → `{pending}` while it asks, `None` when nothing is lost. -fn ask_before_losing(app: &mut VectorcraftApp, path: &str, command: &str, params: &Value) -> Option { - let what = fileio::losses_summary(fileio::overwrite_losses(app.session.active()?, path, params)?); - let name = fileio::file_name(path); - let message = crate::i18n::fmt(tl!("Replace “{name}”, the file this document was opened from?"), &[("name", &name)]); - let detail = crate::i18n::fmt( - tl!("Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it."), - &[("what", &what)], - ); - let mut params = acknowledged(params); - if command != "file.save" { - params["path"] = json!(path); - } - dialogs::confirm::ask(app, &message, &detail, command, params); - Some(json!({ "pending": dialogs::confirm::KIND })) -} - -/// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai` -/// files on Save As (the options dialog after the save panel), and `.ai` files also on Save a Copy, -/// so the copy is not written in the version last saved whatever the panel's `.ai` type says -/// (#3141); Save and templates reuse the remembered options or Use Compression. -fn asks_options(mode: SaveMode, f: &Format) -> bool { - !f.options.is_empty() - && match f.id { - "vectorcraft" => mode == SaveMode::SaveAs, - "ai" => matches!(mode, SaveMode::SaveAs | SaveMode::Copy), - "template" | "ait" => false, - _ => true, - } -} - -/// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)? -fn has_options(p: &Value) -> bool { - ["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty()) -} - -/// Ask for format `f`'s options before a save (`mode`) to `path`: SVG Options for SVG, the Save -/// PDF dialog for PDF, else the save options dialog. OK finishes the save. -pub fn ask_format_options(app: &mut VectorcraftApp, mode: SaveMode, f: &Format, path: &str) -> Result { - match f.id { - "pdf" => ask_pdf_options(app, mode, path), - "svg" | "svgz" => { - let m = if mode == SaveMode::Copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save }; - svg_options::open(app, m, Some(path)); - Ok(json!({ "pending": svg_options::KIND, "dialog": svg_options::KIND, "path": path })) - } - _ => Ok(open_options(app, mode.command(), f, path, false)), - } -} - -/// A save as PDF asks with the Save PDF dialog, filled with the PDF options the document was last -/// saved with; its OK finishes the save ([`save_pdf`]). -pub fn ask_pdf_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) -> Result { - let mut p = Value::Object(plan(app, mode, &json!({ "path": path, "format": "pdf" }))?.options); - p["path"] = json!(path); - dialogs::open_save_pdf(app, &p)?; - let d = app.ui.dialog.as_mut().ok_or("the Save PDF dialog didn't open")?; - d.fields.insert("__save".into(), json!(mode.command())); - Ok(json!({ "pending": d.kind })) -} - -/// The Save PDF dialog's OK during a save (`mode`): `params` are its `document.exportPdf` options -/// and `path`. -pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) -> Result { - let path = params.as_object_mut().and_then(|o| o.remove("path")); - let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); - let r = save(app, mode, &json!({ "path": path, "format": "pdf", "options": params }), false)?; - if view && let Some(path) = r["path"].as_str() { - view_file(app, path); - } - Ok(r) -} - -/// Open a written file in the system viewer (not on the web, which downloads it). -fn view_file(app: &mut VectorcraftApp, path: &str) { - if !is_web(app) { - app.open_url(&file_url(path)); - } -} - -/// "Saved ", with the first of the result's warnings. -pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { - app.status(saved_status(path, r)); -} - -/// [`report_saved`]'s message. -fn saved_status(path: &str, r: &Value) -> String { - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - match warnings.first() { - Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()), - None => format!("Saved {path}"), - } -} - -/// Write a planned save through the services and report it in the status bar. -/// With Background Save on, the file is encoded and written from a snapshot off the UI thread -/// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once -/// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format -/// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { - let uid = app.session.active().ok_or("no document")?.uid; - // Saves of one document finish in order. - if app.background.busy_with(uid) { - background::wait_all(app); - } - let job = fileio::save_job(&mut app.session, plan).map_err(|e| e.to_string())?; - let (retargets, target) = (job.plan().retargets(), job.plan().path.clone()); - let (format, warnings) = (job.plan().format.id, job.known_warnings()); - let label = match &target { - Some(path) => format!("Saving {}", fileio::file_name(path)), - None => "Saving".into(), - }; - // Without a path the bytes come back to the caller: nothing to do in the background. - let enabled = app.session.prefs.background_save && target.is_some(); - // The work writes the file; the copy makes it the document's own afterwards. - let done = job.clone(); - // A native file streams into the file as it is encoded (desktop; not to a download). - let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); - let work = move |write: Writer| { - match stream { - Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), - } - .map_err(|e| e.to_string()) - }; - let failed = target.clone().unwrap_or_default(); - let then = move |app: &mut VectorcraftApp, r: Result| { - // Not written: the document keeps its file, format and modified state, and the user is told. - let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; - // The document may have closed meanwhile: the file is written all the same. - done.complete(&mut app.session, uid); - let path = r["path"].as_str().unwrap_or_default().to_string(); - if retargets { - note_recent(app, &path); - } - report_saved(app, &path, &r); - Ok(r) - }; - let mut r = background::run(app, enabled, label, Some(uid), work, then)?; - if let Some(path) = target.filter(|_| r.get("path").is_none()) { - r["path"] = json!(path); - } - if r.get("background").and_then(Value::as_bool) == Some(true) { - r["format"] = json!(format); - r["warnings"] = json!(warnings); - } - Ok(r) -} - -/// Open the save options dialog of format `f` before writing `path`: `action` is the save command -/// it finishes, and `pick` (the web's Save As) also names the file and chooses the format. -fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str, pick: bool) -> Value { - let mut fields = dialogs::save_options::option_values(app, f); - fields.insert("__action".into(), json!(action)); - fields.insert("__pick".into(), json!(pick)); - fields.insert("path".into(), json!(path)); - fields.insert("format".into(), json!(f.id)); - app.ui.dialog = Some(Dialog { kind: dialogs::save_options::KIND.into(), fields }); - json!({ "pending": dialogs::save_options::KIND }) -} - -/// The most recent files remembered: Preferences → File Handling → Number of Recent Files to Display -/// shows 0 to this many of them. -pub const MAX_RECENT_FILES: usize = 30; - -/// Put `path` at the top of File → Open Recent Files. -pub fn note_recent(app: &mut VectorcraftApp, path: &str) { - let r = &mut app.ui.recent_files; - r.retain(|p| p != path); - r.insert(0, path.to_string()); - r.truncate(MAX_RECENT_FILES); -} - -/// The recent files File → Open Recent Files lists. -pub fn recent_files(app: &VectorcraftApp) -> &[String] { - let r = &app.ui.recent_files; - &r[..r.len().min(app.session.prefs.recent_files_count as usize)] -} - -/// The status once an export was cancelled. -pub const EXPORT_CANCELLED: &str = "Export cancelled"; - -/// File → Cancel Export (the progress dialog's Cancel): every export running in the background -/// stops and writes nothing more → `{cancelled: n}`. -pub fn cancel_exports(app: &mut VectorcraftApp) -> Value { - let n = app.background.cancel_exports(); - if n > 0 { - app.status(EXPORT_CANCELLED); - } - json!({ "cancelled": n }) -} - -/// The progress dialog's Continue in Background: the exports go on, shown in the status bar only -/// → `{hidden: n}`. -pub fn hide_exports(app: &mut VectorcraftApp) -> Value { - json!({ "hidden": app.background.hide_exports() }) -} - -/// Export the active document in `format` (default: the path's extension, else PNG) with the -/// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG -/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The -/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are -/// several). -pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { - // Options given as anything but an object are none: the path and format still reach the - // encoder (an exporter plug-in is told the file it writes). - let params = &if params.is_object() { params.clone() } else { json!({}) }; - let f = fileio::writable_format(format, path.as_deref())?; - let st = app.session.active().ok_or("no document")?; - let doc = match fileio::export_source(st, params).map_err(|e| e.to_string())? { - std::borrow::Cow::Borrowed(_) => st.doc.clone(), - std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), - }; - let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); - let path = export_path(app, path, f)?; - let mut again = params.clone(); - if let Some(o) = again.as_object_mut() { - o.insert("format".into(), json!(f.id)); - } - if let Some(r) = ask_before_losing(app, &path, "file.exportAs", &again) { - return Ok(r); - } - // An SVG given a .svgz name is written compressed. - let f = match fileio::format_for_name(&path) { - Some(z) if f.id == "svg" && z.id == "svgz" => z, - _ => f, - }; - // Background Export: encoded and written from this snapshot while editing goes on. The - // encoder is told the file it writes (an exporter plug-in gets its name and extension). - let label = format!("Exporting {}", fileio::file_name(&path)); - let (mut params, target) = (params.clone(), path.clone()); - if let Some(o) = params.as_object_mut() { - o.insert("path".into(), json!(path)); - } - // The progress dialog's counts and Cancel: a cancelled export stops drawing and never starts - // writing. - let progress = Progress::new(); - let (reporting, asked) = (progress.clone(), progress.clone()); - let work = move |write: Writer| { - progress::scope(&reporting, || { - cancel::check().map_err(|e| e.to_string())?; - let enc = fileio::encode_all(&doc, f.id, ¶ms).map_err(|e| e.to_string())?; - cancel::check().map_err(|e| e.to_string())?; - let files = write_encoded(write, &doc, &path, &enc)?; - let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings }); - if files.len() > 1 { - out["files"] = json!(files); - } - Ok(out) - }) - }; - let enabled = app.session.prefs.background_export; - let failed = target.clone(); - let mut r = background::run_with_progress(app, enabled, label, None, Some(progress), work, move |app, r| { - // A file written before the Cancel arrived is reported as written. - if r.is_err() && asked.cancelled() { - app.status(EXPORT_CANCELLED); - return Ok(json!({ "cancelled": true })); - } - let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?; - let what = match r["files"].as_array() { - Some(files) => format!("{} files", files.len()), - None => r["path"].as_str().unwrap_or_default().to_string(), - }; - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - app.status(match warnings.first() { - Some(first) => format!("Exported {what} with {} note(s): {first}", warnings.len()), - None => format!("Exported {what}"), - }); - Ok(r) - })?; - if r.get("path").is_none() { - r["path"] = json!(target); - } - Ok(r) -} - -/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path -/// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs' -/// Export…), reported with the first of the command's warnings ([`report_saved`]). -pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result { - let (path, v) = run_to_file(app, id, ext, params)?; - report_saved(app, &path, &v); - Ok(path) -} - -/// Run an engine command that returns `{data}` or `{dataBase64}` and write the bytes to a file -/// picked with file name `name` suggested next to the document (the web downloads them as `name`) -/// → where they went. -pub(crate) fn save_command_output_named(app: &mut VectorcraftApp, id: &str, name: &str, params: Value) -> Result { - let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; - let bytes = take_output(&mut v)?; - let path = write_named(app, None, name, &bytes)?; - app.status(format!("Saved {path}")); - Ok(path) -} - -/// The bytes a command returned, taken out of its result `v`, which keeps the rest: binary output -/// comes as base64, text (swatch libraries) as is. -fn take_output(v: &mut Value) -> Result, String> { - let o = v.as_object_mut().ok_or("no data")?; - let bytes = match (o.remove("dataBase64"), o.remove("data")) { - (Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64), - (_, Some(Value::String(text))) => Some(text.into_bytes()), - _ => None, - }; - bytes.ok_or_else(|| "no data".into()) -} - -/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to -/// that path (else a picked or suggested name) → (path, the command's result without the data). -/// The command runs before a path is asked for, so bad params never open a save dialog, except -/// for Export Selection without a `format`, which takes it from the picked path. Export Selection -/// with a `format` picks a file with that format's extension in place of `ext`. -pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> { - let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string)); - let selection = id == "document.exportSelection"; - let given = params.get("format").and_then(Value::as_str).and_then(fileio::format).filter(|_| selection); - let ext = given.map_or(ext, |f| f.extensions[0]); - if let Some(o) = params.as_object_mut() - && o.get("format").is_none() - && selection - { - let picked = target_path_keeping(app, path, ext, |_| true)?; - let e = Some(fileio::extension(&picked)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into()); - o.insert("format".into(), serde_json::json!(e)); - path = Some(picked); - } - let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; - let bytes = take_output(&mut v)?; - let path = match path { - Some(p) => p, - None => target_path(app, None, ext)?, - }; - write_reporting(app, &path, &bytes)?; - Ok((path, v)) -} - -/// File → Save as PDF: `document.exportPdf` with `params` (the Save PDF dialog's options), written -/// to `path` (else a picked or suggested name) → `{path, bytes, warnings}`. With -/// `viewAfterSaving`, the written file opens in the system viewer (not on the web, which -/// downloads it). -pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result { - let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); - let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?; - if view { - view_file(app, &path); - } - report_saved(app, &path, &v); - v["path"] = Value::String(path); - Ok(v) -} - -/// `path` as an absolute `file://` URL for the system opener (bytes other than letters, digits and -/// `/-._~:` percent-encoded). -pub(crate) fn file_url(path: &str) -> String { - let abs = std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()); - let abs = abs.replace('\\', "/"); - let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" }); - for b in abs.bytes() { - if b.is_ascii_alphanumeric() || b"/-._~:".contains(&b) { - url.push(char::from(b)); - } else { - url.push_str(&format!("%{b:02X}")); - } - } - url -} - -/// File → Revert: ask first; OK runs `file.revert` with `confirmed`, which goes to the engine. -pub fn ask_revert(app: &mut VectorcraftApp) -> Result { - let c = vectorcraft_engine::find_command("file.revert").ok_or("no revert command")?; - (c.enabled)(&app.session)?; - let name = app.session.active().map(|d| d.title()).unwrap_or_default(); - let message = crate::i18n::fmt(tl!("Revert to the saved version of “{name}”?"), &[("name", &name)]); - dialogs::confirm::ask(app, &message, tl!("Changes made since it was last saved will be lost."), "file.revert", json!({ "confirmed": true })); - Ok(json!({ "pending": dialogs::confirm::KIND })) -} - -/// File → Show in Folder: the document's file in the system file manager. -pub fn reveal(app: &mut VectorcraftApp) -> Result { - let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?; - reveal_path(app, &path)?; - Ok(json!({ "path": path })) -} - -/// Show the file at `path` in the system file manager (desktop). -pub(crate) fn reveal_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { - app.services.reveal.as_mut().ok_or("no file manager here")?(path) -} - -/// Open `path` (a file or a folder) in the system's default app for it (desktop). -pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { - app.services.open_file.as_mut().ok_or("no app to open files here")?(path) -} - -/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; -/// without one the web downloads them (the ZIP, or each file) and the other frontends get them -/// back as base64. With `openLocation`, the file manager shows the first file written. When an -/// export into a folder or a download has warnings, the status bar shows the first as -/// [`report_saved`] shows a save's notes: "Saved with n note(s): …". -pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { - export_files(app, "document.exportForScreens", params) -} - -/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`). -/// On the desktop with Background Export on, an export into a folder is checked, journaled and -/// prepared from the document as it is now, and the files are encoded and written on the -/// background thread ([`background`]): this returns `{background: true, status}` at once, with the -/// progress dialog's Cancel, and the status bar reports the files once they are written. -pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Result { - let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); - let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); - // Without a folder the files come back in the reply, which the caller waits for. - let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); - if !in_background { - // Made at once, after the exports and saves started before it. - background::wait_all(app); - let r = app.run(id, params)?; - return report_exported(app, r, &folder, open_location); - } - let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; - let label = format!("Exporting {}", fileio::file_name(&folder)); - // The progress dialog counts the files, and its Cancel stops the export before the next one. - let progress = Progress::of_files(); - let (reporting, asked) = (progress.clone(), progress.clone()); - let work = move |write: Writer| { - progress::scope(&reporting, || { - let mut write = |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); - export.write(&mut write).map_err(|e| e.to_string()) - }) - }; - background::run_with_progress(app, true, label, None, Some(progress), work, move |app, r| { - // Files written before the Cancel arrived stay in the folder. - if r.is_err() && asked.cancelled() { - app.status(EXPORT_CANCELLED); - return Ok(json!({ "cancelled": true })); - } - let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; - report_exported(app, r, &folder, open_location) - }) -} - -/// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count -/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, -/// the file manager shows the first file written. -fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { - let count = r["files"].as_array().map_or(0, Vec::len); - let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); - // Files that came back as bytes: (name, base64). - let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() { - Some(zip) => vec![(r["name"].as_str().unwrap_or("Export.zip"), zip)], - None => { - r["files"].as_array().into_iter().flatten().filter_map(|f| Some((f.get("name")?.as_str()?, f.get("dataBase64")?.as_str()?))).collect() - } - }; - if returned.is_empty() { - let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); - let mut status = format!("Exported {count} file(s) to {folder}"); - if warned { - status = saved_status(r["path"].as_str().unwrap_or(folder), &r); - } - if open_location - && let Some(first) = first - && let Err(e) = reveal_path(app, first) - { - status = format!("{status} ({e})"); - } - app.status(status); - } else if let Some(download) = app.services.download.as_mut() { - for (name, data) in &returned { - let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; - download(name, &bytes)?; - } - let what = match returned.as_slice() { - [(name, _)] => (*name).to_string(), - files => format!("{} files", files.len()), - }; - match returned.first().filter(|_| warned) { - Some((name, _)) => report_saved(app, name, &r), - None => app.status(format!("Downloaded {what}")), - } - } - Ok(r) -} - -/// Place a file's bytes (no path, so embedded) centered in the view: `file.place`, read off the UI -/// thread on the desktop ([`crate::place::run_for_user`]). -pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { - crate::place::run_for_user(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) -} +mod export; +mod open; +mod save; + +pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; +pub(crate) use export::{export_files, file_url, open_in_app, reveal_path, run_to_file, save_command_output_named}; +#[cfg(test)] +pub(crate) use open::open_file; +pub(crate) use open::opened; +pub use open::{ + MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, + open_path, open_recent_reporting, open_reporting, opens_as, place_bytes, recent_files, report_open_error, +}; +#[cfg(test)] +pub(crate) use save::report_write_error; +pub use save::{ask_format_options, ask_pdf_options, remember_view, save, save_pdf}; +pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs new file mode 100644 index 000000000..05df48941 --- /dev/null +++ b/crates/ui-egui/src/io/export.rs @@ -0,0 +1,302 @@ +use super::save::{ + ask_before_losing, export_path, is_web, report_saved, report_write_error, saved_status, target_path, target_path_keeping, view_file, + write_encoded, write_named, write_reporting, +}; +use super::*; + +/// The status once an export was cancelled. +pub const EXPORT_CANCELLED: &str = "Export cancelled"; + +/// File → Cancel Export (the progress dialog's Cancel): every export running in the background +/// stops and writes nothing more → `{cancelled: n}`. +pub fn cancel_exports(app: &mut VectorcraftApp) -> Value { + let n = app.background.cancel_exports(); + if n > 0 { + app.status(EXPORT_CANCELLED); + } + json!({ "cancelled": n }) +} + +/// The progress dialog's Continue in Background: the exports go on, shown in the status bar only +/// → `{hidden: n}`. +pub fn hide_exports(app: &mut VectorcraftApp) -> Value { + json!({ "hidden": app.background.hide_exports() }) +} + +/// Export the active document in `format` (default: the path's extension, else PNG) with the +/// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG +/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The +/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are +/// several). +pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { + // Options given as anything but an object are none: the path and format still reach the + // encoder (an exporter plug-in is told the file it writes). + let params = &if params.is_object() { params.clone() } else { json!({}) }; + let f = fileio::writable_format(format, path.as_deref())?; + let st = app.session.active().ok_or("no document")?; + let doc = match fileio::export_source(st, params).map_err(|e| e.to_string())? { + std::borrow::Cow::Borrowed(_) => st.doc.clone(), + std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + }; + let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); + let path = export_path(app, path, f)?; + let mut again = params.clone(); + if let Some(o) = again.as_object_mut() { + o.insert("format".into(), json!(f.id)); + } + if let Some(r) = ask_before_losing(app, &path, "file.exportAs", &again) { + return Ok(r); + } + // An SVG given a .svgz name is written compressed. + let f = match fileio::format_for_name(&path) { + Some(z) if f.id == "svg" && z.id == "svgz" => z, + _ => f, + }; + // Background Export: encoded and written from this snapshot while editing goes on. The + // encoder is told the file it writes (an exporter plug-in gets its name and extension). + let label = format!("Exporting {}", fileio::file_name(&path)); + let (mut params, target) = (params.clone(), path.clone()); + if let Some(o) = params.as_object_mut() { + o.insert("path".into(), json!(path)); + } + // The progress dialog's counts and Cancel: a cancelled export stops drawing and never starts + // writing. + let progress = Progress::new(); + let (reporting, asked) = (progress.clone(), progress.clone()); + let work = move |write: Writer| { + progress::scope(&reporting, || { + cancel::check().map_err(|e| e.to_string())?; + let enc = fileio::encode_all(&doc, f.id, ¶ms).map_err(|e| e.to_string())?; + cancel::check().map_err(|e| e.to_string())?; + let files = write_encoded(write, &doc, &path, &enc)?; + let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings }); + if files.len() > 1 { + out["files"] = json!(files); + } + Ok(out) + }) + }; + let enabled = app.session.prefs.background_export; + let failed = target.clone(); + let mut r = background::run_with_progress(app, enabled, label, None, Some(progress), work, move |app, r| { + // A file written before the Cancel arrived is reported as written. + if r.is_err() && asked.cancelled() { + app.status(EXPORT_CANCELLED); + return Ok(json!({ "cancelled": true })); + } + let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?; + let what = match r["files"].as_array() { + Some(files) => format!("{} files", files.len()), + None => r["path"].as_str().unwrap_or_default().to_string(), + }; + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + app.status(match warnings.first() { + Some(first) => format!("Exported {what} with {} note(s): {first}", warnings.len()), + None => format!("Exported {what}"), + }); + Ok(r) + })?; + if r.get("path").is_none() { + r["path"] = json!(target); + } + Ok(r) +} + +/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path +/// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs' +/// Export…), reported with the first of the command's warnings ([`report_saved`]). +pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result { + let (path, v) = run_to_file(app, id, ext, params)?; + report_saved(app, &path, &v); + Ok(path) +} + +/// Run an engine command that returns `{data}` or `{dataBase64}` and write the bytes to a file +/// picked with file name `name` suggested next to the document (the web downloads them as `name`) +/// → where they went. +pub(crate) fn save_command_output_named(app: &mut VectorcraftApp, id: &str, name: &str, params: Value) -> Result { + let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; + let bytes = take_output(&mut v)?; + let path = write_named(app, None, name, &bytes)?; + app.status(format!("Saved {path}")); + Ok(path) +} + +/// The bytes a command returned, taken out of its result `v`, which keeps the rest: binary output +/// comes as base64, text (swatch libraries) as is. +fn take_output(v: &mut Value) -> Result, String> { + let o = v.as_object_mut().ok_or("no data")?; + let bytes = match (o.remove("dataBase64"), o.remove("data")) { + (Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64), + (_, Some(Value::String(text))) => Some(text.into_bytes()), + _ => None, + }; + bytes.ok_or_else(|| "no data".into()) +} + +/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to +/// that path (else a picked or suggested name) → (path, the command's result without the data). +/// The command runs before a path is asked for, so bad params never open a save dialog, except +/// for Export Selection without a `format`, which takes it from the picked path. Export Selection +/// with a `format` picks a file with that format's extension in place of `ext`. +pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> { + let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string)); + let selection = id == "document.exportSelection"; + let given = params.get("format").and_then(Value::as_str).and_then(fileio::format).filter(|_| selection); + let ext = given.map_or(ext, |f| f.extensions[0]); + if let Some(o) = params.as_object_mut() + && o.get("format").is_none() + && selection + { + let picked = target_path_keeping(app, path, ext, |_| true)?; + let e = Some(fileio::extension(&picked)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into()); + o.insert("format".into(), serde_json::json!(e)); + path = Some(picked); + } + let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; + let bytes = take_output(&mut v)?; + let path = match path { + Some(p) => p, + None => target_path(app, None, ext)?, + }; + write_reporting(app, &path, &bytes)?; + Ok((path, v)) +} + +/// File → Save as PDF: `document.exportPdf` with `params` (the Save PDF dialog's options), written +/// to `path` (else a picked or suggested name) → `{path, bytes, warnings}`. With +/// `viewAfterSaving`, the written file opens in the system viewer (not on the web, which +/// downloads it). +pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result { + let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); + let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?; + if view { + view_file(app, &path); + } + report_saved(app, &path, &v); + v["path"] = Value::String(path); + Ok(v) +} + +/// `path` as an absolute `file://` URL for the system opener (bytes other than letters, digits and +/// `/-._~:` percent-encoded). +pub(crate) fn file_url(path: &str) -> String { + let abs = std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()); + let abs = abs.replace('\\', "/"); + let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" }); + for b in abs.bytes() { + if b.is_ascii_alphanumeric() || b"/-._~:".contains(&b) { + url.push(char::from(b)); + } else { + url.push_str(&format!("%{b:02X}")); + } + } + url +} + +/// File → Show in Folder: the document's file in the system file manager. +pub fn reveal(app: &mut VectorcraftApp) -> Result { + let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?; + reveal_path(app, &path)?; + Ok(json!({ "path": path })) +} + +/// Show the file at `path` in the system file manager (desktop). +pub(crate) fn reveal_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { + app.services.reveal.as_mut().ok_or("no file manager here")?(path) +} + +/// Open `path` (a file or a folder) in the system's default app for it (desktop). +pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { + app.services.open_file.as_mut().ok_or("no app to open files here")?(path) +} + +/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; +/// without one the web downloads them (the ZIP, or each file) and the other frontends get them +/// back as base64. With `openLocation`, the file manager shows the first file written. When an +/// export into a folder or a download has warnings, the status bar shows the first as +/// [`report_saved`] shows a save's notes: "Saved with n note(s): …". +pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { + export_files(app, "document.exportForScreens", params) +} + +/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`). +/// On the desktop with Background Export on, an export into a folder is checked, journaled and +/// prepared from the document as it is now, and the files are encoded and written on the +/// background thread ([`background`]): this returns `{background: true, status}` at once, with the +/// progress dialog's Cancel, and the status bar reports the files once they are written. +pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Result { + let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); + let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); + // Without a folder the files come back in the reply, which the caller waits for. + let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); + if !in_background { + // Made at once, after the exports and saves started before it. + background::wait_all(app); + let r = app.run(id, params)?; + return report_exported(app, r, &folder, open_location); + } + let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; + let label = format!("Exporting {}", fileio::file_name(&folder)); + // The progress dialog counts the files, and its Cancel stops the export before the next one. + let progress = Progress::of_files(); + let (reporting, asked) = (progress.clone(), progress.clone()); + let work = move |write: Writer| { + progress::scope(&reporting, || { + let mut write = |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + export.write(&mut write).map_err(|e| e.to_string()) + }) + }; + background::run_with_progress(app, true, label, None, Some(progress), work, move |app, r| { + // Files written before the Cancel arrived stay in the folder. + if r.is_err() && asked.cancelled() { + app.status(EXPORT_CANCELLED); + return Ok(json!({ "cancelled": true })); + } + let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; + report_exported(app, r, &folder, open_location) + }) +} + +/// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count +/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, +/// the file manager shows the first file written. +fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { + let count = r["files"].as_array().map_or(0, Vec::len); + let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); + // Files that came back as bytes: (name, base64). + let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() { + Some(zip) => vec![(r["name"].as_str().unwrap_or("Export.zip"), zip)], + None => { + r["files"].as_array().into_iter().flatten().filter_map(|f| Some((f.get("name")?.as_str()?, f.get("dataBase64")?.as_str()?))).collect() + } + }; + if returned.is_empty() { + let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); + let mut status = format!("Exported {count} file(s) to {folder}"); + if warned { + status = saved_status(r["path"].as_str().unwrap_or(folder), &r); + } + if open_location + && let Some(first) = first + && let Err(e) = reveal_path(app, first) + { + status = format!("{status} ({e})"); + } + app.status(status); + } else if let Some(download) = app.services.download.as_mut() { + for (name, data) in &returned { + let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; + download(name, &bytes)?; + } + let what = match returned.as_slice() { + [(name, _)] => (*name).to_string(), + files => format!("{} files", files.len()), + }; + match returned.first().filter(|_| warned) { + Some((name, _)) => report_saved(app, name, &r), + None => app.status(format!("Downloaded {what}")), + } + } + Ok(r) +} diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs new file mode 100644 index 000000000..35da8b1b3 --- /dev/null +++ b/crates/ui-egui/src/io/open.rs @@ -0,0 +1,362 @@ +use super::save::suggested; +use super::*; + +/// Template extensions New from Template's open dialog lists first. +const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; + +/// Open bytes of any readable format as a new document (templates open untitled); swatch and +/// graphic style library files open in the library panel, Libraries panel files are added to it, +/// flattener, PDF, print and perspective grid presets files are imported, a shortcut set another +/// app saved (`.kys`) opens Import Shortcut Set, and a script written for the reference app +/// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for +/// a document opened now (its `warnings` say what didn't come in as it was), else null. +pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result { + let opens = opens_as(name); + // A WebAssembly plug-in is installed. + if opens == Some(OpensAs::Plugin) { + let module = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}); + return install_module(app, module).map(|_| Value::Null); + } + if opens == Some(OpensAs::ShortcutSet) { + return dialogs::shortcut_import::open(app, name, bytes).map(|_| Value::Null); + } + if opens == Some(OpensAs::Script) { + let p = match path { + Some(path) => json!({ "path": path }), + None => json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), + }; + return dialogs::script_check::command(app, &p).map(|_| Value::Null); + } + if let Some(OpensAs::Presets { import, what }) = opens { + let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; + let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + app.status(format!("Imported {what}: {}", names.join(", "))); + return Ok(Value::Null); + } + if opens == Some(OpensAs::Library) { + return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null); + } + let swatches = opens == Some(OpensAs::SwatchLibrary); + if swatches || opens == Some(OpensAs::StyleLibrary) { + let p = match path { + Some(path) => serde_json::json!({ "path": path }), + // `.ase` swatch libraries are binary. + None if swatches => serde_json::json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), + None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}), + }; + let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; + return load(app, p).map(|_| Value::Null); + } + // A PDF with several pages or a password asks first (the Import PDF dialog), and so does a + // DXF drawing (DXF Import Options). + if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) + || crate::dialogs::dxf_import::offer(app, name, bytes, path.clone(), None) + { + return Ok(Value::Null); + } + open_document(app, name, bytes, path, &Value::Null) +} + +/// The kinds of file File → Open installs, imports, checks, shows in the library panel or the +/// Libraries panel, or reviews in Import Shortcut Set ([`opens_as`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OpensAs { + /// A WebAssembly plug-in: installed. + Plugin, + /// A shortcut set another app saved (`.kys`): reviewed in Import Shortcut Set. + ShortcutSet, + /// A script written for the reference app: checked without running it (Check Script…). + Script, + /// Flattener, PDF, print or perspective grid presets: imported with command `import`, and + /// `what` names them in the status bar. + Presets { import: &'static str, what: &'static str }, + /// A Libraries panel file: added to the Libraries panel as a new library. + Library, + /// A swatch library: opened in the library panel. + SwatchLibrary, + /// A graphic style library: opened in the library panel. + StyleLibrary, +} + +/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks, +/// shows in the library panel or the Libraries panel, or reviews in Import Shortcut Set +/// ([`open_bytes`]), else `None`. A drop opens these files the same way, wherever they are dropped +/// ([`VectorcraftApp::drop_target`]). +pub fn opens_as(name: &str) -> Option { + let ext = fileio::extension(name); + let presets = [ + (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), + (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), + (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), + (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), + ]; + if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { + Some(OpensAs::Plugin) + } else if vectorcraft_engine::cmd::shortcutset::EXTS.contains(&ext.as_str()) { + Some(OpensAs::ShortcutSet) + } else if vectorcraft_engine::cmd::scripts::EXTS.contains(&ext.as_str()) { + Some(OpensAs::Script) + } else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { + Some(OpensAs::Presets { import, what }) + } else if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) { + Some(OpensAs::Library) + } else if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()) { + Some(OpensAs::SwatchLibrary) + } else if ext == vectorcraft_doc::style_libs::STYLES_EXT { + Some(OpensAs::StyleLibrary) + } else { + None + } +} + +/// Open a document through the engine loader with the `document.open` options in `p` → +/// `document.open`'s result. +pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { + let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?; + opened(app, &r); + Ok(r) +} + +/// A document just opened (`r`: `document.open`'s result): the views follow, the status bar says +/// what didn't come in as it was, and missing links are asked about. +pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { + app.sync_views(); + if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { + app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default())); + } + crate::dialogs::missing_links::after_open(app, r); +} + +/// A path from the open dialog, or "cancelled". +fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result { + crate::picks::open(app, pick).ok_or_else(|| "cancelled".into()) +} + +/// Object › Plug-ins › Install Plug-in…: installs the module `p` gives (`path`, or `dataBase64` +/// with `name`), else a picked `.wasm` (the web's file picker hands the file to [`open_bytes`], +/// which installs it) → [`install_module`]'s result. +pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result { + let given = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + if given("path") || given("dataBase64") { + return install_module(app, p.clone()); + } + if app.services.open_async.is_some() { + return open_dialog(app).map(|_| Value::Null); + } + let path = + pick_open(app, &FilePick { filters: vec![FilePick::filter("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?; + install_module(app, json!({ "path": path })) +} + +/// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` +/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't +/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. +pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { + if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { + let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); + let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source.clone())?; + if info["budget"] == "heavy" { + let name = info["name"].as_str().unwrap_or_default(); + let (minutes, times) = vectorcraft_plugins::heavy_terms(); + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); + let detail = crate::i18n::fmt( + tl!( + "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." + ), + &[("minutes", &minutes.to_string()), ("times", ×.to_string())], + ); + let mut allowed = source; + allowed["allowHeavy"] = json!(true); + dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); + return Ok(json!({ "pending": dialogs::confirm::KIND })); + } + } + let r = app.run("plugin.install", module)?; + app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); + Ok(r) +} + +/// Where File → Open… starts: the folder of the most recently opened file, else the active +/// document's (#4122). +fn open_dialog_folder(app: &VectorcraftApp) -> Option { + app.ui.recent_files.iter().find_map(|p| fileio::dialog_folder(p)).or_else(|| suggested(app, "").1) +} + +/// File → Open… +pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { + if let Some(f) = app.services.open_async.as_mut() { + f(); + return Ok(()); + } + let path = + pick_open(app, &FilePick { filters: FilePick::filters(fileio::open_filters()), folder: open_dialog_folder(app), ..Default::default() })?; + open_reporting(app, &path).map(|_| ()) +} + +/// A file the user asked to open (File › Open, Open Recent, the Home screen, a drop, the Finder) +/// couldn't be: say so in a dialog as well as the status bar, which is easily missed (#861). +pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { + app.status(format!("Couldn't open {name}: {e}")); + let message = crate::i18n::fmt(tl!("Can't open “{name}”."), &[("name", name)]); + dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e)); +} + +/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On +/// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then +/// `{background: true, status}`, and the document opens in its tab (or the failure is reported) +/// once it is ready. +pub fn open_reporting(app: &mut VectorcraftApp, path: &str) -> Result { + let r = open_file(app, path, crate::opening::available()); + if let Err(e) = &r { + report_open_error(app, &fileio::file_name(path), e); + } + r +} + +/// Home › Recent Files and File › Open Recent: focus an already-open tab for that path instead of +/// calling `document.open` again (#3455). +pub fn open_recent_reporting(app: &mut VectorcraftApp, path: &str) -> Result { + if let Some(r) = try_activate_open_document(app, path)? { + return Ok(r); + } + open_reporting(app, path) +} + +fn try_activate_open_document(app: &mut VectorcraftApp, path: &str) -> Result, String> { + let Some(index) = document_index_for_path(app, path) else { + return Ok(None); + }; + let st = &app.session.documents()[index]; + let r = json!({ + "index": index, + "title": st.title(), + "format": st.format, + "warnings": [], + "restored": false, + "activated": true, + }); + app.run("document.activate", json!({ "index": index }))?; + opened(app, &r); + Ok(Some(r)) +} + +fn document_index_for_path(app: &VectorcraftApp, path: &str) -> Option { + app.session.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_open_path(p, path))) +} + +fn same_open_path(a: &str, b: &str) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +fn read(app: &VectorcraftApp, path: &str) -> Result, String> { + app.services.read.as_ref().ok_or("no file reader")?(path) +} + +/// Open the file at `path` (see [`open_bytes`]) → `document.open`'s result, or null. +pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result { + open_file(app, path, false) +} + +/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks +/// first) is decoded off the UI thread ([`crate::opening::start`]). +pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { + // A shortcut set is read by Import Shortcut Set, up to its reader's cap. + if dialogs::shortcut_import::is_kys(path) { + dialogs::shortcut_import::command(app, &json!({ "path": path }))?; + note_recent(app, path); + return Ok(Value::Null); + } + // A script is read by `script.check`, up to its limit. + if opens_as(path) == Some(OpensAs::Script) { + dialogs::script_check::command(app, &json!({ "path": path }))?; + note_recent(app, path); + return Ok(Value::Null); + } + let mut bytes = read(app, path)?; + if background && opens_as(path).is_none() { + // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. + if crate::dialogs::import_pdf::offer(app, path, &bytes, Some(path.to_string()), None) + || crate::dialogs::dxf_import::offer(app, path, &bytes, Some(path.to_string()), None) + { + note_recent(app, path); + return Ok(Value::Null); + } + match crate::opening::start(app, path, bytes) { + Ok(r) => return Ok(r), + // No thread: at once. + Err(back) => bytes = back, + } + } + let r = open_bytes(app, path, &bytes, Some(path.to_string()))?; + note_recent(app, path); + Ok(r) +} + +/// File → New from Template…: a template (or any readable file) as a new untitled document. Without +/// a path the open dialog starts in the Templates folder. On the web the browser's file picker +/// opens it (`.vctemplate`, `.ait` and template files open untitled there too). +pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Result { + let path = match path { + Some(p) => p, + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => { + let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); + pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? + } + }; + let bytes = read(app, &path)?; + let r = fileio::open_template(&mut app.session, &path, &bytes, Some(&path)).map_err(|e| e.to_string())?; + app.sync_views(); + crate::dialogs::missing_links::after_open(app, &r); + Ok(r) +} + +/// Open the save options dialog of format `f` before writing `path`: `action` is the save command +/// it finishes, and `pick` (the web's Save As) also names the file and chooses the format. +pub(super) fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str, pick: bool) -> Value { + let mut fields = dialogs::save_options::option_values(app, f); + fields.insert("__action".into(), json!(action)); + fields.insert("__pick".into(), json!(pick)); + fields.insert("path".into(), json!(path)); + fields.insert("format".into(), json!(f.id)); + app.ui.dialog = Some(Dialog { kind: dialogs::save_options::KIND.into(), fields }); + json!({ "pending": dialogs::save_options::KIND }) +} + +/// The most recent files remembered: Preferences → File Handling → Number of Recent Files to Display +/// shows 0 to this many of them. +pub const MAX_RECENT_FILES: usize = 30; + +/// Put `path` at the top of File → Open Recent Files. +pub fn note_recent(app: &mut VectorcraftApp, path: &str) { + let r = &mut app.ui.recent_files; + r.retain(|p| p != path); + r.insert(0, path.to_string()); + r.truncate(MAX_RECENT_FILES); +} + +/// The recent files File → Open Recent Files lists. +pub fn recent_files(app: &VectorcraftApp) -> &[String] { + let r = &app.ui.recent_files; + &r[..r.len().min(app.session.prefs.recent_files_count as usize)] +} + +/// File → Revert: ask first; OK runs `file.revert` with `confirmed`, which goes to the engine. +pub fn ask_revert(app: &mut VectorcraftApp) -> Result { + let c = vectorcraft_engine::find_command("file.revert").ok_or("no revert command")?; + (c.enabled)(&app.session)?; + let name = app.session.active().map(|d| d.title()).unwrap_or_default(); + let message = crate::i18n::fmt(tl!("Revert to the saved version of “{name}”?"), &[("name", &name)]); + dialogs::confirm::ask(app, &message, tl!("Changes made since it was last saved will be lost."), "file.revert", json!({ "confirmed": true })); + Ok(json!({ "pending": dialogs::confirm::KIND })) +} + +/// Place a file's bytes (no path, so embedded) centered in the view: `file.place`, read off the UI +/// thread on the desktop ([`crate::place::run_for_user`]). +pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { + crate::place::run_for_user(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) +} diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs new file mode 100644 index 000000000..b5489b33d --- /dev/null +++ b/crates/ui-egui/src/io/save.rs @@ -0,0 +1,440 @@ +use super::export::file_url; +use super::open::{note_recent, open_options}; +use super::*; + +/// A save or an export that didn't write `path`: say so in a dialog that names the file and the +/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which +/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved +/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request +/// an agent sent and waits on: the error is its reply. +pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) { + if app.ui.answering_control { + return; + } + let name = fileio::file_name(path); + let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") }; + dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e)); +} + +/// [`write_to`], a failure reported ([`report_write_error`]). +pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { + let r = write_to(&mut app.services, path, bytes); + if let Err(e) = &r { + report_write_error(app, false, path, e); + } + r +} + +/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. +pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> { + if let Some(dl) = services.download.as_mut() { + return dl(&fileio::file_name(path), bytes); + } + services.write.as_mut().ok_or("no file writer")?(path, bytes) +} + +/// The web saves by downloading: no save panel, no folders. +pub(crate) fn is_web(app: &VectorcraftApp) -> bool { + app.services.download.is_some() +} + +/// Write `bytes` to `path`, else to a file picked in a save panel that suggests `name` next to the +/// document and shows its file type, [`file_types`] (the web downloads them as `name`) → where +/// they went. +pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: &str, bytes: &[u8]) -> Result { + let path = match path { + Some(p) => p, + None => { + let (_, folder) = suggested(app, ""); + let filters = file_types(&fileio::extension(name)); + pick_path(app, &FilePick { name: name.to_string(), folder, filters }, |_| false)? + } + }; + write_reporting(app, &path, bytes)?; + Ok(path) +} + +/// Where a file goes when no path was given: the suggested name on the web (a download), else the +/// save panel's choice with the suggested name's extension, [`with_written_extension`]: a name +/// typed with the extension of a format `keeps` takes stays as typed too. +fn pick_path(app: &mut VectorcraftApp, pick: &FilePick, keeps: impl Fn(&Format) -> bool) -> Result { + let ext = fileio::extension(&pick.name); + pick_finished(app, pick, |picked, _| with_written_extension(picked, &ext, &keeps)) +} + +/// `path` as typed in a save panel for a file with extension `ext`: kept when its extension is +/// `ext` in any letter case, or names the format `ext` names (`photo.jpeg` for a `.jpg` file) or +/// a format `keeps` takes. An extension that names another format (`card.svg` for a `.png` file) +/// is replaced with `ext`, and `.ext` is added to any other path (`v1.2` becomes `v1.2.png`). +fn with_written_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { + let written = fileio::format(ext); + let keeps = |g: &Format| written.is_some_and(|w| w.id == g.id) || keeps(g); + if !ext.is_empty() && !fileio::extension(path).eq_ignore_ascii_case(ext) && fileio::format_for_name(path).is_some_and(|g| !keeps(g)) { + return std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(); + } + with_extension(path, ext, keeps) +} + +/// The suggested name made into a path by `finish` on the web (a download), else the save panel's +/// choice made into one, with the extension of the file type picked in the panel when it reports +/// one ([`crate::picks::PickAnswer`]). When `finish` changed the choice (an extension added or +/// replaced) and a file is there, which the panel did not ask about, a question asks before +/// replacing it ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK +/// runs what asked again. +fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str, Option<&str>) -> String) -> Result { + if is_web(app) { + return Ok(finish(&pick.name, None)); + } + let (picked, file_type) = crate::picks::save_with_type(app, pick).ok_or("cancelled")?; + let path = finish(&picked, file_type.as_deref()); + if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, file_type.as_deref(), &path) { + return Err("cancelled".into()); + } + Ok(path) +} + +/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format +/// `keeps` takes, else with `.ext` added. +fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { + let has = fileio::extension(path); + if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) { + path.to_string() + } else { + format!("{path}.{ext}") + } +} + +/// The active document's file name with extension `ext`, and its folder. +pub(super) fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option) { + let st = app.session.active(); + let t = st.map(|d| d.path.clone().unwrap_or_else(|| d.doc.title.clone())).unwrap_or_else(|| "Untitled".into()); + let folder = st.and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(|p| p.to_string_lossy().to_string()); + (format!("{}.{ext}", fileio::file_stem(&t)), folder.filter(|f| !f.is_empty())) +} + +/// The save panel's file type, `(label, extensions)`, for the files `write_named` and +/// `target_path` write that are not a document format (`fileio::format` has those). +const FILE_TYPES: &[(&str, &[&str])] = &[ + ("Swatch libraries", &["vcswatches"]), + ("Swatch libraries", &["gpl"]), + ("Swatch libraries", &["ase"]), + ("Graphic Style Libraries", &[vectorcraft_doc::style_libs::STYLES_EXT]), + ("Libraries", vectorcraft_engine::cmd::library::LIBRARY_EXTS), + ("Flattener presets", vectorcraft_engine::cmd::flatten::PRESET_EXTS), + ("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS), + ("Perspective grid presets", vectorcraft_engine::cmd::perspgrid::PRESET_EXTS), + ("Print presets", vectorcraft_engine::cmd::printpresets::PRESET_EXTS), + ("CSS", &["css"]), + ("PostScript", &["ps"]), +]; + +/// The file type a save panel shows for a file with extension `ext`: its format's, else its +/// [`FILE_TYPES`] row; none for any other extension. +fn file_types(ext: &str) -> Vec { + if let Some(f) = fileio::format(ext) { + return vec![FilePick::filter(f.label, f.extensions)]; + } + let ext = ext.trim_start_matches('.').to_ascii_lowercase(); + FilePick::filters(FILE_TYPES.iter().find(|(_, exts)| exts.contains(&ext.as_str())).copied()) +} + +/// `path`, else one picked in a save panel (the web: the suggested name) for the active document +/// with extension `ext`, filtered to its file type when it has one ([`file_types`]). A name typed +/// with another format's extension gets `ext` instead ([`with_written_extension`]). +pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { + target_path_keeping(app, path, ext, |_| false) +} + +/// [`target_path`] for an export in format `f` (Export As's OK and [`export`]): a name typed with +/// `.svgz` for an SVG stays as typed, and [`export`] writes it compressed. +pub(crate) fn export_path(app: &mut VectorcraftApp, path: Option, f: &Format) -> Result { + target_path_keeping(app, path, f.extensions[0], |g| f.id == "svg" && g.id == "svgz") +} + +/// [`target_path`], keeping a typed extension of a format `keeps` takes. +pub(super) fn target_path_keeping( + app: &mut VectorcraftApp, + path: Option, + ext: &str, + keeps: impl Fn(&Format) -> bool, +) -> Result { + if let Some(p) = path { + return Ok(p); + } + let (name, folder) = suggested(app, ext); + let filters = file_types(ext); + pick_path(app, &FilePick { name, folder, filters }, keeps) +} + +/// Keep the active document's saved view current before a save (native files reopen at it). +pub fn remember_view(app: &mut VectorcraftApp) { + let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; + if let Some(st) = app.session.active_mut() { + st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation }); + } +} + +/// A path typed in a save panel: kept when its extension names one of the panel's file types (a +/// save format, or a template format for Save as Template, `mode`). An extension that names +/// another format (`card.png`, or `card.ait` in Save As) is replaced with `f`'s, and `f`'s is +/// added to any other path (`notes.v2` becomes `notes.v2.vectorcraft`). +fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String { + let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; + if fileio::format_for_name(path).is_some_and(|g| !formats.contains(&g.id)) { + return std::path::Path::new(path).with_extension(f.extensions[0]).to_string_lossy().into_owned(); + } + with_extension(path, f.extensions[0], |g| formats.contains(&g.id)) +} + +/// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`, +/// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s +/// place. A path that still ends in the extension of the name the panel suggested (`suggested`), +/// in any letter case, gets the picked type's extension instead, unless that extension is one of +/// the picked type's; such a path stays as typed. Without a picked type, or with one the panel did not +/// offer, [`with_save_extension`] with `f`. +fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String { + let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; + let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else { + return with_save_extension(path, f, mode); + }; + let has = fileio::extension(path); + if !has.is_empty() && has == fileio::extension(suggested) && !g.extensions.contains(&has.as_str()) { + return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned(); + } + with_save_extension(path, g, mode) +} + +/// Write every file of an export (one per artboard, linked images) for the destination `path`. +/// → the export's own files (without the linked images). +pub(super) fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, String> { + let named = enc.named(doc, path); + for (p, bytes) in &named { + write(p, bytes)?; + } + Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect()) +} + +fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result { + fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string()) +} + +/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the +/// engine command's `{path?, format?, options?, svg?}`. +/// +/// When no path is known, desktop asks with a save panel (one file type per save format, or one +/// per template format for Save as Template) and the web opens the Save As dialog (file name and +/// format). With `ask_options` and no options in `p`, +/// a format picked that way that has options asks for them before anything is written (SVG +/// Options, the Save PDF dialog, or the save options dialog: native files on Save As only, `.ai` +/// files on Save As and Save a Copy), and so does Save As or Save a Copy to a given SVG path. +/// → `{path, format, warnings…}` once written, or `{pending: , +/// path?}` while a dialog is open. +pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + remember_view(app); + // Writing over the file the document was read from is asked about here, not refused. + let first = plan(app, mode, &acknowledged(p))?; + let ask = ask_options && !has_options(p); + if let Some(path) = first.path.clone() { + if let Some(r) = ask_before_losing(app, &path, save_command(mode), p) { + return Ok(r); + } + if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { + return ask_format_options(app, mode, first.format, &path); + } + return write_plan(app, first); + } + if is_web(app) && ask_options { + return Ok(open_options(app, mode.command(), first.format, &first.name, true)); + } + let filters = FilePick::filters(match mode { + SaveMode::Template => fileio::template_filters(first.format.id), + _ => fileio::save_filters(first.format.id), + }); + let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; + let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; + // The picked file type wins over a `format` param. + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = q.as_object_mut() { + o.remove("format"); + o.insert("path".into(), json!(picked)); + } + let chosen = plan(app, mode, &acknowledged(&q))?; + if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { + return Ok(r); + } + if ask && asks_options(mode, chosen.format) { + let path = chosen.path.clone().unwrap_or_default(); + let r = ask_format_options(app, mode, chosen.format, &path); + crate::dialogs::opened_by_panel(app); + return r; + } + write_plan(app, chosen) +} + +/// The UI command that runs a save in `mode`. +fn save_command(mode: SaveMode) -> &'static str { + match mode { + SaveMode::Save => "file.save", + SaveMode::SaveAs => "file.saveAs", + SaveMode::Copy => "file.saveCopy", + SaveMode::Template => "file.saveAsTemplate", + } +} + +/// `p` with `acknowledgeLoss: true`. +fn acknowledged(p: &Value) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["acknowledgeLoss"] = json!(true); + p +} + +/// Writing `path` over the file the active document was read from, when reading it left things +/// out (hidden text, art or layers VectorCraft can't read yet): asks first, and OK runs `command` +/// with `params` and `acknowledgeLoss` → `{pending}` while it asks, `None` when nothing is lost. +pub(super) fn ask_before_losing(app: &mut VectorcraftApp, path: &str, command: &str, params: &Value) -> Option { + let what = fileio::losses_summary(fileio::overwrite_losses(app.session.active()?, path, params)?); + let name = fileio::file_name(path); + let message = crate::i18n::fmt(tl!("Replace “{name}”, the file this document was opened from?"), &[("name", &name)]); + let detail = crate::i18n::fmt( + tl!("Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it."), + &[("what", &what)], + ); + let mut params = acknowledged(params); + if command != "file.save" { + params["path"] = json!(path); + } + dialogs::confirm::ask(app, &message, &detail, command, params); + Some(json!({ "pending": dialogs::confirm::KIND })) +} + +/// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai` +/// files on Save As (the options dialog after the save panel), and `.ai` files also on Save a Copy, +/// so the copy is not written in the version last saved whatever the panel's `.ai` type says +/// (#3141); Save and templates reuse the remembered options or Use Compression. +fn asks_options(mode: SaveMode, f: &Format) -> bool { + !f.options.is_empty() + && match f.id { + "vectorcraft" => mode == SaveMode::SaveAs, + "ai" => matches!(mode, SaveMode::SaveAs | SaveMode::Copy), + "template" | "ait" => false, + _ => true, + } +} + +/// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)? +fn has_options(p: &Value) -> bool { + ["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty()) +} + +/// Ask for format `f`'s options before a save (`mode`) to `path`: SVG Options for SVG, the Save +/// PDF dialog for PDF, else the save options dialog. OK finishes the save. +pub fn ask_format_options(app: &mut VectorcraftApp, mode: SaveMode, f: &Format, path: &str) -> Result { + match f.id { + "pdf" => ask_pdf_options(app, mode, path), + "svg" | "svgz" => { + let m = if mode == SaveMode::Copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save }; + svg_options::open(app, m, Some(path)); + Ok(json!({ "pending": svg_options::KIND, "dialog": svg_options::KIND, "path": path })) + } + _ => Ok(open_options(app, mode.command(), f, path, false)), + } +} + +/// A save as PDF asks with the Save PDF dialog, filled with the PDF options the document was last +/// saved with; its OK finishes the save ([`save_pdf`]). +pub fn ask_pdf_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) -> Result { + let mut p = Value::Object(plan(app, mode, &json!({ "path": path, "format": "pdf" }))?.options); + p["path"] = json!(path); + dialogs::open_save_pdf(app, &p)?; + let d = app.ui.dialog.as_mut().ok_or("the Save PDF dialog didn't open")?; + d.fields.insert("__save".into(), json!(mode.command())); + Ok(json!({ "pending": d.kind })) +} + +/// The Save PDF dialog's OK during a save (`mode`): `params` are its `document.exportPdf` options +/// and `path`. +pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) -> Result { + let path = params.as_object_mut().and_then(|o| o.remove("path")); + let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); + let r = save(app, mode, &json!({ "path": path, "format": "pdf", "options": params }), false)?; + if view && let Some(path) = r["path"].as_str() { + view_file(app, path); + } + Ok(r) +} + +/// Open a written file in the system viewer (not on the web, which downloads it). +pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) { + if !is_web(app) { + app.open_url(&file_url(path)); + } +} + +/// "Saved ", with the first of the result's warnings. +pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { + app.status(saved_status(path, r)); +} + +/// [`report_saved`]'s message. +pub(super) fn saved_status(path: &str, r: &Value) -> String { + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + match warnings.first() { + Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()), + None => format!("Saved {path}"), + } +} + +/// Write a planned save through the services and report it in the status bar. +/// With Background Save on, the file is encoded and written from a snapshot off the UI thread +/// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once +/// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format +/// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { + let uid = app.session.active().ok_or("no document")?.uid; + // Saves of one document finish in order. + if app.background.busy_with(uid) { + background::wait_all(app); + } + let job = fileio::save_job(&mut app.session, plan).map_err(|e| e.to_string())?; + let (retargets, target) = (job.plan().retargets(), job.plan().path.clone()); + let (format, warnings) = (job.plan().format.id, job.known_warnings()); + let label = match &target { + Some(path) => format!("Saving {}", fileio::file_name(path)), + None => "Saving".into(), + }; + // Without a path the bytes come back to the caller: nothing to do in the background. + let enabled = app.session.prefs.background_save && target.is_some(); + // The work writes the file; the copy makes it the document's own afterwards. + let done = job.clone(); + // A native file streams into the file as it is encoded (desktop; not to a download). + let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); + let work = move |write: Writer| { + match stream { + Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), + None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + } + .map_err(|e| e.to_string()) + }; + let failed = target.clone().unwrap_or_default(); + let then = move |app: &mut VectorcraftApp, r: Result| { + // Not written: the document keeps its file, format and modified state, and the user is told. + let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + // The document may have closed meanwhile: the file is written all the same. + done.complete(&mut app.session, uid); + let path = r["path"].as_str().unwrap_or_default().to_string(); + if retargets { + note_recent(app, &path); + } + report_saved(app, &path, &r); + Ok(r) + }; + let mut r = background::run(app, enabled, label, Some(uid), work, then)?; + if let Some(path) = target.filter(|_| r.get("path").is_none()) { + r["path"] = json!(path); + } + if r.get("background").and_then(Value::as_bool) == Some(true) { + r["format"] = json!(format); + r["warnings"] = json!(warnings); + } + Ok(r) +} From 16cdbd1b3399b29743736ae66e3cf90cfe53312c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:50 +0000 Subject: [PATCH 141/199] refactor(ui-egui): move menus.rs items to menus/{bar,context,entries,invoke,labels,run,slots,state,submenus,tree,ui_commands}.rs --- crates/ui-egui/src/menus.rs | 4415 +---------------------- crates/ui-egui/src/menus/bar.rs | 172 + crates/ui-egui/src/menus/context.rs | 173 + crates/ui-egui/src/menus/entries.rs | 147 + crates/ui-egui/src/menus/invoke.rs | 279 ++ crates/ui-egui/src/menus/labels.rs | 333 ++ crates/ui-egui/src/menus/run.rs | 725 ++++ crates/ui-egui/src/menus/slots.rs | 169 + crates/ui-egui/src/menus/state.rs | 275 ++ crates/ui-egui/src/menus/submenus.rs | 210 ++ crates/ui-egui/src/menus/tree.rs | 711 ++++ crates/ui-egui/src/menus/ui_commands.rs | 1227 +++++++ 12 files changed, 4453 insertions(+), 4383 deletions(-) create mode 100644 crates/ui-egui/src/menus/bar.rs create mode 100644 crates/ui-egui/src/menus/context.rs create mode 100644 crates/ui-egui/src/menus/entries.rs create mode 100644 crates/ui-egui/src/menus/invoke.rs create mode 100644 crates/ui-egui/src/menus/labels.rs create mode 100644 crates/ui-egui/src/menus/run.rs create mode 100644 crates/ui-egui/src/menus/slots.rs create mode 100644 crates/ui-egui/src/menus/state.rs create mode 100644 crates/ui-egui/src/menus/submenus.rs create mode 100644 crates/ui-egui/src/menus/tree.rs create mode 100644 crates/ui-egui/src/menus/ui_commands.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 89162c521..2c8c051f6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -45,2304 +45,39 @@ fn sub(label: &'static str, items: Vec) -> Item { } use Item::Sep; -/// UI-level commands: (id, label, shortcut, params doc). -pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ - ( - "app.language", - "Interface Language", - "", - "{lang: auto|} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)", - ), - ( - "file.open", - "Open…", - "Cmd+O", - "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", - ), - ( - "file.save", - "Save", - "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", - ), - ( - "file.place", - "Place…", - "Cmd+Shift+P", - "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)", - ), - ("file.openRecent1", "Open Recent File 1", "", "{}"), - ("file.openRecent2", "Open Recent File 2", "", "{}"), - ("file.openRecent3", "Open Recent File 3", "", "{}"), - ("file.openRecent4", "Open Recent File 4", "", "{}"), - ("file.openRecent5", "Open Recent File 5", "", "{}"), - ("file.openRecent6", "Open Recent File 6", "", "{}"), - ("file.openRecent7", "Open Recent File 7", "", "{}"), - ("file.openRecent8", "Open Recent File 8", "", "{}"), - ("file.openRecent9", "Open Recent File 9", "", "{}"), - ("file.openRecent10", "Open Recent File 10", "", "{}"), - ("view.goto1", "Saved View 1", "", "{} go to the 1. saved view"), - ("view.goto2", "Saved View 2", "", "{} go to the 2. saved view"), - ("view.goto3", "Saved View 3", "", "{} go to the 3. saved view"), - ("view.goto4", "Saved View 4", "", "{} go to the 4. saved view"), - ("view.goto5", "Saved View 5", "", "{} go to the 5. saved view"), - ("view.goto6", "Saved View 6", "", "{} go to the 6. saved view"), - ("view.goto7", "Saved View 7", "", "{} go to the 7. saved view"), - ("view.goto8", "Saved View 8", "", "{} go to the 8. saved view"), - ("view.goto9", "Saved View 9", "", "{} go to the 9. saved view"), - ("view.goto10", "Saved View 10", "", "{} go to the 10. saved view"), - ("view.goto11", "Saved View 11", "", "{} go to the 11. saved view"), - ("view.goto12", "Saved View 12", "", "{} go to the 12. saved view"), - ("view.goto13", "Saved View 13", "", "{} go to the 13. saved view"), - ("view.goto14", "Saved View 14", "", "{} go to the 14. saved view"), - ("view.goto15", "Saved View 15", "", "{} go to the 15. saved view"), - ("view.goto16", "Saved View 16", "", "{} go to the 16. saved view"), - ("view.goto17", "Saved View 17", "", "{} go to the 17. saved view"), - ("view.goto18", "Saved View 18", "", "{} go to the 18. saved view"), - ("view.goto19", "Saved View 19", "", "{} go to the 19. saved view"), - ("view.goto20", "Saved View 20", "", "{} go to the 20. saved view"), - ("view.goto21", "Saved View 21", "", "{} go to the 21. saved view"), - ("view.goto22", "Saved View 22", "", "{} go to the 22. saved view"), - ("view.goto23", "Saved View 23", "", "{} go to the 23. saved view"), - ("view.goto24", "Saved View 24", "", "{} go to the 24. saved view"), - ("view.goto25", "Saved View 25", "", "{} go to the 25. saved view"), - ("select.recall1", "Saved Selection 1", "", "{} select the 1. saved selection"), - ("select.recall2", "Saved Selection 2", "", "{} select the 2. saved selection"), - ("select.recall3", "Saved Selection 3", "", "{} select the 3. saved selection"), - ("select.recall4", "Saved Selection 4", "", "{} select the 4. saved selection"), - ("select.recall5", "Saved Selection 5", "", "{} select the 5. saved selection"), - ("select.recall6", "Saved Selection 6", "", "{} select the 6. saved selection"), - ("select.recall7", "Saved Selection 7", "", "{} select the 7. saved selection"), - ("select.recall8", "Saved Selection 8", "", "{} select the 8. saved selection"), - ("select.recall9", "Saved Selection 9", "", "{} select the 9. saved selection"), - ("select.recall10", "Saved Selection 10", "", "{} select the 10. saved selection"), - ("select.recall11", "Saved Selection 11", "", "{} select the 11. saved selection"), - ("select.recall12", "Saved Selection 12", "", "{} select the 12. saved selection"), - ("select.recall13", "Saved Selection 13", "", "{} select the 13. saved selection"), - ("select.recall14", "Saved Selection 14", "", "{} select the 14. saved selection"), - ("select.recall15", "Saved Selection 15", "", "{} select the 15. saved selection"), - ("select.recall16", "Saved Selection 16", "", "{} select the 16. saved selection"), - ("select.recall17", "Saved Selection 17", "", "{} select the 17. saved selection"), - ("select.recall18", "Saved Selection 18", "", "{} select the 18. saved selection"), - ("select.recall19", "Saved Selection 19", "", "{} select the 19. saved selection"), - ("select.recall20", "Saved Selection 20", "", "{} select the 20. saved selection"), - ("select.recall21", "Saved Selection 21", "", "{} select the 21. saved selection"), - ("select.recall22", "Saved Selection 22", "", "{} select the 22. saved selection"), - ("select.recall23", "Saved Selection 23", "", "{} select the 23. saved selection"), - ("select.recall24", "Saved Selection 24", "", "{} select the 24. saved selection"), - ("select.recall25", "Saved Selection 25", "", "{} select the 25. saved selection"), - ("type.recentFont1", "Recent Font 1", "", "{} apply the 1. most recently used font"), - ("type.recentFont2", "Recent Font 2", "", "{} apply the 2. most recently used font"), - ("type.recentFont3", "Recent Font 3", "", "{} apply the 3. most recently used font"), - ("type.recentFont4", "Recent Font 4", "", "{} apply the 4. most recently used font"), - ("type.recentFont5", "Recent Font 5", "", "{} apply the 5. most recently used font"), - ("type.recentFont6", "Recent Font 6", "", "{} apply the 6. most recently used font"), - ("type.recentFont7", "Recent Font 7", "", "{} apply the 7. most recently used font"), - ("type.recentFont8", "Recent Font 8", "", "{} apply the 8. most recently used font"), - ("type.recentFont9", "Recent Font 9", "", "{} apply the 9. most recently used font"), - ("type.recentFont10", "Recent Font 10", "", "{} apply the 10. most recently used font"), - ("type.recentFont11", "Recent Font 11", "", "{} apply the 11. most recently used font"), - ("type.recentFont12", "Recent Font 12", "", "{} apply the 12. most recently used font"), - ("type.recentFont13", "Recent Font 13", "", "{} apply the 13. most recently used font"), - ("type.recentFont14", "Recent Font 14", "", "{} apply the 14. most recently used font"), - ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), - ("file.clearRecent", "Clear Recent Files", "", "{}"), - ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), - ( - "ui.missingFontsDialog", - "Missing Fonts Dialog", - "", - "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web", - ), - ( - "ui.findFontsInFolder", - "Find Fonts in Folder…", - "", - "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", - ), - ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), - ( - "file.export.svg", - "Export As SVG…", - "", - "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)", - ), - ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), - ( - "file.exportForScreens", - "Export for Screens…", - "Cmd+Alt+E", - "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})", - ), - ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), - ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), - ( - "file.newWithoutDialog", - "New Without Dialog", - "Cmd+Alt+N", - "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}", - ), - ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), - ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), - ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), - ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"), - ("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"), - ("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"), - ("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"), - ( - "shortcuts.preset", - "Keyboard Shortcut Set", - "", - "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", - ), - ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), - ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), - ( - "ui.shortcutImportDialog", - "Import Shortcut Set…", - "", - "{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \"\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too", - ), - ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), - ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), - ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), - ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), - ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), - ("view.snapToGlyph", "Snap to Glyph", "", "{} toggle: drawing snaps to live-text baselines and glyph bounds"), - ( - "view.preciseCursors", - "Precise Cursors", - "", - "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on", - ), - ( - "imageTrace.preview", - "Image Trace Preview", - "", - "{on?: bool} turn the Image Trace panel's Preview on (true), off (false) or toggle it (omitted), as its checkbox does: on, setting changes trace the selected Image Trace object at once; off, they wait for Trace; returns the new state", - ), - ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), - ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), - ( - "view.livePaintGaps", - "Show Live Paint Gaps", - "", - "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on", - ), - ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), - ("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"), - ( - "type.alignCenter", - "Align center", - "Cmd+Shift+C", - "{} align the selected type's paragraphs (or those the Type tool's selection touches) centred", - ), - ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), - ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), - ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), - ( - "type.applyBullets", - "Apply Bullets", - "", - "{bullet?: \"dot\"|\"dash\"|\"circle\"|\"square\"|\"none\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \"bullet\"}); \"none\" turns the list off", - ), - ( - "type.applyNumbers", - "Apply Numbers", - "", - "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", - ), - ( - "type.convertListToText", - "Convert to Text", - "", - "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", - ), - ( - "align.horizontalLeft", - "Horizontal Align Left", - "", - "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.horizontalCenter", - "Horizontal Align Center", - "", - "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.horizontalRight", - "Horizontal Align Right", - "", - "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.horizontalVerticalCenter", - "Horizontal & Vertical Align Center", - "", - "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.verticalTop", - "Vertical Align Top", - "", - "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.verticalCenter", - "Vertical Align Center", - "", - "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.verticalBottom", - "Vertical Align Bottom", - "", - "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", - ), - ( - "align.distributeVerticalTop", - "Vertical Distribute Top", - "", - "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeVerticalCenter", - "Vertical Distribute Center", - "", - "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeVerticalBottom", - "Vertical Distribute Bottom", - "", - "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeHorizontalLeft", - "Horizontal Distribute Left", - "", - "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeHorizontalCenter", - "Horizontal Distribute Center", - "", - "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeHorizontalRight", - "Horizontal Distribute Right", - "", - "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeVerticalSpace", - "Vertical Distribute Space", - "", - "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "align.distributeHorizontalSpace", - "Horizontal Distribute Space", - "", - "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", - ), - ( - "type.bold", - "Bold", - "", - "{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B", - ), - ( - "type.italic", - "Italic", - "", - "{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I", - ), - ("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"), - ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), - ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), - ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), - ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), - ( - "view.actualSize", - "Actual Size", - "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", - ), - ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), - ( - "view.goToArtboard", - "Go to Artboard", - "", - "{index: 0-based number | \"first\" | \"previous\" | \"next\" | \"last\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}", - ), - ("view.edges", "Hide Edges", "Cmd+H", "{}"), - ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), - ( - "view.template", - "Hide Template", - "Cmd+Shift+W", - "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown", - ), - ("view.rulers", "Show Rulers", "Cmd+R", "{}"), - ( - "view.videoRulers", - "Show Video Rulers", - "", - "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show", - ), - ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), - ("view.guides", "Hide Guides", "Cmd+;", "{}"), - ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), - ("view.grid", "Show Grid", "Cmd+'", "{}"), - ("view.snapToGrid", "Snap to Grid", "Cmd+Shift+'", "{}"), - ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), - ("view.presentation", "Presentation Mode", "Shift+F", "{}"), - ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), - ( - "view.rotate", - "Rotate View", - "", - "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]", - ), - ( - "view.rotateToSelection", - "Rotate View to Selection", - "", - "{} turn the view about the center of the canvas to the angle the selected objects share (selectionRotation in document.inspect, 0 when they differ), so their bounding box is square to the window; returns {angle}", - ), - ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), - ("window.control", "Control", "", "{}"), - ("window.toolbar", "Tools", "", "{}"), - ( - "window.toolbarColumns", - "Toolbar: Single/Double Column", - "", - "{double?: bool} show the toolbar's tools in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", - ), - ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), - ( - "window.floatTools", - "Float Tool Group", - "", - "{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state", - ), - ( - "toolbar.show", - "Toolbar: Show Controls", - "", - "{control?: \"fillStroke\"|\"coloring\"|\"drawMode\"|\"screenMode\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state", - ), - ( - "window.editToolbar", - "Edit Toolbar", - "", - "{open?: bool} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar, as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state", - ), - ( - "toolbar.addTool", - "Toolbar: Add Tool", - "", - "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\"} put `tool` in the current (Basic or Advanced) toolbar, moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)", - ), - ( - "toolbar.removeTool", - "Toolbar: Remove Tool", - "", - "{tool: id, group?: bool} take `tool` (with `group`, its whole group) out of the current toolbar (one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots", - ), - ( - "toolbar.reset", - "Toolbar: Reset", - "", - "{} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes; returns its slots", - ), - ("window.taskBar", "Contextual Task Bar", "", "{}"), - ( - "window.taskBar.pin", - "Pin Bar Position", - "", - "{pinned?: bool} keep the Contextual Task Bar where it is instead of following the selection (true), let it follow the selection again from where it is (false) or toggle (omitted), as the bar's More Options menu does; returns the new state", - ), - ("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"), - ( - "window.dock", - "Panels", - "Tab", - "{} hide the panels, the toolbar and the Control bar, or show again the ones the last Tab hid (all three when none of them shows and Tab hid none); returns whether it showed them", - ), - ( - "window.panels", - "Panels Except Tools and Control", - "Shift+Tab", - "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", - ), - ( - "window.panel", - "Show Panel", - "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", - ), - ( - "window.panel.layout", - "Change Panel Layout", - "", - "{action} serialized docking action, including floating geometry, divider sizes and accordion state", - ), - ( - "window.panel.move", - "Move Panel", - "", - "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel, group?: bool, placement?} move a panel (with `group`, its whole group) into the group holding `anchor`: as a tab (before the `before` panel; null appends), a split beside it, or (`above` / `below`) a group of its own above or below it in its dock column or floating window; `placement` (a serialized Placement) replaces zone and before", - ), - ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), - ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), - ( - "window.panel.collapse", - "Collapse Panel Group", - "", - "{panel, collapsed?: bool} minimize the group holding a panel to its tabs (in a floating window, the window to its title bar), or restore it, as a double-click on a tab or the drag bar does; the same as window.panel.minimize {panel, minimized}. Without `collapsed` it toggles. Returns {panel, collapsed, layout}", - ), - ( - "window.panel.float", - "Float Panel", - "", - "{panel: id or \"tools\", x?, y?, width?, height?, group?: bool, column?: bool or n, onto?: id, above?: id, below?: id, collapsed?: bool} float a panel (with `group`, its whole group; with `column`, its whole dock column or floating window) as a floating window with its top-left corner at x, y in window points (default: inside the dock area, cascaded), as dragging its tab out does; a whole floating window keeps its rect unless given one. `onto` makes it a tab of the floating group holding that panel instead; `above` / `below` a group of its own above or below the group holding that panel, in its floating window or dock column. `collapsed` then minimizes (true) or restores (false) its window or group. A hidden panel shows first. \"tools\" floats the Tools panel at x, y. Returns {layout, closed, floating}", - ), - ( - "window.panel.dock", - "Dock Panel", - "", - "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window. Returns {layout, closed, floating}", - ), - ( - "window.collapseDock", - "Collapse Panels to Icons", - "", - "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", - ), - ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), - ( - "window.workspace", - "Workspace", - "", - "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)", - ), - ( - "window.workspace.reset", - "Reset Essentials", - "", - "{} reset the current workspace to the way it was saved, dropping the layout changes made in it", - ), - ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), - ("window.workspace.manage", "Manage Workspaces…", "", "{}"), - ("window.workspace.delete", "Delete Workspace", "", "{name}"), - ("window.workspace.rename", "Rename Workspace", "", "{name, to}"), - ("window.workspace.list", "List Workspaces", "", "{}"), - ("window.newWindow", "New Window", "", "{}"), - ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), - ( - "tool.setOption", - "Tool Option", - "", - "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", - ), - ( - "effect.dialog", - "Effect…", - "", - "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", - ), - ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), - ( - "ui.recolorDialog", - "Recolor Artwork…", - "", - "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name; or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", - ), - ( - "ui.cropImage", - "Crop Image", - "", - "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", - ), - ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), - ( - "file.export.pdf", - "Save as PDF…", - "", - "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}", - ), - ("help.about", "About VectorCraft", "", "{}"), - ("help.checkForUpdates", "Check for Updates…", "", "{}"), - ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), - ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), - ( - "ui.swatchOptions", - "Swatch Options…", - "", - "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern", - ), - ( - "ui.newSwatch", - "New Swatch…", - "", - "{spot?, global?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new); a color opens as a spot color with `spot`, and as a global process color with `global` alone", - ), - ( - "ui.newColorGroup", - "New Color Group…", - "", - "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)", - ), - ( - "ui.colorPicker", - "Color Picker…", - "", - "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", - ), - ( - "ui.sampleScreenColor", - "Sample Color from Screen", - "", - "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", - ), - ( - "ui.newBrush", - "New Brush…", - "", - "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush", - ), - ( - "ui.brushOptions", - "Brush Options…", - "", - "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options", - ), - ( - "ui.graphicStyleOptions", - "Graphic Style Options…", - "", - "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", - ), - ( - "ui.symbolOptions", - "Symbol Options…", - "F8", - "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \"New Symbol\"/\"New Symbol N\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}", - ), - ( - "tool.options", - "Tool Options…", - "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values})", - ), - ( - "ui.colorGuideOptions", - "Color Guide Options…", - "", - "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)", - ), - ( - "ui.colorBalanceDialog", - "Adjust Color Balance…", - "", - "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", - ), - ( - "ui.saturateDialog", - "Saturate…", - "", - "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", - ), - ( - "window.swatchLibrary", - "Swatch Library", - "", - "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", - ), - ( - "window.swatchLibrary.other", - "Other Library…", - "", - "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel", - ), - ( - "ui.libraryPanel", - "Library Panel Options", - "", - "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}", - ), - ( - "ui.libraryPanel.locate", - "Locate Library…", - "", - "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers", - ), - ( - "ui.saveSwatchLibrary", - "Save Swatch Library…", - "", - "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", - ), - ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), - ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), - ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), - ("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"), - ("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"), - ("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"), - ("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"), - ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), - ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), - ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), - ( - "ui.mergeGraphicStyles", - "Merge Graphic Styles…", - "", - "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", - ), - ( - "ui.layerOptions", - "Options for Selection…", - "", - "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step)", - ), - ( - "ui.newLayer", - "New Layer Options…", - "Cmd+Alt+L", - "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", - ), - ( - "ui.newArtboard", - "New Artboard Options…", - "", - "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", - ), - ( - "ui.layersPanelOptions", - "Panel Options…", - "", - "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, locateSelected (Locate Selected Objects, off by default: when the last selected object changes outside the panel, on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as after layer.locate, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off; a change made during a drag is located when the drag ends), rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)", - ), - ( - "ui.layersExpand", - "Expand Layers Panel Rows", - "", - "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}", - ), - ( - "ui.layersFilter", - "Filter Layers Panel Rows", - "", - "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", - ), - ( - "ui.tileEdgeColor", - "Tile Edge Color…", - "", - "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \"Light Blue\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode", - ), - ( - "ui.flattenTransparencyDialog", - "Flatten Transparency…", - "", - "{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step", - ), - ( - "ui.flattenerPresetsDialog", - "Transparency Flattener Presets…", - "", - "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export", - ), - ( - "ui.flattenerPreview", - "Flattener Preview", - "", - "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", - ), - ( - "window.graphicStyleLibrary", - "Graphic Style Library", - "", - "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", - ), - ( - "window.graphicStyleLibrary.other", - "Other Library…", - "", - "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel", - ), - ( - "ui.saveGraphicStyleLibrary", - "Save Graphic Style Library…", - "", - "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", - ), - ( - "window.userGraphicStyleLibrary1", - "User Graphic Style Library 1", - "", - "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)", - ), - ("window.userGraphicStyleLibrary2", "User Graphic Style Library 2", "", "{} open the 2. User Defined graphic style library"), - ("window.userGraphicStyleLibrary3", "User Graphic Style Library 3", "", "{} open the 3. User Defined graphic style library"), - ("window.userGraphicStyleLibrary4", "User Graphic Style Library 4", "", "{} open the 4. User Defined graphic style library"), - ("window.userGraphicStyleLibrary5", "User Graphic Style Library 5", "", "{} open the 5. User Defined graphic style library"), - ("window.userGraphicStyleLibrary6", "User Graphic Style Library 6", "", "{} open the 6. User Defined graphic style library"), - ("window.userGraphicStyleLibrary7", "User Graphic Style Library 7", "", "{} open the 7. User Defined graphic style library"), - ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), - ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), - ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), - ( - "window.symbolLibrary", - "Symbol Library", - "", - "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}", - ), - ( - "window.symbolLibrary.other", - "Other Library…", - "", - "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel", - ), - ( - "ui.saveSymbolLibrary", - "Save Symbols…", - "", - "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary", - ), - ("window.userSymbolLibrary1", "User Symbol Library 1", "", "{} open the 1. User Defined symbol library (symbol.libraries, category user)"), - ("window.userSymbolLibrary2", "User Symbol Library 2", "", "{} open the 2. User Defined symbol library"), - ("window.userSymbolLibrary3", "User Symbol Library 3", "", "{} open the 3. User Defined symbol library"), - ("window.userSymbolLibrary4", "User Symbol Library 4", "", "{} open the 4. User Defined symbol library"), - ("window.userSymbolLibrary5", "User Symbol Library 5", "", "{} open the 5. User Defined symbol library"), - ("window.userSymbolLibrary6", "User Symbol Library 6", "", "{} open the 6. User Defined symbol library"), - ("window.userSymbolLibrary7", "User Symbol Library 7", "", "{} open the 7. User Defined symbol library"), - ("window.userSymbolLibrary8", "User Symbol Library 8", "", "{} open the 8. User Defined symbol library"), - ("window.userSymbolLibrary9", "User Symbol Library 9", "", "{} open the 9. User Defined symbol library"), - ("window.userSymbolLibrary10", "User Symbol Library 10", "", "{} open the 10. User Defined symbol library"), - ( - "window.brushLibrary", - "Brush Library", - "", - "{library: id or name (see brush.libraries) | null (close)} open a brush library in a library panel of its own (UI state `library_panels`, kind brushes); clicking a brush there runs brush.addFromLibrary (with apply when paths are selected), dragging one onto a path runs it with apply on that path, dragging one onto the Brushes panel adds it → {open, panel, name, count}", - ), - ( - "window.brushLibrary.other", - "Other Library…", - "", - "{path?} (default: pick a file) load the brushes of another document or a saved brush library (engine: brush.loadLibrary), and open them in a library panel", - ), - ( - "ui.saveBrushLibrary", - "Save Brushes…", - "", - "{names?: [the brushes selected in the Brushes panel]} open Save Brush Library (dialog `saveBrushLibrary`: name, user: save to the user library folder, selectedOnly); OK runs brush.saveLibrary", - ), - ("window.userBrushLibrary1", "User Brush Library 1", "", "{} open the 1. User Defined brush library (brush.libraries, category user)"), - ("window.userBrushLibrary2", "User Brush Library 2", "", "{} open the 2. User Defined brush library"), - ("window.userBrushLibrary3", "User Brush Library 3", "", "{} open the 3. User Defined brush library"), - ("window.userBrushLibrary4", "User Brush Library 4", "", "{} open the 4. User Defined brush library"), - ("window.userBrushLibrary5", "User Brush Library 5", "", "{} open the 5. User Defined brush library"), - ("window.userBrushLibrary6", "User Brush Library 6", "", "{} open the 6. User Defined brush library"), - ("window.userBrushLibrary7", "User Brush Library 7", "", "{} open the 7. User Defined brush library"), - ("window.userBrushLibrary8", "User Brush Library 8", "", "{} open the 8. User Defined brush library"), - ("window.userBrushLibrary9", "User Brush Library 9", "", "{} open the 9. User Defined brush library"), - ("window.userBrushLibrary10", "User Brush Library 10", "", "{} open the 10. User Defined brush library"), - ( - "ui.expandDialog", - "Expand…", - "", - "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step", - ), - ( - "attributes.openUrl", - "Browser", - "", - "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}", - ), - ( - "ui.javascriptFilesDialog", - "Link JavaScript Files…", - "", - "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected", - ), - ( - "ui.spotColors", - "Spot Colors…", - "", - "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step", - ), - ( - "ui.menuDialog", - "Menu Dialog", - "", - "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", - ), - ( - "ui.widthPointEdit", - "Width Point Edit…", - "", - "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point", - ), - ( - "ui.corners", - "Corners…", - "", - "{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape", - ), - ( - "ui.simplifyAuto", - "Auto-Simplify", - "", - "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open", - ), - ( - "ui.simplifyMoreOptions", - "More Options", - "", - "{} the Simplify dialog (dialog `simplify`: tolerance (pt), angleThreshold, straightLines, showOriginal, retain: ticked, OK makes Object › Path › Simplify open this dialog with these fields next time) takes the open Simplify slider's place, at the slider's tolerance → {tolerance}; an error when the slider isn't open", - ), - ( - "ui.justification", - "Justification…", - "", - "{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected", - ), - ( - "ui.hyphenation", - "Hyphenation…", - "", - "{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected", - ), - ( - "ui.colorGuideLimit", - "Limit Color Guide to Library", - "", - "{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}", - ), - ( - "ui.colorPanel", - "Color Panel Options", - "", - "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", - ), - ( - "ui.savePdfDialog", - "Save PDF Dialog", - "", - "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)", - ), - ( - "file.exportAs", - "Export As…", - "", - "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}", - ), - ( - "ui.fileInfoDialog", - "File Info Dialog", - "", - "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too", - ), - ( - "ui.rasterEffectsSettingsDialog", - "Document Raster Effects Settings Dialog", - "", - "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", - ), - ( - "ui.effectGalleryDialog", - "Effect Gallery Dialog", - "", - "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too", - ), - ( - "ui.pdfPresetsDialog", - "PDF Presets…", - "", - "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export", - ), - ( - "ui.pdfPresetDialog", - "PDF Preset", - "", - "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets", - ), - ("file.openRecent11", "Open Recent File 11", "", "{}"), - ("file.openRecent12", "Open Recent File 12", "", "{}"), - ("file.openRecent13", "Open Recent File 13", "", "{}"), - ("file.openRecent14", "Open Recent File 14", "", "{}"), - ("file.openRecent15", "Open Recent File 15", "", "{}"), - ("file.openRecent16", "Open Recent File 16", "", "{}"), - ("file.openRecent17", "Open Recent File 17", "", "{}"), - ("file.openRecent18", "Open Recent File 18", "", "{}"), - ("file.openRecent19", "Open Recent File 19", "", "{}"), - ("file.openRecent20", "Open Recent File 20", "", "{}"), - ("file.openRecent21", "Open Recent File 21", "", "{}"), - ("file.openRecent22", "Open Recent File 22", "", "{}"), - ("file.openRecent23", "Open Recent File 23", "", "{}"), - ("file.openRecent24", "Open Recent File 24", "", "{}"), - ("file.openRecent25", "Open Recent File 25", "", "{}"), - ("file.openRecent26", "Open Recent File 26", "", "{}"), - ("file.openRecent27", "Open Recent File 27", "", "{}"), - ("file.openRecent28", "Open Recent File 28", "", "{}"), - ("file.openRecent29", "Open Recent File 29", "", "{}"), - ("file.openRecent30", "Open Recent File 30", "", "{}"), - ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), - ( - "file.cancelExport", - "Cancel Export", - "", - "{} stop every export running in the background (Background Export): an export stops between artboards and writes no file once cancelled → {cancelled: n}", - ), - ( - "file.exportInBackground", - "Continue in Background", - "", - "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}", - ), - ( - "ui.dxfOptionsDialog", - "DXF Options Dialog", - "", - "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", - ), - ( - "links.editOriginal", - "Edit Original", - "", - "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", - ), - ( - "links.reveal", - "Show in Folder", - "", - "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}", - ), - ( - "ui.placementOptionsDialog", - "Placement Options…", - "", - "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", - ), - ( - "ui.findLinksInFolder", - "Find Linked Files in Folder…", - "", - "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)", - ), - ( - "ui.packageDialog", - "Package…", - "Cmd+Alt+Shift+P", - "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage", - ), - ( - "ui.batchDialog", - "Batch…", - "", - "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides", - ), - ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), - ( - "docInfo.save", - "Save Document Info…", - "", - "{path?, selectionOnly?} write Document Info's text report (document.info {format: \"text\"}) to path, else a picked file (the web downloads it) → {path}", - ), - ( - "ui.epsOptionsDialog", - "EPS Options Dialog", - "", - "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too", - ), - ( - "file.saveForWeb", - "Save for Web (Legacy)…", - "Cmd+Alt+Shift+S", - "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}", - ), - ( - "file.saveForWeb.browser", - "Preview in Browser", - "", - "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", - ), - ( - "file.exportSelection", - "Export Selection…", - "", - "{multiple?: false} collect the selected objects as assets (assets.add: one of them all, as one image; true: one per object) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", - ), - ( - "css.copy", - "Copy Selected Style", - "", - "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}", - ), - ( - "css.exportFile", - "Export CSS…", - "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", - ), - ( - "file.print", - "Print…", - "Cmd+P", - "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", - ), - ( - "print.printers", - "Printers", - "", - "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}", - ), - ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), - ( - "ui.printPresetsDialog", - "Print Presets…", - "", - "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export", - ), - ( - "ui.printPresetDialog", - "Print Preset", - "", - "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", - ), - ( - "plugin.dialog", - "Plug-in…", - "", - "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", - ), - ( - "ui.installPlugin", - "Install Plug-in…", - "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", - ), - ( - "plugin.cancelRuns", - "Cancel Plug-in Run", - "", - "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", - ), - ( - "plugin.hideRuns", - "Continue in Background", - "", - "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", - ), - ( - "ui.checkScript", - "Check Script…", - "", - "{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)", - ), - ( - "ui.perspectiveGridDialog", - "Define Perspective Grid…", - "", - "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", - ), - ( - "ui.perspectivePresetsDialog", - "Perspective Grid Presets…", - "", - "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*", - ), - ( - "ui.savePerspectivePreset", - "Save Grid as Preset…", - "", - "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields", - ), - ( - "ui.perspectiveUserPreset1.1", - "One Point Perspective Preset 1", - "", - "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset1.2", - "One Point Perspective Preset 2", - "", - "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset1.3", - "One Point Perspective Preset 3", - "", - "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset1.4", - "One Point Perspective Preset 4", - "", - "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset1.5", - "One Point Perspective Preset 5", - "", - "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.1", - "Two Point Perspective Preset 1", - "", - "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.2", - "Two Point Perspective Preset 2", - "", - "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.3", - "Two Point Perspective Preset 3", - "", - "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.4", - "Two Point Perspective Preset 4", - "", - "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset2.5", - "Two Point Perspective Preset 5", - "", - "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.1", - "Three Point Perspective Preset 1", - "", - "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.2", - "Three Point Perspective Preset 2", - "", - "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.3", - "Three Point Perspective Preset 3", - "", - "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.4", - "Three Point Perspective Preset 4", - "", - "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.perspectiveUserPreset3.5", - "Three Point Perspective Preset 5", - "", - "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)", - ), - ( - "ui.blendOptions", - "Blend Options…", - "", - "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", - ), - ( - "ui.livePaintGapOptions", - "Gap Options…", - "", - "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step", - ), - ( - "ui.perspectivePlane", - "Perspective Plane Options…", - "", - "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move", - ), -]; +mod bar; +mod context; +mod entries; +mod invoke; +mod labels; +mod run; +mod slots; +mod state; +mod submenus; +mod tree; +mod ui_commands; -/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label, -/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id -/// as it is (`library:swatches:pastels`). -pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> { - let name = input.trim(); - crate::state::all_panels() - .find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)) - .map(|(id, _)| id) - .or_else(|| crate::panels::library_panel::intern(input)) -} - -/// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle). -pub(crate) fn opt_bool(p: &Value, key: &str) -> Result, String> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(_) => Err(format!("`{key}` must be true or false")), - } -} - -/// Handle a UI command. `None` = not a UI command (the engine handles it). -/// `params` has only keys the params description of engine command `id` names, or the parameter -/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their -/// descriptions haven't been audited against what they read. -pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Value) -> Result<(), String> { - if UI_COMMANDS.iter().any(|c| c.0 == id) { - return Ok(()); - } - session.check_params(id, params).map_err(|e| e.to_string()) -} - -/// Runs `id` when the UI runs it itself: one of [`UI_COMMANDS`], or an engine command the UI -/// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the -/// engine. -pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { - // The Window menu's panel items toggle like its other checked items (#2361); the panel's - // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. - if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { - let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); - if app.ui.open_panel.as_deref() == Some(panel) { - app.ui.open_panel = None; - return Some(Ok(json!({"open": null}))); - } - return Some(app.run("window.panel.close", json!({"panel": panel}))); - } - if let Some(result) = crate::panel_docking::command(app, id, p) { - return Some(result); - } - if id == "app.language" { - let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); - let value = if lang.eq_ignore_ascii_case("auto") { - "auto".to_string() - } else if let Some(l) = crate::i18n::Lang::from_code(lang) { - l.code().to_string() - } else { - let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::all().map(|l| l.code())).collect(); - return Some(Err(format!("lang must be one of {}", codes.join(", ")))); - }; - return Some(app.run("prefs.set", json!({"key": "interfaceLanguage", "value": value})).map(|_| json!(value))); - } - if let Some(r) = crate::panels::character::intercept_text_command(app, id) { - return Some(r); - } - if let Some(r) = crate::shortcut_editor::run_command(app, id, p) - .or_else(|| crate::workspaces::run_command(app, id, p)) - .or_else(|| crate::edit_toolbar::command(app, id, p)) - { - return Some(r); - } - let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); - let flag = |b: &mut bool| { - *b = !*b; - Ok(json!(*b)) - }; - let r = match id { - "file.newDialog" => { - crate::dialogs::open_new_document(app); - Ok(Value::Null) - } - "file.newWithoutDialog" => crate::dialogs::new_document_without_dialog(app), - "app.home" => { - app.ui.home = Some(home_key(app)); - Ok(Value::Null) - } - "file.open" => match s("path") { - Some(path) => io::open_path(app, &path), - None => io::open_dialog(app).map(|_| Value::Null), - }, - // Saves write through the app (save panel, download); with no path known the panel and the - // format's options dialog ask first. - "file.save" => io::save(app, SaveMode::Save, p, true), - "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), - "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), - "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), - "document.exportSelection" => { - io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) - } - // Bytes sent by an agent go straight to the engine; a path or nothing opens it here. - "file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")), - "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), - "file.reveal" => io::reveal(app), - "file.cancelExport" => Ok(io::cancel_exports(app)), - "file.exportInBackground" => Ok(io::hide_exports(app)), - id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { - Some(path) => io::open_recent_reporting(app, &path), - None => Err("no such recent file".into()), - }, - "type.findFont" => { - crate::find_font::open(app); - Ok(Value::Null) - } - "ui.missingFontsDialog" => crate::dialogs::missing_fonts::open_command(app, s("folder")), - "ui.findFontsInFolder" => crate::dialogs::missing_fonts::find_in_folder_command(app, s("folder")), - "file.clearRecent" => { - app.ui.recent_files.clear(); - Ok(Value::Null) - } - "file.recentFiles" => Ok(json!(io::recent_files(app))), - "file.place" => crate::place::run(app, p), - "file.place.queue" => crate::place::queue(app, p), - "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { - crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None); - Ok(Value::Null) - } - "file.export.svg" => io::export(app, Some("svg"), s("path"), p), - "file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => { - crate::dialogs::open_export_for_screens(app); - Ok(Value::Null) - } - "file.exportForScreens" => io::export_for_screens(app, p.clone()), - "file.export.png" if s("path").is_none() => io::target_path(app, None, "png").and_then(|path| { - let f = vectorcraft_engine::cmd::fileio::format("png").ok_or("no PNG encoder")?; - let mut params = if p.is_object() { p.clone() } else { json!({}) }; - params["format"] = json!("png"); - params["path"] = json!(path); - crate::dialogs::open_raster_options(app, f, params); - Ok(Value::Null) - }), - "file.export.png" => io::export(app, Some("png"), s("path"), p), - "file.documentSetup" => crate::dialogs::open_document_setup(app), - "edit.preferences" => { - crate::prefs_dialog::open(app, s("category").as_deref()); - Ok(Value::Null) - } - // Edit → Color Settings… / Assign Profile… (no params): show the colour-management panel. - "edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => { - app.ui.open_panel = Some("separations".into()); - app.ui.dock = true; - app.session.execute(id, p).map_err(|e| e.to_string()) - } - "view.outline" => flag(&mut app.ui.view.outline), - "view.pixelPreview" => flag(&mut app.ui.view.pixel_preview), - "view.trimView" => flag(&mut app.ui.view.trim_view), - "view.cornerWidget" => flag(&mut app.ui.view.corner_widgets), - // New View: the engine stores the current zoom, centre and rotation under the given name. - "view.saved.new" if p.get("zoom").is_none() => { - let Some(v) = app.view().copied() else { return Some(Err("no document".into())) }; - let mut params = if p.is_object() { p.clone() } else { json!({}) }; - params["center"] = json!([v.center.x, v.center.y]); - params["zoom"] = json!(v.zoom); - params["rotation"] = json!(v.rotation); - app.session.execute(id, ¶ms).map_err(|e| e.to_string()) - } - // A saved view: its zoom, centre and rotation, in the current window. - id if saved_view_slot(id).is_some() => { - let saved = saved_view(app, id).and_then(|sv| crate::state::View::saved(sv).map(|to| (sv.name.clone(), to))); - match (saved, app.view_mut()) { - (Some((name, to)), Some(v)) => { - (v.center, v.zoom, v.rotation) = (to.center, to.zoom, to.rotation); - Ok(json!({ "name": name })) - } - _ => Err("no such view".into()), - } - } - id if saved_selection_slot(id).is_some() => match saved_selection_name(app, id) { - Some(name) => app.run("select.recall", json!({ "name": name })), - None => Err("no such saved selection".into()), - }, - "view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel), - "view.snapToGlyph" => flag(&mut app.ui.view.snap_to_glyph), - // The preference itself (General › Use Precise Cursors), so the command, the dialog and the saved - // preferences share one value. - "view.preciseCursors" => { - let on = !app.session.prefs.use_precise_cursors; - app.run("prefs.set", json!({"key": "usePreciseCursors", "value": on})).map(|_| { - crate::prefs_dialog::snapshot(app); - json!(on) - }) - } - "view.textThreads" => flag(&mut app.ui.view.text_threads), - "type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars), - "type.bold" => crate::panels::character::toggle_face(app, Face::Bold), - "type.italic" => crate::panels::character::toggle_face(app, Face::Italic), - id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => { - crate::panels::paragraph::align(app, justify) - } - "type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p), - "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p), - "type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app), - id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b), - "view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator), - "view.livePaintGaps" => flag(&mut app.ui.view.live_paint_gaps), - "effect.last" => match app.last_effect.clone() { - Some((e, params)) => { - let label = vectorcraft_effects::effect_info(&e).map(|i| i.label.trim_end_matches('…').to_string()).unwrap_or_else(|| e.clone()); - let mut fields = params.as_object().cloned().unwrap_or_default(); - fields.insert("__effect".into(), json!(e)); - fields.insert("__label".into(), json!(label)); - fields.insert("preview".into(), json!(true)); - app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields }); - Ok(Value::Null) - } - None => Err("no effect applied yet".into()), - }, - id if id.starts_with("type.recentFont") => { - let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); - match n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned() { - Some(font) => app.run("text.setStyle", json!({ "font": font })), - None => Err("no such recent font".into()), - } - } - "view.edges" => flag(&mut app.ui.view.edges), - "view.artboards" => flag(&mut app.ui.view.artboards), - "view.template" => flag(&mut app.ui.view.templates), - "view.rulers" => flag(&mut app.ui.view.rulers), - "view.videoRulers" => { - let v = &mut app.ui.view; - let show = !(v.video_rulers && v.rulers); - v.video_rulers = show; - v.rulers |= show; - Ok(json!(show)) - } - "view.boundingBox" => flag(&mut app.ui.view.bounding_box), - "view.guides" => { - let r = flag(&mut app.ui.view.guides); - // Hidden ruler guides and guide paths cannot stay selected: their selection overlay - // would otherwise reveal geometry that Hide Guides just hid. - let hidden_selection = !app.ui.view.guides - && app.session.active().is_some_and(|d| { - !d.selection.guides.is_empty() - || d.selection - .objects - .iter() - .any(|id| matches!(d.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Path { guide: true, .. }))) - }); - if hidden_selection { - // Deselecting a document that is open cannot fail. - let _ = app.run("select.none", json!({})); - } - r - } - "view.smartGuides" => flag(&mut app.ui.view.smart_guides), - "view.grid" => flag(&mut app.ui.view.grid), - "view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid), - "view.snapToPoint" => flag(&mut app.ui.view.snap_to_point), - "view.zoomIn" | "view.zoomOut" => { - let up = id == "view.zoomIn"; - let print = app.session.prefs.display_print_size; - // Selection & Anchor Display › Zoom to Selection: the selection comes to the middle. - let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None }; - match app.view_mut() { - Some(v) => { - v.zoom = next_zoom(v.zoom, up); - if let Some(c) = focus { - v.center = c; - } - Ok(json!({"zoom": crate::state::zoom_display_percent(v.zoom, print)})) - } - None => Err("no document".into()), - } - } - "view.setZoom" => { - let print = app.session.prefs.display_print_size; - let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print); - let center = - p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); - match app.view_mut() { - Some(v) => { - v.zoom = z.clamp(0.0313, 640.0); - v.fitted = true; - if let Some(c) = center { - v.center = c; - } - Ok(Value::Null) - } - None => Err("no document".into()), - } - } - "view.fitArtboard" | "view.fitAll" | "view.actualSize" => { - crate::canvas::fit(app, id); - Ok(Value::Null) - } - "view.goToArtboard" => go_to_artboard(app, p), - "view.presentation" => { - app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 }; - Ok(json!(app.ui.screen_mode)) - } - "view.screenMode" => { - app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { - Some(m) => m.min(3) as u8, - // F cycles the three screen modes. Without a mode, the command also goes from - // Presentation Mode (not in the cycle) back to Normal. - None if app.ui.screen_mode >= 2 => 0, - None => app.ui.screen_mode + 1, - }; - Ok(json!(app.ui.screen_mode)) - } - // View › Rotate View and the status bar's angle menu: the view turns about the center of the - // canvas to the angle, normalized as a Rotate View drag normalizes it. - "view.rotate" => { - let key = "angle"; - let Some(angle) = p.get(key).and_then(Value::as_f64).filter(|a| a.is_finite()) else { - return Some(Err(format!("`{key}` must be a number"))); - }; - match app.view_mut() { - Some(v) => { - v.rotation = vectorcraft_geom::normalize_deg(angle); - Ok(json!({ "angle": v.rotation })) - } - None => Err("no document".into()), - } - } - // View › Rotate View to Selection: the view turns about the center of the canvas to the angle - // of the selection's bounding box, so the box is square to the window. - "view.rotateToSelection" => { - let Some(st) = app.session.active() else { return Some(Err("no document".into())) }; - if st.selection.objects.is_empty() { - return Some(Err("nothing selected".into())); - } - let angle = vectorcraft_geom::normalize_deg(st.doc.bbox_angle(&st.selection.objects)); - match app.view_mut() { - Some(v) => { - v.rotation = angle; - Ok(json!({ "angle": angle })) - } - None => Err("no document".into()), - } - } - "view.rotateReset" => { - if let Some(v) = app.view_mut() { - v.rotation = 0.0; - } - Ok(Value::Null) - } - "window.control" => flag(&mut app.ui.control_bar), - "window.toolbar" => flag(&mut app.ui.toolbar), - "window.toolbarColumns" => opt_bool(p, "double").map(|on| { - let on = on.unwrap_or(!app.ui.toolbar_double); - app.ui.toolbar_double = on; - json!(on) - }), - "window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced), - "imageTrace.preview" => opt_bool(p, "on").map(|on| { - let on = on.unwrap_or(!app.ui.image_trace_preview); - app.ui.image_trace_preview = on; - json!(on) - }), - "window.floatTools" => { - let floating = match opt_bool(p, "floating") { - Ok(on) => on, - Err(e) => return Some(Err(e)), - }; - let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) }; - crate::toolbar::float_group(app, &tool, floating).map(Value::Bool) - } - "window.taskBar" => flag(&mut app.ui.task_bar), - "window.taskBar.pin" => { - let place = &mut app.ui.task_bar_place; - opt_bool(p, "pinned").map(|pinned| { - let pinned = pinned.unwrap_or(!place.pinned); - // Pinning holds the bar where it shows; unpinning lets it follow the selection from - // there (the bar turns its pinned spot into an offset when it is next drawn). - if pinned && !place.pinned { - place.pin_at = place.shown_at; - } - place.pinned = pinned; - json!(pinned) - }) - } - "window.taskBar.reset" => { - app.ui.task_bar_place = Default::default(); - Ok(Value::Null) - } - "window.dock" => { - let ui = &mut app.ui; - // The bars the last Tab hid show again, unless the toolbar or the Control bar was shown - // another way since then (Window › Tools, Window › Control). Panels shown with Shift+Tab - // in between stay shown. Switching workspaces clears the record (`workspaces::apply`). - let hid = ui.tab_hid.take().filter(|h| (h.dock || h.toolbar || h.control_bar) && !ui.toolbar && !ui.control_bar); - let on = if let Some(h) = hid { - ui.dock |= h.dock; - ui.toolbar = h.toolbar; - ui.control_bar = h.control_bar; - true - } else if ui.dock || ui.toolbar || ui.control_bar { - ui.tab_hid = Some(crate::state::TabHid { dock: ui.dock, toolbar: ui.toolbar, control_bar: ui.control_bar }); - ui.dock = false; - ui.toolbar = false; - ui.control_bar = false; - ui.open_panel = None; - false - } else { - ui.dock = true; - ui.toolbar = true; - ui.control_bar = true; - true - }; - Ok(json!(on)) - } - "window.panels" => { - let on = !app.ui.dock; - app.ui.dock = on; - if !on { - app.ui.open_panel = None; - } - Ok(json!(on)) - } - "window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) { - Some(b) => { - app.ui.brightness = b; - app.session.prefs.ui_brightness = b.id().into(); - app.canvas.key = None; - Ok(json!(b.id())) - } - None => Err("brightness must be dark|mediumDark|mediumLight|light".into()), - }, - "window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()), - "tool.select" => match s("tool") { - Some(t) if vectorcraft_tools::tool_info(&t).is_some() => { - app.select_tool(&t); - Ok(json!({"tool": app.session.tool_id()})) - } - Some(t) => Err(format!("unknown tool `{t}`")), - None => Err("missing `tool`".into()), - }, - "tool.setOption" => app.session.set_tool_option_cmd(p), - "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), - "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), - "ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()), - "ui.cropImage" => { - app.select_tool(vectorcraft_tools::cropimage::ID); - Ok(json!({"tool": app.session.tool_id()})) - } - "ui.paramDialog" => { - let cmd = s("command").unwrap_or_default(); - let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); - fields.insert("__command".into(), json!(cmd)); - fields.insert("__label".into(), json!(s("label").unwrap_or(cmd.clone()))); - app.ui.dialog = Some(crate::state::Dialog { kind: "command".into(), fields }); - Ok(Value::Null) - } - "effect.applyLast" => match app.last_effect.clone() { - Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})), - None => Err("no effect applied yet".into()), - }, - "file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})), - "file.export.pdf" => io::export_pdf(app, p.clone()), - "help.about" => { - app.ui.about = true; - Ok(Value::Null) - } - "help.checkForUpdates" => crate::updates_ui::check(app), - "help.commandPalette" => { - app.ui.palette_open = !app.ui.palette_open; - app.ui.palette_query.clear(); - Ok(Value::Null) - } - // Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`). - "file.close" => { - let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index())?; - crate::unsaved::close(app, i) - } - "file.closeAll" => crate::unsaved::close_all(app, "closeAll"), - "app.quit" => crate::unsaved::close_all(app, "quit"), - "ui.swatchOptions" => match s("name") { - Some(name) => crate::dialogs::swatch_options::open(app, &name), - None => Err("missing `name`".into()), - }, - "ui.newSwatch" => { - let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); - crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref()) - } - "ui.newColorGroup" => { - let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::new_color_group::open(app, names.unwrap_or_default()) - } - "ui.colorPicker" => crate::dialogs::open_color_picker(app, p), - "ui.sampleScreenColor" => crate::screen_color::start(app, p), - "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), - "ui.symbolOptions" => { - let name = s("name"); - let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false); - if edit || name.is_some() { - crate::dialogs::symbol_options::open_edit(app, name.as_deref()) - } else { - crate::dialogs::symbol_options::open(app) - } - } - "ui.newBrush" => crate::dialogs::new_brush::open(app, p.get("ids").cloned()), - "ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()), - "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), - "ui.colorGuideOptions" => { - crate::dialogs::color_guide_options::open(app); - Ok(Value::Null) - } - "ui.colorBalanceDialog" => { - crate::dialogs::color_balance::open(app); - Ok(Value::Null) - } - "ui.saturateDialog" => { - crate::dialogs::saturate::open(app); - Ok(Value::Null) - } - "window.swatchLibrary" => crate::panels::swatches::open_library(app, p), - "window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")), - "ui.saveSwatchLibrary" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::save_swatch_library::open(app, names.unwrap_or_default()) - } - id if id.starts_with(crate::panels::swatches::USER_SLOT) => match crate::panels::swatches::user_library(app, id) { - Some(l) => crate::panels::swatches::open_library(app, &json!({ "library": l.id })), - None => Err("no such user library".into()), - }, - "ui.mergeGraphicStyles" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default()) - } - "ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app), - "ui.layerOptions" => crate::dialogs::layer_options::open(app, p), - "ui.newLayer" => crate::dialogs::layer_options::open_new(app, p), - "ui.newArtboard" => crate::dialogs::artboard_options::open_new(app), - "ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app), - "ui.layersExpand" => crate::panels::layers::expand(app, p), - "ui.layersFilter" => crate::panels::layers::set_filter(app, p), - "ui.flattenTransparencyDialog" => { - crate::dialogs::flatten::open(app); - Ok(Value::Null) - } - "ui.flattenerPresetsDialog" => { - crate::dialogs::flattener_presets::open(app, s("selected").as_deref()); - Ok(Value::Null) - } - "ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p), - "window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p), - "ui.libraryPanel" => crate::panels::library_panel::command(app, p), - "ui.libraryPanel.locate" => crate::panels::library_panel::locate(app, p), - "window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")), - "ui.saveGraphicStyleLibrary" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::save_style_library::open(app, names.unwrap_or_default()) - } - id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) { - Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), - None => Err("no such user library".into()), - }, - "window.symbolLibrary" => crate::panels::symbols::open_library(app, p), - "window.symbolLibrary.other" => crate::panels::symbols::other_library(app, s("path")), - "ui.saveSymbolLibrary" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::save_symbol_library::open(app, names.unwrap_or_default()) - } - id if id.starts_with(crate::panels::symbols::USER_SLOT) => match crate::panels::symbols::user_library(app, id) { - Some(l) => crate::panels::symbols::open_library(app, &json!({ "library": l.id })), - None => Err("no such user library".into()), - }, - "window.brushLibrary" => crate::panels::brushes::open_library(app, p), - "window.brushLibrary.other" => crate::panels::brushes::other_library(app, s("path")), - "ui.saveBrushLibrary" => { - let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - crate::dialogs::save_brush_library::open(app, names.unwrap_or_default()) - } - id if id.starts_with(crate::panels::brushes::USER_SLOT) => match crate::panels::brushes::user_library(app, id) { - Some(l) => crate::panels::brushes::open_library(app, &json!({ "library": l.id })), - None => Err("no such user library".into()), - }, - "ui.expandDialog" => crate::dialogs::expand::open(app), - "ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app), - "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app), - "attributes.openUrl" => crate::panels::attributes::open_url(app, p), - "ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p), - "ui.spotColors" => crate::dialogs::spot_colors::open(app), - "ui.menuDialog" => match s("command").as_deref() { - Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), - c => match c.and_then(|c| menu_dialog(app, c)) { - Some((kind, fields)) => { - app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); - Ok(Value::Null) - } - None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), - }, - }, - "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), - "ui.corners" => crate::dialogs::corners::open(app, p), - "ui.hyphenation" => crate::dialogs::hyphenation::open(app), - "ui.justification" => crate::dialogs::justification::open(app), - "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), - "ui.colorPanel" => crate::panels::color::command(app, p), - "ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p), - "file.exportAs" if s("path").is_none() => { - crate::dialogs::open_export_as(app, s("format").as_deref(), p); - Ok(Value::Null) - } - "file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p), - "ui.fileInfoDialog" => crate::dialogs::file_info::open(app), - "ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app), - "ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p), - "ui.pdfPresetsDialog" => { - crate::dialogs::pdf_presets::open(app, s("selected").as_deref()); - Ok(Value::Null) - } - "ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p), - // Save for Office Documents…: its dialog; with options, pick the file (or write `path`). - "document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app), - "document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })), - "ui.dxfOptionsDialog" if app.session.active().is_none() => Err("no document".into()), - "ui.dxfOptionsDialog" => { - crate::dialogs::dxf_options::open(app, p); - Ok(Value::Null) - } - "links.editOriginal" => crate::panels::links::open_file(app, p, false), - "links.reveal" => crate::panels::links::open_file(app, p, true), - "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), - "ui.findLinksInFolder" => { - crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) - } - "ui.packageDialog" => crate::dialogs::package::open(app), - "ui.batchDialog" => { - let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; - let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; - crate::dialogs::action_batch::open(app, set, action) - } - "ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p), - "file.showPackage" => crate::dialogs::package::show_package(app, p), - "docInfo.save" => crate::panels::doc_info::save_report(app, p), - // Slice Options… and Divide Slices…: their dialogs, on the selected slices. - "object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app), - "object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app), - "ui.epsOptionsDialog" if app.session.active().is_none() => Err("no document".into()), - "ui.epsOptionsDialog" => { - crate::dialogs::eps_options::open(app, p); - Ok(Value::Null) - } - // Save for Web: its dialog; with settings, write the files. - "file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::save_for_web::open(app), - "file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()), - "file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p), - // File → Export Selection…: the selection becomes one asset (`multiple: true`: one per - // object), shown checked on Export for Screens' Assets tab. - "file.exportSelection" => { - app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| { - let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); - crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); - json!({ "assets": ids }) - }) - } - "css.copy" => crate::panels::css_properties::copy(app, p), - "css.exportFile" => crate::panels::css_properties::export_file(app, p), - // File → Print…: its dialog; with params, print (or save the job as a PDF) without it. - "file.print" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::print::open(app), - "file.print" => crate::print::run(app, p), - "print.printers" => Ok(crate::print::printers(app)), - "print.printerSetup" => { - let printer = s("printer").filter(|n| !n.is_empty()); - match app.services.print.as_mut() { - Some(service) => service.setup(printer.as_deref()).map(|_| Value::Null), - None => Err("printing isn't available here".into()), - } - } - "ui.printPresetsDialog" => { - crate::dialogs::print_presets::open(app, s("selected").as_deref()); - Ok(Value::Null) - } - "ui.printPresetDialog" => crate::dialogs::print::open_preset(app, p), - "plugin.dialog" => crate::dialogs::plugin::open(app, p), - "ui.installPlugin" => io::install_plugin(app, p), - "plugin.cancelRuns" => Ok(crate::plugin_runs::cancel_all(app)), - "plugin.hideRuns" => Ok(crate::plugin_runs::hide_all(app)), - "ui.checkScript" => crate::dialogs::script_check::command(app, p), - "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), - "ui.perspectivePresetsDialog" => { - crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); - Ok(Value::Null) - } - "ui.savePerspectivePreset" => crate::dialogs::perspective_grid::open_save(app), - id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => match crate::dialogs::perspective_presets::slot_preset(app, id) { - Some(name) => app.run("perspective.grid.preset", json!({ "name": name })), - None => Err("no such perspective grid preset".into()), - }, - "ui.blendOptions" => crate::dialogs::blend_options::open(app), - "ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app), - "ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p), - _ => return None, - }; - Some(r) -} - -/// `view.goToArtboard`: make artboard `index` (a number, or first / previous / next / last from -/// the current one) active, synchronizing the Artboard tool when in use, and fit it in the window. -fn go_to_artboard(app: &mut VectorcraftApp, p: &Value) -> Result { - let n = app.session.active().map(|d| d.doc.artboards.len()).ok_or("no document open")?; - let last = n.checked_sub(1).ok_or("the document has no artboards")?; - let current = app.view().map_or(0, |v| v.artboard).min(last); - let to = match p.get("index") { - Some(Value::String(s)) => match s.as_str() { - "first" => 0, - "previous" => current.saturating_sub(1), - "next" => current + 1, - "last" => last, - other => return Err(format!("index must be a number or first, previous, next or last, not `{other}`")), - }, - Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("index must be a number or first, previous, next or last")?, - None => return Err("missing index".into()), - } - .min(last); - crate::panels::artboards::select(app, to); - crate::canvas::fit(app, "view.fitArtboard"); - Ok(json!({ "index": to })) -} - -/// Checked state for toggle items. -pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { - if id == "app.language" { - let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); - let pref = app.session.prefs.interface_language.as_str(); - return Some(if lang.eq_ignore_ascii_case("auto") { - crate::i18n::Lang::from_code(pref).is_none() - } else { - crate::i18n::Lang::from_code(pref).is_some_and(|l| l.code().eq_ignore_ascii_case(lang)) - }); - } - let v = &app.ui.view; - // An item that can be on or off is `Some` whatever the document and selection (off with none): - // the macOS menu bar can't change an item's kind without rebuilding. - // The first type object (of type on a path, with `onpath`) among the selected objects and the - // objects inside them, in the order the type commands act on them. - let selected_text = |onpath: bool| { - let st = app.session.active()?; - let mut found = None; - for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { - n.walk(&mut |c| { - if found.is_none() - && let vectorcraft_engine::doc::NodeKind::Text(t) = &c.kind - && (!onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. })) - { - found = Some(t); - } - }); - if found.is_some() { - break; - } - } - found - }; - Some(match id { - "type.orientation.vertical" | "type.orientation.horizontal" => { - selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical")) - } - // Checked when the first type object `selected_text` finds has it on. - "type.opticalMarginAlignment" => selected_text(false).is_some_and(|t| t.optical_margin), - "view.outline" => v.outline, - "view.pixelPreview" => v.pixel_preview, - "view.trimView" => v.trim_view, - "view.snapToPixel" => v.snap_to_pixel, - "view.snapToGlyph" => v.snap_to_glyph, - // Type on a Path effect of the selected path type. - "type.pathOptions" if p.get("effect").is_some() => { - selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())) - } - "view.smartGuides" => v.smart_guides, - "view.grid" => v.grid, - "view.snapToGrid" => v.snap_to_grid, - "view.snapToPoint" => v.snap_to_point, - "view.rulers" => v.rulers, - // A View › Rotate View item is checked when the active view is turned to its angle; without - // a document no item is checked. - "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), - "window.control" => app.ui.control_bar, - "window.toolbar" => app.ui.toolbar, - "window.toolbarAdvanced" => app.ui.toolbar_advanced, - "window.toolbarColumns" => app.ui.toolbar_double, - "window.editToolbar" => app.ui.tools_drawer, - "window.floatTools" => { - let tool = p.get("tool").and_then(Value::as_str).unwrap_or(""); - app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool)) - } - "window.taskBar" => app.ui.task_bar, - "window.taskBar.pin" => app.ui.task_bar_place.pinned, - "window.panel" => { - let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); - normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) - } - "window.collapseDock" => crate::panel_docking::dock_collapsed(&app.ui), - "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), - "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), - "view.slices.lock" => app.session.slices_locked(), - "object.slice.clipToArtboard" => app.session.active().is_some_and(|d| d.doc.slices_clip_to_artboard), - "view.proofColors" => vectorcraft_render::proof::view().proof_colors, - "view.overprintPreview" => vectorcraft_render::proof::view().overprint, - "view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()), - "perspective.grid.snap" => perspective_grid(app).is_some_and(|g| g.snap), - "perspective.grid.lockStation" => perspective_grid(app).is_some_and(|g| g.lock_station), - "file.documentColorMode" | "object.convertDocumentColorMode" => { - let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk); - p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" }) - } - // The ruler context menu lists the document units, the current one checked. - "document.setUnits" => { - p.get("units").and_then(Value::as_str).and_then(vectorcraft_engine::doc::Unit::named) == Some(app.session.general_unit()) - } - _ => return None, - }) -} - -/// The active document's perspective grid (View → Perspective Grid toggles). -fn perspective_grid(app: &VectorcraftApp) -> Option { - app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc)) -} - -/// Label for toggles whose text flips (Outline/Preview, Hide/Show …). -pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { - let v = &app.ui.view; - match id { - "view.outline" => if v.outline { "Preview" } else { "Outline" }.into(), - "window.workspace.reset" => format!("Reset {}", app.ui.workspace), - "view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(), - "view.template" => if v.templates { "Hide Template" } else { "Show Template" }.into(), - "view.cornerWidget" => if v.corner_widgets { "Hide Corner Widget" } else { "Show Corner Widget" }.into(), - "view.textThreads" => if v.text_threads { "Hide Text Threads" } else { "Show Text Threads" }.into(), - "type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(), - "view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(), - "view.livePaintGaps" => if v.live_paint_gaps { "Hide Live Paint Gaps" } else { "Show Live Paint Gaps" }.into(), - "effect.last" => match &app.last_effect { - Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())), - None => label.into(), - }, - id if id.starts_with("type.recentFont") => { - let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); - n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into()) - } - id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()), - id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), - "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), - "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), - "view.videoRulers" => if v.video_rulers && v.rulers { "Hide Video Rulers" } else { "Show Video Rulers" }.into(), - "view.globalRulers" => if app.session.active().is_some_and(|d| d.doc.rulers == vectorcraft_doc::RulerMode::Global) { - "Change to Artboard Rulers" - } else { - "Change to Global Rulers" - } - .into(), - "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), - "view.transparencyGrid" => { - if app.session.active().is_some_and(|d| d.transparency_grid) { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into() - } - "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), - "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), - "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), - "view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(), - "view.printTiling" => if app.session.active().is_some_and(|d| d.print_tiling) { "Hide Print Tiling" } else { "Show Print Tiling" }.into(), - id if id.starts_with("file.openRecent") => recent_slot(app, id) - .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) - .unwrap_or_else(|| "—".into()), - // Edit Contents reads Edit Envelope while an envelope's contents are being edited. - "object.envelope.editContents" => { - let editing = app.session.active().is_some_and(|st| { - st.selection - .objects - .first() - .and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id)) - .is_some_and(|e| matches!(e.kind, vectorcraft_doc::NodeKind::Envelope { editing: true, .. })) - }); - if editing { "Edit Envelope" } else { "Edit Contents" }.into() - } - "select.globalEdit" => { - if app.session.active().is_some_and(|d| d.global_edit.is_some()) { "Stop Global Edit" } else { "Start Global Edit" }.into() - } - "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), - "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), - id if id.starts_with(crate::panels::swatches::USER_SLOT) => { - crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { - crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::symbols::USER_SLOT) => { - crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::brushes::USER_SLOT) => { - crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { - crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) - } - "perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(), - "perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(), - "perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(), - _ => label.into(), - } -} - -/// Does the menu item `id` show a name that is user, file or system data rather than an interface -/// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a -/// plug-in? Such names are shown as they are, never translated (a workspace the user calls -/// "Layers" stays "Layers"). -fn shows_a_name(id: &str, label: &str) -> bool { - const SLOTS: [&str; 8] = [ - "file.openRecent", - "view.goto", - "type.recentFont", - crate::panels::swatches::USER_SLOT, - crate::panels::graphic_styles::USER_SLOT, - crate::panels::symbols::USER_SLOT, - crate::panels::brushes::USER_SLOT, - crate::dialogs::perspective_presets::SLOT, - ]; - SLOTS.iter().any(|s| id.starts_with(s)) - || saved_selection_slot(id).is_some() - || matches!(id, "text.setStyle" | "plugin.dialog") - || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) - || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) -} - -/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or -/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds -/// while another thread installs or removes plug-ins. -pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { - id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() -} - -/// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled -/// around a name (Undo *Move*, Reset *Essentials*, Last Effect: *Drop Shadow*) are translated as -/// templates so the name can move; names that are user data (files, views, fonts, custom -/// workspaces, plug-ins: [`shows_a_name`]) pass through. -pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String { - let lang = crate::i18n::current(); - let fmt = crate::i18n::fmt; - match id { - "edit.undo" | "edit.redo" => { - let last = app.session.active().and_then(|d| if id == "edit.undo" { d.history.undo.last() } else { d.history.redo.last() }); - // The step's name as the History panel lists it. - let name = crate::panels::history::shown; - match last { - Some(h) if id == "edit.undo" => fmt(tl!("Undo {name}"), &[("name", &name(h))]), - Some(h) => fmt(tl!("Redo {name}"), &[("name", &name(h))]), - None => tl!(if id == "edit.undo" { "Undo" } else { "Redo" }).to_string(), - } - } - "window.workspace.reset" => { - let name = &app.ui.workspace; - fmt(tl!("Reset {name}"), &[("name", if crate::workspaces::is_builtin(name) { tl!(name) } else { name })]) - } - "effect.last" => match &app.last_effect { - Some((e, _)) => { - let name = vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str()); - fmt(tl!("Last Effect: {name}"), &[("name", crate::i18n::tr_id(lang, e, name))]) - } - None => crate::i18n::tr_id(lang, id, label).to_string(), - }, - _ => { - let shown = dynamic_label(app, id, label); - if shows_a_name(id, &shown) { shown } else { crate::i18n::tr_id(lang, id, &shown).to_string() } - } - } -} - -/// The label of an Effect menu item in the UI language, by the effect's own id. Every effect item -/// runs `effect.apply` or `effect.dialog`, so the command id can't tell them apart; an `@id` row -/// for the effect id (for example `pathfinder.add`) wins over the plain row of the English label. -/// `None` when the catalog has no such row, and the plain translation applies. -fn effect_item_label(id: &str, p: &Value) -> Option { - if !matches!(id, "effect.apply" | "effect.dialog") { - return None; - } - let effect = p.get("effect")?.as_str()?; - let shown = crate::i18n::tr_id(crate::i18n::current(), effect, ""); - (!shown.is_empty()).then(|| shown.to_string()) -} - -/// The label of an item of the canvas context menu's Unlock submenu (`layer.setProps` with one id -/// and `locked: false`): the object's name as the Layers panel shows it in `lang` -/// ([`crate::panels::layers::shown_name`]). `None` for any other item, or when the object is gone. -fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: crate::i18n::Lang) -> Option { - if id != "layer.setProps" || p.get("locked") != Some(&Value::Bool(false)) { - return None; - } - let [one] = p.get("ids")?.as_array()?.as_slice() else { return None }; - let n = app.session.active()?.doc.node(vectorcraft_doc::NodeId(one.as_u64()?))?; - Some(crate::panels::layers::shown_name(app, n, lang)) -} - -/// Menu slots that are left out while they have nothing to show (unused saved views, recent files, -/// User Defined swatch and graphic style libraries). -fn hidden_when_disabled(id: &str) -> bool { - id.starts_with("view.goto") - || saved_selection_slot(id).is_some() - || id.starts_with("file.openRecent") - || id.starts_with(crate::panels::swatches::USER_SLOT) - || id.starts_with(crate::panels::graphic_styles::USER_SLOT) - || id.starts_with(crate::panels::symbols::USER_SLOT) - || id.starts_with(crate::panels::brushes::USER_SLOT) - || id.starts_with(crate::dialogs::perspective_presets::SLOT) -} - -/// Menu items another item stands in for right now: Envelope Distort's Reset with Warp and Reset -/// with Mesh take the place of Make with Warp and Make with Mesh while an envelope is selected. -fn swapped_out(app: &VectorcraftApp, id: &str) -> bool { - let reset = match id { - "object.envelope.makeWithWarp" | "object.envelope.makeWithMesh" => false, - "object.envelope.resetWithWarp" | "object.envelope.resetWithMesh" => true, - _ => return false, - }; - enabled(app, "object.envelope.release") != reset -} - -/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them). -const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [ - "file.openRecent1", - "file.openRecent2", - "file.openRecent3", - "file.openRecent4", - "file.openRecent5", - "file.openRecent6", - "file.openRecent7", - "file.openRecent8", - "file.openRecent9", - "file.openRecent10", - "file.openRecent11", - "file.openRecent12", - "file.openRecent13", - "file.openRecent14", - "file.openRecent15", - "file.openRecent16", - "file.openRecent17", - "file.openRecent18", - "file.openRecent19", - "file.openRecent20", - "file.openRecent21", - "file.openRecent22", - "file.openRecent23", - "file.openRecent24", - "file.openRecent25", - "file.openRecent26", - "file.openRecent27", - "file.openRecent28", - "file.openRecent29", - "file.openRecent30", -]; - -/// The saved selection a `select.recallN` slot names (none past the document's last). -fn saved_selection_name(app: &VectorcraftApp, id: &str) -> Option { - let n = saved_selection_slot(id)?.checked_sub(1)?; - app.session.active()?.doc.saved_selections.get(n).map(|x| x.name.clone()) -} - -/// The recent file a `file.openRecentN` slot names (none past the preference's count). -fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> { - let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?; - io::recent_files(app).get(n.checked_sub(1)?) -} - -/// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on -/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item -/// another stands in for ([`swapped_out`]). -pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option)> { - let en = enabled(app, id); - if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { - return None; - } - Some((en, checked(app, id, p))) -} +pub use bar::menu_bar; +pub(crate) use bar::{home_key, home_showing}; +#[cfg(test)] +use bar::{menu_body, pointer_reaches_title}; +pub use context::{context_items, context_menu_body, ruler_menu_body, ruler_unit_items}; +pub use entries::{MenuEntry, Row, context_entries, entry, menu_entries, pretty_shortcut}; +pub(crate) use invoke::graph_dialog; +pub use invoke::{click_target, invoke, invoke_from_system_menu}; +#[cfg(test)] +pub(crate) use labels::lists_plugin; +#[cfg(test)] +use labels::shows_a_name; +pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings}; +pub use run::{check_params, run_ui_command}; +pub(crate) use run::{normalize_panel, opt_bool}; +#[cfg(test)] +use slots::RECENT_FONT_IDS; +pub use state::{checked, enabled, plugin_revision, shortcut_of, shown_state}; +pub(crate) use submenus::PATH_EFFECTS; +pub use tree::{menu_tree, menu_tree_named}; +pub use ui_commands::UI_COMMANDS; /// One row of a menu as it shows now. The in-window menus draw these and the macOS menu bar /// ([`crate::native_menu`]) is built from them, so the two can't drift. @@ -2355,2091 +90,5 @@ pub enum Entry<'a> { Item(Row<'a>), } -/// A menu item as it shows now ([`entry`]). -pub struct Row<'a> { - /// The command and its params; `None` for an item not implemented yet (always disabled). - pub command: Option<(&'static str, &'a Value)>, - /// The tree's own English label, which [`click_target`] reads. - pub source: &'static str, - /// The label in the UI language, as it reads now (Undo *Move*, Show/Hide…, a recent file). - pub label: std::borrow::Cow<'static, str>, - /// The shortcut as the registry writes it (`Cmd+Shift+Z`), the user's overrides applied. - pub shortcut: Option<&'static str>, - pub enabled: bool, - /// `Some` for an item that is on or off. - pub checked: Option, -} - -impl Row<'_> { - /// What choosing it runs ([`click_target`]); `None` for an item not implemented yet. - pub fn target(&self) -> Option<(String, Value)> { - self.command.map(|(id, p)| click_target(self.source, id, p)) - } -} - -/// Item `it` as the menus show it now, or `None` while they leave it out ([`shown_state`]). -pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { - use crate::i18n::t; - Some(match it { - Item::Sep => Entry::Sep, - Item::Header(h) => Entry::Header(t(h)), - Item::Sub(label, children) => Entry::Sub(t(label), children), - Item::Todo(label, sc) => Entry::Item(Row { - command: None, - source: label, - label: t(label).into(), - shortcut: Some(*sc).filter(|s| !s.is_empty()), - enabled: false, - checked: None, - }), - Item::Cmd(label, id, p) => { - let (enabled, checked) = shown_state(app, id, p)?; - Entry::Item(Row { - command: Some((id, p)), - source: label, - label: effect_item_label(id, p) - .or_else(|| unlock_item_label(app, id, p, crate::i18n::current())) - .unwrap_or_else(|| display_label(app, id, label)) - .into(), - shortcut: item_shortcut(id, p), - enabled, - checked, - }) - } - }) -} - -/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins -/// list them (the command palette's cache follows it). -pub fn plugin_revision() -> u64 { - vectorcraft_plugins::registry::revision() -} - -/// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. -const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ - [ - "ui.perspectiveUserPreset1.1", - "ui.perspectiveUserPreset1.2", - "ui.perspectiveUserPreset1.3", - "ui.perspectiveUserPreset1.4", - "ui.perspectiveUserPreset1.5", - ], - [ - "ui.perspectiveUserPreset2.1", - "ui.perspectiveUserPreset2.2", - "ui.perspectiveUserPreset2.3", - "ui.perspectiveUserPreset2.4", - "ui.perspectiveUserPreset2.5", - ], - [ - "ui.perspectiveUserPreset3.1", - "ui.perspectiveUserPreset3.2", - "ui.perspectiveUserPreset3.3", - "ui.perspectiveUserPreset3.4", - "ui.perspectiveUserPreset3.5", - ], -]; - -/// Select → saved selections: the n-th saved selection of the active document. -const SAVED_SELECTION_IDS: [&str; vectorcraft_engine::doc::SavedSelection::MAX] = [ - "select.recall1", - "select.recall2", - "select.recall3", - "select.recall4", - "select.recall5", - "select.recall6", - "select.recall7", - "select.recall8", - "select.recall9", - "select.recall10", - "select.recall11", - "select.recall12", - "select.recall13", - "select.recall14", - "select.recall15", - "select.recall16", - "select.recall17", - "select.recall18", - "select.recall19", - "select.recall20", - "select.recall21", - "select.recall22", - "select.recall23", - "select.recall24", - "select.recall25", -]; - -/// View → saved views: the n-th saved view of the active document. -const SAVED_VIEW_IDS: [&str; vectorcraft_engine::doc::SavedView::MAX] = [ - "view.goto1", - "view.goto2", - "view.goto3", - "view.goto4", - "view.goto5", - "view.goto6", - "view.goto7", - "view.goto8", - "view.goto9", - "view.goto10", - "view.goto11", - "view.goto12", - "view.goto13", - "view.goto14", - "view.goto15", - "view.goto16", - "view.goto17", - "view.goto18", - "view.goto19", - "view.goto20", - "view.goto21", - "view.goto22", - "view.goto23", - "view.goto24", - "view.goto25", -]; - -/// The 1-based slot a `view.gotoN` id stands for. -fn saved_view_slot(id: &str) -> Option { - id.strip_prefix("view.goto")?.parse().ok() -} - -/// The saved view a `view.gotoN` slot names (none past the document's last). -fn saved_view<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a vectorcraft_engine::doc::SavedView> { - app.session.active()?.doc.views.get(saved_view_slot(id)?.checked_sub(1)?) -} - -/// The 1-based slot a `select.recallN` id stands for. -fn saved_selection_slot(id: &str) -> Option { - id.strip_prefix("select.recall")?.parse().ok() -} - -/// Type → Recent Fonts slots (Preferences › Type › Number of Recent Fonts shows up to 15). -const RECENT_FONT_IDS: [&str; crate::MAX_RECENT_FONTS] = [ - "type.recentFont1", - "type.recentFont2", - "type.recentFont3", - "type.recentFont4", - "type.recentFont5", - "type.recentFont6", - "type.recentFont7", - "type.recentFont8", - "type.recentFont9", - "type.recentFont10", - "type.recentFont11", - "type.recentFont12", - "type.recentFont13", - "type.recentFont14", - "type.recentFont15", -]; - -/// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default. -pub fn shortcut_of(id: &str) -> Option<&'static str> { - crate::shortcut_editor::command_shortcut(id) -} - -/// Is a command currently enabled? -/// Commands that would act on the text being typed, held back while an IME composes in the Type -/// tool (its marked text isn't committed yet): Undo/Redo — the native menu takes ⌘Z ahead of the -/// IME —, the clipboard and the selection. -/// Clear History also waits: it ends the typing session as Undo does. -pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool { - app.session.tool_composing() - && (matches!( - id, - "edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "select.all" | "select.none" | "select.inverse" - ) || id == "edit.clearHistory" - || id.starts_with("edit.paste")) -} - -/// Engine commands that take `ids` and otherwise act on the Appearance panel's subject (the -/// targeted layer, group or object, else the selection). Without `ids` and without a subject they -/// are off, as the panel's buttons are: in menus, in the command palette and on their keys. -const SUBJECT_COMMANDS: [&str; 2] = ["appearance.addFill", "appearance.addStroke"]; - -/// Is there an Appearance panel subject: a targeted layer, group or object, or a selected one? -fn has_subject(app: &VectorcraftApp) -> bool { - app.session.active().is_some_and(|st| !st.selection.subjects().is_empty()) -} - -/// Can command `id` run now? An engine command asks the engine, a UI command the app's state; -/// none that would act on text an input method is still composing. -pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { - if waits_for_ime(app, id) { - return false; - } - if SUBJECT_COMMANDS.contains(&id) { - return has_subject(app); - } - if let Some(c) = vectorcraft_engine::find_command(id) { - // The system clipboard's contents can be pasted with an empty internal clipboard. - return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste"); - } - match id { - // Save is off for a clean document that already has its own file. - "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()), - "type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(), - id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(), - "type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(), - id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()), - "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), - "file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()), - "plugin.cancelRuns" => crate::plugin_runs::any_running(app), - "plugin.hideRuns" => crate::plugin_runs::any_shown(app), - "file.place" - | "file.export.svg" - | "file.export.png" - | "file.exportAs" - | "file.exportForScreens" - | "file.documentSetup" - | "view.zoomIn" - | "view.zoomOut" - | "view.fitArtboard" - | "view.fitAll" - | "view.actualSize" - | "view.rotate" => app.session.active().is_some(), - "view.rotateToSelection" => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), - id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), - "file.clearRecent" => !app.ui.recent_files.is_empty(), - id if id.starts_with("type.recentFont") => { - id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.recent_fonts().len()) && app.session.active().is_some() - } - id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).is_some(), - id if saved_view_slot(id).is_some() => saved_view(app, id).is_some(), - "effect.dialog" | "ui.recolorDialog" | "ui.effectGalleryDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.cropImage" => selected_image(app, |_| true), - "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), - "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), - "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), - "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), - "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), - "ui.graphicStyleOptions" => app.session.active().is_some(), - "ui.newArtboard" => app.session.active().is_some(), - "ui.sampleScreenColor" => crate::screen_color::available(app), - "ui.newBrush" => app.session.active().is_some(), - "ui.brushOptions" => crate::dialogs::brush_options::available(app), - "ui.hyphenation" | "ui.justification" => crate::panels::character::text_style(app).is_some(), - "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.saveSwatchLibrary" => app.session.active().is_some(), - id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), - "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.saveGraphicStyleLibrary" => app.session.active().is_some(), - id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), - "ui.saveSymbolLibrary" => app.session.active().is_some_and(|d| !d.doc.symbols.is_empty()), - id if id.starts_with(crate::panels::symbols::USER_SLOT) => crate::panels::symbols::user_library(app, id).is_some(), - "ui.saveBrushLibrary" => crate::panels::brushes::has_brushes(app), - id if id.starts_with(crate::panels::brushes::USER_SLOT) => crate::panels::brushes::user_library(app, id).is_some(), - "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.spotColors" => app.session.active().is_some(), - "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "ui.simplifyAuto" | "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::showing(app), - "ui.dxfOptionsDialog" => app.session.active().is_some(), - "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), - "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), - "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), - "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), - "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), - "ui.epsOptionsDialog" => app.session.active().is_some(), - "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), - "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "css.copy" | "css.exportFile" => app.session.active().is_some(), - "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), - "plugin.dialog" => app.session.active().is_some(), - "ui.perspectiveGridDialog" | "ui.savePerspectivePreset" => app.session.active().is_some(), - id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { - app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() - } - "ui.perspectivePlane" => app.session.active().is_some(), - // Only one window per document so far: greyed out, as Window › Arrange's items are (#3724). - "window.newWindow" => false, - _ => true, - } -} - -/// Is a placed document selected? -fn selected_placed(app: &VectorcraftApp) -> bool { - app.session.active().is_some_and(|st| { - st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::PlacedDocument(_)))) - }) -} - -/// Is an image `keep` accepts selected? -fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool { - app.session.active().is_some_and(|st| { - st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(&n.kind, vectorcraft_doc::NodeKind::Image(im) if keep(im)))) - }) -} - -/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see -/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`). -/// `english_names` is Preferences › Type › Show Font Names in English (the Type → Font labels -/// follow it; tests use the default `true`). -pub fn menu_tree() -> Vec<(&'static str, Vec)> { - menu_tree_named(true) -} - -pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { - let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id, "toggle": true })); - vec![ - ( - // The application menu: About and Discord only. The in-window bar never shows it (File - // is first there, as in Illustrator); on macOS `native_menu::mac_layout` composes the - // App menu from this entry and the other menus (About from Help, Settings from Edit, - // Quit from File, Language and Appearance synthesized). It stays first: `from_tree` - // gives the first menu the App role. - "VectorCraft", - vec![c("About VectorCraft", "help.about"), c("Join Our Discord", "help.discord")], - ), - ( - "File", - vec![ - c("New…", "file.newDialog"), - c("New from Template…", "file.newFromTemplate"), - c("Open…", "file.open"), - sub("Open Recent Files", { - let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); - v.push(Sep); - v.push(c("Clear Recent Files", "file.clearRecent")); - v - }), - c("Show in Folder", "file.reveal"), - Sep, - c("Close", "file.close"), - c("Close All", "file.closeAll"), - c("Save", "file.save"), - c("Save As…", "file.saveAs"), - c("Save a Copy…", "file.saveCopy"), - c("Save as Template…", "file.saveAsTemplate"), - c("Save as PDF…", "file.export.pdf"), - c("Save for Office Documents…", "document.exportForOffice"), - c("Revert", "file.revert"), - Sep, - c("Place…", "file.place"), - Sep, - sub( - "Export", - vec![ - c("Export for Screens…", "file.exportForScreens"), - c("Export As…", "file.exportAs"), - c("Export As SVG…", "file.export.svg"), - c("Export As PNG…", "file.export.png"), - c("Save for Web (Legacy)…", "file.saveForWeb"), - ], - ), - c("Export Selection…", "file.exportSelection"), - Sep, - c("Package…", "ui.packageDialog"), - sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]), - Sep, - c("Document Setup…", "file.documentSetup"), - sub( - "Document Color Mode", - vec![ - cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})), - cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})), - ], - ), - c("File Info…", "file.info"), - Sep, - c("Print…", "file.print"), - Sep, - c("Exit", "app.quit"), - ], - ), - ( - "Edit", - vec![ - c("Undo", "edit.undo"), - c("Redo", "edit.redo"), - Sep, - c("Cut", "edit.cut"), - c("Copy", "edit.copy"), - c("Paste", "edit.paste"), - c("Paste in Front", "edit.pasteInFront"), - c("Paste in Back", "edit.pasteInBack"), - c("Paste in Place", "edit.pasteInPlace"), - c("Paste on All Artboards", "edit.pasteOnAllArtboards"), - c("Paste without Formatting", "edit.pasteWithoutFormatting"), - c("Clear", "edit.clear"), - Sep, - cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})), - cp("Find Next", "edit.findNext", json!({"find": ""})), - sub("Spelling", vec![todo("Auto Spell Check"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")]), - Sep, - sub( - "Edit Colors", - vec![ - c("Recolor Artwork…", "ui.recolorDialog"), - sub( - "Recolor with Preset", - vec![ - cp("1 Color Job…", "ui.recolorDialog", json!({"colors": 1})), - cp("2 Color Job…", "ui.recolorDialog", json!({"colors": 2})), - cp("3 Color Job…", "ui.recolorDialog", json!({"colors": 3})), - cp("Color Library…", "ui.recolorDialog", json!({"library": ""})), - ], - ), - c("Adjust Color Balance…", "ui.colorBalanceDialog"), - c("Blend Front to Back", "edit.colors.blendFrontToBack"), - c("Blend Horizontally", "edit.colors.blendHorizontally"), - c("Blend Vertically", "edit.colors.blendVertically"), - c("Convert to CMYK", "edit.colors.toCMYK"), - c("Convert to Grayscale", "edit.colors.toGrayscale"), - c("Convert to RGB", "edit.colors.toRGB"), - c("Invert Colors", "edit.colors.invert"), - cp( - "Overprint Black…", - "edit.colors.overprintBlack", - json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}), - ), - c("Saturate…", "ui.saturateDialog"), - ], - ), - c("Edit Original", "links.editOriginal"), - Sep, - c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), - c("Print Presets…", "ui.printPresetsDialog"), - c("PDF Presets…", "ui.pdfPresetsDialog"), - c("Perspective Grid Presets…", "ui.perspectivePresetsDialog"), - Sep, - c("Color Settings…", "edit.colorSettings"), - c("Assign Profile…", "edit.assignProfile"), - Sep, - c("Keyboard Shortcuts…", "edit.keyboardShortcuts"), - c("Preferences…", "edit.preferences"), - ], - ), - ( - "Object", - vec![ - sub("Transform", transform_items()), - sub("Arrange", arrange_items()), - sub( - "Align", - vec![ - cp("Horizontal Align Left", "object.align", json!({"horizontal": "left"})), - cp("Horizontal Align Center", "object.align", json!({"horizontal": "center"})), - cp("Horizontal Align Right", "object.align", json!({"horizontal": "right"})), - Sep, - cp("Horizontal & Vertical Align Center", "object.align", json!({"horizontal": "center", "vertical": "center"})), - Sep, - cp("Vertical Align Top", "object.align", json!({"vertical": "top"})), - cp("Vertical Align Center", "object.align", json!({"vertical": "center"})), - cp("Vertical Align Bottom", "object.align", json!({"vertical": "bottom"})), - ], - ), - Sep, - c("Group", "object.group"), - c("Ungroup", "object.ungroup"), - sub( - "Lock", - vec![c("Selection", "object.lock"), c("All Artwork Above", "object.lock.above"), c("Other Layers", "object.lock.otherLayers")], - ), - c("Unlock All", "object.unlockAll"), - sub( - "Hide", - vec![c("Selection", "object.hide"), c("All Artwork Above", "object.hide.above"), c("Other Layers", "object.hide.otherLayers")], - ), - c("Show All", "object.showAll"), - Sep, - c("Expand…", "ui.expandDialog"), - c("Expand Appearance", "effect.expandAppearance"), - c("Crop Image", "ui.cropImage"), - c("Rasterize…", "object.rasterize"), - cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), - cp( - "Create Object Mosaic…", - "object.createObjectMosaic", - json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}), - ), - c("Vector Halftone…", "object.vectorHalftone"), - c("Create Trim Marks", "object.createTrimMarks"), - c("Flatten Transparency…", "ui.flattenTransparencyDialog"), - Sep, - c("Make Pixel Perfect", "object.makePixelPerfect"), - Sep, - sub( - "Slice", - vec![ - c("Make", "object.slice.make"), - c("Release", "object.slice.release"), - c("Create from Guides", "object.slice.fromGuides"), - c("Create from Selection", "object.slice.fromSelection"), - Sep, - c("Duplicate Slice", "object.slice.duplicate"), - c("Combine Slices", "object.slice.combine"), - c("Divide Slices…", "object.slice.divide"), - Sep, - c("Delete All", "object.slice.deleteAll"), - c("Slice Options…", "object.slice.options"), - c("Clip to Artboard", "object.slice.clipToArtboard"), - ], - ), - Sep, - sub( - "Path", - vec![ - c("Join", "path.join"), - c("Average…", "path.average"), - Sep, - c("Outline Stroke", "object.path.outlineStroke"), - c("Offset Path…", "object.path.offsetPath"), - c("Reverse Path Direction", "path.reverse"), - Sep, - c("Simplify…", "object.path.simplify"), - c("Add Anchor Points", "object.path.addAnchorPoints"), - c("Remove Anchor Points", "path.removeAnchors"), - c("Divide Objects Below", "object.path.divideObjectsBelow"), - c("Split Into Grid…", "object.path.splitIntoGrid"), - Sep, - cp("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true, "unpaintedObjects": true, "emptyTextPaths": true})), - ], - ), - sub("Shape", vec![c("Convert to Shape", "object.shape.convertToShape"), c("Expand Shape", "object.expandShape")]), - sub( - "Pattern", - vec![c("Make", "object.pattern.make"), c("Edit Pattern", "object.pattern.edit"), c("Tile Edge Color…", "ui.tileEdgeColor")], - ), - sub( - "Repeat", - vec![ - c("Radial", "object.repeat.radial"), - c("Grid", "object.repeat.grid"), - c("Mirror", "object.repeat.mirror"), - Sep, - c("Release", "object.repeat.release"), - c("Options…", "object.repeat.options"), - ], - ), - sub( - "Intertwine", - vec![c("Make", "object.intertwine.make"), c("Edit", "object.intertwine.edit"), c("Release", "object.intertwine.release")], - ), - sub("Objects on Path", vec![c("Attach", "object.objectsOnPath.attach"), c("Expand", "object.objectsOnPath.expand")]), - sub( - "Blend", - vec![ - c("Make", "object.blend.make"), - c("Release", "object.blend.release"), - Sep, - c("Blend Options…", "object.blend.options"), - Sep, - c("Expand", "object.blend.expand"), - Sep, - c("Replace Spine", "object.blend.replaceSpine"), - c("Reverse Spine", "object.blend.reverseSpine"), - c("Reverse Front to Back", "object.blend.reverseFrontToBack"), - ], - ), - sub( - "Envelope Distort", - vec![ - // While an envelope is selected, Reset takes Make's place (`swapped_out`). - c("Make with Warp…", "object.envelope.makeWithWarp"), - c("Reset with Warp…", "object.envelope.resetWithWarp"), - c("Make with Mesh…", "object.envelope.makeWithMesh"), - c("Reset with Mesh…", "object.envelope.resetWithMesh"), - c("Make with Top Object", "object.envelope.makeWithTopObject"), - Sep, - c("Release", "object.envelope.release"), - c("Envelope Options…", "object.envelope.options"), - c("Expand", "object.envelope.expand"), - Sep, - c("Edit Contents", "object.envelope.editContents"), - ], - ), - sub( - "Perspective", - vec![ - c("Attach to Active Plane", "perspective.attach"), - c("Release with Perspective", "perspective.release"), - c("Move Plane to Match Object", "perspective.plane.matchObject"), - c("Edit Text", "perspective.editText"), - ], - ), - sub( - "Live Paint", - vec![ - c("Make", "livePaint.make"), - c("Merge", "livePaint.merge"), - c("Release", "livePaint.release"), - Sep, - c("Gap Options…", "ui.livePaintGapOptions"), - Sep, - c("Expand", "livePaint.expand"), - ], - ), - sub( - "Image Trace", - vec![ - // Traced at once with the Default preset, as the Control bar's Image Trace - // button does; other presets come from its arrow or the Image Trace panel - // (#543: the "…" form asked for a preset's name in a text box). - cp("Make", "imageTrace.make", json!({"preset": "Default"})), - cp("Make and Expand", "imageTrace.makeAndExpand", json!({"preset": "Default"})), - c("Release", "imageTrace.release"), - c("Expand", "imageTrace.expand"), - ], - ), - sub( - "Text Wrap", - vec![ - c("Make", "object.textWrap.make"), - c("Release", "object.textWrap.release"), - c("Text Wrap Options…", "object.textWrap.options"), - ], - ), - Sep, - sub( - "Clipping Mask", - vec![ - c("Make", "object.clippingMask.make"), - c("Release", "object.clippingMask.release"), - c("Edit Contents", "object.clippingMask.editContents"), - c("Edit Clipping Path", "object.clippingMask.editMask"), - ], - ), - sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]), - sub( - "Artboards", - vec![ - c("Convert to Artboards", "artboard.convertToArtboards"), - c("Rearrange All Artboards…", "artboard.rearrange"), - Sep, - c("Fit to Artwork Bounds", "artboard.fitToArt"), - c("Fit to Selected Art", "artboard.fitToSelection"), - ], - ), - sub( - "Graph", - vec![ - c("Type…", "graph.setType"), - c("Data…", "graph.setData"), - c("Design…", "graph.design"), - c("Column…", "graph.column"), - c("Marker…", "graph.marker"), - ], - ), - sub( - "Collect for Export", - vec![ - cp("As Single Asset", "assets.add", json!({"multiple": false})), - cp("As Multiple Assets", "assets.add", json!({"multiple": true})), - ], - ), - Sep, - sub("Plug-ins", crate::dialogs::plugin::object_menu()), - ], - ), - ( - "Type", - vec![ - sub("Font", font_items(english_names)), - sub("Recent Fonts", RECENT_FONT_IDS.iter().map(|id| c("Recent Font", id)).collect()), - sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()), - c("Bold", "type.bold"), - c("Italic", "type.italic"), - Sep, - panel("Glyphs", "glyphs"), - sub("Insert Special Character", insert_items(INSERT_SPECIAL)), - sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)), - sub("Insert Break Character", insert_items(INSERT_BREAK)), - Sep, - c("Convert To Area Type", "type.convertToAreaType"), - c("Convert To Point Type", "type.convertToPointType"), - c("Area Type Options…", "text.areaOptions"), - sub( - "Type on a Path", - PATH_EFFECTS - .iter() - .map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect }))) - .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) - .collect(), - ), - sub( - "Threaded Text", - vec![ - c("Create", "text.thread.create"), - c("Release Selection", "text.thread.releaseSelection"), - c("Remove Threading", "text.thread.remove"), - ], - ), - c("Fit Headline", "text.fitHeadline"), - Sep, - c("Find Font…", "type.findFont"), - sub( - "Change Case", - vec![ - cp("UPPERCASE", "type.changeCase", json!({"case": "upper"})), - cp("lowercase", "type.changeCase", json!({"case": "lower"})), - cp("Title Case", "type.changeCase", json!({"case": "title"})), - cp("Sentence case", "type.changeCase", json!({"case": "sentence"})), - ], - ), - cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), - sub( - "Bullets and Numbering", - vec![ - c("Apply Bullets", "type.applyBullets"), - c("Apply Numbers", "type.applyNumbers"), - c("Convert to Text", "type.convertListToText"), - ], - ), - Sep, - c("Create Outlines", "type.createOutlines"), - c("Scale Text Attributes", "type.scaleTextAttributes"), - c("Optical Margin Alignment", "type.opticalMarginAlignment"), - Sep, - c("Fill with Placeholder Text", "type.fillPlaceholder"), - c("Insert Inline Symbol", "text.insertInline"), - Sep, - c("Show Hidden Characters", "type.hiddenCharacters"), - sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]), - ], - ), - ("Select", { - let mut v = vec![ - c("All", "select.all"), - c("All on Active Artboard", "select.allOnArtboard"), - c("Deselect", "select.none"), - c("Reselect", "select.reselect"), - Sep, - c("Inverse", "select.inverse"), - Sep, - c("Next Object Above", "select.nextAbove"), - c("Next Object Below", "select.nextBelow"), - Sep, - sub( - "Same", - vec![ - c("Appearance", "select.same.appearance"), - c("Appearance Attribute", "select.same.appearanceAttribute"), - c("Blending Mode", "select.same.blendingMode"), - c("Fill & Stroke", "select.same.fillAndStroke"), - c("Fill Color", "select.same.fillColor"), - c("Opacity", "select.same.opacity"), - c("Stroke Color", "select.same.strokeColor"), - c("Stroke Weight", "select.same.strokeWeight"), - c("Graphic Style", "select.same.graphicStyle"), - c("Shape", "select.same.shapeType"), - c("Symbol Instance", "select.same.symbolInstance"), - Sep, - c("Font Family", "select.same.fontFamily"), - c("Font Family & Style", "select.same.fontFamilyStyle"), - c("Font Family, Style & Size", "select.same.fontFamilyStyleSize"), - c("Font Size", "select.same.fontSize"), - c("Text Fill Color", "select.same.textFillColor"), - c("Text Stroke Color", "select.same.textStrokeColor"), - ], - ), - sub( - "Object", - vec![ - c("All on Same Layers", "select.object.allOnSameLayers"), - c("Direction Handles", "select.object.directionHandles"), - Sep, - c("Brush Strokes", "select.object.brushStrokes"), - c("Bristle Brush Strokes", "select.object.bristleBrushStrokes"), - c("Clipping Masks", "select.object.clippingMasks"), - c("Slices", "select.object.slices"), - c("Stray Points", "select.object.strayPoints"), - c("Open Paths", "select.object.openPaths"), - Sep, - c("All Text Objects", "select.object.textObjects"), - c("Point Text Objects", "select.object.pointText"), - c("Area Text Objects", "select.object.areaText"), - ], - ), - c("Start Global Edit", "select.globalEdit"), - Sep, - c("Save Selection…", "select.save"), - c("Update Selection", "select.update"), - c("Edit Selection…", "select.editSaved"), - Sep, - ]; - v.extend(SAVED_SELECTION_IDS.iter().map(|id| c("Saved Selection", id))); - v - }), - ("Effect", effect_menu()), - ("View", { - let mut v = vec![ - c("Outline", "view.outline"), - c("Overprint Preview", "view.overprintPreview"), - c("Pixel Preview", "view.pixelPreview"), - c("Trim View", "view.trimView"), - c("Presentation Mode", "view.presentation"), - sub( - "Screen Mode", - vec![ - cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), - cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), - cp("Full Screen Mode", "view.screenMode", json!({"mode": 2})), - ], - ), - Sep, - sub( - "Proof Setup", - vec![ - cp("Working CMYK", "view.proofSetup", json!({"target": "workingCmyk", "proof": true})), - cp("Legacy Macintosh RGB (Gamma 1.8)", "view.proofSetup", json!({"target": "legacyMacRgb", "proof": true})), - cp("Internet Standard RGB (sRGB)", "view.proofSetup", json!({"target": "srgb", "proof": true})), - cp("Monitor RGB", "view.proofSetup", json!({"target": "monitorRgb", "proof": true})), - cp("Color blindness – Protanopia-type", "view.proofSetup", json!({"target": "protanopia", "proof": true})), - cp("Color blindness – Deuteranopia-type", "view.proofSetup", json!({"target": "deuteranopia", "proof": true})), - cp("Color blindness – Tritanopia-type", "view.proofSetup", json!({"target": "tritanopia", "proof": true})), - cp("Customize…", "window.panel", json!({"panel": "separations"})), - ], - ), - c("Proof Colors", "view.proofColors"), - Sep, - c("Zoom In", "view.zoomIn"), - c("Zoom Out", "view.zoomOut"), - c("Fit Artboard in Window", "view.fitArtboard"), - c("Fit All in Window", "view.fitAll"), - c("Actual Size", "view.actualSize"), - Sep, - sub("Rotate View", crate::state::ROTATE_VIEW_ANGLES.iter().map(|(l, a)| cp(l, "view.rotate", json!({ "angle": a }))).collect()), - c("Rotate View to Selection", "view.rotateToSelection"), - c("Reset Rotate View", "view.rotateReset"), - Sep, - c("Hide Edges", "view.edges"), - c("Hide Artboards", "view.artboards"), - c("Show Print Tiling", "view.printTiling"), - c("Hide Slices", "view.slices.hide"), - c("Lock Slices", "view.slices.lock"), - c("Hide Template", "view.template"), - Sep, - sub( - "Rulers", - vec![ - c("Show Rulers", "view.rulers"), - c("Change to Global Rulers", "view.globalRulers"), - c("Show Video Rulers", "view.videoRulers"), - ], - ), - c("Hide Bounding Box", "view.boundingBox"), - c("Show Transparency Grid", "view.transparencyGrid"), - c("Hide Text Threads", "view.textThreads"), - c("Hide Gradient Annotator", "view.gradientAnnotator"), - c("Hide Corner Widget", "view.cornerWidget"), - c("Show Live Paint Gaps", "view.livePaintGaps"), - Sep, - sub( - "Guides", - vec![ - c("Hide Guides", "view.guides"), - c("Lock Guides", "view.guides.lock"), - c("Make Guides", "view.guides.make"), - c("Release Guides", "view.guides.release"), - c("Clear Guides", "view.guides.clear"), - ], - ), - c("Smart Guides", "view.smartGuides"), - sub( - "Perspective Grid", - vec![ - c("Show Grid", "perspective.grid.show"), - c("Show Rulers", "perspective.grid.rulers"), - c("Snap to Grid", "perspective.grid.snap"), - c("Lock Grid", "perspective.grid.lock"), - c("Lock Station Point", "perspective.grid.lockStation"), - Sep, - c("Define Grid…", "ui.perspectiveGridDialog"), - sub("One Point Perspective", crate::dialogs::perspective_presets::menu(1, PERSPECTIVE_SLOTS[0])), - sub("Two Point Perspective", crate::dialogs::perspective_presets::menu(2, PERSPECTIVE_SLOTS[1])), - sub("Three Point Perspective", crate::dialogs::perspective_presets::menu(3, PERSPECTIVE_SLOTS[2])), - Sep, - c("Save Grid as Preset…", "ui.savePerspectivePreset"), - ], - ), - c("Show Grid", "view.grid"), - c("Snap to Grid", "view.snapToGrid"), - c("Snap to Pixel", "view.snapToPixel"), - c("Snap to Point", "view.snapToPoint"), - c("Snap to Glyph", "view.snapToGlyph"), - Sep, - c("New View…", "view.saved.new"), - c("Edit Views…", "view.saved.edit"), - Sep, - ]; - v.extend(SAVED_VIEW_IDS.iter().map(|id| c("Saved View", id))); - v - }), - ( - "Window", - vec![ - c("New Window", "window.newWindow"), - sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]), - sub("Workspace", crate::workspaces::menu_items()), - Sep, - c("Control", "window.control"), - c("Contextual Task Bar", "window.taskBar"), - c("Tools", "window.toolbar"), - sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns")]), - Sep, - panel("Actions", "actions"), - panel("Align", "align"), - panel("Appearance", "appearance"), - panel("Artboards", "artboards"), - panel("Asset Export", crate::panels::asset_export::ID), - panel("Attributes", "attributes"), - panel("Blend", crate::panels::blend::ID), - panel("Brushes", "brushes"), - panel("Color", "color"), - panel("Color Guide", "colorGuide"), - panel("Color Themes", "colorThemes"), - panel("CSS Properties", crate::panels::css_properties::ID), - panel("Document Info", "docInfo"), - panel("Flattener Preview", crate::panels::flattener_preview::ID), - panel("Gradient", "gradient"), - panel("Graphic Styles", "graphicStyles"), - panel("History", "history"), - panel("Image Trace", "imageTrace"), - panel("Info", "info"), - panel("Layers", "layers"), - panel("Libraries", "libraries"), - panel("Links", crate::panels::links::ID), - panel("Magic Wand", "magicWand"), - panel("Navigator", "navigator"), - panel("Pathfinder", "pathfinder"), - panel("Pattern Options", "patternOptions"), - panel("Properties", "properties"), - panel("Separations Preview", "separations"), - panel("Stroke", "stroke"), - panel("SVG Interactivity", crate::panels::svg_interactivity::ID), - panel("Swatches", "swatches"), - panel("Symbols", "symbols"), - panel("Transform", "transform"), - panel("Transparency", "transparency"), - sub( - "Type", - vec![ - panel("Character", "character"), - panel("Character Styles", "charStyles"), - panel("Glyphs", "glyphs"), - panel("OpenType", "openType"), - panel("Paragraph", "paragraph"), - panel("Paragraph Styles", "paraStyles"), - panel("Tabs", "tabs"), - ], - ), - panel("Variables", crate::panels::variables::ID), - Sep, - sub("Brush Libraries", crate::panels::brushes::window_menu()), - sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), - sub("Swatch Libraries", crate::panels::swatches::window_menu()), - sub("Symbol Libraries", crate::panels::symbols::window_menu()), - ], - ), - ( - "Help", - vec![ - c("Join Our Discord", "help.discord"), - c("ArtCraft Website", "help.website"), - c("VectorCraft on getartcraft.com", "help.appPage"), - c("VectorCraft on GitHub", "help.github"), - Sep, - c("Search Commands…", "help.commandPalette"), - todos("VectorCraft Help…", "F1"), - Sep, - c("Check for Updates…", "help.checkForUpdates"), - c("About VectorCraft", "help.about"), - ], - ), - ] -} - -/// Object → Transform (also in the canvas context menu). -fn transform_items() -> Vec { - vec![ - c("Transform Again", "object.transformAgain"), - Sep, - c("Move…", "object.move"), - c("Rotate…", "object.rotate"), - c("Reflect…", "object.reflect"), - c("Scale…", "object.scale"), - c("Shear…", "object.shear"), - Sep, - c("Transform Each…", "object.transformEach"), - Sep, - c("Reset Bounding Box", "object.resetBoundingBox"), - ] -} - -/// Object → Arrange (also in the canvas context menu). -fn arrange_items() -> Vec { - vec![ - c("Bring to Front", "object.arrange.bringToFront"), - c("Bring Forward", "object.arrange.bringForward"), - c("Send Backward", "object.arrange.sendBackward"), - c("Send to Back", "object.arrange.sendToBack"), - Sep, - c("Send to Current Layer", "object.arrange.sendToCurrentLayer"), - ] -} - -/// The canvas context menu (right-click): what applies to the selection, or to the view when -/// nothing is selected. Commands that can't run now are left out rather than greyed out. -pub fn context_items(app: &VectorcraftApp) -> Vec { - use vectorcraft_doc::NodeKind; - let Some(st) = app.session.active() else { return vec![] }; - let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); - let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind)); - let several = roots.len() >= 2; - let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]; - if st.isolation.is_some() { - v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]); - } - // Managing artboards from the canvas: with the Artboard tool, the menu locks and unlocks them - // (`artboard.setProps` with `locked` for each artboard named; an entry with none to change is left out). - if app.session.tool_id() == "artboard" { - let n = st.doc.artboards.len(); - let sel = crate::panels::artboards::picked(app, n); - let others: Vec = (0..n).filter(|i| !sel.contains(i)).collect(); - let entry = |label, idx: Vec, locked: bool| { - let list: Vec = idx.iter().map(|i| json!({ "index": i, "locked": locked })).collect(); - (!list.is_empty()).then(|| cp(label, "artboard.setProps", json!({ "artboards": list }))) - }; - let lock: Vec = - [entry("Lock Selected", sel.clone(), true), entry("Lock All Except Selected", others, true), entry("Lock All", (0..n).collect(), true)] - .into_iter() - .flatten() - .collect(); - let unlock: Vec = [entry("Unlock Selected", sel, false), entry("Unlock All", (0..n).collect(), false)].into_iter().flatten().collect(); - for (label, items) in [("Lock Artboards", lock), ("Unlock Artboards", unlock)] { - if !items.is_empty() { - v.push(sub(label, items)); - } - } - v.push(Sep); - } - // The locked objects under the pointer at the right-click, each unlocked as the Layers panel's - // lock column does. `unlock_item_label` shows each object's name. - let unlock: Vec = app - .ui - .context_locked - .iter() - .filter(|(uid, _)| *uid == st.uid) - .flat_map(|(_, ids)| ids) - .filter(|id| st.doc.node(**id).is_some_and(|n| n.locked)) - .map(|id| cp("Unlock", "layer.setProps", json!({ "ids": [id.0], "locked": false }))) - .collect(); - if !unlock.is_empty() { - v.extend([sub("Unlock", unlock), Sep]); - } - if roots.is_empty() { - v.extend([ - c("Paste", "edit.paste"), - Sep, - c("Zoom In", "view.zoomIn"), - c("Zoom Out", "view.zoomOut"), - c("Fit Artboard in Window", "view.fitArtboard"), - Sep, - c("Show Rulers", "view.rulers"), - c("Show Grid", "view.grid"), - c("Hide Guides", "view.guides"), - c("Lock Guides", "view.guides.lock"), - Sep, - c("Select All", "select.all"), - ]); - } else { - v.extend([c("Cut", "edit.cut"), c("Copy", "edit.copy"), c("Paste", "edit.paste"), Sep]); - if let [one] = roots.as_slice() - && one.is_container() - { - v.push(c("Isolate Selected Group", "object.isolate")); - } - if several { - v.push(c("Group", "object.group")); - } - if any(|k| matches!(k, NodeKind::Group { clip: false, .. })) { - v.push(c("Ungroup", "object.ungroup")); - } - let paths = any(|k| matches!(k, NodeKind::Path { .. })); - if paths { - v.extend([c("Join", "path.join"), c("Average…", "path.average")]); - } - if !st.selection.anchors.is_empty() { - v.extend([c("Remove Anchor Points", "path.removeAnchors"), c("Cut Path at Selected Anchor Points", "path.cutAtAnchors")]); - } - if several { - v.push(c("Make Clipping Mask", "object.clippingMask.make")); - } - if any(|k| matches!(k, NodeKind::Group { clip: true, .. })) { - v.push(c("Release Clipping Mask", "object.clippingMask.release")); - } - if several && paths { - v.push(c("Make Compound Path", "object.compoundPath.make")); - } - // Chooses the Objects on Path tool, whose click on a path attaches the selection to it. - if several { - v.push(c("Objects on Path", "object.objectsOnPath.attach")); - } - if any(|k| matches!(k, NodeKind::Compound { .. })) { - v.push(c("Release Compound Path", "object.compoundPath.release")); - } - // The paragraphs of the selected type, or those the Type tool's selection touches. - if crate::panels::character::text_style(app).is_some() { - v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")])); - } - v.extend([ - c("Make Guides", "view.guides.make"), - Sep, - sub("Transform", transform_items()), - sub("Arrange", arrange_items()), - sub( - "Select", - vec![c("Next Object Above", "select.nextAbove"), c("Next Object Below", "select.nextBelow"), Sep, c("Deselect", "select.none")], - ), - Sep, - c("Export Selection…", "file.exportSelection"), - ]); - } - prune(app, v) -} - -/// `items` without the commands that can't run now (Undo and Redo stay, greyed out), submenus -/// left empty, and separators that no longer separate anything. -fn prune(app: &VectorcraftApp, items: Vec) -> Vec { - let mut out: Vec = Vec::with_capacity(items.len()); - for it in items { - let it = match it { - Item::Sub(l, children) => Item::Sub(l, prune(app, children)), - it => it, - }; - let keep = match &it { - Item::Cmd(_, id, _) => matches!(*id, "edit.undo" | "edit.redo") || enabled(app, id), - Item::Sub(_, children) => !children.is_empty(), - Item::Sep => out.last().is_some_and(|l| !matches!(l, Item::Sep)), - _ => true, - }; - if keep { - out.push(it); - } - } - if matches!(out.last(), Some(Item::Sep)) { - out.pop(); - } - out -} - -/// The canvas context menu's popup; the item clicked goes in `clicked`, for [`invoke`]. -pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { - widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); - // Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter. - render_items(app, ui, &context_items(app), false, clicked); - }); -} - -/// The document units a right-click on a ruler offers: each runs `document.setUnits`, exactly as -/// Preferences ▸ Units ▸ General does, with the current one checked ([`checked`]). -pub fn ruler_unit_items() -> Vec { - vectorcraft_engine::doc::Unit::ALL.iter().map(|u| cp(u.label(), "document.setUnits", json!({ "units": u.label() }))).collect() -} - -/// The ruler context menu's popup (right-click a ruler or the origin box); the unit clicked goes in -/// `clicked`, for [`invoke`]. -pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { - widgets::menu_scroll(ui, |ui| { - ui.set_min_width(160.0); - render_items(app, ui, &ruler_unit_items(), true, clicked); - }); -} - -/// Render the menu bar. -/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width. -pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { - let t = Tokens::get(ui.ctx()); - let ctx = ui.ctx().clone(); - // The press that opens a menu takes the keyboard from a focused text field: the field and its - // selection are kept while the menus are open, so their Select All, Cut, Copy and Paste act on - // its text (#516), the frame after the click that chose them (that click takes the keyboard - // from it again). - type Field = (egui::Id, egui::text_edit::TextEditState); - let (field_key, chosen_key) = (egui::Id::unique("menu-bar-field"), egui::Id::unique("menu-bar-field-event")); - if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) { - ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key)); - state.store(&ctx, field); - to_field(&ctx, field, e); - } - if let Some(field) = focused_text_field(&ctx).and_then(|id| Some((id, egui::TextEdit::load_state(&ctx, id)?))) { - ctx.data_mut(|d| d.insert_temp::(field_key, field)); - } - let mut clicked: Option<(String, Value)> = None; - let tree = menu_tree_named(app.session.prefs.font_names_in_english); - // No application menu in the window (as in Illustrator): the bar starts at File on every - // platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there. - let tree: Vec<(&str, Vec)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect(); - // A menu bar for its layout and menu-button style, but the titles use `Popup::menu` directly: - // egui 0.37's `MenuButton` opens the title under a resting pointer while another menu is open, - // and the app switches titles on movement only (`switch_on_hover`, the native behaviour). - let (end, open) = egui::MenuBar::new() - .ui(ui, |ui| { - let mut titles = Vec::with_capacity(tree.len()); - for (title, items) in tree.iter() { - let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text); - let resp = ui.add(egui::Button::new(text)); - egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked)); - titles.push(resp); - } - (ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles)) - }) - .inner; - let field = ctx.data(|d| d.get_temp::(field_key)); - if !open && focused_text_field(&ctx).is_none() { - ctx.data_mut(|d| d.remove::(field_key)); - } - if let Some((id, p)) = clicked { - match field.and_then(|f| Some((f, field_event(app, &id)?))) { - Some(chosen) => { - ctx.data_mut(|d| d.insert_temp(chosen_key, chosen)); - ctx.request_repaint(); - } - None => invoke(app, &id, p), - } - } - end -} - -/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no -/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must -/// not switch to the title under it). -fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool { - title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id) -} - -/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another -/// title opens that menu instead (egui alone needs a click on each title). Returns whether a menu -/// is open. -fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool { - let ids: Vec = titles.iter().map(egui::Popup::default_response_id).collect(); - let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else { - return false; - }; - // `Response::hovered` is false while a menu's popup is open, so hit-test the titles here. - // Only a moving pointer switches: one resting on a title leaves the open menu alone. - let moving = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO); - if let Some(p) = ctx.pointer_hover_pos().filter(|_| moving) - && let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) - && i != open - && let Some(id) = ids.get(i) - { - egui::Popup::open_id(ctx, *id); - ctx.request_repaint(); - } - true -} - -/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt; -/// it scrolls when it is taller than the window. -fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { - widgets::menu_scroll(ui, |ui| { - ui.set_min_width(230.0); - render_items(app, ui, items, true, clicked); - }); -} - -/// `items` as menu buttons; with `checks`, items that can be on or off show a check mark (or the -/// room for one). -fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) { - let t = Tokens::get(ui.ctx()); - for e in items.iter().filter_map(|it| entry(app, it)) { - match e { - Entry::Sep => { - ui.separator(); - } - Entry::Header(h) => { - ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim)); - } - Entry::Sub(label, children) => { - ui.menu_button(label, |ui| { - widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); - render_items(app, ui, children, checks, clicked); - }); - }); - } - Entry::Item(row) => render_row(app, ui, &row, checks, clicked), - } - } -} - -/// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut, -/// and Type › Font's samples. -fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) { - let sc = row.shortcut.map(pretty_shortcut).unwrap_or_default(); - let Some((id, p)) = row.command else { - ui.add_enabled_ui(false, |ui| { - ui.add(egui::Button::new(row.label.as_ref()).shortcut_text(sc)); - }) - .response - .on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan")); - return; - }; - let text = match row.checked.filter(|_| checks) { - Some(true) => format!("✓ {}", row.label), - Some(false) => format!(" {}", row.label), - None => row.label.to_string(), - }; - // Type → Font: each family's sample after its name (Enable in-menu font previews). - let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview); - let r = match sampled { - Some(family) => { - let slot = egui::IdSalt::new(("font-sample", family)); - let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE)); - let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner; - if let Some(rect) = out.rect(slot) { - crate::font_menu::menu_item_sample(ui, rect, family); - } - out.response - } - None => ui.add_enabled(row.enabled, egui::Button::new(text).shortcut_text(sc)), - }; - if r.clicked() { - *clicked = row.target(); - ui.close(); - } -} - -/// What the Home screen remembers when it opens (`app.home`): the active document and the -/// document count. When either changes, the Home screen gives way to the document. -pub(crate) fn home_key(app: &VectorcraftApp) -> (Option, usize) { - (app.session.active().map(|d| d.uid), app.session.documents().len()) -} - -/// Whether the Home screen is up: chosen with the Home button (`app.home`), or no document is open -/// and Preferences › General › Show The Home Screen When No Documents Are Open is on (#394). Off, -/// an app with no document shows an empty window, and the Home button still opens the screen. -pub(crate) fn home_showing(app: &VectorcraftApp) -> bool { - app.ui.home.is_some() || (app.session.active().is_none() && app.session.prefs.show_home_screen) -} - -/// What a menu click runs: items whose label ends with "…" and that carry default params open a -/// generic parameter dialog (fields = the defaults) instead of running immediately. -pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { - let dialog = label.ends_with('…') - && p.as_object().is_some_and(|o| !o.is_empty()) - && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog"); - if dialog { - return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); - } - (id.to_string(), if p.is_null() { json!({}) } else { p.clone() }) -} - -/// The dialog of command `id` that shows the selection's current values (its query): its heading -/// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they -/// are. -fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { - Some(match id { - // Whether the text overflows, and Shrink Text's factor, are read-only facts. - "text.areaOptions" => ("Area Type Options", &["overflow", "fitScale"]), - "type.pathOptions" => ("Type on a Path Options", &["start", "end"]), - _ => return None, - }) -} - -/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does. -fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> { - Some(match id { - // Move opens on the offset moved last and the checkboxes it was last confirmed with. - "object.move" => ("move", crate::dialogs::transform::move_fields(app)), - "object.rotate" => ("rotate", json!({"angle": 0})), - "object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})), - // Transform Each opens on the values it was last confirmed with (#1732). - "object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::open_fields(app)), - "object.reflect" => ("reflect", json!({"axis": "vertical", "angle": 90})), - "object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})), - "path.average" => ("average", json!({"axis": "both"})), - // Offset Path opens on the values it was last confirmed with. - "object.path.offsetPath" if app.ui.offset_path.is_object() => ("offsetPath", app.ui.offset_path.clone()), - "object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})), - // Simplify opens its slider, or the dialog on the fields it was last confirmed with when - // "Open this dialog with these settings next time" was ticked. - "object.path.simplify" if app.ui.simplify.is_object() => ("simplify", app.ui.simplify.clone()), - "object.path.simplify" => (crate::dialogs::simplify_slider::KIND, crate::dialogs::simplify_slider::fields(app)), - "object.path.splitIntoGrid" => { - // The grid starts at the size of the selection. - let size = - app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)).map_or((0.0, 0.0), |b| (b.width(), b.height())); - ("splitIntoGrid", crate::dialogs::path_ops::split_fields(size.0, size.1)) - } - "object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()), - "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::fields()), - _ => return None, - }) -} - -/// The event a focused text field takes for Edit-menu command `id` (Select All, Cut, Copy, -/// Paste), as the same keys would send it. -fn field_event(app: &mut VectorcraftApp, id: &str) -> Option { - Some(match id { - "select.all" => egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }, - "edit.copy" => egui::Event::Copy, - "edit.cut" => egui::Event::Cut, - "edit.paste" | "edit.pasteWithoutFormatting" => egui::Event::Paste(app.written_clipboard_text()?), - _ => return None, - }) -} - -/// Invoke an item clicked in the system menu bar (macOS; its key equivalents come back to egui as -/// key presses, see [`crate::native_menu`]). While a text field has the keyboard, Select All, Cut, -/// Copy and Paste act on the field's text, as their keys do. While its keys select every artboard -/// ([`crate::panels::artboards::selects_all`]), Select All does too. Everything else (and those -/// commands with no field focused) goes to [`invoke`]. -pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) { - match focused_text_field(ctx).and_then(|f| Some((f, field_event(app, id)?))) { - Some((field, e)) => to_field(ctx, field, e), - None if id == "select.all" && crate::panels::artboards::selects_all(app, ctx) => crate::panels::artboards::select_all(app), - None => invoke(app, id, p), - } -} - -/// The text field that has the keyboard, if any. -fn focused_text_field(ctx: &egui::Context) -> Option { - ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some()) -} - -/// Give text field `field` the keyboard (back) and event `e`, a [`field_event`], this frame. -fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) { - ctx.memory_mut(|m| m.request_focus(field)); - ctx.input_mut(|i| i.events.push(e)); -} - -/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input). -/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values -/// (the command's query). -pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result { - // Graph Data: the worksheet. - if id == "graph.setData" { - return crate::dialogs::graph_data::open(app); - } - let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?; - let (label, fields) = ("Graph Type", v); - app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" })) -} - -pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { - if waits_for_ime(app, id) { - return; - } - if SUBJECT_COMMANDS.contains(&id) && p.get("ids").is_none() && p.get("id").is_none() && !has_subject(app) { - return; - } - // Params first: some dialogs' fields take a look through the session (Move's last move). - if p.as_object().is_none_or(|o| o.is_empty()) - && let Some((kind, fields)) = menu_dialog(app, id) - { - app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); - return; - } - // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. - if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::envelope::open(app, id) { - app.status(e); - } - return; - } - // Variables (data merge): a name and a kind, a name and a row of values. - if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { - crate::dialogs::variables::open(app, id, None); - return; - } - // Pathfinder Options: a dialog with the current options. - if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) { - crate::dialogs::pathfinder_options::open(app); - return; - } - // Repeat Options: a dialog with the selected repeat's current values. - if id == "object.repeat.options" - && p.as_object().is_none_or(|o| o.is_empty()) - && let Some(fields) = crate::panels::pattern_options::repeat_fields(app) - { - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields})); - return; - } - // Area Type Options and Type on a Path Options: dialogs with the selected type's current - // values (the command's query), less those the dialog leaves alone. - if let Some((label, hidden)) = queried_dialog(id) - && p.as_object().is_none_or(|o| o.is_empty()) - { - match app.session.execute(id, &json!({})) { - Ok(mut fields) => { - if let Some(o) = fields.as_object_mut() { - o.retain(|k, _| !hidden.contains(&k.as_str())); - } - let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Object → Graph → Type… / Data…: dialogs with the selected graph's current values. - if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = graph_dialog(app, id) { - app.status(e); - } - return; - } - // Object → Graph → Design… / Column… / Marker…: the document's graph designs. - if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) { - let r = match id { - "graph.design" => crate::dialogs::graph_design::open_design(app), - "graph.column" => crate::dialogs::graph_design::open_column(app), - _ => crate::dialogs::graph_design::open_marker(app), - }; - if let Err(e) = r { - app.status(e); - } - return; - } - // New View…: a name dialog, starting from the first free "View N". - if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) { - let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default(); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); - return; - } - // Edit Views…: the saved views in a list, to rename or delete. - if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::edit_views::open(app) { - app.status(e); - } - return; - } - // Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with - // the view's angle filled in. - if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.view_mut().map(|v| v.rotation) { - Some(angle) => { - app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle}))); - } - None => app.status("no document"), - } - return; - } - // Save Selection…: a name dialog, starting from the first free "Selection N". - if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { - let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default(); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}})); - return; - } - // Edit Selection…: the saved selections in a list, to rename or delete. - if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::edit_selection::open(app) { - app.status(e); - } - return; - } - // Document Raster Effects Settings and File Info: their dialogs. - if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) { - let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) }; - if let Err(e) = r { - app.status(e); - } - return; - } - // Rasterize…: its options, starting from the document's raster effects settings. - if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.session.execute("document.rasterEffectsSettings", &json!({})) { - Ok(v) => { - let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]}); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Text Wrap Options: a dialog with the selected wrap object's current values. - if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.session.execute(id, &json!({})) { - Ok(fields) => { - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Blend Options…: its dialog, on the selected blend's options. - if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::blend_options::open(app) { - app.status(e); - } - return; - } - // Expand…: its dialog. - if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::expand::open(app) { - app.status(e); - } - return; - } - // Help links: the command returns the URL; a menu click opens it (agents just get the URL). - if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") { - app.open_link(id); - return; - } - // Object → Align aligns to what the align buttons align to, unless the call names `to`. With - // anchors selected (Direct Selection) it sends its params unchanged, and the points align to - // their own bounds. - let follows_align_to = id == "object.align" - && p.as_object().is_some_and(|o| !o.contains_key("to")) - && app.session.active().is_some_and(|st| st.selection.anchors.is_empty()); - let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p }; - if let Err(e) = app.run(id, p) { - app.status(e); - } else if matches!(id, "object.pattern.make" | "object.pattern.edit") { - app.ui.open_panel = Some("patternOptions".into()); - } -} - -/// `Cmd+Shift+]` → `⇧⌘]` on macOS, `Ctrl+Shift+]` elsewhere. -pub fn pretty_shortcut(s: &str) -> String { - if s.is_empty() { - return String::new(); - } - if cfg!(target_os = "macos") { - let mut mods = String::new(); - let parts: Vec<&str> = s.split('+').collect(); - let (key, ms): (&str, &[&str]) = if s.ends_with("++") { - ("+", parts.get(..parts.len().saturating_sub(2)).unwrap_or_default()) - } else { - parts.split_last().map_or(("", &[][..]), |(k, rest)| (*k, rest)) - }; - for m in ["Ctrl", "Alt", "Shift", "Cmd"] { - if ms.contains(&m) { - mods.push_str(match m { - "Ctrl" => "⌃", - "Alt" => "⌥", - "Shift" => "⇧", - _ => "⌘", - }); - } - } - format!("{mods}{key}") - } else { - s.replace("Cmd", "Ctrl") - } -} - -#[derive(Serialize)] -pub struct MenuEntry { - pub path: Vec, - pub label: String, - pub command: Option, - pub params: Value, - pub enabled: bool, - pub shortcut: String, -} - -/// Flattened menu for `ui.menu.list`. -pub fn menu_entries(app: &VectorcraftApp) -> Vec { - let mut out = vec![]; - for (title, items) in menu_tree_named(app.session.prefs.font_names_in_english) { - flatten(app, vec![title.to_string()], &items, &mut out); - } - out -} - -/// Flattened canvas context menu for `ui.contextMenu.list` (paths are its submenus). -pub fn context_entries(app: &VectorcraftApp) -> Vec { - let mut out = vec![]; - flatten(app, vec![], &context_items(app), &mut out); - out -} - -fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { - for it in items { - match it { - Item::Cmd(l, id, p) => { - let Some((enabled, _)) = shown_state(app, id, p) else { continue }; - out.push(MenuEntry { - path: path.clone(), - label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)), - command: Some(id.to_string()), - params: p.clone(), - enabled, - shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(), - }) - } - Item::Todo(l, sc) => out.push(MenuEntry { - path: path.clone(), - label: l.to_string(), - command: None, - params: Value::Null, - enabled: false, - shortcut: sc.to_string(), - }), - Item::Sub(l, ch) => { - let mut p = path.clone(); - p.push(l.to_string()); - flatten(app, p, ch, out); - } - _ => {} - } - } -} - -/// Type → Size presets. -const TYPE_SIZES: [(&str, u32); 14] = [ - ("6 pt", 6), - ("8 pt", 8), - ("9 pt", 9), - ("10 pt", 10), - ("11 pt", 11), - ("12 pt", 12), - ("14 pt", 14), - ("18 pt", 18), - ("24 pt", 24), - ("30 pt", 30), - ("36 pt", 36), - ("48 pt", 48), - ("60 pt", 60), - ("72 pt", 72), -]; - -/// Type → Type on a Path effects (label, `type.pathOptions` effect), also the choices of its -/// Options dialog. -pub(crate) const PATH_EFFECTS: &[(&str, &str)] = - &[("Rainbow", "rainbow"), ("Skew", "skew"), ("3D Ribbon", "3dRibbon"), ("Stair Step", "stairStep"), ("Gravity", "gravity")]; - -const INSERT_SPECIAL: &[(&str, &str)] = &[ - ("Bullet", "bullet"), - ("Copyright Symbol", "copyright"), - ("Ellipsis", "ellipsis"), - ("Paragraph Symbol", "paragraph"), - ("Registered Trademark Symbol", "registered"), - ("Section Symbol", "section"), - ("Trademark Symbol", "trademark"), - ("Em Dash", "emDash"), - ("En Dash", "enDash"), - ("Discretionary Hyphen", "discretionaryHyphen"), - ("Nonbreaking Hyphen", "nonBreakingHyphen"), - ("Double Left Quotation Marks", "doubleLeftQuote"), - ("Double Right Quotation Marks", "doubleRightQuote"), - ("Single Left Quotation Mark", "singleLeftQuote"), - ("Single Right Quotation Mark", "singleRightQuote"), -]; -const INSERT_WHITESPACE: &[(&str, &str)] = &[ - ("Em Space", "emSpace"), - ("En Space", "enSpace"), - ("Hair Space", "hairSpace"), - ("Sixth Space", "sixthSpace"), - ("Thin Space", "thinSpace"), - ("Nonbreaking Space", "nonBreakingSpace"), - ("Figure Space", "figureSpace"), - ("Punctuation Space", "punctuationSpace"), - ("Third Space", "thirdSpace"), - ("Quarter Space", "quarterSpace"), -]; -const INSERT_BREAK: &[(&str, &str)] = &[("Forced Line Break", "forcedLineBreak"), ("Tab", "tab")]; - -fn insert_items(list: &[(&'static str, &'static str)]) -> Vec { - list.iter().map(|(l, c)| cp(l, "type.insert", json!({ "char": c }))).collect() -} - -/// Type → Font: one item per available family, the installed fonts included. Built again only when -/// the fonts or Show Font Names in English change (the menu tree is built every frame); labels are -/// interned once each, so rebuilding doesn't allocate forever. The command still sets the English -/// family name ([`vectorcraft_text::FontDb::canonical`]). -fn font_items(english_names: bool) -> Vec { - static NAMES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeSet::new()); - static ITEMS: std::sync::Mutex<(u64, bool, Vec)> = std::sync::Mutex::new((u64::MAX, true, Vec::new())); - let db = vectorcraft_text::FontDb::global(); - // Read first: fonts that load meanwhile make the next frame build the list again. - let generation = db.generation(); - let fams = db.menu_family_list(); - let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] }; - if items.0 != generation || items.1 != english_names { - let list = fams - .iter() - .map(|f| { - let english = f.as_str(); - let shown = db.family_display_name(english, english_names); - let label: &'static str = match names.get(shown.as_str()) { - Some(n) => n, - None => { - let n: &'static str = Box::leak(shown.into_boxed_str()); - names.insert(n); - n - } - }; - let font: &'static str = match names.get(english) { - Some(n) => n, - None => { - let n: &'static str = Box::leak(english.to_string().into_boxed_str()); - names.insert(n); - n - } - }; - cp(label, "text.setStyle", json!({ "font": font })) - }) - .collect(); - *items = (generation, english_names, list); - } - items.2.clone() -} - -/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the -/// vector effects): (submenu, the catalogue's menu path of its effects). -/// The raster Stylize submenu shares its path with the vector one and lists only the effects at -/// that path that [`vectorcraft_effects::PIXEL_EFFECTS`] names. -const RASTER_MENUS: [(&str, &[&str]); 10] = [ - ("Artistic", &["Effect", "Artistic"]), - ("Blur", &["Effect", "Blur"]), - ("Brush Strokes", &["Effect", "Brush Strokes"]), - ("Distort", &["Effect", "Distort"]), - ("Pixelate", &["Effect", "Pixelate"]), - ("Sharpen", &["Effect", "Sharpen"]), - ("Sketch", &["Effect", "Sketch"]), - ("Stylize", &["Effect", "Stylize"]), - ("Texture", &["Effect", "Texture"]), - ("Video", &["Effect", "Video"]), -]; - -/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects. -fn effect_menu() -> Vec { - let cat = vectorcraft_effects::effect_catalog(); - let mut out = vec![ - c("Apply Last Effect", "effect.applyLast"), - c("Last Effect…", "effect.last"), - Sep, - c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), - Sep, - Item::Header("Vector Effects"), - ]; - // Submenus (and Crop Marks and Rasterize…, items of their own) in the reference app's order. - let order = [ - "3D and Materials", - "Color Adjustments", - "Convert to Shape", - "Crop Marks", - "Distort & Transform", - "Path", - "Pathfinder", - "Rasterize…", - "Stylize", - "SVG Filters", - "Warp", - ]; - let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label); - // An effect goes in the vector submenu its path names unless it is a Photoshop-style effect, - // which goes in the raster submenu of the same name when `RASTER_MENUS` has one (Stylize › - // Glowing Edges). - let in_vector_menu = - |e: &vectorcraft_effects::EffectInfo| order.iter().any(|s| e.menu == ["Effect", *s]) && !vectorcraft_effects::PIXEL_EFFECTS.contains(&e.id); - let item = |e: &vectorcraft_effects::EffectInfo| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { - // No options (Effect → Pathfinder, Crop Marks): apply directly, like Illustrator. - true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), - false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), - }; - // The effects whose catalogue menu path is `path`. - // `vector` picks the ones for the vector submenu or the ones for the raster submenu. - let items_at = - |path: &[&str], vector: bool| -> Vec { cat.iter().filter(|e| e.menu == path && in_vector_menu(e) == vector).map(item).collect() }; - for sub_name in order { - if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { - out.push(item(e)); - continue; - } - let items = items_at(&["Effect", sub_name], true); - if items.is_empty() { - let placeholder = match sub_name { - "3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")], - "SVG Filters" => vec![todo("Apply SVG Filter…")], - _ => continue, - }; - out.push(sub(sub_name, placeholder)); - } else { - let mut items = items; - if sub_name == "3D and Materials" { - // In the panel's order of 3D types, then Materials (not built yet). - let rank = |label: &str| ["Extrude & Bevel…", "Revolve…", "Inflate…", "Rotate…"].iter().position(|l| *l == label).unwrap_or(9); - items.sort_by_key(|i| match i { - Item::Cmd(label, ..) => rank(label), - _ => 9, - }); - items.push(todo("Materials…")); - } - out.push(sub(sub_name, items)); - } - } - // Live-effect plug-ins close the vector effects. - out.extend(crate::dialogs::plugin::effect_menu()); - let raster: Vec = RASTER_MENUS - .iter() - .filter_map(|(name, path)| { - let items = items_at(path, false); - (!items.is_empty()).then(|| sub(name, items)) - }) - .collect(); - if !raster.is_empty() { - out.push(Sep); - out.push(Item::Header("Raster Effects")); - out.push(c("Effect Gallery…", "ui.effectGalleryDialog")); - out.extend(raster); - } - // Anything not placed above (future effects) still shows up. - let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path); - for e in cat.iter().filter(|e| !placed(e)) { - out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); - } - out -} - -/// The shortcut a menu item shows: its command's (items without params), or a panel's for the -/// Window menu's `window.panel {panel}` items. -pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { - match (id, p.get("panel").and_then(Value::as_str)) { - ("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel), - _ if p.is_null() => shortcut_of(id), - _ => None, - } -} - -/// Labels only the canvas context menu ([`context_items`]) shows, so the catalog tests can insist -/// every one of them is translated (a test checks the list against the menu). -pub const CONTEXT_LABELS: &[&str] = &[ - "Isolate Selected Group", - "Exit Isolation Mode", - "Lock Artboards", - "Lock Selected", - "Lock All Except Selected", - "Lock All", - "Unlock Artboards", - "Unlock Selected", - "Unlock", - "Make Clipping Mask", - "Release Clipping Mask", - "Make Compound Path", - "Release Compound Path", - "Select All", -]; - -/// The labels [`dynamic_label`] can show in place of an item's own (Show/Hide pairs and the like), -/// so the catalog tests can insist every one of them is translated. -pub const DYNAMIC_LABELS: &[&str] = &[ - "Preview", - "Outline", - "Hide Edges", - "Show Edges", - "Hide Corner Widget", - "Show Corner Widget", - "Hide Text Threads", - "Show Text Threads", - "Hide Hidden Characters", - "Show Hidden Characters", - "Hide Gradient Annotator", - "Show Gradient Annotator", - "Hide Live Paint Gaps", - "Show Live Paint Gaps", - "Hide Artboards", - "Show Artboards", - "Hide Rulers", - "Show Rulers", - "Hide Video Rulers", - "Show Video Rulers", - "Change to Global Rulers", - "Change to Artboard Rulers", - "Hide Bounding Box", - "Show Bounding Box", - "Hide Transparency Grid", - "Show Transparency Grid", - "Hide Guides", - "Show Guides", - "Hide Grid", - "Show Grid", - "Unlock Guides", - "Lock Guides", - "Show Slices", - "Hide Slices", - "Hide Template", - "Show Template", - "Hide Print Tiling", - "Show Print Tiling", - "Edit Envelope", - "Edit Contents", - "Start Global Edit", - "Stop Global Edit", - "Undo", - "Redo", - "Unlock Grid", - "Lock Grid", -]; - -/// Every English string the menus, the command palette, the panel registry, the toolbar and the -/// Preferences dialog can show: what a complete language catalog has to cover. Names that are user -/// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the -/// view angles are left out. -pub fn menu_strings() -> std::collections::BTreeSet { - use std::collections::BTreeSet; - fn user_data(id: &str, params: &Value) -> bool { - hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) - } - fn walk(items: &[Item], under: &str, out: &mut BTreeSet) { - for it in items { - match it { - Item::Cmd(l, id, params) => { - let size = l.strip_suffix(" pt").is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())); - // View › Rotate View lists angles: an optional minus sign, digits and a degree sign. - let angle = l - .strip_suffix('°') - .map(|n| n.strip_prefix('-').unwrap_or(n)) - .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); - // VectorCraft › Language lists each language by its own name. - let language_name = *id == "app.language" && crate::i18n::Lang::all().any(|lang| lang.name() == *l); - if !user_data(id, params) && !size && !angle && !language_name && *l != "—" { - out.insert(l.to_string()); - } - } - Item::Todo(l, _) | Item::Header(l) => { - out.insert(l.to_string()); - } - Item::Sub(l, children) => { - out.insert(l.to_string()); - // Type › Font lists the installed families: names, not UI text. - if !(under == "Type" && *l == "Font") { - walk(children, l, out); - } - } - Item::Sep => {} - } - } - } - let mut out = BTreeSet::new(); - for (title, items) in menu_tree() { - out.insert(title.to_string()); - walk(&items, title, &mut out); - } - out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string())); - out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string())); - out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string())); - out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string())); - for c in vectorcraft_engine::cmd::command_specs() { - out.insert(c.label.to_string()); - out.extend(c.menu.iter().map(|m| m.to_string())); - } - out.extend(crate::state::all_panels().map(|(_, label)| label.to_string())); - out.extend(vectorcraft_tools::catalog::all_tools().map(|t| t.label.to_string())); - out.extend(crate::toolbar::BASIC.iter().map(|c| c.0.to_string())); - out.extend(vectorcraft_color::BlendMode::ALL.iter().map(|m| m.label().to_string())); - out.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.to_string())); - out.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|c| c.to_string())); - for sp in vectorcraft_engine::cmd::prefscmds::PREF_SPECS { - out.insert(sp.label.to_string()); - if !sp.section.is_empty() { - out.insert(sp.section.to_string()); - } - if let vectorcraft_engine::cmd::prefscmds::PrefKind::Choice(opts) = sp.kind { - out.extend(opts.iter().map(|o| o.1.to_string())); - } - } - out.extend(crate::prefs_dialog::UI_FIELDS.iter().map(|f| f.2.to_string())); - out.extend(crate::dialogs::button_labels().into_iter().map(str::to_string)); - out.extend(crate::dialogs::FILM_GRAIN_FIELDS.iter().map(|(_, label, _)| label.to_string())); - out.extend(crate::dialogs::PAINT_DAUB_BRUSH_TYPE_LABELS.iter().map(|label| label.to_string())); - out.extend(crate::chrome::hint_strings().into_iter().map(str::to_string)); - out.remove(""); - out -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs new file mode 100644 index 000000000..b00d500d9 --- /dev/null +++ b/crates/ui-egui/src/menus/bar.rs @@ -0,0 +1,172 @@ +use super::entries::{Row, entry, pretty_shortcut}; +use super::invoke::{field_event, focused_text_field, invoke, to_field}; +use super::tree::menu_tree_named; +use super::*; + +/// Render the menu bar. +/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width. +pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { + let t = Tokens::get(ui.ctx()); + let ctx = ui.ctx().clone(); + // The press that opens a menu takes the keyboard from a focused text field: the field and its + // selection are kept while the menus are open, so their Select All, Cut, Copy and Paste act on + // its text (#516), the frame after the click that chose them (that click takes the keyboard + // from it again). + type Field = (egui::Id, egui::text_edit::TextEditState); + let (field_key, chosen_key) = (egui::Id::unique("menu-bar-field"), egui::Id::unique("menu-bar-field-event")); + if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) { + ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key)); + state.store(&ctx, field); + to_field(&ctx, field, e); + } + if let Some(field) = focused_text_field(&ctx).and_then(|id| Some((id, egui::TextEdit::load_state(&ctx, id)?))) { + ctx.data_mut(|d| d.insert_temp::(field_key, field)); + } + let mut clicked: Option<(String, Value)> = None; + let tree = menu_tree_named(app.session.prefs.font_names_in_english); + // No application menu in the window (as in Illustrator): the bar starts at File on every + // platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there. + let tree: Vec<(&str, Vec)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect(); + // A menu bar for its layout and menu-button style, but the titles use `Popup::menu` directly: + // egui 0.37's `MenuButton` opens the title under a resting pointer while another menu is open, + // and the app switches titles on movement only (`switch_on_hover`, the native behaviour). + let (end, open) = egui::MenuBar::new() + .ui(ui, |ui| { + let mut titles = Vec::with_capacity(tree.len()); + for (title, items) in tree.iter() { + let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text); + let resp = ui.add(egui::Button::new(text)); + egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked)); + titles.push(resp); + } + (ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles)) + }) + .inner; + let field = ctx.data(|d| d.get_temp::(field_key)); + if !open && focused_text_field(&ctx).is_none() { + ctx.data_mut(|d| d.remove::(field_key)); + } + if let Some((id, p)) = clicked { + match field.and_then(|f| Some((f, field_event(app, &id)?))) { + Some(chosen) => { + ctx.data_mut(|d| d.insert_temp(chosen_key, chosen)); + ctx.request_repaint(); + } + None => invoke(app, &id, p), + } + } + end +} + +/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no +/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must +/// not switch to the title under it). +pub(super) fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool { + title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id) +} + +/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another +/// title opens that menu instead (egui alone needs a click on each title). Returns whether a menu +/// is open. +fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool { + let ids: Vec = titles.iter().map(egui::Popup::default_response_id).collect(); + let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else { + return false; + }; + // `Response::hovered` is false while a menu's popup is open, so hit-test the titles here. + // Only a moving pointer switches: one resting on a title leaves the open menu alone. + let moving = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO); + if let Some(p) = ctx.pointer_hover_pos().filter(|_| moving) + && let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) + && i != open + && let Some(id) = ids.get(i) + { + egui::Popup::open_id(ctx, *id); + ctx.request_repaint(); + } + true +} + +/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt; +/// it scrolls when it is taller than the window. +pub(super) fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(230.0); + render_items(app, ui, items, true, clicked); + }); +} + +/// `items` as menu buttons; with `checks`, items that can be on or off show a check mark (or the +/// room for one). +pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) { + let t = Tokens::get(ui.ctx()); + for e in items.iter().filter_map(|it| entry(app, it)) { + match e { + Entry::Sep => { + ui.separator(); + } + Entry::Header(h) => { + ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim)); + } + Entry::Sub(label, children) => { + ui.menu_button(label, |ui| { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + render_items(app, ui, children, checks, clicked); + }); + }); + } + Entry::Item(row) => render_row(app, ui, &row, checks, clicked), + } + } +} + +/// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut, +/// and Type › Font's samples. +fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) { + let sc = row.shortcut.map(pretty_shortcut).unwrap_or_default(); + let Some((id, p)) = row.command else { + ui.add_enabled_ui(false, |ui| { + ui.add(egui::Button::new(row.label.as_ref()).shortcut_text(sc)); + }) + .response + .on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan")); + return; + }; + let text = match row.checked.filter(|_| checks) { + Some(true) => format!("✓ {}", row.label), + Some(false) => format!(" {}", row.label), + None => row.label.to_string(), + }; + // Type → Font: each family's sample after its name (Enable in-menu font previews). + let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview); + let r = match sampled { + Some(family) => { + let slot = egui::IdSalt::new(("font-sample", family)); + let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE)); + let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner; + if let Some(rect) = out.rect(slot) { + crate::font_menu::menu_item_sample(ui, rect, family); + } + out.response + } + None => ui.add_enabled(row.enabled, egui::Button::new(text).shortcut_text(sc)), + }; + if r.clicked() { + *clicked = row.target(); + ui.close(); + } +} + +/// What the Home screen remembers when it opens (`app.home`): the active document and the +/// document count. When either changes, the Home screen gives way to the document. +pub(crate) fn home_key(app: &VectorcraftApp) -> (Option, usize) { + (app.session.active().map(|d| d.uid), app.session.documents().len()) +} + +/// Whether the Home screen is up: chosen with the Home button (`app.home`), or no document is open +/// and Preferences › General › Show The Home Screen When No Documents Are Open is on (#394). Off, +/// an app with no document shows an empty window, and the Home button still opens the screen. +pub(crate) fn home_showing(app: &VectorcraftApp) -> bool { + app.ui.home.is_some() || (app.session.active().is_none() && app.session.prefs.show_home_screen) +} diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs new file mode 100644 index 000000000..8a6bfbf44 --- /dev/null +++ b/crates/ui-egui/src/menus/context.rs @@ -0,0 +1,173 @@ +use super::bar::render_items; +use super::state::enabled; +use super::tree::{arrange_items, transform_items}; +use super::*; + +/// The canvas context menu (right-click): what applies to the selection, or to the view when +/// nothing is selected. Commands that can't run now are left out rather than greyed out. +pub fn context_items(app: &VectorcraftApp) -> Vec { + use vectorcraft_doc::NodeKind; + let Some(st) = app.session.active() else { return vec![] }; + let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); + let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind)); + let several = roots.len() >= 2; + let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]; + if st.isolation.is_some() { + v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]); + } + // Managing artboards from the canvas: with the Artboard tool, the menu locks and unlocks them + // (`artboard.setProps` with `locked` for each artboard named; an entry with none to change is left out). + if app.session.tool_id() == "artboard" { + let n = st.doc.artboards.len(); + let sel = crate::panels::artboards::picked(app, n); + let others: Vec = (0..n).filter(|i| !sel.contains(i)).collect(); + let entry = |label, idx: Vec, locked: bool| { + let list: Vec = idx.iter().map(|i| json!({ "index": i, "locked": locked })).collect(); + (!list.is_empty()).then(|| cp(label, "artboard.setProps", json!({ "artboards": list }))) + }; + let lock: Vec = + [entry("Lock Selected", sel.clone(), true), entry("Lock All Except Selected", others, true), entry("Lock All", (0..n).collect(), true)] + .into_iter() + .flatten() + .collect(); + let unlock: Vec = [entry("Unlock Selected", sel, false), entry("Unlock All", (0..n).collect(), false)].into_iter().flatten().collect(); + for (label, items) in [("Lock Artboards", lock), ("Unlock Artboards", unlock)] { + if !items.is_empty() { + v.push(sub(label, items)); + } + } + v.push(Sep); + } + // The locked objects under the pointer at the right-click, each unlocked as the Layers panel's + // lock column does. `unlock_item_label` shows each object's name. + let unlock: Vec = app + .ui + .context_locked + .iter() + .filter(|(uid, _)| *uid == st.uid) + .flat_map(|(_, ids)| ids) + .filter(|id| st.doc.node(**id).is_some_and(|n| n.locked)) + .map(|id| cp("Unlock", "layer.setProps", json!({ "ids": [id.0], "locked": false }))) + .collect(); + if !unlock.is_empty() { + v.extend([sub("Unlock", unlock), Sep]); + } + if roots.is_empty() { + v.extend([ + c("Paste", "edit.paste"), + Sep, + c("Zoom In", "view.zoomIn"), + c("Zoom Out", "view.zoomOut"), + c("Fit Artboard in Window", "view.fitArtboard"), + Sep, + c("Show Rulers", "view.rulers"), + c("Show Grid", "view.grid"), + c("Hide Guides", "view.guides"), + c("Lock Guides", "view.guides.lock"), + Sep, + c("Select All", "select.all"), + ]); + } else { + v.extend([c("Cut", "edit.cut"), c("Copy", "edit.copy"), c("Paste", "edit.paste"), Sep]); + if let [one] = roots.as_slice() + && one.is_container() + { + v.push(c("Isolate Selected Group", "object.isolate")); + } + if several { + v.push(c("Group", "object.group")); + } + if any(|k| matches!(k, NodeKind::Group { clip: false, .. })) { + v.push(c("Ungroup", "object.ungroup")); + } + let paths = any(|k| matches!(k, NodeKind::Path { .. })); + if paths { + v.extend([c("Join", "path.join"), c("Average…", "path.average")]); + } + if !st.selection.anchors.is_empty() { + v.extend([c("Remove Anchor Points", "path.removeAnchors"), c("Cut Path at Selected Anchor Points", "path.cutAtAnchors")]); + } + if several { + v.push(c("Make Clipping Mask", "object.clippingMask.make")); + } + if any(|k| matches!(k, NodeKind::Group { clip: true, .. })) { + v.push(c("Release Clipping Mask", "object.clippingMask.release")); + } + if several && paths { + v.push(c("Make Compound Path", "object.compoundPath.make")); + } + // Chooses the Objects on Path tool, whose click on a path attaches the selection to it. + if several { + v.push(c("Objects on Path", "object.objectsOnPath.attach")); + } + if any(|k| matches!(k, NodeKind::Compound { .. })) { + v.push(c("Release Compound Path", "object.compoundPath.release")); + } + // The paragraphs of the selected type, or those the Type tool's selection touches. + if crate::panels::character::text_style(app).is_some() { + v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")])); + } + v.extend([ + c("Make Guides", "view.guides.make"), + Sep, + sub("Transform", transform_items()), + sub("Arrange", arrange_items()), + sub( + "Select", + vec![c("Next Object Above", "select.nextAbove"), c("Next Object Below", "select.nextBelow"), Sep, c("Deselect", "select.none")], + ), + Sep, + c("Export Selection…", "file.exportSelection"), + ]); + } + prune(app, v) +} + +/// `items` without the commands that can't run now (Undo and Redo stay, greyed out), submenus +/// left empty, and separators that no longer separate anything. +fn prune(app: &VectorcraftApp, items: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(items.len()); + for it in items { + let it = match it { + Item::Sub(l, children) => Item::Sub(l, prune(app, children)), + it => it, + }; + let keep = match &it { + Item::Cmd(_, id, _) => matches!(*id, "edit.undo" | "edit.redo") || enabled(app, id), + Item::Sub(_, children) => !children.is_empty(), + Item::Sep => out.last().is_some_and(|l| !matches!(l, Item::Sep)), + _ => true, + }; + if keep { + out.push(it); + } + } + if matches!(out.last(), Some(Item::Sep)) { + out.pop(); + } + out +} + +/// The canvas context menu's popup; the item clicked goes in `clicked`, for [`invoke`]. +pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + // Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter. + render_items(app, ui, &context_items(app), false, clicked); + }); +} + +/// The document units a right-click on a ruler offers: each runs `document.setUnits`, exactly as +/// Preferences ▸ Units ▸ General does, with the current one checked ([`checked`]). +pub fn ruler_unit_items() -> Vec { + vectorcraft_engine::doc::Unit::ALL.iter().map(|u| cp(u.label(), "document.setUnits", json!({ "units": u.label() }))).collect() +} + +/// The ruler context menu's popup (right-click a ruler or the origin box); the unit clicked goes in +/// `clicked`, for [`invoke`]. +pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(160.0); + render_items(app, ui, &ruler_unit_items(), true, clicked); + }); +} diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs new file mode 100644 index 000000000..fab26507c --- /dev/null +++ b/crates/ui-egui/src/menus/entries.rs @@ -0,0 +1,147 @@ +use super::context::context_items; +use super::invoke::click_target; +use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, unlock_item_label}; +use super::state::{shortcut_of, shown_state}; +use super::tree::menu_tree_named; +use super::*; + +/// A menu item as it shows now ([`entry`]). +pub struct Row<'a> { + /// The command and its params; `None` for an item not implemented yet (always disabled). + pub command: Option<(&'static str, &'a Value)>, + /// The tree's own English label, which [`click_target`] reads. + pub source: &'static str, + /// The label in the UI language, as it reads now (Undo *Move*, Show/Hide…, a recent file). + pub label: std::borrow::Cow<'static, str>, + /// The shortcut as the registry writes it (`Cmd+Shift+Z`), the user's overrides applied. + pub shortcut: Option<&'static str>, + pub enabled: bool, + /// `Some` for an item that is on or off. + pub checked: Option, +} + +impl Row<'_> { + /// What choosing it runs ([`click_target`]); `None` for an item not implemented yet. + pub fn target(&self) -> Option<(String, Value)> { + self.command.map(|(id, p)| click_target(self.source, id, p)) + } +} + +/// Item `it` as the menus show it now, or `None` while they leave it out ([`shown_state`]). +pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { + use crate::i18n::t; + Some(match it { + Item::Sep => Entry::Sep, + Item::Header(h) => Entry::Header(t(h)), + Item::Sub(label, children) => Entry::Sub(t(label), children), + Item::Todo(label, sc) => Entry::Item(Row { + command: None, + source: label, + label: t(label).into(), + shortcut: Some(*sc).filter(|s| !s.is_empty()), + enabled: false, + checked: None, + }), + Item::Cmd(label, id, p) => { + let (enabled, checked) = shown_state(app, id, p)?; + Entry::Item(Row { + command: Some((id, p)), + source: label, + label: effect_item_label(id, p) + .or_else(|| unlock_item_label(app, id, p, crate::i18n::current())) + .unwrap_or_else(|| display_label(app, id, label)) + .into(), + shortcut: item_shortcut(id, p), + enabled, + checked, + }) + } + }) +} + +/// `Cmd+Shift+]` → `⇧⌘]` on macOS, `Ctrl+Shift+]` elsewhere. +pub fn pretty_shortcut(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + if cfg!(target_os = "macos") { + let mut mods = String::new(); + let parts: Vec<&str> = s.split('+').collect(); + let (key, ms): (&str, &[&str]) = if s.ends_with("++") { + ("+", parts.get(..parts.len().saturating_sub(2)).unwrap_or_default()) + } else { + parts.split_last().map_or(("", &[][..]), |(k, rest)| (*k, rest)) + }; + for m in ["Ctrl", "Alt", "Shift", "Cmd"] { + if ms.contains(&m) { + mods.push_str(match m { + "Ctrl" => "⌃", + "Alt" => "⌥", + "Shift" => "⇧", + _ => "⌘", + }); + } + } + format!("{mods}{key}") + } else { + s.replace("Cmd", "Ctrl") + } +} + +#[derive(Serialize)] +pub struct MenuEntry { + pub path: Vec, + pub label: String, + pub command: Option, + pub params: Value, + pub enabled: bool, + pub shortcut: String, +} + +/// Flattened menu for `ui.menu.list`. +pub fn menu_entries(app: &VectorcraftApp) -> Vec { + let mut out = vec![]; + for (title, items) in menu_tree_named(app.session.prefs.font_names_in_english) { + flatten(app, vec![title.to_string()], &items, &mut out); + } + out +} + +/// Flattened canvas context menu for `ui.contextMenu.list` (paths are its submenus). +pub fn context_entries(app: &VectorcraftApp) -> Vec { + let mut out = vec![]; + flatten(app, vec![], &context_items(app), &mut out); + out +} + +fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { + for it in items { + match it { + Item::Cmd(l, id, p) => { + let Some((enabled, _)) = shown_state(app, id, p) else { continue }; + out.push(MenuEntry { + path: path.clone(), + label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)), + command: Some(id.to_string()), + params: p.clone(), + enabled, + shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(), + }) + } + Item::Todo(l, sc) => out.push(MenuEntry { + path: path.clone(), + label: l.to_string(), + command: None, + params: Value::Null, + enabled: false, + shortcut: sc.to_string(), + }), + Item::Sub(l, ch) => { + let mut p = path.clone(); + p.push(l.to_string()); + flatten(app, p, ch, out); + } + _ => {} + } + } +} diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs new file mode 100644 index 000000000..9b4dd262f --- /dev/null +++ b/crates/ui-egui/src/menus/invoke.rs @@ -0,0 +1,279 @@ +use super::state::{SUBJECT_COMMANDS, has_subject, waits_for_ime}; +use super::*; + +/// What a menu click runs: items whose label ends with "…" and that carry default params open a +/// generic parameter dialog (fields = the defaults) instead of running immediately. +pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { + let dialog = label.ends_with('…') + && p.as_object().is_some_and(|o| !o.is_empty()) + && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog"); + if dialog { + return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); + } + (id.to_string(), if p.is_null() { json!({}) } else { p.clone() }) +} + +/// The dialog of command `id` that shows the selection's current values (its query): its heading +/// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they +/// are. +fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { + Some(match id { + // Whether the text overflows, and Shrink Text's factor, are read-only facts. + "text.areaOptions" => ("Area Type Options", &["overflow", "fitScale"]), + "type.pathOptions" => ("Type on a Path Options", &["start", "end"]), + _ => return None, + }) +} + +/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does. +pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> { + Some(match id { + // Move opens on the offset moved last and the checkboxes it was last confirmed with. + "object.move" => ("move", crate::dialogs::transform::move_fields(app)), + "object.rotate" => ("rotate", json!({"angle": 0})), + "object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})), + // Transform Each opens on the values it was last confirmed with (#1732). + "object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::open_fields(app)), + "object.reflect" => ("reflect", json!({"axis": "vertical", "angle": 90})), + "object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})), + "path.average" => ("average", json!({"axis": "both"})), + // Offset Path opens on the values it was last confirmed with. + "object.path.offsetPath" if app.ui.offset_path.is_object() => ("offsetPath", app.ui.offset_path.clone()), + "object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})), + // Simplify opens its slider, or the dialog on the fields it was last confirmed with when + // "Open this dialog with these settings next time" was ticked. + "object.path.simplify" if app.ui.simplify.is_object() => ("simplify", app.ui.simplify.clone()), + "object.path.simplify" => (crate::dialogs::simplify_slider::KIND, crate::dialogs::simplify_slider::fields(app)), + "object.path.splitIntoGrid" => { + // The grid starts at the size of the selection. + let size = + app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)).map_or((0.0, 0.0), |b| (b.width(), b.height())); + ("splitIntoGrid", crate::dialogs::path_ops::split_fields(size.0, size.1)) + } + "object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()), + "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::fields()), + _ => return None, + }) +} + +/// The event a focused text field takes for Edit-menu command `id` (Select All, Cut, Copy, +/// Paste), as the same keys would send it. +pub(super) fn field_event(app: &mut VectorcraftApp, id: &str) -> Option { + Some(match id { + "select.all" => egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }, + "edit.copy" => egui::Event::Copy, + "edit.cut" => egui::Event::Cut, + "edit.paste" | "edit.pasteWithoutFormatting" => egui::Event::Paste(app.written_clipboard_text()?), + _ => return None, + }) +} + +/// Invoke an item clicked in the system menu bar (macOS; its key equivalents come back to egui as +/// key presses, see [`crate::native_menu`]). While a text field has the keyboard, Select All, Cut, +/// Copy and Paste act on the field's text, as their keys do. While its keys select every artboard +/// ([`crate::panels::artboards::selects_all`]), Select All does too. Everything else (and those +/// commands with no field focused) goes to [`invoke`]. +pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) { + match focused_text_field(ctx).and_then(|f| Some((f, field_event(app, id)?))) { + Some((field, e)) => to_field(ctx, field, e), + None if id == "select.all" && crate::panels::artboards::selects_all(app, ctx) => crate::panels::artboards::select_all(app), + None => invoke(app, id, p), + } +} + +/// The text field that has the keyboard, if any. +pub(super) fn focused_text_field(ctx: &egui::Context) -> Option { + ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some()) +} + +/// Give text field `field` the keyboard (back) and event `e`, a [`field_event`], this frame. +pub(super) fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) { + ctx.memory_mut(|m| m.request_focus(field)); + ctx.input_mut(|i| i.events.push(e)); +} + +/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input). +/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values +/// (the command's query). +pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result { + // Graph Data: the worksheet. + if id == "graph.setData" { + return crate::dialogs::graph_data::open(app); + } + let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?; + let (label, fields) = ("Graph Type", v); + app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" })) +} + +pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { + if waits_for_ime(app, id) { + return; + } + if SUBJECT_COMMANDS.contains(&id) && p.get("ids").is_none() && p.get("id").is_none() && !has_subject(app) { + return; + } + // Params first: some dialogs' fields take a look through the session (Move's last move). + if p.as_object().is_none_or(|o| o.is_empty()) + && let Some((kind, fields)) = menu_dialog(app, id) + { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + return; + } + // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. + if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::envelope::open(app, id) { + app.status(e); + } + return; + } + // Variables (data merge): a name and a kind, a name and a row of values. + if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::variables::open(app, id, None); + return; + } + // Pathfinder Options: a dialog with the current options. + if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::pathfinder_options::open(app); + return; + } + // Repeat Options: a dialog with the selected repeat's current values. + if id == "object.repeat.options" + && p.as_object().is_none_or(|o| o.is_empty()) + && let Some(fields) = crate::panels::pattern_options::repeat_fields(app) + { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields})); + return; + } + // Area Type Options and Type on a Path Options: dialogs with the selected type's current + // values (the command's query), less those the dialog leaves alone. + if let Some((label, hidden)) = queried_dialog(id) + && p.as_object().is_none_or(|o| o.is_empty()) + { + match app.session.execute(id, &json!({})) { + Ok(mut fields) => { + if let Some(o) = fields.as_object_mut() { + o.retain(|k, _| !hidden.contains(&k.as_str())); + } + let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return; + } + // Object → Graph → Type… / Data…: dialogs with the selected graph's current values. + if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = graph_dialog(app, id) { + app.status(e); + } + return; + } + // Object → Graph → Design… / Column… / Marker…: the document's graph designs. + if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) { + let r = match id { + "graph.design" => crate::dialogs::graph_design::open_design(app), + "graph.column" => crate::dialogs::graph_design::open_column(app), + _ => crate::dialogs::graph_design::open_marker(app), + }; + if let Err(e) = r { + app.status(e); + } + return; + } + // New View…: a name dialog, starting from the first free "View N". + if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) { + let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default(); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); + return; + } + // Edit Views…: the saved views in a list, to rename or delete. + if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::edit_views::open(app) { + app.status(e); + } + return; + } + // Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with + // the view's angle filled in. + if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.view_mut().map(|v| v.rotation) { + Some(angle) => { + app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle}))); + } + None => app.status("no document"), + } + return; + } + // Save Selection…: a name dialog, starting from the first free "Selection N". + if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { + let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default(); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}})); + return; + } + // Edit Selection…: the saved selections in a list, to rename or delete. + if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::edit_selection::open(app) { + app.status(e); + } + return; + } + // Document Raster Effects Settings and File Info: their dialogs. + if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) { + let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) }; + if let Err(e) = r { + app.status(e); + } + return; + } + // Rasterize…: its options, starting from the document's raster effects settings. + if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute("document.rasterEffectsSettings", &json!({})) { + Ok(v) => { + let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]}); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params})); + } + Err(e) => app.status(e.to_string()), + } + return; + } + // Text Wrap Options: a dialog with the selected wrap object's current values. + if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute(id, &json!({})) { + Ok(fields) => { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return; + } + // Blend Options…: its dialog, on the selected blend's options. + if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::blend_options::open(app) { + app.status(e); + } + return; + } + // Expand…: its dialog. + if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::expand::open(app) { + app.status(e); + } + return; + } + // Help links: the command returns the URL; a menu click opens it (agents just get the URL). + if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") { + app.open_link(id); + return; + } + // Object → Align aligns to what the align buttons align to, unless the call names `to`. With + // anchors selected (Direct Selection) it sends its params unchanged, and the points align to + // their own bounds. + let follows_align_to = id == "object.align" + && p.as_object().is_some_and(|o| !o.contains_key("to")) + && app.session.active().is_some_and(|st| st.selection.anchors.is_empty()); + let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p }; + if let Err(e) = app.run(id, p) { + app.status(e); + } else if matches!(id, "object.pattern.make" | "object.pattern.edit") { + app.ui.open_panel = Some("patternOptions".into()); + } +} diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs new file mode 100644 index 000000000..12afaf058 --- /dev/null +++ b/crates/ui-egui/src/menus/labels.rs @@ -0,0 +1,333 @@ +use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::state::{hidden_when_disabled, shortcut_of}; +use super::tree::menu_tree; +use super::ui_commands::UI_COMMANDS; +use super::*; + +/// Label for toggles whose text flips (Outline/Preview, Hide/Show …). +pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { + let v = &app.ui.view; + match id { + "view.outline" => if v.outline { "Preview" } else { "Outline" }.into(), + "window.workspace.reset" => format!("Reset {}", app.ui.workspace), + "view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(), + "view.template" => if v.templates { "Hide Template" } else { "Show Template" }.into(), + "view.cornerWidget" => if v.corner_widgets { "Hide Corner Widget" } else { "Show Corner Widget" }.into(), + "view.textThreads" => if v.text_threads { "Hide Text Threads" } else { "Show Text Threads" }.into(), + "type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(), + "view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(), + "view.livePaintGaps" => if v.live_paint_gaps { "Hide Live Paint Gaps" } else { "Show Live Paint Gaps" }.into(), + "effect.last" => match &app.last_effect { + Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())), + None => label.into(), + }, + id if id.starts_with("type.recentFont") => { + let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); + n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into()) + } + id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()), + id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), + "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), + "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), + "view.videoRulers" => if v.video_rulers && v.rulers { "Hide Video Rulers" } else { "Show Video Rulers" }.into(), + "view.globalRulers" => if app.session.active().is_some_and(|d| d.doc.rulers == vectorcraft_doc::RulerMode::Global) { + "Change to Artboard Rulers" + } else { + "Change to Global Rulers" + } + .into(), + "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), + "view.transparencyGrid" => { + if app.session.active().is_some_and(|d| d.transparency_grid) { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into() + } + "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), + "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), + "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), + "view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(), + "view.printTiling" => if app.session.active().is_some_and(|d| d.print_tiling) { "Hide Print Tiling" } else { "Show Print Tiling" }.into(), + id if id.starts_with("file.openRecent") => recent_slot(app, id) + .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) + .unwrap_or_else(|| "—".into()), + // Edit Contents reads Edit Envelope while an envelope's contents are being edited. + "object.envelope.editContents" => { + let editing = app.session.active().is_some_and(|st| { + st.selection + .objects + .first() + .and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id)) + .is_some_and(|e| matches!(e.kind, vectorcraft_doc::NodeKind::Envelope { editing: true, .. })) + }); + if editing { "Edit Envelope" } else { "Edit Contents" }.into() + } + "select.globalEdit" => { + if app.session.active().is_some_and(|d| d.global_edit.is_some()) { "Stop Global Edit" } else { "Start Global Edit" }.into() + } + "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), + "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => { + crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { + crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::symbols::USER_SLOT) => { + crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::brushes::USER_SLOT) => { + crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) + } + "perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(), + "perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(), + "perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(), + _ => label.into(), + } +} + +/// Does the menu item `id` show a name that is user, file or system data rather than an interface +/// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a +/// plug-in? Such names are shown as they are, never translated (a workspace the user calls +/// "Layers" stays "Layers"). +pub(super) fn shows_a_name(id: &str, label: &str) -> bool { + const SLOTS: [&str; 8] = [ + "file.openRecent", + "view.goto", + "type.recentFont", + crate::panels::swatches::USER_SLOT, + crate::panels::graphic_styles::USER_SLOT, + crate::panels::symbols::USER_SLOT, + crate::panels::brushes::USER_SLOT, + crate::dialogs::perspective_presets::SLOT, + ]; + SLOTS.iter().any(|s| id.starts_with(s)) + || saved_selection_slot(id).is_some() + || matches!(id, "text.setStyle" | "plugin.dialog") + || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) + || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) +} + +/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or +/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds +/// while another thread installs or removes plug-ins. +pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { + id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() +} + +/// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled +/// around a name (Undo *Move*, Reset *Essentials*, Last Effect: *Drop Shadow*) are translated as +/// templates so the name can move; names that are user data (files, views, fonts, custom +/// workspaces, plug-ins: [`shows_a_name`]) pass through. +pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String { + let lang = crate::i18n::current(); + let fmt = crate::i18n::fmt; + match id { + "edit.undo" | "edit.redo" => { + let last = app.session.active().and_then(|d| if id == "edit.undo" { d.history.undo.last() } else { d.history.redo.last() }); + // The step's name as the History panel lists it. + let name = crate::panels::history::shown; + match last { + Some(h) if id == "edit.undo" => fmt(tl!("Undo {name}"), &[("name", &name(h))]), + Some(h) => fmt(tl!("Redo {name}"), &[("name", &name(h))]), + None => tl!(if id == "edit.undo" { "Undo" } else { "Redo" }).to_string(), + } + } + "window.workspace.reset" => { + let name = &app.ui.workspace; + fmt(tl!("Reset {name}"), &[("name", if crate::workspaces::is_builtin(name) { tl!(name) } else { name })]) + } + "effect.last" => match &app.last_effect { + Some((e, _)) => { + let name = vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str()); + fmt(tl!("Last Effect: {name}"), &[("name", crate::i18n::tr_id(lang, e, name))]) + } + None => crate::i18n::tr_id(lang, id, label).to_string(), + }, + _ => { + let shown = dynamic_label(app, id, label); + if shows_a_name(id, &shown) { shown } else { crate::i18n::tr_id(lang, id, &shown).to_string() } + } + } +} + +/// The label of an Effect menu item in the UI language, by the effect's own id. Every effect item +/// runs `effect.apply` or `effect.dialog`, so the command id can't tell them apart; an `@id` row +/// for the effect id (for example `pathfinder.add`) wins over the plain row of the English label. +/// `None` when the catalog has no such row, and the plain translation applies. +pub(super) fn effect_item_label(id: &str, p: &Value) -> Option { + if !matches!(id, "effect.apply" | "effect.dialog") { + return None; + } + let effect = p.get("effect")?.as_str()?; + let shown = crate::i18n::tr_id(crate::i18n::current(), effect, ""); + (!shown.is_empty()).then(|| shown.to_string()) +} + +/// The label of an item of the canvas context menu's Unlock submenu (`layer.setProps` with one id +/// and `locked: false`): the object's name as the Layers panel shows it in `lang` +/// ([`crate::panels::layers::shown_name`]). `None` for any other item, or when the object is gone. +pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: crate::i18n::Lang) -> Option { + if id != "layer.setProps" || p.get("locked") != Some(&Value::Bool(false)) { + return None; + } + let [one] = p.get("ids")?.as_array()?.as_slice() else { return None }; + let n = app.session.active()?.doc.node(vectorcraft_doc::NodeId(one.as_u64()?))?; + Some(crate::panels::layers::shown_name(app, n, lang)) +} + +/// The shortcut a menu item shows: its command's (items without params), or a panel's for the +/// Window menu's `window.panel {panel}` items. +pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { + match (id, p.get("panel").and_then(Value::as_str)) { + ("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel), + _ if p.is_null() => shortcut_of(id), + _ => None, + } +} + +/// Labels only the canvas context menu ([`context_items`]) shows, so the catalog tests can insist +/// every one of them is translated (a test checks the list against the menu). +pub const CONTEXT_LABELS: &[&str] = &[ + "Isolate Selected Group", + "Exit Isolation Mode", + "Lock Artboards", + "Lock Selected", + "Lock All Except Selected", + "Lock All", + "Unlock Artboards", + "Unlock Selected", + "Unlock", + "Make Clipping Mask", + "Release Clipping Mask", + "Make Compound Path", + "Release Compound Path", + "Select All", +]; + +/// The labels [`dynamic_label`] can show in place of an item's own (Show/Hide pairs and the like), +/// so the catalog tests can insist every one of them is translated. +pub const DYNAMIC_LABELS: &[&str] = &[ + "Preview", + "Outline", + "Hide Edges", + "Show Edges", + "Hide Corner Widget", + "Show Corner Widget", + "Hide Text Threads", + "Show Text Threads", + "Hide Hidden Characters", + "Show Hidden Characters", + "Hide Gradient Annotator", + "Show Gradient Annotator", + "Hide Live Paint Gaps", + "Show Live Paint Gaps", + "Hide Artboards", + "Show Artboards", + "Hide Rulers", + "Show Rulers", + "Hide Video Rulers", + "Show Video Rulers", + "Change to Global Rulers", + "Change to Artboard Rulers", + "Hide Bounding Box", + "Show Bounding Box", + "Hide Transparency Grid", + "Show Transparency Grid", + "Hide Guides", + "Show Guides", + "Hide Grid", + "Show Grid", + "Unlock Guides", + "Lock Guides", + "Show Slices", + "Hide Slices", + "Hide Template", + "Show Template", + "Hide Print Tiling", + "Show Print Tiling", + "Edit Envelope", + "Edit Contents", + "Start Global Edit", + "Stop Global Edit", + "Undo", + "Redo", + "Unlock Grid", + "Lock Grid", +]; + +/// Every English string the menus, the command palette, the panel registry, the toolbar and the +/// Preferences dialog can show: what a complete language catalog has to cover. Names that are user +/// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the +/// view angles are left out. +pub fn menu_strings() -> std::collections::BTreeSet { + use std::collections::BTreeSet; + fn user_data(id: &str, params: &Value) -> bool { + hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) + } + fn walk(items: &[Item], under: &str, out: &mut BTreeSet) { + for it in items { + match it { + Item::Cmd(l, id, params) => { + let size = l.strip_suffix(" pt").is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())); + // View › Rotate View lists angles: an optional minus sign, digits and a degree sign. + let angle = l + .strip_suffix('°') + .map(|n| n.strip_prefix('-').unwrap_or(n)) + .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); + // VectorCraft › Language lists each language by its own name. + let language_name = *id == "app.language" && crate::i18n::Lang::all().any(|lang| lang.name() == *l); + if !user_data(id, params) && !size && !angle && !language_name && *l != "—" { + out.insert(l.to_string()); + } + } + Item::Todo(l, _) | Item::Header(l) => { + out.insert(l.to_string()); + } + Item::Sub(l, children) => { + out.insert(l.to_string()); + // Type › Font lists the installed families: names, not UI text. + if !(under == "Type" && *l == "Font") { + walk(children, l, out); + } + } + Item::Sep => {} + } + } + } + let mut out = BTreeSet::new(); + for (title, items) in menu_tree() { + out.insert(title.to_string()); + walk(&items, title, &mut out); + } + out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string())); + out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string())); + out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string())); + out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string())); + for c in vectorcraft_engine::cmd::command_specs() { + out.insert(c.label.to_string()); + out.extend(c.menu.iter().map(|m| m.to_string())); + } + out.extend(crate::state::all_panels().map(|(_, label)| label.to_string())); + out.extend(vectorcraft_tools::catalog::all_tools().map(|t| t.label.to_string())); + out.extend(crate::toolbar::BASIC.iter().map(|c| c.0.to_string())); + out.extend(vectorcraft_color::BlendMode::ALL.iter().map(|m| m.label().to_string())); + out.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.to_string())); + out.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|c| c.to_string())); + for sp in vectorcraft_engine::cmd::prefscmds::PREF_SPECS { + out.insert(sp.label.to_string()); + if !sp.section.is_empty() { + out.insert(sp.section.to_string()); + } + if let vectorcraft_engine::cmd::prefscmds::PrefKind::Choice(opts) = sp.kind { + out.extend(opts.iter().map(|o| o.1.to_string())); + } + } + out.extend(crate::prefs_dialog::UI_FIELDS.iter().map(|f| f.2.to_string())); + out.extend(crate::dialogs::button_labels().into_iter().map(str::to_string)); + out.extend(crate::dialogs::FILM_GRAIN_FIELDS.iter().map(|(_, label, _)| label.to_string())); + out.extend(crate::dialogs::PAINT_DAUB_BRUSH_TYPE_LABELS.iter().map(|label| label.to_string())); + out.extend(crate::chrome::hint_strings().into_iter().map(str::to_string)); + out.remove(""); + out +} diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs new file mode 100644 index 000000000..57137bee5 --- /dev/null +++ b/crates/ui-egui/src/menus/run.rs @@ -0,0 +1,725 @@ +use super::bar::home_key; +use super::invoke::menu_dialog; +use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::state::{checked, selected_image}; +use super::ui_commands::UI_COMMANDS; +use super::*; + +/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label, +/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id +/// as it is (`library:swatches:pastels`). +pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> { + let name = input.trim(); + crate::state::all_panels() + .find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)) + .map(|(id, _)| id) + .or_else(|| crate::panels::library_panel::intern(input)) +} + +/// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle). +pub(crate) fn opt_bool(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } +} + +/// Handle a UI command. `None` = not a UI command (the engine handles it). +/// `params` has only keys the params description of engine command `id` names, or the parameter +/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their +/// descriptions haven't been audited against what they read. +pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Value) -> Result<(), String> { + if UI_COMMANDS.iter().any(|c| c.0 == id) { + return Ok(()); + } + session.check_params(id, params).map_err(|e| e.to_string()) +} + +/// Runs `id` when the UI runs it itself: one of [`UI_COMMANDS`], or an engine command the UI +/// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the +/// engine. +pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + // The Window menu's panel items toggle like its other checked items (#2361); the panel's + // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. + if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { + let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); + if app.ui.open_panel.as_deref() == Some(panel) { + app.ui.open_panel = None; + return Some(Ok(json!({"open": null}))); + } + return Some(app.run("window.panel.close", json!({"panel": panel}))); + } + if let Some(result) = crate::panel_docking::command(app, id, p) { + return Some(result); + } + if id == "app.language" { + let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); + let value = if lang.eq_ignore_ascii_case("auto") { + "auto".to_string() + } else if let Some(l) = crate::i18n::Lang::from_code(lang) { + l.code().to_string() + } else { + let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::all().map(|l| l.code())).collect(); + return Some(Err(format!("lang must be one of {}", codes.join(", ")))); + }; + return Some(app.run("prefs.set", json!({"key": "interfaceLanguage", "value": value})).map(|_| json!(value))); + } + if let Some(r) = crate::panels::character::intercept_text_command(app, id) { + return Some(r); + } + if let Some(r) = crate::shortcut_editor::run_command(app, id, p) + .or_else(|| crate::workspaces::run_command(app, id, p)) + .or_else(|| crate::edit_toolbar::command(app, id, p)) + { + return Some(r); + } + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let flag = |b: &mut bool| { + *b = !*b; + Ok(json!(*b)) + }; + let r = match id { + "file.newDialog" => { + crate::dialogs::open_new_document(app); + Ok(Value::Null) + } + "file.newWithoutDialog" => crate::dialogs::new_document_without_dialog(app), + "app.home" => { + app.ui.home = Some(home_key(app)); + Ok(Value::Null) + } + "file.open" => match s("path") { + Some(path) => io::open_path(app, &path), + None => io::open_dialog(app).map(|_| Value::Null), + }, + // Saves write through the app (save panel, download); with no path known the panel and the + // format's options dialog ask first. + "file.save" => io::save(app, SaveMode::Save, p, true), + "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), + "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), + "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), + "document.exportSelection" => { + io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) + } + // Bytes sent by an agent go straight to the engine; a path or nothing opens it here. + "file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")), + "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), + "file.reveal" => io::reveal(app), + "file.cancelExport" => Ok(io::cancel_exports(app)), + "file.exportInBackground" => Ok(io::hide_exports(app)), + id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { + Some(path) => io::open_recent_reporting(app, &path), + None => Err("no such recent file".into()), + }, + "type.findFont" => { + crate::find_font::open(app); + Ok(Value::Null) + } + "ui.missingFontsDialog" => crate::dialogs::missing_fonts::open_command(app, s("folder")), + "ui.findFontsInFolder" => crate::dialogs::missing_fonts::find_in_folder_command(app, s("folder")), + "file.clearRecent" => { + app.ui.recent_files.clear(); + Ok(Value::Null) + } + "file.recentFiles" => Ok(json!(io::recent_files(app))), + "file.place" => crate::place::run(app, p), + "file.place.queue" => crate::place::queue(app, p), + "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { + crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None); + Ok(Value::Null) + } + "file.export.svg" => io::export(app, Some("svg"), s("path"), p), + "file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => { + crate::dialogs::open_export_for_screens(app); + Ok(Value::Null) + } + "file.exportForScreens" => io::export_for_screens(app, p.clone()), + "file.export.png" if s("path").is_none() => io::target_path(app, None, "png").and_then(|path| { + let f = vectorcraft_engine::cmd::fileio::format("png").ok_or("no PNG encoder")?; + let mut params = if p.is_object() { p.clone() } else { json!({}) }; + params["format"] = json!("png"); + params["path"] = json!(path); + crate::dialogs::open_raster_options(app, f, params); + Ok(Value::Null) + }), + "file.export.png" => io::export(app, Some("png"), s("path"), p), + "file.documentSetup" => crate::dialogs::open_document_setup(app), + "edit.preferences" => { + crate::prefs_dialog::open(app, s("category").as_deref()); + Ok(Value::Null) + } + // Edit → Color Settings… / Assign Profile… (no params): show the colour-management panel. + "edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => { + app.ui.open_panel = Some("separations".into()); + app.ui.dock = true; + app.session.execute(id, p).map_err(|e| e.to_string()) + } + "view.outline" => flag(&mut app.ui.view.outline), + "view.pixelPreview" => flag(&mut app.ui.view.pixel_preview), + "view.trimView" => flag(&mut app.ui.view.trim_view), + "view.cornerWidget" => flag(&mut app.ui.view.corner_widgets), + // New View: the engine stores the current zoom, centre and rotation under the given name. + "view.saved.new" if p.get("zoom").is_none() => { + let Some(v) = app.view().copied() else { return Some(Err("no document".into())) }; + let mut params = if p.is_object() { p.clone() } else { json!({}) }; + params["center"] = json!([v.center.x, v.center.y]); + params["zoom"] = json!(v.zoom); + params["rotation"] = json!(v.rotation); + app.session.execute(id, ¶ms).map_err(|e| e.to_string()) + } + // A saved view: its zoom, centre and rotation, in the current window. + id if saved_view_slot(id).is_some() => { + let saved = saved_view(app, id).and_then(|sv| crate::state::View::saved(sv).map(|to| (sv.name.clone(), to))); + match (saved, app.view_mut()) { + (Some((name, to)), Some(v)) => { + (v.center, v.zoom, v.rotation) = (to.center, to.zoom, to.rotation); + Ok(json!({ "name": name })) + } + _ => Err("no such view".into()), + } + } + id if saved_selection_slot(id).is_some() => match saved_selection_name(app, id) { + Some(name) => app.run("select.recall", json!({ "name": name })), + None => Err("no such saved selection".into()), + }, + "view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel), + "view.snapToGlyph" => flag(&mut app.ui.view.snap_to_glyph), + // The preference itself (General › Use Precise Cursors), so the command, the dialog and the saved + // preferences share one value. + "view.preciseCursors" => { + let on = !app.session.prefs.use_precise_cursors; + app.run("prefs.set", json!({"key": "usePreciseCursors", "value": on})).map(|_| { + crate::prefs_dialog::snapshot(app); + json!(on) + }) + } + "view.textThreads" => flag(&mut app.ui.view.text_threads), + "type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars), + "type.bold" => crate::panels::character::toggle_face(app, Face::Bold), + "type.italic" => crate::panels::character::toggle_face(app, Face::Italic), + id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => { + crate::panels::paragraph::align(app, justify) + } + "type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p), + "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p), + "type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app), + id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b), + "view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator), + "view.livePaintGaps" => flag(&mut app.ui.view.live_paint_gaps), + "effect.last" => match app.last_effect.clone() { + Some((e, params)) => { + let label = vectorcraft_effects::effect_info(&e).map(|i| i.label.trim_end_matches('…').to_string()).unwrap_or_else(|| e.clone()); + let mut fields = params.as_object().cloned().unwrap_or_default(); + fields.insert("__effect".into(), json!(e)); + fields.insert("__label".into(), json!(label)); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields }); + Ok(Value::Null) + } + None => Err("no effect applied yet".into()), + }, + id if id.starts_with("type.recentFont") => { + let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); + match n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned() { + Some(font) => app.run("text.setStyle", json!({ "font": font })), + None => Err("no such recent font".into()), + } + } + "view.edges" => flag(&mut app.ui.view.edges), + "view.artboards" => flag(&mut app.ui.view.artboards), + "view.template" => flag(&mut app.ui.view.templates), + "view.rulers" => flag(&mut app.ui.view.rulers), + "view.videoRulers" => { + let v = &mut app.ui.view; + let show = !(v.video_rulers && v.rulers); + v.video_rulers = show; + v.rulers |= show; + Ok(json!(show)) + } + "view.boundingBox" => flag(&mut app.ui.view.bounding_box), + "view.guides" => { + let r = flag(&mut app.ui.view.guides); + // Hidden ruler guides and guide paths cannot stay selected: their selection overlay + // would otherwise reveal geometry that Hide Guides just hid. + let hidden_selection = !app.ui.view.guides + && app.session.active().is_some_and(|d| { + !d.selection.guides.is_empty() + || d.selection + .objects + .iter() + .any(|id| matches!(d.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Path { guide: true, .. }))) + }); + if hidden_selection { + // Deselecting a document that is open cannot fail. + let _ = app.run("select.none", json!({})); + } + r + } + "view.smartGuides" => flag(&mut app.ui.view.smart_guides), + "view.grid" => flag(&mut app.ui.view.grid), + "view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid), + "view.snapToPoint" => flag(&mut app.ui.view.snap_to_point), + "view.zoomIn" | "view.zoomOut" => { + let up = id == "view.zoomIn"; + let print = app.session.prefs.display_print_size; + // Selection & Anchor Display › Zoom to Selection: the selection comes to the middle. + let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None }; + match app.view_mut() { + Some(v) => { + v.zoom = next_zoom(v.zoom, up); + if let Some(c) = focus { + v.center = c; + } + Ok(json!({"zoom": crate::state::zoom_display_percent(v.zoom, print)})) + } + None => Err("no document".into()), + } + } + "view.setZoom" => { + let print = app.session.prefs.display_print_size; + let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print); + let center = + p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + match app.view_mut() { + Some(v) => { + v.zoom = z.clamp(0.0313, 640.0); + v.fitted = true; + if let Some(c) = center { + v.center = c; + } + Ok(Value::Null) + } + None => Err("no document".into()), + } + } + "view.fitArtboard" | "view.fitAll" | "view.actualSize" => { + crate::canvas::fit(app, id); + Ok(Value::Null) + } + "view.goToArtboard" => go_to_artboard(app, p), + "view.presentation" => { + app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 }; + Ok(json!(app.ui.screen_mode)) + } + "view.screenMode" => { + app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { + Some(m) => m.min(3) as u8, + // F cycles the three screen modes. Without a mode, the command also goes from + // Presentation Mode (not in the cycle) back to Normal. + None if app.ui.screen_mode >= 2 => 0, + None => app.ui.screen_mode + 1, + }; + Ok(json!(app.ui.screen_mode)) + } + // View › Rotate View and the status bar's angle menu: the view turns about the center of the + // canvas to the angle, normalized as a Rotate View drag normalizes it. + "view.rotate" => { + let key = "angle"; + let Some(angle) = p.get(key).and_then(Value::as_f64).filter(|a| a.is_finite()) else { + return Some(Err(format!("`{key}` must be a number"))); + }; + match app.view_mut() { + Some(v) => { + v.rotation = vectorcraft_geom::normalize_deg(angle); + Ok(json!({ "angle": v.rotation })) + } + None => Err("no document".into()), + } + } + // View › Rotate View to Selection: the view turns about the center of the canvas to the angle + // of the selection's bounding box, so the box is square to the window. + "view.rotateToSelection" => { + let Some(st) = app.session.active() else { return Some(Err("no document".into())) }; + if st.selection.objects.is_empty() { + return Some(Err("nothing selected".into())); + } + let angle = vectorcraft_geom::normalize_deg(st.doc.bbox_angle(&st.selection.objects)); + match app.view_mut() { + Some(v) => { + v.rotation = angle; + Ok(json!({ "angle": angle })) + } + None => Err("no document".into()), + } + } + "view.rotateReset" => { + if let Some(v) = app.view_mut() { + v.rotation = 0.0; + } + Ok(Value::Null) + } + "window.control" => flag(&mut app.ui.control_bar), + "window.toolbar" => flag(&mut app.ui.toolbar), + "window.toolbarColumns" => opt_bool(p, "double").map(|on| { + let on = on.unwrap_or(!app.ui.toolbar_double); + app.ui.toolbar_double = on; + json!(on) + }), + "window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced), + "imageTrace.preview" => opt_bool(p, "on").map(|on| { + let on = on.unwrap_or(!app.ui.image_trace_preview); + app.ui.image_trace_preview = on; + json!(on) + }), + "window.floatTools" => { + let floating = match opt_bool(p, "floating") { + Ok(on) => on, + Err(e) => return Some(Err(e)), + }; + let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) }; + crate::toolbar::float_group(app, &tool, floating).map(Value::Bool) + } + "window.taskBar" => flag(&mut app.ui.task_bar), + "window.taskBar.pin" => { + let place = &mut app.ui.task_bar_place; + opt_bool(p, "pinned").map(|pinned| { + let pinned = pinned.unwrap_or(!place.pinned); + // Pinning holds the bar where it shows; unpinning lets it follow the selection from + // there (the bar turns its pinned spot into an offset when it is next drawn). + if pinned && !place.pinned { + place.pin_at = place.shown_at; + } + place.pinned = pinned; + json!(pinned) + }) + } + "window.taskBar.reset" => { + app.ui.task_bar_place = Default::default(); + Ok(Value::Null) + } + "window.dock" => { + let ui = &mut app.ui; + // The bars the last Tab hid show again, unless the toolbar or the Control bar was shown + // another way since then (Window › Tools, Window › Control). Panels shown with Shift+Tab + // in between stay shown. Switching workspaces clears the record (`workspaces::apply`). + let hid = ui.tab_hid.take().filter(|h| (h.dock || h.toolbar || h.control_bar) && !ui.toolbar && !ui.control_bar); + let on = if let Some(h) = hid { + ui.dock |= h.dock; + ui.toolbar = h.toolbar; + ui.control_bar = h.control_bar; + true + } else if ui.dock || ui.toolbar || ui.control_bar { + ui.tab_hid = Some(crate::state::TabHid { dock: ui.dock, toolbar: ui.toolbar, control_bar: ui.control_bar }); + ui.dock = false; + ui.toolbar = false; + ui.control_bar = false; + ui.open_panel = None; + false + } else { + ui.dock = true; + ui.toolbar = true; + ui.control_bar = true; + true + }; + Ok(json!(on)) + } + "window.panels" => { + let on = !app.ui.dock; + app.ui.dock = on; + if !on { + app.ui.open_panel = None; + } + Ok(json!(on)) + } + "window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) { + Some(b) => { + app.ui.brightness = b; + app.session.prefs.ui_brightness = b.id().into(); + app.canvas.key = None; + Ok(json!(b.id())) + } + None => Err("brightness must be dark|mediumDark|mediumLight|light".into()), + }, + "window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()), + "tool.select" => match s("tool") { + Some(t) if vectorcraft_tools::tool_info(&t).is_some() => { + app.select_tool(&t); + Ok(json!({"tool": app.session.tool_id()})) + } + Some(t) => Err(format!("unknown tool `{t}`")), + None => Err("missing `tool`".into()), + }, + "tool.setOption" => app.session.set_tool_option_cmd(p), + "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), + "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), + "ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()), + "ui.cropImage" => { + app.select_tool(vectorcraft_tools::cropimage::ID); + Ok(json!({"tool": app.session.tool_id()})) + } + "ui.paramDialog" => { + let cmd = s("command").unwrap_or_default(); + let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); + fields.insert("__command".into(), json!(cmd)); + fields.insert("__label".into(), json!(s("label").unwrap_or(cmd.clone()))); + app.ui.dialog = Some(crate::state::Dialog { kind: "command".into(), fields }); + Ok(Value::Null) + } + "effect.applyLast" => match app.last_effect.clone() { + Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})), + None => Err("no effect applied yet".into()), + }, + "file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})), + "file.export.pdf" => io::export_pdf(app, p.clone()), + "help.about" => { + app.ui.about = true; + Ok(Value::Null) + } + "help.checkForUpdates" => crate::updates_ui::check(app), + "help.commandPalette" => { + app.ui.palette_open = !app.ui.palette_open; + app.ui.palette_query.clear(); + Ok(Value::Null) + } + // Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`). + "file.close" => { + let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index())?; + crate::unsaved::close(app, i) + } + "file.closeAll" => crate::unsaved::close_all(app, "closeAll"), + "app.quit" => crate::unsaved::close_all(app, "quit"), + "ui.swatchOptions" => match s("name") { + Some(name) => crate::dialogs::swatch_options::open(app, &name), + None => Err("missing `name`".into()), + }, + "ui.newSwatch" => { + let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref()) + } + "ui.newColorGroup" => { + let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::new_color_group::open(app, names.unwrap_or_default()) + } + "ui.colorPicker" => crate::dialogs::open_color_picker(app, p), + "ui.sampleScreenColor" => crate::screen_color::start(app, p), + "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), + "ui.symbolOptions" => { + let name = s("name"); + let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false); + if edit || name.is_some() { + crate::dialogs::symbol_options::open_edit(app, name.as_deref()) + } else { + crate::dialogs::symbol_options::open(app) + } + } + "ui.newBrush" => crate::dialogs::new_brush::open(app, p.get("ids").cloned()), + "ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()), + "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), + "ui.colorGuideOptions" => { + crate::dialogs::color_guide_options::open(app); + Ok(Value::Null) + } + "ui.colorBalanceDialog" => { + crate::dialogs::color_balance::open(app); + Ok(Value::Null) + } + "ui.saturateDialog" => { + crate::dialogs::saturate::open(app); + Ok(Value::Null) + } + "window.swatchLibrary" => crate::panels::swatches::open_library(app, p), + "window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")), + "ui.saveSwatchLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_swatch_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::swatches::USER_SLOT) => match crate::panels::swatches::user_library(app, id) { + Some(l) => crate::panels::swatches::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, + "ui.mergeGraphicStyles" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default()) + } + "ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app), + "ui.layerOptions" => crate::dialogs::layer_options::open(app, p), + "ui.newLayer" => crate::dialogs::layer_options::open_new(app, p), + "ui.newArtboard" => crate::dialogs::artboard_options::open_new(app), + "ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app), + "ui.layersExpand" => crate::panels::layers::expand(app, p), + "ui.layersFilter" => crate::panels::layers::set_filter(app, p), + "ui.flattenTransparencyDialog" => { + crate::dialogs::flatten::open(app); + Ok(Value::Null) + } + "ui.flattenerPresetsDialog" => { + crate::dialogs::flattener_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p), + "window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p), + "ui.libraryPanel" => crate::panels::library_panel::command(app, p), + "ui.libraryPanel.locate" => crate::panels::library_panel::locate(app, p), + "window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")), + "ui.saveGraphicStyleLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_style_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) { + Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, + "window.symbolLibrary" => crate::panels::symbols::open_library(app, p), + "window.symbolLibrary.other" => crate::panels::symbols::other_library(app, s("path")), + "ui.saveSymbolLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_symbol_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::symbols::USER_SLOT) => match crate::panels::symbols::user_library(app, id) { + Some(l) => crate::panels::symbols::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, + "window.brushLibrary" => crate::panels::brushes::open_library(app, p), + "window.brushLibrary.other" => crate::panels::brushes::other_library(app, s("path")), + "ui.saveBrushLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_brush_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::brushes::USER_SLOT) => match crate::panels::brushes::user_library(app, id) { + Some(l) => crate::panels::brushes::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, + "ui.expandDialog" => crate::dialogs::expand::open(app), + "ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app), + "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app), + "attributes.openUrl" => crate::panels::attributes::open_url(app, p), + "ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p), + "ui.spotColors" => crate::dialogs::spot_colors::open(app), + "ui.menuDialog" => match s("command").as_deref() { + Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), + c => match c.and_then(|c| menu_dialog(app, c)) { + Some((kind, fields)) => { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + Ok(Value::Null) + } + None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), + }, + }, + "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), + "ui.corners" => crate::dialogs::corners::open(app, p), + "ui.hyphenation" => crate::dialogs::hyphenation::open(app), + "ui.justification" => crate::dialogs::justification::open(app), + "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), + "ui.colorPanel" => crate::panels::color::command(app, p), + "ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p), + "file.exportAs" if s("path").is_none() => { + crate::dialogs::open_export_as(app, s("format").as_deref(), p); + Ok(Value::Null) + } + "file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p), + "ui.fileInfoDialog" => crate::dialogs::file_info::open(app), + "ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app), + "ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p), + "ui.pdfPresetsDialog" => { + crate::dialogs::pdf_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p), + // Save for Office Documents…: its dialog; with options, pick the file (or write `path`). + "document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app), + "document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })), + "ui.dxfOptionsDialog" if app.session.active().is_none() => Err("no document".into()), + "ui.dxfOptionsDialog" => { + crate::dialogs::dxf_options::open(app, p); + Ok(Value::Null) + } + "links.editOriginal" => crate::panels::links::open_file(app, p, false), + "links.reveal" => crate::panels::links::open_file(app, p, true), + "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), + "ui.findLinksInFolder" => { + crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) + } + "ui.packageDialog" => crate::dialogs::package::open(app), + "ui.batchDialog" => { + let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; + let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; + crate::dialogs::action_batch::open(app, set, action) + } + "ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p), + "file.showPackage" => crate::dialogs::package::show_package(app, p), + "docInfo.save" => crate::panels::doc_info::save_report(app, p), + // Slice Options… and Divide Slices…: their dialogs, on the selected slices. + "object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app), + "object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app), + "ui.epsOptionsDialog" if app.session.active().is_none() => Err("no document".into()), + "ui.epsOptionsDialog" => { + crate::dialogs::eps_options::open(app, p); + Ok(Value::Null) + } + // Save for Web: its dialog; with settings, write the files. + "file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::save_for_web::open(app), + "file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()), + "file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p), + // File → Export Selection…: the selection becomes one asset (`multiple: true`: one per + // object), shown checked on Export for Screens' Assets tab. + "file.exportSelection" => { + app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); + json!({ "assets": ids }) + }) + } + "css.copy" => crate::panels::css_properties::copy(app, p), + "css.exportFile" => crate::panels::css_properties::export_file(app, p), + // File → Print…: its dialog; with params, print (or save the job as a PDF) without it. + "file.print" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::print::open(app), + "file.print" => crate::print::run(app, p), + "print.printers" => Ok(crate::print::printers(app)), + "print.printerSetup" => { + let printer = s("printer").filter(|n| !n.is_empty()); + match app.services.print.as_mut() { + Some(service) => service.setup(printer.as_deref()).map(|_| Value::Null), + None => Err("printing isn't available here".into()), + } + } + "ui.printPresetsDialog" => { + crate::dialogs::print_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.printPresetDialog" => crate::dialogs::print::open_preset(app, p), + "plugin.dialog" => crate::dialogs::plugin::open(app, p), + "ui.installPlugin" => io::install_plugin(app, p), + "plugin.cancelRuns" => Ok(crate::plugin_runs::cancel_all(app)), + "plugin.hideRuns" => Ok(crate::plugin_runs::hide_all(app)), + "ui.checkScript" => crate::dialogs::script_check::command(app, p), + "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), + "ui.perspectivePresetsDialog" => { + crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.savePerspectivePreset" => crate::dialogs::perspective_grid::open_save(app), + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => match crate::dialogs::perspective_presets::slot_preset(app, id) { + Some(name) => app.run("perspective.grid.preset", json!({ "name": name })), + None => Err("no such perspective grid preset".into()), + }, + "ui.blendOptions" => crate::dialogs::blend_options::open(app), + "ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app), + "ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p), + _ => return None, + }; + Some(r) +} + +/// `view.goToArtboard`: make artboard `index` (a number, or first / previous / next / last from +/// the current one) active, synchronizing the Artboard tool when in use, and fit it in the window. +fn go_to_artboard(app: &mut VectorcraftApp, p: &Value) -> Result { + let n = app.session.active().map(|d| d.doc.artboards.len()).ok_or("no document open")?; + let last = n.checked_sub(1).ok_or("the document has no artboards")?; + let current = app.view().map_or(0, |v| v.artboard).min(last); + let to = match p.get("index") { + Some(Value::String(s)) => match s.as_str() { + "first" => 0, + "previous" => current.saturating_sub(1), + "next" => current + 1, + "last" => last, + other => return Err(format!("index must be a number or first, previous, next or last, not `{other}`")), + }, + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("index must be a number or first, previous, next or last")?, + None => return Err("missing index".into()), + } + .min(last); + crate::panels::artboards::select(app, to); + crate::canvas::fit(app, "view.fitArtboard"); + Ok(json!({ "index": to })) +} diff --git a/crates/ui-egui/src/menus/slots.rs b/crates/ui-egui/src/menus/slots.rs new file mode 100644 index 000000000..8ecc79b9f --- /dev/null +++ b/crates/ui-egui/src/menus/slots.rs @@ -0,0 +1,169 @@ +use super::*; + +/// The active document's perspective grid (View → Perspective Grid toggles). +pub(super) fn perspective_grid(app: &VectorcraftApp) -> Option { + app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc)) +} + +/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them). +pub(super) const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [ + "file.openRecent1", + "file.openRecent2", + "file.openRecent3", + "file.openRecent4", + "file.openRecent5", + "file.openRecent6", + "file.openRecent7", + "file.openRecent8", + "file.openRecent9", + "file.openRecent10", + "file.openRecent11", + "file.openRecent12", + "file.openRecent13", + "file.openRecent14", + "file.openRecent15", + "file.openRecent16", + "file.openRecent17", + "file.openRecent18", + "file.openRecent19", + "file.openRecent20", + "file.openRecent21", + "file.openRecent22", + "file.openRecent23", + "file.openRecent24", + "file.openRecent25", + "file.openRecent26", + "file.openRecent27", + "file.openRecent28", + "file.openRecent29", + "file.openRecent30", +]; + +/// The saved selection a `select.recallN` slot names (none past the document's last). +pub(super) fn saved_selection_name(app: &VectorcraftApp, id: &str) -> Option { + let n = saved_selection_slot(id)?.checked_sub(1)?; + app.session.active()?.doc.saved_selections.get(n).map(|x| x.name.clone()) +} + +/// The recent file a `file.openRecentN` slot names (none past the preference's count). +pub(super) fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> { + let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?; + io::recent_files(app).get(n.checked_sub(1)?) +} + +/// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. +pub(super) const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ + [ + "ui.perspectiveUserPreset1.1", + "ui.perspectiveUserPreset1.2", + "ui.perspectiveUserPreset1.3", + "ui.perspectiveUserPreset1.4", + "ui.perspectiveUserPreset1.5", + ], + [ + "ui.perspectiveUserPreset2.1", + "ui.perspectiveUserPreset2.2", + "ui.perspectiveUserPreset2.3", + "ui.perspectiveUserPreset2.4", + "ui.perspectiveUserPreset2.5", + ], + [ + "ui.perspectiveUserPreset3.1", + "ui.perspectiveUserPreset3.2", + "ui.perspectiveUserPreset3.3", + "ui.perspectiveUserPreset3.4", + "ui.perspectiveUserPreset3.5", + ], +]; + +/// Select → saved selections: the n-th saved selection of the active document. +pub(super) const SAVED_SELECTION_IDS: [&str; vectorcraft_engine::doc::SavedSelection::MAX] = [ + "select.recall1", + "select.recall2", + "select.recall3", + "select.recall4", + "select.recall5", + "select.recall6", + "select.recall7", + "select.recall8", + "select.recall9", + "select.recall10", + "select.recall11", + "select.recall12", + "select.recall13", + "select.recall14", + "select.recall15", + "select.recall16", + "select.recall17", + "select.recall18", + "select.recall19", + "select.recall20", + "select.recall21", + "select.recall22", + "select.recall23", + "select.recall24", + "select.recall25", +]; + +/// View → saved views: the n-th saved view of the active document. +pub(super) const SAVED_VIEW_IDS: [&str; vectorcraft_engine::doc::SavedView::MAX] = [ + "view.goto1", + "view.goto2", + "view.goto3", + "view.goto4", + "view.goto5", + "view.goto6", + "view.goto7", + "view.goto8", + "view.goto9", + "view.goto10", + "view.goto11", + "view.goto12", + "view.goto13", + "view.goto14", + "view.goto15", + "view.goto16", + "view.goto17", + "view.goto18", + "view.goto19", + "view.goto20", + "view.goto21", + "view.goto22", + "view.goto23", + "view.goto24", + "view.goto25", +]; + +/// The 1-based slot a `view.gotoN` id stands for. +pub(super) fn saved_view_slot(id: &str) -> Option { + id.strip_prefix("view.goto")?.parse().ok() +} + +/// The saved view a `view.gotoN` slot names (none past the document's last). +pub(super) fn saved_view<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a vectorcraft_engine::doc::SavedView> { + app.session.active()?.doc.views.get(saved_view_slot(id)?.checked_sub(1)?) +} + +/// The 1-based slot a `select.recallN` id stands for. +pub(super) fn saved_selection_slot(id: &str) -> Option { + id.strip_prefix("select.recall")?.parse().ok() +} + +/// Type → Recent Fonts slots (Preferences › Type › Number of Recent Fonts shows up to 15). +pub(super) const RECENT_FONT_IDS: [&str; crate::MAX_RECENT_FONTS] = [ + "type.recentFont1", + "type.recentFont2", + "type.recentFont3", + "type.recentFont4", + "type.recentFont5", + "type.recentFont6", + "type.recentFont7", + "type.recentFont8", + "type.recentFont9", + "type.recentFont10", + "type.recentFont11", + "type.recentFont12", + "type.recentFont13", + "type.recentFont14", + "type.recentFont15", +]; diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs new file mode 100644 index 000000000..a4998e993 --- /dev/null +++ b/crates/ui-egui/src/menus/state.rs @@ -0,0 +1,275 @@ +use super::run::normalize_panel; +use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::*; + +/// Checked state for toggle items. +pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { + if id == "app.language" { + let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); + let pref = app.session.prefs.interface_language.as_str(); + return Some(if lang.eq_ignore_ascii_case("auto") { + crate::i18n::Lang::from_code(pref).is_none() + } else { + crate::i18n::Lang::from_code(pref).is_some_and(|l| l.code().eq_ignore_ascii_case(lang)) + }); + } + let v = &app.ui.view; + // An item that can be on or off is `Some` whatever the document and selection (off with none): + // the macOS menu bar can't change an item's kind without rebuilding. + // The first type object (of type on a path, with `onpath`) among the selected objects and the + // objects inside them, in the order the type commands act on them. + let selected_text = |onpath: bool| { + let st = app.session.active()?; + let mut found = None; + for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| { + if found.is_none() + && let vectorcraft_engine::doc::NodeKind::Text(t) = &c.kind + && (!onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. })) + { + found = Some(t); + } + }); + if found.is_some() { + break; + } + } + found + }; + Some(match id { + "type.orientation.vertical" | "type.orientation.horizontal" => { + selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical")) + } + // Checked when the first type object `selected_text` finds has it on. + "type.opticalMarginAlignment" => selected_text(false).is_some_and(|t| t.optical_margin), + "view.outline" => v.outline, + "view.pixelPreview" => v.pixel_preview, + "view.trimView" => v.trim_view, + "view.snapToPixel" => v.snap_to_pixel, + "view.snapToGlyph" => v.snap_to_glyph, + // Type on a Path effect of the selected path type. + "type.pathOptions" if p.get("effect").is_some() => { + selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())) + } + "view.smartGuides" => v.smart_guides, + "view.grid" => v.grid, + "view.snapToGrid" => v.snap_to_grid, + "view.snapToPoint" => v.snap_to_point, + "view.rulers" => v.rulers, + // A View › Rotate View item is checked when the active view is turned to its angle; without + // a document no item is checked. + "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), + "window.control" => app.ui.control_bar, + "window.toolbar" => app.ui.toolbar, + "window.toolbarAdvanced" => app.ui.toolbar_advanced, + "window.toolbarColumns" => app.ui.toolbar_double, + "window.editToolbar" => app.ui.tools_drawer, + "window.floatTools" => { + let tool = p.get("tool").and_then(Value::as_str).unwrap_or(""); + app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool)) + } + "window.taskBar" => app.ui.task_bar, + "window.taskBar.pin" => app.ui.task_bar_place.pinned, + "window.panel" => { + let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); + normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) + } + "window.collapseDock" => crate::panel_docking::dock_collapsed(&app.ui), + "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), + "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), + "view.slices.lock" => app.session.slices_locked(), + "object.slice.clipToArtboard" => app.session.active().is_some_and(|d| d.doc.slices_clip_to_artboard), + "view.proofColors" => vectorcraft_render::proof::view().proof_colors, + "view.overprintPreview" => vectorcraft_render::proof::view().overprint, + "view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()), + "perspective.grid.snap" => perspective_grid(app).is_some_and(|g| g.snap), + "perspective.grid.lockStation" => perspective_grid(app).is_some_and(|g| g.lock_station), + "file.documentColorMode" | "object.convertDocumentColorMode" => { + let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk); + p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" }) + } + // The ruler context menu lists the document units, the current one checked. + "document.setUnits" => { + p.get("units").and_then(Value::as_str).and_then(vectorcraft_engine::doc::Unit::named) == Some(app.session.general_unit()) + } + _ => return None, + }) +} + +/// Menu slots that are left out while they have nothing to show (unused saved views, recent files, +/// User Defined swatch and graphic style libraries). +pub(super) fn hidden_when_disabled(id: &str) -> bool { + id.starts_with("view.goto") + || saved_selection_slot(id).is_some() + || id.starts_with("file.openRecent") + || id.starts_with(crate::panels::swatches::USER_SLOT) + || id.starts_with(crate::panels::graphic_styles::USER_SLOT) + || id.starts_with(crate::panels::symbols::USER_SLOT) + || id.starts_with(crate::panels::brushes::USER_SLOT) + || id.starts_with(crate::dialogs::perspective_presets::SLOT) +} + +/// Menu items another item stands in for right now: Envelope Distort's Reset with Warp and Reset +/// with Mesh take the place of Make with Warp and Make with Mesh while an envelope is selected. +fn swapped_out(app: &VectorcraftApp, id: &str) -> bool { + let reset = match id { + "object.envelope.makeWithWarp" | "object.envelope.makeWithMesh" => false, + "object.envelope.resetWithWarp" | "object.envelope.resetWithMesh" => true, + _ => return false, + }; + enabled(app, "object.envelope.release") != reset +} + +/// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on +/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item +/// another stands in for ([`swapped_out`]). +pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option)> { + let en = enabled(app, id); + if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { + return None; + } + Some((en, checked(app, id, p))) +} + +/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins +/// list them (the command palette's cache follows it). +pub fn plugin_revision() -> u64 { + vectorcraft_plugins::registry::revision() +} + +/// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default. +pub fn shortcut_of(id: &str) -> Option<&'static str> { + crate::shortcut_editor::command_shortcut(id) +} + +/// Is a command currently enabled? +/// Commands that would act on the text being typed, held back while an IME composes in the Type +/// tool (its marked text isn't committed yet): Undo/Redo — the native menu takes ⌘Z ahead of the +/// IME —, the clipboard and the selection. +/// Clear History also waits: it ends the typing session as Undo does. +pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool { + app.session.tool_composing() + && (matches!( + id, + "edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "select.all" | "select.none" | "select.inverse" + ) || id == "edit.clearHistory" + || id.starts_with("edit.paste")) +} + +/// Engine commands that take `ids` and otherwise act on the Appearance panel's subject (the +/// targeted layer, group or object, else the selection). Without `ids` and without a subject they +/// are off, as the panel's buttons are: in menus, in the command palette and on their keys. +pub(super) const SUBJECT_COMMANDS: [&str; 2] = ["appearance.addFill", "appearance.addStroke"]; + +/// Is there an Appearance panel subject: a targeted layer, group or object, or a selected one? +pub(super) fn has_subject(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| !st.selection.subjects().is_empty()) +} + +/// Can command `id` run now? An engine command asks the engine, a UI command the app's state; +/// none that would act on text an input method is still composing. +pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { + if waits_for_ime(app, id) { + return false; + } + if SUBJECT_COMMANDS.contains(&id) { + return has_subject(app); + } + if let Some(c) = vectorcraft_engine::find_command(id) { + // The system clipboard's contents can be pasted with an empty internal clipboard. + return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste"); + } + match id { + // Save is off for a clean document that already has its own file. + "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()), + "type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(), + id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(), + "type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(), + id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()), + "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), + "file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()), + "plugin.cancelRuns" => crate::plugin_runs::any_running(app), + "plugin.hideRuns" => crate::plugin_runs::any_shown(app), + "file.place" + | "file.export.svg" + | "file.export.png" + | "file.exportAs" + | "file.exportForScreens" + | "file.documentSetup" + | "view.zoomIn" + | "view.zoomOut" + | "view.fitArtboard" + | "view.fitAll" + | "view.actualSize" + | "view.rotate" => app.session.active().is_some(), + "view.rotateToSelection" => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), + id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), + "file.clearRecent" => !app.ui.recent_files.is_empty(), + id if id.starts_with("type.recentFont") => { + id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.recent_fonts().len()) && app.session.active().is_some() + } + id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).is_some(), + id if saved_view_slot(id).is_some() => saved_view(app, id).is_some(), + "effect.dialog" | "ui.recolorDialog" | "ui.effectGalleryDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.cropImage" => selected_image(app, |_| true), + "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), + "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), + "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), + "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), + "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), + "ui.graphicStyleOptions" => app.session.active().is_some(), + "ui.newArtboard" => app.session.active().is_some(), + "ui.sampleScreenColor" => crate::screen_color::available(app), + "ui.newBrush" => app.session.active().is_some(), + "ui.brushOptions" => crate::dialogs::brush_options::available(app), + "ui.hyphenation" | "ui.justification" => crate::panels::character::text_style(app).is_some(), + "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.saveSwatchLibrary" => app.session.active().is_some(), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), + "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.saveGraphicStyleLibrary" => app.session.active().is_some(), + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), + "ui.saveSymbolLibrary" => app.session.active().is_some_and(|d| !d.doc.symbols.is_empty()), + id if id.starts_with(crate::panels::symbols::USER_SLOT) => crate::panels::symbols::user_library(app, id).is_some(), + "ui.saveBrushLibrary" => crate::panels::brushes::has_brushes(app), + id if id.starts_with(crate::panels::brushes::USER_SLOT) => crate::panels::brushes::user_library(app, id).is_some(), + "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.spotColors" => app.session.active().is_some(), + "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.simplifyAuto" | "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::showing(app), + "ui.dxfOptionsDialog" => app.session.active().is_some(), + "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), + "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), + "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), + "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), + "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), + "ui.epsOptionsDialog" => app.session.active().is_some(), + "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), + "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "css.copy" | "css.exportFile" => app.session.active().is_some(), + "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), + "plugin.dialog" => app.session.active().is_some(), + "ui.perspectiveGridDialog" | "ui.savePerspectivePreset" => app.session.active().is_some(), + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() + } + "ui.perspectivePlane" => app.session.active().is_some(), + // Only one window per document so far: greyed out, as Window › Arrange's items are (#3724). + "window.newWindow" => false, + _ => true, + } +} + +/// Is a placed document selected? +fn selected_placed(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| { + st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::PlacedDocument(_)))) + }) +} + +/// Is an image `keep` accepts selected? +pub(super) fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool { + app.session.active().is_some_and(|st| { + st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(&n.kind, vectorcraft_doc::NodeKind::Image(im) if keep(im)))) + }) +} diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs new file mode 100644 index 000000000..56544260c --- /dev/null +++ b/crates/ui-egui/src/menus/submenus.rs @@ -0,0 +1,210 @@ +use super::*; + +/// Type → Size presets. +pub(super) const TYPE_SIZES: [(&str, u32); 14] = [ + ("6 pt", 6), + ("8 pt", 8), + ("9 pt", 9), + ("10 pt", 10), + ("11 pt", 11), + ("12 pt", 12), + ("14 pt", 14), + ("18 pt", 18), + ("24 pt", 24), + ("30 pt", 30), + ("36 pt", 36), + ("48 pt", 48), + ("60 pt", 60), + ("72 pt", 72), +]; + +/// Type → Type on a Path effects (label, `type.pathOptions` effect), also the choices of its +/// Options dialog. +pub(crate) const PATH_EFFECTS: &[(&str, &str)] = + &[("Rainbow", "rainbow"), ("Skew", "skew"), ("3D Ribbon", "3dRibbon"), ("Stair Step", "stairStep"), ("Gravity", "gravity")]; + +pub(super) const INSERT_SPECIAL: &[(&str, &str)] = &[ + ("Bullet", "bullet"), + ("Copyright Symbol", "copyright"), + ("Ellipsis", "ellipsis"), + ("Paragraph Symbol", "paragraph"), + ("Registered Trademark Symbol", "registered"), + ("Section Symbol", "section"), + ("Trademark Symbol", "trademark"), + ("Em Dash", "emDash"), + ("En Dash", "enDash"), + ("Discretionary Hyphen", "discretionaryHyphen"), + ("Nonbreaking Hyphen", "nonBreakingHyphen"), + ("Double Left Quotation Marks", "doubleLeftQuote"), + ("Double Right Quotation Marks", "doubleRightQuote"), + ("Single Left Quotation Mark", "singleLeftQuote"), + ("Single Right Quotation Mark", "singleRightQuote"), +]; + +pub(super) const INSERT_WHITESPACE: &[(&str, &str)] = &[ + ("Em Space", "emSpace"), + ("En Space", "enSpace"), + ("Hair Space", "hairSpace"), + ("Sixth Space", "sixthSpace"), + ("Thin Space", "thinSpace"), + ("Nonbreaking Space", "nonBreakingSpace"), + ("Figure Space", "figureSpace"), + ("Punctuation Space", "punctuationSpace"), + ("Third Space", "thirdSpace"), + ("Quarter Space", "quarterSpace"), +]; + +pub(super) const INSERT_BREAK: &[(&str, &str)] = &[("Forced Line Break", "forcedLineBreak"), ("Tab", "tab")]; + +pub(super) fn insert_items(list: &[(&'static str, &'static str)]) -> Vec { + list.iter().map(|(l, c)| cp(l, "type.insert", json!({ "char": c }))).collect() +} + +/// Type → Font: one item per available family, the installed fonts included. Built again only when +/// the fonts or Show Font Names in English change (the menu tree is built every frame); labels are +/// interned once each, so rebuilding doesn't allocate forever. The command still sets the English +/// family name ([`vectorcraft_text::FontDb::canonical`]). +pub(super) fn font_items(english_names: bool) -> Vec { + static NAMES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeSet::new()); + static ITEMS: std::sync::Mutex<(u64, bool, Vec)> = std::sync::Mutex::new((u64::MAX, true, Vec::new())); + let db = vectorcraft_text::FontDb::global(); + // Read first: fonts that load meanwhile make the next frame build the list again. + let generation = db.generation(); + let fams = db.menu_family_list(); + let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] }; + if items.0 != generation || items.1 != english_names { + let list = fams + .iter() + .map(|f| { + let english = f.as_str(); + let shown = db.family_display_name(english, english_names); + let label: &'static str = match names.get(shown.as_str()) { + Some(n) => n, + None => { + let n: &'static str = Box::leak(shown.into_boxed_str()); + names.insert(n); + n + } + }; + let font: &'static str = match names.get(english) { + Some(n) => n, + None => { + let n: &'static str = Box::leak(english.to_string().into_boxed_str()); + names.insert(n); + n + } + }; + cp(label, "text.setStyle", json!({ "font": font })) + }) + .collect(); + *items = (generation, english_names, list); + } + items.2.clone() +} + +/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the +/// vector effects): (submenu, the catalogue's menu path of its effects). +/// The raster Stylize submenu shares its path with the vector one and lists only the effects at +/// that path that [`vectorcraft_effects::PIXEL_EFFECTS`] names. +const RASTER_MENUS: [(&str, &[&str]); 10] = [ + ("Artistic", &["Effect", "Artistic"]), + ("Blur", &["Effect", "Blur"]), + ("Brush Strokes", &["Effect", "Brush Strokes"]), + ("Distort", &["Effect", "Distort"]), + ("Pixelate", &["Effect", "Pixelate"]), + ("Sharpen", &["Effect", "Sharpen"]), + ("Sketch", &["Effect", "Sketch"]), + ("Stylize", &["Effect", "Stylize"]), + ("Texture", &["Effect", "Texture"]), + ("Video", &["Effect", "Video"]), +]; + +/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects. +pub(super) fn effect_menu() -> Vec { + let cat = vectorcraft_effects::effect_catalog(); + let mut out = vec![ + c("Apply Last Effect", "effect.applyLast"), + c("Last Effect…", "effect.last"), + Sep, + c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), + Sep, + Item::Header("Vector Effects"), + ]; + // Submenus (and Crop Marks and Rasterize…, items of their own) in the reference app's order. + let order = [ + "3D and Materials", + "Color Adjustments", + "Convert to Shape", + "Crop Marks", + "Distort & Transform", + "Path", + "Pathfinder", + "Rasterize…", + "Stylize", + "SVG Filters", + "Warp", + ]; + let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label); + // An effect goes in the vector submenu its path names unless it is a Photoshop-style effect, + // which goes in the raster submenu of the same name when `RASTER_MENUS` has one (Stylize › + // Glowing Edges). + let in_vector_menu = + |e: &vectorcraft_effects::EffectInfo| order.iter().any(|s| e.menu == ["Effect", *s]) && !vectorcraft_effects::PIXEL_EFFECTS.contains(&e.id); + let item = |e: &vectorcraft_effects::EffectInfo| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + // No options (Effect → Pathfinder, Crop Marks): apply directly, like Illustrator. + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }; + // The effects whose catalogue menu path is `path`. + // `vector` picks the ones for the vector submenu or the ones for the raster submenu. + let items_at = + |path: &[&str], vector: bool| -> Vec { cat.iter().filter(|e| e.menu == path && in_vector_menu(e) == vector).map(item).collect() }; + for sub_name in order { + if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { + out.push(item(e)); + continue; + } + let items = items_at(&["Effect", sub_name], true); + if items.is_empty() { + let placeholder = match sub_name { + "3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")], + "SVG Filters" => vec![todo("Apply SVG Filter…")], + _ => continue, + }; + out.push(sub(sub_name, placeholder)); + } else { + let mut items = items; + if sub_name == "3D and Materials" { + // In the panel's order of 3D types, then Materials (not built yet). + let rank = |label: &str| ["Extrude & Bevel…", "Revolve…", "Inflate…", "Rotate…"].iter().position(|l| *l == label).unwrap_or(9); + items.sort_by_key(|i| match i { + Item::Cmd(label, ..) => rank(label), + _ => 9, + }); + items.push(todo("Materials…")); + } + out.push(sub(sub_name, items)); + } + } + // Live-effect plug-ins close the vector effects. + out.extend(crate::dialogs::plugin::effect_menu()); + let raster: Vec = RASTER_MENUS + .iter() + .filter_map(|(name, path)| { + let items = items_at(path, false); + (!items.is_empty()).then(|| sub(name, items)) + }) + .collect(); + if !raster.is_empty() { + out.push(Sep); + out.push(Item::Header("Raster Effects")); + out.push(c("Effect Gallery…", "ui.effectGalleryDialog")); + out.extend(raster); + } + // Anything not placed above (future effects) still shows up. + let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path); + for e in cat.iter().filter(|e| !placed(e)) { + out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); + } + out +} diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs new file mode 100644 index 000000000..d806eac4a --- /dev/null +++ b/crates/ui-egui/src/menus/tree.rs @@ -0,0 +1,711 @@ +use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS}; +use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items}; +use super::*; + +/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see +/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`). +/// `english_names` is Preferences › Type › Show Font Names in English (the Type → Font labels +/// follow it; tests use the default `true`). +pub fn menu_tree() -> Vec<(&'static str, Vec)> { + menu_tree_named(true) +} + +pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { + let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id, "toggle": true })); + vec![ + ( + // The application menu: About and Discord only. The in-window bar never shows it (File + // is first there, as in Illustrator); on macOS `native_menu::mac_layout` composes the + // App menu from this entry and the other menus (About from Help, Settings from Edit, + // Quit from File, Language and Appearance synthesized). It stays first: `from_tree` + // gives the first menu the App role. + "VectorCraft", + vec![c("About VectorCraft", "help.about"), c("Join Our Discord", "help.discord")], + ), + ( + "File", + vec![ + c("New…", "file.newDialog"), + c("New from Template…", "file.newFromTemplate"), + c("Open…", "file.open"), + sub("Open Recent Files", { + let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); + v.push(Sep); + v.push(c("Clear Recent Files", "file.clearRecent")); + v + }), + c("Show in Folder", "file.reveal"), + Sep, + c("Close", "file.close"), + c("Close All", "file.closeAll"), + c("Save", "file.save"), + c("Save As…", "file.saveAs"), + c("Save a Copy…", "file.saveCopy"), + c("Save as Template…", "file.saveAsTemplate"), + c("Save as PDF…", "file.export.pdf"), + c("Save for Office Documents…", "document.exportForOffice"), + c("Revert", "file.revert"), + Sep, + c("Place…", "file.place"), + Sep, + sub( + "Export", + vec![ + c("Export for Screens…", "file.exportForScreens"), + c("Export As…", "file.exportAs"), + c("Export As SVG…", "file.export.svg"), + c("Export As PNG…", "file.export.png"), + c("Save for Web (Legacy)…", "file.saveForWeb"), + ], + ), + c("Export Selection…", "file.exportSelection"), + Sep, + c("Package…", "ui.packageDialog"), + sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]), + Sep, + c("Document Setup…", "file.documentSetup"), + sub( + "Document Color Mode", + vec![ + cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})), + cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})), + ], + ), + c("File Info…", "file.info"), + Sep, + c("Print…", "file.print"), + Sep, + c("Exit", "app.quit"), + ], + ), + ( + "Edit", + vec![ + c("Undo", "edit.undo"), + c("Redo", "edit.redo"), + Sep, + c("Cut", "edit.cut"), + c("Copy", "edit.copy"), + c("Paste", "edit.paste"), + c("Paste in Front", "edit.pasteInFront"), + c("Paste in Back", "edit.pasteInBack"), + c("Paste in Place", "edit.pasteInPlace"), + c("Paste on All Artboards", "edit.pasteOnAllArtboards"), + c("Paste without Formatting", "edit.pasteWithoutFormatting"), + c("Clear", "edit.clear"), + Sep, + cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})), + cp("Find Next", "edit.findNext", json!({"find": ""})), + sub("Spelling", vec![todo("Auto Spell Check"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")]), + Sep, + sub( + "Edit Colors", + vec![ + c("Recolor Artwork…", "ui.recolorDialog"), + sub( + "Recolor with Preset", + vec![ + cp("1 Color Job…", "ui.recolorDialog", json!({"colors": 1})), + cp("2 Color Job…", "ui.recolorDialog", json!({"colors": 2})), + cp("3 Color Job…", "ui.recolorDialog", json!({"colors": 3})), + cp("Color Library…", "ui.recolorDialog", json!({"library": ""})), + ], + ), + c("Adjust Color Balance…", "ui.colorBalanceDialog"), + c("Blend Front to Back", "edit.colors.blendFrontToBack"), + c("Blend Horizontally", "edit.colors.blendHorizontally"), + c("Blend Vertically", "edit.colors.blendVertically"), + c("Convert to CMYK", "edit.colors.toCMYK"), + c("Convert to Grayscale", "edit.colors.toGrayscale"), + c("Convert to RGB", "edit.colors.toRGB"), + c("Invert Colors", "edit.colors.invert"), + cp( + "Overprint Black…", + "edit.colors.overprintBlack", + json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}), + ), + c("Saturate…", "ui.saturateDialog"), + ], + ), + c("Edit Original", "links.editOriginal"), + Sep, + c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), + c("Print Presets…", "ui.printPresetsDialog"), + c("PDF Presets…", "ui.pdfPresetsDialog"), + c("Perspective Grid Presets…", "ui.perspectivePresetsDialog"), + Sep, + c("Color Settings…", "edit.colorSettings"), + c("Assign Profile…", "edit.assignProfile"), + Sep, + c("Keyboard Shortcuts…", "edit.keyboardShortcuts"), + c("Preferences…", "edit.preferences"), + ], + ), + ( + "Object", + vec![ + sub("Transform", transform_items()), + sub("Arrange", arrange_items()), + sub( + "Align", + vec![ + cp("Horizontal Align Left", "object.align", json!({"horizontal": "left"})), + cp("Horizontal Align Center", "object.align", json!({"horizontal": "center"})), + cp("Horizontal Align Right", "object.align", json!({"horizontal": "right"})), + Sep, + cp("Horizontal & Vertical Align Center", "object.align", json!({"horizontal": "center", "vertical": "center"})), + Sep, + cp("Vertical Align Top", "object.align", json!({"vertical": "top"})), + cp("Vertical Align Center", "object.align", json!({"vertical": "center"})), + cp("Vertical Align Bottom", "object.align", json!({"vertical": "bottom"})), + ], + ), + Sep, + c("Group", "object.group"), + c("Ungroup", "object.ungroup"), + sub( + "Lock", + vec![c("Selection", "object.lock"), c("All Artwork Above", "object.lock.above"), c("Other Layers", "object.lock.otherLayers")], + ), + c("Unlock All", "object.unlockAll"), + sub( + "Hide", + vec![c("Selection", "object.hide"), c("All Artwork Above", "object.hide.above"), c("Other Layers", "object.hide.otherLayers")], + ), + c("Show All", "object.showAll"), + Sep, + c("Expand…", "ui.expandDialog"), + c("Expand Appearance", "effect.expandAppearance"), + c("Crop Image", "ui.cropImage"), + c("Rasterize…", "object.rasterize"), + cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), + cp( + "Create Object Mosaic…", + "object.createObjectMosaic", + json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}), + ), + c("Vector Halftone…", "object.vectorHalftone"), + c("Create Trim Marks", "object.createTrimMarks"), + c("Flatten Transparency…", "ui.flattenTransparencyDialog"), + Sep, + c("Make Pixel Perfect", "object.makePixelPerfect"), + Sep, + sub( + "Slice", + vec![ + c("Make", "object.slice.make"), + c("Release", "object.slice.release"), + c("Create from Guides", "object.slice.fromGuides"), + c("Create from Selection", "object.slice.fromSelection"), + Sep, + c("Duplicate Slice", "object.slice.duplicate"), + c("Combine Slices", "object.slice.combine"), + c("Divide Slices…", "object.slice.divide"), + Sep, + c("Delete All", "object.slice.deleteAll"), + c("Slice Options…", "object.slice.options"), + c("Clip to Artboard", "object.slice.clipToArtboard"), + ], + ), + Sep, + sub( + "Path", + vec![ + c("Join", "path.join"), + c("Average…", "path.average"), + Sep, + c("Outline Stroke", "object.path.outlineStroke"), + c("Offset Path…", "object.path.offsetPath"), + c("Reverse Path Direction", "path.reverse"), + Sep, + c("Simplify…", "object.path.simplify"), + c("Add Anchor Points", "object.path.addAnchorPoints"), + c("Remove Anchor Points", "path.removeAnchors"), + c("Divide Objects Below", "object.path.divideObjectsBelow"), + c("Split Into Grid…", "object.path.splitIntoGrid"), + Sep, + cp("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true, "unpaintedObjects": true, "emptyTextPaths": true})), + ], + ), + sub("Shape", vec![c("Convert to Shape", "object.shape.convertToShape"), c("Expand Shape", "object.expandShape")]), + sub( + "Pattern", + vec![c("Make", "object.pattern.make"), c("Edit Pattern", "object.pattern.edit"), c("Tile Edge Color…", "ui.tileEdgeColor")], + ), + sub( + "Repeat", + vec![ + c("Radial", "object.repeat.radial"), + c("Grid", "object.repeat.grid"), + c("Mirror", "object.repeat.mirror"), + Sep, + c("Release", "object.repeat.release"), + c("Options…", "object.repeat.options"), + ], + ), + sub( + "Intertwine", + vec![c("Make", "object.intertwine.make"), c("Edit", "object.intertwine.edit"), c("Release", "object.intertwine.release")], + ), + sub("Objects on Path", vec![c("Attach", "object.objectsOnPath.attach"), c("Expand", "object.objectsOnPath.expand")]), + sub( + "Blend", + vec![ + c("Make", "object.blend.make"), + c("Release", "object.blend.release"), + Sep, + c("Blend Options…", "object.blend.options"), + Sep, + c("Expand", "object.blend.expand"), + Sep, + c("Replace Spine", "object.blend.replaceSpine"), + c("Reverse Spine", "object.blend.reverseSpine"), + c("Reverse Front to Back", "object.blend.reverseFrontToBack"), + ], + ), + sub( + "Envelope Distort", + vec![ + // While an envelope is selected, Reset takes Make's place (`swapped_out`). + c("Make with Warp…", "object.envelope.makeWithWarp"), + c("Reset with Warp…", "object.envelope.resetWithWarp"), + c("Make with Mesh…", "object.envelope.makeWithMesh"), + c("Reset with Mesh…", "object.envelope.resetWithMesh"), + c("Make with Top Object", "object.envelope.makeWithTopObject"), + Sep, + c("Release", "object.envelope.release"), + c("Envelope Options…", "object.envelope.options"), + c("Expand", "object.envelope.expand"), + Sep, + c("Edit Contents", "object.envelope.editContents"), + ], + ), + sub( + "Perspective", + vec![ + c("Attach to Active Plane", "perspective.attach"), + c("Release with Perspective", "perspective.release"), + c("Move Plane to Match Object", "perspective.plane.matchObject"), + c("Edit Text", "perspective.editText"), + ], + ), + sub( + "Live Paint", + vec![ + c("Make", "livePaint.make"), + c("Merge", "livePaint.merge"), + c("Release", "livePaint.release"), + Sep, + c("Gap Options…", "ui.livePaintGapOptions"), + Sep, + c("Expand", "livePaint.expand"), + ], + ), + sub( + "Image Trace", + vec![ + // Traced at once with the Default preset, as the Control bar's Image Trace + // button does; other presets come from its arrow or the Image Trace panel + // (#543: the "…" form asked for a preset's name in a text box). + cp("Make", "imageTrace.make", json!({"preset": "Default"})), + cp("Make and Expand", "imageTrace.makeAndExpand", json!({"preset": "Default"})), + c("Release", "imageTrace.release"), + c("Expand", "imageTrace.expand"), + ], + ), + sub( + "Text Wrap", + vec![ + c("Make", "object.textWrap.make"), + c("Release", "object.textWrap.release"), + c("Text Wrap Options…", "object.textWrap.options"), + ], + ), + Sep, + sub( + "Clipping Mask", + vec![ + c("Make", "object.clippingMask.make"), + c("Release", "object.clippingMask.release"), + c("Edit Contents", "object.clippingMask.editContents"), + c("Edit Clipping Path", "object.clippingMask.editMask"), + ], + ), + sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]), + sub( + "Artboards", + vec![ + c("Convert to Artboards", "artboard.convertToArtboards"), + c("Rearrange All Artboards…", "artboard.rearrange"), + Sep, + c("Fit to Artwork Bounds", "artboard.fitToArt"), + c("Fit to Selected Art", "artboard.fitToSelection"), + ], + ), + sub( + "Graph", + vec![ + c("Type…", "graph.setType"), + c("Data…", "graph.setData"), + c("Design…", "graph.design"), + c("Column…", "graph.column"), + c("Marker…", "graph.marker"), + ], + ), + sub( + "Collect for Export", + vec![ + cp("As Single Asset", "assets.add", json!({"multiple": false})), + cp("As Multiple Assets", "assets.add", json!({"multiple": true})), + ], + ), + Sep, + sub("Plug-ins", crate::dialogs::plugin::object_menu()), + ], + ), + ( + "Type", + vec![ + sub("Font", font_items(english_names)), + sub("Recent Fonts", RECENT_FONT_IDS.iter().map(|id| c("Recent Font", id)).collect()), + sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()), + c("Bold", "type.bold"), + c("Italic", "type.italic"), + Sep, + panel("Glyphs", "glyphs"), + sub("Insert Special Character", insert_items(INSERT_SPECIAL)), + sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)), + sub("Insert Break Character", insert_items(INSERT_BREAK)), + Sep, + c("Convert To Area Type", "type.convertToAreaType"), + c("Convert To Point Type", "type.convertToPointType"), + c("Area Type Options…", "text.areaOptions"), + sub( + "Type on a Path", + PATH_EFFECTS + .iter() + .map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect }))) + .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) + .collect(), + ), + sub( + "Threaded Text", + vec![ + c("Create", "text.thread.create"), + c("Release Selection", "text.thread.releaseSelection"), + c("Remove Threading", "text.thread.remove"), + ], + ), + c("Fit Headline", "text.fitHeadline"), + Sep, + c("Find Font…", "type.findFont"), + sub( + "Change Case", + vec![ + cp("UPPERCASE", "type.changeCase", json!({"case": "upper"})), + cp("lowercase", "type.changeCase", json!({"case": "lower"})), + cp("Title Case", "type.changeCase", json!({"case": "title"})), + cp("Sentence case", "type.changeCase", json!({"case": "sentence"})), + ], + ), + cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), + sub( + "Bullets and Numbering", + vec![ + c("Apply Bullets", "type.applyBullets"), + c("Apply Numbers", "type.applyNumbers"), + c("Convert to Text", "type.convertListToText"), + ], + ), + Sep, + c("Create Outlines", "type.createOutlines"), + c("Scale Text Attributes", "type.scaleTextAttributes"), + c("Optical Margin Alignment", "type.opticalMarginAlignment"), + Sep, + c("Fill with Placeholder Text", "type.fillPlaceholder"), + c("Insert Inline Symbol", "text.insertInline"), + Sep, + c("Show Hidden Characters", "type.hiddenCharacters"), + sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]), + ], + ), + ("Select", { + let mut v = vec![ + c("All", "select.all"), + c("All on Active Artboard", "select.allOnArtboard"), + c("Deselect", "select.none"), + c("Reselect", "select.reselect"), + Sep, + c("Inverse", "select.inverse"), + Sep, + c("Next Object Above", "select.nextAbove"), + c("Next Object Below", "select.nextBelow"), + Sep, + sub( + "Same", + vec![ + c("Appearance", "select.same.appearance"), + c("Appearance Attribute", "select.same.appearanceAttribute"), + c("Blending Mode", "select.same.blendingMode"), + c("Fill & Stroke", "select.same.fillAndStroke"), + c("Fill Color", "select.same.fillColor"), + c("Opacity", "select.same.opacity"), + c("Stroke Color", "select.same.strokeColor"), + c("Stroke Weight", "select.same.strokeWeight"), + c("Graphic Style", "select.same.graphicStyle"), + c("Shape", "select.same.shapeType"), + c("Symbol Instance", "select.same.symbolInstance"), + Sep, + c("Font Family", "select.same.fontFamily"), + c("Font Family & Style", "select.same.fontFamilyStyle"), + c("Font Family, Style & Size", "select.same.fontFamilyStyleSize"), + c("Font Size", "select.same.fontSize"), + c("Text Fill Color", "select.same.textFillColor"), + c("Text Stroke Color", "select.same.textStrokeColor"), + ], + ), + sub( + "Object", + vec![ + c("All on Same Layers", "select.object.allOnSameLayers"), + c("Direction Handles", "select.object.directionHandles"), + Sep, + c("Brush Strokes", "select.object.brushStrokes"), + c("Bristle Brush Strokes", "select.object.bristleBrushStrokes"), + c("Clipping Masks", "select.object.clippingMasks"), + c("Slices", "select.object.slices"), + c("Stray Points", "select.object.strayPoints"), + c("Open Paths", "select.object.openPaths"), + Sep, + c("All Text Objects", "select.object.textObjects"), + c("Point Text Objects", "select.object.pointText"), + c("Area Text Objects", "select.object.areaText"), + ], + ), + c("Start Global Edit", "select.globalEdit"), + Sep, + c("Save Selection…", "select.save"), + c("Update Selection", "select.update"), + c("Edit Selection…", "select.editSaved"), + Sep, + ]; + v.extend(SAVED_SELECTION_IDS.iter().map(|id| c("Saved Selection", id))); + v + }), + ("Effect", effect_menu()), + ("View", { + let mut v = vec![ + c("Outline", "view.outline"), + c("Overprint Preview", "view.overprintPreview"), + c("Pixel Preview", "view.pixelPreview"), + c("Trim View", "view.trimView"), + c("Presentation Mode", "view.presentation"), + sub( + "Screen Mode", + vec![ + cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), + cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), + cp("Full Screen Mode", "view.screenMode", json!({"mode": 2})), + ], + ), + Sep, + sub( + "Proof Setup", + vec![ + cp("Working CMYK", "view.proofSetup", json!({"target": "workingCmyk", "proof": true})), + cp("Legacy Macintosh RGB (Gamma 1.8)", "view.proofSetup", json!({"target": "legacyMacRgb", "proof": true})), + cp("Internet Standard RGB (sRGB)", "view.proofSetup", json!({"target": "srgb", "proof": true})), + cp("Monitor RGB", "view.proofSetup", json!({"target": "monitorRgb", "proof": true})), + cp("Color blindness – Protanopia-type", "view.proofSetup", json!({"target": "protanopia", "proof": true})), + cp("Color blindness – Deuteranopia-type", "view.proofSetup", json!({"target": "deuteranopia", "proof": true})), + cp("Color blindness – Tritanopia-type", "view.proofSetup", json!({"target": "tritanopia", "proof": true})), + cp("Customize…", "window.panel", json!({"panel": "separations"})), + ], + ), + c("Proof Colors", "view.proofColors"), + Sep, + c("Zoom In", "view.zoomIn"), + c("Zoom Out", "view.zoomOut"), + c("Fit Artboard in Window", "view.fitArtboard"), + c("Fit All in Window", "view.fitAll"), + c("Actual Size", "view.actualSize"), + Sep, + sub("Rotate View", crate::state::ROTATE_VIEW_ANGLES.iter().map(|(l, a)| cp(l, "view.rotate", json!({ "angle": a }))).collect()), + c("Rotate View to Selection", "view.rotateToSelection"), + c("Reset Rotate View", "view.rotateReset"), + Sep, + c("Hide Edges", "view.edges"), + c("Hide Artboards", "view.artboards"), + c("Show Print Tiling", "view.printTiling"), + c("Hide Slices", "view.slices.hide"), + c("Lock Slices", "view.slices.lock"), + c("Hide Template", "view.template"), + Sep, + sub( + "Rulers", + vec![ + c("Show Rulers", "view.rulers"), + c("Change to Global Rulers", "view.globalRulers"), + c("Show Video Rulers", "view.videoRulers"), + ], + ), + c("Hide Bounding Box", "view.boundingBox"), + c("Show Transparency Grid", "view.transparencyGrid"), + c("Hide Text Threads", "view.textThreads"), + c("Hide Gradient Annotator", "view.gradientAnnotator"), + c("Hide Corner Widget", "view.cornerWidget"), + c("Show Live Paint Gaps", "view.livePaintGaps"), + Sep, + sub( + "Guides", + vec![ + c("Hide Guides", "view.guides"), + c("Lock Guides", "view.guides.lock"), + c("Make Guides", "view.guides.make"), + c("Release Guides", "view.guides.release"), + c("Clear Guides", "view.guides.clear"), + ], + ), + c("Smart Guides", "view.smartGuides"), + sub( + "Perspective Grid", + vec![ + c("Show Grid", "perspective.grid.show"), + c("Show Rulers", "perspective.grid.rulers"), + c("Snap to Grid", "perspective.grid.snap"), + c("Lock Grid", "perspective.grid.lock"), + c("Lock Station Point", "perspective.grid.lockStation"), + Sep, + c("Define Grid…", "ui.perspectiveGridDialog"), + sub("One Point Perspective", crate::dialogs::perspective_presets::menu(1, PERSPECTIVE_SLOTS[0])), + sub("Two Point Perspective", crate::dialogs::perspective_presets::menu(2, PERSPECTIVE_SLOTS[1])), + sub("Three Point Perspective", crate::dialogs::perspective_presets::menu(3, PERSPECTIVE_SLOTS[2])), + Sep, + c("Save Grid as Preset…", "ui.savePerspectivePreset"), + ], + ), + c("Show Grid", "view.grid"), + c("Snap to Grid", "view.snapToGrid"), + c("Snap to Pixel", "view.snapToPixel"), + c("Snap to Point", "view.snapToPoint"), + c("Snap to Glyph", "view.snapToGlyph"), + Sep, + c("New View…", "view.saved.new"), + c("Edit Views…", "view.saved.edit"), + Sep, + ]; + v.extend(SAVED_VIEW_IDS.iter().map(|id| c("Saved View", id))); + v + }), + ( + "Window", + vec![ + c("New Window", "window.newWindow"), + sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]), + sub("Workspace", crate::workspaces::menu_items()), + Sep, + c("Control", "window.control"), + c("Contextual Task Bar", "window.taskBar"), + c("Tools", "window.toolbar"), + sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns")]), + Sep, + panel("Actions", "actions"), + panel("Align", "align"), + panel("Appearance", "appearance"), + panel("Artboards", "artboards"), + panel("Asset Export", crate::panels::asset_export::ID), + panel("Attributes", "attributes"), + panel("Blend", crate::panels::blend::ID), + panel("Brushes", "brushes"), + panel("Color", "color"), + panel("Color Guide", "colorGuide"), + panel("Color Themes", "colorThemes"), + panel("CSS Properties", crate::panels::css_properties::ID), + panel("Document Info", "docInfo"), + panel("Flattener Preview", crate::panels::flattener_preview::ID), + panel("Gradient", "gradient"), + panel("Graphic Styles", "graphicStyles"), + panel("History", "history"), + panel("Image Trace", "imageTrace"), + panel("Info", "info"), + panel("Layers", "layers"), + panel("Libraries", "libraries"), + panel("Links", crate::panels::links::ID), + panel("Magic Wand", "magicWand"), + panel("Navigator", "navigator"), + panel("Pathfinder", "pathfinder"), + panel("Pattern Options", "patternOptions"), + panel("Properties", "properties"), + panel("Separations Preview", "separations"), + panel("Stroke", "stroke"), + panel("SVG Interactivity", crate::panels::svg_interactivity::ID), + panel("Swatches", "swatches"), + panel("Symbols", "symbols"), + panel("Transform", "transform"), + panel("Transparency", "transparency"), + sub( + "Type", + vec![ + panel("Character", "character"), + panel("Character Styles", "charStyles"), + panel("Glyphs", "glyphs"), + panel("OpenType", "openType"), + panel("Paragraph", "paragraph"), + panel("Paragraph Styles", "paraStyles"), + panel("Tabs", "tabs"), + ], + ), + panel("Variables", crate::panels::variables::ID), + Sep, + sub("Brush Libraries", crate::panels::brushes::window_menu()), + sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), + sub("Swatch Libraries", crate::panels::swatches::window_menu()), + sub("Symbol Libraries", crate::panels::symbols::window_menu()), + ], + ), + ( + "Help", + vec![ + c("Join Our Discord", "help.discord"), + c("ArtCraft Website", "help.website"), + c("VectorCraft on getartcraft.com", "help.appPage"), + c("VectorCraft on GitHub", "help.github"), + Sep, + c("Search Commands…", "help.commandPalette"), + todos("VectorCraft Help…", "F1"), + Sep, + c("Check for Updates…", "help.checkForUpdates"), + c("About VectorCraft", "help.about"), + ], + ), + ] +} + +/// Object → Transform (also in the canvas context menu). +pub(super) fn transform_items() -> Vec { + vec![ + c("Transform Again", "object.transformAgain"), + Sep, + c("Move…", "object.move"), + c("Rotate…", "object.rotate"), + c("Reflect…", "object.reflect"), + c("Scale…", "object.scale"), + c("Shear…", "object.shear"), + Sep, + c("Transform Each…", "object.transformEach"), + Sep, + c("Reset Bounding Box", "object.resetBoundingBox"), + ] +} + +/// Object → Arrange (also in the canvas context menu). +pub(super) fn arrange_items() -> Vec { + vec![ + c("Bring to Front", "object.arrange.bringToFront"), + c("Bring Forward", "object.arrange.bringForward"), + c("Send Backward", "object.arrange.sendBackward"), + c("Send to Back", "object.arrange.sendToBack"), + Sep, + c("Send to Current Layer", "object.arrange.sendToCurrentLayer"), + ] +} diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs new file mode 100644 index 000000000..9867228b5 --- /dev/null +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -0,0 +1,1227 @@ +/// UI-level commands: (id, label, shortcut, params doc). +pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ( + "app.language", + "Interface Language", + "", + "{lang: auto|} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)", + ), + ( + "file.open", + "Open…", + "Cmd+O", + "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", + ), + ( + "file.save", + "Save", + "Cmd+S", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + ), + ( + "file.place", + "Place…", + "Cmd+Shift+P", + "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)", + ), + ("file.openRecent1", "Open Recent File 1", "", "{}"), + ("file.openRecent2", "Open Recent File 2", "", "{}"), + ("file.openRecent3", "Open Recent File 3", "", "{}"), + ("file.openRecent4", "Open Recent File 4", "", "{}"), + ("file.openRecent5", "Open Recent File 5", "", "{}"), + ("file.openRecent6", "Open Recent File 6", "", "{}"), + ("file.openRecent7", "Open Recent File 7", "", "{}"), + ("file.openRecent8", "Open Recent File 8", "", "{}"), + ("file.openRecent9", "Open Recent File 9", "", "{}"), + ("file.openRecent10", "Open Recent File 10", "", "{}"), + ("view.goto1", "Saved View 1", "", "{} go to the 1. saved view"), + ("view.goto2", "Saved View 2", "", "{} go to the 2. saved view"), + ("view.goto3", "Saved View 3", "", "{} go to the 3. saved view"), + ("view.goto4", "Saved View 4", "", "{} go to the 4. saved view"), + ("view.goto5", "Saved View 5", "", "{} go to the 5. saved view"), + ("view.goto6", "Saved View 6", "", "{} go to the 6. saved view"), + ("view.goto7", "Saved View 7", "", "{} go to the 7. saved view"), + ("view.goto8", "Saved View 8", "", "{} go to the 8. saved view"), + ("view.goto9", "Saved View 9", "", "{} go to the 9. saved view"), + ("view.goto10", "Saved View 10", "", "{} go to the 10. saved view"), + ("view.goto11", "Saved View 11", "", "{} go to the 11. saved view"), + ("view.goto12", "Saved View 12", "", "{} go to the 12. saved view"), + ("view.goto13", "Saved View 13", "", "{} go to the 13. saved view"), + ("view.goto14", "Saved View 14", "", "{} go to the 14. saved view"), + ("view.goto15", "Saved View 15", "", "{} go to the 15. saved view"), + ("view.goto16", "Saved View 16", "", "{} go to the 16. saved view"), + ("view.goto17", "Saved View 17", "", "{} go to the 17. saved view"), + ("view.goto18", "Saved View 18", "", "{} go to the 18. saved view"), + ("view.goto19", "Saved View 19", "", "{} go to the 19. saved view"), + ("view.goto20", "Saved View 20", "", "{} go to the 20. saved view"), + ("view.goto21", "Saved View 21", "", "{} go to the 21. saved view"), + ("view.goto22", "Saved View 22", "", "{} go to the 22. saved view"), + ("view.goto23", "Saved View 23", "", "{} go to the 23. saved view"), + ("view.goto24", "Saved View 24", "", "{} go to the 24. saved view"), + ("view.goto25", "Saved View 25", "", "{} go to the 25. saved view"), + ("select.recall1", "Saved Selection 1", "", "{} select the 1. saved selection"), + ("select.recall2", "Saved Selection 2", "", "{} select the 2. saved selection"), + ("select.recall3", "Saved Selection 3", "", "{} select the 3. saved selection"), + ("select.recall4", "Saved Selection 4", "", "{} select the 4. saved selection"), + ("select.recall5", "Saved Selection 5", "", "{} select the 5. saved selection"), + ("select.recall6", "Saved Selection 6", "", "{} select the 6. saved selection"), + ("select.recall7", "Saved Selection 7", "", "{} select the 7. saved selection"), + ("select.recall8", "Saved Selection 8", "", "{} select the 8. saved selection"), + ("select.recall9", "Saved Selection 9", "", "{} select the 9. saved selection"), + ("select.recall10", "Saved Selection 10", "", "{} select the 10. saved selection"), + ("select.recall11", "Saved Selection 11", "", "{} select the 11. saved selection"), + ("select.recall12", "Saved Selection 12", "", "{} select the 12. saved selection"), + ("select.recall13", "Saved Selection 13", "", "{} select the 13. saved selection"), + ("select.recall14", "Saved Selection 14", "", "{} select the 14. saved selection"), + ("select.recall15", "Saved Selection 15", "", "{} select the 15. saved selection"), + ("select.recall16", "Saved Selection 16", "", "{} select the 16. saved selection"), + ("select.recall17", "Saved Selection 17", "", "{} select the 17. saved selection"), + ("select.recall18", "Saved Selection 18", "", "{} select the 18. saved selection"), + ("select.recall19", "Saved Selection 19", "", "{} select the 19. saved selection"), + ("select.recall20", "Saved Selection 20", "", "{} select the 20. saved selection"), + ("select.recall21", "Saved Selection 21", "", "{} select the 21. saved selection"), + ("select.recall22", "Saved Selection 22", "", "{} select the 22. saved selection"), + ("select.recall23", "Saved Selection 23", "", "{} select the 23. saved selection"), + ("select.recall24", "Saved Selection 24", "", "{} select the 24. saved selection"), + ("select.recall25", "Saved Selection 25", "", "{} select the 25. saved selection"), + ("type.recentFont1", "Recent Font 1", "", "{} apply the 1. most recently used font"), + ("type.recentFont2", "Recent Font 2", "", "{} apply the 2. most recently used font"), + ("type.recentFont3", "Recent Font 3", "", "{} apply the 3. most recently used font"), + ("type.recentFont4", "Recent Font 4", "", "{} apply the 4. most recently used font"), + ("type.recentFont5", "Recent Font 5", "", "{} apply the 5. most recently used font"), + ("type.recentFont6", "Recent Font 6", "", "{} apply the 6. most recently used font"), + ("type.recentFont7", "Recent Font 7", "", "{} apply the 7. most recently used font"), + ("type.recentFont8", "Recent Font 8", "", "{} apply the 8. most recently used font"), + ("type.recentFont9", "Recent Font 9", "", "{} apply the 9. most recently used font"), + ("type.recentFont10", "Recent Font 10", "", "{} apply the 10. most recently used font"), + ("type.recentFont11", "Recent Font 11", "", "{} apply the 11. most recently used font"), + ("type.recentFont12", "Recent Font 12", "", "{} apply the 12. most recently used font"), + ("type.recentFont13", "Recent Font 13", "", "{} apply the 13. most recently used font"), + ("type.recentFont14", "Recent Font 14", "", "{} apply the 14. most recently used font"), + ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), + ("file.clearRecent", "Clear Recent Files", "", "{}"), + ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), + ( + "ui.missingFontsDialog", + "Missing Fonts Dialog", + "", + "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web", + ), + ( + "ui.findFontsInFolder", + "Find Fonts in Folder…", + "", + "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", + ), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), + ( + "file.export.svg", + "Export As SVG…", + "", + "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)", + ), + ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), + ( + "file.exportForScreens", + "Export for Screens…", + "Cmd+Alt+E", + "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})", + ), + ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), + ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), + ( + "file.newWithoutDialog", + "New Without Dialog", + "Cmd+Alt+N", + "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}", + ), + ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), + ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), + ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), + ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"), + ("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"), + ("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"), + ("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"), + ( + "shortcuts.preset", + "Keyboard Shortcut Set", + "", + "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", + ), + ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), + ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), + ( + "ui.shortcutImportDialog", + "Import Shortcut Set…", + "", + "{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \"\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too", + ), + ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), + ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), + ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), + ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), + ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ("view.snapToGlyph", "Snap to Glyph", "", "{} toggle: drawing snaps to live-text baselines and glyph bounds"), + ( + "view.preciseCursors", + "Precise Cursors", + "", + "{} toggle General › Use Precise Cursors: the drawing tools' pointers become a crosshair → true when on", + ), + ( + "imageTrace.preview", + "Image Trace Preview", + "", + "{on?: bool} turn the Image Trace panel's Preview on (true), off (false) or toggle it (omitted), as its checkbox does: on, setting changes trace the selected Image Trace object at once; off, they wait for Trace; returns the new state", + ), + ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), + ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), + ( + "view.livePaintGaps", + "Show Live Paint Gaps", + "", + "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on", + ), + ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), + ("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"), + ( + "type.alignCenter", + "Align center", + "Cmd+Shift+C", + "{} align the selected type's paragraphs (or those the Type tool's selection touches) centred", + ), + ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), + ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), + ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), + ( + "type.applyBullets", + "Apply Bullets", + "", + "{bullet?: \"dot\"|\"dash\"|\"circle\"|\"square\"|\"none\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \"bullet\"}); \"none\" turns the list off", + ), + ( + "type.applyNumbers", + "Apply Numbers", + "", + "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", + ), + ( + "type.convertListToText", + "Convert to Text", + "", + "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", + ), + ( + "align.horizontalLeft", + "Horizontal Align Left", + "", + "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalCenter", + "Horizontal Align Center", + "", + "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalRight", + "Horizontal Align Right", + "", + "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalVerticalCenter", + "Horizontal & Vertical Align Center", + "", + "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalTop", + "Vertical Align Top", + "", + "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalCenter", + "Vertical Align Center", + "", + "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalBottom", + "Vertical Align Bottom", + "", + "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.distributeVerticalTop", + "Vertical Distribute Top", + "", + "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalCenter", + "Vertical Distribute Center", + "", + "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalBottom", + "Vertical Distribute Bottom", + "", + "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalLeft", + "Horizontal Distribute Left", + "", + "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalCenter", + "Horizontal Distribute Center", + "", + "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalRight", + "Horizontal Distribute Right", + "", + "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalSpace", + "Vertical Distribute Space", + "", + "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalSpace", + "Horizontal Distribute Space", + "", + "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "type.bold", + "Bold", + "", + "{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B", + ), + ( + "type.italic", + "Italic", + "", + "{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I", + ), + ("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"), + ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), + ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), + ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), + ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), + ( + "view.actualSize", + "Actual Size", + "Cmd+1", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + ), + ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), + ( + "view.goToArtboard", + "Go to Artboard", + "", + "{index: 0-based number | \"first\" | \"previous\" | \"next\" | \"last\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}", + ), + ("view.edges", "Hide Edges", "Cmd+H", "{}"), + ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), + ( + "view.template", + "Hide Template", + "Cmd+Shift+W", + "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown", + ), + ("view.rulers", "Show Rulers", "Cmd+R", "{}"), + ( + "view.videoRulers", + "Show Video Rulers", + "", + "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show", + ), + ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), + ("view.guides", "Hide Guides", "Cmd+;", "{}"), + ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), + ("view.grid", "Show Grid", "Cmd+'", "{}"), + ("view.snapToGrid", "Snap to Grid", "Cmd+Shift+'", "{}"), + ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), + ("view.presentation", "Presentation Mode", "Shift+F", "{}"), + ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), + ( + "view.rotate", + "Rotate View", + "", + "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]", + ), + ( + "view.rotateToSelection", + "Rotate View to Selection", + "", + "{} turn the view about the center of the canvas to the angle the selected objects share (selectionRotation in document.inspect, 0 when they differ), so their bounding box is square to the window; returns {angle}", + ), + ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), + ("window.control", "Control", "", "{}"), + ("window.toolbar", "Tools", "", "{}"), + ( + "window.toolbarColumns", + "Toolbar: Single/Double Column", + "", + "{double?: bool} show the toolbar's tools in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", + ), + ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ( + "window.floatTools", + "Float Tool Group", + "", + "{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state", + ), + ( + "toolbar.show", + "Toolbar: Show Controls", + "", + "{control?: \"fillStroke\"|\"coloring\"|\"drawMode\"|\"screenMode\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state", + ), + ( + "window.editToolbar", + "Edit Toolbar", + "", + "{open?: bool} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar, as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state", + ), + ( + "toolbar.addTool", + "Toolbar: Add Tool", + "", + "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\"} put `tool` in the current (Basic or Advanced) toolbar, moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)", + ), + ( + "toolbar.removeTool", + "Toolbar: Remove Tool", + "", + "{tool: id, group?: bool} take `tool` (with `group`, its whole group) out of the current toolbar (one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots", + ), + ( + "toolbar.reset", + "Toolbar: Reset", + "", + "{} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes; returns its slots", + ), + ("window.taskBar", "Contextual Task Bar", "", "{}"), + ( + "window.taskBar.pin", + "Pin Bar Position", + "", + "{pinned?: bool} keep the Contextual Task Bar where it is instead of following the selection (true), let it follow the selection again from where it is (false) or toggle (omitted), as the bar's More Options menu does; returns the new state", + ), + ("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"), + ( + "window.dock", + "Panels", + "Tab", + "{} hide the panels, the toolbar and the Control bar, or show again the ones the last Tab hid (all three when none of them shows and Tab hid none); returns whether it showed them", + ), + ( + "window.panels", + "Panels Except Tools and Control", + "Shift+Tab", + "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", + ), + ( + "window.panel", + "Show Panel", + "", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + ), + ( + "window.panel.layout", + "Change Panel Layout", + "", + "{action} serialized docking action, including floating geometry, divider sizes and accordion state", + ), + ( + "window.panel.move", + "Move Panel", + "", + "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel, group?: bool, placement?} move a panel (with `group`, its whole group) into the group holding `anchor`: as a tab (before the `before` panel; null appends), a split beside it, or (`above` / `below`) a group of its own above or below it in its dock column or floating window; `placement` (a serialized Placement) replaces zone and before", + ), + ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), + ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), + ( + "window.panel.collapse", + "Collapse Panel Group", + "", + "{panel, collapsed?: bool} minimize the group holding a panel to its tabs (in a floating window, the window to its title bar), or restore it, as a double-click on a tab or the drag bar does; the same as window.panel.minimize {panel, minimized}. Without `collapsed` it toggles. Returns {panel, collapsed, layout}", + ), + ( + "window.panel.float", + "Float Panel", + "", + "{panel: id or \"tools\", x?, y?, width?, height?, group?: bool, column?: bool or n, onto?: id, above?: id, below?: id, collapsed?: bool} float a panel (with `group`, its whole group; with `column`, its whole dock column or floating window) as a floating window with its top-left corner at x, y in window points (default: inside the dock area, cascaded), as dragging its tab out does; a whole floating window keeps its rect unless given one. `onto` makes it a tab of the floating group holding that panel instead; `above` / `below` a group of its own above or below the group holding that panel, in its floating window or dock column. `collapsed` then minimizes (true) or restores (false) its window or group. A hidden panel shows first. \"tools\" floats the Tools panel at x, y. Returns {layout, closed, floating}", + ), + ( + "window.panel.dock", + "Dock Panel", + "", + "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window. Returns {layout, closed, floating}", + ), + ( + "window.collapseDock", + "Collapse Panels to Icons", + "", + "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", + ), + ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), + ( + "window.workspace", + "Workspace", + "", + "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)", + ), + ( + "window.workspace.reset", + "Reset Essentials", + "", + "{} reset the current workspace to the way it was saved, dropping the layout changes made in it", + ), + ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), + ("window.workspace.manage", "Manage Workspaces…", "", "{}"), + ("window.workspace.delete", "Delete Workspace", "", "{name}"), + ("window.workspace.rename", "Rename Workspace", "", "{name, to}"), + ("window.workspace.list", "List Workspaces", "", "{}"), + ("window.newWindow", "New Window", "", "{}"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ( + "tool.setOption", + "Tool Option", + "", + "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", + ), + ( + "effect.dialog", + "Effect…", + "", + "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", + ), + ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), + ( + "ui.recolorDialog", + "Recolor Artwork…", + "", + "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name; or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", + ), + ( + "ui.cropImage", + "Crop Image", + "", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + ), + ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), + ( + "file.export.pdf", + "Save as PDF…", + "", + "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}", + ), + ("help.about", "About VectorCraft", "", "{}"), + ("help.checkForUpdates", "Check for Updates…", "", "{}"), + ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), + ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), + ( + "ui.swatchOptions", + "Swatch Options…", + "", + "{name} edit a swatch: Swatch Options for a colour (dialog `swatchOptions`, engine: swatch.edit) or a gradient (the same dialog with `name` only; it shows the gradient), pattern editing for a pattern", + ), + ( + "ui.newSwatch", + "New Swatch…", + "", + "{spot?, global?, group?: colour group name} open New Swatch for the active fill or stroke (dialog `newSwatch`, engine: swatch.new); a color opens as a spot color with `spot`, and as a global process color with `global` alone", + ), + ( + "ui.newColorGroup", + "New Color Group…", + "", + "{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)", + ), + ( + "ui.colorPicker", + "Color Picker…", + "", + "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", + ), + ( + "ui.sampleScreenColor", + "Sample Color from Screen", + "", + "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", + ), + ( + "ui.newBrush", + "New Brush…", + "", + "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush", + ), + ( + "ui.brushOptions", + "Brush Options…", + "", + "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options", + ), + ( + "ui.graphicStyleOptions", + "Graphic Style Options…", + "", + "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", + ), + ( + "ui.symbolOptions", + "Symbol Options…", + "F8", + "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \"New Symbol\"/\"New Symbol N\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}", + ), + ( + "tool.options", + "Tool Options…", + "", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values})", + ), + ( + "ui.colorGuideOptions", + "Color Guide Options…", + "", + "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)", + ), + ( + "ui.colorBalanceDialog", + "Adjust Color Balance…", + "", + "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", + ), + ( + "ui.saturateDialog", + "Saturate…", + "", + "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", + ), + ( + "window.swatchLibrary", + "Swatch Library", + "", + "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", + ), + ( + "window.swatchLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel", + ), + ( + "ui.libraryPanel", + "Library Panel Options", + "", + "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}", + ), + ( + "ui.libraryPanel.locate", + "Locate Library…", + "", + "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers", + ), + ( + "ui.saveSwatchLibrary", + "Save Swatch Library…", + "", + "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", + ), + ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), + ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), + ("window.userSwatchLibrary3", "User Swatch Library 3", "", "{} open the 3. User Defined swatch library"), + ("window.userSwatchLibrary4", "User Swatch Library 4", "", "{} open the 4. User Defined swatch library"), + ("window.userSwatchLibrary5", "User Swatch Library 5", "", "{} open the 5. User Defined swatch library"), + ("window.userSwatchLibrary6", "User Swatch Library 6", "", "{} open the 6. User Defined swatch library"), + ("window.userSwatchLibrary7", "User Swatch Library 7", "", "{} open the 7. User Defined swatch library"), + ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), + ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), + ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), + ( + "ui.mergeGraphicStyles", + "Merge Graphic Styles…", + "", + "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", + ), + ( + "ui.layerOptions", + "Options for Selection…", + "", + "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step)", + ), + ( + "ui.newLayer", + "New Layer Options…", + "Cmd+Alt+L", + "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", + ), + ( + "ui.newArtboard", + "New Artboard Options…", + "", + "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", + ), + ( + "ui.layersPanelOptions", + "Panel Options…", + "", + "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, locateSelected (Locate Selected Objects, off by default: when the last selected object changes outside the panel, on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as after layer.locate, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off; a change made during a drag is located when the drag ends), rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)", + ), + ( + "ui.layersExpand", + "Expand Layers Panel Rows", + "", + "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}", + ), + ( + "ui.layersFilter", + "Filter Layers Panel Rows", + "", + "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", + ), + ( + "ui.tileEdgeColor", + "Tile Edge Color…", + "", + "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \"Light Blue\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode", + ), + ( + "ui.flattenTransparencyDialog", + "Flatten Transparency…", + "", + "{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step", + ), + ( + "ui.flattenerPresetsDialog", + "Transparency Flattener Presets…", + "", + "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export", + ), + ( + "ui.flattenerPreview", + "Flattener Preview", + "", + "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", + ), + ( + "window.graphicStyleLibrary", + "Graphic Style Library", + "", + "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", + ), + ( + "window.graphicStyleLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel", + ), + ( + "ui.saveGraphicStyleLibrary", + "Save Graphic Style Library…", + "", + "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", + ), + ( + "window.userGraphicStyleLibrary1", + "User Graphic Style Library 1", + "", + "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)", + ), + ("window.userGraphicStyleLibrary2", "User Graphic Style Library 2", "", "{} open the 2. User Defined graphic style library"), + ("window.userGraphicStyleLibrary3", "User Graphic Style Library 3", "", "{} open the 3. User Defined graphic style library"), + ("window.userGraphicStyleLibrary4", "User Graphic Style Library 4", "", "{} open the 4. User Defined graphic style library"), + ("window.userGraphicStyleLibrary5", "User Graphic Style Library 5", "", "{} open the 5. User Defined graphic style library"), + ("window.userGraphicStyleLibrary6", "User Graphic Style Library 6", "", "{} open the 6. User Defined graphic style library"), + ("window.userGraphicStyleLibrary7", "User Graphic Style Library 7", "", "{} open the 7. User Defined graphic style library"), + ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), + ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), + ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), + ( + "window.symbolLibrary", + "Symbol Library", + "", + "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}", + ), + ( + "window.symbolLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveSymbolLibrary", + "Save Symbols…", + "", + "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary", + ), + ("window.userSymbolLibrary1", "User Symbol Library 1", "", "{} open the 1. User Defined symbol library (symbol.libraries, category user)"), + ("window.userSymbolLibrary2", "User Symbol Library 2", "", "{} open the 2. User Defined symbol library"), + ("window.userSymbolLibrary3", "User Symbol Library 3", "", "{} open the 3. User Defined symbol library"), + ("window.userSymbolLibrary4", "User Symbol Library 4", "", "{} open the 4. User Defined symbol library"), + ("window.userSymbolLibrary5", "User Symbol Library 5", "", "{} open the 5. User Defined symbol library"), + ("window.userSymbolLibrary6", "User Symbol Library 6", "", "{} open the 6. User Defined symbol library"), + ("window.userSymbolLibrary7", "User Symbol Library 7", "", "{} open the 7. User Defined symbol library"), + ("window.userSymbolLibrary8", "User Symbol Library 8", "", "{} open the 8. User Defined symbol library"), + ("window.userSymbolLibrary9", "User Symbol Library 9", "", "{} open the 9. User Defined symbol library"), + ("window.userSymbolLibrary10", "User Symbol Library 10", "", "{} open the 10. User Defined symbol library"), + ( + "window.brushLibrary", + "Brush Library", + "", + "{library: id or name (see brush.libraries) | null (close)} open a brush library in a library panel of its own (UI state `library_panels`, kind brushes); clicking a brush there runs brush.addFromLibrary (with apply when paths are selected), dragging one onto a path runs it with apply on that path, dragging one onto the Brushes panel adds it → {open, panel, name, count}", + ), + ( + "window.brushLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the brushes of another document or a saved brush library (engine: brush.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveBrushLibrary", + "Save Brushes…", + "", + "{names?: [the brushes selected in the Brushes panel]} open Save Brush Library (dialog `saveBrushLibrary`: name, user: save to the user library folder, selectedOnly); OK runs brush.saveLibrary", + ), + ("window.userBrushLibrary1", "User Brush Library 1", "", "{} open the 1. User Defined brush library (brush.libraries, category user)"), + ("window.userBrushLibrary2", "User Brush Library 2", "", "{} open the 2. User Defined brush library"), + ("window.userBrushLibrary3", "User Brush Library 3", "", "{} open the 3. User Defined brush library"), + ("window.userBrushLibrary4", "User Brush Library 4", "", "{} open the 4. User Defined brush library"), + ("window.userBrushLibrary5", "User Brush Library 5", "", "{} open the 5. User Defined brush library"), + ("window.userBrushLibrary6", "User Brush Library 6", "", "{} open the 6. User Defined brush library"), + ("window.userBrushLibrary7", "User Brush Library 7", "", "{} open the 7. User Defined brush library"), + ("window.userBrushLibrary8", "User Brush Library 8", "", "{} open the 8. User Defined brush library"), + ("window.userBrushLibrary9", "User Brush Library 9", "", "{} open the 9. User Defined brush library"), + ("window.userBrushLibrary10", "User Brush Library 10", "", "{} open the 10. User Defined brush library"), + ( + "ui.expandDialog", + "Expand…", + "", + "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step", + ), + ( + "attributes.openUrl", + "Browser", + "", + "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}", + ), + ( + "ui.javascriptFilesDialog", + "Link JavaScript Files…", + "", + "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected", + ), + ( + "ui.spotColors", + "Spot Colors…", + "", + "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step", + ), + ( + "ui.menuDialog", + "Menu Dialog", + "", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", + ), + ( + "ui.widthPointEdit", + "Width Point Edit…", + "", + "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point", + ), + ( + "ui.corners", + "Corners…", + "", + "{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape", + ), + ( + "ui.simplifyAuto", + "Auto-Simplify", + "", + "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open", + ), + ( + "ui.simplifyMoreOptions", + "More Options", + "", + "{} the Simplify dialog (dialog `simplify`: tolerance (pt), angleThreshold, straightLines, showOriginal, retain: ticked, OK makes Object › Path › Simplify open this dialog with these fields next time) takes the open Simplify slider's place, at the slider's tolerance → {tolerance}; an error when the slider isn't open", + ), + ( + "ui.justification", + "Justification…", + "", + "{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected", + ), + ( + "ui.hyphenation", + "Hyphenation…", + "", + "{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected", + ), + ( + "ui.colorGuideLimit", + "Limit Color Guide to Library", + "", + "{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}", + ), + ( + "ui.colorPanel", + "Color Panel Options", + "", + "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", + ), + ( + "ui.savePdfDialog", + "Save PDF Dialog", + "", + "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)", + ), + ( + "file.exportAs", + "Export As…", + "", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}", + ), + ( + "ui.fileInfoDialog", + "File Info Dialog", + "", + "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too", + ), + ( + "ui.rasterEffectsSettingsDialog", + "Document Raster Effects Settings Dialog", + "", + "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", + ), + ( + "ui.effectGalleryDialog", + "Effect Gallery Dialog", + "", + "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too", + ), + ( + "ui.pdfPresetsDialog", + "PDF Presets…", + "", + "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export", + ), + ( + "ui.pdfPresetDialog", + "PDF Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets", + ), + ("file.openRecent11", "Open Recent File 11", "", "{}"), + ("file.openRecent12", "Open Recent File 12", "", "{}"), + ("file.openRecent13", "Open Recent File 13", "", "{}"), + ("file.openRecent14", "Open Recent File 14", "", "{}"), + ("file.openRecent15", "Open Recent File 15", "", "{}"), + ("file.openRecent16", "Open Recent File 16", "", "{}"), + ("file.openRecent17", "Open Recent File 17", "", "{}"), + ("file.openRecent18", "Open Recent File 18", "", "{}"), + ("file.openRecent19", "Open Recent File 19", "", "{}"), + ("file.openRecent20", "Open Recent File 20", "", "{}"), + ("file.openRecent21", "Open Recent File 21", "", "{}"), + ("file.openRecent22", "Open Recent File 22", "", "{}"), + ("file.openRecent23", "Open Recent File 23", "", "{}"), + ("file.openRecent24", "Open Recent File 24", "", "{}"), + ("file.openRecent25", "Open Recent File 25", "", "{}"), + ("file.openRecent26", "Open Recent File 26", "", "{}"), + ("file.openRecent27", "Open Recent File 27", "", "{}"), + ("file.openRecent28", "Open Recent File 28", "", "{}"), + ("file.openRecent29", "Open Recent File 29", "", "{}"), + ("file.openRecent30", "Open Recent File 30", "", "{}"), + ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), + ( + "file.cancelExport", + "Cancel Export", + "", + "{} stop every export running in the background (Background Export): an export stops between artboards and writes no file once cancelled → {cancelled: n}", + ), + ( + "file.exportInBackground", + "Continue in Background", + "", + "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}", + ), + ( + "ui.dxfOptionsDialog", + "DXF Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + ), + ( + "links.editOriginal", + "Edit Original", + "", + "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", + ), + ( + "links.reveal", + "Show in Folder", + "", + "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}", + ), + ( + "ui.placementOptionsDialog", + "Placement Options…", + "", + "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", + ), + ( + "ui.findLinksInFolder", + "Find Linked Files in Folder…", + "", + "{folder?, ids?: [image or placed document ids]} the Links panel's Find in Folder…: asks for a folder (or takes folder), then opens Find Linked Files (dialog `findLinks`) looking in it and its subfolders for the files of linked objects `ids` (default: the selected linked images and placed documents, else every missing file; links.findFiles). An error when there is nothing to look for. Disabled where there is no folder picker (the web)", + ), + ( + "ui.packageDialog", + "Package…", + "Cmd+Alt+Shift+P", + "{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage", + ), + ( + "ui.batchDialog", + "Batch…", + "", + "{set?: index, action?: index} open Batch (dialog `actionBatch`) for the action at set/action (default: the first action of the first set); OK runs action.batch with the dialog's source, destination and overrides", + ), + ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), + ( + "docInfo.save", + "Save Document Info…", + "", + "{path?, selectionOnly?} write Document Info's text report (document.info {format: \"text\"}) to path, else a picked file (the web downloads it) → {path}", + ), + ( + "ui.epsOptionsDialog", + "EPS Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too", + ), + ( + "file.saveForWeb", + "Save for Web (Legacy)…", + "Cmd+Alt+Shift+S", + "{} open Save for Web (dialog `saveForWeb`: fields = the webExport.settings settings, `preset` loads a preset, __view: original|optimized|2up|4up, __zoom, __kbps; Save… remembers the settings and writes the files, Done only remembers them); with settings = document.exportForWeb, written to path, else a picked file (the web downloads the files) → {path, files, bytes}", + ), + ( + "file.saveForWeb.browser", + "Preview in Browser", + "", + "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", + ), + ( + "file.exportSelection", + "Export Selection…", + "", + "{multiple?: false} collect the selected objects as assets (assets.add: one of them all, as one image; true: one per object) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", + ), + ( + "css.copy", + "Copy Selected Style", + "", + "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}", + ), + ( + "css.exportFile", + "Export CSS…", + "", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + ), + ( + "file.print", + "Print…", + "Cmd+P", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", + ), + ( + "print.printers", + "Printers", + "", + "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}", + ), + ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), + ( + "ui.printPresetsDialog", + "Print Presets…", + "", + "{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export", + ), + ( + "ui.printPresetDialog", + "Print Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", + ), + ( + "plugin.dialog", + "Plug-in…", + "", + "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", + ), + ( + "ui.installPlugin", + "Install Plug-in…", + "", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", + ), + ( + "plugin.cancelRuns", + "Cancel Plug-in Run", + "", + "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", + ), + ( + "plugin.hideRuns", + "Continue in Background", + "", + "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", + ), + ( + "ui.checkScript", + "Check Script…", + "", + "{path? | data? | dataBase64?, name?, includes?: true with a path} check a script written for the reference app without running it (script.check; with a path, the files its #include lines name are checked too) and show the report (dialog `scriptCheck`: `report`, script.check's result; Copy Report copies it as JSON); without a file pick one (the web opens the browser's file picker; File › Open of a .jsx, .jsxinc or .jsxbin file shows the report too)", + ), + ( + "ui.perspectiveGridDialog", + "Define Perspective Grid…", + "", + "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", + ), + ( + "ui.perspectivePresetsDialog", + "Perspective Grid Presets…", + "", + "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*", + ), + ( + "ui.savePerspectivePreset", + "Save Grid as Preset…", + "", + "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields", + ), + ( + "ui.perspectiveUserPreset1.1", + "One Point Perspective Preset 1", + "", + "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.2", + "One Point Perspective Preset 2", + "", + "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.3", + "One Point Perspective Preset 3", + "", + "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.4", + "One Point Perspective Preset 4", + "", + "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.5", + "One Point Perspective Preset 5", + "", + "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.1", + "Two Point Perspective Preset 1", + "", + "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.2", + "Two Point Perspective Preset 2", + "", + "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.3", + "Two Point Perspective Preset 3", + "", + "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.4", + "Two Point Perspective Preset 4", + "", + "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.5", + "Two Point Perspective Preset 5", + "", + "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.1", + "Three Point Perspective Preset 1", + "", + "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.2", + "Three Point Perspective Preset 2", + "", + "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.3", + "Three Point Perspective Preset 3", + "", + "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.4", + "Three Point Perspective Preset 4", + "", + "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.5", + "Three Point Perspective Preset 5", + "", + "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.blendOptions", + "Blend Options…", + "", + "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", + ), + ( + "ui.livePaintGapOptions", + "Gap Options…", + "", + "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step", + ), + ( + "ui.perspectivePlane", + "Perspective Plane Options…", + "", + "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move", + ), +]; From d8a6f8f3d872497bb676ae5a73688308cfabc16e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:50 +0000 Subject: [PATCH 142/199] refactor(ui-egui): move panels/layers.rs items to panels/layers/{controls,model,rows,thumbs}.rs --- crates/ui-egui/src/panels/layers.rs | 1371 +----------------- crates/ui-egui/src/panels/layers/controls.rs | 297 ++++ crates/ui-egui/src/panels/layers/model.rs | 303 ++++ crates/ui-egui/src/panels/layers/rows.rs | 520 +++++++ crates/ui-egui/src/panels/layers/thumbs.rs | 247 ++++ 5 files changed, 1384 insertions(+), 1354 deletions(-) create mode 100644 crates/ui-egui/src/panels/layers/controls.rs create mode 100644 crates/ui-egui/src/panels/layers/model.rs create mode 100644 crates/ui-egui/src/panels/layers/rows.rs create mode 100644 crates/ui-egui/src/panels/layers/thumbs.rs diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0cd69aa02..9c118a329 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -52,6 +52,23 @@ use crate::thumb_worker::{Settle, ThumbWorker}; use crate::widgets::{Live, PanelDrag}; use crate::{VectorcraftApp, icons, widgets}; +mod controls; +mod model; +mod rows; +mod thumbs; + +use controls::{bottom_bar, filter_button}; +pub use controls::{expand, menu, set_filter}; +pub(crate) use model::shown_name; +pub use model::{Filter, ObjectKind, RowState}; +#[cfg(test)] +use model::{Row, list_rows, painted_name}; +use model::{push_rows, rows_of_doc}; +use rows::{drag_ghost, key, rename_id, resolve_click, row}; +#[cfg(test)] +use thumbs::THUMBS; +pub(crate) use thumbs::{ThumbSource, node_arc, real_thumb, thumb_options}; + /// Width of the eye and the lock columns. const COLUMN: f32 = 25.0; /// Indent per nesting level. @@ -126,370 +143,6 @@ struct ColumnDrag { ids: Vec, } -/// Stable id for the layers panel's per-name UI state. -fn key(name: &str) -> egui::Id { - egui::Id::unique(("layers-panel", name)) -} - -/// Temp-memory key for the layer rename editor. -fn rename_id() -> egui::Id { - egui::Id::unique("layers-rename") -} - -/// Ghost rows are drawn at this opacity. -const GHOST_OPACITY: f32 = 0.5; - -/// The colour of the layer holding `id`. -fn layer_colour(doc: &Document, id: NodeId) -> Color32 { - let [r, g, b] = doc.layer_color(id); - Color32::from_rgb(r, g, b) -} - -/// The layer colour bar of row `n` (rect `r`) at `x`: wider on layers. -fn colour_bar(ui: &Ui, n: &Node, r: egui::Rect, x: f32, colour: Color32) { - let w = if n.is_layer() { 4.0 } else { 2.0 }; - ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(w, r.height())), 0.0, colour); -} - -/// The thumbnail of row `n` (rect `r`) at `x`; returns where it went. -fn row_thumb(ui: &Ui, view: &View, n: &Arc, r: egui::Rect, x: f32) -> egui::Rect { - let s = (view.h - 4.0).min(40.0); - let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s)); - ui.painter().rect_filled(th, 0.0, Color32::WHITE); - ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, view.t.border), StrokeKind::Outside); - if !real_thumb(ui, &view.thumbs, n, th) { - thumb(ui, n, th); - } - th -} - -/// The dragged row `src`, dimmed, following the pointer at `pos` above everything else: its -/// colour bar, thumbnail and name. -fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) { - let (doc, t, h) = (view.doc, &view.t, view.h); - let Some(n) = node_arc(doc, src) else { return }; - let n = &n; - let (grab, width, depth) = ui.data(|d| d.get_temp::<(egui::Vec2, f32, usize)>(key("grab"))).unwrap_or((vec2(h, h / 2.0), 240.0, 0)); - // A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in. - let r = egui::Rect::from_min_size(pos - grab, vec2(width, h)); - let id = key("ghost"); - let mut ui = Ui::new(ui.ctx().clone(), id, egui::UiBuilder::new().layer_id(egui::LayerId::new(egui::Order::Tooltip, id)).max_rect(r)); - ui.set_opacity(GHOST_OPACITY); - let p = ui.painter(); - p.rect_filled(r, 0.0, t.row_selected); - p.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); - let mut x = r.left() + 2.0 * COLUMN + 2.0; - colour_bar(&ui, n, r, x, layer_colour(doc, src)); - x += 22.0 + depth as f32 * INDENT; - if view.opts.thumb(n) { - x = row_thumb(&ui, view, n, r, x).right() + 4.0; - } - let label = n.identify_label(view.identify_xml_id); - let name = if view.identify_xml_id { label } else { painted_name(n, &label, crate::i18n::current()) }; - ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text); -} - -/// Where a dragged row goes relative to the row it is dropped on. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Place { - Above, - Below, - Inside, -} - -impl Place { - fn id(self) -> &'static str { - match self { - Place::Above => "above", - Place::Below => "below", - Place::Inside => "inside", - } - } -} - -/// The name painted for a row in `lang`: a generated `` name is translated, anything else is -/// user data (an unnamed text object shows its text, which can look like ``; only an empty -/// one is called ``). -fn painted_name(n: &Node, name: &str, lang: crate::i18n::Lang) -> String { - let generated = n.name.is_none() && !matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| !r.text.is_empty())); - if generated && let Some(inner) = name.strip_prefix('<').and_then(|s| s.strip_suffix('>')) { - return format!("<{}>", crate::i18n::tr(lang, inner)); - } - name.to_string() -} - -/// The name the panel shows for `n` in `lang`: its XML ID while Identify Objects By is XML ID, -/// otherwise [`painted_name`]. -pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang) -> String { - let xml_id = app.session.prefs.identify_objects_by == "xmlId"; - let label = n.identify_label(xml_id); - if xml_id { label } else { painted_name(n, &label, lang) } -} - -/// Whether `n` gets rows for what it holds (a compound path's paths are one object). -fn opens(n: &Node, opts: &PanelOptions) -> bool { - match n.kind { - NodeKind::Compound { .. } => false, - _ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())), - } -} - -/// One row of the panel. -#[derive(Clone)] -struct Row { - node: Arc, - /// Nesting level (0: a top-level layer). - depth: usize, - /// A clipping path: its group or layer clips. - clip_path: bool, - /// It holds rows ([`opens`]), and shows them. - opens: bool, - open: bool, - /// The colour of its layer (its own, for a layer). - colour: Color32, - /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. - dim: bool, - /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. - guide: Option, -} - -/// An object kind under the Filter's By Object (`ui.layersFilter`'s `objects`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ObjectKind { - Paths, - Shapes, - Text, - Images, - Effects, - Groups, - LinkedFiles, - Guides, -} - -impl ObjectKind { - /// Every kind with its parameter name, in the popup's order. - pub const ALL: [(Self, &'static str); 8] = [ - (Self::Paths, "paths"), - (Self::Shapes, "shapes"), - (Self::Text, "text"), - (Self::Images, "images"), - (Self::Effects, "effects"), - (Self::Groups, "groups"), - (Self::LinkedFiles, "linkedFiles"), - (Self::Guides, "guides"), - ]; - - fn label(self) -> &'static str { - match self { - Self::Paths => tl!("Paths"), - Self::Shapes => tl!("Shapes"), - Self::Text => tl!("Text"), - Self::Images => tl!("Images"), - Self::Effects => tl!("Effects"), - Self::Groups => tl!("Groups"), - Self::LinkedFiles => tl!("Linked Files"), - Self::Guides => tl!("Guides"), - } - } - - /// Whether `n`'s row is of this kind. Paths take plain paths, the paths whose corners Live - /// Corners cut and compound paths; Shapes take live rectangles, ellipses, polygons and lines; - /// Paths and Shapes leave out paths made into guides. Effects take a row with an effect, shown - /// or hidden, on itself or on one of its fills or strokes. Linked Files take linked images and - /// placed documents. - fn takes(self, n: &Node) -> bool { - match (self, &n.kind) { - (Self::Guides, NodeKind::Path { guide, .. }) => *guide, - (Self::Paths | Self::Shapes, NodeKind::Path { guide: true, .. }) => false, - (Self::Paths, NodeKind::Path { live, .. }) => live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. })), - (Self::Shapes, NodeKind::Path { live, .. }) => live.as_deref().is_some_and(|l| !matches!(l, LiveShape::Path { .. })), - (Self::Paths, NodeKind::Compound { .. }) - | (Self::Text, NodeKind::Text(_)) - | (Self::Images, NodeKind::Image(_)) - | (Self::Groups, NodeKind::Group { .. }) - | (Self::LinkedFiles, NodeKind::PlacedDocument(_)) => true, - (Self::LinkedFiles, NodeKind::Image(im)) => im.link.is_some(), - (Self::Effects, _) => !n.appearance.effects.is_empty() || n.appearance.items.iter().any(|i| !i.effects().is_empty()), - _ => false, - } - } -} - -/// A row state under the Filter's By Layer (`ui.layersFilter`'s `layers`): the row's own lock or -/// eye, as its lock and eye columns show them. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RowState { - Locked, - Hidden, -} - -impl RowState { - /// Every state with its parameter name, in the popup's order. - pub const ALL: [(Self, &'static str); 2] = [(Self::Locked, "locked"), (Self::Hidden, "hidden")]; - - fn label(self) -> &'static str { - match self { - Self::Locked => tl!("Locked"), - Self::Hidden => tl!("Hidden"), - } - } - - fn takes(self, n: &Node) -> bool { - match self { - Self::Locked => n.locked, - Self::Hidden => !n.visible, - } - } -} - -/// The Layers panel's Filter (`ui.layersFilter`): the object kinds ticked under By Object and the -/// row states ticked under By Layer, each in the popup's order. A section with nothing ticked takes -/// every row. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Filter { - pub objects: Vec, - pub layers: Vec, -} - -impl Filter { - /// Whether anything is ticked. - pub fn is_on(&self) -> bool { - !self.objects.is_empty() || !self.layers.is_empty() - } - - /// Whether `n`'s row is of a ticked kind and in a ticked state. - fn keeps(&self, n: &Node) -> bool { - (self.objects.is_empty() || self.objects.iter().any(|k| k.takes(n))) && (self.layers.is_empty() || self.layers.iter().any(|s| s.takes(n))) - } - - /// Whether the ruler guide rows stay: with no filter on, or with Guides ticked and no row state - /// ticked. - fn keeps_guides(&self) -> bool { - !self.is_on() || (self.objects.contains(&ObjectKind::Guides) && self.layers.is_empty()) - } -} - -/// The parameter names of the ones of `all` in `on`, in `all`'s order. -fn names(all: &[(T, &'static str)], on: &[T]) -> Vec<&'static str> { - all.iter().filter(|(t, _)| on.contains(t)).map(|(_, name)| *name).collect() -} - -/// The parameter names of `on` with `t` added, or taken out when it is in `on`. -fn toggled(all: &[(T, &'static str)], on: &[T], t: T) -> Vec<&'static str> { - all.iter().filter(|(x, _)| if *x == t { !on.contains(x) } else { on.contains(x) }).map(|(_, name)| *name).collect() -} - -/// The list `key` of `p` read by the parameter names of `all`, in `all`'s order: `None` when `p` -/// leaves it out or sets it to null, and an error when it is anything other than an array of -/// those names. -fn parse_names(p: &Value, key: &str, all: &[(T, &'static str)]) -> Result>, String> { - let given = match p.get(key) { - None | Some(Value::Null) => return Ok(None), - Some(v) => v - .as_array() - .and_then(|a| a.iter().map(|name| all.iter().find(|(_, n)| Some(*n) == name.as_str()).map(|(t, _)| *t)).collect::>>()), - }; - let Some(given) = given else { - let names: Vec<&str> = all.iter().map(|(_, n)| *n).collect(); - return Err(format!("`{key}` must be an array of these names: {}", names.join(", "))); - }; - Ok(Some(all.iter().map(|(t, _)| *t).filter(|t| given.contains(t)).collect())) -} - -/// What the rows were listed from: listed again when any of it changes. -#[derive(Clone, PartialEq)] -struct RowsKey { - uid: u64, - revision: u64, - open: u64, - layers_only: bool, - query: String, - filter: Filter, -} - -/// The rows the panel shows, top down: the layers (only the opacity mask's while one is edited) -/// with the rows of the open ones inside them. A search or a filter shows every row whose name -/// contains `query` (lowercase) and that `filter` keeps, and every row holding one. -fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec { - let mask_layer = doc.mask_edit.map(|m| m.layer); - let mut out = vec![]; - for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, doc), &mut out); - } - out -} - -/// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds -/// it (the layer's colour, and whether it is visible). -fn push_rows( - n: &Arc, - depth: usize, - clip_path: bool, - (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document), - out: &mut Vec, -) { - let (open_rows, opts, query, filter, doc) = cx; - if opts.layers_only && !n.is_layer() { - return; - } - let colour = match &n.kind { - NodeKind::Layer { color, .. } => { - let [r, g, b] = color.rgb(); - Color32::from_rgb(r, g, b) - } - _ => colour, - }; - let shown = shown && n.visible; - let searching = !query.is_empty(); - let narrowing = searching || filter.is_on(); - // A layer's ruler guides are rows in it, above its art (not while searching, nor while the - // filter leaves them out). - let guides: Vec = - if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; - let opens = opens(n, opts) || !guides.is_empty(); - let open = opens && (narrowing || open_rows.contains(n.id)); - let at = out.len(); - out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None }); - if open { - for i in guides.into_iter().rev() { - out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) }); - } - } - if open && let Some(children) = n.children() { - for (i, c) in children.iter().enumerate().rev() { - push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out); - } - } - // Searching or filtering, a row stays for a match of its own (its name holds the search text - // and the filter keeps it) or for a row it keeps inside it. - if narrowing && out.len() == at + 1 && !(filter.keeps(n) && (!searching || n.display_name().to_lowercase().contains(query))) { - out.truncate(at); - } -} - -/// The rows of the active document as [`list_rows`] lists them, listed again only when the -/// document, its open rows, the search, the filter or Show Layers Only changed. -fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, query: &str, filter: &Filter) -> Arc> { - let now = RowsKey { - uid: st.uid, - revision: st.revision, - open: st.layers_open.generation(), - layers_only: opts.layers_only, - query: query.to_string(), - filter: filter.clone(), - }; - let id = key("rows"); - if let Some((was, rows)) = ui.data(|d| d.get_temp::<(RowsKey, Arc>)>(id)) - && was == now - { - return rows; - } - let rows = Arc::new(list_rows(&st.doc, &st.layers_open, opts, query, filter)); - ui.data_mut(|d| d.insert_temp(id, (now, rows.clone()))); - rows -} - /// Everything one frame of the panel reads. struct View<'a> { doc: &'a Arc, @@ -773,995 +426,5 @@ fn see_selection(app: &VectorcraftApp, ui: &Ui, before: Option<(u64, Option) { - let t = &view.t; - let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); - ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider)); - let n = doc.layers.len(); - ui.painter().text( - bar.left_center() + vec2(4.0, 0.0), - egui::Align2::LEFT_CENTER, - crate::i18n::tn(n as u64, "{n} Layer", "{n} Layers"), - egui::FontId::proportional(11.5), - t.text_dim, - ); - let m = ui.input(|i| i.modifiers); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); - let trash = widgets::icon_button(&mut child, "trash-2", tl!("Delete Selection"), false, 24.0); - // A target circle dropped on the trash clears that appearance (dragged rows: below). Peek at - // the payload's type before taking it: egui's take drops a payload of another type. - if trash.dnd_hover_payload::().is_some() - && let Some(d) = trash.dnd_release_payload::() - && let PanelDrag::Appearance(id) = *d - { - actions.push(("appearance.clear".into(), json!({"ids": [id.0]}))); - } else if trash.clicked() { - if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) { - actions.push(("edit.clear".into(), json!({}))); - } else { - actions.push(("layer.delete".into(), json!({}))); - } - } - // Create New Layer: Alt asks for its options first, Ctrl/Cmd puts it on top of every layer. - let new_layer = widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0); - if new_layer.clicked() { - if m.alt { - actions.push(("ui.newLayer".into(), json!({}))); - } else { - actions.push(("layer.new".into(), json!({"top": m.command}))); - } - } - // Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are - // outlined while rows (or, on the trash, a target circle) hang over them. - for (button, cmd) in [(&trash, "layer.delete"), (&new_layer, "layer.duplicate")] { - if let Some(d) = button.dnd_release_payload::() - && let LayersDrag::Rows(ids) = &*d - { - actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))); - } - let over = button.dnd_hover_payload::().is_some_and(|d| matches!(*d, LayersDrag::Rows(_))) - || (cmd == "layer.delete" && button.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_)))); - if over { - child.painter().rect_stroke(button.rect, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); - } - } - if widgets::icon_button(&mut child, "plus", tl!("Create New Sublayer"), false, 24.0).clicked() { - actions.push(if m.alt { ("ui.newLayer".into(), json!({"sublayer": true})) } else { ("layer.newSublayer".into(), json!({})) }); - } - if widgets::icon_button(&mut child, "frame", tl!("Make/Release Clipping Mask"), false, 24.0).clicked() { - actions.push(("layer.clippingMask.toggle".into(), json!({}))); - } - if widgets::icon_button(&mut child, "search", tl!("Locate Object"), false, 24.0).clicked() { - actions.push(("layer.locate".into(), json!({}))); - } -} - -/// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift -/// (highlight the rows from the last one clicked down to it). -fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) { - let Some((id, m)) = out.click.take() else { return }; - let anchor: Option = ui.data(|d| d.get_temp(key("anchor"))); - ui.data_mut(|d| d.insert_temp(key("focus"), true)); - let at = |id: NodeId| list.iter().position(|r| r.node.id == id); - if m.shift - && let Some(a) = anchor.or(view.rows.last().copied()) - && let (Some(i), Some(j)) = (at(a), at(id)) - { - let range: Vec = list.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.node.id.0).collect(); - out.actions.push(("layer.highlight".into(), json!({"ids": range, "mode": if m.command { "add" } else { "set" }}))); - return; - } - ui.data_mut(|d| d.insert_temp(key("anchor"), id)); - if m.command { - out.actions.push(("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"}))); - } else { - out.actions.push(("layer.setCurrent".into(), json!({"id": id.0}))); - } -} - -/// Whether row `n` can take `drag` inside it. -fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { - match n.kind { - NodeKind::Layer { .. } => true, - NodeKind::Group { .. } => match drag { - LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)), - LayersDrag::Art => true, - LayersDrag::Guides(_) => false, - }, - _ => false, - } -} - -/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection -/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it -/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged. -fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { - let (t, h) = (&view.t, view.h); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); - let Some(g) = view.doc.guides.get(i) else { return }; - let selected = view.guides.contains(&i); - let editable = view.doc.guide_editable(g); - if editable && resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); - } - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); - let mut x = r.left() + 2.0 * COLUMN + 2.0; - colour_bar(ui, &item.node, r, x, item.colour); - x += 6.0 + item.depth as f32 * INDENT + 16.0; - // The guide mark: a short line across or down, in the guides' colour. - let c = egui::pos2(x + 8.0, r.center().y); - let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) }; - ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent)); - x += 22.0; - let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") }); - let color = if item.dim { t.text_dim } else { t.text }; - ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color); - let col_x = r.right() - 43.5; - ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); - if selected { - let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0)); - ui.painter().rect_filled(q, 0.0, item.colour); - ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); - } - if editable && resp.clicked() { - let m = ui.input(|i| i.modifiers); - out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); - } - // Dragged: the selected guides when this is one of them, else this one, to another layer's row. - if editable && resp.drag_started() { - let guides = if selected { view.guides.clone() } else { vec![i] }; - egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); - } - if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } - // Guides dropped here go to this guide's layer. - if let Some(drag) = resp.dnd_hover_payload::() - && let LayersDrag::Guides(guides) = &*drag - { - guide_drop(ui, view, guides, item.node.id, r, &resp, out); - } -} - -/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the -/// release puts them on that layer (`guide.setLayer`, one undo step). -fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) { - ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside); - if resp.dnd_release_payload::().is_some() { - out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0}))); - } -} - -/// The widgets of row `item`. -fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { - if let Some(i) = item.guide { - return guide_row(ui, view, item, i, out); - } - let n = &*item.node; - let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour); - let doc = view.doc; - let t = &view.t; - let h = view.h; - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); - if view.reveal == Some(n.id) { - ui.scroll_to_rect(r, None); - } - let is_sel = view.sel.contains(&n.id); - let child_sel = !is_sel - && n.children().is_some_and(|_| { - let mut any = false; - n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id)); - any - }); - // Highlighted rows (else the current layer's). - let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) }; - if highlighted { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); - } - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); - if n.is_layer() && Some(n.id) == view.current { - // Current-layer marker: a small triangle in the top-right corner. - let c = r.right_top(); - ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE)); - } - eye_and_lock(ui, view, n, r, out); - // Layer colour bar (every row shows the colour of its layer). - let mut x = r.left() + 2.0 * COLUMN + 2.0; - colour_bar(ui, n, r, x, color); - x += 6.0 + depth as f32 * INDENT; - // Disclosure triangle; Alt opens or closes everything inside too. - if has_children { - let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0)); - let dresp = ui.interact(dr, ui.scope_id().with(("disc", n.id.0)), Sense::click()); - icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim); - if dresp.clicked() { - out.toggle.push((n.id, !open)); - if ui.input(|i| i.modifiers.alt) { - n.walk(&mut |c| { - if c.id != n.id && opens(c, &view.opts) { - out.toggle.push((c.id, !open)); - } - }); - } - } - } - x += 16.0; - // Thumbnail. - let thumb_rect = view.opts.thumb(n).then(|| { - let th = row_thumb(ui, view, &item.node, r, x); - x = th.right() + 4.0; - th - }); - // Name (Preferences › Units › Identify Objects By: Object Name or XML ID). - let xml_id = view.identify_xml_id; - let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "".to_string() } else { n.identify_label(xml_id) }; - let font = egui::FontId::proportional(13.0); - let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id())); - let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0)); - let name_end; - match renaming { - Some((rid, mut buf)) if rid == n.id.0 => { - let field = rename_id().with("field"); - let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); - let te = child.add(egui::TextEdit::singleline(&mut buf).id(field).desired_width(name_rect.width()).font(font.clone())); - // Focus the field as it opens. Asking every frame took the focus back from Enter, - // Escape or a click elsewhere, so the rename never ended. - if !te.has_focus() && !te.lost_focus() { - te.request_focus(); - } - if te.lost_focus() { - ui.data_mut(|d| d.remove::<(u64, String)>(rename_id())); - if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name { - out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf}))); - } - } else { - // Shift+Enter renames it and keeps the field open. - if kept && buf != name { - out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()}))); - } - ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf))); - } - name_end = name_rect.right(); - } - _ => { - let painter = ui.painter().with_clip_rect(name_rect); - // Generated names ("", "") are translated where painted; the stored name stays English. - // XML IDs stay as ids (not translated). - let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { - tl!("").to_string() - } else if xml_id { - name.clone() - } else { - painted_name(n, &n.display_name(), crate::i18n::current()) - }; - let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if item.dim { t.text_dim } else { t.text }); - name_end = text.right(); - // A clipping path's name is underlined, a masked object's with a dashed line. - if clip_path { - painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); - } else if n.extras().mask.is_some() { - painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0)); - } - } - } - // Target circle and selection square. - let col_x = r.right() - 43.5; - ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); - let tc = egui::pos2(r.right() - TARGET_X, r.center().y); - let tresp = target_circle(ui, n, tc, h, view.target.map_or(is_sel, |id| id == n.id), &mut out.actions, t); - let sq = egui::pos2(r.right() - SQUARE_X, r.center().y); - if is_sel { - let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0)); - ui.painter().rect_filled(q, 0.0, color); - ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); - } else if child_sel { - ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color); - } - let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.scope_id().with(("selsq", n.id.0)), Sense::click_and_drag()); - // Dragging the selected-art square carries the selection to another row. - if (is_sel || child_sel) && sq_resp.drag_started() { - egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art); - } - let m = ui.input(|i| i.modifiers); - if tresp.clicked() { - out.actions.push(("layer.target".into(), json!({"id": n.id.0, "add": m.shift}))); - } else if sq_resp.clicked() { - out.actions.push(("layer.selectAll".into(), json!({"id": n.id.0, "add": m.shift}))); - } else if resp.clicked() { - out.click = Some((n.id, m)); - } - if resp.double_clicked() { - let on_name = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x <= name_end.max(name_rect.left() + 20.0) && p.x >= name_rect.left()); - let on_thumb = thumb_rect.is_some_and(|th| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| th.contains(p))); - if on_name && !on_thumb { - ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, n.identify_label(view.identify_xml_id)))); - } else { - out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]}))); - } - } - // Row menu: Collect for Export on this row (or every highlighted row when this is one of them). - let collect_ids: Vec = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] }; - resp.context_menu(|ui| { - collect_for_export_menu(ui, true, |multiple| { - out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple }))); - }); - }); - // Dragging rows: the highlighted rows when this is one of them, else this row. - if resp.drag_started() && view.column.is_none() { - let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] }; - egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids)); - let grab = resp.interact_pointer_pos().map_or(vec2(h, h / 2.0), |p| p - r.min); - ui.data_mut(|d| d.insert_temp(key("grab"), (grab, r.width(), depth))); - } - if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } - drop_target(ui, view, n, r, x - 16.0, open, &resp, out); -} - -/// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer -/// but the one holding the row; when they all are hidden (locked) already, Show All Layers (Unlock -/// All Layers). Each is one undo step. -fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) { - let own = doc.layer_of(n.id); - let mut others = doc.layers.iter().filter(|l| Some(l.id) != own); - let cmd = match (eye, if eye { others.any(|l| l.visible) } else { others.any(|l| !l.locked) }) { - (true, true) => "layer.hideOthers", - (true, false) => "layer.showAll", - (false, true) => "layer.lockOthers", - (false, false) => "layer.unlockAll", - }; - // Show All and Unlock All take no rows. - let params = if cmd.ends_with("Others") { json!({ "ids": [n.id.0] }) } else { json!({}) }; - (cmd.into(), params) -} - -/// Ctrl/Cmd+Alt-clicking row `n`'s eye runs Outline Others, which puts every top-level layer but the -/// one holding the row in Outline and puts that one in Preview. When all the other layers are in -/// Outline already, it runs Preview All Layers. Each is one undo step. -fn outline_others_action(doc: &Document, n: &Node) -> (String, Value) { - let own = doc.layer_of(n.id); - if doc.layers.iter().any(|l| Some(l.id) != own && !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { - ("layer.outlineOthers".into(), json!({ "ids": [n.id.0] })) - } else { - // Preview All takes no rows. - ("layer.previewAll".into(), json!({})) - } -} - -/// The eye and lock columns of row `n` at `r`. -fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) { - let t = &view.t; - let h = view.h; - let eye = egui::Rect::from_min_size(r.min, vec2(COLUMN, h)); - let lock = egui::Rect::from_min_size(r.min + vec2(COLUMN, 0.0), vec2(COLUMN, h)); - ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border)); - ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border)); - let preview = !matches!(n.kind, NodeKind::Layer { preview: false, .. }); - let er = ui.interact(eye, ui.scope_id().with(("eye", n.id.0)), Sense::click_and_drag()); - if n.visible { - let icon = egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)); - if n.is_template() { - // A template layer shows the template mark instead of the eye. - icons::paint(ui, "shapes", icon, t.icon); - } else if preview { - icons::paint(ui, "eye", icon, t.icon); - } else { - // Outline view: a hollow eye. - icons::paint(ui, "eye", icon, t.text_dim); - ui.painter().circle_stroke(eye.center(), 7.5, Stroke::new(1.0, t.text_dim)); - } - } - let lr = ui.interact(lock, ui.scope_id().with(("lock", n.id.0)), Sense::click_and_drag()); - if n.locked { - icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon); - } else if lr.hovered() { - icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled); - } - let m = ui.input(|i| i.modifiers); - if er.clicked() { - if m.alt && m.command { - out.actions.push(outline_others_action(view.doc, n)); - } else if m.alt { - out.actions.push(others_action(view.doc, n, true)); - } else if m.command && n.is_layer() { - // Ctrl/Cmd-click: Preview ↔ Outline for this layer. - out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "preview": !preview}))); - } else { - out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "visible": !n.visible}))); - } - } - if lr.clicked() && m.alt { - out.actions.push(others_action(view.doc, n, false)); - } else if lr.clicked() { - out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "locked": !n.locked}))); - } - // Dragging down a column gives every row it passes this row's new state. - if er.drag_started() { - out.column = Some(Some(ColumnDrag { eye: true, value: !n.visible, ids: vec![n.id.0] })); - } else if lr.drag_started() { - out.column = Some(Some(ColumnDrag { eye: false, value: !n.locked, ids: vec![n.id.0] })); - } else if let Some(c) = out.column.clone().unwrap_or_else(|| view.column.clone()) - && !c.ids.contains(&n.id.0) - && ui.input(|i| i.pointer.primary_down() && i.pointer.interact_pos().is_some_and(|p| p.y >= r.top() && p.y < r.bottom())) - { - let mut c = c; - c.ids.push(n.id.0); - out.column = Some(Some(c)); - } -} - -/// Row `n` at `r` as a drop target for dragged rows or the selected-art square: shows where they -/// would go (a line above or below it, or a box around it for inside) and moves them on release -/// (Alt copies). `indent` is where the row's content starts. -#[allow(clippy::too_many_arguments)] -fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: bool, resp: &egui::Response, out: &mut Out) { - let Some(drag) = resp.dnd_hover_payload::() else { return }; - let doc = view.doc; - let moving: Vec = match &*drag { - LayersDrag::Rows(ids) => ids.clone(), - LayersDrag::Art => view.sel.iter().copied().collect(), - LayersDrag::Guides(guides) => { - // Guides go to the layer of the row (the row's own, for a layer). - if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } { - guide_drop(ui, view, guides, layer, r, resp, out); - } - return; - } - }; - // Never into itself or a row inside it. - let around = doc.ancestry(n.id).unwrap_or_default(); - if moving.is_empty() || moving.iter().any(|m| around.contains(m)) { - return; - } - let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; - let rel = (p.y - r.top()) / r.height().max(1.0); - let inside = takes(n, &drag, doc); - let place = match &*drag { - LayersDrag::Art if inside => Place::Inside, - LayersDrag::Art => Place::Above, - LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside, - LayersDrag::Rows(_) if rel < 0.5 => Place::Above, - LayersDrag::Rows(_) | LayersDrag::Guides(_) => Place::Below, - }; - let stroke = Stroke::new(2.0, view.t.accent); - match place { - Place::Above => { - ui.painter().line_segment([egui::pos2(indent, r.top()), egui::pos2(r.right(), r.top())], stroke); - } - Place::Below => { - ui.painter().line_segment([egui::pos2(indent, r.bottom()), egui::pos2(r.right(), r.bottom())], stroke); - } - Place::Inside => { - ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside); - } - } - if resp.dnd_release_payload::().is_some() { - let copy = ui.input(|i| i.modifiers.alt); - let ids: Vec = match &*drag { - // The selection's top objects, as Arrange and Group take them. - LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(), - LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(), - LayersDrag::Guides(_) => return, - }; - out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy}))); - } -} - -/// The target circle of `n`'s row at `c`: a ring, doubled while `n` is targeted, filled when `n` -/// has an appearance or transparency of its own. Its response is clicked to target `n`; dragging -/// it carries `n`'s appearance ([`PanelDrag::Appearance`]) onto another row's circle (Alt -/// copies it) or the trash. -fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response { - let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.scope_id().with(("target", n.id.0)), Sense::click_and_drag()); - widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id)); - let held = resp.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id)); - if let Some(d) = resp.dnd_release_payload::() - && let PanelDrag::Appearance(source) = *d - && source != n.id - { - let copy = ui.input(|i| i.modifiers.alt); - actions.push(("appearance.transfer".into(), json!({"source": source.0, "target": n.id.0, "copy": copy}))); - } - let ring = if held { t.accent } else { t.icon }; - ui.painter().circle_stroke(c, 5.0, Stroke::new(if held { 1.5 } else { 1.0 }, ring)); - if targeted { - ui.painter().circle_stroke(c, 2.8, Stroke::new(1.0, t.icon)); - } - if has_styled_target(n) { - ui.painter().circle_filled(c, 3.2, t.icon); - } - if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } - resp -} - -/// Whether an object's target circle is filled: its appearance is not basic (a group or layer: -/// it has fills, strokes or effects of its own) or its transparency (opacity, blend mode, -/// isolation, knockout or an opacity mask) is not the default. -fn has_styled_target(n: &Node) -> bool { - !n.appearance.is_basic() - || (matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.appearance.items.is_empty()) - || !n.has_default_transparency() -} - -/// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in -/// progress (the thumbnails shown then stay until it ends). -pub(crate) struct ThumbSource<'a> { - pub uid: u64, - pub revision: u64, - pub doc: &'a Arc, - pub busy: bool, -} - -impl<'a> ThumbSource<'a> { - /// The thumbnails of the document `st` holds (`doc`, its document). - pub(crate) fn of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, doc: &'a Arc) -> Self { - Self { uid: st.uid, revision: st.revision, doc, busy: st.interaction.is_some() || app.session.tool_busy() } - } -} - -/// Node `id` of `doc`, as the `Arc` its parent holds. -pub(crate) fn node_arc(doc: &Document, id: NodeId) -> Option> { - let path = doc.index_path(id)?; - let (first, rest) = path.split_first()?; - let mut cur = doc.layers.get(*first)?; - for &i in rest { - cur = cur.children()?.get(i)?; - } - Some(cur.clone()) -} - -/// A row thumbnail's image. -struct ThumbEntry { - /// The node it shows. Weak, so the cache doesn't keep old art alive, but its allocation stays - /// and no other node can come to have its address. - node: Weak, - px: u32, - /// None: the node draws nothing. - tex: Option, - /// How long it took to render (ms). - ms: f64, - /// The pass it was last drawn in. - used: u64, -} - -/// A row thumbnail job: the row (document uid, node), the node and the size rendered. -type ThumbJob = ((u64, NodeId), Weak, u32); - -#[derive(Default)] -struct Thumbs { - entries: HashMap<(u64, NodeId), ThumbEntry>, - /// What the worker is rendering, by row. - pending: HashMap<(u64, NodeId), (Weak, u32)>, - worker: Option>, - worker_started: bool, - settle: Settle, - /// (pass, ms, count) rendered on the UI thread in that pass. - spent: (u64, f64, u32), - /// Thumbnails rendered on the UI thread (tests count them). - renders: u64, -} - -thread_local! { - static THUMBS: crate::graphics::TexCache = crate::graphics::TexCache::default(); -} - -/// The most row thumbnails kept: past it the ones drawn longest ago go. -const MAX_THUMBS: usize = 512; -/// Rendering a thumbnail that took longer than this (ms) again goes to the worker. -const LIGHT_THUMB_MS: f64 = 4.0; -/// A node with more objects than this inside has its first thumbnail made on the worker. -const LIGHT_THUMB_NODES: usize = 200; -/// At most this long (ms) and this many thumbnails are rendered on the UI thread in a pass; the -/// rest go to the worker, or wait for the next pass where there is none. -const THUMB_BUDGET_MS: f64 = 8.0; -const THUMB_BUDGET_COUNT: u32 = 16; - -/// How row thumbnails render: images far smaller than they are sample their mip levels. -pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions { - vectorcraft_render::RenderOptions { image_mips: true, ..Default::default() } -} - -/// Has `n` at most `limit` objects inside it (counting stops there)? -fn small_node(n: &Node, limit: usize) -> bool { - fn count(n: &Node, left: &mut usize) -> bool { - for c in n.children().into_iter().flatten() { - if *left == 0 { - return false; - } - *left -= 1; - if !count(c, left) { - return false; - } - } - true - } - let mut left = limit; - count(n, &mut left) -} - -impl Thumbs { - /// Keep `img` (None: nothing drawn) as the thumbnail of row `key` showing `node`. - fn upload(&mut self, ctx: &egui::Context, key: (u64, NodeId), node: Weak, px: u32, img: Option<&vectorcraft_render::Rendered>, ms: f64) { - let color = img.map(crate::canvas::color_image); - let used = ctx.cumulative_pass_nr(); - let name = format!("layer-thumb-{}", key.1.0); - match self.entries.get_mut(&key) { - Some(e) => { - match (color, &mut e.tex) { - (Some(color), Some(tex)) => tex.set(color, egui::TextureOptions::LINEAR), - (Some(color), tex) => *tex = Some(ctx.load_texture(name, color, egui::TextureOptions::LINEAR)), - (None, tex) => *tex = None, - } - (e.node, e.px, e.ms, e.used) = (node, px, ms, used); - } - None => { - let tex = color.map(|color| ctx.load_texture(name, color, egui::TextureOptions::LINEAR)); - self.entries.insert(key, ThumbEntry { node, px, tex, ms, used }); - if self.entries.len() > MAX_THUMBS { - // Keep the three quarters drawn last. - let mut by_use: Vec = self.entries.values().map(|e| e.used).collect(); - by_use.sort_unstable(); - let cut = by_use.get(by_use.len() - MAX_THUMBS * 3 / 4).copied().unwrap_or(0); - self.entries.retain(|_, e| e.used >= cut); - } - } - } - } - - /// The texture of row `n` (`px` pixels square) to draw: made or updated now, or on the - /// worker (the old one is drawn meanwhile). Thumbnails shown stay during a drag and until - /// the document settles. - fn texture(&mut self, ctx: &egui::Context, src: &ThumbSource, n: &Arc, px: u32) -> Option { - if !self.worker_started { - self.worker_started = true; - self.worker = ThumbWorker::spawn(ctx, "thumbnails"); - } - let done = match self.worker.as_ref().map(ThumbWorker::poll) { - Some(Some(done)) => done, - Some(None) => { - // The thread is gone: render here from now on. - self.worker = None; - self.pending.clear(); - vec![] - } - None => vec![], - }; - for d in done { - let (key, node, px) = d.key; - if self.pending.get(&key).is_some_and(|(p, ppx)| p.ptr_eq(&node) && *ppx == px) { - self.pending.remove(&key); - } - self.upload(ctx, key, node, px, d.img.as_ref(), d.ms); - } - let key = (src.uid, n.id); - let pass = ctx.cumulative_pass_nr(); - let (fresh, light) = match self.entries.get_mut(&key) { - Some(e) => { - e.used = pass; - (std::ptr::eq(e.node.as_ptr(), Arc::as_ptr(n)) && e.px == px, Some(e.ms <= LIGHT_THUMB_MS)) - } - None => (false, None), - }; - let shown = self.entries.get(&key).and_then(|e| e.tex.clone()); - if fresh || self.pending.get(&key).is_some_and(|(p, ppx)| std::ptr::eq(p.as_ptr(), Arc::as_ptr(n)) && *ppx == px) { - return shown; - } - if light.is_some() && (src.busy || !self.settle.settled(ctx, src.uid, src.revision, ctx.input(|i| i.time))) { - return shown; - } - if self.spent.0 != pass { - self.spent = (pass, 0.0, 0); - } - let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT; - let light = light.unwrap_or_else(|| small_node(n, LIGHT_THUMB_NODES)) && in_budget; - if !light && let Some(w) = &self.worker { - let (doc, node) = (src.doc.clone(), n.clone()); - let job: ThumbJob = (key, Arc::downgrade(n), px); - if w.submit(key, job, Box::new(move |r| r.render_node_thumbnail_with(&doc, &node, px, None, &thumb_options()))) { - self.pending.insert(key, (Arc::downgrade(n), px)); - return shown; - } - self.worker = None; - self.pending.clear(); - } - if !in_budget { - ctx.request_repaint(); - return shown; - } - thread_local! { - static RENDERER: std::cell::RefCell = std::cell::RefCell::new(vectorcraft_render::Renderer::new()); - } - let t0 = crate::now_ms(); - let img = RENDERER.with(|r| r.borrow_mut().render_node_thumbnail_with(src.doc, n, px, None, &thumb_options())); - let ms = crate::now_ms() - t0; - self.spent.1 += ms; - self.spent.2 += 1; - self.renders += 1; - self.upload(ctx, key, Arc::downgrade(n), px, img.as_ref(), ms); - self.entries.get(&key).and_then(|e| e.tex.clone()) - } -} - -/// A real rendered thumbnail of row `n`, cached by node identity (unchanged nodes keep their `Arc` -/// allocation, so the address is a free change detector). Only rendered for visible rows; see -/// [`Thumbs::texture`] for when. -pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc, r: egui::Rect) -> bool { - if !ui.is_rect_visible(r) { - return true; - } - let ppp = ui.ctx().pixels_per_point(); - let px = ((r.width() * ppp).round() as u32).max(8); - match THUMBS.with(|c| c.borrow_mut().texture(ui.ctx(), src, n, px)) { - Some(t) => { - ui.painter().image(t.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); - true - } - None => false, - } -} - -/// Tiny vector thumbnail painted with egui (fallback when rendering isn't possible). -fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { - let Some(b) = n.visual_bounds() else { return }; - let s = ((r.width() - 3.0) as f64 / b.width().max(b.height()).max(1e-6)) as f32; - let off = r.center() - vec2(b.center().x as f32 * s, b.center().y as f32 * s); - let p = ui.painter().with_clip_rect(r); - let mut count = 0; - n.walk(&mut |c| { - if count > 40 { - return; - } - if let Some(pd) = c.path_data() { - count += 1; - let col = c.appearance.fill_paint().color().or(c.appearance.stroke_paint().color()).map(|cc| { - let [a, b2, d, _] = cc.to_rgba8(1.0); - Color32::from_rgb(a, b2, d) - }); - if let Some(bb) = pd.bounds() { - let rr = egui::Rect::from_min_max(off + vec2(bb.x0 as f32 * s, bb.y0 as f32 * s), off + vec2(bb.x1 as f32 * s, bb.y1 as f32 * s)); - p.rect_filled(rr, 0.0, col.unwrap_or(Color32::from_gray(120))); - } - } - }); -} - -/// A panel menu item: label, command, params, enabled, checked. -type MenuItem<'a> = (&'a str, &'a str, Value, bool, bool); - -/// The Layers panel's (≡) menu. -pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let Some(st) = app.session.active() else { return }; - let doc = st.doc.clone(); - let rows = st.highlighted_rows(); - let current = st.current_layer(); - let has_sel = !st.selection.is_empty(); - let isolated = st.isolation.is_some(); - let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; - let one_container = match targets.as_slice() { - [id] => doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. })), - _ => false, - }; - let layers_in: Vec<&Node> = targets.iter().filter_map(|id| doc.node(*id)).filter(|n| n.is_layer()).collect(); - let template = layers_in.first().is_some_and(|l| l.is_template()); - // The top-level layers holding the rows, and the others. - let kept: HashSet = targets.iter().filter_map(|id| doc.layer_of(*id)).collect(); - let others: Vec<&Node> = doc.layers.iter().map(|l| &**l).filter(|l| !kept.contains(&l.id)).collect(); - let mut all_layers = vec![]; - doc.walk(|n| { - if n.is_layer() { - all_layers.push(n); - } - }); - let mut items: Vec> = vec![ - Some(("New Layer…", "ui.newLayer", json!({}), true, false)), - Some(("New Sublayer…", "ui.newLayer", json!({"sublayer": true}), current.is_some(), false)), - Some(("Duplicate Selection", "layer.duplicate", json!({}), !targets.is_empty(), false)), - Some(("Delete Selection", "layer.delete", json!({}), !targets.is_empty(), false)), - Some(("Options for Selection…", "ui.layerOptions", json!({}), !targets.is_empty(), false)), - None, - Some(("Make/Release Clipping Mask", "layer.clippingMask.toggle", json!({}), true, false)), - None, - ]; - items.push(if isolated { - Some(("Exit Isolation Mode", "object.exitIsolation", json!({}), true, false)) - } else { - let id = targets.first().map(|i| i.0); - Some(("Enter Isolation Mode", "object.isolate", json!({ "id": id }), one_container, false)) - }); - items.extend([ - None, - Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), - // Sentinel: rendered as the Save Selection submenu (the Select menu's saved-selection items). - Some(("Save Selection", "", json!({}), true, false)), - None, - Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), - Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), - Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), - // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). - Some(("Collect for Export", "", json!({}), !targets.is_empty() || has_sel, false)), - Some(("Release to Layers (Sequence)", "layer.releaseToLayers", json!({}), one_container, false)), - Some(("Release to Layers (Build)", "layer.releaseToLayersBuild", json!({}), one_container, false)), - Some(("Reverse Order", "layer.reverse", json!({}), rows.len() >= 2, false)), - None, - Some(("Template", "layer.template", json!({}), !layers_in.is_empty(), template)), - ]); - // Each pair reads by state: Hide Others while another layer shows, else Show All Layers. - let any_other = |f: &dyn Fn(&Node) -> bool| others.iter().any(|l| f(l)); - items.push(Some(if any_other(&|l| l.visible) { - ("Hide Others", "layer.hideOthers", json!({}), true, false) - } else { - ("Show All Layers", "layer.showAll", json!({}), all_layers.iter().any(|l| !l.visible), false) - })); - items.push(Some(if any_other(&|l| !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { - ("Outline Others", "layer.outlineOthers", json!({}), true, false) - } else { - ( - "Preview All Layers", - "layer.previewAll", - json!({}), - all_layers.iter().any(|l| matches!(l.kind, NodeKind::Layer { preview: false, .. })), - false, - ) - })); - items.push(Some(if any_other(&|l| !l.locked) { - ("Lock Others", "layer.lockOthers", json!({}), true, false) - } else { - ("Unlock All Layers", "layer.unlockAll", json!({}), all_layers.iter().any(|l| l.locked), false) - })); - items.push(None); - let remembers = doc.paste_remembers_layers; - items.push(Some(("Paste Remembers Layers", "layer.pasteRemembersLayers", json!({"on": !remembers}), true, remembers))); - items.push(None); - items.push(Some(("Panel Options…", "ui.layersPanelOptions", json!({}), true, false))); - for item in items { - match item { - None => { - ui.separator(); - } - Some(("Collect for Export", _, _, enabled, _)) => { - collect_for_export_menu(ui, enabled, |multiple| { - let mut p = json!({ "multiple": multiple }); - if !targets.is_empty() { - p["ids"] = json!(targets.iter().map(|id| id.0).collect::>()); - } - app.run("assets.add", p).ok(); - }); - } - Some(("Save Selection", "", ..)) => save_selection_menu(app, ui), - Some((label, cmd, p, enabled, checked)) => { - if widgets::menu_item(ui, label, enabled, checked) { - let selected = last_selected(app); - app.run(cmd, p).ok(); - see_selection(app, ui, selected); - } - } - } - } -} - -/// Collect for Export › As Single Asset / As Multiple Assets (`assets.add`). -fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl FnMut(bool)) { - ui.add_enabled_ui(enabled, |ui| { - // English labels match the rest of this panel menu (and Object › Collect for Export). - ui.menu_button("Collect for Export", |ui| { - if widgets::menu_item(ui, "As Single Asset", true, false) { - run(false); - ui.close(); - } - if widgets::menu_item(ui, "As Multiple Assets", true, false) { - run(true); - ui.close(); - } - }); - }); -} - -/// Save Selection › Save Selection…, Update Selection and Edit Selection…, enabled and run as in -/// the Select menu, then the active document's saved selections: choosing one recalls it. -fn save_selection_menu(app: &mut VectorcraftApp, ui: &mut Ui) { - ui.menu_button(format!(" {}", tl!("Save Selection")), |ui| { - for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] { - if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) { - crate::menus::invoke(app, id, json!({})); - ui.close(); - } - } - let names: Vec = app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); - if !names.is_empty() { - ui.separator(); - } - for name in names { - if widgets::menu_item_name(ui, &name, true, false) { - let selected = last_selected(app); - app.run("select.recall", json!({ "name": name })).ok(); - see_selection(app, ui, selected); - ui.close(); - } - } - }); -} - -/// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel; -/// without `ids`, every row that holds others. -pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result { - let st = app.session.active().ok_or("no document open")?; - let open = p.get("open").and_then(Value::as_bool).unwrap_or(true); - let ids: Vec = match p.get("ids").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_u64).filter(|i| st.doc.node(NodeId(*i)).is_some()).collect(), - None => { - let mut v = vec![]; - st.doc.walk(|n| { - if n.is_container() { - v.push(n.id.0); - } - }); - v - } - }; - let st = app.session.active_mut().ok_or("no document open")?; - for i in &ids { - st.layers_open.set(NodeId(*i), open); - } - Ok(json!({ "count": ids.len() })) -} - -/// `ui.layersFilter {objects?, layers?}`: set the Layers panel's Filter, `objects` to the object -/// kinds ticked under By Object and `layers` to the row states ticked under By Layer, by parameter -/// name ([`ObjectKind::ALL`], [`RowState::ALL`]). A key left out or set to null keeps its list; any -/// other value that is not an array of those names is an error, and then nothing changes. -/// → {objects, layers} -pub fn set_filter(app: &mut VectorcraftApp, p: &Value) -> Result { - let objects = parse_names(p, "objects", &ObjectKind::ALL)?; - let layers = parse_names(p, "layers", &RowState::ALL)?; - let f = &mut app.ui.layers_filter; - if let Some(objects) = objects { - f.objects = objects; - } - if let Some(layers) = layers { - f.layers = layers; - } - Ok(json!({"objects": names(&ObjectKind::ALL, &f.objects), "layers": names(&RowState::ALL, &f.layers)})) -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs new file mode 100644 index 000000000..a8b0b3abe --- /dev/null +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -0,0 +1,297 @@ +use super::model::{ObjectKind, RowState, names, parse_names, toggled}; +use super::*; + +/// The Filter button, lit while a filter is on, and its popup: Clear All, the object kinds under +/// By Object and the row states under By Layer, each ticked or not. A tick, or Clear All, runs +/// `ui.layersFilter`; the popup stays open until a click outside it or Escape. +pub(super) fn filter_button(app: &mut VectorcraftApp, ui: &mut Ui) { + let f = app.ui.layers_filter.clone(); + let button = widgets::icon_button(ui, "dc-filter", tl!("Filter"), f.is_on(), 24.0); + let mut set = None; + egui::Popup::menu(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.horizontal(|ui| { + widgets::subheader(ui, tl!("Filter")); + ui.add_space(12.0); + if ui.add_enabled(f.is_on(), egui::Button::new(egui::RichText::new(tl!("Clear All")).size(12.5)).frame(false)).clicked() { + set = Some(json!({"objects": [], "layers": []})); + } + }); + widgets::subheader(ui, tl!("By Object")); + for (k, _) in ObjectKind::ALL { + if widgets::check(ui, k.label(), f.objects.contains(&k), true) { + set = Some(json!({"objects": toggled(&ObjectKind::ALL, &f.objects, k)})); + } + } + widgets::subheader(ui, tl!("By Layer")); + for (s, _) in RowState::ALL { + if widgets::check(ui, s.label(), f.layers.contains(&s), true) { + set = Some(json!({"layers": toggled(&RowState::ALL, &f.layers, s)})); + } + } + }); + if let Some(p) = set { + app.run("ui.layersFilter", p).ok(); + } +} + +/// The bottom bar: the layer count and the panel's buttons. +pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document, actions: &mut Vec<(String, Value)>) { + let t = &view.t; + let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); + ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider)); + let n = doc.layers.len(); + ui.painter().text( + bar.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tn(n as u64, "{n} Layer", "{n} Layers"), + egui::FontId::proportional(11.5), + t.text_dim, + ); + let m = ui.input(|i| i.modifiers); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + let trash = widgets::icon_button(&mut child, "trash-2", tl!("Delete Selection"), false, 24.0); + // A target circle dropped on the trash clears that appearance (dragged rows: below). Peek at + // the payload's type before taking it: egui's take drops a payload of another type. + if trash.dnd_hover_payload::().is_some() + && let Some(d) = trash.dnd_release_payload::() + && let PanelDrag::Appearance(id) = *d + { + actions.push(("appearance.clear".into(), json!({"ids": [id.0]}))); + } else if trash.clicked() { + if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) { + actions.push(("edit.clear".into(), json!({}))); + } else { + actions.push(("layer.delete".into(), json!({}))); + } + } + // Create New Layer: Alt asks for its options first, Ctrl/Cmd puts it on top of every layer. + let new_layer = widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0); + if new_layer.clicked() { + if m.alt { + actions.push(("ui.newLayer".into(), json!({}))); + } else { + actions.push(("layer.new".into(), json!({"top": m.command}))); + } + } + // Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are + // outlined while rows (or, on the trash, a target circle) hang over them. + for (button, cmd) in [(&trash, "layer.delete"), (&new_layer, "layer.duplicate")] { + if let Some(d) = button.dnd_release_payload::() + && let LayersDrag::Rows(ids) = &*d + { + actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))); + } + let over = button.dnd_hover_payload::().is_some_and(|d| matches!(*d, LayersDrag::Rows(_))) + || (cmd == "layer.delete" && button.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_)))); + if over { + child.painter().rect_stroke(button.rect, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + } + if widgets::icon_button(&mut child, "plus", tl!("Create New Sublayer"), false, 24.0).clicked() { + actions.push(if m.alt { ("ui.newLayer".into(), json!({"sublayer": true})) } else { ("layer.newSublayer".into(), json!({})) }); + } + if widgets::icon_button(&mut child, "frame", tl!("Make/Release Clipping Mask"), false, 24.0).clicked() { + actions.push(("layer.clippingMask.toggle".into(), json!({}))); + } + if widgets::icon_button(&mut child, "search", tl!("Locate Object"), false, 24.0).clicked() { + actions.push(("layer.locate".into(), json!({}))); + } +} + +/// A panel menu item: label, command, params, enabled, checked. +type MenuItem<'a> = (&'a str, &'a str, Value, bool, bool); + +/// The Layers panel's (≡) menu. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let rows = st.highlighted_rows(); + let current = st.current_layer(); + let has_sel = !st.selection.is_empty(); + let isolated = st.isolation.is_some(); + let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; + let one_container = match targets.as_slice() { + [id] => doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. })), + _ => false, + }; + let layers_in: Vec<&Node> = targets.iter().filter_map(|id| doc.node(*id)).filter(|n| n.is_layer()).collect(); + let template = layers_in.first().is_some_and(|l| l.is_template()); + // The top-level layers holding the rows, and the others. + let kept: HashSet = targets.iter().filter_map(|id| doc.layer_of(*id)).collect(); + let others: Vec<&Node> = doc.layers.iter().map(|l| &**l).filter(|l| !kept.contains(&l.id)).collect(); + let mut all_layers = vec![]; + doc.walk(|n| { + if n.is_layer() { + all_layers.push(n); + } + }); + let mut items: Vec> = vec![ + Some(("New Layer…", "ui.newLayer", json!({}), true, false)), + Some(("New Sublayer…", "ui.newLayer", json!({"sublayer": true}), current.is_some(), false)), + Some(("Duplicate Selection", "layer.duplicate", json!({}), !targets.is_empty(), false)), + Some(("Delete Selection", "layer.delete", json!({}), !targets.is_empty(), false)), + Some(("Options for Selection…", "ui.layerOptions", json!({}), !targets.is_empty(), false)), + None, + Some(("Make/Release Clipping Mask", "layer.clippingMask.toggle", json!({}), true, false)), + None, + ]; + items.push(if isolated { + Some(("Exit Isolation Mode", "object.exitIsolation", json!({}), true, false)) + } else { + let id = targets.first().map(|i| i.0); + Some(("Enter Isolation Mode", "object.isolate", json!({ "id": id }), one_container, false)) + }); + items.extend([ + None, + Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), + // Sentinel: rendered as the Save Selection submenu (the Select menu's saved-selection items). + Some(("Save Selection", "", json!({}), true, false)), + None, + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), + Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), + Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), + // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). + Some(("Collect for Export", "", json!({}), !targets.is_empty() || has_sel, false)), + Some(("Release to Layers (Sequence)", "layer.releaseToLayers", json!({}), one_container, false)), + Some(("Release to Layers (Build)", "layer.releaseToLayersBuild", json!({}), one_container, false)), + Some(("Reverse Order", "layer.reverse", json!({}), rows.len() >= 2, false)), + None, + Some(("Template", "layer.template", json!({}), !layers_in.is_empty(), template)), + ]); + // Each pair reads by state: Hide Others while another layer shows, else Show All Layers. + let any_other = |f: &dyn Fn(&Node) -> bool| others.iter().any(|l| f(l)); + items.push(Some(if any_other(&|l| l.visible) { + ("Hide Others", "layer.hideOthers", json!({}), true, false) + } else { + ("Show All Layers", "layer.showAll", json!({}), all_layers.iter().any(|l| !l.visible), false) + })); + items.push(Some(if any_other(&|l| !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { + ("Outline Others", "layer.outlineOthers", json!({}), true, false) + } else { + ( + "Preview All Layers", + "layer.previewAll", + json!({}), + all_layers.iter().any(|l| matches!(l.kind, NodeKind::Layer { preview: false, .. })), + false, + ) + })); + items.push(Some(if any_other(&|l| !l.locked) { + ("Lock Others", "layer.lockOthers", json!({}), true, false) + } else { + ("Unlock All Layers", "layer.unlockAll", json!({}), all_layers.iter().any(|l| l.locked), false) + })); + items.push(None); + let remembers = doc.paste_remembers_layers; + items.push(Some(("Paste Remembers Layers", "layer.pasteRemembersLayers", json!({"on": !remembers}), true, remembers))); + items.push(None); + items.push(Some(("Panel Options…", "ui.layersPanelOptions", json!({}), true, false))); + for item in items { + match item { + None => { + ui.separator(); + } + Some(("Collect for Export", _, _, enabled, _)) => { + collect_for_export_menu(ui, enabled, |multiple| { + let mut p = json!({ "multiple": multiple }); + if !targets.is_empty() { + p["ids"] = json!(targets.iter().map(|id| id.0).collect::>()); + } + app.run("assets.add", p).ok(); + }); + } + Some(("Save Selection", "", ..)) => save_selection_menu(app, ui), + Some((label, cmd, p, enabled, checked)) => { + if widgets::menu_item(ui, label, enabled, checked) { + let selected = last_selected(app); + app.run(cmd, p).ok(); + see_selection(app, ui, selected); + } + } + } + } +} + +/// Collect for Export › As Single Asset / As Multiple Assets (`assets.add`). +pub(super) fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl FnMut(bool)) { + ui.add_enabled_ui(enabled, |ui| { + // English labels match the rest of this panel menu (and Object › Collect for Export). + ui.menu_button("Collect for Export", |ui| { + if widgets::menu_item(ui, "As Single Asset", true, false) { + run(false); + ui.close(); + } + if widgets::menu_item(ui, "As Multiple Assets", true, false) { + run(true); + ui.close(); + } + }); + }); +} + +/// Save Selection › Save Selection…, Update Selection and Edit Selection…, enabled and run as in +/// the Select menu, then the active document's saved selections: choosing one recalls it. +fn save_selection_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.menu_button(format!(" {}", tl!("Save Selection")), |ui| { + for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] { + if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) { + crate::menus::invoke(app, id, json!({})); + ui.close(); + } + } + let names: Vec = app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + if !names.is_empty() { + ui.separator(); + } + for name in names { + if widgets::menu_item_name(ui, &name, true, false) { + let selected = last_selected(app); + app.run("select.recall", json!({ "name": name })).ok(); + see_selection(app, ui, selected); + ui.close(); + } + } + }); +} + +/// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel; +/// without `ids`, every row that holds others. +pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result { + let st = app.session.active().ok_or("no document open")?; + let open = p.get("open").and_then(Value::as_bool).unwrap_or(true); + let ids: Vec = match p.get("ids").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_u64).filter(|i| st.doc.node(NodeId(*i)).is_some()).collect(), + None => { + let mut v = vec![]; + st.doc.walk(|n| { + if n.is_container() { + v.push(n.id.0); + } + }); + v + } + }; + let st = app.session.active_mut().ok_or("no document open")?; + for i in &ids { + st.layers_open.set(NodeId(*i), open); + } + Ok(json!({ "count": ids.len() })) +} + +/// `ui.layersFilter {objects?, layers?}`: set the Layers panel's Filter, `objects` to the object +/// kinds ticked under By Object and `layers` to the row states ticked under By Layer, by parameter +/// name ([`ObjectKind::ALL`], [`RowState::ALL`]). A key left out or set to null keeps its list; any +/// other value that is not an array of those names is an error, and then nothing changes. +/// → {objects, layers} +pub fn set_filter(app: &mut VectorcraftApp, p: &Value) -> Result { + let objects = parse_names(p, "objects", &ObjectKind::ALL)?; + let layers = parse_names(p, "layers", &RowState::ALL)?; + let f = &mut app.ui.layers_filter; + if let Some(objects) = objects { + f.objects = objects; + } + if let Some(layers) = layers { + f.layers = layers; + } + Ok(json!({"objects": names(&ObjectKind::ALL, &f.objects), "layers": names(&RowState::ALL, &f.layers)})) +} diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs new file mode 100644 index 000000000..642aec542 --- /dev/null +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -0,0 +1,303 @@ +use super::rows::key; +use super::*; + +/// Where a dragged row goes relative to the row it is dropped on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Place { + Above, + Below, + Inside, +} + +impl Place { + pub(super) fn id(self) -> &'static str { + match self { + Place::Above => "above", + Place::Below => "below", + Place::Inside => "inside", + } + } +} + +/// The name painted for a row in `lang`: a generated `` name is translated, anything else is +/// user data (an unnamed text object shows its text, which can look like ``; only an empty +/// one is called ``). +pub(super) fn painted_name(n: &Node, name: &str, lang: crate::i18n::Lang) -> String { + let generated = n.name.is_none() && !matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| !r.text.is_empty())); + if generated && let Some(inner) = name.strip_prefix('<').and_then(|s| s.strip_suffix('>')) { + return format!("<{}>", crate::i18n::tr(lang, inner)); + } + name.to_string() +} + +/// The name the panel shows for `n` in `lang`: its XML ID while Identify Objects By is XML ID, +/// otherwise [`painted_name`]. +pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang) -> String { + let xml_id = app.session.prefs.identify_objects_by == "xmlId"; + let label = n.identify_label(xml_id); + if xml_id { label } else { painted_name(n, &label, lang) } +} + +/// Whether `n` gets rows for what it holds (a compound path's paths are one object). +pub(super) fn opens(n: &Node, opts: &PanelOptions) -> bool { + match n.kind { + NodeKind::Compound { .. } => false, + _ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())), + } +} + +/// One row of the panel. +#[derive(Clone)] +pub(super) struct Row { + pub(super) node: Arc, + /// Nesting level (0: a top-level layer). + pub(super) depth: usize, + /// A clipping path: its group or layer clips. + pub(super) clip_path: bool, + /// It holds rows ([`opens`]), and shows them. + pub(super) opens: bool, + pub(super) open: bool, + /// The colour of its layer (its own, for a layer). + pub(super) colour: Color32, + /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. + pub(super) dim: bool, + /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. + pub(super) guide: Option, +} + +/// An object kind under the Filter's By Object (`ui.layersFilter`'s `objects`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectKind { + Paths, + Shapes, + Text, + Images, + Effects, + Groups, + LinkedFiles, + Guides, +} + +impl ObjectKind { + /// Every kind with its parameter name, in the popup's order. + pub const ALL: [(Self, &'static str); 8] = [ + (Self::Paths, "paths"), + (Self::Shapes, "shapes"), + (Self::Text, "text"), + (Self::Images, "images"), + (Self::Effects, "effects"), + (Self::Groups, "groups"), + (Self::LinkedFiles, "linkedFiles"), + (Self::Guides, "guides"), + ]; + + pub(super) fn label(self) -> &'static str { + match self { + Self::Paths => tl!("Paths"), + Self::Shapes => tl!("Shapes"), + Self::Text => tl!("Text"), + Self::Images => tl!("Images"), + Self::Effects => tl!("Effects"), + Self::Groups => tl!("Groups"), + Self::LinkedFiles => tl!("Linked Files"), + Self::Guides => tl!("Guides"), + } + } + + /// Whether `n`'s row is of this kind. Paths take plain paths, the paths whose corners Live + /// Corners cut and compound paths; Shapes take live rectangles, ellipses, polygons and lines; + /// Paths and Shapes leave out paths made into guides. Effects take a row with an effect, shown + /// or hidden, on itself or on one of its fills or strokes. Linked Files take linked images and + /// placed documents. + fn takes(self, n: &Node) -> bool { + match (self, &n.kind) { + (Self::Guides, NodeKind::Path { guide, .. }) => *guide, + (Self::Paths | Self::Shapes, NodeKind::Path { guide: true, .. }) => false, + (Self::Paths, NodeKind::Path { live, .. }) => live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. })), + (Self::Shapes, NodeKind::Path { live, .. }) => live.as_deref().is_some_and(|l| !matches!(l, LiveShape::Path { .. })), + (Self::Paths, NodeKind::Compound { .. }) + | (Self::Text, NodeKind::Text(_)) + | (Self::Images, NodeKind::Image(_)) + | (Self::Groups, NodeKind::Group { .. }) + | (Self::LinkedFiles, NodeKind::PlacedDocument(_)) => true, + (Self::LinkedFiles, NodeKind::Image(im)) => im.link.is_some(), + (Self::Effects, _) => !n.appearance.effects.is_empty() || n.appearance.items.iter().any(|i| !i.effects().is_empty()), + _ => false, + } + } +} + +/// A row state under the Filter's By Layer (`ui.layersFilter`'s `layers`): the row's own lock or +/// eye, as its lock and eye columns show them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RowState { + Locked, + Hidden, +} + +impl RowState { + /// Every state with its parameter name, in the popup's order. + pub const ALL: [(Self, &'static str); 2] = [(Self::Locked, "locked"), (Self::Hidden, "hidden")]; + + pub(super) fn label(self) -> &'static str { + match self { + Self::Locked => tl!("Locked"), + Self::Hidden => tl!("Hidden"), + } + } + + fn takes(self, n: &Node) -> bool { + match self { + Self::Locked => n.locked, + Self::Hidden => !n.visible, + } + } +} + +/// The Layers panel's Filter (`ui.layersFilter`): the object kinds ticked under By Object and the +/// row states ticked under By Layer, each in the popup's order. A section with nothing ticked takes +/// every row. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Filter { + pub objects: Vec, + pub layers: Vec, +} + +impl Filter { + /// Whether anything is ticked. + pub fn is_on(&self) -> bool { + !self.objects.is_empty() || !self.layers.is_empty() + } + + /// Whether `n`'s row is of a ticked kind and in a ticked state. + fn keeps(&self, n: &Node) -> bool { + (self.objects.is_empty() || self.objects.iter().any(|k| k.takes(n))) && (self.layers.is_empty() || self.layers.iter().any(|s| s.takes(n))) + } + + /// Whether the ruler guide rows stay: with no filter on, or with Guides ticked and no row state + /// ticked. + fn keeps_guides(&self) -> bool { + !self.is_on() || (self.objects.contains(&ObjectKind::Guides) && self.layers.is_empty()) + } +} + +/// The parameter names of the ones of `all` in `on`, in `all`'s order. +pub(super) fn names(all: &[(T, &'static str)], on: &[T]) -> Vec<&'static str> { + all.iter().filter(|(t, _)| on.contains(t)).map(|(_, name)| *name).collect() +} + +/// The parameter names of `on` with `t` added, or taken out when it is in `on`. +pub(super) fn toggled(all: &[(T, &'static str)], on: &[T], t: T) -> Vec<&'static str> { + all.iter().filter(|(x, _)| if *x == t { !on.contains(x) } else { on.contains(x) }).map(|(_, name)| *name).collect() +} + +/// The list `key` of `p` read by the parameter names of `all`, in `all`'s order: `None` when `p` +/// leaves it out or sets it to null, and an error when it is anything other than an array of +/// those names. +pub(super) fn parse_names(p: &Value, key: &str, all: &[(T, &'static str)]) -> Result>, String> { + let given = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(v) => v + .as_array() + .and_then(|a| a.iter().map(|name| all.iter().find(|(_, n)| Some(*n) == name.as_str()).map(|(t, _)| *t)).collect::>>()), + }; + let Some(given) = given else { + let names: Vec<&str> = all.iter().map(|(_, n)| *n).collect(); + return Err(format!("`{key}` must be an array of these names: {}", names.join(", "))); + }; + Ok(Some(all.iter().map(|(t, _)| *t).filter(|t| given.contains(t)).collect())) +} + +/// What the rows were listed from: listed again when any of it changes. +#[derive(Clone, PartialEq)] +struct RowsKey { + uid: u64, + revision: u64, + open: u64, + layers_only: bool, + query: String, + filter: Filter, +} + +/// The rows the panel shows, top down: the layers (only the opacity mask's while one is edited) +/// with the rows of the open ones inside them. A search or a filter shows every row whose name +/// contains `query` (lowercase) and that `filter` keeps, and every row holding one. +pub(super) fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec { + let mask_layer = doc.mask_edit.map(|m| m.layer); + let mut out = vec![]; + for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, doc), &mut out); + } + out +} + +/// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds +/// it (the layer's colour, and whether it is visible). +pub(super) fn push_rows( + n: &Arc, + depth: usize, + clip_path: bool, + (colour, shown): (Color32, bool), + cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document), + out: &mut Vec, +) { + let (open_rows, opts, query, filter, doc) = cx; + if opts.layers_only && !n.is_layer() { + return; + } + let colour = match &n.kind { + NodeKind::Layer { color, .. } => { + let [r, g, b] = color.rgb(); + Color32::from_rgb(r, g, b) + } + _ => colour, + }; + let shown = shown && n.visible; + let searching = !query.is_empty(); + let narrowing = searching || filter.is_on(); + // A layer's ruler guides are rows in it, above its art (not while searching, nor while the + // filter leaves them out). + let guides: Vec = + if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + let opens = opens(n, opts) || !guides.is_empty(); + let open = opens && (narrowing || open_rows.contains(n.id)); + let at = out.len(); + out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None }); + if open { + for i in guides.into_iter().rev() { + out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) }); + } + } + if open && let Some(children) = n.children() { + for (i, c) in children.iter().enumerate().rev() { + push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out); + } + } + // Searching or filtering, a row stays for a match of its own (its name holds the search text + // and the filter keeps it) or for a row it keeps inside it. + if narrowing && out.len() == at + 1 && !(filter.keeps(n) && (!searching || n.display_name().to_lowercase().contains(query))) { + out.truncate(at); + } +} + +/// The rows of the active document as [`list_rows`] lists them, listed again only when the +/// document, its open rows, the search, the filter or Show Layers Only changed. +pub(super) fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, query: &str, filter: &Filter) -> Arc> { + let now = RowsKey { + uid: st.uid, + revision: st.revision, + open: st.layers_open.generation(), + layers_only: opts.layers_only, + query: query.to_string(), + filter: filter.clone(), + }; + let id = key("rows"); + if let Some((was, rows)) = ui.data(|d| d.get_temp::<(RowsKey, Arc>)>(id)) + && was == now + { + return rows; + } + let rows = Arc::new(list_rows(&st.doc, &st.layers_open, opts, query, filter)); + ui.data_mut(|d| d.insert_temp(id, (now, rows.clone()))); + rows +} diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs new file mode 100644 index 000000000..7c92566aa --- /dev/null +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -0,0 +1,520 @@ +use super::controls::collect_for_export_menu; +use super::model::{Place, Row, opens, painted_name}; +use super::thumbs::{node_arc, real_thumb, thumb}; +use super::*; + +/// Stable id for the layers panel's per-name UI state. +pub(super) fn key(name: &str) -> egui::Id { + egui::Id::unique(("layers-panel", name)) +} + +/// Temp-memory key for the layer rename editor. +pub(super) fn rename_id() -> egui::Id { + egui::Id::unique("layers-rename") +} + +/// Ghost rows are drawn at this opacity. +const GHOST_OPACITY: f32 = 0.5; + +/// The colour of the layer holding `id`. +fn layer_colour(doc: &Document, id: NodeId) -> Color32 { + let [r, g, b] = doc.layer_color(id); + Color32::from_rgb(r, g, b) +} + +/// The layer colour bar of row `n` (rect `r`) at `x`: wider on layers. +fn colour_bar(ui: &Ui, n: &Node, r: egui::Rect, x: f32, colour: Color32) { + let w = if n.is_layer() { 4.0 } else { 2.0 }; + ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(w, r.height())), 0.0, colour); +} + +/// The thumbnail of row `n` (rect `r`) at `x`; returns where it went. +fn row_thumb(ui: &Ui, view: &View, n: &Arc, r: egui::Rect, x: f32) -> egui::Rect { + let s = (view.h - 4.0).min(40.0); + let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s)); + ui.painter().rect_filled(th, 0.0, Color32::WHITE); + ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, view.t.border), StrokeKind::Outside); + if !real_thumb(ui, &view.thumbs, n, th) { + thumb(ui, n, th); + } + th +} + +/// The dragged row `src`, dimmed, following the pointer at `pos` above everything else: its +/// colour bar, thumbnail and name. +pub(super) fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) { + let (doc, t, h) = (view.doc, &view.t, view.h); + let Some(n) = node_arc(doc, src) else { return }; + let n = &n; + let (grab, width, depth) = ui.data(|d| d.get_temp::<(egui::Vec2, f32, usize)>(key("grab"))).unwrap_or((vec2(h, h / 2.0), 240.0, 0)); + // A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in. + let r = egui::Rect::from_min_size(pos - grab, vec2(width, h)); + let id = key("ghost"); + let mut ui = Ui::new(ui.ctx().clone(), id, egui::UiBuilder::new().layer_id(egui::LayerId::new(egui::Order::Tooltip, id)).max_rect(r)); + ui.set_opacity(GHOST_OPACITY); + let p = ui.painter(); + p.rect_filled(r, 0.0, t.row_selected); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + let mut x = r.left() + 2.0 * COLUMN + 2.0; + colour_bar(&ui, n, r, x, layer_colour(doc, src)); + x += 22.0 + depth as f32 * INDENT; + if view.opts.thumb(n) { + x = row_thumb(&ui, view, n, r, x).right() + 4.0; + } + let label = n.identify_label(view.identify_xml_id); + let name = if view.identify_xml_id { label } else { painted_name(n, &label, crate::i18n::current()) }; + ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text); +} + +/// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift +/// (highlight the rows from the last one clicked down to it). +pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) { + let Some((id, m)) = out.click.take() else { return }; + let anchor: Option = ui.data(|d| d.get_temp(key("anchor"))); + ui.data_mut(|d| d.insert_temp(key("focus"), true)); + let at = |id: NodeId| list.iter().position(|r| r.node.id == id); + if m.shift + && let Some(a) = anchor.or(view.rows.last().copied()) + && let (Some(i), Some(j)) = (at(a), at(id)) + { + let range: Vec = list.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.node.id.0).collect(); + out.actions.push(("layer.highlight".into(), json!({"ids": range, "mode": if m.command { "add" } else { "set" }}))); + return; + } + ui.data_mut(|d| d.insert_temp(key("anchor"), id)); + if m.command { + out.actions.push(("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"}))); + } else { + out.actions.push(("layer.setCurrent".into(), json!({"id": id.0}))); + } +} + +/// Whether row `n` can take `drag` inside it. +fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { + match n.kind { + NodeKind::Layer { .. } => true, + NodeKind::Group { .. } => match drag { + LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)), + LayersDrag::Art => true, + LayersDrag::Guides(_) => false, + }, + _ => false, + } +} + +/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection +/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it +/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged. +fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { + let (t, h) = (&view.t, view.h); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); + let Some(g) = view.doc.guides.get(i) else { return }; + let selected = view.guides.contains(&i); + let editable = view.doc.guide_editable(g); + if editable && resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); + } + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + let mut x = r.left() + 2.0 * COLUMN + 2.0; + colour_bar(ui, &item.node, r, x, item.colour); + x += 6.0 + item.depth as f32 * INDENT + 16.0; + // The guide mark: a short line across or down, in the guides' colour. + let c = egui::pos2(x + 8.0, r.center().y); + let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) }; + ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent)); + x += 22.0; + let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") }); + let color = if item.dim { t.text_dim } else { t.text }; + ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color); + let col_x = r.right() - 43.5; + ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); + if selected { + let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0)); + ui.painter().rect_filled(q, 0.0, item.colour); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); + } + if editable && resp.clicked() { + let m = ui.input(|i| i.modifiers); + out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); + } + // Dragged: the selected guides when this is one of them, else this one, to another layer's row. + if editable && resp.drag_started() { + let guides = if selected { view.guides.clone() } else { vec![i] }; + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + // Guides dropped here go to this guide's layer. + if let Some(drag) = resp.dnd_hover_payload::() + && let LayersDrag::Guides(guides) = &*drag + { + guide_drop(ui, view, guides, item.node.id, r, &resp, out); + } +} + +/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the +/// release puts them on that layer (`guide.setLayer`, one undo step). +fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside); + if resp.dnd_release_payload::().is_some() { + out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0}))); + } +} + +/// The widgets of row `item`. +pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { + if let Some(i) = item.guide { + return guide_row(ui, view, item, i, out); + } + let n = &*item.node; + let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour); + let doc = view.doc; + let t = &view.t; + let h = view.h; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); + if view.reveal == Some(n.id) { + ui.scroll_to_rect(r, None); + } + let is_sel = view.sel.contains(&n.id); + let child_sel = !is_sel + && n.children().is_some_and(|_| { + let mut any = false; + n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id)); + any + }); + // Highlighted rows (else the current layer's). + let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) }; + if highlighted { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); + } + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + if n.is_layer() && Some(n.id) == view.current { + // Current-layer marker: a small triangle in the top-right corner. + let c = r.right_top(); + ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE)); + } + eye_and_lock(ui, view, n, r, out); + // Layer colour bar (every row shows the colour of its layer). + let mut x = r.left() + 2.0 * COLUMN + 2.0; + colour_bar(ui, n, r, x, color); + x += 6.0 + depth as f32 * INDENT; + // Disclosure triangle; Alt opens or closes everything inside too. + if has_children { + let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0)); + let dresp = ui.interact(dr, ui.scope_id().with(("disc", n.id.0)), Sense::click()); + icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim); + if dresp.clicked() { + out.toggle.push((n.id, !open)); + if ui.input(|i| i.modifiers.alt) { + n.walk(&mut |c| { + if c.id != n.id && opens(c, &view.opts) { + out.toggle.push((c.id, !open)); + } + }); + } + } + } + x += 16.0; + // Thumbnail. + let thumb_rect = view.opts.thumb(n).then(|| { + let th = row_thumb(ui, view, &item.node, r, x); + x = th.right() + 4.0; + th + }); + // Name (Preferences › Units › Identify Objects By: Object Name or XML ID). + let xml_id = view.identify_xml_id; + let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "".to_string() } else { n.identify_label(xml_id) }; + let font = egui::FontId::proportional(13.0); + let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id())); + let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0)); + let name_end; + match renaming { + Some((rid, mut buf)) if rid == n.id.0 => { + let field = rename_id().with("field"); + let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); + let te = child.add(egui::TextEdit::singleline(&mut buf).id(field).desired_width(name_rect.width()).font(font.clone())); + // Focus the field as it opens. Asking every frame took the focus back from Enter, + // Escape or a click elsewhere, so the rename never ended. + if !te.has_focus() && !te.lost_focus() { + te.request_focus(); + } + if te.lost_focus() { + ui.data_mut(|d| d.remove::<(u64, String)>(rename_id())); + if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf}))); + } + } else { + // Shift+Enter renames it and keeps the field open. + if kept && buf != name { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()}))); + } + ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf))); + } + name_end = name_rect.right(); + } + _ => { + let painter = ui.painter().with_clip_rect(name_rect); + // Generated names ("", "") are translated where painted; the stored name stays English. + // XML IDs stay as ids (not translated). + let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { + tl!("").to_string() + } else if xml_id { + name.clone() + } else { + painted_name(n, &n.display_name(), crate::i18n::current()) + }; + let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if item.dim { t.text_dim } else { t.text }); + name_end = text.right(); + // A clipping path's name is underlined, a masked object's with a dashed line. + if clip_path { + painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); + } else if n.extras().mask.is_some() { + painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0)); + } + } + } + // Target circle and selection square. + let col_x = r.right() - 43.5; + ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); + let tc = egui::pos2(r.right() - TARGET_X, r.center().y); + let tresp = target_circle(ui, n, tc, h, view.target.map_or(is_sel, |id| id == n.id), &mut out.actions, t); + let sq = egui::pos2(r.right() - SQUARE_X, r.center().y); + if is_sel { + let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0)); + ui.painter().rect_filled(q, 0.0, color); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); + } else if child_sel { + ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color); + } + let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.scope_id().with(("selsq", n.id.0)), Sense::click_and_drag()); + // Dragging the selected-art square carries the selection to another row. + if (is_sel || child_sel) && sq_resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art); + } + let m = ui.input(|i| i.modifiers); + if tresp.clicked() { + out.actions.push(("layer.target".into(), json!({"id": n.id.0, "add": m.shift}))); + } else if sq_resp.clicked() { + out.actions.push(("layer.selectAll".into(), json!({"id": n.id.0, "add": m.shift}))); + } else if resp.clicked() { + out.click = Some((n.id, m)); + } + if resp.double_clicked() { + let on_name = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x <= name_end.max(name_rect.left() + 20.0) && p.x >= name_rect.left()); + let on_thumb = thumb_rect.is_some_and(|th| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| th.contains(p))); + if on_name && !on_thumb { + ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, n.identify_label(view.identify_xml_id)))); + } else { + out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]}))); + } + } + // Row menu: Collect for Export on this row (or every highlighted row when this is one of them). + let collect_ids: Vec = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] }; + resp.context_menu(|ui| { + collect_for_export_menu(ui, true, |multiple| { + out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple }))); + }); + }); + // Dragging rows: the highlighted rows when this is one of them, else this row. + if resp.drag_started() && view.column.is_none() { + let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] }; + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids)); + let grab = resp.interact_pointer_pos().map_or(vec2(h, h / 2.0), |p| p - r.min); + ui.data_mut(|d| d.insert_temp(key("grab"), (grab, r.width(), depth))); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + drop_target(ui, view, n, r, x - 16.0, open, &resp, out); +} + +/// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer +/// but the one holding the row; when they all are hidden (locked) already, Show All Layers (Unlock +/// All Layers). Each is one undo step. +fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) { + let own = doc.layer_of(n.id); + let mut others = doc.layers.iter().filter(|l| Some(l.id) != own); + let cmd = match (eye, if eye { others.any(|l| l.visible) } else { others.any(|l| !l.locked) }) { + (true, true) => "layer.hideOthers", + (true, false) => "layer.showAll", + (false, true) => "layer.lockOthers", + (false, false) => "layer.unlockAll", + }; + // Show All and Unlock All take no rows. + let params = if cmd.ends_with("Others") { json!({ "ids": [n.id.0] }) } else { json!({}) }; + (cmd.into(), params) +} + +/// Ctrl/Cmd+Alt-clicking row `n`'s eye runs Outline Others, which puts every top-level layer but the +/// one holding the row in Outline and puts that one in Preview. When all the other layers are in +/// Outline already, it runs Preview All Layers. Each is one undo step. +fn outline_others_action(doc: &Document, n: &Node) -> (String, Value) { + let own = doc.layer_of(n.id); + if doc.layers.iter().any(|l| Some(l.id) != own && !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { + ("layer.outlineOthers".into(), json!({ "ids": [n.id.0] })) + } else { + // Preview All takes no rows. + ("layer.previewAll".into(), json!({})) + } +} + +/// The eye and lock columns of row `n` at `r`. +fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) { + let t = &view.t; + let h = view.h; + let eye = egui::Rect::from_min_size(r.min, vec2(COLUMN, h)); + let lock = egui::Rect::from_min_size(r.min + vec2(COLUMN, 0.0), vec2(COLUMN, h)); + ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border)); + ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border)); + let preview = !matches!(n.kind, NodeKind::Layer { preview: false, .. }); + let er = ui.interact(eye, ui.scope_id().with(("eye", n.id.0)), Sense::click_and_drag()); + if n.visible { + let icon = egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)); + if n.is_template() { + // A template layer shows the template mark instead of the eye. + icons::paint(ui, "shapes", icon, t.icon); + } else if preview { + icons::paint(ui, "eye", icon, t.icon); + } else { + // Outline view: a hollow eye. + icons::paint(ui, "eye", icon, t.text_dim); + ui.painter().circle_stroke(eye.center(), 7.5, Stroke::new(1.0, t.text_dim)); + } + } + let lr = ui.interact(lock, ui.scope_id().with(("lock", n.id.0)), Sense::click_and_drag()); + if n.locked { + icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon); + } else if lr.hovered() { + icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled); + } + let m = ui.input(|i| i.modifiers); + if er.clicked() { + if m.alt && m.command { + out.actions.push(outline_others_action(view.doc, n)); + } else if m.alt { + out.actions.push(others_action(view.doc, n, true)); + } else if m.command && n.is_layer() { + // Ctrl/Cmd-click: Preview ↔ Outline for this layer. + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "preview": !preview}))); + } else { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "visible": !n.visible}))); + } + } + if lr.clicked() && m.alt { + out.actions.push(others_action(view.doc, n, false)); + } else if lr.clicked() { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "locked": !n.locked}))); + } + // Dragging down a column gives every row it passes this row's new state. + if er.drag_started() { + out.column = Some(Some(ColumnDrag { eye: true, value: !n.visible, ids: vec![n.id.0] })); + } else if lr.drag_started() { + out.column = Some(Some(ColumnDrag { eye: false, value: !n.locked, ids: vec![n.id.0] })); + } else if let Some(c) = out.column.clone().unwrap_or_else(|| view.column.clone()) + && !c.ids.contains(&n.id.0) + && ui.input(|i| i.pointer.primary_down() && i.pointer.interact_pos().is_some_and(|p| p.y >= r.top() && p.y < r.bottom())) + { + let mut c = c; + c.ids.push(n.id.0); + out.column = Some(Some(c)); + } +} + +/// Row `n` at `r` as a drop target for dragged rows or the selected-art square: shows where they +/// would go (a line above or below it, or a box around it for inside) and moves them on release +/// (Alt copies). `indent` is where the row's content starts. +#[allow(clippy::too_many_arguments)] +fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: bool, resp: &egui::Response, out: &mut Out) { + let Some(drag) = resp.dnd_hover_payload::() else { return }; + let doc = view.doc; + let moving: Vec = match &*drag { + LayersDrag::Rows(ids) => ids.clone(), + LayersDrag::Art => view.sel.iter().copied().collect(), + LayersDrag::Guides(guides) => { + // Guides go to the layer of the row (the row's own, for a layer). + if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } { + guide_drop(ui, view, guides, layer, r, resp, out); + } + return; + } + }; + // Never into itself or a row inside it. + let around = doc.ancestry(n.id).unwrap_or_default(); + if moving.is_empty() || moving.iter().any(|m| around.contains(m)) { + return; + } + let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + let rel = (p.y - r.top()) / r.height().max(1.0); + let inside = takes(n, &drag, doc); + let place = match &*drag { + LayersDrag::Art if inside => Place::Inside, + LayersDrag::Art => Place::Above, + LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside, + LayersDrag::Rows(_) if rel < 0.5 => Place::Above, + LayersDrag::Rows(_) | LayersDrag::Guides(_) => Place::Below, + }; + let stroke = Stroke::new(2.0, view.t.accent); + match place { + Place::Above => { + ui.painter().line_segment([egui::pos2(indent, r.top()), egui::pos2(r.right(), r.top())], stroke); + } + Place::Below => { + ui.painter().line_segment([egui::pos2(indent, r.bottom()), egui::pos2(r.right(), r.bottom())], stroke); + } + Place::Inside => { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside); + } + } + if resp.dnd_release_payload::().is_some() { + let copy = ui.input(|i| i.modifiers.alt); + let ids: Vec = match &*drag { + // The selection's top objects, as Arrange and Group take them. + LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(), + LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(), + LayersDrag::Guides(_) => return, + }; + out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy}))); + } +} + +/// The target circle of `n`'s row at `c`: a ring, doubled while `n` is targeted, filled when `n` +/// has an appearance or transparency of its own. Its response is clicked to target `n`; dragging +/// it carries `n`'s appearance ([`PanelDrag::Appearance`]) onto another row's circle (Alt +/// copies it) or the trash. +fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response { + let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.scope_id().with(("target", n.id.0)), Sense::click_and_drag()); + widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id)); + let held = resp.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id)); + if let Some(d) = resp.dnd_release_payload::() + && let PanelDrag::Appearance(source) = *d + && source != n.id + { + let copy = ui.input(|i| i.modifiers.alt); + actions.push(("appearance.transfer".into(), json!({"source": source.0, "target": n.id.0, "copy": copy}))); + } + let ring = if held { t.accent } else { t.icon }; + ui.painter().circle_stroke(c, 5.0, Stroke::new(if held { 1.5 } else { 1.0 }, ring)); + if targeted { + ui.painter().circle_stroke(c, 2.8, Stroke::new(1.0, t.icon)); + } + if has_styled_target(n) { + ui.painter().circle_filled(c, 3.2, t.icon); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + resp +} + +/// Whether an object's target circle is filled: its appearance is not basic (a group or layer: +/// it has fills, strokes or effects of its own) or its transparency (opacity, blend mode, +/// isolation, knockout or an opacity mask) is not the default. +fn has_styled_target(n: &Node) -> bool { + !n.appearance.is_basic() + || (matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.appearance.items.is_empty()) + || !n.has_default_transparency() +} diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs new file mode 100644 index 000000000..cd9850212 --- /dev/null +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -0,0 +1,247 @@ +use super::*; + +/// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in +/// progress (the thumbnails shown then stay until it ends). +pub(crate) struct ThumbSource<'a> { + pub uid: u64, + pub revision: u64, + pub doc: &'a Arc, + pub busy: bool, +} + +impl<'a> ThumbSource<'a> { + /// The thumbnails of the document `st` holds (`doc`, its document). + pub(crate) fn of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, doc: &'a Arc) -> Self { + Self { uid: st.uid, revision: st.revision, doc, busy: st.interaction.is_some() || app.session.tool_busy() } + } +} + +/// Node `id` of `doc`, as the `Arc` its parent holds. +pub(crate) fn node_arc(doc: &Document, id: NodeId) -> Option> { + let path = doc.index_path(id)?; + let (first, rest) = path.split_first()?; + let mut cur = doc.layers.get(*first)?; + for &i in rest { + cur = cur.children()?.get(i)?; + } + Some(cur.clone()) +} + +/// A row thumbnail's image. +struct ThumbEntry { + /// The node it shows. Weak, so the cache doesn't keep old art alive, but its allocation stays + /// and no other node can come to have its address. + node: Weak, + px: u32, + /// None: the node draws nothing. + tex: Option, + /// How long it took to render (ms). + ms: f64, + /// The pass it was last drawn in. + used: u64, +} + +/// A row thumbnail job: the row (document uid, node), the node and the size rendered. +type ThumbJob = ((u64, NodeId), Weak, u32); + +#[derive(Default)] +pub(super) struct Thumbs { + entries: HashMap<(u64, NodeId), ThumbEntry>, + /// What the worker is rendering, by row. + pending: HashMap<(u64, NodeId), (Weak, u32)>, + worker: Option>, + worker_started: bool, + settle: Settle, + /// (pass, ms, count) rendered on the UI thread in that pass. + spent: (u64, f64, u32), + /// Thumbnails rendered on the UI thread (tests count them). + pub(super) renders: u64, +} + +thread_local! { + pub(super) static THUMBS: crate::graphics::TexCache = crate::graphics::TexCache::default(); +} + +/// The most row thumbnails kept: past it the ones drawn longest ago go. +const MAX_THUMBS: usize = 512; + +/// Rendering a thumbnail that took longer than this (ms) again goes to the worker. +const LIGHT_THUMB_MS: f64 = 4.0; + +/// A node with more objects than this inside has its first thumbnail made on the worker. +const LIGHT_THUMB_NODES: usize = 200; + +/// At most this long (ms) and this many thumbnails are rendered on the UI thread in a pass; the +/// rest go to the worker, or wait for the next pass where there is none. +const THUMB_BUDGET_MS: f64 = 8.0; + +const THUMB_BUDGET_COUNT: u32 = 16; + +/// How row thumbnails render: images far smaller than they are sample their mip levels. +pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions { + vectorcraft_render::RenderOptions { image_mips: true, ..Default::default() } +} + +/// Has `n` at most `limit` objects inside it (counting stops there)? +fn small_node(n: &Node, limit: usize) -> bool { + fn count(n: &Node, left: &mut usize) -> bool { + for c in n.children().into_iter().flatten() { + if *left == 0 { + return false; + } + *left -= 1; + if !count(c, left) { + return false; + } + } + true + } + let mut left = limit; + count(n, &mut left) +} + +impl Thumbs { + /// Keep `img` (None: nothing drawn) as the thumbnail of row `key` showing `node`. + fn upload(&mut self, ctx: &egui::Context, key: (u64, NodeId), node: Weak, px: u32, img: Option<&vectorcraft_render::Rendered>, ms: f64) { + let color = img.map(crate::canvas::color_image); + let used = ctx.cumulative_pass_nr(); + let name = format!("layer-thumb-{}", key.1.0); + match self.entries.get_mut(&key) { + Some(e) => { + match (color, &mut e.tex) { + (Some(color), Some(tex)) => tex.set(color, egui::TextureOptions::LINEAR), + (Some(color), tex) => *tex = Some(ctx.load_texture(name, color, egui::TextureOptions::LINEAR)), + (None, tex) => *tex = None, + } + (e.node, e.px, e.ms, e.used) = (node, px, ms, used); + } + None => { + let tex = color.map(|color| ctx.load_texture(name, color, egui::TextureOptions::LINEAR)); + self.entries.insert(key, ThumbEntry { node, px, tex, ms, used }); + if self.entries.len() > MAX_THUMBS { + // Keep the three quarters drawn last. + let mut by_use: Vec = self.entries.values().map(|e| e.used).collect(); + by_use.sort_unstable(); + let cut = by_use.get(by_use.len() - MAX_THUMBS * 3 / 4).copied().unwrap_or(0); + self.entries.retain(|_, e| e.used >= cut); + } + } + } + } + + /// The texture of row `n` (`px` pixels square) to draw: made or updated now, or on the + /// worker (the old one is drawn meanwhile). Thumbnails shown stay during a drag and until + /// the document settles. + fn texture(&mut self, ctx: &egui::Context, src: &ThumbSource, n: &Arc, px: u32) -> Option { + if !self.worker_started { + self.worker_started = true; + self.worker = ThumbWorker::spawn(ctx, "thumbnails"); + } + let done = match self.worker.as_ref().map(ThumbWorker::poll) { + Some(Some(done)) => done, + Some(None) => { + // The thread is gone: render here from now on. + self.worker = None; + self.pending.clear(); + vec![] + } + None => vec![], + }; + for d in done { + let (key, node, px) = d.key; + if self.pending.get(&key).is_some_and(|(p, ppx)| p.ptr_eq(&node) && *ppx == px) { + self.pending.remove(&key); + } + self.upload(ctx, key, node, px, d.img.as_ref(), d.ms); + } + let key = (src.uid, n.id); + let pass = ctx.cumulative_pass_nr(); + let (fresh, light) = match self.entries.get_mut(&key) { + Some(e) => { + e.used = pass; + (std::ptr::eq(e.node.as_ptr(), Arc::as_ptr(n)) && e.px == px, Some(e.ms <= LIGHT_THUMB_MS)) + } + None => (false, None), + }; + let shown = self.entries.get(&key).and_then(|e| e.tex.clone()); + if fresh || self.pending.get(&key).is_some_and(|(p, ppx)| std::ptr::eq(p.as_ptr(), Arc::as_ptr(n)) && *ppx == px) { + return shown; + } + if light.is_some() && (src.busy || !self.settle.settled(ctx, src.uid, src.revision, ctx.input(|i| i.time))) { + return shown; + } + if self.spent.0 != pass { + self.spent = (pass, 0.0, 0); + } + let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT; + let light = light.unwrap_or_else(|| small_node(n, LIGHT_THUMB_NODES)) && in_budget; + if !light && let Some(w) = &self.worker { + let (doc, node) = (src.doc.clone(), n.clone()); + let job: ThumbJob = (key, Arc::downgrade(n), px); + if w.submit(key, job, Box::new(move |r| r.render_node_thumbnail_with(&doc, &node, px, None, &thumb_options()))) { + self.pending.insert(key, (Arc::downgrade(n), px)); + return shown; + } + self.worker = None; + self.pending.clear(); + } + if !in_budget { + ctx.request_repaint(); + return shown; + } + thread_local! { + static RENDERER: std::cell::RefCell = std::cell::RefCell::new(vectorcraft_render::Renderer::new()); + } + let t0 = crate::now_ms(); + let img = RENDERER.with(|r| r.borrow_mut().render_node_thumbnail_with(src.doc, n, px, None, &thumb_options())); + let ms = crate::now_ms() - t0; + self.spent.1 += ms; + self.spent.2 += 1; + self.renders += 1; + self.upload(ctx, key, Arc::downgrade(n), px, img.as_ref(), ms); + self.entries.get(&key).and_then(|e| e.tex.clone()) + } +} + +/// A real rendered thumbnail of row `n`, cached by node identity (unchanged nodes keep their `Arc` +/// allocation, so the address is a free change detector). Only rendered for visible rows; see +/// [`Thumbs::texture`] for when. +pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc, r: egui::Rect) -> bool { + if !ui.is_rect_visible(r) { + return true; + } + let ppp = ui.ctx().pixels_per_point(); + let px = ((r.width() * ppp).round() as u32).max(8); + match THUMBS.with(|c| c.borrow_mut().texture(ui.ctx(), src, n, px)) { + Some(t) => { + ui.painter().image(t.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + true + } + None => false, + } +} + +/// Tiny vector thumbnail painted with egui (fallback when rendering isn't possible). +pub(super) fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { + let Some(b) = n.visual_bounds() else { return }; + let s = ((r.width() - 3.0) as f64 / b.width().max(b.height()).max(1e-6)) as f32; + let off = r.center() - vec2(b.center().x as f32 * s, b.center().y as f32 * s); + let p = ui.painter().with_clip_rect(r); + let mut count = 0; + n.walk(&mut |c| { + if count > 40 { + return; + } + if let Some(pd) = c.path_data() { + count += 1; + let col = c.appearance.fill_paint().color().or(c.appearance.stroke_paint().color()).map(|cc| { + let [a, b2, d, _] = cc.to_rgba8(1.0); + Color32::from_rgb(a, b2, d) + }); + if let Some(bb) = pd.bounds() { + let rr = egui::Rect::from_min_max(off + vec2(bb.x0 as f32 * s, bb.y0 as f32 * s), off + vec2(bb.x1 as f32 * s, bb.y1 as f32 * s)); + p.rect_filled(rr, 0.0, col.unwrap_or(Color32::from_gray(120))); + } + } + }); +} From 556825b363b8c2415a2946e6d4a67a77ea5af5ae Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:51 +0000 Subject: [PATCH 143/199] refactor(ui-egui): move panels/swatches.rs items to panels/swatches/{actions,body,dnd,libraries,tiles}.rs --- crates/ui-egui/src/panels/swatches.rs | 1125 +---------------- crates/ui-egui/src/panels/swatches/actions.rs | 199 +++ crates/ui-egui/src/panels/swatches/body.rs | 194 +++ crates/ui-egui/src/panels/swatches/dnd.rs | 341 +++++ .../ui-egui/src/panels/swatches/libraries.rs | 183 +++ crates/ui-egui/src/panels/swatches/tiles.rs | 208 +++ 6 files changed, 1149 insertions(+), 1101 deletions(-) create mode 100644 crates/ui-egui/src/panels/swatches/actions.rs create mode 100644 crates/ui-egui/src/panels/swatches/body.rs create mode 100644 crates/ui-egui/src/panels/swatches/dnd.rs create mode 100644 crates/ui-egui/src/panels/swatches/libraries.rs create mode 100644 crates/ui-egui/src/panels/swatches/tiles.rs diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 66cbce4c5..34262e71c 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -22,6 +22,30 @@ use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, SwatchRows, menu_item, swatch_tile}; use crate::{VectorcraftApp, icons}; +mod actions; +mod body; +mod dnd; +mod libraries; +mod tiles; + +pub use actions::menu; +#[cfg(test)] +use actions::{deletable, delete, mergeable, new_swatch, selected_group}; +use body::body; +#[cfg(test)] +use body::{active_swatch, find_id, focus_swatch, kept_swatch}; +use dnd::drag_of; +#[cfg(test)] +use dnd::highlighted; +pub(crate) use dnd::{click_selection, drag_preview}; +pub(crate) use libraries::{ + DOCUMENT_SWATCHES, SwatchLibraries, USER_SLOT, colour_libraries, limit_key, limit_name, load_library, open_library, other_library, user_library, + window_menu, +}; +#[cfg(test)] +pub(crate) use tiles::describe; +pub(crate) use tiles::{draw_folder, folder_tile, global_mark, list_row, list_row_name, tile_grid}; + /// View modes of the swatch list (and of the library panels: `ui.libraryPanel {view}`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -175,483 +199,6 @@ fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { super::apply_click(app, ui, params); } -/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). -fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) { - use std::collections::HashMap; - type Key = (String, Vec, String, u32); - thread_local! { - static CACHE: crate::graphics::TexCache>> = crate::graphics::TexCache::default(); - } - let Paint::Pattern { pattern, .. } = paint else { return }; - let Some(st) = app.session.active() else { return }; - let Some(def) = st.doc.pattern(pattern) else { return }; - let px = (r.width() * ui.ctx().pixels_per_point()).round().max(4.0) as u32; - let key = ( - pattern.clone(), - def.art.iter().map(|a| std::sync::Arc::as_ptr(a) as usize).collect(), - format!("{:?}{:?}{:?}", def.tile, def.tile_type, def.overlap), - px, - ); - let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).unwrap_or_else(|| { - let tex = vectorcraft_render::render_pattern_swatch(&st.doc, pattern, px).map(|img| { - let color = crate::canvas::color_image(&img); - ui.ctx().load_texture(format!("pattern-swatch-{pattern}-{px}"), color, egui::TextureOptions::LINEAR) - }); - CACHE.with(|c| { - let mut c = c.borrow_mut(); - if c.len() > 256 { - c.clear(); - } - c.insert(key, tex.clone()); - }); - tex - }); - if let Some(tex) = tex { - ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); - } -} - -fn draw_registration(ui: &Ui, r: Rect) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - let c = r.center(); - let rad = r.width() * 0.28; - let s = Stroke::new(1.0, egui::Color32::BLACK); - ui.painter().circle_stroke(c, rad, s); - ui.painter().line_segment([pos2(r.left() + 2.0, c.y), pos2(r.right() - 2.0, c.y)], s); - ui.painter().line_segment([pos2(c.x, r.top() + 2.0), pos2(c.x, r.bottom() - 2.0)], s); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); -} - -pub(crate) fn draw_folder(ui: &Ui, r: Rect) { - let t = Tokens::get(ui.ctx()); - icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); -} - -/// The ink colours of the CMYK glyph (cyan, magenta, yellow, black) and the RGB glyph's bars. -const CMYK_INKS: [Color32; 4] = [Color32::from_rgb(0, 174, 239), Color32::from_rgb(236, 0, 140), Color32::from_rgb(255, 242, 0), Color32::BLACK]; -const RGB_BARS: [Color32; 3] = [Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 200, 0), Color32::from_rgb(0, 0, 255)]; -/// Lab's opponent axes: green | red (a) over blue | yellow (b). -const LAB_AXES: [Color32; 4] = - [Color32::from_rgb(0, 166, 81), Color32::from_rgb(230, 30, 60), Color32::from_rgb(0, 90, 220), Color32::from_rgb(250, 205, 0)]; - -/// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK"). -pub(crate) fn describe(paint: &Paint, global: bool, spot: bool) -> String { - match paint { - Paint::Solid { color, .. } => { - let kind = if spot { - tl!("Spot Color") - } else if global { - tl!("Global Process Color") - } else { - tl!("Process Color") - }; - crate::i18n::fmt(tl!("{kind}, {model}"), &[("kind", kind), ("model", color.model_name())]) - } - Paint::Gradient(g) => crate::i18n::fmt( - tl!("{kind} Gradient"), - &[("kind", crate::i18n::tr_ctx(crate::i18n::current(), "gradient-kind", g.gradient.kind.label()))], - ), - Paint::Pattern { .. } => tl!("Pattern").into(), - Paint::None => tl!("None").into(), - } -} - -/// The kind and colour-mode icons at the right of list row `r`: spot (a dot in a ring), global (a -/// square with a filled corner) or process colours (a square), gradients and patterns; then a -/// colour's model (CMYK as four ink quarters, RGB as three bars, Gray as a grey square, Lab as its -/// two opponent axes). -pub(crate) fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bool) { - let t = Tokens::get(ui.ctx()); - let p = ui.painter(); - let mode = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0)); - let kind = mode.translate(vec2(-16.0, 0.0)); - let line = Stroke::new(1.0, t.icon); - match paint { - Paint::Solid { color, .. } => { - if spot { - p.circle_stroke(kind.center(), 4.5, line); - p.circle_filled(kind.center(), 1.8, t.icon); - } else { - p.rect_stroke(kind, 0.0, line, StrokeKind::Inside); - if global { - let c = kind.right_bottom(); - p.add(Shape::convex_polygon(vec![c, c - vec2(6.0, 0.0), c - vec2(0.0, 6.0)], t.icon, Stroke::NONE)); - } - } - let quarters = |colors: [Color32; 4]| { - let q = mode.size() / 2.0; - for (i, c) in colors.into_iter().enumerate() { - p.rect_filled(Rect::from_min_size(mode.min + vec2((i % 2) as f32 * q.x, (i / 2) as f32 * q.y), q), 0.0, c); - } - }; - match color { - Color::Cmyk { .. } => quarters(CMYK_INKS), - Color::Lab { .. } => quarters(LAB_AXES), - Color::Rgb { .. } => { - let w = mode.width() / 3.0; - for (i, bar) in RGB_BARS.into_iter().enumerate() { - p.rect_filled(Rect::from_min_size(mode.min + vec2(i as f32 * w, 0.0), vec2(w, mode.height())), 0.0, bar); - } - } - Color::Gray { .. } => { - p.rect_filled(mode, 0.0, Color32::from_gray(128)); - } - } - p.rect_stroke(mode, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - } - Paint::Gradient(g) => { - let icon = match g.gradient.kind { - GradientKind::Linear => "dc-grad-linear", - GradientKind::Radial => "dc-grad-radial", - GradientKind::Freeform => "dc-grad-freeform", - }; - icons::paint(ui, icon, kind.expand(1.0), t.icon); - } - Paint::Pattern { .. } => icons::paint(ui, "grid-3x3", kind.expand(1.0), t.icon), - Paint::None => {} - } -} - -/// The swatches and colour groups selected in the panel, in click order, without names that no -/// longer exist (deleted, renamed or undone). -fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec { - let Some(st) = app.session.active() else { return vec![] }; - let mut names: Vec = pstate(ui.ctx(), "swatch-selected"); - names.retain(|n| n == REGISTRATION || st.doc.swatch_name_taken(n)); - names -} - -/// The names the panel highlights: `sel` plus the swatches of selected colour groups (clicking a -/// folder selects the whole group). -fn highlighted<'a>(d: &'a Document, sel: &'a [String]) -> Vec<&'a str> { - let mut out: Vec<&str> = sel.iter().map(String::as_str).collect(); - for g in d.swatch_groups.iter().filter(|g| sel.contains(&g.name)) { - out.extend(g.swatches.iter().map(|w| w.name.as_str())); - } - out -} - -/// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked -/// name (kept in panel state `anchor_key`) over `order` (the names as displayed), a plain click -/// selects it alone. -pub(crate) fn click_selection(ui: &Ui, anchor_key: &str, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { - let anchor: String = pstate(ui.ctx(), anchor_key); - let pos = |n: &str| order.iter().position(|o| *o == n); - if m.shift - && let (Some(a), Some(b)) = (pos(&anchor), pos(name)) - { - for n in &order[a.min(b)..=a.max(b)] { - if !sel.iter().any(|s| s == n) { - sel.push(n.to_string()); - } - } - return sel; - } - set_pstate(ui.ctx(), anchor_key, name.to_string()); - if !m.command { - return vec![name.to_string()]; - } - match sel.iter().position(|s| s == name) { - Some(i) => { - sel.remove(i); - } - None => sel.push(name.to_string()), - } - sel -} - -/// A drag released on the panel. -enum Drop { - /// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or - /// at the end (`None`). With Alt held, `with` names the swatch grabbed: dropped on swatch - /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it - /// gives that swatch its paint instead of moving. - Move { names: Vec, target: Option, after: bool, with: Option }, - /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row - /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch - /// `target` when that is of its kind and not a tint swatch. - New { paint: Paint, target: Option, replace: bool }, - /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). - Pattern { ids: Vec }, -} - -impl Drop { - /// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows - /// it carries (with Alt held, `replace`, a swatch grabbed may replace `target`), make a swatch of - /// a paint dragged from elsewhere, or a pattern swatch of art dragged off the canvas (appearances: - /// nothing). - fn of(d: &PanelDrag, target: Option, after: bool, replace: bool) -> Option { - match d { - PanelDrag::Paint { rows: Some(r), .. } => { - let with = (replace && !r.groups).then(|| r.grabbed.clone()); - Some(Drop::Move { names: r.names.clone(), target, after, with }) - } - PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target, replace }), - PanelDrag::Art(ids) => Some(Drop::Pattern { ids: ids.iter().map(|id| id.0).collect() }), - _ => None, - } - } -} - -/// The drag held over `resp`, if the panel takes it (paints and art dragged off the canvas, not the -/// Appearance panel's thumbnail). -fn held(resp: &Response) -> Option> { - resp.dnd_hover_payload::().filter(|d| matches!(**d, PanelDrag::Paint { .. } | PanelDrag::Art(_))) -} - -/// The drag a tile or row of `e` starts, moving rows `names`. Colour groups have no paint of their -/// own (the chip under the pointer draws Registration's mark for Registration). -fn drag_of(e: &Entry, names: Vec) -> PanelDrag { - let paint = match e { - Entry::Swatch { paint, .. } => paint.clone(), - Entry::Registration => Paint::registration(), - Entry::Folder(_) => Paint::None, - }; - let rows = SwatchRows { grabbed: e.name().to_string(), names, groups: e.is_folder() }; - PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows), library: None } -} - -/// What the tiles or rows saw this frame. -#[derive(Default)] -struct TileEvents { - clicked: Option<(Entry, egui::Modifiers)>, - /// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing), - /// or Edit or Apply Color Group on a colour group. - edit: Option, - drop: Option, - /// A drag is held over a tile or row. - over: bool, -} - -/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e` -/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it -/// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it. -fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) { - let name = e.name(); - if matches!(e, Entry::Swatch { .. } | Entry::Folder(_)) && resp.double_clicked() { - ev.edit = Some(e.clone()); - } - if resp.drag_started() { - let with_sel = sel.iter().any(|s| s == name); - let names = items - .iter() - .filter(|x| x.is_folder() == e.is_folder() && (x.name() == name || (with_sel && sel.iter().any(|s| s == x.name())))) - .map(|x| x.name().to_string()) - .collect(); - egui::DragAndDrop::set_payload(ui.ctx(), drag_of(e, names)); - } - tile_drop(ui, &resp, e, list, ev); - // The default swatches' names show in the UI's language; the document keeps them as they are. - let shown = crate::i18n::t_name("swatch", name); - let resp = match e { - Entry::Folder(_) => resp.on_hover_text(crate::i18n::fmt(tl!("Color Group: {name}"), &[("name", shown)])), - Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| { - ui.label(format!("{shown} ({})", describe(paint, *global, *spot))); - }), - _ => resp.on_hover_text(shown), - }; - if resp.clicked() { - ev.clicked = Some((e.clone(), ui.input(|i| i.modifiers))); - } -} - -/// A drag held over the tile or row `resp` of entry `e` shows where it would land (before or after -/// it by the pointer's half, or into a folder); a release there sets the drop. -fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvents) { - let Some(d) = held(resp) else { return }; - let PanelDrag::Paint { rows, .. } = &*d else { return }; - let name = e.name(); - ev.over = true; - // Swatches dropped on one of themselves stay where they are. - if rows.as_ref().is_some_and(|r| r.names.iter().any(|n| n == name)) { - resp.dnd_release_payload::(); - return; - } - let t = Tokens::get(ui.ctx()); - let r = resp.rect; - let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center()); - let after = if list { at.y > r.center().y } else { at.x > r.center().x }; - // A paint from elsewhere, or swatches over a folder, go into it; otherwise a bar marks the slot. - if rows.as_ref().is_none_or(|d| e.is_folder() && !d.groups) { - ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); - } else if list { - let y = if after { r.bottom() } else { r.top() }; - ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); - } else { - let x = if after { r.right() + 0.75 } else { r.left() - 0.75 }; - ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent)); - } - if resp.dnd_release_payload::().is_some() { - ev.drop = Drop::of(&d, Some(name.to_string()), after, super::alt_held(ui)); - } -} - -/// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped -/// swatches (colour groups to the end of the groups); the list is outlined while one is held there. -fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) { - let Some(d) = held(zone) else { return }; - if !ev.over { - ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - } - if zone.dnd_release_payload::().is_some() { - ev.drop = Drop::of(&d, None, false, false); - } -} - -/// The colour group `name` is, or the one swatch `name` belongs to. -fn group_of(d: &Document, name: &str) -> Option { - if d.swatch_groups.iter().any(|g| g.name == name) { - return Some(name.to_string()); - } - d.swatch_group_of(name).map(|g| d.swatch_groups[g].name.clone()) -} - -/// `swatch.move` params for dropping `names` before or `after` row `target`, into folder `target` -/// (swatches), or at the end (`target` None); `None` when nothing would move. Only solid colours go -/// into colour groups; None and Registration stay put. -fn move_params(d: &Document, names: &[String], target: Option<&str>, after: bool) -> Option { - if target.is_some_and(|t| names.iter().any(|n| n == t)) { - return None; - } - let group_index = |n: &str| d.swatch_groups.iter().position(|g| g.name == n); - if names.iter().all(|n| group_index(n).is_some()) { - let to = match target { - None => d.swatch_groups.len(), - // Over a group's folder or one of its swatches: next to that group; else before the first. - Some(t) => group_index(t).or_else(|| d.swatch_group_of(t)).map_or(0, |i| i + usize::from(after)), - }; - return Some(json!({"names": names, "to": to})); - } - let (group, to) = match target { - Some(t) if group_index(t).is_some() => (Some(t), None), - Some(REGISTRATION) => (None, Some(d.swatches.iter().position(|w| !w.paint.is_none()).unwrap_or(0))), - Some(t) => { - let (group, list) = match d.swatch_group_of(t) { - Some(g) => (Some(d.swatch_groups[g].name.as_str()), &d.swatch_groups[g].swatches), - None => (None, &*d.swatches), - }; - (group, list.iter().position(|w| w.name == t).map(|i| i + usize::from(after))) - } - None => (None, None), - }; - let movable: Vec<&String> = - names.iter().filter(|n| d.swatch(n).is_some_and(|w| !w.paint.is_none() && (group.is_none() || w.paint.color().is_some()))).collect(); - (!movable.is_empty()).then(|| json!({"names": movable, "to": to, "group": group})) -} - -/// Are both paints colours, gradients or patterns? -fn same_kind(a: &Paint, b: &Paint) -> bool { - std::mem::discriminant(a) == std::mem::discriminant(b) && !a.is_none() -} - -/// Can swatch `with` give its paint to swatch `target`? Both are swatches of one kind, neither is -/// None or Registration, and `target` is not a tint swatch (a tint swatch only takes a new name). -fn replaces(d: &Document, with: &str, target: &str) -> bool { - match (d.swatch(with), d.swatch(target)) { - (Some(w), Some(t)) => with != target && !w.is_reserved() && !t.is_reserved() && t.tint_of().is_none() && same_kind(&t.paint, &w.paint), - _ => false, - } -} - -/// Run a drop on the panel: move the swatches, give the paint of a swatch Alt-dropped on another of -/// its kind to that one, or make a swatch of the proxy's paint (a color dropped on a folder or a -/// grouped swatch joins that group). -fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { - let Some(d) = app.session.active().map(|st| &st.doc) else { return }; - let (cmd, params) = match drop { - // Alt: the swatch grabbed replaces the color, gradient or pattern of the swatch of its kind - // it is dropped on, which keeps its name and place. - Drop::Move { with: Some(w), target: Some(t), .. } if replaces(d, &w, &t) => ("swatch.edit", json!({"name": t, "paint": {"swatch": w}})), - Drop::Move { names, target, after, .. } => match move_params(d, &names, target.as_deref(), after) { - Some(p) => ("swatch.move", p), - None => return, - }, - // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. - Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { - ("swatch.edit", json!({"name": t, "paint": super::swatch_paint_params(&paint)})) - } - Drop::New { paint, target, .. } => { - let mut p = super::swatch_paint_params(&paint); - if paint.color().is_some() - && let Some(g) = target.and_then(|t| group_of(d, &t)) - { - p["group"] = json!(g); - } - ("swatch.new", p) - } - Drop::Pattern { ids } => ("object.pattern.make", json!({"ids": ids, "edit": false})), - }; - if let Err(e) = app.run(cmd, params) { - app.status(e); - } -} - -/// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, -/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance -/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the -/// thumbnail of a dragged graphic style. -pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { - let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; - let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); - let (paint, registration) = match &*d { - // Colour groups paint nothing. - PanelDrag::Paint { params, .. } if params.is_null() => return, - PanelDrag::Paint { paint, rows, .. } => (std::borrow::Cow::Borrowed(paint), rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION)), - PanelDrag::Appearance(id) => match fill_of(id) { - Some(p) => (std::borrow::Cow::Owned(p), false), - None => return, - }, - PanelDrag::Art(ids) => match ids.first().and_then(fill_of) { - Some(p) => (std::borrow::Cow::Owned(p), false), - None => return, - }, - PanelDrag::GraphicStyle(name) => return pointer_chip(ctx, |ui, r| super::graphic_styles::paint_style(app, ui, r, name)), - PanelDrag::Symbol(name) => match app.session.active() { - Some(st) => return pointer_chip(ctx, |ui, r| super::symbols::chip(ui, &st.doc, &super::symbols::document_symbols(st), r, name)), - None => return, - }, - PanelDrag::LibrarySymbol { library, name } => match vectorcraft_engine::cmd::symbollib::library(&app.session, library) { - Some((_, lib)) => return pointer_chip(ctx, |ui, r| super::symbols::chip(ui, &lib.doc, &super::symbols::library_symbols(&lib), r, name)), - None => return, - }, - PanelDrag::Brush { name, .. } => match app.session.active() { - Some(st) => return pointer_chip(ctx, |ui, r| super::brushes::document_chip(ui, r, st, name)), - None => return, - }, - PanelDrag::LibraryBrush { library, name } => match vectorcraft_engine::cmd::brushlib::library(&app.session, library) { - Some((_, lib)) => return pointer_chip(ctx, |ui, r| super::brushes::library_chip(ui, r, &lib, name)), - None => return, - }, - PanelDrag::LibraryGraphic { library, item } => return pointer_chip(ctx, |ui, r| super::libraries::chip(app, ui, r, library, item)), - PanelDrag::LibraryItem { .. } | PanelDrag::LibraryGroup { .. } => return, - }; - pointer_chip(ctx, |ui, r| { - if registration { - draw_registration(ui, r); - } else { - swatch_tile(ui, r, &paint, false, false); - pattern_thumb(app, ui, r.shrink(1.0), &paint); - } - }); -} - -/// A 16-point chip `draw` paints, following the pointer. -fn pointer_chip(ctx: &egui::Context, draw: impl FnOnce(&Ui, Rect)) { - let Some(at) = ctx.pointer_hover_pos() else { return }; - let area = egui::Area::new(egui::Id::unique("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); - area.interactable(false).show(ctx, |ui| { - let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); - draw(ui, r); - }); -} - -/// The id of the find field's text (shown with the panel menu's Show Find Field). -fn find_id() -> egui::Id { - egui::Id::unique("swatch-find") -} - pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { body(app, ui, TILE); } @@ -666,629 +213,5 @@ pub(crate) fn popover(app: &mut VectorcraftApp, ui: &mut Ui) { const TILE: &str = "swatch-tile"; const POPOVER_TILE: &str = "swatch-pop-tile"; -/// The panel's body; `salt` keys its tiles (see [`tile_id`]). -fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { - if app.session.active().is_none() { - super::empty_state(ui, "swatch-book", tl!("No document"), tl!("Open a document to see its swatches.")); - return; - } - let view: View = pstate(ui.ctx(), "swatch-view"); - let kind: Kind = pstate(ui.ctx(), "swatch-kind"); - // Top row: proxy at left, list / grid view toggles at right. - ui.horizontal(|ui| { - super::proxy(app, ui, 34.0); - ui.add_space(ui.available_width() - 58.0); - if widgets::icon_button(ui, "dc-list-view", tl!("Show List View"), view.is_list(), 26.0).clicked() { - set_pstate(ui.ctx(), "swatch-view", View::SmallList); - } - if widgets::icon_button(ui, "dc-grid-view", tl!("Show Thumbnail View"), !view.is_list(), 26.0).clicked() { - set_pstate(ui.ctx(), "swatch-view", View::MediumThumb); - } - }); - ui.add_space(4.0); - // A recent colour paints the active proxy (Alt: the inactive one), as a swatch does. - if let Some(c) = super::recent_colors_row(app, ui) { - super::apply_click(app, ui, json!({"color": super::color_json(&c)})); - } - widgets::divider(ui); - widgets::subheader(ui, tl!("Swatch Tiles")); - let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() }; - let items = entries(app, kind, &query); - let active_swatch = kept_swatch(app, ui, active_swatch(app)); - let focus = focus_swatch(app, ui, active_swatch.as_deref()); - let selected = selection(app, ui); - // Without a selection the swatch of the active paint is highlighted. - let fallback: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] }; - let sel = if selected.is_empty() { &fallback } else { &selected }; - let lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default(); - let is_sel = |name: &str| lit.contains(&name); - let mut ev = TileEvents::default(); - widgets::list_box(ui, |ui| { - let max_height = if view == View::LargeThumb { 200.0 } else { 150.0 }; - let out = egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(max_height).show(ui, |ui| { - ui.set_width(ui.available_width()); - if view.is_list() { - for e in &items { - let name = e.name(); - let (r, resp, chip) = list_row(ui, tile_id(salt, e), view, is_sel(name)); - if focus.as_deref() == Some(name) { - ui.scroll_to_rect(r, Some(egui::Align::Center)); - } - match e { - Entry::Registration => draw_registration(ui, chip), - Entry::Swatch { paint, global, spot, .. } => { - swatch_tile(ui, chip, paint, false, false); - pattern_thumb(app, ui, chip, paint); - list_icons(ui, r, paint, *global, *spot); - } - Entry::Folder(_) => draw_folder(ui, chip), - } - list_row_name(ui, r, chip, crate::i18n::t_name("swatch", name)); - tile_input(ui, resp, e, &items, sel, true, &mut ev); - } - } else { - for (e, cell) in tile_grid(ui, &items, view, Entry::is_folder) { - let resp = ui.interact(cell, tile_id(salt, e), Sense::click_and_drag()); - let name = e.name(); - if focus.as_deref() == Some(name) { - ui.scroll_to_rect(cell, Some(egui::Align::Center)); - } - match e { - Entry::Registration => draw_registration(ui, cell), - Entry::Swatch { paint, global, spot, .. } => { - swatch_tile(ui, cell, paint, is_sel(name), resp.hovered()); - pattern_thumb(app, ui, cell.shrink(1.0), paint); - if *global { - global_mark(ui, cell, *spot); - } - } - Entry::Folder(_) => folder_tile(ui, cell, is_sel(name)), - } - tile_input(ui, resp, e, &items, sel, false, &mut ev); - } - } - }); - let zone = ui.interact(out.inner_rect, ui.scope_id().with("swatch-drop"), Sense::hover()); - zone_input(ui, &zone, &mut ev); - }); - match ev.edit { - Some(Entry::Folder(g)) => edit_group(app, &g), - Some(e) => { - app.run("ui.swatchOptions", json!({"name": e.name()})).ok(); - } - None => {} - } - if let Some(drop) = ev.drop { - apply_drop(app, drop); - } - let selected = match ev.clicked { - Some((e, m)) => { - let order: Vec<&str> = items.iter().map(Entry::name).collect(); - let sel = click_selection(ui, "swatch-anchor", selected, &order, e.name(), m); - set_pstate(ui.ctx(), "swatch-selected", sel.clone()); - // A plain click also applies the swatch; modifier clicks only select. - if !(m.shift || m.command) { - apply(app, ui, &e); - } - sel - } - None => selected, - }; - bottom(app, ui, &selected); -} - -/// The document and the objects selected in it. -type SelectedArt = (u64, Vec); - -/// The swatch highlighted for the active paint (`active`): with art selected, its swatch; once the -/// art is deselected, still that swatch, until another document shows or the paints for new art -/// change (a swatch or colour picked with nothing selected). Deselecting doesn't jump to the -/// swatch of the paint new art gets (White). -fn kept_swatch(app: &VectorcraftApp, ui: &Ui, active: Option) -> Option { - let Some(st) = app.session.active() else { return active }; - let p = &app.session.paint; - // The document, and the paints new art gets, which picking with nothing selected changes. - let defaults = format!("{}|{:?}|{:?}|{}", st.uid, p.fill, p.stroke, app.session.fill_active); - if !st.selection.objects.is_empty() { - set_pstate(ui.ctx(), "swatch-kept", Some((defaults, active.clone()))); - return active; - } - match pstate::)>>(ui.ctx(), "swatch-kept") { - Some((d, kept)) if d == defaults => kept, - Some(_) => { - set_pstate(ui.ctx(), "swatch-kept", None::<(String, Option)>); - active - } - None => active, - } -} - -/// The swatch the tiles scroll to this frame, once: the active paint's swatch when it changed, or -/// when other art was selected. Selecting other art also clears the panel's own selection, so its -/// swatch is the one highlighted. -fn focus_swatch(app: &VectorcraftApp, ui: &Ui, active: Option<&str>) -> Option { - let art: Option = app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())); - // (None the first time the panel shows.) - let (last_art, last_swatch): (Option>, Option) = pstate(ui.ctx(), "swatch-focus"); - let reselected = last_art.as_ref().is_some_and(|l| *l != art); - if reselected { - set_pstate(ui.ctx(), "swatch-selected", Vec::::new()); - } - let changed = reselected || last_art.is_none() || last_swatch.as_deref() != active; - set_pstate(ui.ctx(), "swatch-focus", (Some(art), active.map(str::to_string))); - active.filter(|_| changed).map(str::to_string) -} - -/// The swatch the active paint comes from: a linked colour's global swatch (a tint's tint swatch -/// when there is one), a process colour's swatch of that colour (a colour no swatch has: none, so -/// text in a colour of its own highlights nothing), a gradient's gradient swatch, or None; nothing -/// for selected images. -fn active_swatch(app: &VectorcraftApp) -> Option { - // Images and placed files have no fill or stroke: no swatch (not None). - let st = app.session.active()?; - let unpainted = |id: &vectorcraft_doc::NodeId| { - st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_) | vectorcraft_doc::NodeKind::PlacedDocument(_))) - }; - if !st.selection.objects.is_empty() && st.selection.objects.iter().all(unpainted) { - return None; - } - match active_paint(app) { - Paint::Solid { swatch: Some(n), tint, .. } => { - let d = &app.session.active()?.doc; - let tint_swatch = d.swatches_iter().find(|w| w.tint_of() == Some((n.as_str(), tint))); - Some(tint_swatch.map_or(n.clone(), |w| w.name.clone())) - } - // A process colour isn't linked: the swatch of that very colour. - Paint::Solid { color, swatch: None, .. } => { - let d = &app.session.active()?.doc; - d.swatches_iter().find(|w| matches!(w.paint, Paint::Solid { color: c, swatch: None, .. } if c == color)).map(|w| w.name.clone()) - } - Paint::Gradient(g) => g.swatch, - Paint::None => Some("[None]".to_string()), - _ => None, - } -} - -/// The thumbnail cells of `items` for `view` in the full available width, row by row; a folder -/// (`is_folder`) starts a new row. -pub(crate) fn tile_grid<'a, T>(ui: &mut Ui, items: &'a [T], view: View, is_folder: impl Fn(&T) -> bool) -> Vec<(&'a T, Rect)> { - let (tile, pitch) = view.tile(); - let w = ui.available_width(); - let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize; - let mut rows: Vec> = vec![vec![]]; - for e in items { - match rows.last_mut() { - Some(row) if row.len() < per_row && (row.is_empty() || !is_folder(e)) => row.push(e), - _ => rows.push(vec![e]), - } - } - let mut out = Vec::with_capacity(items.len()); - for row in rows { - let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); - let cell = |c: usize| Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); - out.extend(row.into_iter().enumerate().map(|(c, e)| (e, cell(c)))); - } - out -} - -/// A list-view row for `view` taking clicks and drags as `id`, shaded when `selected` or hovered -/// → (the row, its response, the chip at its left). -pub(crate) fn list_row(ui: &mut Ui, id: egui::Id, view: View, selected: bool) -> (Rect, Response, Rect) { - let t = Tokens::get(ui.ctx()); - let (tile, pitch) = view.tile(); - let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch)); - let resp = ui.interact(r, id, Sense::click_and_drag()); - if selected { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - (r, resp, Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile))) -} - -/// A list row's name, right of its `chip`. -pub(crate) fn list_row_name(ui: &Ui, r: Rect, chip: Rect, name: &str) { - let t = Tokens::get(ui.ctx()); - ui.painter().text(pos2(chip.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); -} - -/// The white corner of a global colour's tile; a spot colour has a dot in it. -pub(crate) fn global_mark(ui: &Ui, cell: Rect, spot: bool) { - let k = cell.shrink(1.0).right_bottom(); - let corner = vec![k, k - vec2(5.0, 0.0), k - vec2(0.0, 5.0)]; - ui.painter().add(Shape::convex_polygon(corner, Color32::WHITE, Stroke::NONE)); - if spot { - ui.painter().circle_filled(k - vec2(1.6, 1.6), 1.0, Color32::BLACK); - } -} - -/// A colour group's folder in the thumbnail view, outlined when `selected`. -pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) { - draw_folder(ui, cell); - if selected { - ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Outside); - } -} - -/// The interaction id of a tile or list row (stable per swatch or group name and host `salt`). -fn tile_id(salt: &'static str, e: &Entry) -> egui::Id { - egui::Id::unique((salt, e.name())) -} - -/// The selected swatches and groups that can be deleted (all but None and Registration). -fn deletable(sel: &[String]) -> Vec { - sel.iter().filter(|n| !n.starts_with('[')).cloned().collect() -} - -/// The selected swatch when exactly one swatch with an editor is selected (not None, Registration -/// or a group). -fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { - match sel { - [n] if !n.starts_with('[') && app.session.active().is_some_and(|st| st.doc.swatch(n).is_some()) => Some(n.clone()), - _ => None, - } -} - -/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first). -fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec { - let Some(st) = app.session.active() else { return vec![] }; - sel.iter().filter(|n| st.doc.swatch(n).is_some_and(|w| w.paint.color().is_some())).cloned().collect() -} - -/// The first selected colour group (Ungroup Color Group). -fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option { - let d = &app.session.active()?.doc; - sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned() -} - -/// Edit or Apply Color Group: Recolor Artwork on colour group `group` (and the selected art). -fn edit_group(app: &mut VectorcraftApp, group: &str) { - app.run("ui.recolorDialog", json!({ "group": group })).ok(); -} - -/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). -fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { - let params = json!({"names": names}); - if now { - app.run("swatch.delete", params).ok(); - return; - } - let message = match names.as_slice() { - [n] => crate::i18n::fmt(tl!("Delete “{name}”?"), &[("name", n)]), - _ => crate::i18n::fmt(tl!("Delete these {count} swatches and groups?"), &[("count", &names.len().to_string())]), - }; - crate::dialogs::confirm::ask(app, &message, tl!("Art using a deleted global swatch keeps its colour."), "swatch.delete", params); -} - -fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { - let kind: Kind = pstate(ui.ctx(), "swatch-kind"); - widgets::bottom_bar(ui, |ui| { - let open = library_panel::any_open::(&app.ui); - let lr = widgets::icon_button(ui, "library", SwatchLibraries::MENU, open, 24.0); - egui::Popup::menu(&lr).show(|ui| { - ui.set_min_width(200.0); - library_panel::library_menu::(app, ui); - }); - let kr = widgets::icon_button(ui, "dc-swatch-kinds", tl!("Show Swatch Kinds"), kind != Kind::All, 24.0); - egui::Popup::menu(&kr).show(|ui| { - for (k, label) in Kind::ALL { - if menu_item(ui, label, true, k == kind) { - set_pstate(ui.ctx(), "swatch-kind", k); - } - } - }); - // With a colour group selected the options button edits or applies the group. - let opts = editable(app, sel); - if let Some(g) = selected_group(app, sel).filter(|_| opts.is_none()) { - if widgets::icon_button(ui, "palette", tl!("Edit or Apply Color Group"), false, 24.0).clicked() { - edit_group(app, &g); - } - } else if widgets::icon_button_enabled(ui, "dc-options", tl!("Swatch Options"), false, opts.is_some(), 24.0).clicked() - && let Some(n) = opts - { - app.run("ui.swatchOptions", json!({"name": n})).ok(); - } - ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); - if widgets::icon_button(ui, "dc-folder", tl!("New Color Group"), false, 24.0).clicked() { - app.run("ui.newColorGroup", json!({"swatches": deletable(sel)})).ok(); - } - // Alt-click skips the dialog; Ctrl/Cmd-click makes a spot color, and Ctrl/Cmd+Shift-click - // makes a global process color. - if widgets::icon_button(ui, "dc-new-item", tl!("New Swatch"), false, 24.0).clicked() { - new_swatch(app, sel, ui.input(|i| i.modifiers)); - } - // Alt-click deletes without asking. - let del = deletable(sel); - if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Swatch"), false, !del.is_empty(), 24.0).clicked() { - delete(app, del, ui.input(|i| i.modifiers.alt)); - } - }); -} - -/// The colour group the selection points at: the last selected group, or the group of the last -/// selected swatch. -fn target_group(app: &VectorcraftApp, sel: &[String]) -> Option { - group_of(&app.session.active()?.doc, sel.last()?) -} - -/// New Swatch from the active paint (a color goes into the selected color group), for a click -/// with modifiers `m`: a plain click opens the dialog and an Alt-click saves at once. Cmd/Ctrl -/// makes a color a spot color, and Cmd/Ctrl+Shift makes it a global process color. -fn new_swatch(app: &mut VectorcraftApp, sel: &[String], m: egui::Modifiers) { - let paint = active_paint(app); - let group = paint.color().and_then(|_| target_group(app, sel)); - let (spot, global) = (m.command && !m.shift, m.command && m.shift); - if !m.alt { - app.run("ui.newSwatch", json!({"spot": spot, "global": global, "group": group})).ok(); - return; - } - let mut params = super::swatch_paint_params(&paint); - if paint.color().is_some() { - params["spot"] = json!(spot); - params["global"] = json!(global); - } - if let Some(g) = group { - params["group"] = json!(g); - } - app.run("swatch.new", params).ok(); -} - -pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let view: View = pstate(ui.ctx(), "swatch-view"); - let selected = selection(app, ui); - let del = deletable(&selected); - let opts = editable(app, &selected); - if menu_item(ui, tl!("New Swatch…"), true, false) { - new_swatch(app, &selected, egui::Modifiers::NONE); - } - if menu_item(ui, tl!("New Color Group…"), true, false) { - app.run("ui.newColorGroup", json!({"swatches": del})).ok(); - } - if menu_item(ui, tl!("Duplicate Swatch"), opts.is_some(), false) - && let Some(n) = &opts - { - app.run("swatch.duplicate", json!({"name": n})).ok(); - } - // The first selected colour is kept. - let merge = mergeable(app, &selected); - if menu_item(ui, tl!("Merge Swatches"), merge.len() > 1, false) { - app.run("swatch.merge", json!({ "names": merge })).ok(); - } - if menu_item(ui, tl!("Delete Swatch"), !del.is_empty(), false) { - delete(app, del, false); - } - let group = selected_group(app, &selected); - if menu_item(ui, tl!("Edit or Apply Color Group…"), group.is_some(), false) - && let Some(g) = &group - { - edit_group(app, g); - } - if menu_item(ui, tl!("Ungroup Color Group"), group.is_some(), false) - && let Some(g) = group - { - app.run("swatch.ungroup", json!({ "name": g })).ok(); - } - if menu_item(ui, tl!("Select All Unused"), true, false) - && let Ok(r) = app.run("swatch.unused", json!({})) - { - let names: Vec = r["names"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); - set_pstate(ui.ctx(), "swatch-selected", names); - } - if menu_item(ui, tl!("Add Used Colors"), true, false) { - app.run("swatch.addUsedColors", json!({})).ok(); - } - ui.separator(); - if menu_item(ui, tl!("Sort by Name"), true, false) { - app.run("swatch.sortByName", json!({})).ok(); - } - if menu_item(ui, tl!("Sort by Kind"), true, false) { - app.run("swatch.sortByKind", json!({})).ok(); - } - ui.separator(); - for (v, label) in View::ALL { - if menu_item(ui, label, true, v == view) { - set_pstate(ui.ctx(), "swatch-view", v); - } - } - ui.separator(); - let find: bool = pstate(ui.ctx(), "swatch-show-find"); - if menu_item(ui, tl!("Show Find Field"), true, find) { - set_pstate(ui.ctx(), "swatch-show-find", !find); - } - ui.separator(); - if menu_item(ui, tl!("Swatch Options…"), opts.is_some(), false) - && let Some(n) = opts - { - app.run("ui.swatchOptions", json!({"name": n})).ok(); - } - if menu_item(ui, tl!("Spot Colors…"), app.session.active().is_some(), false) { - app.run("ui.spotColors", json!({})).ok(); - } - ui.separator(); - ui.menu_button(tl!("Open Swatch Library"), |ui| library_panel::library_menu::(app, ui)); - if menu_item(ui, tl!("Save Swatch Library…"), true, false) { - app.run("ui.saveSwatchLibrary", json!({ "names": deletable(&selected) })).ok(); - } -} - -fn library_refs(libs: Vec) -> Vec { - libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() -} - -/// The swatch libraries with solid colours (not the gradient-only ones), for Limit to Library. -pub(crate) fn colour_libraries(app: &VectorcraftApp) -> Vec { - let has_colours = |id: &str| swatchlib::library(&app.session, id).is_some_and(|(_, lib)| lib.iter().any(|w| w.paint.color().is_some())); - library_refs(swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients" && has_colours(&l.id)).collect()) -} - -/// What Limit to Library menus call the document's own swatches ([`swatchlib::DOCUMENT_SWATCHES`]). -pub(crate) const DOCUMENT_SWATCHES: &str = "Document Swatches"; - -/// The name of Limit to Library choice `key` (a library id or [`swatchlib::DOCUMENT_SWATCHES`]); -/// `None` for no limit or a library that is gone. -pub(crate) fn limit_name(app: &VectorcraftApp, key: &str) -> Option { - match key { - "" => None, - swatchlib::DOCUMENT_SWATCHES => Some(DOCUMENT_SWATCHES.into()), - _ => swatchlib::library(&app.session, key).map(|(info, _)| info.name), - } -} - -/// The Limit to Library key for `l`: a library's id (from its id or name), or -/// [`swatchlib::DOCUMENT_SWATCHES`] as it is. -pub(crate) fn limit_key(app: &VectorcraftApp, l: &str) -> Result { - if l == swatchlib::DOCUMENT_SWATCHES { - return Ok(l.into()); - } - swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}` (see swatch.library.list)")) -} - -/// Swatch libraries in library panels (engine: `swatch.library.*`). -pub(crate) struct SwatchLibraries; - -impl LibraryKind for SwatchLibraries { - const KIND: &'static str = "swatches"; - const OPEN: &'static str = "window.swatchLibrary"; - const LIST: &'static str = "swatch.library.list"; - const LOAD: &'static str = "swatch.library.load"; - const MENU: &'static str = "Swatch Libraries Menu"; - const ADD: &'static str = "Add to Swatches"; - type Lib = Arc; - type Item = Swatch; - - fn list(app: &VectorcraftApp) -> Vec { - library_refs(swatchlib::libraries(&app.session)) - } - fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { - swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) - } - fn find(app: &VectorcraftApp, key: &str) -> Option<(String, String, usize)> { - swatchlib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) - } - fn path(app: &VectorcraftApp, id: &str) -> Option { - app.session.swatch_libraries.path(id).map(str::to_string) - } - /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name - /// matches shows all of them), as in the Swatches panel. - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { - let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); - let row = |w: &'a Swatch| Row { name: &w.name, item: Some(w) }; - let mut out: Vec> = lib.swatches.iter().filter(|w| found(&w.name)).map(row).collect(); - for g in &lib.groups { - let all = found(&g.name); - let mut items = g.swatches.iter().filter(|w| all || found(&w.name)).map(row).peekable(); - if items.peek().is_some() { - out.push(Row { name: &g.name, item: None }); - out.extend(items); - } - } - out - } - fn draw(_: &VectorcraftApp, ui: &Ui, r: Rect, w: &Swatch, selected: bool, hovered: bool) { - swatch_tile(ui, r, &w.paint, selected, hovered); - if w.global { - global_mark(ui, r, w.spot); - } - } - fn row_icons(ui: &Ui, r: Rect, w: &Swatch) { - list_icons(ui, r, &w.paint, w.global, w.spot); - } - fn describe(w: &Swatch) -> String { - describe(&w.paint, w.global, w.spot) - } - /// Adds the swatch and applies it to the active proxy (Alt: the inactive one), as one step. - fn click(app: &mut VectorcraftApp, ui: &Ui, id: &str, name: &str) { - let alt = super::alt_held(ui); - let apply = if super::proxy_cmd(app, alt) == "paint.setFill" { "fill" } else { "stroke" }; - add_from_library(app, json!({"library": id, "names": [name], "apply": apply, "focus": !alt})); - } - fn add(app: &mut VectorcraftApp, id: &str, names: Vec) { - add_from_library(app, json!({"library": id, "names": names})); - } - fn menu_head(app: &mut VectorcraftApp, ui: &mut Ui) { - if menu_item(ui, tl!("Default Swatches"), app.session.active().is_some(), false) { - app.run("swatch.resetDefaults", json!({})).ok(); - } - ui.separator(); - } - fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { - ui.separator(); - // As a command, so its file dialog, shown off the UI thread, loads the library when it - // answers. - if menu_item(ui, tl!("Other Library…"), true, false) - && let Err(e) = app.run("window.swatchLibrary.other", json!({})) - { - app.status(e); - } - if menu_item(ui, tl!("Save Swatch Library…"), app.session.active().is_some(), false) { - let names = deletable(&selection(app, ui)); - app.run("ui.saveSwatchLibrary", json!({ "names": names })).ok(); - } - } -} - -fn add_from_library(app: &mut VectorcraftApp, params: Value) { - if let Err(e) = app.run("swatch.library.add", params) { - app.status(e); - } -} - -/// `window.swatchLibrary {library}`: open a swatch library in its own panel (`library` null closes -/// every swatch library panel). -pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { - library_panel::open_command::(app, p) -} - -/// `swatch.library.load` params, then open the library in its panel. -pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result { - let r = app.run("swatch.library.load", params)?; - open_library(app, &json!({ "library": r["library"] })) -} - -/// Other Library…: load the library (or document) at `path`, else one picked in an open dialog -/// (on the web the picked file arrives later and opens through [`crate::io::open_bytes`]). -pub(crate) fn other_library(app: &mut VectorcraftApp, path: Option) -> Result { - match library_panel::pick_library_file(app, path, library_panel::library_pick_filters(SwatchLibraries::KIND))? { - Some(path) => load_library(app, json!({ "path": path })), - None => Ok(Value::Null), - } -} - -/// The id prefix of the Window → Swatch Libraries → User Defined slots. -pub(crate) const USER_SLOT: &str = "window.userSwatchLibrary"; - -/// The User Defined library slot `id` (`window.userSwatchLibrary3`) stands for. -pub(crate) fn user_library(app: &VectorcraftApp, id: &str) -> Option { - library_panel::user_slot(id, USER_SLOT, swatchlib::libraries(&app.session)) -} - -/// Window → Swatch Libraries. -pub(crate) fn window_menu() -> Vec { - const SLOTS: [&str; 10] = [ - "window.userSwatchLibrary1", - "window.userSwatchLibrary2", - "window.userSwatchLibrary3", - "window.userSwatchLibrary4", - "window.userSwatchLibrary5", - "window.userSwatchLibrary6", - "window.userSwatchLibrary7", - "window.userSwatchLibrary8", - "window.userSwatchLibrary9", - "window.userSwatchLibrary10", - ]; - let open = |b: &'static BuiltinLibrary| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id })); - let mut items = vec![Item::Cmd("Default Swatches", "swatch.resetDefaults", Value::Null), Item::Sep]; - items.extend(SWATCH_LIBRARIES.iter().map(open)); - items.extend([ - Item::Sub("Gradients", GRADIENT_LIBRARIES.iter().map(open).collect()), - Item::Sep, - Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()), - Item::Sep, - Item::Cmd("Other Library…", "window.swatchLibrary.other", Value::Null), - Item::Cmd("Save Swatch Library…", "ui.saveSwatchLibrary", Value::Null), - ]); - items -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs new file mode 100644 index 000000000..33a4078cd --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -0,0 +1,199 @@ +use super::dnd::{group_of, selection}; +use super::libraries::SwatchLibraries; +use super::*; + +/// The selected swatches and groups that can be deleted (all but None and Registration). +pub(super) fn deletable(sel: &[String]) -> Vec { + sel.iter().filter(|n| !n.starts_with('[')).cloned().collect() +} + +/// The selected swatch when exactly one swatch with an editor is selected (not None, Registration +/// or a group). +fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { + match sel { + [n] if !n.starts_with('[') && app.session.active().is_some_and(|st| st.doc.swatch(n).is_some()) => Some(n.clone()), + _ => None, + } +} + +/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first). +pub(super) fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + sel.iter().filter(|n| st.doc.swatch(n).is_some_and(|w| w.paint.color().is_some())).cloned().collect() +} + +/// The first selected colour group (Ungroup Color Group). +pub(super) fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option { + let d = &app.session.active()?.doc; + sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned() +} + +/// Edit or Apply Color Group: Recolor Artwork on colour group `group` (and the selected art). +pub(super) fn edit_group(app: &mut VectorcraftApp, group: &str) { + app.run("ui.recolorDialog", json!({ "group": group })).ok(); +} + +/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). +pub(super) fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { + let params = json!({"names": names}); + if now { + app.run("swatch.delete", params).ok(); + return; + } + let message = match names.as_slice() { + [n] => crate::i18n::fmt(tl!("Delete “{name}”?"), &[("name", n)]), + _ => crate::i18n::fmt(tl!("Delete these {count} swatches and groups?"), &[("count", &names.len().to_string())]), + }; + crate::dialogs::confirm::ask(app, &message, tl!("Art using a deleted global swatch keeps its colour."), "swatch.delete", params); +} + +pub(super) fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { + let kind: Kind = pstate(ui.ctx(), "swatch-kind"); + widgets::bottom_bar(ui, |ui| { + let open = library_panel::any_open::(&app.ui); + let lr = widgets::icon_button(ui, "library", SwatchLibraries::MENU, open, 24.0); + egui::Popup::menu(&lr).show(|ui| { + ui.set_min_width(200.0); + library_panel::library_menu::(app, ui); + }); + let kr = widgets::icon_button(ui, "dc-swatch-kinds", tl!("Show Swatch Kinds"), kind != Kind::All, 24.0); + egui::Popup::menu(&kr).show(|ui| { + for (k, label) in Kind::ALL { + if menu_item(ui, label, true, k == kind) { + set_pstate(ui.ctx(), "swatch-kind", k); + } + } + }); + // With a colour group selected the options button edits or applies the group. + let opts = editable(app, sel); + if let Some(g) = selected_group(app, sel).filter(|_| opts.is_none()) { + if widgets::icon_button(ui, "palette", tl!("Edit or Apply Color Group"), false, 24.0).clicked() { + edit_group(app, &g); + } + } else if widgets::icon_button_enabled(ui, "dc-options", tl!("Swatch Options"), false, opts.is_some(), 24.0).clicked() + && let Some(n) = opts + { + app.run("ui.swatchOptions", json!({"name": n})).ok(); + } + ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); + if widgets::icon_button(ui, "dc-folder", tl!("New Color Group"), false, 24.0).clicked() { + app.run("ui.newColorGroup", json!({"swatches": deletable(sel)})).ok(); + } + // Alt-click skips the dialog; Ctrl/Cmd-click makes a spot color, and Ctrl/Cmd+Shift-click + // makes a global process color. + if widgets::icon_button(ui, "dc-new-item", tl!("New Swatch"), false, 24.0).clicked() { + new_swatch(app, sel, ui.input(|i| i.modifiers)); + } + // Alt-click deletes without asking. + let del = deletable(sel); + if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Swatch"), false, !del.is_empty(), 24.0).clicked() { + delete(app, del, ui.input(|i| i.modifiers.alt)); + } + }); +} + +/// The colour group the selection points at: the last selected group, or the group of the last +/// selected swatch. +fn target_group(app: &VectorcraftApp, sel: &[String]) -> Option { + group_of(&app.session.active()?.doc, sel.last()?) +} + +/// New Swatch from the active paint (a color goes into the selected color group), for a click +/// with modifiers `m`: a plain click opens the dialog and an Alt-click saves at once. Cmd/Ctrl +/// makes a color a spot color, and Cmd/Ctrl+Shift makes it a global process color. +pub(super) fn new_swatch(app: &mut VectorcraftApp, sel: &[String], m: egui::Modifiers) { + let paint = active_paint(app); + let group = paint.color().and_then(|_| target_group(app, sel)); + let (spot, global) = (m.command && !m.shift, m.command && m.shift); + if !m.alt { + app.run("ui.newSwatch", json!({"spot": spot, "global": global, "group": group})).ok(); + return; + } + let mut params = crate::panels::swatch_paint_params(&paint); + if paint.color().is_some() { + params["spot"] = json!(spot); + params["global"] = json!(global); + } + if let Some(g) = group { + params["group"] = json!(g); + } + app.run("swatch.new", params).ok(); +} + +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let view: View = pstate(ui.ctx(), "swatch-view"); + let selected = selection(app, ui); + let del = deletable(&selected); + let opts = editable(app, &selected); + if menu_item(ui, tl!("New Swatch…"), true, false) { + new_swatch(app, &selected, egui::Modifiers::NONE); + } + if menu_item(ui, tl!("New Color Group…"), true, false) { + app.run("ui.newColorGroup", json!({"swatches": del})).ok(); + } + if menu_item(ui, tl!("Duplicate Swatch"), opts.is_some(), false) + && let Some(n) = &opts + { + app.run("swatch.duplicate", json!({"name": n})).ok(); + } + // The first selected colour is kept. + let merge = mergeable(app, &selected); + if menu_item(ui, tl!("Merge Swatches"), merge.len() > 1, false) { + app.run("swatch.merge", json!({ "names": merge })).ok(); + } + if menu_item(ui, tl!("Delete Swatch"), !del.is_empty(), false) { + delete(app, del, false); + } + let group = selected_group(app, &selected); + if menu_item(ui, tl!("Edit or Apply Color Group…"), group.is_some(), false) + && let Some(g) = &group + { + edit_group(app, g); + } + if menu_item(ui, tl!("Ungroup Color Group"), group.is_some(), false) + && let Some(g) = group + { + app.run("swatch.ungroup", json!({ "name": g })).ok(); + } + if menu_item(ui, tl!("Select All Unused"), true, false) + && let Ok(r) = app.run("swatch.unused", json!({})) + { + let names: Vec = r["names"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + set_pstate(ui.ctx(), "swatch-selected", names); + } + if menu_item(ui, tl!("Add Used Colors"), true, false) { + app.run("swatch.addUsedColors", json!({})).ok(); + } + ui.separator(); + if menu_item(ui, tl!("Sort by Name"), true, false) { + app.run("swatch.sortByName", json!({})).ok(); + } + if menu_item(ui, tl!("Sort by Kind"), true, false) { + app.run("swatch.sortByKind", json!({})).ok(); + } + ui.separator(); + for (v, label) in View::ALL { + if menu_item(ui, label, true, v == view) { + set_pstate(ui.ctx(), "swatch-view", v); + } + } + ui.separator(); + let find: bool = pstate(ui.ctx(), "swatch-show-find"); + if menu_item(ui, tl!("Show Find Field"), true, find) { + set_pstate(ui.ctx(), "swatch-show-find", !find); + } + ui.separator(); + if menu_item(ui, tl!("Swatch Options…"), opts.is_some(), false) + && let Some(n) = opts + { + app.run("ui.swatchOptions", json!({"name": n})).ok(); + } + if menu_item(ui, tl!("Spot Colors…"), app.session.active().is_some(), false) { + app.run("ui.spotColors", json!({})).ok(); + } + ui.separator(); + ui.menu_button(tl!("Open Swatch Library"), |ui| library_panel::library_menu::(app, ui)); + if menu_item(ui, tl!("Save Swatch Library…"), true, false) { + app.run("ui.saveSwatchLibrary", json!({ "names": deletable(&selected) })).ok(); + } +} diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs new file mode 100644 index 000000000..259a48809 --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -0,0 +1,194 @@ +use super::actions::{bottom, edit_group}; +use super::dnd::{TileEvents, apply_drop, click_selection, highlighted, selection, tile_input, zone_input}; +use super::tiles::{ + draw_folder, draw_registration, folder_tile, global_mark, list_icons, list_row, list_row_name, pattern_thumb, tile_grid, tile_id, +}; +use super::*; + +/// The id of the find field's text (shown with the panel menu's Show Find Field). +pub(super) fn find_id() -> egui::Id { + egui::Id::unique("swatch-find") +} + +/// The panel's body; `salt` keys its tiles (see [`tile_id`]). +pub(super) fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { + if app.session.active().is_none() { + crate::panels::empty_state(ui, "swatch-book", tl!("No document"), tl!("Open a document to see its swatches.")); + return; + } + let view: View = pstate(ui.ctx(), "swatch-view"); + let kind: Kind = pstate(ui.ctx(), "swatch-kind"); + // Top row: proxy at left, list / grid view toggles at right. + ui.horizontal(|ui| { + crate::panels::proxy(app, ui, 34.0); + ui.add_space(ui.available_width() - 58.0); + if widgets::icon_button(ui, "dc-list-view", tl!("Show List View"), view.is_list(), 26.0).clicked() { + set_pstate(ui.ctx(), "swatch-view", View::SmallList); + } + if widgets::icon_button(ui, "dc-grid-view", tl!("Show Thumbnail View"), !view.is_list(), 26.0).clicked() { + set_pstate(ui.ctx(), "swatch-view", View::MediumThumb); + } + }); + ui.add_space(4.0); + // A recent colour paints the active proxy (Alt: the inactive one), as a swatch does. + if let Some(c) = crate::panels::recent_colors_row(app, ui) { + crate::panels::apply_click(app, ui, json!({"color": crate::panels::color_json(&c)})); + } + widgets::divider(ui); + widgets::subheader(ui, tl!("Swatch Tiles")); + let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() }; + let items = entries(app, kind, &query); + let active_swatch = kept_swatch(app, ui, active_swatch(app)); + let focus = focus_swatch(app, ui, active_swatch.as_deref()); + let selected = selection(app, ui); + // Without a selection the swatch of the active paint is highlighted. + let fallback: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] }; + let sel = if selected.is_empty() { &fallback } else { &selected }; + let lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default(); + let is_sel = |name: &str| lit.contains(&name); + let mut ev = TileEvents::default(); + widgets::list_box(ui, |ui| { + let max_height = if view == View::LargeThumb { 200.0 } else { 150.0 }; + let out = egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(max_height).show(ui, |ui| { + ui.set_width(ui.available_width()); + if view.is_list() { + for e in &items { + let name = e.name(); + let (r, resp, chip) = list_row(ui, tile_id(salt, e), view, is_sel(name)); + if focus.as_deref() == Some(name) { + ui.scroll_to_rect(r, Some(egui::Align::Center)); + } + match e { + Entry::Registration => draw_registration(ui, chip), + Entry::Swatch { paint, global, spot, .. } => { + swatch_tile(ui, chip, paint, false, false); + pattern_thumb(app, ui, chip, paint); + list_icons(ui, r, paint, *global, *spot); + } + Entry::Folder(_) => draw_folder(ui, chip), + } + list_row_name(ui, r, chip, crate::i18n::t_name("swatch", name)); + tile_input(ui, resp, e, &items, sel, true, &mut ev); + } + } else { + for (e, cell) in tile_grid(ui, &items, view, Entry::is_folder) { + let resp = ui.interact(cell, tile_id(salt, e), Sense::click_and_drag()); + let name = e.name(); + if focus.as_deref() == Some(name) { + ui.scroll_to_rect(cell, Some(egui::Align::Center)); + } + match e { + Entry::Registration => draw_registration(ui, cell), + Entry::Swatch { paint, global, spot, .. } => { + swatch_tile(ui, cell, paint, is_sel(name), resp.hovered()); + pattern_thumb(app, ui, cell.shrink(1.0), paint); + if *global { + global_mark(ui, cell, *spot); + } + } + Entry::Folder(_) => folder_tile(ui, cell, is_sel(name)), + } + tile_input(ui, resp, e, &items, sel, false, &mut ev); + } + } + }); + let zone = ui.interact(out.inner_rect, ui.scope_id().with("swatch-drop"), Sense::hover()); + zone_input(ui, &zone, &mut ev); + }); + match ev.edit { + Some(Entry::Folder(g)) => edit_group(app, &g), + Some(e) => { + app.run("ui.swatchOptions", json!({"name": e.name()})).ok(); + } + None => {} + } + if let Some(drop) = ev.drop { + apply_drop(app, drop); + } + let selected = match ev.clicked { + Some((e, m)) => { + let order: Vec<&str> = items.iter().map(Entry::name).collect(); + let sel = click_selection(ui, "swatch-anchor", selected, &order, e.name(), m); + set_pstate(ui.ctx(), "swatch-selected", sel.clone()); + // A plain click also applies the swatch; modifier clicks only select. + if !(m.shift || m.command) { + apply(app, ui, &e); + } + sel + } + None => selected, + }; + bottom(app, ui, &selected); +} + +/// The document and the objects selected in it. +type SelectedArt = (u64, Vec); + +/// The swatch highlighted for the active paint (`active`): with art selected, its swatch; once the +/// art is deselected, still that swatch, until another document shows or the paints for new art +/// change (a swatch or colour picked with nothing selected). Deselecting doesn't jump to the +/// swatch of the paint new art gets (White). +pub(super) fn kept_swatch(app: &VectorcraftApp, ui: &Ui, active: Option) -> Option { + let Some(st) = app.session.active() else { return active }; + let p = &app.session.paint; + // The document, and the paints new art gets, which picking with nothing selected changes. + let defaults = format!("{}|{:?}|{:?}|{}", st.uid, p.fill, p.stroke, app.session.fill_active); + if !st.selection.objects.is_empty() { + set_pstate(ui.ctx(), "swatch-kept", Some((defaults, active.clone()))); + return active; + } + match pstate::)>>(ui.ctx(), "swatch-kept") { + Some((d, kept)) if d == defaults => kept, + Some(_) => { + set_pstate(ui.ctx(), "swatch-kept", None::<(String, Option)>); + active + } + None => active, + } +} + +/// The swatch the tiles scroll to this frame, once: the active paint's swatch when it changed, or +/// when other art was selected. Selecting other art also clears the panel's own selection, so its +/// swatch is the one highlighted. +pub(super) fn focus_swatch(app: &VectorcraftApp, ui: &Ui, active: Option<&str>) -> Option { + let art: Option = app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())); + // (None the first time the panel shows.) + let (last_art, last_swatch): (Option>, Option) = pstate(ui.ctx(), "swatch-focus"); + let reselected = last_art.as_ref().is_some_and(|l| *l != art); + if reselected { + set_pstate(ui.ctx(), "swatch-selected", Vec::::new()); + } + let changed = reselected || last_art.is_none() || last_swatch.as_deref() != active; + set_pstate(ui.ctx(), "swatch-focus", (Some(art), active.map(str::to_string))); + active.filter(|_| changed).map(str::to_string) +} + +/// The swatch the active paint comes from: a linked colour's global swatch (a tint's tint swatch +/// when there is one), a process colour's swatch of that colour (a colour no swatch has: none, so +/// text in a colour of its own highlights nothing), a gradient's gradient swatch, or None; nothing +/// for selected images. +pub(super) fn active_swatch(app: &VectorcraftApp) -> Option { + // Images and placed files have no fill or stroke: no swatch (not None). + let st = app.session.active()?; + let unpainted = |id: &vectorcraft_doc::NodeId| { + st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_) | vectorcraft_doc::NodeKind::PlacedDocument(_))) + }; + if !st.selection.objects.is_empty() && st.selection.objects.iter().all(unpainted) { + return None; + } + match active_paint(app) { + Paint::Solid { swatch: Some(n), tint, .. } => { + let d = &app.session.active()?.doc; + let tint_swatch = d.swatches_iter().find(|w| w.tint_of() == Some((n.as_str(), tint))); + Some(tint_swatch.map_or(n.clone(), |w| w.name.clone())) + } + // A process colour isn't linked: the swatch of that very colour. + Paint::Solid { color, swatch: None, .. } => { + let d = &app.session.active()?.doc; + d.swatches_iter().find(|w| matches!(w.paint, Paint::Solid { color: c, swatch: None, .. } if c == color)).map(|w| w.name.clone()) + } + Paint::Gradient(g) => g.swatch, + Paint::None => Some("[None]".to_string()), + _ => None, + } +} diff --git a/crates/ui-egui/src/panels/swatches/dnd.rs b/crates/ui-egui/src/panels/swatches/dnd.rs new file mode 100644 index 000000000..e7916fe8b --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/dnd.rs @@ -0,0 +1,341 @@ +use super::tiles::{describe, draw_registration, pattern_thumb}; +use super::*; + +/// The swatches and colour groups selected in the panel, in click order, without names that no +/// longer exist (deleted, renamed or undone). +pub(super) fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let mut names: Vec = pstate(ui.ctx(), "swatch-selected"); + names.retain(|n| n == REGISTRATION || st.doc.swatch_name_taken(n)); + names +} + +/// The names the panel highlights: `sel` plus the swatches of selected colour groups (clicking a +/// folder selects the whole group). +pub(super) fn highlighted<'a>(d: &'a Document, sel: &'a [String]) -> Vec<&'a str> { + let mut out: Vec<&str> = sel.iter().map(String::as_str).collect(); + for g in d.swatch_groups.iter().filter(|g| sel.contains(&g.name)) { + out.extend(g.swatches.iter().map(|w| w.name.as_str())); + } + out +} + +/// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked +/// name (kept in panel state `anchor_key`) over `order` (the names as displayed), a plain click +/// selects it alone. +pub(crate) fn click_selection(ui: &Ui, anchor_key: &str, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { + let anchor: String = pstate(ui.ctx(), anchor_key); + let pos = |n: &str| order.iter().position(|o| *o == n); + if m.shift + && let (Some(a), Some(b)) = (pos(&anchor), pos(name)) + { + for n in &order[a.min(b)..=a.max(b)] { + if !sel.iter().any(|s| s == n) { + sel.push(n.to_string()); + } + } + return sel; + } + set_pstate(ui.ctx(), anchor_key, name.to_string()); + if !m.command { + return vec![name.to_string()]; + } + match sel.iter().position(|s| s == name) { + Some(i) => { + sel.remove(i); + } + None => sel.push(name.to_string()), + } + sel +} + +/// A drag released on the panel. +pub(super) enum Drop { + /// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or + /// at the end (`None`). With Alt held, `with` names the swatch grabbed: dropped on swatch + /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it + /// gives that swatch its paint instead of moving. + Move { names: Vec, target: Option, after: bool, with: Option }, + /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row + /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch + /// `target` when that is of its kind and not a tint swatch. + New { paint: Paint, target: Option, replace: bool }, + /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). + Pattern { ids: Vec }, +} + +impl Drop { + /// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows + /// it carries (with Alt held, `replace`, a swatch grabbed may replace `target`), make a swatch of + /// a paint dragged from elsewhere, or a pattern swatch of art dragged off the canvas (appearances: + /// nothing). + fn of(d: &PanelDrag, target: Option, after: bool, replace: bool) -> Option { + match d { + PanelDrag::Paint { rows: Some(r), .. } => { + let with = (replace && !r.groups).then(|| r.grabbed.clone()); + Some(Drop::Move { names: r.names.clone(), target, after, with }) + } + PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target, replace }), + PanelDrag::Art(ids) => Some(Drop::Pattern { ids: ids.iter().map(|id| id.0).collect() }), + _ => None, + } + } +} + +/// The drag held over `resp`, if the panel takes it (paints and art dragged off the canvas, not the +/// Appearance panel's thumbnail). +fn held(resp: &Response) -> Option> { + resp.dnd_hover_payload::().filter(|d| matches!(**d, PanelDrag::Paint { .. } | PanelDrag::Art(_))) +} + +/// The drag a tile or row of `e` starts, moving rows `names`. Colour groups have no paint of their +/// own (the chip under the pointer draws Registration's mark for Registration). +pub(super) fn drag_of(e: &Entry, names: Vec) -> PanelDrag { + let paint = match e { + Entry::Swatch { paint, .. } => paint.clone(), + Entry::Registration => Paint::registration(), + Entry::Folder(_) => Paint::None, + }; + let rows = SwatchRows { grabbed: e.name().to_string(), names, groups: e.is_folder() }; + PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows), library: None } +} + +/// What the tiles or rows saw this frame. +#[derive(Default)] +pub(super) struct TileEvents { + pub(super) clicked: Option<(Entry, egui::Modifiers)>, + /// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing), + /// or Edit or Apply Color Group on a colour group. + pub(super) edit: Option, + pub(super) drop: Option, + /// A drag is held over a tile or row. + over: bool, +} + +/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e` +/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it +/// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it. +pub(super) fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) { + let name = e.name(); + if matches!(e, Entry::Swatch { .. } | Entry::Folder(_)) && resp.double_clicked() { + ev.edit = Some(e.clone()); + } + if resp.drag_started() { + let with_sel = sel.iter().any(|s| s == name); + let names = items + .iter() + .filter(|x| x.is_folder() == e.is_folder() && (x.name() == name || (with_sel && sel.iter().any(|s| s == x.name())))) + .map(|x| x.name().to_string()) + .collect(); + egui::DragAndDrop::set_payload(ui.ctx(), drag_of(e, names)); + } + tile_drop(ui, &resp, e, list, ev); + // The default swatches' names show in the UI's language; the document keeps them as they are. + let shown = crate::i18n::t_name("swatch", name); + let resp = match e { + Entry::Folder(_) => resp.on_hover_text(crate::i18n::fmt(tl!("Color Group: {name}"), &[("name", shown)])), + Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| { + ui.label(format!("{shown} ({})", describe(paint, *global, *spot))); + }), + _ => resp.on_hover_text(shown), + }; + if resp.clicked() { + ev.clicked = Some((e.clone(), ui.input(|i| i.modifiers))); + } +} + +/// A drag held over the tile or row `resp` of entry `e` shows where it would land (before or after +/// it by the pointer's half, or into a folder); a release there sets the drop. +fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvents) { + let Some(d) = held(resp) else { return }; + let PanelDrag::Paint { rows, .. } = &*d else { return }; + let name = e.name(); + ev.over = true; + // Swatches dropped on one of themselves stay where they are. + if rows.as_ref().is_some_and(|r| r.names.iter().any(|n| n == name)) { + resp.dnd_release_payload::(); + return; + } + let t = Tokens::get(ui.ctx()); + let r = resp.rect; + let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center()); + let after = if list { at.y > r.center().y } else { at.x > r.center().x }; + // A paint from elsewhere, or swatches over a folder, go into it; otherwise a bar marks the slot. + if rows.as_ref().is_none_or(|d| e.is_folder() && !d.groups) { + ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); + } else if list { + let y = if after { r.bottom() } else { r.top() }; + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); + } else { + let x = if after { r.right() + 0.75 } else { r.left() - 0.75 }; + ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent)); + } + if resp.dnd_release_payload::().is_some() { + ev.drop = Drop::of(&d, Some(name.to_string()), after, crate::panels::alt_held(ui)); + } +} + +/// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped +/// swatches (colour groups to the end of the groups); the list is outlined while one is held there. +pub(super) fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) { + let Some(d) = held(zone) else { return }; + if !ev.over { + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if zone.dnd_release_payload::().is_some() { + ev.drop = Drop::of(&d, None, false, false); + } +} + +/// The colour group `name` is, or the one swatch `name` belongs to. +pub(super) fn group_of(d: &Document, name: &str) -> Option { + if d.swatch_groups.iter().any(|g| g.name == name) { + return Some(name.to_string()); + } + d.swatch_group_of(name).map(|g| d.swatch_groups[g].name.clone()) +} + +/// `swatch.move` params for dropping `names` before or `after` row `target`, into folder `target` +/// (swatches), or at the end (`target` None); `None` when nothing would move. Only solid colours go +/// into colour groups; None and Registration stay put. +fn move_params(d: &Document, names: &[String], target: Option<&str>, after: bool) -> Option { + if target.is_some_and(|t| names.iter().any(|n| n == t)) { + return None; + } + let group_index = |n: &str| d.swatch_groups.iter().position(|g| g.name == n); + if names.iter().all(|n| group_index(n).is_some()) { + let to = match target { + None => d.swatch_groups.len(), + // Over a group's folder or one of its swatches: next to that group; else before the first. + Some(t) => group_index(t).or_else(|| d.swatch_group_of(t)).map_or(0, |i| i + usize::from(after)), + }; + return Some(json!({"names": names, "to": to})); + } + let (group, to) = match target { + Some(t) if group_index(t).is_some() => (Some(t), None), + Some(REGISTRATION) => (None, Some(d.swatches.iter().position(|w| !w.paint.is_none()).unwrap_or(0))), + Some(t) => { + let (group, list) = match d.swatch_group_of(t) { + Some(g) => (Some(d.swatch_groups[g].name.as_str()), &d.swatch_groups[g].swatches), + None => (None, &*d.swatches), + }; + (group, list.iter().position(|w| w.name == t).map(|i| i + usize::from(after))) + } + None => (None, None), + }; + let movable: Vec<&String> = + names.iter().filter(|n| d.swatch(n).is_some_and(|w| !w.paint.is_none() && (group.is_none() || w.paint.color().is_some()))).collect(); + (!movable.is_empty()).then(|| json!({"names": movable, "to": to, "group": group})) +} + +/// Are both paints colours, gradients or patterns? +fn same_kind(a: &Paint, b: &Paint) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) && !a.is_none() +} + +/// Can swatch `with` give its paint to swatch `target`? Both are swatches of one kind, neither is +/// None or Registration, and `target` is not a tint swatch (a tint swatch only takes a new name). +fn replaces(d: &Document, with: &str, target: &str) -> bool { + match (d.swatch(with), d.swatch(target)) { + (Some(w), Some(t)) => with != target && !w.is_reserved() && !t.is_reserved() && t.tint_of().is_none() && same_kind(&t.paint, &w.paint), + _ => false, + } +} + +/// Run a drop on the panel: move the swatches, give the paint of a swatch Alt-dropped on another of +/// its kind to that one, or make a swatch of the proxy's paint (a color dropped on a folder or a +/// grouped swatch joins that group). +pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { + let Some(d) = app.session.active().map(|st| &st.doc) else { return }; + let (cmd, params) = match drop { + // Alt: the swatch grabbed replaces the color, gradient or pattern of the swatch of its kind + // it is dropped on, which keeps its name and place. + Drop::Move { with: Some(w), target: Some(t), .. } if replaces(d, &w, &t) => ("swatch.edit", json!({"name": t, "paint": {"swatch": w}})), + Drop::Move { names, target, after, .. } => match move_params(d, &names, target.as_deref(), after) { + Some(p) => ("swatch.move", p), + None => return, + }, + // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. + Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { + ("swatch.edit", json!({"name": t, "paint": crate::panels::swatch_paint_params(&paint)})) + } + Drop::New { paint, target, .. } => { + let mut p = crate::panels::swatch_paint_params(&paint); + if paint.color().is_some() + && let Some(g) = target.and_then(|t| group_of(d, &t)) + { + p["group"] = json!(g); + } + ("swatch.new", p) + } + Drop::Pattern { ids } => ("object.pattern.make", json!({"ids": ids, "edit": false})), + }; + if let Err(e) = app.run(cmd, params) { + app.status(e); + } +} + +/// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, +/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance +/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the +/// thumbnail of a dragged graphic style. +pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { + let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; + let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); + let (paint, registration) = match &*d { + // Colour groups paint nothing. + PanelDrag::Paint { params, .. } if params.is_null() => return, + PanelDrag::Paint { paint, rows, .. } => (std::borrow::Cow::Borrowed(paint), rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION)), + PanelDrag::Appearance(id) => match fill_of(id) { + Some(p) => (std::borrow::Cow::Owned(p), false), + None => return, + }, + PanelDrag::Art(ids) => match ids.first().and_then(fill_of) { + Some(p) => (std::borrow::Cow::Owned(p), false), + None => return, + }, + PanelDrag::GraphicStyle(name) => return pointer_chip(ctx, |ui, r| crate::panels::graphic_styles::paint_style(app, ui, r, name)), + PanelDrag::Symbol(name) => match app.session.active() { + Some(st) => { + return pointer_chip(ctx, |ui, r| crate::panels::symbols::chip(ui, &st.doc, &crate::panels::symbols::document_symbols(st), r, name)); + } + None => return, + }, + PanelDrag::LibrarySymbol { library, name } => match vectorcraft_engine::cmd::symbollib::library(&app.session, library) { + Some((_, lib)) => { + return pointer_chip(ctx, |ui, r| { + crate::panels::symbols::chip(ui, &lib.doc, &crate::panels::symbols::library_symbols(&lib), r, name) + }); + } + None => return, + }, + PanelDrag::Brush { name, .. } => match app.session.active() { + Some(st) => return pointer_chip(ctx, |ui, r| crate::panels::brushes::document_chip(ui, r, st, name)), + None => return, + }, + PanelDrag::LibraryBrush { library, name } => match vectorcraft_engine::cmd::brushlib::library(&app.session, library) { + Some((_, lib)) => return pointer_chip(ctx, |ui, r| crate::panels::brushes::library_chip(ui, r, &lib, name)), + None => return, + }, + PanelDrag::LibraryGraphic { library, item } => return pointer_chip(ctx, |ui, r| crate::panels::libraries::chip(app, ui, r, library, item)), + PanelDrag::LibraryItem { .. } | PanelDrag::LibraryGroup { .. } => return, + }; + pointer_chip(ctx, |ui, r| { + if registration { + draw_registration(ui, r); + } else { + swatch_tile(ui, r, &paint, false, false); + pattern_thumb(app, ui, r.shrink(1.0), &paint); + } + }); +} + +/// A 16-point chip `draw` paints, following the pointer. +fn pointer_chip(ctx: &egui::Context, draw: impl FnOnce(&Ui, Rect)) { + let Some(at) = ctx.pointer_hover_pos() else { return }; + let area = egui::Area::new(egui::Id::unique("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); + area.interactable(false).show(ctx, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + draw(ui, r); + }); +} diff --git a/crates/ui-egui/src/panels/swatches/libraries.rs b/crates/ui-egui/src/panels/swatches/libraries.rs new file mode 100644 index 000000000..3f0200398 --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/libraries.rs @@ -0,0 +1,183 @@ +use super::actions::deletable; +use super::dnd::selection; +use super::tiles::{describe, global_mark, list_icons}; +use super::*; + +fn library_refs(libs: Vec) -> Vec { + libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() +} + +/// The swatch libraries with solid colours (not the gradient-only ones), for Limit to Library. +pub(crate) fn colour_libraries(app: &VectorcraftApp) -> Vec { + let has_colours = |id: &str| swatchlib::library(&app.session, id).is_some_and(|(_, lib)| lib.iter().any(|w| w.paint.color().is_some())); + library_refs(swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients" && has_colours(&l.id)).collect()) +} + +/// What Limit to Library menus call the document's own swatches ([`swatchlib::DOCUMENT_SWATCHES`]). +pub(crate) const DOCUMENT_SWATCHES: &str = "Document Swatches"; + +/// The name of Limit to Library choice `key` (a library id or [`swatchlib::DOCUMENT_SWATCHES`]); +/// `None` for no limit or a library that is gone. +pub(crate) fn limit_name(app: &VectorcraftApp, key: &str) -> Option { + match key { + "" => None, + swatchlib::DOCUMENT_SWATCHES => Some(DOCUMENT_SWATCHES.into()), + _ => swatchlib::library(&app.session, key).map(|(info, _)| info.name), + } +} + +/// The Limit to Library key for `l`: a library's id (from its id or name), or +/// [`swatchlib::DOCUMENT_SWATCHES`] as it is. +pub(crate) fn limit_key(app: &VectorcraftApp, l: &str) -> Result { + if l == swatchlib::DOCUMENT_SWATCHES { + return Ok(l.into()); + } + swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}` (see swatch.library.list)")) +} + +/// Swatch libraries in library panels (engine: `swatch.library.*`). +pub(crate) struct SwatchLibraries; + +impl LibraryKind for SwatchLibraries { + const KIND: &'static str = "swatches"; + const OPEN: &'static str = "window.swatchLibrary"; + const LIST: &'static str = "swatch.library.list"; + const LOAD: &'static str = "swatch.library.load"; + const MENU: &'static str = "Swatch Libraries Menu"; + const ADD: &'static str = "Add to Swatches"; + type Lib = Arc; + type Item = Swatch; + + fn list(app: &VectorcraftApp) -> Vec { + library_refs(swatchlib::libraries(&app.session)) + } + fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { + swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) + } + fn find(app: &VectorcraftApp, key: &str) -> Option<(String, String, usize)> { + swatchlib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) + } + fn path(app: &VectorcraftApp, id: &str) -> Option { + app.session.swatch_libraries.path(id).map(str::to_string) + } + /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name + /// matches shows all of them), as in the Swatches panel. + fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); + let row = |w: &'a Swatch| Row { name: &w.name, item: Some(w) }; + let mut out: Vec> = lib.swatches.iter().filter(|w| found(&w.name)).map(row).collect(); + for g in &lib.groups { + let all = found(&g.name); + let mut items = g.swatches.iter().filter(|w| all || found(&w.name)).map(row).peekable(); + if items.peek().is_some() { + out.push(Row { name: &g.name, item: None }); + out.extend(items); + } + } + out + } + fn draw(_: &VectorcraftApp, ui: &Ui, r: Rect, w: &Swatch, selected: bool, hovered: bool) { + swatch_tile(ui, r, &w.paint, selected, hovered); + if w.global { + global_mark(ui, r, w.spot); + } + } + fn row_icons(ui: &Ui, r: Rect, w: &Swatch) { + list_icons(ui, r, &w.paint, w.global, w.spot); + } + fn describe(w: &Swatch) -> String { + describe(&w.paint, w.global, w.spot) + } + /// Adds the swatch and applies it to the active proxy (Alt: the inactive one), as one step. + fn click(app: &mut VectorcraftApp, ui: &Ui, id: &str, name: &str) { + let alt = crate::panels::alt_held(ui); + let apply = if crate::panels::proxy_cmd(app, alt) == "paint.setFill" { "fill" } else { "stroke" }; + add_from_library(app, json!({"library": id, "names": [name], "apply": apply, "focus": !alt})); + } + fn add(app: &mut VectorcraftApp, id: &str, names: Vec) { + add_from_library(app, json!({"library": id, "names": names})); + } + fn menu_head(app: &mut VectorcraftApp, ui: &mut Ui) { + if menu_item(ui, tl!("Default Swatches"), app.session.active().is_some(), false) { + app.run("swatch.resetDefaults", json!({})).ok(); + } + ui.separator(); + } + fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.separator(); + // As a command, so its file dialog, shown off the UI thread, loads the library when it + // answers. + if menu_item(ui, tl!("Other Library…"), true, false) + && let Err(e) = app.run("window.swatchLibrary.other", json!({})) + { + app.status(e); + } + if menu_item(ui, tl!("Save Swatch Library…"), app.session.active().is_some(), false) { + let names = deletable(&selection(app, ui)); + app.run("ui.saveSwatchLibrary", json!({ "names": names })).ok(); + } + } +} + +fn add_from_library(app: &mut VectorcraftApp, params: Value) { + if let Err(e) = app.run("swatch.library.add", params) { + app.status(e); + } +} + +/// `window.swatchLibrary {library}`: open a swatch library in its own panel (`library` null closes +/// every swatch library panel). +pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { + library_panel::open_command::(app, p) +} + +/// `swatch.library.load` params, then open the library in its panel. +pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result { + let r = app.run("swatch.library.load", params)?; + open_library(app, &json!({ "library": r["library"] })) +} + +/// Other Library…: load the library (or document) at `path`, else one picked in an open dialog +/// (on the web the picked file arrives later and opens through [`crate::io::open_bytes`]). +pub(crate) fn other_library(app: &mut VectorcraftApp, path: Option) -> Result { + match library_panel::pick_library_file(app, path, library_panel::library_pick_filters(SwatchLibraries::KIND))? { + Some(path) => load_library(app, json!({ "path": path })), + None => Ok(Value::Null), + } +} + +/// The id prefix of the Window → Swatch Libraries → User Defined slots. +pub(crate) const USER_SLOT: &str = "window.userSwatchLibrary"; + +/// The User Defined library slot `id` (`window.userSwatchLibrary3`) stands for. +pub(crate) fn user_library(app: &VectorcraftApp, id: &str) -> Option { + library_panel::user_slot(id, USER_SLOT, swatchlib::libraries(&app.session)) +} + +/// Window → Swatch Libraries. +pub(crate) fn window_menu() -> Vec { + const SLOTS: [&str; 10] = [ + "window.userSwatchLibrary1", + "window.userSwatchLibrary2", + "window.userSwatchLibrary3", + "window.userSwatchLibrary4", + "window.userSwatchLibrary5", + "window.userSwatchLibrary6", + "window.userSwatchLibrary7", + "window.userSwatchLibrary8", + "window.userSwatchLibrary9", + "window.userSwatchLibrary10", + ]; + let open = |b: &'static BuiltinLibrary| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id })); + let mut items = vec![Item::Cmd("Default Swatches", "swatch.resetDefaults", Value::Null), Item::Sep]; + items.extend(SWATCH_LIBRARIES.iter().map(open)); + items.extend([ + Item::Sub("Gradients", GRADIENT_LIBRARIES.iter().map(open).collect()), + Item::Sep, + Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()), + Item::Sep, + Item::Cmd("Other Library…", "window.swatchLibrary.other", Value::Null), + Item::Cmd("Save Swatch Library…", "ui.saveSwatchLibrary", Value::Null), + ]); + items +} diff --git a/crates/ui-egui/src/panels/swatches/tiles.rs b/crates/ui-egui/src/panels/swatches/tiles.rs new file mode 100644 index 000000000..d2d36efa9 --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/tiles.rs @@ -0,0 +1,208 @@ +use super::*; + +/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). +pub(super) fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) { + use std::collections::HashMap; + type Key = (String, Vec, String, u32); + thread_local! { + static CACHE: crate::graphics::TexCache>> = crate::graphics::TexCache::default(); + } + let Paint::Pattern { pattern, .. } = paint else { return }; + let Some(st) = app.session.active() else { return }; + let Some(def) = st.doc.pattern(pattern) else { return }; + let px = (r.width() * ui.ctx().pixels_per_point()).round().max(4.0) as u32; + let key = ( + pattern.clone(), + def.art.iter().map(|a| std::sync::Arc::as_ptr(a) as usize).collect(), + format!("{:?}{:?}{:?}", def.tile, def.tile_type, def.overlap), + px, + ); + let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).unwrap_or_else(|| { + let tex = vectorcraft_render::render_pattern_swatch(&st.doc, pattern, px).map(|img| { + let color = crate::canvas::color_image(&img); + ui.ctx().load_texture(format!("pattern-swatch-{pattern}-{px}"), color, egui::TextureOptions::LINEAR) + }); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 256 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + tex + }); + if let Some(tex) = tex { + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + } +} + +pub(super) fn draw_registration(ui: &Ui, r: Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + let c = r.center(); + let rad = r.width() * 0.28; + let s = Stroke::new(1.0, egui::Color32::BLACK); + ui.painter().circle_stroke(c, rad, s); + ui.painter().line_segment([pos2(r.left() + 2.0, c.y), pos2(r.right() - 2.0, c.y)], s); + ui.painter().line_segment([pos2(c.x, r.top() + 2.0), pos2(c.x, r.bottom() - 2.0)], s); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); +} + +pub(crate) fn draw_folder(ui: &Ui, r: Rect) { + let t = Tokens::get(ui.ctx()); + icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); +} + +/// The ink colours of the CMYK glyph (cyan, magenta, yellow, black) and the RGB glyph's bars. +const CMYK_INKS: [Color32; 4] = [Color32::from_rgb(0, 174, 239), Color32::from_rgb(236, 0, 140), Color32::from_rgb(255, 242, 0), Color32::BLACK]; + +const RGB_BARS: [Color32; 3] = [Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 200, 0), Color32::from_rgb(0, 0, 255)]; + +/// Lab's opponent axes: green | red (a) over blue | yellow (b). +const LAB_AXES: [Color32; 4] = + [Color32::from_rgb(0, 166, 81), Color32::from_rgb(230, 30, 60), Color32::from_rgb(0, 90, 220), Color32::from_rgb(250, 205, 0)]; + +/// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK"). +pub(crate) fn describe(paint: &Paint, global: bool, spot: bool) -> String { + match paint { + Paint::Solid { color, .. } => { + let kind = if spot { + tl!("Spot Color") + } else if global { + tl!("Global Process Color") + } else { + tl!("Process Color") + }; + crate::i18n::fmt(tl!("{kind}, {model}"), &[("kind", kind), ("model", color.model_name())]) + } + Paint::Gradient(g) => crate::i18n::fmt( + tl!("{kind} Gradient"), + &[("kind", crate::i18n::tr_ctx(crate::i18n::current(), "gradient-kind", g.gradient.kind.label()))], + ), + Paint::Pattern { .. } => tl!("Pattern").into(), + Paint::None => tl!("None").into(), + } +} + +/// The kind and colour-mode icons at the right of list row `r`: spot (a dot in a ring), global (a +/// square with a filled corner) or process colours (a square), gradients and patterns; then a +/// colour's model (CMYK as four ink quarters, RGB as three bars, Gray as a grey square, Lab as its +/// two opponent axes). +pub(crate) fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bool) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + let mode = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0)); + let kind = mode.translate(vec2(-16.0, 0.0)); + let line = Stroke::new(1.0, t.icon); + match paint { + Paint::Solid { color, .. } => { + if spot { + p.circle_stroke(kind.center(), 4.5, line); + p.circle_filled(kind.center(), 1.8, t.icon); + } else { + p.rect_stroke(kind, 0.0, line, StrokeKind::Inside); + if global { + let c = kind.right_bottom(); + p.add(Shape::convex_polygon(vec![c, c - vec2(6.0, 0.0), c - vec2(0.0, 6.0)], t.icon, Stroke::NONE)); + } + } + let quarters = |colors: [Color32; 4]| { + let q = mode.size() / 2.0; + for (i, c) in colors.into_iter().enumerate() { + p.rect_filled(Rect::from_min_size(mode.min + vec2((i % 2) as f32 * q.x, (i / 2) as f32 * q.y), q), 0.0, c); + } + }; + match color { + Color::Cmyk { .. } => quarters(CMYK_INKS), + Color::Lab { .. } => quarters(LAB_AXES), + Color::Rgb { .. } => { + let w = mode.width() / 3.0; + for (i, bar) in RGB_BARS.into_iter().enumerate() { + p.rect_filled(Rect::from_min_size(mode.min + vec2(i as f32 * w, 0.0), vec2(w, mode.height())), 0.0, bar); + } + } + Color::Gray { .. } => { + p.rect_filled(mode, 0.0, Color32::from_gray(128)); + } + } + p.rect_stroke(mode, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + } + Paint::Gradient(g) => { + let icon = match g.gradient.kind { + GradientKind::Linear => "dc-grad-linear", + GradientKind::Radial => "dc-grad-radial", + GradientKind::Freeform => "dc-grad-freeform", + }; + icons::paint(ui, icon, kind.expand(1.0), t.icon); + } + Paint::Pattern { .. } => icons::paint(ui, "grid-3x3", kind.expand(1.0), t.icon), + Paint::None => {} + } +} + +/// The thumbnail cells of `items` for `view` in the full available width, row by row; a folder +/// (`is_folder`) starts a new row. +pub(crate) fn tile_grid<'a, T>(ui: &mut Ui, items: &'a [T], view: View, is_folder: impl Fn(&T) -> bool) -> Vec<(&'a T, Rect)> { + let (tile, pitch) = view.tile(); + let w = ui.available_width(); + let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize; + let mut rows: Vec> = vec![vec![]]; + for e in items { + match rows.last_mut() { + Some(row) if row.len() < per_row && (row.is_empty() || !is_folder(e)) => row.push(e), + _ => rows.push(vec![e]), + } + } + let mut out = Vec::with_capacity(items.len()); + for row in rows { + let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); + let cell = |c: usize| Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); + out.extend(row.into_iter().enumerate().map(|(c, e)| (e, cell(c)))); + } + out +} + +/// A list-view row for `view` taking clicks and drags as `id`, shaded when `selected` or hovered +/// → (the row, its response, the chip at its left). +pub(crate) fn list_row(ui: &mut Ui, id: egui::Id, view: View, selected: bool) -> (Rect, Response, Rect) { + let t = Tokens::get(ui.ctx()); + let (tile, pitch) = view.tile(); + let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch)); + let resp = ui.interact(r, id, Sense::click_and_drag()); + if selected { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + (r, resp, Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile))) +} + +/// A list row's name, right of its `chip`. +pub(crate) fn list_row_name(ui: &Ui, r: Rect, chip: Rect, name: &str) { + let t = Tokens::get(ui.ctx()); + ui.painter().text(pos2(chip.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); +} + +/// The white corner of a global colour's tile; a spot colour has a dot in it. +pub(crate) fn global_mark(ui: &Ui, cell: Rect, spot: bool) { + let k = cell.shrink(1.0).right_bottom(); + let corner = vec![k, k - vec2(5.0, 0.0), k - vec2(0.0, 5.0)]; + ui.painter().add(Shape::convex_polygon(corner, Color32::WHITE, Stroke::NONE)); + if spot { + ui.painter().circle_filled(k - vec2(1.6, 1.6), 1.0, Color32::BLACK); + } +} + +/// A colour group's folder in the thumbnail view, outlined when `selected`. +pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) { + draw_folder(ui, cell); + if selected { + ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Outside); + } +} + +/// The interaction id of a tile or list row (stable per swatch or group name and host `salt`). +pub(super) fn tile_id(salt: &'static str, e: &Entry) -> egui::Id { + egui::Id::unique((salt, e.name())) +} From b3789b280cf9a56b1fe683446aabb145eed47093 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:51 +0000 Subject: [PATCH 144/199] refactor(ui-egui): move shortcuts.rs items to shortcuts/{paste,presentation,typing}.rs --- crates/ui-egui/src/shortcuts.rs | 501 +------------------ crates/ui-egui/src/shortcuts/paste.rs | 72 +++ crates/ui-egui/src/shortcuts/presentation.rs | 103 ++++ crates/ui-egui/src/shortcuts/typing.rs | 316 ++++++++++++ 4 files changed, 504 insertions(+), 488 deletions(-) create mode 100644 crates/ui-egui/src/shortcuts/paste.rs create mode 100644 crates/ui-egui/src/shortcuts/presentation.rs create mode 100644 crates/ui-egui/src/shortcuts/typing.rs diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 378590131..251617931 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -11,6 +11,19 @@ use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey}; use crate::VectorcraftApp; +mod paste; +mod presentation; +mod typing; + +use paste::unclip_chords; +pub(crate) use paste::{PasteChord, paste_command}; +pub(crate) use presentation::presentation_step; +use presentation::{escape_pair_id, escape_press, escape_resets_rotation, presentation_keys}; +#[cfg(test)] +use typing::is_shortcut_chord; +pub(crate) use typing::keep_marked_text; +use typing::{drop_typing_presses, type_text}; + /// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere. /// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a /// `Cmd+=` chord also answers `+` however it is typed. @@ -91,18 +104,6 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json v } -/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.) -const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste]; - -/// The paste command whose V chord `held` holds (Paste in Place for Cmd+Shift+V by default), else -/// Paste: egui reports every Cmd+V chord as a paste, never as its key. -pub(crate) fn paste_command(held: Modifiers) -> &'static str { - all_shortcuts() - .into_iter() - .find(|(sc, id, _)| sc.logical_key == Key::V && id.starts_with("edit.paste") && held.matches_logically(sc.modifiers)) - .map_or("edit.paste", |(_, id, _)| id) -} - /// Does every press of `key` this frame hold a chord with modifiers that a command shortcut is bound /// to, matched as [`consume`] matches it? `cmd_only`: only chords with Cmd count. False when `key` /// isn't pressed. @@ -140,380 +141,6 @@ pub fn runs_a_shortcut(key: &str, code: &str, modifiers: Modifiers) -> bool { craft_keymap::Layout::default().candidates(&input).iter().any(|c| mirror.keymap.lookup(c, false, &Active::NONE) != craft_keymap::Lookup::None) } -/// Give a copy or cut chord held with more modifiers its key back when it has a shortcut of its own: -/// egui reports every Cmd+C and Cmd+X chord as Copy or Cut, Shift and Alt held too, so Cmd+Shift+C -/// (Align center) copied instead (#1940). -fn unclip_chords(i: &mut egui::InputState) { - if !i.events.iter().any(|e| matches!(e, egui::Event::Copy | egui::Event::Cut)) { - return; - } - let (held, shortcuts) = (i.modifiers, all_shortcuts()); - for e in &mut i.events { - let (key, own) = match e { - egui::Event::Copy => (Key::C, "edit.copy"), - egui::Event::Cut => (Key::X, "edit.cut"), - _ => continue, - }; - if shortcuts.iter().any(|(sc, id, _)| sc.logical_key == key && *id != own && held.matches_exact(sc.modifiers)) { - *e = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: held }; - } - } -} - -/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox -/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a -/// Ctrl+Alt+L press and then the text "ł", which the Type tool types. Without the press no command -/// chord runs for it: neither Cmd+Alt+L nor Cmd+L, which egui matches with the extra Alt held. -fn drop_typing_presses(i: &mut egui::InputState) { - let next = i.events.iter().skip(1).map(Some).chain([None]); - let typed: Vec = i - .events - .iter() - .zip(next) - .map(|(e, next)| { - matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.ctrl && modifiers.alt) - && matches!(next, Some(egui::Event::Text(t)) if !t.is_empty()) - }) - .collect(); - let mut typed = typed.into_iter(); - i.events.retain(|_| !typed.next().unwrap_or(false)); -} - -/// Keyboard pastes of something other than text. egui swallows a paste chord's key press and -/// sends a Paste event only when the clipboard holds text, so a paste key released without its -/// press (or a Paste event) reported was a paste of a bitmap or a PDF. -#[derive(Clone, Default)] -pub(crate) struct PasteChord { - /// Paste keys whose press was reported (typed, not pasting). - pressed: Vec, - /// A Paste event came since the last paste key was released. - pasted: bool, -} - -impl PasteChord { - /// Follow one frame's `events` → the modifiers of a paste egui sent no event for (the chord's, - /// as its key is released). - pub(crate) fn textless_paste(&mut self, events: &[egui::Event]) -> Option { - let mut fire = None; - for e in events { - match e { - egui::Event::Paste(_) => self.pasted = true, - // A release that comes while the window is away is never seen. - egui::Event::WindowFocused(false) => *self = Self::default(), - egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key), - egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => { - match self.pressed.iter().position(|k| k == key) { - Some(i) => { - self.pressed.swap_remove(i); - } - // Unless a Paste event came (the guard takes it either way). - None if !std::mem::take(&mut self.pasted) => fire = Some(*modifiers), - None => {} - } - } - _ => {} - } - } - fire - } -} - -/// End an IME composition outside the IME (a click away, the IME going away): the -/// marked text stays as typed, and the system IME is told to drop it. -pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) { - let Some(m) = app.ime_marked.take() else { return }; - app.ime_discard = true; - if app.session.tool_composing() { - // Errors are already reported by the engine's interaction. - let _ = app.session.tool_text(&m, app.view_info()); - } -} - -/// Cmd/Ctrl chords are shortcuts (Create Outlines, Bold, …): their letter is never typed into the -/// text if a host still emits `Text` for them (#2094). Alt is left to type: Option+key on macOS -/// and AltGr (Ctrl+Alt) on Windows keyboards type characters such as é, ø and @. -fn is_shortcut_chord(m: egui::Modifiers) -> bool { - (m.command || m.ctrl) && !m.alt -} - -enum Seq { - Text(String), - Preedit(String, Option>), - Commit(String), - Erase(ToolKey, Mods), - /// An input method replaces text around the composition (upstream's character counts or - /// the fork's exact UTF-8 byte counts), then commits. - DeleteSurrounding(usize, usize, SurroundingUnit), - // The actual pointer/key still belongs to its existing handler. Its position in this - // sequence must nevertheless interrupt an old composition before later terminal input. - InterruptPointer, - InterruptNavigation, - InterruptCommand, - ResetCompleted(egui::ImeResetId), -} - -/// Typed text and IME events, in the order they came, to the Type tool. -fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { - let view = app.view_info(); - // Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes - // on with it when the window comes back (measured with Kotoeri), as the tool does. - let shortcut_mods = ctx.input(|i| is_shortcut_chord(i.modifiers)); - // A frame that began composing keeps its editing keys for the IME: a Backspace that empties the - // marked text must not then delete the committed character before it. - let was_composing = app.session.tool_composing(); - // Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so - // a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects - // characters in one frame interleaves correctly. Taken as two passes (all text, then the keys) - // the deletes land after the inserts and the diacritics are lost. Each handled event is removed - // so `route_type_input` below does not process the same Backspace/Delete again. - let seq = type_sequence(ctx); - let viewport = ctx.viewport_id(); - let mut old_composition_retired = false; - for e in seq { - // Before the UI, a changed composition or guard is settled `pending`: a token bound here - // could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to - // the stock descriptor. The post-UI finalizer binds the confirmed replacement. - crate::ime::sync_incoming_before_ui(app, ctx); - if let Seq::ResetCompleted(request) = &e { - old_composition_retired |= crate::ime::complete(app, ctx, *request); - continue; - } - if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) { - match &e { - // Accepted old payloads precede the fence even when their terminal arrives. - // None of these events proves the local reset has reached its boundary. - Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, - _ => {} - } - } - // A pointer/navigation/command notice has not reached its real handler yet. Text and - // erases after it belong to the old caret, not to the destination it will establish. - if crate::ime::deferred(app, ctx) { - match &e { - Seq::Text(_) | Seq::Erase(..) => continue, - _ => {} - } - } - let preedit_empty = match &e { - Seq::Preedit(text, _) => Some(text.is_empty()), - _ => None, - }; - let terminal_commit = matches!(&e, Seq::Commit(_)); - let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..)); - // Errors are already reported by the engine's interaction (a failed preview keeps it). - let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired); - if result.is_ok() && from_ime { - if terminal_commit { - app.ime_compositions.remove(&viewport); - } else { - crate::ime::remember_incoming(app, ctx, preedit_empty); - } - } - } - old_composition_retired -} - -/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived. -fn type_sequence(ctx: &egui::Context) -> Vec { - let mut seq = vec![]; - ctx.input_mut(|i| { - // Move the events out instead of cloning their strings: a keyboard hook that reinjects a - // batch keeps the batch in `seq` only, not in both the input list and a second copy. - let mut kept = Vec::with_capacity(i.events.len()); - // InputState's held flag describes the end of the batch. Recover its initial state from - // the first transition, so a move before a later press cannot cancel an earlier commit. - let mut primary_down = i - .events - .iter() - .find_map(|e| match e { - egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some(!pressed), - _ => None, - }) - .unwrap_or_else(|| i.pointer.primary_down()); - for e in std::mem::take(&mut i.events) { - match e { - egui::Event::Text(t) => seq.push(Seq::Text(t)), - egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { - seq.push(Seq::Preedit(text, active_range_chars)); - } - egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)), - egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { - seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars)); - } - egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { - seq.push(Seq::DeleteSurrounding(before_bytes, after_bytes, SurroundingUnit::Bytes)); - } - egui::Event::ImeResetCompleted { request_id } => seq.push(Seq::ResetCompleted(request_id)), - // X11/Wayland PRIMARY middle-click: text at the pointer, inserted as typed text (never - // routed as a key or shortcut). - egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)), - // Only unmodified Backspace/Delete interleave with injected text; word-delete and other - // modified forms stay with `route_type_input`. - egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. } - if !m.command && !m.alt && !m.ctrl => - { - let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete }; - seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() })); - } - e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => { - primary_down = pressed; - if pressed { - seq.push(Seq::InterruptPointer); - } - kept.push(e); - } - e @ egui::Event::PointerMoved(_) => { - if primary_down { - seq.push(Seq::InterruptPointer); - } - kept.push(e); - } - e @ egui::Event::Key { key, pressed: true, modifiers, .. } - if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End) - || key == Key::A && modifiers.command => - { - seq.push(Seq::InterruptNavigation); - kept.push(e); - } - e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => { - seq.push(Seq::InterruptCommand); - kept.push(e); - } - e @ (egui::Event::Cut | egui::Event::Paste(_)) => { - seq.push(Seq::InterruptCommand); - kept.push(e); - } - other => kept.push(other), - } - } - i.events = kept; - }); - seq -} - -/// Apply one sequence item to the active tool; the caller filters stale/deferred events and -/// turns a successful IME mutation into its receipt. -fn apply_type_event( - app: &mut VectorcraftApp, - ctx: &egui::Context, - view: ViewInfo, - e: Seq, - shortcut_mods: bool, - was_composing: bool, - old_composition_retired: &mut bool, -) -> EngineResult> { - match e { - Seq::InterruptPointer => { - *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); - Ok(vec![]) - } - Seq::InterruptNavigation => { - // Active composition owns its navigation keys. Once cleared, application caret - // navigation interrupts its still-pending terminal event, even in this batch. - if !app.session.tool_composing() { - *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); - } - Ok(vec![]) - } - Seq::InterruptCommand => { - if crate::ime::blocked(app, ctx) { - *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); - } - Ok(vec![]) - } - Seq::ResetCompleted(_) => Ok(vec![]), - // Keys the IME passed through come as text only when nothing is marked. This is an - // application edit (also a hook IME's reinjected characters): `Other` dominates. - Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { - let target_before = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - let r = app.session.tool_text(&t, view); - if r.is_ok() { - crate::ime::note_application_edit(app, ctx, view, target_before, before); - } - r - } - Seq::Text(_) => Ok(vec![]), - // A pre-edit is not committed text: it changes neither the outgoing snapshot (the - // engine query removes it) nor the cause. - Seq::Preedit(text, active_range_chars) => { - app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty()); - app.session.tool_preedit(&text, active_range_chars, view) - } - // A bare line break confirms the composition (Enter): the marked text, not a newline. - // It is an accepted IME commit of that marked text when the tool inserts the marked - // text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an - // application edit instead. - Seq::Commit(t) if t == "\n" || t == "\r" => { - let target_before = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - let composing = app.session.tool_composing(); - let verbatim = app.session.tool_ime_session_matches(view) - && match app.ime_marked.as_deref() { - Some(m) => app.session.tool_text_is_verbatim(m, view), - None => true, - }; - keep_marked_text(app); - if composing { - if verbatim { - crate::ime::note_ime_apply(app, ctx, view, target_before, before); - } else { - crate::ime::note_application_edit(app, ctx, view, target_before, before); - } - } - Ok(vec![]) - } - // An accepted IME commit: stamp the bounded committed context immediately before and - // after, so only a real mutation (not a no-op, rejection or failure) earns the receipt. - // A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an - // application edit and must never earn `InputMethod`. - Seq::Commit(t) => { - app.ime_marked = None; - let target_before = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - // A document edit since the composition means this commit starts a fresh typing - // session: that reset is application-side, so it cannot earn `InputMethod`. - let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view); - let r = app.session.tool_text(&t, view); - if r.is_ok() { - if verbatim { - crate::ime::note_ime_apply(app, ctx, view, target_before, before); - } else { - crate::ime::note_application_edit(app, ctx, view, target_before, before); - } - } - r - } - // While composing (or in a frame that began composing), editing keys belong to the IME. - Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => { - // Mirror `route_type_input`/`text_key`: surface an erase error in the status bar - // instead of discarding it, and report the successful edit as application-origin. - let target_before = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - let r = app.session.tool_key(tk, m, view); - match &r { - Err(e) => app.status(e.to_string()), - Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before), - } - r - } - Seq::Erase(..) => Ok(vec![]), - // Deleting around the composition starts the same typing session as the commit that - // follows, so the replacement is one undo step; an error reaches the status bar. The - // receipt covers only this accepted deletion. - Seq::DeleteSurrounding(before, after, unit) => { - let target_before = crate::ime::target(&mut app.session, view); - let stamp_before = crate::ime::stamp(&mut app.session, view); - let r = app.session.tool_ime_delete_surrounding(before, after, unit, view); - match &r { - Err(e) => app.status(e.to_string()), - Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before), - } - r - } - } -} - /// Process one frame's events against the keymap at most once per context, viewport and frame, so /// reruns cannot fire a shortcut or tool key twice. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { @@ -1036,108 +663,6 @@ fn settings_on_a_mac(app: &mut VectorcraftApp, ctx: &egui::Context, mac: bool) - pressed } -/// The second press of Escape pressed twice comes less than this many seconds after the first. -const ESCAPE_PAIR_SECONDS: f64 = 1.0; - -/// The key of the last counted Escape's time in egui's temporary data. -fn escape_pair_id() -> egui::Id { - egui::Id::unique("escape-pair") -} - -/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or -/// has another key or pointer button press as well (a key repeat counts as another press, and the -/// modifier keys do not count). Otherwise, whether the last counted Escape came less than -/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the -/// last counted Escape's time. -fn escape_press(ctx: &egui::Context) -> Option { - let (escape, other, now) = ctx.input(|i| { - let (mut escape, mut other) = (false, false); - for e in &i.events { - match e { - egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true, - egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true, - egui::Event::PointerButton { pressed: true, .. } => other = true, - _ => {} - } - } - (escape, other, i.time) - }); - if !escape && !other { - return None; - } - let counted = ctx.data_mut(|d| d.remove_temp::(escape_pair_id())); - (escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS)) -} - -/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, -/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the -/// Rotate View tool is chosen. -fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { - app.view().is_some_and(|v| v.rotation != 0.0) - && (second || app.session.tool_id() == "rotateView") - && !egui::Popup::is_any_open(ctx) - && !crate::canvas::ruler_origin_dragging(ctx) -} - -/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to -/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it -/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on -/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]). -fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option) { - let (mut leave, mut steps) = (false, vec![]); - ctx.input_mut(|i| { - i.events.retain(|e| match e { - egui::Event::Key { key: Key::Escape, pressed: true, .. } => { - leave = true; - false - } - egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => { - steps.push(false); - false - } - egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => { - steps.push(true); - false - } - egui::Event::Key { pressed: true, .. } - | egui::Event::Text(_) - | egui::Event::Ime(_) - | egui::Event::Copy - | egui::Event::Cut - | egui::Event::Paste(_) - | egui::Event::PasteImage(_) - | egui::Event::MiddleClickPaste { .. } => false, - _ => true, - }) - }); - for next in steps { - presentation_step(app, next); - } - if leave { - let _ = app.run("view.presentation", json!({})); - // The Escape that left the mode can start a pair, as in `handle`. - if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { - let now = ctx.input(|i| i.time); - ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); - } - } -} - -/// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar -/// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode. -/// On the last artboard a step forward leaves the view as it is, and so does a step back on the -/// first. -pub(crate) fn presentation_step(app: &mut VectorcraftApp, next: bool) { - let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); - let at = crate::panels::artboards::selected(app, n); - let to = if next { (at + 1 < n).then_some("next") } else { (at > 0).then_some("previous") }; - if let Some(to) = to - && let Err(e) = app.run("view.goToArtboard", json!({ "index": to })) - { - app.status(e); - } -} - /// What a single-key shortcut reads for `text`, typed by the key at `physical`: the text itself, /// or, when it holds no ASCII character (a layout without Latin letters), what that key types on a /// US layout, so V selects the Selection tool whatever the V key types. diff --git a/crates/ui-egui/src/shortcuts/paste.rs b/crates/ui-egui/src/shortcuts/paste.rs new file mode 100644 index 000000000..9f43e76c5 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/paste.rs @@ -0,0 +1,72 @@ +use super::*; + +/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.) +const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste]; + +/// The paste command whose V chord `held` holds (Paste in Place for Cmd+Shift+V by default), else +/// Paste: egui reports every Cmd+V chord as a paste, never as its key. +pub(crate) fn paste_command(held: Modifiers) -> &'static str { + all_shortcuts() + .into_iter() + .find(|(sc, id, _)| sc.logical_key == Key::V && id.starts_with("edit.paste") && held.matches_logically(sc.modifiers)) + .map_or("edit.paste", |(_, id, _)| id) +} + +/// Give a copy or cut chord held with more modifiers its key back when it has a shortcut of its own: +/// egui reports every Cmd+C and Cmd+X chord as Copy or Cut, Shift and Alt held too, so Cmd+Shift+C +/// (Align center) copied instead (#1940). +pub(super) fn unclip_chords(i: &mut egui::InputState) { + if !i.events.iter().any(|e| matches!(e, egui::Event::Copy | egui::Event::Cut)) { + return; + } + let (held, shortcuts) = (i.modifiers, all_shortcuts()); + for e in &mut i.events { + let (key, own) = match e { + egui::Event::Copy => (Key::C, "edit.copy"), + egui::Event::Cut => (Key::X, "edit.cut"), + _ => continue, + }; + if shortcuts.iter().any(|(sc, id, _)| sc.logical_key == key && *id != own && held.matches_exact(sc.modifiers)) { + *e = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: held }; + } + } +} + +/// Keyboard pastes of something other than text. egui swallows a paste chord's key press and +/// sends a Paste event only when the clipboard holds text, so a paste key released without its +/// press (or a Paste event) reported was a paste of a bitmap or a PDF. +#[derive(Clone, Default)] +pub(crate) struct PasteChord { + /// Paste keys whose press was reported (typed, not pasting). + pressed: Vec, + /// A Paste event came since the last paste key was released. + pasted: bool, +} + +impl PasteChord { + /// Follow one frame's `events` → the modifiers of a paste egui sent no event for (the chord's, + /// as its key is released). + pub(crate) fn textless_paste(&mut self, events: &[egui::Event]) -> Option { + let mut fire = None; + for e in events { + match e { + egui::Event::Paste(_) => self.pasted = true, + // A release that comes while the window is away is never seen. + egui::Event::WindowFocused(false) => *self = Self::default(), + egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key), + egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => { + match self.pressed.iter().position(|k| k == key) { + Some(i) => { + self.pressed.swap_remove(i); + } + // Unless a Paste event came (the guard takes it either way). + None if !std::mem::take(&mut self.pasted) => fire = Some(*modifiers), + None => {} + } + } + _ => {} + } + } + fire + } +} diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs new file mode 100644 index 000000000..691922ab7 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -0,0 +1,103 @@ +use super::*; + +/// The second press of Escape pressed twice comes less than this many seconds after the first. +const ESCAPE_PAIR_SECONDS: f64 = 1.0; + +/// The key of the last counted Escape's time in egui's temporary data. +pub(super) fn escape_pair_id() -> egui::Id { + egui::Id::unique("escape-pair") +} + +/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or +/// has another key or pointer button press as well (a key repeat counts as another press, and the +/// modifier keys do not count). Otherwise, whether the last counted Escape came less than +/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the +/// last counted Escape's time. +pub(super) fn escape_press(ctx: &egui::Context) -> Option { + let (escape, other, now) = ctx.input(|i| { + let (mut escape, mut other) = (false, false); + for e in &i.events { + match e { + egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true, + egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true, + egui::Event::PointerButton { pressed: true, .. } => other = true, + _ => {} + } + } + (escape, other, i.time) + }); + if !escape && !other { + return None; + } + let counted = ctx.data_mut(|d| d.remove_temp::(escape_pair_id())); + (escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS)) +} + +/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, +/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the +/// Rotate View tool is chosen. +pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { + app.view().is_some_and(|v| v.rotation != 0.0) + && (second || app.session.tool_id() == "rotateView") + && !egui::Popup::is_any_open(ctx) + && !crate::canvas::ruler_origin_dragging(ctx) +} + +/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to +/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it +/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on +/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]). +pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option) { + let (mut leave, mut steps) = (false, vec![]); + ctx.input_mut(|i| { + i.events.retain(|e| match e { + egui::Event::Key { key: Key::Escape, pressed: true, .. } => { + leave = true; + false + } + egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => { + steps.push(false); + false + } + egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => { + steps.push(true); + false + } + egui::Event::Key { pressed: true, .. } + | egui::Event::Text(_) + | egui::Event::Ime(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Paste(_) + | egui::Event::PasteImage(_) + | egui::Event::MiddleClickPaste { .. } => false, + _ => true, + }) + }); + for next in steps { + presentation_step(app, next); + } + if leave { + let _ = app.run("view.presentation", json!({})); + // The Escape that left the mode can start a pair, as in `handle`. + if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { + let now = ctx.input(|i| i.time); + ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); + } + } +} + +/// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar +/// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode. +/// On the last artboard a step forward leaves the view as it is, and so does a step back on the +/// first. +pub(crate) fn presentation_step(app: &mut VectorcraftApp, next: bool) { + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + let at = crate::panels::artboards::selected(app, n); + let to = if next { (at + 1 < n).then_some("next") } else { (at > 0).then_some("previous") }; + if let Some(to) = to + && let Err(e) = app.run("view.goToArtboard", json!({ "index": to })) + { + app.status(e); + } +} diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs new file mode 100644 index 000000000..c42e5d57d --- /dev/null +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -0,0 +1,316 @@ +use super::*; + +/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox +/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a +/// Ctrl+Alt+L press and then the text "ł", which the Type tool types. Without the press no command +/// chord runs for it: neither Cmd+Alt+L nor Cmd+L, which egui matches with the extra Alt held. +pub(super) fn drop_typing_presses(i: &mut egui::InputState) { + let next = i.events.iter().skip(1).map(Some).chain([None]); + let typed: Vec = i + .events + .iter() + .zip(next) + .map(|(e, next)| { + matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.ctrl && modifiers.alt) + && matches!(next, Some(egui::Event::Text(t)) if !t.is_empty()) + }) + .collect(); + let mut typed = typed.into_iter(); + i.events.retain(|_| !typed.next().unwrap_or(false)); +} + +/// End an IME composition outside the IME (a click away, the IME going away): the +/// marked text stays as typed, and the system IME is told to drop it. +pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) { + let Some(m) = app.ime_marked.take() else { return }; + app.ime_discard = true; + if app.session.tool_composing() { + // Errors are already reported by the engine's interaction. + let _ = app.session.tool_text(&m, app.view_info()); + } +} + +/// Cmd/Ctrl chords are shortcuts (Create Outlines, Bold, …): their letter is never typed into the +/// text if a host still emits `Text` for them (#2094). Alt is left to type: Option+key on macOS +/// and AltGr (Ctrl+Alt) on Windows keyboards type characters such as é, ø and @. +pub(super) fn is_shortcut_chord(m: egui::Modifiers) -> bool { + (m.command || m.ctrl) && !m.alt +} + +enum Seq { + Text(String), + Preedit(String, Option>), + Commit(String), + Erase(ToolKey, Mods), + /// An input method replaces text around the composition (upstream's character counts or + /// the fork's exact UTF-8 byte counts), then commits. + DeleteSurrounding(usize, usize, SurroundingUnit), + // The actual pointer/key still belongs to its existing handler. Its position in this + // sequence must nevertheless interrupt an old composition before later terminal input. + InterruptPointer, + InterruptNavigation, + InterruptCommand, + ResetCompleted(egui::ImeResetId), +} + +/// Typed text and IME events, in the order they came, to the Type tool. +pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + let view = app.view_info(); + // Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes + // on with it when the window comes back (measured with Kotoeri), as the tool does. + let shortcut_mods = ctx.input(|i| is_shortcut_chord(i.modifiers)); + // A frame that began composing keeps its editing keys for the IME: a Backspace that empties the + // marked text must not then delete the committed character before it. + let was_composing = app.session.tool_composing(); + // Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so + // a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects + // characters in one frame interleaves correctly. Taken as two passes (all text, then the keys) + // the deletes land after the inserts and the diacritics are lost. Each handled event is removed + // so `route_type_input` below does not process the same Backspace/Delete again. + let seq = type_sequence(ctx); + let viewport = ctx.viewport_id(); + let mut old_composition_retired = false; + for e in seq { + // Before the UI, a changed composition or guard is settled `pending`: a token bound here + // could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to + // the stock descriptor. The post-UI finalizer binds the confirmed replacement. + crate::ime::sync_incoming_before_ui(app, ctx); + if let Seq::ResetCompleted(request) = &e { + old_composition_retired |= crate::ime::complete(app, ctx, *request); + continue; + } + if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) { + match &e { + // Accepted old payloads precede the fence even when their terminal arrives. + // None of these events proves the local reset has reached its boundary. + Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, + _ => {} + } + } + // A pointer/navigation/command notice has not reached its real handler yet. Text and + // erases after it belong to the old caret, not to the destination it will establish. + if crate::ime::deferred(app, ctx) { + match &e { + Seq::Text(_) | Seq::Erase(..) => continue, + _ => {} + } + } + let preedit_empty = match &e { + Seq::Preedit(text, _) => Some(text.is_empty()), + _ => None, + }; + let terminal_commit = matches!(&e, Seq::Commit(_)); + let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..)); + // Errors are already reported by the engine's interaction (a failed preview keeps it). + let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired); + if result.is_ok() && from_ime { + if terminal_commit { + app.ime_compositions.remove(&viewport); + } else { + crate::ime::remember_incoming(app, ctx, preedit_empty); + } + } + } + old_composition_retired +} + +/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived. +fn type_sequence(ctx: &egui::Context) -> Vec { + let mut seq = vec![]; + ctx.input_mut(|i| { + // Move the events out instead of cloning their strings: a keyboard hook that reinjects a + // batch keeps the batch in `seq` only, not in both the input list and a second copy. + let mut kept = Vec::with_capacity(i.events.len()); + // InputState's held flag describes the end of the batch. Recover its initial state from + // the first transition, so a move before a later press cannot cancel an earlier commit. + let mut primary_down = i + .events + .iter() + .find_map(|e| match e { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some(!pressed), + _ => None, + }) + .unwrap_or_else(|| i.pointer.primary_down()); + for e in std::mem::take(&mut i.events) { + match e { + egui::Event::Text(t) => seq.push(Seq::Text(t)), + egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { + seq.push(Seq::Preedit(text, active_range_chars)); + } + egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)), + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { + seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars)); + } + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { + seq.push(Seq::DeleteSurrounding(before_bytes, after_bytes, SurroundingUnit::Bytes)); + } + egui::Event::ImeResetCompleted { request_id } => seq.push(Seq::ResetCompleted(request_id)), + // X11/Wayland PRIMARY middle-click: text at the pointer, inserted as typed text (never + // routed as a key or shortcut). + egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)), + // Only unmodified Backspace/Delete interleave with injected text; word-delete and other + // modified forms stay with `route_type_input`. + egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. } + if !m.command && !m.alt && !m.ctrl => + { + let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete }; + seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() })); + } + e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => { + primary_down = pressed; + if pressed { + seq.push(Seq::InterruptPointer); + } + kept.push(e); + } + e @ egui::Event::PointerMoved(_) => { + if primary_down { + seq.push(Seq::InterruptPointer); + } + kept.push(e); + } + e @ egui::Event::Key { key, pressed: true, modifiers, .. } + if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End) + || key == Key::A && modifiers.command => + { + seq.push(Seq::InterruptNavigation); + kept.push(e); + } + e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => { + seq.push(Seq::InterruptCommand); + kept.push(e); + } + e @ (egui::Event::Cut | egui::Event::Paste(_)) => { + seq.push(Seq::InterruptCommand); + kept.push(e); + } + other => kept.push(other), + } + } + i.events = kept; + }); + seq +} + +/// Apply one sequence item to the active tool; the caller filters stale/deferred events and +/// turns a successful IME mutation into its receipt. +fn apply_type_event( + app: &mut VectorcraftApp, + ctx: &egui::Context, + view: ViewInfo, + e: Seq, + shortcut_mods: bool, + was_composing: bool, + old_composition_retired: &mut bool, +) -> EngineResult> { + match e { + Seq::InterruptPointer => { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + Ok(vec![]) + } + Seq::InterruptNavigation => { + // Active composition owns its navigation keys. Once cleared, application caret + // navigation interrupts its still-pending terminal event, even in this batch. + if !app.session.tool_composing() { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + } + Ok(vec![]) + } + Seq::InterruptCommand => { + if crate::ime::blocked(app, ctx) { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + } + Ok(vec![]) + } + Seq::ResetCompleted(_) => Ok(vec![]), + // Keys the IME passed through come as text only when nothing is marked. This is an + // application edit (also a hook IME's reinjected characters): `Other` dominates. + Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_text(&t, view); + if r.is_ok() { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + r + } + Seq::Text(_) => Ok(vec![]), + // A pre-edit is not committed text: it changes neither the outgoing snapshot (the + // engine query removes it) nor the cause. + Seq::Preedit(text, active_range_chars) => { + app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty()); + app.session.tool_preedit(&text, active_range_chars, view) + } + // A bare line break confirms the composition (Enter): the marked text, not a newline. + // It is an accepted IME commit of that marked text when the tool inserts the marked + // text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an + // application edit instead. + Seq::Commit(t) if t == "\n" || t == "\r" => { + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let composing = app.session.tool_composing(); + let verbatim = app.session.tool_ime_session_matches(view) + && match app.ime_marked.as_deref() { + Some(m) => app.session.tool_text_is_verbatim(m, view), + None => true, + }; + keep_marked_text(app); + if composing { + if verbatim { + crate::ime::note_ime_apply(app, ctx, view, target_before, before); + } else { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + } + Ok(vec![]) + } + // An accepted IME commit: stamp the bounded committed context immediately before and + // after, so only a real mutation (not a no-op, rejection or failure) earns the receipt. + // A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an + // application edit and must never earn `InputMethod`. + Seq::Commit(t) => { + app.ime_marked = None; + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + // A document edit since the composition means this commit starts a fresh typing + // session: that reset is application-side, so it cannot earn `InputMethod`. + let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view); + let r = app.session.tool_text(&t, view); + if r.is_ok() { + if verbatim { + crate::ime::note_ime_apply(app, ctx, view, target_before, before); + } else { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + } + r + } + // While composing (or in a frame that began composing), editing keys belong to the IME. + Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => { + // Mirror `route_type_input`/`text_key`: surface an erase error in the status bar + // instead of discarding it, and report the successful edit as application-origin. + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_key(tk, m, view); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before), + } + r + } + Seq::Erase(..) => Ok(vec![]), + // Deleting around the composition starts the same typing session as the commit that + // follows, so the replacement is one undo step; an error reaches the status bar. The + // receipt covers only this accepted deletion. + Seq::DeleteSurrounding(before, after, unit) => { + let target_before = crate::ime::target(&mut app.session, view); + let stamp_before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_ime_delete_surrounding(before, after, unit, view); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before), + } + r + } + } +} From 055d01760322d2fd6f8e1ae7888d97effd84465a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:51 +0000 Subject: [PATCH 145/199] refactor(ui-egui): move widgets.rs items to widgets/{blend,buttons,chips,color,drag,dropdowns,labels,numeric,panel_chrome,text,toggles}.rs --- crates/ui-egui/src/widgets.rs | 3041 +------------------- crates/ui-egui/src/widgets/blend.rs | 161 ++ crates/ui-egui/src/widgets/buttons.rs | 202 ++ crates/ui-egui/src/widgets/chips.rs | 250 ++ crates/ui-egui/src/widgets/color.rs | 556 ++++ crates/ui-egui/src/widgets/drag.rs | 125 + crates/ui-egui/src/widgets/dropdowns.rs | 309 ++ crates/ui-egui/src/widgets/labels.rs | 181 ++ crates/ui-egui/src/widgets/numeric.rs | 661 +++++ crates/ui-egui/src/widgets/panel_chrome.rs | 136 + crates/ui-egui/src/widgets/text.rs | 328 +++ crates/ui-egui/src/widgets/toggles.rs | 105 + 12 files changed, 3068 insertions(+), 2987 deletions(-) create mode 100644 crates/ui-egui/src/widgets/blend.rs create mode 100644 crates/ui-egui/src/widgets/buttons.rs create mode 100644 crates/ui-egui/src/widgets/chips.rs create mode 100644 crates/ui-egui/src/widgets/color.rs create mode 100644 crates/ui-egui/src/widgets/drag.rs create mode 100644 crates/ui-egui/src/widgets/dropdowns.rs create mode 100644 crates/ui-egui/src/widgets/labels.rs create mode 100644 crates/ui-egui/src/widgets/numeric.rs create mode 100644 crates/ui-egui/src/widgets/panel_chrome.rs create mode 100644 crates/ui-egui/src/widgets/text.rs create mode 100644 crates/ui-egui/src/widgets/toggles.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 147e0f673..03467a8ef 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -9,6 +9,60 @@ use vectorcraft_doc::Unit; use crate::theme::{self, Tokens}; use crate::{icons, scrub}; +mod blend; +mod buttons; +mod chips; +mod color; +mod drag; +mod dropdowns; +mod labels; +mod numeric; +mod panel_chrome; +mod text; +mod toggles; + +pub use blend::{ + TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point, +}; +pub use buttons::{ + SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button, + primary_button_enabled, secondary_button, split_button, split_button_named, toggle_icon, +}; +pub(crate) use buttons::{icon_button_info, primary_button_width, secondary_button_width}; +#[cfg(test)] +use chips::gradient_chip_colors; +pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; +pub(crate) use color::pointer_phase; +pub use color::{ + Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider, + color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, +}; +pub use drag::{PanelDrag, SwatchRows, art_drop, doc_preview, drag_source}; +pub(crate) use dropdowns::combo; +#[cfg(test)] +use dropdowns::combo_button_id; +pub use dropdowns::{ + dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, + panel_scroll, popover, set_menu_floor, strip_scroll, +}; +pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_label, section_header, section_toggle, section_toggle_name, subheader}; +pub(crate) use labels::{label_field, take_previous_label}; +pub use numeric::{ + SpinPick, mixed_field, mixed_field_named, num_field, num_field_named, opt_field, opt_field_named, range_field, range_field_named, + range_field_stepped, size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_plain, + spin_plain_named, +}; +#[cfg(test)] +use numeric::{decimal_point, share_of, spinner_step, step_to_multiple}; +pub(crate) use numeric::{round_to, set_bare_numbers_are_points, typed_unit}; +pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar}; +pub(crate) use text::{dialog_focus_flag, enter_modifiers, focus_field, own_field_shift_enter, set_dialog_open, take_dialog_focus}; +pub use text::{ + exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, + text_presets_named, +}; +pub use toggles::{check, check_box, check_name, check_tip, check3, radio, radio_button}; + /// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also /// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do /// not need a `Debug` bound merely to salt a widget id, and never make an accessible name from it. @@ -35,2993 +89,6 @@ fn id_with(parent: egui::Id, salt: impl std::hash::Hash) -> egui::Id { parent.with(HashOnly(salt)) } -/// Square icon button; `selected` draws the pressed well. -pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response { - let (_, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - paint_icon_button(ui, resp, icon, tip, selected) -} - -/// [`icon_button`] drawn for a response the caller made (its own id or sense). -pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, selected: bool) -> Response { - let t = Tokens::get(ui.ctx()); - let rect = resp.rect; - let size = rect.width(); - let bg = if selected { - t.tool_active - } else if resp.hovered() { - t.hover - } else { - Color32::TRANSPARENT - }; - ui.painter().rect_filled(rect, CornerRadius::same(5), bg); - let pad = (size * 0.2).round(); - icons::paint(ui, icon, rect.shrink(pad), if selected { t.text_strong } else { t.icon }); - resp.widget_info(|| icon_button_info(ui.is_enabled(), selected, tl!(tip))); - if !tip.is_empty() { resp.on_hover_text(tl!(tip)) } else { resp } -} - -/// Small flat text button (Quick Actions style). -pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); - let rect = rect.shrink2(vec2(0.0, 0.5)); - ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); - flat_fill(ui, rect, &resp); - ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - resp -} - -/// A [`flat_button`] that expands to fit localized labels wider than `min_width`. -pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); - let width = (galley.size().x + 24.0).max(min_width); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); - let rect = rect.shrink2(vec2(0.0, 0.5)); - ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); - flat_fill(ui, rect, &resp); - ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - resp -} - -/// Width of a [`split_button`]'s arrow. -pub const SPLIT_ARROW: f32 = 18.0; - -/// A [`flat_button`] `width` wide whose right end is a menu arrow (Image Trace ▾) → the -/// responses of the button and of its arrow. -pub fn split_button(ui: &mut Ui, text: &str, width: f32) -> (Response, Response) { - split_button_named(ui, text, "", width) -} - -/// [`split_button`] whose arrow has the tool tip `arrow_tip`, which screen readers also call it -/// (none when empty). -pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) -> (Response, Response) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); - let rect = rect.shrink2(vec2(0.0, 0.5)); - let split = rect.right() - SPLIT_ARROW; - let main_rect = Rect::from_min_max(rect.min, pos2(split, rect.bottom())); - let arrow_rect = Rect::from_min_max(pos2(split, rect.top()), rect.max); - let main = ui.interact(main_rect, ui.scope_id().with((text, "split")), Sense::click()); - let arrow = ui.interact(arrow_rect, ui.scope_id().with((text, "split-arrow")), Sense::click()); - ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); - flat_fill(ui, main_rect, &main); - flat_fill(ui, arrow_rect, &arrow); - ui.painter().vline(split, rect.y_range().shrink(5.0), Stroke::new(1.0, t.panel)); - ui.painter().with_clip_rect(main_rect).text( - main_rect.center(), - egui::Align2::CENTER_CENTER, - tl!(text), - egui::FontId::proportional(12.5), - t.text_strong, - ); - icons::paint(ui, "chevron-down", Rect::from_center_size(arrow_rect.center(), vec2(12.0, 12.0)), t.text_dim); - main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - if arrow_tip.is_empty() { - return (main, arrow); - } - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(arrow_tip))); - (main, arrow.on_hover_text(tl!(arrow_tip))) -} - -/// The pressed or hovered fill of a flat button part. -fn flat_fill(ui: &Ui, rect: Rect, resp: &Response) { - let t = Tokens::get(ui.ctx()); - if resp.is_pointer_button_down_on() { - ui.painter().rect_filled(rect, CornerRadius::same(5), t.tool_active); - } else if resp.hovered() { - ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover); - } -} - -/// The accent call-to-action button (dialog OK/Create). -pub fn primary_button(ui: &mut Ui, text: &str) -> Response { - primary_button_enabled(ui, text, true) -} - -/// [`primary_button`], greyed out and not clickable unless `enabled`. -pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Response { - primary_button_sized(ui, text, enabled, 72.0) -} - -/// Primary dialog button with a minimum width, for aligned action columns. -pub(crate) fn primary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response { - primary_button_sized(ui, text, true, min_width) -} - -/// Draws a primary button at least `min_width` wide, growing to fit the translated label. -/// Returns its response with the enabled state and screen-reader name preserved. -fn primary_button_sized(ui: &mut Ui, text: &str, enabled: bool, min_width: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let fg = if enabled { Color32::WHITE } else { t.text_disabled }; - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), fg); - let w = galley.size().x + 32.0; - let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), if enabled { Sense::click() } else { Sense::hover() }); - let bg = match (enabled, resp.hovered()) { - (false, _) => t.input, - (true, true) => t.accent, - (true, false) => t.accent_strong, - }; - ui.painter().rect_filled(rect, CornerRadius::same(6), bg); - if !enabled { - ui.painter().rect_stroke(rect, CornerRadius::same(6), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); - } - ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!(text))); - resp -} - -/// The quiet secondary button (dialog Cancel). -pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { - secondary_button_width(ui, text, 72.0) -} - -/// Secondary dialog button with a minimum width, for aligned action columns. -pub(crate) fn secondary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), t.text); - let w = galley.size().x + 32.0; - let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), Sense::click()); - ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button }); - ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - resp -} - -/// Bold section header (Properties panel). -pub fn section_header(ui: &mut Ui, text: &str) { - let t = Tokens::get(ui.ctx()); - ui.add_space(2.0); - ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text_strong)); - ui.add_space(2.0); -} - -/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`; -/// a click anywhere on it toggles the section. Whether it was clicked. -pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool { - section_toggle_name(ui, tl!(text), open).clicked() -} - -/// [`section_toggle`] for a name that is user data (a library group), shown as it is → its -/// response (clicked: toggle; it takes a context menu). -pub fn section_toggle_name(ui: &mut Ui, text: &str, open: bool) -> Response { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(rect, 3.0, t.hover); - } - let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0)); - icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon }); - ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, text, theme::semibold(12.5), t.text); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::DisclosureTriangle, true, open, text)); - resp.on_hover_cursor(egui::CursorIcon::PointingHand) -} - -/// Full-width 1 px divider with vertical margins. -pub fn divider(ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - ui.add_space(6.0); - let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); - ui.painter().rect_filled(rect, 0.0, t.divider); - ui.add_space(6.0); -} - -/// The `Id` under which [`dim_label`]/[`field_label`] leave the label a following field should be -/// named by. egui 0.37's kittest fails an input with no accessible name, and VectorCraft paints -/// most labels beside, not inside, their fields. -fn next_label_id() -> egui::Id { - egui::Id::unique("vectorcraft-next-input-label") -} - -/// The label a field may be named by, and the widget id the field must be drawn under for the -/// label to belong to it. egui allocates a fresh auto id for every widget, but not for moving -/// between grid cells: the next field in a grid still has the id the label recorded, while a -/// checkbox or another widget between the label and a field has taken it. A label therefore names -/// the very next field, also in a grid, but not a later one after an unrelated widget. -#[derive(Clone, Copy)] -struct NextLabel { - id: egui::Id, - viewport: egui::ViewportId, - pass: u64, - at: Pos2, - next_auto_id: egui::Id, -} - -/// Record a label's widget id, viewport, pass, position and next auto id so only the immediately -/// following field can take the label. -fn remember_next_label(ui: &Ui, id: egui::Id) { - let label = NextLabel { - id, - viewport: ui.ctx().viewport_id(), - pass: ui.ctx().cumulative_pass_nr(), - at: ui.next_widget_position(), - next_auto_id: ui.next_auto_id(), - }; - ui.ctx().data_mut(|d| d.insert_temp(next_label_id(), label)); -} - -/// A label taken for the next field, and whether no widget came between the two. -pub(crate) struct PreviousLabel { - id: egui::Id, - at: Pos2, - immediate: bool, -} - -/// The label drawn immediately before a field in this pass, if no other widget came between them -/// (an app-local adapter around [`egui::Response::labelled_by`], not a general compatibility -/// layer). Takes the record, so one label names one field. -pub(crate) fn take_previous_label(ui: &Ui) -> Option { - let label = ui.ctx().data_mut(|d| { - let label = d.get_temp::(next_label_id()); - d.remove::(next_label_id()); - label - })?; - // Frame and pass numbers are per viewport; a label left in one viewport must never name a - // field in another. The shipped hosts draw one root viewport, so this scoping is defensive. - (label.viewport == ui.ctx().viewport_id() && label.pass == ui.ctx().cumulative_pass_nr()).then(|| PreviousLabel { - id: label.id, - at: label.at, - immediate: ui.next_auto_id() == label.next_auto_id, - }) -} - -/// Name `resp` by the field's real adjacent label (see [`take_previous_label`]). -pub(crate) fn label_field(resp: Response, label: Option) -> Response { - let Some(label) = label else { return resp }; - let adjacent = label.immediate || resp.rect.min.distance(label.at) <= 16.0 || resp.rect.contains(label.at); - if adjacent { resp.labelled_by(label.id) } else { resp } -} - -/// Give `resp` the explicit semantic name of a field whose visible label is not a widget beside -/// it (a label below a grid of dash cells, a heading over a row of fields). The name is already -/// translated at the call site. An empty name leaves the field unnamed rather than inventing one. -fn name_field(resp: Response, name: Option<&str>) -> Response { - match name { - Some(name) if !name.trim().is_empty() => resp.accessible_name(name), - _ => resp, - } -} - -/// A label in the panels' text colour; before a numeric field it scrubs the field -/// ([`scrub`]). -pub fn dim_label(ui: &mut Ui, text: &str) -> Response { - let resp = dim_name(ui, tl!(text)); - scrub::note_label(ui, resp.rect); - remember_next_label(ui, resp.id); - resp -} - -/// A field's label in other styles (`text` as given): before a numeric field it scrubs the field -/// ([`scrub`]). -pub fn field_label(ui: &mut Ui, text: impl Into) -> Response { - let resp = ui.label(text); - scrub::note_label(ui, resp.rect); - remember_next_label(ui, resp.id); - resp -} - -/// [`dim_label`] for a name that is user or file data (a swatch, style, artboard or font name): -/// shown as it is, never translated. -pub fn dim_name(ui: &mut Ui, text: &str) -> Response { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(text).color(t.text).size(12.5)) -} - -/// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it -/// when the preference changes (`prefs_dialog::apply_runtime`), the length fields read it. -pub(crate) fn set_bare_numbers_are_points(ctx: &egui::Context, on: bool) { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("bare_numbers_are_points"), on)); -} - -/// The unit a number typed with no unit into a field in `unit` is read in: `unit`, or for a field -/// in picas points with Numbers Without Units Are Points on (where `12` could be either). A typed -/// unit (`12 mm`, `2p6`, `1in`) always wins. -pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit { - let bare_points = ctx.data(|d| d.get_temp::(egui::Id::unique("bare_numbers_are_points"))).unwrap_or(true); - if bare_points && unit == Unit::Picas { Unit::Points } else { unit } -} - -/// `text` typed in a numeric field, with a decimal comma read as a point: `0,1 mm` is `0.1 mm` -/// (#3837). Text with a point keeps its commas, so `1,000.5` is still refused rather than read -/// with two decimal separators, and so does a number that reads as thousands ([`thousands`]). -fn decimal_point(text: &str) -> std::borrow::Cow<'_, str> { - if text.contains(',') && !text.contains('.') && !thousands(text) { text.replace(',', ".").into() } else { text.into() } -} - -/// Is `text` a number written with a thousands comma, as `1,000` or `12,500 mm`: one to three -/// digits (not a lone 0), a comma, exactly three digits, then at most a unit? Read as a decimal it -/// would be a thousand times too small, so the field refuses it instead (`0,125` and `1,25` are -/// decimals). -fn thousands(text: &str) -> bool { - let number = text.trim().trim_start_matches('-'); - let Some((whole, rest)) = number.split_once(',') else { return false }; - let (part, unit) = rest.split_at(rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())); - (1..=3).contains(&whole.len()) - && whole.bytes().all(|b| b.is_ascii_digit()) - && !whole.starts_with('0') - && part.len() == 3 - && unit.trim().chars().all(char::is_alphabetic) -} - -/// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) -/// when the user commits (Enter / focus loss / Shift+Enter, which keeps the focus outside dialogs). -/// Supports unit suffixes and arithmetic; a number without a unit is in `unit` ([`typed_unit`]: in -/// points in a picas field with Numbers Without Units Are Points on). -pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { - num_field_named(ui, id, None, value, unit, width) -} - -/// [`num_field`] for a row whose visible label is not an adjacent label widget (a grid cell with -/// its label painted separately): screen readers name the field `name` (already translated). -pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { - let label = take_previous_label(ui); - unit_field(ui, id, label, name, value, unit, width, false, ANY) -} - -/// Any number: the bounds of a field that has none. -const ANY: RangeInclusive = f64::NEG_INFINITY..=f64::INFINITY; - -/// `v` within `bounds` (bounds the wrong way round or not numbers don't panic, as `f64::clamp` would). -fn within(v: f64, bounds: &RangeInclusive) -> f64 { - v.max(*bounds.start()).min(*bounds.end()) -} - -/// [`num_field`]; with `share_of_value` a percentage typed alone is that share of `value` -/// ([`share_of`]). ↑/↓, the wheel and a scrub stop at `bounds` (points), and the field shows where -/// they stopped. -#[allow(clippy::too_many_arguments)] -fn unit_field( - ui: &mut Ui, - id: impl std::hash::Hash, - label: Option, - name: Option<&str>, - value: Option, - unit: Unit, - width: f32, - share_of_value: bool, - bounds: RangeInclusive, -) -> Option { - let id = id_with(ui.scope_id(), id); - let shown = value.map(|v| unit.format(v)).unwrap_or_default(); - take_dialog_focus(ui, id, &shown); - let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); - select_all_on_focus(ui, &resp, &buf); - // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. - let typed = typed_unit(ui.ctx(), unit); - let read = |b: &str| typed.parse(&decimal_point(b)); - let bounds = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); - let stepped = step_value(ui, &resp, &mut buf, &bounds, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None); - let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds); - let commit = commit && buf != shown; - ui.data_mut(|d| d.insert_temp(id, buf.clone())); - if commit { - read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None }) - } else { - stepped.or(scrubbed).map(|v| unit.to_pt(v)) - } -} - -/// A percentage typed alone (`50%`, `150 %`, `100/3%`) as that share of `value`; `None` for any -/// other text. `Unit::parse` reads a bare percentage as nothing, having no value to take it of. -fn share_of(text: &str, value: f64) -> Option { - let percent = vectorcraft_doc::parse_number(text.trim().strip_suffix('%')?)?; - Some(value * percent / 100.0) -} - -/// The places a stepped value in a unit field keeps (no 12.300000001). -const STEP_DECIMALS: i32 = 3; - -/// `v` rounded to `decimals` places. -pub(crate) fn round_to(v: f64, decimals: i32) -> f64 { - let scale = 10f64.powi(decimals.clamp(0, 6)); - (v * scale).round() / scale -} - -/// One step of a numeric field with each modifier, most specific first (a plain pattern would also -/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to -/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten. -const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; - -/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next -/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1 -/// goes up to 6 and down to 5, and 5 goes up to 6 and down to 4. A value within a millionth of a -/// step of a multiple counts as that multiple. -fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 { - if n == 0.0 { - return v; - } - let q = v / size; - let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() }; - (from + n) * size -} - -/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf` -/// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step -/// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to -/// the next tenth, and a Shift step adds or takes ten. Steps stop at the field's `bounds` (#3370: -/// a weight stops at 0, a miter limit at 1). The new value is applied at once, as in -/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing -/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused -/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the -/// wheel to the panel and the canvas. -fn step_value( - ui: &Ui, - resp: &Response, - buf: &mut String, - bounds: &RangeInclusive, - (read, show): (impl Fn(&str) -> Option, impl Fn(f64) -> String), - step: Option f64>, -) -> Option { - // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the - // window to report keyboard focus. - if !ui.memory(|m| m.has_focus(resp.id)) { - return None; - } - use egui::{Event, Key, MouseWheelUnit}; - let wheel = resp.contains_pointer(); - // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is - // left of a notch carries over to the next turn. - let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); - let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 }; - // Each press and each turn of whole notches in the order they came: its modifiers and size from - // [`STEPS`], and how many steps up (down when negative). - let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); - ui.input_mut(|i| { - i.events.retain(|e| { - let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; - let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; - let n = match e { - Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0, - Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0, - Event::MouseWheel { unit, delta, .. } if wheel => { - // Shift may turn the wheel sideways (the Mac does). - let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; - notches += match unit { - MouseWheelUnit::Point => d / line.max(1.0), - MouseWheelUnit::Line | MouseWheelUnit::Page => d, - }; - let whole = notches.trunc(); - notches -= whole; - f64::from(whole) - } - _ => return true, - }; - if n != 0.0 { - steps.push((mods, size, n)); - } - false - }); - if wheel { - // The rest of a turn egui spreads over the next frames scrolls nothing either. - i.smooth_scroll_delta = egui::Vec2::ZERO; - } - }); - if wheel { - ui.data_mut(|d| d.insert_temp(notch_id, notches)); - } - if steps.is_empty() { - return None; - } - let mut v = read(buf)?; - for (mods, size, n) in steps { - v = match step { - // The field's own steps: ten at once with Shift, one with or without Ctrl/Cmd. - Some(step) => step(v, if mods == egui::Modifiers::SHIFT { 10.0 * n } else { n }), - None if mods == egui::Modifiers::SHIFT => v + size * n, - None => step_to_multiple(v, size, n), - }; - // Each step stops at the bounds, so a step back in the same frame starts from there. - v = within(v, bounds); - } - // Rounded as fields show it, within the field's bounds. - let v = within(round_to(v, STEP_DECIMALS), bounds); - *buf = show(v); - ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); - select_all(ui, resp.id, buf); - Some(v) -} - -/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the -/// first enabled field drawn takes it ([`take_dialog_focus`]). -pub(crate) fn dialog_focus_flag() -> egui::Id { - egui::Id::unique("dialog-focus-first-field") -} - -/// If field `id` (showing `text`) is the first enabled field of a dialog that has just opened, give -/// it the keyboard focus with all of `text` selected: typing replaces the value and Enter applies it. -pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) { - // A new window's first frame only measures its contents: nothing can take focus yet. Nor can a - // greyed-out field (egui drops its focus): the flag stays for the first field that is enabled. - if ui.is_sizing_pass() || !ui.is_enabled() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { - return; - } - focus_field(ui, id, text); -} - -/// Give text field `id` the keyboard focus with all of `text` selected. -pub(crate) fn focus_field(ui: &Ui, id: egui::Id, text: &str) { - select_all(ui, id, text); - ui.memory_mut(|m| m.request_focus(id)); -} - -/// The modifiers held with an Enter press among this frame's events, `None` without one, as when a -/// field's editing ends with a click elsewhere. -pub(crate) fn enter_modifiers(ui: &Ui) -> Option { - ui.input(|i| { - i.events.iter().find_map(|e| match e { - egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } => Some(*modifiers), - _ => None, - }) - }) -} - -/// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter -/// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A -/// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). -pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { - size_field_named(ui, id, None, value, unit, width) -} - -/// [`size_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn size_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { - use egui::{Event, Key}; - let label = take_previous_label(ui); - // The text box does not end its editing on an Enter with Ctrl/Cmd: a plain Enter goes in right - // after it, so the box first takes the text typed before it in this frame. - let field = id_with(ui.scope_id(), &id); - if ui.memory(|m| m.has_focus(field)) { - ui.input_mut(|i| { - let cmd_enter = |e: &Event| matches!(e, Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command); - if let Some(at) = i.events.iter().position(cmd_enter) { - let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - i.events.insert(at + 1, enter); - } - }); - } - unit_field(ui, id, label, name, value, unit, width, true, ANY) -} - -/// Select all of `text` in text field `id`. -fn select_all(ui: &Ui, id: egui::Id, text: &str) { - let mut st = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); - st.cursor.set_char_range(Some(all)); - st.store(ui.ctx(), id); -} - -/// The flag that tells the fields a dialog is open in this frame ([`set_dialog_open`]). -fn dialog_open_flag() -> egui::Id { - egui::Id::unique("dialog-open") -} - -/// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field -/// applies its value and confirms the dialog as Enter does ([`shift_enter_keeps_focus`]). -pub(crate) fn set_dialog_open(ctx: &egui::Context, open: bool) { - ctx.data_mut(|d| d.insert_temp(dialog_open_flag(), open)); -} - -/// Shift+Enter (Shift+Return) in single-line field `id` while it has the keyboard focus and no -/// dialog is open: the press is taken from this frame's events before the field's text box reads -/// them, so the field keeps the focus. Returns true when it was pressed; the field then applies its -/// text as Enter does, and in the next frame shows its value with all of it selected -/// ([`after_shift_enter`]). -fn shift_enter_keeps_focus(ui: &Ui, id: egui::Id) -> bool { - if !ui.memory(|m| m.has_focus(id)) || ui.data(|d| d.get_temp::(dialog_open_flag())).unwrap_or(false) { - return false; - } - let pressed = ui.input_mut(|i| { - let before = i.events.len(); - i.events.retain(|e| { - !matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } if modifiers.matches_exact(egui::Modifiers::SHIFT)) - }); - i.events.len() < before - }); - if pressed { - ui.data_mut(|d| d.insert_temp(id.with("shift-enter"), true)); - } - pressed -} - -/// Whether field `id`, still focused, took Shift+Enter in the last frame -/// ([`shift_enter_keeps_focus`]). Clears that record. -fn after_shift_enter(ui: &Ui, id: egui::Id) -> bool { - ui.data_mut(|d| d.remove_temp::(id.with("shift-enter"))).unwrap_or(false) && ui.memory(|m| m.has_focus(id)) -} - -/// Handles Shift+Enter in a single-line panel field that draws its own text box `id` (Pattern -/// Options' Name, the inline renames in the Layers, Artboards and Asset Export panels). Call it -/// before the box is drawn, with `buf` the text the box will show and `shown` the value as it is -/// now. When the field took Shift+Enter in the last frame, `buf` becomes `shown`, all of it -/// selected. Returns whether Shift+Enter was pressed in this frame ([`shift_enter_keeps_focus`]): -/// the field then applies `buf` as Enter does and keeps the focus. -pub(crate) fn own_field_shift_enter(ui: &Ui, id: egui::Id, buf: &mut String, shown: &str) -> bool { - if after_shift_enter(ui, id) { - *buf = shown.to_string(); - select_all(ui, id, buf); - } - shift_enter_keeps_focus(ui, id) -} - -/// Numeric fields select the whole text on keyboard focus, a completed focus click or a -/// double-click. A pointer press keeps its caret so dragging selects from the pressed position, -/// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap -/// releases into select-all. -fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { - let pending = resp.id.with("select-all-on-focus-click"); - let press_pos = resp.id.with("select-all-on-focus-press"); - let (pressed, released, at_press) = ui.input(|i| { - (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.pointer.interact_pos()) - }); - if resp.gained_focus() { - ui.data_mut(|d| { - if pressed { - d.insert_temp(pending, true); - if let Some(p) = at_press { - d.insert_temp(press_pos, p); - } - } else { - d.remove_temp::(pending); - d.remove_temp::(press_pos); - } - }); - } - let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); - // A press released a few points away is a short drag, not a tap. egui still reports a - // click, and a release inside one character leaves no text selection, so select-all on - // release would replace the drag the user just made (#1326). - let dragged = clicked_focus - && ui - .data_mut(|d| d.remove_temp::(press_pos)) - .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); - if resp.lost_focus() { - ui.data_mut(|d| { - d.remove_temp::(pending); - d.remove_temp::(press_pos); - }); - } - if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus && !dragged) { - return; - } - if let Some(st) = egui::TextEdit::load_state(ui.ctx(), resp.id) { - // Preserve a text selection if the focus click already produced one. - if clicked_focus && !resp.double_clicked() && st.cursor.char_range().is_some_and(|r| !r.as_sorted_char_range().is_empty()) { - return; - } - select_all(ui, resp.id, text); - ui.ctx().request_repaint(); - } -} - -/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line), -/// its text kept under `id` while it has focus. Returns the text (as edited), whether the field -/// applies it in this frame (it lost the focus, or a single-line field took Shift+Enter and kept -/// the focus: [`shift_enter_keeps_focus`]), the text edit's response and the box. -fn recessed_text( - ui: &mut Ui, - id: egui::Id, - shown: &str, - width: f32, - rows: usize, - label: Option, - name: Option<&str>, -) -> (String, bool, Response, Rect) { - let t = Tokens::get(ui.ctx()); - let editing = ui.memory(|m| m.has_focus(id)); - // A press outside the field ends its editing in this frame, so the text typed is committed - // before the press acts: the canvas, drawn after the panels, may select another object, and a - // frame later the text would be committed to that one. - if editing - && let Some(at) = ui.input(|i| i.pointer.primary_pressed().then(|| i.pointer.interact_pos()).flatten()) - && ui.ctx().read_response(id).is_some_and(|r| !r.rect.contains(at)) - { - ui.memory_mut(|m| m.surrender_focus(id)); - } - // When the value changes under a focused field (the slider beside it, a preview, another - // field's edit), its text follows: the text typed for the old value would otherwise go back in - // on the next Enter, Tab or arrow key (#1457). - let shown_id = id.with("shown"); - let followed = ui.data_mut(|d| d.get_temp::(shown_id)).is_none_or(|before| before == shown); - ui.data_mut(|d| d.insert_temp(shown_id, shown.to_string())); - // A press elsewhere can end the focus outside this field's own drawing (a field drawn before it - // takes it, the canvas ends text input), so `editing` is already false on the frame it reports - // `lost_focus`: the text typed is still taken then, to be committed (#1637). Escape drops it. - let focus_id = id.with("had-focus"); - let had_focus = ui.data_mut(|d| d.get_temp::(focus_id)).unwrap_or(false) && !ui.input(|i| i.key_pressed(egui::Key::Escape)); - // After Shift+Enter applied the text, the field shows the value as it is now, all selected. - let reshown = after_shift_enter(ui, id); - let typed = if (editing || had_focus) && followed && !reshown { ui.data_mut(|d| d.get_temp::(id)) } else { None }; - let mut buf: String = typed.unwrap_or_else(|| shown.to_string()); - if reshown { - select_all(ui, id, &buf); - } - let kept = rows == 1 && shift_enter_keeps_focus(ui, id); - // The box is exactly as tall as the other controls of a row (26 points a line), its text - // centred in it: a frame around the text came out a point taller and stood out next to - // spinners and dropdowns (#900). - let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32; - let layout = if rows > 1 { egui::Layout::top_down(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Center) }; - let (rect, resp) = ui - .allocate_ui_with_layout(vec2(width, height), layout, |ui| { - ui.set_min_size(vec2(width, height)); - let rect = ui.max_rect(); - ui.painter().rect(rect, 5.0, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside); - let margin = egui::Margin { left: 7, right: 7, top: if rows > 1 { 4 } else { 0 }, bottom: 0 }; - let resp = egui::Frame::NONE - .inner_margin(margin) - .show(ui, |ui| { - crate::theme::strong_selected_text(ui); - let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) }; - ui.add( - edit.id(id) - .frame(egui::Frame::NONE) - .margin(egui::Margin::ZERO) - .desired_width(width - 14.0) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner; - (rect, resp) - }) - .inner; - let resp = name_field(label_field(resp, label), name); - let focused = ui.memory(|m| m.has_focus(id)); - ui.data_mut(|d| { - d.insert_temp(id, buf.clone()); - d.insert_temp(focus_id, focused); - }); - let commit = resp.lost_focus() || kept; - (buf, commit, resp, rect) -} - -/// A recessed text field showing `value` (blank when `None`: the selection's values differ), -/// `rows` lines tall (1: single line, which Enter and Shift+Enter commit). Returns the new text, -/// trimmed, when a change is committed (Enter, Shift+Enter or focus loss). -pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option<&str>, width: f32, rows: usize) -> Option { - text_field_named(ui, id, None, value, width, rows) -} - -/// [`text_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option<&str>, width: f32, rows: usize) -> Option { - let label = take_previous_label(ui); - let id = id_with(ui.scope_id(), id); - let shown = value.unwrap_or_default(); - let (buf, commit, _, _) = recessed_text(ui, id, shown, width, rows, label, name); - (commit && buf.trim() != shown).then(|| buf.trim().to_string()) -} - -/// A one-line [`text_field`] that keeps the text exactly as typed, spaces included (a tab -/// leader such as ". "). Returns it when a change is committed (Enter, Shift+Enter or focus loss). -pub fn exact_text_field(ui: &mut Ui, id: impl std::hash::Hash, value: &str, width: f32) -> Option { - exact_text_field_named(ui, id, None, value, width) -} - -/// [`exact_text_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn exact_text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, width: f32) -> Option { - let label = take_previous_label(ui); - let id = id_with(ui.scope_id(), id); - let (buf, commit, _, _) = recessed_text(ui, id, value, width, 1, label, name); - (commit && buf != value).then_some(buf) -} - -/// A form row: `label` in a column `label_width` wide, then what `add` draws. -pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) { - ui.horizontal(|ui| { - let (r, resp) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover()); - let t = Tokens::get(ui.ctx()); - let shown = tl!(label); - ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, shown, egui::FontId::proportional(12.5), t.text); - scrub::note_label(ui, r); - // The label is painted, not a widget of its own: give it a real semantic node so the - // input beside it can be named by `label_inputs_by` (egui 0.37's strict a11y gate). - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), shown)); - if label.trim().is_empty() { - add(ui); - } else { - ui.label_inputs_by(resp.id, add); - } - }); -} - -/// A plain number field (percent, degrees, counts) with optional suffix. -pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { - plain_field_named(ui, id, None, value, suffix, decimals, width) -} - -/// [`plain_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn plain_field_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - value: f64, - suffix: &str, - decimals: usize, - width: f32, -) -> Option { - mixed_field_named(ui, id, name, Some(value), suffix, decimals, width) -} - -/// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels -/// such as the Stroke panel's Limit); `None` shows a blank field, as [`mixed_field`] does. What is -/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count), and -/// ↑/↓, the wheel and a scrub stop at its ends in the field too (#3370). -pub fn range_field( - ui: &mut Ui, - id: impl std::hash::Hash, - value: impl Into>, - range: RangeInclusive, - suffix: &str, - decimals: usize, - width: f32, -) -> Option { - range_field_named(ui, id, None, value, range, suffix, decimals, width) -} - -/// [`range_field`] for a row named explicitly instead of by an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn range_field_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - value: impl Into>, - range: RangeInclusive, - suffix: &str, - decimals: usize, - width: f32, -) -> Option { - let places = decimals.min(6) as i32; - let value = value.into().map(|v| within(v, &range)); - let label = take_previous_label(ui); - bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None).map(|v| within(round_to(v, places), &range)) -} - -/// [`range_field`] whose ↑/↓ and wheel take `step`'s steps instead of whole numbers (see -/// [`SliderValue::step`]), for a number whose useful values are fractions, such as the Pencil's -/// Fidelity. -#[allow(clippy::too_many_arguments)] -pub fn range_field_stepped( - ui: &mut Ui, - id: impl std::hash::Hash, - value: impl Into>, - range: RangeInclusive, - suffix: &str, - decimals: usize, - width: f32, - step: fn(f64, f64) -> f64, -) -> Option { - let places = decimals.min(6) as i32; - let value = value.into().map(|v| within(v, &range)); - let label = take_previous_label(ui); - bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step)).map(|v| within(round_to(v, places), &range)) -} - -/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field. -pub fn mixed_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, suffix: &str, decimals: usize, width: f32) -> Option { - mixed_field_named(ui, id, None, value, suffix, decimals, width) -} - -/// [`mixed_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn mixed_field_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - value: Option, - suffix: &str, - decimals: usize, - width: f32, -) -> Option { - let label = take_previous_label(ui); - bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None) -} - -/// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`; ↑/↓ and the wheel take `step`'s steps -/// when it is given (see [`SliderValue::step`]). -#[allow(clippy::too_many_arguments)] -fn bounded_field( - ui: &mut Ui, - id: impl std::hash::Hash, - label: Option, - name: Option<&str>, - value: Option, - suffix: &str, - decimals: usize, - width: f32, - bounds: &RangeInclusive, - step: Option f64>, -) -> Option { - let id = id_with(ui.scope_id(), id); - let show = |value: f64| field_text(value, suffix, decimals); - // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. - let read = |buf: &str| { - let suffix = suffix.trim(); - let buf = decimal_point(buf); - let bare = if suffix.is_empty() { buf.to_string() } else { buf.replace(suffix, "") }; - vectorcraft_doc::parse_number(&bare.replace(['%', '°'], "")) - }; - let shown = value.map(show).unwrap_or_default(); - take_dialog_focus(ui, id, &shown); - let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); - select_all_on_focus(ui, &resp, &buf); - // ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on - // its label scrubs it. - let decimals = decimals.min(6) as i32; - let stepped = step_value(ui, &resp, &mut buf, bounds, (read, show), step).map(|v| round_to(v, decimals)); - let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals, bounds); - if commit && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) } -} - -/// The text [`plain_field`] shows for `value`: at most `decimals` places, without trailing zeros, -/// then `suffix`. -fn field_text(value: f64, suffix: &str, decimals: usize) -> String { - let s = format!("{:.*}", decimals, value); - let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; - format!("{s}{suffix}") -} - -/// Draw a paint preview (swatch chip) into `rect`. -pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { - let p = ui.painter(); - match paint { - Paint::None => { - p.rect_filled(rect, 0.0, Color32::WHITE); - p.line_segment([rect.left_bottom(), rect.right_top()], Stroke::new(1.6, Color32::from_rgb(0xe0, 0x20, 0x20))); - } - Paint::Solid { color, .. } => { - let [r, g, b, _] = color.to_rgba8(1.0); - p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); - } - Paint::Gradient(gp) => gradient_chip(ui, rect, gp), - Paint::Pattern { .. } => { - p.rect_filled(rect, 0.0, Color32::from_gray(200)); - for i in 0..4 { - let x = rect.left() + rect.width() * i as f32 / 4.0; - p.line_segment([pos2(x, rect.bottom()), pos2(x + rect.width() / 4.0, rect.top())], Stroke::new(1.0, Color32::from_gray(90))); - } - } - } -} - -/// Draw a gradient preview into `rect`, laid out as the gradient would be on an object of that -/// shape: linear along its angle, radial from the centre, freeform from its colour points. -pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::GradientPaint) { - if !(rect.width() > 0.0 && rect.height() > 0.0) { - return; - } - // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). - let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; - let max = if freeform { 16 } else { 64 }; - let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); - let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); - let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); - let mut mesh = egui::Mesh::default(); - for (k, color) in colors.into_iter().enumerate() { - let (i, j) = (k % (cols + 1), k / (cols + 1)); - let at = pos2(rect.left() + rect.width() * i as f32 / cols as f32, rect.top() + rect.height() * j as f32 / rows as f32); - mesh.colored_vertex(at, color); - } - for j in 0..rows as u32 { - for i in 0..cols as u32 { - let v = j * (cols as u32 + 1) + i; - mesh.add_triangle(v, v + 1, v + cols as u32 + 1); - mesh.add_triangle(v + 1, v + cols as u32 + 2, v + cols as u32 + 1); - } - } - ui.painter().add(egui::Shape::mesh(mesh)); -} - -/// The colours at the `(cols + 1) x (rows + 1)` grid points (row by row) of a `width` x `height` -/// chip showing `gp` as it would look on an object of that size. -fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: f32, height: f32, cols: usize, rows: usize) -> Vec { - use vectorcraft_color::{GradientGeom, GradientKind}; - let b = vectorcraft_geom::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64); - let rgb = |c: vectorcraft_color::Color| { - let [r, g, b, _] = c.to_rgba8(1.0); - Color32::from_rgb(r, g, b) - }; - let at = |k: usize| { - let (i, j) = (k % (cols + 1), k / (cols + 1)); - vectorcraft_geom::kurbo::Point::new(b.width() * i as f64 / cols as f64, b.height() * j as f64 / rows as f64) - }; - let n = (cols + 1) * (rows + 1); - if gp.gradient.kind == GradientKind::Freeform { - let Some(painted) = vectorcraft_color::freeform::painted_box(b) else { return vec![Color32::TRANSPARENT; n] }; - let field = gp.freeform_on(painted).field(vectorcraft_color::freeform::spread_scale(painted)); - return (0..n) - .map(|k| { - let ([r, g, b], _) = field.sample(at(k)); - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(q(r), q(g), q(b)) - }) - .collect(); - } - // A placed gradient keeps its direction; a fresh one is fitted to the chip at its angle. - let angle = gp.geom.map_or(gp.angle, |g| g.angle_deg()); - let geom = GradientGeom::fit(gp.gradient.kind, b, if angle.is_finite() { angle } else { 0.0 }); - (0..n) - .map(|k| { - let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; - rgb(gp.gradient.sample(t).0) - }) - .collect() -} - -/// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a -/// panel-coloured hole (returned; the whole chip for fills). A selection whose paints differ -/// (`mixed`) shows a white chip with a "?" (in the hole for strokes). -pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option) -> Rect { - let t = Tokens::get(ui.ctx()); - if mixed { - ui.painter().rect_filled(rect, 0.0, Color32::WHITE); - } else { - paint_chip(ui, rect, paint); - } - let (hole, mark) = match ring { - Some(w) => { - let inner = rect.shrink(w); - ui.painter().rect_filled(inner, 0.0, t.panel); - (inner, t.text_strong) - } - None => (rect, Color32::BLACK), - }; - if mixed { - ui.painter().text(hole.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(hole.height() * 0.8), mark); - } - hole -} - -/// A chip that opens something (the Control bar's and Properties' Fill/Stroke chips, the Control -/// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with -/// `chevron` a chevron follows it. -pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response { - chip_button_named(ui, size, chevron, "", draw) -} - -/// [`chip_button`] with the tool tip `tip`, which screen readers also call it (none when empty). -pub fn chip_button_named(ui: &mut Ui, size: f32, chevron: bool, tip: &str, draw: impl FnOnce(&Ui, Rect)) -> Response { - let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click()); - let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0); - draw(ui, chip); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); - if chevron { - icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim); - } - if tip.is_empty() { - return resp; - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(tip))); - resp.on_hover_text(tl!(tip)) -} - -/// What the user did on a [`fill_stroke_proxy`]. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ProxyClicks { - pub fill: bool, - pub stroke: bool, - pub swap: bool, - pub default: bool, - /// Double-clicked a square: open the Color Picker for it (`true` = the stroke). - pub pick: Option, - /// A paint dragged from the other proxy (or a swatch) dropped on the fill square. - pub dropped_fill: Option, - /// A paint dropped on the stroke square. - pub dropped_stroke: Option, -} - -/// The Fill/Stroke proxy pair (two overlapping squares). `mixed` (fill, stroke) draws a "?" square -/// for a selection whose objects differ. -pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool, bool), fill_active: bool, size: f32) -> ProxyClicks { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); - let s = size * 0.62; - let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); - let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); - let stroke_resp = ui.interact(stroke_r, ui.scope_id().with("stroke-proxy"), Sense::click_and_drag()); - let fill_resp = ui.interact(fill_r, ui.scope_id().with("fill-proxy"), Sense::click_and_drag()); - let dropped_fill = if mixed.0 { None } else { proxy_paint_drop(ui, &fill_resp) }; - let dropped_stroke = if mixed.1 { None } else { proxy_paint_drop(ui, &stroke_resp) }; - // A proxy drags its paint (onto the Swatches panel or art), unless it shows "?". - for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] { - if !mixed { - drag_source(ui, resp, || PanelDrag::paint(paint.clone())); - } - } - let draw_fill = |ui: &Ui| { - proxy_chip(ui, fill_r, fill, mixed.0, None); - ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); - ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); - }; - let draw_stroke = |ui: &Ui| { - let inner = proxy_chip(ui, stroke_r, stroke, mixed.1, Some(s * 0.28)); - ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); - ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside); - ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); - }; - if fill_active { - draw_stroke(ui); - draw_fill(ui); - } else { - draw_fill(ui); - draw_stroke(ui); - } - // Swap arrow (top-right) and default (bottom-left) mini buttons. - let swap_r = Rect::from_min_size(pos2(rect.right() - size * 0.3, rect.top()), Vec2::splat(size * 0.3)); - let def_r = Rect::from_min_size(pos2(rect.left(), rect.bottom() - size * 0.3), Vec2::splat(size * 0.3)); - let swap = ui.interact(swap_r, ui.scope_id().with("swap-proxy"), Sense::click()); - let def = ui.interact(def_r, ui.scope_id().with("default-proxy"), Sense::click()); - icons::paint(ui, "dc-swap", swap_r.shrink(1.0), if swap.hovered() { t.text } else { t.icon }); - let a = Rect::from_min_size(def_r.min + vec2(1.0, 1.0), Vec2::splat(size * 0.14)); - let b = Rect::from_min_size(def_r.min + vec2(size * 0.1, size * 0.1), Vec2::splat(size * 0.14)); - ui.painter().rect_filled(b, 0.0, Color32::BLACK); - ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); - ui.painter().rect_filled(a, 0.0, Color32::WHITE); - ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); - let pick = if fill_resp.double_clicked() { - Some(false) - } else if stroke_resp.double_clicked() { - Some(true) - } else { - None - }; - let on = ui.is_enabled(); - fill_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, fill_active, tl!("Fill (X), double-click for the Color Picker"))); - stroke_resp - .widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, !fill_active, tl!("Stroke (X), double-click for the Color Picker"))); - swap.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Swap Fill and Stroke (Shift+X)"))); - def.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Default Fill and Stroke (D)"))); - ProxyClicks { - fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(), - stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(), - swap: swap.on_hover_text(tl!("Swap Fill and Stroke (Shift+X)")).clicked(), - default: def.on_hover_text(tl!("Default Fill and Stroke (D)")).clicked(), - pick, - dropped_fill, - dropped_stroke, - } -} - -/// A [`PanelDrag::Paint`] released on a Fill/Stroke proxy square: highlight while held over it. -fn proxy_paint_drop(ui: &Ui, resp: &Response) -> Option { - if let Some(drag) = resp.dnd_hover_payload::() - && let PanelDrag::Paint { params, .. } = &*drag - && !params.is_null() - { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(resp.rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); - } - resp.dnd_release_payload::().and_then(|d| { - let PanelDrag::Paint { params, .. } = &*d else { return None }; - (!params.is_null()).then_some(params.clone()) - }) -} - -/// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index. -pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { - dropdown_with_named(ui, id, None, current, options, width, |_| true) -} - -/// [`dropdown`] for a row whose visible label is not an adjacent label widget. -pub fn dropdown_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { - dropdown_with_named(ui, id, name, current, options, width, |_| true) -} - -/// [`dropdown`] of names that are user, file or system data (font styles, artboards, presets the -/// user saved, printers, profiles), shown as they are, never translated. For a list that mixes -/// built-in labels with such names, translate the built-in ones (`tl!`) before passing them. -pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { - dropdown_names_named(ui, id, None, current, options, width) -} - -/// [`dropdown_names`] for a row whose visible label is not an adjacent label widget. -pub fn dropdown_names_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { - combo_named(ui, id, name, current, width, false, |ui| { - let mut chosen = None; - for (i, o) in options.iter().enumerate() { - if ui.add(egui::Button::selectable(*o == current, *o)).clicked() { - chosen = Some(i); - } - } - chosen - }) -} - -/// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). -pub fn dropdown_with( - ui: &mut Ui, - id: impl std::hash::Hash, - current: &str, - options: &[&str], - width: f32, - enabled: impl Fn(usize) -> bool, -) -> Option { - dropdown_with_named(ui, id, None, current, options, width, enabled) -} - -/// [`dropdown_with`] for a row whose visible label is not an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn dropdown_with_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - current: &str, - options: &[&str], - width: f32, - enabled: impl Fn(usize) -> bool, -) -> Option { - combo_named(ui, id, name, tl!(current), width, false, |ui| { - let mut chosen = None; - for (i, o) in options.iter().enumerate() { - if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, tl!(o))).clicked() { - chosen = Some(i); - } - } - chosen - }) -} - -/// The id egui's [`egui::ComboBox::from_id_salt`] gives its button in this `Ui`: `from_id_salt` -/// stores `IdSalt::new(salt)` and hashes it once more with the scope, so the app must hash `id` -/// the same way to read the button's focus and open state before it is drawn. -fn combo_button_id(ui: &Ui, id: &impl std::hash::Hash) -> egui::Id { - ui.make_persistent_id(egui::IdSalt::new(HashOnly(id))) -} - -/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns -/// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open -/// while it is clicked: it closes itself when an option is chosen. -pub(crate) fn combo( - ui: &mut Ui, - id: impl std::hash::Hash, - current: &str, - width: f32, - searchable: bool, - list: impl FnOnce(&mut Ui) -> Option, -) -> Option { - combo_named(ui, id, None, current, width, searchable, list) -} - -/// [`combo`] for a row whose visible label is not an adjacent label widget. -pub(crate) fn combo_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - current: &str, - width: f32, - searchable: bool, - list: impl FnOnce(&mut Ui) -> Option, -) -> Option { - let t = Tokens::get(ui.ctx()); - let label = take_previous_label(ui); - egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, t.input_border)) - .corner_radius(CornerRadius::same(3)) - .show(ui, |ui| { - if searchable { - return searchable_combo(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0, list); - } - let button_id = combo_button_id(ui, &id); - let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || egui::ComboBox::is_open(ui.ctx(), button_id); - let response = egui::ComboBox::from_id_salt(HashOnly(&id)) - .selected_text(egui::RichText::new(current).size(12.0)) - .width(width - 4.0) - .height(ui.spacing().combo_height) - .show_ui(ui, list); - // The button or open list handles Enter before the modal can accept it. - if enter_for_combo { - ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - } - let _ = name_field(label_field(response.response.clone(), label), name); - let chosen = response.inner.flatten(); - // egui closes the list on a click of the pointer only: an option picked with Space or - // Enter closes it here, and the combo gives the keyboard back, so that Enter reaches the - // dialog's OK (#2789). - if chosen.is_some() { - egui::Popup::close_all(ui.ctx()); - ui.memory_mut(|m| m.surrender_focus(button_id)); - } - chosen - }) - .inner -} - -/// A dropdown whose list keeps its own search field and scrolling rows (the font menu): drawn as -/// egui's combo box, but in a popup without the scroll area egui puts around a combo's list, so -/// the field stays at the top and only the rows scroll (#555). A click outside closes it. -fn searchable_combo( - ui: &mut Ui, - id: egui::Id, - label: Option, - name: Option<&str>, - current: &str, - width: f32, - list: impl FnOnce(&mut Ui) -> Option, -) -> Option { - let popup = id.with("popup"); - let open = egui::Popup::is_id_open(ui.ctx(), popup); - let pad = ui.spacing().button_padding; - let icon = Vec2::splat(ui.spacing().icon_width); - let galley = egui::WidgetText::from(egui::RichText::new(current).size(12.0)).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - width - 2.0 * pad.x - ui.spacing().icon_spacing - icon.x, - egui::TextStyle::Button, - ); - let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y); - let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); - // Screen readers get what egui's combo box gives them: its role and the text it shows. - resp.widget_info(|| egui::WidgetInfo { - enabled: ui.is_enabled(), - current_text_value: Some(current.to_owned()), - ..egui::WidgetInfo::new(egui::Role::ComboBox) - }); - let resp = name_field(label_field(resp, label), name); - if ui.is_rect_visible(rect) { - let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) }; - ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside); - let inner = rect.shrink2(pad); - ui.painter().galley(egui::Align2::LEFT_CENTER.align_size_within_rect(galley.size(), inner).min, galley, v.text_color()); - // egui's combo arrow: a downward triangle. - let arrow = - Rect::from_center_size(egui::Align2::RIGHT_CENTER.align_size_within_rect(icon, inner).center(), vec2(icon.x * 0.7, icon.y * 0.45)); - ui.painter().add(egui::Shape::convex_polygon( - vec![arrow.left_top(), arrow.right_top(), arrow.center_bottom()], - v.fg_stroke.color, - Stroke::NONE, - )); - } - // It opens below the button, or above it when there is more room there, and the list gets - // the room left (the popup's frame aside). - const GAP: f32 = 12.0; - let screen = ui.ctx().content_rect(); - let frame = ui.spacing().menu_margin.sum().y + GAP; - let (below, above) = (screen.bottom() - rect.bottom() - frame, rect.top() - screen.top() - frame); - let (align, room) = if below >= above { (egui::RectAlign::BOTTOM_START, below) } else { (egui::RectAlign::TOP_START, above) }; - egui::Popup::menu(&resp) - .id(popup) - .width(rect.width()) - .align(align) - .align_alternatives(&[]) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| { - ui.set_min_width(ui.available_width()); - ui.set_max_height(room); - ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); - list(ui) - }) - .and_then(|r| r.inner) -} - -/// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and -/// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its -/// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or -/// popups of its own: egui remembers one open popup at a time, so opening a dropdown inside a -/// remembered popup would close the popup (and the dropdown with it). This one keeps its open -/// state itself: the last frame it was open in. -pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -> R) -> Option { - let ctx = &resp.ctx; - let id = resp.id.with("popover"); - let frame = ctx.cumulative_frame_nr(); - let was_open = ctx.data(|d| d.get_temp::(id)).is_some_and(|f| f + 1 >= frame); - let mut open = if toggle { - !was_open - } else { - // Popups (this one and those it opened) are on foreground layers; a click anywhere - // else closes it. While the pointer is down or a panel paint drag is active, keep the - // popover open so a Fill/Stroke proxy drag can finish (#3440). - let pointer_down = ctx.input(|i| i.pointer.any_down()); - let panel_drag = egui::DragAndDrop::payload::(ctx).is_some(); - let click_pos = ctx.input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten()); - let clicked_anchor = click_pos.is_some_and(|pos| resp.rect.contains(pos)); - let clicked_elsewhere = !pointer_down - && !panel_drag - && !clicked_anchor - && click_pos.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground)); - was_open && !clicked_elsewhere - }; - let inner = - egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner); - ctx.data_mut(|d| { - if open { - d.insert_temp(id, frame); - } else { - d.remove::(id); - } - }); - inner -} - -/// Records where the bottom bars (status and hint bars) start this frame, so menus can stop above -/// them (#2086). Without bars it is the bottom of the window. -pub fn set_menu_floor(ctx: &egui::Context, y: f32) { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("menu_floor"), y)); -} - -/// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt. -pub fn menu_room(floor: f32, top: f32) -> f32 { - const BOTTOM_GAP: f32 = 12.0; - const MIN_HEIGHT: f32 = 120.0; - (floor - top - BOTTOM_GAP).max(MIN_HEIGHT) -} - -/// The scroll bar of a menu's list: slim, in a column of its own beside the items, so it never -/// covers what sits at their right end (shortcuts, submenu arrows, a font's favourite star). -pub fn list_scroll_style() -> egui::style::ScrollStyle { - egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() } -} - -/// The body of a menu or popup list: as tall as its items up to the bottom bars of the window, and -/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on -/// small windows. Its scroll bar has a column of its own, so it never covers the shortcuts or the -/// submenu arrows (#2086). -pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); - let room = menu_room(floor, ui.next_widget_position().y); - ui.scope(|ui| { - ui.spacing_mut().scroll = list_scroll_style(); - // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to - // its items when they need less. - egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner - }) - .inner -} - -/// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the -/// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is -/// over it. -/// Its scroll bar stays a slim line at the edge, even while scrolled or dragged, so it never covers -/// the buttons (#808). -pub fn strip_scroll(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - ui.scope(|ui| { - ui.spacing_mut().scroll = egui::style::ScrollStyle { - bar_width: 4.0, - floating_width: 2.0, - bar_inner_margin: 0.0, - bar_outer_margin: 1.0, - ..egui::style::ScrollStyle::floating() - }; - egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner - }) - .inner -} - -/// A panel's scrolling body (Properties, a dock column, a panel in the shared dock): `area` with -/// the scroll bar of a menu's list ([`list_scroll_style`]), in a column of its own at the right -/// edge, so it never covers the controls there, such as a dropdown's arrow (#3093). The scroll -/// areas inside the body keep their own style. -pub fn panel_scroll(ui: &mut Ui, area: egui::ScrollArea, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - ui.scope(|ui| { - let inner = std::mem::replace(&mut ui.spacing_mut().scroll, list_scroll_style()); - area.show(ui, |ui| { - ui.spacing_mut().scroll = inner; - add_contents(ui) - }) - .inner - }) - .inner -} - -/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a -/// [`BlendMode::group`] starts. -pub fn blend_separator_before(i: usize) -> bool { - i > 0 && i < BlendMode::ALL.len() && BlendMode::ALL[i].group() != BlendMode::ALL[i - 1].group() -} - -/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion -/// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode. -pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: Option, width: f32) -> Option { - combo(ui, id, current.map_or("", |m| tl!(m.label())), width, false, |ui| { - let mut chosen = None; - for (i, m) in BlendMode::ALL.into_iter().enumerate() { - if blend_separator_before(i) { - ui.separator(); - } - if ui.selectable_label(Some(m) == current, tl!(m.label())).clicked() { - chosen = Some(m); - } - } - chosen - }) -} - -/// The blend mode an effect parameter names (`"mode": "multiply"`), which editors show as a -/// [`blend_param_dropdown`]. -pub fn blend_param(key: &str, value: &serde_json::Value) -> Option { - value.as_str().filter(|_| key == "mode").and_then(BlendMode::parse) -} - -/// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value. -pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: BlendMode) -> Option { - blend_dropdown(ui, id, Some(current), 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) -} - -/// An edit made with [`opacity_blend`]. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum TransparencyEdit { - Blend(BlendMode), - /// Opacity in percent, with the slider's drag phase (`Released` for a typed value). - Opacity(f64, Live), -} - -/// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity -/// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup. -/// `opacity` (0..1) and `blend` are `None` where the objects differ (shown blank). -pub fn opacity_blend( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - opacity: Option, - blend: Option, - enabled: bool, -) -> Option { - let t = Tokens::get(ui.ctx()); - let mut edit = None; - ui.horizontal(|ui| { - ui.add_enabled_ui(enabled, |ui| { - if let Some(m) = blend_dropdown(ui, (id, "blend"), blend, 104.0) { - edit = Some(TransparencyEdit::Blend(m)); - } - }); - dim_label(ui, "Opacity:"); - ui.spacing_mut().item_spacing.x = 0.0; - ui.add_enabled_ui(enabled, |ui| { - if let Some(o) = range_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), 0.0..=100.0, "%", 0, 50.0) { - edit = Some(TransparencyEdit::Opacity(o, Live::Released)); - } - }); - let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); - ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!("Opacity"))); - egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - let mut o = opacity.unwrap_or(1.0) * 100.0; - let r = slider(ui, egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); - let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { - Live::Released - } else if r.changed() { - Live::Dragging - } else { - return; - }; - edit = Some(TransparencyEdit::Opacity(o.round() as f64, phase)); - }); - }); - edit -} - -/// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around -/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked. -pub fn reference_point(ui: &mut Ui, current: usize) -> Option { - let t = Tokens::get(ui.ctx()); - // 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through - // the squares' centres stay crisp. - let (sq, step) = (5.0, 9.0); - let size = 2.0 * step + sq; - let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let o = rect.min.round(); - let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step); - let line = Stroke::new(1.0, t.text_dim); - ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle); - let mut out = None; - // Screen readers name the squares left to right and top to bottom. - let names = [ - tl!("Top Left"), - tl!("Top"), - tl!("Top Right"), - tl!("Left"), - tl!("Center"), - tl!("Right"), - tl!("Bottom Left"), - tl!("Bottom"), - tl!("Bottom Right"), - ]; - for (i, name) in names.into_iter().enumerate() { - let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); - let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name)); - if resp.clicked() { - out = Some(i); - } - if i == current { - ui.painter().rect_filled(r, 0.0, t.text); - } else { - ui.painter().rect_filled(r, 0.0, t.panel); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); - } - } - out -} - -/// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees, -/// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole -/// degrees (-179..180), Shift in 45° steps. Returns the new angle. -pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash, angle: f64, size: f32) -> Option { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); - let (c, r) = (rect.center(), size / 2.0 - 1.0); - let ring = if resp.hovered() || resp.dragged() { t.text } else { t.input_border }; - ui.painter().circle(c, r, t.input, Stroke::new(1.0, ring)); - let a = (angle as f32).to_radians(); - ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * (r - 3.0)], Stroke::new(1.5, t.text)); - ui.painter().circle_filled(c, 2.0, t.text); - resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), angle, tl!("Angle"))); - let (p, _) = pointer_phase(&resp)?; - let v = p - c; - if v.length() < 2.0 { - return None; - } - let step = if ui.input(|i| i.modifiers.shift) { 45.0 } else { 1.0 }; - let deg = ((-v.y).atan2(v.x).to_degrees() as f64 / step).round() * step; - // atan2 gives -180..180: -180 shows as 180. - let deg = if deg <= -180.0 { deg + 360.0 } else { deg }; - (deg != angle).then_some(deg) -} - -/// A toggle icon (e.g. eye / lock columns) — returns clicked. -pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) -> bool { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - if on { - icons::paint(ui, on_icon, rect.shrink(size * 0.2), if resp.hovered() { t.text } else { t.icon }); - } else if resp.hovered() { - icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tl!(tip))); - resp.on_hover_text(tl!(tip)).clicked() -} - -// ---------- panel widgets (Swatches, Color, Stroke, Gradient, Appearance, …) ---------- - -/// Icon button that can be disabled (greyed, no hover, never clicked). -pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, enabled: bool, size: f32) -> Response { - if enabled { - return icon_button(ui, icon, tip, selected, size); - } - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let pad = (size * 0.2).round(); - icons::paint(ui, icon, rect.shrink(pad), t.text_disabled); - resp.widget_info(|| icon_button_info(false, selected, tl!(tip))); - if tip.is_empty() { resp } else { resp.on_hover_text(tl!(tip)) } -} - -/// What screen readers get for an icon button: a button called `name` (already translated), -/// reported as pressed while `selected` and with no pressed state otherwise. -pub(crate) fn icon_button_info(enabled: bool, selected: bool, name: &str) -> egui::WidgetInfo { - egui::WidgetInfo { selected: selected.then_some(true), ..egui::WidgetInfo::labeled(egui::Role::Button, enabled, name) } -} - -/// A slider whose rail shows, filled up to the handle: the theme's widget fill matches the panel -/// and dialog background, which leaves only the handle visible (#1483). -pub fn slider(ui: &mut Ui, slider: egui::Slider<'_>) -> Response { - let t = Tokens::get(ui.ctx()); - ui.scope(|ui| { - let v = ui.visuals_mut(); - // The buttons' border shows against the panel in every brightness; the fields' border - // all but vanished on Medium Dark's panel (#1662). - v.widgets.inactive.bg_fill = t.button_border; - v.selection.bg_fill = t.accent; - ui.add(slider.trailing_fill(true)) - }) - .inner -} - -/// Regular-weight panel sub-header ("Shape Modes:", "Align Objects:"). -pub fn subheader(ui: &mut Ui, text: &str) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text)); -} - -/// A label drawn with a dotted underline (Illustrator's link labels: "Stroke:", "Opacity:"). -pub fn link_label(ui: &mut Ui, text: &str) -> Response { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); - let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(0.0, 3.0), Sense::click()); - let y = rect.top() + galley.size().y + 1.0; - ui.painter().galley(rect.min, galley, t.text_strong); - let mut x = rect.left(); - while x < rect.right() - 1.0 { - ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim)); - x += 2.5; - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - resp -} - -/// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated), -/// draws the text and returns the box, the response and the box's border colour. -fn choice_row(ui: &mut Ui, text: &str, enabled: bool) -> (Rect, Response, Color32) { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled }); - let (rect, resp) = - ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() }); - let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0)); - ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text); - let border = if !enabled { - t.divider - } else if resp.hovered() { - t.text - } else { - t.button_border - }; - (bx, resp, border) -} - -/// Panel-style checkbox with a disabled state. Returns true when toggled. -pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { - check3(ui, label, Some(value), enabled) -} - -/// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled. -pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool { - let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled); - resp.on_hover_text(tip); - clicked -} - -/// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked. -pub fn check3(ui: &mut Ui, label: &str, value: Option, enabled: bool) -> bool { - check_row(ui, tl!(label), value, enabled).0 -} - -/// [`check`] for a name of the document's (an artboard's, an asset's), shown as it is, never -/// translated. Returns true when toggled. -pub fn check_name(ui: &mut Ui, name: &str, value: bool) -> bool { - check_row(ui, name, Some(value), true).0 -} - -/// A [`check`] box without text, for a row whose label is drawn beside it in another column: -/// screen readers call it `name` (already translated). Returns true when toggled. -pub fn check_box(ui: &mut Ui, name: &str, value: bool, enabled: bool) -> bool { - check_row_named(ui, "", name, Some(value), enabled).0 -} - -/// [`check3`] with its `text` already translated, also returning the row's response (what a -/// tooltip goes on: a widget around it never counts as hovered under the box). -fn check_row(ui: &mut Ui, text: &str, value: Option, enabled: bool) -> (bool, Response) { - check_row_named(ui, text, text, value, enabled) -} - -/// [`check_row`] that screen readers call `name` instead of its `text`. -fn check_row_named(ui: &mut Ui, text: &str, name: &str, value: Option, enabled: bool) -> (bool, Response) { - let t = Tokens::get(ui.ctx()); - let (bx, resp, border) = choice_row(ui, text, enabled); - let checked = value == Some(true); - ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input }); - ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside); - match value { - Some(true) => { - let c = if enabled { Color32::WHITE } else { t.text_disabled }; - ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); - ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); - } - None => { - let c = if enabled { t.text } else { t.text_disabled }; - ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, c)); - } - Some(false) => {} - } - resp.widget_info(|| match value { - Some(on) => egui::WidgetInfo::selected(egui::Role::CheckBox, enabled && ui.is_enabled(), on, name), - None => egui::WidgetInfo::labeled(egui::Role::CheckBox, enabled && ui.is_enabled(), name), - }); - (enabled && resp.clicked(), resp) -} - -/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked. -pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool { - radio_row(ui, tl!(label), tl!(label), selected, enabled) -} - -/// A [`radio`] button without text, for one followed by a field and the text that names it: -/// screen readers call it `name` (already translated). Returns true when clicked. -pub fn radio_button(ui: &mut Ui, name: &str, selected: bool, enabled: bool) -> bool { - radio_row(ui, "", name, selected, enabled) -} - -/// [`radio`] with its `text` already translated, which screen readers call `name`. -fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) -> bool { - let t = Tokens::get(ui.ctx()); - let (bx, resp, border) = choice_row(ui, text, enabled); - let c = bx.center(); - ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border)); - if selected { - ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled }); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, enabled && ui.is_enabled(), selected, name)); - enabled && resp.clicked() -} - -/// Numeric field with an up/down spinner on the left and a preset dropdown on the right -/// (Stroke weight, font size…): an editable combo box whose field keeps typing, ↑/↓ stepping and -/// label scrubbing. `value` (points) shows in `unit`, blank when `None` (the selection's values -/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. The spinner, ↑/↓, -/// the wheel and a scrub stop at `min` (#3370), and a typed value below it applies `min`. Returns -/// the committed, stepped or picked value. -#[allow(clippy::too_many_arguments)] -pub fn spin_field( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - value: Option, - unit: Unit, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - spin_field_named(ui, id, None, value, unit, width, step, min, presets) -} - -/// [`spin_field`] for a row named explicitly instead of by an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn spin_field_named( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - name: Option<&str>, - value: Option, - unit: Unit, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - spin_field_auto_named(ui, id, name, value, None, unit, width, step, min, presets).and_then(SpinPick::value) -} - -/// What a [`spin_field_auto`] returns: a value (points), or its Auto entry. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum SpinPick { - Value(f64), - Auto, -} - -impl SpinPick { - /// The value, `None` for Auto. - pub fn value(self) -> Option { - match self { - SpinPick::Value(v) => Some(v), - SpinPick::Auto => None, - } - } -} - -/// [`spin_field`] whose dropdown starts with an Auto entry (Leading): `auto` is `Some(on)`, with -/// `on` checking it (no preset is then checked), or `None` for no Auto entry. -#[allow(clippy::too_many_arguments)] -pub fn spin_field_auto( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - value: Option, - auto: Option, - unit: Unit, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - spin_field_auto_named(ui, id, None, value, auto, unit, width, step, min, presets) -} - -/// [`spin_field_auto`] for a row named explicitly instead of by an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn spin_field_auto_named( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - name: Option<&str>, - value: Option, - auto: Option, - unit: Unit, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - let label = take_previous_label(ui); - spin_generic(ui, label, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY) - }) -} - -/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix; a -/// `None` value (the selection's values differ) shows blank. -#[allow(clippy::too_many_arguments)] -pub fn spin_plain( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - value: impl Into>, - suffix: &str, - decimals: usize, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - spin_plain_named(ui, id, None, value, suffix, decimals, width, step, min, presets) -} - -/// [`spin_plain`] for a row named explicitly instead of by an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn spin_plain_named( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - name: Option<&str>, - value: impl Into>, - suffix: &str, - decimals: usize, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - let label = take_previous_label(ui); - let value = value.into(); - let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, label, value, None, width, step, min, presets, &fmt, &mut |ui, fw, label| { - bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None) - }) - .and_then(SpinPick::value) -} - -type SpinField<'a> = dyn FnMut(&mut Ui, f32, Option) -> Option + 'a; - -#[allow(clippy::too_many_arguments)] -fn spin_generic( - ui: &mut Ui, - label: Option, - value: Option, - auto: Option, - width: f32, - step: f64, - min: f64, - presets: &[f64], - fmt: &dyn Fn(f64) -> String, - field: &mut SpinField<'_>, -) -> Option { - let t = Tokens::get(ui.ctx()); - let mut out = None; - let h = 26.0; - let enabled = ui.is_enabled(); - ui.scope(|ui| { - // The spinner, the field and the chevron share their borders: one box in three parts. - ui.spacing_mut().item_spacing.x = -1.0; - let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click()); - ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input); - ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y)); - let down = Rect::from_min_max(pos2(sr.left(), sr.center().y), sr.max); - let hover = sresp.hover_pos(); - for (r, is_up) in [(up, true), (down, false)] { - let c = if !enabled { - t.text_disabled - } else if hover.is_some_and(|p| r.contains(p)) { - t.text_strong - } else { - t.icon - }; - let m = r.center(); - let d = if is_up { -1.5 } else { 1.5 }; - ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); - ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); - } - sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); - // A blank field (values that differ) has nothing to step from. - if sresp.clicked() - && let Some(p) = sresp.interact_pointer_pos() - && let Some(v) = value - { - let modifiers = ui.input(|i| i.modifiers); - out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers))); - } - let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; - if let Some(v) = field(ui, fw, label) { - out = Some(SpinPick::Value(v.max(min))); - } - if !presets.is_empty() { - let dresp = chevron_cell(ui, h); - egui::Popup::menu(&dresp).show(|ui| { - ui.set_min_width(width - 10.0); - if let Some(on) = auto - && ui.selectable_label(on, tl!("Auto")).clicked() - { - out = Some(SpinPick::Auto); - } - let current = value.filter(|_| auto != Some(true)); - for p in presets { - if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { - out = Some(SpinPick::Value(*p)); - } - } - }); - } - }); - out -} - -/// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: -/// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step. -fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers) -> f64 { - let multiplier = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or(1.0, |(_, factor)| *factor); - let delta = step * multiplier; - (value + if up { delta } else { -delta }).max(min) -} - -/// Drag phase of a live slider/handle: previews while dragging, commits on release. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Live { - Idle, - Dragging, - Released, -} - -/// A colour slider with a gradient track (`track(t)` gives the colour at 0..1) and a triangular -/// thumb below it, like Illustrator's Color panel. Returns the new normalized value and the phase. -pub fn color_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, width: f32, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { - color_slider_named(ui, id, "", value, width, track) -} - -/// [`color_slider`] that screen readers get as a slider named `name` (already translated) with no -/// value (none when `name` is empty). -pub fn color_slider_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { - color_slider_valued(ui, id, name, None, value, width, track) -} - -/// The value a slider gives screen readers: the number the field beside it shows, the range of the -/// slider's rail and the field's text. -#[derive(Clone, Debug)] -pub struct SliderValue { - pub value: f64, - pub range: std::ops::RangeInclusive, - /// The field's text, such as "50%"; `None` gives the number alone. - pub text: Option, - /// How the keys and screen readers step the number ([`color_slider_stepped`]): the number `n` - /// steps from `value` (±1 for an arrow, ±10 with Shift or Page Up/Down). `None` steps to whole - /// numbers. - pub step: Option f64>, -} - -/// Two values are the same when they show the same number, range and text, whatever their keys' steps -/// (function pointers don't compare reliably). -impl PartialEq for SliderValue { - fn eq(&self, other: &Self) -> bool { - (self.value, &self.range, &self.text) == (other.value, &other.range, &other.text) - } -} - -impl SliderValue { - /// `value` on a rail over `range`, without a text. - pub fn number(value: f64, range: std::ops::RangeInclusive) -> Self { - Self { value, range, text: None, step: None } - } - - /// `value` on a rail over `range` as [`plain_field`] shows it with `suffix` and at most - /// `decimals` places: the number it shows (`value` rounded) and its text. - pub fn field(value: f64, range: std::ops::RangeInclusive, suffix: &str, decimals: usize) -> Self { - let number = field_text(value, "", decimals); - Self { value: number.parse().unwrap_or(value), range, text: Some(format!("{number}{suffix}")), step: None } - } - - /// `value` on a rail over `range`, with `text`, for a field that writes its own text (a length - /// in the document's unit, an egui `DragValue`). - pub fn text(value: f64, range: std::ops::RangeInclusive, text: String) -> Self { - Self { value, range, text: Some(text), step: None } - } - - /// This value, stepped by the keys with `step` instead of to whole numbers (see - /// [`SliderValue::step`]), for a rail whose useful numbers are fractions. - pub fn with_step(self, step: fn(f64, f64) -> f64) -> Self { - Self { step: Some(step), ..self } - } -} - -/// Give `resp` a slider's screen-reader node named `name` (nothing when `name` is empty), with -/// `shown`'s number, text and range: the rail's range, extended to the number when the field holds -/// a number past the rail's end. -fn slider_info(ui: &Ui, resp: &Response, name: &str, shown: Option) { - if name.is_empty() { - return; - } - let enabled = ui.is_enabled(); - let Some(s) = shown else { - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, enabled, name)); - return; - }; - resp.widget_info(|| egui::WidgetInfo { current_text_value: s.text.clone(), ..egui::WidgetInfo::slider(enabled, s.value, name) }); - ui.ctx().accesskit_node_builder(resp.id, |node| { - node.set_min_numeric_value(s.range.start().min(s.value)); - node.set_max_numeric_value(s.range.end().max(s.value)); - }); -} - -/// [`color_slider_named`] that screen readers also get `shown` from (see [`SliderValue`]). With -/// `None`, as for a field left blank for a mixed value, it reports no value. -pub fn color_slider_valued( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - shown: Option, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { - let id = id_with(ui.scope_id(), id); - let (_, picked, phase) = color_rail(ui, id, name, shown, value, width, track); - (picked, phase) -} - -/// [`color_slider_valued`] for a dialog's rail beside a number field, which also responds to the -/// keyboard. A click or a drag gives the rail the keyboard focus; an outline around it shows the -/// focus once a key has stepped it, or when the focus came from the keyboard (Tab). -/// While it has the focus, → and ↑ raise the number `shown` holds to the next whole number and ← -/// and ↓ lower it to the next whole number, Shift with an arrow, Page Up and Page Down add or take -/// ten, and Home and End set the start and the end of `shown`'s range. Screen readers' increment -/// and decrement actions step the number as → and ← do. A number past the range steps from the -/// range's nearer end, and no step goes past either end. With `shown` `None` (a blank field), or -/// in a disabled `ui`, the keys and actions leave the rail as it is. Returns the new value from 0 -/// to 1 that a click or a drag gives, its phase, and the number in `shown`'s units that a key or -/// an action gives. -pub fn color_slider_stepped( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - shown: Option, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live, Option) { - let id = id_with(ui.scope_id(), id); - let (resp, picked, phase) = color_rail(ui, id, name, shown.clone(), value, width, track); - // Focus a click or a drag gave shows no outline until a key steps the rail: the outline tells - // a keyboard user where the focus is, and after a drag it only framed the rail. - let by_pointer = id.with("focused-by-pointer"); - if resp.drag_started() || resp.clicked() { - resp.request_focus(); - ui.data_mut(|d| d.insert_temp(by_pointer, true)); - } - let focused = ui.memory(|m| m.has_focus(id)); - if !focused { - ui.data_mut(|d| d.remove_temp::(by_pointer)); - } - let stepped = shown.filter(|_| ui.is_enabled()).and_then(|s| { - ui.ctx().accesskit_node_builder(id, |node| { - node.set_numeric_value_step(1.0); - node.add_action(egui::accesskit::Action::Increment); - node.add_action(egui::accesskit::Action::Decrement); - }); - rail_step(ui, id, focused, &s) - }); - if stepped.is_some() { - ui.data_mut(|d| d.remove_temp::(by_pointer)); - } - if focused && !ui.data(|d| d.get_temp::(by_pointer)).unwrap_or(false) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(resp.rect, 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); - } - (picked, phase, stepped.filter(|_| picked.is_none())) -} - -/// The number of slider `id` (`s`) after this frame's keys while it is `focused`, and after screen -/// readers' actions on it ([`color_slider_stepped`]); `None` when they leave it as it was. -fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option { - use egui::accesskit::Action; - use egui::{Event, Key}; - let (lo, hi) = (*s.range.start(), *s.range.end()); - if !lo.is_finite() || !hi.is_finite() || hi <= lo { - return None; - } - let from = s.value.clamp(lo, hi); - let mut v = from; - // `n` steps from `v`: the rail's own steps, or to whole numbers (ten at a time with Shift). - let by = |v: f64, n: f64| match s.step { - Some(step) => step(v, n), - None if n.abs() >= 10.0 => v + n, - None => step_to_multiple(v, 1.0, n), - }; - // Home or End: an end of the range, which also replaces a number past it. - let mut end = false; - if focused { - // The arrows step the rail and do not move the focus. egui keeps the filter only when the - // rail also had the focus in the frame before, and applies it from the next frame; until - // then, an arrow the rail takes cancels the focus move that egui set at the start of the frame. - let filter = egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; - ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); - let mut arrows = false; - ui.input_mut(|i| { - i.events.retain(|e| { - let Event::Key { key, pressed: true, modifiers, .. } = e else { return true }; - let up = matches!(key, Key::ArrowRight | Key::ArrowUp | Key::PageUp); - let n = if up { 1.0 } else { -1.0 }; - match key { - Key::Home | Key::End if modifiers.is_none() => { - v = if *key == Key::Home { lo } else { hi }; - end = true; - } - Key::PageUp | Key::PageDown if modifiers.is_none() => v = by(v, 10.0 * n).clamp(lo, hi), - Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.shift_only() => { - v = by(v, 10.0 * n).clamp(lo, hi); - } - Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.is_none() => { - v = by(v, n).clamp(lo, hi); - arrows = true; - } - _ => return true, - } - false - }); - }); - if arrows { - ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - } - } - ui.input(|i| { - let n = i.num_accesskit_action_requests(id, Action::Increment) as f64 - i.num_accesskit_action_requests(id, Action::Decrement) as f64; - v = by(v, n).clamp(lo, hi); - }); - (v != from || (end && v != s.value)).then_some(round_to(v, STEP_DECIMALS).clamp(lo, hi)) -} - -/// The rail of [`color_slider_valued`] with the id `id`: its response, the new value from 0 to 1 -/// that a click or drag gives and the phase. -fn color_rail( - ui: &mut Ui, - id: egui::Id, - name: &str, - shown: Option, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Response, Option, Live) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); - let bar = Rect::from_min_size(pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(width - 8.0, 7.0)); - let resp = ui.interact(rect, id, Sense::click_and_drag()); - gradient_strip(ui, bar, (bar.width() / 2.0).ceil().max(2.0) as usize, false, track); - ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let v = value.clamp(0.0, 1.0); - let x = bar.left() + v * bar.width(); - let tip = pos2(x, bar.bottom() - 1.0); - let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; - let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; - ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); - slider_info(ui, &resp, name, shown); - let (picked, phase) = match pointer_phase(&resp) { - Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), - None => (None, Live::Idle), - }; - (resp, picked, phase) -} - -/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop). -pub(crate) fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> { - if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) { - return None; - } - let p = resp.interact_pointer_pos()?; - Some((p, if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released })) -} - -/// A gradient strip into `rect`: `n` bands along x (or up y when `vertical`), each filled with -/// `track(t)` at its centre. -fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(f32) -> Color32) { - let len = if vertical { rect.height() } else { rect.width() }; - let seg = len / n as f32; - for i in 0..n { - let r = if vertical { - Rect::from_min_size(pos2(rect.left(), rect.bottom() - (i + 1) as f32 * seg), vec2(rect.width(), seg + 0.6)) - } else { - Rect::from_min_size(pos2(rect.left() + i as f32 * seg, rect.top()), vec2(seg + 0.6, rect.height())) - }; - ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); - } -} - -/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y -/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. -pub fn color_field( - ui: &mut Ui, - id: impl std::hash::Hash, - size: Vec2, - pos: (f32, f32), - color_at: &dyn Fn(f32, f32) -> Color32, -) -> (Option<(f32, f32)>, Live) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); - let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); - let painter = ui.painter_at(rect.expand(6.0)); - painter.add(color_mesh(rect, (32, 32), color_at)); - painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); - painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); - painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); - match pointer_phase(&resp) { - Some((p, phase)) => { - let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); - (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) - } - None => (None, Live::Idle), - } -} - -/// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1 -/// each, y down) at its vertices. -pub fn color_mesh(rect: Rect, (cols, rows): (u32, u32), color_at: &dyn Fn(f32, f32) -> Color32) -> egui::Shape { - let mut mesh = egui::Mesh::default(); - let row = cols + 1; - mesh.reserve_vertices((row * (rows + 1)) as usize); - mesh.reserve_triangles((cols * rows * 2) as usize); - for j in 0..=rows { - for i in 0..=cols { - let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32); - mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y)); - } - } - for j in 0..rows { - for i in 0..cols { - let a = j * row + i; - mesh.add_triangle(a, a + 1, a + row); - mesh.add_triangle(a + 1, a + row + 1, a + row); - } - } - egui::Shape::mesh(mesh) -} - -/// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top) -/// and arrows on both sides mark `value`. Returns the new value and the phase. -pub fn channel_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, size: Vec2, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { - channel_slider_named(ui, id, "", value, size, track) -} - -/// [`channel_slider`] that screen readers get as a slider named `name` (already translated) with -/// no value, as for [`color_slider_named`] (none when `name` is empty). -pub fn channel_slider_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - value: f32, - size: Vec2, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { - channel_slider_valued(ui, id, name, None, value, size, track) -} - -/// [`channel_slider_named`] that screen readers also get `shown` from, as for -/// [`color_slider_valued`]. -pub fn channel_slider_valued( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - shown: Option, - value: f32, - size: Vec2, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); - let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); - let bar = rect.shrink2(vec2(6.0, 0.0)); - gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track); - ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let y = bar.bottom() - value.clamp(0.0, 1.0) * bar.height(); - let fill = if resp.dragged() || resp.hovered() { t.text_strong } else { t.icon }; - for (x, d) in [(rect.left(), 1.0), (rect.right(), -1.0)] { - let tip = pos2(x + d * 5.0, y); - ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE)); - } - slider_info(ui, &resp, name, shown); - match pointer_phase(&resp) { - Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase), - None => (None, Live::Idle), - } -} - -/// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it -/// was typed into the field and differs from `hex`. Enter, Shift+Enter (which keeps the focus -/// outside dialogs) and focus loss commit it. -pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash, hex: &str) -> Option { - hex_field_named(ui, id, None, hex) -} - -/// [`hex_field`] for a colour field named explicitly instead of by an adjacent label widget. -pub fn hex_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, hex: &str) -> Option { - let t = Tokens::get(ui.ctx()); - let label = take_previous_label(ui); - ui.label(egui::RichText::new("#").size(15.0).color(t.text)); - let id = id_with(ui.scope_id(), id); - let editing = ui.memory(|m| m.has_focus(id)); - // Until the user types into it, the field shows `hex` as it is now, also while it has the - // focus. Tab can give it the focus in the frame another field commits an edit, before the edit - // changes `hex`; the text from before that edit is not committed when the focus moves on - // (#1692). - let edited_id = id.with("edited"); - let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); - let mut buf: String = if edited { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; - if after_shift_enter(ui, id) { - select_all(ui, id, &buf); - } - let kept = shift_enter_keeps_focus(ui, id); - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(5) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - // As tall as the other fields (26 points with the margin), whatever the font's line - // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. - ui.spacing_mut().interact_size.y = 18.0; - ui.set_height(18.0); - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(62.0) - .char_limit(7) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner; - let resp = name_field(label_field(resp, label), name); - let commit = (resp.lost_focus() || kept) && buf != hex; - let focused = ui.memory(|m| m.has_focus(id)); - ui.data_mut(|d| { - d.insert_temp(id, buf.clone()); - d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); - }); - commit.then_some(buf) -} - -/// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection). -pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered: bool) { - let t = Tokens::get(ui.ctx()); - paint_chip(ui, rect, paint); - ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); - if selected || hovered { - ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); - ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, if selected { t.accent } else { t.text }), StrokeKind::Outside); - } -} - -/// A recessed search field with an italic `hint` (Layers' Search All, Swatches' Find), keeping its -/// text in egui memory under `id`. Returns the text. -pub fn search_field(ui: &mut Ui, id: egui::Id, hint: &str) -> String { - let t = Tokens::get(ui.ctx()); - let mut query: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); - egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, t.input_border)) - .corner_radius(egui::CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(8, 5)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut query) - .id(id.with("edit")) - .frame(egui::Frame::NONE) - .hint_text(egui::RichText::new(tl!(hint)).italics()) - .desired_width(ui.available_width()), - ); - }); - ui.data_mut(|d| d.insert_temp(id, query.clone())); - query -} - -/// A bordered list box (Swatches tiles, Appearance rows, Artboards list). -pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.stroke(Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(1)).show(ui, add).inner -} - -/// A bottom button bar separated from the content by a divider (panel footers). -pub fn bottom_bar(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { - let t = Tokens::get(ui.ctx()); - ui.add_space(4.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); - ui.painter().rect_filled(r, 0.0, t.divider); - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - add(ui) - }); -} - -/// A menu row for panel (≡) menus: label, optional check mark, disabled when not implemented. -pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { - menu_item_name(ui, tl!(label), enabled, checked) -} - -/// [`menu_item`] for a name that is user or file data (a library, a recent URL), never translated. -/// Like the menu bar's items, the row lights up under the pointer. -pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { - let text = if checked { format!("✓ {label}") } else { format!(" {label}") }; - ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame_when_inactive(false)).clicked() -} - -/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without -/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit (Enter, Shift+Enter or -/// focus loss, as in [`num_field`]), where `new` is `None` when the field was cleared; typed units -/// and arithmetic work as in [`num_field`]. The value is a length: ↑/↓, the wheel and a scrub stop -/// at 0. -pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option> { - opt_field_named(ui, id, None, value, unit, width) -} - -/// [`opt_field`] for a field named explicitly instead of by an adjacent label widget (the Stroke -/// panel's dash and gap cells have their labels in a row below them). -pub fn opt_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option> { - let t = Tokens::get(ui.ctx()); - let label = take_previous_label(ui); - let id = id_with(ui.scope_id(), id); - let shown = value.map(|v| unit.number(v)).unwrap_or_default(); - let editing = ui.memory(|m| m.has_focus(id)); - // As in `hex_field`: until the user types into it, the field shows `shown` as it is now - // (#1692). When `shown` changes while the field has the focus and nothing has been typed into - // it, all of the new text is selected, as on keyboard focus. - let edited_id = id.with("edited"); - let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); - let before = ui.data_mut(|d| d.get_temp::(id)); - let mut buf: String = if edited { before.clone().unwrap_or_else(|| shown.clone()) } else { shown.clone() }; - let reshown = after_shift_enter(ui, id); - if reshown || editing && !edited && before.is_some_and(|b| b != buf) { - select_all(ui, id, &buf); - } - let kept = shift_enter_keeps_focus(ui, id); - let enabled = ui.is_enabled(); - let framed = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(4, 4)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(width - 10.0) - .font(egui::FontId::proportional(12.5)) - .text_color(if enabled { t.text_strong } else { t.text_disabled }), - ) - }); - let resp = framed.inner; - let resp = name_field(label_field(resp, label), name); - select_all_on_focus(ui, &resp, &buf); - // ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it. - let lengths = 0.0..=f64::INFINITY; - let stepped = step_value( - ui, - &resp, - &mut buf, - &lengths, - (|b: &str| Some(unit.from_pt(unit.parse(&decimal_point(b))?)), |v| unit.number(unit.to_pt(v))), - None, - ); - let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &lengths); - let focused = ui.memory(|m| m.has_focus(id)); - ui.data_mut(|d| { - d.insert_temp(id, buf.clone()); - d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); - }); - if (resp.lost_focus() || kept) && buf != shown { - let s = buf.trim(); - if s.is_empty() { Some(None) } else { typed_unit(ui.ctx(), unit).parse(&decimal_point(s)).map(Some) } - } else { - stepped.or(scrubbed).map(|v| Some(unit.to_pt(v))) - } -} - -/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop -/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. -#[derive(Clone, Debug, PartialEq)] -pub enum PanelDrag { - /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it - /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, - /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the - /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes - /// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is - /// (library id, colour name), which that panel moves to where it is dropped. - Paint { paint: Paint, params: serde_json::Value, rows: Option, library: Option<(String, String)> }, - /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance - /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. - Appearance(vectorcraft_doc::NodeId), - /// A Graphic Styles panel style: art it is dropped on takes it (`graphicStyle.apply` with its - /// `ids`); the panel moves it to where it is dropped. - GraphicStyle(String), - /// The selected art, dragged off the canvas with the Selection tool: the Graphic Styles panel - /// makes a style of the first object (`graphicStyle.new`), the Symbols panel a symbol of it all - /// (`symbol.new`), the Swatches panel a pattern swatch of a copy (`object.pattern.make`) and the - /// Brushes panel an Art brush (`brush.new`). - Art(Vec), - /// A Symbols panel symbol: the canvas places an instance of it centred where it is dropped - /// (`symbol.place`). - Symbol(String), - /// A symbol of a symbol library panel (`name`: its name in `library`): the canvas adds it to the - /// document's Symbols and places an instance where it is dropped (`symbol.addFromLibrary` with - /// `place`). - LibrarySymbol { library: String, name: String }, - /// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is - /// dropped on takes it (`brush.apply`). - Brush { name: String, def: serde_json::Value }, - /// A brush of a brush library panel (`name`: its name in `library`): the path it is dropped on - /// takes it (`brush.addFromLibrary` with `apply`), and the Brushes panel adds it. - LibraryBrush { library: String, name: String }, - /// A Libraries panel graphic (`item`: its id in `library`): the canvas places a copy centred - /// where it is dropped (`library.use`); the panel moves it to where it is dropped. - LibraryGraphic { library: String, item: String }, - /// A Libraries panel text style (`item`: its name in `library`), which the panel moves to where - /// it is dropped. - LibraryItem { library: String, kind: vectorcraft_engine::cmd::library::ItemKind, item: String }, - /// A Libraries panel group, by its header, which the panel moves to where it is dropped. - LibraryGroup { library: String, group: String }, -} - -/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it; -/// → the dragged ids when it is released there. -pub fn art_drop(ui: &Ui, zone: &Response) -> Option> { - let drag = zone.dnd_hover_payload::()?; - let PanelDrag::Art(ids) = &*drag else { return None }; - ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - zone.dnd_release_payload::().map(|_| ids.iter().map(|id| id.0).collect()) -} - -/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour -/// group under the pointer when the drag started (`grabbed`) and `names`, the panel selection when -/// the grabbed one is part of it (only colour groups, or only swatches, like the grabbed one), in -/// panel order. -#[derive(Clone, Debug, PartialEq)] -pub struct SwatchRows { - pub grabbed: String, - pub names: Vec, - /// The names are colour groups. - pub groups: bool, -} - -impl PanelDrag { - /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient - /// carries no placement: it fits the art it lands on (freeform points are placed afresh there, - /// coloured like the dragged ones). - pub fn paint(mut paint: Paint) -> Self { - if let Paint::Gradient(g) = &mut paint { - g.geom = None; - g.freeform = None; - } - Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None, library: None } - } - /// The dragged colour (a solid paint's), which the Gradient panel's ramp takes. - pub fn color(&self) -> Option { - match self { - Self::Paint { paint, .. } => paint.color(), - _ => None, - } - } -} - -/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag starts -/// on it. -pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) { - if resp.drag_started() { - egui::DragAndDrop::set_payload(ui.ctx(), drag()); - } -} - -/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes, -/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when -/// the texture for `key` at this size isn't cached yet. -pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64) -> Option) -> Option { - use std::cell::RefCell; - use std::collections::HashMap; - thread_local! { - static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); - static CACHE: crate::graphics::TexCache> = crate::graphics::TexCache::default(); - } - let ppp = ui.ctx().pixels_per_point() as f64; - let (w, h) = (size.x as f64, size.y as f64); - let key = format!("{w}x{h}@{ppp}:{key}"); - if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { - return Some(t); - } - let doc = build(w, h)?; - let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false)); - let color = crate::canvas::color_image(&img); - let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR); - CACHE.with(|c| { - let mut c = c.borrow_mut(); - if c.len() > 256 { - c.clear(); - } - c.insert(key, tex.clone()); - }); - Some(tex) -} - -/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`. -fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 { - let a = h.to_radians(); - c + vec2(a.cos(), -a.sin()) * r * s -} - -/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards. -fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape { - const SEG: u32 = 72; - const RINGS: u32 = 8; - let mut mesh = egui::Mesh::default(); - mesh.colored_vertex(c, crate::panels::c32(&Color::from_hsb(0.0, 0.0, v))); - for ring in 1..=RINGS { - let s = ring as f32 / RINGS as f32; - for k in 0..SEG { - let h = k as f32 * 360.0 / SEG as f32; - mesh.colored_vertex(wheel_pos(c, r, h, s), crate::panels::c32(&Color::from_hsb(h, s, v))); - } - } - let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG }; - for ring in 0..RINGS { - for k in 0..SEG { - if ring == 0 { - mesh.add_triangle(0, at(1, k), at(1, k + 1)); - } else { - mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1)); - mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1)); - } - } - } - egui::Shape::mesh(mesh) -} - -/// What a [`harmony_wheel`] did this frame. -#[derive(Default)] -pub struct WheelResponse { - /// The colour whose marker was pressed (it becomes the selected one). - pub pressed: Option, - /// A colour moved by dragging its marker (a new hue and saturation) or, the selected colour, - /// the brightness slider (a new brightness): `[h°, s, v]`, `None` where the control kept it. - pub moved: Option<(usize, [Option; 3])>, -} - -/// The harmony wheel of Recolor Artwork's Edit tab and the Color Themes panel: a hue and saturation -/// wheel `size` across (hue around, saturation outwards) at the selected colour's brightness, a -/// marker per colour joined to the centre (the selected colour, `base`, larger) and a Brightness -/// slider for the selected colour below it. -pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: Option) -> WheelResponse { - let t = Tokens::get(ui.ctx()); - let mut out = WheelResponse::default(); - let base_hsb = base.and_then(|b| colors.get(b)).map(Color::to_hsb); - let v = base_hsb.map_or(1.0, |c| c[2]); - ui.horizontal(|ui| { - ui.add_space(((ui.available_width() - size) / 2.0).max(0.0)); - let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); - let (c, r) = (rect.center(), size / 2.0 - 10.0); - let painter = ui.painter_at(rect); - painter.add(wheel_shape(c, r, v.max(0.15))); - painter.circle_stroke(c, r, Stroke::new(1.0, t.border)); - let at = |col: &Color| { - let [h, s, _] = col.to_hsb(); - wheel_pos(c, r, h, s) - }; - for (i, col) in colors.iter().enumerate() { - let (p, big) = (at(col), Some(i) == base); - painter.line_segment([c, p], Stroke::new(1.0, Color32::from_white_alpha(160))); - painter.circle(p, if big { 8.0 } else { 5.5 }, crate::panels::c32(col), Stroke::new(1.5, Color32::WHITE)); - painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, Stroke::new(1.0, Color32::BLACK)); - } - // Drag a marker: the nearest one within reach of the press. - let drag = ui.scope_id().with((id, "drag")); - if (resp.drag_started() || resp.clicked()) - && let Some(p) = resp.interact_pointer_pos() - { - let near = colors.iter().map(at).enumerate().map(|(i, m)| (i, m.distance(p))).filter(|x| x.1 <= 14.0).min_by(|a, b| a.1.total_cmp(&b.1)); - out.pressed = near.map(|x| x.0); - ui.data_mut(|m| m.insert_temp(drag, out.pressed)); - } - if resp.dragged() - && let (Some(i), Some(p)) = (ui.data(|m| m.get_temp::>(drag)).flatten(), resp.interact_pointer_pos()) - && i < colors.len() - { - let dv = p - c; - let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); - out.moved = Some((i, [Some(h), Some((dv.length() / r).clamp(0.0, 1.0)), None])); - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!("Color wheel"))); - }); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0)); - ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim))); - // Screen readers get the brightness in percent, as the Color panel's HSB B field shows it. - let shown = SliderValue::field((f64::from(v) * 100.0).round(), 0.0..=100.0, "%", 0); - if let (Some(b), Some([h, s, _])) = (base, base_hsb) - && let (Some(nv), _) = color_slider_valued(ui, (id, "brightness"), tl!("Brightness"), Some(shown), v, size, &|x| { - crate::panels::c32(&Color::from_hsb(h, s, x)) - }) - { - out.moved = Some((b, [None, None, Some(nv)])); - } - }); - out -} - -/// A row of text tabs, the current one underlined in the accent colour (New Document's -/// categories, Document Setup's sections). Returns the clicked tab's index. -pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { - let t = Tokens::get(ui.ctx()); - let mut clicked = None; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 18.0; - for (i, tab) in tabs.iter().enumerate() { - let sel = i == current; - let font = if sel { theme::semibold(13.0) } else { egui::FontId::proportional(13.0) }; - let galley = ui.painter().layout_no_wrap(tl!(tab).to_string(), font, t.text); - let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x, 30.0), Sense::click()); - let color = if sel || resp.hovered() { t.text_strong } else { t.text_dim }; - ui.painter().galley(pos2(rect.left(), rect.center().y - galley.size().y / 2.0 - 2.0), galley, color); - if sel { - ui.painter().rect_filled( - Rect::from_min_max(pos2(rect.left(), rect.bottom() - 2.0), rect.right_bottom()), - CornerRadius::same(1), - t.accent, - ); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), sel, tl!(tab))); - if resp.clicked() { - clicked = Some(i); - } - } - }); - clicked -} - -/// A page-orientation toggle drawn in code: a portrait or landscape sheet with a folded corner, -/// accent-filled when `selected`. Returns clicked. -pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &str) -> bool { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); - if resp.hovered() && !selected { - ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); - } - let size = if landscape { vec2(16.0, 12.0) } else { vec2(12.0, 16.0) }; - let page = Rect::from_center_size(rect.center(), size); - let color = if selected { t.accent } else { t.icon }; - let fold = 4.0; - let outline = - vec![page.left_top(), page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(0.0, fold), page.right_bottom(), page.left_bottom()]; - let fill = if selected { t.accent } else { Color32::TRANSPARENT }; - ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); - let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; - ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, tl!(tip))); - resp.on_hover_text(tl!(tip)).clicked() -} - -/// An artboard-layout toggle drawn in code (Rearrange All Artboards): small artboards in the grid -/// cells `tiles` (column, row), tinted, and over them their order as a line from a dot on the first -/// to an arrowhead on the last; accent-drawn in a pressed well when `selected`, greyed in a disabled -/// `ui`. `tip` is shown as it is (the caller translates it). Returns clicked. -pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) -> bool { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); - let enabled = ui.is_enabled(); - let bg = if selected { - t.tool_active - } else if resp.hovered() && enabled { - t.hover - } else { - Color32::TRANSPARENT - }; - ui.painter().rect_filled(rect, CornerRadius::same(3), bg); - let color = if !enabled { - t.text_disabled - } else if selected { - t.accent - } else { - t.icon - }; - // Cells at most 10 points a side in a 20-point square: a row's artboards stand tall, a - // column's lie flat. - let (cols, rows) = tiles.iter().fold((1u8, 1u8), |(c, r), &(x, y)| (c.max(x.saturating_add(1)), r.max(y.saturating_add(1)))); - let cell = vec2((20.0 / f32::from(cols)).min(10.0), (20.0 / f32::from(rows)).min(10.0)); - let origin = rect.center() - vec2(f32::from(cols) * cell.x, f32::from(rows) * cell.y) / 2.0; - let centre = |&(x, y): &(u8, u8)| origin + vec2((f32::from(x) + 0.5) * cell.x, (f32::from(y) + 0.5) * cell.y); - for tile in tiles { - ui.painter().rect_filled(Rect::from_center_size(centre(tile), cell - vec2(2.0, 2.0)), 1.0, color.gamma_multiply(0.35)); - } - let path: Vec = tiles.iter().map(centre).collect(); - if let ([first, ..], [.., from, to]) = (path.as_slice(), path.as_slice()) { - let (first, from, to) = (*first, *from, *to); - let dir = (to - from).normalized(); - let side = dir.rot90() * 2.5; - let head = vec![to + dir * 1.5, to - dir * 2.5 + side, to - dir * 2.5 - side]; - ui.painter().circle_filled(first, 1.6, color); - ui.painter().add(egui::Shape::line(path, Stroke::new(1.2, color))); - ui.painter().add(egui::Shape::convex_polygon(head, color, Stroke::NONE)); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, selected, tip)); - resp.on_hover_text(tip).clicked() -} - -/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name` -/// (artboard and page thumbnails). -pub fn region_texture( - ctx: &egui::Context, - renderer: &mut vectorcraft_render::Renderer, - name: &str, - doc: &vectorcraft_doc::Document, - region: vectorcraft_geom::Rect, - px: f64, -) -> egui::TextureHandle { - let scale = px / region.width().max(region.height()).max(1.0); - let img = renderer.render_region(doc, region, scale, true); - let color = crate::canvas::color_image(&img); - ctx.load_texture(name, color, egui::TextureOptions::LINEAR) -} - -/// The chevron cell on the right of a field that opens its preset list (rounded on the right). -fn chevron_cell(ui: &mut Ui, h: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); - ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); - ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let c = if !ui.is_enabled() { - t.text_disabled - } else if dresp.hovered() { - t.text_strong - } else { - t.icon - }; - icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); - dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets"))); - dresp -} - -/// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…). -/// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked. -pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash, value: &str, presets: &[&str], width: f32) -> Option { - text_presets_named(ui, id, None, value, presets, width) -} - -/// [`text_presets`] for a row whose heading names it instead of an adjacent label widget. -pub fn text_presets_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, presets: &[&str], width: f32) -> Option { - let mut out = None; - ui.scope(|ui| { - // The field and the chevron share a border. - ui.spacing_mut().item_spacing.x = -1.0; - out = text_field_named(ui, &id, name, Some(value), width - 19.0, 1); - let resp = chevron_cell(ui, 26.0); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(width - 10.0); - for p in presets { - if ui.selectable_label(*p == value, *p).clicked() { - out = Some((*p).to_string()); - } - } - }); - }); - out -} - #[cfg(test)] pub(crate) mod accessibility_tests { use egui::accesskit::{Node, Role, Toggled}; diff --git a/crates/ui-egui/src/widgets/blend.rs b/crates/ui-egui/src/widgets/blend.rs new file mode 100644 index 000000000..267bec339 --- /dev/null +++ b/crates/ui-egui/src/widgets/blend.rs @@ -0,0 +1,161 @@ +use super::color::{Live, pointer_phase}; +use super::dropdowns::combo; +use super::labels::dim_label; +use super::numeric::{range_field, slider}; +use super::*; + +/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a +/// [`BlendMode::group`] starts. +pub fn blend_separator_before(i: usize) -> bool { + i > 0 && i < BlendMode::ALL.len() && BlendMode::ALL[i].group() != BlendMode::ALL[i - 1].group() +} + +/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion +/// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode. +pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: Option, width: f32) -> Option { + combo(ui, id, current.map_or("", |m| tl!(m.label())), width, false, |ui| { + let mut chosen = None; + for (i, m) in BlendMode::ALL.into_iter().enumerate() { + if blend_separator_before(i) { + ui.separator(); + } + if ui.selectable_label(Some(m) == current, tl!(m.label())).clicked() { + chosen = Some(m); + } + } + chosen + }) +} + +/// The blend mode an effect parameter names (`"mode": "multiply"`), which editors show as a +/// [`blend_param_dropdown`]. +pub fn blend_param(key: &str, value: &serde_json::Value) -> Option { + value.as_str().filter(|_| key == "mode").and_then(BlendMode::parse) +} + +/// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value. +pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: BlendMode) -> Option { + blend_dropdown(ui, id, Some(current), 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) +} + +/// An edit made with [`opacity_blend`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TransparencyEdit { + Blend(BlendMode), + /// Opacity in percent, with the slider's drag phase (`Released` for a typed value). + Opacity(f64, Live), +} + +/// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity +/// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup. +/// `opacity` (0..1) and `blend` are `None` where the objects differ (shown blank). +pub fn opacity_blend( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + opacity: Option, + blend: Option, + enabled: bool, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut edit = None; + ui.horizontal(|ui| { + ui.add_enabled_ui(enabled, |ui| { + if let Some(m) = blend_dropdown(ui, (id, "blend"), blend, 104.0) { + edit = Some(TransparencyEdit::Blend(m)); + } + }); + dim_label(ui, "Opacity:"); + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_enabled_ui(enabled, |ui| { + if let Some(o) = range_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), 0.0..=100.0, "%", 0, 50.0) { + edit = Some(TransparencyEdit::Opacity(o, Live::Released)); + } + }); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); + ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!("Opacity"))); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let mut o = opacity.unwrap_or(1.0) * 100.0; + let r = slider(ui, egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); + let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { + Live::Released + } else if r.changed() { + Live::Dragging + } else { + return; + }; + edit = Some(TransparencyEdit::Opacity(o.round() as f64, phase)); + }); + }); + edit +} + +/// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around +/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked. +pub fn reference_point(ui: &mut Ui, current: usize) -> Option { + let t = Tokens::get(ui.ctx()); + // 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through + // the squares' centres stay crisp. + let (sq, step) = (5.0, 9.0); + let size = 2.0 * step + sq; + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let o = rect.min.round(); + let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step); + let line = Stroke::new(1.0, t.text_dim); + ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle); + let mut out = None; + // Screen readers name the squares left to right and top to bottom. + let names = [ + tl!("Top Left"), + tl!("Top"), + tl!("Top Right"), + tl!("Left"), + tl!("Center"), + tl!("Right"), + tl!("Bottom Left"), + tl!("Bottom"), + tl!("Bottom Right"), + ]; + for (i, name) in names.into_iter().enumerate() { + let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); + let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name)); + if resp.clicked() { + out = Some(i); + } + if i == current { + ui.painter().rect_filled(r, 0.0, t.text); + } else { + ui.painter().rect_filled(r, 0.0, t.panel); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); + } + } + out +} + +/// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees, +/// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole +/// degrees (-179..180), Shift in 45° steps. Returns the new angle. +pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash, angle: f64, size: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let (c, r) = (rect.center(), size / 2.0 - 1.0); + let ring = if resp.hovered() || resp.dragged() { t.text } else { t.input_border }; + ui.painter().circle(c, r, t.input, Stroke::new(1.0, ring)); + let a = (angle as f32).to_radians(); + ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * (r - 3.0)], Stroke::new(1.5, t.text)); + ui.painter().circle_filled(c, 2.0, t.text); + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), angle, tl!("Angle"))); + let (p, _) = pointer_phase(&resp)?; + let v = p - c; + if v.length() < 2.0 { + return None; + } + let step = if ui.input(|i| i.modifiers.shift) { 45.0 } else { 1.0 }; + let deg = ((-v.y).atan2(v.x).to_degrees() as f64 / step).round() * step; + // atan2 gives -180..180: -180 shows as 180. + let deg = if deg <= -180.0 { deg + 360.0 } else { deg }; + (deg != angle).then_some(deg) +} diff --git a/crates/ui-egui/src/widgets/buttons.rs b/crates/ui-egui/src/widgets/buttons.rs new file mode 100644 index 000000000..d759dcd29 --- /dev/null +++ b/crates/ui-egui/src/widgets/buttons.rs @@ -0,0 +1,202 @@ +use super::*; + +/// Square icon button; `selected` draws the pressed well. +pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response { + let (_, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + paint_icon_button(ui, resp, icon, tip, selected) +} + +/// [`icon_button`] drawn for a response the caller made (its own id or sense). +pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, selected: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let rect = resp.rect; + let size = rect.width(); + let bg = if selected { + t.tool_active + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(5), bg); + let pad = (size * 0.2).round(); + icons::paint(ui, icon, rect.shrink(pad), if selected { t.text_strong } else { t.icon }); + resp.widget_info(|| icon_button_info(ui.is_enabled(), selected, tl!(tip))); + if !tip.is_empty() { resp.on_hover_text(tl!(tip)) } else { resp } +} + +/// Small flat text button (Quick Actions style). +pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); + let rect = rect.shrink2(vec2(0.0, 0.5)); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); + flat_fill(ui, rect, &resp); + ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + resp +} + +/// A [`flat_button`] that expands to fit localized labels wider than `min_width`. +pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); + let width = (galley.size().x + 24.0).max(min_width); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); + let rect = rect.shrink2(vec2(0.0, 0.5)); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); + flat_fill(ui, rect, &resp); + ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + resp +} + +/// Width of a [`split_button`]'s arrow. +pub const SPLIT_ARROW: f32 = 18.0; + +/// A [`flat_button`] `width` wide whose right end is a menu arrow (Image Trace ▾) → the +/// responses of the button and of its arrow. +pub fn split_button(ui: &mut Ui, text: &str, width: f32) -> (Response, Response) { + split_button_named(ui, text, "", width) +} + +/// [`split_button`] whose arrow has the tool tip `arrow_tip`, which screen readers also call it +/// (none when empty). +pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) -> (Response, Response) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); + let rect = rect.shrink2(vec2(0.0, 0.5)); + let split = rect.right() - SPLIT_ARROW; + let main_rect = Rect::from_min_max(rect.min, pos2(split, rect.bottom())); + let arrow_rect = Rect::from_min_max(pos2(split, rect.top()), rect.max); + let main = ui.interact(main_rect, ui.scope_id().with((text, "split")), Sense::click()); + let arrow = ui.interact(arrow_rect, ui.scope_id().with((text, "split-arrow")), Sense::click()); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); + flat_fill(ui, main_rect, &main); + flat_fill(ui, arrow_rect, &arrow); + ui.painter().vline(split, rect.y_range().shrink(5.0), Stroke::new(1.0, t.panel)); + ui.painter().with_clip_rect(main_rect).text( + main_rect.center(), + egui::Align2::CENTER_CENTER, + tl!(text), + egui::FontId::proportional(12.5), + t.text_strong, + ); + icons::paint(ui, "chevron-down", Rect::from_center_size(arrow_rect.center(), vec2(12.0, 12.0)), t.text_dim); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + if arrow_tip.is_empty() { + return (main, arrow); + } + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(arrow_tip))); + (main, arrow.on_hover_text(tl!(arrow_tip))) +} + +/// The pressed or hovered fill of a flat button part. +fn flat_fill(ui: &Ui, rect: Rect, resp: &Response) { + let t = Tokens::get(ui.ctx()); + if resp.is_pointer_button_down_on() { + ui.painter().rect_filled(rect, CornerRadius::same(5), t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover); + } +} + +/// The accent call-to-action button (dialog OK/Create). +pub fn primary_button(ui: &mut Ui, text: &str) -> Response { + primary_button_enabled(ui, text, true) +} + +/// [`primary_button`], greyed out and not clickable unless `enabled`. +pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Response { + primary_button_sized(ui, text, enabled, 72.0) +} + +/// Primary dialog button with a minimum width, for aligned action columns. +pub(crate) fn primary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response { + primary_button_sized(ui, text, true, min_width) +} + +/// Draws a primary button at least `min_width` wide, growing to fit the translated label. +/// Returns its response with the enabled state and screen-reader name preserved. +fn primary_button_sized(ui: &mut Ui, text: &str, enabled: bool, min_width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let fg = if enabled { Color32::WHITE } else { t.text_disabled }; + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), fg); + let w = galley.size().x + 32.0; + let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), if enabled { Sense::click() } else { Sense::hover() }); + let bg = match (enabled, resp.hovered()) { + (false, _) => t.input, + (true, true) => t.accent, + (true, false) => t.accent_strong, + }; + ui.painter().rect_filled(rect, CornerRadius::same(6), bg); + if !enabled { + ui.painter().rect_stroke(rect, CornerRadius::same(6), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); + } + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!(text))); + resp +} + +/// The quiet secondary button (dialog Cancel). +pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { + secondary_button_width(ui, text, 72.0) +} + +/// Secondary dialog button with a minimum width, for aligned action columns. +pub(crate) fn secondary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), t.text); + let w = galley.size().x + 32.0; + let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), Sense::click()); + ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button }); + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + resp +} + +/// A toggle icon (e.g. eye / lock columns) — returns clicked. +pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + if on { + icons::paint(ui, on_icon, rect.shrink(size * 0.2), if resp.hovered() { t.text } else { t.icon }); + } else if resp.hovered() { + icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tl!(tip))); + resp.on_hover_text(tl!(tip)).clicked() +} + +// ---------- panel widgets (Swatches, Color, Stroke, Gradient, Appearance, …) ---------- + +/// Icon button that can be disabled (greyed, no hover, never clicked). +pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, enabled: bool, size: f32) -> Response { + if enabled { + return icon_button(ui, icon, tip, selected, size); + } + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let pad = (size * 0.2).round(); + icons::paint(ui, icon, rect.shrink(pad), t.text_disabled); + resp.widget_info(|| icon_button_info(false, selected, tl!(tip))); + if tip.is_empty() { resp } else { resp.on_hover_text(tl!(tip)) } +} + +/// What screen readers get for an icon button: a button called `name` (already translated), +/// reported as pressed while `selected` and with no pressed state otherwise. +pub(crate) fn icon_button_info(enabled: bool, selected: bool, name: &str) -> egui::WidgetInfo { + egui::WidgetInfo { selected: selected.then_some(true), ..egui::WidgetInfo::labeled(egui::Role::Button, enabled, name) } +} + +/// A menu row for panel (≡) menus: label, optional check mark, disabled when not implemented. +pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { + menu_item_name(ui, tl!(label), enabled, checked) +} + +/// [`menu_item`] for a name that is user or file data (a library, a recent URL), never translated. +/// Like the menu bar's items, the row lights up under the pointer. +pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { + let text = if checked { format!("✓ {label}") } else { format!(" {label}") }; + ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame_when_inactive(false)).clicked() +} diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs new file mode 100644 index 000000000..068bd4e7d --- /dev/null +++ b/crates/ui-egui/src/widgets/chips.rs @@ -0,0 +1,250 @@ +use super::drag::{PanelDrag, drag_source}; +use super::*; + +/// Draw a paint preview (swatch chip) into `rect`. +pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { + let p = ui.painter(); + match paint { + Paint::None => { + p.rect_filled(rect, 0.0, Color32::WHITE); + p.line_segment([rect.left_bottom(), rect.right_top()], Stroke::new(1.6, Color32::from_rgb(0xe0, 0x20, 0x20))); + } + Paint::Solid { color, .. } => { + let [r, g, b, _] = color.to_rgba8(1.0); + p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); + } + Paint::Gradient(gp) => gradient_chip(ui, rect, gp), + Paint::Pattern { .. } => { + p.rect_filled(rect, 0.0, Color32::from_gray(200)); + for i in 0..4 { + let x = rect.left() + rect.width() * i as f32 / 4.0; + p.line_segment([pos2(x, rect.bottom()), pos2(x + rect.width() / 4.0, rect.top())], Stroke::new(1.0, Color32::from_gray(90))); + } + } + } +} + +/// Draw a gradient preview into `rect`, laid out as the gradient would be on an object of that +/// shape: linear along its angle, radial from the centre, freeform from its colour points. +pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::GradientPaint) { + if !(rect.width() > 0.0 && rect.height() > 0.0) { + return; + } + // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). + let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; + let max = if freeform { 16 } else { 64 }; + let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); + let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); + let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); + let mut mesh = egui::Mesh::default(); + for (k, color) in colors.into_iter().enumerate() { + let (i, j) = (k % (cols + 1), k / (cols + 1)); + let at = pos2(rect.left() + rect.width() * i as f32 / cols as f32, rect.top() + rect.height() * j as f32 / rows as f32); + mesh.colored_vertex(at, color); + } + for j in 0..rows as u32 { + for i in 0..cols as u32 { + let v = j * (cols as u32 + 1) + i; + mesh.add_triangle(v, v + 1, v + cols as u32 + 1); + mesh.add_triangle(v + 1, v + cols as u32 + 2, v + cols as u32 + 1); + } + } + ui.painter().add(egui::Shape::mesh(mesh)); +} + +/// The colours at the `(cols + 1) x (rows + 1)` grid points (row by row) of a `width` x `height` +/// chip showing `gp` as it would look on an object of that size. +pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: f32, height: f32, cols: usize, rows: usize) -> Vec { + use vectorcraft_color::{GradientGeom, GradientKind}; + let b = vectorcraft_geom::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64); + let rgb = |c: vectorcraft_color::Color| { + let [r, g, b, _] = c.to_rgba8(1.0); + Color32::from_rgb(r, g, b) + }; + let at = |k: usize| { + let (i, j) = (k % (cols + 1), k / (cols + 1)); + vectorcraft_geom::kurbo::Point::new(b.width() * i as f64 / cols as f64, b.height() * j as f64 / rows as f64) + }; + let n = (cols + 1) * (rows + 1); + if gp.gradient.kind == GradientKind::Freeform { + let Some(painted) = vectorcraft_color::freeform::painted_box(b) else { return vec![Color32::TRANSPARENT; n] }; + let field = gp.freeform_on(painted).field(vectorcraft_color::freeform::spread_scale(painted)); + return (0..n) + .map(|k| { + let ([r, g, b], _) = field.sample(at(k)); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(r), q(g), q(b)) + }) + .collect(); + } + // A placed gradient keeps its direction; a fresh one is fitted to the chip at its angle. + let angle = gp.geom.map_or(gp.angle, |g| g.angle_deg()); + let geom = GradientGeom::fit(gp.gradient.kind, b, if angle.is_finite() { angle } else { 0.0 }); + (0..n) + .map(|k| { + let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; + rgb(gp.gradient.sample(t).0) + }) + .collect() +} + +/// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a +/// panel-coloured hole (returned; the whole chip for fills). A selection whose paints differ +/// (`mixed`) shows a white chip with a "?" (in the hole for strokes). +pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option) -> Rect { + let t = Tokens::get(ui.ctx()); + if mixed { + ui.painter().rect_filled(rect, 0.0, Color32::WHITE); + } else { + paint_chip(ui, rect, paint); + } + let (hole, mark) = match ring { + Some(w) => { + let inner = rect.shrink(w); + ui.painter().rect_filled(inner, 0.0, t.panel); + (inner, t.text_strong) + } + None => (rect, Color32::BLACK), + }; + if mixed { + ui.painter().text(hole.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(hole.height() * 0.8), mark); + } + hole +} + +/// A chip that opens something (the Control bar's and Properties' Fill/Stroke chips, the Control +/// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with +/// `chevron` a chevron follows it. +pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response { + chip_button_named(ui, size, chevron, "", draw) +} + +/// [`chip_button`] with the tool tip `tip`, which screen readers also call it (none when empty). +pub fn chip_button_named(ui: &mut Ui, size: f32, chevron: bool, tip: &str, draw: impl FnOnce(&Ui, Rect)) -> Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click()); + let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0); + draw(ui, chip); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); + if chevron { + icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim); + } + if tip.is_empty() { + return resp; + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(tip))); + resp.on_hover_text(tl!(tip)) +} + +/// What the user did on a [`fill_stroke_proxy`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProxyClicks { + pub fill: bool, + pub stroke: bool, + pub swap: bool, + pub default: bool, + /// Double-clicked a square: open the Color Picker for it (`true` = the stroke). + pub pick: Option, + /// A paint dragged from the other proxy (or a swatch) dropped on the fill square. + pub dropped_fill: Option, + /// A paint dropped on the stroke square. + pub dropped_stroke: Option, +} + +/// The Fill/Stroke proxy pair (two overlapping squares). `mixed` (fill, stroke) draws a "?" square +/// for a selection whose objects differ. +pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool, bool), fill_active: bool, size: f32) -> ProxyClicks { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + let s = size * 0.62; + let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); + let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); + let stroke_resp = ui.interact(stroke_r, ui.scope_id().with("stroke-proxy"), Sense::click_and_drag()); + let fill_resp = ui.interact(fill_r, ui.scope_id().with("fill-proxy"), Sense::click_and_drag()); + let dropped_fill = if mixed.0 { None } else { proxy_paint_drop(ui, &fill_resp) }; + let dropped_stroke = if mixed.1 { None } else { proxy_paint_drop(ui, &stroke_resp) }; + // A proxy drags its paint (onto the Swatches panel or art), unless it shows "?". + for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] { + if !mixed { + drag_source(ui, resp, || PanelDrag::paint(paint.clone())); + } + } + let draw_fill = |ui: &Ui| { + proxy_chip(ui, fill_r, fill, mixed.0, None); + ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); + ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); + }; + let draw_stroke = |ui: &Ui| { + let inner = proxy_chip(ui, stroke_r, stroke, mixed.1, Some(s * 0.28)); + ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); + ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside); + ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); + }; + if fill_active { + draw_stroke(ui); + draw_fill(ui); + } else { + draw_fill(ui); + draw_stroke(ui); + } + // Swap arrow (top-right) and default (bottom-left) mini buttons. + let swap_r = Rect::from_min_size(pos2(rect.right() - size * 0.3, rect.top()), Vec2::splat(size * 0.3)); + let def_r = Rect::from_min_size(pos2(rect.left(), rect.bottom() - size * 0.3), Vec2::splat(size * 0.3)); + let swap = ui.interact(swap_r, ui.scope_id().with("swap-proxy"), Sense::click()); + let def = ui.interact(def_r, ui.scope_id().with("default-proxy"), Sense::click()); + icons::paint(ui, "dc-swap", swap_r.shrink(1.0), if swap.hovered() { t.text } else { t.icon }); + let a = Rect::from_min_size(def_r.min + vec2(1.0, 1.0), Vec2::splat(size * 0.14)); + let b = Rect::from_min_size(def_r.min + vec2(size * 0.1, size * 0.1), Vec2::splat(size * 0.14)); + ui.painter().rect_filled(b, 0.0, Color32::BLACK); + ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); + ui.painter().rect_filled(a, 0.0, Color32::WHITE); + ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); + let pick = if fill_resp.double_clicked() { + Some(false) + } else if stroke_resp.double_clicked() { + Some(true) + } else { + None + }; + let on = ui.is_enabled(); + fill_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, fill_active, tl!("Fill (X), double-click for the Color Picker"))); + stroke_resp + .widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, !fill_active, tl!("Stroke (X), double-click for the Color Picker"))); + swap.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Swap Fill and Stroke (Shift+X)"))); + def.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Default Fill and Stroke (D)"))); + ProxyClicks { + fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(), + stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(), + swap: swap.on_hover_text(tl!("Swap Fill and Stroke (Shift+X)")).clicked(), + default: def.on_hover_text(tl!("Default Fill and Stroke (D)")).clicked(), + pick, + dropped_fill, + dropped_stroke, + } +} + +/// A [`PanelDrag::Paint`] released on a Fill/Stroke proxy square: highlight while held over it. +fn proxy_paint_drop(ui: &Ui, resp: &Response) -> Option { + if let Some(drag) = resp.dnd_hover_payload::() + && let PanelDrag::Paint { params, .. } = &*drag + && !params.is_null() + { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + resp.dnd_release_payload::().and_then(|d| { + let PanelDrag::Paint { params, .. } = &*d else { return None }; + (!params.is_null()).then_some(params.clone()) + }) +} + +/// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection). +pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered: bool) { + let t = Tokens::get(ui.ctx()); + paint_chip(ui, rect, paint); + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + if selected || hovered { + ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, if selected { t.accent } else { t.text }), StrokeKind::Outside); + } +} diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs new file mode 100644 index 000000000..4ffdaf6a9 --- /dev/null +++ b/crates/ui-egui/src/widgets/color.rs @@ -0,0 +1,556 @@ +use super::labels::{label_field, name_field, take_previous_label}; +use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple}; +use super::text::{after_shift_enter, select_all, shift_enter_keeps_focus}; +use super::*; + +/// Drag phase of a live slider/handle: previews while dragging, commits on release. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Live { + Idle, + Dragging, + Released, +} + +/// A colour slider with a gradient track (`track(t)` gives the colour at 0..1) and a triangular +/// thumb below it, like Illustrator's Color panel. Returns the new normalized value and the phase. +pub fn color_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, width: f32, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { + color_slider_named(ui, id, "", value, width, track) +} + +/// [`color_slider`] that screen readers get as a slider named `name` (already translated) with no +/// value (none when `name` is empty). +pub fn color_slider_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + color_slider_valued(ui, id, name, None, value, width, track) +} + +/// The value a slider gives screen readers: the number the field beside it shows, the range of the +/// slider's rail and the field's text. +#[derive(Clone, Debug)] +pub struct SliderValue { + pub value: f64, + pub range: std::ops::RangeInclusive, + /// The field's text, such as "50%"; `None` gives the number alone. + pub text: Option, + /// How the keys and screen readers step the number ([`color_slider_stepped`]): the number `n` + /// steps from `value` (±1 for an arrow, ±10 with Shift or Page Up/Down). `None` steps to whole + /// numbers. + pub step: Option f64>, +} + +/// Two values are the same when they show the same number, range and text, whatever their keys' steps +/// (function pointers don't compare reliably). +impl PartialEq for SliderValue { + fn eq(&self, other: &Self) -> bool { + (self.value, &self.range, &self.text) == (other.value, &other.range, &other.text) + } +} + +impl SliderValue { + /// `value` on a rail over `range`, without a text. + pub fn number(value: f64, range: std::ops::RangeInclusive) -> Self { + Self { value, range, text: None, step: None } + } + + /// `value` on a rail over `range` as [`plain_field`] shows it with `suffix` and at most + /// `decimals` places: the number it shows (`value` rounded) and its text. + pub fn field(value: f64, range: std::ops::RangeInclusive, suffix: &str, decimals: usize) -> Self { + let number = field_text(value, "", decimals); + Self { value: number.parse().unwrap_or(value), range, text: Some(format!("{number}{suffix}")), step: None } + } + + /// `value` on a rail over `range`, with `text`, for a field that writes its own text (a length + /// in the document's unit, an egui `DragValue`). + pub fn text(value: f64, range: std::ops::RangeInclusive, text: String) -> Self { + Self { value, range, text: Some(text), step: None } + } + + /// This value, stepped by the keys with `step` instead of to whole numbers (see + /// [`SliderValue::step`]), for a rail whose useful numbers are fractions. + pub fn with_step(self, step: fn(f64, f64) -> f64) -> Self { + Self { step: Some(step), ..self } + } +} + +/// Give `resp` a slider's screen-reader node named `name` (nothing when `name` is empty), with +/// `shown`'s number, text and range: the rail's range, extended to the number when the field holds +/// a number past the rail's end. +fn slider_info(ui: &Ui, resp: &Response, name: &str, shown: Option) { + if name.is_empty() { + return; + } + let enabled = ui.is_enabled(); + let Some(s) = shown else { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, enabled, name)); + return; + }; + resp.widget_info(|| egui::WidgetInfo { current_text_value: s.text.clone(), ..egui::WidgetInfo::slider(enabled, s.value, name) }); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(s.range.start().min(s.value)); + node.set_max_numeric_value(s.range.end().max(s.value)); + }); +} + +/// [`color_slider_named`] that screen readers also get `shown` from (see [`SliderValue`]). With +/// `None`, as for a field left blank for a mixed value, it reports no value. +pub fn color_slider_valued( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + shown: Option, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + let id = id_with(ui.scope_id(), id); + let (_, picked, phase) = color_rail(ui, id, name, shown, value, width, track); + (picked, phase) +} + +/// [`color_slider_valued`] for a dialog's rail beside a number field, which also responds to the +/// keyboard. A click or a drag gives the rail the keyboard focus; an outline around it shows the +/// focus once a key has stepped it, or when the focus came from the keyboard (Tab). +/// While it has the focus, → and ↑ raise the number `shown` holds to the next whole number and ← +/// and ↓ lower it to the next whole number, Shift with an arrow, Page Up and Page Down add or take +/// ten, and Home and End set the start and the end of `shown`'s range. Screen readers' increment +/// and decrement actions step the number as → and ← do. A number past the range steps from the +/// range's nearer end, and no step goes past either end. With `shown` `None` (a blank field), or +/// in a disabled `ui`, the keys and actions leave the rail as it is. Returns the new value from 0 +/// to 1 that a click or a drag gives, its phase, and the number in `shown`'s units that a key or +/// an action gives. +pub fn color_slider_stepped( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + shown: Option, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live, Option) { + let id = id_with(ui.scope_id(), id); + let (resp, picked, phase) = color_rail(ui, id, name, shown.clone(), value, width, track); + // Focus a click or a drag gave shows no outline until a key steps the rail: the outline tells + // a keyboard user where the focus is, and after a drag it only framed the rail. + let by_pointer = id.with("focused-by-pointer"); + if resp.drag_started() || resp.clicked() { + resp.request_focus(); + ui.data_mut(|d| d.insert_temp(by_pointer, true)); + } + let focused = ui.memory(|m| m.has_focus(id)); + if !focused { + ui.data_mut(|d| d.remove_temp::(by_pointer)); + } + let stepped = shown.filter(|_| ui.is_enabled()).and_then(|s| { + ui.ctx().accesskit_node_builder(id, |node| { + node.set_numeric_value_step(1.0); + node.add_action(egui::accesskit::Action::Increment); + node.add_action(egui::accesskit::Action::Decrement); + }); + rail_step(ui, id, focused, &s) + }); + if stepped.is_some() { + ui.data_mut(|d| d.remove_temp::(by_pointer)); + } + if focused && !ui.data(|d| d.get_temp::(by_pointer)).unwrap_or(false) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect, 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + (picked, phase, stepped.filter(|_| picked.is_none())) +} + +/// The number of slider `id` (`s`) after this frame's keys while it is `focused`, and after screen +/// readers' actions on it ([`color_slider_stepped`]); `None` when they leave it as it was. +fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option { + use egui::accesskit::Action; + use egui::{Event, Key}; + let (lo, hi) = (*s.range.start(), *s.range.end()); + if !lo.is_finite() || !hi.is_finite() || hi <= lo { + return None; + } + let from = s.value.clamp(lo, hi); + let mut v = from; + // `n` steps from `v`: the rail's own steps, or to whole numbers (ten at a time with Shift). + let by = |v: f64, n: f64| match s.step { + Some(step) => step(v, n), + None if n.abs() >= 10.0 => v + n, + None => step_to_multiple(v, 1.0, n), + }; + // Home or End: an end of the range, which also replaces a number past it. + let mut end = false; + if focused { + // The arrows step the rail and do not move the focus. egui keeps the filter only when the + // rail also had the focus in the frame before, and applies it from the next frame; until + // then, an arrow the rail takes cancels the focus move that egui set at the start of the frame. + let filter = egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + let mut arrows = false; + ui.input_mut(|i| { + i.events.retain(|e| { + let Event::Key { key, pressed: true, modifiers, .. } = e else { return true }; + let up = matches!(key, Key::ArrowRight | Key::ArrowUp | Key::PageUp); + let n = if up { 1.0 } else { -1.0 }; + match key { + Key::Home | Key::End if modifiers.is_none() => { + v = if *key == Key::Home { lo } else { hi }; + end = true; + } + Key::PageUp | Key::PageDown if modifiers.is_none() => v = by(v, 10.0 * n).clamp(lo, hi), + Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.shift_only() => { + v = by(v, 10.0 * n).clamp(lo, hi); + } + Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.is_none() => { + v = by(v, n).clamp(lo, hi); + arrows = true; + } + _ => return true, + } + false + }); + }); + if arrows { + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + } + ui.input(|i| { + let n = i.num_accesskit_action_requests(id, Action::Increment) as f64 - i.num_accesskit_action_requests(id, Action::Decrement) as f64; + v = by(v, n).clamp(lo, hi); + }); + (v != from || (end && v != s.value)).then_some(round_to(v, STEP_DECIMALS).clamp(lo, hi)) +} + +/// The rail of [`color_slider_valued`] with the id `id`: its response, the new value from 0 to 1 +/// that a click or drag gives and the phase. +fn color_rail( + ui: &mut Ui, + id: egui::Id, + name: &str, + shown: Option, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Response, Option, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); + let bar = Rect::from_min_size(pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(width - 8.0, 7.0)); + let resp = ui.interact(rect, id, Sense::click_and_drag()); + gradient_strip(ui, bar, (bar.width() / 2.0).ceil().max(2.0) as usize, false, track); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let v = value.clamp(0.0, 1.0); + let x = bar.left() + v * bar.width(); + let tip = pos2(x, bar.bottom() - 1.0); + let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; + let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; + ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); + slider_info(ui, &resp, name, shown); + let (picked, phase) = match pointer_phase(&resp) { + Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), + None => (None, Live::Idle), + }; + (resp, picked, phase) +} + +/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop). +pub(crate) fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> { + if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) { + return None; + } + let p = resp.interact_pointer_pos()?; + Some((p, if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released })) +} + +/// A gradient strip into `rect`: `n` bands along x (or up y when `vertical`), each filled with +/// `track(t)` at its centre. +fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(f32) -> Color32) { + let len = if vertical { rect.height() } else { rect.width() }; + let seg = len / n as f32; + for i in 0..n { + let r = if vertical { + Rect::from_min_size(pos2(rect.left(), rect.bottom() - (i + 1) as f32 * seg), vec2(rect.width(), seg + 0.6)) + } else { + Rect::from_min_size(pos2(rect.left() + i as f32 * seg, rect.top()), vec2(seg + 0.6, rect.height())) + }; + ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); + } +} + +/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y +/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. +pub fn color_field( + ui: &mut Ui, + id: impl std::hash::Hash, + size: Vec2, + pos: (f32, f32), + color_at: &dyn Fn(f32, f32) -> Color32, +) -> (Option<(f32, f32)>, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let painter = ui.painter_at(rect.expand(6.0)); + painter.add(color_mesh(rect, (32, 32), color_at)); + painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); + painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); + painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); + match pointer_phase(&resp) { + Some((p, phase)) => { + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) + } + None => (None, Live::Idle), + } +} + +/// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1 +/// each, y down) at its vertices. +pub fn color_mesh(rect: Rect, (cols, rows): (u32, u32), color_at: &dyn Fn(f32, f32) -> Color32) -> egui::Shape { + let mut mesh = egui::Mesh::default(); + let row = cols + 1; + mesh.reserve_vertices((row * (rows + 1)) as usize); + mesh.reserve_triangles((cols * rows * 2) as usize); + for j in 0..=rows { + for i in 0..=cols { + let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32); + mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y)); + } + } + for j in 0..rows { + for i in 0..cols { + let a = j * row + i; + mesh.add_triangle(a, a + 1, a + row); + mesh.add_triangle(a + 1, a + row + 1, a + row); + } + } + egui::Shape::mesh(mesh) +} + +/// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top) +/// and arrows on both sides mark `value`. Returns the new value and the phase. +pub fn channel_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, size: Vec2, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { + channel_slider_named(ui, id, "", value, size, track) +} + +/// [`channel_slider`] that screen readers get as a slider named `name` (already translated) with +/// no value, as for [`color_slider_named`] (none when `name` is empty). +pub fn channel_slider_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + channel_slider_valued(ui, id, name, None, value, size, track) +} + +/// [`channel_slider_named`] that screen readers also get `shown` from, as for +/// [`color_slider_valued`]. +pub fn channel_slider_valued( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + shown: Option, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let bar = rect.shrink2(vec2(6.0, 0.0)); + gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let y = bar.bottom() - value.clamp(0.0, 1.0) * bar.height(); + let fill = if resp.dragged() || resp.hovered() { t.text_strong } else { t.icon }; + for (x, d) in [(rect.left(), 1.0), (rect.right(), -1.0)] { + let tip = pos2(x + d * 5.0, y); + ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE)); + } + slider_info(ui, &resp, name, shown); + match pointer_phase(&resp) { + Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase), + None => (None, Live::Idle), + } +} + +/// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it +/// was typed into the field and differs from `hex`. Enter, Shift+Enter (which keeps the focus +/// outside dialogs) and focus loss commit it. +pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash, hex: &str) -> Option { + hex_field_named(ui, id, None, hex) +} + +/// [`hex_field`] for a colour field named explicitly instead of by an adjacent label widget. +pub fn hex_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, hex: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let label = take_previous_label(ui); + ui.label(egui::RichText::new("#").size(15.0).color(t.text)); + let id = id_with(ui.scope_id(), id); + let editing = ui.memory(|m| m.has_focus(id)); + // Until the user types into it, the field shows `hex` as it is now, also while it has the + // focus. Tab can give it the focus in the frame another field commits an edit, before the edit + // changes `hex`; the text from before that edit is not committed when the focus moves on + // (#1692). + let edited_id = id.with("edited"); + let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); + let mut buf: String = if edited { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; + if after_shift_enter(ui, id) { + select_all(ui, id, &buf); + } + let kept = shift_enter_keeps_focus(ui, id); + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(5) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + // As tall as the other fields (26 points with the margin), whatever the font's line + // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. + ui.spacing_mut().interact_size.y = 18.0; + ui.set_height(18.0); + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(62.0) + .char_limit(7) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + let resp = name_field(label_field(resp, label), name); + let commit = (resp.lost_focus() || kept) && buf != hex; + let focused = ui.memory(|m| m.has_focus(id)); + ui.data_mut(|d| { + d.insert_temp(id, buf.clone()); + d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); + }); + commit.then_some(buf) +} + +/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`. +fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 { + let a = h.to_radians(); + c + vec2(a.cos(), -a.sin()) * r * s +} + +/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards. +fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape { + const SEG: u32 = 72; + const RINGS: u32 = 8; + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(c, crate::panels::c32(&Color::from_hsb(0.0, 0.0, v))); + for ring in 1..=RINGS { + let s = ring as f32 / RINGS as f32; + for k in 0..SEG { + let h = k as f32 * 360.0 / SEG as f32; + mesh.colored_vertex(wheel_pos(c, r, h, s), crate::panels::c32(&Color::from_hsb(h, s, v))); + } + } + let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG }; + for ring in 0..RINGS { + for k in 0..SEG { + if ring == 0 { + mesh.add_triangle(0, at(1, k), at(1, k + 1)); + } else { + mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1)); + mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1)); + } + } + } + egui::Shape::mesh(mesh) +} + +/// What a [`harmony_wheel`] did this frame. +#[derive(Default)] +pub struct WheelResponse { + /// The colour whose marker was pressed (it becomes the selected one). + pub pressed: Option, + /// A colour moved by dragging its marker (a new hue and saturation) or, the selected colour, + /// the brightness slider (a new brightness): `[h°, s, v]`, `None` where the control kept it. + pub moved: Option<(usize, [Option; 3])>, +} + +/// The harmony wheel of Recolor Artwork's Edit tab and the Color Themes panel: a hue and saturation +/// wheel `size` across (hue around, saturation outwards) at the selected colour's brightness, a +/// marker per colour joined to the centre (the selected colour, `base`, larger) and a Brightness +/// slider for the selected colour below it. +pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: Option) -> WheelResponse { + let t = Tokens::get(ui.ctx()); + let mut out = WheelResponse::default(); + let base_hsb = base.and_then(|b| colors.get(b)).map(Color::to_hsb); + let v = base_hsb.map_or(1.0, |c| c[2]); + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - size) / 2.0).max(0.0)); + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); + let (c, r) = (rect.center(), size / 2.0 - 10.0); + let painter = ui.painter_at(rect); + painter.add(wheel_shape(c, r, v.max(0.15))); + painter.circle_stroke(c, r, Stroke::new(1.0, t.border)); + let at = |col: &Color| { + let [h, s, _] = col.to_hsb(); + wheel_pos(c, r, h, s) + }; + for (i, col) in colors.iter().enumerate() { + let (p, big) = (at(col), Some(i) == base); + painter.line_segment([c, p], Stroke::new(1.0, Color32::from_white_alpha(160))); + painter.circle(p, if big { 8.0 } else { 5.5 }, crate::panels::c32(col), Stroke::new(1.5, Color32::WHITE)); + painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, Stroke::new(1.0, Color32::BLACK)); + } + // Drag a marker: the nearest one within reach of the press. + let drag = ui.scope_id().with((id, "drag")); + if (resp.drag_started() || resp.clicked()) + && let Some(p) = resp.interact_pointer_pos() + { + let near = colors.iter().map(at).enumerate().map(|(i, m)| (i, m.distance(p))).filter(|x| x.1 <= 14.0).min_by(|a, b| a.1.total_cmp(&b.1)); + out.pressed = near.map(|x| x.0); + ui.data_mut(|m| m.insert_temp(drag, out.pressed)); + } + if resp.dragged() + && let (Some(i), Some(p)) = (ui.data(|m| m.get_temp::>(drag)).flatten(), resp.interact_pointer_pos()) + && i < colors.len() + { + let dv = p - c; + let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); + out.moved = Some((i, [Some(h), Some((dv.length() / r).clamp(0.0, 1.0)), None])); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!("Color wheel"))); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0)); + ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim))); + // Screen readers get the brightness in percent, as the Color panel's HSB B field shows it. + let shown = SliderValue::field((f64::from(v) * 100.0).round(), 0.0..=100.0, "%", 0); + if let (Some(b), Some([h, s, _])) = (base, base_hsb) + && let (Some(nv), _) = color_slider_valued(ui, (id, "brightness"), tl!("Brightness"), Some(shown), v, size, &|x| { + crate::panels::c32(&Color::from_hsb(h, s, x)) + }) + { + out.moved = Some((b, [None, None, Some(nv)])); + } + }); + out +} + +/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name` +/// (artboard and page thumbnails). +pub fn region_texture( + ctx: &egui::Context, + renderer: &mut vectorcraft_render::Renderer, + name: &str, + doc: &vectorcraft_doc::Document, + region: vectorcraft_geom::Rect, + px: f64, +) -> egui::TextureHandle { + let scale = px / region.width().max(region.height()).max(1.0); + let img = renderer.render_region(doc, region, scale, true); + let color = crate::canvas::color_image(&img); + ctx.load_texture(name, color, egui::TextureOptions::LINEAR) +} diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs new file mode 100644 index 000000000..4a73214b9 --- /dev/null +++ b/crates/ui-egui/src/widgets/drag.rs @@ -0,0 +1,125 @@ +use super::*; + +/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop +/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. +#[derive(Clone, Debug, PartialEq)] +pub enum PanelDrag { + /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it + /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, + /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the + /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes + /// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is + /// (library id, colour name), which that panel moves to where it is dropped. + Paint { paint: Paint, params: serde_json::Value, rows: Option, library: Option<(String, String)> }, + /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance + /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. + Appearance(vectorcraft_doc::NodeId), + /// A Graphic Styles panel style: art it is dropped on takes it (`graphicStyle.apply` with its + /// `ids`); the panel moves it to where it is dropped. + GraphicStyle(String), + /// The selected art, dragged off the canvas with the Selection tool: the Graphic Styles panel + /// makes a style of the first object (`graphicStyle.new`), the Symbols panel a symbol of it all + /// (`symbol.new`), the Swatches panel a pattern swatch of a copy (`object.pattern.make`) and the + /// Brushes panel an Art brush (`brush.new`). + Art(Vec), + /// A Symbols panel symbol: the canvas places an instance of it centred where it is dropped + /// (`symbol.place`). + Symbol(String), + /// A symbol of a symbol library panel (`name`: its name in `library`): the canvas adds it to the + /// document's Symbols and places an instance where it is dropped (`symbol.addFromLibrary` with + /// `place`). + LibrarySymbol { library: String, name: String }, + /// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is + /// dropped on takes it (`brush.apply`). + Brush { name: String, def: serde_json::Value }, + /// A brush of a brush library panel (`name`: its name in `library`): the path it is dropped on + /// takes it (`brush.addFromLibrary` with `apply`), and the Brushes panel adds it. + LibraryBrush { library: String, name: String }, + /// A Libraries panel graphic (`item`: its id in `library`): the canvas places a copy centred + /// where it is dropped (`library.use`); the panel moves it to where it is dropped. + LibraryGraphic { library: String, item: String }, + /// A Libraries panel text style (`item`: its name in `library`), which the panel moves to where + /// it is dropped. + LibraryItem { library: String, kind: vectorcraft_engine::cmd::library::ItemKind, item: String }, + /// A Libraries panel group, by its header, which the panel moves to where it is dropped. + LibraryGroup { library: String, group: String }, +} + +/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it; +/// → the dragged ids when it is released there. +pub fn art_drop(ui: &Ui, zone: &Response) -> Option> { + let drag = zone.dnd_hover_payload::()?; + let PanelDrag::Art(ids) = &*drag else { return None }; + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + zone.dnd_release_payload::().map(|_| ids.iter().map(|id| id.0).collect()) +} + +/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour +/// group under the pointer when the drag started (`grabbed`) and `names`, the panel selection when +/// the grabbed one is part of it (only colour groups, or only swatches, like the grabbed one), in +/// panel order. +#[derive(Clone, Debug, PartialEq)] +pub struct SwatchRows { + pub grabbed: String, + pub names: Vec, + /// The names are colour groups. + pub groups: bool, +} + +impl PanelDrag { + /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient + /// carries no placement: it fits the art it lands on (freeform points are placed afresh there, + /// coloured like the dragged ones). + pub fn paint(mut paint: Paint) -> Self { + if let Paint::Gradient(g) = &mut paint { + g.geom = None; + g.freeform = None; + } + Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None, library: None } + } + /// The dragged colour (a solid paint's), which the Gradient panel's ramp takes. + pub fn color(&self) -> Option { + match self { + Self::Paint { paint, .. } => paint.color(), + _ => None, + } + } +} + +/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag starts +/// on it. +pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) { + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), drag()); + } +} + +/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes, +/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when +/// the texture for `key` at this size isn't cached yet. +pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64) -> Option) -> Option { + use std::cell::RefCell; + use std::collections::HashMap; + thread_local! { + static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); + static CACHE: crate::graphics::TexCache> = crate::graphics::TexCache::default(); + } + let ppp = ui.ctx().pixels_per_point() as f64; + let (w, h) = (size.x as f64, size.y as f64); + let key = format!("{w}x{h}@{ppp}:{key}"); + if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + return Some(t); + } + let doc = build(w, h)?; + let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false)); + let color = crate::canvas::color_image(&img); + let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 256 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) +} diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs new file mode 100644 index 000000000..d1bca8067 --- /dev/null +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -0,0 +1,309 @@ +use super::drag::PanelDrag; +use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; +use super::*; + +/// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index. +pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { + dropdown_with_named(ui, id, None, current, options, width, |_| true) +} + +/// [`dropdown`] for a row whose visible label is not an adjacent label widget. +pub fn dropdown_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { + dropdown_with_named(ui, id, name, current, options, width, |_| true) +} + +/// [`dropdown`] of names that are user, file or system data (font styles, artboards, presets the +/// user saved, printers, profiles), shown as they are, never translated. For a list that mixes +/// built-in labels with such names, translate the built-in ones (`tl!`) before passing them. +pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { + dropdown_names_named(ui, id, None, current, options, width) +} + +/// [`dropdown_names`] for a row whose visible label is not an adjacent label widget. +pub fn dropdown_names_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { + combo_named(ui, id, name, current, width, false, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if ui.add(egui::Button::selectable(*o == current, *o)).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + +/// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). +pub fn dropdown_with( + ui: &mut Ui, + id: impl std::hash::Hash, + current: &str, + options: &[&str], + width: f32, + enabled: impl Fn(usize) -> bool, +) -> Option { + dropdown_with_named(ui, id, None, current, options, width, enabled) +} + +/// [`dropdown_with`] for a row whose visible label is not an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn dropdown_with_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + options: &[&str], + width: f32, + enabled: impl Fn(usize) -> bool, +) -> Option { + combo_named(ui, id, name, tl!(current), width, false, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, tl!(o))).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + +/// The id egui's [`egui::ComboBox::from_id_salt`] gives its button in this `Ui`: `from_id_salt` +/// stores `IdSalt::new(salt)` and hashes it once more with the scope, so the app must hash `id` +/// the same way to read the button's focus and open state before it is drawn. +pub(super) fn combo_button_id(ui: &Ui, id: &impl std::hash::Hash) -> egui::Id { + ui.make_persistent_id(egui::IdSalt::new(HashOnly(id))) +} + +/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns +/// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open +/// while it is clicked: it closes itself when an option is chosen. +pub(crate) fn combo( + ui: &mut Ui, + id: impl std::hash::Hash, + current: &str, + width: f32, + searchable: bool, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + combo_named(ui, id, None, current, width, searchable, list) +} + +/// [`combo`] for a row whose visible label is not an adjacent label widget. +pub(crate) fn combo_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + width: f32, + searchable: bool, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + let t = Tokens::get(ui.ctx()); + let label = take_previous_label(ui); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(CornerRadius::same(3)) + .show(ui, |ui| { + if searchable { + return searchable_combo(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0, list); + } + let button_id = combo_button_id(ui, &id); + let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || egui::ComboBox::is_open(ui.ctx(), button_id); + let response = egui::ComboBox::from_id_salt(HashOnly(&id)) + .selected_text(egui::RichText::new(current).size(12.0)) + .width(width - 4.0) + .height(ui.spacing().combo_height) + .show_ui(ui, list); + // The button or open list handles Enter before the modal can accept it. + if enter_for_combo { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + let _ = name_field(label_field(response.response.clone(), label), name); + let chosen = response.inner.flatten(); + // egui closes the list on a click of the pointer only: an option picked with Space or + // Enter closes it here, and the combo gives the keyboard back, so that Enter reaches the + // dialog's OK (#2789). + if chosen.is_some() { + egui::Popup::close_all(ui.ctx()); + ui.memory_mut(|m| m.surrender_focus(button_id)); + } + chosen + }) + .inner +} + +/// A dropdown whose list keeps its own search field and scrolling rows (the font menu): drawn as +/// egui's combo box, but in a popup without the scroll area egui puts around a combo's list, so +/// the field stays at the top and only the rows scroll (#555). A click outside closes it. +fn searchable_combo( + ui: &mut Ui, + id: egui::Id, + label: Option, + name: Option<&str>, + current: &str, + width: f32, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + let popup = id.with("popup"); + let open = egui::Popup::is_id_open(ui.ctx(), popup); + let pad = ui.spacing().button_padding; + let icon = Vec2::splat(ui.spacing().icon_width); + let galley = egui::WidgetText::from(egui::RichText::new(current).size(12.0)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + width - 2.0 * pad.x - ui.spacing().icon_spacing - icon.x, + egui::TextStyle::Button, + ); + let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y); + let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + // Screen readers get what egui's combo box gives them: its role and the text it shows. + resp.widget_info(|| egui::WidgetInfo { + enabled: ui.is_enabled(), + current_text_value: Some(current.to_owned()), + ..egui::WidgetInfo::new(egui::Role::ComboBox) + }); + let resp = name_field(label_field(resp, label), name); + if ui.is_rect_visible(rect) { + let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) }; + ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside); + let inner = rect.shrink2(pad); + ui.painter().galley(egui::Align2::LEFT_CENTER.align_size_within_rect(galley.size(), inner).min, galley, v.text_color()); + // egui's combo arrow: a downward triangle. + let arrow = + Rect::from_center_size(egui::Align2::RIGHT_CENTER.align_size_within_rect(icon, inner).center(), vec2(icon.x * 0.7, icon.y * 0.45)); + ui.painter().add(egui::Shape::convex_polygon( + vec![arrow.left_top(), arrow.right_top(), arrow.center_bottom()], + v.fg_stroke.color, + Stroke::NONE, + )); + } + // It opens below the button, or above it when there is more room there, and the list gets + // the room left (the popup's frame aside). + const GAP: f32 = 12.0; + let screen = ui.ctx().content_rect(); + let frame = ui.spacing().menu_margin.sum().y + GAP; + let (below, above) = (screen.bottom() - rect.bottom() - frame, rect.top() - screen.top() - frame); + let (align, room) = if below >= above { (egui::RectAlign::BOTTOM_START, below) } else { (egui::RectAlign::TOP_START, above) }; + egui::Popup::menu(&resp) + .id(popup) + .width(rect.width()) + .align(align) + .align_alternatives(&[]) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.set_min_width(ui.available_width()); + ui.set_max_height(room); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + list(ui) + }) + .and_then(|r| r.inner) +} + +/// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and +/// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its +/// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or +/// popups of its own: egui remembers one open popup at a time, so opening a dropdown inside a +/// remembered popup would close the popup (and the dropdown with it). This one keeps its open +/// state itself: the last frame it was open in. +pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -> R) -> Option { + let ctx = &resp.ctx; + let id = resp.id.with("popover"); + let frame = ctx.cumulative_frame_nr(); + let was_open = ctx.data(|d| d.get_temp::(id)).is_some_and(|f| f + 1 >= frame); + let mut open = if toggle { + !was_open + } else { + // Popups (this one and those it opened) are on foreground layers; a click anywhere + // else closes it. While the pointer is down or a panel paint drag is active, keep the + // popover open so a Fill/Stroke proxy drag can finish (#3440). + let pointer_down = ctx.input(|i| i.pointer.any_down()); + let panel_drag = egui::DragAndDrop::payload::(ctx).is_some(); + let click_pos = ctx.input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten()); + let clicked_anchor = click_pos.is_some_and(|pos| resp.rect.contains(pos)); + let clicked_elsewhere = !pointer_down + && !panel_drag + && !clicked_anchor + && click_pos.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground)); + was_open && !clicked_elsewhere + }; + let inner = + egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner); + ctx.data_mut(|d| { + if open { + d.insert_temp(id, frame); + } else { + d.remove::(id); + } + }); + inner +} + +/// Records where the bottom bars (status and hint bars) start this frame, so menus can stop above +/// them (#2086). Without bars it is the bottom of the window. +pub fn set_menu_floor(ctx: &egui::Context, y: f32) { + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("menu_floor"), y)); +} + +/// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt. +pub fn menu_room(floor: f32, top: f32) -> f32 { + const BOTTOM_GAP: f32 = 12.0; + const MIN_HEIGHT: f32 = 120.0; + (floor - top - BOTTOM_GAP).max(MIN_HEIGHT) +} + +/// The scroll bar of a menu's list: slim, in a column of its own beside the items, so it never +/// covers what sits at their right end (shortcuts, submenu arrows, a font's favourite star). +pub fn list_scroll_style() -> egui::style::ScrollStyle { + egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() } +} + +/// The body of a menu or popup list: as tall as its items up to the bottom bars of the window, and +/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on +/// small windows. Its scroll bar has a column of its own, so it never covers the shortcuts or the +/// submenu arrows (#2086). +pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); + let room = menu_room(floor, ui.next_widget_position().y); + ui.scope(|ui| { + ui.spacing_mut().scroll = list_scroll_style(); + // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to + // its items when they need less. + egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner + }) + .inner +} + +/// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the +/// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is +/// over it. +/// Its scroll bar stays a slim line at the edge, even while scrolled or dragged, so it never covers +/// the buttons (#808). +pub fn strip_scroll(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope(|ui| { + ui.spacing_mut().scroll = egui::style::ScrollStyle { + bar_width: 4.0, + floating_width: 2.0, + bar_inner_margin: 0.0, + bar_outer_margin: 1.0, + ..egui::style::ScrollStyle::floating() + }; + egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner + }) + .inner +} + +/// A panel's scrolling body (Properties, a dock column, a panel in the shared dock): `area` with +/// the scroll bar of a menu's list ([`list_scroll_style`]), in a column of its own at the right +/// edge, so it never covers the controls there, such as a dropdown's arrow (#3093). The scroll +/// areas inside the body keep their own style. +pub fn panel_scroll(ui: &mut Ui, area: egui::ScrollArea, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope(|ui| { + let inner = std::mem::replace(&mut ui.spacing_mut().scroll, list_scroll_style()); + area.show(ui, |ui| { + ui.spacing_mut().scroll = inner; + add_contents(ui) + }) + .inner + }) + .inner +} diff --git a/crates/ui-egui/src/widgets/labels.rs b/crates/ui-egui/src/widgets/labels.rs new file mode 100644 index 000000000..76c69053e --- /dev/null +++ b/crates/ui-egui/src/widgets/labels.rs @@ -0,0 +1,181 @@ +use super::*; + +/// Bold section header (Properties panel). +pub fn section_header(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(2.0); + ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text_strong)); + ui.add_space(2.0); +} + +/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`; +/// a click anywhere on it toggles the section. Whether it was clicked. +pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool { + section_toggle_name(ui, tl!(text), open).clicked() +} + +/// [`section_toggle`] for a name that is user data (a library group), shown as it is → its +/// response (clicked: toggle; it takes a context menu). +pub fn section_toggle_name(ui: &mut Ui, text: &str, open: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0)); + icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon }); + ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, text, theme::semibold(12.5), t.text); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::DisclosureTriangle, true, open, text)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// Full-width 1 px divider with vertical margins. +pub fn divider(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().rect_filled(rect, 0.0, t.divider); + ui.add_space(6.0); +} + +/// The `Id` under which [`dim_label`]/[`field_label`] leave the label a following field should be +/// named by. egui 0.37's kittest fails an input with no accessible name, and VectorCraft paints +/// most labels beside, not inside, their fields. +fn next_label_id() -> egui::Id { + egui::Id::unique("vectorcraft-next-input-label") +} + +/// The label a field may be named by, and the widget id the field must be drawn under for the +/// label to belong to it. egui allocates a fresh auto id for every widget, but not for moving +/// between grid cells: the next field in a grid still has the id the label recorded, while a +/// checkbox or another widget between the label and a field has taken it. A label therefore names +/// the very next field, also in a grid, but not a later one after an unrelated widget. +#[derive(Clone, Copy)] +struct NextLabel { + id: egui::Id, + viewport: egui::ViewportId, + pass: u64, + at: Pos2, + next_auto_id: egui::Id, +} + +/// Record a label's widget id, viewport, pass, position and next auto id so only the immediately +/// following field can take the label. +fn remember_next_label(ui: &Ui, id: egui::Id) { + let label = NextLabel { + id, + viewport: ui.ctx().viewport_id(), + pass: ui.ctx().cumulative_pass_nr(), + at: ui.next_widget_position(), + next_auto_id: ui.next_auto_id(), + }; + ui.ctx().data_mut(|d| d.insert_temp(next_label_id(), label)); +} + +/// A label taken for the next field, and whether no widget came between the two. +pub(crate) struct PreviousLabel { + id: egui::Id, + at: Pos2, + immediate: bool, +} + +/// The label drawn immediately before a field in this pass, if no other widget came between them +/// (an app-local adapter around [`egui::Response::labelled_by`], not a general compatibility +/// layer). Takes the record, so one label names one field. +pub(crate) fn take_previous_label(ui: &Ui) -> Option { + let label = ui.ctx().data_mut(|d| { + let label = d.get_temp::(next_label_id()); + d.remove::(next_label_id()); + label + })?; + // Frame and pass numbers are per viewport; a label left in one viewport must never name a + // field in another. The shipped hosts draw one root viewport, so this scoping is defensive. + (label.viewport == ui.ctx().viewport_id() && label.pass == ui.ctx().cumulative_pass_nr()).then(|| PreviousLabel { + id: label.id, + at: label.at, + immediate: ui.next_auto_id() == label.next_auto_id, + }) +} + +/// Name `resp` by the field's real adjacent label (see [`take_previous_label`]). +pub(crate) fn label_field(resp: Response, label: Option) -> Response { + let Some(label) = label else { return resp }; + let adjacent = label.immediate || resp.rect.min.distance(label.at) <= 16.0 || resp.rect.contains(label.at); + if adjacent { resp.labelled_by(label.id) } else { resp } +} + +/// Give `resp` the explicit semantic name of a field whose visible label is not a widget beside +/// it (a label below a grid of dash cells, a heading over a row of fields). The name is already +/// translated at the call site. An empty name leaves the field unnamed rather than inventing one. +pub(super) fn name_field(resp: Response, name: Option<&str>) -> Response { + match name { + Some(name) if !name.trim().is_empty() => resp.accessible_name(name), + _ => resp, + } +} + +/// A label in the panels' text colour; before a numeric field it scrubs the field +/// ([`scrub`]). +pub fn dim_label(ui: &mut Ui, text: &str) -> Response { + let resp = dim_name(ui, tl!(text)); + scrub::note_label(ui, resp.rect); + remember_next_label(ui, resp.id); + resp +} + +/// A field's label in other styles (`text` as given): before a numeric field it scrubs the field +/// ([`scrub`]). +pub fn field_label(ui: &mut Ui, text: impl Into) -> Response { + let resp = ui.label(text); + scrub::note_label(ui, resp.rect); + remember_next_label(ui, resp.id); + resp +} + +/// [`dim_label`] for a name that is user or file data (a swatch, style, artboard or font name): +/// shown as it is, never translated. +pub fn dim_name(ui: &mut Ui, text: &str) -> Response { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).color(t.text).size(12.5)) +} + +/// A form row: `label` in a column `label_width` wide, then what `add` draws. +pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) { + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover()); + let t = Tokens::get(ui.ctx()); + let shown = tl!(label); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, shown, egui::FontId::proportional(12.5), t.text); + scrub::note_label(ui, r); + // The label is painted, not a widget of its own: give it a real semantic node so the + // input beside it can be named by `label_inputs_by` (egui 0.37's strict a11y gate). + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), shown)); + if label.trim().is_empty() { + add(ui); + } else { + ui.label_inputs_by(resp.id, add); + } + }); +} + +/// Regular-weight panel sub-header ("Shape Modes:", "Align Objects:"). +pub fn subheader(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text)); +} + +/// A label drawn with a dotted underline (Illustrator's link labels: "Stroke:", "Opacity:"). +pub fn link_label(ui: &mut Ui, text: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); + let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(0.0, 3.0), Sense::click()); + let y = rect.top() + galley.size().y + 1.0; + ui.painter().galley(rect.min, galley, t.text_strong); + let mut x = rect.left(); + while x < rect.right() - 1.0 { + ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim)); + x += 2.5; + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + resp +} diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs new file mode 100644 index 000000000..655f3f978 --- /dev/null +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -0,0 +1,661 @@ +use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; +use super::panel_chrome::chevron_cell; +use super::text::{after_shift_enter, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_dialog_focus}; +use super::*; + +/// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it +/// when the preference changes (`prefs_dialog::apply_runtime`), the length fields read it. +pub(crate) fn set_bare_numbers_are_points(ctx: &egui::Context, on: bool) { + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("bare_numbers_are_points"), on)); +} + +/// The unit a number typed with no unit into a field in `unit` is read in: `unit`, or for a field +/// in picas points with Numbers Without Units Are Points on (where `12` could be either). A typed +/// unit (`12 mm`, `2p6`, `1in`) always wins. +pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit { + let bare_points = ctx.data(|d| d.get_temp::(egui::Id::unique("bare_numbers_are_points"))).unwrap_or(true); + if bare_points && unit == Unit::Picas { Unit::Points } else { unit } +} + +/// `text` typed in a numeric field, with a decimal comma read as a point: `0,1 mm` is `0.1 mm` +/// (#3837). Text with a point keeps its commas, so `1,000.5` is still refused rather than read +/// with two decimal separators, and so does a number that reads as thousands ([`thousands`]). +pub(super) fn decimal_point(text: &str) -> std::borrow::Cow<'_, str> { + if text.contains(',') && !text.contains('.') && !thousands(text) { text.replace(',', ".").into() } else { text.into() } +} + +/// Is `text` a number written with a thousands comma, as `1,000` or `12,500 mm`: one to three +/// digits (not a lone 0), a comma, exactly three digits, then at most a unit? Read as a decimal it +/// would be a thousand times too small, so the field refuses it instead (`0,125` and `1,25` are +/// decimals). +fn thousands(text: &str) -> bool { + let number = text.trim().trim_start_matches('-'); + let Some((whole, rest)) = number.split_once(',') else { return false }; + let (part, unit) = rest.split_at(rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())); + (1..=3).contains(&whole.len()) + && whole.bytes().all(|b| b.is_ascii_digit()) + && !whole.starts_with('0') + && part.len() == 3 + && unit.trim().chars().all(char::is_alphabetic) +} + +/// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) +/// when the user commits (Enter / focus loss / Shift+Enter, which keeps the focus outside dialogs). +/// Supports unit suffixes and arithmetic; a number without a unit is in `unit` ([`typed_unit`]: in +/// points in a picas field with Numbers Without Units Are Points on). +pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { + num_field_named(ui, id, None, value, unit, width) +} + +/// [`num_field`] for a row whose visible label is not an adjacent label widget (a grid cell with +/// its label painted separately): screen readers name the field `name` (already translated). +pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { + let label = take_previous_label(ui); + unit_field(ui, id, label, name, value, unit, width, false, ANY) +} + +/// Any number: the bounds of a field that has none. +const ANY: RangeInclusive = f64::NEG_INFINITY..=f64::INFINITY; + +/// `v` within `bounds` (bounds the wrong way round or not numbers don't panic, as `f64::clamp` would). +fn within(v: f64, bounds: &RangeInclusive) -> f64 { + v.max(*bounds.start()).min(*bounds.end()) +} + +/// [`num_field`]; with `share_of_value` a percentage typed alone is that share of `value` +/// ([`share_of`]). ↑/↓, the wheel and a scrub stop at `bounds` (points), and the field shows where +/// they stopped. +#[allow(clippy::too_many_arguments)] +fn unit_field( + ui: &mut Ui, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, + value: Option, + unit: Unit, + width: f32, + share_of_value: bool, + bounds: RangeInclusive, +) -> Option { + let id = id_with(ui.scope_id(), id); + let shown = value.map(|v| unit.format(v)).unwrap_or_default(); + take_dialog_focus(ui, id, &shown); + let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); + select_all_on_focus(ui, &resp, &buf); + // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. + let typed = typed_unit(ui.ctx(), unit); + let read = |b: &str| typed.parse(&decimal_point(b)); + let bounds = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); + let stepped = step_value(ui, &resp, &mut buf, &bounds, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None); + let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds); + let commit = commit && buf != shown; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + if commit { + read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None }) + } else { + stepped.or(scrubbed).map(|v| unit.to_pt(v)) + } +} + +/// A percentage typed alone (`50%`, `150 %`, `100/3%`) as that share of `value`; `None` for any +/// other text. `Unit::parse` reads a bare percentage as nothing, having no value to take it of. +pub(super) fn share_of(text: &str, value: f64) -> Option { + let percent = vectorcraft_doc::parse_number(text.trim().strip_suffix('%')?)?; + Some(value * percent / 100.0) +} + +/// The places a stepped value in a unit field keeps (no 12.300000001). +pub(super) const STEP_DECIMALS: i32 = 3; + +/// `v` rounded to `decimals` places. +pub(crate) fn round_to(v: f64, decimals: i32) -> f64 { + let scale = 10f64.powi(decimals.clamp(0, 6)); + (v * scale).round() / scale +} + +/// One step of a numeric field with each modifier, most specific first (a plain pattern would also +/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to +/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten. +const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; + +/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next +/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1 +/// goes up to 6 and down to 5, and 5 goes up to 6 and down to 4. A value within a millionth of a +/// step of a multiple counts as that multiple. +pub(super) fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 { + if n == 0.0 { + return v; + } + let q = v / size; + let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() }; + (from + n) * size +} + +/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf` +/// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step +/// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to +/// the next tenth, and a Shift step adds or takes ten. Steps stop at the field's `bounds` (#3370: +/// a weight stops at 0, a miter limit at 1). The new value is applied at once, as in +/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing +/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused +/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the +/// wheel to the panel and the canvas. +fn step_value( + ui: &Ui, + resp: &Response, + buf: &mut String, + bounds: &RangeInclusive, + (read, show): (impl Fn(&str) -> Option, impl Fn(f64) -> String), + step: Option f64>, +) -> Option { + // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the + // window to report keyboard focus. + if !ui.memory(|m| m.has_focus(resp.id)) { + return None; + } + use egui::{Event, Key, MouseWheelUnit}; + let wheel = resp.contains_pointer(); + // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is + // left of a notch carries over to the next turn. + let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); + let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 }; + // Each press and each turn of whole notches in the order they came: its modifiers and size from + // [`STEPS`], and how many steps up (down when negative). + let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); + ui.input_mut(|i| { + i.events.retain(|e| { + let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; + let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; + let n = match e { + Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0, + Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0, + Event::MouseWheel { unit, delta, .. } if wheel => { + // Shift may turn the wheel sideways (the Mac does). + let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; + notches += match unit { + MouseWheelUnit::Point => d / line.max(1.0), + MouseWheelUnit::Line | MouseWheelUnit::Page => d, + }; + let whole = notches.trunc(); + notches -= whole; + f64::from(whole) + } + _ => return true, + }; + if n != 0.0 { + steps.push((mods, size, n)); + } + false + }); + if wheel { + // The rest of a turn egui spreads over the next frames scrolls nothing either. + i.smooth_scroll_delta = egui::Vec2::ZERO; + } + }); + if wheel { + ui.data_mut(|d| d.insert_temp(notch_id, notches)); + } + if steps.is_empty() { + return None; + } + let mut v = read(buf)?; + for (mods, size, n) in steps { + v = match step { + // The field's own steps: ten at once with Shift, one with or without Ctrl/Cmd. + Some(step) => step(v, if mods == egui::Modifiers::SHIFT { 10.0 * n } else { n }), + None if mods == egui::Modifiers::SHIFT => v + size * n, + None => step_to_multiple(v, size, n), + }; + // Each step stops at the bounds, so a step back in the same frame starts from there. + v = within(v, bounds); + } + // Rounded as fields show it, within the field's bounds. + let v = within(round_to(v, STEP_DECIMALS), bounds); + *buf = show(v); + ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); + select_all(ui, resp.id, buf); + Some(v) +} + +/// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter +/// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A +/// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). +pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { + size_field_named(ui, id, None, value, unit, width) +} + +/// [`size_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn size_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { + use egui::{Event, Key}; + let label = take_previous_label(ui); + // The text box does not end its editing on an Enter with Ctrl/Cmd: a plain Enter goes in right + // after it, so the box first takes the text typed before it in this frame. + let field = id_with(ui.scope_id(), &id); + if ui.memory(|m| m.has_focus(field)) { + ui.input_mut(|i| { + let cmd_enter = |e: &Event| matches!(e, Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command); + if let Some(at) = i.events.iter().position(cmd_enter) { + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + i.events.insert(at + 1, enter); + } + }); + } + unit_field(ui, id, label, name, value, unit, width, true, ANY) +} + +/// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels +/// such as the Stroke panel's Limit); `None` shows a blank field, as [`mixed_field`] does. What is +/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count), and +/// ↑/↓, the wheel and a scrub stop at its ends in the field too (#3370). +pub fn range_field( + ui: &mut Ui, + id: impl std::hash::Hash, + value: impl Into>, + range: RangeInclusive, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + range_field_named(ui, id, None, value, range, suffix, decimals, width) +} + +/// [`range_field`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn range_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + value: impl Into>, + range: RangeInclusive, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + let places = decimals.min(6) as i32; + let value = value.into().map(|v| within(v, &range)); + let label = take_previous_label(ui); + bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None).map(|v| within(round_to(v, places), &range)) +} + +/// [`range_field`] whose ↑/↓ and wheel take `step`'s steps instead of whole numbers (see +/// [`SliderValue::step`]), for a number whose useful values are fractions, such as the Pencil's +/// Fidelity. +#[allow(clippy::too_many_arguments)] +pub fn range_field_stepped( + ui: &mut Ui, + id: impl std::hash::Hash, + value: impl Into>, + range: RangeInclusive, + suffix: &str, + decimals: usize, + width: f32, + step: fn(f64, f64) -> f64, +) -> Option { + let places = decimals.min(6) as i32; + let value = value.into().map(|v| within(v, &range)); + let label = take_previous_label(ui); + bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step)).map(|v| within(round_to(v, places), &range)) +} + +/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field. +pub fn mixed_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, suffix: &str, decimals: usize, width: f32) -> Option { + mixed_field_named(ui, id, None, value, suffix, decimals, width) +} + +/// [`mixed_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn mixed_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + value: Option, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + let label = take_previous_label(ui); + bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None) +} + +/// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`; ↑/↓ and the wheel take `step`'s steps +/// when it is given (see [`SliderValue::step`]). +#[allow(clippy::too_many_arguments)] +fn bounded_field( + ui: &mut Ui, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, + value: Option, + suffix: &str, + decimals: usize, + width: f32, + bounds: &RangeInclusive, + step: Option f64>, +) -> Option { + let id = id_with(ui.scope_id(), id); + let show = |value: f64| field_text(value, suffix, decimals); + // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. + let read = |buf: &str| { + let suffix = suffix.trim(); + let buf = decimal_point(buf); + let bare = if suffix.is_empty() { buf.to_string() } else { buf.replace(suffix, "") }; + vectorcraft_doc::parse_number(&bare.replace(['%', '°'], "")) + }; + let shown = value.map(show).unwrap_or_default(); + take_dialog_focus(ui, id, &shown); + let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); + select_all_on_focus(ui, &resp, &buf); + // ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on + // its label scrubs it. + let decimals = decimals.min(6) as i32; + let stepped = step_value(ui, &resp, &mut buf, bounds, (read, show), step).map(|v| round_to(v, decimals)); + let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals, bounds); + if commit && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) } +} + +/// The text [`plain_field`] shows for `value`: at most `decimals` places, without trailing zeros, +/// then `suffix`. +pub(super) fn field_text(value: f64, suffix: &str, decimals: usize) -> String { + let s = format!("{:.*}", decimals, value); + let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; + format!("{s}{suffix}") +} + +/// A slider whose rail shows, filled up to the handle: the theme's widget fill matches the panel +/// and dialog background, which leaves only the handle visible (#1483). +pub fn slider(ui: &mut Ui, slider: egui::Slider<'_>) -> Response { + let t = Tokens::get(ui.ctx()); + ui.scope(|ui| { + let v = ui.visuals_mut(); + // The buttons' border shows against the panel in every brightness; the fields' border + // all but vanished on Medium Dark's panel (#1662). + v.widgets.inactive.bg_fill = t.button_border; + v.selection.bg_fill = t.accent; + ui.add(slider.trailing_fill(true)) + }) + .inner +} + +/// Numeric field with an up/down spinner on the left and a preset dropdown on the right +/// (Stroke weight, font size…): an editable combo box whose field keeps typing, ↑/↓ stepping and +/// label scrubbing. `value` (points) shows in `unit`, blank when `None` (the selection's values +/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. The spinner, ↑/↓, +/// the wheel and a scrub stop at `min` (#3370), and a typed value below it applies `min`. Returns +/// the committed, stepped or picked value. +#[allow(clippy::too_many_arguments)] +pub fn spin_field( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + value: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_field_named(ui, id, None, value, unit, width, step, min, presets) +} + +/// [`spin_field`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_field_auto_named(ui, id, name, value, None, unit, width, step, min, presets).and_then(SpinPick::value) +} + +/// What a [`spin_field_auto`] returns: a value (points), or its Auto entry. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SpinPick { + Value(f64), + Auto, +} + +impl SpinPick { + /// The value, `None` for Auto. + pub fn value(self) -> Option { + match self { + SpinPick::Value(v) => Some(v), + SpinPick::Auto => None, + } + } +} + +/// [`spin_field`] whose dropdown starts with an Auto entry (Leading): `auto` is `Some(on)`, with +/// `on` checking it (no preset is then checked), or `None` for no Auto entry. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_auto( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + value: Option, + auto: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_field_auto_named(ui, id, None, value, auto, unit, width, step, min, presets) +} + +/// [`spin_field_auto`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_auto_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: Option, + auto: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + let label = take_previous_label(ui); + spin_generic(ui, label, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw, label| { + unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY) + }) +} + +/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix; a +/// `None` value (the selection's values differ) shows blank. +#[allow(clippy::too_many_arguments)] +pub fn spin_plain( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + value: impl Into>, + suffix: &str, + decimals: usize, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_plain_named(ui, id, None, value, suffix, decimals, width, step, min, presets) +} + +/// [`spin_plain`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_plain_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: impl Into>, + suffix: &str, + decimals: usize, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + let label = take_previous_label(ui); + let value = value.into(); + let fmt = |v: f64| format!("{v}{suffix}"); + spin_generic(ui, label, value, None, width, step, min, presets, &fmt, &mut |ui, fw, label| { + bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None) + }) + .and_then(SpinPick::value) +} + +type SpinField<'a> = dyn FnMut(&mut Ui, f32, Option) -> Option + 'a; + +#[allow(clippy::too_many_arguments)] +fn spin_generic( + ui: &mut Ui, + label: Option, + value: Option, + auto: Option, + width: f32, + step: f64, + min: f64, + presets: &[f64], + fmt: &dyn Fn(f64) -> String, + field: &mut SpinField<'_>, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out = None; + let h = 26.0; + let enabled = ui.is_enabled(); + ui.scope(|ui| { + // The spinner, the field and the chevron share their borders: one box in three parts. + ui.spacing_mut().item_spacing.x = -1.0; + let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click()); + ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input); + ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y)); + let down = Rect::from_min_max(pos2(sr.left(), sr.center().y), sr.max); + let hover = sresp.hover_pos(); + for (r, is_up) in [(up, true), (down, false)] { + let c = if !enabled { + t.text_disabled + } else if hover.is_some_and(|p| r.contains(p)) { + t.text_strong + } else { + t.icon + }; + let m = r.center(); + let d = if is_up { -1.5 } else { 1.5 }; + ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); + ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); + } + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); + // A blank field (values that differ) has nothing to step from. + if sresp.clicked() + && let Some(p) = sresp.interact_pointer_pos() + && let Some(v) = value + { + let modifiers = ui.input(|i| i.modifiers); + out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers))); + } + let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; + if let Some(v) = field(ui, fw, label) { + out = Some(SpinPick::Value(v.max(min))); + } + if !presets.is_empty() { + let dresp = chevron_cell(ui, h); + egui::Popup::menu(&dresp).show(|ui| { + ui.set_min_width(width - 10.0); + if let Some(on) = auto + && ui.selectable_label(on, tl!("Auto")).clicked() + { + out = Some(SpinPick::Auto); + } + let current = value.filter(|_| auto != Some(true)); + for p in presets { + if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { + out = Some(SpinPick::Value(*p)); + } + } + }); + } + }); + out +} + +/// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: +/// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step. +pub(super) fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers) -> f64 { + let multiplier = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or(1.0, |(_, factor)| *factor); + let delta = step * multiplier; + (value + if up { delta } else { -delta }).max(min) +} + +/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without +/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit (Enter, Shift+Enter or +/// focus loss, as in [`num_field`]), where `new` is `None` when the field was cleared; typed units +/// and arithmetic work as in [`num_field`]. The value is a length: ↑/↓, the wheel and a scrub stop +/// at 0. +pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option> { + opt_field_named(ui, id, None, value, unit, width) +} + +/// [`opt_field`] for a field named explicitly instead of by an adjacent label widget (the Stroke +/// panel's dash and gap cells have their labels in a row below them). +pub fn opt_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option> { + let t = Tokens::get(ui.ctx()); + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); + let shown = value.map(|v| unit.number(v)).unwrap_or_default(); + let editing = ui.memory(|m| m.has_focus(id)); + // As in `hex_field`: until the user types into it, the field shows `shown` as it is now + // (#1692). When `shown` changes while the field has the focus and nothing has been typed into + // it, all of the new text is selected, as on keyboard focus. + let edited_id = id.with("edited"); + let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); + let before = ui.data_mut(|d| d.get_temp::(id)); + let mut buf: String = if edited { before.clone().unwrap_or_else(|| shown.clone()) } else { shown.clone() }; + let reshown = after_shift_enter(ui, id); + if reshown || editing && !edited && before.is_some_and(|b| b != buf) { + select_all(ui, id, &buf); + } + let kept = shift_enter_keeps_focus(ui, id); + let enabled = ui.is_enabled(); + let framed = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(4, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(width - 10.0) + .font(egui::FontId::proportional(12.5)) + .text_color(if enabled { t.text_strong } else { t.text_disabled }), + ) + }); + let resp = framed.inner; + let resp = name_field(label_field(resp, label), name); + select_all_on_focus(ui, &resp, &buf); + // ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it. + let lengths = 0.0..=f64::INFINITY; + let stepped = step_value( + ui, + &resp, + &mut buf, + &lengths, + (|b: &str| Some(unit.from_pt(unit.parse(&decimal_point(b))?)), |v| unit.number(unit.to_pt(v))), + None, + ); + let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &lengths); + let focused = ui.memory(|m| m.has_focus(id)); + ui.data_mut(|d| { + d.insert_temp(id, buf.clone()); + d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); + }); + if (resp.lost_focus() || kept) && buf != shown { + let s = buf.trim(); + if s.is_empty() { Some(None) } else { typed_unit(ui.ctx(), unit).parse(&decimal_point(s)).map(Some) } + } else { + stepped.or(scrubbed).map(|v| Some(unit.to_pt(v))) + } +} diff --git a/crates/ui-egui/src/widgets/panel_chrome.rs b/crates/ui-egui/src/widgets/panel_chrome.rs new file mode 100644 index 000000000..f92114448 --- /dev/null +++ b/crates/ui-egui/src/widgets/panel_chrome.rs @@ -0,0 +1,136 @@ +use super::*; + +/// A bordered list box (Swatches tiles, Appearance rows, Artboards list). +pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(1)).show(ui, add).inner +} + +/// A bottom button bar separated from the content by a divider (panel footers). +pub fn bottom_bar(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, t.divider); + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + add(ui) + }); +} + +/// A row of text tabs, the current one underlined in the accent colour (New Document's +/// categories, Document Setup's sections). Returns the clicked tab's index. +pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { + let t = Tokens::get(ui.ctx()); + let mut clicked = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 18.0; + for (i, tab) in tabs.iter().enumerate() { + let sel = i == current; + let font = if sel { theme::semibold(13.0) } else { egui::FontId::proportional(13.0) }; + let galley = ui.painter().layout_no_wrap(tl!(tab).to_string(), font, t.text); + let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x, 30.0), Sense::click()); + let color = if sel || resp.hovered() { t.text_strong } else { t.text_dim }; + ui.painter().galley(pos2(rect.left(), rect.center().y - galley.size().y / 2.0 - 2.0), galley, color); + if sel { + ui.painter().rect_filled( + Rect::from_min_max(pos2(rect.left(), rect.bottom() - 2.0), rect.right_bottom()), + CornerRadius::same(1), + t.accent, + ); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), sel, tl!(tab))); + if resp.clicked() { + clicked = Some(i); + } + } + }); + clicked +} + +/// A page-orientation toggle drawn in code: a portrait or landscape sheet with a folded corner, +/// accent-filled when `selected`. Returns clicked. +pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); + if resp.hovered() && !selected { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); + } + let size = if landscape { vec2(16.0, 12.0) } else { vec2(12.0, 16.0) }; + let page = Rect::from_center_size(rect.center(), size); + let color = if selected { t.accent } else { t.icon }; + let fold = 4.0; + let outline = + vec![page.left_top(), page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(0.0, fold), page.right_bottom(), page.left_bottom()]; + let fill = if selected { t.accent } else { Color32::TRANSPARENT }; + ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); + let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; + ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, tl!(tip))); + resp.on_hover_text(tl!(tip)).clicked() +} + +/// An artboard-layout toggle drawn in code (Rearrange All Artboards): small artboards in the grid +/// cells `tiles` (column, row), tinted, and over them their order as a line from a dot on the first +/// to an arrowhead on the last; accent-drawn in a pressed well when `selected`, greyed in a disabled +/// `ui`. `tip` is shown as it is (the caller translates it). Returns clicked. +pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); + let enabled = ui.is_enabled(); + let bg = if selected { + t.tool_active + } else if resp.hovered() && enabled { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(3), bg); + let color = if !enabled { + t.text_disabled + } else if selected { + t.accent + } else { + t.icon + }; + // Cells at most 10 points a side in a 20-point square: a row's artboards stand tall, a + // column's lie flat. + let (cols, rows) = tiles.iter().fold((1u8, 1u8), |(c, r), &(x, y)| (c.max(x.saturating_add(1)), r.max(y.saturating_add(1)))); + let cell = vec2((20.0 / f32::from(cols)).min(10.0), (20.0 / f32::from(rows)).min(10.0)); + let origin = rect.center() - vec2(f32::from(cols) * cell.x, f32::from(rows) * cell.y) / 2.0; + let centre = |&(x, y): &(u8, u8)| origin + vec2((f32::from(x) + 0.5) * cell.x, (f32::from(y) + 0.5) * cell.y); + for tile in tiles { + ui.painter().rect_filled(Rect::from_center_size(centre(tile), cell - vec2(2.0, 2.0)), 1.0, color.gamma_multiply(0.35)); + } + let path: Vec = tiles.iter().map(centre).collect(); + if let ([first, ..], [.., from, to]) = (path.as_slice(), path.as_slice()) { + let (first, from, to) = (*first, *from, *to); + let dir = (to - from).normalized(); + let side = dir.rot90() * 2.5; + let head = vec![to + dir * 1.5, to - dir * 2.5 + side, to - dir * 2.5 - side]; + ui.painter().circle_filled(first, 1.6, color); + ui.painter().add(egui::Shape::line(path, Stroke::new(1.2, color))); + ui.painter().add(egui::Shape::convex_polygon(head, color, Stroke::NONE)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, selected, tip)); + resp.on_hover_text(tip).clicked() +} + +/// The chevron cell on the right of a field that opens its preset list (rounded on the right). +pub(super) fn chevron_cell(ui: &mut Ui, h: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); + ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); + ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let c = if !ui.is_enabled() { + t.text_disabled + } else if dresp.hovered() { + t.text_strong + } else { + t.icon + }; + icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets"))); + dresp +} diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs new file mode 100644 index 000000000..f3f922dfa --- /dev/null +++ b/crates/ui-egui/src/widgets/text.rs @@ -0,0 +1,328 @@ +use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; +use super::numeric::mixed_field_named; +use super::panel_chrome::chevron_cell; +use super::*; + +/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the +/// first enabled field drawn takes it ([`take_dialog_focus`]). +pub(crate) fn dialog_focus_flag() -> egui::Id { + egui::Id::unique("dialog-focus-first-field") +} + +/// If field `id` (showing `text`) is the first enabled field of a dialog that has just opened, give +/// it the keyboard focus with all of `text` selected: typing replaces the value and Enter applies it. +pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) { + // A new window's first frame only measures its contents: nothing can take focus yet. Nor can a + // greyed-out field (egui drops its focus): the flag stays for the first field that is enabled. + if ui.is_sizing_pass() || !ui.is_enabled() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { + return; + } + focus_field(ui, id, text); +} + +/// Give text field `id` the keyboard focus with all of `text` selected. +pub(crate) fn focus_field(ui: &Ui, id: egui::Id, text: &str) { + select_all(ui, id, text); + ui.memory_mut(|m| m.request_focus(id)); +} + +/// The modifiers held with an Enter press among this frame's events, `None` without one, as when a +/// field's editing ends with a click elsewhere. +pub(crate) fn enter_modifiers(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) + }) +} + +/// Select all of `text` in text field `id`. +pub(super) fn select_all(ui: &Ui, id: egui::Id, text: &str) { + let mut st = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + st.cursor.set_char_range(Some(all)); + st.store(ui.ctx(), id); +} + +/// The flag that tells the fields a dialog is open in this frame ([`set_dialog_open`]). +fn dialog_open_flag() -> egui::Id { + egui::Id::unique("dialog-open") +} + +/// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field +/// applies its value and confirms the dialog as Enter does ([`shift_enter_keeps_focus`]). +pub(crate) fn set_dialog_open(ctx: &egui::Context, open: bool) { + ctx.data_mut(|d| d.insert_temp(dialog_open_flag(), open)); +} + +/// Shift+Enter (Shift+Return) in single-line field `id` while it has the keyboard focus and no +/// dialog is open: the press is taken from this frame's events before the field's text box reads +/// them, so the field keeps the focus. Returns true when it was pressed; the field then applies its +/// text as Enter does, and in the next frame shows its value with all of it selected +/// ([`after_shift_enter`]). +pub(super) fn shift_enter_keeps_focus(ui: &Ui, id: egui::Id) -> bool { + if !ui.memory(|m| m.has_focus(id)) || ui.data(|d| d.get_temp::(dialog_open_flag())).unwrap_or(false) { + return false; + } + let pressed = ui.input_mut(|i| { + let before = i.events.len(); + i.events.retain(|e| { + !matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } if modifiers.matches_exact(egui::Modifiers::SHIFT)) + }); + i.events.len() < before + }); + if pressed { + ui.data_mut(|d| d.insert_temp(id.with("shift-enter"), true)); + } + pressed +} + +/// Whether field `id`, still focused, took Shift+Enter in the last frame +/// ([`shift_enter_keeps_focus`]). Clears that record. +pub(super) fn after_shift_enter(ui: &Ui, id: egui::Id) -> bool { + ui.data_mut(|d| d.remove_temp::(id.with("shift-enter"))).unwrap_or(false) && ui.memory(|m| m.has_focus(id)) +} + +/// Handles Shift+Enter in a single-line panel field that draws its own text box `id` (Pattern +/// Options' Name, the inline renames in the Layers, Artboards and Asset Export panels). Call it +/// before the box is drawn, with `buf` the text the box will show and `shown` the value as it is +/// now. When the field took Shift+Enter in the last frame, `buf` becomes `shown`, all of it +/// selected. Returns whether Shift+Enter was pressed in this frame ([`shift_enter_keeps_focus`]): +/// the field then applies `buf` as Enter does and keeps the focus. +pub(crate) fn own_field_shift_enter(ui: &Ui, id: egui::Id, buf: &mut String, shown: &str) -> bool { + if after_shift_enter(ui, id) { + *buf = shown.to_string(); + select_all(ui, id, buf); + } + shift_enter_keeps_focus(ui, id) +} + +/// Numeric fields select the whole text on keyboard focus, a completed focus click or a +/// double-click. A pointer press keeps its caret so dragging selects from the pressed position, +/// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap +/// releases into select-all. +pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { + let pending = resp.id.with("select-all-on-focus-click"); + let press_pos = resp.id.with("select-all-on-focus-press"); + let (pressed, released, at_press) = ui.input(|i| { + (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.pointer.interact_pos()) + }); + if resp.gained_focus() { + ui.data_mut(|d| { + if pressed { + d.insert_temp(pending, true); + if let Some(p) = at_press { + d.insert_temp(press_pos, p); + } + } else { + d.remove_temp::(pending); + d.remove_temp::(press_pos); + } + }); + } + let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); + // A press released a few points away is a short drag, not a tap. egui still reports a + // click, and a release inside one character leaves no text selection, so select-all on + // release would replace the drag the user just made (#1326). + let dragged = clicked_focus + && ui + .data_mut(|d| d.remove_temp::(press_pos)) + .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); + if resp.lost_focus() { + ui.data_mut(|d| { + d.remove_temp::(pending); + d.remove_temp::(press_pos); + }); + } + if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus && !dragged) { + return; + } + if let Some(st) = egui::TextEdit::load_state(ui.ctx(), resp.id) { + // Preserve a text selection if the focus click already produced one. + if clicked_focus && !resp.double_clicked() && st.cursor.char_range().is_some_and(|r| !r.as_sorted_char_range().is_empty()) { + return; + } + select_all(ui, resp.id, text); + ui.ctx().request_repaint(); + } +} + +/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line), +/// its text kept under `id` while it has focus. Returns the text (as edited), whether the field +/// applies it in this frame (it lost the focus, or a single-line field took Shift+Enter and kept +/// the focus: [`shift_enter_keeps_focus`]), the text edit's response and the box. +pub(super) fn recessed_text( + ui: &mut Ui, + id: egui::Id, + shown: &str, + width: f32, + rows: usize, + label: Option, + name: Option<&str>, +) -> (String, bool, Response, Rect) { + let t = Tokens::get(ui.ctx()); + let editing = ui.memory(|m| m.has_focus(id)); + // A press outside the field ends its editing in this frame, so the text typed is committed + // before the press acts: the canvas, drawn after the panels, may select another object, and a + // frame later the text would be committed to that one. + if editing + && let Some(at) = ui.input(|i| i.pointer.primary_pressed().then(|| i.pointer.interact_pos()).flatten()) + && ui.ctx().read_response(id).is_some_and(|r| !r.rect.contains(at)) + { + ui.memory_mut(|m| m.surrender_focus(id)); + } + // When the value changes under a focused field (the slider beside it, a preview, another + // field's edit), its text follows: the text typed for the old value would otherwise go back in + // on the next Enter, Tab or arrow key (#1457). + let shown_id = id.with("shown"); + let followed = ui.data_mut(|d| d.get_temp::(shown_id)).is_none_or(|before| before == shown); + ui.data_mut(|d| d.insert_temp(shown_id, shown.to_string())); + // A press elsewhere can end the focus outside this field's own drawing (a field drawn before it + // takes it, the canvas ends text input), so `editing` is already false on the frame it reports + // `lost_focus`: the text typed is still taken then, to be committed (#1637). Escape drops it. + let focus_id = id.with("had-focus"); + let had_focus = ui.data_mut(|d| d.get_temp::(focus_id)).unwrap_or(false) && !ui.input(|i| i.key_pressed(egui::Key::Escape)); + // After Shift+Enter applied the text, the field shows the value as it is now, all selected. + let reshown = after_shift_enter(ui, id); + let typed = if (editing || had_focus) && followed && !reshown { ui.data_mut(|d| d.get_temp::(id)) } else { None }; + let mut buf: String = typed.unwrap_or_else(|| shown.to_string()); + if reshown { + select_all(ui, id, &buf); + } + let kept = rows == 1 && shift_enter_keeps_focus(ui, id); + // The box is exactly as tall as the other controls of a row (26 points a line), its text + // centred in it: a frame around the text came out a point taller and stood out next to + // spinners and dropdowns (#900). + let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32; + let layout = if rows > 1 { egui::Layout::top_down(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Center) }; + let (rect, resp) = ui + .allocate_ui_with_layout(vec2(width, height), layout, |ui| { + ui.set_min_size(vec2(width, height)); + let rect = ui.max_rect(); + ui.painter().rect(rect, 5.0, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside); + let margin = egui::Margin { left: 7, right: 7, top: if rows > 1 { 4 } else { 0 }, bottom: 0 }; + let resp = egui::Frame::NONE + .inner_margin(margin) + .show(ui, |ui| { + crate::theme::strong_selected_text(ui); + let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) }; + ui.add( + edit.id(id) + .frame(egui::Frame::NONE) + .margin(egui::Margin::ZERO) + .desired_width(width - 14.0) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + (rect, resp) + }) + .inner; + let resp = name_field(label_field(resp, label), name); + let focused = ui.memory(|m| m.has_focus(id)); + ui.data_mut(|d| { + d.insert_temp(id, buf.clone()); + d.insert_temp(focus_id, focused); + }); + let commit = resp.lost_focus() || kept; + (buf, commit, resp, rect) +} + +/// A recessed text field showing `value` (blank when `None`: the selection's values differ), +/// `rows` lines tall (1: single line, which Enter and Shift+Enter commit). Returns the new text, +/// trimmed, when a change is committed (Enter, Shift+Enter or focus loss). +pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option<&str>, width: f32, rows: usize) -> Option { + text_field_named(ui, id, None, value, width, rows) +} + +/// [`text_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option<&str>, width: f32, rows: usize) -> Option { + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); + let shown = value.unwrap_or_default(); + let (buf, commit, _, _) = recessed_text(ui, id, shown, width, rows, label, name); + (commit && buf.trim() != shown).then(|| buf.trim().to_string()) +} + +/// A one-line [`text_field`] that keeps the text exactly as typed, spaces included (a tab +/// leader such as ". "). Returns it when a change is committed (Enter, Shift+Enter or focus loss). +pub fn exact_text_field(ui: &mut Ui, id: impl std::hash::Hash, value: &str, width: f32) -> Option { + exact_text_field_named(ui, id, None, value, width) +} + +/// [`exact_text_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn exact_text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, width: f32) -> Option { + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); + let (buf, commit, _, _) = recessed_text(ui, id, value, width, 1, label, name); + (commit && buf != value).then_some(buf) +} + +/// A plain number field (percent, degrees, counts) with optional suffix. +pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { + plain_field_named(ui, id, None, value, suffix, decimals, width) +} + +/// [`plain_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn plain_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + value: f64, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + mixed_field_named(ui, id, name, Some(value), suffix, decimals, width) +} + +/// A recessed search field with an italic `hint` (Layers' Search All, Swatches' Find), keeping its +/// text in egui memory under `id`. Returns the text. +pub fn search_field(ui: &mut Ui, id: egui::Id, hint: &str) -> String { + let t = Tokens::get(ui.ctx()); + let mut query: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(egui::CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut query) + .id(id.with("edit")) + .frame(egui::Frame::NONE) + .hint_text(egui::RichText::new(tl!(hint)).italics()) + .desired_width(ui.available_width()), + ); + }); + ui.data_mut(|d| d.insert_temp(id, query.clone())); + query +} + +/// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…). +/// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked. +pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash, value: &str, presets: &[&str], width: f32) -> Option { + text_presets_named(ui, id, None, value, presets, width) +} + +/// [`text_presets`] for a row whose heading names it instead of an adjacent label widget. +pub fn text_presets_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, presets: &[&str], width: f32) -> Option { + let mut out = None; + ui.scope(|ui| { + // The field and the chevron share a border. + ui.spacing_mut().item_spacing.x = -1.0; + out = text_field_named(ui, &id, name, Some(value), width - 19.0, 1); + let resp = chevron_cell(ui, 26.0); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(width - 10.0); + for p in presets { + if ui.selectable_label(*p == value, *p).clicked() { + out = Some((*p).to_string()); + } + } + }); + }); + out +} diff --git a/crates/ui-egui/src/widgets/toggles.rs b/crates/ui-egui/src/widgets/toggles.rs new file mode 100644 index 000000000..26ee2e125 --- /dev/null +++ b/crates/ui-egui/src/widgets/toggles.rs @@ -0,0 +1,105 @@ +use super::*; + +/// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated), +/// draws the text and returns the box, the response and the box's border colour. +fn choice_row(ui: &mut Ui, text: &str, enabled: bool) -> (Rect, Response, Color32) { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled }); + let (rect, resp) = + ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() }); + let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0)); + ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text); + let border = if !enabled { + t.divider + } else if resp.hovered() { + t.text + } else { + t.button_border + }; + (bx, resp, border) +} + +/// Panel-style checkbox with a disabled state. Returns true when toggled. +pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { + check3(ui, label, Some(value), enabled) +} + +/// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled. +pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool { + let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled); + resp.on_hover_text(tip); + clicked +} + +/// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked. +pub fn check3(ui: &mut Ui, label: &str, value: Option, enabled: bool) -> bool { + check_row(ui, tl!(label), value, enabled).0 +} + +/// [`check`] for a name of the document's (an artboard's, an asset's), shown as it is, never +/// translated. Returns true when toggled. +pub fn check_name(ui: &mut Ui, name: &str, value: bool) -> bool { + check_row(ui, name, Some(value), true).0 +} + +/// A [`check`] box without text, for a row whose label is drawn beside it in another column: +/// screen readers call it `name` (already translated). Returns true when toggled. +pub fn check_box(ui: &mut Ui, name: &str, value: bool, enabled: bool) -> bool { + check_row_named(ui, "", name, Some(value), enabled).0 +} + +/// [`check3`] with its `text` already translated, also returning the row's response (what a +/// tooltip goes on: a widget around it never counts as hovered under the box). +fn check_row(ui: &mut Ui, text: &str, value: Option, enabled: bool) -> (bool, Response) { + check_row_named(ui, text, text, value, enabled) +} + +/// [`check_row`] that screen readers call `name` instead of its `text`. +fn check_row_named(ui: &mut Ui, text: &str, name: &str, value: Option, enabled: bool) -> (bool, Response) { + let t = Tokens::get(ui.ctx()); + let (bx, resp, border) = choice_row(ui, text, enabled); + let checked = value == Some(true); + ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input }); + ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside); + match value { + Some(true) => { + let c = if enabled { Color32::WHITE } else { t.text_disabled }; + ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); + ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); + } + None => { + let c = if enabled { t.text } else { t.text_disabled }; + ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, c)); + } + Some(false) => {} + } + resp.widget_info(|| match value { + Some(on) => egui::WidgetInfo::selected(egui::Role::CheckBox, enabled && ui.is_enabled(), on, name), + None => egui::WidgetInfo::labeled(egui::Role::CheckBox, enabled && ui.is_enabled(), name), + }); + (enabled && resp.clicked(), resp) +} + +/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked. +pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool { + radio_row(ui, tl!(label), tl!(label), selected, enabled) +} + +/// A [`radio`] button without text, for one followed by a field and the text that names it: +/// screen readers call it `name` (already translated). Returns true when clicked. +pub fn radio_button(ui: &mut Ui, name: &str, selected: bool, enabled: bool) -> bool { + radio_row(ui, "", name, selected, enabled) +} + +/// [`radio`] with its `text` already translated, which screen readers call `name`. +fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (bx, resp, border) = choice_row(ui, text, enabled); + let c = bx.center(); + ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border)); + if selected { + ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled }); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, enabled && ui.is_enabled(), selected, name)); + enabled && resp.clicked() +} From c50026fc86ba377807f044e166fb838fd750ff44 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:51 +0000 Subject: [PATCH 146/199] refactor(ui-egui): move lib.rs items to {app,app/clipboard,app/commands,app/control,app/files,app/fonts,app/frame,app/ime,app/input,app/links,app/prefs,app/screenshot,app/view,caches,clock,services,state}.rs --- crates/ui-egui/src/app.rs | 507 +++++++ crates/ui-egui/src/app/clipboard.rs | 12 + crates/ui-egui/src/app/commands.rs | 178 +++ crates/ui-egui/src/app/control.rs | 102 ++ crates/ui-egui/src/app/files.rs | 26 + crates/ui-egui/src/app/fonts.rs | 59 + crates/ui-egui/src/app/frame.rs | 73 + crates/ui-egui/src/app/ime.rs | 24 + crates/ui-egui/src/app/input.rs | 227 +++ crates/ui-egui/src/app/links.rs | 99 ++ crates/ui-egui/src/app/prefs.rs | 9 + crates/ui-egui/src/app/screenshot.rs | 175 +++ crates/ui-egui/src/app/view.rs | 160 +++ crates/ui-egui/src/caches.rs | 116 ++ crates/ui-egui/src/clock.rs | 25 + crates/ui-egui/src/lib.rs | 1981 +------------------------- crates/ui-egui/src/services.rs | 160 +++ crates/ui-egui/src/state.rs | 104 ++ 18 files changed, 2070 insertions(+), 1967 deletions(-) create mode 100644 crates/ui-egui/src/app.rs create mode 100644 crates/ui-egui/src/app/clipboard.rs create mode 100644 crates/ui-egui/src/app/commands.rs create mode 100644 crates/ui-egui/src/app/control.rs create mode 100644 crates/ui-egui/src/app/files.rs create mode 100644 crates/ui-egui/src/app/fonts.rs create mode 100644 crates/ui-egui/src/app/frame.rs create mode 100644 crates/ui-egui/src/app/ime.rs create mode 100644 crates/ui-egui/src/app/input.rs create mode 100644 crates/ui-egui/src/app/links.rs create mode 100644 crates/ui-egui/src/app/prefs.rs create mode 100644 crates/ui-egui/src/app/screenshot.rs create mode 100644 crates/ui-egui/src/app/view.rs create mode 100644 crates/ui-egui/src/caches.rs create mode 100644 crates/ui-egui/src/clock.rs create mode 100644 crates/ui-egui/src/services.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs new file mode 100644 index 000000000..86b75c03d --- /dev/null +++ b/crates/ui-egui/src/app.rs @@ -0,0 +1,507 @@ +use super::caches::{CanvasCache, Perf}; +use super::clock::now_ms; +use super::clock::now_secs; +use super::services::{Services, browsable_url}; +#[cfg(not(target_arch = "wasm32"))] +use super::state::FONT_INSTALL_POLL; +use super::state::{ + CaptureEpoch, GeometryStamp, InputGeneration, MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, + QueuedScreenshot, SCREENSHOT_SETTLE_FRAMES, SCREENSHOT_TIMEOUT_ERROR, SCREENSHOT_TIMEOUT_MS, SYSTEM_CLIPBOARD_POLL, take_frame_once, +}; +use super::*; + +mod clipboard; +mod commands; +mod control; +mod files; +mod fonts; +mod frame; +mod ime; +mod input; +mod links; +mod prefs; +mod screenshot; +mod view; + +pub use fonts::MAX_RECENT_FONTS; + +impl VectorcraftApp { + /// The app around `session`, reaching the platform through `services` (file pickers, the + /// clipboard, files and the like). It starts cataloguing the installed fonts at once, gives + /// Data Recovery the host's store and makes a view for each open document. + pub fn new(mut session: Session, services: Services) -> Self { + // The font menus and the first file opened need the installed fonts: catalog them now. + vectorcraft_text::FontDb::global().scan_in_background(); + if let Some(store) = &services.recovery_store { + session.recovery.set_store(store.clone()); + } + // The place cursor's clicks and drags place their files through the app + // ([`place::place_from_cursor`]). + session.place_cursor_requests = true; + let views = session.documents().iter().map(View::of).collect(); + Self { + session, + ui: UiState::default(), + views, + services, + canvas: CanvasCache { + renderer: vectorcraft_render::Renderer::new(), + texture: None, + key: None, + shown: None, + last_ms: 0.0, + moved: false, + from_worker: false, + waits_for_still: cfg!(target_arch = "wasm32"), + still: None, + worker: None, + worker_started: false, + slices: None, + print_tiling: None, + live_paint_gaps: None, + hidden_chars: Default::default(), + selection_box: None, + selection_bounds: None, + content_bounds: None, + cursors: Default::default(), + view_moved: None, + hover_cursor: None, + }, + perf: Perf::default(), + dock_rt: Default::default(), + titlebar_inset: 0.0, + last_effect: None, + last_warp: None, + run_count: 0, + clipboard_out: None, + clipboard_published: None, + clipboard_in: None, + clipboard_publishing: Default::default(), + pending_url: None, + control_rx: None, + pending_screenshots: vec![], + queued_screenshots: vec![], + screenshot_token: 0, + screenshot_reserved: 0, + synthetic: vec![], + styled: false, + fonts_ready: false, + context: 0, + ui_ctx: None, + ui_fonts: Default::default(), + updates_rt: Default::default(), + frame_rate: Default::default(), + canvas_rect: None, + hover_doc: None, + custom_titlebar: false, + last_window_title: String::new(), + graphics_adapter: None, + place: Default::default(), + system_paste: false, + clipboard_image_request: None, + system_paste_at: f64::NEG_INFINITY, + clipboard_probe: None, + picks: picks::Picks::default(), + screen_sampler: None, + font_check: None, + #[cfg(not(target_arch = "wasm32"))] + font_install_check_at: f64::NEG_INFINITY, + paste_chord: Default::default(), + // Held keys repeat their command (Cmd+= keeps zooming in). + dispatcher: craft_keymap::egui::Dispatcher::new().with_repeat(true), + background: Default::default(), + opening: Default::default(), + plugin_runs: Default::default(), + placing: Default::default(), + recovery: Default::default(), + host_modifiers: Default::default(), + forwarded: Vec::new(), + forwarded_modifiers: None, + forwarded_modifiers_held: false, + forwarded_frame: false, + synthetic_modifiers: false, + ime_marked: None, + ime_context: Default::default(), + ime_compositions: Default::default(), + ime_resets: Default::default(), + ime_caret_rect: None, + ime_discard: false, + prefs_changed: false, + touch_screen: false, + scrub_group: false, + scrub_scope: None, + automation_roots: None, + synthetic_frame: false, + pen_end: Default::default(), + host_iteration: 0, + #[cfg(test)] + now_override: None, + tick_iteration: None, + input_frames: vec![], + shortcuts_frames: vec![], + synthetic_injected: false, + injected_events: vec![], + injected_generation: InputGeneration::default(), + injected_settle_hook: false, + injected_settled: Default::default(), + geometry_epoch: 0, + geometry_stamp: None, + } + } + + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + self + } + + /// Confine what the control channel does to `roots` (#832): its requests, the input it + /// injects (in the frames that carry it) and the saves and exports they start read and write + /// only inside them ([`file_access`]). The host's file services check every path they're given + /// against the roots in force, and so does the engine; outside those scopes the person at the + /// keyboard works as usual. + pub fn with_automation_roots(mut self, roots: Option>) -> Self { + let Some(roots) = roots else { return self }; + self.automation_roots = Some(roots); + let s = &mut self.services; + if let Some(read) = s.read.take() { + s.read = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| read(p)))); + } + if let Some(mut write) = s.write.take() { + s.write = Some(Box::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b)))); + } + if let Some(write) = s.write_shared.take() { + s.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b)))); + } + if let Some(stream) = s.stream_shared.take() { + s.stream_shared = Some(Arc::new(move |p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + file_access::check_write(p).and_then(|()| stream(p, fill)) + })); + } + // Edit Original and Show in Folder hand a file to another app: only one automation may read. + for service in [&mut s.open_file, &mut s.reveal] { + if let Some(mut open) = service.take() { + *service = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| open(p)))); + } + } + self + } +} + +/// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are +/// called from the host's `eframe::App` impl. +impl VectorcraftApp { + /// The language the UI is drawn in: the Preferences dialog's choice while it is open (so a + /// change shows before OK), else the `interfaceLanguage` preference (`auto` = the system's). + pub fn ui_language(&self) -> i18n::Lang { + let editing = + self.ui.dialog.as_ref().filter(|d| d.kind == "preferences").and_then(|d| d.fields.get("interfaceLanguage")).and_then(Value::as_str); + i18n::Lang::from_pref(editing.unwrap_or(&self.session.prefs.interface_language)) + } + + /// The host starts one iteration: called from both hosts' `raw_input_hook`, before the host + /// decides whether a UI pass follows. It counts the iteration; it is not a frame or pass count + /// and does not promise that `ui` will run (a hidden window gets `tick` only). + pub fn begin_host_iteration(&mut self) { + self.host_iteration = self.host_iteration.wrapping_add(1); + // Frame keys identify input already taken in this host iteration. Each iteration starts + // fresh; this is what makes a viewport's discarded pass a duplicate, while two viewports' + // frame 0 both count. + self.input_frames.clear(); + self.shortcuts_frames.clear(); + } + + /// Background work, once per host iteration (full or logic-only): control requests, background + /// saves/exports/opens, plug-ins, recovery, font and clipboard polls, capture completion. It + /// reads only viewport state from `ctx.input` — never events or `i.time`: a logic-only pass + /// leaves the last shown frame's input in place. + /// + /// A bug that panics costs one iteration and shows an error, instead of closing the app with + /// unsaved work. + pub fn tick(&mut self, ctx: &egui::Context) { + let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); + if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.tick_frame(ctx))) { + self.picks.recover(); + self.status(format!("Internal error: {msg} (please report this bug)")); + } + } + + /// [`Self::tick`]'s work, once per host iteration: a second call in the same iteration (a + /// discarded rerun shares one hook call) does nothing. + fn tick_frame(&mut self, ctx: &egui::Context) { + if self.tick_iteration == Some(self.host_iteration) { + return; + } + self.tick_iteration = Some(self.host_iteration); + let dialog_was_open = self.ui.dialog.is_some(); + let lang = self.ui_language(); + i18n::set_current(lang); + // The engine gives new type the Japanese defaults while the UI is in Japanese. + if self.session.ui_language.as_deref() != Some(lang.code()) { + self.session.ui_language = Some(lang.code().to_string()); + } + self.install_injected_settle_hook(ctx); + self.advance_geometry_epoch(ctx); + // Monotonic host time, never egui's `InputState::time`: a logic-only (hidden) iteration + // replays the last shown pass's time, freezing recovery, clipboard polling and deadlines. + // Tests inject a clock to drive idle scheduling deterministically. + #[cfg(test)] + let now = self.now_override.unwrap_or_else(now_secs); + #[cfg(not(test))] + let now = now_secs(); + self.perf.fps = self.frame_rate.frame(now, self.perf.frame_ms / 1000.0); + self.sync_views(); + updates_ui::tick(self, ctx); + // Read the system clipboard only when that alone decides whether Paste is enabled. A + // background thread reads it where the host installs one (an unresponsive owner must never + // stall the frame loop); otherwise, or once that thread is gone, it is read here, at most a + // few times a second (opening it locks it against other apps on some systems). + let wanted = self.session.clipboard.is_empty() && self.session.active().is_some(); + if let Some(make) = self.services.clipboard_probe.take() { + self.clipboard_probe = clipboard_probe::Probe::start(make, ctx.clone()); + } + if let Some(pasteable) = self.clipboard_probe.as_mut().and_then(|p| p.pasteable(wanted)) { + self.system_paste = pasteable; + } else if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) { + self.clipboard_probe = None; + self.system_paste_at = now; + self.system_paste = wanted && self.system_clipboard_pasteable(); + } + #[cfg(not(target_arch = "wasm32"))] + if !(0.0..FONT_INSTALL_POLL).contains(&(now - self.font_install_check_at)) { + self.font_install_check_at = now; + // Read on the next frame the app draws anyway: an idle app isn't woken for it. + self.refresh_installed_fonts(false); + } + self.poll_font_check(ctx); + picks::poll(self, ctx); + // A Paste the host read before this iteration's formats were written must still wait for + // them, even if the worker below finishes them during this same tick. + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("clipboard.pending-at-tick"), self.clipboard_publishing.is_pending())); + background::poll(self); + screen_color::poll(self, ctx); + opening::poll(self); + plugin_runs::poll(self); + placing::poll(self); + #[cfg(not(target_arch = "wasm32"))] + self.tick_link_updates(ctx, now); + if !self.background.jobs.is_empty() + || !self.opening.pending.is_empty() + || !self.plugin_runs.running.is_empty() + || !self.placing.pending.is_empty() + { + // Keep the status bar's progress moving and pick the result up when it arrives. + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + // The window's close button (or the system quitting the app) asks about unsaved documents, + // once the saves running in the background are done. Not while a file dialog's sheet is on + // the window: what asked for it runs again when it answers. + if ctx.input(|i| i.viewport().close_requested()) { + // This native viewport is going away: release its IME context. No request is + // allocated here: the close prompt or file sheet publishes no custom descriptor, so a + // token would survive for a later stock field. A close the user then cancels starts a + // fresh generation on the next pass (never inherits a composition across the prompt). + crate::ime::close_viewport(self, ctx); + self.ime_context.end_viewport(ctx.viewport_id()); + background::wait_all(self); + if self.file_sheet_open() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.status(picks::busy()); + } else if unsaved::any_dirty(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if let Err(e) = unsaved::close_all(self, "quit") { + self.status(e); + } + } else { + // Quitting with nothing unsaved: no copies to leave behind. + vectorcraft_engine::cmd::recovery::forget_all(&mut self.session); + } + } + if let Some(wait) = recovery::frame(self, now) { + // The timer and heartbeat run in an idle window too. + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + crate::keymap::note_os(ctx); + shortcut_editor::sync(&self.ui); + prefs_dialog::apply_runtime(self, ctx); + // The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the + // system title bar this is where the document name lives, as the in-app mark is hidden. + crate::chrome::sync_window_title(self, ctx); + self.drain_control(ctx); + // An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It + // goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears + // the request) to read the formats and then pastes them. A text paste in the same UI pass + // never sets the request, so the two cannot both paste. + if let Some(held) = self.clipboard_image_request.take() { + crate::menus::invoke(self, crate::shortcuts::paste_command(held), json!({})); + } + self.collect_screenshots(ctx); + if let Some(u) = self.pending_url.take() { + ctx.open_url(egui::OpenUrl::new_tab(u)); + } + self.flush_clipboard_out(ctx); + self.drain_inbox(); + if self.viewport_hidden(ctx) && (self.needs_hidden_paint() || (!dialog_was_open && self.ui.dialog.is_some())) { + // A plain repaint runs only logic in a hidden window: the ui that applies injected + // input, shows a queued dialog or issues a capture needs this command. egui-winit + // handles it; the 0.37 web backend does not (a pending fork fix), so a hidden browser + // tab cannot be claimed as qualified. + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + if !self.synthetic.is_empty() || self.synthetic_injected || !self.pending_screenshots.is_empty() { + ctx.request_repaint(); + } + } + + /// The 0.36-style entry point, kept for the crate's tests, which call one `logic` inside a + /// `run_ui` pass and draw a panel afterwards (a UI phase). Production hosts call + /// `raw_input_hook`, then `tick` in `App::logic` and `ui` in `App::ui`; `tick` alone never + /// takes input, so a hidden logic-only iteration cannot replay the last shown frame's events. + /// + /// Outside tests this is just [`Self::tick`] with an iteration boundary. + pub fn logic(&mut self, ctx: &egui::Context) { + self.begin_host_iteration(); + self.tick(ctx); + #[cfg(test)] + { + let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); + let _ = file_access::confine(confined.as_ref(), || { + vectorcraft_engine::guard::catch_panic(|| { + self.ui_setup(ctx); + self.handle_input_once(ctx); + }) + }); + } + self.finish_ime_logic(ctx); + } + + /// First UI-pass setup, in a real `run_ui` phase: adopt the context, install the interface + /// fonts and theme, and the image loaders. 0.37's `set_fonts` applies at once when no text has + /// been laid out, so the same pass can draw; a hidden logic-only iteration never installs them. + fn ui_setup(&mut self, ctx: &egui::Context) { + // Hosts install before their first pass. Installing here covers later passes only: egui + // has already snapshotted this pass's plugin list, so a direct harness that calls `logic` + // inside its first `run_ui` must call `install_ime_reset_hooks` before that `run_ui` + // (as `tests_lifecycle::ime_frame` does). This is not a first-pass bootstrap fallback. + crate::ime::install_hooks(ctx); + self.adopt_context(ctx); + if !self.styled { + theme::apply(ctx, self.ui.brightness); + egui_extras::install_image_loaders(ctx); + self.styled = true; + } + if !self.fonts_ready { + // `set_fonts` applies at once only while no text was laid out in this pass; when it + // defers, the next pass applies it and this leaves input and drawing to that pass. + let fonts = theme::interface_fonts(); + ctx.set_fonts(fonts.clone()); + self.fonts_ready = ctx.fonts(|applied| applied.definitions() == &fonts); + } + } + + /// The canvas panel's body: the document tabs, the canvas and the isolation bar over it. + pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) { + let t = theme::Tokens::get(ui.ctx()); + ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard); + if self.ui.screen_mode < 3 { + chrome::doc_tabs(self, ui); + } + canvas::show(self, ui); + // Over the canvas inside the rulers, so the rulers and scroll bars stay uncovered. + if self.ui.screen_mode < 3 + && !menus::home_showing(self) + && let Some(area) = self.canvas_rect + { + chrome::isolation_bar(self, ui, area); + } + } + + /// Lay out the whole window: the first UI pass of a host iteration sets the UI up (fonts, + /// theme, image loaders) and takes the input; later passes (a `request_discard` rerun) draw + /// only. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + crate::ime::begin_ui(self, &ctx); + let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); + if let Err(msg) = file_access::confine(confined.as_ref(), || { + vectorcraft_engine::guard::catch_panic(|| { + self.ui_setup(&ctx); + self.handle_input_once(&ctx); + self.ui_frame(ui); + }) + }) { + self.picks.recover(); + self.status(format!("Internal error: {msg} (please report this bug)")); + } + crate::ime::finish_ui(self, &ctx); + } + + /// [`Self::ui`]'s work, inside its guard: the bars, panels and canvas, then the dialogs and + /// progress windows over them. + fn ui_frame(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if !self.fonts_ready { + // Do not draw (or run the app's input handling) before the interface fonts are the + // context's: a deferred `set_fonts` applies at the start of the next pass. + ctx.request_repaint(); + return; + } + crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); + let t0 = now_ms(); + widgets::set_dialog_open(&ctx, self.ui.dialog.is_some()); + // The canvas records this pass's IME caret geometry; a pass that draws no canvas has none. + self.ime_caret_rect = None; + scrub::begin_frame(self, &ctx); + font_menu::end_stale_preview(self, &ctx); + tools_float::track(self, &ctx); + // A selection move released over a panel ends before any panel sees the release (#2826). + canvas::release_over_panel(self, &ctx); + let t = theme::Tokens::get(&ctx); + if self.ui.screen_mode < 2 { + chrome::app_bar(self, ui); + if self.ui.control_bar { + chrome::control_bar(self, ui); + } + } + if self.ui.status_bar && self.ui.screen_mode < 3 { + chrome::status_bar(self, ui); + chrome::hint_bar(self, ui); + } else { + // Keeps the previous frame's value valid for menus drawn above, before the bars. + widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); + } + if self.ui.toolbar && self.ui.screen_mode < 3 { + toolbar::show(self, ui); + } + panels::library_panel::tidy(self); + // The canvas and the panels: one docking layout. Tab hides the panels but not the canvas. + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui)); + dialogs::show(self, &ctx); + updates_ui::show(self, &ctx); + export_progress::show(self, &ctx); + plugin_runs::show(self, &ctx); + palette::show(self, &ctx); + if self.custom_titlebar { + titlebar::resize_zones(ui); + } + self.ui_fonts.frame(&ctx); + scrub::end_frame(self, &ctx); + native_menu::sync(self, &ctx); + // Observe once, after all of this pass's input, dialogs, floating panels, palette and + // commands: the latest completed pass is authoritative for owner, text and cause. + crate::ime::end_of_pass(self, &ctx); + self.drop_formats_after_egui_copy(&ctx); + // A command run by a menu, panel or dialog in this pass may have queued its text copy + // after the UI phase's input flush. Publish it now for the same reason as there. + self.flush_clipboard_out(&ctx); + if opening::long_operation_busy(self) { + ctx.set_cursor_icon(egui::CursorIcon::Progress); + } + // A pass egui will discard and rerun is not a presented frame: it must not become the + // frame-time sample, and it must not issue a capture of what it drew. + if !ctx.will_discard() { + self.perf.frame_ms = now_ms() - t0; + self.issue_screenshots(&ctx); + } + let _ = json!(null); + } +} diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs new file mode 100644 index 000000000..b6260b661 --- /dev/null +++ b/crates/ui-egui/src/app/clipboard.rs @@ -0,0 +1,12 @@ +use super::*; + +impl VectorcraftApp { + /// Hand a queued text copy to egui. Called after `tick`, after the UI phase's input and at the + /// end of the pass; the later calls are what an input- or menu-driven fallback Copy needs, and + /// `take` keeps it single-shot. + pub(super) fn flush_clipboard_out(&mut self, ctx: &egui::Context) { + if let Some(t) = self.clipboard_out.take() { + ctx.copy_text(t); + } + } +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs new file mode 100644 index 000000000..b31f42d85 --- /dev/null +++ b/crates/ui-egui/src/app/commands.rs @@ -0,0 +1,178 @@ +use super::fonts::MAX_RECENT_FONTS; +use super::*; + +impl VectorcraftApp { + /// Run a UI or engine command by id. The single entry point for every frontend path. + pub fn run(&mut self, id: &str, params: Value) -> Result { + // A file dialog it shows off the UI thread runs it again with the path picked. + if self.picks.is_entry_free() { + let entry = picks::Entry::Command(id.to_string(), params.clone()); + return picks::as_entry(self, move || entry, |app| app.run_now(id, params)); + } + self.run_now(id, params) + } + + /// [`Self::run`] it, inside what asks for file dialogs. + fn run_now(&mut self, id: &str, params: Value) -> Result { + crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); + self.run_count = self.run_count.wrapping_add(1); + // In debug builds the app's own calls are held to the params descriptions too (#1442): a + // panel passing a key its command doesn't read shows up in the tests. + if cfg!(debug_assertions) { + menus::check_params(&self.session, id, ¶ms)?; + } + let artboards = self.artboard_tool_state(); + if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + self.follow_artboard_tool(artboards); + return r; + } + let mut params = params; + // Paste in place, in front and in back (#693), Select › All on Active Artboard (#1006), and Align, + // Distribute Objects and Distribute Spacing with Align to Artboard act on the active artboard, the + // view's, which the app passes as `artboard`. A batch passes it on to those steps. + if (vectorcraft_engine::ON_ACTIVE_ARTBOARD.contains(&id) || id == "command.batch") + && params.get("artboard").is_none() + && self.view().is_some() + && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) + && let Some(p) = params.as_object_mut() + { + p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n))); + } + // Object › Artboards › Fit to Artwork Bounds resizes the selected artboards (the Artboard + // tool's, else the active one), which the app passes as its `indices`, and Fit to Selected + // Art resizes the active artboard, which the app passes as its `index`. + if vectorcraft_engine::FIT_ACTIVE_ARTBOARD.contains(&id) + && params.get("index").is_none() + && params.get("indices").is_none() + && self.view().is_some() + && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) + && let Some(p) = params.as_object_mut() + { + if id == "artboard.fitToArt" { + p.insert("indices".into(), serde_json::json!(panels::artboards::picked(self, n))); + } else { + p.insert("index".into(), serde_json::json!(panels::artboards::selected(self, n))); + } + } + if id.starts_with("edit.paste") { + if let Err(e) = self.adopt_system_clipboard() { + self.ui.status = e.clone(); + return Err(e); + } + // Paste (also without formatting) goes to the centre of the view. + if matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") + && ["center", "dx", "dy"].iter().all(|k| params.get(k).is_none()) + && let Some(c) = self.view().filter(|v| v.fitted).map(|v| v.center) + && let Some(p) = params.as_object_mut() + { + p.insert("center".into(), serde_json::json!([c.x, c.y])); + } + if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) { + return r; + } + } + // Native files carry the view they reopen at. + if fileio::SaveMode::of(id).is_some() { + io::remember_view(self); + } + // A command that changes the edited text is an application edit (undo, a panel or menu + // edit, a control request). Capture the bounded committed context around it so `Other` + // dominates the frame even if an IME receipt follows. Selection/view commands that leave + // the committed text and caret unchanged record nothing. + let ime_view = self.view_info(); + let ime_target = crate::ime::target(&mut self.session, ime_view); + let ime_before = crate::ime::stamp(&mut self.session, ime_view); + let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + if r.is_ok() + && let Some(ctx) = self.ui_ctx.clone() + { + crate::ime::note_application_edit(self, &ctx, ime_view, ime_target, ime_before); + } + if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { + self.publish_clipboard(); + } + self.sync_views(); + // File › Revert replaces the document in its tab, keeps the tab's view and starts the tool afresh. + self.follow_artboard_tool(artboards); + match &r { + Err(e) => self.ui.status = e.clone(), + Ok(v) => { + // Image Trace's notes (a large image traces more slowly, or was scaled down). + if id.starts_with("imageTrace.make") + && let Some(w) = v["warnings"].as_array().and_then(|w| w.first()).and_then(Value::as_str) + { + self.ui.status = w.to_string(); + } + if id == "file.new" { + self.ui.status.clear(); + // New Document's Pixel preview mode (Overprint Preview is the engine's). + if v["previewMode"] == "pixel" { + self.ui.view.pixel_preview = true; + } + } + // Choosing a data set, or stepping to one, clears the status bar. The reply's `warning` + // below then shows when no object is bound to a variable the data set has a value for. + if matches!(id, "dataset.select" | "dataset.next" | "dataset.prev") { + self.ui.status.clear(); + } + if matches!(id, "text.setStyle" | "text.setRangeStyle") + && let Some(font) = params.get("font").and_then(Value::as_str) + { + let r = &mut self.ui.recent_fonts; + r.retain(|f| !f.eq_ignore_ascii_case(font)); + r.insert(0, font.to_string()); + r.truncate(MAX_RECENT_FONTS); + } + // A command's warning (Rasterize's when an image couldn't be decoded) shows in the + // status bar, as a tool preview's does. + if let Some(w) = v.get("warning").and_then(Value::as_str) { + self.ui.status = w.to_string(); + } + } + } + r + } + + /// Select a tool (also used by the toolbar and shortcuts). + pub fn select_tool(&mut self, id: &str) { + // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice. + self.pen_end.forget_tool(); + self.activate_tool(id, true); + } + + /// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]): + /// a perspective grid hidden meanwhile stays hidden. + pub(crate) fn return_to_tool(&mut self, id: &str) { + self.activate_tool(id, false); + } + + /// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]), + /// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]). + fn activate_tool(&mut self, id: &str, fresh: bool) { + let v = self.view_info(); + let before = self.session.tool_id(); + let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) }; + if let Err(e) = switched { + self.ui.status = e.to_string(); + } + // A newly chosen Artboard tool starts on the active artboard (the view's). + if before != "artboard" + && self.session.tool_id() == "artboard" + && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) + { + panels::artboards::select(self, panels::artboards::selected(self, n)); + } + if let Some(g) = vectorcraft_tools::catalog::group_of(id) + && let Some(slot) = self.ui.group_tool.get_mut(g) + { + *slot = id.to_string(); + } + toolbar::remember(self, id); + self.ui.flyout = None; + } + + /// Show a transient status message. + pub fn status(&mut self, s: impl Into) { + self.ui.status = s.into(); + } +} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs new file mode 100644 index 000000000..13eca92db --- /dev/null +++ b/crates/ui-egui/src/app/control.rs @@ -0,0 +1,102 @@ +use super::*; +use crate::control; + +impl VectorcraftApp { + pub(crate) fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + let mut handled = 0; + while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME { + let Ok(req) = rx.try_recv() else { break }; + // Its caller stopped waiting (the app was busy or its window not drawn for too long) + // and was told it won't run: an edit applied now would come from nowhere (#1725). + if !req.claim() { + continue; + } + handled += 1; + let reply = req.reply.clone(); + // Guarded per request: a panic must not drop the channel (taken out of `self` above). + let roots = self.automation_roots.clone(); + self.ui.answering_control = true; + let answer = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))); + self.ui.answering_control = false; + let outcome = match answer { + Ok(outcome) => outcome, + Err(msg) => { + self.picks.recover(); + control::err(format!("internal error: {msg} (please report this bug)")) + } + }; + match outcome { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { path, data } => { + // Admit with the shared ceilings (4 requests, 128 MiB each, 256 MiB together) + // before issue, reserving the checked bytes this viewport would need. + match self.screenshot_pixels(ctx) { + Ok((width, height, bytes)) if bytes > MAX_SCREENSHOT_BYTES => { + let _ = reply.send(json!({"ok": false, "error": format!( + "screenshot would use {bytes} bytes ({width}×{height}), over the {MAX_SCREENSHOT_BYTES}-byte limit" + )})); + } + Ok((width, height, bytes)) if data && path.is_none() && bytes > control::MAX_SCREENSHOT_PNG_REPLY_BYTES => { + let _ = reply.send(json!({"ok": false, "error": format!( + "a PNG reply of this {width}×{height} screenshot ({bytes} bytes of pixels) would exceed the {}-byte reply-encoding limit; write it to a path instead", + control::MAX_SCREENSHOT_PNG_REPLY_BYTES + )})); + } + Ok((_, _, _)) if self.pending_screenshots.len() >= MAX_PENDING_SCREENSHOTS => { + let _ = reply.send(json!({"ok": false, "error": format!( + "already {MAX_PENDING_SCREENSHOTS} screenshots are waiting; wait for one to finish" + )})); + } + Ok((width, height, bytes)) if self.screenshot_reserved.saturating_add(bytes) > MAX_SCREENSHOT_TOTAL_BYTES => { + let _ = reply.send(json!({ + "ok": false, + "error": format!( + "screenshots already reserved {} bytes; {bytes} more ({width}×{height}) would pass the {MAX_SCREENSHOT_TOTAL_BYTES}-byte limit", + self.screenshot_reserved + ) + })); + } + Ok((_, _, bytes)) => { + self.screenshot_token += 1; + let id = self.screenshot_token; + let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0; + let due_ms = now_ms() + settle; + self.screenshot_reserved = self.screenshot_reserved.saturating_add(bytes); + self.queued_screenshots.push(QueuedScreenshot { id, due_ms, frames: 0 }); + let document = self.document_identity(); + self.pending_screenshots.push(PendingScreenshot { + id, + path, + data, + reply, + deadline_ms: due_ms + SCREENSHOT_TIMEOUT_MS, + reserved_bytes: bytes, + document, + slot: None, + issued: None, + attempts: 0, + }); + } + Err(e) => { + let _ = reply.send(json!({"ok": false, "error": e})); + } + } + } + } + } + if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME { + // Leave later requests on the channel and schedule another frame to serve them. + ctx.request_repaint(); + } + self.control_rx = Some(rx); + } + + /// The active document's identity: its process-unique uid and revision. A capture records it + /// at admission and issuance and refuses to answer with a different document or undo state. + pub(super) fn document_identity(&self) -> Option<(u64, u64)> { + self.session.active().map(|document| (document.uid, document.revision)) + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs new file mode 100644 index 000000000..03bcc240b --- /dev/null +++ b/crates/ui-egui/src/app/files.rs @@ -0,0 +1,26 @@ +use super::*; + +impl VectorcraftApp { + /// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs + /// again when it answers. + pub fn file_dialog_open(&self) -> bool { + self.picks.is_waiting() + } + + /// A file dialog is open as a sheet on the window ([`Services::dialogs_are_sheets`]): nothing + /// closes or quits until it answers. + pub fn file_sheet_open(&self) -> bool { + self.services.dialogs_are_sheets && self.file_dialog_open() + } + + pub(super) fn drain_inbox(&mut self) { + let arrived: Vec<(String, Vec)> = + self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + for (name, bytes) in arrived { + if let Err(e) = io::open_bytes(self, &name, &bytes, None) { + io::report_open_error(self, &name, &e); + } + } + place::drain(self); + } +} diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs new file mode 100644 index 000000000..54fd9f86d --- /dev/null +++ b/crates/ui-egui/src/app/fonts.rs @@ -0,0 +1,59 @@ +use super::*; + +/// The most fonts Type › Recent Fonts lists (Preferences › Type › Number of Recent Fonts). +pub const MAX_RECENT_FONTS: usize = 15; + +impl VectorcraftApp { + /// Type › Recent Fonts: the fonts used last, newest first, as many as Preferences › Type › + /// Number of Recent Fonts says. + pub fn recent_fonts(&self) -> &[String] { + let n = usize::try_from(self.session.prefs.recent_fonts_count).unwrap_or(MAX_RECENT_FONTS).clamp(1, MAX_RECENT_FONTS); + self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts) + } +} + +impl VectorcraftApp { + /// Fonts installed or removed while the app runs are listed without Refresh Font List: a look + /// at the font folders and the platform's font list on a timer and when the window comes back, + /// a scan only when they changed. + /// The look runs on another thread (asking DirectWrite for the fonts font services loaded + /// meanwhile takes tens of milliseconds on Windows, #579), [`Self::poll_font_check`] scans; + /// `wake` wakes the app for the answer. + pub(super) fn refresh_installed_fonts(&mut self, wake: bool) { + if let Some((_, w)) = &mut self.font_check { + *w |= wake; + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let look = move || { + // The UI gone meanwhile has nothing to refresh. + let _ = tx.send(vectorcraft_text::FontDb::global().installed_fonts_changed()); + }; + let spawned = + if cfg!(target_arch = "wasm32") { None } else { std::thread::Builder::new().name("font-check".into()).spawn(look.clone()).ok() }; + if spawned.is_none() { + look(); + } + self.font_check = Some((rx, wake)); + } + + /// Rescan the fonts once [`Self::refresh_installed_fonts`]'s look says they changed. + pub(super) fn poll_font_check(&mut self, ctx: &egui::Context) { + let Some((check, wake)) = &self.font_check else { return }; + match check.try_recv() { + Ok(changed) => { + self.font_check = None; + if changed { + // A failure shows in the status bar, as the menu item's does. + let _ = self.run("text.rescanFonts", json!({})); + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + if *wake { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.font_check = None, + } + } +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs new file mode 100644 index 000000000..19374770f --- /dev/null +++ b/crates/ui-egui/src/app/frame.rs @@ -0,0 +1,73 @@ +use super::*; + +impl VectorcraftApp { + /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a + /// logic-only one), for panel state that asks "was this drawn in the last iteration?". + pub(crate) fn frame_iteration(&self) -> u64 { + self.host_iteration + } + + /// The canvas on screen in document coordinates (none before it is laid out). + pub fn screen_frame(&self) -> Option { + let (rect, view) = (self.canvas_rect?, self.view()?); + let xf = canvas::Xf::new(rect, view); + let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y)); + Some(vectorcraft_tools::ScreenFrame { + origin: xf.to_doc(rect.left_top()), + right: px(1.0, 0.0), + down: px(0.0, 1.0), + size: (f64::from(rect.width()), f64::from(rect.height())), + }) + } +} + +impl VectorcraftApp { + /// Is the window minimized or fully occluded (as far as the host reports)? + pub(crate) fn viewport_hidden(&self, ctx: &egui::Context) -> bool { + ctx.input(|i| { + let v = i.viewport(); + v.minimized == Some(true) || v.occluded == Some(true) + }) + } + + /// Is there work that only a UI pass can do, so a hidden window must be asked to paint? + pub(super) fn needs_hidden_paint(&self) -> bool { + !self.synthetic.is_empty() + || self.synthetic_injected + || !self.queued_screenshots.is_empty() + || self.pending_screenshots.iter().any(|p| p.slot.is_none()) + } + + /// Capture the viewport identity and geometry (content and inner rectangles, pixels per point and + /// zoom) whose change advances the screenshot epoch. + fn geometry_stamp(ctx: &egui::Context) -> GeometryStamp { + GeometryStamp { + viewport: ctx.viewport_id(), + content: ctx.content_rect(), + inner: ctx.input(|i| i.viewport().inner_rect), + pixels_per_point: ctx.pixels_per_point(), + zoom: ctx.zoom_factor(), + } + } + + /// Advance the capture geometry epoch when any part of the window geometry changed, even back + /// to a previous value: a capture issued before the change must not be saved as if it showed + /// the new one. + pub(super) fn advance_geometry_epoch(&mut self, ctx: &egui::Context) { + let stamp = Self::geometry_stamp(ctx); + if self.geometry_stamp != Some(stamp) { + self.geometry_stamp = Some(stamp); + self.geometry_epoch = self.geometry_epoch.wrapping_add(1); + } + } + + /// Identify the document, modal dialog and focused widget that the current accepted input batch + /// belongs to. + pub(super) fn input_generation(&self) -> InputGeneration { + InputGeneration { + document: self.session.active().map_or(0, |document| document.uid), + dialog: self.ui.dialog.as_ref().map(|dialog| egui::Id::unique(&dialog.kind)), + focus: self.ui_ctx.as_ref().and_then(|ctx| ctx.memory(|memory| memory.focused())), + } + } +} diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs new file mode 100644 index 000000000..05222c39f --- /dev/null +++ b/crates/ui-egui/src/app/ime.rs @@ -0,0 +1,24 @@ +use super::*; +use crate::ime; + +impl VectorcraftApp { + /// Did the Type tool end an IME composition on its own this frame? Interrupting the IME + /// through egui (`should_interrupt_composition`) doesn't reach the macOS input context, which + /// keeps the marked text and types it again into the next composition: the host discards it. + pub fn take_ime_discard(&mut self) -> bool { + std::mem::take(&mut self.ime_discard) + } +} + +impl VectorcraftApp { + /// Install once during host Context creation, before its first run_ui. + pub fn install_ime_reset_hooks(ctx: &egui::Context) { + ime::install_hooks(ctx); + } + + /// Host logic wrappers call this after their last application mutation. Full-pass logic + /// leaves final output observation to UI; standalone logic retires unavailable publication. + pub fn finish_ime_logic(&mut self, ctx: &egui::Context) { + ime::finish_logic(self, ctx); + } +} diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs new file mode 100644 index 000000000..8b9f96760 --- /dev/null +++ b/crates/ui-egui/src/app/input.rs @@ -0,0 +1,227 @@ +use super::*; + +impl VectorcraftApp { + /// The first UI pass of a host iteration takes the host's input, once per context, viewport and + /// frame: clipboard formats, native-menu clicks, shortcuts and dropped files. A later pass + /// (`request_discard` rerun) or an interleaved second viewport pass with the same key draws + /// again but does not replay them; a logic-only iteration never reaches this. + pub(super) fn handle_input_once(&mut self, ctx: &egui::Context) { + let frame = (graphics::context_tag(ctx), ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id())); + if !take_frame_once(&mut self.input_frames, frame) { + return; + } + self.write_formats_before_text_input(ctx); + native_menu::run(self, ctx); + if self.fonts_ready { + shortcuts::handle(self, ctx); + } + // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in + // this same pass: waiting for the next `tick` can leave the command unissued when an idle + // web host has no further frame to run it in. Native rich-format Copy does not use this + // path; it writes through `SystemClipboard` and leaves `clipboard_out` empty. + self.flush_clipboard_out(ctx); + // Native only: the web host reads dropped files asynchronously and feeds the inboxes. + #[cfg(not(target_arch = "wasm32"))] + self.take_dropped_files(ctx); + } + + /// Files dropped on the window: documents, libraries, presets, plug-ins and shortcut sets + /// opened, pictures and text placed on the canvas ([`Self::drop_target`]). + #[cfg(not(target_arch = "wasm32"))] + fn take_dropped_files(&mut self, ctx: &egui::Context) { + let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift)); + self.open_dropped_files(dropped, place::drag_pos(ctx), shift); + } + + /// Files dropped on the main window or on a floating panel's own OS window, opened or placed + /// by kind; `pos` is the drop point in the main window, where the backend reports one. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn open_dropped_files(&mut self, dropped: Vec, pos: Option, shift: bool) { + if dropped.is_empty() { + return; + } + let mut files = vec![]; + for f in dropped { + let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty()); + let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name); + // Native file drops usually carry a path. Use the same file reader as File → Open + // (and File → Place), even if the drop handle can't supply the bytes itself. + let bytes = if path.is_some() && self.services.read.is_some() { Ok(vec![]) } else { f.bytes() }; + match bytes { + Ok(bytes) => { + // A backend may give bytes without a path or extension. Sniff their format + // before deciding: an SVG opens as a document, a raster image is placed. + let target = if path.is_none() && fileio::detect(&name, &bytes).is_none_or(|f| !f.raster) { + place::DropTarget::Open + } else { + self.drop_target(&name, pos, shift) + }; + files.push((target, (name, path, bytes))); + } + Err(e) => self.status(format!("Couldn't read {name}: {e}")), + } + } + place::drop_files(self, files); + } + + /// Install the end-of-pass observer that tells the next host iteration a settled UI pass took + /// the injected batch (a font-gated or discarded pass does not). The shipped hosts have only + /// the root viewport; a multi-viewport host would need pending batches and settlement keyed + /// per viewport instead of this root-only acknowledgement. + pub(super) fn install_injected_settle_hook(&mut self, ctx: &egui::Context) { + if self.injected_settle_hook { + return; + } + let settled = self.injected_settled.clone(); + ctx.on_end_pass( + "vectorcraft injected input settlement", + Arc::new(move |ui| { + if ui.ctx().viewport_id() == egui::ViewportId::ROOT && !ui.ctx().will_discard() { + settled.store(true, Ordering::Relaxed); + } + }), + ); + self.injected_settle_hook = true; + } + + /// The settled pass took the injected batch: allow the next step at the next host iteration. + fn acknowledge_injected_batch(&mut self) { + self.synthetic_injected = false; + self.injected_events.clear(); + self.synthetic_frame = false; + } + + /// The document, dialog or focus changed before a UI pass took the batch: remove exactly the + /// events we appended (they still live in eframe's pending raw input) so a forced hidden paint + /// cannot replay stale clicks or text into the new state. + fn discard_stale_injected_batch(&mut self, raw: &mut egui::RawInput) { + if !self.synthetic_injected || self.input_generation() == self.injected_generation { + return; + } + let events = std::mem::take(&mut self.injected_events); + let mut removed = false; + if !events.is_empty() + && let Some(start) = raw.events.windows(events.len()).rposition(|window| window == events.as_slice()) + { + raw.events.drain(start..start + events.len()); + removed = true; + } + if !events.is_empty() && !removed { + // The host's raw input no longer contains the batch as we injected it (another + // handler rewrote or interleaved events). We cannot tell stale synthetic input from + // the person's, so fail closed: deliver nothing this pass rather than replay a stale + // click or text into the state that replaced it, and cancel the rest of that + // synthetic sequence instead of injecting its next step after this one. + raw.events.clear(); + self.synthetic.clear(); + } + // The discarded batch may have carried the modifiers it set; the host's modifiers are + // what the next real event has, so do not leave a synthetic latch behind. + self.synthetic_modifiers = false; + self.synthetic_injected = false; + self.synthetic_frame = false; + } + + /// Inject synthetic events (one press/release step, wheel turn or short key sequence per host + /// iteration), carrying control provenance until a settled UI pass has taken the batch. The + /// batch belongs to the one root viewport the shipped hosts draw (see + /// `install_injected_settle_hook`); a multi-viewport host would key it by viewport. A + /// hidden logic-only iteration may see this hook run without a UI pass: nothing more is + /// injected until the batch was actually applied, so a burst of hidden ticks cannot queue + /// several steps of a drag or replay them together later. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Both hosts call this hook once per host iteration, before deciding whether a UI pass + // follows; it is the iteration boundary, not a UI-phase marker. + self.begin_host_iteration(); + // The observer lives on the context so it also settles a harness that consumes raw input + // without calling `VectorcraftApp::ui`. + self.install_injected_settle_hook(ctx); + // The native menu's key equivalents become the input their keys make, ahead of what came + // after them (see `native_menu`). + let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default(); + // The menu bar does nothing while a file dialog is open (`native_menu`): a key that raced it + // going inert is dropped. + if !keys.is_empty() && !self.file_dialog_open() { + let events: Vec = keys.into_iter().flat_map(|k| native_menu::key_events(k, || self.system_clipboard_text())).collect(); + raw.events.splice(0..0, events); + } + for e in &raw.events { + match e { + egui::Event::ModifiersChanged(m) => self.host_modifiers = *m, + egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE, + egui::Event::WindowFocused(true) => self.refresh_installed_fonts(true), + _ => {} + } + } + // Keys a floating panel's own OS window left over run here, as if typed in this window, + // under the modifiers held there for this frame only. + if std::mem::take(&mut self.forwarded_modifiers_held) { + raw.events.insert(0, egui::Event::ModifiersChanged(self.host_modifiers)); + } + self.forwarded_frame = !self.forwarded.is_empty(); + if !self.forwarded.is_empty() { + if let Some(m) = self.forwarded_modifiers.take() { + raw.events.push(egui::Event::ModifiersChanged(m)); + self.forwarded_modifiers_held = true; + } + raw.events.append(&mut self.forwarded); + } + // The previous pass settled: the batch it took is done, so the next step may go. + if self.injected_settled.swap(false, Ordering::Relaxed) { + self.acknowledge_injected_batch(); + } + self.discard_stale_injected_batch(raw); + // While the previous batch is unapplied (it lives in eframe's pending raw input), inject + // nothing more: one step per settled UI pass, not per hidden logic tick. + if self.synthetic_injected { + return; + } + let Some(first) = self.synthetic.first() else { + if std::mem::take(&mut self.synthetic_modifiers) { + raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)); + } + return; + }; + // Pointer events go one per frame so egui sees presses, drags and releases as real input; + // keyboard sequences go up to the key release. + let n = match first { + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + let (now, later) = self.synthetic.split_at(n.min(self.synthetic.len())); + let mut batch: Vec = Vec::with_capacity(n + 2); + if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { + batch.push(egui::Event::PointerMoved(*p)); + } + // This frame's key or button, else the button a drag holds down (released later). + let held = now + .iter() + .find_map(|e| match e { + egui::Event::Key { modifiers, .. } | egui::Event::PointerButton { modifiers, .. } | egui::Event::MouseWheel { modifiers, .. } => { + Some(*modifiers) + } + _ => None, + }) + .or_else(|| match later.iter().find(|e| matches!(e, egui::Event::PointerButton { .. })) { + Some(egui::Event::PointerButton { pressed: false, modifiers, .. }) => Some(*modifiers), + _ => None, + }); + match held { + Some(m) => { + batch.push(egui::Event::ModifiersChanged(m)); + self.synthetic_modifiers = true; + } + None if std::mem::take(&mut self.synthetic_modifiers) => batch.push(egui::Event::ModifiersChanged(self.host_modifiers)), + None => {} + } + batch.extend(now.iter().cloned()); + // `batch` holds the n drained events plus the pointer/modifier companions that make the + // step read like real input. + self.synthetic.drain(..n); + self.injected_generation = self.input_generation(); + self.injected_events = batch.clone(); + self.synthetic_injected = true; + self.synthetic_frame = true; + raw.events.extend(batch); + } +} diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs new file mode 100644 index 000000000..e4c6fc925 --- /dev/null +++ b/crates/ui-egui/src/app/links.rs @@ -0,0 +1,99 @@ +use super::*; + +impl VectorcraftApp { + /// Open a link in the browser (Help → Discord, website, GitHub…). + pub fn open_url(&mut self, url: &str) { + match self.services.open_url.as_mut() { + Some(open) => open(url), + None => self.pending_url = Some(url.to_string()), + } + self.ui.status = format!("Opened {url}"); + } + + /// Open `url`, which came from a document (an object's URL attribute), in the browser: + /// [`browsable_url`]s only. + pub(crate) fn open_document_url(&mut self, url: &str) -> Result<(), String> { + browsable_url(url)?; + self.open_url(url); + Ok(()) + } + + /// Refuse UI command `id`, which hands something to another app, when automation sent it (a + /// control or MCP request, or a frame of synthetic input): the automation roots don't confine + /// what that app does. + pub(crate) fn refuse_automation(&self, id: &str) -> Result<(), String> { + if self.ui.answering_control || self.synthetic_frame { + return Err(format!("{id} opens another app: only the person at the keyboard can run it")); + } + Ok(()) + } + + /// Run a Help link command and open the URL it returns. + pub fn open_link(&mut self, id: &str) { + if let Ok(v) = self.run(id, serde_json::json!({})) + && let Some(u) = v["url"].as_str() + { + let u = u.to_string(); + self.open_url(&u); + } + } + + /// Linked files another app changed while their document is open: every two seconds, and as + /// soon as the window comes back to the front, stamp the active document's linked files on a + /// worker thread and act on the changed ones as Preferences › File Handling › Update Links + /// says ([`vectorcraft_engine::link_watch`]). No look starts while a dialog is open, so an + /// Ask When Modified question never goes unseen. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn tick_link_updates(&mut self, ctx: &egui::Context, now: f64) { + if let Some(r) = self.session.poll_link_scan() { + match r { + Ok(r) => { + let n = |k: &str| r[k].as_array().map_or(0, Vec::len); + if n("updated") > 0 { + self.status(format!("Updated {} linked file(s) changed on disk", n("updated"))); + } + if n("ask") > 0 { + dialogs::missing_links::ask_update_changed(self, r["ask"].as_array().cloned().unwrap_or_default()); + } + if n("modified") > 0 { + self.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", n("modified"))); + } + } + Err(e) => self.status(e.to_string()), + } + } + let (last_key, focus_key) = (egui::Id::unique("linkWatch.lastScan"), egui::Id::unique("linkWatch.focused")); + let focused = ctx.input(|i| i.focused); + let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); + ctx.data_mut(|d| d.insert_temp(focus_key, focused)); + if self.ui.dialog.is_some() || self.session.active().is_none() { + return; + } + let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); + // The active document as the last look saw it: its uid and revision. + let (seen_key, doc) = (egui::Id::unique("linkWatch.seen"), self.session.active().map(|d| (d.uid, d.revision))); + if now - last >= 2.0 || (focused && !was_focused) { + // Walking the document's links costs a pass over it: only when a look is due. + // `start_link_scan` starts none for a document without links, which then doesn't + // wake the app for looks of its own (any frame still looks once one is due). + ctx.data_mut(|d| d.insert_temp(last_key, now)); + self.session.start_link_scan(); + let links = self.session.link_scan.is_some(); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::unique("linkWatch.links"), links); + d.insert_temp(seen_key, doc); + }); + } + if self.session.link_scan.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } else if ctx.data(|d| d.get_temp::(egui::Id::unique("linkWatch.links"))).unwrap_or(false) { + ctx.request_repaint_after(std::time::Duration::from_millis(2000)); + } else if ctx.data(|d| d.get_temp::>(seen_key)).is_some_and(|seen| seen != doc) { + // The document changed (a file placed into one without links) or another came to the + // front since that look: wake for the next one even if the app sits idle until then, + // or a file placed and changed meanwhile is first seen after the change, which is then + // never acted on. + ctx.request_repaint_after(std::time::Duration::from_secs_f64((last + 2.0 - now).clamp(0.0, 2.0))); + } + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs new file mode 100644 index 000000000..39b202330 --- /dev/null +++ b/crates/ui-egui/src/app/prefs.rs @@ -0,0 +1,9 @@ +use super::*; + +impl VectorcraftApp { + /// Was Preferences confirmed since the last call? The host then writes the preferences file, + /// so what the user just set survives a session that doesn't end with a normal quit (#1747). + pub fn take_prefs_changed(&mut self) -> bool { + std::mem::take(&mut self.prefs_changed) + } +} diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs new file mode 100644 index 000000000..a680f11b2 --- /dev/null +++ b/crates/ui-egui/src/app/screenshot.rs @@ -0,0 +1,175 @@ +use super::*; +use crate::control; + +impl VectorcraftApp { + /// The image size (pixels) and checked byte count a capture of the current viewport would + /// have: the window's content rect times the current DPI/zoom. The viewport's own `inner_rect` + /// is the fresh source; `content_rect` is only a fallback for hosts that do not report one + /// (Wayland, headless), and a logic-only pass leaves it from the last shown frame. Issuance in + /// a real UI pass recomputes this and re-checks the budget, so a stale admission size cannot + /// let an oversized image through. + pub(super) fn screenshot_pixels(&self, ctx: &egui::Context) -> Result<(usize, usize, usize), String> { + let rect = ctx.input(|i| i.viewport().inner_rect).unwrap_or_else(|| ctx.content_rect()); + let scale = f64::from(ctx.pixels_per_point()); + let pixels = |points: f32, what: &str| -> Result { + let value = f64::from(points) * scale; + if !value.is_finite() || !(1.0..=100_000.0).contains(&value) { + return Err(format!("the window's {what} ({points} points × {scale} DPI) cannot size a screenshot")); + } + Ok(value.round() as usize) + }; + let width = pixels(rect.width(), "width")?; + let height = pixels(rect.height(), "height")?; + let bytes = width + .checked_mul(height) + .and_then(|n| n.checked_mul(4)) + .ok_or_else(|| format!("a {width}×{height} screenshot's byte count overflows"))?; + Ok((width, height, bytes)) + } + + /// Issue the first due capture of a settled UI pass, at most one in flight: the one-shot + /// callback makes correlation trivial, and serializing bounds readback memory. + pub(super) fn issue_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_screenshots.iter().any(|p| p.slot.is_some()) { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + return; + } + for q in &mut self.queued_screenshots { + q.frames = q.frames.saturating_add(1); + } + let now = now_ms(); + let Some(pos) = self.queued_screenshots.iter().position(|q| now >= q.due_ms && q.frames >= SCREENSHOT_SETTLE_FRAMES) else { + if !self.queued_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + return; + }; + let queued = self.queued_screenshots.remove(pos); + let Some(index) = self.pending_screenshots.iter().position(|p| p.id == queued.id) else { return }; + let document = self.document_identity(); + if self.pending_screenshots[index].document != document { + self.fail_screenshot(index, "the document changed while the screenshot was pending"); + return; + } + let (width, height, bytes) = match self.screenshot_pixels(ctx) { + Ok(v) => v, + Err(e) => { + self.fail_screenshot(index, &e); + return; + } + }; + let (data, path, previous) = + (self.pending_screenshots[index].data, self.pending_screenshots[index].path.is_some(), self.pending_screenshots[index].reserved_bytes); + if data && !path && bytes > control::MAX_SCREENSHOT_PNG_REPLY_BYTES { + let e = format!( + "a PNG reply of this {width}×{height} screenshot ({bytes} bytes of pixels) no longer fits the {}-byte reply-encoding limit; write it to a path instead", + control::MAX_SCREENSHOT_PNG_REPLY_BYTES + ); + self.fail_screenshot(index, &e); + return; + } + if bytes > MAX_SCREENSHOT_BYTES || self.screenshot_reserved.saturating_sub(previous).saturating_add(bytes) > MAX_SCREENSHOT_TOTAL_BYTES { + let e = format!("the window grew since the screenshot was admitted: {width}×{height} no longer fits the capture budget"); + self.fail_screenshot(index, &e); + return; + } + self.screenshot_reserved = self.screenshot_reserved.saturating_sub(previous).saturating_add(bytes); + let viewport = ctx.viewport_id(); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, viewport, MAX_SCREENSHOT_BYTES); + { + let pending = &mut self.pending_screenshots[index]; + pending.reserved_bytes = bytes; + pending.issued = Some(CaptureEpoch { geometry: self.geometry_epoch, viewport, document, width, height }); + pending.slot = Some(slot); + } + // A pending `Screenshot` asks for the paint it needs, even while hidden. + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + ctx.request_repaint(); + } + + /// Answer a pending request with an error and release its reservation. + fn fail_screenshot(&mut self, index: usize, error: &str) { + if index >= self.pending_screenshots.len() { + return; + } + let pending = self.pending_screenshots.remove(index); + self.queued_screenshots.retain(|q| q.id != pending.id); + self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); + if let Some(slot) = pending.slot { + slot.cancel(); + } + let _ = pending.reply.send(json!({"ok": false, "error": error})); + } + + /// Take completed captures on the UI thread only, verify they still match the document and + /// geometry they were issued for, and write them under the automation roots. A late completion + /// (after a timeout or close) has already been canceled with its slot, so it writes nothing. + /// + /// This runs in `tick`, as both hosts call logic and the UI on the same thread. `save_screenshot` + /// and `file_access::confine` here, and the `&mut self` write, must move with it if logic ever + /// runs off the UI thread. + pub(super) fn collect_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let mut completed: Vec<(usize, Result, craft_ui::capture::CaptureError>)> = vec![]; + for (index, pending) in self.pending_screenshots.iter().enumerate() { + if let Some(slot) = &pending.slot + && let Some(result) = slot.take() + { + completed.push((index, result)); + } + } + let now = now_ms(); + let document = self.document_identity(); + for (index, result) in completed.into_iter().rev() { + let Some((id, issued, deadline)) = self.pending_screenshots.get(index).map(|p| (p.id, p.issued, p.deadline_ms)) else { continue }; + match result { + // The frame came only after the request's deadline: it is late even though the + // callback had stored it before this pass took it. Do not encode or write it. + _ if now >= deadline => { + self.fail_screenshot(index, SCREENSHOT_TIMEOUT_ERROR); + } + Ok(_) if issued.is_some_and(|e| e.document != document) => { + self.fail_screenshot(index, "the document changed while the screenshot was pending"); + } + Ok(image) => { + let size = (image.size[0], image.size[1]); + let stale = + issued.is_none_or(|e| e.geometry != self.geometry_epoch || e.viewport != ctx.viewport_id() || (e.width, e.height) != size); + if stale { + // Resize, DPI or zoom changed while the capture was in flight: a fresh slot + // and a fresh frame, not the old pixels. Attempts are bounded. + let retry = self.pending_screenshots[index].attempts == 0; + self.pending_screenshots[index].attempts = self.pending_screenshots[index].attempts.saturating_add(1); + self.pending_screenshots[index].slot = None; + self.pending_screenshots[index].issued = None; + if retry { + self.queued_screenshots.insert(0, QueuedScreenshot { id, due_ms: now_ms(), frames: 0 }); + } else { + self.fail_screenshot(index, "the window changed while the screenshot was in flight"); + } + continue; + } + let pending = self.pending_screenshots.remove(index); + self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); + let roots = self.automation_roots.clone(); + let _ = pending + .reply + .send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data))); + } + Err(e) => { + let error = format!("the screenshot could not be used: {e}"); + self.fail_screenshot(index, &error); + } + } + } + let timed_out: Vec = self.pending_screenshots.iter().enumerate().filter(|(_, p)| now >= p.deadline_ms).map(|(i, _)| i).collect(); + for index in timed_out.into_iter().rev() { + self.fail_screenshot(index, SCREENSHOT_TIMEOUT_ERROR); + } + if !self.pending_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } +} diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs new file mode 100644 index 000000000..2306d1f45 --- /dev/null +++ b/crates/ui-egui/src/app/view.rs @@ -0,0 +1,160 @@ +use super::*; + +impl VectorcraftApp { + /// Keep `views` aligned with the session's documents (a new one starts at its saved view). + pub fn sync_views(&mut self) { + let docs = self.session.documents(); + self.views.truncate(docs.len()); + self.views.extend(docs.iter().skip(self.views.len()).map(View::of)); + for (view, doc) in self.views.iter_mut().zip(docs) { + view.artboard = view.artboard.min(doc.doc.artboards.len().saturating_sub(1)); + } + // Undo can remove the Artboard tool's active or additionally selected boards. Repair only + // invalid indices: valid multiselection, drag state and tool options must survive history. + if self.session.tool_id() != "artboard" || self.session.tool_busy() { + return; + } + let Some(n) = self.session.active().map(|d| d.doc.artboards.len()).filter(|n| *n > 0) else { return }; + let Some(view) = self.session.active_index().and_then(|i| self.views.get_mut(i)) else { return }; + let options = self.session.tool_options(); + let Some(selected) = options["selected"].as_array() else { return }; + let mut valid: Vec = selected.iter().filter_map(Value::as_u64).filter_map(|i| usize::try_from(i).ok()).filter(|i| *i < n).collect(); + if valid.len() != selected.len() { + if valid.is_empty() { + valid.push(view.artboard); + } + if let Some(active) = valid.first() { + view.artboard = *active; + } + self.session.set_tool_option("selected", &serde_json::json!(valid)); + } + } + + /// The active document's uid, how many times the tool has started afresh + /// ([`vectorcraft_engine::Session::tool_resets`]) and the artboards the Artboard tool has + /// selected (null with another tool), which [`Self::follow_artboard_tool`] compares before and + /// after a command. + pub(super) fn artboard_tool_state(&self) -> (Option, u64, Value) { + let selected = if self.session.tool_id() == "artboard" { self.session.tool_options()["selected"].clone() } else { Value::Null }; + (self.session.active().map(|d| d.uid), self.session.tool_resets(), selected) + } + + /// After a command run with the Artboard tool, given [`Self::artboard_tool_state`] from before + /// it: when the command chose another document, or started the tool afresh in the same one + /// (File › Revert, a `command.batch` or `action.batch` that reverts it or switches to another + /// document and back), the tool takes the document's active artboard, as choosing its tab + /// does. When the command changed the artboards the tool has selected (Paste and Cut with the + /// tool, `tool.setOption`), also after starting it afresh (a batch step after the revert or the + /// switch), the tool's active artboard becomes the active one (the view's), as a click with the + /// tool makes it. + pub(super) fn follow_artboard_tool(&mut self, before: (Option, u64, Value)) { + self.sync_views(); + if self.session.tool_id() != "artboard" { + return; + } + let Some(n) = self.session.active().map(|d| d.doc.artboards.len()).filter(|n| *n > 0) else { return }; + let (doc, resets, selected) = self.artboard_tool_state(); + // The tool started afresh during the command, and no later step (a Paste or Cut) set its options. + let reset = resets != before.1; + let fresh = reset && !self.session.tool_set_since_reset(); + if fresh || (doc != before.0 && !reset) { + panels::artboards::select(self, panels::artboards::selected(self, n)); + } else if (reset || selected != before.2) + && let Some(i) = self.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()) + && i < n + && let Some(v) = self.view_mut() + { + v.artboard = i; + } + } + + pub fn view(&self) -> Option<&View> { + self.session.active_index().and_then(|i| self.views.get(i)) + } + + /// The zero point shown by rulers and position fields: the document's with global rulers, the + /// active artboard's with artboard rulers. Command coordinates stay absolute. + pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point { + let artboard = self.view().map_or(0, |v| v.artboard); + self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin_for(artboard)) + } + + pub fn view_mut(&mut self) -> Option<&mut View> { + self.sync_views(); + let i = self.session.active_index()?; + self.views.get_mut(i) + } + + pub fn view_info(&self) -> ViewInfo { + ViewInfo { + zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + outline: self.ui.view.outline, + smart_guides: self.ui.view.smart_guides, + guides: self.ui.view.guides, + snap_to_grid: self.ui.view.snap_to_grid, + snap_to_pixel: self.ui.view.snap_to_pixel, + // View › Hide Edges: the canvas draws neither the bounding box (with the type widget) + // nor the Live Corners widgets, and the tools do not answer the pointer on them. + show_bbox: self.ui.view.bounding_box && self.ui.view.edges, + snap_to_point: self.ui.view.snap_to_point, + snap_to_glyph: self.ui.view.snap_to_glyph, + corner_widgets: self.ui.view.corner_widgets && self.ui.view.edges, + screen: self.screen_frame(), + artboard: self.view().map_or(0, |v| v.artboard), + } + } +} + +impl VectorcraftApp { + /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]); with + /// anchors selected with Direct Selection, the box around them square to the page, which + /// `object.setBounds` then edits (#1824). The canvas and the transform fields read it every + /// frame: it is measured once per revision. + pub fn selection_box(&mut self) -> Option { + let st = self.session.active()?; + let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); + if let Some((k, b)) = self.canvas.selection_box + && k == key + { + return b; + } + let b = match vectorcraft_effects::preview::anchor_bounds(&st.selection, &st.doc, self.session.prefs.use_preview_bounds) { + Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)), + None => self.session.transform_box(&st.selection.objects), + }; + self.canvas.selection_box = Some((key, b)); + b + } + + /// The selection's visual bounds (strokes included, effects left out), square to the page: + /// measured once per revision, as the canvas reads it every frame (a traced photo selects a + /// group of hundreds of thousands of paths). + pub fn selection_bounds(&mut self) -> Option { + let st = self.session.active()?; + let key = (st.uid, st.revision); + if let Some((k, b)) = self.canvas.selection_bounds + && k == key + { + return b; + } + let b = st.doc.bounds_of(&st.selection.objects, true); + self.canvas.selection_bounds = Some((key, b)); + b + } + + /// The art and the artboards together, the span of the canvas's scroll bars: measured once + /// per revision, as the canvas reads it every frame. + pub fn content_bounds(&mut self) -> Option { + let st = self.session.active()?; + let key = (st.uid, st.revision); + if let Some((k, b)) = self.canvas.content_bounds + && k == key + { + return b; + } + let boards = st.doc.artboards.iter().map(|a| a.rect).reduce(|r, a| r.union(a)); + let b = vectorcraft_geom::union_opt(st.doc.art_bounds(), boards); + self.canvas.content_bounds = Some((key, b)); + b + } +} diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs new file mode 100644 index 000000000..d5b2e6e1d --- /dev/null +++ b/crates/ui-egui/src/caches.rs @@ -0,0 +1,116 @@ +use super::*; + +/// Cached canvas raster. +pub struct CanvasCache { + pub renderer: vectorcraft_render::Renderer, + pub texture: Option, + pub key: Option, + /// The document the frame shown was rendered from, and the options it was rendered with, with + /// [`Self::key`]: when only the document changes, the canvas renders again only where it + /// changed ([`vectorcraft_render::changed`]). + pub shown: Option<(std::sync::Arc, vectorcraft_render::RenderOptions)>, + pub last_ms: f64, + /// The frame shown drew raster effects from rasters made before their objects moved, or at a + /// drag's lower resolution ([`vectorcraft_render::FrameStats::moved`]): the next frame outside + /// an interaction runs them again, on the worker when there is one. + pub moved: bool, + /// The frame shown was rendered on the worker: an interaction's frames stay there + /// ([`canvas::show`]). + pub from_worker: bool, + /// Set in the web build, which has no render worker: while the view changes on a document whose + /// last render took more than 8 ms, the canvas draws the frame shown reprojected and renders + /// once the view has been still for [`canvas::STILL_SECS`]. + pub waits_for_still: bool, + /// The cache key the canvas last asked for and when it first asked for it (seconds, egui's + /// input time), kept while [`Self::waits_for_still`] is set. + pub still: Option<(CacheKey, f64)>, + pub worker: Option, + pub worker_started: bool, + /// The slices as laid out for (document uid, revision): the canvas draws them every frame. + pub slices: Option, + /// The print tiling's pages for (document uid, revision) (View → Show Print Tiling). + pub print_tiling: Option, + /// The gap lines View → Show Live Paint Gaps draws for (document uid, revision). + pub live_paint_gaps: Option, + /// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame + /// that does not draw them drops them ([`canvas::show`]). + pub hidden_chars: canvas::HiddenChars, + /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). + pub selection_box: Option<((u64, u64, bool), Option)>, + /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). + pub selection_bounds: Option<((u64, u64), Option)>, + /// [`VectorcraftApp::content_bounds`] for (document uid, revision). + pub content_bounds: Option<((u64, u64), Option)>, + /// The tools' cursors as OS cursor bitmaps. + pub cursors: cursors::Images, + /// The view the canvas last drew and when it last changed (egui's input time): while it moves, + /// the canvas doesn't hit-test the art under the pointer ([`canvas::hover_wait`], #2452). + pub view_moved: Option<(canvas::ViewKey, f64)>, + /// The cursor the active tool last gave over the canvas, with the tool and the modifier keys + /// it was for: kept while the view moves. + pub hover_cursor: Option<(&'static str, vectorcraft_tools::Mods, vectorcraft_tools::Cursor)>, +} + +/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision). +pub type SliceCache = ((u64, u64), std::sync::Arc>); + +/// [`CanvasCache::live_paint_gaps`]: the gap lines of the selected Live Paint groups of (document +/// uid, revision). +pub type LivePaintGapsCache = ((u64, u64), std::sync::Arc>); + +/// [`CanvasCache::print_tiling`]: the print tiling of (document uid, revision). +pub type PrintTilingCache = ((u64, u64), std::sync::Arc>); + +#[derive(Clone, Debug, PartialEq)] +pub struct CacheKey { + pub doc: usize, + pub revision: u64, + /// An interaction is in progress ([`vectorcraft_render::RenderOptions::interacting`]). + pub interacting: bool, + /// A tool's drag on the canvas is in progress ([`vectorcraft_render::RenderOptions::dragging`]). + pub dragging: bool, + pub zoom: f64, + pub cx: f64, + pub cy: f64, + pub w: u32, + pub h: u32, + pub outline: bool, + pub trim: bool, + /// View › Hide Template: template layers left out ([`vectorcraft_render::RenderOptions::hide_templates`]). + pub templates: bool, + /// View › Show/Hide Guides: whether guide paths are visible in the artwork raster. + pub guides: bool, + pub ppp: f32, + pub hidden: Vec, + pub rot: f64, + /// General › Anti-aliased Artwork. + pub anti_alias: bool, + /// Type › Highlight Alternate Glyphs (#394). + pub highlight_alternate_glyphs: bool, + /// Type › Highlight Substituted Fonts. + pub highlight_substituted_fonts: bool, + /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. + pub placed: u64, + /// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown + /// with hard edges. None: the art is rendered for the screen. + pub pixel: Option<[i64; 4]>, + /// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]). + pub smooth_images: bool, + /// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]). + pub isolated: Option, + /// Appearance of Black › On Screen › Display All Blacks as Rich Black + /// ([`vectorcraft_render::RenderOptions::rich_black`]). + pub rich_black: bool, + /// Colour and style of guide paths ([`vectorcraft_render::RenderOptions::guide`]). + pub guide: ([u8; 3], bool), + /// The Image Trace objects drawn with their source image + /// ([`vectorcraft_render::RenderOptions::trace_source`]). + pub trace_source: Option>>, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Perf { + pub frame_ms: f64, + pub render_ms: f64, + pub fps: f64, +} diff --git a/crates/ui-egui/src/clock.rs b/crates/ui-egui/src/clock.rs new file mode 100644 index 000000000..3d3a50438 --- /dev/null +++ b/crates/ui-egui/src/clock.rs @@ -0,0 +1,25 @@ +/// Seconds since the process started, from a monotonic clock (`web_time::Instant`, which maps to +/// `performance.now()` in the browser and `std::time::Instant` natively). Background work, poll +/// pacing and deadlines use this, not egui's `InputState::time`: a logic-only pass replays the +/// last shown frame's time, and a wall clock jumps with the system's. +pub fn now_secs() -> f64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::LazyLock; + use std::time::Instant; + static START: LazyLock = LazyLock::new(Instant::now); + START.elapsed().as_secs_f64() + } + #[cfg(target_arch = "wasm32")] + { + use std::sync::LazyLock; + use web_time::Instant; + static START: LazyLock = LazyLock::new(Instant::now); + START.elapsed().as_secs_f64() + } +} + +/// [`now_secs`] in milliseconds, for frame timing and capture deadlines. +pub fn now_ms() -> f64 { + now_secs() * 1000.0 +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f168c83e0..0262a21ec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -93,273 +93,21 @@ pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket}; pub use state::{UiState, View}; pub use sysclip::{ClipboardProbeFactory, SystemClipboard}; -/// A file type a dialog filters by: its label, which shows translated, and its extensions without -/// the dot. -pub type FileFilter = (String, Vec); +mod app; +mod caches; +mod clock; +mod services; -/// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the -/// file-type filters. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct FilePick { - pub name: String, - pub folder: Option, - /// The default type first; empty = every file. - pub filters: Vec, -} - -impl FilePick { - /// A save dialog suggesting `name`. - pub fn named(name: &str) -> Self { - Self { name: name.to_string(), ..Default::default() } - } - - /// The file type `label` with `extensions`. - pub fn filter(label: &str, extensions: &[&str]) -> FileFilter { - (label.to_owned(), extensions.iter().map(|e| (*e).to_owned()).collect()) - } - - /// The file types of `list`, as the engine lists them: `(label, extensions)`. - pub fn filters<'a>(list: impl IntoIterator) -> Vec { - list.into_iter().map(|(label, extensions)| Self::filter(label, extensions)).collect() - } -} - -pub type PickOpen = Box Option>; -pub type PickSave = Box Option>; -pub type ReadFn = Box Result, String>>; -pub type WriteFn = Box Result<(), String>>; -pub type ExistsFn = Box bool>; -pub type Inbox = Arc)>>>; -/// Starts a browser download of the bytes under a file name; an error when it couldn't be started -/// (the save or export then fails as a refused write does: #1693). -pub type DownloadFn = Box Result<(), String>>; - -/// Opens a URL in the system browser. -pub type OpenUrlFn = Box; - -/// Whether a URL from a document may open in the browser: an `http:`, `https:` or `mailto:` URL -/// without control characters. The system hands other schemes (`file:`, application schemes) to -/// other programs. -pub(crate) fn browsable_url(url: &str) -> Result<(), String> { - let scheme = url.split_once(':').map_or("", |(s, _)| s); - if ["http", "https", "mailto"].iter().any(|k| scheme.eq_ignore_ascii_case(k)) && !url.chars().any(char::is_control) { - Ok(()) - } else { - Err(format!("only http, https and mailto URLs open in the browser, not “{url}”")) - } -} - -/// Shows a file in the system file manager, or opens it in its app. -pub type RevealFn = Box Result<(), String>>; - -/// Platform services injected by the host app (desktop or web). -#[derive(Default)] -pub struct Services { - /// Show an open dialog; returns a path. - pub pick_open: Option, - /// Show a save dialog (suggested name, folder, file types); returns a path. - pub pick_save: Option, - pub read: Option, - pub write: Option, - /// Is there a file or folder at a path (desktop)? The app asks before replacing a file that a - /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). - /// Without it, no file counts as there. - pub exists: Option, - /// Files that arrived asynchronously (web open / drops): (name, bytes). - pub inbox: Option, - /// Web: trigger a browser download instead of writing a path. - pub download: Option, - /// Web: start an async open (bytes arrive via `inbox`). - pub open_async: Option>, - /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with - /// egui's Paste event (web, and keyboard paste everywhere). - pub clipboard_read: Option Option>>, - /// Open a URL in the system browser (desktop). Without it, egui opens it (a new tab on the web). - pub open_url: Option, - /// Show an open dialog for several files (File → Place…); returns their paths (none: cancelled). - pub pick_open_multi: Option Vec>>, - /// Web: start an async pick of files to place (they arrive via `place_inbox`). - pub place_async: Option>, - /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the - /// canvas). - pub place_inbox: Option, - /// The system clipboard with all its formats (desktop): Copy offers text, SVG, PDF and PNG, - /// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through - /// egui and `clipboard_read`). - pub system_clipboard: Option>, - /// A second system-clipboard handle, for checking whether Paste has something to take on a - /// background thread (Linux: an X11 clipboard owner that never answers would freeze the UI). - /// Taken on the first frame. Without it the check runs on the UI thread. - pub clipboard_probe: Option, - /// File → Show in Folder: select a file in the system file manager (desktop). - pub reveal: Option, - /// Write a file from any thread (desktop): lets Background Save and Export write off the UI - /// thread ([`background`]). Without it they run at once. - pub write_shared: Option, - /// Write a file from any thread with what a filler writes into it (desktop): native saves - /// stream into the file as they are encoded instead of being held whole in memory first. - /// Without it they are encoded, then written with `write` / `write_shared`. - pub stream_shared: Option, - /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show - /// Package). Without it (the web) those answer with an error. - pub open_file: Option, - /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). - pub pick_folder: Option Option>>, - /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, and - /// browsers with an EyeDropper): Sample Color from Screen ([`screen_color`]). Without it that - /// command answers with an error and the Color Picker's eyedropper button is gray. - pub screen_color: Option, - /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage; - /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]). - pub recovery_store: Option>, - /// File → Print: the system's printers and print queue (desktop), the browser's print dialog - /// (web). Without it Print saves the job as a PDF. - pub print: Option>, - /// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then. - pub native_menu: Option, - /// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window - /// and the compositor finds it not answering, #592) or as sheets on the window (macOS, where a - /// dialog in line aborts the app, #867): what asked runs again with the answer ([`picks`]). - /// Without it they are shown in line. - pub start_pick: Option, - /// [`Self::start_pick`]'s dialogs are sheets on the window (macOS): while one is open the app - /// neither quits nor closes documents, as under the modal dialog before. - pub dialogs_are_sheets: bool, - /// The window is on Wayland: the tool cursors are painted into it instead of handed to the - /// compositor as bitmaps ([`cursors::os_cursors`]). - pub wayland: bool, - /// On Wayland, no cursor theme can be loaded, so winit sets no pointer and the compositor's - /// default one would never come back once the canvas hid it: the canvas keeps the system - /// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450). - pub no_cursor_theme: bool, - /// The pen pressure and pen end the host reads from the system itself (desktop Linux under - /// X11 and XWayland, macOS and Windows). The canvas uses the pressure when a frame's events carry no touch force, - /// and the end to switch to the Eraser tool and back. Without it, pen pressure comes from - /// touch events only ([`stylus`]). - pub stylus: Option, - /// The host's rewrites of a window's raw input (pen barrel button, closing the macOS menu - /// bar's open menu) for floating panels in OS windows of their own: eframe runs the host's - /// `raw_input_hook` for the main window only. - pub native_input_hook: Option, -} - -/// Rewrites one window's raw input before egui reads it ([`Services::native_input_hook`]). -pub type NativeInputHook = std::sync::Arc; - -/// Cached canvas raster. -pub struct CanvasCache { - pub renderer: vectorcraft_render::Renderer, - pub texture: Option, - pub key: Option, - /// The document the frame shown was rendered from, and the options it was rendered with, with - /// [`Self::key`]: when only the document changes, the canvas renders again only where it - /// changed ([`vectorcraft_render::changed`]). - pub shown: Option<(std::sync::Arc, vectorcraft_render::RenderOptions)>, - pub last_ms: f64, - /// The frame shown drew raster effects from rasters made before their objects moved, or at a - /// drag's lower resolution ([`vectorcraft_render::FrameStats::moved`]): the next frame outside - /// an interaction runs them again, on the worker when there is one. - pub moved: bool, - /// The frame shown was rendered on the worker: an interaction's frames stay there - /// ([`canvas::show`]). - pub from_worker: bool, - /// Set in the web build, which has no render worker: while the view changes on a document whose - /// last render took more than 8 ms, the canvas draws the frame shown reprojected and renders - /// once the view has been still for [`canvas::STILL_SECS`]. - pub waits_for_still: bool, - /// The cache key the canvas last asked for and when it first asked for it (seconds, egui's - /// input time), kept while [`Self::waits_for_still`] is set. - pub still: Option<(CacheKey, f64)>, - pub worker: Option, - pub worker_started: bool, - /// The slices as laid out for (document uid, revision): the canvas draws them every frame. - pub slices: Option, - /// The print tiling's pages for (document uid, revision) (View → Show Print Tiling). - pub print_tiling: Option, - /// The gap lines View → Show Live Paint Gaps draws for (document uid, revision). - pub live_paint_gaps: Option, - /// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame - /// that does not draw them drops them ([`canvas::show`]). - pub hidden_chars: canvas::HiddenChars, - /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). - pub selection_box: Option<((u64, u64, bool), Option)>, - /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). - pub selection_bounds: Option<((u64, u64), Option)>, - /// [`VectorcraftApp::content_bounds`] for (document uid, revision). - pub content_bounds: Option<((u64, u64), Option)>, - /// The tools' cursors as OS cursor bitmaps. - pub cursors: cursors::Images, - /// The view the canvas last drew and when it last changed (egui's input time): while it moves, - /// the canvas doesn't hit-test the art under the pointer ([`canvas::hover_wait`], #2452). - pub view_moved: Option<(canvas::ViewKey, f64)>, - /// The cursor the active tool last gave over the canvas, with the tool and the modifier keys - /// it was for: kept while the view moves. - pub hover_cursor: Option<(&'static str, vectorcraft_tools::Mods, vectorcraft_tools::Cursor)>, -} - -/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision). -pub type SliceCache = ((u64, u64), std::sync::Arc>); - -/// [`CanvasCache::live_paint_gaps`]: the gap lines of the selected Live Paint groups of (document -/// uid, revision). -pub type LivePaintGapsCache = ((u64, u64), std::sync::Arc>); - -/// [`CanvasCache::print_tiling`]: the print tiling of (document uid, revision). -pub type PrintTilingCache = ((u64, u64), std::sync::Arc>); - -#[derive(Clone, Debug, PartialEq)] -pub struct CacheKey { - pub doc: usize, - pub revision: u64, - /// An interaction is in progress ([`vectorcraft_render::RenderOptions::interacting`]). - pub interacting: bool, - /// A tool's drag on the canvas is in progress ([`vectorcraft_render::RenderOptions::dragging`]). - pub dragging: bool, - pub zoom: f64, - pub cx: f64, - pub cy: f64, - pub w: u32, - pub h: u32, - pub outline: bool, - pub trim: bool, - /// View › Hide Template: template layers left out ([`vectorcraft_render::RenderOptions::hide_templates`]). - pub templates: bool, - /// View › Show/Hide Guides: whether guide paths are visible in the artwork raster. - pub guides: bool, - pub ppp: f32, - pub hidden: Vec, - pub rot: f64, - /// General › Anti-aliased Artwork. - pub anti_alias: bool, - /// Type › Highlight Alternate Glyphs (#394). - pub highlight_alternate_glyphs: bool, - /// Type › Highlight Substituted Fonts. - pub highlight_substituted_fonts: bool, - /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. - pub placed: u64, - /// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown - /// with hard edges. None: the art is rendered for the screen. - pub pixel: Option<[i64; 4]>, - /// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]). - pub smooth_images: bool, - /// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]). - pub isolated: Option, - /// Appearance of Black › On Screen › Display All Blacks as Rich Black - /// ([`vectorcraft_render::RenderOptions::rich_black`]). - pub rich_black: bool, - /// Colour and style of guide paths ([`vectorcraft_render::RenderOptions::guide`]). - pub guide: ([u8; 3], bool), - /// The Image Trace objects drawn with their source image - /// ([`vectorcraft_render::RenderOptions::trace_source`]). - pub trace_source: Option>>, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct Perf { - pub frame_ms: f64, - pub render_ms: f64, - pub fps: f64, -} +pub use app::MAX_RECENT_FONTS; +pub use caches::{CacheKey, CanvasCache, LivePaintGapsCache, Perf, PrintTilingCache, SliceCache}; +pub use clock::{now_ms, now_secs}; +#[cfg(test)] +pub(crate) use services::browsable_url; +pub use services::{ + DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, Services, WriteFn, +}; +use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot}; +pub(crate) use state::{InputFrame, take_frame_once}; pub struct VectorcraftApp { pub session: Session, @@ -545,1704 +293,3 @@ pub struct VectorcraftApp { /// The geometry stamp [`Self::geometry_epoch`] was last advanced for. geometry_stamp: Option, } - -/// One UI pass key: the context identity, the viewport and that viewport's cumulative frame. -pub(crate) type InputFrame = (u64, egui::ViewportId, u64); - -/// Record `frame` unless this host iteration already took it. A discarded rerun and any -/// interleaved pass order stay exact-once because the key, not the last-seen slot, is compared. -/// The list is cleared at every host-iteration boundary, so it stays as small as that iteration's -/// pass count. -pub(crate) fn take_frame_once(frames: &mut Vec, frame: InputFrame) -> bool { - if frames.contains(&frame) { - return false; - } - frames.push(frame); - true -} - -/// The document, dialog and focus an injected batch belongs to: if any changed before a UI pass -/// took the batch, the clicks or text no longer belong to the new state and are dropped. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -struct InputGeneration { - document: u64, - dialog: Option, - focus: Option, -} - -/// The window geometry a capture belongs to (logical and screen bounds, DPI and zoom). -#[derive(Clone, Copy, Debug, PartialEq)] -struct GeometryStamp { - viewport: egui::ViewportId, - content: egui::Rect, - inner: Option, - pixels_per_point: f32, - zoom: f32, -} - -/// What the issued capture must still match when it completes. -#[derive(Clone, Copy, Debug)] -struct CaptureEpoch { - geometry: u64, - viewport: egui::ViewportId, - /// The active document (uid, revision) at issuance: a screenshot never answers with pixels of - /// a document, undo state or selection that replaced it while the capture was in flight. - document: Option<(u64, u64)>, - /// The image size the issuing geometry predicts (`width * height`, checked). - width: usize, - height: usize, -} - -/// An admitted capture waiting for its settled frame (`frames`) and animation delay (`due_ms`). -struct QueuedScreenshot { - id: u64, - due_ms: f64, - frames: u32, -} - -/// A window capture asked for through the control channel (`ui.screenshot`), answered once a -/// frame is presented; a window that isn't presented never delivers one, hence the deadline. -struct PendingScreenshot { - id: u64, - path: Option, - /// Send the PNG back as `pngBase64`. - data: bool, - reply: Sender, - /// When to give up (ms, as [`now_ms`], monotonic). - deadline_ms: f64, - /// The checked bytes this request reserved before it was admitted. - reserved_bytes: usize, - /// The active document at admission: switching documents before issuance fails the request - /// rather than capturing a different document. - document: Option<(u64, u64)>, - /// The live one-shot completion slot while the command is outstanding. - slot: Option, - /// The geometry and expected image size at issuance. - issued: Option, - /// Fresh slots made after a geometry change (a resize while the capture was in flight). - attempts: u8, -} - -/// At most this many captures admitted (pending or queued) at once. -const MAX_PENDING_SCREENSHOTS: usize = 4; -/// The largest single capture admitted (128 MiB: a 4K viewport at 2x). -const MAX_SCREENSHOT_BYTES: usize = 128 * 1024 * 1024; -/// The aggregate budget of admitted captures (256 MiB). -const MAX_SCREENSHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024; -/// Settle frames a capture waits for after a control request (and the animation time). -const SCREENSHOT_SETTLE_FRAMES: u32 = 3; -/// How long after settling an issued capture may take (ms). -const SCREENSHOT_TIMEOUT_MS: f64 = 8000.0; -/// The timeout's answer: no file, no second reply. -const SCREENSHOT_TIMEOUT_ERROR: &str = - "no frame was presented (screen locked, window minimized or fully covered); ui.render still renders the artboard"; - -/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. -const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; -/// Seconds between looks for fonts a font manager activated while the app stays in front (#2576). -#[cfg(not(target_arch = "wasm32"))] -const FONT_INSTALL_POLL: f64 = 2.0; - -impl VectorcraftApp { - /// The app around `session`, reaching the platform through `services` (file pickers, the - /// clipboard, files and the like). It starts cataloguing the installed fonts at once, gives - /// Data Recovery the host's store and makes a view for each open document. - pub fn new(mut session: Session, services: Services) -> Self { - // The font menus and the first file opened need the installed fonts: catalog them now. - vectorcraft_text::FontDb::global().scan_in_background(); - if let Some(store) = &services.recovery_store { - session.recovery.set_store(store.clone()); - } - // The place cursor's clicks and drags place their files through the app - // ([`place::place_from_cursor`]). - session.place_cursor_requests = true; - let views = session.documents().iter().map(View::of).collect(); - Self { - session, - ui: UiState::default(), - views, - services, - canvas: CanvasCache { - renderer: vectorcraft_render::Renderer::new(), - texture: None, - key: None, - shown: None, - last_ms: 0.0, - moved: false, - from_worker: false, - waits_for_still: cfg!(target_arch = "wasm32"), - still: None, - worker: None, - worker_started: false, - slices: None, - print_tiling: None, - live_paint_gaps: None, - hidden_chars: Default::default(), - selection_box: None, - selection_bounds: None, - content_bounds: None, - cursors: Default::default(), - view_moved: None, - hover_cursor: None, - }, - perf: Perf::default(), - dock_rt: Default::default(), - titlebar_inset: 0.0, - last_effect: None, - last_warp: None, - run_count: 0, - clipboard_out: None, - clipboard_published: None, - clipboard_in: None, - clipboard_publishing: Default::default(), - pending_url: None, - control_rx: None, - pending_screenshots: vec![], - queued_screenshots: vec![], - screenshot_token: 0, - screenshot_reserved: 0, - synthetic: vec![], - styled: false, - fonts_ready: false, - context: 0, - ui_ctx: None, - ui_fonts: Default::default(), - updates_rt: Default::default(), - frame_rate: Default::default(), - canvas_rect: None, - hover_doc: None, - custom_titlebar: false, - last_window_title: String::new(), - graphics_adapter: None, - place: Default::default(), - system_paste: false, - clipboard_image_request: None, - system_paste_at: f64::NEG_INFINITY, - clipboard_probe: None, - picks: picks::Picks::default(), - screen_sampler: None, - font_check: None, - #[cfg(not(target_arch = "wasm32"))] - font_install_check_at: f64::NEG_INFINITY, - paste_chord: Default::default(), - // Held keys repeat their command (Cmd+= keeps zooming in). - dispatcher: craft_keymap::egui::Dispatcher::new().with_repeat(true), - background: Default::default(), - opening: Default::default(), - plugin_runs: Default::default(), - placing: Default::default(), - recovery: Default::default(), - host_modifiers: Default::default(), - forwarded: Vec::new(), - forwarded_modifiers: None, - forwarded_modifiers_held: false, - forwarded_frame: false, - synthetic_modifiers: false, - ime_marked: None, - ime_context: Default::default(), - ime_compositions: Default::default(), - ime_resets: Default::default(), - ime_caret_rect: None, - ime_discard: false, - prefs_changed: false, - touch_screen: false, - scrub_group: false, - scrub_scope: None, - automation_roots: None, - synthetic_frame: false, - pen_end: Default::default(), - host_iteration: 0, - #[cfg(test)] - now_override: None, - tick_iteration: None, - input_frames: vec![], - shortcuts_frames: vec![], - synthetic_injected: false, - injected_events: vec![], - injected_generation: InputGeneration::default(), - injected_settle_hook: false, - injected_settled: Default::default(), - geometry_epoch: 0, - geometry_stamp: None, - } - } - - pub fn with_control(mut self, rx: Receiver) -> Self { - self.control_rx = Some(rx); - self - } - - /// Confine what the control channel does to `roots` (#832): its requests, the input it - /// injects (in the frames that carry it) and the saves and exports they start read and write - /// only inside them ([`file_access`]). The host's file services check every path they're given - /// against the roots in force, and so does the engine; outside those scopes the person at the - /// keyboard works as usual. - pub fn with_automation_roots(mut self, roots: Option>) -> Self { - let Some(roots) = roots else { return self }; - self.automation_roots = Some(roots); - let s = &mut self.services; - if let Some(read) = s.read.take() { - s.read = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| read(p)))); - } - if let Some(mut write) = s.write.take() { - s.write = Some(Box::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b)))); - } - if let Some(write) = s.write_shared.take() { - s.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b)))); - } - if let Some(stream) = s.stream_shared.take() { - s.stream_shared = Some(Arc::new(move |p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { - file_access::check_write(p).and_then(|()| stream(p, fill)) - })); - } - // Edit Original and Show in Folder hand a file to another app: only one automation may read. - for service in [&mut s.open_file, &mut s.reveal] { - if let Some(mut open) = service.take() { - *service = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| open(p)))); - } - } - self - } - - /// Keep `views` aligned with the session's documents (a new one starts at its saved view). - pub fn sync_views(&mut self) { - let docs = self.session.documents(); - self.views.truncate(docs.len()); - self.views.extend(docs.iter().skip(self.views.len()).map(View::of)); - for (view, doc) in self.views.iter_mut().zip(docs) { - view.artboard = view.artboard.min(doc.doc.artboards.len().saturating_sub(1)); - } - // Undo can remove the Artboard tool's active or additionally selected boards. Repair only - // invalid indices: valid multiselection, drag state and tool options must survive history. - if self.session.tool_id() != "artboard" || self.session.tool_busy() { - return; - } - let Some(n) = self.session.active().map(|d| d.doc.artboards.len()).filter(|n| *n > 0) else { return }; - let Some(view) = self.session.active_index().and_then(|i| self.views.get_mut(i)) else { return }; - let options = self.session.tool_options(); - let Some(selected) = options["selected"].as_array() else { return }; - let mut valid: Vec = selected.iter().filter_map(Value::as_u64).filter_map(|i| usize::try_from(i).ok()).filter(|i| *i < n).collect(); - if valid.len() != selected.len() { - if valid.is_empty() { - valid.push(view.artboard); - } - if let Some(active) = valid.first() { - view.artboard = *active; - } - self.session.set_tool_option("selected", &serde_json::json!(valid)); - } - } - - /// The active document's uid, how many times the tool has started afresh - /// ([`vectorcraft_engine::Session::tool_resets`]) and the artboards the Artboard tool has - /// selected (null with another tool), which [`Self::follow_artboard_tool`] compares before and - /// after a command. - fn artboard_tool_state(&self) -> (Option, u64, Value) { - let selected = if self.session.tool_id() == "artboard" { self.session.tool_options()["selected"].clone() } else { Value::Null }; - (self.session.active().map(|d| d.uid), self.session.tool_resets(), selected) - } - - /// After a command run with the Artboard tool, given [`Self::artboard_tool_state`] from before - /// it: when the command chose another document, or started the tool afresh in the same one - /// (File › Revert, a `command.batch` or `action.batch` that reverts it or switches to another - /// document and back), the tool takes the document's active artboard, as choosing its tab - /// does. When the command changed the artboards the tool has selected (Paste and Cut with the - /// tool, `tool.setOption`), also after starting it afresh (a batch step after the revert or the - /// switch), the tool's active artboard becomes the active one (the view's), as a click with the - /// tool makes it. - fn follow_artboard_tool(&mut self, before: (Option, u64, Value)) { - self.sync_views(); - if self.session.tool_id() != "artboard" { - return; - } - let Some(n) = self.session.active().map(|d| d.doc.artboards.len()).filter(|n| *n > 0) else { return }; - let (doc, resets, selected) = self.artboard_tool_state(); - // The tool started afresh during the command, and no later step (a Paste or Cut) set its options. - let reset = resets != before.1; - let fresh = reset && !self.session.tool_set_since_reset(); - if fresh || (doc != before.0 && !reset) { - panels::artboards::select(self, panels::artboards::selected(self, n)); - } else if (reset || selected != before.2) - && let Some(i) = self.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()) - && i < n - && let Some(v) = self.view_mut() - { - v.artboard = i; - } - } - - pub fn view(&self) -> Option<&View> { - self.session.active_index().and_then(|i| self.views.get(i)) - } - - /// The zero point shown by rulers and position fields: the document's with global rulers, the - /// active artboard's with artboard rulers. Command coordinates stay absolute. - pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point { - let artboard = self.view().map_or(0, |v| v.artboard); - self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin_for(artboard)) - } - pub fn view_mut(&mut self) -> Option<&mut View> { - self.sync_views(); - let i = self.session.active_index()?; - self.views.get_mut(i) - } - - pub fn view_info(&self) -> ViewInfo { - ViewInfo { - zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), - outline: self.ui.view.outline, - smart_guides: self.ui.view.smart_guides, - guides: self.ui.view.guides, - snap_to_grid: self.ui.view.snap_to_grid, - snap_to_pixel: self.ui.view.snap_to_pixel, - // View › Hide Edges: the canvas draws neither the bounding box (with the type widget) - // nor the Live Corners widgets, and the tools do not answer the pointer on them. - show_bbox: self.ui.view.bounding_box && self.ui.view.edges, - snap_to_point: self.ui.view.snap_to_point, - snap_to_glyph: self.ui.view.snap_to_glyph, - corner_widgets: self.ui.view.corner_widgets && self.ui.view.edges, - screen: self.screen_frame(), - artboard: self.view().map_or(0, |v| v.artboard), - } - } - - /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a - /// logic-only one), for panel state that asks "was this drawn in the last iteration?". - pub(crate) fn frame_iteration(&self) -> u64 { - self.host_iteration - } - - /// The canvas on screen in document coordinates (none before it is laid out). - pub fn screen_frame(&self) -> Option { - let (rect, view) = (self.canvas_rect?, self.view()?); - let xf = canvas::Xf::new(rect, view); - let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y)); - Some(vectorcraft_tools::ScreenFrame { - origin: xf.to_doc(rect.left_top()), - right: px(1.0, 0.0), - down: px(0.0, 1.0), - size: (f64::from(rect.width()), f64::from(rect.height())), - }) - } - - /// Run a UI or engine command by id. The single entry point for every frontend path. - pub fn run(&mut self, id: &str, params: Value) -> Result { - // A file dialog it shows off the UI thread runs it again with the path picked. - if self.picks.is_entry_free() { - let entry = picks::Entry::Command(id.to_string(), params.clone()); - return picks::as_entry(self, move || entry, |app| app.run_now(id, params)); - } - self.run_now(id, params) - } - - /// [`Self::run`] it, inside what asks for file dialogs. - fn run_now(&mut self, id: &str, params: Value) -> Result { - crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); - self.run_count = self.run_count.wrapping_add(1); - // In debug builds the app's own calls are held to the params descriptions too (#1442): a - // panel passing a key its command doesn't read shows up in the tests. - if cfg!(debug_assertions) { - menus::check_params(&self.session, id, ¶ms)?; - } - let artboards = self.artboard_tool_state(); - if let Some(r) = menus::run_ui_command(self, id, ¶ms) { - self.follow_artboard_tool(artboards); - return r; - } - let mut params = params; - // Paste in place, in front and in back (#693), Select › All on Active Artboard (#1006), and Align, - // Distribute Objects and Distribute Spacing with Align to Artboard act on the active artboard, the - // view's, which the app passes as `artboard`. A batch passes it on to those steps. - if (vectorcraft_engine::ON_ACTIVE_ARTBOARD.contains(&id) || id == "command.batch") - && params.get("artboard").is_none() - && self.view().is_some() - && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) - && let Some(p) = params.as_object_mut() - { - p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n))); - } - // Object › Artboards › Fit to Artwork Bounds resizes the selected artboards (the Artboard - // tool's, else the active one), which the app passes as its `indices`, and Fit to Selected - // Art resizes the active artboard, which the app passes as its `index`. - if vectorcraft_engine::FIT_ACTIVE_ARTBOARD.contains(&id) - && params.get("index").is_none() - && params.get("indices").is_none() - && self.view().is_some() - && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) - && let Some(p) = params.as_object_mut() - { - if id == "artboard.fitToArt" { - p.insert("indices".into(), serde_json::json!(panels::artboards::picked(self, n))); - } else { - p.insert("index".into(), serde_json::json!(panels::artboards::selected(self, n))); - } - } - if id.starts_with("edit.paste") { - if let Err(e) = self.adopt_system_clipboard() { - self.ui.status = e.clone(); - return Err(e); - } - // Paste (also without formatting) goes to the centre of the view. - if matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") - && ["center", "dx", "dy"].iter().all(|k| params.get(k).is_none()) - && let Some(c) = self.view().filter(|v| v.fitted).map(|v| v.center) - && let Some(p) = params.as_object_mut() - { - p.insert("center".into(), serde_json::json!([c.x, c.y])); - } - if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) { - return r; - } - } - // Native files carry the view they reopen at. - if fileio::SaveMode::of(id).is_some() { - io::remember_view(self); - } - // A command that changes the edited text is an application edit (undo, a panel or menu - // edit, a control request). Capture the bounded committed context around it so `Other` - // dominates the frame even if an IME receipt follows. Selection/view commands that leave - // the committed text and caret unchanged record nothing. - let ime_view = self.view_info(); - let ime_target = crate::ime::target(&mut self.session, ime_view); - let ime_before = crate::ime::stamp(&mut self.session, ime_view); - let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); - if r.is_ok() - && let Some(ctx) = self.ui_ctx.clone() - { - crate::ime::note_application_edit(self, &ctx, ime_view, ime_target, ime_before); - } - if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { - self.publish_clipboard(); - } - self.sync_views(); - // File › Revert replaces the document in its tab, keeps the tab's view and starts the tool afresh. - self.follow_artboard_tool(artboards); - match &r { - Err(e) => self.ui.status = e.clone(), - Ok(v) => { - // Image Trace's notes (a large image traces more slowly, or was scaled down). - if id.starts_with("imageTrace.make") - && let Some(w) = v["warnings"].as_array().and_then(|w| w.first()).and_then(Value::as_str) - { - self.ui.status = w.to_string(); - } - if id == "file.new" { - self.ui.status.clear(); - // New Document's Pixel preview mode (Overprint Preview is the engine's). - if v["previewMode"] == "pixel" { - self.ui.view.pixel_preview = true; - } - } - // Choosing a data set, or stepping to one, clears the status bar. The reply's `warning` - // below then shows when no object is bound to a variable the data set has a value for. - if matches!(id, "dataset.select" | "dataset.next" | "dataset.prev") { - self.ui.status.clear(); - } - if matches!(id, "text.setStyle" | "text.setRangeStyle") - && let Some(font) = params.get("font").and_then(Value::as_str) - { - let r = &mut self.ui.recent_fonts; - r.retain(|f| !f.eq_ignore_ascii_case(font)); - r.insert(0, font.to_string()); - r.truncate(MAX_RECENT_FONTS); - } - // A command's warning (Rasterize's when an image couldn't be decoded) shows in the - // status bar, as a tool preview's does. - if let Some(w) = v.get("warning").and_then(Value::as_str) { - self.ui.status = w.to_string(); - } - } - } - r - } - - /// Open a link in the browser (Help → Discord, website, GitHub…). - pub fn open_url(&mut self, url: &str) { - match self.services.open_url.as_mut() { - Some(open) => open(url), - None => self.pending_url = Some(url.to_string()), - } - self.ui.status = format!("Opened {url}"); - } - - /// Open `url`, which came from a document (an object's URL attribute), in the browser: - /// [`browsable_url`]s only. - pub(crate) fn open_document_url(&mut self, url: &str) -> Result<(), String> { - browsable_url(url)?; - self.open_url(url); - Ok(()) - } - - /// Refuse UI command `id`, which hands something to another app, when automation sent it (a - /// control or MCP request, or a frame of synthetic input): the automation roots don't confine - /// what that app does. - pub(crate) fn refuse_automation(&self, id: &str) -> Result<(), String> { - if self.ui.answering_control || self.synthetic_frame { - return Err(format!("{id} opens another app: only the person at the keyboard can run it")); - } - Ok(()) - } - - /// Run a Help link command and open the URL it returns. - pub fn open_link(&mut self, id: &str) { - if let Ok(v) = self.run(id, serde_json::json!({})) - && let Some(u) = v["url"].as_str() - { - let u = u.to_string(); - self.open_url(&u); - } - } - - /// Select a tool (also used by the toolbar and shortcuts). - pub fn select_tool(&mut self, id: &str) { - // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice. - self.pen_end.forget_tool(); - self.activate_tool(id, true); - } - - /// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]): - /// a perspective grid hidden meanwhile stays hidden. - pub(crate) fn return_to_tool(&mut self, id: &str) { - self.activate_tool(id, false); - } - - /// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]), - /// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]). - fn activate_tool(&mut self, id: &str, fresh: bool) { - let v = self.view_info(); - let before = self.session.tool_id(); - let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) }; - if let Err(e) = switched { - self.ui.status = e.to_string(); - } - // A newly chosen Artboard tool starts on the active artboard (the view's). - if before != "artboard" - && self.session.tool_id() == "artboard" - && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) - { - panels::artboards::select(self, panels::artboards::selected(self, n)); - } - if let Some(g) = vectorcraft_tools::catalog::group_of(id) - && let Some(slot) = self.ui.group_tool.get_mut(g) - { - *slot = id.to_string(); - } - toolbar::remember(self, id); - self.ui.flyout = None; - } - - fn drain_control(&mut self, ctx: &egui::Context) { - let Some(rx) = self.control_rx.take() else { return }; - let mut handled = 0; - while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME { - let Ok(req) = rx.try_recv() else { break }; - // Its caller stopped waiting (the app was busy or its window not drawn for too long) - // and was told it won't run: an edit applied now would come from nowhere (#1725). - if !req.claim() { - continue; - } - handled += 1; - let reply = req.reply.clone(); - // Guarded per request: a panic must not drop the channel (taken out of `self` above). - let roots = self.automation_roots.clone(); - self.ui.answering_control = true; - let answer = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))); - self.ui.answering_control = false; - let outcome = match answer { - Ok(outcome) => outcome, - Err(msg) => { - self.picks.recover(); - control::err(format!("internal error: {msg} (please report this bug)")) - } - }; - match outcome { - control::Outcome::Done(v) => { - let _ = reply.send(v); - } - control::Outcome::Screenshot { path, data } => { - // Admit with the shared ceilings (4 requests, 128 MiB each, 256 MiB together) - // before issue, reserving the checked bytes this viewport would need. - match self.screenshot_pixels(ctx) { - Ok((width, height, bytes)) if bytes > MAX_SCREENSHOT_BYTES => { - let _ = reply.send(json!({"ok": false, "error": format!( - "screenshot would use {bytes} bytes ({width}×{height}), over the {MAX_SCREENSHOT_BYTES}-byte limit" - )})); - } - Ok((width, height, bytes)) if data && path.is_none() && bytes > control::MAX_SCREENSHOT_PNG_REPLY_BYTES => { - let _ = reply.send(json!({"ok": false, "error": format!( - "a PNG reply of this {width}×{height} screenshot ({bytes} bytes of pixels) would exceed the {}-byte reply-encoding limit; write it to a path instead", - control::MAX_SCREENSHOT_PNG_REPLY_BYTES - )})); - } - Ok((_, _, _)) if self.pending_screenshots.len() >= MAX_PENDING_SCREENSHOTS => { - let _ = reply.send(json!({"ok": false, "error": format!( - "already {MAX_PENDING_SCREENSHOTS} screenshots are waiting; wait for one to finish" - )})); - } - Ok((width, height, bytes)) if self.screenshot_reserved.saturating_add(bytes) > MAX_SCREENSHOT_TOTAL_BYTES => { - let _ = reply.send(json!({ - "ok": false, - "error": format!( - "screenshots already reserved {} bytes; {bytes} more ({width}×{height}) would pass the {MAX_SCREENSHOT_TOTAL_BYTES}-byte limit", - self.screenshot_reserved - ) - })); - } - Ok((_, _, bytes)) => { - self.screenshot_token += 1; - let id = self.screenshot_token; - let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0; - let due_ms = now_ms() + settle; - self.screenshot_reserved = self.screenshot_reserved.saturating_add(bytes); - self.queued_screenshots.push(QueuedScreenshot { id, due_ms, frames: 0 }); - let document = self.document_identity(); - self.pending_screenshots.push(PendingScreenshot { - id, - path, - data, - reply, - deadline_ms: due_ms + SCREENSHOT_TIMEOUT_MS, - reserved_bytes: bytes, - document, - slot: None, - issued: None, - attempts: 0, - }); - } - Err(e) => { - let _ = reply.send(json!({"ok": false, "error": e})); - } - } - } - } - } - if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME { - // Leave later requests on the channel and schedule another frame to serve them. - ctx.request_repaint(); - } - self.control_rx = Some(rx); - } - - /// The active document's identity: its process-unique uid and revision. A capture records it - /// at admission and issuance and refuses to answer with a different document or undo state. - fn document_identity(&self) -> Option<(u64, u64)> { - self.session.active().map(|document| (document.uid, document.revision)) - } - - /// The image size (pixels) and checked byte count a capture of the current viewport would - /// have: the window's content rect times the current DPI/zoom. The viewport's own `inner_rect` - /// is the fresh source; `content_rect` is only a fallback for hosts that do not report one - /// (Wayland, headless), and a logic-only pass leaves it from the last shown frame. Issuance in - /// a real UI pass recomputes this and re-checks the budget, so a stale admission size cannot - /// let an oversized image through. - fn screenshot_pixels(&self, ctx: &egui::Context) -> Result<(usize, usize, usize), String> { - let rect = ctx.input(|i| i.viewport().inner_rect).unwrap_or_else(|| ctx.content_rect()); - let scale = f64::from(ctx.pixels_per_point()); - let pixels = |points: f32, what: &str| -> Result { - let value = f64::from(points) * scale; - if !value.is_finite() || !(1.0..=100_000.0).contains(&value) { - return Err(format!("the window's {what} ({points} points × {scale} DPI) cannot size a screenshot")); - } - Ok(value.round() as usize) - }; - let width = pixels(rect.width(), "width")?; - let height = pixels(rect.height(), "height")?; - let bytes = width - .checked_mul(height) - .and_then(|n| n.checked_mul(4)) - .ok_or_else(|| format!("a {width}×{height} screenshot's byte count overflows"))?; - Ok((width, height, bytes)) - } - - /// Issue the first due capture of a settled UI pass, at most one in flight: the one-shot - /// callback makes correlation trivial, and serializing bounds readback memory. - fn issue_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.iter().any(|p| p.slot.is_some()) { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - return; - } - for q in &mut self.queued_screenshots { - q.frames = q.frames.saturating_add(1); - } - let now = now_ms(); - let Some(pos) = self.queued_screenshots.iter().position(|q| now >= q.due_ms && q.frames >= SCREENSHOT_SETTLE_FRAMES) else { - if !self.queued_screenshots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - return; - }; - let queued = self.queued_screenshots.remove(pos); - let Some(index) = self.pending_screenshots.iter().position(|p| p.id == queued.id) else { return }; - let document = self.document_identity(); - if self.pending_screenshots[index].document != document { - self.fail_screenshot(index, "the document changed while the screenshot was pending"); - return; - } - let (width, height, bytes) = match self.screenshot_pixels(ctx) { - Ok(v) => v, - Err(e) => { - self.fail_screenshot(index, &e); - return; - } - }; - let (data, path, previous) = - (self.pending_screenshots[index].data, self.pending_screenshots[index].path.is_some(), self.pending_screenshots[index].reserved_bytes); - if data && !path && bytes > control::MAX_SCREENSHOT_PNG_REPLY_BYTES { - let e = format!( - "a PNG reply of this {width}×{height} screenshot ({bytes} bytes of pixels) no longer fits the {}-byte reply-encoding limit; write it to a path instead", - control::MAX_SCREENSHOT_PNG_REPLY_BYTES - ); - self.fail_screenshot(index, &e); - return; - } - if bytes > MAX_SCREENSHOT_BYTES || self.screenshot_reserved.saturating_sub(previous).saturating_add(bytes) > MAX_SCREENSHOT_TOTAL_BYTES { - let e = format!("the window grew since the screenshot was admitted: {width}×{height} no longer fits the capture budget"); - self.fail_screenshot(index, &e); - return; - } - self.screenshot_reserved = self.screenshot_reserved.saturating_sub(previous).saturating_add(bytes); - let viewport = ctx.viewport_id(); - let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, viewport, MAX_SCREENSHOT_BYTES); - { - let pending = &mut self.pending_screenshots[index]; - pending.reserved_bytes = bytes; - pending.issued = Some(CaptureEpoch { geometry: self.geometry_epoch, viewport, document, width, height }); - pending.slot = Some(slot); - } - // A pending `Screenshot` asks for the paint it needs, even while hidden. - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); - ctx.request_repaint(); - } - - /// Answer a pending request with an error and release its reservation. - fn fail_screenshot(&mut self, index: usize, error: &str) { - if index >= self.pending_screenshots.len() { - return; - } - let pending = self.pending_screenshots.remove(index); - self.queued_screenshots.retain(|q| q.id != pending.id); - self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); - if let Some(slot) = pending.slot { - slot.cancel(); - } - let _ = pending.reply.send(json!({"ok": false, "error": error})); - } - - /// Take completed captures on the UI thread only, verify they still match the document and - /// geometry they were issued for, and write them under the automation roots. A late completion - /// (after a timeout or close) has already been canceled with its slot, so it writes nothing. - /// - /// This runs in `tick`, as both hosts call logic and the UI on the same thread. `save_screenshot` - /// and `file_access::confine` here, and the `&mut self` write, must move with it if logic ever - /// runs off the UI thread. - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let mut completed: Vec<(usize, Result, craft_ui::capture::CaptureError>)> = vec![]; - for (index, pending) in self.pending_screenshots.iter().enumerate() { - if let Some(slot) = &pending.slot - && let Some(result) = slot.take() - { - completed.push((index, result)); - } - } - let now = now_ms(); - let document = self.document_identity(); - for (index, result) in completed.into_iter().rev() { - let Some((id, issued, deadline)) = self.pending_screenshots.get(index).map(|p| (p.id, p.issued, p.deadline_ms)) else { continue }; - match result { - // The frame came only after the request's deadline: it is late even though the - // callback had stored it before this pass took it. Do not encode or write it. - _ if now >= deadline => { - self.fail_screenshot(index, SCREENSHOT_TIMEOUT_ERROR); - } - Ok(_) if issued.is_some_and(|e| e.document != document) => { - self.fail_screenshot(index, "the document changed while the screenshot was pending"); - } - Ok(image) => { - let size = (image.size[0], image.size[1]); - let stale = - issued.is_none_or(|e| e.geometry != self.geometry_epoch || e.viewport != ctx.viewport_id() || (e.width, e.height) != size); - if stale { - // Resize, DPI or zoom changed while the capture was in flight: a fresh slot - // and a fresh frame, not the old pixels. Attempts are bounded. - let retry = self.pending_screenshots[index].attempts == 0; - self.pending_screenshots[index].attempts = self.pending_screenshots[index].attempts.saturating_add(1); - self.pending_screenshots[index].slot = None; - self.pending_screenshots[index].issued = None; - if retry { - self.queued_screenshots.insert(0, QueuedScreenshot { id, due_ms: now_ms(), frames: 0 }); - } else { - self.fail_screenshot(index, "the window changed while the screenshot was in flight"); - } - continue; - } - let pending = self.pending_screenshots.remove(index); - self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); - let roots = self.automation_roots.clone(); - let _ = pending - .reply - .send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data))); - } - Err(e) => { - let error = format!("the screenshot could not be used: {e}"); - self.fail_screenshot(index, &error); - } - } - } - let timed_out: Vec = self.pending_screenshots.iter().enumerate().filter(|(_, p)| now >= p.deadline_ms).map(|(i, _)| i).collect(); - for index in timed_out.into_iter().rev() { - self.fail_screenshot(index, SCREENSHOT_TIMEOUT_ERROR); - } - if !self.pending_screenshots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - /// Did the Type tool end an IME composition on its own this frame? Interrupting the IME - /// through egui (`should_interrupt_composition`) doesn't reach the macOS input context, which - /// keeps the marked text and types it again into the next composition: the host discards it. - pub fn take_ime_discard(&mut self) -> bool { - std::mem::take(&mut self.ime_discard) - } - - /// Was Preferences confirmed since the last call? The host then writes the preferences file, - /// so what the user just set survives a session that doesn't end with a normal quit (#1747). - pub fn take_prefs_changed(&mut self) -> bool { - std::mem::take(&mut self.prefs_changed) - } - - /// Linked files another app changed while their document is open: every two seconds, and as - /// soon as the window comes back to the front, stamp the active document's linked files on a - /// worker thread and act on the changed ones as Preferences › File Handling › Update Links - /// says ([`vectorcraft_engine::link_watch`]). No look starts while a dialog is open, so an - /// Ask When Modified question never goes unseen. - #[cfg(not(target_arch = "wasm32"))] - fn tick_link_updates(&mut self, ctx: &egui::Context, now: f64) { - if let Some(r) = self.session.poll_link_scan() { - match r { - Ok(r) => { - let n = |k: &str| r[k].as_array().map_or(0, Vec::len); - if n("updated") > 0 { - self.status(format!("Updated {} linked file(s) changed on disk", n("updated"))); - } - if n("ask") > 0 { - dialogs::missing_links::ask_update_changed(self, r["ask"].as_array().cloned().unwrap_or_default()); - } - if n("modified") > 0 { - self.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", n("modified"))); - } - } - Err(e) => self.status(e.to_string()), - } - } - let (last_key, focus_key) = (egui::Id::unique("linkWatch.lastScan"), egui::Id::unique("linkWatch.focused")); - let focused = ctx.input(|i| i.focused); - let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); - ctx.data_mut(|d| d.insert_temp(focus_key, focused)); - if self.ui.dialog.is_some() || self.session.active().is_none() { - return; - } - let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); - // The active document as the last look saw it: its uid and revision. - let (seen_key, doc) = (egui::Id::unique("linkWatch.seen"), self.session.active().map(|d| (d.uid, d.revision))); - if now - last >= 2.0 || (focused && !was_focused) { - // Walking the document's links costs a pass over it: only when a look is due. - // `start_link_scan` starts none for a document without links, which then doesn't - // wake the app for looks of its own (any frame still looks once one is due). - ctx.data_mut(|d| d.insert_temp(last_key, now)); - self.session.start_link_scan(); - let links = self.session.link_scan.is_some(); - ctx.data_mut(|d| { - d.insert_temp(egui::Id::unique("linkWatch.links"), links); - d.insert_temp(seen_key, doc); - }); - } - if self.session.link_scan.is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } else if ctx.data(|d| d.get_temp::(egui::Id::unique("linkWatch.links"))).unwrap_or(false) { - ctx.request_repaint_after(std::time::Duration::from_millis(2000)); - } else if ctx.data(|d| d.get_temp::>(seen_key)).is_some_and(|seen| seen != doc) { - // The document changed (a file placed into one without links) or another came to the - // front since that look: wake for the next one even if the app sits idle until then, - // or a file placed and changed meanwhile is first seen after the change, which is then - // never acted on. - ctx.request_repaint_after(std::time::Duration::from_secs_f64((last + 2.0 - now).clamp(0.0, 2.0))); - } - } - - /// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs - /// again when it answers. - pub fn file_dialog_open(&self) -> bool { - self.picks.is_waiting() - } - - /// A file dialog is open as a sheet on the window ([`Services::dialogs_are_sheets`]): nothing - /// closes or quits until it answers. - pub fn file_sheet_open(&self) -> bool { - self.services.dialogs_are_sheets && self.file_dialog_open() - } - - /// Show a transient status message. - pub fn status(&mut self, s: impl Into) { - self.ui.status = s.into(); - } - - fn drain_inbox(&mut self) { - let arrived: Vec<(String, Vec)> = - self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); - for (name, bytes) in arrived { - if let Err(e) = io::open_bytes(self, &name, &bytes, None) { - io::report_open_error(self, &name, &e); - } - } - place::drain(self); - } -} - -/// Seconds since the process started, from a monotonic clock (`web_time::Instant`, which maps to -/// `performance.now()` in the browser and `std::time::Instant` natively). Background work, poll -/// pacing and deadlines use this, not egui's `InputState::time`: a logic-only pass replays the -/// last shown frame's time, and a wall clock jumps with the system's. -pub fn now_secs() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - use std::sync::LazyLock; - use std::time::Instant; - static START: LazyLock = LazyLock::new(Instant::now); - START.elapsed().as_secs_f64() - } - #[cfg(target_arch = "wasm32")] - { - use std::sync::LazyLock; - use web_time::Instant; - static START: LazyLock = LazyLock::new(Instant::now); - START.elapsed().as_secs_f64() - } -} - -/// [`now_secs`] in milliseconds, for frame timing and capture deadlines. -pub fn now_ms() -> f64 { - now_secs() * 1000.0 -} - -/// The most fonts Type › Recent Fonts lists (Preferences › Type › Number of Recent Fonts). -pub const MAX_RECENT_FONTS: usize = 15; - -impl VectorcraftApp { - /// Type › Recent Fonts: the fonts used last, newest first, as many as Preferences › Type › - /// Number of Recent Fonts says. - pub fn recent_fonts(&self) -> &[String] { - let n = usize::try_from(self.session.prefs.recent_fonts_count).unwrap_or(MAX_RECENT_FONTS).clamp(1, MAX_RECENT_FONTS); - self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts) - } - - /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]); with - /// anchors selected with Direct Selection, the box around them square to the page, which - /// `object.setBounds` then edits (#1824). The canvas and the transform fields read it every - /// frame: it is measured once per revision. - pub fn selection_box(&mut self) -> Option { - let st = self.session.active()?; - let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); - if let Some((k, b)) = self.canvas.selection_box - && k == key - { - return b; - } - let b = match vectorcraft_effects::preview::anchor_bounds(&st.selection, &st.doc, self.session.prefs.use_preview_bounds) { - Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)), - None => self.session.transform_box(&st.selection.objects), - }; - self.canvas.selection_box = Some((key, b)); - b - } - - /// The selection's visual bounds (strokes included, effects left out), square to the page: - /// measured once per revision, as the canvas reads it every frame (a traced photo selects a - /// group of hundreds of thousands of paths). - pub fn selection_bounds(&mut self) -> Option { - let st = self.session.active()?; - let key = (st.uid, st.revision); - if let Some((k, b)) = self.canvas.selection_bounds - && k == key - { - return b; - } - let b = st.doc.bounds_of(&st.selection.objects, true); - self.canvas.selection_bounds = Some((key, b)); - b - } - - /// The art and the artboards together, the span of the canvas's scroll bars: measured once - /// per revision, as the canvas reads it every frame. - pub fn content_bounds(&mut self) -> Option { - let st = self.session.active()?; - let key = (st.uid, st.revision); - if let Some((k, b)) = self.canvas.content_bounds - && k == key - { - return b; - } - let boards = st.doc.artboards.iter().map(|a| a.rect).reduce(|r, a| r.union(a)); - let b = vectorcraft_geom::union_opt(st.doc.art_bounds(), boards); - self.canvas.content_bounds = Some((key, b)); - b - } -} - -/// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are -/// called from the host's `eframe::App` impl. -impl VectorcraftApp { - /// The language the UI is drawn in: the Preferences dialog's choice while it is open (so a - /// change shows before OK), else the `interfaceLanguage` preference (`auto` = the system's). - pub fn ui_language(&self) -> i18n::Lang { - let editing = - self.ui.dialog.as_ref().filter(|d| d.kind == "preferences").and_then(|d| d.fields.get("interfaceLanguage")).and_then(Value::as_str); - i18n::Lang::from_pref(editing.unwrap_or(&self.session.prefs.interface_language)) - } - - /// The host starts one iteration: called from both hosts' `raw_input_hook`, before the host - /// decides whether a UI pass follows. It counts the iteration; it is not a frame or pass count - /// and does not promise that `ui` will run (a hidden window gets `tick` only). - pub fn begin_host_iteration(&mut self) { - self.host_iteration = self.host_iteration.wrapping_add(1); - // Frame keys identify input already taken in this host iteration. Each iteration starts - // fresh; this is what makes a viewport's discarded pass a duplicate, while two viewports' - // frame 0 both count. - self.input_frames.clear(); - self.shortcuts_frames.clear(); - } - - /// Background work, once per host iteration (full or logic-only): control requests, background - /// saves/exports/opens, plug-ins, recovery, font and clipboard polls, capture completion. It - /// reads only viewport state from `ctx.input` — never events or `i.time`: a logic-only pass - /// leaves the last shown frame's input in place. - /// - /// A bug that panics costs one iteration and shows an error, instead of closing the app with - /// unsaved work. - pub fn tick(&mut self, ctx: &egui::Context) { - let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); - if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.tick_frame(ctx))) { - self.picks.recover(); - self.status(format!("Internal error: {msg} (please report this bug)")); - } - } - - /// [`Self::tick`]'s work, once per host iteration: a second call in the same iteration (a - /// discarded rerun shares one hook call) does nothing. - fn tick_frame(&mut self, ctx: &egui::Context) { - if self.tick_iteration == Some(self.host_iteration) { - return; - } - self.tick_iteration = Some(self.host_iteration); - let dialog_was_open = self.ui.dialog.is_some(); - let lang = self.ui_language(); - i18n::set_current(lang); - // The engine gives new type the Japanese defaults while the UI is in Japanese. - if self.session.ui_language.as_deref() != Some(lang.code()) { - self.session.ui_language = Some(lang.code().to_string()); - } - self.install_injected_settle_hook(ctx); - self.advance_geometry_epoch(ctx); - // Monotonic host time, never egui's `InputState::time`: a logic-only (hidden) iteration - // replays the last shown pass's time, freezing recovery, clipboard polling and deadlines. - // Tests inject a clock to drive idle scheduling deterministically. - #[cfg(test)] - let now = self.now_override.unwrap_or_else(now_secs); - #[cfg(not(test))] - let now = now_secs(); - self.perf.fps = self.frame_rate.frame(now, self.perf.frame_ms / 1000.0); - self.sync_views(); - updates_ui::tick(self, ctx); - // Read the system clipboard only when that alone decides whether Paste is enabled. A - // background thread reads it where the host installs one (an unresponsive owner must never - // stall the frame loop); otherwise, or once that thread is gone, it is read here, at most a - // few times a second (opening it locks it against other apps on some systems). - let wanted = self.session.clipboard.is_empty() && self.session.active().is_some(); - if let Some(make) = self.services.clipboard_probe.take() { - self.clipboard_probe = clipboard_probe::Probe::start(make, ctx.clone()); - } - if let Some(pasteable) = self.clipboard_probe.as_mut().and_then(|p| p.pasteable(wanted)) { - self.system_paste = pasteable; - } else if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) { - self.clipboard_probe = None; - self.system_paste_at = now; - self.system_paste = wanted && self.system_clipboard_pasteable(); - } - #[cfg(not(target_arch = "wasm32"))] - if !(0.0..FONT_INSTALL_POLL).contains(&(now - self.font_install_check_at)) { - self.font_install_check_at = now; - // Read on the next frame the app draws anyway: an idle app isn't woken for it. - self.refresh_installed_fonts(false); - } - self.poll_font_check(ctx); - picks::poll(self, ctx); - // A Paste the host read before this iteration's formats were written must still wait for - // them, even if the worker below finishes them during this same tick. - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("clipboard.pending-at-tick"), self.clipboard_publishing.is_pending())); - background::poll(self); - screen_color::poll(self, ctx); - opening::poll(self); - plugin_runs::poll(self); - placing::poll(self); - #[cfg(not(target_arch = "wasm32"))] - self.tick_link_updates(ctx, now); - if !self.background.jobs.is_empty() - || !self.opening.pending.is_empty() - || !self.plugin_runs.running.is_empty() - || !self.placing.pending.is_empty() - { - // Keep the status bar's progress moving and pick the result up when it arrives. - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } - // The window's close button (or the system quitting the app) asks about unsaved documents, - // once the saves running in the background are done. Not while a file dialog's sheet is on - // the window: what asked for it runs again when it answers. - if ctx.input(|i| i.viewport().close_requested()) { - // This native viewport is going away: release its IME context. No request is - // allocated here: the close prompt or file sheet publishes no custom descriptor, so a - // token would survive for a later stock field. A close the user then cancels starts a - // fresh generation on the next pass (never inherits a composition across the prompt). - ime::close_viewport(self, ctx); - self.ime_context.end_viewport(ctx.viewport_id()); - background::wait_all(self); - if self.file_sheet_open() { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - self.status(picks::busy()); - } else if unsaved::any_dirty(self) { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - if let Err(e) = unsaved::close_all(self, "quit") { - self.status(e); - } - } else { - // Quitting with nothing unsaved: no copies to leave behind. - vectorcraft_engine::cmd::recovery::forget_all(&mut self.session); - } - } - if let Some(wait) = recovery::frame(self, now) { - // The timer and heartbeat run in an idle window too. - ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); - } - crate::keymap::note_os(ctx); - shortcut_editor::sync(&self.ui); - prefs_dialog::apply_runtime(self, ctx); - // The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the - // system title bar this is where the document name lives, as the in-app mark is hidden. - crate::chrome::sync_window_title(self, ctx); - self.drain_control(ctx); - // An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It - // goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears - // the request) to read the formats and then pastes them. A text paste in the same UI pass - // never sets the request, so the two cannot both paste. - if let Some(held) = self.clipboard_image_request.take() { - crate::menus::invoke(self, crate::shortcuts::paste_command(held), json!({})); - } - self.collect_screenshots(ctx); - if let Some(u) = self.pending_url.take() { - ctx.open_url(egui::OpenUrl::new_tab(u)); - } - self.flush_clipboard_out(ctx); - self.drain_inbox(); - if self.viewport_hidden(ctx) && (self.needs_hidden_paint() || (!dialog_was_open && self.ui.dialog.is_some())) { - // A plain repaint runs only logic in a hidden window: the ui that applies injected - // input, shows a queued dialog or issues a capture needs this command. egui-winit - // handles it; the 0.37 web backend does not (a pending fork fix), so a hidden browser - // tab cannot be claimed as qualified. - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - } - if !self.synthetic.is_empty() || self.synthetic_injected || !self.pending_screenshots.is_empty() { - ctx.request_repaint(); - } - } - - /// The 0.36-style entry point, kept for the crate's tests, which call one `logic` inside a - /// `run_ui` pass and draw a panel afterwards (a UI phase). Production hosts call - /// `raw_input_hook`, then `tick` in `App::logic` and `ui` in `App::ui`; `tick` alone never - /// takes input, so a hidden logic-only iteration cannot replay the last shown frame's events. - /// - /// Outside tests this is just [`Self::tick`] with an iteration boundary. - pub fn logic(&mut self, ctx: &egui::Context) { - self.begin_host_iteration(); - self.tick(ctx); - #[cfg(test)] - { - let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); - let _ = file_access::confine(confined.as_ref(), || { - vectorcraft_engine::guard::catch_panic(|| { - self.ui_setup(ctx); - self.handle_input_once(ctx); - }) - }); - } - self.finish_ime_logic(ctx); - } - - /// Install once during host Context creation, before its first run_ui. - pub fn install_ime_reset_hooks(ctx: &egui::Context) { - ime::install_hooks(ctx); - } - - /// Host logic wrappers call this after their last application mutation. Full-pass logic - /// leaves final output observation to UI; standalone logic retires unavailable publication. - pub fn finish_ime_logic(&mut self, ctx: &egui::Context) { - ime::finish_logic(self, ctx); - } - - /// First UI-pass setup, in a real `run_ui` phase: adopt the context, install the interface - /// fonts and theme, and the image loaders. 0.37's `set_fonts` applies at once when no text has - /// been laid out, so the same pass can draw; a hidden logic-only iteration never installs them. - fn ui_setup(&mut self, ctx: &egui::Context) { - // Hosts install before their first pass. Installing here covers later passes only: egui - // has already snapshotted this pass's plugin list, so a direct harness that calls `logic` - // inside its first `run_ui` must call `install_ime_reset_hooks` before that `run_ui` - // (as `tests_lifecycle::ime_frame` does). This is not a first-pass bootstrap fallback. - ime::install_hooks(ctx); - self.adopt_context(ctx); - if !self.styled { - theme::apply(ctx, self.ui.brightness); - egui_extras::install_image_loaders(ctx); - self.styled = true; - } - if !self.fonts_ready { - // `set_fonts` applies at once only while no text was laid out in this pass; when it - // defers, the next pass applies it and this leaves input and drawing to that pass. - let fonts = theme::interface_fonts(); - ctx.set_fonts(fonts.clone()); - self.fonts_ready = ctx.fonts(|applied| applied.definitions() == &fonts); - } - } - - /// The first UI pass of a host iteration takes the host's input, once per context, viewport and - /// frame: clipboard formats, native-menu clicks, shortcuts and dropped files. A later pass - /// (`request_discard` rerun) or an interleaved second viewport pass with the same key draws - /// again but does not replay them; a logic-only iteration never reaches this. - fn handle_input_once(&mut self, ctx: &egui::Context) { - let frame = (graphics::context_tag(ctx), ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id())); - if !take_frame_once(&mut self.input_frames, frame) { - return; - } - self.write_formats_before_text_input(ctx); - native_menu::run(self, ctx); - if self.fonts_ready { - shortcuts::handle(self, ctx); - } - // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in - // this same pass: waiting for the next `tick` can leave the command unissued when an idle - // web host has no further frame to run it in. Native rich-format Copy does not use this - // path; it writes through `SystemClipboard` and leaves `clipboard_out` empty. - self.flush_clipboard_out(ctx); - // Native only: the web host reads dropped files asynchronously and feeds the inboxes. - #[cfg(not(target_arch = "wasm32"))] - self.take_dropped_files(ctx); - } - - /// Hand a queued text copy to egui. Called after `tick`, after the UI phase's input and at the - /// end of the pass; the later calls are what an input- or menu-driven fallback Copy needs, and - /// `take` keeps it single-shot. - fn flush_clipboard_out(&mut self, ctx: &egui::Context) { - if let Some(t) = self.clipboard_out.take() { - ctx.copy_text(t); - } - } - - /// Is the window minimized or fully occluded (as far as the host reports)? - fn viewport_hidden(&self, ctx: &egui::Context) -> bool { - ctx.input(|i| { - let v = i.viewport(); - v.minimized == Some(true) || v.occluded == Some(true) - }) - } - - /// Is there work that only a UI pass can do, so a hidden window must be asked to paint? - fn needs_hidden_paint(&self) -> bool { - !self.synthetic.is_empty() - || self.synthetic_injected - || !self.queued_screenshots.is_empty() - || self.pending_screenshots.iter().any(|p| p.slot.is_none()) - } - - /// Capture the viewport identity and geometry (content and inner rectangles, pixels per point and - /// zoom) whose change advances the screenshot epoch. - fn geometry_stamp(ctx: &egui::Context) -> GeometryStamp { - GeometryStamp { - viewport: ctx.viewport_id(), - content: ctx.content_rect(), - inner: ctx.input(|i| i.viewport().inner_rect), - pixels_per_point: ctx.pixels_per_point(), - zoom: ctx.zoom_factor(), - } - } - - /// Files dropped on the window: documents, libraries, presets, plug-ins and shortcut sets - /// opened, pictures and text placed on the canvas ([`Self::drop_target`]). - #[cfg(not(target_arch = "wasm32"))] - fn take_dropped_files(&mut self, ctx: &egui::Context) { - let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift)); - self.open_dropped_files(dropped, place::drag_pos(ctx), shift); - } - - /// Files dropped on the main window or on a floating panel's own OS window, opened or placed - /// by kind; `pos` is the drop point in the main window, where the backend reports one. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn open_dropped_files(&mut self, dropped: Vec, pos: Option, shift: bool) { - if dropped.is_empty() { - return; - } - let mut files = vec![]; - for f in dropped { - let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty()); - let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name); - // Native file drops usually carry a path. Use the same file reader as File → Open - // (and File → Place), even if the drop handle can't supply the bytes itself. - let bytes = if path.is_some() && self.services.read.is_some() { Ok(vec![]) } else { f.bytes() }; - match bytes { - Ok(bytes) => { - // A backend may give bytes without a path or extension. Sniff their format - // before deciding: an SVG opens as a document, a raster image is placed. - let target = if path.is_none() && fileio::detect(&name, &bytes).is_none_or(|f| !f.raster) { - place::DropTarget::Open - } else { - self.drop_target(&name, pos, shift) - }; - files.push((target, (name, path, bytes))); - } - Err(e) => self.status(format!("Couldn't read {name}: {e}")), - } - } - place::drop_files(self, files); - } - - /// Fonts installed or removed while the app runs are listed without Refresh Font List: a look - /// at the font folders and the platform's font list on a timer and when the window comes back, - /// a scan only when they changed. - /// The look runs on another thread (asking DirectWrite for the fonts font services loaded - /// meanwhile takes tens of milliseconds on Windows, #579), [`Self::poll_font_check`] scans; - /// `wake` wakes the app for the answer. - fn refresh_installed_fonts(&mut self, wake: bool) { - if let Some((_, w)) = &mut self.font_check { - *w |= wake; - return; - } - let (tx, rx) = std::sync::mpsc::channel(); - let look = move || { - // The UI gone meanwhile has nothing to refresh. - let _ = tx.send(vectorcraft_text::FontDb::global().installed_fonts_changed()); - }; - let spawned = - if cfg!(target_arch = "wasm32") { None } else { std::thread::Builder::new().name("font-check".into()).spawn(look.clone()).ok() }; - if spawned.is_none() { - look(); - } - self.font_check = Some((rx, wake)); - } - - /// Rescan the fonts once [`Self::refresh_installed_fonts`]'s look says they changed. - fn poll_font_check(&mut self, ctx: &egui::Context) { - let Some((check, wake)) = &self.font_check else { return }; - match check.try_recv() { - Ok(changed) => { - self.font_check = None; - if changed { - // A failure shows in the status bar, as the menu item's does. - let _ = self.run("text.rescanFonts", json!({})); - } - } - Err(std::sync::mpsc::TryRecvError::Empty) => { - if *wake { - ctx.request_repaint_after(std::time::Duration::from_millis(50)); - } - } - Err(std::sync::mpsc::TryRecvError::Disconnected) => self.font_check = None, - } - } - - /// Advance the capture geometry epoch when any part of the window geometry changed, even back - /// to a previous value: a capture issued before the change must not be saved as if it showed - /// the new one. - fn advance_geometry_epoch(&mut self, ctx: &egui::Context) { - let stamp = Self::geometry_stamp(ctx); - if self.geometry_stamp != Some(stamp) { - self.geometry_stamp = Some(stamp); - self.geometry_epoch = self.geometry_epoch.wrapping_add(1); - } - } - - /// Identify the document, modal dialog and focused widget that the current accepted input batch - /// belongs to. - fn input_generation(&self) -> InputGeneration { - InputGeneration { - document: self.session.active().map_or(0, |document| document.uid), - dialog: self.ui.dialog.as_ref().map(|dialog| egui::Id::unique(&dialog.kind)), - focus: self.ui_ctx.as_ref().and_then(|ctx| ctx.memory(|memory| memory.focused())), - } - } - - /// Install the end-of-pass observer that tells the next host iteration a settled UI pass took - /// the injected batch (a font-gated or discarded pass does not). The shipped hosts have only - /// the root viewport; a multi-viewport host would need pending batches and settlement keyed - /// per viewport instead of this root-only acknowledgement. - fn install_injected_settle_hook(&mut self, ctx: &egui::Context) { - if self.injected_settle_hook { - return; - } - let settled = self.injected_settled.clone(); - ctx.on_end_pass( - "vectorcraft injected input settlement", - Arc::new(move |ui| { - if ui.ctx().viewport_id() == egui::ViewportId::ROOT && !ui.ctx().will_discard() { - settled.store(true, Ordering::Relaxed); - } - }), - ); - self.injected_settle_hook = true; - } - - /// The settled pass took the injected batch: allow the next step at the next host iteration. - fn acknowledge_injected_batch(&mut self) { - self.synthetic_injected = false; - self.injected_events.clear(); - self.synthetic_frame = false; - } - - /// The document, dialog or focus changed before a UI pass took the batch: remove exactly the - /// events we appended (they still live in eframe's pending raw input) so a forced hidden paint - /// cannot replay stale clicks or text into the new state. - fn discard_stale_injected_batch(&mut self, raw: &mut egui::RawInput) { - if !self.synthetic_injected || self.input_generation() == self.injected_generation { - return; - } - let events = std::mem::take(&mut self.injected_events); - let mut removed = false; - if !events.is_empty() - && let Some(start) = raw.events.windows(events.len()).rposition(|window| window == events.as_slice()) - { - raw.events.drain(start..start + events.len()); - removed = true; - } - if !events.is_empty() && !removed { - // The host's raw input no longer contains the batch as we injected it (another - // handler rewrote or interleaved events). We cannot tell stale synthetic input from - // the person's, so fail closed: deliver nothing this pass rather than replay a stale - // click or text into the state that replaced it, and cancel the rest of that - // synthetic sequence instead of injecting its next step after this one. - raw.events.clear(); - self.synthetic.clear(); - } - // The discarded batch may have carried the modifiers it set; the host's modifiers are - // what the next real event has, so do not leave a synthetic latch behind. - self.synthetic_modifiers = false; - self.synthetic_injected = false; - self.synthetic_frame = false; - } - - /// Inject synthetic events (one press/release step, wheel turn or short key sequence per host - /// iteration), carrying control provenance until a settled UI pass has taken the batch. The - /// batch belongs to the one root viewport the shipped hosts draw (see - /// `install_injected_settle_hook`); a multi-viewport host would key it by viewport. A - /// hidden logic-only iteration may see this hook run without a UI pass: nothing more is - /// injected until the batch was actually applied, so a burst of hidden ticks cannot queue - /// several steps of a drag or replay them together later. - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - // Both hosts call this hook once per host iteration, before deciding whether a UI pass - // follows; it is the iteration boundary, not a UI-phase marker. - self.begin_host_iteration(); - // The observer lives on the context so it also settles a harness that consumes raw input - // without calling `VectorcraftApp::ui`. - self.install_injected_settle_hook(ctx); - // The native menu's key equivalents become the input their keys make, ahead of what came - // after them (see `native_menu`). - let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default(); - // The menu bar does nothing while a file dialog is open (`native_menu`): a key that raced it - // going inert is dropped. - if !keys.is_empty() && !self.file_dialog_open() { - let events: Vec = keys.into_iter().flat_map(|k| native_menu::key_events(k, || self.system_clipboard_text())).collect(); - raw.events.splice(0..0, events); - } - for e in &raw.events { - match e { - egui::Event::ModifiersChanged(m) => self.host_modifiers = *m, - egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE, - egui::Event::WindowFocused(true) => self.refresh_installed_fonts(true), - _ => {} - } - } - // Keys a floating panel's own OS window left over run here, as if typed in this window, - // under the modifiers held there for this frame only. - if std::mem::take(&mut self.forwarded_modifiers_held) { - raw.events.insert(0, egui::Event::ModifiersChanged(self.host_modifiers)); - } - self.forwarded_frame = !self.forwarded.is_empty(); - if !self.forwarded.is_empty() { - if let Some(m) = self.forwarded_modifiers.take() { - raw.events.push(egui::Event::ModifiersChanged(m)); - self.forwarded_modifiers_held = true; - } - raw.events.append(&mut self.forwarded); - } - // The previous pass settled: the batch it took is done, so the next step may go. - if self.injected_settled.swap(false, Ordering::Relaxed) { - self.acknowledge_injected_batch(); - } - self.discard_stale_injected_batch(raw); - // While the previous batch is unapplied (it lives in eframe's pending raw input), inject - // nothing more: one step per settled UI pass, not per hidden logic tick. - if self.synthetic_injected { - return; - } - let Some(first) = self.synthetic.first() else { - if std::mem::take(&mut self.synthetic_modifiers) { - raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)); - } - return; - }; - // Pointer events go one per frame so egui sees presses, drags and releases as real input; - // keyboard sequences go up to the key release. - let n = match first { - egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - let (now, later) = self.synthetic.split_at(n.min(self.synthetic.len())); - let mut batch: Vec = Vec::with_capacity(n + 2); - if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { - batch.push(egui::Event::PointerMoved(*p)); - } - // This frame's key or button, else the button a drag holds down (released later). - let held = now - .iter() - .find_map(|e| match e { - egui::Event::Key { modifiers, .. } | egui::Event::PointerButton { modifiers, .. } | egui::Event::MouseWheel { modifiers, .. } => { - Some(*modifiers) - } - _ => None, - }) - .or_else(|| match later.iter().find(|e| matches!(e, egui::Event::PointerButton { .. })) { - Some(egui::Event::PointerButton { pressed: false, modifiers, .. }) => Some(*modifiers), - _ => None, - }); - match held { - Some(m) => { - batch.push(egui::Event::ModifiersChanged(m)); - self.synthetic_modifiers = true; - } - None if std::mem::take(&mut self.synthetic_modifiers) => batch.push(egui::Event::ModifiersChanged(self.host_modifiers)), - None => {} - } - batch.extend(now.iter().cloned()); - // `batch` holds the n drained events plus the pointer/modifier companions that make the - // step read like real input. - self.synthetic.drain(..n); - self.injected_generation = self.input_generation(); - self.injected_events = batch.clone(); - self.synthetic_injected = true; - self.synthetic_frame = true; - raw.events.extend(batch); - } - - /// The canvas panel's body: the document tabs, the canvas and the isolation bar over it. - pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) { - let t = theme::Tokens::get(ui.ctx()); - ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard); - if self.ui.screen_mode < 3 { - chrome::doc_tabs(self, ui); - } - canvas::show(self, ui); - // Over the canvas inside the rulers, so the rulers and scroll bars stay uncovered. - if self.ui.screen_mode < 3 - && !menus::home_showing(self) - && let Some(area) = self.canvas_rect - { - chrome::isolation_bar(self, ui, area); - } - } - - /// Lay out the whole window: the first UI pass of a host iteration sets the UI up (fonts, - /// theme, image loaders) and takes the input; later passes (a `request_discard` rerun) draw - /// only. - pub fn ui(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - ime::begin_ui(self, &ctx); - let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); - if let Err(msg) = file_access::confine(confined.as_ref(), || { - vectorcraft_engine::guard::catch_panic(|| { - self.ui_setup(&ctx); - self.handle_input_once(&ctx); - self.ui_frame(ui); - }) - }) { - self.picks.recover(); - self.status(format!("Internal error: {msg} (please report this bug)")); - } - ime::finish_ui(self, &ctx); - } - - /// [`Self::ui`]'s work, inside its guard: the bars, panels and canvas, then the dialogs and - /// progress windows over them. - fn ui_frame(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - if !self.fonts_ready { - // Do not draw (or run the app's input handling) before the interface fonts are the - // context's: a deferred `set_fonts` applies at the start of the next pass. - ctx.request_repaint(); - return; - } - crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); - let t0 = now_ms(); - widgets::set_dialog_open(&ctx, self.ui.dialog.is_some()); - // The canvas records this pass's IME caret geometry; a pass that draws no canvas has none. - self.ime_caret_rect = None; - scrub::begin_frame(self, &ctx); - font_menu::end_stale_preview(self, &ctx); - tools_float::track(self, &ctx); - // A selection move released over a panel ends before any panel sees the release (#2826). - canvas::release_over_panel(self, &ctx); - let t = theme::Tokens::get(&ctx); - if self.ui.screen_mode < 2 { - chrome::app_bar(self, ui); - if self.ui.control_bar { - chrome::control_bar(self, ui); - } - } - if self.ui.status_bar && self.ui.screen_mode < 3 { - chrome::status_bar(self, ui); - chrome::hint_bar(self, ui); - } else { - // Keeps the previous frame's value valid for menus drawn above, before the bars. - widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); - } - if self.ui.toolbar && self.ui.screen_mode < 3 { - toolbar::show(self, ui); - } - panels::library_panel::tidy(self); - // The canvas and the panels: one docking layout. Tab hides the panels but not the canvas. - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui)); - dialogs::show(self, &ctx); - updates_ui::show(self, &ctx); - export_progress::show(self, &ctx); - plugin_runs::show(self, &ctx); - palette::show(self, &ctx); - if self.custom_titlebar { - titlebar::resize_zones(ui); - } - self.ui_fonts.frame(&ctx); - scrub::end_frame(self, &ctx); - native_menu::sync(self, &ctx); - // Observe once, after all of this pass's input, dialogs, floating panels, palette and - // commands: the latest completed pass is authoritative for owner, text and cause. - ime::end_of_pass(self, &ctx); - self.drop_formats_after_egui_copy(&ctx); - // A command run by a menu, panel or dialog in this pass may have queued its text copy - // after the UI phase's input flush. Publish it now for the same reason as there. - self.flush_clipboard_out(&ctx); - if opening::long_operation_busy(self) { - ctx.set_cursor_icon(egui::CursorIcon::Progress); - } - // A pass egui will discard and rerun is not a presented frame: it must not become the - // frame-time sample, and it must not issue a capture of what it drew. - if !ctx.will_discard() { - self.perf.frame_ms = now_ms() - t0; - self.issue_screenshots(&ctx); - } - let _ = json!(null); - } -} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs new file mode 100644 index 000000000..1b0488754 --- /dev/null +++ b/crates/ui-egui/src/services.rs @@ -0,0 +1,160 @@ +use super::*; + +/// A file type a dialog filters by: its label, which shows translated, and its extensions without +/// the dot. +pub type FileFilter = (String, Vec); + +/// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the +/// file-type filters. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FilePick { + pub name: String, + pub folder: Option, + /// The default type first; empty = every file. + pub filters: Vec, +} + +impl FilePick { + /// A save dialog suggesting `name`. + pub fn named(name: &str) -> Self { + Self { name: name.to_string(), ..Default::default() } + } + + /// The file type `label` with `extensions`. + pub fn filter(label: &str, extensions: &[&str]) -> FileFilter { + (label.to_owned(), extensions.iter().map(|e| (*e).to_owned()).collect()) + } + + /// The file types of `list`, as the engine lists them: `(label, extensions)`. + pub fn filters<'a>(list: impl IntoIterator) -> Vec { + list.into_iter().map(|(label, extensions)| Self::filter(label, extensions)).collect() + } +} + +pub type PickOpen = Box Option>; + +pub type PickSave = Box Option>; + +pub type ReadFn = Box Result, String>>; + +pub type WriteFn = Box Result<(), String>>; + +pub type ExistsFn = Box bool>; + +pub type Inbox = Arc)>>>; + +/// Starts a browser download of the bytes under a file name; an error when it couldn't be started +/// (the save or export then fails as a refused write does: #1693). +pub type DownloadFn = Box Result<(), String>>; + +/// Opens a URL in the system browser. +pub type OpenUrlFn = Box; + +/// Whether a URL from a document may open in the browser: an `http:`, `https:` or `mailto:` URL +/// without control characters. The system hands other schemes (`file:`, application schemes) to +/// other programs. +pub(crate) fn browsable_url(url: &str) -> Result<(), String> { + let scheme = url.split_once(':').map_or("", |(s, _)| s); + if ["http", "https", "mailto"].iter().any(|k| scheme.eq_ignore_ascii_case(k)) && !url.chars().any(char::is_control) { + Ok(()) + } else { + Err(format!("only http, https and mailto URLs open in the browser, not “{url}”")) + } +} + +/// Shows a file in the system file manager, or opens it in its app. +pub type RevealFn = Box Result<(), String>>; + +/// Platform services injected by the host app (desktop or web). +#[derive(Default)] +pub struct Services { + /// Show an open dialog; returns a path. + pub pick_open: Option, + /// Show a save dialog (suggested name, folder, file types); returns a path. + pub pick_save: Option, + pub read: Option, + pub write: Option, + /// Is there a file or folder at a path (desktop)? The app asks before replacing a file that a + /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). + /// Without it, no file counts as there. + pub exists: Option, + /// Files that arrived asynchronously (web open / drops): (name, bytes). + pub inbox: Option, + /// Web: trigger a browser download instead of writing a path. + pub download: Option, + /// Web: start an async open (bytes arrive via `inbox`). + pub open_async: Option>, + /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with + /// egui's Paste event (web, and keyboard paste everywhere). + pub clipboard_read: Option Option>>, + /// Open a URL in the system browser (desktop). Without it, egui opens it (a new tab on the web). + pub open_url: Option, + /// Show an open dialog for several files (File → Place…); returns their paths (none: cancelled). + pub pick_open_multi: Option Vec>>, + /// Web: start an async pick of files to place (they arrive via `place_inbox`). + pub place_async: Option>, + /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the + /// canvas). + pub place_inbox: Option, + /// The system clipboard with all its formats (desktop): Copy offers text, SVG, PDF and PNG, + /// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through + /// egui and `clipboard_read`). + pub system_clipboard: Option>, + /// A second system-clipboard handle, for checking whether Paste has something to take on a + /// background thread (Linux: an X11 clipboard owner that never answers would freeze the UI). + /// Taken on the first frame. Without it the check runs on the UI thread. + pub clipboard_probe: Option, + /// File → Show in Folder: select a file in the system file manager (desktop). + pub reveal: Option, + /// Write a file from any thread (desktop): lets Background Save and Export write off the UI + /// thread ([`background`]). Without it they run at once. + pub write_shared: Option, + /// Write a file from any thread with what a filler writes into it (desktop): native saves + /// stream into the file as they are encoded instead of being held whole in memory first. + /// Without it they are encoded, then written with `write` / `write_shared`. + pub stream_shared: Option, + /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show + /// Package). Without it (the web) those answer with an error. + pub open_file: Option, + /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). + pub pick_folder: Option Option>>, + /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, and + /// browsers with an EyeDropper): Sample Color from Screen ([`screen_color`]). Without it that + /// command answers with an error and the Color Picker's eyedropper button is gray. + pub screen_color: Option, + /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage; + /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]). + pub recovery_store: Option>, + /// File → Print: the system's printers and print queue (desktop), the browser's print dialog + /// (web). Without it Print saves the job as a PDF. + pub print: Option>, + /// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then. + pub native_menu: Option, + /// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window + /// and the compositor finds it not answering, #592) or as sheets on the window (macOS, where a + /// dialog in line aborts the app, #867): what asked runs again with the answer ([`picks`]). + /// Without it they are shown in line. + pub start_pick: Option, + /// [`Self::start_pick`]'s dialogs are sheets on the window (macOS): while one is open the app + /// neither quits nor closes documents, as under the modal dialog before. + pub dialogs_are_sheets: bool, + /// The window is on Wayland: the tool cursors are painted into it instead of handed to the + /// compositor as bitmaps ([`cursors::os_cursors`]). + pub wayland: bool, + /// On Wayland, no cursor theme can be loaded, so winit sets no pointer and the compositor's + /// default one would never come back once the canvas hid it: the canvas keeps the system + /// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450). + pub no_cursor_theme: bool, + /// The pen pressure and pen end the host reads from the system itself (desktop Linux under + /// X11 and XWayland, macOS and Windows). The canvas uses the pressure when a frame's events carry no touch force, + /// and the end to switch to the Eraser tool and back. Without it, pen pressure comes from + /// touch events only ([`stylus`]). + pub stylus: Option, + /// The host's rewrites of a window's raw input (pen barrel button, closing the macOS menu + /// bar's open menu) for floating panels in OS windows of their own: eframe runs the host's + /// `raw_input_hook` for the main window only. + pub native_input_hook: Option, +} + +/// Rewrites one window's raw input before egui reads it ([`Services::native_input_hook`]). +pub type NativeInputHook = std::sync::Arc; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cfe3715aa..8e357a340 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -66,6 +66,7 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [ ("-90°", -90.0), ]; +use super::*; use std::sync::atomic::{AtomicBool, Ordering}; /// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom @@ -900,6 +901,109 @@ fn default_workspace() -> String { crate::workspaces::ESSENTIALS.to_string() } +/// One UI pass key: the context identity, the viewport and that viewport's cumulative frame. +pub(crate) type InputFrame = (u64, egui::ViewportId, u64); + +/// Record `frame` unless this host iteration already took it. A discarded rerun and any +/// interleaved pass order stay exact-once because the key, not the last-seen slot, is compared. +/// The list is cleared at every host-iteration boundary, so it stays as small as that iteration's +/// pass count. +pub(crate) fn take_frame_once(frames: &mut Vec, frame: InputFrame) -> bool { + if frames.contains(&frame) { + return false; + } + frames.push(frame); + true +} + +/// The document, dialog and focus an injected batch belongs to: if any changed before a UI pass +/// took the batch, the clicks or text no longer belong to the new state and are dropped. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct InputGeneration { + pub(crate) document: u64, + pub(crate) dialog: Option, + pub(crate) focus: Option, +} + +/// The window geometry a capture belongs to (logical and screen bounds, DPI and zoom). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct GeometryStamp { + pub(crate) viewport: egui::ViewportId, + pub(crate) content: egui::Rect, + pub(crate) inner: Option, + pub(crate) pixels_per_point: f32, + pub(crate) zoom: f32, +} + +/// What the issued capture must still match when it completes. +#[derive(Clone, Copy, Debug)] +pub(crate) struct CaptureEpoch { + pub(crate) geometry: u64, + pub(crate) viewport: egui::ViewportId, + /// The active document (uid, revision) at issuance: a screenshot never answers with pixels of + /// a document, undo state or selection that replaced it while the capture was in flight. + pub(crate) document: Option<(u64, u64)>, + /// The image size the issuing geometry predicts (`width * height`, checked). + pub(crate) width: usize, + pub(crate) height: usize, +} + +/// An admitted capture waiting for its settled frame (`frames`) and animation delay (`due_ms`). +pub(crate) struct QueuedScreenshot { + pub(crate) id: u64, + pub(crate) due_ms: f64, + pub(crate) frames: u32, +} + +/// A window capture asked for through the control channel (`ui.screenshot`), answered once a +/// frame is presented; a window that isn't presented never delivers one, hence the deadline. +pub(crate) struct PendingScreenshot { + pub(crate) id: u64, + pub(crate) path: Option, + /// Send the PNG back as `pngBase64`. + pub(crate) data: bool, + pub(crate) reply: Sender, + /// When to give up (ms, as [`now_ms`], monotonic). + pub(crate) deadline_ms: f64, + /// The checked bytes this request reserved before it was admitted. + pub(crate) reserved_bytes: usize, + /// The active document at admission: switching documents before issuance fails the request + /// rather than capturing a different document. + pub(crate) document: Option<(u64, u64)>, + /// The live one-shot completion slot while the command is outstanding. + pub(crate) slot: Option, + /// The geometry and expected image size at issuance. + pub(crate) issued: Option, + /// Fresh slots made after a geometry change (a resize while the capture was in flight). + pub(crate) attempts: u8, +} + +/// At most this many captures admitted (pending or queued) at once. +pub(crate) const MAX_PENDING_SCREENSHOTS: usize = 4; + +/// The largest single capture admitted (128 MiB: a 4K viewport at 2x). +pub(crate) const MAX_SCREENSHOT_BYTES: usize = 128 * 1024 * 1024; + +/// The aggregate budget of admitted captures (256 MiB). +pub(crate) const MAX_SCREENSHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024; + +/// Settle frames a capture waits for after a control request (and the animation time). +pub(crate) const SCREENSHOT_SETTLE_FRAMES: u32 = 3; + +/// How long after settling an issued capture may take (ms). +pub(crate) const SCREENSHOT_TIMEOUT_MS: f64 = 8000.0; + +/// The timeout's answer: no file, no second reply. +pub(crate) const SCREENSHOT_TIMEOUT_ERROR: &str = + "no frame was presented (screen locked, window minimized or fully covered); ui.render still renders the artboard"; + +/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. +pub(crate) const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; + +/// Seconds between looks for fonts a font manager activated while the app stays in front (#2576). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const FONT_INSTALL_POLL: f64 = 2.0; + #[cfg(test)] mod tests { use super::*; From 7bbf803f7e5b83161a106905d2870651e01a4c44 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:48:21 +0000 Subject: [PATCH 147/199] refactor(vectorcraft): move main.rs items to {app,host_services,prefs,titlebar}.rs --- apps/vectorcraft/src/app.rs | 166 ++++++ apps/vectorcraft/src/host_services.rs | 326 ++++++++++++ apps/vectorcraft/src/main.rs | 737 +------------------------- apps/vectorcraft/src/prefs.rs | 107 ++++ apps/vectorcraft/src/titlebar.rs | 120 +++++ 5 files changed, 744 insertions(+), 712 deletions(-) create mode 100644 apps/vectorcraft/src/app.rs create mode 100644 apps/vectorcraft/src/host_services.rs create mode 100644 apps/vectorcraft/src/prefs.rs create mode 100644 apps/vectorcraft/src/titlebar.rs diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs new file mode 100644 index 000000000..c7d6ada32 --- /dev/null +++ b/apps/vectorcraft/src/app.rs @@ -0,0 +1,166 @@ +use super::prefs::save_prefs; +use super::*; + +/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating +/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel +/// button's secondary click on Windows. The control channel's input and the native menu's keys +/// go to the main window only. +#[cfg(target_os = "macos")] +pub(crate) fn native_input_hook(app: &VectorcraftApp) -> Option { + app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook) +} + +#[cfg(all(windows, not(target_vendor = "win7")))] +pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option { + Some(std::sync::Arc::new(windows_stylus::raw_input_hook)) +} + +#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))] +pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option { + None +} + +impl eframe::App for App { + // Each frame runs unconfined: it is the person at the keyboard's. The app confines what the + // control channel asks for, and the frames carrying input it injected, to the automation roots + // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + file_access::unconfined(|| self.logic_frame(ctx, frame)); + self.app.finish_ime_logic(ctx); + } + /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the + /// app's raw-input hook while the keyboard user is unconfined. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // winit gives the window its caption back when it changes the window's flags (maximize, + // restore, …): take it off again before the frame is laid out (#1758). + if let Some(window_frame) = self.window_frame.as_mut() { + window_frame(); + } + #[cfg(target_os = "macos")] + if self.app.services.native_menu.is_some() { + mac_menu::raw_input_hook(raw); + } + // A pen contact with the barrel button held presses the secondary button (Windows Ink). + #[cfg(all(windows, not(target_vendor = "win7")))] + windows_stylus::raw_input_hook(raw); + file_access::unconfined(|| self.app.raw_input_hook(ctx, raw)); + } + #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + { + let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; + let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar); + self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET); + } + file_access::unconfined(|| self.app.ui(ui)); + // Preferences are written when confirmed, not only at exit: a session ended any other way + // (the system shutting down, the process ended or crashing) used to lose them (#1747). + if self.app.take_prefs_changed() { + file_access::unconfined(|| save_prefs(&self.app)); + } + // Not while a file dialog's sheet has the keyboard: its name field's composition would go. + #[cfg(target_os = "macos")] + if !self.app.file_dialog_open() && self.app.take_ime_discard() { + discard_marked_text(); + } + } + #[cfg(not(feature = "windows7"))] + fn on_exit(&mut self) { + file_access::unconfined(|| save_prefs(&self.app)); + end_before_teardown(self.frames); + } + #[cfg(feature = "windows7")] + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + file_access::unconfined(|| save_prefs(&self.app)); + } +} + +impl App { + fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.frames = ctx.cumulative_frame_nr(); + if let Some(why) = self.graphics_loss.take() { + // eframe can't give a window a new device: the user saves and starts again. + self.graphics_lost = self.app.graphics_lost(&why); + self.app.status(if self.graphics_lost { + "The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft" + } else { + "The graphics device was lost: save your documents and restart VectorCraft" + }); + } + if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) { + // The window can't show the Save Changes question: it closes, and the next launch + // offers the changes back. + vectorcraft_ui_egui::background::wait_all(&mut self.app); + return; + } + // Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs + // again on its document when it answers. + #[cfg(target_os = "macos")] + if !self.app.file_dialog_open() { + let files = open_documents::take(); + if !files.is_empty() { + bring_window_forward(ctx); + } + open_files(&mut self.app, files); + } + self.app.tick(ctx); + if self.pending_maximize { + if let Some(w) = &self.winit_window { + self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries); + } else { + self.pending_maximize = false; + } + } + let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized()); + window::track(ctx, &mut self.app.ui.window, host_maximized); + if self.app.ui.status == "quit" { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } +} + +/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting, +/// before eframe tears the window down (#661). AccessKit then gives the window's content view back +/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the +/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit. +/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics +/// failure then starts the app again on another adapter (#651). +#[allow(unused_variables)] +fn end_before_teardown(frames: u64) { + #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] + if frames >= gpu::STARTUP_FRAMES { + log::info!("quitting"); + log::logger().flush(); + std::process::exit(0); + } +} + +/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back +/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn bring_window_forward(ctx: &egui::Context) { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); +} + +/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that +/// can't be opened is reported to the user and on stderr; the others still open. +pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec) { + for f in files { + if let Err(e) = vectorcraft_ui_egui::io::open_reporting(app, &f) { + eprintln!("vectorcraft: {f}: {e}"); + } + } +} + +/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as +/// typed). winit's IME toggle only clears its own copy, so the IME would type it again. +#[cfg(target_os = "macos")] +fn discard_marked_text() { + if let Some(mtm) = objc2::MainThreadMarker::new() + && let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm) + { + ic.discardMarkedText(); + } +} diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs new file mode 100644 index 000000000..aa7d7f42c --- /dev/null +++ b/apps/vectorcraft/src/host_services.rs @@ -0,0 +1,326 @@ +use super::*; + +/// The window file dialogs belong to. +pub(crate) type Parent = Option>; + +/// What rfd's blocking and asynchronous file dialogs are both set up with. +trait DialogSettings: Sized { + fn parent(self, window: &winit::window::Window) -> Self; + fn title(self, title: &str) -> Self; + fn filter(self, name: &str, exts: &[impl ToString]) -> Self; + fn folder(self, folder: &str) -> Self; + fn file_name(self, name: &str) -> Self; +} + +macro_rules! dialog_settings { + ($($dialog:ty),*) => {$( + impl DialogSettings for $dialog { + fn parent(self, window: &winit::window::Window) -> Self { + self.set_parent(window) + } + fn title(self, title: &str) -> Self { + self.set_title(title) + } + fn filter(self, name: &str, exts: &[impl ToString]) -> Self { + self.add_filter(name, exts) + } + fn folder(self, folder: &str) -> Self { + self.set_directory(folder) + } + fn file_name(self, name: &str) -> Self { + self.set_file_name(name) + } + } + )*}; +} + +dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog); + +/// `d` set up for `request`: its title, file types, folder and suggested name. +fn dialog_for(d: D, request: &PickRequest) -> D { + let pick = match request { + PickRequest::Open(pick) | PickRequest::Save(pick) => pick, + PickRequest::OpenMany => { + return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| { + d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) + }); + } + PickRequest::Folder => return d, + }; + let d = pick.filters.iter().fold(d, |d, (name, exts)| { + d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts) + }); + let d = match &pick.folder { + Some(folder) => d.folder(folder), + None => d, + }; + if pick.name.is_empty() { d } else { d.file_name(&pick.name) } +} + +/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the +/// window and keeps it in front). +#[cfg(not(target_os = "macos"))] +fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog { + match parent { + Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request), + None => dialog_for(rfd::FileDialog::new(), request), + } +} + +/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet. +fn make_folder(request: &PickRequest) { + if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request { + let _ = std::fs::create_dir_all(folder); + } +} + +/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On +/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over +/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when +/// the portal can't. +#[cfg(not(target_os = "macos"))] +#[cfg_attr(windows, allow(unused_variables))] +fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec { + make_folder(request); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + if let PickRequest::Save(pick) = request + && let Some(answer) = portal_save::pick(pick, parent.as_deref()) + { + return answer; + } + let paths = match request { + PickRequest::Open(_) => dialog.pick_file().into_iter().collect(), + PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(), + PickRequest::Save(_) => dialog.save_file().into_iter().collect(), + PickRequest::Folder => dialog.pick_folder().into_iter().collect(), + }; + paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect() +} + +/// Show the dialog for `request` over `parent` now → the paths picked. +#[cfg(not(target_os = "macos"))] +fn pick_now(request: PickRequest, parent: &Parent) -> Vec { + show_dialog(file_dialog(&request, parent), &request, parent) +} + +/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an +/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]); +/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing. +#[cfg(target_os = "macos")] +fn pick_now(request: PickRequest, _parent: &Parent) -> Vec { + log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only"); + Vec::new() +} + +/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere. +fn reveal(path: &str) -> Result<(), String> { + explorer_safe(path)?; + reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}")) +} + +#[cfg(target_os = "macos")] +fn reveal_command(path: &str) -> std::process::Command { + let mut c = std::process::Command::new("open"); + c.args(["-R", path]); + c +} + +#[cfg(windows)] +fn reveal_command(path: &str) -> std::process::Command { + use std::os::windows::process::CommandExt as _; + // Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces) + // and needs backslashes. + let mut c = std::process::Command::new("explorer"); + c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\"))); + c +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn reveal_command(path: &str) -> std::process::Command { + let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); + let mut c = std::process::Command::new("xdg-open"); + c.arg(folder); + c +} + +/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]). +fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string()) +} + +/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the +/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then +/// offers to kill the window as not responding (#592). +#[cfg(not(target_os = "macos"))] +fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { + let dialog = file_dialog(&request, parent); + let (tx, rx) = std::sync::mpsc::channel(); + let ui = ui.clone(); + let parent = parent.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + // The app gone meanwhile has no use for the answer. + let _ = tx.send(show_dialog(dialog, &request, &parent)); + ui.request_repaint(); + }) + .ok()?; + Some(rx) +} + +/// The paths a dialog shown as a sheet picks, once it closes. +#[cfg(target_os = "macos")] +type SheetAnswer = std::pin::Pin> + Send>>; + +/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver +/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an +/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered +/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet +/// returns at once and the window keeps running; a thread of its own waits for the answer. +#[cfg(target_os = "macos")] +fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { + use winit::raw_window_handle::HasWindowHandle as _; + // rfd runs the panel modally after all unless it has the running app's window to hold the + // sheet (and from another thread it can't reach the window): no dialog then (`pick_now`). + let mtm = objc2::MainThreadMarker::new(); + let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning()); + let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else { + log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some()); + return None; + }; + // The thread first, so the answer of a sheet once shown always has somewhere to go. + let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::(); + let (tx, rx) = std::sync::mpsc::channel(); + let ui = ui.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + if let Ok(answer) = sheet_rx.recv() { + // The app gone meanwhile has no use for the answer. + let _ = tx.send(pollster::block_on(answer)); + ui.request_repaint(); + } + }) + .ok()?; + make_folder(&request); + // The panel is built and the sheet is begun below, on the main thread (as it must). + let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request); + let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string(); + let answer: SheetAnswer = match request { + PickRequest::Open(_) => { + let picked = dialog.pick_file(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + PickRequest::OpenMany => { + let picked = dialog.pick_files(); + Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() }) + } + // A save panel with two or more named file types is built by `mac_save_format`, which sets + // up its Format menu before the sheet begins (#866); rfd shows the others. + PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) { + // The dialog thread blocks here until the sheet answers. + Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }), + None => { + // Nothing is added to the sheet once rfd has begun it: an accessory view set then + // made recent macOS versions dismiss the sheet at once (#1800). + let picked = dialog.save_file(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + }, + PickRequest::Folder => { + let picked = dialog.pick_folder(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + }; + // The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled. + let _ = sheet_tx.send(answer); + Some(rx) +} + +/// The host's services; `ui` is woken when a file dialog answers. +pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_))); + // A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764). + #[cfg(target_os = "linux")] + let x11 = parent + .as_ref() + .and_then(|w| w.window_handle().ok()) + .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); + let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); + #[cfg(target_os = "macos")] + let sampled_ui = ui.clone(); + Services { + pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())), + pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))), + pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())), + // Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows + // dialogs run the window's events while they are open. + start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick), + dialogs_are_sheets: cfg!(target_os = "macos"), + wayland, + no_cursor_theme: wayland && cursor_theme::theme_missing(), + #[cfg(target_os = "linux")] + stylus: if x11 { tablet::start() } else { None }, + // macOS reads it from AppKit's events (#852). + #[cfg(target_os = "macos")] + stylus: mac_tablet::start(), + // Windows reads it from the pen's pointer messages (`windows_stylus`). + #[cfg(all(windows, not(target_vendor = "win7")))] + stylus: Some(windows_stylus::start()), + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(write_file)), + exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + // Background Save and Export write from a worker thread. + write_shared: Some(std::sync::Arc::new(write_file)), + // Native saves stream into the file as they are encoded. + stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + fileio::write_file_with(p, fill).map_err(|e| e.to_string()) + })), + // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). + system_clipboard: Some(clipboard::system_clipboard()), + // Linux checks whether Paste has something to take on a background thread: an X11 clipboard + // owner that never answers holds a read for up to 4 s. Windows only asks which formats the + // clipboard holds and macOS asks the pasteboard server, so they check in line. + clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory), + // Help → Discord / website / GitHub, the Discord button, About and Home links. + open_url: Some(Box::new(|url: &str| { + let _ = webbrowser::open(url); + })), + reveal: Some(Box::new(reveal)), + // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. + open_file: Some(Box::new(open_file)), + pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())), + // Sample Color from Screen: the system's color sampler. + #[cfg(target_os = "macos")] + screen_color: Some(mac_screen_color::sampler(sampled_ui)), + // File → Print: the system's printers and print queue. + print: Some(Box::new(printing::SystemPrint)), + ..Default::default() + } +} + +/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which +/// no Windows file name has) is refused rather than split into other arguments. +fn explorer_safe(path: &str) -> Result<(), String> { + if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) } +} + +/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it. +fn open_file(path: &str) -> Result<(), String> { + explorer_safe(path)?; + #[cfg(windows)] + let mut c = { + use std::os::windows::process::CommandExt as _; + let mut c = std::process::Command::new("explorer"); + c.raw_arg(format!("\"{}\"", path.replace('/', "\\"))); + c + }; + #[cfg(target_os = "macos")] + let mut c = std::process::Command::new("open"); + #[cfg(not(any(target_os = "macos", windows)))] + let mut c = std::process::Command::new("xdg-open"); + #[cfg(not(windows))] + c.arg(path); + c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 76b18e515..2cdf80b42 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -65,6 +65,31 @@ use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::picks::PickRequest; use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp}; +mod app; +mod host_services; +mod prefs; +mod titlebar; + +#[cfg(all(test, feature = "wgpu"))] +use app::bring_window_forward; +use app::native_input_hook; +use app::open_files; +use host_services::services; +#[cfg(all(test, feature = "wgpu"))] +use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; +use prefs::{load_prefs, log_dir, prefs_enabled, prefs_path, read_prefs}; +#[cfg(all(test, feature = "wgpu", any(target_os = "macos", target_os = "windows")))] +use titlebar::CUSTOM_TITLEBAR; +#[cfg(feature = "wgpu")] +use titlebar::adapter_summary; +#[cfg(all(test, feature = "wgpu"))] +use titlebar::custom_titlebar_from; +#[cfg(all(test, feature = "wgpu"))] +use titlebar::opted_into_system_title_bar; +#[cfg(windows)] +use titlebar::set_taskbar_icon; +use titlebar::{custom_titlebar, native_options}; + struct App { app: VectorcraftApp, /// Reported by wgpu when the window's graphics device is lost (a driver reset). @@ -83,718 +108,6 @@ struct App { maximize_tries: u8, } -/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating -/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel -/// button's secondary click on Windows. The control channel's input and the native menu's keys -/// go to the main window only. -#[cfg(target_os = "macos")] -fn native_input_hook(app: &VectorcraftApp) -> Option { - app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook) -} -#[cfg(all(windows, not(target_vendor = "win7")))] -fn native_input_hook(_: &VectorcraftApp) -> Option { - Some(std::sync::Arc::new(windows_stylus::raw_input_hook)) -} -#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))] -fn native_input_hook(_: &VectorcraftApp) -> Option { - None -} - -impl eframe::App for App { - // Each frame runs unconfined: it is the person at the keyboard's. The app confines what the - // control channel asks for, and the frames carrying input it injected, to the automation roots - // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. - fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - file_access::unconfined(|| self.logic_frame(ctx, frame)); - self.app.finish_ime_logic(ctx); - } - /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the - /// app's raw-input hook while the keyboard user is unconfined. - fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - // winit gives the window its caption back when it changes the window's flags (maximize, - // restore, …): take it off again before the frame is laid out (#1758). - if let Some(window_frame) = self.window_frame.as_mut() { - window_frame(); - } - #[cfg(target_os = "macos")] - if self.app.services.native_menu.is_some() { - mac_menu::raw_input_hook(raw); - } - // A pen contact with the barrel button held presses the secondary button (Windows Ink). - #[cfg(all(windows, not(target_vendor = "win7")))] - windows_stylus::raw_input_hook(raw); - file_access::unconfined(|| self.app.raw_input_hook(ctx, raw)); - } - #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] - fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - #[cfg(target_os = "macos")] - { - let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; - let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar); - self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET); - } - file_access::unconfined(|| self.app.ui(ui)); - // Preferences are written when confirmed, not only at exit: a session ended any other way - // (the system shutting down, the process ended or crashing) used to lose them (#1747). - if self.app.take_prefs_changed() { - file_access::unconfined(|| save_prefs(&self.app)); - } - // Not while a file dialog's sheet has the keyboard: its name field's composition would go. - #[cfg(target_os = "macos")] - if !self.app.file_dialog_open() && self.app.take_ime_discard() { - discard_marked_text(); - } - } - #[cfg(not(feature = "windows7"))] - fn on_exit(&mut self) { - file_access::unconfined(|| save_prefs(&self.app)); - end_before_teardown(self.frames); - } - #[cfg(feature = "windows7")] - fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - file_access::unconfined(|| save_prefs(&self.app)); - } -} - -impl App { - fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.frames = ctx.cumulative_frame_nr(); - if let Some(why) = self.graphics_loss.take() { - // eframe can't give a window a new device: the user saves and starts again. - self.graphics_lost = self.app.graphics_lost(&why); - self.app.status(if self.graphics_lost { - "The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft" - } else { - "The graphics device was lost: save your documents and restart VectorCraft" - }); - } - if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) { - // The window can't show the Save Changes question: it closes, and the next launch - // offers the changes back. - vectorcraft_ui_egui::background::wait_all(&mut self.app); - return; - } - // Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs - // again on its document when it answers. - #[cfg(target_os = "macos")] - if !self.app.file_dialog_open() { - let files = open_documents::take(); - if !files.is_empty() { - bring_window_forward(ctx); - } - open_files(&mut self.app, files); - } - self.app.tick(ctx); - if self.pending_maximize { - if let Some(w) = &self.winit_window { - self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries); - } else { - self.pending_maximize = false; - } - } - let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized()); - window::track(ctx, &mut self.app.ui.window, host_maximized); - if self.app.ui.status == "quit" { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - } -} - -/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting, -/// before eframe tears the window down (#661). AccessKit then gives the window's content view back -/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the -/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit. -/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics -/// failure then starts the app again on another adapter (#651). -#[allow(unused_variables)] -fn end_before_teardown(frames: u64) { - #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] - if frames >= gpu::STARTUP_FRAMES { - log::info!("quitting"); - log::logger().flush(); - std::process::exit(0); - } -} - -/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back -/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769). -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -fn bring_window_forward(ctx: &egui::Context) { - ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); -} - -/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that -/// can't be opened is reported to the user and on stderr; the others still open. -fn open_files(app: &mut VectorcraftApp, files: Vec) { - for f in files { - if let Err(e) = vectorcraft_ui_egui::io::open_reporting(app, &f) { - eprintln!("vectorcraft: {f}: {e}"); - } - } -} - -/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as -/// typed). winit's IME toggle only clears its own copy, so the IME would type it again. -#[cfg(target_os = "macos")] -fn discard_marked_text() { - if let Some(mtm) = objc2::MainThreadMarker::new() - && let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm) - { - ic.discardMarkedText(); - } -} - -/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS), -/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux). -fn prefs_path() -> Option { - prefs_dir::prefs_path_for("VectorCraft", "vectorcraft") -} - -/// The same place under the project's former name (DrawCraft): read once if there are no -/// VectorCraft preferences yet, so settings survive the rename. -fn legacy_prefs_path() -> Option { - prefs_dir::prefs_path_for("DrawCraft", "drawcraft") -} - -/// Where the log files live: `logs` in the preferences folder (see `logging`). -fn log_dir() -> Option { - Some(prefs_path()?.parent()?.join("logs")) -} - -/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them. -fn prefs_enabled() -> bool { - std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() -} - -/// The saved UI preferences, read before the window opens (they hold its size and position). -/// A file read only in part, or with layouts converted from an older version, is copied beside -/// itself before anything saves over it. -fn read_prefs() -> Option { - if !prefs_enabled() { - return None; - } - if let Some(p) = prefs_path() - && let Ok(bytes) = std::fs::read(&p) - { - let read = vectorcraft_ui_egui::UiState::read_preferences(&bytes); - if read.as_ref().is_none_or(|read| !read.intact) { - keep_prefs_copy(&p, &bytes, "ui.unreadable"); - } - if read.as_ref().is_some_and(|read| read.converted) { - keep_prefs_copy(&p, &bytes, "ui.before-layout-migration"); - } - return read.map(|read| read.ui); - } - let bytes = legacy_prefs_path().and_then(|p| std::fs::read(p).ok())?; - decode_saved_prefs(&bytes) -} - -/// Saved preferences, with what this version cannot read of their docking data dropped and -/// older layouts converted (`vectorcraft_ui_egui::layout_migration`). -fn decode_saved_prefs(bytes: &[u8]) -> Option { - vectorcraft_ui_egui::UiState::read_preferences(bytes).map(|read| read.ui) -} - -/// Set when `ui.json` needed a copy kept ([`keep_prefs_copy`]) and none could be: the file is -/// then left as it is rather than overwritten. -static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); - -/// Copy `ui.json` beside it as `.json` or the first free `-N.json`, before the next -/// save replaces it: `ui.unreadable` when it could not be read in full (a newer VectorCraft may -/// have written it), `ui.before-layout-migration` when its layouts were converted. -fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str) { - use std::io::Write as _; - for n in 1..100u32 { - let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") }; - match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { - Ok(mut file) => { - if file.write_all(bytes).is_ok() { - return; - } - break; - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(_) => break, - } - } - KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed); -} - -fn load_prefs(app: &mut VectorcraftApp, saved: Option) { - if !prefs_enabled() { - return; - } - if let Some(ui) = saved { - app.ui = ui.sanitized(); - } - vectorcraft_ui_egui::prefs_dialog::restore(app); -} - -fn save_prefs(app: &VectorcraftApp) { - if !prefs_enabled() || KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) { - return; - } - if let Some(p) = prefs_path() { - let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = prefs_bytes(app) { - // Preferences are best effort: a failed write keeps the previous file. - let _ = fileio::write_atomic(&p, &bytes); - } - } -} - -/// The preferences file's contents: the UI state with the engine preferences. -fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { - let mut ui = app.ui.clone(); - ui.engine_prefs = app.session.prefs.to_json(); - serde_json::to_vec_pretty(&ui) -} - -/// The window file dialogs belong to. -type Parent = Option>; - -/// What rfd's blocking and asynchronous file dialogs are both set up with. -trait DialogSettings: Sized { - fn parent(self, window: &winit::window::Window) -> Self; - fn title(self, title: &str) -> Self; - fn filter(self, name: &str, exts: &[impl ToString]) -> Self; - fn folder(self, folder: &str) -> Self; - fn file_name(self, name: &str) -> Self; -} - -macro_rules! dialog_settings { - ($($dialog:ty),*) => {$( - impl DialogSettings for $dialog { - fn parent(self, window: &winit::window::Window) -> Self { - self.set_parent(window) - } - fn title(self, title: &str) -> Self { - self.set_title(title) - } - fn filter(self, name: &str, exts: &[impl ToString]) -> Self { - self.add_filter(name, exts) - } - fn folder(self, folder: &str) -> Self { - self.set_directory(folder) - } - fn file_name(self, name: &str) -> Self { - self.set_file_name(name) - } - } - )*}; -} - -dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog); - -/// `d` set up for `request`: its title, file types, folder and suggested name. -fn dialog_for(d: D, request: &PickRequest) -> D { - let pick = match request { - PickRequest::Open(pick) | PickRequest::Save(pick) => pick, - PickRequest::OpenMany => { - return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) - }); - } - PickRequest::Folder => return d, - }; - let d = pick.filters.iter().fold(d, |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts) - }); - let d = match &pick.folder { - Some(folder) => d.folder(folder), - None => d, - }; - if pick.name.is_empty() { d } else { d.file_name(&pick.name) } -} - -/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the -/// window and keeps it in front). -#[cfg(not(target_os = "macos"))] -fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog { - match parent { - Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request), - None => dialog_for(rfd::FileDialog::new(), request), - } -} - -/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet. -fn make_folder(request: &PickRequest) { - if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request { - let _ = std::fs::create_dir_all(folder); - } -} - -/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On -/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over -/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when -/// the portal can't. -#[cfg(not(target_os = "macos"))] -#[cfg_attr(windows, allow(unused_variables))] -fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec { - make_folder(request); - #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] - if let PickRequest::Save(pick) = request - && let Some(answer) = portal_save::pick(pick, parent.as_deref()) - { - return answer; - } - let paths = match request { - PickRequest::Open(_) => dialog.pick_file().into_iter().collect(), - PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(), - PickRequest::Save(_) => dialog.save_file().into_iter().collect(), - PickRequest::Folder => dialog.pick_folder().into_iter().collect(), - }; - paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect() -} - -/// Show the dialog for `request` over `parent` now → the paths picked. -#[cfg(not(target_os = "macos"))] -fn pick_now(request: PickRequest, parent: &Parent) -> Vec { - show_dialog(file_dialog(&request, parent), &request, parent) -} - -/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an -/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]); -/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing. -#[cfg(target_os = "macos")] -fn pick_now(request: PickRequest, _parent: &Parent) -> Vec { - log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only"); - Vec::new() -} - -/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere. -fn reveal(path: &str) -> Result<(), String> { - explorer_safe(path)?; - reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}")) -} - -#[cfg(target_os = "macos")] -fn reveal_command(path: &str) -> std::process::Command { - let mut c = std::process::Command::new("open"); - c.args(["-R", path]); - c -} - -#[cfg(windows)] -fn reveal_command(path: &str) -> std::process::Command { - use std::os::windows::process::CommandExt as _; - // Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces) - // and needs backslashes. - let mut c = std::process::Command::new("explorer"); - c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\"))); - c -} - -#[cfg(not(any(target_os = "macos", windows)))] -fn reveal_command(path: &str) -> std::process::Command { - let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); - let mut c = std::process::Command::new("xdg-open"); - c.arg(folder); - c -} - -/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]). -fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { - fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string()) -} - -/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the -/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then -/// offers to kill the window as not responding (#592). -#[cfg(not(target_os = "macos"))] -fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { - let dialog = file_dialog(&request, parent); - let (tx, rx) = std::sync::mpsc::channel(); - let ui = ui.clone(); - let parent = parent.clone(); - std::thread::Builder::new() - .name("file-dialog".into()) - .spawn(move || { - // The app gone meanwhile has no use for the answer. - let _ = tx.send(show_dialog(dialog, &request, &parent)); - ui.request_repaint(); - }) - .ok()?; - Some(rx) -} - -/// The paths a dialog shown as a sheet picks, once it closes. -#[cfg(target_os = "macos")] -type SheetAnswer = std::pin::Pin> + Send>>; - -/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver -/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an -/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered -/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet -/// returns at once and the window keeps running; a thread of its own waits for the answer. -#[cfg(target_os = "macos")] -fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { - use winit::raw_window_handle::HasWindowHandle as _; - // rfd runs the panel modally after all unless it has the running app's window to hold the - // sheet (and from another thread it can't reach the window): no dialog then (`pick_now`). - let mtm = objc2::MainThreadMarker::new(); - let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning()); - let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else { - log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some()); - return None; - }; - // The thread first, so the answer of a sheet once shown always has somewhere to go. - let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::(); - let (tx, rx) = std::sync::mpsc::channel(); - let ui = ui.clone(); - std::thread::Builder::new() - .name("file-dialog".into()) - .spawn(move || { - if let Ok(answer) = sheet_rx.recv() { - // The app gone meanwhile has no use for the answer. - let _ = tx.send(pollster::block_on(answer)); - ui.request_repaint(); - } - }) - .ok()?; - make_folder(&request); - // The panel is built and the sheet is begun below, on the main thread (as it must). - let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request); - let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string(); - let answer: SheetAnswer = match request { - PickRequest::Open(_) => { - let picked = dialog.pick_file(); - Box::pin(async move { picked.await.iter().map(path).collect() }) - } - PickRequest::OpenMany => { - let picked = dialog.pick_files(); - Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() }) - } - // A save panel with two or more named file types is built by `mac_save_format`, which sets - // up its Format menu before the sheet begins (#866); rfd shows the others. - PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) { - // The dialog thread blocks here until the sheet answers. - Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }), - None => { - // Nothing is added to the sheet once rfd has begun it: an accessory view set then - // made recent macOS versions dismiss the sheet at once (#1800). - let picked = dialog.save_file(); - Box::pin(async move { picked.await.iter().map(path).collect() }) - } - }, - PickRequest::Folder => { - let picked = dialog.pick_folder(); - Box::pin(async move { picked.await.iter().map(path).collect() }) - } - }; - // The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled. - let _ = sheet_tx.send(answer); - Some(rx) -} - -/// The host's services; `ui` is woken when a file dialog answers. -fn services(parent: Parent, ui: egui::Context) -> Services { - use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; - let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_))); - // A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764). - #[cfg(target_os = "linux")] - let x11 = parent - .as_ref() - .and_then(|w| w.window_handle().ok()) - .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); - let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); - #[cfg(target_os = "macos")] - let sampled_ui = ui.clone(); - Services { - pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())), - pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))), - pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())), - // Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows - // dialogs run the window's events while they are open. - start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick), - dialogs_are_sheets: cfg!(target_os = "macos"), - wayland, - no_cursor_theme: wayland && cursor_theme::theme_missing(), - #[cfg(target_os = "linux")] - stylus: if x11 { tablet::start() } else { None }, - // macOS reads it from AppKit's events (#852). - #[cfg(target_os = "macos")] - stylus: mac_tablet::start(), - // Windows reads it from the pen's pointer messages (`windows_stylus`). - #[cfg(all(windows, not(target_vendor = "win7")))] - stylus: Some(windows_stylus::start()), - read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), - write: Some(Box::new(write_file)), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), - // Background Save and Export write from a worker thread. - write_shared: Some(std::sync::Arc::new(write_file)), - // Native saves stream into the file as they are encoded. - stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { - fileio::write_file_with(p, fill).map_err(|e| e.to_string()) - })), - // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). - system_clipboard: Some(clipboard::system_clipboard()), - // Linux checks whether Paste has something to take on a background thread: an X11 clipboard - // owner that never answers holds a read for up to 4 s. Windows only asks which formats the - // clipboard holds and macOS asks the pasteboard server, so they check in line. - clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory), - // Help → Discord / website / GitHub, the Discord button, About and Home links. - open_url: Some(Box::new(|url: &str| { - let _ = webbrowser::open(url); - })), - reveal: Some(Box::new(reveal)), - // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. - open_file: Some(Box::new(open_file)), - pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())), - // Sample Color from Screen: the system's color sampler. - #[cfg(target_os = "macos")] - screen_color: Some(mac_screen_color::sampler(sampled_ui)), - // File → Print: the system's printers and print queue. - print: Some(Box::new(printing::SystemPrint)), - ..Default::default() - } -} - -/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which -/// no Windows file name has) is refused rather than split into other arguments. -fn explorer_safe(path: &str) -> Result<(), String> { - if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) } -} - -/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it. -fn open_file(path: &str) -> Result<(), String> { - explorer_safe(path)?; - #[cfg(windows)] - let mut c = { - use std::os::windows::process::CommandExt as _; - let mut c = std::process::Command::new("explorer"); - c.raw_arg(format!("\"{}\"", path.replace('/', "\\"))); - c - }; - #[cfg(target_os = "macos")] - let mut c = std::process::Command::new("open"); - #[cfg(not(any(target_os = "macos", windows)))] - let mut c = std::process::Command::new("xdg-open"); - #[cfg(not(windows))] - c.arg(path); - c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) -} - -/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets -/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches -/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon. -fn app_icon() -> egui::IconData { - #[cfg(target_os = "macos")] - let png: &[u8] = include_bytes!("../../../assets/app-icon/vectorcraft-macos-512.png"); - #[cfg(not(target_os = "macos"))] - let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png"); - eframe::icon_data::from_png_bytes(png).unwrap_or_default() -} - -/// Windows shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the -/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too. -#[cfg(windows)] -fn set_taskbar_icon(w: &winit::window::Window) { - use winit::platform::windows::WindowExtWindows as _; - let icon = app_icon(); - // An icon that can't be made leaves the generic one: nothing else depends on it. - if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) { - w.set_taskbar_icon(Some(i)); - } -} - -/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports. -#[cfg(feature = "wgpu")] -fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String { - format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type) -} - -/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). -/// macOS keeps its traffic lights over the integrated title strip. -/// -/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the -/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe. -#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] -const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); - -/// Whether the user asked for the system title bar in Preferences › User Interface › System Title -/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is -/// `false` (the default). This preference forces the desktop/native frame back on. -fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) -} - -/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate -/// decided and the user's System Title Bar preference. -/// -/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame -/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with -/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it -/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter), -/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly -/// when `craft-window` says the app owns the frame **and** the user has not opted into the system -/// title bar; the preference always forces the native frame back on. -/// -/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the -/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but -/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons -/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not -/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe. -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool { - matches!(frame, craft_window::Frame::App(_)) && !opted_into_system -} - -/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see -/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`); -/// Windows draws its own caption unless the user opted into the system title bar. -#[cfg(any(target_os = "macos", target_os = "windows"))] -fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool { - CUSTOM_TITLEBAR && !opted_into_system -} - -/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its -/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System -/// Title Bar preference. Call once at startup, before [`native_options`]. -/// -/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through -/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs -/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native -/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. -fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); - let opted_into_system = opted_into_system_title_bar(saved); - #[cfg(target_os = "linux")] - { - let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); - // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window - // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are - // present, the same two conditions the launch path checks below — that run is effectively - // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so - // the probe's own decision (app-drawn on GNOME) must stand. - let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); - if choice.overridden().is_none() && forced_to_x11 { - return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); - } - } - custom_titlebar_from(choice.frame(), opted_into_system) -} - -fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { - eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("VectorCraft") - .with_inner_size(window::DEFAULT_SIZE) - .with_min_inner_size(window::MIN_SIZE) - .with_drag_and_drop(true) - .with_decorations(!custom_titlebar) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - .with_icon(app_icon()) - .with_app_id("ai.storyteller.vectorcraft"), - #[cfg(feature = "windows7")] - renderer: eframe::Renderer::Glow, - ..Default::default() - } -} - /// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s. const USAGE: &str = "\ Usage: vectorcraft [options] [files…] diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs new file mode 100644 index 000000000..67a5a4a08 --- /dev/null +++ b/apps/vectorcraft/src/prefs.rs @@ -0,0 +1,107 @@ +use super::*; + +/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS), +/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux). +pub(crate) fn prefs_path() -> Option { + prefs_dir::prefs_path_for("VectorCraft", "vectorcraft") +} + +/// The same place under the project's former name (DrawCraft): read once if there are no +/// VectorCraft preferences yet, so settings survive the rename. +fn legacy_prefs_path() -> Option { + prefs_dir::prefs_path_for("DrawCraft", "drawcraft") +} + +/// Where the log files live: `logs` in the preferences folder (see `logging`). +pub(crate) fn log_dir() -> Option { + Some(prefs_path()?.parent()?.join("logs")) +} + +/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them. +pub(crate) fn prefs_enabled() -> bool { + std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() +} + +/// The saved UI preferences, read before the window opens (they hold its size and position). +/// A file read only in part, or with layouts converted from an older version, is copied beside +/// itself before anything saves over it. +pub(crate) fn read_prefs() -> Option { + if !prefs_enabled() { + return None; + } + if let Some(p) = prefs_path() + && let Ok(bytes) = std::fs::read(&p) + { + let read = vectorcraft_ui_egui::UiState::read_preferences(&bytes); + if read.as_ref().is_none_or(|read| !read.intact) { + keep_prefs_copy(&p, &bytes, "ui.unreadable"); + } + if read.as_ref().is_some_and(|read| read.converted) { + keep_prefs_copy(&p, &bytes, "ui.before-layout-migration"); + } + return read.map(|read| read.ui); + } + let bytes = legacy_prefs_path().and_then(|p| std::fs::read(p).ok())?; + decode_saved_prefs(&bytes) +} + +/// Saved preferences, with what this version cannot read of their docking data dropped and +/// older layouts converted (`vectorcraft_ui_egui::layout_migration`). +pub(crate) fn decode_saved_prefs(bytes: &[u8]) -> Option { + vectorcraft_ui_egui::UiState::read_preferences(bytes).map(|read| read.ui) +} + +/// Set when `ui.json` needed a copy kept ([`keep_prefs_copy`]) and none could be: the file is +/// then left as it is rather than overwritten. +pub(crate) static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Copy `ui.json` beside it as `.json` or the first free `-N.json`, before the next +/// save replaces it: `ui.unreadable` when it could not be read in full (a newer VectorCraft may +/// have written it), `ui.before-layout-migration` when its layouts were converted. +pub(crate) fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str) { + use std::io::Write as _; + for n in 1..100u32 { + let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") }; + match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return; + } + break; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => break, + } + } + KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed); +} + +pub(crate) fn load_prefs(app: &mut VectorcraftApp, saved: Option) { + if !prefs_enabled() { + return; + } + if let Some(ui) = saved { + app.ui = ui.sanitized(); + } + vectorcraft_ui_egui::prefs_dialog::restore(app); +} + +pub(crate) fn save_prefs(app: &VectorcraftApp) { + if !prefs_enabled() || KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + if let Some(p) = prefs_path() { + let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); + if let Ok(bytes) = prefs_bytes(app) { + // Preferences are best effort: a failed write keeps the previous file. + let _ = fileio::write_atomic(&p, &bytes); + } + } +} + +/// The preferences file's contents: the UI state with the engine preferences. +pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { + let mut ui = app.ui.clone(); + ui.engine_prefs = app.session.prefs.to_json(); + serde_json::to_vec_pretty(&ui) +} diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs new file mode 100644 index 000000000..3d538ec88 --- /dev/null +++ b/apps/vectorcraft/src/titlebar.rs @@ -0,0 +1,120 @@ +use super::*; + +/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets +/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches +/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon. +fn app_icon() -> egui::IconData { + #[cfg(target_os = "macos")] + let png: &[u8] = include_bytes!("../../../assets/app-icon/vectorcraft-macos-512.png"); + #[cfg(not(target_os = "macos"))] + let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png"); + eframe::icon_data::from_png_bytes(png).unwrap_or_default() +} + +/// Windows shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the +/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too. +#[cfg(windows)] +pub(crate) fn set_taskbar_icon(w: &winit::window::Window) { + use winit::platform::windows::WindowExtWindows as _; + let icon = app_icon(); + // An icon that can't be made leaves the generic one: nothing else depends on it. + if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) { + w.set_taskbar_icon(Some(i)); + } +} + +/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports. +#[cfg(feature = "wgpu")] +pub(crate) fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String { + format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type) +} + +/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). +/// macOS keeps its traffic lights over the integrated title strip. +/// +/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the +/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe. +#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] +pub(crate) const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); + +/// Whether the user asked for the system title bar in Preferences › User Interface › System Title +/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is +/// `false` (the default). This preference forces the desktop/native frame back on. +pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) +} + +/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate +/// decided and the user's System Title Bar preference. +/// +/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame +/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with +/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it +/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter), +/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly +/// when `craft-window` says the app owns the frame **and** the user has not opted into the system +/// title bar; the preference always forces the native frame back on. +/// +/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the +/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but +/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons +/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not +/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub(crate) fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool { + matches!(frame, craft_window::Frame::App(_)) && !opted_into_system +} + +/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see +/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`); +/// Windows draws its own caption unless the user opted into the system title bar. +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool { + CUSTOM_TITLEBAR && !opted_into_system +} + +/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its +/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System +/// Title Bar preference. Call once at startup, before [`native_options`]. +/// +/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through +/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs +/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native +/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. +pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); + let opted_into_system = opted_into_system_title_bar(saved); + #[cfg(target_os = "linux")] + { + let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); + // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window + // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are + // present, the same two conditions the launch path checks below — that run is effectively + // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so + // the probe's own decision (app-drawn on GNOME) must stand. + let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); + if choice.overridden().is_none() && forced_to_x11 { + return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); + } + } + custom_titlebar_from(choice.frame(), opted_into_system) +} + +pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("VectorCraft") + .with_inner_size(window::DEFAULT_SIZE) + .with_min_inner_size(window::MIN_SIZE) + .with_drag_and_drop(true) + .with_decorations(!custom_titlebar) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + .with_icon(app_icon()) + .with_app_id("ai.storyteller.vectorcraft"), + #[cfg(feature = "windows7")] + renderer: eframe::Renderer::Glow, + ..Default::default() + } +} From 48b3ea91f0d4f5ec13e841a5cf72e15c9e25a50d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:59:59 +0000 Subject: [PATCH 148/199] refactor(engine): move cmd/brushsym.rs items to cmd/brushsym/{brushes,symbols}.rs --- crates/engine/src/cmd/brushsym.rs | 1147 +-------------------- crates/engine/src/cmd/brushsym/brushes.rs | 447 ++++++++ crates/engine/src/cmd/brushsym/symbols.rs | 688 ++++++++++++ 3 files changed, 1149 insertions(+), 1133 deletions(-) create mode 100644 crates/engine/src/cmd/brushsym/brushes.rs create mode 100644 crates/engine/src/cmd/brushsym/symbols.rs diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index d485366ea..b677b3003 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -21,6 +21,20 @@ use super::swatch::{anchor, names_param}; use super::*; use crate::EngineError; +mod brushes; +mod symbols; + +pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; +use brushes::{ + brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, + brush_selection_art, brush_set_current, brush_unused, expand_brush, +}; +pub(crate) use symbols::{natural_size, normalize, place_xf, set_natural_size}; +use symbols::{ + symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place, + symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_update, +}; + const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; pub(crate) const CURRENT_SYMBOL: &str = "currentSymbol"; /// Half the instance box `Node::geometric_bounds` uses for symbol instances. @@ -266,96 +280,6 @@ fn name_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`")) } -// ---------- brushes ---------- - -/// Brushable nodes (paths and compound paths) under `ids`. -pub(crate) fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { - let mut out = vec![]; - for id in ids { - let Some(n) = d.node(*id) else { continue }; - n.walk(&mut |c| { - let own = matches!(c.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }); - let in_compound = d.parent_of(c.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); - if own && !in_compound && !out.contains(&c.id) { - out.push(c.id); - } - }); - } - out -} - -fn brush_list(s: &mut Session, _: &Value) -> Result { - let d = &s.doc()?.doc; - let list: Vec = brush::library(d).iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); - Ok(json!({ "brushes": list, "current": brush::current(d) })) -} - -fn brush_get(s: &mut Session, p: &Value) -> Result { - let name = name_param(p, "brush.get")?; - let b = brush::find(&s.doc()?.doc, name).ok_or_else(|| EngineError::Other(format!("no brush named `{name}`")))?; - Ok(serde_json::to_value(b).unwrap_or_default()) -} - -/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]). -fn brush_selection_art(s: &mut Session, p: &Value) -> Result { - let art = selection_art(s, p)?; - Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) -} - -/// The stroke paint a brush gives paths without one: the current stroke colour, else black. -pub(crate) fn stroke_paint(s: &Session) -> Paint { - if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } -} - -/// Paint paths `ids` of `d` with brush `name` (a path without a stroke takes `stroke`). -pub(crate) fn apply_brush(d: &mut Document, ids: &[NodeId], name: &str, stroke: &Paint) { - for id in ids { - let Some(n) = d.node_mut(*id) else { continue }; - if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { - n.appearance.set_stroke(stroke.clone()); - } - if let Some(st) = n.appearance.stroke_mut() { - if st.width <= 0.0 { - st.width = 1.0; - } - st.brush = Some(name.to_string()); - } - } -} - -fn brush_apply(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.apply"; - let name = name_param(p, C)?.to_string(); - if brush::find(&s.doc()?.doc, &name).is_none() { - return Err(bad(C, format!("no brush named `{name}`"))); - } - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - let stroke = stroke_paint(s); - s.edit("Apply Brush", |d, _| { - d.unknown.insert(brush::CURRENT_KEY.into(), json!(name)); - apply_brush(d, &ids, &name, &stroke); - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn brush_remove(s: &mut Session, p: &Value) -> Result { - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - s.edit("Remove Brush Stroke", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - for it in &mut n.appearance.items { - if let AppearanceItem::Stroke(st) = it { - st.brush = None; - } - } - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - /// Set a document preference outside the undo history. fn set_unknown(s: &mut Session, key: &str, v: Option) -> Result<()> { let st = s.doc_mut()?; @@ -367,19 +291,6 @@ fn set_unknown(s: &mut Session, key: &str, v: Option) -> Result<()> { Ok(()) } -fn brush_set_current(s: &mut Session, p: &Value) -> Result { - match str_param(p, "name") { - Some(n) => { - if brush::find(&s.doc()?.doc, n).is_none() { - return Err(bad("brush.setCurrent", format!("no brush named `{n}`"))); - } - set_unknown(s, brush::CURRENT_KEY, Some(json!(n)))?; - } - None => set_unknown(s, brush::CURRENT_KEY, None)?, - } - ok() -} - /// The selection as one art node (a group for several objects), ids kept. pub(crate) fn selection_art(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; @@ -391,1033 +302,3 @@ pub(crate) fn selection_art(s: &Session, p: &Value) -> Result> { _ => Some(Node::group(NodeId(0), nodes.into_iter().map(Arc::new).collect())), }) } - -/// The definition fields that `params` change field by field when the brush has them as objects: -/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only -/// `start` keeps `end`. -const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"]; - -fn merge(base: &mut Value, patch: &Value) { - if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { - for (k, v) in pt { - match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) { - (Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => { - for (f, fv) in fields { - inner.insert(f.clone(), fv.clone()); - } - } - _ => { - b.insert(k.clone(), v.clone()); - } - } - } - } -} - -/// Sets brush `to` on the strokes in `items` that paint with brush `from`. -fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) { - for it in items { - if let AppearanceItem::Stroke(st) = it - && st.brush.as_deref() == Some(from) - { - st.brush = to.map(str::to_string); - } - } -} - -/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`. -fn paints_with(n: &Node, name: &str) -> bool { - let mut hit = false; - vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name))); - }); - hit -} - -/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that -/// hold such a stroke, and those that hold them, are copied. -fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) { - set_stroke_brush(&mut n.appearance.items, from, to); - if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) - && paints_with(&mask.art, from) - { - set_brush_in(Arc::make_mut(&mut mask.art), from, to); - } - for c in n.children_mut().into_iter().flatten() { - if paints_with(c, from) { - set_brush_in(Arc::make_mut(c), from, to); - } - } -} - -/// Returns the art a brush is made of ([`Brush::art`]) for editing. -fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> { - use vectorcraft_brush::BrushKind; - match &mut b.kind { - BrushKind::Scatter(s) => vec![&mut s.art], - BrushKind::Art(a) => vec![&mut a.art], - BrushKind::Pattern(p) => { - std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect() - } - BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], - } -} - -/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art -/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern -/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`. -fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) { - let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); - let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); - for n in trees.chain(patterns.flat_map(|p| &mut p.art)) { - if paints_with(n, from) { - set_brush_in(Arc::make_mut(n), from, to); - } - } - for g in &mut d.graphic_styles { - set_stroke_brush(&mut g.appearance.items, from, to); - } - for n in lib.iter_mut().flat_map(brush_art_mut) { - set_brush_in(n, from, to); - } -} - -fn save_library(s: &mut Session, label: &str, mut lib: Vec, renames: &[(String, Option)]) -> Result<()> { - s.edit(label, |d, _| { - for (from, to) in renames { - rename_brush_strokes(d, &mut lib, from, to.as_deref()); - if brush::current(d).as_deref() == Some(from.as_str()) { - match to { - Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), - None => d.unknown.remove(brush::CURRENT_KEY), - }; - } - } - brush::store(d, &lib); - Ok(()) - }) -} - -fn brush_new(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.new"; - let ty = str_param(p, "type").ok_or_else(|| bad(C, "missing `type`"))?; - let art_key = match ty { - "calligraphic" | "bristle" => None, - "scatter" | "art" => Some("art"), - "pattern" => Some("side"), - o => return Err(bad(C, format!("unknown brush type `{o}`"))), - }; - let mut lib = brush::library(&s.doc()?.doc); - let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); - let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string); - let name = brush::unique_name(&lib, &base_name); - if ty == "bristle" { - bristle_name_fits(C, &name)?; - } - let mut v = json!({"name": name, "type": ty}); - if let Some(params) = p.get("params") { - merge(&mut v, params); - v["name"] = json!(name); - v["type"] = json!(ty); - } - if let Some(k) = art_key - && v.get(k).is_none() - { - let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; - if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { - return Err(bad(C, "the selected art has no width")); - } - v[k] = serde_json::to_value(&art).unwrap_or_default(); - } - lib.push(parse_brush(C, v)?); - save_library(s, "New Brush", lib, &[])?; - Ok(json!({ "name": name })) -} - -/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. -fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { - let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); - if count > max { - return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}"))); - } - Ok(()) -} - -/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]). -fn parse_brush(cmd: &str, v: Value) -> Result { - Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}"))) -} - -fn brush_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.delete"; - let names = names_param(p, C)?; - let mut lib = brush::library(&s.doc()?.doc); - if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) { - return Err(bad(C, format!("no brush named `{name}`"))); - } - lib.retain(|b| !names.contains(&b.name)); - let renames: Vec<(String, Option)> = names.into_iter().map(|n| (n, None)).collect(); - save_library(s, "Delete Brush", lib, &renames)?; - ok() -} - -/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their -/// type in library order: before the first brush of that type at or after `to` that isn't moving, -/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type` -/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step. -fn brush_move(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.move"; - const WITHIN: &str = "brushes move only within their type"; - let names = names_param(p, C)?; - check_numbers(p, &["to"], C)?; - let to = match p.get("to").filter(|v| !v.is_null()) { - Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?), - None => None, - }; - let old = brush::library(&s.doc()?.doc); - let mut lib = old.clone(); - // `names_param` refuses an empty list, so the loop sets `kind`. - let mut kind = ""; - for name in &names { - let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - if !kind.is_empty() && kind != b.kind.type_id() { - return Err(bad(C, WITHIN)); - } - kind = b.kind.type_id(); - } - if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) { - return Err(bad(C, WITHIN)); - } - let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect(); - let before = anchor(&same, to, &names, |b| &b.name); - let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len()); - let mut items = vec![]; - for name in &names { - if let Some(i) = lib.iter().position(|b| &b.name == name) { - items.push(lib.remove(i)); - } - } - let at = match before { - Some(a) => lib.iter().position(|b| b.name == a), - None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1), - }; - let at = at.unwrap_or(first).min(lib.len()); - lib.splice(at..at, items); - if lib != old { - save_library(s, "Move Brush", lib, &[])?; - } - Ok(json!({ "moved": names.len() })) -} - -/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given -/// without `names`, names the copy. -fn brush_duplicate(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.duplicate"; - let names = names_param(p, C)?; - let before = brush::library(&s.doc()?.doc); - let mut lib = before.clone(); - let mut out = vec![]; - for name in &names { - let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none()); - let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string); - b.name = brush::unique_name(&lib, &new_name); - out.push(b.name.clone()); - lib.push(b); - } - save_library(s, "Duplicate Brush", lib, &[])?; - Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) }) -} - -/// Adds the names of the brushes the strokes of `items` paint with to `out`. -fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec) { - for it in items { - if let AppearanceItem::Stroke(st) = it - && let Some(b) = &st.brush - { - out.push(b.clone()); - } - } -} - -/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to -/// `out`. -fn art_brushes(n: &Node, out: &mut Vec) { - vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out)); -} - -/// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph -/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush -/// that a counted stroke paints with. -fn brush_unused(s: &mut Session, _: &Value) -> Result { - let d = &s.doc()?.doc; - let lib = brush::library(d); - let mut todo = vec![]; - let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); - trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); - d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo)); - let mut used = BTreeSet::new(); - while let Some(name) = todo.pop() { - if !used.contains(&name) { - lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo)); - used.insert(name); - } - } - let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); - Ok(json!({ "names": names })) -} - -fn brush_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.options"; - let name = name_param(p, C)?.to_string(); - let mut lib = brush::library(&s.doc()?.doc); - let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - let mut v = serde_json::to_value(&lib[i]).unwrap_or_default(); - if let Some(params) = p.get("params") { - merge(&mut v, params); - } - let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); - if let Some(nn) = &new_name { - if lib.iter().any(|b| &b.name == nn) { - return Err(bad(C, format!("a brush named `{nn}` exists"))); - } - // A `newName` that only trims the white space around the saved name is taken at any length. - if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() { - bristle_name_fits(C, nn)?; - } - v["name"] = json!(nn); - } else { - v["name"] = json!(name); - } - v["type"] = json!(lib[i].kind.type_id()); - lib[i] = parse_brush(C, v)?; - let out = lib[i].name.clone(); - let rename = new_name.map(|nn| (name, Some(nn))); - save_library(s, "Brush Options", lib, rename.as_slice())?; - Ok(json!({ "name": out })) -} - -fn expand_brush(s: &mut Session, p: &Value) -> Result { - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - let ids: Vec = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); - if ids.is_empty() { - return Err(EngineError::Other("Expand: no brush strokes selected".into())); - } - let out = s.edit("Expand", |d, sel| { - let mut out = vec![]; - for id in &ids { - let Some(n) = d.node(*id).cloned() else { continue }; - let Some(g) = brush::expand(d, &n) else { continue }; - let g = d.reid(&g); - let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; - let gid = g.id; - d.remove(*id)?; - d.insert(par, idx, g)?; - out.push(gid); - } - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&out) })) -} - -fn brush_freehand(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); - let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; - let r = (spec.run)(s, p)?; - let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; - s.edit("Paintbrush", |d, _| { - if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { - st.brush = Some(name.clone()); - } - Ok(()) - })?; - Ok(r) -} - -// ---------- symbols ---------- - -pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { - d.unknown - .get(SIZES_KEY) - .and_then(|m| m.get(name)) - .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) - .unwrap_or((2.0 * HALF, 2.0 * HALF)) -} - -pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { - let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); - if let Some(o) = m.as_object_mut() { - match size { - Some((w, h)) => o.insert(name.into(), json!([w, h])), - None => o.remove(name), - }; - } -} - -/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`. -pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { - let c = at - reg.offset_from_center(w, h); - Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) -} - -fn registration_param(p: &Value, cmd: &str) -> Result> { - match p.get("registration") { - None => Ok(None), - Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")), - } -} - -fn export_type_param(p: &Value, cmd: &str) -> Result> { - match str_param(p, "exportType") { - None => Ok(None), - Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))), - } -} - -/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural -/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it -/// on with guides `g` (the symbol's current ones, else the centre third, when none are given). -fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { - let on = match p.get("nineSlice") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?), - }; - let guides = - match p.get("nineSliceGuides") { - None | Some(Value::Null) => None, - Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { - bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1") - })?), - }; - Ok(match (on, guides) { - (Some(false), _) => Some(None), - (None, None) => None, - (_, Some(g)) => Some(Some(g)), - (Some(true), None) => Some(Some(current.unwrap_or_default())), - }) -} - -fn nine_slice_json(d: &Document, name: &str) -> Value { - d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value) -} - -fn rename_natural_size(d: &mut Document, from: &str, to: &str) { - if from == to { - return; - } - let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned(); - set_natural_size(d, from, None); - if let Some(v) = size - && let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64)) - { - set_natural_size(d, to, Some((w, h))); - } -} - -/// Normalise art for a symbol definition: returns (art, natural size, centre in the document). -pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { - let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; - let (w, h) = (b.width().max(1.0), b.height().max(1.0)); - let c = b.center(); - let n = Affine::scale_non_uniform(2.0 * HALF / w, 2.0 * HALF / h) * Affine::translate(-c.to_vec2()); - art.transform(n, false); - Ok((art, (w, h), c)) -} - -fn instances_of(d: &Document, name: Option<&str>) -> Vec { - let mut v = vec![]; - d.walk(|n| { - if let NodeKind::SymbolInstance { symbol, .. } = &n.kind - && name.is_none_or(|x| x == symbol) - { - v.push(n.id); - } - }); - v -} - -fn symbol_names(d: &Document) -> Vec { - d.symbols.iter().map(|s| s.name.clone()).collect() -} - -fn current_symbol(d: &Document) -> Option { - d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str).filter(|n| d.symbols.iter().any(|s| s.name == *n)).map(str::to_string) -} - -fn symbol_list(s: &mut Session, _: &Value) -> Result { - let d = &s.doc()?.doc; - let list: Vec = d - .symbols - .iter() - .map(|sym| { - let (w, h) = natural_size(d, &sym.name); - json!({ - "name": sym.name, - "instances": instances_of(d, Some(&sym.name)).len(), - "size": [w, h], - "registration": d.symbol_registration(&sym.name).0, - "exportType": d.symbol_export_type(&sym.name).id(), - "nineSlice": nine_slice_json(d, &sym.name), - }) - }) - .collect(); - Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) -} - -/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id. -fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { - let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; - if separate(d, ids) { - let id = d.alloc_id(); - return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?); - } - let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; - let id = d.alloc_id(); - d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?; - for r in ids { - d.remove(*r)?; - } - Ok(id) -} - -fn symbol_new(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.new"; - let ids = roots(s, p)?; - let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; - super::threads::keep_shown_text(&s.doc()?.doc, &mut art); - let (art, size, c) = normalize(art)?; - let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol")); - let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); - let export = export_type_param(p, C)?.unwrap_or_default(); - let nine = nine_slice_param(p, C, None)?.flatten(); - // Keep the art where it was: the geometric centre stays at `c`, whatever the registration is. - let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg); - let id = s.edit("New Symbol", |d, sel| { - let art = d.reid(&art); - d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) }); - set_natural_size(d, &name, Some(size)); - d.set_symbol_registration(&name, reg); - d.set_symbol_export_type(&name, export); - d.set_symbol_nine_slice(&name, nine); - d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); - let threaded = super::threads::frames(d); - let id = replace_with_instance(d, &ids, &name, place)?; - // Frames of threaded text in the symbol leave their threads with the text they show. - super::threads::leave_threads(d, &threaded); - sel.set([id]); - Ok(id) - })?; - Ok( - json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }), - ) -} - -fn symbol_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.options"; - let name = resolve_symbol(&s.doc()?.doc, p, C)?; - let export = export_type_param(p, C)?; - let reg = registration_param(p, C)?; - let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?; - let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() { - return Err(bad(C, "pass newName, registration, exportType and/or nineSlice")); - } - if let Some(ref n) = new_name - && n != &name - && s.doc()?.doc.symbols.iter().any(|x| x.name == *n) - { - return Err(bad(C, format!("a symbol named `{n}` already exists"))); - } - let final_name = new_name.clone().unwrap_or_else(|| name.clone()); - s.edit("Symbol Options", |d, _| { - if let Some(ref n) = new_name - && n != &name - { - if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { - sym.name = n.clone(); - } - for id in instances_of(d, Some(&name)) { - if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) { - *symbol = n.clone(); - } - } - rename_natural_size(d, &name, n); - d.rename_symbol_registration(&name, n); - d.rename_symbol_export_type(&name, n); - d.rename_symbol_nine_slice(&name, n); - if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { - d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); - } - } - if let Some(r) = reg { - d.set_symbol_registration(&final_name, r); - } - if let Some(kind) = export { - d.set_symbol_export_type(&final_name, kind); - } - if let Some(g) = nine { - d.set_symbol_nine_slice(&final_name, g); - } - Ok(()) - })?; - let d = &s.doc()?.doc; - Ok(json!({ - "name": final_name, - "registration": d.symbol_registration(&final_name).0, - "exportType": d.symbol_export_type(&final_name).id(), - "nineSlice": nine_slice_json(d, &final_name), - })) -} - -fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result { - match str_param(p, "name") { - Some(n) if d.symbols.iter().any(|s| s.name == n) => Ok(n.to_string()), - Some(n) => Err(bad(cmd, format!("no symbol named `{n}`"))), - None => { - current_symbol(d).or_else(|| symbol_names(d).into_iter().next()).ok_or_else(|| EngineError::Other("the document has no symbols".into())) - } - } -} - -fn symbol_place(s: &mut Session, p: &Value) -> Result { - let d = &s.doc()?.doc; - let name = resolve_symbol(d, p, "symbol.place")?; - let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); - let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?); - let size = natural_size(d, &name); - let reg = d.symbol_registration(&name); - let parent = s.doc()?.target_parent()?; - let id = s.edit("Place Symbol Instance", |d, sel| { - let id = d.alloc_id(); - d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?; - sel.set([id]); - Ok(id) - })?; - Ok(json!({ "id": id.0 })) -} - -/// The plain art of instance `inst` in document space (fresh ids). -fn expanded_instance(d: &mut Document, inst: &Node) -> Option { - let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; - let sym = d.symbols.iter().find(|s| &s.name == symbol)?; - let mut art = (*sym.art).clone(); - if let Some(f) = inst.appearance.fill() - && let Some(c) = f.paint.color() - { - brush::tint_node(&mut art, &c, f.opacity); - } - art.transform(*xf, false); - let mut art = d.reid(&art); - if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { - if !matches!(art.kind, NodeKind::Group { .. }) { - let id = d.alloc_id(); - art = Node::group(id, vec![Arc::new(art)]); - } - art.opacity *= inst.opacity; - art.blend = inst.blend; - } - Some(art) -} - -fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { - let mut out = vec![]; - for id in ids { - let Some(inst) = d.node(*id).cloned() else { continue }; - let Some(art) = expanded_instance(d, &inst) else { continue }; - let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; - let aid = art.id; - d.remove(*id)?; - d.insert(par, idx, art)?; - out.push(aid); - } - Ok(out) -} - -fn selected_instances(s: &Session, p: &Value) -> Result> { - let d = &s.doc()?.doc; - let mut out = vec![]; - for id in roots(s, p)? { - if let Some(n) = d.node(id) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::SymbolInstance { .. }) { - out.push(c.id); - } - }); - } - } - Ok(out) -} - -fn symbol_break_link(s: &mut Session, p: &Value) -> Result { - let ids = selected_instances(s, p)?; - if ids.is_empty() { - return Err(EngineError::Other("no symbol instances selected".into())); - } - let out = s.edit("Break Link to Symbol", |d, sel| { - let out = break_links(d, &ids)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&out) })) -} - -fn symbol_edit(s: &mut Session, p: &Value) -> Result { - let ids = selected_instances(s, p)?; - let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?; - let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) { - Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(), - _ => return Err(EngineError::Other("select a symbol instance".into())), - }; - let out = s.edit("Edit Symbol", |d, sel| { - let out = break_links(d, &[first])?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "name": name, "ids": ids_json(&out) })) -} - -fn symbol_update(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.update"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { - return Err(bad(C, format!("no symbol named `{name}`"))); - } - let ids = roots(s, p)?; - let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; - super::threads::keep_shown_text(&s.doc()?.doc, &mut art); - let (art, size, c) = normalize(art)?; - let old = natural_size(&s.doc()?.doc, &name); - let id = s.edit("Redefine Symbol", |d, sel| { - let art = d.reid(&art); - if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { - sym.art = Arc::new(art); - } - set_natural_size(d, &name, Some(size)); - let fix = Affine::scale_non_uniform(size.0 / old.0, size.1 / old.1); - for i in instances_of(d, Some(&name)) { - if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(i).map(|n| &mut n.kind) { - *xf *= fix; - } - } - let reg = d.symbol_registration(&name); - let threaded = super::threads::frames(d); - let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; - // Frames of threaded text in the symbol leave their threads with the text they show. - super::threads::leave_threads(d, &threaded); - sel.set([id]); - Ok(id) - })?; - Ok(json!({ "name": name, "id": id.0 })) -} - -fn symbol_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.delete"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { - return Err(bad(C, format!("no symbol named `{name}`"))); - } - let expand = switch_or(p, "expandInstances", true, C)?; - s.edit("Delete Symbol", |d, _| { - let inst = instances_of(d, Some(&name)); - if expand { - break_links(d, &inst)?; - } else { - remove_each(d, &inst)?; - } - d.symbols.retain(|x| x.name != name); - set_natural_size(d, &name, None); - d.set_symbol_registration(&name, SymbolRegistration::CENTER); - d.clear_symbol_export_type(&name); - d.set_symbol_nine_slice(&name, None); - if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { - d.unknown.remove(CURRENT_SYMBOL); - } - Ok(()) - })?; - ok() -} - -fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.duplicate"; - let name = name_param(p, C)?.to_string(); - let d = &s.doc()?.doc; - let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?; - let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()); - let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string)); - let size = natural_size(d, &name); - let reg = d.symbol_registration(&name); - let export = d.symbol_export_type(&name); - let nine = d.symbol_nine_slice(&name); - s.edit("Duplicate Symbol", |d, _| { - let art = d.reid(&sym.art); - d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); - set_natural_size(d, &new, Some(size)); - d.set_symbol_registration(&new, reg); - d.set_symbol_export_type(&new, export); - d.set_symbol_nine_slice(&new, nine); - Ok(()) - })?; - Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) })) -} - -fn symbol_replace(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.replace"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { - return Err(bad(C, format!("no symbol named `{name}`"))); - } - let ids = selected_instances(s, p)?; - if ids.is_empty() { - return Err(EngineError::Other("no symbol instances selected".into())); - } - s.edit("Replace Symbol", |d, _| { - let new = natural_size(d, &name); - for id in &ids { - let old = match d.node(*id).map(|n| &n.kind) { - Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol), - _ => continue, - }; - if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { - *symbol = name.clone(); - *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn symbol_set_current(s: &mut Session, p: &Value) -> Result { - let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; - set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; - ok() -} - -fn symbol_select_instances(s: &mut Session, p: &Value) -> Result { - let name = resolve_symbol(&s.doc()?.doc, p, "symbol.selectInstances")?; - let ids = instances_of(&s.doc()?.doc, Some(&name)); - let n = ids.len(); - s.select(|_, sel| sel.set(ids))?; - Ok(json!({ "count": n })) -} - -// ---------- symbolism tools ---------- - -fn points(p: &Value, cmd: &str) -> Result> { - let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing points [[x,y]…]"))?; - let v: Vec = a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(); - if v.is_empty() || v.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { - return Err(bad(cmd, "points must be finite [x,y] pairs")); - } - Ok(v) -} - -/// Deterministic per-point jitter in [-1, 1]² (so previews re-applied on the snapshot agree). -fn jitter(i: usize, q: Point) -> (f64, f64, f64) { - let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ i as u64; - for v in [(q.x * 16.0).round() as i64 as u64, (q.y * 16.0).round() as i64 as u64] { - h ^= v; - h = h.wrapping_mul(0x0000_0100_0000_01b3); - h ^= h >> 29; - } - let f = |k: u32| ((h.rotate_left(k * 21) & 0xffff) as f64 / 65535.0) * 2.0 - 1.0; - (f(0), f(1), f(2)) -} - -fn is_symbol_set(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some("Symbol Set") -} - -fn symbol_spray(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.spray"; - let pts = points(p, C)?; - let st = s.doc()?; - let name = resolve_symbol(&st.doc, p, C)?; - let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); - let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0); - let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0); - let alt = switch_or(p, "alt", false, C)?; - let size = natural_size(&st.doc, &name); - let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); - // Only a new Symbol Set needs a layer that takes new art. - let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; - if alt { - // Remove instances of this symbol under the brush (within the set, or anywhere). - let d = &st.doc; - let scope: Vec = match set { - Some(g) => d.node(g).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(), - None => instances_of(d, Some(&name)), - }; - let doomed: Vec = scope - .into_iter() - .filter(|id| { - d.node(*id).is_some_and(|n| { - matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name) - && n.geometric_bounds().is_some_and(|b| pts.iter().any(|q| q.distance(b.center()) <= radius)) - }) - }) - .collect(); - let n = doomed.len(); - if n > 0 { - s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?; - } - return Ok(json!({ "count": n })); - } - // Keep instances at least this far apart along the stroke (denser = closer). - let min_gap = size.0.max(size.1) * scale * (1.15 - density * 0.1).max(0.1); - let mut placed: Vec = vec![]; - for (i, q) in pts.iter().enumerate() { - let (jx, jy, _) = jitter(i, *q); - let c = Point::new(q.x + jx * radius * 0.5, q.y + jy * radius * 0.5); - if placed.last().is_some_and(|l| l.distance(c) < min_gap) && i > 0 { - continue; - } - placed.push(c); - } - let count = placed.len(); - let reg = s.doc()?.doc.symbol_registration(&name); - let id = s.edit("Symbol Sprayer", |d, sel| { - let mut nodes = vec![]; - for (i, c) in placed.iter().enumerate() { - let (_, _, js) = jitter(i + 7, *c); - let k = scale * (1.0 + js * 0.1); - let id = d.alloc_id(); - nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) })); - } - let gid = match set { - Some(g) => { - for n in nodes { - d.insert(Some(g), usize::MAX, n)?; - } - g - } - None => { - let gid = d.alloc_id(); - let mut g = Node::group(gid, nodes.into_iter().map(Arc::new).collect()); - g.name = Some("Symbol Set".into()); - d.insert(parent, usize::MAX, g)?; - gid - } - }; - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": id.0, "count": count })) -} - -fn instance_center(n: &Node) -> Option { - match &n.kind { - NodeKind::SymbolInstance { xf, .. } => Some(*xf * Point::ZERO), - _ => None, - } -} - -fn symbol_adjust(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.adjust"; - let tool = str_param(p, "tool").ok_or_else(|| bad(C, "missing `tool`"))?.to_string(); - if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) { - return Err(bad(C, format!("unknown tool `{tool}`"))); - } - super::paint::check_keep_model(p, C)?; - let pts = points(p, C)?; - let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); - let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; - let alt = switch_or(p, "alt", false, C)?; - let st = s.doc()?; - let color = match p.get("color").and_then(color_value) { - Some(c) => super::paint::new_color(s, p, c), - None => s.paint.fill.color().unwrap_or(Color::BLACK), - }; - let style_paint = match str_param(p, "style") { - Some(n) => st.doc.graphic_styles.iter().find(|g| g.name == n).map(|g| g.appearance.fill_paint()), - None => st.doc.graphic_styles.get(1).or(st.doc.graphic_styles.first()).map(|g| g.appearance.fill_paint()), - }; - let candidates: Vec = { - let d = &st.doc; - let mut v = vec![]; - if st.selection.is_empty() { - v = instances_of(d, None); - } else { - for id in &st.selection.objects { - if let Some(n) = d.node(*id) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !v.contains(&c.id) { - v.push(c.id); - } - }); - } - } - } - let editable = d.editable(); - v.retain(|id| editable(*id)); - v - }; - let label = match tool.as_str() { - "shift" => "Symbol Shifter", - "scrunch" => "Symbol Scruncher", - "size" => "Symbol Sizer", - "spin" => "Symbol Spinner", - "stain" => "Symbol Stainer", - "screen" => "Symbol Screener", - _ => "Symbol Styler", - }; - let count = s.edit(label, |d, _| { - let mut touched = std::collections::BTreeSet::new(); - for (j, q) in pts.iter().enumerate() { - let delta = if j > 0 { *q - pts[j - 1] } else { Vec2::ZERO }; - for id in &candidates { - let Some(n) = d.node_mut(*id) else { continue }; - let Some(c) = instance_center(n) else { continue }; - let dist = c.distance(*q); - if dist > radius { - continue; - } - let w = intensity * (1.0 - dist / radius).max(0.0); - if w <= 0.0 { - continue; - } - touched.insert(*id); - let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); - match tool.as_str() { - "shift" => n.transform(Affine::translate(delta * w.min(1.0)), false), - "scrunch" => { - let v = (*q - c) * (0.15 * w) * if alt { -1.0 } else { 1.0 }; - n.transform(Affine::translate(v), false); - } - "size" => { - let k = 1.0 + 0.08 * w; - n.transform(about(Affine::scale(if alt { 1.0 / k } else { k })), false); - } - "spin" => { - // Turn towards the drag direction; a click spins a little. - let a = if delta.hypot() > 1e-9 { (c - *q).cross(delta).signum() * 0.1 * w } else { 0.05 * w }; - n.transform(about(Affine::rotate(if alt { -a } else { a })), false); - } - "stain" | "style" => { - let paint = if tool == "style" { style_paint.clone().unwrap_or(Paint::solid(color)) } else { Paint::solid(color) }; - if tool == "style" && alt { - n.appearance.items.clear(); - continue; - } - let cur = n.appearance.fill().filter(|f| f.paint == paint).map(|f| f.opacity).unwrap_or(0.0); - let step = if tool == "style" { 1.0 } else { 0.15 * w as f32 }; - let o = if alt { (cur - step).max(0.0) } else { (cur + step).min(1.0) }; - n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); - if o > 0.0 { - let mut f = FillLayer::new(paint); - f.opacity = o; - n.appearance.items.insert(0, AppearanceItem::Fill(f)); - } - } - _ => { - let step = (0.1 * w) as f32; - n.opacity = if alt { (n.opacity + step).min(1.0) } else { (n.opacity - step).max(0.05) }; - } - } - } - } - Ok(touched.len()) - })?; - Ok(json!({ "count": count })) -} diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs new file mode 100644 index 000000000..18f744b1d --- /dev/null +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -0,0 +1,447 @@ +use super::*; + +// ---------- brushes ---------- + +/// Brushable nodes (paths and compound paths) under `ids`. +pub(crate) fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in ids { + let Some(n) = d.node(*id) else { continue }; + n.walk(&mut |c| { + let own = matches!(c.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }); + let in_compound = d.parent_of(c.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if own && !in_compound && !out.contains(&c.id) { + out.push(c.id); + } + }); + } + out +} + +pub(super) fn brush_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let list: Vec = brush::library(d).iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); + Ok(json!({ "brushes": list, "current": brush::current(d) })) +} + +pub(super) fn brush_get(s: &mut Session, p: &Value) -> Result { + let name = name_param(p, "brush.get")?; + let b = brush::find(&s.doc()?.doc, name).ok_or_else(|| EngineError::Other(format!("no brush named `{name}`")))?; + Ok(serde_json::to_value(b).unwrap_or_default()) +} + +/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]). +pub(super) fn brush_selection_art(s: &mut Session, p: &Value) -> Result { + let art = selection_art(s, p)?; + Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) +} + +/// The stroke paint a brush gives paths without one: the current stroke colour, else black. +pub(crate) fn stroke_paint(s: &Session) -> Paint { + if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } +} + +/// Paint paths `ids` of `d` with brush `name` (a path without a stroke takes `stroke`). +pub(crate) fn apply_brush(d: &mut Document, ids: &[NodeId], name: &str, stroke: &Paint) { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { + n.appearance.set_stroke(stroke.clone()); + } + if let Some(st) = n.appearance.stroke_mut() { + if st.width <= 0.0 { + st.width = 1.0; + } + st.brush = Some(name.to_string()); + } + } +} + +pub(super) fn brush_apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.apply"; + let name = name_param(p, C)?.to_string(); + if brush::find(&s.doc()?.doc, &name).is_none() { + return Err(bad(C, format!("no brush named `{name}`"))); + } + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + let stroke = stroke_paint(s); + s.edit("Apply Brush", |d, _| { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(name)); + apply_brush(d, &ids, &name, &stroke); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn brush_remove(s: &mut Session, p: &Value) -> Result { + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + s.edit("Remove Brush Stroke", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + for it in &mut n.appearance.items { + if let AppearanceItem::Stroke(st) = it { + st.brush = None; + } + } + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn brush_set_current(s: &mut Session, p: &Value) -> Result { + match str_param(p, "name") { + Some(n) => { + if brush::find(&s.doc()?.doc, n).is_none() { + return Err(bad("brush.setCurrent", format!("no brush named `{n}`"))); + } + set_unknown(s, brush::CURRENT_KEY, Some(json!(n)))?; + } + None => set_unknown(s, brush::CURRENT_KEY, None)?, + } + ok() +} + +/// The definition fields that `params` change field by field when the brush has them as objects: +/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only +/// `start` keeps `end`. +const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"]; + +fn merge(base: &mut Value, patch: &Value) { + if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { + for (k, v) in pt { + match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) { + (Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => { + for (f, fv) in fields { + inner.insert(f.clone(), fv.clone()); + } + } + _ => { + b.insert(k.clone(), v.clone()); + } + } + } + } +} + +/// Sets brush `to` on the strokes in `items` that paint with brush `from`. +fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && st.brush.as_deref() == Some(from) + { + st.brush = to.map(str::to_string); + } + } +} + +/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`. +fn paints_with(n: &Node, name: &str) -> bool { + let mut hit = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name))); + }); + hit +} + +/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that +/// hold such a stroke, and those that hold them, are copied. +fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) { + set_stroke_brush(&mut n.appearance.items, from, to); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && paints_with(&mask.art, from) + { + set_brush_in(Arc::make_mut(&mut mask.art), from, to); + } + for c in n.children_mut().into_iter().flatten() { + if paints_with(c, from) { + set_brush_in(Arc::make_mut(c), from, to); + } + } +} + +/// Returns the art a brush is made of ([`Brush::art`]) for editing. +fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> { + use vectorcraft_brush::BrushKind; + match &mut b.kind { + BrushKind::Scatter(s) => vec![&mut s.art], + BrushKind::Art(a) => vec![&mut a.art], + BrushKind::Pattern(p) => { + std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect() + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], + } +} + +/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art +/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern +/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`. +fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) { + let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); + for n in trees.chain(patterns.flat_map(|p| &mut p.art)) { + if paints_with(n, from) { + set_brush_in(Arc::make_mut(n), from, to); + } + } + for g in &mut d.graphic_styles { + set_stroke_brush(&mut g.appearance.items, from, to); + } + for n in lib.iter_mut().flat_map(brush_art_mut) { + set_brush_in(n, from, to); + } +} + +fn save_library(s: &mut Session, label: &str, mut lib: Vec, renames: &[(String, Option)]) -> Result<()> { + s.edit(label, |d, _| { + for (from, to) in renames { + rename_brush_strokes(d, &mut lib, from, to.as_deref()); + if brush::current(d).as_deref() == Some(from.as_str()) { + match to { + Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), + None => d.unknown.remove(brush::CURRENT_KEY), + }; + } + } + brush::store(d, &lib); + Ok(()) + }) +} + +pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.new"; + let ty = str_param(p, "type").ok_or_else(|| bad(C, "missing `type`"))?; + let art_key = match ty { + "calligraphic" | "bristle" => None, + "scatter" | "art" => Some("art"), + "pattern" => Some("side"), + o => return Err(bad(C, format!("unknown brush type `{o}`"))), + }; + let mut lib = brush::library(&s.doc()?.doc); + let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); + let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string); + let name = brush::unique_name(&lib, &base_name); + if ty == "bristle" { + bristle_name_fits(C, &name)?; + } + let mut v = json!({"name": name, "type": ty}); + if let Some(params) = p.get("params") { + merge(&mut v, params); + v["name"] = json!(name); + v["type"] = json!(ty); + } + if let Some(k) = art_key + && v.get(k).is_none() + { + let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; + if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { + return Err(bad(C, "the selected art has no width")); + } + v[k] = serde_json::to_value(&art).unwrap_or_default(); + } + lib.push(parse_brush(C, v)?); + save_library(s, "New Brush", lib, &[])?; + Ok(json!({ "name": name })) +} + +/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. +fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { + let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); + if count > max { + return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}"))); + } + Ok(()) +} + +/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]). +fn parse_brush(cmd: &str, v: Value) -> Result { + Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}"))) +} + +pub(super) fn brush_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.delete"; + let names = names_param(p, C)?; + let mut lib = brush::library(&s.doc()?.doc); + if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) { + return Err(bad(C, format!("no brush named `{name}`"))); + } + lib.retain(|b| !names.contains(&b.name)); + let renames: Vec<(String, Option)> = names.into_iter().map(|n| (n, None)).collect(); + save_library(s, "Delete Brush", lib, &renames)?; + ok() +} + +/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their +/// type in library order: before the first brush of that type at or after `to` that isn't moving, +/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type` +/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step. +pub(super) fn brush_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.move"; + const WITHIN: &str = "brushes move only within their type"; + let names = names_param(p, C)?; + check_numbers(p, &["to"], C)?; + let to = match p.get("to").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?), + None => None, + }; + let old = brush::library(&s.doc()?.doc); + let mut lib = old.clone(); + // `names_param` refuses an empty list, so the loop sets `kind`. + let mut kind = ""; + for name in &names { + let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + if !kind.is_empty() && kind != b.kind.type_id() { + return Err(bad(C, WITHIN)); + } + kind = b.kind.type_id(); + } + if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) { + return Err(bad(C, WITHIN)); + } + let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect(); + let before = anchor(&same, to, &names, |b| &b.name); + let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len()); + let mut items = vec![]; + for name in &names { + if let Some(i) = lib.iter().position(|b| &b.name == name) { + items.push(lib.remove(i)); + } + } + let at = match before { + Some(a) => lib.iter().position(|b| b.name == a), + None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1), + }; + let at = at.unwrap_or(first).min(lib.len()); + lib.splice(at..at, items); + if lib != old { + save_library(s, "Move Brush", lib, &[])?; + } + Ok(json!({ "moved": names.len() })) +} + +/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given +/// without `names`, names the copy. +pub(super) fn brush_duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.duplicate"; + let names = names_param(p, C)?; + let before = brush::library(&s.doc()?.doc); + let mut lib = before.clone(); + let mut out = vec![]; + for name in &names { + let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none()); + let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string); + b.name = brush::unique_name(&lib, &new_name); + out.push(b.name.clone()); + lib.push(b); + } + save_library(s, "Duplicate Brush", lib, &[])?; + Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) }) +} + +/// Adds the names of the brushes the strokes of `items` paint with to `out`. +fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && let Some(b) = &st.brush + { + out.push(b.clone()); + } + } +} + +/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to +/// `out`. +fn art_brushes(n: &Node, out: &mut Vec) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out)); +} + +/// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph +/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush +/// that a counted stroke paints with. +pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let lib = brush::library(d); + let mut todo = vec![]; + let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); + trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); + d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo)); + let mut used = BTreeSet::new(); + while let Some(name) = todo.pop() { + if !used.contains(&name) { + lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo)); + used.insert(name); + } + } + let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); + Ok(json!({ "names": names })) +} + +pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.options"; + let name = name_param(p, C)?.to_string(); + let mut lib = brush::library(&s.doc()?.doc); + let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let mut v = serde_json::to_value(&lib[i]).unwrap_or_default(); + if let Some(params) = p.get("params") { + merge(&mut v, params); + } + let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); + if let Some(nn) = &new_name { + if lib.iter().any(|b| &b.name == nn) { + return Err(bad(C, format!("a brush named `{nn}` exists"))); + } + // A `newName` that only trims the white space around the saved name is taken at any length. + if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() { + bristle_name_fits(C, nn)?; + } + v["name"] = json!(nn); + } else { + v["name"] = json!(name); + } + v["type"] = json!(lib[i].kind.type_id()); + lib[i] = parse_brush(C, v)?; + let out = lib[i].name.clone(); + let rename = new_name.map(|nn| (name, Some(nn))); + save_library(s, "Brush Options", lib, rename.as_slice())?; + Ok(json!({ "name": out })) +} + +pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result { + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + let ids: Vec = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); + if ids.is_empty() { + return Err(EngineError::Other("Expand: no brush strokes selected".into())); + } + let out = s.edit("Expand", |d, sel| { + let mut out = vec![]; + for id in &ids { + let Some(n) = d.node(*id).cloned() else { continue }; + let Some(g) = brush::expand(d, &n) else { continue }; + let g = d.reid(&g); + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let gid = g.id; + d.remove(*id)?; + d.insert(par, idx, g)?; + out.push(gid); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&out) })) +} + +pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); + let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; + let r = (spec.run)(s, p)?; + let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; + s.edit("Paintbrush", |d, _| { + if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { + st.brush = Some(name.clone()); + } + Ok(()) + })?; + Ok(r) +} diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs new file mode 100644 index 000000000..63eeed6b5 --- /dev/null +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -0,0 +1,688 @@ +use super::*; + +// ---------- symbols ---------- + +pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { + d.unknown + .get(SIZES_KEY) + .and_then(|m| m.get(name)) + .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) + .unwrap_or((2.0 * HALF, 2.0 * HALF)) +} + +pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { + let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); + if let Some(o) = m.as_object_mut() { + match size { + Some((w, h)) => o.insert(name.into(), json!([w, h])), + None => o.remove(name), + }; + } +} + +/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`. +pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { + let c = at - reg.offset_from_center(w, h); + Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) +} + +fn registration_param(p: &Value, cmd: &str) -> Result> { + match p.get("registration") { + None => Ok(None), + Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")), + } +} + +fn export_type_param(p: &Value, cmd: &str) -> Result> { + match str_param(p, "exportType") { + None => Ok(None), + Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))), + } +} + +/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural +/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it +/// on with guides `g` (the symbol's current ones, else the centre third, when none are given). +fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { + let on = match p.get("nineSlice") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?), + }; + let guides = + match p.get("nineSliceGuides") { + None | Some(Value::Null) => None, + Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { + bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1") + })?), + }; + Ok(match (on, guides) { + (Some(false), _) => Some(None), + (None, None) => None, + (_, Some(g)) => Some(Some(g)), + (Some(true), None) => Some(Some(current.unwrap_or_default())), + }) +} + +fn nine_slice_json(d: &Document, name: &str) -> Value { + d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value) +} + +fn rename_natural_size(d: &mut Document, from: &str, to: &str) { + if from == to { + return; + } + let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned(); + set_natural_size(d, from, None); + if let Some(v) = size + && let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64)) + { + set_natural_size(d, to, Some((w, h))); + } +} + +/// Normalise art for a symbol definition: returns (art, natural size, centre in the document). +pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { + let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; + let (w, h) = (b.width().max(1.0), b.height().max(1.0)); + let c = b.center(); + let n = Affine::scale_non_uniform(2.0 * HALF / w, 2.0 * HALF / h) * Affine::translate(-c.to_vec2()); + art.transform(n, false); + Ok((art, (w, h), c)) +} + +fn instances_of(d: &Document, name: Option<&str>) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && name.is_none_or(|x| x == symbol) + { + v.push(n.id); + } + }); + v +} + +fn symbol_names(d: &Document) -> Vec { + d.symbols.iter().map(|s| s.name.clone()).collect() +} + +fn current_symbol(d: &Document) -> Option { + d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str).filter(|n| d.symbols.iter().any(|s| s.name == *n)).map(str::to_string) +} + +pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let list: Vec = d + .symbols + .iter() + .map(|sym| { + let (w, h) = natural_size(d, &sym.name); + json!({ + "name": sym.name, + "instances": instances_of(d, Some(&sym.name)).len(), + "size": [w, h], + "registration": d.symbol_registration(&sym.name).0, + "exportType": d.symbol_export_type(&sym.name).id(), + "nineSlice": nine_slice_json(d, &sym.name), + }) + }) + .collect(); + Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) +} + +/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id. +fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { + let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; + if separate(d, ids) { + let id = d.alloc_id(); + return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?); + } + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let id = d.alloc_id(); + d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?; + for r in ids { + d.remove(*r)?; + } + Ok(id) +} + +pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.new"; + let ids = roots(s, p)?; + let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; + crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art); + let (art, size, c) = normalize(art)?; + let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol")); + let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); + let export = export_type_param(p, C)?.unwrap_or_default(); + let nine = nine_slice_param(p, C, None)?.flatten(); + // Keep the art where it was: the geometric centre stays at `c`, whatever the registration is. + let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg); + let id = s.edit("New Symbol", |d, sel| { + let art = d.reid(&art); + d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) }); + set_natural_size(d, &name, Some(size)); + d.set_symbol_registration(&name, reg); + d.set_symbol_export_type(&name, export); + d.set_symbol_nine_slice(&name, nine); + d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); + let threaded = crate::cmd::threads::frames(d); + let id = replace_with_instance(d, &ids, &name, place)?; + // Frames of threaded text in the symbol leave their threads with the text they show. + crate::cmd::threads::leave_threads(d, &threaded); + sel.set([id]); + Ok(id) + })?; + Ok( + json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }), + ) +} + +pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.options"; + let name = resolve_symbol(&s.doc()?.doc, p, C)?; + let export = export_type_param(p, C)?; + let reg = registration_param(p, C)?; + let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?; + let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() { + return Err(bad(C, "pass newName, registration, exportType and/or nineSlice")); + } + if let Some(ref n) = new_name + && n != &name + && s.doc()?.doc.symbols.iter().any(|x| x.name == *n) + { + return Err(bad(C, format!("a symbol named `{n}` already exists"))); + } + let final_name = new_name.clone().unwrap_or_else(|| name.clone()); + s.edit("Symbol Options", |d, _| { + if let Some(ref n) = new_name + && n != &name + { + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.name = n.clone(); + } + for id in instances_of(d, Some(&name)) { + if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) { + *symbol = n.clone(); + } + } + rename_natural_size(d, &name, n); + d.rename_symbol_registration(&name, n); + d.rename_symbol_export_type(&name, n); + d.rename_symbol_nine_slice(&name, n); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); + } + } + if let Some(r) = reg { + d.set_symbol_registration(&final_name, r); + } + if let Some(kind) = export { + d.set_symbol_export_type(&final_name, kind); + } + if let Some(g) = nine { + d.set_symbol_nine_slice(&final_name, g); + } + Ok(()) + })?; + let d = &s.doc()?.doc; + Ok(json!({ + "name": final_name, + "registration": d.symbol_registration(&final_name).0, + "exportType": d.symbol_export_type(&final_name).id(), + "nineSlice": nine_slice_json(d, &final_name), + })) +} + +fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result { + match str_param(p, "name") { + Some(n) if d.symbols.iter().any(|s| s.name == n) => Ok(n.to_string()), + Some(n) => Err(bad(cmd, format!("no symbol named `{n}`"))), + None => { + current_symbol(d).or_else(|| symbol_names(d).into_iter().next()).ok_or_else(|| EngineError::Other("the document has no symbols".into())) + } + } +} + +pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + let name = resolve_symbol(d, p, "symbol.place")?; + let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); + let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?); + let size = natural_size(d, &name); + let reg = d.symbol_registration(&name); + let parent = s.doc()?.target_parent()?; + let id = s.edit("Place Symbol Instance", |d, sel| { + let id = d.alloc_id(); + d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +/// The plain art of instance `inst` in document space (fresh ids). +fn expanded_instance(d: &mut Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = d.symbols.iter().find(|s| &s.name == symbol)?; + let mut art = (*sym.art).clone(); + if let Some(f) = inst.appearance.fill() + && let Some(c) = f.paint.color() + { + brush::tint_node(&mut art, &c, f.opacity); + } + art.transform(*xf, false); + let mut art = d.reid(&art); + if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { + if !matches!(art.kind, NodeKind::Group { .. }) { + let id = d.alloc_id(); + art = Node::group(id, vec![Arc::new(art)]); + } + art.opacity *= inst.opacity; + art.blend = inst.blend; + } + Some(art) +} + +fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { + let mut out = vec![]; + for id in ids { + let Some(inst) = d.node(*id).cloned() else { continue }; + let Some(art) = expanded_instance(d, &inst) else { continue }; + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let aid = art.id; + d.remove(*id)?; + d.insert(par, idx, art)?; + out.push(aid); + } + Ok(out) +} + +fn selected_instances(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let mut out = vec![]; + for id in roots(s, p)? { + if let Some(n) = d.node(id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) { + out.push(c.id); + } + }); + } + } + Ok(out) +} + +pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + let out = s.edit("Break Link to Symbol", |d, sel| { + let out = break_links(d, &ids)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&out) })) +} + +pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?; + let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) { + Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(), + _ => return Err(EngineError::Other("select a symbol instance".into())), + }; + let out = s.edit("Edit Symbol", |d, sel| { + let out = break_links(d, &[first])?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "name": name, "ids": ids_json(&out) })) +} + +pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.update"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let ids = roots(s, p)?; + let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; + crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art); + let (art, size, c) = normalize(art)?; + let old = natural_size(&s.doc()?.doc, &name); + let id = s.edit("Redefine Symbol", |d, sel| { + let art = d.reid(&art); + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.art = Arc::new(art); + } + set_natural_size(d, &name, Some(size)); + let fix = Affine::scale_non_uniform(size.0 / old.0, size.1 / old.1); + for i in instances_of(d, Some(&name)) { + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(i).map(|n| &mut n.kind) { + *xf *= fix; + } + } + let reg = d.symbol_registration(&name); + let threaded = crate::cmd::threads::frames(d); + let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; + // Frames of threaded text in the symbol leave their threads with the text they show. + crate::cmd::threads::leave_threads(d, &threaded); + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "name": name, "id": id.0 })) +} + +pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.delete"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let expand = switch_or(p, "expandInstances", true, C)?; + s.edit("Delete Symbol", |d, _| { + let inst = instances_of(d, Some(&name)); + if expand { + break_links(d, &inst)?; + } else { + remove_each(d, &inst)?; + } + d.symbols.retain(|x| x.name != name); + set_natural_size(d, &name, None); + d.set_symbol_registration(&name, SymbolRegistration::CENTER); + d.clear_symbol_export_type(&name); + d.set_symbol_nine_slice(&name, None); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.remove(CURRENT_SYMBOL); + } + Ok(()) + })?; + ok() +} + +pub(super) fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.duplicate"; + let name = name_param(p, C)?.to_string(); + let d = &s.doc()?.doc; + let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?; + let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()); + let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string)); + let size = natural_size(d, &name); + let reg = d.symbol_registration(&name); + let export = d.symbol_export_type(&name); + let nine = d.symbol_nine_slice(&name); + s.edit("Duplicate Symbol", |d, _| { + let art = d.reid(&sym.art); + d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); + set_natural_size(d, &new, Some(size)); + d.set_symbol_registration(&new, reg); + d.set_symbol_export_type(&new, export); + d.set_symbol_nine_slice(&new, nine); + Ok(()) + })?; + Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) })) +} + +pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.replace"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + s.edit("Replace Symbol", |d, _| { + let new = natural_size(d, &name); + for id in &ids { + let old = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol), + _ => continue, + }; + if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { + *symbol = name.clone(); + *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn symbol_set_current(s: &mut Session, p: &Value) -> Result { + let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; + set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; + ok() +} + +pub(super) fn symbol_select_instances(s: &mut Session, p: &Value) -> Result { + let name = resolve_symbol(&s.doc()?.doc, p, "symbol.selectInstances")?; + let ids = instances_of(&s.doc()?.doc, Some(&name)); + let n = ids.len(); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": n })) +} + +// ---------- symbolism tools ---------- + +fn points(p: &Value, cmd: &str) -> Result> { + let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing points [[x,y]…]"))?; + let v: Vec = a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(); + if v.is_empty() || v.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { + return Err(bad(cmd, "points must be finite [x,y] pairs")); + } + Ok(v) +} + +/// Deterministic per-point jitter in [-1, 1]² (so previews re-applied on the snapshot agree). +fn jitter(i: usize, q: Point) -> (f64, f64, f64) { + let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ i as u64; + for v in [(q.x * 16.0).round() as i64 as u64, (q.y * 16.0).round() as i64 as u64] { + h ^= v; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + h ^= h >> 29; + } + let f = |k: u32| ((h.rotate_left(k * 21) & 0xffff) as f64 / 65535.0) * 2.0 - 1.0; + (f(0), f(1), f(2)) +} + +fn is_symbol_set(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some("Symbol Set") +} + +pub(super) fn symbol_spray(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.spray"; + let pts = points(p, C)?; + let st = s.doc()?; + let name = resolve_symbol(&st.doc, p, C)?; + let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); + let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0); + let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0); + let alt = switch_or(p, "alt", false, C)?; + let size = natural_size(&st.doc, &name); + let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); + // Only a new Symbol Set needs a layer that takes new art. + let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; + if alt { + // Remove instances of this symbol under the brush (within the set, or anywhere). + let d = &st.doc; + let scope: Vec = match set { + Some(g) => d.node(g).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(), + None => instances_of(d, Some(&name)), + }; + let doomed: Vec = scope + .into_iter() + .filter(|id| { + d.node(*id).is_some_and(|n| { + matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name) + && n.geometric_bounds().is_some_and(|b| pts.iter().any(|q| q.distance(b.center()) <= radius)) + }) + }) + .collect(); + let n = doomed.len(); + if n > 0 { + s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?; + } + return Ok(json!({ "count": n })); + } + // Keep instances at least this far apart along the stroke (denser = closer). + let min_gap = size.0.max(size.1) * scale * (1.15 - density * 0.1).max(0.1); + let mut placed: Vec = vec![]; + for (i, q) in pts.iter().enumerate() { + let (jx, jy, _) = jitter(i, *q); + let c = Point::new(q.x + jx * radius * 0.5, q.y + jy * radius * 0.5); + if placed.last().is_some_and(|l| l.distance(c) < min_gap) && i > 0 { + continue; + } + placed.push(c); + } + let count = placed.len(); + let reg = s.doc()?.doc.symbol_registration(&name); + let id = s.edit("Symbol Sprayer", |d, sel| { + let mut nodes = vec![]; + for (i, c) in placed.iter().enumerate() { + let (_, _, js) = jitter(i + 7, *c); + let k = scale * (1.0 + js * 0.1); + let id = d.alloc_id(); + nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) })); + } + let gid = match set { + Some(g) => { + for n in nodes { + d.insert(Some(g), usize::MAX, n)?; + } + g + } + None => { + let gid = d.alloc_id(); + let mut g = Node::group(gid, nodes.into_iter().map(Arc::new).collect()); + g.name = Some("Symbol Set".into()); + d.insert(parent, usize::MAX, g)?; + gid + } + }; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "count": count })) +} + +fn instance_center(n: &Node) -> Option { + match &n.kind { + NodeKind::SymbolInstance { xf, .. } => Some(*xf * Point::ZERO), + _ => None, + } +} + +pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.adjust"; + let tool = str_param(p, "tool").ok_or_else(|| bad(C, "missing `tool`"))?.to_string(); + if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) { + return Err(bad(C, format!("unknown tool `{tool}`"))); + } + crate::cmd::paint::check_keep_model(p, C)?; + let pts = points(p, C)?; + let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); + let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; + let alt = switch_or(p, "alt", false, C)?; + let st = s.doc()?; + let color = match p.get("color").and_then(color_value) { + Some(c) => crate::cmd::paint::new_color(s, p, c), + None => s.paint.fill.color().unwrap_or(Color::BLACK), + }; + let style_paint = match str_param(p, "style") { + Some(n) => st.doc.graphic_styles.iter().find(|g| g.name == n).map(|g| g.appearance.fill_paint()), + None => st.doc.graphic_styles.get(1).or(st.doc.graphic_styles.first()).map(|g| g.appearance.fill_paint()), + }; + let candidates: Vec = { + let d = &st.doc; + let mut v = vec![]; + if st.selection.is_empty() { + v = instances_of(d, None); + } else { + for id in &st.selection.objects { + if let Some(n) = d.node(*id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !v.contains(&c.id) { + v.push(c.id); + } + }); + } + } + } + let editable = d.editable(); + v.retain(|id| editable(*id)); + v + }; + let label = match tool.as_str() { + "shift" => "Symbol Shifter", + "scrunch" => "Symbol Scruncher", + "size" => "Symbol Sizer", + "spin" => "Symbol Spinner", + "stain" => "Symbol Stainer", + "screen" => "Symbol Screener", + _ => "Symbol Styler", + }; + let count = s.edit(label, |d, _| { + let mut touched = std::collections::BTreeSet::new(); + for (j, q) in pts.iter().enumerate() { + let delta = if j > 0 { *q - pts[j - 1] } else { Vec2::ZERO }; + for id in &candidates { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(c) = instance_center(n) else { continue }; + let dist = c.distance(*q); + if dist > radius { + continue; + } + let w = intensity * (1.0 - dist / radius).max(0.0); + if w <= 0.0 { + continue; + } + touched.insert(*id); + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + match tool.as_str() { + "shift" => n.transform(Affine::translate(delta * w.min(1.0)), false), + "scrunch" => { + let v = (*q - c) * (0.15 * w) * if alt { -1.0 } else { 1.0 }; + n.transform(Affine::translate(v), false); + } + "size" => { + let k = 1.0 + 0.08 * w; + n.transform(about(Affine::scale(if alt { 1.0 / k } else { k })), false); + } + "spin" => { + // Turn towards the drag direction; a click spins a little. + let a = if delta.hypot() > 1e-9 { (c - *q).cross(delta).signum() * 0.1 * w } else { 0.05 * w }; + n.transform(about(Affine::rotate(if alt { -a } else { a })), false); + } + "stain" | "style" => { + let paint = if tool == "style" { style_paint.clone().unwrap_or(Paint::solid(color)) } else { Paint::solid(color) }; + if tool == "style" && alt { + n.appearance.items.clear(); + continue; + } + let cur = n.appearance.fill().filter(|f| f.paint == paint).map(|f| f.opacity).unwrap_or(0.0); + let step = if tool == "style" { 1.0 } else { 0.15 * w as f32 }; + let o = if alt { (cur - step).max(0.0) } else { (cur + step).min(1.0) }; + n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); + if o > 0.0 { + let mut f = FillLayer::new(paint); + f.opacity = o; + n.appearance.items.insert(0, AppearanceItem::Fill(f)); + } + } + _ => { + let step = (0.1 * w) as f32; + n.opacity = if alt { (n.opacity + step).min(1.0) } else { (n.opacity - step).max(0.05) }; + } + } + } + } + Ok(touched.len()) + })?; + Ok(json!({ "count": count })) +} From c2ead918b1ea95e10684543b99a04cc7edd8879e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:00 +0000 Subject: [PATCH 149/199] refactor(engine): move cmd/buildcmds.rs items to cmd/buildcmds/{live_paint,shape_builder,trace}.rs --- crates/engine/src/cmd/buildcmds.rs | 994 +----------------- crates/engine/src/cmd/buildcmds/live_paint.rs | 357 +++++++ .../engine/src/cmd/buildcmds/shape_builder.rs | 214 ++++ crates/engine/src/cmd/buildcmds/trace.rs | 418 ++++++++ 4 files changed, 998 insertions(+), 985 deletions(-) create mode 100644 crates/engine/src/cmd/buildcmds/live_paint.rs create mode 100644 crates/engine/src/cmd/buildcmds/shape_builder.rs create mode 100644 crates/engine/src/cmd/buildcmds/trace.rs diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 03166a5b0..8d72ce335 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -29,6 +29,15 @@ use super::paint::paint_from; use super::pathops::{Beside, shape_node}; use super::*; +mod live_paint; +mod shape_builder; +mod trace; + +pub use live_paint::live_paint_gaps; +use live_paint::{lp_expand, lp_fill, lp_gap_options, lp_info, lp_make, lp_merge, lp_release, lp_stroke_edge}; +use shape_builder::{sb_merge, sb_regions}; +use trace::{trace_expand, trace_make, trace_release, trace_set_view}; + /// Name of an Image Trace group. pub const TRACE_NAME: &str = "Image Trace"; @@ -253,195 +262,6 @@ fn marquee_param(p: &Value, cmd: &str) -> Result> { } } -fn sb_merge(s: &mut Session, p: &Value) -> Result { - const C: &str = "shapeBuilder.merge"; - let marquee = marquee_param(p, C)?; - let pts = match points_param(p, "points") { - Some(pts) => pts, - // A marquee needs no drag path. - None if marquee.is_some() && p.get("points").is_none_or(Value::is_null) => vec![], - None => return Err(bad(C, "missing points [[x,y]…]")), - }; - if (pts.is_empty() && marquee.is_none()) || !pts.iter().all(|q| finite(*q)) { - return Err(bad(C, "points must be a non-empty list of finite [x,y]")); - } - let erase = switch_or(p, "erase", false, C)?; - check_numbers(p, &["tolerance", "gap"], C)?; - // Line pieces and edges are only deleted (erase mode), never merged. - let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); - // In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it; a - // marquee does not. - let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1 && marquee.is_none()) - .then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); - let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?; - let swatches = match p.get("pickColorFrom") { - None | Some(Value::Null) => false, - Some(v) => match v.as_str() { - Some("artwork") => false, - Some("swatches") => true, - _ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))), - }, - }; - let fill = match fill_param(s, p, C)? { - None if swatches => Some(s.paint.fill.clone()), - f => f, - }; - let roots = root_ids(s, p)?; - let gap = gap_param(p); - // The map the Shape Builder tool already has, which a click or drag of it is made over. - let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap); - let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; - let (ids, merged, touched, split) = s.edit(label, |d, sel| { - let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed); - let built; - let map = match &shared { - // The tool's map has the outline edges, which erasing and splitting need. - Some((m, _)) if m.shapes == shapes => m.as_ref(), - _ => { - built = BuilderMap::of_shapes(shapes, erase || split_tol.is_some(), gap); - &built - } - }; - let outlines = b::drawn_outlines(d, &leaves); - let leaves: Vec = leaves.into_iter().cloned().collect(); - if map.shapes.is_empty() { - return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); - } - if let Some(tol) = split_tol - && let Some(&q) = pts.first() - && let Some((i, run)) = map.stroke_at(&outlines, q, tol) - && let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i)) - && let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn)) - && rest.length() > b::outline_snap(drawn) - { - // The path keeps the rest of it, and a copy of it with the run under the click goes - // directly above it; the other objects stay as they are. Gradients fitted to the - // path's bounds keep that fit on both. - if let Some(n) = d.node_mut(leaf.id) { - n.pin_gradients(); - } - let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect(); - let mut replaced = Beside::replacing(d, &[leaf.id]); - let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?; - replaced.finish(d)?; - let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?; - // Pick Color From Color Swatches (or `fill`): the stroke takes that paint. - if let Some(f) = &fill - && let Some(n) = d.node_mut(split) - { - n.appearance.set_stroke(f.clone()); - } - // The roots stay selected, with the two paths in place of the path when it was one. - let ids: Vec = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect(); - sel.set(ids.iter().copied()); - let mut touched = Touched::default(); - touched.add(map.hit(q, Some(tol))); - return Ok((ids, None, touched, Some(split))); - } - let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; - touched.extend(&map.builder_hits_along(&pts, piece_tol)); - touched.edges = map.erased_edges(&touched); - if touched.is_empty() { - return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); - } - let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); - let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); - let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; - // Pick Color From Artwork: the merged shape takes the look of the front-most object under the - // drag's first point, else under its last point, else of the front-most object. Color - // Swatches: the current fill, with the rest of the look of the front-most object over the - // first region the drag touched that an object covers, else of the front-most object. A - // marquee's drag starts at its first corner and ends at the opposite one. - let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top()); - let (first, last) = match marquee { - Some((a, b)) => (Some(a), Some(b)), - None => (pts.first().copied(), pts.last().copied()), - }; - let covered = - if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) }; - let style_src = covered.or(map.shapes.len().checked_sub(1)); - let mut new_nodes: Vec = vec![]; - let mut merged_id = None; - for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { - let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); - if !pieces.is_empty() { - // An open path loses the pieces deleted and those between two merged (or deleted) regions. - let keep: Vec = - pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect(); - if keep.len() == pieces.len() { - new_nodes.push(d.reid(leaf)); - } else { - for k in keep { - if let Some((l, _)) = map.lines.get(k) { - new_nodes.push(remnant(d, leaf, l.path.clone(), None)); - } - } - } - } else { - // A shape loses the regions deleted or merged, then the edges deleted (each one on - // its outline, shared edges from every shape along them). - let in_union = picked.iter().any(|r| r.sources.contains(&i)); - let mut rest = if in_union { - // What is left of it: its faces not merged or deleted, united. They share their - // edges with the merged faces exactly, where subtracting the merged shape left - // slivers of the outline along them (#569). A sliver goes in only where the - // shape covers it and the merged shape does not take it in. - let kept: Vec<&po::Region> = map - .regions - .iter() - .enumerate() - .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) - .map(|(_, (r, ..))| r) - .collect(); - if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) } - } else { - b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) - }; - let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); - // Edges come back from the planar map within a few hundredths of the outlines they - // follow, whatever else is selected. - let cut_tol = b::outline_snap(&sh.path); - let mut cut = false; - for piece in &cuts { - if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { - rest = r; - cut = true; - } - } - if !in_union && !cut { - new_nodes.push(d.reid(leaf)); - } else if !rest.is_empty() { - // What is left of its own outline keeps its fill rule; united faces fill with non-zero. - new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))); - } - } - if !erase && !union.is_empty() && Some(i) == style_src { - let mut m = shape_node(d, union.clone(), Some(leaf)); - if let Some(f) = &fill { - m.appearance.set_fill(f.clone()); - } - merged_id = Some(m.id); - new_nodes.push(m); - } - } - let new_ids = replace_roots(d, &roots, new_nodes)?; - sel.set(new_ids.iter().copied()); - Ok((new_ids, merged_id, touched, None)) - })?; - let mut out = json!({ - "ids": ids_json(&ids), - "merged": merged.map(|m| m.0), - "regions": touched.regions.len(), - "lines": touched.lines.len(), - "edges": touched.edges.len(), - }); - if let Some(e) = split { - out["split"] = json!(e.0); - } - Ok(out) -} - /// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids. /// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.) fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec) -> Result> { @@ -478,30 +298,6 @@ fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option) n } -fn sb_regions(s: &mut Session, p: &Value) -> Result { - let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?; - check_numbers(p, &["gap"], "shapeBuilder.regions")?; - let roots = root_ids(s, p)?; - let d = &s.doc()?.doc; - let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p)); - let id_of = |i: usize| leaves.get(i).map(|n| n.id.0); - let regions: Vec = map - .regions - .iter() - .map(|(r, _, bb)| { - json!({ - "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], - "area": po::area(&r.path, FillRule::NonZero), - "sources": r.sources.iter().filter_map(|&i| id_of(i)).collect::>(), - }) - }) - .collect(); - let pieces = |list: &[(po::Shape, Rect)]| -> Vec { - list.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect() - }; - Ok(json!({ "regions": regions, "lines": pieces(&map.lines), "edges": pieces(&map.edges) })) -} - // ---------- Live Paint ---------- /// The source nodes a root contributes (a Live Paint group contributes its own sources). @@ -604,775 +400,3 @@ fn repaint_from(g: &mut Node, old: &Node) { } } } - -/// `options` as stored on a Live Paint group: none for the defaults. -fn stored_gap_options(options: GapOptions) -> Option> { - (options != GapOptions::default()).then(|| Box::new(options)) -} - -/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most -/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another -/// group's is on; else the bottom-most group's. -fn merged_gap_options(groups: &[GapOptions]) -> GapOptions { - let Some(&bottom) = groups.first() else { return GapOptions::default() }; - if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom } -} - -/// Refuse `roots` when one of them is something Live Paint can't take ([`b::live_paint_takes`]): -/// its faces and edges come from path outlines alone, so type or an image would vanish among the -/// group's hidden sources. Checked before anything changes. -fn check_live_paint_takes(d: &Document, roots: &[NodeId]) -> Result<()> { - for r in roots { - if !b::live_paint_takes(d.node(*r).ok_or(EngineError::NoNode(*r))?) { - return Err(EngineError::Other( - "Live Paint takes paths and compound paths: convert type, images and other objects to paths first".into(), - )); - } - } - Ok(()) -} - -/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them -/// as Merge does; returns (group, faces, edges). -fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { - check_live_paint_takes(d, roots)?; - if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) { - return merge_live(d, sel, roots); - } - let mut sources = vec![]; - for r in roots { - let n = d.node(*r).ok_or(EngineError::NoNode(*r))?; - sources.extend(source_nodes(n)); - } - let (children, nf, ne) = build_children(d, sources, None)?; - let gid = d.alloc_id(); - let mut g = Node::group(gid, children); - g.name = Some(LIVE_PAINT_NAME.into()); - replace_roots(d, roots, vec![g])?; - sel.set([gid]); - Ok((gid, nf, ne)) -} - -fn lp_make(s: &mut Session, p: &Value) -> Result { - let roots = root_ids(s, p)?; - let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?; - Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) -} - -/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its -/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which -/// are merged; returns (group, faces, edges). -fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { - let target = roots - .iter() - .copied() - .find(|r| d.node(*r).is_some_and(is_live_paint)) - .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; - check_live_paint_takes(d, roots)?; - let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - // Paint to carry over: (point inside face, fill) and (point on edge, stroke). - let keep_faces: Vec<(Point, Paint)> = b::faces(&old) - .iter() - .filter(|f| !f.appearance.fill_paint().is_none()) - .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) - .collect(); - let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) - .iter() - .filter_map(|e| { - let pd = e.path_data()?; - let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone())) - }) - .collect(); - let mut sources = source_nodes(&old); - for r in roots { - if *r != target { - sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?)); - } - } - // Merge applies the Gap Options' merge rules to the groups' options, bottom-most first. - let groups: Vec = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect(); - let options = merged_gap_options(&groups); - let (children, nf, ne) = build_children(d, sources, options.max_gap())?; - // The group keeps its name, lock, visibility, transparency, appearance, opacity mask, graphic - // style link and the rest; its children are made again and its Gap Options set to the merged ones. - let mut g = old; - if let Some(ch) = g.children_mut() { - *ch = children; - } - g.extras_mut().live_paint_gaps = stored_gap_options(options); - for (pt, paint) in keep_faces { - if let Some(fid) = face_at(&g, pt).map(|f| f.id) - && let Some(ch) = g.children_mut() - && let Some(f) = ch.iter_mut().find(|c| c.id == fid) - { - Arc::make_mut(f).appearance.set_fill(paint); - } - } - for (pt, app) in keep_edges { - if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) - && let Some(ch) = g.children_mut() - && let Some(e) = ch.iter_mut().find(|c| c.id == eid) - { - Arc::make_mut(e).appearance = app.into(); - } - } - let mut merged = Beside::replacing(d, roots); - for r in roots.iter().filter(|r| **r != target) { - if d.node(*r).is_some() { - merged.put(d, *r, vec![])?; - } - } - merged.put(d, target, vec![g])?; - merged.finish(d)?; - sel.set([target]); - Ok((target, nf, ne)) -} - -fn lp_merge(s: &mut Session, p: &Value) -> Result { - let roots = root_ids(s, p)?; - let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?; - Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) -} - -fn live_targets(s: &Session, p: &Value) -> Result> { - let d = &s.doc()?.doc; - let ids = root_ids(s, p)?; - let v: Vec = ids.into_iter().filter(|i| d.node(*i).is_some_and(is_live_paint)).collect(); - if v.is_empty() { - return Err(EngineError::Other("select a Live Paint group".into())); - } - Ok(v) -} - -fn lp_release(s: &mut Session, p: &Value) -> Result { - let targets = live_targets(s, p)?; - let ids = s.edit("Release Live Paint", |d, sel| { - let mut out = vec![]; - let mut released = Beside::replacing(d, &targets); - for g in &targets { - let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; - let srcs: Vec = source_nodes(&n).iter().map(|c| (**c).clone()).collect(); - out.extend(srcs.iter().map(|c| c.id)); - released.put(d, *g, srcs)?; - } - released.finish(d)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn lp_expand(s: &mut Session, p: &Value) -> Result { - let targets = live_targets(s, p)?; - s.edit("Expand Live Paint", |d, sel| { - for g in &targets { - let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - n.name = None; - { - let mut e = n.extras_mut(); - e.trace = None; - e.live_paint_gaps = None; - } - if let Some(ch) = n.children_mut() { - ch.retain(|c| match c.name.as_deref() { - Some(SOURCES_NAME) => false, - Some(FACE_NAME) => !c.appearance.fill_paint().is_none(), - Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0, - _ => true, - }); - } - } - sel.set(targets.iter().copied()); - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&targets) })) -} - -/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to). -fn gaps_json(gaps: &[[Point; 2]]) -> Value { - json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::>()) -} - -/// A group's Gap Options as `livePaint.info` gives them. -fn gap_options_json(o: GapOptions) -> Value { - let [r, g, b] = o.color; - let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id)); - json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") }) -} - -/// The gaps up to `options`' size wide among a Live Paint group's sources. -fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> { - po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points()) -} - -/// The gaps View › Show Live Paint Gaps shows on `group`: those up to its Gap Options' size wide, -/// as `livePaint.info` lists them. None when `group` is not a Live Paint group. -pub fn live_paint_gaps(group: &Node) -> Vec<[Point; 2]> { - if is_live_paint(group) { open_gaps(group, group.gap_options()) } else { vec![] } -} - -fn lp_gap_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "livePaint.gapOptions"; - let detect = match p.get("detect").filter(|v| !v.is_null()) { - Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?), - None => None, - }; - let size = - match p.get("size").filter(|v| !v.is_null()) { - Some(v) => Some(GapSize::parse(v).ok_or_else(|| { - bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX)) - })?), - None => None, - }; - let color = match p.get("color").filter(|v| !v.is_null()) { - Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()), - None => None, - }; - let close = switch_or(p, "closeGaps", false, C)?; - let targets = live_targets(s, p)?; - let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| { - let (mut faces, mut gaps, mut closed) = (0, vec![], 0); - let mut changed = Beside::replacing(d, &targets); - for g in &targets { - let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; - let mut options = old.gap_options(); - options.detect = detect.unwrap_or(options.detect); - options.size = size.unwrap_or(options.size); - options.color = color.unwrap_or(options.color); - let mut sources = source_nodes(&old); - if close { - // Close Gaps With Paths adds an unpainted line across each gap to the group's paths. - for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) { - let line = PathData::single(SubPath::polyline(&gap, false)); - sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default()))); - closed += 1; - } - } - let mut n = old.clone(); - // The faces change only with what paint stops at, or with new paths. - if close || options.max_gap() != old.gap_options().max_gap() { - let (children, _, _) = build_children(d, sources, options.max_gap())?; - if let Some(ch) = n.children_mut() { - *ch = children; - } - repaint_from(&mut n, &old); - } - n.extras_mut().live_paint_gaps = stored_gap_options(options); - faces += b::faces(&n).len(); - gaps.extend(open_gaps(&n, options)); - changed.put(d, *g, vec![n])?; - } - changed.finish(d)?; - sel.set(targets.iter().copied()); - Ok((faces, gaps, closed)) - })?; - Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed })) -} - -fn lp_fill(s: &mut Session, p: &Value) -> Result { - const C: &str = "livePaint.fill"; - let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - super::paint::check_keep_model(p, C)?; - let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); - let group = id_param(p, "group"); - let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; - let (g, face) = s.edit("Live Paint Bucket", |d, sel| { - let gid = match group { - Some(g) => { - if !d.node(g).is_some_and(is_live_paint) { - return Err(bad(C, format!("{g} is not a Live Paint group"))); - } - g - } - None => { - let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { - roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some())) - }; - match (live_at(b::visible_face_at), live_at(face_at)) { - (Some(g), _) => g, - // At a point on a hidden face of a Live Paint group among the objects, the command returns an error - // and does not merge the objects. - (None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())), - (None, None) => make_live(d, sel, &roots)?.0, - } - } - }; - let fid = d - .node(gid) - .and_then(|n| b::visible_face_at(n, pt)) - .map(|f| f.id) - .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; - d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); - Ok((gid, fid)) - })?; - s.remember_paint(&paint); - Ok(json!({ "group": g.0, "face": face.0 })) -} - -fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { - const C: &str = "livePaint.strokeEdge"; - let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; - super::paint::check_keep_model(p, C)?; - let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); - let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; - if !(0.0..=1000.0).contains(&width) { - return Err(bad(C, "width out of range")); - } - let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0); - let edge = s.edit("Live Paint Bucket", |d, _| { - let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; - let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; - let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; - e.appearance.set_stroke(paint.clone()); - if let Some(st) = e.appearance.stroke_mut() { - st.width = width; - } - Ok(eid) - })?; - s.remember_paint(&paint); - Ok(json!({ "group": g.0, "edge": edge.0 })) -} - -fn lp_info(s: &mut Session, p: &Value) -> Result { - let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; - let d = &s.doc()?.doc; - let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; - let faces: Vec = b::faces(n) - .iter() - .map(|f| { - let bb = f.geometric_bounds().unwrap_or(Rect::ZERO); - json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] }) - }) - .collect(); - let edges: Vec = b::edges(n) - .iter() - .map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() })) - .collect(); - let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0); - let options = n.gap_options(); - Ok(json!({ - "faces": faces, - "edges": edges, - "sources": sources, - "gapOptions": gap_options_json(options), - "gaps": gaps_json(&open_gaps(n, options)), - })) -} - -// ---------- Image Trace ---------- - -fn is_trace_group(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) - && n.name.as_deref() == Some(TRACE_NAME) - && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) -} - -/// The `view` param ([`TraceView`] id), if given. -fn view_param(cmd: &str, p: &Value) -> Result> { - let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; - let ids = || TraceView::ALL.map(TraceView::id).join(", "); - v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids()))) -} - -/// The parameters command `c` traces with: the preset's, with `params` over them. -fn trace_params(c: &str, p: &Value) -> Result { - let name = str_param(p, "preset").unwrap_or("Default"); - let enhanced = switch_or(p, "enhanced", false, c)?; - // (A preset without an enhanced version is traced as it is.) - let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name)); - let base = base.ok_or_else(|| bad(c, format!("unknown preset `{name}` (see imageTrace.presets)")))?; - let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) }; - let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?; - let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad(c, "params must be an object")) }; - // A key that isn't one of the settings is an error, with the nearest setting. - if let Some(key) = src.keys().find(|k| !o.contains_key(k.as_str())) { - return Err(super::params::unknown(c, key, &o.keys().map(String::as_str).collect::>())); - } - for (k, val) in src { - o.insert(k.clone(), val.clone()); - } - let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; - if !params.fills && !params.strokes { - return Err(bad(c, "Create needs fills, strokes or both")); - } - if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) { - return Err(bad(c, "strokeWidth must be a positive number of pixels")); - } - // A color group is refused unless the trace uses it: Color mode with Document Library and the - // document's swatches. - if params.color_group.as_deref().is_some_and(|g| !g.is_empty()) { - if params.palette != tr::Palette::DocumentLibrary { - return Err(bad(c, "`colorGroup` needs `palette: \"documentLibrary\"`")); - } - if params.library != super::swatchlib::DOCUMENT_SWATCHES { - return Err(bad(c, "`colorGroup` needs `library: \"document\"`")); - } - if params.mode != tr::Mode::Color { - return Err(bad(c, "`colorGroup` needs `mode: \"color\"`")); - } - } - Ok(params) -} - -/// Document Library with `colorGroup`: the solid colors of the active document's color group -/// `name` (so named exactly, else ignoring case). Errors name command `c`. -/// ([`trace_params`] has checked that the library is the document's swatches.) -fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result> { - let groups = &s.doc()?.doc.swatch_groups; - let lower = name.to_lowercase(); - let group = groups - .iter() - .find(|g| g.name == name) - .or_else(|| groups.iter().find(|g| g.name.to_lowercase() == lower)) - .ok_or_else(|| bad(c, format!("no color group `{name}` in the document (see swatch.list)")))?; - Ok(group.swatches.iter().filter_map(|w| w.paint.color()).collect()) -} - -/// Document Library in Color mode: look the library's colors (or its color group's) up into -/// `params.swatches` → each one as traced (sRGB) with the color to fill with. Errors name command `c`. -fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result> { - if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { - return Ok(vec![]); - } - let group = params.color_group.as_deref().filter(|g| !g.is_empty()); - let key = group.unwrap_or(params.library.as_str()); - let colors = match group { - Some(name) => trace_group_colors(c, s, name)?, - None => super::swatchlib::library_colors(s, key) - .ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?, - }; - if colors.is_empty() { - return Err(bad(c, format!("`{key}` has no colours"))); - } - let library: Vec<([u8; 3], Color)> = colors - .into_iter() - .map(|c| { - let [r, g, b, _] = c.to_rgba8(1.0); - ([r, g, b], c) - }) - .collect(); - params.swatches = library.iter().map(|l| l.0).collect(); - Ok(library) -} - -/// The most anchor points an Image Trace may make, a guard for memory: a trace past it is refused -/// with advice instead (#525). A trace keeps about 90 bytes an anchor in the document (measured on -/// code-drawn photos of 2000 × 2000 to 6000 × 4000 pixels), so 8,000,000 anchors are about -/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at High Fidelity Photo -/// makes about 1.9 million, and even at Paths 100, Noise 1 and every tone about 7.9 million. On the -/// web, where a page has at most 4 GB of memory, the limit stays at 2,000,000. -#[cfg(not(target_arch = "wasm32"))] -pub const MAX_TRACE_ANCHORS: usize = 8_000_000; -/// The most anchor points an Image Trace may make on the web (see the native limit). -#[cfg(target_arch = "wasm32")] -pub const MAX_TRACE_ANCHORS: usize = 2_000_000; - -/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`. -fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape { - use vectorcraft_geom::Affine; - let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians()); - let mut live = match shape { - tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle { - w: width, - h: height, - radii: [radius; 4], - kinds: Default::default(), - radii_relative: false, - xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)), - }, - tr::TracedShape::Ellipse { center, rx, ry, rotation } => { - LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) } - } - tr::TracedShape::Polygon { center, radius, sides, rotation } => { - LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![], radii_relative: false } - } - }; - // (Whether the move changed the shape's own numbers does not matter: its path is made from them next.) - let _ = live.transform(xf); - live -} - -/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the -/// document by `xf`. -fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint { - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; - let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect(); - let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); - paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None }); - Paint::Gradient(Box::new(paint)) -} - -/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others -/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had: -/// abutting paths painted in another order would show a seam where they meet. Without parents, -/// the nodes as they are. -fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec> { - let n = nodes.len(); - let mut inside: Vec> = vec![vec![]; n]; - let mut roots = vec![]; - for i in 0..n { - match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) { - Some(of) => of.push(i), - None => roots.push(i), - } - } - // Outermost first, so that backwards each path comes after those inside it. - let mut order = roots.clone(); - let mut at = 0; - while let Some(&i) = order.get(at) { - order.extend(inside.get(i).into_iter().flatten()); - at += 1; - } - let mut nodes: Vec> = nodes.into_iter().map(Some).collect(); - let mut built: Vec>> = vec![None; n]; - for &i in order.iter().rev() { - let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue }; - let mut within: Vec<(usize, Arc)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect(); - let made = if within.is_empty() { - node - } else { - within.push((i, Arc::new(node))); - within.sort_by_key(|m| m.0); - Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect()) - }; - if let Some(slot) = built.get_mut(i) { - *slot = Some(Arc::new(made)); - } - } - // (Paths that are inside each other in a ring are reached from no root: the tracer makes none, - // and they are kept all the same.) - let ring = nodes.into_iter().flatten().map(Arc::new).collect::>(); - roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() -} - -fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { - let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; - let mut params = trace_params(c, p)?; - let view = view_param(c, p)?; - let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; - // The preset's name if the parameters are exactly that preset's or its enhanced version's, - // else "Custom". - let named = str_param(p, "preset").unwrap_or("Default"); - let is = |b: Option| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json); - let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() }; - let roots = root_ids(s, p)?; - // The image (inside a trace group, or selected directly). - let (target, img) = { - let d = &s.doc()?.doc; - roots - .iter() - .find_map(|r| { - let n = d.node(*r)?; - match &n.kind { - NodeKind::Image(i) => Some((*r, i.clone())), - _ if is_trace_group(n) => match &n.children()?.first()?.kind { - NodeKind::Image(i) => Some((*r, i.clone())), - _ => None, - }, - _ => None, - } - }) - .ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))? - }; - let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?; - // An image over the tracer's size is traced scaled down (the paths are scaled back below). - let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; - let library = trace_library(c, s, &mut params)?; - let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?; - let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height), res.worked_at); - // Pixel space of the decoded raster → the image object's pixel space → document. - let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; - let sy = img.height.max(1) as f64 / raster.height.max(1) as f64; - let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy); - // Document units per raster pixel (stroke widths). - let unit = xf.determinant().abs().sqrt(); - let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len()); - let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" }; - let id = s.edit(label, |d, sel| { - let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - let mut image = if is_trace_group(&src) { - src.children() - .and_then(|c| c.first()) - .map(|c| (**c).clone()) - .ok_or_else(|| EngineError::Other("Image Trace: the traced image is missing".into()))? - } else { - src.clone() - }; - image.visible = true; - let mut children: Vec> = vec![]; - if !expand { - let mut hidden = image.clone(); - hidden.visible = false; - children.push(Arc::new(hidden)); - } - let mut traced = Vec::with_capacity(res.paths.len()); - for tp in res.paths { - let path = tp.path.transformed(xf); - let mut n = shape_node(d, path, None); - // Shapes: a path the tracer found to be a shape is that live shape. - if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) { - let shape = traced_shape(shape, xf); - *path = shape.to_path(); - *live = Some(Box::new(shape)); - } - // A library colour fills with the swatch's own colour (its model kept). - let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1); - // Gradients: a path the tracer found to be a linear gradient is filled with it. - let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf)); - n.appearance = match tp.stroke { - // Traced lines: round corners, and ends round or square as the tracer found them. - Some(width) => Appearance { - items: vec![AppearanceItem::Stroke(StrokeLayer { - cap: if tp.square_caps { LineCap::Square } else { LineCap::Round }, - join: LineJoin::Round, - ..StrokeLayer::new(paint, width * unit) - })], - ..Default::default() - } - .into(), - None => fill_only(paint).into(), - }; - traced.push(n); - } - children.extend(grouped(d, traced, &res.parents)); - let gid = d.alloc_id(); - let mut g = Node::group(gid, children); - g.name = if expand { None } else { Some(TRACE_NAME.into()) }; - if !expand { - // Tracing again keeps the object's view unless another is asked for. - let view = view.unwrap_or_else(|| src.trace_view()); - g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); - } - let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; - d.remove(target)?; - d.insert(par, idx, g)?; - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors, "warnings": warnings })) -} - -/// Images larger than this on a side are traced, but more slowly: the trace says so. -pub const LARGE_TRACE_SIDE: u32 = 1500; - -/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that -/// Flat Logo averaged it down to `worked` pixels first, or else that a large image takes longer -/// (the public documentation's warning for images over 1500 px) and that one over the tracer's -/// [`tr::MAX_PIXELS`] was traced scaled down. -pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Option<(u32, u32)>) -> Vec { - let mut out = vec![]; - if worked.is_none() && size.0.max(size.1) > LARGE_TRACE_SIDE { - out.push(format!( - "The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.", - size.0, size.1 - )); - } - if let Some(worked) = worked { - out.push(format!("Flat Logo traced the image at {} × {} pixels, averaged down from {} × {}.", worked.0, worked.1, size.0, size.1)); - } else if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) { - out.push(format!( - "The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).", - traced.0, - traced.1, - size.0, - size.1, - tr::MAX_PIXELS >> 20 - )); - } - out -} - -fn trace_targets(s: &Session, p: &Value) -> Result> { - let d = &s.doc()?.doc; - let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect(); - if v.is_empty() { - return Err(EngineError::Other("select an Image Trace object".into())); - } - Ok(v) -} - -fn trace_set_view(s: &mut Session, p: &Value) -> Result { - const C: &str = "imageTrace.setView"; - let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?; - let targets = trace_targets(s, p)?; - let d = &s.doc()?.doc; - if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) { - return Ok(json!({ "ids": ids_json(&targets), "view": view.id() })); - } - s.edit("Image Trace View", |d, _| { - for g in &targets { - let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) { - t.insert("view".into(), json!(view.id())); - } else { - n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&targets), "view": view.id() })) -} - -fn trace_release(s: &mut Session, p: &Value) -> Result { - let targets = trace_targets(s, p)?; - let ids = s.edit("Release Image Trace", |d, sel| { - let mut out = vec![]; - let mut released = Beside::replacing(d, &targets); - for g in &targets { - let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; - let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone(); - img.visible = true; - out.push(img.id); - released.put(d, *g, vec![img])?; - } - released.finish(d)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn trace_expand(s: &mut Session, p: &Value) -> Result { - let targets = trace_targets(s, p)?; - s.edit("Expand Image Trace", |d, sel| { - for g in &targets { - let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - n.name = None; - n.extras_mut().trace = None; - if let Some(ch) = n.children_mut() { - ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); - } - } - sel.set(targets.iter().copied()); - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&targets) })) -} - -#[cfg(test)] -mod trace_limit_tests { - use super::{MAX_TRACE_ANCHORS, trace_warnings}; - - #[test] - fn trace_warnings_name_a_large_or_scaled_down_image() { - assert!(trace_warnings((1500, 1500), (1500, 1500), None).is_empty()); - assert_eq!(trace_warnings((1501, 10), (1501, 10), None).len(), 1); - let w = trace_warnings((16000, 12000), (9459, 7094), None); - assert_eq!(w.len(), 2, "{w:?}"); - assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}"); - // Flat Logo's own working size is named instead of the megapixel limit. - let w = trace_warnings((1025, 1025), (1025, 1025), Some((513, 513))); - assert_eq!(w, ["Flat Logo traced the image at 513 × 513 pixels, averaged down from 1025 × 1025."]); - // Flat Logo traces a large image at its reduced size, so it gives no slow-trace warning. - let w = trace_warnings((1600, 1600), (1600, 1600), Some((800, 800))); - assert_eq!(w, ["Flat Logo traced the image at 800 × 800 pixels, averaged down from 1600 × 1600."]); - let w = trace_warnings((16000, 12000), (9459, 7094), Some((946, 710))); - assert_eq!(w, ["Flat Logo traced the image at 946 × 710 pixels, averaged down from 16000 × 12000."]); - } - - // A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit; - // the limit still guards memory (about 90 bytes an anchor). Checked when the tests build. - const _: () = assert!(MAX_TRACE_ANCHORS >= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000); -} diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs new file mode 100644 index 000000000..f9b224ce8 --- /dev/null +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -0,0 +1,357 @@ +use super::*; + +/// `options` as stored on a Live Paint group: none for the defaults. +fn stored_gap_options(options: GapOptions) -> Option> { + (options != GapOptions::default()).then(|| Box::new(options)) +} + +/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most +/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another +/// group's is on; else the bottom-most group's. +fn merged_gap_options(groups: &[GapOptions]) -> GapOptions { + let Some(&bottom) = groups.first() else { return GapOptions::default() }; + if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom } +} + +/// Refuse `roots` when one of them is something Live Paint can't take ([`b::live_paint_takes`]): +/// its faces and edges come from path outlines alone, so type or an image would vanish among the +/// group's hidden sources. Checked before anything changes. +fn check_live_paint_takes(d: &Document, roots: &[NodeId]) -> Result<()> { + for r in roots { + if !b::live_paint_takes(d.node(*r).ok_or(EngineError::NoNode(*r))?) { + return Err(EngineError::Other( + "Live Paint takes paths and compound paths: convert type, images and other objects to paths first".into(), + )); + } + } + Ok(()) +} + +/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them +/// as Merge does; returns (group, faces, edges). +fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { + check_live_paint_takes(d, roots)?; + if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) { + return merge_live(d, sel, roots); + } + let mut sources = vec![]; + for r in roots { + let n = d.node(*r).ok_or(EngineError::NoNode(*r))?; + sources.extend(source_nodes(n)); + } + let (children, nf, ne) = build_children(d, sources, None)?; + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = Some(LIVE_PAINT_NAME.into()); + replace_roots(d, roots, vec![g])?; + sel.set([gid]); + Ok((gid, nf, ne)) +} + +pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result { + let roots = root_ids(s, p)?; + let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?; + Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) +} + +/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its +/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which +/// are merged; returns (group, faces, edges). +fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { + let target = roots + .iter() + .copied() + .find(|r| d.node(*r).is_some_and(is_live_paint)) + .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; + check_live_paint_takes(d, roots)?; + let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; + // Paint to carry over: (point inside face, fill) and (point on edge, stroke). + let keep_faces: Vec<(Point, Paint)> = b::faces(&old) + .iter() + .filter(|f| !f.appearance.fill_paint().is_none()) + .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) + .collect(); + let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) + .iter() + .filter_map(|e| { + let pd = e.path_data()?; + let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone())) + }) + .collect(); + let mut sources = source_nodes(&old); + for r in roots { + if *r != target { + sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?)); + } + } + // Merge applies the Gap Options' merge rules to the groups' options, bottom-most first. + let groups: Vec = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect(); + let options = merged_gap_options(&groups); + let (children, nf, ne) = build_children(d, sources, options.max_gap())?; + // The group keeps its name, lock, visibility, transparency, appearance, opacity mask, graphic + // style link and the rest; its children are made again and its Gap Options set to the merged ones. + let mut g = old; + if let Some(ch) = g.children_mut() { + *ch = children; + } + g.extras_mut().live_paint_gaps = stored_gap_options(options); + for (pt, paint) in keep_faces { + if let Some(fid) = face_at(&g, pt).map(|f| f.id) + && let Some(ch) = g.children_mut() + && let Some(f) = ch.iter_mut().find(|c| c.id == fid) + { + Arc::make_mut(f).appearance.set_fill(paint); + } + } + for (pt, app) in keep_edges { + if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) + && let Some(ch) = g.children_mut() + && let Some(e) = ch.iter_mut().find(|c| c.id == eid) + { + Arc::make_mut(e).appearance = app.into(); + } + } + let mut merged = Beside::replacing(d, roots); + for r in roots.iter().filter(|r| **r != target) { + if d.node(*r).is_some() { + merged.put(d, *r, vec![])?; + } + } + merged.put(d, target, vec![g])?; + merged.finish(d)?; + sel.set([target]); + Ok((target, nf, ne)) +} + +pub(super) fn lp_merge(s: &mut Session, p: &Value) -> Result { + let roots = root_ids(s, p)?; + let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?; + Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) +} + +fn live_targets(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let ids = root_ids(s, p)?; + let v: Vec = ids.into_iter().filter(|i| d.node(*i).is_some_and(is_live_paint)).collect(); + if v.is_empty() { + return Err(EngineError::Other("select a Live Paint group".into())); + } + Ok(v) +} + +pub(super) fn lp_release(s: &mut Session, p: &Value) -> Result { + let targets = live_targets(s, p)?; + let ids = s.edit("Release Live Paint", |d, sel| { + let mut out = vec![]; + let mut released = Beside::replacing(d, &targets); + for g in &targets { + let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let srcs: Vec = source_nodes(&n).iter().map(|c| (**c).clone()).collect(); + out.extend(srcs.iter().map(|c| c.id)); + released.put(d, *g, srcs)?; + } + released.finish(d)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result { + let targets = live_targets(s, p)?; + s.edit("Expand Live Paint", |d, sel| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + n.name = None; + { + let mut e = n.extras_mut(); + e.trace = None; + e.live_paint_gaps = None; + } + if let Some(ch) = n.children_mut() { + ch.retain(|c| match c.name.as_deref() { + Some(SOURCES_NAME) => false, + Some(FACE_NAME) => !c.appearance.fill_paint().is_none(), + Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0, + _ => true, + }); + } + } + sel.set(targets.iter().copied()); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets) })) +} + +/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to). +fn gaps_json(gaps: &[[Point; 2]]) -> Value { + json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::>()) +} + +/// A group's Gap Options as `livePaint.info` gives them. +fn gap_options_json(o: GapOptions) -> Value { + let [r, g, b] = o.color; + let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id)); + json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") }) +} + +/// The gaps up to `options`' size wide among a Live Paint group's sources. +fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> { + po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points()) +} + +/// The gaps View › Show Live Paint Gaps shows on `group`: those up to its Gap Options' size wide, +/// as `livePaint.info` lists them. None when `group` is not a Live Paint group. +pub fn live_paint_gaps(group: &Node) -> Vec<[Point; 2]> { + if is_live_paint(group) { open_gaps(group, group.gap_options()) } else { vec![] } +} + +pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.gapOptions"; + let detect = match p.get("detect").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?), + None => None, + }; + let size = + match p.get("size").filter(|v| !v.is_null()) { + Some(v) => Some(GapSize::parse(v).ok_or_else(|| { + bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX)) + })?), + None => None, + }; + let color = match p.get("color").filter(|v| !v.is_null()) { + Some(v) => Some(crate::cmd::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()), + None => None, + }; + let close = switch_or(p, "closeGaps", false, C)?; + let targets = live_targets(s, p)?; + let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| { + let (mut faces, mut gaps, mut closed) = (0, vec![], 0); + let mut changed = Beside::replacing(d, &targets); + for g in &targets { + let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let mut options = old.gap_options(); + options.detect = detect.unwrap_or(options.detect); + options.size = size.unwrap_or(options.size); + options.color = color.unwrap_or(options.color); + let mut sources = source_nodes(&old); + if close { + // Close Gaps With Paths adds an unpainted line across each gap to the group's paths. + for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) { + let line = PathData::single(SubPath::polyline(&gap, false)); + sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default()))); + closed += 1; + } + } + let mut n = old.clone(); + // The faces change only with what paint stops at, or with new paths. + if close || options.max_gap() != old.gap_options().max_gap() { + let (children, _, _) = build_children(d, sources, options.max_gap())?; + if let Some(ch) = n.children_mut() { + *ch = children; + } + repaint_from(&mut n, &old); + } + n.extras_mut().live_paint_gaps = stored_gap_options(options); + faces += b::faces(&n).len(); + gaps.extend(open_gaps(&n, options)); + changed.put(d, *g, vec![n])?; + } + changed.finish(d)?; + sel.set(targets.iter().copied()); + Ok((faces, gaps, closed)) + })?; + Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed })) +} + +pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.fill"; + let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + crate::cmd::paint::check_keep_model(p, C)?; + let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); + let group = id_param(p, "group"); + let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; + let (g, face) = s.edit("Live Paint Bucket", |d, sel| { + let gid = match group { + Some(g) => { + if !d.node(g).is_some_and(is_live_paint) { + return Err(bad(C, format!("{g} is not a Live Paint group"))); + } + g + } + None => { + let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { + roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some())) + }; + match (live_at(b::visible_face_at), live_at(face_at)) { + (Some(g), _) => g, + // At a point on a hidden face of a Live Paint group among the objects, the command returns an error + // and does not merge the objects. + (None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())), + (None, None) => make_live(d, sel, &roots)?.0, + } + } + }; + let fid = d + .node(gid) + .and_then(|n| b::visible_face_at(n, pt)) + .map(|f| f.id) + .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; + d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); + Ok((gid, fid)) + })?; + s.remember_paint(&paint); + Ok(json!({ "group": g.0, "face": face.0 })) +} + +pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.strokeEdge"; + let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + crate::cmd::paint::check_keep_model(p, C)?; + let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); + let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; + if !(0.0..=1000.0).contains(&width) { + return Err(bad(C, "width out of range")); + } + let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0); + let edge = s.edit("Live Paint Bucket", |d, _| { + let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; + let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; + e.appearance.set_stroke(paint.clone()); + if let Some(st) = e.appearance.stroke_mut() { + st.width = width; + } + Ok(eid) + })?; + s.remember_paint(&paint); + Ok(json!({ "group": g.0, "edge": edge.0 })) +} + +pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result { + let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; + let d = &s.doc()?.doc; + let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; + let faces: Vec = b::faces(n) + .iter() + .map(|f| { + let bb = f.geometric_bounds().unwrap_or(Rect::ZERO); + json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] }) + }) + .collect(); + let edges: Vec = b::edges(n) + .iter() + .map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() })) + .collect(); + let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0); + let options = n.gap_options(); + Ok(json!({ + "faces": faces, + "edges": edges, + "sources": sources, + "gapOptions": gap_options_json(options), + "gaps": gaps_json(&open_gaps(n, options)), + })) +} diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs new file mode 100644 index 000000000..07d4949a0 --- /dev/null +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -0,0 +1,214 @@ +use super::*; + +pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { + const C: &str = "shapeBuilder.merge"; + let marquee = marquee_param(p, C)?; + let pts = match points_param(p, "points") { + Some(pts) => pts, + // A marquee needs no drag path. + None if marquee.is_some() && p.get("points").is_none_or(Value::is_null) => vec![], + None => return Err(bad(C, "missing points [[x,y]…]")), + }; + if (pts.is_empty() && marquee.is_none()) || !pts.iter().all(|q| finite(*q)) { + return Err(bad(C, "points must be a non-empty list of finite [x,y]")); + } + let erase = switch_or(p, "erase", false, C)?; + check_numbers(p, &["tolerance", "gap"], C)?; + // Line pieces and edges are only deleted (erase mode), never merged. + let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); + // In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it; a + // marquee does not. + let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1 && marquee.is_none()) + .then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); + let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?; + let swatches = match p.get("pickColorFrom") { + None | Some(Value::Null) => false, + Some(v) => match v.as_str() { + Some("artwork") => false, + Some("swatches") => true, + _ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))), + }, + }; + let fill = match fill_param(s, p, C)? { + None if swatches => Some(s.paint.fill.clone()), + f => f, + }; + let roots = root_ids(s, p)?; + let gap = gap_param(p); + // The map the Shape Builder tool already has, which a click or drag of it is made over. + let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap); + let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; + let (ids, merged, touched, split) = s.edit(label, |d, sel| { + let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed); + let built; + let map = match &shared { + // The tool's map has the outline edges, which erasing and splitting need. + Some((m, _)) if m.shapes == shapes => m.as_ref(), + _ => { + built = BuilderMap::of_shapes(shapes, erase || split_tol.is_some(), gap); + &built + } + }; + let outlines = b::drawn_outlines(d, &leaves); + let leaves: Vec = leaves.into_iter().cloned().collect(); + if map.shapes.is_empty() { + return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); + } + if let Some(tol) = split_tol + && let Some(&q) = pts.first() + && let Some((i, run)) = map.stroke_at(&outlines, q, tol) + && let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i)) + && let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn)) + && rest.length() > b::outline_snap(drawn) + { + // The path keeps the rest of it, and a copy of it with the run under the click goes + // directly above it; the other objects stay as they are. Gradients fitted to the + // path's bounds keep that fit on both. + if let Some(n) = d.node_mut(leaf.id) { + n.pin_gradients(); + } + let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect(); + let mut replaced = Beside::replacing(d, &[leaf.id]); + let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?; + replaced.finish(d)?; + let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?; + // Pick Color From Color Swatches (or `fill`): the stroke takes that paint. + if let Some(f) = &fill + && let Some(n) = d.node_mut(split) + { + n.appearance.set_stroke(f.clone()); + } + // The roots stay selected, with the two paths in place of the path when it was one. + let ids: Vec = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect(); + sel.set(ids.iter().copied()); + let mut touched = Touched::default(); + touched.add(map.hit(q, Some(tol))); + return Ok((ids, None, touched, Some(split))); + } + let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; + touched.extend(&map.builder_hits_along(&pts, piece_tol)); + touched.edges = map.erased_edges(&touched); + if touched.is_empty() { + return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); + } + let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); + let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); + let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); + let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; + // Pick Color From Artwork: the merged shape takes the look of the front-most object under the + // drag's first point, else under its last point, else of the front-most object. Color + // Swatches: the current fill, with the rest of the look of the front-most object over the + // first region the drag touched that an object covers, else of the front-most object. A + // marquee's drag starts at its first corner and ends at the opposite one. + let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top()); + let (first, last) = match marquee { + Some((a, b)) => (Some(a), Some(b)), + None => (pts.first().copied(), pts.last().copied()), + }; + let covered = + if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) }; + let style_src = covered.or(map.shapes.len().checked_sub(1)); + let mut new_nodes: Vec = vec![]; + let mut merged_id = None; + for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { + let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); + if !pieces.is_empty() { + // An open path loses the pieces deleted and those between two merged (or deleted) regions. + let keep: Vec = + pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect(); + if keep.len() == pieces.len() { + new_nodes.push(d.reid(leaf)); + } else { + for k in keep { + if let Some((l, _)) = map.lines.get(k) { + new_nodes.push(remnant(d, leaf, l.path.clone(), None)); + } + } + } + } else { + // A shape loses the regions deleted or merged, then the edges deleted (each one on + // its outline, shared edges from every shape along them). + let in_union = picked.iter().any(|r| r.sources.contains(&i)); + let mut rest = if in_union { + // What is left of it: its faces not merged or deleted, united. They share their + // edges with the merged faces exactly, where subtracting the merged shape left + // slivers of the outline along them (#569). A sliver goes in only where the + // shape covers it and the merged shape does not take it in. + let kept: Vec<&po::Region> = map + .regions + .iter() + .enumerate() + .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) + .map(|(_, (r, ..))| r) + .collect(); + if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) } + } else { + b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) + }; + let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); + // Edges come back from the planar map within a few hundredths of the outlines they + // follow, whatever else is selected. + let cut_tol = b::outline_snap(&sh.path); + let mut cut = false; + for piece in &cuts { + if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { + rest = r; + cut = true; + } + } + if !in_union && !cut { + new_nodes.push(d.reid(leaf)); + } else if !rest.is_empty() { + // What is left of its own outline keeps its fill rule; united faces fill with non-zero. + new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))); + } + } + if !erase && !union.is_empty() && Some(i) == style_src { + let mut m = shape_node(d, union.clone(), Some(leaf)); + if let Some(f) = &fill { + m.appearance.set_fill(f.clone()); + } + merged_id = Some(m.id); + new_nodes.push(m); + } + } + let new_ids = replace_roots(d, &roots, new_nodes)?; + sel.set(new_ids.iter().copied()); + Ok((new_ids, merged_id, touched, None)) + })?; + let mut out = json!({ + "ids": ids_json(&ids), + "merged": merged.map(|m| m.0), + "regions": touched.regions.len(), + "lines": touched.lines.len(), + "edges": touched.edges.len(), + }); + if let Some(e) = split { + out["split"] = json!(e.0); + } + Ok(out) +} + +pub(super) fn sb_regions(s: &mut Session, p: &Value) -> Result { + let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?; + check_numbers(p, &["gap"], "shapeBuilder.regions")?; + let roots = root_ids(s, p)?; + let d = &s.doc()?.doc; + let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p)); + let id_of = |i: usize| leaves.get(i).map(|n| n.id.0); + let regions: Vec = map + .regions + .iter() + .map(|(r, _, bb)| { + json!({ + "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], + "area": po::area(&r.path, FillRule::NonZero), + "sources": r.sources.iter().filter_map(|&i| id_of(i)).collect::>(), + }) + }) + .collect(); + let pieces = |list: &[(po::Shape, Rect)]| -> Vec { + list.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect() + }; + Ok(json!({ "regions": regions, "lines": pieces(&map.lines), "edges": pieces(&map.edges) })) +} diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs new file mode 100644 index 000000000..2eb4bcfcc --- /dev/null +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -0,0 +1,418 @@ +use super::*; + +// ---------- Image Trace ---------- + +fn is_trace_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && n.name.as_deref() == Some(TRACE_NAME) + && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) +} + +/// The `view` param ([`TraceView`] id), if given. +fn view_param(cmd: &str, p: &Value) -> Result> { + let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; + let ids = || TraceView::ALL.map(TraceView::id).join(", "); + v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids()))) +} + +/// The parameters command `c` traces with: the preset's, with `params` over them. +fn trace_params(c: &str, p: &Value) -> Result { + let name = str_param(p, "preset").unwrap_or("Default"); + let enhanced = switch_or(p, "enhanced", false, c)?; + // (A preset without an enhanced version is traced as it is.) + let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name)); + let base = base.ok_or_else(|| bad(c, format!("unknown preset `{name}` (see imageTrace.presets)")))?; + let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) }; + let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?; + let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad(c, "params must be an object")) }; + // A key that isn't one of the settings is an error, with the nearest setting. + if let Some(key) = src.keys().find(|k| !o.contains_key(k.as_str())) { + return Err(crate::cmd::params::unknown(c, key, &o.keys().map(String::as_str).collect::>())); + } + for (k, val) in src { + o.insert(k.clone(), val.clone()); + } + let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; + if !params.fills && !params.strokes { + return Err(bad(c, "Create needs fills, strokes or both")); + } + if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) { + return Err(bad(c, "strokeWidth must be a positive number of pixels")); + } + // A color group is refused unless the trace uses it: Color mode with Document Library and the + // document's swatches. + if params.color_group.as_deref().is_some_and(|g| !g.is_empty()) { + if params.palette != tr::Palette::DocumentLibrary { + return Err(bad(c, "`colorGroup` needs `palette: \"documentLibrary\"`")); + } + if params.library != crate::cmd::swatchlib::DOCUMENT_SWATCHES { + return Err(bad(c, "`colorGroup` needs `library: \"document\"`")); + } + if params.mode != tr::Mode::Color { + return Err(bad(c, "`colorGroup` needs `mode: \"color\"`")); + } + } + Ok(params) +} + +/// Document Library with `colorGroup`: the solid colors of the active document's color group +/// `name` (so named exactly, else ignoring case). Errors name command `c`. +/// ([`trace_params`] has checked that the library is the document's swatches.) +fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result> { + let groups = &s.doc()?.doc.swatch_groups; + let lower = name.to_lowercase(); + let group = groups + .iter() + .find(|g| g.name == name) + .or_else(|| groups.iter().find(|g| g.name.to_lowercase() == lower)) + .ok_or_else(|| bad(c, format!("no color group `{name}` in the document (see swatch.list)")))?; + Ok(group.swatches.iter().filter_map(|w| w.paint.color()).collect()) +} + +/// Document Library in Color mode: look the library's colors (or its color group's) up into +/// `params.swatches` → each one as traced (sRGB) with the color to fill with. Errors name command `c`. +fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result> { + if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { + return Ok(vec![]); + } + let group = params.color_group.as_deref().filter(|g| !g.is_empty()); + let key = group.unwrap_or(params.library.as_str()); + let colors = match group { + Some(name) => trace_group_colors(c, s, name)?, + None => crate::cmd::swatchlib::library_colors(s, key) + .ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?, + }; + if colors.is_empty() { + return Err(bad(c, format!("`{key}` has no colours"))); + } + let library: Vec<([u8; 3], Color)> = colors + .into_iter() + .map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + ([r, g, b], c) + }) + .collect(); + params.swatches = library.iter().map(|l| l.0).collect(); + Ok(library) +} + +/// The most anchor points an Image Trace may make, a guard for memory: a trace past it is refused +/// with advice instead (#525). A trace keeps about 90 bytes an anchor in the document (measured on +/// code-drawn photos of 2000 × 2000 to 6000 × 4000 pixels), so 8,000,000 anchors are about +/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at High Fidelity Photo +/// makes about 1.9 million, and even at Paths 100, Noise 1 and every tone about 7.9 million. On the +/// web, where a page has at most 4 GB of memory, the limit stays at 2,000,000. +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_TRACE_ANCHORS: usize = 8_000_000; + +/// The most anchor points an Image Trace may make on the web (see the native limit). +#[cfg(target_arch = "wasm32")] +pub const MAX_TRACE_ANCHORS: usize = 2_000_000; + +/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`. +fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape { + use vectorcraft_geom::Affine; + let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians()); + let mut live = match shape { + tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle { + w: width, + h: height, + radii: [radius; 4], + kinds: Default::default(), + radii_relative: false, + xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)), + }, + tr::TracedShape::Ellipse { center, rx, ry, rotation } => { + LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) } + } + tr::TracedShape::Polygon { center, radius, sides, rotation } => { + LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![], radii_relative: false } + } + }; + // (Whether the move changed the shape's own numbers does not matter: its path is made from them next.) + let _ = live.transform(xf); + live +} + +/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the +/// document by `xf`. +fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect(); + let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None }); + Paint::Gradient(Box::new(paint)) +} + +/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others +/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had: +/// abutting paths painted in another order would show a seam where they meet. Without parents, +/// the nodes as they are. +fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec> { + let n = nodes.len(); + let mut inside: Vec> = vec![vec![]; n]; + let mut roots = vec![]; + for i in 0..n { + match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) { + Some(of) => of.push(i), + None => roots.push(i), + } + } + // Outermost first, so that backwards each path comes after those inside it. + let mut order = roots.clone(); + let mut at = 0; + while let Some(&i) = order.get(at) { + order.extend(inside.get(i).into_iter().flatten()); + at += 1; + } + let mut nodes: Vec> = nodes.into_iter().map(Some).collect(); + let mut built: Vec>> = vec![None; n]; + for &i in order.iter().rev() { + let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue }; + let mut within: Vec<(usize, Arc)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect(); + let made = if within.is_empty() { + node + } else { + within.push((i, Arc::new(node))); + within.sort_by_key(|m| m.0); + Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect()) + }; + if let Some(slot) = built.get_mut(i) { + *slot = Some(Arc::new(made)); + } + } + // (Paths that are inside each other in a ring are reached from no root: the tracer makes none, + // and they are kept all the same.) + let ring = nodes.into_iter().flatten().map(Arc::new).collect::>(); + roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() +} + +pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { + let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; + let mut params = trace_params(c, p)?; + let view = view_param(c, p)?; + let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; + // The preset's name if the parameters are exactly that preset's or its enhanced version's, + // else "Custom". + let named = str_param(p, "preset").unwrap_or("Default"); + let is = |b: Option| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json); + let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() }; + let roots = root_ids(s, p)?; + // The image (inside a trace group, or selected directly). + let (target, img) = { + let d = &s.doc()?.doc; + roots + .iter() + .find_map(|r| { + let n = d.node(*r)?; + match &n.kind { + NodeKind::Image(i) => Some((*r, i.clone())), + _ if is_trace_group(n) => match &n.children()?.first()?.kind { + NodeKind::Image(i) => Some((*r, i.clone())), + _ => None, + }, + _ => None, + } + }) + .ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))? + }; + let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?; + // An image over the tracer's size is traced scaled down (the paths are scaled back below). + let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let library = trace_library(c, s, &mut params)?; + let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?; + let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height), res.worked_at); + // Pixel space of the decoded raster → the image object's pixel space → document. + let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; + let sy = img.height.max(1) as f64 / raster.height.max(1) as f64; + let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy); + // Document units per raster pixel (stroke widths). + let unit = xf.determinant().abs().sqrt(); + let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len()); + let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" }; + let id = s.edit(label, |d, sel| { + let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; + let mut image = if is_trace_group(&src) { + src.children() + .and_then(|c| c.first()) + .map(|c| (**c).clone()) + .ok_or_else(|| EngineError::Other("Image Trace: the traced image is missing".into()))? + } else { + src.clone() + }; + image.visible = true; + let mut children: Vec> = vec![]; + if !expand { + let mut hidden = image.clone(); + hidden.visible = false; + children.push(Arc::new(hidden)); + } + let mut traced = Vec::with_capacity(res.paths.len()); + for tp in res.paths { + let path = tp.path.transformed(xf); + let mut n = shape_node(d, path, None); + // Shapes: a path the tracer found to be a shape is that live shape. + if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) { + let shape = traced_shape(shape, xf); + *path = shape.to_path(); + *live = Some(Box::new(shape)); + } + // A library colour fills with the swatch's own colour (its model kept). + let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1); + // Gradients: a path the tracer found to be a linear gradient is filled with it. + let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf)); + n.appearance = match tp.stroke { + // Traced lines: round corners, and ends round or square as the tracer found them. + Some(width) => Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { + cap: if tp.square_caps { LineCap::Square } else { LineCap::Round }, + join: LineJoin::Round, + ..StrokeLayer::new(paint, width * unit) + })], + ..Default::default() + } + .into(), + None => fill_only(paint).into(), + }; + traced.push(n); + } + children.extend(grouped(d, traced, &res.parents)); + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = if expand { None } else { Some(TRACE_NAME.into()) }; + if !expand { + // Tracing again keeps the object's view unless another is asked for. + let view = view.unwrap_or_else(|| src.trace_view()); + g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); + } + let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; + d.remove(target)?; + d.insert(par, idx, g)?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors, "warnings": warnings })) +} + +/// Images larger than this on a side are traced, but more slowly: the trace says so. +pub const LARGE_TRACE_SIDE: u32 = 1500; + +/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that +/// Flat Logo averaged it down to `worked` pixels first, or else that a large image takes longer +/// (the public documentation's warning for images over 1500 px) and that one over the tracer's +/// [`tr::MAX_PIXELS`] was traced scaled down. +pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Option<(u32, u32)>) -> Vec { + let mut out = vec![]; + if worked.is_none() && size.0.max(size.1) > LARGE_TRACE_SIDE { + out.push(format!( + "The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.", + size.0, size.1 + )); + } + if let Some(worked) = worked { + out.push(format!("Flat Logo traced the image at {} × {} pixels, averaged down from {} × {}.", worked.0, worked.1, size.0, size.1)); + } else if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) { + out.push(format!( + "The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).", + traced.0, + traced.1, + size.0, + size.1, + tr::MAX_PIXELS >> 20 + )); + } + out +} + +fn trace_targets(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect(); + if v.is_empty() { + return Err(EngineError::Other("select an Image Trace object".into())); + } + Ok(v) +} + +pub(super) fn trace_set_view(s: &mut Session, p: &Value) -> Result { + const C: &str = "imageTrace.setView"; + let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?; + let targets = trace_targets(s, p)?; + let d = &s.doc()?.doc; + if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) { + return Ok(json!({ "ids": ids_json(&targets), "view": view.id() })); + } + s.edit("Image Trace View", |d, _| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) { + t.insert("view".into(), json!(view.id())); + } else { + n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets), "view": view.id() })) +} + +pub(super) fn trace_release(s: &mut Session, p: &Value) -> Result { + let targets = trace_targets(s, p)?; + let ids = s.edit("Release Image Trace", |d, sel| { + let mut out = vec![]; + let mut released = Beside::replacing(d, &targets); + for g in &targets { + let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone(); + img.visible = true; + out.push(img.id); + released.put(d, *g, vec![img])?; + } + released.finish(d)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { + let targets = trace_targets(s, p)?; + s.edit("Expand Image Trace", |d, sel| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + n.name = None; + n.extras_mut().trace = None; + if let Some(ch) = n.children_mut() { + ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); + } + } + sel.set(targets.iter().copied()); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets) })) +} + +#[cfg(test)] +mod trace_limit_tests { + use super::{MAX_TRACE_ANCHORS, trace_warnings}; + + #[test] + fn trace_warnings_name_a_large_or_scaled_down_image() { + assert!(trace_warnings((1500, 1500), (1500, 1500), None).is_empty()); + assert_eq!(trace_warnings((1501, 10), (1501, 10), None).len(), 1); + let w = trace_warnings((16000, 12000), (9459, 7094), None); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}"); + // Flat Logo's own working size is named instead of the megapixel limit. + let w = trace_warnings((1025, 1025), (1025, 1025), Some((513, 513))); + assert_eq!(w, ["Flat Logo traced the image at 513 × 513 pixels, averaged down from 1025 × 1025."]); + // Flat Logo traces a large image at its reduced size, so it gives no slow-trace warning. + let w = trace_warnings((1600, 1600), (1600, 1600), Some((800, 800))); + assert_eq!(w, ["Flat Logo traced the image at 800 × 800 pixels, averaged down from 1600 × 1600."]); + let w = trace_warnings((16000, 12000), (9459, 7094), Some((946, 710))); + assert_eq!(w, ["Flat Logo traced the image at 946 × 710 pixels, averaged down from 16000 × 12000."]); + } + + // A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit; + // the limit still guards memory (about 90 bytes an anchor). Checked when the tests build. + const _: () = assert!(MAX_TRACE_ANCHORS >= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000); +} From c75c6a9586ecebe3cf2d4526d7e4469713b55d5d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:00 +0000 Subject: [PATCH 150/199] refactor(engine): move cmd/distortcmds.rs items to cmd/distortcmds/{perspective}.rs --- crates/engine/src/cmd/distortcmds.rs | 617 +----------------- .../engine/src/cmd/distortcmds/perspective.rs | 610 +++++++++++++++++ 2 files changed, 618 insertions(+), 609 deletions(-) create mode 100644 crates/engine/src/cmd/distortcmds/perspective.rs diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 0d7a69639..674d44362 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -17,6 +17,14 @@ use super::edit::{duplicate_in, outermost, selected_roots}; use super::*; use crate::EngineError; +mod perspective; + +pub use perspective::perspective_click; +use perspective::{ + attach, edit_text, grid_set, grid_show, persp_draw, persp_move, persp_nudge, persp_transform, plane_match, plane_move, plane_set, release, +}; +pub(crate) use perspective::{finish_edit_text, grid_of, perspective_rewrite, silent, store_grid, transform_again}; + pub fn specs() -> Vec { vec![ cmd!( @@ -775,612 +783,3 @@ fn puppet_pins(s: &mut Session, p: &Value) -> Result { v["auto"] = json!(set.auto); Ok(v) } - -// ---------- perspective grid ---------- - -/// The document's perspective grid (the default preset when none was defined). -pub(crate) fn grid_of(d: &Document) -> PerspectiveGrid { - PerspectiveGrid::effective(d) -} - -/// Write the grid into the document (`Document.unknown["perspectiveGrid"]`). -pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) { - g.store(d); -} - -/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g` -/// leaves `d` as it is. -fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool { - let mut g = g.clone(); - g.attached.clear(); - serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v)) -} - -/// Change view state of the grid without an undo step. -pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result { - let st = s.doc_mut()?; - let mut g = grid_of(&st.doc); - f(&mut g); - store_grid(Arc::make_mut(&mut st.doc), &g); - if let Some(it) = &mut st.interaction { - store_grid(Arc::make_mut(&mut it.doc), &g); - } - st.revision += 1; - Ok(g) -} - -fn grid_set(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.grid.set"; - let old = grid_of(&s.doc()?.doc); - let g = old.merged(p).map_err(|e| bad(C, e))?; - let reproject = switch_or(p, "reproject", false, C)?; - // When the document holds this grid already, no object moves, and the document and its undo - // history stay as they are. - if grid_kept(&s.doc()?.doc, &g) { - return Ok(g.definition_json()); - } - s.edit("Define Perspective Grid", |d, _| { - if reproject { - old.adopt_stored_attachments(d); - for (id, plane, depth) in persp::attached_roots(d) { - let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth)); - let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?; - let m = h.then_after(&hi); - warp_checked(d, id, &m)?; - } - } - store_grid(d, &g); - Ok(()) - })?; - Ok(g.definition_json()) -} - -fn grid_show(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.grid.show"; - // A missing or null `visible` toggles the grid. - let want = match p.get("visible") { - None | Some(Value::Null) => None, - Some(_) => Some(switch_or(p, "visible", false, C)?), - }; - let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?; - Ok(json!({ "visible": g.visible })) -} - -fn plane_set(s: &mut Session, p: &Value) -> Result { - let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?; - silent(s, |g| g.plane = plane)?; - Ok(json!({ "plane": plane.id() })) -} - -fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result { - let plane = match str_param(p, "plane") { - Some(v) => Plane::parse(v).ok_or_else(|| bad(cmd, "plane must be left|right|ground"))?, - None => g.plane, - }; - if plane == Plane::None { - return Err(EngineError::Other("no active perspective plane".into())); - } - Ok(plane) -} - -fn roots(s: &Session, p: &Value) -> Result> { - match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { - Some(v) => Ok(outermost(&s.doc()?.doc, v)), - None => selected_roots(s), - } -} - -/// Map `n` with `f`, failing if any point would leave the plane's visible side. -/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it), -/// failing if any point would leave the plane's visible side. -fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let mut pts = vec![]; - collect_points(n, &mut pts); - if pts.iter().any(|q| h.apply(*q).is_none()) { - return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into())); - } - project_node(n, h); - Ok(()) -} - -/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners -/// (on the nearest gridlines with `snap`). -fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option) -> Result<()> { - let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?; - let m = - map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?; - warp_checked(d, id, &m)?; - persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane)); - g.attached.insert(id.0.to_string(), plane); - Ok(()) -} - -fn attach(s: &mut Session, p: &Value) -> Result { - let ids = roots(s, p)?; - let mut g = grid_of(&s.doc()?.doc); - let plane = plane_param(&g, p, "perspective.attach")?; - // With no object to attach, when the document holds the grid already, the document and its undo - // history stay as they are. - if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) { - return Ok(json!({ "ids": [], "plane": plane.id() })); - } - s.edit("Attach to Active Plane", |d, _| { - for id in &ids { - attach_in(d, &mut g, *id, plane, false, None)?; - } - store_grid(d, &g); - Ok(()) - })?; - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "plane": plane.id() })) -} - -fn release(s: &mut Session, p: &Value) -> Result { - let ids = roots(s, p)?; - let mut g = grid_of(&s.doc()?.doc); - // When releasing leaves every object as it is (none is in perspective) and the document holds - // the grid already, the document and its undo history stay as they are. - let d = &s.doc()?.doc; - let unchanged = |id: &NodeId| { - d.node(*id).is_none_or(|n| { - let mut released = n.clone(); - persp::release(&mut released); - released == *n - }) - }; - if ids.iter().all(unchanged) && grid_kept(d, &g) { - return ok(); - } - s.edit("Release with Perspective", |d, _| { - for id in &ids { - g.attached.remove(&id.0.to_string()); - if let Some(n) = d.node_mut(*id) { - persp::release(n); - } - } - store_grid(d, &g); - Ok(()) - })?; - ok() -} - -/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and -/// the depth it ends at. -type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>; - -/// Where an object is in perspective: its plane and depth. -type Attached = (Plane, f64); - -fn beyond_horizon() -> EngineError { - EngineError::Other("the pointer is beyond the plane's horizon".into()) -} - -/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments -/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]). -fn stores_attachments(d: &Document) -> bool { - !PerspectiveGrid::current(d).attached.is_empty() -} - -/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay -/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach -/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an -/// interaction whose document is still its snapshot, the duplicates are the copies the interaction -/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its -/// own ([`stores_attachments`]). -fn persp_edit( - s: &mut Session, - label: &str, - ids: &[NodeId], - fallback: Option, - copy: bool, - step: PerspStep, -) -> Result<(Vec, Option)> { - if ids.is_empty() { - return Err(EngineError::Other("select the objects to transform in perspective".into())); - } - let mut g = grid_of(&s.doc()?.doc); - let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids, false)? } else { None }; - s.edit(label, |d, sel| { - g.adopt_stored_attachments(d); - let targets = match &kept { - // The document becomes the snapshot with the kept copies, not yet mapped, and the loop - // below maps only the copies. - Some(k) => { - *d = Document::clone(&k.doc); - sel.clone_from(&k.selection); - k.ids.clone() - } - None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?, - None => ids.to_vec(), - }; - let mut first = None; - for id in &targets { - let (plane, depth) = match g.attachment_of(d, *id) { - Some(a) => a, - None => { - let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?; - attach_in(d, &mut g, *id, pl, false, None)?; - (pl, g.offset(pl)) - } - }; - first.get_or_insert((plane, depth)); - let (h, depth) = step(&g, plane, depth)?; - warp_checked(d, *id, &h)?; - persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth); - } - store_grid(d, &g); - Ok((targets, first)) - }) -} - -/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one -/// of them is in perspective already, `still` holds for each one's plane and depth, and the document -/// holds the grid already; None otherwise. -fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result> { - let d = &s.doc()?.doc; - let g = grid_of(d); - if ids.is_empty() || !grid_kept(d, &g) { - return Ok(None); - } - let at: Option> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect(); - Ok(at.and_then(|at| at.first().copied())) -} - -/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once -/// the drag that made it is committed. -fn record_again(s: &mut Session, again: Value) -> Result<()> { - let st = s.doc_mut()?; - match &mut st.interaction { - Some(it) => it.perspective_again = Some(again), - None => st.last_perspective = Some(again), - } - Ok(()) -} - -fn ids_json(ids: &[NodeId]) -> Value { - json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) -} - -/// A plane-space affine map as `perspective.transform`'s `matrix` param. -fn matrix_json(m: Affine) -> Value { - json!(m.as_coeffs()) -} - -fn persp_move(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.move"; - let ids = roots(s, p)?; - let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?; - let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?; - if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) { - return Err(bad(C, "from and to must be finite")); - } - let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); - let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?); - // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. - check_switches(p, &["snap"], C)?; - // Snap to Grid: a correction for the objects on the first one's plane and depth. - let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; - let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { - dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2) - }; - // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. - let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { - if perp { - let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; - Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) - } else { - let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); - Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) - } - }; - let label = if copy { "Copy in Perspective" } else { "Move in Perspective" }; - // When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid - // included) and there is no copy, the document and its undo history stay as they are. - let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| { - if perp { - g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9) - } else { - g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) - } - }; - let first_still = if copy { None } else { unmoved(s, &ids, still)? }; - let (targets, first) = match first_still { - Some(at) => (ids, Some(at)), - None => persp_edit(s, label, &ids, fallback, copy, &step)?, - }; - // Transform Again repeats the first object's move, and the identity when no object moved. - if let Some((pl, depth)) = first { - let g = grid_of(&s.doc()?.doc); - let again = match perp { - _ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})), - true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), - false => { - g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy})) - } - }; - if let Some(a) = again { - record_again(s, a)?; - } - } - Ok(ids_json(&targets)) -} - -fn persp_transform(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.transform"; - let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?; - let dz = number_or(p, "depth", 0.0, C)?; - if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() { - return Err(bad(C, "matrix must be finite and invertible, depth finite")); - } - let ids = roots(s, p)?; - let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); - let copy = switch_or(p, "copy", false, C)?; - let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { - let to = depth + dz; - if to.abs() > persp::MAX_DEPTH { - return Err(EngineError::Other("the objects would leave the grid".into())); - } - Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to)) - }; - // An identity without a copy moves no object that is in perspective already: then the document - // and its undo history stay as they are, and Transform Again repeats the identity. - let identity = !copy && m == Affine::IDENTITY && dz == 0.0; - let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() { - ids - } else { - persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0 - }; - record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?; - Ok(ids_json(&targets)) -} - -/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space -/// transform on the selection. -pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result { - let mut p = again.clone(); - if let Some(o) = p.as_object_mut() { - o.remove("ids"); - } - persp_transform(s, &p) -} - -fn plane_move(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.plane.move"; - check_numbers(p, &["offset", "by"], C)?; - let old = grid_of(&s.doc()?.doc); - let plane = plane_param(&old, p, C)?; - let from = old.offset(plane); - let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) { - (Some(o), _) => o, - (None, Some(b)) => from + b, - (None, None) => return Err(bad(C, "give offset or by")), - }; - let objects = str_param(p, "objects").unwrap_or("none"); - if !matches!(objects, "none" | "move" | "copy") { - return Err(bad(C, "objects must be none, move or copy")); - } - let mut g = old.clone(); - g.set_offset(plane, to); - g.validate().map_err(|e| bad(C, e))?; - let label = match objects { - "copy" => "Move Plane and Copy Objects", - "move" => "Move Plane and Objects", - _ => "Move Plane", - }; - // The objects on the plane where it was (not those moved off it along its normal). - let on_plane = |d: &Document| -> Vec { - let editable = d.editable(); - persp::attached_roots(d) - .into_iter() - .filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id)) - .map(|(id, ..)| id) - .collect() - }; - // A plane moved to where it is, with its objects or without them, moves nothing: when the - // document holds the grid already, the document and its undo history stay as they are. - if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) { - let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] }; - return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })); - } - // In an interaction whose document is still its snapshot, the copies of the objects on the - // plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid - // lists attachments of its own (`stores_attachments`). - let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) { - let on = on_plane(&s.doc()?.doc); - super::object::preview_copies(s, &on, false)? - } else { - None - }; - let ids = s.edit(label, |d, sel| { - old.adopt_stored_attachments(d); - let mut ids = vec![]; - if objects != "none" { - ids = match &kept { - // The document becomes the snapshot with the kept copies, not yet moved, and the - // loop below moves only the copies. - Some(k) => { - *d = Document::clone(&k.doc); - sel.clone_from(&k.selection); - k.ids.clone() - } - None => { - let on = on_plane(d); - if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on } - } - }; - let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?; - for id in &ids { - warp_checked(d, *id, &m)?; - persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to); - } - } - store_grid(d, &g); - Ok(ids) - })?; - Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })) -} - -fn plane_match(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let id = id_param(p, "id") - .or_else(|| st.selection.objects.first().copied()) - .ok_or_else(|| EngineError::Other("select an object in perspective".into()))?; - let mut g = grid_of(&st.doc); - let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?; - g.set_offset(plane, depth); - g.plane = plane; - g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?; - // When the plane is at the object and active already, and the document holds that grid, the - // document and its undo history stay as they are. - if grid_kept(&st.doc, &g) { - return Ok(json!({ "plane": plane.id(), "offset": depth })); - } - s.edit("Move Plane to Match Object", |d, _| { - store_grid(d, &g); - Ok(()) - })?; - Ok(json!({ "plane": plane.id(), "offset": depth })) -} - -fn persp_nudge(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.nudge"; - let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); - if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { - return Err(bad(C, "dx and dy give the arrow's direction")); - } - let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 }; - let copy = switch_or(p, "copy", false, C)?; - let ids = selected_roots(s)?; - // The step is measured on the page at the centre of the selection's perspective box. - let st = s.doc()?; - let g = grid_of(&st.doc); - let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?; - let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?; - let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k; - persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy})) -} - -fn persp_draw(s: &mut Session, p: &Value) -> Result { - const C: &str = "perspective.draw"; - let command = str_param(p, "command").filter(|c| c.starts_with("shape.")).ok_or_else(|| bad(C, "command must be a shape.* command"))?.to_string(); - let params = p.get("params").cloned().unwrap_or_else(|| json!({})); - let mut g = grid_of(&s.doc()?.doc); - let plane = plane_param(&g, p, C)?; - let snap = switch_or(p, "snap", g.snap, C)?; - // A click-to-size dialog's shape keeps its sizes in plane units from the click. - let map = match p.get("at") { - None | Some(Value::Null) => None, - Some(v) => { - let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; - Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?) - } - }; - let undo_before = s.doc()?.history.undo.len(); - let r = s.execute(&command, ¶ms)?; - let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?; - let res = s.edit("Draw in Perspective", |d, _| { - attach_in(d, &mut g, id, plane, snap, map)?; - store_grid(d, &g); - Ok(()) - }); - // One undo step for the whole gesture. - let st = s.doc_mut()?; - if st.interaction.is_none() && st.history.undo.len() > undo_before + 1 { - st.history.undo.truncate(undo_before + 1); - if let Some(e) = st.history.undo.last_mut() { - e.label = "Draw in Perspective".into(); - } - st.history.recost_last(&st.doc); - } - res?; - Ok(json!({ "id": id.0, "plane": plane.id() })) -} - -/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane. -pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { - const SHAPES: &[&str] = &[ - "shape.rectangle", - "shape.ellipse", - "shape.polygon", - "shape.star", - "shape.line", - "shape.rectangularGrid", - "shape.arc", - "shape.flare", - "shape.spiral", - "shape.polarGrid", - ]; - if !SHAPES.contains(&cmd) { - return None; - } - let g = PerspectiveGrid::current(&s.active()?.doc); - (g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params}))) -} - -/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the -/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the -/// click), else `None` (run it as it is). -pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> { - let (c, mut p) = perspective_rewrite(s, cmd, params)?; - p["at"] = json!([at.x, at.y]); - Some((c, p)) -} - -/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation -/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]). -fn edit_text(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let id = - id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?; - if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) { - return Err(EngineError::Other("select type in perspective".into())); - } - s.edit("Edit Text", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?; - let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) }; - // Shown flat where it is drawn: the type moves there and the projection moves the other - // way, so the picture stays. - let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; - let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); - t.transform(shift); - if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) { - rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); - rec.editing = true; - } - Ok(()) - })?; - let st = s.doc_mut()?; - st.isolation = Some(id); - st.selection.set([id]); - st.revision += 1; - Ok(json!({ "id": id.0 })) -} - -/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and -/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved -/// state stays saved, [`crate::DocState::change_unsaved`]). -pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { - let clear = |doc: &mut Arc| { - // Undo steps keep no lookup cache: look without building one, and drop the one an edit - // builds (the document now keeps its own). - if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing) - && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut())) - { - p.editing = false; - } - }; - st.change_unsaved(clear); - for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { - let saved = Arc::ptr_eq(&e.doc, &st.saved_doc); - clear(&mut e.doc); - e.doc.drop_lookup_cache(); - if saved { - st.saved_doc = e.doc.clone(); - } - } - if let Some(it) = &mut st.interaction { - clear(&mut it.doc); - } - st.revision += 1; -} diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs new file mode 100644 index 000000000..a655e3e3f --- /dev/null +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -0,0 +1,610 @@ +use super::*; + +// ---------- perspective grid ---------- + +/// The document's perspective grid (the default preset when none was defined). +pub(crate) fn grid_of(d: &Document) -> PerspectiveGrid { + PerspectiveGrid::effective(d) +} + +/// Write the grid into the document (`Document.unknown["perspectiveGrid"]`). +pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) { + g.store(d); +} + +/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g` +/// leaves `d` as it is. +fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool { + let mut g = g.clone(); + g.attached.clear(); + serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v)) +} + +/// Change view state of the grid without an undo step. +pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result { + let st = s.doc_mut()?; + let mut g = grid_of(&st.doc); + f(&mut g); + store_grid(Arc::make_mut(&mut st.doc), &g); + if let Some(it) = &mut st.interaction { + store_grid(Arc::make_mut(&mut it.doc), &g); + } + st.revision += 1; + Ok(g) +} + +pub(super) fn grid_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.grid.set"; + let old = grid_of(&s.doc()?.doc); + let g = old.merged(p).map_err(|e| bad(C, e))?; + let reproject = switch_or(p, "reproject", false, C)?; + // When the document holds this grid already, no object moves, and the document and its undo + // history stay as they are. + if grid_kept(&s.doc()?.doc, &g) { + return Ok(g.definition_json()); + } + s.edit("Define Perspective Grid", |d, _| { + if reproject { + old.adopt_stored_attachments(d); + for (id, plane, depth) in persp::attached_roots(d) { + let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth)); + let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?; + let m = h.then_after(&hi); + warp_checked(d, id, &m)?; + } + } + store_grid(d, &g); + Ok(()) + })?; + Ok(g.definition_json()) +} + +pub(super) fn grid_show(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.grid.show"; + // A missing or null `visible` toggles the grid. + let want = match p.get("visible") { + None | Some(Value::Null) => None, + Some(_) => Some(switch_or(p, "visible", false, C)?), + }; + let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?; + Ok(json!({ "visible": g.visible })) +} + +pub(super) fn plane_set(s: &mut Session, p: &Value) -> Result { + let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?; + silent(s, |g| g.plane = plane)?; + Ok(json!({ "plane": plane.id() })) +} + +fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result { + let plane = match str_param(p, "plane") { + Some(v) => Plane::parse(v).ok_or_else(|| bad(cmd, "plane must be left|right|ground"))?, + None => g.plane, + }; + if plane == Plane::None { + return Err(EngineError::Other("no active perspective plane".into())); + } + Ok(plane) +} + +pub(super) fn roots(s: &Session, p: &Value) -> Result> { + match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(v) => Ok(outermost(&s.doc()?.doc, v)), + None => selected_roots(s), + } +} + +/// Map `n` with `f`, failing if any point would leave the plane's visible side. +/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it), +/// failing if any point would leave the plane's visible side. +fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut pts = vec![]; + collect_points(n, &mut pts); + if pts.iter().any(|q| h.apply(*q).is_none()) { + return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into())); + } + project_node(n, h); + Ok(()) +} + +/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners +/// (on the nearest gridlines with `snap`). +fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option) -> Result<()> { + let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?; + let m = + map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?; + warp_checked(d, id, &m)?; + persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane)); + g.attached.insert(id.0.to_string(), plane); + Ok(()) +} + +pub(super) fn attach(s: &mut Session, p: &Value) -> Result { + let ids = roots(s, p)?; + let mut g = grid_of(&s.doc()?.doc); + let plane = plane_param(&g, p, "perspective.attach")?; + // With no object to attach, when the document holds the grid already, the document and its undo + // history stay as they are. + if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) { + return Ok(json!({ "ids": [], "plane": plane.id() })); + } + s.edit("Attach to Active Plane", |d, _| { + for id in &ids { + attach_in(d, &mut g, *id, plane, false, None)?; + } + store_grid(d, &g); + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "plane": plane.id() })) +} + +pub(super) fn release(s: &mut Session, p: &Value) -> Result { + let ids = roots(s, p)?; + let mut g = grid_of(&s.doc()?.doc); + // When releasing leaves every object as it is (none is in perspective) and the document holds + // the grid already, the document and its undo history stay as they are. + let d = &s.doc()?.doc; + let unchanged = |id: &NodeId| { + d.node(*id).is_none_or(|n| { + let mut released = n.clone(); + persp::release(&mut released); + released == *n + }) + }; + if ids.iter().all(unchanged) && grid_kept(d, &g) { + return ok(); + } + s.edit("Release with Perspective", |d, _| { + for id in &ids { + g.attached.remove(&id.0.to_string()); + if let Some(n) = d.node_mut(*id) { + persp::release(n); + } + } + store_grid(d, &g); + Ok(()) + })?; + ok() +} + +/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and +/// the depth it ends at. +type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>; + +/// Where an object is in perspective: its plane and depth. +type Attached = (Plane, f64); + +fn beyond_horizon() -> EngineError { + EngineError::Other("the pointer is beyond the plane's horizon".into()) +} + +/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments +/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]). +fn stores_attachments(d: &Document) -> bool { + !PerspectiveGrid::current(d).attached.is_empty() +} + +/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay +/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach +/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an +/// interaction whose document is still its snapshot, the duplicates are the copies the interaction +/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its +/// own ([`stores_attachments`]). +fn persp_edit( + s: &mut Session, + label: &str, + ids: &[NodeId], + fallback: Option, + copy: bool, + step: PerspStep, +) -> Result<(Vec, Option)> { + if ids.is_empty() { + return Err(EngineError::Other("select the objects to transform in perspective".into())); + } + let mut g = grid_of(&s.doc()?.doc); + let kept = if copy && !stores_attachments(&s.doc()?.doc) { crate::cmd::object::preview_copies(s, ids, false)? } else { None }; + s.edit(label, |d, sel| { + g.adopt_stored_attachments(d); + let targets = match &kept { + // The document becomes the snapshot with the kept copies, not yet mapped, and the loop + // below maps only the copies. + Some(k) => { + *d = Document::clone(&k.doc); + sel.clone_from(&k.selection); + k.ids.clone() + } + None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?, + None => ids.to_vec(), + }; + let mut first = None; + for id in &targets { + let (plane, depth) = match g.attachment_of(d, *id) { + Some(a) => a, + None => { + let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?; + attach_in(d, &mut g, *id, pl, false, None)?; + (pl, g.offset(pl)) + } + }; + first.get_or_insert((plane, depth)); + let (h, depth) = step(&g, plane, depth)?; + warp_checked(d, *id, &h)?; + persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth); + } + store_grid(d, &g); + Ok((targets, first)) + }) +} + +/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one +/// of them is in perspective already, `still` holds for each one's plane and depth, and the document +/// holds the grid already; None otherwise. +fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result> { + let d = &s.doc()?.doc; + let g = grid_of(d); + if ids.is_empty() || !grid_kept(d, &g) { + return Ok(None); + } + let at: Option> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect(); + Ok(at.and_then(|at| at.first().copied())) +} + +/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once +/// the drag that made it is committed. +fn record_again(s: &mut Session, again: Value) -> Result<()> { + let st = s.doc_mut()?; + match &mut st.interaction { + Some(it) => it.perspective_again = Some(again), + None => st.last_perspective = Some(again), + } + Ok(()) +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// A plane-space affine map as `perspective.transform`'s `matrix` param. +fn matrix_json(m: Affine) -> Value { + json!(m.as_coeffs()) +} + +pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.move"; + let ids = roots(s, p)?; + let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?; + let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?; + if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) { + return Err(bad(C, "from and to must be finite")); + } + let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); + let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?); + // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. + check_switches(p, &["snap"], C)?; + // Snap to Grid: a correction for the objects on the first one's plane and depth. + let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; + let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { + dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2) + }; + // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. + let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { + if perp { + let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; + Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) + } else { + let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); + Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) + } + }; + let label = if copy { "Copy in Perspective" } else { "Move in Perspective" }; + // When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid + // included) and there is no copy, the document and its undo history stay as they are. + let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| { + if perp { + g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9) + } else { + g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) + } + }; + let first_still = if copy { None } else { unmoved(s, &ids, still)? }; + let (targets, first) = match first_still { + Some(at) => (ids, Some(at)), + None => persp_edit(s, label, &ids, fallback, copy, &step)?, + }; + // Transform Again repeats the first object's move, and the identity when no object moved. + if let Some((pl, depth)) = first { + let g = grid_of(&s.doc()?.doc); + let again = match perp { + _ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})), + true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), + false => { + g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy})) + } + }; + if let Some(a) = again { + record_again(s, a)?; + } + } + Ok(ids_json(&targets)) +} + +pub(super) fn persp_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.transform"; + let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?; + let dz = number_or(p, "depth", 0.0, C)?; + if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() { + return Err(bad(C, "matrix must be finite and invertible, depth finite")); + } + let ids = roots(s, p)?; + let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); + let copy = switch_or(p, "copy", false, C)?; + let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { + let to = depth + dz; + if to.abs() > persp::MAX_DEPTH { + return Err(EngineError::Other("the objects would leave the grid".into())); + } + Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to)) + }; + // An identity without a copy moves no object that is in perspective already: then the document + // and its undo history stay as they are, and Transform Again repeats the identity. + let identity = !copy && m == Affine::IDENTITY && dz == 0.0; + let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() { + ids + } else { + persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0 + }; + record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?; + Ok(ids_json(&targets)) +} + +/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space +/// transform on the selection. +pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result { + let mut p = again.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("ids"); + } + persp_transform(s, &p) +} + +pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.plane.move"; + check_numbers(p, &["offset", "by"], C)?; + let old = grid_of(&s.doc()?.doc); + let plane = plane_param(&old, p, C)?; + let from = old.offset(plane); + let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) { + (Some(o), _) => o, + (None, Some(b)) => from + b, + (None, None) => return Err(bad(C, "give offset or by")), + }; + let objects = str_param(p, "objects").unwrap_or("none"); + if !matches!(objects, "none" | "move" | "copy") { + return Err(bad(C, "objects must be none, move or copy")); + } + let mut g = old.clone(); + g.set_offset(plane, to); + g.validate().map_err(|e| bad(C, e))?; + let label = match objects { + "copy" => "Move Plane and Copy Objects", + "move" => "Move Plane and Objects", + _ => "Move Plane", + }; + // The objects on the plane where it was (not those moved off it along its normal). + let on_plane = |d: &Document| -> Vec { + let editable = d.editable(); + persp::attached_roots(d) + .into_iter() + .filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id)) + .map(|(id, ..)| id) + .collect() + }; + // A plane moved to where it is, with its objects or without them, moves nothing: when the + // document holds the grid already, the document and its undo history stay as they are. + if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) { + let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] }; + return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })); + } + // In an interaction whose document is still its snapshot, the copies of the objects on the + // plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid + // lists attachments of its own (`stores_attachments`). + let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) { + let on = on_plane(&s.doc()?.doc); + crate::cmd::object::preview_copies(s, &on, false)? + } else { + None + }; + let ids = s.edit(label, |d, sel| { + old.adopt_stored_attachments(d); + let mut ids = vec![]; + if objects != "none" { + ids = match &kept { + // The document becomes the snapshot with the kept copies, not yet moved, and the + // loop below moves only the copies. + Some(k) => { + *d = Document::clone(&k.doc); + sel.clone_from(&k.selection); + k.ids.clone() + } + None => { + let on = on_plane(d); + if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on } + } + }; + let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?; + for id in &ids { + warp_checked(d, *id, &m)?; + persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to); + } + } + store_grid(d, &g); + Ok(ids) + })?; + Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +pub(super) fn plane_match(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = id_param(p, "id") + .or_else(|| st.selection.objects.first().copied()) + .ok_or_else(|| EngineError::Other("select an object in perspective".into()))?; + let mut g = grid_of(&st.doc); + let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?; + g.set_offset(plane, depth); + g.plane = plane; + g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?; + // When the plane is at the object and active already, and the document holds that grid, the + // document and its undo history stay as they are. + if grid_kept(&st.doc, &g) { + return Ok(json!({ "plane": plane.id(), "offset": depth })); + } + s.edit("Move Plane to Match Object", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + Ok(json!({ "plane": plane.id(), "offset": depth })) +} + +pub(super) fn persp_nudge(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.nudge"; + let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); + if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { + return Err(bad(C, "dx and dy give the arrow's direction")); + } + let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 }; + let copy = switch_or(p, "copy", false, C)?; + let ids = selected_roots(s)?; + // The step is measured on the page at the centre of the selection's perspective box. + let st = s.doc()?; + let g = grid_of(&st.doc); + let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?; + let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?; + let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k; + persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy})) +} + +pub(super) fn persp_draw(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.draw"; + let command = str_param(p, "command").filter(|c| c.starts_with("shape.")).ok_or_else(|| bad(C, "command must be a shape.* command"))?.to_string(); + let params = p.get("params").cloned().unwrap_or_else(|| json!({})); + let mut g = grid_of(&s.doc()?.doc); + let plane = plane_param(&g, p, C)?; + let snap = switch_or(p, "snap", g.snap, C)?; + // A click-to-size dialog's shape keeps its sizes in plane units from the click. + let map = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => { + let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; + Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?) + } + }; + let undo_before = s.doc()?.history.undo.len(); + let r = s.execute(&command, ¶ms)?; + let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?; + let res = s.edit("Draw in Perspective", |d, _| { + attach_in(d, &mut g, id, plane, snap, map)?; + store_grid(d, &g); + Ok(()) + }); + // One undo step for the whole gesture. + let st = s.doc_mut()?; + if st.interaction.is_none() && st.history.undo.len() > undo_before + 1 { + st.history.undo.truncate(undo_before + 1); + if let Some(e) = st.history.undo.last_mut() { + e.label = "Draw in Perspective".into(); + } + st.history.recost_last(&st.doc); + } + res?; + Ok(json!({ "id": id.0, "plane": plane.id() })) +} + +/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane. +pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { + const SHAPES: &[&str] = &[ + "shape.rectangle", + "shape.ellipse", + "shape.polygon", + "shape.star", + "shape.line", + "shape.rectangularGrid", + "shape.arc", + "shape.flare", + "shape.spiral", + "shape.polarGrid", + ]; + if !SHAPES.contains(&cmd) { + return None; + } + let g = PerspectiveGrid::current(&s.active()?.doc); + (g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params}))) +} + +/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the +/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the +/// click), else `None` (run it as it is). +pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> { + let (c, mut p) = perspective_rewrite(s, cmd, params)?; + p["at"] = json!([at.x, at.y]); + Some((c, p)) +} + +/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation +/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]). +pub(super) fn edit_text(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = + id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?; + if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) { + return Err(EngineError::Other("select type in perspective".into())); + } + s.edit("Edit Text", |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?; + let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) }; + // Shown flat where it is drawn: the type moves there and the projection moves the other + // way, so the picture stays. + let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; + let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); + t.transform(shift); + if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) { + rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); + rec.editing = true; + } + Ok(()) + })?; + let st = s.doc_mut()?; + st.isolation = Some(id); + st.selection.set([id]); + st.revision += 1; + Ok(json!({ "id": id.0 })) +} + +/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and +/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved +/// state stays saved, [`crate::DocState::change_unsaved`]). +pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { + let clear = |doc: &mut Arc| { + // Undo steps keep no lookup cache: look without building one, and drop the one an edit + // builds (the document now keeps its own). + if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing) + && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut())) + { + p.editing = false; + } + }; + st.change_unsaved(clear); + for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { + let saved = Arc::ptr_eq(&e.doc, &st.saved_doc); + clear(&mut e.doc); + e.doc.drop_lookup_cache(); + if saved { + st.saved_doc = e.doc.clone(); + } + } + if let Some(it) = &mut st.interaction { + clear(&mut it.doc); + } + st.revision += 1; +} From 71aef5cc85100ac63f1283ced577391c3aee2d78 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:00 +0000 Subject: [PATCH 151/199] refactor(engine): move cmd/draw2.rs items to cmd/draw2/{anchors,commands,common,cut,freehand,smooth}.rs --- crates/engine/src/cmd/draw2.rs | 1636 +---------------------- crates/engine/src/cmd/draw2/anchors.rs | 269 ++++ crates/engine/src/cmd/draw2/commands.rs | 136 ++ crates/engine/src/cmd/draw2/common.rs | 235 ++++ crates/engine/src/cmd/draw2/cut.rs | 328 +++++ crates/engine/src/cmd/draw2/freehand.rs | 396 ++++++ crates/engine/src/cmd/draw2/smooth.rs | 284 ++++ 7 files changed, 1657 insertions(+), 1627 deletions(-) create mode 100644 crates/engine/src/cmd/draw2/anchors.rs create mode 100644 crates/engine/src/cmd/draw2/commands.rs create mode 100644 crates/engine/src/cmd/draw2/common.rs create mode 100644 crates/engine/src/cmd/draw2/cut.rs create mode 100644 crates/engine/src/cmd/draw2/freehand.rs create mode 100644 crates/engine/src/cmd/draw2/smooth.rs diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 0ef78a06a..5b884b3ee 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -20,1631 +20,13 @@ use super::pathops::{Beside, remove_each}; use super::*; use crate::EngineError; -pub fn specs() -> Vec { - vec![ - cmd!( - "path.freehand", - "Pencil", - [], - None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", - has_doc, - freehand - ), - cmd!( - "path.curvature", - "Curvature", - [], - None, - "{points: [{x, y, corner?}], closed?, id?} create (or with id: replace) a path curving through the points → {id}", - has_doc, - curvature - ), - cmd!( - "path.curvatureEdit", - "Curvature", - [], - None, - "{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", corner?, x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments (a smooth anchor takes the Curvature tool's handles, along the circle through it and its neighbours); insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y, curving into it unless corner is true; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner", - has_doc, - curvature_edit - ), - cmd!( - "path.removeAnchor", - "Delete Anchor Point", - [], - None, - "{id, subpath?, anchor} remove one anchor, re-fitting the curve", - has_doc, - remove_anchor - ), - cmd!( - "path.removeAnchors", - "Remove Anchor Points", - ["Object", "Path"], - None, - "{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}", - has_anchors, - remove_anchors - ), - cmd!( - "path.convertAnchor", - "Convert Anchor Point", - [], - None, - "{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)", - has_doc, - convert_anchor - ), - cmd!( - "path.reshapeSegment", - "Reshape Segment", - [], - None, - "{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does", - has_doc, - reshape_segment - ), - cmd!( - "path.split", - "Split Path", - [], - None, - "{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}", - has_doc, - split - ), - cmd!( - "path.knife", - "Knife", - [], - None, - "{points: [[x,y]…]} cut closed shapes (selected, or all when nothing is selected) along the polyline → {ids}", - has_doc, - knife - ), - cmd!( - "path.eraseRegion", - "Eraser", - [], - None, - "{points: [[x,y]…], size?: pt (10), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", - has_doc, - erase_region - ), - cmd!( - "path.eraseSegments", - "Path Eraser", - [], - None, - "{points: [[x,y]…], width?: pt (8)} erase the parts of selected paths under the drag → {ids}", - has_selection, - erase_segments - ), - cmd!( - "path.smoothRegion", - "Smooth", - [], - None, - "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}", - has_selection, - smooth_region - ), - cmd!( - "path.joinScrub", - "Join", - [], - None, - "{points: [[x,y]…], tolerance?: pt (12)} join the open-path endpoints scrubbed over → {id}", - has_doc, - join_scrub - ), - cmd!( - "path.blob", - "Blob Brush", - [], - None, - "{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}", - has_doc, - blob - ), - ] -} +mod anchors; +mod commands; +mod common; +mod cut; +mod freehand; +mod smooth; -// ---------- helpers ---------- - -/// `[[x,y]…]` or `[{x,y}…]`. -fn points_param(p: &Value, key: &str, cmd: &str) -> Result> { - Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect()) -} - -/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite -/// number. -fn point_entry(v: &Value, cmd: &str) -> Result { - let xy = match v { - Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)), - Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)), - _ => None, - }; - xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers")) -} - -/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or -/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a -/// number nor null, is an error. -fn samples_param(p: &Value, key: &str, cmd: &str) -> Result)>> { - let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?; - let pts: Vec<(Point, Option)> = a - .iter() - .map(|v| { - let pressure = match v { - Value::Array(xy) => xy.get(2), - _ => v.get("pressure"), - }; - match pressure { - None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))), - Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))), - } - }) - .collect::>()?; - if pts.is_empty() { - return Err(bad(cmd, "need at least one point")); - } - Ok(pts) -} - -/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of -/// its samples has one; samples without one count as full pressure, as a pen event without one. -fn stroke_pressure(samples: &[(Point, Option)]) -> Option { - if samples.iter().all(|(_, p)| p.is_none()) { - return None; - } - let mut at = 0.0; - let mut prev: Option = None; - let along: Vec<(f64, f64)> = samples - .iter() - .map(|(pt, p)| { - at += prev.map_or(0.0, |q| q.distance(*pt)); - prev = Some(*pt); - (at, p.unwrap_or(1.0)) - }) - .collect(); - PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p))) -} - -/// The length of a subpath. -fn subpath_len(sp: &SubPath) -> f64 { - use kurbo::ParamCurveArclen; - PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum() -} - -/// The bound, in points, on the length of a `path.reshapeSegment` delta and on the x and y of a -/// point given to `path.convertAnchor` or `path.freehand`. The blend spine commands set the same -/// bound (`object.blend.spine.reshapeSegment`, `object.blend.spine.moveAnchor`). -const MAX_INPUT: f64 = 1e9; - -/// Whether `q` is finite and its x and y are each smaller than [`MAX_INPUT`] in size. -fn within_input_bound(q: Point) -> bool { - q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT -} - -/// The whole number of 0 or more that `key` holds, read with [`index`]. -fn usize_req(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) -} - -/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other -/// value is [`usize_req`]'s error. -pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(_) => usize_req(p, key, cmd).map(Some), - } -} - -fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - match &mut n.kind { - NodeKind::Path { path, live, .. } => { - *live = None; - Ok(path) - } - _ => Err(EngineError::Other(format!("object {id} is not a path"))), - } -} - -fn path_of(d: &Document, id: NodeId) -> Result<(PathData, FillRule)> { - match d.node(id).map(|n| &n.kind) { - Some(NodeKind::Path { path, rule, .. }) => Ok((path.clone(), *rule)), - Some(_) => Err(EngineError::Other(format!("object {id} is not a path"))), - None => Err(EngineError::NoNode(id)), - } -} - -fn dist_to_polyline(pts: &[Point], q: Point) -> f64 { - if pts.len() == 1 { - return pts[0].distance(q); - } - pts.windows(2) - .map(|w| { - let (a, b) = (w[0], w[1]); - let ab = b - a; - let l2 = ab.hypot2(); - let t = if l2 < 1e-18 { 0.0 } else { ((q - a).dot(ab) / l2).clamp(0.0, 1.0) }; - (a + ab * t).distance(q) - }) - .fold(f64::INFINITY, f64::min) -} - -fn poly_bounds(pts: &[Point], pad: f64) -> Rect { - let r = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p)); - r.inflate(pad, pad) -} - -/// The filled area painted by a round brush of diameter `size` along `pts`. -fn brush_region(pts: &[Point], size: f64) -> PathData { - let mut pts = pts.to_vec(); - if pts.len() == 1 { - pts.push(pts[0] + Vec2::new(0.01, 0.0)); - } - po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), size, Cap::Round, Join::Round, 4.0) -} - -/// Path leaves the area tools act on: under the selected objects, or every path when nothing is -/// selected. Only editable, non-guide paths whose bounds meet `area`. -fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec { - let mut out = vec![]; - let mut push = |n: &Node| { - if let NodeKind::Path { guide: false, path, .. } = &n.kind - && path.control_bounds().is_some_and(|b| b.inflate(0.5, 0.5).intersect(area).area() > 0.0 || b.width() == 0.0 || b.height() == 0.0) - && !out.contains(&n.id) - { - out.push(n.id); - } - }; - if sel.is_empty() { - doc.walk(&mut push); - } else { - for id in &sel.objects { - if let Some(n) = doc.node(*id) { - n.walk(&mut |c: &Node| push(c)); - } - } - } - let editable = doc.editable(); - out.retain(|id| editable(*id)); - out -} - -/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids. -fn compound_members(d: &mut Document, pd: PathData) -> Vec> { - pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect() -} - -/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members -/// are rebuilt, one per subpath). -pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> { - if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) { - let members = compound_members(d, pd); - if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) { - *children = members; - } - return Ok(()); - } - *path_mut(d, id)? = pd; - Ok(()) -} - -/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`. -fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result { - let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone(); - n.id = d.alloc_id(); - match &mut n.kind { - NodeKind::Path { path, live, .. } => { - *path = pd; - *live = None; - } - NodeKind::Compound { children, .. } => *children = compound_members(d, pd), - _ => {} - } - Ok(n) -} - -/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above -/// it; none removes it). `cut` removes the path or puts the other pieces above it. Returns ids. -pub(super) fn replace_with_pieces(d: &mut Document, cut: &mut Beside, id: NodeId, pieces: Vec) -> Result> { - let mut it = pieces.into_iter(); - let Some(first) = it.next() else { - cut.put(d, id, vec![])?; - return Ok(vec![]); - }; - let mut ids = vec![id]; - let mut above = vec![]; - for pd in it { - let n = sibling_with(d, id, pd)?; - ids.push(n.id); - above.push(n); - } - set_geometry(d, id, first)?; - if !above.is_empty() { - let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; - cut.put(d, id, [n].into_iter().chain(above).collect())?; - } - Ok(ids) -} - -pub(super) fn ids_json(ids: &[NodeId]) -> Value { - json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) -} - -/// Split a filled path into connected pieces (each outer contour with its holes). -pub(super) fn components(pd: &PathData) -> Vec { - let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect(); - if subs.len() <= 1 { - return if pd.is_empty() { vec![] } else { vec![pd.clone()] }; - } - let bps: Vec = subs - .iter() - .map(|s| { - let mut b = BezPath::new(); - s.to_bezpath_into(&mut b); - b - }) - .collect(); - let samples: Vec = subs.iter().map(|s| s.segment(0).eval(0.5)).collect(); - let n = subs.len(); - let inside = |j: usize, i: usize| fill_contains(&bps[j], FillRule::NonZero, samples[i]); - let depth: Vec = (0..n).map(|i| (0..n).filter(|&j| j != i && inside(j, i)).count()).collect(); - let areas: Vec = subs.iter().map(|s| s.area().abs()).collect(); - let outers: Vec = (0..n).filter(|&i| depth[i].is_multiple_of(2)).collect(); - let mut groups: Vec> = outers.iter().map(|&o| vec![subs[o].clone()]).collect(); - for h in (0..n).filter(|&i| !depth[i].is_multiple_of(2)) { - let best = outers - .iter() - .enumerate() - .filter(|(_, o)| depth[**o] + 1 == depth[h] && inside(**o, h)) - .min_by(|a, b| areas[*a.1].total_cmp(&areas[*b.1])); - if let Some((k, _)) = best { - groups[k].push(subs[h].clone()); - } - } - groups.into_iter().map(PathData::new).collect() -} - -/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. Each segment -/// is sampled at least every `step` pt (and at least 24 times), so a removed stretch at least -/// `step` long is found. -fn cut_subpath(sp: &SubPath, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { - let nseg = sp.segment_count(); - if nseg == 0 { - return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]); - } - const K: usize = 24; - const MAX_SAMPLES: usize = 4096; - let mut runs: Vec> = vec![]; - let mut cur: Vec<(usize, f64, f64)> = vec![]; - let mut any = false; - for i in 0..nseg { - let c = sp.segment(i); - // The curve moves at most 3 times its longest control-polygon edge per unit of t, so with - // n samples, neighboring samples are at most 3 · edge / n apart. - let edge = (c.p1 - c.p0).hypot().max((c.p2 - c.p1).hypot()).max((c.p3 - c.p2).hypot()); - let wanted = (3.0 * edge / step).ceil(); - let samples = if wanted.is_finite() && wanted > K as f64 { (wanted as usize).min(MAX_SAMPLES) } else { K }; - let mut prev_t = 0.0; - let mut prev_rm = remove(c.eval(0.0)); - let mut start = if prev_rm { None } else { Some(0.0) }; - if prev_rm { - any = true; - if !cur.is_empty() { - runs.push(std::mem::take(&mut cur)); - } - } - for k in 1..=samples { - let t = k as f64 / samples as f64; - let rm = remove(c.eval(t)); - if rm != prev_rm { - let (mut lo, mut hi) = (prev_t, t); - for _ in 0..30 { - let m = 0.5 * (lo + hi); - if remove(c.eval(m)) == prev_rm { lo = m } else { hi = m } - } - let b = 0.5 * (lo + hi); - if prev_rm { - start = Some(b); - } else { - any = true; - if let Some(s) = start.take() { - cur.push((i, s, b)); - } - runs.push(std::mem::take(&mut cur)); - } - } - prev_t = t; - prev_rm = rm; - } - if let Some(s) = start { - cur.push((i, s, 1.0)); - } - } - if !cur.is_empty() { - runs.push(cur); - } - runs.retain(|r| !r.is_empty()); - if !any { - return None; - } - // A closed path's last and first runs meet at anchor 0. - if sp.closed && runs.len() >= 2 { - let first_starts = runs.first().and_then(|r| r.first()).is_some_and(|s| s.0 == 0 && s.1 == 0.0); - let last_ends = runs.last().and_then(|r| r.last()).is_some_and(|s| s.0 + 1 == nseg && s.2 == 1.0); - if first_starts && last_ends { - let first = runs.remove(0); - if let Some(last) = runs.last_mut() { - last.extend(first); - } - } - } - let out = runs - .iter() - .filter(|r| r.len() > 1 || r[0].2 - r[0].1 > 1e-4) - .map(|r| { - let mut anchors: Vec = vec![]; - for &(i, t0, t1) in r { - let c = sp.segment(i).subsegment(t0..t1); - let (h1, h2) = if sp.segment_is_line(i) { (c.p0, c.p3) } else { (c.p1, c.p2) }; - match anchors.last_mut() { - Some(a) => a.h_out = h1, - None => anchors.push(Anchor { h_out: h1, ..Anchor::corner(c.p0) }), - } - anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind: AnchorKind::Corner }); - } - for a in &mut anchors { - *a = Anchor::with_handles(a.p, a.h_in, a.h_out); - } - SubPath::new(anchors, false) - }) - .filter(|s| s.anchors.len() >= 2) - .collect(); - Some(out) -} - -/// Erase from an outline path (open or closed) where `remove` holds, sampling each segment at least -/// every `step` pt (`f64::INFINITY` for the fixed sampling). Returns the new geometry per output -/// object (one object per piece when the path had a single subpath), or None if unchanged. -pub(super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { - let mut changed = false; - let mut subs = vec![]; - for sp in &pd.subpaths { - match cut_subpath(sp, step, remove) { - Some(pieces) => { - changed = true; - subs.extend(pieces); - } - None => subs.push(sp.clone()), - } - } - if !changed { - return None; - } - Some(if pd.subpaths.len() == 1 { - subs.into_iter().map(PathData::single).collect() - } else if subs.is_empty() { - vec![] - } else { - vec![PathData::new(subs)] - }) -} - -fn stroke_paint(s: &Session) -> Paint { - if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } -} - -fn path_kind(path: PathData) -> NodeKind { - NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false } -} - -// ---------- Pencil / Paintbrush ---------- - -/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay -/// corners) and simplify it to `tol`. -fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { - let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIDELITY * 0.3); - let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) }; - let mut kept: Vec = vec![p0]; - for &p in pts.iter().skip(1) { - if kept.last().is_none_or(|k| k.distance(p) >= min) { - kept.push(p); - } - } - // The last sample always ends the stroke. - if kept.len() > 1 { - if let Some(k) = kept.last_mut() { - *k = pn; - } - } else if pts.len() > 1 { - kept.push(pn); - } - let n = kept.len(); - let marked: Vec<(Point, bool)> = (0..n) - .map(|i| { - let corner = if i == 0 || i + 1 == n { - false - } else { - let (a, b) = (kept[i] - kept[i - 1], kept[i + 1] - kept[i]); - a.dot(b) / (a.hypot() * b.hypot()).max(1e-12) < -0.2 // turn > ~100° - }; - (kept[i], corner) - }) - .collect(); - let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed); - if sp.anchors.len() < 3 { - return sp; - } - let fitted = po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false }); - fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) -} - -/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points -/// joined by straight segments (`path.freehand` with `fit: false`). -fn stroke_subpath(pts: &[Point], tol: Option, closed: bool) -> SubPath { - match tol { - Some(tol) => fit_freehand(pts, tol, closed), - None => SubPath::new(pts.iter().map(|&p| Anchor::corner(p)).collect(), closed), - } -} - -/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of -/// `pd` to another, each within `within` of it, replaces the section between them (the shorter -/// way round a closed subpath) with the stroke fitted at `tol` (with no `tol`, unfitted). The -/// subpath's index and its new shape; None when the stroke doesn't start and end on the same -/// subpath, or meets it once. -fn redraw_section(pd: &PathData, pts: &[Point], tol: Option, within: f64) -> Option<(usize, SubPath)> { - use kurbo::ParamCurveArclen; - let (first, last) = (*pts.first()?, *pts.last()?); - let (si, _, _, _, d_a) = pd.nearest(first)?; - let (sj, _, _, _, d_b) = pd.nearest(last)?; - if si != sj || d_a > within || d_b > within || pts.len() < 2 { - return None; - } - let mut sp = pd.subpaths.get(si)?.clone(); - if sp.anchors.len() < 2 { - return None; - } - // The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each - // point is looked up on the subpath as it is by then: splitting a straight segment makes two - // new ones, which a `t` along the old one doesn't measure. - let place = |sp: &mut SubPath, q: Point| { - let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?; - let n = sp.anchors.len(); - Some(if t <= 1e-6 { - (seg % n, false) - } else if t >= 1.0 - 1e-6 { - ((seg + 1) % n, false) - } else { - (sp.insert_anchor(seg, t), true) - }) - }; - let (mut from, _) = place(&mut sp, first)?; - let (to, added) = place(&mut sp, last)?; - if added && to <= from { - from += 1; - } - if from == to { - return None; - } - let n = sp.anchors.len(); - // The length of the run of segments from anchor i forward to anchor j. - let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::(); - // The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from - // `end` round to `start`, then the stroke's inner anchors follow. - let (start, end, reversed) = if sp.closed { - if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) } - } else if from < to { - (from, to, false) - } else { - (to, from, true) - }; - let mut stroke = pts.to_vec(); - if reversed { - stroke.reverse(); - } - let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None }; - *s0 = s1.p; - let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None }; - *e0 = e1.p; - let fit = stroke_subpath(&stroke, tol, false).anchors; - let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None }; - let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default(); - let (before, after): (Vec, Vec) = if sp.closed { - ((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![]) - } else { - (sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec()) - }; - let mut anchors = before; - if let Some(a) = anchors.last_mut() { - *a = Anchor::with_handles(a.p, a.h_in, f0.h_out); - } - anchors.extend_from_slice(inner); - let at_end = anchors.len(); - anchors.extend(after); - // The anchor the stroke ends on: the first after it, or round to the first of a closed one. - let end_at = if sp.closed { 0 } else { at_end }; - if let Some(a) = anchors.get_mut(end_at) { - *a = Anchor::with_handles(a.p, f1.h_in, a.h_out); - } - Some((si, SubPath::new(anchors, sp.closed))) -} - -fn freehand(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.freehand"; - let samples = samples_param(p, "points", C)?; - if samples.iter().any(|(q, _)| !within_input_bound(*q)) { - return Err(bad(C, "x and y must be finite")); - } - let pressure = stroke_pressure(&samples); - let mut pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); - let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0); - // The tolerance the stroke is fitted at; with `fit: false` it is not fitted. - let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol); - let closed = switch_or(p, "closed", false, C)?; - let brush = str_param(p, "style") == Some("brush"); - let keep_selected = switch_or(p, "keepSelected", true, C)?; - let filled = switch_or(p, "fill", false, C)?; - if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { - let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; - let at_start = str_param(ext, "end") == Some("start"); - let (pd, _) = path_of(&s.doc()?.doc, id)?; - let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; - let mut sp = pd.subpaths[si].clone(); - let old_len = subpath_len(&sp); - if at_start { - sp.reverse(); - } - let (Some(first), Some(end)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p)) else { - return Err(bad(C, "path has no open end")); - }; - let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) }; - *p0 = end; - if pts.len() < 2 { - return Ok(json!({ "id": id.0 })); - } - // A stroke that ends on an open end of another path connects the two: (that path, its last - // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). - let connect = match p.get("connect").filter(|v| !v.is_null()) { - Some(cn) => { - let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; - if other == id { - return Err(bad(C, "connect needs a path other than the one extended")); - } - let (opd, _) = path_of(&s.doc()?.doc, other)?; - let osi = - opd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "connect path has no open end"))?; - let mut y = opd.subpaths[osi].clone(); - let at_end = match str_param(cn, "end") { - Some("start") => false, - Some("end") => true, - _ => return Err(bad(C, "connect end must be \"start\" or \"end\"")), - }; - if at_end { - y.reverse(); - } - Some((other, osi, y, at_end)) - } - None => None, - }; - let close_tol = (tol * 4.0).max(6.0); - let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); - if closing && let Some(l) = pts.last_mut() { - *l = first; - } - if let Some((_, _, y, _)) = &connect - && let (Some(l), Some(y0)) = (pts.last_mut(), y.anchors.first()) - { - *l = y0.p; - } - let fit = stroke_subpath(&pts, fit_tol, false); - let mut add = fit.anchors; - if let (Some(last), Some(a0)) = (sp.anchors.last_mut(), add.first()) { - last.h_out = a0.h_out; - } - if !add.is_empty() { - add.remove(0); - } - sp.anchors.extend(add); - if closing - && sp.anchors.len() > 2 - && let Some(l) = sp.anchors.pop() - && let Some(a0) = sp.anchors.first_mut() - { - a0.h_in = l.h_in; - sp.closed = true; - } - for a in &mut sp.anchors { - *a = Anchor::with_handles(a.p, a.h_in, a.h_out); - } - let drawn_len = subpath_len(&sp); - // The other path's anchors follow on from the one the stroke ends on, which takes its - // outgoing handle. - let mut other_len = 0.0; - if let Some((_, _, y, _)) = &connect - && let Some((y0, rest)) = y.anchors.split_first() - { - if let Some(last) = sp.anchors.last_mut() { - *last = Anchor::with_handles(y0.p, last.h_in + (y0.p - last.p), y0.h_out); - } - sp.anchors.extend_from_slice(rest); - other_len = subpath_len(y); - } - // The path's pressure (in the direction it is continued in) followed by the stroke's, and - // by the pressure of the path it connects to. - let pressure_of = |n: NodeId| s.doc().ok()?.doc.node(n).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone()); - let old_pressure = pressure_of(id); - let other_pressure = connect.as_ref().and_then(|(o, _, _, at_end)| pressure_of(*o).map(|p| if *at_end { p.reversed() } else { p })); - let joined = (old_pressure.is_some() || pressure.is_some() || other_pressure.is_some()).then(|| { - let old = old_pressure.map(|p| if at_start { p.reversed() } else { p }); - let drawn = PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), drawn_len - old_len); - if connect.is_some() { PressureProfile::extended(drawn.as_ref(), drawn_len, other_pressure.as_ref(), other_len) } else { drawn } - }); - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { - let reverse = at_start && !sp.closed; - if reverse { - sp.reverse(); - } - path_mut(d, id)?.subpaths[si] = sp; - if let Some((other, osi, ..)) = &connect { - let o = path_mut(d, *other)?; - if *osi < o.subpaths.len() { - o.subpaths.remove(*osi); - } - if o.is_empty() { - d.remove(*other)?; - sel.remove(*other); - } - // Direct-selected anchors and segments of the subpaths after the one removed move - // down one; those of the one removed go, and a path left with no selected anchors - // is no longer selected. - let shift = |refs: &mut BTreeSet<(usize, usize)>| { - *refs = refs.iter().filter(|r| r.0 != *osi).map(|&(sub, i)| if sub > *osi { (sub - 1, i) } else { (sub, i) }).collect(); - }; - if let Some(refs) = sel.anchors.get_mut(other) { - shift(refs); - } - if let Some(refs) = sel.segments.get_mut(other) { - shift(refs); - } - if sel.anchors.get(other).is_some_and(BTreeSet::is_empty) { - sel.remove(*other); - } - } - if let Some(joined) = joined - && let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) - { - st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined }; - } - if keep_selected { - sel.set([id]); - } else { - sel.remove(id); - } - Ok(()) - })?; - return Ok(json!({ "id": id.0 })); - } - if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { - let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; - let within = number_or(rd, "within", 12.0, C)?; - if !within.is_finite() || within < 0.0 { - return Err(bad(C, "redraw within must be a finite distance of 0 or more")); - } - let (pd, _) = path_of(&s.doc()?.doc, id)?; - // shortcut: a stroke pressure profile is left as it was, measured along the old path; fit - // it to the new length if Calligraphic pressure strokes get redrawn. - if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) { - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { - *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; - // A member path of a compound path is redrawn in it: the compound path is what is - // selected. - let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id); - if keep_selected { - sel.set([whole]); - } else { - sel.remove(whole); - } - Ok(()) - })?; - return Ok(json!({ "id": id.0 })); - } - } - if pts.len() < 2 { - return Err(bad(C, "need at least two points")); - } - let sp = stroke_subpath(&pts, fit_tol, closed); - let fill = if filled { s.paint.fill.clone() } else { Paint::None }; - // A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing - // gets black. A brush is its stroke. - let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() }; - let mut look = s.new_art_look(fill, stroke, s.paint.stroke_width.max(0.1)); - if let Some(st) = look.appearance.stroke_mut() { - st.pressure = pressure; - } - insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected) -} - -// ---------- Curvature ---------- - -fn curvature(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.curvature"; - let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?; - let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::>()?; - if pts.is_empty() { - return Err(bad(C, "need at least one point")); - } - let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?); - if let Some(id) = id_param(p, "id") { - s.edit("Curvature", |d, sel| { - *path_mut(d, id)? = PathData::single(sp); - sel.set([id]); - Ok(()) - })?; - return Ok(json!({ "id": id.0 })); - } - let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); - let ap = &mut look.appearance; - if ap.stroke_paint().is_none() && ap.fill_paint().is_none() { - ap.set_stroke(Paint::solid(Color::BLACK)); - } - add_look(s, "Curvature", path_kind(PathData::single(sp)), look, None) -} - -/// An edit `path.curvatureEdit` makes. -enum CurvatureOp { - Move(usize, Point), - Insert(usize, f64, Option), - Extend(Point), - Close, -} - -fn curvature_edit(s: &mut Session, p: &Value) -> Result { - use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; - const C: &str = "path.curvatureEdit"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - check_numbers(p, &["x", "y", "t"], C)?; - let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)), - (None, None) => None, - _ => return Err(bad(C, "x and y must be finite numbers")), - }; - let to = || at.ok_or_else(|| bad(C, "missing x, y")); - let start = match str_param(p, "end") { - None | Some("end") => false, - Some("start") => true, - Some(e) => return Err(bad(C, format!("unknown end {e:?}"))), - }; - let how = match str_param(p, "from") { - None => EndCurve::Keep, - Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?, - }; - let corner = switch_or(p, "corner", false, C)?; - let op = match str_param(p, "op") { - Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?), - Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at), - Some("extend") => CurvatureOp::Extend(to()?), - Some("close") => CurvatureOp::Close, - Some(o) => return Err(bad(C, format!("unknown op {o:?}"))), - None => return Err(bad(C, "missing op")), - }; - let anchor = s.edit("Curvature", |d, sel| { - let path = path_mut(d, id)?; - let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; - if matches!(op, CurvatureOp::Extend(_) | CurvatureOp::Close) && sp.closed { - return Err(EngineError::Other("path is already closed".into())); - } - let ai = match op { - CurvatureOp::Move(ai, q) => curvature_move(sp, ai, q).then_some(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?, - CurvatureOp::Insert(seg, t, q) => { - let ai = curvature_insert(sp, seg, t).ok_or_else(|| EngineError::Other("no such segment".into()))?; - if let Some(q) = q { - curvature_move(sp, ai, q); - } - ai - } - CurvatureOp::Extend(q) => { - curvature_extend(sp, start, q, how, corner); - if start { 0 } else { sp.anchors.len() - 1 } - } - CurvatureOp::Close => { - if sp.anchors.len() < 3 { - return Err(EngineError::Other("closing needs three anchors".into())); - } - curvature_close(sp, start, how); - if start { sp.anchors.len() - 1 } else { 0 } - } - }; - sel.set([id]); - sel.anchors.insert(id, [(si, ai)].into()); - Ok(ai) - })?; - Ok(json!({ "anchor": anchor })) -} - -// ---------- anchor tools ---------- - -fn remove_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.removeAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - let ai = usize_req(p, "anchor", C)?; - let removed = s.edit("Delete Anchor Point", |d, sel| { - let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; - if ai >= sp.anchors.len() { - return Err(EngineError::Other("no such anchor".into())); - } - let mut emptied = Beside::replacing(d, &[id]); - let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?; - emptied.finish(d)?; - Ok(removed) - })?; - Ok(json!({ "removedObject": removed })) -} - -fn remove_anchors(s: &mut Session, _: &Value) -> Result { - let st = s.doc()?; - let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st - .selection - .anchors - .iter() - .filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }))) - .map(|(id, set)| (*id, set.iter().copied().collect())) - .collect(); - if jobs.is_empty() { - return Err(bad("path.removeAnchors", "direct-select anchor points of a path")); - } - let removed = s.edit("Remove Anchor Points", |d, sel| { - let mut removed = 0; - let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::>()); - for (id, anchors) in jobs { - let shape: Vec<(usize, bool)> = - d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); - let next = following_anchors(&shape, &anchors); - if remove_anchors_of(d, sel, &mut emptied, id, anchors)? { - removed += 1; - } else if !next.is_empty() { - // The next anchor along is selected, so the next Remove Anchor Points goes on from - // there without picking it first (#2241). - sel.anchors.insert(id, next); - } - } - emptied.finish(d)?; - Ok(removed) - })?; - Ok(json!({ "removedObjects": removed })) -} - -/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a -/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its -/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's -/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors. -fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { - let mut next = BTreeSet::new(); - let mut kept = 0; - for (si, &(n, closed)) in shape.iter().enumerate() { - let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); - if n - gone.len() < 2 { - continue; - } - if let Some(&last) = gone.last() { - let wrap = if closed { 0..last } else { 0..0 }; - let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); - if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { - next.insert((kept, a - gone.range(..a).count())); - } - } - kept += 1; - } - next -} - -/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round -/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed -/// through `emptied`. True when the path went. -fn remove_anchors_of( - d: &mut Document, - sel: &mut Selection, - emptied: &mut Beside, - id: NodeId, - anchors: impl IntoIterator, -) -> Result { - let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); - // The last first, so the indexes still to come stay valid. - anchors.sort_unstable_by(|a, b| b.cmp(a)); - anchors.dedup(); - let path = path_mut(d, id)?; - for (si, ai) in anchors { - if let Some(sp) = path.subpaths.get_mut(si) { - po::remove_anchor(sp, ai); - } - } - path.subpaths.retain(|sp| sp.anchors.len() >= 2); - let gone = path.is_empty(); - if gone { - emptied.put(d, id, vec![])?; - sel.remove(id); - } else { - sel.anchors.remove(&id); - sel.segments.remove(&id); - } - Ok(gone) -} - -fn convert_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.convertAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - let ai = usize_req(p, "anchor", C)?; - let smooth = str_param(p, "to") == Some("smooth"); - check_numbers(p, &["x", "y"], C)?; - let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) => Some(Point::new(x, y)), - (None, None) => None, - _ => return Err(bad(C, "x and y must be given together")), - }; - if target.is_some_and(|q| !within_input_bound(q)) { - return Err(bad(C, "x and y must be finite")); - } - s.edit("Convert Anchor Point", |d, _| { - let path = path_mut(d, id)?; - let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; - if smooth && target.is_none() { - if ai >= sp.anchors.len() { - return Err(EngineError::Other("no such anchor".into())); - } - // Without a handle position the anchor curves as the Curvature tool curves a point; one - // with no direction to take stays as it is. - vectorcraft_tools::draw2::curvature_smooth(sp, ai); - return Ok(()); - } - let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; - match target { - Some(t) if smooth && t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t), - _ => a.retract(), - } - Ok(()) - })?; - ok() -} - -fn reshape_segment(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.reshapeSegment"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - let seg = usize_req(p, "segment", C)?; - let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); - let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); - if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) { - return Err(bad(C, "invalid delta")); - } - let perpendicular = switch_or(p, "perpendicular", false, C)?; - s.edit("Reshape", |d, _| { - let path = path_mut(d, id)?; - let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; - let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; - if !bent { - return Err(EngineError::Other("no such segment".into())); - } - Ok(()) - })?; - ok() -} - -// ---------- Scissors ---------- - -fn split(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.split"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let si = usize_opt(p, "subpath", C)?.unwrap_or(0); - let anchor = usize_opt(p, "anchor", C)?; - let seg = usize_opt(p, "segment", C)?; - if anchor.is_none() && seg.is_none() { - return Err(bad(C, "need `anchor` or `segment` + `t`")); - } - let t = f64_or(p, "t", 0.5); - if !t.is_finite() || p.get("t").is_some_and(|v| !v.is_f64() && !v.is_i64() && !v.is_u64()) { - return Err(bad(C, "invalid t")); - } - let ids = s.edit("Split Path", |d, sel| { - let path = path_mut(d, id)?; - let mut sp = path.subpaths.get(si).cloned().ok_or_else(|| EngineError::Other("no such subpath".into()))?; - let n = sp.anchors.len(); - let k = match (anchor, seg) { - (Some(a), _) if a < n => a, - (Some(_), _) => return Err(EngineError::Other("no such anchor".into())), - (None, Some(g)) if g < sp.segment_count() => { - if t <= 1e-6 { - g - } else if t >= 1.0 - 1e-6 { - (g + 1) % n - } else { - sp.insert_anchor(g, t) - } - } - _ => return Err(EngineError::Other("no such segment".into())), - }; - let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k])); - if sp.closed { - path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?; - sel.set([id]); - return Ok(vec![id]); - } - let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?; - if path.subpaths.len() == 1 { - path.subpaths[0] = left; - let node = sibling_with(d, id, PathData::single(right))?; - let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; - let nid = d.insert(parent, idx + 1, node)?; - sel.set([id, nid]); - Ok(vec![id, nid]) - } else { - path.subpaths[si] = left; - path.subpaths.insert(si + 1, right); - sel.set([id]); - Ok(vec![id]) - } - })?; - Ok(ids_json(&ids)) -} - -// ---------- Knife / Eraser ---------- - -/// Width of the slit the knife leaves between pieces. -const KNIFE_GAP: f64 = 0.05; - -fn knife(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.knife"; - let pts = points_param(p, "points", C)?; - if pts.len() < 2 { - return Err(bad(C, "need at least two points")); - } - let st = s.doc()?; - let strip = po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), KNIFE_GAP, Cap::Butt, Join::Round, 4.0); - let mut plan: Vec<(NodeId, Vec)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, 1.0)) { - let (pd, rule) = path_of(&st.doc, id)?; - if !pd.is_closed() { - continue; - } - let pieces = components(&po::boolean(&pd, rule, &strip, FillRule::NonZero, BoolOp::Difference)); - if pieces.len() >= 2 { - plan.push((id, pieces)); - } - } - if plan.is_empty() { - return Ok(json!({ "ids": [] })); - } - let ids = s.edit("Knife", |d, sel| { - let mut all = vec![]; - let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::>()); - for (id, pieces) in plan { - all.extend(replace_with_pieces(d, &mut cut, id, pieces)?); - } - cut.finish(d)?; - sel.set(all.iter().copied()); - Ok(all) - })?; - Ok(ids_json(&ids)) -} - -fn erase_region(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.eraseRegion"; - let pts = points_param(p, "points", C)?; - let size = number_or(p, "size", 10.0, C)?; - if !(size.is_finite() && size > 0.0) { - return Err(bad(C, "size must be positive")); - } - let angle = number_or(p, "angle", 0.0, C)?; - let roundness = number_or(p, "roundness", 100.0, C)?; - let r = size / 2.0; - // The tip's short axis: an open path is sampled at least every half of it. - let short = size * roundness.clamp(1.0, 100.0) / 100.0; - let st = s.doc()?; - let region = eraser_area(&pts, size, angle, roundness); - // A point is under the tip when, with the tip squashed back into a circle, it is within the - // circle's radius of the squashed drag. - let squash = eraser_tip(angle, roundness).inverse(); - let round: Vec = pts.iter().map(|q| squash * *q).collect(); - let mut plan: Vec<(NodeId, Vec)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) { - let (pd, rule) = path_of(&st.doc, id)?; - if pd.is_closed() { - if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { - continue; - } - plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); - } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| dist_to_polyline(&round, squash * q) <= r) { - plan.push((id, pieces)); - } - } - apply_plan(s, "Eraser", plan) -} - -fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { - if plan.is_empty() { - return Ok(json!({ "ids": [] })); - } - let ids = s.edit(label, |d, sel| { - let mut all = vec![]; - let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::>()); - for (id, pieces) in plan { - all.extend(replace_with_pieces(d, &mut cut, id, pieces)?); - } - cut.finish(d)?; - sel.set(all.iter().copied()); - Ok(all) - })?; - Ok(ids_json(&ids)) -} - -fn erase_segments(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.eraseSegments"; - let pts = points_param(p, "points", C)?; - let w = number_or(p, "width", 8.0, C)?; - if !(w.is_finite() && w > 0.0) { - return Err(bad(C, "width must be positive")); - } - let st = s.doc()?; - let mut plan = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) { - let (pd, _) = path_of(&st.doc, id)?; - if let Some(pieces) = erase_outline(&pd, w / 2.0, &|q| dist_to_polyline(&pts, q) <= w / 2.0) { - plan.push((id, pieces)); - } - } - apply_plan(s, "Path Eraser", plan) -} - -// ---------- Smooth ---------- - -/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. -fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { - let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); - let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); - let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; - let (f, l) = (anchors[0], anchors[anchors.len() - 1]); - let last = out.len() - 1; - out[0].p = f.p; - out[0].h_in = f.h_in; - out[last].p = l.p; - out[last].h_out = l.h_out; - for a in &mut out { - *a = Anchor::with_handles(a.p, a.h_in, a.h_out); - } - out -} - -fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option { - let n = sp.anchors.len(); - let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); - if n < 3 || !marked.iter().any(|m| *m) { - return None; - } - if sp.closed { - let Some(k) = marked.iter().position(|m| !m) else { - // Everything is in range: smooth the whole closed path. - let f = - po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); - return f.subpaths.into_iter().next(); - }; - // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. - let mut open = sp.clone(); - open.anchors.rotate_left(k); - let mut m = marked.clone(); - m.rotate_left(k); - open.anchors.push(open.anchors[0]); - m.push(false); - open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol); - let l = res.pop()?; - if let Some(r0) = res.first_mut() { - r0.h_in = l.h_in; - } - return Some(SubPath::new(res, true)); - } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false)) -} - -/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it -/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver -/// is outside. -fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> { - const STEPS: u32 = 32; - let step = 1.0 / f64::from(STEPS); - // Back from the end in reach to the first sample outside, then halving to the crossing. - let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?; - let (mut lo, mut hi) = (outside, outside + step); - for _ in 0..20 { - let mid = (lo + hi) / 2.0; - if near(c.eval(mid)) { - hi = mid; - } else { - lo = mid; - } - } - // A straight segment's parts stay straight, their handles on their ends. - let straight = c.p1 == c.p0 && c.p2 == c.p3; - let part = |range| { - let s = c.subsegment(range); - if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s } - }; - (hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0))) -} - -/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments -/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under -/// it, and the rest of a long segment next to it stays as it was (#1512). -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec { - let n = anchors.len(); - let mut runs = vec![]; - let mut i = 0; - while i < n { - if marked[i] { - let a = i; - while i + 1 < n && marked[i + 1] { - i += 1; - } - runs.push((a, i)); - } - i += 1; - } - let mut out = anchors.to_vec(); - for (a, b) in runs.into_iter().rev() { - let lo = a.saturating_sub(1); - let hi = (b + 1).min(n - 1); - if hi - lo < 2 { - continue; - } - let mut piece = out[lo..=hi].to_vec(); - // The anchors outside the reach, kept with the part of their segment that is outside. - let (mut head, mut tail) = (None, None); - if hi > b - && let [.., inner, outer] = piece.as_mut_slice() - && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near) - { - inner.h_out = reach.p2; - tail = Some(Anchor { h_in: far.p1, ..*outer }); - *outer = Anchor::with_handles(far.p3, reach.p1, far.p2); - } - if lo < a - && let [outer, inner, ..] = piece.as_mut_slice() - && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near) - { - inner.h_in = reach.p2; - head = Some(Anchor { h_out: far.p1, ..*outer }); - *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); - } - let new = refit_run(&piece, tol); - out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); - } - out -} - -fn smooth_region(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.smoothRegion"; - let pts = points_param(p, "points", C)?; - let radius = number_or(p, "radius", 12.0, C)?.max(0.0); - let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); - let st = s.doc()?; - let near = |q: Point| dist_to_polyline(&pts, q) <= radius; - let mut plan = vec![]; - let (mut before, mut after) = (0, 0); - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) { - let (pd, _) = path_of(&st.doc, id)?; - let mut changed = false; - let subs: Vec = pd - .subpaths - .iter() - .map(|sp| match smooth_subpath(sp, &near, tol) { - Some(n) if n != *sp && n.anchors.len() >= 2 => { - changed = true; - n - } - _ => sp.clone(), - }) - .collect(); - if changed { - let np = PathData::new(subs); - before += pd.anchor_count(); - after += np.anchor_count(); - plan.push((id, np)); - } - } - if plan.is_empty() { - return Ok(json!({ "ids": [], "before": 0, "after": 0 })); - } - let ids: Vec = plan.iter().map(|(id, _)| *id).collect(); - s.edit("Smooth", |d, _| { - for (id, np) in plan { - *path_mut(d, id)? = np; - } - Ok(()) - })?; - let mut v = ids_json(&ids); - v["before"] = json!(before); - v["after"] = json!(after); - Ok(v) -} - -// ---------- Join tool ---------- - -fn join_scrub(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.joinScrub"; - let pts = points_param(p, "points", C)?; - let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0); - let st = s.doc()?; - // Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start). - let poly = { - let mut bp = BezPath::new(); - bp.move_to(pts[0]); - for q in &pts[1..] { - bp.line_to(*q); - } - bp - }; - let order = |q: Point| -> f64 { - poly.segments() - .enumerate() - .map(|(i, sg)| (i as f64 + sg.nearest(q, 1e-6).t, sg.nearest(q, 1e-6).distance_sq)) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map_or(0.0, |x| x.0) - }; - let (first_pt, last_pt) = (pts.first().copied(), pts.last().copied()); - let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) { - let (pd, _) = path_of(&st.doc, id)?; - for (si, sp) in pd.subpaths.iter().enumerate() { - if sp.closed || sp.anchors.len() < 2 { - continue; - } - for (start, a) in [(true, sp.anchors[0].p), (false, sp.anchors[sp.anchors.len() - 1].p)] { - let on_scrub = dist_to_polyline(&pts, a) <= tol; - let near_start = first_pt.is_some_and(|p| a.distance(p) <= tol); - let near_end = last_pt.is_some_and(|p| a.distance(p) <= tol); - if !on_scrub && !near_start && !near_end { - continue; - } - let ord = if on_scrub { - order(a) - } else if near_start && (!near_end || first_pt.is_some_and(|p| a.distance(p) <= a.distance(last_pt.unwrap_or(p)))) { - -1.0 - } else { - 1e30 - }; - ends.push((ord, id, si, start, a)); - } - } - } - ends.sort_by(|a, b| a.0.total_cmp(&b.0)); - if ends.len() < 2 { - return Ok(json!({ "id": null })); - } - let (_, ida, sa, starta, pa) = ends[0]; - let (_, idb, sb, startb, pb) = ends[1]; - let mid = pa.midpoint(pb); - let merge = pa.distance(pb) <= tol; - s.edit("Join", |d, sel| { - if ida == idb && sa == sb { - let path = path_mut(d, ida)?; - let sp = &mut path.subpaths[sa]; - if merge - && sp.anchors.len() > 2 - && let Some(l) = sp.anchors.pop() - { - let d0 = mid - sp.anchors[0].p; - sp.anchors[0].translate(d0); - sp.anchors[0].h_in = l.h_in + (mid - l.p); - } - sp.closed = true; - sel.set([ida]); - return Ok(()); - } - let (pdb, _) = path_of(d, idb)?; - let mut x = path_of(d, ida)?.0.subpaths[sa].clone(); - let mut y = pdb.subpaths[sb].clone(); - if starta { - x.reverse(); - } - if !startb { - y.reverse(); - } - if merge - && !y.anchors.is_empty() - && let Some(last) = x.anchors.last_mut() - { - let yf = y.anchors.remove(0); - let lp = last.p; - last.translate(mid - lp); - last.h_out = yf.h_out + (mid - yf.p); - *last = Anchor::with_handles(last.p, last.h_in, last.h_out); - } - x.anchors.extend(y.anchors); - if ida == idb { - let path = path_mut(d, ida)?; - path.subpaths[sa] = x; - path.subpaths.remove(sb); - } else { - path_mut(d, ida)?.subpaths[sa] = x; - let pbm = path_mut(d, idb)?; - pbm.subpaths.remove(sb); - if pbm.is_empty() { - d.remove(idb)?; - } - } - sel.set([ida]); - Ok(()) - })?; - Ok(json!({ "id": ida.0 })) -} - -// ---------- Blob Brush ---------- - -fn blob(s: &mut Session, p: &Value) -> Result { - const C: &str = "path.blob"; - let pts = points_param(p, "points", C)?; - let size = number_or(p, "size", 10.0, C)?; - if !(size.is_finite() && size > 0.0) { - return Err(bad(C, "size must be positive")); - } - let region = brush_region(&pts, size); - if region.is_empty() { - return Err(EngineError::Other("empty brush stroke".into())); - } - let paint = if !s.paint.stroke.is_none() { - s.paint.stroke.clone() - } else if !s.paint.fill.is_none() { - s.paint.fill.clone() - } else { - Paint::solid(Color::BLACK) - }; - let keep_selected = switch_or(p, "keepSelected", false, C)?; - let only_selection = switch_or(p, "mergeOnlySelection", false, C)?; - let merge = switch_or(p, "merge", true, C)?; - let st = s.doc()?; - let mut merge_ids = vec![]; - if merge && !(only_selection && st.selection.is_empty()) { - let all = Selection::default(); - let from = if only_selection { &st.selection } else { &all }; - for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { - let Some(n) = st.doc.node(id) else { continue }; - if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() { - continue; - } - let (pd, rule) = path_of(&st.doc, id)?; - if pd.is_closed() && po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) > 1e-6 { - merge_ids.push((id, pd, rule)); - } - } - } - let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0)); - if merge_ids.is_empty() { - return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); - } - let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; - inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); - let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?; - let Some(((keep, _, _), rest)) = merge_ids.split_last() else { - return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); - }; - let keep = *keep; - let others: Vec = rest.iter().map(|m| m.0).collect(); - s.edit("Blob Brush", |d, sel| { - *path_mut(d, keep)? = merged; - remove_each(d, &others)?; - if keep_selected { - sel.set([keep]); - } else { - sel.remove(keep); - } - Ok(()) - })?; - Ok(json!({ "id": keep.0 })) -} +pub use commands::specs; +pub(super) use common::{ids_json, replace_with_pieces, usize_opt}; +pub(super) use cut::{components, erase_outline}; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs new file mode 100644 index 000000000..540627fe0 --- /dev/null +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -0,0 +1,269 @@ +use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; +use super::*; + +// ---------- Curvature ---------- + +pub(super) fn curvature(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.curvature"; + let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?; + let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::>()?; + if pts.is_empty() { + return Err(bad(C, "need at least one point")); + } + let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?); + if let Some(id) = id_param(p, "id") { + s.edit("Curvature", |d, sel| { + *path_mut(d, id)? = PathData::single(sp); + sel.set([id]); + Ok(()) + })?; + return Ok(json!({ "id": id.0 })); + } + let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + let ap = &mut look.appearance; + if ap.stroke_paint().is_none() && ap.fill_paint().is_none() { + ap.set_stroke(Paint::solid(Color::BLACK)); + } + add_look(s, "Curvature", path_kind(PathData::single(sp)), look, None) +} + +/// An edit `path.curvatureEdit` makes. +enum CurvatureOp { + Move(usize, Point), + Insert(usize, f64, Option), + Extend(Point), + Close, +} + +pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result { + use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; + const C: &str = "path.curvatureEdit"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + check_numbers(p, &["x", "y", "t"], C)?; + let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)), + (None, None) => None, + _ => return Err(bad(C, "x and y must be finite numbers")), + }; + let to = || at.ok_or_else(|| bad(C, "missing x, y")); + let start = match str_param(p, "end") { + None | Some("end") => false, + Some("start") => true, + Some(e) => return Err(bad(C, format!("unknown end {e:?}"))), + }; + let how = match str_param(p, "from") { + None => EndCurve::Keep, + Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?, + }; + let corner = switch_or(p, "corner", false, C)?; + let op = match str_param(p, "op") { + Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?), + Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at), + Some("extend") => CurvatureOp::Extend(to()?), + Some("close") => CurvatureOp::Close, + Some(o) => return Err(bad(C, format!("unknown op {o:?}"))), + None => return Err(bad(C, "missing op")), + }; + let anchor = s.edit("Curvature", |d, sel| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if matches!(op, CurvatureOp::Extend(_) | CurvatureOp::Close) && sp.closed { + return Err(EngineError::Other("path is already closed".into())); + } + let ai = match op { + CurvatureOp::Move(ai, q) => curvature_move(sp, ai, q).then_some(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?, + CurvatureOp::Insert(seg, t, q) => { + let ai = curvature_insert(sp, seg, t).ok_or_else(|| EngineError::Other("no such segment".into()))?; + if let Some(q) = q { + curvature_move(sp, ai, q); + } + ai + } + CurvatureOp::Extend(q) => { + curvature_extend(sp, start, q, how, corner); + if start { 0 } else { sp.anchors.len() - 1 } + } + CurvatureOp::Close => { + if sp.anchors.len() < 3 { + return Err(EngineError::Other("closing needs three anchors".into())); + } + curvature_close(sp, start, how); + if start { sp.anchors.len() - 1 } else { 0 } + } + }; + sel.set([id]); + sel.anchors.insert(id, [(si, ai)].into()); + Ok(ai) + })?; + Ok(json!({ "anchor": anchor })) +} + +// ---------- anchor tools ---------- + +pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.removeAnchor"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + let ai = usize_req(p, "anchor", C)?; + let removed = s.edit("Delete Anchor Point", |d, sel| { + let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if ai >= sp.anchors.len() { + return Err(EngineError::Other("no such anchor".into())); + } + let mut emptied = Beside::replacing(d, &[id]); + let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?; + emptied.finish(d)?; + Ok(removed) + })?; + Ok(json!({ "removedObject": removed })) +} + +pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st + .selection + .anchors + .iter() + .filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }))) + .map(|(id, set)| (*id, set.iter().copied().collect())) + .collect(); + if jobs.is_empty() { + return Err(bad("path.removeAnchors", "direct-select anchor points of a path")); + } + let removed = s.edit("Remove Anchor Points", |d, sel| { + let mut removed = 0; + let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::>()); + for (id, anchors) in jobs { + let shape: Vec<(usize, bool)> = + d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); + let next = following_anchors(&shape, &anchors); + if remove_anchors_of(d, sel, &mut emptied, id, anchors)? { + removed += 1; + } else if !next.is_empty() { + // The next anchor along is selected, so the next Remove Anchor Points goes on from + // there without picking it first (#2241). + sel.anchors.insert(id, next); + } + } + emptied.finish(d)?; + Ok(removed) + })?; + Ok(json!({ "removedObjects": removed })) +} + +/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a +/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its +/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's +/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors. +fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { + let mut next = BTreeSet::new(); + let mut kept = 0; + for (si, &(n, closed)) in shape.iter().enumerate() { + let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + if n - gone.len() < 2 { + continue; + } + if let Some(&last) = gone.last() { + let wrap = if closed { 0..last } else { 0..0 }; + let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); + if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { + next.insert((kept, a - gone.range(..a).count())); + } + } + kept += 1; + } + next +} + +/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round +/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed +/// through `emptied`. True when the path went. +fn remove_anchors_of( + d: &mut Document, + sel: &mut Selection, + emptied: &mut Beside, + id: NodeId, + anchors: impl IntoIterator, +) -> Result { + let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); + // The last first, so the indexes still to come stay valid. + anchors.sort_unstable_by(|a, b| b.cmp(a)); + anchors.dedup(); + let path = path_mut(d, id)?; + for (si, ai) in anchors { + if let Some(sp) = path.subpaths.get_mut(si) { + po::remove_anchor(sp, ai); + } + } + path.subpaths.retain(|sp| sp.anchors.len() >= 2); + let gone = path.is_empty(); + if gone { + emptied.put(d, id, vec![])?; + sel.remove(id); + } else { + sel.anchors.remove(&id); + sel.segments.remove(&id); + } + Ok(gone) +} + +pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.convertAnchor"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + let ai = usize_req(p, "anchor", C)?; + let smooth = str_param(p, "to") == Some("smooth"); + check_numbers(p, &["x", "y"], C)?; + let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => Some(Point::new(x, y)), + (None, None) => None, + _ => return Err(bad(C, "x and y must be given together")), + }; + if target.is_some_and(|q| !within_input_bound(q)) { + return Err(bad(C, "x and y must be finite")); + } + s.edit("Convert Anchor Point", |d, _| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if smooth && target.is_none() { + if ai >= sp.anchors.len() { + return Err(EngineError::Other("no such anchor".into())); + } + // Without a handle position the anchor curves as the Curvature tool curves a point; one + // with no direction to take stays as it is. + vectorcraft_tools::draw2::curvature_smooth(sp, ai); + return Ok(()); + } + let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; + match target { + Some(t) if smooth && t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t), + _ => a.retract(), + } + Ok(()) + })?; + ok() +} + +pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.reshapeSegment"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + let seg = usize_req(p, "segment", C)?; + let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); + let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); + if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) { + return Err(bad(C, "invalid delta")); + } + let perpendicular = switch_or(p, "perpendicular", false, C)?; + s.edit("Reshape", |d, _| { + let path = path_mut(d, id)?; + let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; + if !bent { + return Err(EngineError::Other("no such segment".into())); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs new file mode 100644 index 000000000..84337aa27 --- /dev/null +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -0,0 +1,136 @@ +use super::anchors::{convert_anchor, curvature, curvature_edit, remove_anchor, remove_anchors, reshape_segment}; +use super::cut::{erase_region, erase_segments, knife, split}; +use super::freehand::{blob, freehand}; +use super::smooth::{join_scrub, smooth_region}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "path.freehand", + "Pencil", + [], + None, + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + has_doc, + freehand + ), + cmd!( + "path.curvature", + "Curvature", + [], + None, + "{points: [{x, y, corner?}], closed?, id?} create (or with id: replace) a path curving through the points → {id}", + has_doc, + curvature + ), + cmd!( + "path.curvatureEdit", + "Curvature", + [], + None, + "{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", corner?, x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments (a smooth anchor takes the Curvature tool's handles, along the circle through it and its neighbours); insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y, curving into it unless corner is true; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner", + has_doc, + curvature_edit + ), + cmd!( + "path.removeAnchor", + "Delete Anchor Point", + [], + None, + "{id, subpath?, anchor} remove one anchor, re-fitting the curve", + has_doc, + remove_anchor + ), + cmd!( + "path.removeAnchors", + "Remove Anchor Points", + ["Object", "Path"], + None, + "{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}", + has_anchors, + remove_anchors + ), + cmd!( + "path.convertAnchor", + "Convert Anchor Point", + [], + None, + "{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)", + has_doc, + convert_anchor + ), + cmd!( + "path.reshapeSegment", + "Reshape Segment", + [], + None, + "{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does", + has_doc, + reshape_segment + ), + cmd!( + "path.split", + "Split Path", + [], + None, + "{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}", + has_doc, + split + ), + cmd!( + "path.knife", + "Knife", + [], + None, + "{points: [[x,y]…]} cut closed shapes (selected, or all when nothing is selected) along the polyline → {ids}", + has_doc, + knife + ), + cmd!( + "path.eraseRegion", + "Eraser", + [], + None, + "{points: [[x,y]…], size?: pt (10), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", + has_doc, + erase_region + ), + cmd!( + "path.eraseSegments", + "Path Eraser", + [], + None, + "{points: [[x,y]…], width?: pt (8)} erase the parts of selected paths under the drag → {ids}", + has_selection, + erase_segments + ), + cmd!( + "path.smoothRegion", + "Smooth", + [], + None, + "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}", + has_selection, + smooth_region + ), + cmd!( + "path.joinScrub", + "Join", + [], + None, + "{points: [[x,y]…], tolerance?: pt (12)} join the open-path endpoints scrubbed over → {id}", + has_doc, + join_scrub + ), + cmd!( + "path.blob", + "Blob Brush", + [], + None, + "{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}", + has_doc, + blob + ), + ] +} diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs new file mode 100644 index 000000000..db5d7e372 --- /dev/null +++ b/crates/engine/src/cmd/draw2/common.rs @@ -0,0 +1,235 @@ +use super::*; + +// ---------- helpers ---------- + +/// `[[x,y]…]` or `[{x,y}…]`. +pub(super) fn points_param(p: &Value, key: &str, cmd: &str) -> Result> { + Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect()) +} + +/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite +/// number. +pub(super) fn point_entry(v: &Value, cmd: &str) -> Result { + let xy = match v { + Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)), + Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)), + _ => None, + }; + xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers")) +} + +/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or +/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a +/// number nor null, is an error. +pub(super) fn samples_param(p: &Value, key: &str, cmd: &str) -> Result)>> { + let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?; + let pts: Vec<(Point, Option)> = a + .iter() + .map(|v| { + let pressure = match v { + Value::Array(xy) => xy.get(2), + _ => v.get("pressure"), + }; + match pressure { + None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))), + Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))), + } + }) + .collect::>()?; + if pts.is_empty() { + return Err(bad(cmd, "need at least one point")); + } + Ok(pts) +} + +/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of +/// its samples has one; samples without one count as full pressure, as a pen event without one. +pub(super) fn stroke_pressure(samples: &[(Point, Option)]) -> Option { + if samples.iter().all(|(_, p)| p.is_none()) { + return None; + } + let mut at = 0.0; + let mut prev: Option = None; + let along: Vec<(f64, f64)> = samples + .iter() + .map(|(pt, p)| { + at += prev.map_or(0.0, |q| q.distance(*pt)); + prev = Some(*pt); + (at, p.unwrap_or(1.0)) + }) + .collect(); + PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p))) +} + +/// The length of a subpath. +pub(super) fn subpath_len(sp: &SubPath) -> f64 { + use kurbo::ParamCurveArclen; + PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum() +} + +/// The bound, in points, on the length of a `path.reshapeSegment` delta and on the x and y of a +/// point given to `path.convertAnchor` or `path.freehand`. The blend spine commands set the same +/// bound (`object.blend.spine.reshapeSegment`, `object.blend.spine.moveAnchor`). +pub(super) const MAX_INPUT: f64 = 1e9; + +/// Whether `q` is finite and its x and y are each smaller than [`MAX_INPUT`] in size. +pub(super) fn within_input_bound(q: Point) -> bool { + q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT +} + +/// The whole number of 0 or more that `key` holds, read with [`index`]. +pub(super) fn usize_req(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) +} + +/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other +/// value is [`usize_req`]'s error. +pub(in super::super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(_) => usize_req(p, key, cmd).map(Some), + } +} + +pub(super) fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + *live = None; + Ok(path) + } + _ => Err(EngineError::Other(format!("object {id} is not a path"))), + } +} + +pub(super) fn path_of(d: &Document, id: NodeId) -> Result<(PathData, FillRule)> { + match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Path { path, rule, .. }) => Ok((path.clone(), *rule)), + Some(_) => Err(EngineError::Other(format!("object {id} is not a path"))), + None => Err(EngineError::NoNode(id)), + } +} + +pub(super) fn dist_to_polyline(pts: &[Point], q: Point) -> f64 { + if pts.len() == 1 { + return pts[0].distance(q); + } + pts.windows(2) + .map(|w| { + let (a, b) = (w[0], w[1]); + let ab = b - a; + let l2 = ab.hypot2(); + let t = if l2 < 1e-18 { 0.0 } else { ((q - a).dot(ab) / l2).clamp(0.0, 1.0) }; + (a + ab * t).distance(q) + }) + .fold(f64::INFINITY, f64::min) +} + +pub(super) fn poly_bounds(pts: &[Point], pad: f64) -> Rect { + let r = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p)); + r.inflate(pad, pad) +} + +/// The filled area painted by a round brush of diameter `size` along `pts`. +pub(super) fn brush_region(pts: &[Point], size: f64) -> PathData { + let mut pts = pts.to_vec(); + if pts.len() == 1 { + pts.push(pts[0] + Vec2::new(0.01, 0.0)); + } + po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), size, Cap::Round, Join::Round, 4.0) +} + +/// Path leaves the area tools act on: under the selected objects, or every path when nothing is +/// selected. Only editable, non-guide paths whose bounds meet `area`. +pub(super) fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec { + let mut out = vec![]; + let mut push = |n: &Node| { + if let NodeKind::Path { guide: false, path, .. } = &n.kind + && path.control_bounds().is_some_and(|b| b.inflate(0.5, 0.5).intersect(area).area() > 0.0 || b.width() == 0.0 || b.height() == 0.0) + && !out.contains(&n.id) + { + out.push(n.id); + } + }; + if sel.is_empty() { + doc.walk(&mut push); + } else { + for id in &sel.objects { + if let Some(n) = doc.node(*id) { + n.walk(&mut |c: &Node| push(c)); + } + } + } + let editable = doc.editable(); + out.retain(|id| editable(*id)); + out +} + +/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids. +fn compound_members(d: &mut Document, pd: PathData) -> Vec> { + pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect() +} + +/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members +/// are rebuilt, one per subpath). +pub(in super::super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> { + if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) { + let members = compound_members(d, pd); + if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + *children = members; + } + return Ok(()); + } + *path_mut(d, id)? = pd; + Ok(()) +} + +/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`. +pub(super) fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result { + let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone(); + n.id = d.alloc_id(); + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + *path = pd; + *live = None; + } + NodeKind::Compound { children, .. } => *children = compound_members(d, pd), + _ => {} + } + Ok(n) +} + +/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above +/// it; none removes it). `cut` removes the path or puts the other pieces above it. Returns ids. +pub(in super::super) fn replace_with_pieces(d: &mut Document, cut: &mut Beside, id: NodeId, pieces: Vec) -> Result> { + let mut it = pieces.into_iter(); + let Some(first) = it.next() else { + cut.put(d, id, vec![])?; + return Ok(vec![]); + }; + let mut ids = vec![id]; + let mut above = vec![]; + for pd in it { + let n = sibling_with(d, id, pd)?; + ids.push(n.id); + above.push(n); + } + set_geometry(d, id, first)?; + if !above.is_empty() { + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + cut.put(d, id, [n].into_iter().chain(above).collect())?; + } + Ok(ids) +} + +pub(in super::super) fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +pub(super) fn stroke_paint(s: &Session) -> Paint { + if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } +} + +pub(super) fn path_kind(path: PathData) -> NodeKind { + NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false } +} diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs new file mode 100644 index 000000000..69306ce8f --- /dev/null +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -0,0 +1,328 @@ +use super::common::{ + dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, usize_opt, +}; +use super::*; + +/// Split a filled path into connected pieces (each outer contour with its holes). +pub(in super::super) fn components(pd: &PathData) -> Vec { + let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect(); + if subs.len() <= 1 { + return if pd.is_empty() { vec![] } else { vec![pd.clone()] }; + } + let bps: Vec = subs + .iter() + .map(|s| { + let mut b = BezPath::new(); + s.to_bezpath_into(&mut b); + b + }) + .collect(); + let samples: Vec = subs.iter().map(|s| s.segment(0).eval(0.5)).collect(); + let n = subs.len(); + let inside = |j: usize, i: usize| fill_contains(&bps[j], FillRule::NonZero, samples[i]); + let depth: Vec = (0..n).map(|i| (0..n).filter(|&j| j != i && inside(j, i)).count()).collect(); + let areas: Vec = subs.iter().map(|s| s.area().abs()).collect(); + let outers: Vec = (0..n).filter(|&i| depth[i].is_multiple_of(2)).collect(); + let mut groups: Vec> = outers.iter().map(|&o| vec![subs[o].clone()]).collect(); + for h in (0..n).filter(|&i| !depth[i].is_multiple_of(2)) { + let best = outers + .iter() + .enumerate() + .filter(|(_, o)| depth[**o] + 1 == depth[h] && inside(**o, h)) + .min_by(|a, b| areas[*a.1].total_cmp(&areas[*b.1])); + if let Some((k, _)) = best { + groups[k].push(subs[h].clone()); + } + } + groups.into_iter().map(PathData::new).collect() +} + +/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. Each segment +/// is sampled at least every `step` pt (and at least 24 times), so a removed stretch at least +/// `step` long is found. +fn cut_subpath(sp: &SubPath, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { + let nseg = sp.segment_count(); + if nseg == 0 { + return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]); + } + const K: usize = 24; + const MAX_SAMPLES: usize = 4096; + let mut runs: Vec> = vec![]; + let mut cur: Vec<(usize, f64, f64)> = vec![]; + let mut any = false; + for i in 0..nseg { + let c = sp.segment(i); + // The curve moves at most 3 times its longest control-polygon edge per unit of t, so with + // n samples, neighboring samples are at most 3 · edge / n apart. + let edge = (c.p1 - c.p0).hypot().max((c.p2 - c.p1).hypot()).max((c.p3 - c.p2).hypot()); + let wanted = (3.0 * edge / step).ceil(); + let samples = if wanted.is_finite() && wanted > K as f64 { (wanted as usize).min(MAX_SAMPLES) } else { K }; + let mut prev_t = 0.0; + let mut prev_rm = remove(c.eval(0.0)); + let mut start = if prev_rm { None } else { Some(0.0) }; + if prev_rm { + any = true; + if !cur.is_empty() { + runs.push(std::mem::take(&mut cur)); + } + } + for k in 1..=samples { + let t = k as f64 / samples as f64; + let rm = remove(c.eval(t)); + if rm != prev_rm { + let (mut lo, mut hi) = (prev_t, t); + for _ in 0..30 { + let m = 0.5 * (lo + hi); + if remove(c.eval(m)) == prev_rm { lo = m } else { hi = m } + } + let b = 0.5 * (lo + hi); + if prev_rm { + start = Some(b); + } else { + any = true; + if let Some(s) = start.take() { + cur.push((i, s, b)); + } + runs.push(std::mem::take(&mut cur)); + } + } + prev_t = t; + prev_rm = rm; + } + if let Some(s) = start { + cur.push((i, s, 1.0)); + } + } + if !cur.is_empty() { + runs.push(cur); + } + runs.retain(|r| !r.is_empty()); + if !any { + return None; + } + // A closed path's last and first runs meet at anchor 0. + if sp.closed && runs.len() >= 2 { + let first_starts = runs.first().and_then(|r| r.first()).is_some_and(|s| s.0 == 0 && s.1 == 0.0); + let last_ends = runs.last().and_then(|r| r.last()).is_some_and(|s| s.0 + 1 == nseg && s.2 == 1.0); + if first_starts && last_ends { + let first = runs.remove(0); + if let Some(last) = runs.last_mut() { + last.extend(first); + } + } + } + let out = runs + .iter() + .filter(|r| r.len() > 1 || r[0].2 - r[0].1 > 1e-4) + .map(|r| { + let mut anchors: Vec = vec![]; + for &(i, t0, t1) in r { + let c = sp.segment(i).subsegment(t0..t1); + let (h1, h2) = if sp.segment_is_line(i) { (c.p0, c.p3) } else { (c.p1, c.p2) }; + match anchors.last_mut() { + Some(a) => a.h_out = h1, + None => anchors.push(Anchor { h_out: h1, ..Anchor::corner(c.p0) }), + } + anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind: AnchorKind::Corner }); + } + for a in &mut anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + SubPath::new(anchors, false) + }) + .filter(|s| s.anchors.len() >= 2) + .collect(); + Some(out) +} + +/// Erase from an outline path (open or closed) where `remove` holds, sampling each segment at least +/// every `step` pt (`f64::INFINITY` for the fixed sampling). Returns the new geometry per output +/// object (one object per piece when the path had a single subpath), or None if unchanged. +pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { + let mut changed = false; + let mut subs = vec![]; + for sp in &pd.subpaths { + match cut_subpath(sp, step, remove) { + Some(pieces) => { + changed = true; + subs.extend(pieces); + } + None => subs.push(sp.clone()), + } + } + if !changed { + return None; + } + Some(if pd.subpaths.len() == 1 { + subs.into_iter().map(PathData::single).collect() + } else if subs.is_empty() { + vec![] + } else { + vec![PathData::new(subs)] + }) +} + +// ---------- Scissors ---------- + +pub(super) fn split(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.split"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + let anchor = usize_opt(p, "anchor", C)?; + let seg = usize_opt(p, "segment", C)?; + if anchor.is_none() && seg.is_none() { + return Err(bad(C, "need `anchor` or `segment` + `t`")); + } + let t = f64_or(p, "t", 0.5); + if !t.is_finite() || p.get("t").is_some_and(|v| !v.is_f64() && !v.is_i64() && !v.is_u64()) { + return Err(bad(C, "invalid t")); + } + let ids = s.edit("Split Path", |d, sel| { + let path = path_mut(d, id)?; + let mut sp = path.subpaths.get(si).cloned().ok_or_else(|| EngineError::Other("no such subpath".into()))?; + let n = sp.anchors.len(); + let k = match (anchor, seg) { + (Some(a), _) if a < n => a, + (Some(_), _) => return Err(EngineError::Other("no such anchor".into())), + (None, Some(g)) if g < sp.segment_count() => { + if t <= 1e-6 { + g + } else if t >= 1.0 - 1e-6 { + (g + 1) % n + } else { + sp.insert_anchor(g, t) + } + } + _ => return Err(EngineError::Other("no such segment".into())), + }; + let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k])); + if sp.closed { + path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?; + sel.set([id]); + return Ok(vec![id]); + } + let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?; + if path.subpaths.len() == 1 { + path.subpaths[0] = left; + let node = sibling_with(d, id, PathData::single(right))?; + let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + let nid = d.insert(parent, idx + 1, node)?; + sel.set([id, nid]); + Ok(vec![id, nid]) + } else { + path.subpaths[si] = left; + path.subpaths.insert(si + 1, right); + sel.set([id]); + Ok(vec![id]) + } + })?; + Ok(ids_json(&ids)) +} + +// ---------- Knife / Eraser ---------- + +/// Width of the slit the knife leaves between pieces. +const KNIFE_GAP: f64 = 0.05; + +pub(super) fn knife(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.knife"; + let pts = points_param(p, "points", C)?; + if pts.len() < 2 { + return Err(bad(C, "need at least two points")); + } + let st = s.doc()?; + let strip = po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), KNIFE_GAP, Cap::Butt, Join::Round, 4.0); + let mut plan: Vec<(NodeId, Vec)> = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, 1.0)) { + let (pd, rule) = path_of(&st.doc, id)?; + if !pd.is_closed() { + continue; + } + let pieces = components(&po::boolean(&pd, rule, &strip, FillRule::NonZero, BoolOp::Difference)); + if pieces.len() >= 2 { + plan.push((id, pieces)); + } + } + if plan.is_empty() { + return Ok(json!({ "ids": [] })); + } + let ids = s.edit("Knife", |d, sel| { + let mut all = vec![]; + let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::>()); + for (id, pieces) in plan { + all.extend(replace_with_pieces(d, &mut cut, id, pieces)?); + } + cut.finish(d)?; + sel.set(all.iter().copied()); + Ok(all) + })?; + Ok(ids_json(&ids)) +} + +pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.eraseRegion"; + let pts = points_param(p, "points", C)?; + let size = number_or(p, "size", 10.0, C)?; + if !(size.is_finite() && size > 0.0) { + return Err(bad(C, "size must be positive")); + } + let angle = number_or(p, "angle", 0.0, C)?; + let roundness = number_or(p, "roundness", 100.0, C)?; + let r = size / 2.0; + // The tip's short axis: an open path is sampled at least every half of it. + let short = size * roundness.clamp(1.0, 100.0) / 100.0; + let st = s.doc()?; + let region = eraser_area(&pts, size, angle, roundness); + // A point is under the tip when, with the tip squashed back into a circle, it is within the + // circle's radius of the squashed drag. + let squash = eraser_tip(angle, roundness).inverse(); + let round: Vec = pts.iter().map(|q| squash * *q).collect(); + let mut plan: Vec<(NodeId, Vec)> = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) { + let (pd, rule) = path_of(&st.doc, id)?; + if pd.is_closed() { + if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { + continue; + } + plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); + } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| dist_to_polyline(&round, squash * q) <= r) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Eraser", plan) +} + +fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { + if plan.is_empty() { + return Ok(json!({ "ids": [] })); + } + let ids = s.edit(label, |d, sel| { + let mut all = vec![]; + let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::>()); + for (id, pieces) in plan { + all.extend(replace_with_pieces(d, &mut cut, id, pieces)?); + } + cut.finish(d)?; + sel.set(all.iter().copied()); + Ok(all) + })?; + Ok(ids_json(&ids)) +} + +pub(super) fn erase_segments(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.eraseSegments"; + let pts = points_param(p, "points", C)?; + let w = number_or(p, "width", 8.0, C)?; + if !(w.is_finite() && w > 0.0) { + return Err(bad(C, "width must be positive")); + } + let st = s.doc()?; + let mut plan = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) { + let (pd, _) = path_of(&st.doc, id)?; + if let Some(pieces) = erase_outline(&pd, w / 2.0, &|q| dist_to_polyline(&pts, q) <= w / 2.0) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Path Eraser", plan) +} diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs new file mode 100644 index 000000000..e6df1a60b --- /dev/null +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -0,0 +1,396 @@ +use super::common::{ + brush_region, path_kind, path_mut, path_of, points_param, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths, + within_input_bound, +}; +use super::*; + +// ---------- Pencil / Paintbrush ---------- + +/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay +/// corners) and simplify it to `tol`. +fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { + let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIDELITY * 0.3); + let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) }; + let mut kept: Vec = vec![p0]; + for &p in pts.iter().skip(1) { + if kept.last().is_none_or(|k| k.distance(p) >= min) { + kept.push(p); + } + } + // The last sample always ends the stroke. + if kept.len() > 1 { + if let Some(k) = kept.last_mut() { + *k = pn; + } + } else if pts.len() > 1 { + kept.push(pn); + } + let n = kept.len(); + let marked: Vec<(Point, bool)> = (0..n) + .map(|i| { + let corner = if i == 0 || i + 1 == n { + false + } else { + let (a, b) = (kept[i] - kept[i - 1], kept[i + 1] - kept[i]); + a.dot(b) / (a.hypot() * b.hypot()).max(1e-12) < -0.2 // turn > ~100° + }; + (kept[i], corner) + }) + .collect(); + let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed); + if sp.anchors.len() < 3 { + return sp; + } + let fitted = po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false }); + fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) +} + +/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points +/// joined by straight segments (`path.freehand` with `fit: false`). +fn stroke_subpath(pts: &[Point], tol: Option, closed: bool) -> SubPath { + match tol { + Some(tol) => fit_freehand(pts, tol, closed), + None => SubPath::new(pts.iter().map(|&p| Anchor::corner(p)).collect(), closed), + } +} + +/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of +/// `pd` to another, each within `within` of it, replaces the section between them (the shorter +/// way round a closed subpath) with the stroke fitted at `tol` (with no `tol`, unfitted). The +/// subpath's index and its new shape; None when the stroke doesn't start and end on the same +/// subpath, or meets it once. +fn redraw_section(pd: &PathData, pts: &[Point], tol: Option, within: f64) -> Option<(usize, SubPath)> { + use kurbo::ParamCurveArclen; + let (first, last) = (*pts.first()?, *pts.last()?); + let (si, _, _, _, d_a) = pd.nearest(first)?; + let (sj, _, _, _, d_b) = pd.nearest(last)?; + if si != sj || d_a > within || d_b > within || pts.len() < 2 { + return None; + } + let mut sp = pd.subpaths.get(si)?.clone(); + if sp.anchors.len() < 2 { + return None; + } + // The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each + // point is looked up on the subpath as it is by then: splitting a straight segment makes two + // new ones, which a `t` along the old one doesn't measure. + let place = |sp: &mut SubPath, q: Point| { + let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?; + let n = sp.anchors.len(); + Some(if t <= 1e-6 { + (seg % n, false) + } else if t >= 1.0 - 1e-6 { + ((seg + 1) % n, false) + } else { + (sp.insert_anchor(seg, t), true) + }) + }; + let (mut from, _) = place(&mut sp, first)?; + let (to, added) = place(&mut sp, last)?; + if added && to <= from { + from += 1; + } + if from == to { + return None; + } + let n = sp.anchors.len(); + // The length of the run of segments from anchor i forward to anchor j. + let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::(); + // The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from + // `end` round to `start`, then the stroke's inner anchors follow. + let (start, end, reversed) = if sp.closed { + if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) } + } else if from < to { + (from, to, false) + } else { + (to, from, true) + }; + let mut stroke = pts.to_vec(); + if reversed { + stroke.reverse(); + } + let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None }; + *s0 = s1.p; + let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None }; + *e0 = e1.p; + let fit = stroke_subpath(&stroke, tol, false).anchors; + let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None }; + let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default(); + let (before, after): (Vec, Vec) = if sp.closed { + ((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![]) + } else { + (sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec()) + }; + let mut anchors = before; + if let Some(a) = anchors.last_mut() { + *a = Anchor::with_handles(a.p, a.h_in, f0.h_out); + } + anchors.extend_from_slice(inner); + let at_end = anchors.len(); + anchors.extend(after); + // The anchor the stroke ends on: the first after it, or round to the first of a closed one. + let end_at = if sp.closed { 0 } else { at_end }; + if let Some(a) = anchors.get_mut(end_at) { + *a = Anchor::with_handles(a.p, f1.h_in, a.h_out); + } + Some((si, SubPath::new(anchors, sp.closed))) +} + +pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.freehand"; + let samples = samples_param(p, "points", C)?; + if samples.iter().any(|(q, _)| !within_input_bound(*q)) { + return Err(bad(C, "x and y must be finite")); + } + let pressure = stroke_pressure(&samples); + let mut pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); + let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0); + // The tolerance the stroke is fitted at; with `fit: false` it is not fitted. + let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol); + let closed = switch_or(p, "closed", false, C)?; + let brush = str_param(p, "style") == Some("brush"); + let keep_selected = switch_or(p, "keepSelected", true, C)?; + let filled = switch_or(p, "fill", false, C)?; + if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { + let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; + let at_start = str_param(ext, "end") == Some("start"); + let (pd, _) = path_of(&s.doc()?.doc, id)?; + let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; + let mut sp = pd.subpaths[si].clone(); + let old_len = subpath_len(&sp); + if at_start { + sp.reverse(); + } + let (Some(first), Some(end)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p)) else { + return Err(bad(C, "path has no open end")); + }; + let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) }; + *p0 = end; + if pts.len() < 2 { + return Ok(json!({ "id": id.0 })); + } + // A stroke that ends on an open end of another path connects the two: (that path, its last + // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). + let connect = match p.get("connect").filter(|v| !v.is_null()) { + Some(cn) => { + let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; + if other == id { + return Err(bad(C, "connect needs a path other than the one extended")); + } + let (opd, _) = path_of(&s.doc()?.doc, other)?; + let osi = + opd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "connect path has no open end"))?; + let mut y = opd.subpaths[osi].clone(); + let at_end = match str_param(cn, "end") { + Some("start") => false, + Some("end") => true, + _ => return Err(bad(C, "connect end must be \"start\" or \"end\"")), + }; + if at_end { + y.reverse(); + } + Some((other, osi, y, at_end)) + } + None => None, + }; + let close_tol = (tol * 4.0).max(6.0); + let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); + if closing && let Some(l) = pts.last_mut() { + *l = first; + } + if let Some((_, _, y, _)) = &connect + && let (Some(l), Some(y0)) = (pts.last_mut(), y.anchors.first()) + { + *l = y0.p; + } + let fit = stroke_subpath(&pts, fit_tol, false); + let mut add = fit.anchors; + if let (Some(last), Some(a0)) = (sp.anchors.last_mut(), add.first()) { + last.h_out = a0.h_out; + } + if !add.is_empty() { + add.remove(0); + } + sp.anchors.extend(add); + if closing + && sp.anchors.len() > 2 + && let Some(l) = sp.anchors.pop() + && let Some(a0) = sp.anchors.first_mut() + { + a0.h_in = l.h_in; + sp.closed = true; + } + for a in &mut sp.anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + let drawn_len = subpath_len(&sp); + // The other path's anchors follow on from the one the stroke ends on, which takes its + // outgoing handle. + let mut other_len = 0.0; + if let Some((_, _, y, _)) = &connect + && let Some((y0, rest)) = y.anchors.split_first() + { + if let Some(last) = sp.anchors.last_mut() { + *last = Anchor::with_handles(y0.p, last.h_in + (y0.p - last.p), y0.h_out); + } + sp.anchors.extend_from_slice(rest); + other_len = subpath_len(y); + } + // The path's pressure (in the direction it is continued in) followed by the stroke's, and + // by the pressure of the path it connects to. + let pressure_of = |n: NodeId| s.doc().ok()?.doc.node(n).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone()); + let old_pressure = pressure_of(id); + let other_pressure = connect.as_ref().and_then(|(o, _, _, at_end)| pressure_of(*o).map(|p| if *at_end { p.reversed() } else { p })); + let joined = (old_pressure.is_some() || pressure.is_some() || other_pressure.is_some()).then(|| { + let old = old_pressure.map(|p| if at_start { p.reversed() } else { p }); + let drawn = PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), drawn_len - old_len); + if connect.is_some() { PressureProfile::extended(drawn.as_ref(), drawn_len, other_pressure.as_ref(), other_len) } else { drawn } + }); + s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + let reverse = at_start && !sp.closed; + if reverse { + sp.reverse(); + } + path_mut(d, id)?.subpaths[si] = sp; + if let Some((other, osi, ..)) = &connect { + let o = path_mut(d, *other)?; + if *osi < o.subpaths.len() { + o.subpaths.remove(*osi); + } + if o.is_empty() { + d.remove(*other)?; + sel.remove(*other); + } + // Direct-selected anchors and segments of the subpaths after the one removed move + // down one; those of the one removed go, and a path left with no selected anchors + // is no longer selected. + let shift = |refs: &mut BTreeSet<(usize, usize)>| { + *refs = refs.iter().filter(|r| r.0 != *osi).map(|&(sub, i)| if sub > *osi { (sub - 1, i) } else { (sub, i) }).collect(); + }; + if let Some(refs) = sel.anchors.get_mut(other) { + shift(refs); + } + if let Some(refs) = sel.segments.get_mut(other) { + shift(refs); + } + if sel.anchors.get(other).is_some_and(BTreeSet::is_empty) { + sel.remove(*other); + } + } + if let Some(joined) = joined + && let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) + { + st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined }; + } + if keep_selected { + sel.set([id]); + } else { + sel.remove(id); + } + Ok(()) + })?; + return Ok(json!({ "id": id.0 })); + } + if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { + let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; + let within = number_or(rd, "within", 12.0, C)?; + if !within.is_finite() || within < 0.0 { + return Err(bad(C, "redraw within must be a finite distance of 0 or more")); + } + let (pd, _) = path_of(&s.doc()?.doc, id)?; + // shortcut: a stroke pressure profile is left as it was, measured along the old path; fit + // it to the new length if Calligraphic pressure strokes get redrawn. + if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) { + s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; + // A member path of a compound path is redrawn in it: the compound path is what is + // selected. + let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id); + if keep_selected { + sel.set([whole]); + } else { + sel.remove(whole); + } + Ok(()) + })?; + return Ok(json!({ "id": id.0 })); + } + } + if pts.len() < 2 { + return Err(bad(C, "need at least two points")); + } + let sp = stroke_subpath(&pts, fit_tol, closed); + let fill = if filled { s.paint.fill.clone() } else { Paint::None }; + // A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing + // gets black. A brush is its stroke. + let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() }; + let mut look = s.new_art_look(fill, stroke, s.paint.stroke_width.max(0.1)); + if let Some(st) = look.appearance.stroke_mut() { + st.pressure = pressure; + } + insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected) +} + +// ---------- Blob Brush ---------- + +pub(super) fn blob(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.blob"; + let pts = points_param(p, "points", C)?; + let size = number_or(p, "size", 10.0, C)?; + if !(size.is_finite() && size > 0.0) { + return Err(bad(C, "size must be positive")); + } + let region = brush_region(&pts, size); + if region.is_empty() { + return Err(EngineError::Other("empty brush stroke".into())); + } + let paint = if !s.paint.stroke.is_none() { + s.paint.stroke.clone() + } else if !s.paint.fill.is_none() { + s.paint.fill.clone() + } else { + Paint::solid(Color::BLACK) + }; + let keep_selected = switch_or(p, "keepSelected", false, C)?; + let only_selection = switch_or(p, "mergeOnlySelection", false, C)?; + let merge = switch_or(p, "merge", true, C)?; + let st = s.doc()?; + let mut merge_ids = vec![]; + if merge && !(only_selection && st.selection.is_empty()) { + let all = Selection::default(); + let from = if only_selection { &st.selection } else { &all }; + for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { + let Some(n) = st.doc.node(id) else { continue }; + if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() { + continue; + } + let (pd, rule) = path_of(&st.doc, id)?; + if pd.is_closed() && po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) > 1e-6 { + merge_ids.push((id, pd, rule)); + } + } + } + let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0)); + if merge_ids.is_empty() { + return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); + } + let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; + inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); + let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?; + let Some(((keep, _, _), rest)) = merge_ids.split_last() else { + return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); + }; + let keep = *keep; + let others: Vec = rest.iter().map(|m| m.0).collect(); + s.edit("Blob Brush", |d, sel| { + *path_mut(d, keep)? = merged; + remove_each(d, &others)?; + if keep_selected { + sel.set([keep]); + } else { + sel.remove(keep); + } + Ok(()) + })?; + Ok(json!({ "id": keep.0 })) +} diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs new file mode 100644 index 000000000..e20665447 --- /dev/null +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -0,0 +1,284 @@ +use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, target_paths}; +use super::*; + +// ---------- Smooth ---------- + +/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. +fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { + let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); + let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); + let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; + let (f, l) = (anchors[0], anchors[anchors.len() - 1]); + let last = out.len() - 1; + out[0].p = f.p; + out[0].h_in = f.h_in; + out[last].p = l.p; + out[last].h_out = l.h_out; + for a in &mut out { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } + out +} + +fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option { + let n = sp.anchors.len(); + let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); + if n < 3 || !marked.iter().any(|m| *m) { + return None; + } + if sp.closed { + let Some(k) = marked.iter().position(|m| !m) else { + // Everything is in range: smooth the whole closed path. + let f = + po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false }); + return f.subpaths.into_iter().next(); + }; + // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. + let mut open = sp.clone(); + open.anchors.rotate_left(k); + let mut m = marked.clone(); + m.rotate_left(k); + open.anchors.push(open.anchors[0]); + m.push(false); + open.closed = false; + let mut res = smooth_open(&open.anchors, &m, near, tol); + let l = res.pop()?; + if let Some(r0) = res.first_mut() { + r0.h_in = l.h_in; + } + return Some(SubPath::new(res, true)); + } + Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false)) +} + +/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it +/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver +/// is outside. +fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> { + const STEPS: u32 = 32; + let step = 1.0 / f64::from(STEPS); + // Back from the end in reach to the first sample outside, then halving to the crossing. + let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?; + let (mut lo, mut hi) = (outside, outside + step); + for _ in 0..20 { + let mid = (lo + hi) / 2.0; + if near(c.eval(mid)) { + hi = mid; + } else { + lo = mid; + } + } + // A straight segment's parts stay straight, their handles on their ends. + let straight = c.p1 == c.p0 && c.p2 == c.p3; + let part = |range| { + let s = c.subsegment(range); + if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s } + }; + (hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0))) +} + +/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments +/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under +/// it, and the rest of a long segment next to it stays as it was (#1512). +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec { + let n = anchors.len(); + let mut runs = vec![]; + let mut i = 0; + while i < n { + if marked[i] { + let a = i; + while i + 1 < n && marked[i + 1] { + i += 1; + } + runs.push((a, i)); + } + i += 1; + } + let mut out = anchors.to_vec(); + for (a, b) in runs.into_iter().rev() { + let lo = a.saturating_sub(1); + let hi = (b + 1).min(n - 1); + if hi - lo < 2 { + continue; + } + let mut piece = out[lo..=hi].to_vec(); + // The anchors outside the reach, kept with the part of their segment that is outside. + let (mut head, mut tail) = (None, None); + if hi > b + && let [.., inner, outer] = piece.as_mut_slice() + && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near) + { + inner.h_out = reach.p2; + tail = Some(Anchor { h_in: far.p1, ..*outer }); + *outer = Anchor::with_handles(far.p3, reach.p1, far.p2); + } + if lo < a + && let [outer, inner, ..] = piece.as_mut_slice() + && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near) + { + inner.h_in = reach.p2; + head = Some(Anchor { h_out: far.p1, ..*outer }); + *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); + } + let new = refit_run(&piece, tol); + out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); + } + out +} + +pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.smoothRegion"; + let pts = points_param(p, "points", C)?; + let radius = number_or(p, "radius", 12.0, C)?.max(0.0); + let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); + let st = s.doc()?; + let near = |q: Point| dist_to_polyline(&pts, q) <= radius; + let mut plan = vec![]; + let (mut before, mut after) = (0, 0); + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) { + let (pd, _) = path_of(&st.doc, id)?; + let mut changed = false; + let subs: Vec = pd + .subpaths + .iter() + .map(|sp| match smooth_subpath(sp, &near, tol) { + Some(n) if n != *sp && n.anchors.len() >= 2 => { + changed = true; + n + } + _ => sp.clone(), + }) + .collect(); + if changed { + let np = PathData::new(subs); + before += pd.anchor_count(); + after += np.anchor_count(); + plan.push((id, np)); + } + } + if plan.is_empty() { + return Ok(json!({ "ids": [], "before": 0, "after": 0 })); + } + let ids: Vec = plan.iter().map(|(id, _)| *id).collect(); + s.edit("Smooth", |d, _| { + for (id, np) in plan { + *path_mut(d, id)? = np; + } + Ok(()) + })?; + let mut v = ids_json(&ids); + v["before"] = json!(before); + v["after"] = json!(after); + Ok(v) +} + +// ---------- Join tool ---------- + +pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.joinScrub"; + let pts = points_param(p, "points", C)?; + let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0); + let st = s.doc()?; + // Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start). + let poly = { + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + for q in &pts[1..] { + bp.line_to(*q); + } + bp + }; + let order = |q: Point| -> f64 { + poly.segments() + .enumerate() + .map(|(i, sg)| (i as f64 + sg.nearest(q, 1e-6).t, sg.nearest(q, 1e-6).distance_sq)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map_or(0.0, |x| x.0) + }; + let (first_pt, last_pt) = (pts.first().copied(), pts.last().copied()); + let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![]; + for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) { + let (pd, _) = path_of(&st.doc, id)?; + for (si, sp) in pd.subpaths.iter().enumerate() { + if sp.closed || sp.anchors.len() < 2 { + continue; + } + for (start, a) in [(true, sp.anchors[0].p), (false, sp.anchors[sp.anchors.len() - 1].p)] { + let on_scrub = dist_to_polyline(&pts, a) <= tol; + let near_start = first_pt.is_some_and(|p| a.distance(p) <= tol); + let near_end = last_pt.is_some_and(|p| a.distance(p) <= tol); + if !on_scrub && !near_start && !near_end { + continue; + } + let ord = if on_scrub { + order(a) + } else if near_start && (!near_end || first_pt.is_some_and(|p| a.distance(p) <= a.distance(last_pt.unwrap_or(p)))) { + -1.0 + } else { + 1e30 + }; + ends.push((ord, id, si, start, a)); + } + } + } + ends.sort_by(|a, b| a.0.total_cmp(&b.0)); + if ends.len() < 2 { + return Ok(json!({ "id": null })); + } + let (_, ida, sa, starta, pa) = ends[0]; + let (_, idb, sb, startb, pb) = ends[1]; + let mid = pa.midpoint(pb); + let merge = pa.distance(pb) <= tol; + s.edit("Join", |d, sel| { + if ida == idb && sa == sb { + let path = path_mut(d, ida)?; + let sp = &mut path.subpaths[sa]; + if merge + && sp.anchors.len() > 2 + && let Some(l) = sp.anchors.pop() + { + let d0 = mid - sp.anchors[0].p; + sp.anchors[0].translate(d0); + sp.anchors[0].h_in = l.h_in + (mid - l.p); + } + sp.closed = true; + sel.set([ida]); + return Ok(()); + } + let (pdb, _) = path_of(d, idb)?; + let mut x = path_of(d, ida)?.0.subpaths[sa].clone(); + let mut y = pdb.subpaths[sb].clone(); + if starta { + x.reverse(); + } + if !startb { + y.reverse(); + } + if merge + && !y.anchors.is_empty() + && let Some(last) = x.anchors.last_mut() + { + let yf = y.anchors.remove(0); + let lp = last.p; + last.translate(mid - lp); + last.h_out = yf.h_out + (mid - yf.p); + *last = Anchor::with_handles(last.p, last.h_in, last.h_out); + } + x.anchors.extend(y.anchors); + if ida == idb { + let path = path_mut(d, ida)?; + path.subpaths[sa] = x; + path.subpaths.remove(sb); + } else { + path_mut(d, ida)?.subpaths[sa] = x; + let pbm = path_mut(d, idb)?; + pbm.subpaths.remove(sb); + if pbm.is_empty() { + d.remove(idb)?; + } + } + sel.set([ida]); + Ok(()) + })?; + Ok(json!({ "id": ida.0 })) +} From 2a90e827364d56051ad5ffa9e218097ae9fe1937 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:00 +0000 Subject: [PATCH 152/199] refactor(engine): move cmd/fileio/affinity.rs items to cmd/fileio/affinity/{document,import,layout,master_copies,nodes,paint,text}.rs --- crates/engine/src/cmd/fileio/affinity.rs | 1468 +---------------- .../src/cmd/fileio/affinity/document.rs | 211 +++ .../engine/src/cmd/fileio/affinity/import.rs | 48 + .../engine/src/cmd/fileio/affinity/layout.rs | 223 +++ .../src/cmd/fileio/affinity/master_copies.rs | 47 + .../engine/src/cmd/fileio/affinity/nodes.rs | 249 +++ .../engine/src/cmd/fileio/affinity/paint.rs | 387 +++++ crates/engine/src/cmd/fileio/affinity/text.rs | 326 ++++ 8 files changed, 1500 insertions(+), 1459 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/affinity/document.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/import.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/layout.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/master_copies.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/nodes.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/paint.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/text.rs diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index ddb6905e9..671bc04b6 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -20,52 +20,16 @@ use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKin use super::load::{err, raster_doc}; use crate::Result; -/// What an Affinity file opened as. -pub(super) struct Imported { - pub doc: Document, - pub warnings: Vec, - /// Only the embedded preview: never placed, used as a template or mined for styles. - pub preview_only: bool, -} +mod document; +mod import; +mod layout; +mod master_copies; +mod nodes; +mod paint; +mod text; -pub(super) fn import(name: &str, bytes: &[u8]) -> Result { - match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) { - Ok(d) => { - let mut b = Builder::new(72.0 / d.dpi); - b.document(&d)?; - b.doc.limit_depth_noting(&mut b.warnings); - let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); - warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}."))); - Ok(Imported { doc: b.doc, warnings, preview_only: false }) - } - Err(native) => { - let (doc, w) = preview(name, bytes, &native)?; - Ok(Imported { doc, warnings: vec![w], preview_only: true }) - } - } -} - -/// The embedded preview, for files whose native document can't be read. -fn preview(name: &str, bytes: &[u8], native: &vectorcraft_affinity::Error) -> Result<(Document, String)> { - let p = vectorcraft_affinity::preview(bytes).map_err(|e| err(format!("{native}; its embedded preview can't be read either ({e})")))?; - // `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this - // untrusted preview's complete pixels first, so corrupt data cannot create a blank image. - let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(4096); - limits.max_image_height = Some(4096); - limits.max_alloc = Some(128 << 20); - reader.limits(limits); - let _pixels = reader.decode().map_err(err)?; - let d = raster_doc(name, p.png)?; - Ok(( - d, - format!( - "The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.", - p.width, p.height - ), - )) -} +pub(super) use import::import; +use master_copies::{CopyCost, MasterPart}; struct Builder { doc: Document, @@ -92,1417 +56,3 @@ struct Builder { /// Each master spread's objects as its pages show them, made the first time one does. master_parts: std::collections::HashMap>, } - -/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. -#[derive(Clone, Copy, Debug, Default)] -struct CopyCost { - work: u64, - pixels: u64, -} - -impl CopyCost { - fn plus(self, o: CopyCost) -> CopyCost { - CopyCost { work: self.work.saturating_add(o.work), pixels: self.pixels.saturating_add(o.pixels) } - } - fn minus(self, o: CopyCost) -> Option { - Some(CopyCost { work: self.work.checked_sub(o.work)?, pixels: self.pixels.checked_sub(o.pixels)? }) - } -} - -/// Master page copies of one import together: as many objects, characters and pixels as a -/// document may hold, a few times over. A master shown on thousands of pages, or a huge one -/// on many, would otherwise multiply them. -const MAX_MASTER_COPIES: CopyCost = CopyCost { work: 1 << 23, pixels: 512 << 20 }; - -/// One object a master spread shows on the pages that show it: a top-level node of the spread -/// (`child` `None`) or one object in one of its layers, with its bounds and what copying it takes. -#[derive(Clone, Copy, Debug)] -struct MasterPart { - node: usize, - child: Option, - bounds: Option, - cost: CopyCost, -} - -fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { - if depth > MAX_DEPTH { - return CopyCost::default(); - } - let len = |runs: &[af::TextRun]| runs.iter().map(|r| r.text.len() as u64).sum::(); - let own = match &n.kind { - Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, - Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, - Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, - _ => CopyCost { work: 1, pixels: 0 }, - }; - n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) -} - -/// Deepest node tree mapped (the reader bounds trees well below this). -const MAX_DEPTH: usize = 160; - -fn rect_of(r: af::Rect) -> Rect { - Rect::new(r.x0, r.y0, r.x1, r.y1) -} - -fn affine(a: af::Affine) -> Affine { - Affine::new(a.0) -} - -/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node -/// space into it (applied to the frame's geometry, not the type), plus the type's own scale. -struct Placement { - /// Node space to the frame's space, before `place`. - to_frame: Affine, - place: Affine, - /// Points per node unit of type size. - size_scale: f64, - /// Horizontal scale of the type relative to `size_scale`. - h_scale: f64, -} - -impl Builder { - fn new(k: f64) -> Self { - Self { - doc: Document::new(0.0, 0.0), - warnings: Vec::new(), - k, - layer: 0, - embedded: 0, - masters: Vec::new(), - cmyk_profile: None, - cmyk_lut: None, - working_cmyk: None, - overlay_work: vectorcraft_affinity::overlay::MAX_WORK, - copy_left: MAX_MASTER_COPIES, - master_parts: std::collections::HashMap::new(), - } - } - - fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) { - self.warnings.push(w.to_string()); - } - } - - fn document(&mut self, d: &af::Document) -> Result<()> { - self.cmyk_profile = d.cmyk_profile.clone(); - // Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin. - let mut layers = Vec::new(); - let mut artboards = Vec::new(); - let mut x = 0.0; - for (i, spread) in d.spreads.iter().enumerate() { - let shift = if i == 0 { 0.0 } else { x - spread.bounds.x0 * self.k }; - let to_doc = Affine::translate((shift, 0.0)) * Affine::scale(self.k); - let before = artboards.len(); - let mut loose: Vec> = Vec::new(); - for n in &spread.nodes { - let top = match &n.kind { - Kind::Layer => self.node(n, to_doc, &mut artboards, true, 0), - Kind::Artboard { .. } => { - self.node(n, to_doc, &mut artboards, true, 0).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)) - } - _ => { - if let Some(art) = self.node(n, to_doc, &mut artboards, false, 0) { - loose.push(art); - } - continue; - } - }; - if !loose.is_empty() { - layers.push(self.layer_with(None, std::mem::take(&mut loose))); - } - layers.extend(top); - } - if !loose.is_empty() { - layers.push(self.layer_with(None, loose)); - } - self.master_pages(d, spread, to_doc); - // Without artboards, each page (or the spread) becomes one. - if artboards.len() == before { - let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; - for r in rects { - let name = format!("{} {}", if d.spreads.len() > 1 || spread.pages.len() > 1 { "Page" } else { "Artboard" }, artboards.len() + 1); - artboards.push((name, to_doc.transform_rect_bbox(rect_of(r)))); - } - } - // (Earlier spreads' artboards are all left of `x` already.) - x = artboards.get(before..).unwrap_or_default().iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0; - if spread.transparent && i == 0 { - self.warn("transparent page backgrounds show as white artboards"); - } - } - self.doc.artboards = artboards - .into_iter() - .enumerate() - .filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0) - .map(|(i, (name, rect))| Artboard { - id: i as u32 + 1, - name, - rect, - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }) - .collect(); - if self.doc.artboards.is_empty() { - return Err(err("the Affinity document has no page with a size")); - } - // Master pages are behind everything on the pages that show them. - let mut masters = std::mem::take(&mut self.masters); - masters.sort_by_key(|(m, _)| *m); - let mut bottom = Vec::new(); - for (m, art) in masters { - let mut l = (*self.layer_with(Some(&format!("Master {}", master_prefix(m))), art)).clone(); - l.locked = true; - bottom.push(Arc::new(l)); - } - // Bottom to top, as Affinity lists them. - bottom.extend(layers); - self.doc.layers = bottom; - if self.doc.layers.is_empty() { - let empty = self.layer_with(None, Vec::new()); - self.doc.layers.push(empty); - } - Ok(()) - } - - /// The master pages `spread`'s pages show, placed on those pages (`to_doc` places the spread). - /// Each master page shows only the objects on it, moved from its page to the one showing it. - fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) { - let instances = master_instances(&spread.nodes); - if instances.is_empty() { - return; - } - let owners = page_masters(&instances, spread.pages.len()); - for (pi, page) in spread.pages.iter().enumerate() { - let Some(&Some((master, first, master_page))) = owners.get(pi) else { continue }; - let Some(m) = d.masters.get(master) else { continue }; - let index = master_page.saturating_add(pi.saturating_sub(first)); - let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds); - let parts = self.master_parts(master, m); - let on_page = |b: &Option| b.is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1); - // Looking through the master's objects takes one each, copying them what they hold. - let looked = CopyCost { work: parts.len() as u64, pixels: 0 }; - let cost = parts.iter().filter(|p| on_page(&p.bounds)).fold(looked, |c, p| c.plus(p.cost)); - let Some(left) = self.copy_left.minus(cost) else { - self.warn("master pages shown on more pages than one import copies them to (left out on the rest)"); - return; - }; - self.copy_left = left; - let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0)); - let mut art = Vec::new(); - let mut boards = Vec::new(); - self.embedded += 1; - let mut i = 0; - while let Some(p) = parts.get(i).copied() { - let Some(n) = m.nodes.get(p.node) else { - i += 1; - continue; - }; - if p.child.is_none() { - if on_page(&p.bounds) { - art.extend(self.node(n, place, &mut boards, false, 0)); - } - i += 1; - continue; - } - // A layer of the master spread: just its objects on this page. - let mut kids: Vec> = Vec::new(); - while let Some(q) = parts.get(i).copied().filter(|q| q.node == p.node) { - if on_page(&q.bounds) - && let Some(c) = q.child.and_then(|c| n.children.get(c)) - { - kids.extend(self.node(c, place, &mut boards, false, 1)); - } - i += 1; - } - if !kids.is_empty() { - let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); - g.isolate = n.blend != Blend::PassThrough; - art.push(Arc::new(g)); - } - } - self.embedded -= 1; - if art.is_empty() { - continue; - } - let mut g = Node::group(self.doc.alloc_id(), art); - g.name = Some(format!("Master {} (page {})", master_prefix(master), pi + 1)); - match self.masters.iter_mut().find(|(i, _)| *i == master) { - Some((_, v)) => v.push(Arc::new(g)), - None => self.masters.push((master, vec![Arc::new(g)])), - } - } - } - - /// The objects of master spread `m` (number `index`) as its pages show them: its layers' - /// objects one by one and its other top-level objects, with their bounds and costs. - fn master_parts(&mut self, index: usize, m: &af::Spread) -> Arc<[MasterPart]> { - if let Some(parts) = self.master_parts.get(&index) { - return parts.clone(); - } - if !master_instances(&m.nodes).is_empty() { - self.warn("master pages based on other master pages (only their own objects are used)"); - } - let part = |node: usize, child: Option, n: &af::Node| MasterPart { - node, - child, - bounds: af::nodes_bounds(std::slice::from_ref(n)), - cost: copy_cost(n, 0), - }; - let mut parts = Vec::new(); - for (i, n) in m.nodes.iter().enumerate() { - if matches!(n.kind, Kind::Layer) { - parts.extend(n.children.iter().enumerate().map(|(c, child)| part(i, Some(c), child))); - } else { - parts.push(part(i, None, n)); - } - } - let parts: Arc<[MasterPart]> = parts.into(); - self.master_parts.insert(index, parts.clone()); - parts - } - - fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { - let id = self.doc.alloc_id(); - self.layer = self.layer.wrapping_add(1); - let label = name.map(str::to_string).unwrap_or_else(|| format!("Layer {}", self.layer)); - let mut n = Node::layer(id, &label, LayerColor::Preset(self.layer % 9)); - if let Some(c) = n.children_mut() { - *c = children; - } - Arc::new(n) - } - - /// An artboard at the top level becomes a layer of its name holding its clip group. - fn layer_of(&mut self, name: &str, art: Arc, visible: bool, locked: bool) -> Arc { - let mut l = (*self.layer_with(Some(if name.is_empty() { "Artboard" } else { name }), vec![art])).clone(); - l.visible = visible; - l.locked = locked; - Arc::new(l) - } - - fn base(&mut self, n: &af::Node, kind: NodeKind) -> Node { - let mut out = Node::new(self.doc.alloc_id(), kind); - out.name = (!n.name.is_empty()).then(|| n.name.clone()); - out.visible = n.visible; - out.locked = n.locked; - out.opacity = n.opacity as f32; - out.blend = self.blend(n.blend); - out - } - - fn blend(&mut self, b: Blend) -> BlendMode { - match b { - Blend::Normal | Blend::PassThrough => BlendMode::Normal, - Blend::Darken => BlendMode::Darken, - Blend::Multiply => BlendMode::Multiply, - Blend::ColorBurn => BlendMode::ColorBurn, - Blend::Lighten => BlendMode::Lighten, - Blend::Screen => BlendMode::Screen, - Blend::ColorDodge => BlendMode::ColorDodge, - Blend::Overlay => BlendMode::Overlay, - Blend::SoftLight => BlendMode::SoftLight, - Blend::HardLight => BlendMode::HardLight, - Blend::Difference => BlendMode::Difference, - Blend::Exclusion => BlendMode::Exclusion, - Blend::Hue => BlendMode::Hue, - Blend::Saturation => BlendMode::Saturation, - Blend::Color => BlendMode::Color, - Blend::Luminosity => BlendMode::Luminosity, - _ => { - self.warn("blend modes VectorCraft doesn't have (Add, Linear Light, Vivid Light, Pin Light, Hard Mix, Subtract, Darker/Lighter Colour, Average, Negation, Reflect, Glow, Erase) import as Normal"); - BlendMode::Normal - } - } - } - - /// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has - /// no overlay effect). - fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> { - // Each kind of message once, however often it is heard. - let mut warnings: Vec = Vec::new(); - let mut work = self.overlay_work; - let out = vectorcraft_affinity::overlay::apply(n, &mut work, &mut |w| { - if !warnings.iter().any(|x| x == w) { - warnings.push(w.to_string()); - } - }); - self.overlay_work = work; - for w in warnings { - self.warn(&w); - } - out - } - - /// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to - /// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer. - fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool, depth: usize) -> Option> { - if depth > MAX_DEPTH { - self.warn("objects nested too deeply (left out)"); - return None; - } - // VectorCraft documents stay within `MAX_COORD` of the origin (renderers and exports rely - // on it): objects reaching beyond it are left out. - if let Some(b) = af::kind_bounds(&n.kind) { - let r = to_doc.transform_rect_bbox(rect_of(b)); - if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) { - self.warn("objects beyond the document range VectorCraft supports (left out)"); - return None; - } - } - let n = &*self.overlaid(n); - // Register before descending so nested artboards keep their document order. Inside an - // embedded document an artboard is just its content's frame. - if let Kind::Artboard { rect, .. } = &n.kind - && self.embedded == 0 - { - boards.push(( - if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, - to_doc.transform_rect_bbox(rect_of(*rect)), - )); - } - let child_level = matches!(n.kind, Kind::Layer) && layer_level; - let embeds = n.class == vectorcraft_affinity::stream::Tag::of(b"EmbN"); - self.embedded += usize::from(embeds); - let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level, depth + 1)).collect(); - self.embedded -= usize::from(embeds); - let mut out = match &n.kind { - Kind::Layer if layer_level => { - let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone(); - l.visible = n.visible; - l.locked = n.locked; - l.opacity = n.opacity as f32; - l.blend = self.blend(n.blend); - l - } - Kind::Layer | Kind::Group => { - let mut g = self.base(n, NodeKind::Group { children, clip: false }); - // Affinity groups isolate their blending unless they pass through. - g.isolate = n.blend != Blend::PassThrough; - g - } - Kind::Shape { path, fills, strokes, even_odd } => { - let data = self.path(path, to_doc); - let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; - let mut appearance = self.appearance(fills, strokes, to_doc); - // An outline effect on a shape is one more stroke. - self.outline_stroke(&n.effects, &mut appearance); - if children.is_empty() { - let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false }); - p.appearance = appearance.into(); - p - } else { - // A shape clips its children: VectorCraft's clipping path keeps its paint, its - // fill behind the clipped art and its stroke over it, as Affinity draws it. - let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false }); - clip.appearance = appearance.into(); - let mut kids = vec![Arc::new(clip)]; - kids.extend(children); - self.base(n, NodeKind::Group { children: kids, clip: true }) - } - } - Kind::Artboard { path, even_odd, background, strokes, .. } => { - let mut clip = Node::new( - self.doc.alloc_id(), - NodeKind::Path { - path: self.path(path, to_doc), - rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, - live: None, - clipping: true, - guide: false, - }, - ); - clip.appearance = self.appearance(background, strokes, to_doc).into(); - let mut kids = vec![Arc::new(clip)]; - kids.extend(children); - let mut g = self.base(n, NodeKind::Group { children: kids, clip: true }); - g.name = Some(if n.name.is_empty() { "Artboard".into() } else { n.name.clone() }); - g - } - Kind::Text(t) => { - let Some((mut text, alpha)) = self.text(t, to_doc) else { - // The text is left out (warned); what it holds still comes in. - if children.is_empty() { - return None; - } - let g = self.base(n, NodeKind::Group { children, clip: false }); - return Some(Arc::new(g)); - }; - self.outline_text(&n.effects, &mut text); - let mut node = self.base(n, NodeKind::Text(Box::new(text))); - node.opacity *= alpha; - if !children.is_empty() { - let mut kids = vec![Arc::new(node)]; - kids.extend(children); - node = Node::group(self.doc.alloc_id(), kids); - } - node - } - Kind::Table(t) => { - let mut kids = self.table(t, to_doc); - if kids.is_empty() && children.is_empty() { - return None; - } - kids.extend(children); - let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); - g.name.get_or_insert_with(|| "Table".into()); - g - } - Kind::Image(img) => match self.image(img, to_doc) { - Some(kind) => self.base(n, kind), - None if children.is_empty() => return None, - None => self.base(n, NodeKind::Group { children, clip: false }), - }, - // Shown on the pages through the master's layer (see `master_pages`). - Kind::MasterInstance { .. } => return None, - Kind::Unsupported if children.is_empty() => return None, - Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }), - }; - if !matches!(n.kind, Kind::Shape { .. } | Kind::Text(_)) && n.effects.iter().any(|e| matches!(e, af::Effect::Outline { .. })) { - self.warn("outline effects on groups, images and tables (left out)"); - } - if let Some(img) = &n.pixel_mask { - // A pixel mask: its grey levels show (white) or hide (black) the node, and nothing - // shows outside it, as an opacity mask that clips. - if let Some(kind) = self.image(img, to_doc) { - let art = Node::new(self.doc.alloc_id(), kind); - out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); - } - } - if let Some(mask) = &n.mask { - // A vector mask: the node shows only inside it. - let clip = Node::new( - self.doc.alloc_id(), - NodeKind::Path { path: self.path(mask, to_doc), rule: FillRule::NonZero, live: None, clipping: true, guide: false }, - ); - let (opacity, blend, visible, name) = (out.opacity, out.blend, out.visible, out.name.clone()); - out.opacity = 1.0; - out.blend = BlendMode::Normal; - let mut g = Node::new(self.doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(out)], clip: true }); - (g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name); - out = g; - } - // Shadows and glows apply to what the node shows, mask and all. - let fx = self.effects(&n.effects); - if !fx.is_empty() { - out.appearance.effects.extend(fx); - } - Some(Arc::new(out)) - } - - /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's - /// blur radius sets the same Gaussian as VectorCraft's blur (σ = half of it), and its angle - /// is the direction the shadow goes in. - fn effects(&mut self, effects: &[af::Effect]) -> Vec { - let k = self.k; - let len = move |v: f64| if v.is_finite() { (v * k).clamp(0.0, 1000.0) } else { 0.0 }; - let num = |v: f64| (v * 1000.0).round() / 1000.0; - let mut out = Vec::new(); - for e in effects { - let (id, params) = match e { - af::Effect::DropShadow { color, opacity, radius, distance, angle } => { - let (color, alpha) = Self::color(*color); - let d = len(*distance); - let a = if angle.is_finite() { *angle } else { 0.0 }; - let p = json!({ - "mode": "normal", - "opacity": num(opacity * f64::from(alpha) * 100.0), - "x": num(d * a.cos()), - "y": num(d * a.sin()), - "blur": num(len(*radius)), - "color": color, - }); - ("stylize.dropShadow", p) - } - af::Effect::InnerShadow { color, opacity, radius, distance, .. } => { - self.warn("inner shadows (imported as inner glows at the edge, without their offset)"); - let (color, alpha) = Self::color(*color); - let p = json!({ - "mode": "normal", - "opacity": num(opacity * f64::from(alpha) * 100.0), - "blur": num(len(*radius) + len(*distance)), - "color": color, - "source": "edge", - }); - ("stylize.innerGlow", p) - } - af::Effect::OuterGlow { color, opacity, radius } => { - let (color, alpha) = Self::color(*color); - let p = - json!({ "mode": "normal", "opacity": num(opacity * f64::from(alpha) * 100.0), "blur": num(len(*radius)), "color": color }); - ("stylize.outerGlow", p) - } - af::Effect::Bevel { .. } => { - self.warn("bevel and emboss effects (left out)"); - continue; - } - // Outlines become strokes (`outline_stroke`, `outline_text`), overlays are baked - // into the paint (`overlaid`). - af::Effect::Outline { .. } | af::Effect::ColorOverlay { .. } | af::Effect::GradientOverlay { .. } => continue, - }; - out.push(Effect { id: id.into(), params, visible: true }); - } - out - } - - /// An outline effect on a shape: a stroke layer. Affinity outlines what the shape shows, its own - /// strokes included: an outline outside goes under them (from the path out to as far past them as - /// it is wide), one centred on or inside that edge over them. A centred one reaches as far into - /// the shape as out of it here, which only differs by the width of the shape's own stroke. - fn outline_stroke(&mut self, effects: &[af::Effect], appearance: &mut Appearance) { - // How far the shape's own strokes reach outside its path. - let reach = appearance - .items - .iter() - .filter_map(|i| match i { - AppearanceItem::Stroke(s) => Some(match s.align { - StrokeAlign::Center => s.width / 2.0, - StrokeAlign::Outside => s.width, - StrokeAlign::Inside => 0.0, - }), - _ => None, - }) - .filter(|w| w.is_finite()) - .fold(0.0, f64::max); - for e in effects { - let af::Effect::Outline { color, opacity, width, align } = e else { continue }; - let w = width * self.k; - if !(w.is_finite() && w > 0.0) { - continue; - } - let (c, alpha) = Self::color(*color); - let (align, w) = match align { - 1 => (StrokeAlign::Inside, w), - 2 => (StrokeAlign::Center, w + 2.0 * reach), - _ => (StrokeAlign::Outside, w + reach), - }; - let mut l = StrokeLayer::new(Paint::solid(c), w.min(1000.0)); - l.opacity = (opacity.clamp(0.0, 1.0) as f32) * alpha; - l.join = LineJoin::Round; - l.align = align; - let item = AppearanceItem::Stroke(l); - if align == StrokeAlign::Outside { - // Under the shape's strokes, over its fills. - let at = appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap_or(appearance.items.len()); - appearance.items.insert(at, item); - } else { - appearance.items.push(item); - } - } - } - - /// An outline effect on text: the characters' stroke, where they have none of their own. - fn outline_text(&mut self, effects: &[af::Effect], text: &mut TextObject) { - for e in effects { - let af::Effect::Outline { color, opacity, width, align } = e else { continue }; - let w = width * self.k; - if !(w.is_finite() && w > 0.0) { - continue; - } - let (c, alpha) = Self::color(*color); - if opacity * f64::from(alpha) < 0.999 { - self.warn("semi-transparent outline effects on text (imported opaque)"); - } - if *align != 2 { - self.warn("outline effects on text inside or outside the characters (centred on them)"); - } - for r in &mut text.runs { - if r.style.stroke == Paint::None { - r.style.stroke = Paint::solid(c); - // Centred on the outline, so twice as wide to show as far outside. - r.style.stroke_width = if *align == 2 { w } else { 2.0 * w }.min(1000.0); - } - } - } - } - - fn path(&self, p: &af::Path, to_doc: Affine) -> PathData { - let mut bp = BezPath::new(); - for s in &p.subpaths { - let pt = |q: af::Point| Point::new(q.x, q.y); - bp.move_to(pt(s.start)); - for [c1, c2, e] in &s.segments { - if c1 == c2 && c2 == e { - bp.line_to(pt(*e)); - } else { - bp.curve_to(pt(*c1), pt(*c2), pt(*e)); - } - } - if s.closed { - bp.close_path(); - } - } - PathData::from_bezpath(&(to_doc * bp)) - } - - fn color(c: ap::Color) -> (Color, f32) { - let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; - match c { - ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)), - ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)), - ap::Color::Gray { v, a } => (Color::Gray { k: 1.0 - f(v) }, f(a)), - ap::Color::Lab { l, a, b, alpha } => (Color::Lab { l: l as f32, a: a as f32, b: b as f32 }, f(alpha)), - } - } - - /// A paint and the opacity its colour's alpha becomes. - fn paint(&mut self, p: &ap::Paint, to_doc: Affine) -> Option<(Paint, f32)> { - match p { - ap::Paint::None => None, - ap::Paint::Solid(c) => { - let (color, a) = Self::color(*c); - Some((Paint::solid(color), a)) - } - ap::Paint::Gradient(g) => { - let stops = g - .stops - .iter() - .map(|s| { - let (color, opacity) = Self::color(s.color); - let mut stop = GradientStop::new(s.offset as f32, color); - stop.opacity = opacity; - stop.midpoint = s.half_point().clamp(0.13, 0.87) as f32; - stop - }) - .collect(); - let m = to_doc * affine(g.transform); - let (o, x, y) = (m * Point::ZERO, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0)); - let kind = match g.kind { - AfKind::Linear => GradientKind::Linear, - AfKind::Radial => GradientKind::Radial, - AfKind::Conical => { - self.warn("conical gradients import as radial gradients"); - GradientKind::Radial - } - }; - let (u, v): (Vec2, Vec2) = (x - o, y - o); - if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 { - self.warn("skewed elliptical gradients import as upright ellipses"); - } - let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 }; - let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None }; - let mut gp = GradientPaint::new(Gradient::new(kind, stops)); - gp.angle = (-(u.y)).atan2(u.x).to_degrees(); - gp.geom = Some(geom); - Some((Paint::Gradient(Box::new(gp)), 1.0)) - } - } - } - - fn stroke_layer(&mut self, s: &ap::Stroke, to_doc: Affine) -> Option { - let (paint, opacity) = self.paint(&s.paint, to_doc)?; - let mut l = StrokeLayer::new(paint, s.width * self.k); - l.opacity = opacity; - l.cap = match s.cap { - Cap::Butt => LineCap::Butt, - Cap::Round => LineCap::Round, - Cap::Square => LineCap::Square, - }; - l.join = match s.join { - Join::Miter => LineJoin::Miter, - Join::Round => LineJoin::Round, - Join::Bevel => LineJoin::Bevel, - }; - l.miter_limit = s.miter_limit; - l.align = match s.align { - ap::Align::Center => StrokeAlign::Center, - ap::Align::Inside => StrokeAlign::Inside, - ap::Align::Outside => StrokeAlign::Outside, - }; - l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { - pattern: pattern.iter().map(|v| v * self.k).collect(), - offset: phase * self.k, - align_corners: false, - }); - Some(l) - } - - fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance { - let mut items = Vec::new(); - let mut above = Vec::new(); - for f in fills { - if let Some((paint, opacity)) = self.paint(f, to_doc) { - let mut l = FillLayer::new(paint); - l.opacity = opacity; - items.push(AppearanceItem::Fill(l)); - } - } - for s in strokes { - let Some(l) = self.stroke_layer(s, to_doc) else { continue }; - if s.behind { - items.insert(0, AppearanceItem::Stroke(l)); - } else { - above.push(AppearanceItem::Stroke(l)); - } - } - items.extend(above); - Appearance { items, ..Appearance::default() } - } - - /// Where text placed by `m` (node space to points) goes, its type scaled by `type_m`. - fn placement(&mut self, m: Affine, type_m: Affine) -> Option { - let [a, b, c, d, e, f] = m.as_coeffs(); - let sx = a.hypot(b); - let det = a * d - b * c; - if !(sx > 1e-9 && det.is_finite() && det.abs() > 1e-12 && e.is_finite() && f.is_finite()) { - self.warn("text with an invalid transform (left out)"); - return None; - } - let sy = det / sx; - let shear = (a * c + b * d) / (sx * sx); - if shear.abs() > 1e-3 || sy < 0.0 { - self.warn("skewed or flipped text (imported without the skew or flip)"); - } - // The type's vertical scale gives the font size, its horizontal scale relative to that the - // characters' width. - let [ta, tb, tc, td, ..] = type_m.as_coeffs(); - let (tsx, tdet) = (ta.hypot(tb), ta * td - tb * tc); - let (size_scale, h_scale) = if tsx > 1e-9 && tdet.is_finite() && tdet.abs() > 1e-12 { - let tsy = (tdet / tsx).abs(); - (tsy, tsx / tsy) - } else { - self.warn("text with an invalid text scale (imported unscaled)"); - (det.abs().sqrt(), 1.0) - }; - Some(Placement { - to_frame: Affine::scale_non_uniform(sx, sy.abs()), - place: Affine::translate((e, f)) * Affine::rotate(b.atan2(a)), - size_scale, - h_scale, - }) - } - - /// Text → its object and the opacity its characters' shared transparency gives the node. - fn text(&mut self, t: &af::Text, to_doc: Affine) -> Option<(TextObject, f32)> { - let p = self.placement(to_doc * affine(t.transform), to_doc * affine(t.text_transform))?; - let alpha = self.text_alpha(&t.runs); - let mut obj = self.type_object(&t.runs, &p)?; - obj.para.justify = justify(t.align); - if let Some(first) = obj.paras.first_mut() { - first.justify = obj.para.justify; - } - match t.frame { - Some(f) => { - let (a, b) = (p.to_frame * Point::new(f.x0, f.y0), p.to_frame * Point::new(f.x1, f.y1)); - let r = Rect::from_points(a, b); - obj.xf = p.place * Affine::translate(r.origin().to_vec2()); - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, r.width(), r.height())) }; - // Affinity hangs a frame's first line from its capitals' tops, not the font's ascent. - obj.area.first_baseline = FirstBaseline::CapHeight; - if fit_lines(&mut obj, r.width(), r.height()) { - self.warn("text frames whose last lines hang out of them (made taller so the lines show)"); - } - // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. - if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { - self.warn("text frames too short for their first line (made taller so the line shows)"); - } - } - None => { - align_lines(&mut obj); - obj.xf = p.place * Affine::translate((p.to_frame * Point::new(t.anchor.x, t.anchor.y)).to_vec2()); - } - } - Some((obj, alpha)) - } - - /// The opacity text takes from its characters' colour: VectorCraft's characters have none of - /// their own, so text whose characters share one becomes an object with that opacity. - fn text_alpha(&mut self, runs: &[af::TextRun]) -> f32 { - let alphas: Vec = - runs.iter().filter_map(|r| if let ap::Paint::Solid(c) = &r.fill { Some(c.alpha()) } else { None }).filter(|a| a.is_finite()).collect(); - let (lo, hi) = alphas.iter().fold((1.0f64, 0.0f64), |(lo, hi), a| (lo.min(*a), hi.max(*a))); - if alphas.is_empty() || lo >= 1.0 { - return 1.0; - } - if hi - lo > 0.01 { - self.warn("text with characters of different transparency (imported opaque)"); - return 1.0; - } - lo.clamp(0.0, 1.0) as f32 - } - - /// The characters and paragraphs of `runs` as a text object at the origin of text space. - fn type_object(&mut self, runs: &[af::TextRun], p: &Placement) -> Option { - let (v, h) = (p.size_scale, p.size_scale * p.h_scale); - let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); - let mut formats: Vec = Vec::new(); - let mut out: Vec = Vec::new(); - let mut paragraph = 0usize; - // Whether each paragraph here continues the one before after a line break (U+2028): - // VectorCraft has no line break inside a paragraph, so the lines become paragraphs. - let mut continues = vec![false]; - for r in runs { - for c in r.text.chars() { - match c { - '\u{2028}' => continues.push(true), - '\u{2029}' | '\r' | '\n' => continues.push(false), - _ => {} - } - } - // Affinity separates paragraphs with U+2029 and lines with U+2028; other control - // characters don't show. - let text: String = r - .text - .chars() - .filter_map(|c| match c { - '\u{2029}' | '\u{2028}' | '\r' | '\n' => Some('\n'), - '\t' => Some('\t'), - c if c.is_control() => None, - c => Some(c), - }) - .collect(); - if text.is_empty() { - continue; - } - for c in text.chars() { - if formats.len() <= paragraph { - formats.push(r.paragraph); - } - if c == '\n' { - paragraph += 1; - } - } - let mut style = self.char_style(r, (v, p.h_scale)); - if style.leading.is_none() { - style.leading = r.paragraph.leading.map(|l| l * v).filter(|l| l.is_finite() && *l > 0.0).map(|l| l.min(5000.0)); - } - out.push(TextRun::new(text, style)); - } - // Affinity ends a story with a paragraph mark; VectorCraft's last paragraph has none. - if let Some(last) = out.last_mut() - && last.text.ends_with('\n') - { - last.text.pop(); - if last.text.is_empty() { - out.pop(); - } - } - obj.runs = out; - let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::() + 1; - let mut paras = Vec::with_capacity(count); - let mut previous_after = 0.0; - // The run of each paragraph's first character. - let mut first_runs = Vec::with_capacity(count); - { - let mut at_start = true; - for (i, r) in obj.runs.iter().enumerate() { - for c in r.text.chars() { - if at_start { - first_runs.push(i); - at_start = false; - } - if c == '\n' { - at_start = true; - } - } - } - } - // Line gaps by font, each looked up once. - let mut gaps: std::collections::HashMap<(&str, &str), f64> = std::collections::HashMap::new(); - for i in 0..count { - let f = formats.get(i).or(formats.last()).copied().unwrap_or_default(); - let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 }; - let mut ps = vectorcraft_doc::text::ParaStyle { justify: justify(f.align), ..Default::default() }; - // Affinity places the first line and the others from the left edge; VectorCraft's - // first-line indent is relative to its left indent. - ps.left_indent = pt(f.left, h); - ps.first_line_indent = pt(f.first - f.left, h); - ps.right_indent = pt(f.right, h); - // Between two paragraphs Affinity keeps the larger of the space after the first and - // before the second; VectorCraft adds them. - let (mut before, mut after) = (pt(f.space_before, v).max(0.0), pt(f.space_after, v).max(0.0)); - // The lines of one Affinity paragraph: no space between them, and the first-line - // indent only on its first. - if continues.get(i).copied().unwrap_or(false) { - before = 0.0; - ps.first_line_indent = 0.0; - } - if continues.get(i + 1).copied().unwrap_or(false) { - after = 0.0; - } - ps.space_before = if i == 0 { before } else { (before - previous_after).max(0.0) }; - ps.space_after = after; - previous_after = after; - // Automatic leading: the font size plus the font's line gap, times the paragraph's - // factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas - // Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em, - // and the public Arial frame fixture fits this better than the font's line height. - let (family, style) = - first_runs.get(i).and_then(|r| obj.runs.get(*r)).map_or(("", ""), |r| (r.style.font_family.as_str(), r.style.font_style.as_str())); - let gap = *gaps.entry((family, style)).or_insert_with(|| line_gap(family, style)); - let ratio = (1.0 + gap) * f.auto_leading; - // Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft - // takes 0–500 %. - if ratio.is_finite() && (0.01..=5.0).contains(&ratio) { - ps.justification.auto_leading = ratio * 100.0; - } - paras.push(ps); - } - if let Some(first) = paras.first() { - obj.para = first.clone(); - } - obj.paras = paras; - obj.normalize_paras(); - Some(obj) - } - - /// A run's character style; `(size_scale, h_scale)` the type's vertical scale to points and - /// its horizontal scale relative to that. - fn char_style(&mut self, r: &af::TextRun, (size_scale, h_scale): (f64, f64)) -> CharStyle { - let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; - let mut style = CharStyle::default(); - if !r.family.is_empty() { - style.font_family = r.family.clone(); - } else if !r.postscript.is_empty() { - style.font_family = r.postscript.clone(); - } else { - self.warn("text without a font name uses the default font"); - } - // A family/weight pair cannot identify width variants such as Arial Narrow. - // Resolve a known PostScript identity to the exact installed family and style. - if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { - style.font_family = family; - style.font_style = face_style; - } else { - style.font_style = font_style(r.weight, r.italic); - } - style.features = r.features.clone(); - if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { - self.warn("unsupported text OpenType features (retained but not rendered)"); - } - style.all_caps = r.all_caps; - style.underline = r.underline; - style.strikethrough = r.strikethrough; - style.size = finite(r.size * size_scale, 12.0).clamp(0.1, 1296.0); - style.leading = r.leading.map(|l| finite(l * size_scale, 0.0)).filter(|l| *l > 0.0).map(|l| l.min(5000.0)); - style.tracking = finite(r.tracking * 1000.0, 0.0).clamp(-1000.0, 10000.0); - // Text squeezed or stretched by its transform keeps that as horizontal scale, on top of - // its own. - let hs = h_scale * r.h_scale; - if hs.is_finite() && (hs - 1.0).abs() > 1e-3 { - style.h_scale = (hs * 100.0).clamp(1.0, 1000.0); - } - // Transparency is the object's (see `text_alpha`); the paint's transform doesn't matter - // for a solid colour, and gradients on type are laid out in text space. - style.fill = match self.paint(&r.fill, Affine::scale(size_scale)) { - Some((p, _)) => p, - None => Paint::None, - }; - if let Some(st) = &r.stroke { - let w = st.width * size_scale; - if let Some((paint, _)) = self.paint(&st.paint, Affine::scale(size_scale)) - && w.is_finite() - && w > 0.0 - { - style.stroke = paint; - style.stroke_width = w.min(1000.0); - } - } - style - } - - /// A table: its cells as area type and the lines on their edges, as VectorCraft has no - /// tables. - fn table(&mut self, t: &af::Table, to_doc: Affine) -> Vec> { - let mut out = Vec::new(); - let (ncols, nrows) = (t.ncols(), t.nrows()); - if ncols == 0 || nrows == 0 { - return out; - } - self.warn("tables (imported as text frames and lines)"); - let m = to_doc * affine(t.transform); - let Some(p) = self.placement(m, to_doc * affine(t.text_transform)) else { return out }; - let col = |i: usize| t.columns.get(i).copied().unwrap_or(0.0); - let row = |i: usize| t.rows.get(i).copied().unwrap_or(0.0); - for (i, cell) in t.cells.iter().enumerate() { - if cell.merged_left || cell.runs.is_empty() { - continue; - } - let (r, c) = (i / ncols, i % ncols); - // Cells to the right merged into this one widen it. - let span = 1 + t.cells.iter().skip(i + 1).take(ncols - c - 1).take_while(|n| n.merged_left).count(); - let [l, tp, rt, bt] = cell.inset; - let node = af::Rect { x0: col(c) + l, y0: row(r) + tp, x1: col(c + span) - rt, y1: row(r + 1) - bt }; - let (a, b) = (p.to_frame * Point::new(node.x0, node.y0), p.to_frame * Point::new(node.x1, node.y1)); - let rect = Rect::from_points(a, b); - if !(rect.width() > 0.0 && rect.height() > 0.0) { - continue; - } - let Some(mut obj) = self.type_object(&cell.runs, &p) else { continue }; - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, rect.width(), rect.height())) }; - obj.area.vertical_align = match cell.valign { - af::VAlign::Top => VerticalAlign::Top, - af::VAlign::Center => VerticalAlign::Center, - af::VAlign::Bottom => VerticalAlign::Bottom, - }; - obj.area.first_baseline = FirstBaseline::CapHeight; - let dy = place_cell(&mut obj, rect.width(), rect.height()); - obj.xf = p.place * Affine::translate(rect.origin().to_vec2() + Vec2::new(0.0, dy)); - // A cell's text never vanishes for want of a line's height. - vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj); - let alpha = self.text_alpha(&cell.runs); - let mut n = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))); - n.opacity = alpha; - out.push(Arc::new(n)); - } - // Lines over the cells: the vertical edges row by row (none inside merged cells), then - // the horizontal ones. - let mut line = |b: &mut Self, s: &Option, from: (f64, f64), to: (f64, f64)| { - let Some(s) = s else { return }; - let Some(layer) = b.stroke_layer(s, to_doc) else { return }; - let mut bp = BezPath::new(); - bp.move_to(m * Point::new(from.0, from.1)); - bp.line_to(m * Point::new(to.0, to.1)); - let mut n = Node::new( - b.doc.alloc_id(), - NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false }, - ); - n.appearance = Appearance { items: vec![AppearanceItem::Stroke(layer)], ..Appearance::default() }.into(); - out.push(Arc::new(n)); - }; - for r in 0..nrows { - for k in 0..=ncols { - let merged = k > 0 && k < ncols && t.cells.get(r * ncols + k).is_some_and(|c| c.merged_left); - if merged { - continue; - } - if let Some(s) = t.vertical.get(r * (ncols + 1) + k) { - line(self, s, (col(k), row(r)), (col(k), row(r + 1))); - } - } - } - for j in 0..=nrows { - for k in 0..ncols { - if let Some(s) = t.horizontal.get(j * ncols + k) { - line(self, s, (col(k), row(j)), (col(k + 1), row(j))); - } - } - } - out - } - - /// A CMYK pixel layer as an image file. Through the document's own CMYK profile when it - /// embeds one (as Affinity shows it): RGBA. Otherwise, opaque, a CMYK image that keeps its - /// inks (shown and separated through the working CMYK profile, as placed CMYK images are); - /// with transparency, RGBA through the working CMYK profile. - fn cmyk_image(&mut self, width: u32, height: u32, cmyka: &[u8]) -> Option> { - let n = (width as usize).checked_mul(height as usize)?; - let px = cmyka.get(..n.checked_mul(5)?)?.as_chunks::<5>().0; - if let Some(lut) = self.document_cmyk() { - let rgba = px - .iter() - .flat_map(|p| { - let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); - [r, g, b, p[4]] - }) - .collect(); - return png(width, height, rgba); - } - if px.iter().all(|p| p[4] == 255) { - let inks = px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).collect(); - if let Some(blob) = vectorcraft_doc::cmyk::Inks::new(width, height, inks).and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) { - return Some(Arc::unwrap_or_clone(blob.bytes)); - } - } - self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)"); - let lut = self - .working_cmyk - .get_or_insert_with(|| { - Arc::new(vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| { - let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb(); - [r, g, b, 0.0] - })) - }) - .clone(); - let rgba = px - .iter() - .flat_map(|p| { - let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); - [r, g, b, p[4]] - }) - .collect(); - png(width, height, rgba) - } - - /// The document's CMYK profile as a table from ink amounts to sRGB (relative colorimetric, - /// with black-point compensation, as Affinity's pixel layers convert), made once; `None` when - /// the document embeds none, or not one that converts. - fn document_cmyk(&mut self) -> Option> { - use vectorcraft_color::cms::{self, CmykLut, IccProfile, Intent}; - if let Some(lut) = &self.cmyk_lut { - return lut.clone(); - } - let embedded = self.cmyk_profile.is_some(); - let lut = self.cmyk_profile.take().and_then(|bytes| { - let profile = IccProfile::from_bytes(None, &bytes).ok()?; - let srgb = cms::icc::builtin_rgb(cms::SRGB)?; - profile.to_rgb_of(&srgb, &[0.0; 4], Intent::RelativeColorimetric, true)?; - Some(Arc::new(CmykLut::build(|inks| { - let [r, g, b] = profile - .to_rgb_of(&srgb, &inks, Intent::RelativeColorimetric, true) - .unwrap_or_else(|| Color::cmyk(inks[0], inks[1], inks[2], inks[3]).to_rgb()); - [r, g, b, 0.0] - }))) - }); - if embedded && lut.is_none() { - self.warn("CMYK pixel layers whose document's colour profile could not be read (shown through the working CMYK profile)"); - } - self.cmyk_lut = Some(lut.clone()); - lut - } - - fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { - let bytes = match &img.pixels { - Pixels::Encoded(b) => b.clone(), - Pixels::Rgba8(rgba) => png(img.width, img.height, rgba.clone())?, - Pixels::Cmyka8(cmyka) => self.cmyk_image(img.width, img.height, cmyka)?, - }; - let r = match super::load::raster_image(&bytes) { - Ok(r) => r, - Err(_) => { - self.warn("an embedded image that could not be decoded was left out"); - return None; - } - }; - // The pixel grid maps onto the Affinity image's own size, whatever the stored file's. - let sx = img.width as f64 / f64::from(r.width.max(1)); - let sy = img.height as f64 / f64::from(r.height.max(1)); - let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); - self.doc.images.entry(r.key.clone()).or_insert(r.blob); - Some(NodeKind::Image(Box::new(ImageObject { - key: r.key, - width: r.width, - height: r.height, - xf, - link: None, - placement: Default::default(), - pixels: None, - }))) - } -} - -/// The master page instances among `nodes` (also inside layers and groups): (master, first page, -/// first master page, page count). -fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> { - fn visit(n: &af::Node, depth: usize, out: &mut Vec<(usize, usize, usize, usize)>) { - if depth > MAX_DEPTH || !n.visible { - return; - } - match n.kind { - Kind::MasterInstance { master, page, master_page, count } => out.push((master, page, master_page, count)), - Kind::Layer | Kind::Group => n.children.iter().for_each(|c| visit(c, depth + 1, out)), - _ => {} - } - } - let mut out = Vec::new(); - nodes.iter().for_each(|n| visit(n, 0, &mut out)); - out -} - -/// Which master page instance each of `pages` pages shows: (master, first page, first master -/// page) of the first instance in `instances` that covers it. Each page is settled once (the -/// next page still open is found by a path-halving jump), so this is about linear in pages and -/// instances however much their ranges overlap. -fn page_masters(instances: &[(usize, usize, usize, usize)], pages: usize) -> Vec> { - fn open_from(next: &mut [usize], mut i: usize) -> usize { - while let Some(&n) = next.get(i) { - if n == i { - break; - } - let skip = next.get(n).copied().unwrap_or(n); - if let Some(slot) = next.get_mut(i) { - *slot = skip; - } - i = n; - } - i - } - let mut owner = vec![None; pages]; - // `next[p]` leads towards the first page from `p` on with no master yet (`pages`: none). - let mut next: Vec = (0..=pages).collect(); - for &(master, first, master_page, count) in instances { - let end = first.saturating_add(count).min(pages); - let mut p = open_from(&mut next, first.min(pages)); - while p < end { - if let Some(o) = owner.get_mut(p) { - *o = Some((master, first, master_page)); - } - if let Some(n) = next.get_mut(p) { - *n = p + 1; - } - p = open_from(&mut next, p + 1); - } - } - owner -} - -/// Master names: A, B … Z, then A1, B1 …. -fn master_prefix(index: usize) -> String { - let letter = char::from(b'A' + (index % 26) as u8); - match index / 26 { - 0 => letter.to_string(), - n => format!("{letter}{n}"), - } -} - -/// A table cell's text `w` × `h` at the origin, placed as Affinity places it. Affinity aligns the -/// block from the first line's capitals' tops to the last baseline in the cell, and draws it even -/// where it doesn't fit (centred cells measured on a document's thumbnail: two lines in a cell -/// shorter than them reach out of it at both ends). VectorCraft aligns the block down to the last -/// line's descenders and keeps lines inside the frame: the frame moves by what this returns (down -/// the cell) and grows where the lines need more room. -fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 { - let share = match obj.area.vertical_align { - VerticalAlign::Center => 0.5, - VerticalAlign::Bottom => 1.0, - _ => 0.0, - }; - let mut tall = obj.clone(); - tall.area.vertical_align = VerticalAlign::Top; - tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; - let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tall).lines; - let Some(last) = lines.iter().rfind(|l| l.region == 0) else { return 0.0 }; - let (baseline, descent) = (last.baseline, last.descent); - if !(baseline.is_finite() && descent.is_finite() && h.is_finite()) { - return 0.0; - } - // (A little more than the lines need, for the rounding of the layout.) - let frame = h.max(baseline + descent + 0.05); - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, frame)) }; - // Where Affinity has the block's top, less where VectorCraft puts it in the frame. - (h - baseline) * share - (frame - baseline - descent) * share -} - -/// Area type `w` × `h` at the origin whose lines don't all fit. Affinity shows each line whose -/// baseline is inside the frame, its descenders hanging out of it; VectorCraft's area type leaves -/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was. -fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool { - let db = vectorcraft_text::FontDb::global(); - if !(w > 0.0 && h > 0.0 && h.is_finite()) { - return false; - } - let first = vectorcraft_text::layout(db, obj); - if !first.overflow { - return false; - } - // Where the lines go in a frame with room for all of them. - let mut tall = obj.clone(); - tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; - let need = vectorcraft_text::layout(db, &tall) - .lines - .iter() - .filter(|l| l.region == 0 && l.baseline <= h + 1e-6) - .map(|l| l.baseline + l.descent) - .filter(|b| b.is_finite()) - .fold(0.0, f64::max); - if need <= h { - return false; - } - let before = first.lines.len(); - // (A little more than they need, for the rounding of the layout.) - let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) }); - // Something else keeps the lines out: as the source has it. - if vectorcraft_text::layout(db, obj).lines.len() <= before { - obj.kind = short; - return false; - } - true -} - -/// RGBA pixels as a PNG file. -fn png(width: u32, height: u32, rgba: Vec) -> Option> { - let buf = image::RgbaImage::from_raw(width, height, rgba)?; - let mut png = Vec::new(); - buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - Some(png) -} - -/// Where a line of `w` starts in a span from 0 to `room` (as a share of `room - w`). -fn share(j: Justify) -> f64 { - match j { - Justify::Center | Justify::JustifyCenter => 0.5, - Justify::Right | Justify::JustifyRight => 1.0, - _ => 0.0, - } -} - -/// Artistic text whose paragraphs are aligned differently. Affinity aligns each line within the -/// width of the longest, and places that block at the anchor by the text's own alignment (its -/// first paragraph's); VectorCraft's point type aligns each line on the anchor. Indents move the -/// lines to where Affinity has them and keep their alignment: a centred line centres between its -/// indents and a right-aligned one ends at its right indent, whatever its width. -fn align_lines(obj: &mut TextObject) { - let block = share(obj.para.justify); - if obj.paras.iter().all(|p| share(p.justify) == block) { - return; - } - let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; - let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); - if !(w > 0.0 && w < 1e6) { - return; - } - // The block's left edge, from the anchor. - let left = -w * block; - for p in &mut obj.paras { - let at = share(p.justify); - let shift = left + w * at; - // A left-aligned line starts at its left indent, a right-aligned one ends at minus its right - // indent, and a centred one centres on half their difference. - if at < 0.25 { - p.left_indent += shift; - } else if at > 0.75 { - p.right_indent -= shift; - } else { - p.left_indent += shift; - p.right_indent -= shift; - } - } - if let Some(first) = obj.paras.first() { - obj.para = first.clone(); - } -} - -fn justify(a: AfAlign) -> Justify { - match a { - AfAlign::Left => Justify::Left, - AfAlign::Center => Justify::Center, - AfAlign::Right => Justify::Right, - AfAlign::Justify => Justify::JustifyLeft, - } -} - -/// A font's line gap in em, as VectorCraft will set it (the installed face, or the fallback for -/// a missing one); 0 when no font is loaded. -fn line_gap(family: &str, style: &str) -> f64 { - let defaults = CharStyle::default(); - let family = if family.is_empty() { defaults.font_family.as_str() } else { family }; - let style = if style.is_empty() { defaults.font_style.as_str() } else { style }; - let Some(face) = vectorcraft_text::FontDb::global().face(family, style) else { return 0.0 }; - let upem = face.units_per_em(); - let gap = if upem > 0.0 { face.line_gap() / upem } else { 0.0 }; - if gap.is_finite() { gap.clamp(0.0, 2.0) } else { 0.0 } -} - -fn font_style(weight: i64, italic: bool) -> String { - let w = match weight { - ..=150 => "Thin", - 151..=250 => "ExtraLight", - 251..=325 => "Light", - 326..=450 => "Regular", - 451..=550 => "Medium", - 551..=650 => "SemiBold", - 651..=750 => "Bold", - 751..=850 => "ExtraBold", - _ => "Black", - }; - match (w, italic) { - ("Regular", true) => "Italic".into(), - (w, true) => format!("{w} Italic"), - (w, false) => w.into(), - } -} diff --git a/crates/engine/src/cmd/fileio/affinity/document.rs b/crates/engine/src/cmd/fileio/affinity/document.rs new file mode 100644 index 000000000..0d74d1d81 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/document.rs @@ -0,0 +1,211 @@ +use super::layout::{master_instances, master_prefix, page_masters}; +use super::master_copies::{CopyCost, MAX_MASTER_COPIES, MasterPart, copy_cost}; +use super::nodes::rect_of; +use super::*; + +impl Builder { + pub(super) fn new(k: f64) -> Self { + Self { + doc: Document::new(0.0, 0.0), + warnings: Vec::new(), + k, + layer: 0, + embedded: 0, + masters: Vec::new(), + cmyk_profile: None, + cmyk_lut: None, + working_cmyk: None, + overlay_work: vectorcraft_affinity::overlay::MAX_WORK, + copy_left: MAX_MASTER_COPIES, + master_parts: std::collections::HashMap::new(), + } + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + pub(super) fn document(&mut self, d: &af::Document) -> Result<()> { + self.cmyk_profile = d.cmyk_profile.clone(); + // Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin. + let mut layers = Vec::new(); + let mut artboards = Vec::new(); + let mut x = 0.0; + for (i, spread) in d.spreads.iter().enumerate() { + let shift = if i == 0 { 0.0 } else { x - spread.bounds.x0 * self.k }; + let to_doc = Affine::translate((shift, 0.0)) * Affine::scale(self.k); + let before = artboards.len(); + let mut loose: Vec> = Vec::new(); + for n in &spread.nodes { + let top = match &n.kind { + Kind::Layer => self.node(n, to_doc, &mut artboards, true, 0), + Kind::Artboard { .. } => { + self.node(n, to_doc, &mut artboards, true, 0).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)) + } + _ => { + if let Some(art) = self.node(n, to_doc, &mut artboards, false, 0) { + loose.push(art); + } + continue; + } + }; + if !loose.is_empty() { + layers.push(self.layer_with(None, std::mem::take(&mut loose))); + } + layers.extend(top); + } + if !loose.is_empty() { + layers.push(self.layer_with(None, loose)); + } + self.master_pages(d, spread, to_doc); + // Without artboards, each page (or the spread) becomes one. + if artboards.len() == before { + let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; + for r in rects { + let name = format!("{} {}", if d.spreads.len() > 1 || spread.pages.len() > 1 { "Page" } else { "Artboard" }, artboards.len() + 1); + artboards.push((name, to_doc.transform_rect_bbox(rect_of(r)))); + } + } + // (Earlier spreads' artboards are all left of `x` already.) + x = artboards.get(before..).unwrap_or_default().iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0; + if spread.transparent && i == 0 { + self.warn("transparent page backgrounds show as white artboards"); + } + } + self.doc.artboards = artboards + .into_iter() + .enumerate() + .filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0) + .map(|(i, (name, rect))| Artboard { + id: i as u32 + 1, + name, + rect, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }) + .collect(); + if self.doc.artboards.is_empty() { + return Err(err("the Affinity document has no page with a size")); + } + // Master pages are behind everything on the pages that show them. + let mut masters = std::mem::take(&mut self.masters); + masters.sort_by_key(|(m, _)| *m); + let mut bottom = Vec::new(); + for (m, art) in masters { + let mut l = (*self.layer_with(Some(&format!("Master {}", master_prefix(m))), art)).clone(); + l.locked = true; + bottom.push(Arc::new(l)); + } + // Bottom to top, as Affinity lists them. + bottom.extend(layers); + self.doc.layers = bottom; + if self.doc.layers.is_empty() { + let empty = self.layer_with(None, Vec::new()); + self.doc.layers.push(empty); + } + Ok(()) + } + + /// The master pages `spread`'s pages show, placed on those pages (`to_doc` places the spread). + /// Each master page shows only the objects on it, moved from its page to the one showing it. + fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) { + let instances = master_instances(&spread.nodes); + if instances.is_empty() { + return; + } + let owners = page_masters(&instances, spread.pages.len()); + for (pi, page) in spread.pages.iter().enumerate() { + let Some(&Some((master, first, master_page))) = owners.get(pi) else { continue }; + let Some(m) = d.masters.get(master) else { continue }; + let index = master_page.saturating_add(pi.saturating_sub(first)); + let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds); + let parts = self.master_parts(master, m); + let on_page = |b: &Option| b.is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1); + // Looking through the master's objects takes one each, copying them what they hold. + let looked = CopyCost { work: parts.len() as u64, pixels: 0 }; + let cost = parts.iter().filter(|p| on_page(&p.bounds)).fold(looked, |c, p| c.plus(p.cost)); + let Some(left) = self.copy_left.minus(cost) else { + self.warn("master pages shown on more pages than one import copies them to (left out on the rest)"); + return; + }; + self.copy_left = left; + let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0)); + let mut art = Vec::new(); + let mut boards = Vec::new(); + self.embedded += 1; + let mut i = 0; + while let Some(p) = parts.get(i).copied() { + let Some(n) = m.nodes.get(p.node) else { + i += 1; + continue; + }; + if p.child.is_none() { + if on_page(&p.bounds) { + art.extend(self.node(n, place, &mut boards, false, 0)); + } + i += 1; + continue; + } + // A layer of the master spread: just its objects on this page. + let mut kids: Vec> = Vec::new(); + while let Some(q) = parts.get(i).copied().filter(|q| q.node == p.node) { + if on_page(&q.bounds) + && let Some(c) = q.child.and_then(|c| n.children.get(c)) + { + kids.extend(self.node(c, place, &mut boards, false, 1)); + } + i += 1; + } + if !kids.is_empty() { + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.isolate = n.blend != Blend::PassThrough; + art.push(Arc::new(g)); + } + } + self.embedded -= 1; + if art.is_empty() { + continue; + } + let mut g = Node::group(self.doc.alloc_id(), art); + g.name = Some(format!("Master {} (page {})", master_prefix(master), pi + 1)); + match self.masters.iter_mut().find(|(i, _)| *i == master) { + Some((_, v)) => v.push(Arc::new(g)), + None => self.masters.push((master, vec![Arc::new(g)])), + } + } + } + + /// The objects of master spread `m` (number `index`) as its pages show them: its layers' + /// objects one by one and its other top-level objects, with their bounds and costs. + fn master_parts(&mut self, index: usize, m: &af::Spread) -> Arc<[MasterPart]> { + if let Some(parts) = self.master_parts.get(&index) { + return parts.clone(); + } + if !master_instances(&m.nodes).is_empty() { + self.warn("master pages based on other master pages (only their own objects are used)"); + } + let part = |node: usize, child: Option, n: &af::Node| MasterPart { + node, + child, + bounds: af::nodes_bounds(std::slice::from_ref(n)), + cost: copy_cost(n, 0), + }; + let mut parts = Vec::new(); + for (i, n) in m.nodes.iter().enumerate() { + if matches!(n.kind, Kind::Layer) { + parts.extend(n.children.iter().enumerate().map(|(c, child)| part(i, Some(c), child))); + } else { + parts.push(part(i, None, n)); + } + } + let parts: Arc<[MasterPart]> = parts.into(); + self.master_parts.insert(index, parts.clone()); + parts + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/import.rs b/crates/engine/src/cmd/fileio/affinity/import.rs new file mode 100644 index 000000000..625df1e3b --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/import.rs @@ -0,0 +1,48 @@ +use super::*; + +/// What an Affinity file opened as. +pub(in super::super) struct Imported { + pub doc: Document, + pub warnings: Vec, + /// Only the embedded preview: never placed, used as a template or mined for styles. + pub preview_only: bool, +} + +pub(in super::super) fn import(name: &str, bytes: &[u8]) -> Result { + match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) { + Ok(d) => { + let mut b = Builder::new(72.0 / d.dpi); + b.document(&d)?; + b.doc.limit_depth_noting(&mut b.warnings); + let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); + warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}."))); + Ok(Imported { doc: b.doc, warnings, preview_only: false }) + } + Err(native) => { + let (doc, w) = preview(name, bytes, &native)?; + Ok(Imported { doc, warnings: vec![w], preview_only: true }) + } + } +} + +/// The embedded preview, for files whose native document can't be read. +fn preview(name: &str, bytes: &[u8], native: &vectorcraft_affinity::Error) -> Result<(Document, String)> { + let p = vectorcraft_affinity::preview(bytes).map_err(|e| err(format!("{native}; its embedded preview can't be read either ({e})")))?; + // `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this + // untrusted preview's complete pixels first, so corrupt data cannot create a blank image. + let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(128 << 20); + reader.limits(limits); + let _pixels = reader.decode().map_err(err)?; + let d = raster_doc(name, p.png)?; + Ok(( + d, + format!( + "The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.", + p.width, p.height + ), + )) +} diff --git a/crates/engine/src/cmd/fileio/affinity/layout.rs b/crates/engine/src/cmd/fileio/affinity/layout.rs new file mode 100644 index 000000000..157a38836 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/layout.rs @@ -0,0 +1,223 @@ +use super::nodes::MAX_DEPTH; +use super::*; + +/// The master page instances among `nodes` (also inside layers and groups): (master, first page, +/// first master page, page count). +pub(super) fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> { + fn visit(n: &af::Node, depth: usize, out: &mut Vec<(usize, usize, usize, usize)>) { + if depth > MAX_DEPTH || !n.visible { + return; + } + match n.kind { + Kind::MasterInstance { master, page, master_page, count } => out.push((master, page, master_page, count)), + Kind::Layer | Kind::Group => n.children.iter().for_each(|c| visit(c, depth + 1, out)), + _ => {} + } + } + let mut out = Vec::new(); + nodes.iter().for_each(|n| visit(n, 0, &mut out)); + out +} + +/// Which master page instance each of `pages` pages shows: (master, first page, first master +/// page) of the first instance in `instances` that covers it. Each page is settled once (the +/// next page still open is found by a path-halving jump), so this is about linear in pages and +/// instances however much their ranges overlap. +pub(super) fn page_masters(instances: &[(usize, usize, usize, usize)], pages: usize) -> Vec> { + fn open_from(next: &mut [usize], mut i: usize) -> usize { + while let Some(&n) = next.get(i) { + if n == i { + break; + } + let skip = next.get(n).copied().unwrap_or(n); + if let Some(slot) = next.get_mut(i) { + *slot = skip; + } + i = n; + } + i + } + let mut owner = vec![None; pages]; + // `next[p]` leads towards the first page from `p` on with no master yet (`pages`: none). + let mut next: Vec = (0..=pages).collect(); + for &(master, first, master_page, count) in instances { + let end = first.saturating_add(count).min(pages); + let mut p = open_from(&mut next, first.min(pages)); + while p < end { + if let Some(o) = owner.get_mut(p) { + *o = Some((master, first, master_page)); + } + if let Some(n) = next.get_mut(p) { + *n = p + 1; + } + p = open_from(&mut next, p + 1); + } + } + owner +} + +/// Master names: A, B … Z, then A1, B1 …. +pub(super) fn master_prefix(index: usize) -> String { + let letter = char::from(b'A' + (index % 26) as u8); + match index / 26 { + 0 => letter.to_string(), + n => format!("{letter}{n}"), + } +} + +/// A table cell's text `w` × `h` at the origin, placed as Affinity places it. Affinity aligns the +/// block from the first line's capitals' tops to the last baseline in the cell, and draws it even +/// where it doesn't fit (centred cells measured on a document's thumbnail: two lines in a cell +/// shorter than them reach out of it at both ends). VectorCraft aligns the block down to the last +/// line's descenders and keeps lines inside the frame: the frame moves by what this returns (down +/// the cell) and grows where the lines need more room. +pub(super) fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 { + let share = match obj.area.vertical_align { + VerticalAlign::Center => 0.5, + VerticalAlign::Bottom => 1.0, + _ => 0.0, + }; + let mut tall = obj.clone(); + tall.area.vertical_align = VerticalAlign::Top; + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tall).lines; + let Some(last) = lines.iter().rfind(|l| l.region == 0) else { return 0.0 }; + let (baseline, descent) = (last.baseline, last.descent); + if !(baseline.is_finite() && descent.is_finite() && h.is_finite()) { + return 0.0; + } + // (A little more than the lines need, for the rounding of the layout.) + let frame = h.max(baseline + descent + 0.05); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, frame)) }; + // Where Affinity has the block's top, less where VectorCraft puts it in the frame. + (h - baseline) * share - (frame - baseline - descent) * share +} + +/// Area type `w` × `h` at the origin whose lines don't all fit. Affinity shows each line whose +/// baseline is inside the frame, its descenders hanging out of it; VectorCraft's area type leaves +/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was. +pub(super) fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool { + let db = vectorcraft_text::FontDb::global(); + if !(w > 0.0 && h > 0.0 && h.is_finite()) { + return false; + } + let first = vectorcraft_text::layout(db, obj); + if !first.overflow { + return false; + } + // Where the lines go in a frame with room for all of them. + let mut tall = obj.clone(); + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let need = vectorcraft_text::layout(db, &tall) + .lines + .iter() + .filter(|l| l.region == 0 && l.baseline <= h + 1e-6) + .map(|l| l.baseline + l.descent) + .filter(|b| b.is_finite()) + .fold(0.0, f64::max); + if need <= h { + return false; + } + let before = first.lines.len(); + // (A little more than they need, for the rounding of the layout.) + let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) }); + // Something else keeps the lines out: as the source has it. + if vectorcraft_text::layout(db, obj).lines.len() <= before { + obj.kind = short; + return false; + } + true +} + +/// RGBA pixels as a PNG file. +pub(super) fn png(width: u32, height: u32, rgba: Vec) -> Option> { + let buf = image::RgbaImage::from_raw(width, height, rgba)?; + let mut png = Vec::new(); + buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + +/// Where a line of `w` starts in a span from 0 to `room` (as a share of `room - w`). +fn share(j: Justify) -> f64 { + match j { + Justify::Center | Justify::JustifyCenter => 0.5, + Justify::Right | Justify::JustifyRight => 1.0, + _ => 0.0, + } +} + +/// Artistic text whose paragraphs are aligned differently. Affinity aligns each line within the +/// width of the longest, and places that block at the anchor by the text's own alignment (its +/// first paragraph's); VectorCraft's point type aligns each line on the anchor. Indents move the +/// lines to where Affinity has them and keep their alignment: a centred line centres between its +/// indents and a right-aligned one ends at its right indent, whatever its width. +pub(super) fn align_lines(obj: &mut TextObject) { + let block = share(obj.para.justify); + if obj.paras.iter().all(|p| share(p.justify) == block) { + return; + } + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; + let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); + if !(w > 0.0 && w < 1e6) { + return; + } + // The block's left edge, from the anchor. + let left = -w * block; + for p in &mut obj.paras { + let at = share(p.justify); + let shift = left + w * at; + // A left-aligned line starts at its left indent, a right-aligned one ends at minus its right + // indent, and a centred one centres on half their difference. + if at < 0.25 { + p.left_indent += shift; + } else if at > 0.75 { + p.right_indent -= shift; + } else { + p.left_indent += shift; + p.right_indent -= shift; + } + } + if let Some(first) = obj.paras.first() { + obj.para = first.clone(); + } +} + +pub(super) fn justify(a: AfAlign) -> Justify { + match a { + AfAlign::Left => Justify::Left, + AfAlign::Center => Justify::Center, + AfAlign::Right => Justify::Right, + AfAlign::Justify => Justify::JustifyLeft, + } +} + +/// A font's line gap in em, as VectorCraft will set it (the installed face, or the fallback for +/// a missing one); 0 when no font is loaded. +pub(super) fn line_gap(family: &str, style: &str) -> f64 { + let defaults = CharStyle::default(); + let family = if family.is_empty() { defaults.font_family.as_str() } else { family }; + let style = if style.is_empty() { defaults.font_style.as_str() } else { style }; + let Some(face) = vectorcraft_text::FontDb::global().face(family, style) else { return 0.0 }; + let upem = face.units_per_em(); + let gap = if upem > 0.0 { face.line_gap() / upem } else { 0.0 }; + if gap.is_finite() { gap.clamp(0.0, 2.0) } else { 0.0 } +} + +pub(super) fn font_style(weight: i64, italic: bool) -> String { + let w = match weight { + ..=150 => "Thin", + 151..=250 => "ExtraLight", + 251..=325 => "Light", + 326..=450 => "Regular", + 451..=550 => "Medium", + 551..=650 => "SemiBold", + 651..=750 => "Bold", + 751..=850 => "ExtraBold", + _ => "Black", + }; + match (w, italic) { + ("Regular", true) => "Italic".into(), + (w, true) => format!("{w} Italic"), + (w, false) => w.into(), + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/master_copies.rs b/crates/engine/src/cmd/fileio/affinity/master_copies.rs new file mode 100644 index 000000000..bc55dbc25 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/master_copies.rs @@ -0,0 +1,47 @@ +use super::nodes::MAX_DEPTH; +use super::*; + +/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct CopyCost { + pub(super) work: u64, + pub(super) pixels: u64, +} + +impl CopyCost { + pub(super) fn plus(self, o: CopyCost) -> CopyCost { + CopyCost { work: self.work.saturating_add(o.work), pixels: self.pixels.saturating_add(o.pixels) } + } + pub(super) fn minus(self, o: CopyCost) -> Option { + Some(CopyCost { work: self.work.checked_sub(o.work)?, pixels: self.pixels.checked_sub(o.pixels)? }) + } +} + +/// Master page copies of one import together: as many objects, characters and pixels as a +/// document may hold, a few times over. A master shown on thousands of pages, or a huge one +/// on many, would otherwise multiply them. +pub(super) const MAX_MASTER_COPIES: CopyCost = CopyCost { work: 1 << 23, pixels: 512 << 20 }; + +/// One object a master spread shows on the pages that show it: a top-level node of the spread +/// (`child` `None`) or one object in one of its layers, with its bounds and what copying it takes. +#[derive(Clone, Copy, Debug)] +pub(super) struct MasterPart { + pub(super) node: usize, + pub(super) child: Option, + pub(super) bounds: Option, + pub(super) cost: CopyCost, +} + +pub(super) fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { + if depth > MAX_DEPTH { + return CopyCost::default(); + } + let len = |runs: &[af::TextRun]| runs.iter().map(|r| r.text.len() as u64).sum::(); + let own = match &n.kind { + Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, + Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, + Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, + _ => CopyCost { work: 1, pixels: 0 }, + }; + n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) +} diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs new file mode 100644 index 000000000..a3867d0bd --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -0,0 +1,249 @@ +use super::*; + +/// Deepest node tree mapped (the reader bounds trees well below this). +pub(super) const MAX_DEPTH: usize = 160; + +pub(super) fn rect_of(r: af::Rect) -> Rect { + Rect::new(r.x0, r.y0, r.x1, r.y1) +} + +pub(super) fn affine(a: af::Affine) -> Affine { + Affine::new(a.0) +} + +impl Builder { + pub(super) fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { + let id = self.doc.alloc_id(); + self.layer = self.layer.wrapping_add(1); + let label = name.map(str::to_string).unwrap_or_else(|| format!("Layer {}", self.layer)); + let mut n = Node::layer(id, &label, LayerColor::Preset(self.layer % 9)); + if let Some(c) = n.children_mut() { + *c = children; + } + Arc::new(n) + } + + /// An artboard at the top level becomes a layer of its name holding its clip group. + pub(super) fn layer_of(&mut self, name: &str, art: Arc, visible: bool, locked: bool) -> Arc { + let mut l = (*self.layer_with(Some(if name.is_empty() { "Artboard" } else { name }), vec![art])).clone(); + l.visible = visible; + l.locked = locked; + Arc::new(l) + } + + pub(super) fn base(&mut self, n: &af::Node, kind: NodeKind) -> Node { + let mut out = Node::new(self.doc.alloc_id(), kind); + out.name = (!n.name.is_empty()).then(|| n.name.clone()); + out.visible = n.visible; + out.locked = n.locked; + out.opacity = n.opacity as f32; + out.blend = self.blend(n.blend); + out + } + + fn blend(&mut self, b: Blend) -> BlendMode { + match b { + Blend::Normal | Blend::PassThrough => BlendMode::Normal, + Blend::Darken => BlendMode::Darken, + Blend::Multiply => BlendMode::Multiply, + Blend::ColorBurn => BlendMode::ColorBurn, + Blend::Lighten => BlendMode::Lighten, + Blend::Screen => BlendMode::Screen, + Blend::ColorDodge => BlendMode::ColorDodge, + Blend::Overlay => BlendMode::Overlay, + Blend::SoftLight => BlendMode::SoftLight, + Blend::HardLight => BlendMode::HardLight, + Blend::Difference => BlendMode::Difference, + Blend::Exclusion => BlendMode::Exclusion, + Blend::Hue => BlendMode::Hue, + Blend::Saturation => BlendMode::Saturation, + Blend::Color => BlendMode::Color, + Blend::Luminosity => BlendMode::Luminosity, + _ => { + self.warn("blend modes VectorCraft doesn't have (Add, Linear Light, Vivid Light, Pin Light, Hard Mix, Subtract, Darker/Lighter Colour, Average, Negation, Reflect, Glow, Erase) import as Normal"); + BlendMode::Normal + } + } + } + + /// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has + /// no overlay effect). + fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> { + // Each kind of message once, however often it is heard. + let mut warnings: Vec = Vec::new(); + let mut work = self.overlay_work; + let out = vectorcraft_affinity::overlay::apply(n, &mut work, &mut |w| { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } + }); + self.overlay_work = work; + for w in warnings { + self.warn(&w); + } + out + } + + /// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to + /// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer. + pub(super) fn node( + &mut self, + n: &af::Node, + to_doc: Affine, + boards: &mut Vec<(String, Rect)>, + layer_level: bool, + depth: usize, + ) -> Option> { + if depth > MAX_DEPTH { + self.warn("objects nested too deeply (left out)"); + return None; + } + // VectorCraft documents stay within `MAX_COORD` of the origin (renderers and exports rely + // on it): objects reaching beyond it are left out. + if let Some(b) = af::kind_bounds(&n.kind) { + let r = to_doc.transform_rect_bbox(rect_of(b)); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) { + self.warn("objects beyond the document range VectorCraft supports (left out)"); + return None; + } + } + let n = &*self.overlaid(n); + // Register before descending so nested artboards keep their document order. Inside an + // embedded document an artboard is just its content's frame. + if let Kind::Artboard { rect, .. } = &n.kind + && self.embedded == 0 + { + boards.push(( + if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, + to_doc.transform_rect_bbox(rect_of(*rect)), + )); + } + let child_level = matches!(n.kind, Kind::Layer) && layer_level; + let embeds = n.class == vectorcraft_affinity::stream::Tag::of(b"EmbN"); + self.embedded += usize::from(embeds); + let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level, depth + 1)).collect(); + self.embedded -= usize::from(embeds); + let mut out = match &n.kind { + Kind::Layer if layer_level => { + let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone(); + l.visible = n.visible; + l.locked = n.locked; + l.opacity = n.opacity as f32; + l.blend = self.blend(n.blend); + l + } + Kind::Layer | Kind::Group => { + let mut g = self.base(n, NodeKind::Group { children, clip: false }); + // Affinity groups isolate their blending unless they pass through. + g.isolate = n.blend != Blend::PassThrough; + g + } + Kind::Shape { path, fills, strokes, even_odd } => { + let data = self.path(path, to_doc); + let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; + let mut appearance = self.appearance(fills, strokes, to_doc); + // An outline effect on a shape is one more stroke. + self.outline_stroke(&n.effects, &mut appearance); + if children.is_empty() { + let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false }); + p.appearance = appearance.into(); + p + } else { + // A shape clips its children: VectorCraft's clipping path keeps its paint, its + // fill behind the clipped art and its stroke over it, as Affinity draws it. + let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false }); + clip.appearance = appearance.into(); + let mut kids = vec![Arc::new(clip)]; + kids.extend(children); + self.base(n, NodeKind::Group { children: kids, clip: true }) + } + } + Kind::Artboard { path, even_odd, background, strokes, .. } => { + let mut clip = Node::new( + self.doc.alloc_id(), + NodeKind::Path { + path: self.path(path, to_doc), + rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, + live: None, + clipping: true, + guide: false, + }, + ); + clip.appearance = self.appearance(background, strokes, to_doc).into(); + let mut kids = vec![Arc::new(clip)]; + kids.extend(children); + let mut g = self.base(n, NodeKind::Group { children: kids, clip: true }); + g.name = Some(if n.name.is_empty() { "Artboard".into() } else { n.name.clone() }); + g + } + Kind::Text(t) => { + let Some((mut text, alpha)) = self.text(t, to_doc) else { + // The text is left out (warned); what it holds still comes in. + if children.is_empty() { + return None; + } + let g = self.base(n, NodeKind::Group { children, clip: false }); + return Some(Arc::new(g)); + }; + self.outline_text(&n.effects, &mut text); + let mut node = self.base(n, NodeKind::Text(Box::new(text))); + node.opacity *= alpha; + if !children.is_empty() { + let mut kids = vec![Arc::new(node)]; + kids.extend(children); + node = Node::group(self.doc.alloc_id(), kids); + } + node + } + Kind::Table(t) => { + let mut kids = self.table(t, to_doc); + if kids.is_empty() && children.is_empty() { + return None; + } + kids.extend(children); + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.name.get_or_insert_with(|| "Table".into()); + g + } + Kind::Image(img) => match self.image(img, to_doc) { + Some(kind) => self.base(n, kind), + None if children.is_empty() => return None, + None => self.base(n, NodeKind::Group { children, clip: false }), + }, + // Shown on the pages through the master's layer (see `master_pages`). + Kind::MasterInstance { .. } => return None, + Kind::Unsupported if children.is_empty() => return None, + Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }), + }; + if !matches!(n.kind, Kind::Shape { .. } | Kind::Text(_)) && n.effects.iter().any(|e| matches!(e, af::Effect::Outline { .. })) { + self.warn("outline effects on groups, images and tables (left out)"); + } + if let Some(img) = &n.pixel_mask { + // A pixel mask: its grey levels show (white) or hide (black) the node, and nothing + // shows outside it, as an opacity mask that clips. + if let Some(kind) = self.image(img, to_doc) { + let art = Node::new(self.doc.alloc_id(), kind); + out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + } + } + if let Some(mask) = &n.mask { + // A vector mask: the node shows only inside it. + let clip = Node::new( + self.doc.alloc_id(), + NodeKind::Path { path: self.path(mask, to_doc), rule: FillRule::NonZero, live: None, clipping: true, guide: false }, + ); + let (opacity, blend, visible, name) = (out.opacity, out.blend, out.visible, out.name.clone()); + out.opacity = 1.0; + out.blend = BlendMode::Normal; + let mut g = Node::new(self.doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(out)], clip: true }); + (g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name); + out = g; + } + // Shadows and glows apply to what the node shows, mask and all. + let fx = self.effects(&n.effects); + if !fx.is_empty() { + out.appearance.effects.extend(fx); + } + Some(Arc::new(out)) + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/paint.rs b/crates/engine/src/cmd/fileio/affinity/paint.rs new file mode 100644 index 000000000..a60911e0d --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/paint.rs @@ -0,0 +1,387 @@ +use super::layout::png; +use super::nodes::affine; +use super::text::Placement; +use super::*; + +impl Builder { + /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's + /// blur radius sets the same Gaussian as VectorCraft's blur (σ = half of it), and its angle + /// is the direction the shadow goes in. + pub(super) fn effects(&mut self, effects: &[af::Effect]) -> Vec { + let k = self.k; + let len = move |v: f64| if v.is_finite() { (v * k).clamp(0.0, 1000.0) } else { 0.0 }; + let num = |v: f64| (v * 1000.0).round() / 1000.0; + let mut out = Vec::new(); + for e in effects { + let (id, params) = match e { + af::Effect::DropShadow { color, opacity, radius, distance, angle } => { + let (color, alpha) = Self::color(*color); + let d = len(*distance); + let a = if angle.is_finite() { *angle } else { 0.0 }; + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "x": num(d * a.cos()), + "y": num(d * a.sin()), + "blur": num(len(*radius)), + "color": color, + }); + ("stylize.dropShadow", p) + } + af::Effect::InnerShadow { color, opacity, radius, distance, .. } => { + self.warn("inner shadows (imported as inner glows at the edge, without their offset)"); + let (color, alpha) = Self::color(*color); + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "blur": num(len(*radius) + len(*distance)), + "color": color, + "source": "edge", + }); + ("stylize.innerGlow", p) + } + af::Effect::OuterGlow { color, opacity, radius } => { + let (color, alpha) = Self::color(*color); + let p = + json!({ "mode": "normal", "opacity": num(opacity * f64::from(alpha) * 100.0), "blur": num(len(*radius)), "color": color }); + ("stylize.outerGlow", p) + } + af::Effect::Bevel { .. } => { + self.warn("bevel and emboss effects (left out)"); + continue; + } + // Outlines become strokes (`outline_stroke`, `outline_text`), overlays are baked + // into the paint (`overlaid`). + af::Effect::Outline { .. } | af::Effect::ColorOverlay { .. } | af::Effect::GradientOverlay { .. } => continue, + }; + out.push(Effect { id: id.into(), params, visible: true }); + } + out + } + + /// An outline effect on a shape: a stroke layer. Affinity outlines what the shape shows, its own + /// strokes included: an outline outside goes under them (from the path out to as far past them as + /// it is wide), one centred on or inside that edge over them. A centred one reaches as far into + /// the shape as out of it here, which only differs by the width of the shape's own stroke. + pub(super) fn outline_stroke(&mut self, effects: &[af::Effect], appearance: &mut Appearance) { + // How far the shape's own strokes reach outside its path. + let reach = appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Stroke(s) => Some(match s.align { + StrokeAlign::Center => s.width / 2.0, + StrokeAlign::Outside => s.width, + StrokeAlign::Inside => 0.0, + }), + _ => None, + }) + .filter(|w| w.is_finite()) + .fold(0.0, f64::max); + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + let (align, w) = match align { + 1 => (StrokeAlign::Inside, w), + 2 => (StrokeAlign::Center, w + 2.0 * reach), + _ => (StrokeAlign::Outside, w + reach), + }; + let mut l = StrokeLayer::new(Paint::solid(c), w.min(1000.0)); + l.opacity = (opacity.clamp(0.0, 1.0) as f32) * alpha; + l.join = LineJoin::Round; + l.align = align; + let item = AppearanceItem::Stroke(l); + if align == StrokeAlign::Outside { + // Under the shape's strokes, over its fills. + let at = appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap_or(appearance.items.len()); + appearance.items.insert(at, item); + } else { + appearance.items.push(item); + } + } + } + + /// An outline effect on text: the characters' stroke, where they have none of their own. + pub(super) fn outline_text(&mut self, effects: &[af::Effect], text: &mut TextObject) { + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + if opacity * f64::from(alpha) < 0.999 { + self.warn("semi-transparent outline effects on text (imported opaque)"); + } + if *align != 2 { + self.warn("outline effects on text inside or outside the characters (centred on them)"); + } + for r in &mut text.runs { + if r.style.stroke == Paint::None { + r.style.stroke = Paint::solid(c); + // Centred on the outline, so twice as wide to show as far outside. + r.style.stroke_width = if *align == 2 { w } else { 2.0 * w }.min(1000.0); + } + } + } + } + + pub(super) fn path(&self, p: &af::Path, to_doc: Affine) -> PathData { + let mut bp = BezPath::new(); + for s in &p.subpaths { + let pt = |q: af::Point| Point::new(q.x, q.y); + bp.move_to(pt(s.start)); + for [c1, c2, e] in &s.segments { + if c1 == c2 && c2 == e { + bp.line_to(pt(*e)); + } else { + bp.curve_to(pt(*c1), pt(*c2), pt(*e)); + } + } + if s.closed { + bp.close_path(); + } + } + PathData::from_bezpath(&(to_doc * bp)) + } + + fn color(c: ap::Color) -> (Color, f32) { + let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; + match c { + ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)), + ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)), + ap::Color::Gray { v, a } => (Color::Gray { k: 1.0 - f(v) }, f(a)), + ap::Color::Lab { l, a, b, alpha } => (Color::Lab { l: l as f32, a: a as f32, b: b as f32 }, f(alpha)), + } + } + + /// A paint and the opacity its colour's alpha becomes. + pub(super) fn paint(&mut self, p: &ap::Paint, to_doc: Affine) -> Option<(Paint, f32)> { + match p { + ap::Paint::None => None, + ap::Paint::Solid(c) => { + let (color, a) = Self::color(*c); + Some((Paint::solid(color), a)) + } + ap::Paint::Gradient(g) => { + let stops = g + .stops + .iter() + .map(|s| { + let (color, opacity) = Self::color(s.color); + let mut stop = GradientStop::new(s.offset as f32, color); + stop.opacity = opacity; + stop.midpoint = s.half_point().clamp(0.13, 0.87) as f32; + stop + }) + .collect(); + let m = to_doc * affine(g.transform); + let (o, x, y) = (m * Point::ZERO, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0)); + let kind = match g.kind { + AfKind::Linear => GradientKind::Linear, + AfKind::Radial => GradientKind::Radial, + AfKind::Conical => { + self.warn("conical gradients import as radial gradients"); + GradientKind::Radial + } + }; + let (u, v): (Vec2, Vec2) = (x - o, y - o); + if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 { + self.warn("skewed elliptical gradients import as upright ellipses"); + } + let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 }; + let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None }; + let mut gp = GradientPaint::new(Gradient::new(kind, stops)); + gp.angle = (-(u.y)).atan2(u.x).to_degrees(); + gp.geom = Some(geom); + Some((Paint::Gradient(Box::new(gp)), 1.0)) + } + } + } + + pub(super) fn stroke_layer(&mut self, s: &ap::Stroke, to_doc: Affine) -> Option { + let (paint, opacity) = self.paint(&s.paint, to_doc)?; + let mut l = StrokeLayer::new(paint, s.width * self.k); + l.opacity = opacity; + l.cap = match s.cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }; + l.join = match s.join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }; + l.miter_limit = s.miter_limit; + l.align = match s.align { + ap::Align::Center => StrokeAlign::Center, + ap::Align::Inside => StrokeAlign::Inside, + ap::Align::Outside => StrokeAlign::Outside, + }; + l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { + pattern: pattern.iter().map(|v| v * self.k).collect(), + offset: phase * self.k, + align_corners: false, + }); + Some(l) + } + + pub(super) fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance { + let mut items = Vec::new(); + let mut above = Vec::new(); + for f in fills { + if let Some((paint, opacity)) = self.paint(f, to_doc) { + let mut l = FillLayer::new(paint); + l.opacity = opacity; + items.push(AppearanceItem::Fill(l)); + } + } + for s in strokes { + let Some(l) = self.stroke_layer(s, to_doc) else { continue }; + if s.behind { + items.insert(0, AppearanceItem::Stroke(l)); + } else { + above.push(AppearanceItem::Stroke(l)); + } + } + items.extend(above); + Appearance { items, ..Appearance::default() } + } + + /// Where text placed by `m` (node space to points) goes, its type scaled by `type_m`. + pub(super) fn placement(&mut self, m: Affine, type_m: Affine) -> Option { + let [a, b, c, d, e, f] = m.as_coeffs(); + let sx = a.hypot(b); + let det = a * d - b * c; + if !(sx > 1e-9 && det.is_finite() && det.abs() > 1e-12 && e.is_finite() && f.is_finite()) { + self.warn("text with an invalid transform (left out)"); + return None; + } + let sy = det / sx; + let shear = (a * c + b * d) / (sx * sx); + if shear.abs() > 1e-3 || sy < 0.0 { + self.warn("skewed or flipped text (imported without the skew or flip)"); + } + // The type's vertical scale gives the font size, its horizontal scale relative to that the + // characters' width. + let [ta, tb, tc, td, ..] = type_m.as_coeffs(); + let (tsx, tdet) = (ta.hypot(tb), ta * td - tb * tc); + let (size_scale, h_scale) = if tsx > 1e-9 && tdet.is_finite() && tdet.abs() > 1e-12 { + let tsy = (tdet / tsx).abs(); + (tsy, tsx / tsy) + } else { + self.warn("text with an invalid text scale (imported unscaled)"); + (det.abs().sqrt(), 1.0) + }; + Some(Placement { + to_frame: Affine::scale_non_uniform(sx, sy.abs()), + place: Affine::translate((e, f)) * Affine::rotate(b.atan2(a)), + size_scale, + h_scale, + }) + } + + /// A CMYK pixel layer as an image file. Through the document's own CMYK profile when it + /// embeds one (as Affinity shows it): RGBA. Otherwise, opaque, a CMYK image that keeps its + /// inks (shown and separated through the working CMYK profile, as placed CMYK images are); + /// with transparency, RGBA through the working CMYK profile. + fn cmyk_image(&mut self, width: u32, height: u32, cmyka: &[u8]) -> Option> { + let n = (width as usize).checked_mul(height as usize)?; + let px = cmyka.get(..n.checked_mul(5)?)?.as_chunks::<5>().0; + if let Some(lut) = self.document_cmyk() { + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + return png(width, height, rgba); + } + if px.iter().all(|p| p[4] == 255) { + let inks = px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).collect(); + if let Some(blob) = vectorcraft_doc::cmyk::Inks::new(width, height, inks).and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) { + return Some(Arc::unwrap_or_clone(blob.bytes)); + } + } + self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)"); + let lut = self + .working_cmyk + .get_or_insert_with(|| { + Arc::new(vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| { + let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb(); + [r, g, b, 0.0] + })) + }) + .clone(); + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + png(width, height, rgba) + } + + /// The document's CMYK profile as a table from ink amounts to sRGB (relative colorimetric, + /// with black-point compensation, as Affinity's pixel layers convert), made once; `None` when + /// the document embeds none, or not one that converts. + fn document_cmyk(&mut self) -> Option> { + use vectorcraft_color::cms::{self, CmykLut, IccProfile, Intent}; + if let Some(lut) = &self.cmyk_lut { + return lut.clone(); + } + let embedded = self.cmyk_profile.is_some(); + let lut = self.cmyk_profile.take().and_then(|bytes| { + let profile = IccProfile::from_bytes(None, &bytes).ok()?; + let srgb = cms::icc::builtin_rgb(cms::SRGB)?; + profile.to_rgb_of(&srgb, &[0.0; 4], Intent::RelativeColorimetric, true)?; + Some(Arc::new(CmykLut::build(|inks| { + let [r, g, b] = profile + .to_rgb_of(&srgb, &inks, Intent::RelativeColorimetric, true) + .unwrap_or_else(|| Color::cmyk(inks[0], inks[1], inks[2], inks[3]).to_rgb()); + [r, g, b, 0.0] + }))) + }); + if embedded && lut.is_none() { + self.warn("CMYK pixel layers whose document's colour profile could not be read (shown through the working CMYK profile)"); + } + self.cmyk_lut = Some(lut.clone()); + lut + } + + pub(super) fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { + let bytes = match &img.pixels { + Pixels::Encoded(b) => b.clone(), + Pixels::Rgba8(rgba) => png(img.width, img.height, rgba.clone())?, + Pixels::Cmyka8(cmyka) => self.cmyk_image(img.width, img.height, cmyka)?, + }; + let r = match crate::cmd::fileio::load::raster_image(&bytes) { + Ok(r) => r, + Err(_) => { + self.warn("an embedded image that could not be decoded was left out"); + return None; + } + }; + // The pixel grid maps onto the Affinity image's own size, whatever the stored file's. + let sx = img.width as f64 / f64::from(r.width.max(1)); + let sy = img.height as f64 / f64::from(r.height.max(1)); + let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); + self.doc.images.entry(r.key.clone()).or_insert(r.blob); + Some(NodeKind::Image(Box::new(ImageObject { + key: r.key, + width: r.width, + height: r.height, + xf, + link: None, + placement: Default::default(), + pixels: None, + }))) + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/text.rs b/crates/engine/src/cmd/fileio/affinity/text.rs new file mode 100644 index 000000000..cae76dc7b --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/text.rs @@ -0,0 +1,326 @@ +use super::layout::{align_lines, fit_lines, font_style, justify, line_gap, place_cell}; +use super::nodes::affine; +use super::*; + +/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node +/// space into it (applied to the frame's geometry, not the type), plus the type's own scale. +pub(super) struct Placement { + /// Node space to the frame's space, before `place`. + pub(super) to_frame: Affine, + pub(super) place: Affine, + /// Points per node unit of type size. + pub(super) size_scale: f64, + /// Horizontal scale of the type relative to `size_scale`. + pub(super) h_scale: f64, +} + +impl Builder { + /// Text → its object and the opacity its characters' shared transparency gives the node. + pub(super) fn text(&mut self, t: &af::Text, to_doc: Affine) -> Option<(TextObject, f32)> { + let p = self.placement(to_doc * affine(t.transform), to_doc * affine(t.text_transform))?; + let alpha = self.text_alpha(&t.runs); + let mut obj = self.type_object(&t.runs, &p)?; + obj.para.justify = justify(t.align); + if let Some(first) = obj.paras.first_mut() { + first.justify = obj.para.justify; + } + match t.frame { + Some(f) => { + let (a, b) = (p.to_frame * Point::new(f.x0, f.y0), p.to_frame * Point::new(f.x1, f.y1)); + let r = Rect::from_points(a, b); + obj.xf = p.place * Affine::translate(r.origin().to_vec2()); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, r.width(), r.height())) }; + // Affinity hangs a frame's first line from its capitals' tops, not the font's ascent. + obj.area.first_baseline = FirstBaseline::CapHeight; + if fit_lines(&mut obj, r.width(), r.height()) { + self.warn("text frames whose last lines hang out of them (made taller so the lines show)"); + } + // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. + if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { + self.warn("text frames too short for their first line (made taller so the line shows)"); + } + } + None => { + align_lines(&mut obj); + obj.xf = p.place * Affine::translate((p.to_frame * Point::new(t.anchor.x, t.anchor.y)).to_vec2()); + } + } + Some((obj, alpha)) + } + + /// The opacity text takes from its characters' colour: VectorCraft's characters have none of + /// their own, so text whose characters share one becomes an object with that opacity. + fn text_alpha(&mut self, runs: &[af::TextRun]) -> f32 { + let alphas: Vec = + runs.iter().filter_map(|r| if let ap::Paint::Solid(c) = &r.fill { Some(c.alpha()) } else { None }).filter(|a| a.is_finite()).collect(); + let (lo, hi) = alphas.iter().fold((1.0f64, 0.0f64), |(lo, hi), a| (lo.min(*a), hi.max(*a))); + if alphas.is_empty() || lo >= 1.0 { + return 1.0; + } + if hi - lo > 0.01 { + self.warn("text with characters of different transparency (imported opaque)"); + return 1.0; + } + lo.clamp(0.0, 1.0) as f32 + } + + /// The characters and paragraphs of `runs` as a text object at the origin of text space. + fn type_object(&mut self, runs: &[af::TextRun], p: &Placement) -> Option { + let (v, h) = (p.size_scale, p.size_scale * p.h_scale); + let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); + let mut formats: Vec = Vec::new(); + let mut out: Vec = Vec::new(); + let mut paragraph = 0usize; + // Whether each paragraph here continues the one before after a line break (U+2028): + // VectorCraft has no line break inside a paragraph, so the lines become paragraphs. + let mut continues = vec![false]; + for r in runs { + for c in r.text.chars() { + match c { + '\u{2028}' => continues.push(true), + '\u{2029}' | '\r' | '\n' => continues.push(false), + _ => {} + } + } + // Affinity separates paragraphs with U+2029 and lines with U+2028; other control + // characters don't show. + let text: String = r + .text + .chars() + .filter_map(|c| match c { + '\u{2029}' | '\u{2028}' | '\r' | '\n' => Some('\n'), + '\t' => Some('\t'), + c if c.is_control() => None, + c => Some(c), + }) + .collect(); + if text.is_empty() { + continue; + } + for c in text.chars() { + if formats.len() <= paragraph { + formats.push(r.paragraph); + } + if c == '\n' { + paragraph += 1; + } + } + let mut style = self.char_style(r, (v, p.h_scale)); + if style.leading.is_none() { + style.leading = r.paragraph.leading.map(|l| l * v).filter(|l| l.is_finite() && *l > 0.0).map(|l| l.min(5000.0)); + } + out.push(TextRun::new(text, style)); + } + // Affinity ends a story with a paragraph mark; VectorCraft's last paragraph has none. + if let Some(last) = out.last_mut() + && last.text.ends_with('\n') + { + last.text.pop(); + if last.text.is_empty() { + out.pop(); + } + } + obj.runs = out; + let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::() + 1; + let mut paras = Vec::with_capacity(count); + let mut previous_after = 0.0; + // The run of each paragraph's first character. + let mut first_runs = Vec::with_capacity(count); + { + let mut at_start = true; + for (i, r) in obj.runs.iter().enumerate() { + for c in r.text.chars() { + if at_start { + first_runs.push(i); + at_start = false; + } + if c == '\n' { + at_start = true; + } + } + } + } + // Line gaps by font, each looked up once. + let mut gaps: std::collections::HashMap<(&str, &str), f64> = std::collections::HashMap::new(); + for i in 0..count { + let f = formats.get(i).or(formats.last()).copied().unwrap_or_default(); + let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 }; + let mut ps = vectorcraft_doc::text::ParaStyle { justify: justify(f.align), ..Default::default() }; + // Affinity places the first line and the others from the left edge; VectorCraft's + // first-line indent is relative to its left indent. + ps.left_indent = pt(f.left, h); + ps.first_line_indent = pt(f.first - f.left, h); + ps.right_indent = pt(f.right, h); + // Between two paragraphs Affinity keeps the larger of the space after the first and + // before the second; VectorCraft adds them. + let (mut before, mut after) = (pt(f.space_before, v).max(0.0), pt(f.space_after, v).max(0.0)); + // The lines of one Affinity paragraph: no space between them, and the first-line + // indent only on its first. + if continues.get(i).copied().unwrap_or(false) { + before = 0.0; + ps.first_line_indent = 0.0; + } + if continues.get(i + 1).copied().unwrap_or(false) { + after = 0.0; + } + ps.space_before = if i == 0 { before } else { (before - previous_after).max(0.0) }; + ps.space_after = after; + previous_after = after; + // Automatic leading: the font size plus the font's line gap, times the paragraph's + // factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas + // Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em, + // and the public Arial frame fixture fits this better than the font's line height. + let (family, style) = + first_runs.get(i).and_then(|r| obj.runs.get(*r)).map_or(("", ""), |r| (r.style.font_family.as_str(), r.style.font_style.as_str())); + let gap = *gaps.entry((family, style)).or_insert_with(|| line_gap(family, style)); + let ratio = (1.0 + gap) * f.auto_leading; + // Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft + // takes 0–500 %. + if ratio.is_finite() && (0.01..=5.0).contains(&ratio) { + ps.justification.auto_leading = ratio * 100.0; + } + paras.push(ps); + } + if let Some(first) = paras.first() { + obj.para = first.clone(); + } + obj.paras = paras; + obj.normalize_paras(); + Some(obj) + } + + /// A run's character style; `(size_scale, h_scale)` the type's vertical scale to points and + /// its horizontal scale relative to that. + fn char_style(&mut self, r: &af::TextRun, (size_scale, h_scale): (f64, f64)) -> CharStyle { + let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; + let mut style = CharStyle::default(); + if !r.family.is_empty() { + style.font_family = r.family.clone(); + } else if !r.postscript.is_empty() { + style.font_family = r.postscript.clone(); + } else { + self.warn("text without a font name uses the default font"); + } + // A family/weight pair cannot identify width variants such as Arial Narrow. + // Resolve a known PostScript identity to the exact installed family and style. + if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { + style.font_family = family; + style.font_style = face_style; + } else { + style.font_style = font_style(r.weight, r.italic); + } + style.features = r.features.clone(); + if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { + self.warn("unsupported text OpenType features (retained but not rendered)"); + } + style.all_caps = r.all_caps; + style.underline = r.underline; + style.strikethrough = r.strikethrough; + style.size = finite(r.size * size_scale, 12.0).clamp(0.1, 1296.0); + style.leading = r.leading.map(|l| finite(l * size_scale, 0.0)).filter(|l| *l > 0.0).map(|l| l.min(5000.0)); + style.tracking = finite(r.tracking * 1000.0, 0.0).clamp(-1000.0, 10000.0); + // Text squeezed or stretched by its transform keeps that as horizontal scale, on top of + // its own. + let hs = h_scale * r.h_scale; + if hs.is_finite() && (hs - 1.0).abs() > 1e-3 { + style.h_scale = (hs * 100.0).clamp(1.0, 1000.0); + } + // Transparency is the object's (see `text_alpha`); the paint's transform doesn't matter + // for a solid colour, and gradients on type are laid out in text space. + style.fill = match self.paint(&r.fill, Affine::scale(size_scale)) { + Some((p, _)) => p, + None => Paint::None, + }; + if let Some(st) = &r.stroke { + let w = st.width * size_scale; + if let Some((paint, _)) = self.paint(&st.paint, Affine::scale(size_scale)) + && w.is_finite() + && w > 0.0 + { + style.stroke = paint; + style.stroke_width = w.min(1000.0); + } + } + style + } + + /// A table: its cells as area type and the lines on their edges, as VectorCraft has no + /// tables. + pub(super) fn table(&mut self, t: &af::Table, to_doc: Affine) -> Vec> { + let mut out = Vec::new(); + let (ncols, nrows) = (t.ncols(), t.nrows()); + if ncols == 0 || nrows == 0 { + return out; + } + self.warn("tables (imported as text frames and lines)"); + let m = to_doc * affine(t.transform); + let Some(p) = self.placement(m, to_doc * affine(t.text_transform)) else { return out }; + let col = |i: usize| t.columns.get(i).copied().unwrap_or(0.0); + let row = |i: usize| t.rows.get(i).copied().unwrap_or(0.0); + for (i, cell) in t.cells.iter().enumerate() { + if cell.merged_left || cell.runs.is_empty() { + continue; + } + let (r, c) = (i / ncols, i % ncols); + // Cells to the right merged into this one widen it. + let span = 1 + t.cells.iter().skip(i + 1).take(ncols - c - 1).take_while(|n| n.merged_left).count(); + let [l, tp, rt, bt] = cell.inset; + let node = af::Rect { x0: col(c) + l, y0: row(r) + tp, x1: col(c + span) - rt, y1: row(r + 1) - bt }; + let (a, b) = (p.to_frame * Point::new(node.x0, node.y0), p.to_frame * Point::new(node.x1, node.y1)); + let rect = Rect::from_points(a, b); + if !(rect.width() > 0.0 && rect.height() > 0.0) { + continue; + } + let Some(mut obj) = self.type_object(&cell.runs, &p) else { continue }; + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, rect.width(), rect.height())) }; + obj.area.vertical_align = match cell.valign { + af::VAlign::Top => VerticalAlign::Top, + af::VAlign::Center => VerticalAlign::Center, + af::VAlign::Bottom => VerticalAlign::Bottom, + }; + obj.area.first_baseline = FirstBaseline::CapHeight; + let dy = place_cell(&mut obj, rect.width(), rect.height()); + obj.xf = p.place * Affine::translate(rect.origin().to_vec2() + Vec2::new(0.0, dy)); + // A cell's text never vanishes for want of a line's height. + vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj); + let alpha = self.text_alpha(&cell.runs); + let mut n = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))); + n.opacity = alpha; + out.push(Arc::new(n)); + } + // Lines over the cells: the vertical edges row by row (none inside merged cells), then + // the horizontal ones. + let mut line = |b: &mut Self, s: &Option, from: (f64, f64), to: (f64, f64)| { + let Some(s) = s else { return }; + let Some(layer) = b.stroke_layer(s, to_doc) else { return }; + let mut bp = BezPath::new(); + bp.move_to(m * Point::new(from.0, from.1)); + bp.line_to(m * Point::new(to.0, to.1)); + let mut n = Node::new( + b.doc.alloc_id(), + NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false }, + ); + n.appearance = Appearance { items: vec![AppearanceItem::Stroke(layer)], ..Appearance::default() }.into(); + out.push(Arc::new(n)); + }; + for r in 0..nrows { + for k in 0..=ncols { + let merged = k > 0 && k < ncols && t.cells.get(r * ncols + k).is_some_and(|c| c.merged_left); + if merged { + continue; + } + if let Some(s) = t.vertical.get(r * (ncols + 1) + k) { + line(self, s, (col(k), row(r)), (col(k), row(r + 1))); + } + } + } + for j in 0..=nrows { + for k in 0..ncols { + if let Some(s) = t.horizontal.get(j * ncols + k) { + line(self, s, (col(k), row(j)), (col(k + 1), row(j))); + } + } + } + out + } +} From fa1848be4cf3980e5a492a14da9ded769e72ecae Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:01 +0000 Subject: [PATCH 153/199] refactor(engine): move cmd/fileio.rs items to cmd/fileio/{access,commands,export_names,formats}.rs --- crates/engine/src/cmd/fileio.rs | 1368 +----------------- crates/engine/src/cmd/fileio/access.rs | 418 ++++++ crates/engine/src/cmd/fileio/commands.rs | 146 ++ crates/engine/src/cmd/fileio/export_names.rs | 100 ++ crates/engine/src/cmd/fileio/formats.rs | 732 ++++++++++ 5 files changed, 1419 insertions(+), 1345 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/access.rs create mode 100644 crates/engine/src/cmd/fileio/commands.rs create mode 100644 crates/engine/src/cmd/fileio/export_names.rs create mode 100644 crates/engine/src/cmd/fileio/formats.rs diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index e5ab9b9d2..6e5c9402e 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -95,1356 +95,34 @@ pub use vectorcraft_format::write_atomic; use super::*; use crate::EngineError; -pub fn specs() -> Vec { - vec![ - cmd!( - "document.open", - "Open Document", - [], - None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", - always, - load::open - ), - cmd!( - query "file.list", - "List Folder", - [], - None, - "{path} the files and folders in folder `path`, by name: what an agent may open there (held to the automation read root when one is in force) → {path, entries: [{name, kind: file|folder|link, size? (files, bytes)}], truncated (more than 5000 entries)} (#1163)", - always, - file_list - ), - cmd!( - query "document.serialize", - "Serialize Document", - [], - None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", - has_doc, - export::serialize - ), - cmd!( - "document.export", - "Export Document", - [], - None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", - has_doc, - export::export - ), - cmd!( - "document.exportSelection", - "Export Selection…", - ["File"], - None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export", - has_selection, - export::export_selection - ), - cmd!( - "document.exportForScreens", - "Export for Screens", - ["File", "Export"], - None, - "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,